Merge branch 'questie-learner-comms-improvements'

# Conflicts:
#	README.md
This commit is contained in:
Xurkon
2026-06-06 00:05:37 -05:00
40 changed files with 10357 additions and 359 deletions
+32 -3
View File
@@ -1,5 +1,34 @@
# Changelog
## [Unreleased] - Performance Refactor Branches
### Performance
- **[QuestieLearner - Kill/Pin Refresh Throttling]** Debounced learner-triggered map-pin refreshes so heavy kill streaks do not redraw pins on every event. Added a maximum wait cap so batched updates still flush predictably instead of being pushed out forever by constant activity.
- **[QuestieLearner - Bystander Kill Suppression]** Changed visible nearby `UNIT_DIED` handling so kills from other players can update short-lived correlation evidence without immediately running full learner injection or pin refresh work.
- **[QuestieLearner - PARTY_KILL Event-Order Fix]** Fixed an edge case where a `UNIT_DIED` debounce entry could suppress a later authoritative `PARTY_KILL` for the same GUID. The debounce now tracks event type and allows the player's/group's kill event through while still suppressing true duplicates.
- **[QuestieLearner - Immediate Spawn Pin Rendering]** Learner kill evidence now becomes spawn coordinates immediately in learner mode so learned NPC kills can spawn map pins without waiting for the later confidence merge path.
- **[QuestieLearner - Live Performance Options]** Added Advanced-tab controls for learner intensity, pin refresh delay, maximum pin refresh wait, minimum kills before learned pins, and live NPC update delay so users can tune the system for low-end PCs or heavy-activity zones.
- **[QuestieComms - User-Controlled Intensity]** Added Advanced-tab QuestieComms controls, including a full disable switch and live throttles for queue processing, quest-state broadcasts, and bulk sync pacing.
- **[QuestieComms - Disable Gate Fix]** Scoped the comms enable helper so the disable switch no longer calls a nil global and every send/process entry point consistently respects the setting.
- **[Arrow - Low-End Performance Controls]** Added live Arrow update throttles to reduce repeated nearest-target and coordinate work while preserving existing arrow behavior.
- **[Measured Hot Paths - Phase 3]** Landed measured optimizations on the phase 3 branch for literal localization caching, available quest redraw batching, `QuestieDB.IsDoable` batch reads, hot profile aliases, `GetTime()` hoists, NPC fallback lookup caching, and validate-cache allocation cleanup.
### Bug Fixes
- **[Error Suppression - Debug Modes]** Moved missing quest and other non-fatal database/error spam out of normal chat output and into Questie debug-critical/developer output. Fatal startup failures remain loud.
- **[Tooltip Data Precedence]** Updated tooltip handling so QuestieLearner defers to AscensionDB-owned tooltip/objective data instead of hiding or replacing server-plugin data for active quests.
- **[QuestieQuest - Unavailable Quest Guard]** Guarded the available-quest draw thread so unresolved quest IDs are skipped safely instead of crashing the thread, and deduped the skip log so the same missing quest does not spam every redraw.
- **[QuestieLearner - Data Source Mode Cohesion]** Reworked the Auto / Learner / Static / Neither data-source modes so switching between them applies live and consistently. A single missing static sub-table (npc/object/quest/item) no longer locks the whole addon into learner mode — only the genuinely-missing store falls back. Static and Neither no longer silently fall back to learner records, switching modes now clears the per-zone quest cache, and the mode switch drives a full pin/tracker redraw through `QuestieQuest:SmoothReset()` (the previous redraw call imported a mis-named module and silently did nothing).
- **[QuestieLearner - Pin Refresh Latency]** Collapsed a redundant second debounce stage on the live-learn pin-refresh path. Newly learned spawns now redraw within a single debounce window instead of waiting out both the NPC live-update delay and a separate pin-refresh delay, roughly halving perceived pin-update latency in the Balanced and Low presets. Also fixed a latent infinite timer re-arm that could occur once the pin flush was triggered directly.
- **[Map - Dense Pin Clustering Aggressiveness Knob]** Re-implemented density-adaptive clustering for crowded kill objectives, now controlled by a new "Dense pin clustering aggressiveness" slider on the Advanced tab (0 = show every pin, higher = tighter consolidation where many pins share a zone). Coincident pins are always deduplicated regardless of the clustering settings, and the intentional per-zone (Sunstrider Isle) and object-icon range overrides are preserved.
- **[QuestieLearner - One Pin Per Spawn, Not Per Kill]** Fixed learner kill evidence rendering a separate pin for every kill. Because kill coordinates are the player's position at kill time (and respawns carry fresh GUIDs), repeated kills at the same spot drifted just enough to dodge the exact-match dedup. The immediate learner-mode spawn builder now merges evidence within a small radius into one pin per physical spawn, and the weighted spawn-evidence merge groups kills by coordinate bucket so a single spot can actually accumulate enough evidence to clear the confidence threshold. The merge distance is exposed as a new "Spawn Pin Dedup Radius" slider on the Advanced tab (0 = show every distinct position, higher = fewer/tighter pins per spawn) which redraws live.
### Branch / Release Notes
- The most complete performance candidate is not yet a single branch: `questie-learner-comms-improvements` has the latest learner/comms/arrow controls, while `phase3-measured-perf` has the broader measured hot-path changes.
- Before release, merge into a dedicated integration branch, remove or revalidate the stale `QuestieMap.ProcessQueue` profile-local commit that was reverted on `main`, fix the unrelated Arrow asset test expectation mismatch, and validate in game with minimap open, nearby-player kills, looting, comms toggles, Arrow throttles, and learner-only/static-only mode switching.
## [1.6.3]
### Bug Fixes
@@ -109,7 +138,7 @@
### Maintenance
- **Removed Turtle WoW references** — Deleted Turtle WoW row from the server compatibility table and removed Turtle WoW load tip from installation instructions in README.md. Turtle WoW is shutting down and no longer has a supported plugin.
- **Removed unsupported legacy server references** — Deleted an unsupported legacy server row from the server compatibility table and removed its load tip from installation instructions in README.md.
### Documentation
@@ -282,7 +311,7 @@ Addressed micro-stutters and FPS drops (190 → sub-100) reported during high-fr
- **[Feature — Enhanced Logging]** Improved Stage 3 initialization logging to provide detailed reporting on custom data injection. Developers can now verify the exact number of NPCs, Objects, and Items injected by plugins directly from the `DEVELOP` log.
- **[Fix — Ascension Zone Mapping]** Fixed a regression in `QuestieCompat` where `uiMapData` for Ascension-specific zones was not correctly propagating to the global mapping table, restoring map pin functionality for seasonal and custom zones.
- **[Fix — QuestieLearner]** Centralized zone/area ID lookup in `l10n` module to prevent `GetAreaIdByLocalName` nil errors (Fixes Project Ebonhold runtime crash).
- **[Fix — Custom Server Compilation]** Fixed database compilation not running on custom servers (Ascension, Ebonhold, Turtle WoW, etc.) where plugins inject data after initial load.
- **[Fix — Custom Server Compilation]** Fixed database compilation not running on custom servers (Ascension, Ebonhold, etc.) where plugins inject data after initial load.
- Modified `Modules/QuestieInit.lua` Stage1 to defer compilation to Stage3 for custom servers, ensuring plugins finish injecting data before compilation runs.
- Added `l10n:Initialize()` and `QuestieCorrections:MinimalInit()` calls when deferring to Stage3, as Stage2 (`QuestieJourney:Initialize()`) requires `hiddenQuests` to be populated.
- Added "Bronzebeard" and "Warcraft Reborn" to Ascension realm detection patterns in `Modules/QuestieServer.lua`.
@@ -593,7 +622,7 @@ Addressed micro-stutters and FPS drops (190 → sub-100) reported during high-fr
### Core & Stability (v1.3.5)
- **[Lua 5.0]** Globally polyfilled `string.match` and `string.gmatch` using `string.find` and `string.gfind` to ensure universal compatibility with legacy WoW clients (e.g., Turtle WoW).
- **[Lua 5.0]** Globally polyfilled `string.match` and `string.gmatch` using `string.find` and `string.gfind` to ensure universal compatibility with legacy WoW clients.
- **[AceTimer]** Patched embedded `AceTimer-3.0` instances in ElvUI and OG-RaidHelper to resolve `math.mod` errors on Lua 5.0 clients.
- **[Colors]** Updated `CreateColor` polyfill with `SetRGB`, `SetRGBA`, `SetColor`, and `GetColor` methods.
- **[Comm]** Improved cross-client data sharing stability.
+224 -14
View File
@@ -204,6 +204,132 @@ QuestieDB.NPCPointers = _dummyHandle.pointers
QuestieDB.ObjectPointers = _dummyHandle.pointers
QuestieDB.ItemPointers = _dummyHandle.pointers
-- Set by QuestieInit when the compiled/static base DB fails to load.
-- When this is true, learner should force itself on so the addon still works
-- even if the static database is unavailable or partially missing.
QuestieDB.baseDatabaseMissing = false
QuestieDB.baseDatabaseMissingKeys = {}
-- The four core static stores. The base DB is only considered fully "missing"
-- (which forces learner mode) when EVERY one of these failed to load.
QuestieDB._baseDatabaseStores = { "npcData", "objectData", "questData", "itemData" }
-- True only when a specific static store failed to load. Reads use this so a
-- single missing/mismatched store falls back to learner data for that store
-- alone, instead of locking the whole addon into learner mode.
function QuestieDB:IsStoreMissing(storeKey)
return QuestieDB.baseDatabaseMissingKeys and QuestieDB.baseDatabaseMissingKeys[storeKey] == true
end
-- Clears every per-entity cache. Used when the data source mode changes so that
-- quest/npc/item/object AND per-zone results are rebuilt against the new mode.
function QuestieDB:ClearModeCaches()
_QuestieDB.questCache = {}
_QuestieDB.itemCache = {}
_QuestieDB.npcCache = {}
_QuestieDB.objectCache = {}
_QuestieDB.zoneCache = {}
end
function QuestieDB:IsBaseDatabaseMissing()
if QuestieDB.baseDatabaseMissing ~= true then
return false
end
-- Only report the base DB as missing when EVERY core store failed. A partial
-- failure (e.g. itemData missing but npcData present) must not override the
-- user's Data Source Mode selection — per-read fallback handles the gaps.
local keys = QuestieDB.baseDatabaseMissingKeys
if not keys then
return false
end
local stores = QuestieDB._baseDatabaseStores
for i = 1, 4 do
if not keys[stores[i]] then
return false
end
end
return true
end
local function _GetLearnerSettings()
local ld = Questie and Questie.dbLearner and Questie.dbLearner.global
return ld and ld.settings or nil
end
local function _GetLearnerRecord(storeName, id)
local ld = Questie and Questie.dbLearner and Questie.dbLearner.global
if not ld then
return nil
end
local store = ld[storeName]
if not store then
return nil
end
return store[id] or store[tostring(id)]
end
-- Map-percent radius used to collapse per-GUID kill evidence into one pin per
-- physical spawn. Kill coordinates are the player's position at kill time, so
-- repeated kills (and respawns, which carry fresh GUIDs) land on slightly
-- different coords. Without this merge, every kill would render its own pin.
-- A distance test (rather than a grid bucket) avoids the boundary artifact where
-- two near-identical coords straddle a cell edge and split into separate pins.
-- The radius is user-tunable via the learner "Spawn Pin Dedup Radius" knob; 0
-- disables merging (every distinct coord shown).
local GUID_SPAWN_DEDUP_RADIUS = 4.0
local function _GetSpawnDedupRadius()
local settings = _GetLearnerSettings()
local r = settings and tonumber(settings.spawnDedupRadius)
if r and r >= 0 then
return r
end
return GUID_SPAWN_DEDUP_RADIUS
end
local function _BuildSpawnTableFromGuidEvidence(evidence)
if type(evidence) ~= "table" then
return nil
end
local radius = _GetSpawnDedupRadius()
local radiusSq = radius * radius
local spawns = {}
local hasEntries = false
for _, entry in pairs(evidence) do
if type(entry) == "table" and entry.zoneId and entry.x and entry.y then
local zoneId = tonumber(entry.zoneId)
local x = tonumber(entry.x)
local y = tonumber(entry.y)
if zoneId and x and y then
spawns[zoneId] = spawns[zoneId] or {}
local zoneSpawns = spawns[zoneId]
local exists = false
for _, coord in ipairs(zoneSpawns) do
local dx = coord[1] - x
local dy = coord[2] - y
if (dx * dx + dy * dy) <= radiusSq then
exists = true
break
end
end
if not exists then
zoneSpawns[#zoneSpawns + 1] = { x, y }
hasEntries = true
end
end
end
end
if not hasEntries then
return nil
end
return spawns
end
---@type QuestieQuest
local QuestieQuest = QuestieLoader:ImportModule("QuestieQuest")
---@type QuestieQuestPrivate
@@ -321,6 +447,19 @@ if not Item then
end
if not Item.CreateFromItemID then
local function SafeGetItemName(itemID)
local numericItemID = tonumber(itemID)
if not numericItemID or numericItemID == 0 then
return "item:" .. tostring(itemID)
end
local ok, name = pcall(GetItemInfo, numericItemID)
if not ok then
return "item:" .. tostring(numericItemID)
end
return name or ("item:" .. tostring(numericItemID))
end
function Item:CreateFromItemID(itemID)
local obj = {}
@@ -332,8 +471,7 @@ if not Item.CreateFromItemID then
end
function obj:GetItemName()
local name = GetItemInfo(itemID)
return name or ("item:" .. tostring(itemID))
return SafeGetItemName(itemID)
end
return obj
@@ -520,11 +658,24 @@ function QuestieDB:GetObject(objectId)
return _QuestieDB.objectCache[objectId];
end
-- Try to get from compiled DB first
local rawdata = QuestieDB.QueryObject(objectId, QuestieDB._objectAdapterQueryOrder)
local settings = _GetLearnerSettings()
local mode = settings and settings.dataSourceMode or "auto"
local learnerRecord = _GetLearnerRecord("objects", objectId)
-- Check for overrides
local override = QuestieDB.objectDataOverrides and (QuestieDB.objectDataOverrides[objectId] or QuestieDB.objectDataOverrides[tostring(objectId)])
local rawdata
local override
if mode == "learner" or QuestieDB:IsStoreMissing("objectData") then
rawdata = learnerRecord
override = nil
else
rawdata = QuestieDB.QueryObject(objectId, QuestieDB._objectAdapterQueryOrder)
if not rawdata and learnerRecord and mode == "auto" then
-- Only "auto" overlays learner data on top of the static DB. "static"
-- and "none" must never silently fall back to learner records.
rawdata = learnerRecord
end
override = QuestieDB.objectDataOverrides and (QuestieDB.objectDataOverrides[objectId] or QuestieDB.objectDataOverrides[tostring(objectId)])
end
if not rawdata and not override then
Questie:Debug(Questie.DEBUG_CRITICAL, "[QuestieDB:GetObject] data not found for objectID:", objectId)
@@ -566,8 +717,23 @@ function QuestieDB:GetItem(itemId)
return _QuestieDB.itemCache[itemId];
end
local rawdata = QuestieDB.QueryItem(itemId, QuestieDB._itemAdapterQueryOrder)
local override = QuestieDB.itemDataOverrides and (QuestieDB.itemDataOverrides[itemId] or QuestieDB.itemDataOverrides[tostring(itemId)])
local settings = _GetLearnerSettings()
local mode = settings and settings.dataSourceMode or "auto"
local learnerRecord = _GetLearnerRecord("items", itemId)
local rawdata
local override
if mode == "learner" or QuestieDB:IsStoreMissing("itemData") then
rawdata = learnerRecord
override = nil
else
rawdata = QuestieDB.QueryItem(itemId, QuestieDB._itemAdapterQueryOrder)
if not rawdata and learnerRecord and mode == "auto" then
-- Only "auto" overlays learner data on top of the static DB. "static"
-- and "none" must never silently fall back to learner records.
rawdata = learnerRecord
end
override = QuestieDB.itemDataOverrides and (QuestieDB.itemDataOverrides[itemId] or QuestieDB.itemDataOverrides[tostring(itemId)])
end
if not rawdata and not override then
Questie:Debug(Questie.DEBUG_CRITICAL, "[QuestieDB:GetItem] data not found for itemID:", itemId)
@@ -807,7 +973,9 @@ end
function QuestieDB.GetSuppressedNPCs(zoneId)
local suppressed = {}
local ld = Questie.dbLearner.global
if ld and ld.settings and ld.settings.enabled and ld.settings.prioritizeMyData and ld.npcs then
local mode = ld and ld.settings and ld.settings.dataSourceMode or "auto"
if mode == "auto" or mode == "learner" then
if ld and ld.settings and ld.settings.enabled and ld.npcs then
local threshold = ld.settings.minConfidencePins or 2
local npcId, entry = next(ld.npcs)
while npcId do
@@ -817,6 +985,7 @@ function QuestieDB.GetSuppressedNPCs(zoneId)
end
npcId, entry = next(ld.npcs, npcId)
end
end
end
return suppressed
end
@@ -828,7 +997,9 @@ end
function QuestieDB.GetSuppressedObjects(zoneId)
local suppressed = {}
local ld = Questie.dbLearner.global
if ld and ld.settings and ld.settings.enabled and ld.settings.prioritizeMyData and ld.objects then
local mode = ld and ld.settings and ld.settings.dataSourceMode or "auto"
if mode == "auto" or mode == "learner" then
if ld and ld.settings and ld.settings.enabled and ld.objects then
local threshold = ld.settings.minConfidencePins or 2
local objId, entry = next(ld.objects)
while objId do
@@ -838,6 +1009,7 @@ function QuestieDB.GetSuppressedObjects(zoneId)
end
objId, entry = next(ld.objects, objId)
end
end
end
return suppressed
end
@@ -1429,8 +1601,23 @@ function QuestieDB.GetQuest(questId, ...) -- /dump QuestieDB.GetQuest(867)
return _QuestieDB.questCache[questId];
end
local rawdata = QuestieDB.QueryQuest(questId, QuestieDB._questAdapterQueryOrder)
local overrideData = QuestieDB.questDataOverrides and (QuestieDB.questDataOverrides[questId] or QuestieDB.questDataOverrides[tostring(questId)])
local settings = _GetLearnerSettings()
local mode = settings and settings.dataSourceMode or "auto"
local learnerRecord = _GetLearnerRecord("quests", questId)
local rawdata
local overrideData
if mode == "learner" or QuestieDB:IsStoreMissing("questData") then
rawdata = learnerRecord
overrideData = nil
else
rawdata = QuestieDB.QueryQuest(questId, QuestieDB._questAdapterQueryOrder)
if not rawdata and learnerRecord and mode == "auto" then
-- Only "auto" overlays learner data on top of the static DB. "static"
-- and "none" must never silently fall back to learner records.
rawdata = learnerRecord
end
overrideData = QuestieDB.questDataOverrides and (QuestieDB.questDataOverrides[questId] or QuestieDB.questDataOverrides[tostring(questId)])
end
if (not rawdata) then
rawdata = overrideData
@@ -2029,8 +2216,23 @@ function QuestieDB:GetNPC(npcId)
return _QuestieDB.npcCache[npcId]
end
local rawdata = QuestieDB.QueryNPC(npcId, QuestieDB._npcAdapterQueryOrder)
local override = QuestieDB.npcDataOverrides and (QuestieDB.npcDataOverrides[npcId] or QuestieDB.npcDataOverrides[tostring(npcId)])
local settings = _GetLearnerSettings()
local mode = settings and settings.dataSourceMode or "auto"
local learnerRecord = _GetLearnerRecord("npcs", npcId)
local rawdata
local override
if mode == "learner" or QuestieDB:IsStoreMissing("npcData") then
rawdata = learnerRecord
override = nil
else
rawdata = QuestieDB.QueryNPC(npcId, QuestieDB._npcAdapterQueryOrder)
if not rawdata and learnerRecord and mode == "auto" then
-- Only "auto" overlays learner data on top of the static DB. "static"
-- and "none" must never silently fall back to learner records.
rawdata = learnerRecord
end
override = QuestieDB.npcDataOverrides and (QuestieDB.npcDataOverrides[npcId] or QuestieDB.npcDataOverrides[tostring(npcId)])
end
if not rawdata and not override then
Questie:Debug(Questie.DEBUG_CRITICAL, "[QuestieDB:GetNPC] data not found for npcID:", npcId)
@@ -2060,6 +2262,14 @@ function QuestieDB:GetNPC(npcId)
local friendlyToFaction = npc.friendlyToFaction
npc.friendly = (not friendlyToFaction) and true or factionReactions[friendlyToFaction]
if (not npc.spawns or next(npc.spawns) == nil) and mode == "learner" then
local guidSpawns = rawdata[8]
local learnedSpawns = _BuildSpawnTableFromGuidEvidence(guidSpawns)
if learnedSpawns then
npc.spawns = learnedSpawns
end
end
_QuestieDB.npcCache[npcId] = npc
return npc
end
+112 -33
View File
@@ -37,9 +37,9 @@ local ARROW_SHEET_ROWS = 12
local ARROW_TOTAL_CELLS = ARROW_SHEET_COLS * ARROW_SHEET_ROWS
local ARROW_DEFAULT_STYLE = "arrow1"
local UPDATE_THROTTLE_SECONDS = 0.05
local RECALC_NEAREST_SECONDS = 1.0
local TRACKER_REFRESH_THROTTLE_SECONDS = 0.5
local DEFAULT_UPDATE_THROTTLE_SECONDS = 0.05
local DEFAULT_RECALC_NEAREST_SECONDS = 1.0
local DEFAULT_TRACKER_REFRESH_THROTTLE_SECONDS = 0.5
---@type Frame?
local arrowFrame = nil
@@ -57,6 +57,7 @@ local hasManualTarget = false
local _arrow_playerX, _arrow_playerY, _arrow_playerInstance
local _arrow_usingAutoLogic, _arrow_playerZoneId, _arrow_playerUiMapId
local _arrow_quest -- current quest being processed by the hoisted helpers
local _arrow_zoneUiMapCache = {}
local lastPopulateByQuestId = {}
@@ -73,6 +74,36 @@ local function _IsArrowEnabled()
return Questie.db.profile.arrowEnabled ~= false
end
local function _GetProfileNumber(key, defaultValue, minValue, maxValue)
if not Questie or not Questie.db or not Questie.db.profile then
return defaultValue
end
local value = Questie.db.profile[key]
if type(value) ~= "number" then
return defaultValue
end
if value < minValue then
return minValue
end
if value > maxValue then
return maxValue
end
return value
end
local function _GetArrowUpdateThrottle()
return _GetProfileNumber("arrowUpdateThrottle", DEFAULT_UPDATE_THROTTLE_SECONDS, 0.03, 0.5)
end
local function _GetArrowRecalcInterval()
return _GetProfileNumber("arrowRecalcInterval", DEFAULT_RECALC_NEAREST_SECONDS, 0.5, 10.0)
end
local function _GetArrowTrackerRefreshThrottle()
return _GetProfileNumber("arrowTrackerRefreshThrottle", DEFAULT_TRACKER_REFRESH_THROTTLE_SECONDS, 0.25, 5.0)
end
local function _GetArrowScale()
if not Questie or not Questie.db or not Questie.db.profile then
return 1
@@ -151,6 +182,37 @@ local function _FormatDistance(dist)
return string.format("%.1f%s", value, suffix)
end
local function _SortTargetByDistance(a, b)
return a.distance < b.distance
end
local function _GetUiMapIdForZone(zone)
if not zone then return nil end
local cached = _arrow_zoneUiMapCache[zone]
if cached ~= nil then
return cached
end
local uiMapId = ZoneDB:GetUiMapIdByAreaId(zone)
_arrow_zoneUiMapCache[zone] = uiMapId or false
return uiMapId
end
local function _AddArrowTarget(x, y, uiMapId, title, questLevel, iconPath, worldX, worldY, worldInstance, distance)
table.insert(sortedTargets, {
x = x,
y = y,
uiMapId = uiMapId,
title = title,
questLevel = questLevel,
iconPath = iconPath,
worldX = worldX,
worldY = worldY,
worldInstance = worldInstance,
distance = distance,
})
end
local function _GetArrowStyleKey()
if not Questie or not Questie.db or not Questie.db.profile then
return ARROW_DEFAULT_STYLE
@@ -766,7 +828,7 @@ EnsureArrowFrame = function()
objectiveFrame:Show()
end
if (self._lastUpdate or 0) + UPDATE_THROTTLE_SECONDS > now then
if (self._lastUpdate or 0) + _GetArrowUpdateThrottle() > now then
return
end
self._lastUpdate = now
@@ -779,8 +841,12 @@ EnsureArrowFrame = function()
return
end
local targetX, targetY, targetInstance = HBD:GetWorldCoordinatesFromZone(target.x / 100.0, target.y / 100.0,
target.uiMapId)
local targetX, targetY, targetInstance = target.worldX, target.worldY, target.worldInstance
if not targetX or not targetY or not targetInstance then
targetX, targetY, targetInstance = HBD:GetWorldCoordinatesFromZone(target.x / 100.0, target.y / 100.0,
target.uiMapId)
target.worldX, target.worldY, target.worldInstance = targetX, targetY, targetInstance
end
if not targetX or not targetY or not targetInstance then
if objectiveFrame then
objectiveFrame.distance:SetText("Distance: --")
@@ -859,7 +925,11 @@ EnsureArrowFrame = function()
self.arrow:SetAlpha(alpha)
if objectiveFrame then
objectiveFrame.distance:SetText("Distance: " .. _FormatDistance(dist))
local distanceText = "Distance: " .. _FormatDistance(dist)
if objectiveFrame._lastDistanceText ~= distanceText then
objectiveFrame.distance:SetText(distanceText)
objectiveFrame._lastDistanceText = distanceText
end
end
end
@@ -884,7 +954,7 @@ local function EnsureDriverFrame()
driverFrame:SetScript("OnUpdate", function(self)
local now = GetTime()
if (self._lastRecalc or 0) + RECALC_NEAREST_SECONDS < now then
if (self._lastRecalc or 0) + _GetArrowRecalcInterval() < now then
self._lastRecalc = now
if not _IsArrowEnabled() then
if arrowFrame then
@@ -944,16 +1014,16 @@ local function _CollectFinisherSpawns(finisher, quest)
local y = value[3]
-- Zone filtering disabled (zone ID vs area ID mismatch)
if true then
local uiMapId = ZoneDB:GetUiMapIdByAreaId(zone)
local uiMapId = _GetUiMapIdForZone(zone)
if uiMapId and x and y then
local tX, tY, tInst = HBD:GetWorldCoordinatesFromZone(x / 100.0, y / 100.0, uiMapId)
if tX and tY and tInst then
local dist = HBD:GetWorldDistance(tInst, pX, pY, tX, tY)
if dist then
if tInst ~= pInst then dist = 500000 + dist * 100 end
table.insert(sortedTargets, {
x = x, y = y, uiMapId = uiMapId, title = quest.name, questLevel = quest.level, iconPath = iconPath, distance = dist,
})
if tInst ~= pInst then
dist = 500000 + dist * 100
end
_AddArrowTarget(x, y, uiMapId, quest.name, quest.level, iconPath, tX, tY, tInst, dist)
end
end
end
@@ -965,16 +1035,16 @@ local function _CollectFinisherSpawns(finisher, quest)
if true then
local x = coords[1]
local y = coords[2]
local uiMapId = ZoneDB:GetUiMapIdByAreaId(finisherZone)
local uiMapId = _GetUiMapIdForZone(finisherZone)
if uiMapId then
local tX, tY, tInst = HBD:GetWorldCoordinatesFromZone(x / 100.0, y / 100.0, uiMapId)
if tX and tY and tInst then
local dist = HBD:GetWorldDistance(tInst, pX, pY, tX, tY)
if dist then
if tInst ~= pInst then dist = 500000 + dist * 100 end
table.insert(sortedTargets, {
x = x, y = y, uiMapId = uiMapId, title = quest.name, questLevel = quest.level, iconPath = iconPath, distance = dist,
})
if tInst ~= pInst then
dist = 500000 + dist * 100
end
_AddArrowTarget(x, y, uiMapId, quest.name, quest.level, iconPath, tX, tY, tInst, dist)
end
end
end
@@ -992,16 +1062,16 @@ local function _CollectFinisherSpawns(finisher, quest)
if waypoints and waypoints[1] and waypoints[1][1] and waypoints[1][1][1] then
local x = waypoints[1][1][1]
local y = waypoints[1][1][2]
local uiMapId = ZoneDB:GetUiMapIdByAreaId(zone)
local uiMapId = _GetUiMapIdForZone(zone)
if uiMapId and x and y then
local tX, tY, tInst = HBD:GetWorldCoordinatesFromZone(x / 100.0, y / 100.0, uiMapId)
if tX and tY and tInst then
local dist = HBD:GetWorldDistance(tInst, pX, pY, tX, tY)
if dist then
if tInst ~= pInst then dist = 500000 + dist * 100 end
table.insert(sortedTargets, {
x = x, y = y, uiMapId = uiMapId, title = quest.name, questLevel = quest.level, iconPath = iconPath, distance = dist,
})
if tInst ~= pInst then
dist = 500000 + dist * 100
end
_AddArrowTarget(x, y, uiMapId, quest.name, quest.level, iconPath, tX, tY, tInst, dist)
end
end
end
@@ -1041,8 +1111,8 @@ local function _CollectObjective(objective, quest)
print(string.format(" zone=%s filtered=%s (pZone=%s pMap=%s)", tostring(zone), tostring(zoneFiltered), tostring(pZone), tostring(pMap)))
end
if not zoneFiltered then
local uiMapId = _GetUiMapIdForZone(zone)
for _, spawn in pairs(spawns) do
local uiMapId = ZoneDB:GetUiMapIdByAreaId(zone)
if debugCollect then
print(string.format(" spawn=(%.1f,%.1f) uiMapId=%s", spawn[1], spawn[2], tostring(uiMapId)))
end
@@ -1051,16 +1121,24 @@ local function _CollectObjective(objective, quest)
if tX and tY and tInst then
local dist = HBD:GetWorldDistance(tInst, pX, pY, tX, tY)
if dist then
if tInst ~= pInst then dist = 500000 + dist * 100 end
if tInst ~= pInst then
dist = 500000 + dist * 100
end
if debugCollect then
print(string.format(" ADDED dist=%.0f", dist))
end
table.insert(sortedTargets, {
x = spawn[1], y = spawn[2], uiMapId = uiMapId,
title = quest.name, questLevel = quest.level,
iconPath = ResolveIconTexture(objective.Icon) or ResolveIconTexture(spawnData and spawnData.Icon),
distance = dist,
})
_AddArrowTarget(
spawn[1],
spawn[2],
uiMapId,
quest.name,
quest.level,
ResolveIconTexture(objective.Icon) or ResolveIconTexture(spawnData and spawnData.Icon),
tX,
tY,
tInst,
dist
)
end
end
end
@@ -1096,6 +1174,7 @@ function QuestieArrow:UpdateNearestTargets()
local usingAutoLogic = Questie.db.profile.autoTrackQuests or not hasTracked
local playerZoneId = QuestiePlayer:GetCurrentZoneId()
local playerUiMapId = QuestiePlayer:GetCurrentUiMapId()
_arrow_zoneUiMapCache = {}
-- Publish context for hoisted helper functions (avoids closure allocation every call)
_arrow_playerX, _arrow_playerY, _arrow_playerInstance = playerX, playerY, playerInstance
@@ -1209,7 +1288,7 @@ function QuestieArrow:UpdateNearestTargets()
end
-- Sort by distance
table.sort(sortedTargets, function(a, b) return a.distance < b.distance end)
table.sort(sortedTargets, _SortTargetByDistance)
end
function QuestieArrow:Refresh()
@@ -1456,7 +1535,7 @@ function QuestieArrow:Initialize()
end
local now = GetTime()
if (lastTrackerRefresh + TRACKER_REFRESH_THROTTLE_SECONDS) > now then
if (lastTrackerRefresh + _GetArrowTrackerRefreshThrottle()) > now then
return
end
+13 -7
View File
@@ -427,19 +427,25 @@ function QuestieLib:CacheItemNames(questId)
if (quest and quest.ObjectiveData) then
for _, objectiveDB in pairs(quest.ObjectiveData) do
if objectiveDB.Type == "item" then
if not ((QuestieDB.ItemPointers or QuestieDB.itemData)[objectiveDB.Id]) then
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLib:CacheItemNames] Requesting item information for missing itemId:", objectiveDB.Id)
local item = Item:CreateFromItemID(objectiveDB.Id)
local itemId = objectiveDB.Id
if not itemId then
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLib:CacheItemNames] Skipping item objective without itemId for quest:", questId)
elseif not ((QuestieDB.ItemPointers or QuestieDB.itemData)[itemId]) then
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLib:CacheItemNames] Requesting item information for missing itemId:", itemId)
local item = Item:CreateFromItemID(itemId)
item:ContinueOnItemLoad(
function()
local itemName = item:GetItemName()
if not QuestieDB.itemDataOverrides[objectiveDB.Id] then
QuestieDB.itemDataOverrides[objectiveDB.Id] = { itemName, { questId }, {}, {} }
if not itemId then
return
end
if not QuestieDB.itemDataOverrides[itemId] then
QuestieDB.itemDataOverrides[itemId] = { itemName, { questId }, {}, {} }
else
QuestieDB.itemDataOverrides[objectiveDB.Id][1] = itemName
QuestieDB.itemDataOverrides[itemId][1] = itemName
end
Questie:Debug(Questie.DEBUG_DEVELOP,
"[QuestieLib:CacheItemNames] Created item information for item:", itemName, ":", objectiveDB.Id)
"[QuestieLib:CacheItemNames] Created item information for item:", itemName, ":", itemId)
end)
end
end
+146 -4
View File
@@ -1,5 +1,5 @@
-- Shim for Lua 5.2+ (Retail) where table.getn was removed.
-- We cannot use the '#' length operator directly because Lua 5.0 (Turtle WoW)
-- We cannot use the '#' length operator directly because Lua 5.0
-- will trigger a compile-time syntax error parsing the file.
-- Using loadstring bypasses the 5.0 compiler and safely injects the # operator in 5.2+.
if not table.getn then
@@ -19,7 +19,112 @@ if not math.mod then
end
end
-- Shim for Lua 5.0 (Turtle WoW) where string.match is missing.
-- Shim for Lua 5.0 where the bit library may be missing.
-- Questie uses band/bor/bxor/lshift/rshift in core runtime code, so we provide
-- a pure-Lua fallback when the host does not expose one.
if not bit or not bit.band or not bit.bor or not bit.bxor or not bit.lshift or not bit.rshift then
local bitlib = bit or {}
local U32 = 4294967296
local U32_MAX = 4294967295
local floor = math.floor
local mod = math.mod or function(a, b)
return a - floor(a / b) * b
end
local function normalizeU32(n)
n = tonumber(n) or 0
n = floor(n)
if n < 0 then
n = U32 + mod(n, U32)
elseif n >= U32 then
n = mod(n, U32)
end
return n
end
local function bitAt(n, mask)
return mod(floor(n / mask), 2)
end
local function band32(a, b)
a = normalizeU32(a)
b = normalizeU32(b)
local result = 0
local mask = 1
for _ = 1, 32 do
if bitAt(a, mask) == 1 and bitAt(b, mask) == 1 then
result = result + mask
end
mask = mask * 2
end
return normalizeU32(result)
end
local function bor32(a, b)
a = normalizeU32(a)
b = normalizeU32(b)
local result = 0
local mask = 1
for _ = 1, 32 do
if bitAt(a, mask) == 1 or bitAt(b, mask) == 1 then
result = result + mask
end
mask = mask * 2
end
return normalizeU32(result)
end
local function bxor32(a, b)
a = normalizeU32(a)
b = normalizeU32(b)
local result = 0
local mask = 1
for _ = 1, 32 do
if bitAt(a, mask) ~= bitAt(b, mask) then
result = result + mask
end
mask = mask * 2
end
return normalizeU32(result)
end
local function lshift32(a, disp)
a = normalizeU32(a)
disp = tonumber(disp) or 0
if disp <= 0 then
return normalizeU32(math.floor(a / (2 ^ (-disp))))
elseif disp >= 32 then
return 0
end
return normalizeU32(a * (2 ^ disp))
end
local function rshift32(a, disp)
a = normalizeU32(a)
disp = tonumber(disp) or 0
if disp <= 0 then
return normalizeU32(a * (2 ^ (-disp)))
elseif disp >= 32 then
return 0
end
return normalizeU32(floor(a / (2 ^ disp)))
end
local function bnot32(a)
return normalizeU32(U32_MAX - normalizeU32(a))
end
bitlib.band = bitlib.band or band32
bitlib.bor = bitlib.bor or bor32
bitlib.bxor = bitlib.bxor or bxor32
bitlib.lshift = bitlib.lshift or lshift32
bitlib.rshift = bitlib.rshift or rshift32
bitlib.bnot = bitlib.bnot or bnot32
bit = bitlib
_G.bit = bitlib
end
-- Shim for Lua 5.0 where string.match is missing.
-- Supports up to 5 captures (sufficient for all Questie uses).
if not string.match then
string.match = function(str, pattern, init)
@@ -41,7 +146,7 @@ if not string.gmatch then
string.gmatch = string.gfind
end
-- Shim for Lua 5.0 (Turtle WoW) where select() was not yet implemented.
-- Shim for Lua 5.0 where select() was not yet implemented.
-- Fix #7: The original used a `while n > 0` loop that never decremented n,
-- making the loop body run exactly once before returning. Use a plain
-- sequential block instead so the intent is obvious.
@@ -57,6 +162,44 @@ if not select then
end
end
-- Shim for Lua 5.0 where strsplit is missing.
if not strsplit then
strsplit = function(separator, text, max)
if text == nil then
return nil
end
if separator == "" then
return text
end
local results = {}
local resultCount = 0
local startPos = 1
local maxSplits = tonumber(max)
while true do
if maxSplits and resultCount >= (maxSplits - 1) then
resultCount = resultCount + 1
results[resultCount] = string.sub(text, startPos)
break
end
local sepStart, sepEnd = string.find(text, separator, startPos, true)
if not sepStart then
resultCount = resultCount + 1
results[resultCount] = string.sub(text, startPos)
break
end
resultCount = resultCount + 1
results[resultCount] = string.sub(text, startPos, sepStart - 1)
startPos = sepEnd + 1
end
return unpack(results, 1, resultCount)
end
end
-- The only public class except for Questie
---@class QuestieLoader
QuestieLoader = QuestieLoader or {}
@@ -136,4 +279,3 @@ function Questie:Colorize(str, color)
-- Initial stub: just return the string without color or with basic color
return str
end
+2 -2
View File
@@ -21,7 +21,7 @@ function QuestiePluginAPI:HasPendingPlugins()
return self.pendingPluginsCount > 0
end
--- Returns the flavor key of the loaded DB plugin ("WotLK", "Classic", "TBC", "Turtle", "Ascension", etc.)
--- Returns the flavor key of the loaded DB plugin ("WotLK", "Classic", "TBC", "Ascension", etc.)
---@return string|nil
function QuestiePluginAPI:GetLoadedFlavor()
return self.loadedDBFlavor
@@ -276,7 +276,7 @@ function QuestiePlugin:InjectUiMapData(customUiMapData)
end
--- Signals that the plugin has finished loading. This automatically cleans up necessary caches.
---@param flavorKey string|nil Optional flavor label e.g. "WotLK", "Classic", "Turtle"
---@param flavorKey string|nil Optional flavor label e.g. "WotLK", "Classic", "Ascension"
function QuestiePlugin:FinishLoading(flavorKey)
local QuestieDB = QuestieLoader:ImportModule("QuestieDB")
if not QuestieDB then
+5 -3
View File
@@ -6,6 +6,8 @@ local QuestieSerializer = QuestieLoader:CreateModule("QuestieSerializer");
---@type QuestieStreamLib
local QuestieStreamLib = QuestieLoader:ImportModule("QuestieStreamLib");
local mod = math.mod
function QuestieSerializer:Hash(value)
if not value or type(value) ~= "string" or (string.len(value) <= 0) then
@@ -70,14 +72,14 @@ local function floatBitsToInt(n)
else
expo = expo + 0x7E
mant = floor((mant * 2.0 - 1.0) * ldexp(0.5, 24))
return _pack(sign + floor(expo / 0x2), (expo % 0x2) * 0x80 + floor(mant / 0x10000), floor(mant / 0x100) % 0x100, mant % 0x100)
return _pack(sign + floor(expo / 0x2), mod(expo, 0x2) * 0x80 + floor(mant / 0x10000), mod(floor(mant / 0x100), 0x100), mod(mant, 0x100))
end
end
local function intBitsToFloat(int)
local b1, b2, b3, b4 = _unpack(int)
local sign = b1 > 0x7F
local expo = (b1 % 0x80) * 0x2 + floor(b2 / 0x80)
local mant = ((b2 % 0x80) * 0x100 + b3) * 0x100 + b4
local expo = mod(b1, 0x80) * 0x2 + floor(b2 / 0x80)
local mant = ((mod(b2, 0x80)) * 0x100 + b3) * 0x100 + b4
if sign then
sign = -1
else
+10 -1
View File
@@ -10,6 +10,11 @@ local HBD = QuestieCompat.HBD or LibStub("HereBeDragonsQuestie-2.0")
local ZOOM_MODIFIER = 1;
-- Pins whose world-coordinate distance is within this epsilon are treated as the
-- same physical spot and always merged, independent of the clustering range. This
-- is the baseline pin deduplication that runs even when clustering is turned off.
QuestieMap.utils.COINCIDENT_EPSILON = 0.2;
-- All the speed we can get is worth it.
local tinsert = table.insert
local next = next
@@ -146,7 +151,11 @@ function QuestieMap.utils:CalcHotzones(points, rangeR, count)
-- Do not cluster icons if they have no coordinates
and aX ~= 0 and aY ~= 0 and point2.worldX ~= 0 and point2.worldY ~= 0 then
local distance = QuestieLib:Euclid(aX, aY, point2.worldX, point2.worldY)
if (distance < movingRange) then
-- Always deduplicate pins that sit on (essentially) the same spot,
-- even when clustering is disabled (movingRange == 0). This keeps two
-- icons from stacking on an identical coordinate regardless of the
-- clusterDensityAggressiveness / clusterLevelHotzone settings.
if (distance < movingRange) or (distance <= QuestieMap.utils.COINCIDENT_EPSILON) then
point2.touched = true
tinsert(notes, point2)
end
+147 -27
View File
@@ -47,6 +47,7 @@ local suggestUpdate = true;
-- forward declaration
local _DoYell
local IsQuestieCommsEnabled
--Not used, contains a list of hashes for quest, used to compare change.
--_QuestieComms.questHashes = {};
@@ -189,6 +190,7 @@ end
-- Local Functions --
function _QuestieComms:BroadcastQuestUpdate(questId) -- broadcast quest update to group or raid
if not IsQuestieCommsEnabled() then return end
Questie:Debug(Questie.DEBUG_INFO, "[QuestieComms:BroadcastQuestUpdate] Questid", questId)
if(questId) then
local partyType = QuestiePlayer:GetGroupType()
@@ -220,6 +222,7 @@ end
-- Removes the quest from everyones external quest-log
function _QuestieComms:BroadcastQuestRemove(questId) -- broadcast quest update to group or raid
if not IsQuestieCommsEnabled() then return end
local partyType = QuestiePlayer:GetGroupType()
Questie:Debug(Questie.DEBUG_COMMS, "[QuestieComms:BroadcastQuestRemove] QuestId:", questId, "partyType:", tostring(partyType))
if partyType then
@@ -256,6 +259,106 @@ for class, index in pairs(_classToIndex) do
_indexToClass[index] = class
end
local DEFAULT_QUEST_LIST_PACKET_SIZE_LIMIT = 200
local DEFAULT_QUEST_LIST_INITIAL_JITTER = 3
local DEFAULT_QUEST_LIST_BLOCK_INTERVAL = 3
local function GetProfileNumber(key, defaultValue, minValue, maxValue)
if not Questie or not Questie.db or not Questie.db.profile then
return defaultValue
end
local value = Questie.db.profile[key]
if type(value) ~= "number" then
return defaultValue
end
if value < minValue then
return minValue
end
if value > maxValue then
return maxValue
end
return value
end
local function GetQuestListPacketSizeLimit()
return GetProfileNumber("questieCommsQuestListPacketSize", DEFAULT_QUEST_LIST_PACKET_SIZE_LIMIT, 100, 500)
end
local function GetQuestListInitialJitter()
return GetProfileNumber("questieCommsQuestListInitialJitter", DEFAULT_QUEST_LIST_INITIAL_JITTER, 0, 10)
end
local function GetQuestListBlockInterval()
return GetProfileNumber("questieCommsQuestListBlockInterval", DEFAULT_QUEST_LIST_BLOCK_INTERVAL, 0.5, 10)
end
IsQuestieCommsEnabled = function()
if not Questie or not Questie.db or not Questie.db.profile then
return true
end
return Questie.db.profile.questieCommsEnabled ~= false
end
local function GetSerializedPacketSize(packet)
return string.len(QuestieSerializer:Serialize(packet))
end
local function QueuePush(queue, queueState, value)
queueState.tail = queueState.tail + 1
queue[queueState.tail] = value
end
local function QueuePop(queue, queueState)
if queueState.head > queueState.tail then
queueState.head = 1
queueState.tail = 0
return nil
end
local value = queue[queueState.head]
queue[queueState.head] = nil
queueState.head = queueState.head + 1
if queueState.head > queueState.tail then
queueState.head = 1
queueState.tail = 0
end
return value
end
local function GetQuestDataPacketV2Size(questId)
local questObject = QuestieDB.GetQuest(questId)
local rawObjectives = QuestLogCache.GetQuestObjectives(questId) -- DO NOT MODIFY THE RETURNED TABLE
if (not rawObjectives) or (not questObject) or (not questObject.Objectives) or (not next(questObject.Objectives)) then
return 0
end
local questPacket = {}
local offset = 1
local count = 0
questPacket[offset] = questId
local countOffset = offset + 1
offset = offset + 2
for objectiveIndex, objective in pairs(rawObjectives) do -- DO NOT MODIFY THE RETURNED TABLE
local objectiveData = questObject.Objectives[objectiveIndex]
if objectiveData then
questPacket[offset] = objectiveData.Id
questPacket[offset + 1] = string.byte(string.sub(objective.type, 1, 1))
questPacket[offset + 2] = objective.numFulfilled
questPacket[offset + 3] = objective.numRequired
offset = offset + 4
count = count + 1
end
end
questPacket[countOffset] = count
return GetSerializedPacketSize(questPacket)
end
function QuestieComms:PopulateQuestDataPacketV2_noclass_renameme(questId, quest, offset)
local questObject = QuestieDB.GetQuest(questId);
@@ -472,6 +575,7 @@ QuestieComms._yellQueue = {}
QuestieComms._isYelling = false
function QuestieComms:YellProgress(questId)
if not IsQuestieCommsEnabled() then return end
if Questie.db.profile.disableYellComms or badYellLocations[C_Map.GetBestMapForUnit("player")] or QuestiePlayer.numberOfGroupMembers > 4 then
return
end
@@ -489,10 +593,12 @@ end
_QuestieComms._isBroadcasting = false
_QuestieComms._needsNewBroadcast = false
_QuestieComms._nextBroadcastData = {}
_QuestieComms._nextBroadcastDataState = { head = 1, tail = 0 }
function _QuestieComms:BroadcastQuestLog(eventName, sendMode, targetPlayer) -- broadcast quest update to group or raid
if not IsQuestieCommsEnabled() then return end
if _QuestieComms._isBroadcasting then
tinsert(_QuestieComms._nextBroadcastData, {eventName, sendMode, targetPlayer})
QueuePush(_QuestieComms._nextBroadcastData, _QuestieComms._nextBroadcastDataState, {eventName, sendMode, targetPlayer})
return
end
local partyType = QuestiePlayer:GetGroupType()
@@ -543,33 +649,37 @@ function _QuestieComms:BroadcastQuestLog(eventName, sendMode, targetPlayer) -- b
end
end)
local questListPacketSizeLimit = GetQuestListPacketSizeLimit()
local rawQuestList = {}
local blocks = {}
local blockState = { head = 1, tail = 0 }
local entryCount = 0
local blockCount = 2 -- the extra tick allows checking tremove() == nil to set _isBroadcasting=false
local blockSerializedSize = 2
for _, entry in pairs(sorted) do
local quest = QuestieComms:CreateQuestDataPacket(entry.questId);
--print("[CommsSendOrder][Block " .. (blockCount - 1) .. "] " .. QuestieDB.QueryQuestSingle(entry.questId, "name"))
entryCount = entryCount + 1
rawQuestList[quest.id] = quest;
if string.len(QuestieSerializer:Serialize(rawQuestList)) > 200 then--extra space for packet metadata and CTL stuff
rawQuestList[quest.id] = nil
tinsert(blocks, rawQuestList)
rawQuestList = {
[quest.id] = quest
}
entryCount = 1
local questPacketSize = GetSerializedPacketSize(quest)
if entryCount ~= 0 and (blockSerializedSize + questPacketSize) > questListPacketSizeLimit then
QueuePush(blocks, blockState, rawQuestList)
rawQuestList = {}
entryCount = 0
blockSerializedSize = 2
blockCount = blockCount + 1
end
entryCount = entryCount + 1
rawQuestList[quest.id] = quest;
blockSerializedSize = blockSerializedSize + questPacketSize
end
if entryCount ~= 0 then
tinsert(blocks, rawQuestList) -- add the last block
QueuePush(blocks, blockState, rawQuestList) -- add the last block
_QuestieComms._isBroadcasting = true
-- hopefully reduce server load by staggering responses
C_Timer.After(random() * 3, function()
C_Timer.NewTicker(3, function()
local block = tremove(blocks, 1)
C_Timer.After(random() * GetQuestListInitialJitter(), function()
C_Timer.NewTicker(GetQuestListBlockInterval(), function()
local block = QueuePop(blocks, blockState)
if block then
-- send the block
local questPacket = _QuestieComms:CreatePacket(_QuestieComms.QC_ID_BROADCAST_FULL_QUESTLIST);
@@ -593,7 +703,7 @@ function _QuestieComms:BroadcastQuestLog(eventName, sendMode, targetPlayer) -- b
questPacket:write();
else
_QuestieComms._isBroadcasting = false
local nextBroadcast = tremove(_QuestieComms._nextBroadcastData, 1)
local nextBroadcast = QueuePop(_QuestieComms._nextBroadcastData, _QuestieComms._nextBroadcastDataState)
if nextBroadcast then
_QuestieComms:BroadcastQuestLog(unpack(nextBroadcast))
end
@@ -606,10 +716,12 @@ end
_QuestieComms._isBroadcastingV2 = false
_QuestieComms._nextBroadcastDataV2 = {}
_QuestieComms._nextBroadcastDataV2State = { head = 1, tail = 0 }
function _QuestieComms:BroadcastQuestLogV2(eventName, sendMode, targetPlayer) -- broadcast quest update to group or raid
if not IsQuestieCommsEnabled() then return end
if _QuestieComms._isBroadcastingV2 then
tinsert(_QuestieComms._nextBroadcastDataV2, {eventName, sendMode, targetPlayer})
QueuePush(_QuestieComms._nextBroadcastDataV2, _QuestieComms._nextBroadcastDataV2State, {eventName, sendMode, targetPlayer})
return
end
local partyType = QuestiePlayer:GetGroupType()
@@ -660,36 +772,41 @@ function _QuestieComms:BroadcastQuestLogV2(eventName, sendMode, targetPlayer) --
end
end)
local questListPacketSizeLimit = GetQuestListPacketSizeLimit()
local rawQuestList = {}
local blocks = {}
local blockState = { head = 1, tail = 0 }
local entryCount = 0
local blockCount = 2 -- the extra tick allows checking tremove() == nil to set _isBroadcasting=false
local blockSerializedSize = 2
local offset = 2
for _, entry in pairs(sorted) do
--print("[CommsSendOrder][Block " .. (blockCount - 1) .. "] " .. QuestieDB.QueryQuestSingle(entry.questId, "name"))
entryCount = entryCount + 1
offset = QuestieComms:PopulateQuestDataPacketV2_noclass_renameme(entry.questId, rawQuestList, offset)
if string.len(QuestieSerializer:Serialize(rawQuestList)) > 200 then--extra space for packet metadata and CTL stuff
local questPacketSize = GetQuestDataPacketV2Size(entry.questId)
if entryCount ~= 0 and (blockSerializedSize + questPacketSize) > questListPacketSizeLimit then
rawQuestList[1] = entryCount
tinsert(blocks, rawQuestList)
QueuePush(blocks, blockState, rawQuestList)
rawQuestList = {}
entryCount = 0
blockSerializedSize = 2
blockCount = blockCount + 1
offset = 2
end
entryCount = entryCount + 1
offset = QuestieComms:PopulateQuestDataPacketV2_noclass_renameme(entry.questId, rawQuestList, offset)
blockSerializedSize = blockSerializedSize + questPacketSize
end
if entryCount ~= 0 or blockCount ~= 2 then
rawQuestList[1] = entryCount
tinsert(blocks, rawQuestList) -- add the last block
QueuePush(blocks, blockState, rawQuestList) -- add the last block
_QuestieComms._isBroadcastingV2 = true
-- hopefully reduce server load by staggering responses
C_Timer.After(random() * 3, function()
C_Timer.NewTicker(3, function()
local block = tremove(blocks, 1)
C_Timer.After(random() * GetQuestListInitialJitter(), function()
C_Timer.NewTicker(GetQuestListBlockInterval(), function()
local block = QueuePop(blocks, blockState)
if block then
-- send the block
local questPacket = _QuestieComms:CreatePacket(_QuestieComms.QC_ID_BROADCAST_FULL_QUESTLISTV2);
@@ -713,7 +830,7 @@ function _QuestieComms:BroadcastQuestLogV2(eventName, sendMode, targetPlayer) --
questPacket:write();
else
_QuestieComms._isBroadcastingV2 = false
local nextBroadcast = tremove(_QuestieComms._nextBroadcastDataV2, 1)
local nextBroadcast = QueuePop(_QuestieComms._nextBroadcastDataV2, _QuestieComms._nextBroadcastDataV2State)
if nextBroadcast then
_QuestieComms:BroadcastQuestLogV2(unpack(nextBroadcast))
end
@@ -726,6 +843,7 @@ end
-- The "Hi" of questie, request others to send their questlog.
function _QuestieComms:RequestQuestLog(eventName) -- broadcast quest update to group or raid
if not IsQuestieCommsEnabled() then return end
local partyType = QuestiePlayer:GetGroupType()
Questie:Debug(Questie.DEBUG_COMMS, "[QuestieComms] Message", eventName, "partyType:", tostring(partyType))
if partyType then
@@ -942,6 +1060,7 @@ _QuestieComms.packets = {
-- Renamed Write function
function _QuestieComms:Broadcast(packet)
if not IsQuestieCommsEnabled() then return end
-- If the priority is not set, it must not be very important
if packet.writeMode ~= _QuestieComms.QC_WRITE_WHISPER and (QuestiePlayer.numberOfGroupMembers > 15 or UnitInBattleground("Player")) then
-- dont broadcast to large raids
@@ -981,6 +1100,7 @@ function _QuestieComms:Broadcast(packet)
end
function _QuestieComms:OnCommReceived(message, distribution, sender)
if not IsQuestieCommsEnabled() then return end
pcall(_QuestieComms.OnCommReceived_unsafe, _QuestieComms, message, distribution, sender)
end
+62 -9
View File
@@ -19,7 +19,6 @@ local math_min = math.min
local math_floor = math.floor
local math_random = math.random
local table_insert = table.insert
local table_remove = table.remove
local table_getn = table.getn
-- Dev Logging Flags — defined first so all functions below can call DebugLog
@@ -62,11 +61,15 @@ local lastTokenUpdate = GetTime()
local minChatInterval = 3.5
local lastChatMessageTime = 0
local rateLimitQueue = {}
local rateLimitQueueHead = 1
local rateLimitQueueTail = 0
-- Deduplication & Quarantine
local messageCache = {}
local messageCacheCount = 0 -- O(1) counter; avoids pairs() scan on every message
local incomingMessageQueue = {}
local incomingMessageQueueHead = 1
local incomingMessageQueueTail = 0
-- Cached hidden channel ID (avoids GetChannelName every ProcessQueues tick)
local _hiddenChannelId = 0
@@ -138,6 +141,26 @@ local function IsDuplicateMessage(serializedData)
return false
end
local function GetLearnerSettings()
if Questie and Questie.dbLearner and Questie.dbLearner.global and Questie.dbLearner.global.settings then
return Questie.dbLearner.global.settings
end
return {}
end
local function GetCommsTuning()
local intensity = GetLearnerSettings().learnerCommsIntensity or "normal"
if intensity == "off" then
return false, 0, 999999, 0, 0
elseif intensity == "low" then
return true, 4, 6.0, 2, 1
elseif intensity == "fast" then
return true, 15, 1.5, 10, 4
end
return true, 9, 3.5, 6, 2
end
function QuestieLearnerComms:Initialize()
DebugLog("DEVELOP", "Initializing QuestieLearnerComms")
@@ -192,6 +215,8 @@ function _QuestieLearnerComms:ProcessReinforcement()
end
function QuestieLearnerComms:BroadcastLearnedData(op, entityType, entityId, data)
local commsEnabled = GetCommsTuning()
if not commsEnabled then return end
if not data or type(data) ~= "table" then return end
-- 1. Create Payload (sanitize data to remove functions before serialization)
@@ -223,10 +248,20 @@ function QuestieLearnerComms:BroadcastLearnedData(op, entityType, entityId, data
end
function _QuestieLearnerComms:QueueMessage(encodedMessage)
table.insert(rateLimitQueue, encodedMessage)
local commsEnabled = GetCommsTuning()
if not commsEnabled then return end
rateLimitQueueTail = rateLimitQueueTail + 1
rateLimitQueue[rateLimitQueueTail] = encodedMessage
end
function _QuestieLearnerComms:ProcessQueues()
local commsEnabled, tunedBucketCapacity, tunedMinChatInterval, normalIncomingCount, combatIncomingCount = GetCommsTuning()
bucketCapacity = tunedBucketCapacity
tokenRefillRate = bucketCapacity / bucketWindow
minChatInterval = tunedMinChatInterval
currentTokens = math_min(bucketCapacity, currentTokens)
if not commsEnabled then return end
-- 1. Refill Tokens
local now = GetTime()
local elapsed = now - lastTokenUpdate
@@ -234,8 +269,14 @@ function _QuestieLearnerComms:ProcessQueues()
lastTokenUpdate = now
-- 2. Drain Outgoing Queue
if table_getn(rateLimitQueue) > 0 and currentTokens >= 1 and (now - lastChatMessageTime) >= minChatInterval then
local msg = table_remove(rateLimitQueue, 1)
if rateLimitQueueHead <= rateLimitQueueTail and currentTokens >= 1 and (now - lastChatMessageTime) >= minChatInterval then
local msg = rateLimitQueue[rateLimitQueueHead]
rateLimitQueue[rateLimitQueueHead] = nil
rateLimitQueueHead = rateLimitQueueHead + 1
if rateLimitQueueHead > rateLimitQueueTail then
rateLimitQueueHead = 1
rateLimitQueueTail = 0
end
currentTokens = currentTokens - 1
lastChatMessageTime = now
@@ -250,10 +291,16 @@ function _QuestieLearnerComms:ProcessQueues()
end
-- 3. Process Incoming Queue (Combat Aware)
local processCount = InCombatLockdown() and 2 or 6
local processCount = InCombatLockdown() and combatIncomingCount or normalIncomingCount
for i = 1, processCount do
if table_getn(incomingMessageQueue) == 0 then break end
local rawMsg = table_remove(incomingMessageQueue, 1)
if incomingMessageQueueHead > incomingMessageQueueTail then break end
local rawMsg = incomingMessageQueue[incomingMessageQueueHead]
incomingMessageQueue[incomingMessageQueueHead] = nil
incomingMessageQueueHead = incomingMessageQueueHead + 1
if incomingMessageQueueHead > incomingMessageQueueTail then
incomingMessageQueueHead = 1
incomingMessageQueueTail = 0
end
_QuestieLearnerComms:ProcessRawMessage(rawMsg.text, rawMsg.sender)
end
end
@@ -262,14 +309,20 @@ end
local frame = CreateFrame("Frame")
frame:RegisterEvent("CHAT_MSG_CHANNEL")
frame:SetScript("OnEvent", function(self, event, msg, sender, _, _, _, _, _, channelId, channelName)
local commsEnabled = GetCommsTuning()
if not commsEnabled then return end
if channelName == hiddenChannelName and sender ~= UnitName("player") then
table.insert(incomingMessageQueue, {text = msg, sender = sender})
incomingMessageQueueTail = incomingMessageQueueTail + 1
incomingMessageQueue[incomingMessageQueueTail] = {text = msg, sender = sender}
end
end)
function QuestieLearnerComms:OnCommReceived(prefix, message, distribution, sender)
local commsEnabled = GetCommsTuning()
if not commsEnabled then return end
if prefix == addonPrefix and sender ~= UnitName("player") then
table.insert(incomingMessageQueue, {text = message, sender = sender})
incomingMessageQueueTail = incomingMessageQueueTail + 1
incomingMessageQueue[incomingMessageQueueTail] = {text = message, sender = sender}
end
end
@@ -19,11 +19,77 @@ local IsleOfQuelDanas = QuestieLoader:ImportModule("IsleOfQuelDanas");
local l10n = QuestieLoader:ImportModule("l10n")
---@type QuestieCompat
local QuestieCompat = QuestieLoader:ImportModule("QuestieCompat")
---@type QuestieDB
local QuestieDB = QuestieLoader:ImportModule("QuestieDB")
QuestieOptions.tabs.advanced = {}
local optionsDefaults = QuestieOptionsDefaults:Load()
local _GetLanguages
local function GetLearnerSettings()
Questie.dbLearner = Questie.dbLearner or {}
Questie.dbLearner.global = Questie.dbLearner.global or {}
Questie.dbLearner.global.settings = Questie.dbLearner.global.settings or {}
local settings = Questie.dbLearner.global.settings
if settings.performanceMode == nil then
settings.performanceMode = "balanced"
end
if settings.pinRefreshDelay == nil then
settings.pinRefreshDelay = 0.75
end
if settings.pinRefreshMode == nil then
settings.pinRefreshMode = "batched"
end
if settings.pinRefreshMaxWait == nil then
settings.pinRefreshMaxWait = 5.0
end
if settings.liveNpcUpdateDelay == nil then
settings.liveNpcUpdateDelay = 0.75
end
if settings.learnerCommsIntensity == nil then
settings.learnerCommsIntensity = "normal"
end
if settings.minConfidencePins == nil then
settings.minConfidencePins = 1
end
if settings.spawnDedupRadius == nil then
settings.spawnDedupRadius = 4.0
end
return settings
end
local function ApplyLearnerPerformancePreset(mode)
local settings = GetLearnerSettings()
settings.performanceMode = mode
if mode == "realtime" then
settings.pinRefreshDelay = 0.1
settings.pinRefreshMode = "immediate"
settings.pinRefreshMaxWait = 2.0
settings.liveNpcUpdateDelay = 0.25
settings.learnerCommsIntensity = "fast"
settings.minConfidencePins = 1
elseif mode == "low" then
settings.pinRefreshDelay = 2.0
settings.pinRefreshMode = "batched"
settings.pinRefreshMaxWait = 10.0
settings.liveNpcUpdateDelay = 2.0
settings.learnerCommsIntensity = "low"
settings.minConfidencePins = 3
elseif mode == "balanced" then
settings.pinRefreshDelay = 0.75
settings.pinRefreshMode = "batched"
settings.pinRefreshMaxWait = 5.0
settings.liveNpcUpdateDelay = 0.75
settings.learnerCommsIntensity = "normal"
settings.minConfidencePins = 1
end
if mode == "realtime" or mode == "balanced" or mode == "low" then
Questie.db.profile.learnerBroadcast = true
end
end
function QuestieOptions.tabs.advanced:Initialize()
-- This needs to be called inside of the Init process for l10n to be fully loaded
StaticPopupDialogs["QUESTIE_LANG_CHANGED_RELOAD"] = {
@@ -107,6 +173,22 @@ function QuestieOptions.tabs.advanced:Initialize()
QuestieOptionsUtils.DetermineTheme()
end,
},
clusterDensityAggressiveness = {
type = "range",
order = 1.41,
name = function() return l10n('Dense pin clustering aggressiveness'); end,
desc = function() return l10n('How aggressively crowded kill objectives are consolidated into fewer pins. 0 shows every pin; higher values tighten clustering where many pins share a zone. Coincident pins are always deduplicated.'); end,
width = 1.5,
disabled = function() return (not Questie.db.profile.enabled); end,
min = 0,
max = 100,
step = 5,
get = function(info) return QuestieOptions:GetProfileValue(info); end,
set = function(info, value)
QuestieOptions:SetProfileValue(info, value)
QuestieOptionsUtils:Delay(0.5, QuestieOptions.ClusterRedraw, l10n('Setting dense pin clustering aggressiveness to %s : Redrawing!', value))
end,
},
quelDanasSpacer1 = QuestieOptionsUtils:Spacer(1.45, (not Questie.IsTBC)),
npcrules_group = {
type = "group",
@@ -157,6 +239,272 @@ function QuestieOptions.tabs.advanced:Initialize()
},
},
learnerPerformanceSpacer = QuestieOptionsUtils:Spacer(1.9),
learnerPerformanceHeader = {
type = "header",
order = 2,
name = function() return l10n('QuestieLearner Performance'); end,
},
learnerPerformanceMode = {
type = "select",
order = 2.1,
values = {
realtime = l10n("Realtime"),
balanced = l10n("Balanced"),
low = l10n("Low Impact"),
manual = l10n("Manual"),
},
style = "dropdown",
name = function() return l10n('Performance Mode'); end,
desc = function() return l10n('Controls how aggressively QuestieLearner updates learned pins, live data, and learner comms. Low Impact is recommended for heavy activity zones or low-end computers.'); end,
get = function() return GetLearnerSettings().performanceMode or "balanced" end,
set = function(_, value)
ApplyLearnerPerformancePreset(value)
end,
},
learnerPinRefreshMode = {
type = "select",
order = 2.2,
values = {
immediate = l10n("Immediate"),
batched = l10n("Batched"),
manual = l10n("Manual / Reload"),
},
style = "dropdown",
name = function() return l10n('Pin Refresh Behavior'); end,
desc = function() return l10n('Controls when learned pins refresh after QuestieLearner records new data. Manual / Reload records data but avoids live pin redraws until reload or another Questie refresh.'); end,
get = function() return GetLearnerSettings().pinRefreshMode or "batched" end,
set = function(_, value)
local settings = GetLearnerSettings()
settings.pinRefreshMode = value
settings.performanceMode = "manual"
end,
},
learnerPinRefreshDelay = {
type = "range",
order = 2.3,
name = function() return l10n('Pin Refresh Delay'); end,
desc = function() return l10n('Seconds to wait before refreshing learned quest pins after learner activity. Higher values reduce stutter during kill or loot bursts.'); end,
min = 0.1,
max = 5,
step = 0.1,
width = 1.5,
get = function() return GetLearnerSettings().pinRefreshDelay or 0.5 end,
set = function(_, value)
local settings = GetLearnerSettings()
settings.pinRefreshDelay = value
settings.performanceMode = "manual"
end,
},
learnerPinRefreshMaxWait = {
type = "range",
order = 2.35,
name = function() return l10n('Pin Refresh Max Wait'); end,
desc = function() return l10n('Maximum seconds learned pins will wait during continuous activity (e.g. nearby players killing mobs) before a forced refresh. The refresh delay resets on each kill, so pins only redraw once things go quiet or this cap is hit. Set to 0 to never force a refresh while activity continues.'); end,
min = 0,
max = 30,
step = 0.5,
width = 1.5,
get = function() return GetLearnerSettings().pinRefreshMaxWait or 5.0 end,
set = function(_, value)
local settings = GetLearnerSettings()
settings.pinRefreshMaxWait = value
settings.performanceMode = "manual"
end,
},
learnerLiveNpcUpdateDelay = {
type = "range",
order = 2.4,
name = function() return l10n('Live NPC Update Delay'); end,
desc = function() return l10n('Seconds to batch learned NPC live database updates. Higher values reduce work during combat and crowded zones.'); end,
min = 0.25,
max = 5,
step = 0.25,
width = 1.5,
get = function() return GetLearnerSettings().liveNpcUpdateDelay or 0.5 end,
set = function(_, value)
local settings = GetLearnerSettings()
settings.liveNpcUpdateDelay = value
settings.performanceMode = "manual"
end,
},
learnerMinConfidencePins = {
type = "range",
order = 2.5,
name = function() return l10n('Minimum Kills Before Learned Pins'); end,
desc = function() return l10n('How many matching NPC sightings are needed before QuestieLearner shows learned pins. Higher values reduce one-off pin churn.'); end,
min = 1,
max = 10,
step = 1,
width = 1.5,
get = function() return GetLearnerSettings().minConfidencePins or 1 end,
set = function(_, value)
local settings = GetLearnerSettings()
settings.minConfidencePins = value
settings.performanceMode = "manual"
end,
},
learnerSpawnDedupRadius = {
type = "range",
order = 2.55,
name = function() return l10n('Spawn Pin Dedup Radius'); end,
desc = function() return l10n('How close two learned kill positions must be (in map %) to merge into a single pin. Higher values show fewer, tighter pins per spawn; 0 shows every distinct position.'); end,
min = 0,
max = 15,
step = 0.5,
width = 1.5,
get = function() return GetLearnerSettings().spawnDedupRadius or 4.0 end,
set = function(_, value)
GetLearnerSettings().spawnDedupRadius = value
-- Spawn tables are cached per NPC; clear so the new radius is
-- applied on the redraw instead of serving stale merged coords.
if QuestieDB and QuestieDB.ClearModeCaches then
QuestieDB:ClearModeCaches()
end
QuestieOptionsUtils:Delay(0.5, QuestieQuest.SmoothReset, l10n('Setting spawn pin dedup radius to %s : Redrawing!', value))
end,
},
learnerCommsIntensity = {
type = "select",
order = 2.6,
values = {
off = l10n("Off"),
low = l10n("Low"),
normal = l10n("Normal"),
fast = l10n("Fast"),
},
style = "dropdown",
name = function() return l10n('Learner Comms Intensity'); end,
desc = function() return l10n('Controls how much learner data Questie processes and sends through learner comms. Lower values reduce CPU and chat-channel work.'); end,
get = function() return GetLearnerSettings().learnerCommsIntensity or "normal" end,
set = function(_, value)
local settings = GetLearnerSettings()
settings.learnerCommsIntensity = value
settings.performanceMode = "manual"
if value == "off" then
Questie.db.profile.learnerBroadcast = false
elseif Questie.db.profile.learnerBroadcast == false then
Questie.db.profile.learnerBroadcast = true
end
end,
},
arrowPerformanceSpacer = QuestieOptionsUtils:Spacer(2.69),
arrowPerformanceHeader = {
type = "header",
order = 2.7,
name = function() return l10n('QuestieArrow Performance'); end,
},
arrowUpdateThrottle = {
type = "range",
order = 2.71,
width = 1.5,
name = function() return l10n('Arrow Movement Update Interval'); end,
desc = function() return l10n('Seconds between arrow rotation and distance updates. Higher values reduce CPU usage but make the arrow feel less smooth.'); end,
min = 0.03,
max = 0.5,
step = 0.01,
get = function() return Questie.db.profile.arrowUpdateThrottle or optionsDefaults.profile.arrowUpdateThrottle end,
set = function(_, value)
Questie.db.profile.arrowUpdateThrottle = value
end,
},
arrowRecalcInterval = {
type = "range",
order = 2.72,
width = 1.5,
name = function() return l10n('Target Scan Interval'); end,
desc = function() return l10n('Seconds between full nearest-objective scans. Higher values reduce HBD and ZoneDB work in large quest logs.'); end,
min = 0.5,
max = 10,
step = 0.5,
get = function() return Questie.db.profile.arrowRecalcInterval or optionsDefaults.profile.arrowRecalcInterval end,
set = function(_, value)
Questie.db.profile.arrowRecalcInterval = value
local QuestieArrow = QuestieLoader:ImportModule("QuestieArrow")
if QuestieArrow and QuestieArrow.Refresh then
QuestieArrow:Refresh()
end
end,
},
arrowTrackerRefreshThrottle = {
type = "range",
order = 2.73,
width = 1.5,
name = function() return l10n('Tracker Refresh Throttle'); end,
desc = function() return l10n('Minimum seconds between arrow refreshes triggered by tracker updates. Higher values reduce refresh bursts during quest progress changes.'); end,
min = 0.25,
max = 5,
step = 0.25,
get = function() return Questie.db.profile.arrowTrackerRefreshThrottle or optionsDefaults.profile.arrowTrackerRefreshThrottle end,
set = function(_, value)
Questie.db.profile.arrowTrackerRefreshThrottle = value
end,
},
questieCommsPerformanceSpacer = QuestieOptionsUtils:Spacer(2.79),
questieCommsPerformanceHeader = {
type = "header",
order = 2.8,
name = function() return l10n('QuestieComms Performance'); end,
},
questieCommsEnabled = {
type = "toggle",
order = 2.805,
name = function() return l10n('Enable QuestieComms'); end,
desc = function() return l10n('Enable Questie group quest-progress communication. Disabling this stops outgoing QuestieComms and ignores incoming QuestieComms immediately.'); end,
width = 1.5,
get = function() return Questie.db.profile.questieCommsEnabled ~= false end,
set = function(_, value)
Questie.db.profile.questieCommsEnabled = value
end,
},
questieCommsQuestListPacketSize = {
type = "range",
order = 2.81,
name = function() return l10n('Quest List Packet Size'); end,
desc = function() return l10n('Maximum serialized payload size per full quest-list block. Lower values create smaller packets but may send more blocks.'); end,
min = 100,
max = 500,
step = 25,
width = 1.5,
disabled = function() return Questie.db.profile.questieCommsEnabled == false end,
get = function() return Questie.db.profile.questieCommsQuestListPacketSize or optionsDefaults.profile.questieCommsQuestListPacketSize end,
set = function(_, value)
Questie.db.profile.questieCommsQuestListPacketSize = value
end,
},
questieCommsQuestListInitialJitter = {
type = "range",
order = 2.82,
name = function() return l10n('Quest List Initial Jitter'); end,
desc = function() return l10n('Maximum random delay before responding with a full quest list. Higher values spread group responses out to reduce bursts.'); end,
min = 0,
max = 10,
step = 0.5,
width = 1.5,
disabled = function() return Questie.db.profile.questieCommsEnabled == false end,
get = function() return Questie.db.profile.questieCommsQuestListInitialJitter or optionsDefaults.profile.questieCommsQuestListInitialJitter end,
set = function(_, value)
Questie.db.profile.questieCommsQuestListInitialJitter = value
end,
},
questieCommsQuestListBlockInterval = {
type = "range",
order = 2.83,
name = function() return l10n('Quest List Block Interval'); end,
desc = function() return l10n('Seconds between full quest-list blocks. Higher values reduce comms bursts on slower systems and crowded groups.'); end,
min = 0.5,
max = 10,
step = 0.5,
width = 1.5,
disabled = function() return Questie.db.profile.questieCommsEnabled == false end,
get = function() return Questie.db.profile.questieCommsQuestListBlockInterval or optionsDefaults.profile.questieCommsQuestListBlockInterval end,
set = function(_, value)
Questie.db.profile.questieCommsQuestListBlockInterval = value
end,
},
Spacer_A = QuestieOptionsUtils:Spacer(2.9),
locale_header = {
type = "header",
@@ -95,7 +95,7 @@ function QuestieOptions.tabs.credits:Initialize()
thanks = {
type = "description",
order = 1,
name = function() return 'The Turtle WoW, Project Ascension, and Project Ebonhold communities for their ongoing support and feedback.'; end,
name = function() return 'The Project Ascension, Project Ebonhold, and custom server communities for their ongoing support and feedback.'; end,
fontSize = "medium",
},
},
@@ -20,7 +20,6 @@ end
local function GetServer()
if Questie.IsAscension then return "Ascension" end
if Questie.IsTurtle then return "Turtle" end
if Questie.IsEbonhold then return "Ebonhold" end
if Questie.IsEra then return "Era" end
if Questie.Is335 then return "WotLK" end
@@ -47,6 +46,47 @@ local function GetLearnedCounts()
return s
end
local function ApplyLearnerMode()
local QuestieLearner = QuestieLoader:ImportModule("QuestieLearner")
if QuestieLearner and QuestieLearner.ApplyDataSourceMode then
QuestieLearner:ApplyDataSourceMode()
end
-- SmoothReset is the canonical full refresh: it clears all map/minimap notes
-- and tooltips, recalculates and redraws available quests, re-updates every
-- active quest, and refreshes the tracker. This makes a data-source-mode
-- switch (auto/learner/static/none) take effect everywhere in real time.
local QuestieQuest = QuestieLoader:ImportModule("QuestieQuest")
if QuestieQuest and QuestieQuest.SmoothReset then
QuestieQuest:SmoothReset()
else
local QuestieTracker = QuestieLoader:ImportModule("QuestieTracker")
if QuestieTracker and QuestieTracker.Update then
QuestieTracker:Update()
end
end
end
local function GetLearnerRuntimeMode()
local QuestieLearner = QuestieLoader:ImportModule("QuestieLearner")
if QuestieLearner and QuestieLearner.GetDataSourceMode then
return QuestieLearner:GetDataSourceMode()
end
return (Questie.dbLearner.global and Questie.dbLearner.global.settings and Questie.dbLearner.global.settings.dataSourceMode) or "auto"
end
local function GetLearnerSelectedMode()
return (Questie.dbLearner.global and Questie.dbLearner.global.settings and Questie.dbLearner.global.settings.dataSourceMode) or "auto"
end
local function IsLearnerRuntimeEnabled()
local QuestieLearner = QuestieLoader:ImportModule("QuestieLearner")
if QuestieLearner and QuestieLearner.IsEnabled then
return QuestieLearner:IsEnabled()
end
return Questie.dbLearner.global and Questie.dbLearner.global.settings and Questie.dbLearner.global.settings.enabled
end
-----------------------------------------------------------------------
-- Export Dialog
-----------------------------------------------------------------------
@@ -205,6 +245,68 @@ function QuestieOptions.tabs.database:Initialize()
name = function() return l10n("What To Learn") end,
},
learner_enabled = {
type = "toggle",
order = 2.05,
name = function() return l10n("Enable Learner Recording") end,
desc = function() return l10n("Record live learner data. Disable this to stop recording and live learner injection. Learner will still auto-enable if the static DB is missing.") end,
get = function() return IsLearnerRuntimeEnabled() end,
set = function(_, v)
if Questie.dbLearner.global and Questie.dbLearner.global.settings then
Questie.dbLearner.global.settings.enabled = v
ApplyLearnerMode()
end
end,
},
data_source_mode = {
type = "select",
order = 2.06,
name = function() return l10n("Data Source Mode") end,
desc = function()
local runtimeMode = GetLearnerRuntimeMode()
if runtimeMode == "learner" and GetLearnerSelectedMode() ~= "learner" then
return l10n("Choose whether Questie should display learner data, static database data, both, or neither. Learner recording can stay enabled independently. The runtime may still force learner if the static DB is unavailable.")
end
return l10n("Choose whether Questie should display learner data, static database data, both, or neither. Learner recording can stay enabled independently.")
end,
values = {
auto = l10n("Auto (current behavior)"),
learner = l10n("Learner Only"),
static = l10n("Static Only"),
none = l10n("Neither (base DB only)"),
},
get = function() return GetLearnerSelectedMode() end,
set = function(_, v)
if Questie.dbLearner.global and Questie.dbLearner.global.settings then
Questie.dbLearner.global.settings.dataSourceMode = v
Questie.dbLearner.global.settings.prioritizeMyData = (v ~= "static" and v ~= "none")
if v == "learner" then
Questie.dbLearner.global.settings.enabled = true
end
ApplyLearnerMode()
end
end,
},
data_source_runtime = {
type = "description",
order = 2.07,
fontSize = "medium",
name = function()
local selectedMode = GetLearnerSelectedMode()
local runtimeMode = GetLearnerRuntimeMode()
if runtimeMode == selectedMode then
return string.format("|cFF5EBAF3Runtime mode:|r %s", l10n(runtimeMode == "learner" and "Learner" or runtimeMode == "static" and "Static" or runtimeMode == "none" and "Neither" or "Auto"))
end
return string.format(
"|cFF5EBAF3Runtime mode:|r %s |cFF888888(selected: %s)|r",
l10n(runtimeMode == "learner" and "Learner" or runtimeMode == "static" and "Static" or runtimeMode == "none" and "Neither" or "Auto"),
l10n(selectedMode == "learner" and "Learner" or selectedMode == "static" and "Static" or selectedMode == "none" and "Neither" or "Auto")
)
end,
},
learn_npcs = {
type = "toggle",
order = 2.1,
@@ -434,9 +536,9 @@ function QuestieOptions.tabs.database:Initialize()
confirm = true,
confirmText = "Are you sure? This cannot be undone.",
func = function()
if Questie.db and Questie.db.global then
Questie.dbLearner.global = nil
Questie:Print("|cFFFF4444[Questie-X]|r All learned data has been reset.")
local QuestieLearner = QuestieLoader:ImportModule("QuestieLearner")
if QuestieLearner and QuestieLearner.ClearAllData then
QuestieLearner:ClearAllData()
end
end,
},
@@ -9,6 +9,11 @@ function QuestieOptionsDefaults:Load()
ascensionScalingAsked = false,
--Ascension
clusterLevelHotzone = 50,
-- How aggressively dense objectives (many pins in one zone) get
-- consolidated. 0 = off (every pin shown); higher tightens the
-- clustering range for crowded kill objectives. Coincident pins are
-- always deduplicated regardless of this value. See QuestieQuest:_DrawObjectiveIcons.
clusterDensityAggressiveness = 35,
enableIconLimit = false,
iconLimit = 200,
availableScale = 1.2,
@@ -64,6 +69,13 @@ function QuestieOptionsDefaults:Load()
arrowCustomIsSheet = false,
arrowFontSize = 10,
arrowFont = 'Friz Quadrata TT',
arrowUpdateThrottle = 0.05,
arrowRecalcInterval = 1.0,
arrowTrackerRefreshThrottle = 0.5,
questieCommsEnabled = true,
questieCommsQuestListPacketSize = 200,
questieCommsQuestListInitialJitter = 3,
questieCommsQuestListBlockInterval = 3,
debugArrow = false,
enableObjectives = true,
enableTurnins = true,
@@ -77,8 +89,12 @@ function QuestieOptionsDefaults:Load()
enableTooltipsQuestLevel = true,
showQuestXpAtMaxLevel = true,
enableTooltipsNextInChain = true,
learnerBroadcast = true,
enableMapIcons = true,
enableMiniMapIcons = true,
-- Learner source preference (Database tab)
-- auto = current behavior, learner = learner only, static = static only, none = base DB only
dataSourceMode = "auto",
questieShutUp = false,
bugWorkarounds = true,
hideIconsOnContinents = false,
+10
View File
@@ -35,6 +35,7 @@ local timer
-- Keep track of all available quests to unload undoable when abandoning a quest
local availableQuests = {}
local unavailableQuestLogged = {}
local dungeons = ZoneDB:GetDungeons()
@@ -282,12 +283,21 @@ end
_DrawAvailableQuest = function(questId)
NewThread(function()
local quest = QuestieDB.GetQuest(questId)
if not quest then
if not unavailableQuestLogged[questId] then
unavailableQuestLogged[questId] = true
Questie:Debug(Questie.DEBUG_LEARNER, "[AvailableQuests] Skipping unavailable quest during draw:", questId)
end
return
end
if (not quest.tagInfoWasCached) then
QuestieDB.GetQuestTagInfo(questId) -- cache to load in the tooltip
quest.tagInfoWasCached = true
end
unavailableQuestLogged[questId] = nil
AvailableQuests.DrawAvailableQuest(quest)
end, 0)
end
+2 -1
View File
@@ -237,7 +237,8 @@ end
---@param questLogIndex number
---@param questId number
function _QuestEventHandler:QuestAccepted(questLogIndex, questId)
questId = questId or select(8, GetQuestLogTitle(questLogIndex))
local _, _, _, _, _, _, _, questLogQuestId = GetQuestLogTitle(questLogIndex)
questId = questId or questLogQuestId
Questie:Debug(Questie.DEBUG_DEVELOP, "[Quest Event] QUEST_ACCEPTED", questLogIndex, questId)
if questLog[questId] and questLog[questId].timer then
+69 -16
View File
@@ -1581,8 +1581,13 @@ function QuestieQuest:PopulateObjective(quest, objectiveIndex, objective, blockI
objectiveCenter = { x = x, y = y }
end
-- Filter static spawns if prioritizeMyData is enabled and we have high-confidence learned data
if Questie.dbLearner and Questie.dbLearner.global and Questie.dbLearner.global.settings and Questie.dbLearner.global.settings.enabled and Questie.dbLearner.global.settings.prioritizeMyData then
-- Filter static spawns only when the learner is allowed to influence display.
local dataSourceMode = Questie.dbLearner
and Questie.dbLearner.global
and Questie.dbLearner.global.settings
and Questie.dbLearner.global.settings.dataSourceMode or "auto"
if Questie.dbLearner and Questie.dbLearner.global and Questie.dbLearner.global.settings and Questie.dbLearner.global.settings.enabled
and (dataSourceMode == "auto" or dataSourceMode == "learner") then
local zone, _ = next(zones)
while zone do
local suppressed = (objectiveData.Type == "monster" and QuestieDB.GetSuppressedNPCs(zone)) or (objectiveData.Type == "object" and QuestieDB.GetSuppressedObjects(zone))
@@ -1646,13 +1651,49 @@ _RegisterObjectiveTooltips = function(objective, questId, blockItemTooltips)
objective.hasRegisteredTooltips = true
end
else
-- No spawnList and no Id means there is nothing Questie can draw for this objective.
-- This covers server-tracked trigger objectives (e.g. "complete N quests in zone" for
-- quest 50150) which may have any objectiveType from the server, not just "event".
if not objective.Id or objective.Id == 0 then
-- No spawnList and no Id means there is nothing Questie can draw for this objective.
-- This covers server-tracked trigger objectives (e.g. "complete N quests in zone" for
-- quest 50150) which may have any objectiveType from the server, not just "event".
objective.hasRegisteredTooltips = true
return
end
if objective.Type == "monster" or objective.Type == "object" or objective.Type == "item" then
local tooltipKey = nil
if objective.Type == "monster" then
tooltipKey = "m_" .. objective.Id
elseif objective.Type == "object" then
tooltipKey = "o_" .. objective.Id
elseif objective.Type == "item" then
tooltipKey = "i_" .. objective.Id
end
if tooltipKey and not objective.hasRegisteredTooltips then
QuestieTooltips:RegisterObjectiveTooltip(questId, tooltipKey, objective)
objective.hasRegisteredTooltips = true
end
if objective.Type == "item" then
objective.registeredItemTooltips = true
end
return
elseif objective.Type == "killcredit" then
local ids = objective.IdList
if type(ids) ~= "table" then
ids = { objective.Id }
end
for _, id in ipairs(ids) do
if id then
QuestieTooltips:RegisterObjectiveTooltip(questId, "m_" .. id, objective)
end
end
objective.hasRegisteredTooltips = true
return
end
Questie:Error("[QuestieQuest]: [Tooltips] " ..
l10n("There was an error populating objectives for %s %s %s %s", objective.Description or "No objective text",
questId or "No quest id", 0 or "No objective", "No error"))
@@ -1806,18 +1847,30 @@ _DrawObjectiveIcons = function(questId, iconsToDraw, objective, maxPerType)
local iconCount, orderedList = _GetIconsSortedByDistance(iconsToDraw)
-- Dense kill objectives (like Sunstrider Isle mana wyrms) previously used
-- a lower clustering hotzone here. Leave the old behavior commented so we
-- can restore it quickly if we need to revisit consolidation again.
--[[
if iconCount >= 20 then
range = math.max(6, math.floor(range * 0.25))
elseif iconCount >= 12 then
range = math.max(10, math.floor(range * 0.4))
elseif iconCount >= 6 then
range = math.max(16, math.floor(range * 0.65))
-- Dense kill objectives (like Sunstrider Isle mana wyrms) consolidate more
-- aggressively the more pins share a zone. This is now user-tunable via the
-- clusterDensityAggressiveness knob (0 = off / show every pin, 100 = the
-- original aggressive consolidation). The per-zone and object overrides below
-- still take precedence, and coincident pins are always deduped in CalcHotzones.
local densityAggression = Questie.db.profile.clusterDensityAggressiveness or 0
if densityAggression > 0 then
local factor = densityAggression / 100
if factor > 1 then factor = 1 end
local baseMult
if iconCount >= 20 then
baseMult = 0.25
elseif iconCount >= 12 then
baseMult = 0.4
elseif iconCount >= 6 then
baseMult = 0.65
end
if baseMult then
-- Interpolate between no reduction (factor 0) and the full base
-- multiplier (factor 1) so the knob scales smoothly.
local mult = 1 - (1 - baseMult) * factor
range = math.max(1, math.floor(range * mult))
end
end
--]]
if orderedList[1] and orderedList[1].Icon == Questie.ICON_TYPE_OBJECT then -- new clustering / limit code should prevent problems, always show all object notes
range = range * 0.2; -- Only use 20% of the default range.
+58 -11
View File
@@ -4,6 +4,7 @@ local QuestieQuest = QuestieLoader:ImportModule("QuestieQuest")
QuestieQuest.private = QuestieQuest.private or {}
---@class QuestieQuestPrivate
local _QuestieQuest = QuestieQuest.private
_QuestieQuest.objectiveFallbackLogged = _QuestieQuest.objectiveFallbackLogged or {}
---@type QuestieDB
local QuestieDB = QuestieLoader:ImportModule("QuestieDB")
@@ -143,10 +144,16 @@ monster = function(npcId, objective)
return nil
end
local name = QuestieDB.QueryNPCSingle(npcId, "name")
local dataSourceMode = Questie.dbLearner
and Questie.dbLearner.global
and Questie.dbLearner.global.settings
and Questie.dbLearner.global.settings.dataSourceMode or "auto"
local npcData = QuestieDB:GetNPC(npcId)
local name = npcData and npcData.name or nil
if not name or name == "" then
-- Last resort: extract NPC name from objective description text.
-- This mirrors the name-parsing logic in the killcredit function.
-- This mirrors the name-parsing logic in the killcredit function and
-- only runs when learner-only mode still needs a display label.
if objective then
local desc = objective.Description or objective.text
if desc then
@@ -154,7 +161,8 @@ monster = function(npcId, objective)
if not name then
name = desc:gsub("%d+/%d+", ""):gsub("[:!?,.%(%)%[%]]", ""):gsub("^%s+", ""):gsub("%s+$", "")
end
if name and name ~= "" then
if name and name ~= "" and not _QuestieQuest.objectiveFallbackLogged[npcId] then
_QuestieQuest.objectiveFallbackLogged[npcId] = true
Questie:Debug(Questie.DEBUG_DEVELOP, "[monster] Using objective description as name fallback for NPC:", npcId, name)
end
end
@@ -165,7 +173,7 @@ monster = function(npcId, objective)
return nil
end
local spawns = QuestieDB.QueryNPCSingle(npcId, "spawns")
local spawns = npcData and npcData.spawns or {}
if (not spawns) then
Questie:Debug(Questie.DEBUG_CRITICAL, "Spawn data missing for NPC:", npcId)
spawns = {}
@@ -173,13 +181,18 @@ monster = function(npcId, objective)
local isLearned = false
if dataSourceMode == "none" then
spawns = {}
end
-- Learner safety net: when prioritizeMyData is enabled and the Learner has
-- verified spawn data for this NPC, prefer it over compiled DB spawns.
-- This catches edge cases where the npcDataOverrides chain doesn't fully
-- replace retail positions (e.g. format migration gaps, timing issues).
if Questie.IsAscension and Questie.dbLearner and Questie.dbLearner.global then
if Questie.IsAscension and Questie.dbLearner and Questie.dbLearner.global
and (dataSourceMode == "auto" or dataSourceMode == "learner") then
local ld = Questie.dbLearner.global
if ld.settings and ld.settings.enabled and ld.settings.prioritizeMyData then
if ld.settings and ld.settings.enabled then
local learnedNpc = ld.npcs and ld.npcs[npcId]
if learnedNpc then
local learnedSpawns = learnedNpc[7]
@@ -193,8 +206,12 @@ monster = function(npcId, objective)
and QuestieDB.ascensionOverrideKeys["NPC"][npcId]
and QuestieDB.ascensionOverrideKeys["NPC"][npcId][7]
local hasReliableLearnedSpawns = _CountUniqueSpawnPositions(learnedSpawns) > 1
local staticHasSpawns = spawns and next(spawns) ~= nil
local canUseLearnerSpawns = dataSourceMode == "learner"
or hasReliableLearnedSpawns
or not staticHasSpawns
if learnedSpawns and next(learnedSpawns) and learnedNpc.mc and learnedNpc.mc >= threshold
and hasReliableLearnedSpawns and not ascProtected then
and canUseLearnerSpawns and not ascProtected then
Questie:Debug(Questie.DEBUG_DEVELOP, "[monster] Preferring learned spawns for NPC:", npcId, "(mc=" .. tostring(learnedNpc.mc) .. ")")
spawns = learnedSpawns
isLearned = true
@@ -203,7 +220,7 @@ monster = function(npcId, objective)
end
end
local rank = QuestieDB.QueryNPCSingle(npcId, "rank")
local rank = npcData and npcData.rank
local enableSpawns = not QuestieCorrections.questNPCBlacklist[npcId]
local enableWaypoints = enableSpawns and 2 ~= rank -- a rare mob spawn. todo: option for this
@@ -213,7 +230,7 @@ monster = function(npcId, objective)
Id = npcId,
Name = name,
Spawns = enableSpawns and spawns or {},
Waypoints = enableWaypoints and QuestieDB.QueryNPCSingle(npcId, "waypoints") or {},
Waypoints = enableWaypoints and (npcData and npcData.waypoints or {}) or {},
Hostile = true,
Icon = Questie.ICON_TYPE_SLAY,
GetIconScale = _GetIconScaleForMonster,
@@ -241,18 +258,47 @@ object = function(objectId, objective)
return nil
end
local name = QuestieDB.QueryObjectSingle(objectId, "name")
local dataSourceMode = Questie.dbLearner
and Questie.dbLearner.global
and Questie.dbLearner.global.settings
and Questie.dbLearner.global.settings.dataSourceMode or "auto"
local objectData = QuestieDB:GetObject(objectId)
local name = objectData and objectData.name or nil
if (not name) then
Questie:Debug(Questie.DEBUG_CRITICAL, "Name missing for object:", objectId)
return nil
end
local spawns = QuestieDB.QueryObjectSingle(objectId, "spawns")
local spawns = objectData and objectData.spawns or {}
if (not spawns) then
Questie:Debug(Questie.DEBUG_CRITICAL, "Spawn data missing for object:", objectId)
spawns = {}
end
local isLearned = false
if dataSourceMode == "none" then
spawns = {}
end
if Questie.IsAscension and Questie.dbLearner and Questie.dbLearner.global
and (dataSourceMode == "auto" or dataSourceMode == "learner") then
local ld = Questie.dbLearner.global
if ld.settings and ld.settings.enabled then
local learnedObj = ld.objects and ld.objects[objectId]
if learnedObj and learnedObj[4] and next(learnedObj[4]) then
local threshold = ld.settings.minConfidencePins or 1
local staticHasSpawns = spawns and next(spawns) ~= nil
local canUseLearnerSpawns = dataSourceMode == "learner"
or _CountUniqueSpawnPositions(learnedObj[4]) > 1
or not staticHasSpawns
if learnedObj.mc and learnedObj.mc >= threshold and canUseLearnerSpawns then
spawns = learnedObj[4]
isLearned = true
end
end
end
end
---@type SpawnListObject
local retObject = {
@@ -263,6 +309,7 @@ object = function(objectId, objective)
GetIconScale = _GetIconScaleForObject,
IconScale = _GetIconScaleForObject(),
TooltipKey = "o_" .. objectId,
isLearned = isLearned,
}
return {
+2 -3
View File
@@ -241,7 +241,7 @@ end
-- old Lua 5.0 host with no secure-call protection), addon code can still call it
-- but it will simply be a no-op that prints a warning rather than injecting taint.
if not hooksecurefunc then
-- Lua 5.0 hosts (Turtle WoW pre-2.0 or custom servers) have no secure-call model,
-- Lua 5.0 hosts and older custom servers have no secure-call model,
-- so raw-hooking is equivalent to what Blizzard would do internally anyway.
-- Use a local to avoid polluting _G unnecessarily.
local function _rawHook(arg1, arg2, arg3)
@@ -278,7 +278,7 @@ end
if C_Timer then
QuestieCompat.C_Timer = C_Timer
else
-- C_Timer polyfill for Lua 5.0/5.1 clients that don't have it (e.g. Turtle WoW pre-2.0).
-- C_Timer polyfill for Lua 5.0/5.1 clients that don't have it.
-- Only stored in QuestieCompat namespace, NOT in bare _G.
local TickerFrame = CreateFrame("Frame")
local tickers = {}
@@ -626,4 +626,3 @@ end
QuestieCompat.LibUIDropDownMenu = QuestieCompat.LibUIDropDownMenu or {}
QuestieCompat.LibUIDropDownMenu.UIDropDownMenu_Menu_NewSize = function()
end
+25 -6
View File
@@ -128,14 +128,18 @@ local function loadFullDatabase()
print("\124cFF4DDBFF [1/9] " .. l10n("Loading database") .. "...")
QuestieInit:LoadBaseDB()
Questie:Debug(Questie.DEBUG_DEVELOP, "[DBDiag] After LoadBaseDB - quest:" .. _dbStats(QuestieDB.questData) .. " npc:" .. _dbStats(QuestieDB.npcData))
Questie:Debug(Questie.DEBUG_DEVELOP, "[DBDiag] obj:" .. _dbStats(QuestieDB.objectData) .. " item:" .. _dbStats(QuestieDB.itemData))
if Questie.db and Questie.db.profile and Questie.db.profile.debugEnabled then
Questie:Debug(Questie.DEBUG_DEVELOP, "[DBDiag] After LoadBaseDB - quest:" .. _dbStats(QuestieDB.questData) .. " npc:" .. _dbStats(QuestieDB.npcData))
Questie:Debug(Questie.DEBUG_DEVELOP, "[DBDiag] obj:" .. _dbStats(QuestieDB.objectData) .. " item:" .. _dbStats(QuestieDB.itemData))
end
print("\124cFF4DDBFF [2/9] " .. l10n("Applying database corrections") .. "...")
coYield()
QuestieCorrections:Initialize()
Questie:Debug(Questie.DEBUG_DEVELOP, "[DBDiag] After Corrections - quest:" .. _dbStats(QuestieDB.questData) .. " npc:" .. _dbStats(QuestieDB.npcData))
if Questie.db and Questie.db.profile and Questie.db.profile.debugEnabled then
Questie:Debug(Questie.DEBUG_DEVELOP, "[DBDiag] After Corrections - quest:" .. _dbStats(QuestieDB.questData) .. " npc:" .. _dbStats(QuestieDB.npcData))
end
print("\124cFF4DDBFF [3/9] " .. l10n("Initializing townfolks") .. "...")
coYield()
@@ -279,7 +283,7 @@ QuestieInit.Stages[1] = function() -- run as a coroutine
needsCompilation = (not dbIsCompiled) or (QuestieLib:GetAddonVersionString() ~= dbCompiledOnVersion) or (l10n:GetUILocale() ~= dbCompiledLang) or (Questie.db.global.dbCompiledExpansion ~= WOW_PROJECT_ID)
-- Custom servers or presence of DB plugins: always defer to Stage3 to wait for plugin data injection
if Questie.IsAscension or Questie.IsEbonhold or Questie.IsValanior or Questie.IsTurtle or QuestieServer:IsAnyDBPluginEnabled() then
if Questie.IsAscension or Questie.IsEbonhold or Questie.IsValanior or QuestieServer:IsAnyDBPluginEnabled() then
compilationDeferred = true
l10n:Initialize()
coYield()
@@ -289,7 +293,9 @@ QuestieInit.Stages[1] = function() -- run as a coroutine
l10n("Questie DB has updated!") ..
"|r|cFFFF6F22 " .. l10n("Data is being processed, this may take a few moments and cause some lag..."))
loadFullDatabase()
Questie:Debug(Questie.DEBUG_DEVELOP, "[DBDiag] Before Compile - quest:" .. _dbStats(QuestieDB.questData) .. " npc:" .. _dbStats(QuestieDB.npcData))
if Questie.db and Questie.db.profile and Questie.db.profile.debugEnabled then
Questie:Debug(Questie.DEBUG_DEVELOP, "[DBDiag] Before Compile - quest:" .. _dbStats(QuestieDB.questData) .. " npc:" .. _dbStats(QuestieDB.npcData))
end
QuestieDBCompiler:Compile()
Questie:Debug(Questie.DEBUG_DEVELOP, "[DBDiag] After Compile - quest:type=" .. type(QuestieDB.questData) .. " npc:type=" .. type(QuestieDB.npcData))
dbCompiled = true
@@ -390,7 +396,7 @@ QuestieInit.Stages[3] = function() -- run as a coroutine
-- Always re-compile on custom servers to pick up QuestieLearner changes from SavedVariables,
-- or if compilation was explicitly deferred/needed.
local isCustomServer = Questie.IsAscension or Questie.IsEbonhold or Questie.IsValanior or Questie.IsTurtle or QuestieServer:IsAnyDBPluginEnabled()
local isCustomServer = Questie.IsAscension or Questie.IsEbonhold or Questie.IsValanior or QuestieServer:IsAnyDBPluginEnabled()
if isCustomServer or needsCompilation or (not Questie.db.global.dbIsCompiled) then
Questie:Debug(Questie.DEBUG_CRITICAL, "[QuestieInit:Stage3] Starting compilation (Server=" .. tostring(isCustomServer) .. ", Needed=" .. tostring(needsCompilation) .. ")")
if not QuestieDB.questData then
@@ -502,6 +508,12 @@ end
function QuestieInit:LoadDatabase(key)
local function MarkBaseDatabaseMissing()
QuestieDB.baseDatabaseMissing = true
QuestieDB.baseDatabaseMissingKeys = QuestieDB.baseDatabaseMissingKeys or {}
QuestieDB.baseDatabaseMissingKeys[key] = true
end
if type(QuestieDB[key]) == "string" then
-- Fix #6: `loadstring` at LOAD TIME is safe, but calling it here during
-- event-driven runtime taints any tables produced on WotLK/Era clients.
@@ -524,6 +536,7 @@ function QuestieInit:LoadDatabase(key)
"[DBDiag] LEGACY DB ('" .. key .. "' is string) on modern client. "
.. "Runtime loadstring() would taint this data. "
.. "Please reinstall the Questie-X-WotLKDB addon in split-file format.")
MarkBaseDatabaseMissing()
QuestieDB[key] = {}
return
end
@@ -537,16 +550,19 @@ function QuestieInit:LoadDatabase(key)
QuestieDB[key] = result
else
Questie:Debug(Questie.DEBUG_DEVELOP, "[DBDiag] ERROR executing('" .. key .. "'): " .. tostring(result))
MarkBaseDatabaseMissing()
QuestieDB[key] = nil
end
else
Questie:Debug(Questie.DEBUG_DEVELOP, "[DBDiag] ERROR loadstring('" .. key .. "'): " .. tostring(loadErr) .. " | len=" .. string.len(QuestieDB[key] or ""))
MarkBaseDatabaseMissing()
QuestieDB[key] = nil
end
elseif type(QuestieDB[key]) == "table" then
Questie:Debug(Questie.DEBUG_DEVELOP, "[LoadDatabase] '" .. key .. "' already a table (split-file format), skipping loadstring")
else
Questie:Debug(Questie.DEBUG_DEVELOP, "Database is missing, this is likely do to era vs tbc: ", key)
MarkBaseDatabaseMissing()
end
if not QuestieDB[key] then
QuestieDB[key] = {}
@@ -560,6 +576,9 @@ function QuestieInit:LoadBaseDB()
-- Pointer compilation will look at npcDataOverrides etc, which are populated by plugins.
-- Base tables (Classic) are loaded here.
QuestieDB.baseDatabaseMissing = false
QuestieDB.baseDatabaseMissingKeys = {}
QuestieInit:LoadDatabase("npcData")
QuestieInit:LoadDatabase("objectData")
QuestieInit:LoadDatabase("questData")
+984 -169
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -39,7 +39,6 @@ end
local function GetServerKey()
if QuestieServer then
if Questie.IsAscension then return "Ascension" end
if Questie.IsTurtle then return "Turtle" end
if Questie.IsEbonhold then return "Ebonhold" end
if Questie.IsEra then return "Era" end
if Questie.Is335 then return "WotLK" end
+4 -2
View File
@@ -120,7 +120,8 @@ function QuestiePlayer:GetCurrentZoneId()
return uiMapId
end
return ZoneDB.instanceIdToUiMapId[select(8, GetInstanceInfo())]
local _, _, _, _, _, _, _, instanceMapID = GetInstanceInfo()
return ZoneDB.instanceIdToUiMapId[instanceMapID]
end
function QuestiePlayer:GetCurrentUiMapId()
@@ -129,7 +130,8 @@ function QuestiePlayer:GetCurrentUiMapId()
if uiMapId then
return uiMapId
end
return ZoneDB.instanceIdToUiMapId[select(8, GetInstanceInfo())]
local _, _, _, _, _, _, _, instanceMapID = GetInstanceInfo()
return ZoneDB.instanceIdToUiMapId[instanceMapID]
end
---@return number
+1 -8
View File
@@ -17,11 +17,6 @@ Questie.IsWotlk = (WOW_PROJECT_ID == WOW_PROJECT_WRATH_CLASSIC)
Questie.IsTBC = (WOW_PROJECT_ID == WOW_PROJECT_BURNING_CRUSADE_CLASSIC)
Questie.IsClassicEra = (WOW_PROJECT_ID == WOW_PROJECT_CLASSIC)
-- Try GetBuildInfo for Turtle WoW (Interface: 11200, no WOW_PROJECT globals)
local _, _, _, tocVersion = GetBuildInfo()
Questie.IsTurtle = (not Questie.IsRetail and not Questie.IsWotlk and not Questie.IsTBC and
not Questie.IsClassicEra and tocVersion and tocVersion < 20000)
-- Custom server detection
Questie.IsEbonhold = false
Questie.IsAscension = false
@@ -41,7 +36,6 @@ local function GetExpectedPluginFlavor()
if Questie.IsAscension then return "AscensionDB", "Questie-X-AscensionDB" end
if Questie.IsEbonhold then return "EbonholdDB", "Questie-X-EbonholdDB" end
if Questie.IsValanior then return "ValaniorDB", "Questie-X-ValaniorDB" end
if Questie.IsTurtle then return "TurtleDB", "Questie-X-TurtleDB" end
if Questie.IsWotlk then return "WotLKDB", "Questie-X-WotLKDB" end
if Questie.IsTBC then return "TBCDB", "Questie-X-TBCDB" end
if Questie.IsClassicEra then return "ClassicDB", "Questie-X-ClassicDB" end
@@ -53,7 +47,7 @@ function QuestieServer:Init()
Questie:Debug(Questie.DEBUG_INFO, "[QuestieServer] Realm:", realmName)
Questie:Debug(Questie.DEBUG_INFO, "[QuestieServer] WOW_PROJECT_ID:", tostring(WOW_PROJECT_ID))
Questie:Debug(Questie.DEBUG_INFO, "[QuestieServer] IsWotlk:", tostring(Questie.IsWotlk), "IsTBC:", tostring(Questie.IsTBC),
"IsClassicEra:", tostring(Questie.IsClassicEra), "IsTurtle:", tostring(Questie.IsTurtle),
"IsClassicEra:", tostring(Questie.IsClassicEra),
"IsAscension:", tostring(Questie.IsAscension), "IsEbonhold:", tostring(Questie.IsEbonhold))
end
@@ -64,7 +58,6 @@ function QuestieServer:IsAnyDBPluginEnabled()
"Questie-X-WotLKDB",
"Questie-X-ClassicDB",
"Questie-X-TBCDB",
"Questie-X-TurtleDB",
"Questie-X-AscensionDB",
"Questie-X-EbonholdDB",
"Questie-X-ValaniorDB",
+25 -24
View File
@@ -10,6 +10,7 @@ local unpack_limit = 4096 -- wow api limits unpack to somewhere between 7000-800
local band = bit.band
local lshift = bit.lshift
local rshift = bit.rshift
local mod = math.mod
local stringchar = string.char
local stringbyte = string.byte
local stringsub = string.sub
@@ -128,7 +129,7 @@ end
function QuestieStreamLib:_writeByte(val)
local p = self._pointer
local chunkId = math.floor((p - 1) / 100000) + 1
local localIdx = ((p - 1) % 100000) + 1
local localIdx = mod(p - 1, 100000) + 1
if not self._bin[chunkId] then
self._bin[chunkId] = {}
@@ -152,7 +153,7 @@ function QuestieStreamLib:_readByte()
return stringbyte(self._bin, p)
else
local chunkId = math.floor((p - 1) / 100000) + 1
local localIdx = ((p - 1) % 100000) + 1
local localIdx = mod(p - 1, 100000) + 1
if self._bin[chunkId] then
return stringbyte(self._bin[chunkId][localIdx] or "\0")
end
@@ -184,7 +185,7 @@ function QuestieStreamLib:_WriteByte_b89(e)
self._level = level
self:_writeByte(QSL_ltab[level])
end
local chr = (e % 86) + 33
local chr = mod(e, 86) + 33
if QSL_ttab[chr] then
self:_writeByte(QSL_ttab[chr])
else
@@ -267,7 +268,7 @@ function QuestieStreamLib:_ReadInt12Pair_raw()
local p = self._pointer
self._pointer = p + 3
local a,b,c = stringbyte(self._bin, p, p+2)
local low4bit = a % 16
local low4bit = mod(a, 16)
return low4bit * 256 + b, (a - low4bit) * 16 + c
end
@@ -372,8 +373,8 @@ end
function QuestieStreamLib:_WriteShort(val)
--print("wshort: " .. val);
self:WriteByte(rshift(val, 8) % 256);
self:WriteByte(val % 256);
self:WriteByte(mod(rshift(val, 8), 256));
self:WriteByte(mod(val, 256));
end
function QuestieStreamLib:_WriteShort_assert(val)
@@ -384,10 +385,10 @@ function QuestieStreamLib:_WriteShort_assert(val)
end
function QuestieStreamLib:_WriteInt(val)
self:WriteByte(rshift(val, 24) % 256);
self:WriteByte(rshift(val, 16) % 256);
self:WriteByte(rshift(val, 8) % 256);
self:WriteByte(val % 256);
self:WriteByte(mod(rshift(val, 24), 256));
self:WriteByte(mod(rshift(val, 16), 256));
self:WriteByte(mod(rshift(val, 8), 256));
self:WriteByte(mod(val, 256));
end
function QuestieStreamLib:_WriteInt_assert(val)
@@ -399,9 +400,9 @@ end
function QuestieStreamLib:_WriteInt24(val)
--print("wi24: " .. val);
self:WriteByte(rshift(val, 16) % 256);
self:WriteByte(rshift(val, 8) % 256);
self:WriteByte(val % 256);
self:WriteByte(mod(rshift(val, 16), 256));
self:WriteByte(mod(rshift(val, 8), 256));
self:WriteByte(mod(val, 256));
end
function QuestieStreamLib:_WriteInt24_assert(val)
@@ -413,8 +414,8 @@ end
function QuestieStreamLib:_WriteInt12Pair(val1, val2)
self:WriteByte(band(rshift(val1, 8), 15) + lshift(band(rshift(val2, 8), 15), 4))
self:WriteByte(val1 % 256)
self:WriteByte(val2 % 256)
self:WriteByte(mod(val1, 256))
self:WriteByte(mod(val2, 256))
end
function QuestieStreamLib:_WriteInt12Pair_assert(val1, val2)
@@ -430,14 +431,14 @@ function QuestieStreamLib:_WriteInt12Pair_assert(val1, val2)
end
function QuestieStreamLib:_WriteLong(val)
self:WriteByte(rshift(val, 56) % 256);
self:WriteByte(rshift(val, 48) % 256);
self:WriteByte(rshift(val, 40) % 256);
self:WriteByte(rshift(val, 32) % 256);
self:WriteByte(rshift(val, 24) % 256);
self:WriteByte(rshift(val, 16) % 256);
self:WriteByte(rshift(val, 8) % 256);
self:WriteByte(val % 256);
self:WriteByte(mod(rshift(val, 56), 256));
self:WriteByte(mod(rshift(val, 48), 256));
self:WriteByte(mod(rshift(val, 40), 256));
self:WriteByte(mod(rshift(val, 32), 256));
self:WriteByte(mod(rshift(val, 24), 256));
self:WriteByte(mod(rshift(val, 16), 256));
self:WriteByte(mod(rshift(val, 8), 256));
self:WriteByte(mod(val, 256));
end
function QuestieStreamLib:_WriteLong_assert(val)
@@ -476,7 +477,7 @@ end
function QuestieStreamLib:Save()
local chunks = {}
for i=1, #self._bin do
for i=1, table.getn(self._bin) do
table.insert(chunks, table.concat(self._bin[i]))
end
return table.concat(chunks)
+44 -2
View File
@@ -43,6 +43,31 @@ local _tooltipLastText = ""
local _InitObjectiveTexts
local function _GetQuestObjectiveSummary(questId)
if not QuestieDB or not QuestieDB.GetQuest then
return nil
end
local quest = QuestieDB:GetQuest(questId)
if not quest or not quest.ObjectiveData then
return nil
end
local summary = {}
for _, objective in ipairs(quest.ObjectiveData) do
local text = objective and (objective.Text or objective.Description)
if type(text) == "string" and text ~= "" then
tinsert(summary, text)
end
end
if table.getn(summary) == 0 then
return nil
end
return summary
end
---@param questId number
---@param key string monster: m_, items: i_, objects: o_ + string name of the objective
---@param objective table
@@ -205,7 +230,7 @@ function QuestieTooltips:GetTooltip(key)
return nil
end
if QuestiePlayer.numberOfGroupMembers > MAX_GROUP_MEMBER_COUNT then
if (QuestiePlayer.numberOfGroupMembers or 0) > MAX_GROUP_MEMBER_COUNT then
return nil -- temporary disable tooltips in raids, we should make a proper fix
end
@@ -228,7 +253,8 @@ function QuestieTooltips:GetTooltip(key)
if (not QuestieTooltips.lookupByKey[key]) then
local QuestieLearner = QuestieLoader:ImportModule("QuestieLearner")
local QuestLogCache = QuestieLoader:ImportModule("QuestLogCache")
if QuestieLearner and QuestieLearner.data then
local mode = QuestieLearner and QuestieLearner.GetDataSourceMode and QuestieLearner:GetDataSourceMode() or "auto"
if QuestieLearner and QuestieLearner.data and mode ~= "static" and mode ~= "none" then
-- Try to find in learned NPCs or objects
local id = tonumber(key:sub(3))
if id then
@@ -345,11 +371,27 @@ elseif key:sub(1,2) == "o_" then
if QuestieTooltips.lookupByKey[key] then
local playerName = UnitName("player")
local hasObjectiveEntries = false
for _, tooltip in next, QuestieTooltips.lookupByKey[key] do
if not tooltip.name then
hasObjectiveEntries = true
break
end
end
for k, tooltip in next, QuestieTooltips.lookupByKey[key] do
if tooltip.name then
if Questie.db.profile.showQuestsInNpcTooltip then
local questString = QuestieLib:GetColoredQuestName(tooltip.questId, Questie.db.profile.enableTooltipsQuestLevel, true, true)
tinsert(tooltipLines, questString)
if not hasObjectiveEntries then
local objectiveSummary = _GetQuestObjectiveSummary(tooltip.questId)
if objectiveSummary then
for _, objectiveText in ipairs(objectiveSummary) do
tinsert(tooltipLines, " |cFFcbcbcb" .. objectiveText .. "|r")
end
end
end
end
else
local objective = tooltip.objective
+2 -2
View File
@@ -413,7 +413,7 @@ function QuestieTracker.Initialize()
for i = 1, questsWatched do
local questIndex = GetQuestIndexForWatch(i)
if questIndex then
local questId = select(8, GetQuestLogTitle(questIndex))
local _, _, _, _, _, _, _, questId = GetQuestLogTitle(questIndex)
if questId then
tempQuestIDs[i] = questId
end
@@ -2277,7 +2277,7 @@ function QuestieTracker:HookBaseTracker()
-- Intercept and return a Questie boolean value
IsQuestWatched = function(index)
local questId = select(8, GetQuestLogTitle(index))
local _, _, _, _, _, _, _, questId = GetQuestLogTitle(index)
if questId == 0 then
-- When an objective progresses in TBC "index" is the questId, but when a quest is manually added to the quest watch
-- (e.g. shift clicking it in the quest log) "index" is the questLogIndex.
+18
View File
@@ -1,4 +1,6 @@
> **Notice:** I am actively working on a major performance refactor. Until I publish a stable release that I am satisfied with, please use the repository version instead of the release asset.
>
> Current refactor focus: reducing QuestieLearner pin redraws during heavy kill activity, adding live Advanced performance controls for QuestieLearner and QuestieComms, adding Arrow throttles for low-end systems, and suppressing non-fatal error spam outside Questie debug modes. These changes are being staged on feature branches first and still need in-game validation before they become the next stable release.
<div align="center">
@@ -27,6 +29,22 @@ Questie-X is a fork of the original [Questie](https://github.com/Questie/Questie
---
## Current Performance Refactor Status
The active performance work is split across review branches so each phase can be reverted independently if needed.
| Area | Current Status |
|------|----------------|
| QuestieLearner | Kill-triggered map-pin refreshes are now debounced with a maximum wait cap, bystander `UNIT_DIED` events no longer force learner pin redraws, and `PARTY_KILL` events are protected from being suppressed by earlier `UNIT_DIED` debounce entries. |
| QuestieComms | Advanced options include a full comms disable switch plus throttles for processing, quest-state broadcasts, and bulk sync behavior. |
| Arrow | Advanced options include update throttles that apply live and are intended to reduce repeated target/coordinate work on lower-end PCs. |
| Error noise | Missing quest and non-fatal database messages are being routed to Questie debug-critical/developer output instead of normal chat spam. |
| Phase 3 hot paths | A measured branch contains additional localization, tooltip/map, quest eligibility, and cache-allocation improvements that still need integration with the learner/comms branch. |
Before the next stable release, the combined branch needs in-game testing in heavy kill zones with the minimap open, nearby players killing mobs, comms disabled/low/normal/fast, and Arrow throttles adjusted live from the options menu.
---
## Installation
> **⚠️ Two addons are always required — even on supported servers**
+386
View File
@@ -0,0 +1,386 @@
-- Audit verification suite for workflow/performance-audit-2026-06-03-FULL.md (Pass 8).
--
-- These are STATIC source assertions: each test reads the actual file and checks
-- that the audit's claim still matches the source. They run in desktop Lua (no
-- WoW mock needed) and are re-runnable.
--
-- Tests in "false positives" and "structural facts" should ALWAYS pass.
-- Tests in "confirmed bugs"/"confirmed perf" are a SNAPSHOT at HEAD 581634d:
-- they pass while the finding is unfixed. When you land a fix, invert or remove
-- the matching assertion (each is tagged with its Pass-8 ID, e.g. [B1]).
local function read(path)
local f = assert(io.open(path, "r"), "cannot open " .. path)
local c = f:read("*a")
f:close()
return c
end
-- plain (non-pattern) substring search
local function has(content, needle)
return string.find(content, needle, 1, true) ~= nil
end
-- count plain (non-pattern) occurrences
local function count(content, needle)
local n, pos = 0, 1
while true do
local s, e = string.find(content, needle, pos, true)
if not s then break end
n = n + 1
pos = e + 1
end
return n
end
local function startsWithBOM(path)
local f = assert(io.open(path, "rb"), "cannot open " .. path)
local head = f:read(3)
f:close()
return head == "\239\187\191"
end
describe("Audit Pass 8.1 - Lua 5.0 incompatibility surface", function()
it("[8.1] raw # length operator is used in TOC-loaded files (5.0 parse error)", function()
-- The dominant 5.0 blocker the pass-6/7 scan missed entirely.
assert.is_true(has(read("Localization/l10n.lua"), "#args"))
assert.is_true(has(read("Modules/Map/QuestieMap.lua"), "#mapDrawQueue"))
end)
it("[8.1] QuestieLib.tpack uses the ... expression (real 5.0 parse error)", function()
assert.is_true(has(read("Modules/Libs/QuestieLib.lua"), 'n = select("#", ...), ...'))
end)
it("[8.1] % is NOT used as a modulo operator (codebase uses math.mod)", function()
-- math.mod shim exists; raw % only appears in format strings.
assert.is_true(has(read("Modules/Libs/QuestieLoader.lua"), "math.mod"))
end)
it("[8.1] no goto / bit32 / string.pack / utf8 in core runtime", function()
local db = read("Database/QuestieDB.lua")
assert.is_false(has(db, "goto "))
assert.is_false(has(db, "bit32."))
assert.is_false(has(db, "string.pack"))
end)
end)
describe("Audit Pass 8.2 - corrected FALSE POSITIVES (must always pass)", function()
it("[FP2] UnitFactionGroup('Player') capital-P is an established working pattern", function()
-- Used across Corrections DB files that gate real entries; proves tokens
-- are case-insensitive, so QuestieMenu.lua:114 is NOT a bug.
assert.is_true(has(read("Database/Corrections/classicItemFixes.lua"),
'UnitFactionGroup("Player")'))
assert.is_true(has(read("Modules/QuestieMenu/QuestieMenu.lua"),
'UnitFactionGroup("Player")'))
end)
it("[FP1] Ascension_IsScalingEnabled is declared no-arg but called with questId (harmless lint)", function()
local lib = read("Modules/Libs/QuestieLib.lua")
assert.is_true(has(lib, "local function Ascension_IsScalingEnabled()"))
assert.is_true(has(lib, "Ascension_IsScalingEnabled(questId)"))
-- Lua discards extra args; this changes no behavior. Lint only.
end)
it("[FP3] the select() shim exists, proving Lua 5.0 lacks select (pass-3 was wrong)", function()
local loader = read("Modules/Libs/QuestieLoader.lua")
assert.is_true(has(loader, "if not select then"))
assert.is_true(has(loader, "select = function(index, ...)"))
end)
it("[FP5] TaskQueue is wired (NOT dead code)", function()
assert.is_true(has(read("Questie-X.toc"), "TaskQueue.lua"))
assert.is_nil(io.open("Questie-X-Turtle.toc", "r"))
local qq = read("Modules/Quest/QuestieQuest.lua")
assert.is_true(has(qq, 'ImportModule("TaskQueue")'))
assert.is_true(has(qq, "TaskQueue:Queue("))
end)
end)
describe("Audit Pass 8.3 - confirmed BUGS (snapshot at HEAD; invert on fix)", function()
it("[B1] IsComplete calls GetQuest(questId) twice in one expression", function()
assert.is_true(has(read("Database/QuestieDB.lua"),
"QuestieDB.GetQuest(questId) and QuestieDB.GetQuest(questId).ObjectiveData"))
end)
it("[B2] QuestieOptionsTracker calls :Cancel() on the number fadeTickerValue", function()
local content = read("Modules/Options/TrackerTab/QuestieOptionsTracker.lua")
assert.is_true(count(content, "fadeTickerValue:Cancel()") >= 3)
end)
it("[B3] alreadySentBandaid is declared once and never wiped (unbounded)", function()
local content = read("Modules/QuestieAnnounce.lua")
assert.is_true(has(content, "local alreadySentBandaid = {}"))
assert.is_false(has(content, "wipe(alreadySentBandaid)"))
assert.equals(1, count(content, "alreadySentBandaid = {}"))
end)
it("[B4] factionReactions reads UnitFactionGroup at module load time", function()
assert.is_true(has(read("Database/QuestieDB.lua"),
'local playerFaction = UnitFactionGroup("player")'))
end)
it("[B5] Questie-X.toc lists QuestieSlash.lua twice", function()
assert.equals(2, count(read("Questie-X.toc"), "QuestieSlash.lua"))
end)
it("[B6] correction files begin with a UTF-8 BOM", function()
assert.is_true(startsWithBOM("Database/Corrections/tbcQuestFixes.lua"))
assert.is_true(startsWithBOM("Database/Corrections/wotlkItemFixes.lua"))
assert.is_true(startsWithBOM("Database/Corrections/wotlkQuestFixes.lua"))
end)
it("[B7] _Qframe.BaseOnUpdate is referenced but never defined (dead glow ticker)", function()
local frame = read("Modules/FramePool/QuestieFrame.lua")
local pool = read("Modules/FramePool/QuestieFramePool.lua")
assert.is_true(has(frame, "_Qframe.BaseOnUpdate")) -- assigned from
assert.is_true(has(pool, "returnFrame.BaseOnUpdate")) -- gated on
-- never defined anywhere:
assert.is_false(has(frame, "function _Qframe.BaseOnUpdate"))
assert.is_false(has(frame, "function _Qframe:BaseOnUpdate"))
assert.is_false(has(frame, "_Qframe.BaseOnUpdate = function"))
end)
end)
describe("Audit Pass 8.4 - confirmed PERFORMANCE findings (snapshot)", function()
it("[P1] QuestieComms no longer re-serializes the accumulating list inside the loop", function()
local comms = read("Modules/Network/QuestieComms.lua")
assert.is_false(has(comms, "string.len(QuestieSerializer:Serialize(rawQuestList)) > 200"))
assert.is_true(has(comms, "GetSerializedPacketSize(quest)"))
end)
it("[P3] QuestieComms no longer front-removes its broadcast queues", function()
local comms = read("Modules/Network/QuestieComms.lua")
assert.is_false(has(comms, "tremove(blocks, 1)"))
assert.is_false(has(comms, "tremove(_QuestieComms._nextBroadcastData, 1)"))
assert.is_false(has(comms, "tremove(_QuestieComms._nextBroadcastDataV2, 1)"))
assert.is_true(has(comms, "local function QueuePop(queue, queueState)"))
end)
it("[P4] QuestieLib.tunpack is recursive (slow vararg unpack)", function()
local lib = read("Modules/Libs/QuestieLib.lua")
assert.is_true(has(lib, "local function recursion(i)"))
assert.is_true(has(lib, "return tbl[i], recursion(i + 1)"))
assert.is_false(has(lib, "return unpack(tbl, 1, tbl.n)")) -- the proposed fix
end)
it("[P6] O(n) front-insert tinsert(t, 1, x) is used in the tooltip path", function()
assert.is_true(has(read("Modules/Tooltips/Tooltip.lua"),
"tinsert(tempObjectives, 1, objectiveInfo.text)"))
end)
end)
describe("Audit Pass 9 - file-by-file findings (snapshot at HEAD)", function()
it("[N1] bit library is used UNGUARDED in TOC files (1.12 runtime risk)", function()
assert.is_true(has(read("Questie.lua"), "local band = bit.band"))
assert.is_true(has(read("Database/QuestieDB.lua"), "local bitband = bit.band"))
-- no bit shim in the compat layer:
assert.is_false(has(read("Modules/QuestieCompat.lua"), "bit ="))
-- ...while the vendored XXH lib DOES guard it (the contrast):
assert.is_true(has(read("Libs/XXH_Lua_Lib/XXH_Lua_Lib.lua"), "bit and bit.band"))
end)
it("[N2] strsplit is used in runtime but only shimmed in a test mock", function()
assert.is_true(has(read("Modules/Network/QuestieComms.lua"), "strsplit"))
assert.is_false(has(read("Modules/QuestieCompat.lua"), "strsplit"))
end)
it("[N3] Options files use { ... } where {} was intended (5.0 parse error)", function()
assert.is_true(has(read("Modules/Options/QuestieOptions.lua"), "tabs = { ... }"))
assert.is_true(has(read("Modules/Options/ArrowTab/QuestieOptionsArrow.lua"), "= { ... }"))
end)
it("[N4] QuestieCommsData indexes GetNPC/GetObject result with no nil-check", function()
local d = read("Modules/Network/QuestieCommsData.lua")
assert.is_true(has(d, "QuestieDB:GetNPC(objective.id).name"))
assert.is_true(has(d, "QuestieDB:GetObject(objective.id).name"))
-- the item branch right below DOES guard, proving the inconsistency:
assert.is_true(has(d, "if(dbItem and dbItem.name and (not dbItem.Hidden)) then"))
end)
it("[N5] explicit unpack replaced select(8, ...) in live quest/instance lookups", function()
local player = read("Modules/QuestiePlayer.lua")
local handler = read("Modules/Quest/QuestEventHandler.lua")
local tracker = read("Modules/Tracker/QuestieTracker.lua")
assert.is_true(has(player, "local _, _, _, _, _, _, _, instanceMapID = GetInstanceInfo()"))
assert.is_false(has(player, "select(8, GetInstanceInfo())"))
assert.is_true(has(handler, "local _, _, _, _, _, _, _, questLogQuestId = GetQuestLogTitle(questLogIndex)"))
assert.is_false(has(handler, "select(8, GetQuestLogTitle(questLogIndex))"))
assert.is_true(has(tracker, "local _, _, _, _, _, _, _, questId = GetQuestLogTitle(questIndex)"))
assert.is_true(has(tracker, "local _, _, _, _, _, _, _, questId = GetQuestLogTitle(index)"))
assert.is_false(has(tracker, "select(8, GetQuestLogTitle(questIndex))"))
assert.is_false(has(tracker, "select(8, GetQuestLogTitle(index))"))
end)
it("[9.1->11.1] CORRECTED: % modulo IS used (the 'avoided' claim was wrong)", function()
-- Pass 8-10 wrongly said % modulo = 0. Real modulo operators exist in
-- Turtle-TOC files and are 5.0 parse errors that math.mod cannot rescue.
assert.is_true(has(read("Modules/QuestieStream.lua"), "mod(val, 256)"))
assert.is_true(has(read("Modules/QuestiePlayer.lua"), "% playerRaceFlagX2"))
end)
end)
describe("Audit Pass 11 - gap-fill findings (snapshot at HEAD)", function()
it("[11.1] % modulo operator is present in multiple Turtle-TOC files", function()
assert.is_true(has(read("Database/QuestieDB.lua"), " % "))
assert.is_true(has(read("Modules/QuestieLearner.lua"), " % "))
end)
it("[G1] QuestieNameplate:UpdateNameplate re-splits the GUID and early-returns in-loop", function()
local np = read("Modules/QuestieNameplate.lua")
-- re-derives npcId from the guid on every update (cacheable):
assert.is_true(has(np, 'strsplit("-", guid)'))
-- the early return aborts the whole loop on a missing unit:
assert.is_true(has(np, "if (not unitName) or (not npcId) then\n return"))
end)
it("[G2] QuestieValidateGameCache has the unreachable isQuestLogGood guard", function()
local v = read("Modules/QuestieValidateGameCache.lua")
assert.is_true(has(v, "local isQuestLogGood = true"))
assert.is_false(has(v, "isQuestLogGood = false")) -- never set false => guard is dead
end)
it("[G3] QuestieCompat shims neither bit nor strsplit (N1/N2 gaps stand)", function()
local c = read("Modules/QuestieCompat.lua")
assert.is_false(has(c, "strsplit ="))
assert.is_false(has(c, "bit ="))
assert.is_true(has(c, "QuestieCompat.C_Timer")) -- but C_Timer IS polyfilled
end)
end)
describe("Audit Pass 10 - additional performance findings (snapshot)", function()
it("[PP1] a batch Query(id, keys) API exists that IsDoable does not use", function()
local compiler = read("Database/compiler.lua")
assert.is_true(has(compiler, "handle.Query = function(id, keys)")) -- batch reader exists
local db = read("Database/QuestieDB.lua")
-- IsDoable issues many single-key reads instead of one batch read:
assert.is_true(count(db, "QueryQuestSingle(questId,") >= 8)
end)
it("[PP2] hot non-fragile files repeat Questie.db.profile chains", function()
assert.is_true(count(read("Modules/Tooltips/MapIconTooltip.lua"), "Questie.db.profile.") >= 8)
assert.is_true(count(read("Modules/Map/QuestieMap.lua"), "Questie.db.profile.") >= 8)
end)
it("[PP3] arrow target sort no longer allocates an inline comparator per refresh", function()
assert.is_true(has(read("Modules/Arrow/QuestieArrow.lua"),
"local function _SortTargetByDistance(a, b)"))
assert.is_true(has(read("Modules/Arrow/QuestieArrow.lua"),
"table.sort(sortedTargets, _SortTargetByDistance)"))
assert.is_true(count(read("Modules/Network/QuestieComms.lua"), "table.sort(") >= 3)
end)
it("[PP4] arrow avoids re-setting unchanged distance text every throttled tick", function()
assert.is_true(has(read("Modules/Arrow/QuestieArrow.lua"),
"objectiveFrame._lastDistanceText ~= distanceText"))
end)
it("[PP7] arrow performance throttles are profile-backed and exposed in Arrow options", function()
local arrow = read("Modules/Arrow/QuestieArrow.lua")
local arrowOptions = read("Modules/Options/ArrowTab/QuestieOptionsArrow.lua")
local advancedOptions = read("Modules/Options/AdvancedTab/QuestieOptionsAdvanced.lua")
local defaults = read("Modules/Options/QuestieOptionsDefaults.lua")
assert.is_true(has(arrow, 'return _GetProfileNumber("arrowUpdateThrottle"'))
assert.is_true(has(arrow, 'return _GetProfileNumber("arrowRecalcInterval"'))
assert.is_true(has(arrow, 'return _GetProfileNumber("arrowTrackerRefreshThrottle"'))
assert.is_false(has(arrowOptions, "arrowPerformanceHeader"))
assert.is_false(has(arrowOptions, "arrowUpdateThrottle"))
assert.is_true(has(advancedOptions, "arrowPerformanceHeader"))
assert.is_true(has(advancedOptions, "arrowUpdateThrottle"))
assert.is_true(has(advancedOptions, "arrowRecalcInterval"))
assert.is_true(has(advancedOptions, "arrowTrackerRefreshThrottle"))
assert.is_true(has(defaults, "arrowUpdateThrottle = 0.05"))
assert.is_true(has(defaults, "arrowRecalcInterval = 1.0"))
assert.is_true(has(defaults, "arrowTrackerRefreshThrottle = 0.5"))
end)
it("[L9] learner performance presets keep comms UI and broadcast gate synchronized", function()
local advancedOptions = read("Modules/Options/AdvancedTab/QuestieOptionsAdvanced.lua")
assert.is_true(has(advancedOptions, 'settings.learnerCommsIntensity = "fast"'))
assert.is_true(has(advancedOptions, 'settings.learnerCommsIntensity = "normal"'))
assert.is_true(has(advancedOptions, 'settings.learnerCommsIntensity = "low"'))
assert.is_true(has(advancedOptions, "Questie.db.profile.learnerBroadcast = true"))
end)
it("[C1] QuestieComms full quest-list throttles are profile-backed", function()
local comms = read("Modules/Network/QuestieComms.lua")
local defaults = read("Modules/Options/QuestieOptionsDefaults.lua")
local broadcastQuestUpdatePos = comms:find("function _QuestieComms:BroadcastQuestUpdate", 1, true)
local isQuestieCommsEnabledPos = comms:find("local IsQuestieCommsEnabled", 1, true)
assert.is_true(has(comms, 'GetProfileNumber("questieCommsQuestListPacketSize"'))
assert.is_true(has(comms, 'GetProfileNumber("questieCommsQuestListInitialJitter"'))
assert.is_true(has(comms, 'GetProfileNumber("questieCommsQuestListBlockInterval"'))
assert.is_true(has(comms, "IsQuestieCommsEnabled = function()"))
assert.is_true(isQuestieCommsEnabledPos ~= nil)
assert.is_true(broadcastQuestUpdatePos ~= nil)
assert.is_true(isQuestieCommsEnabledPos < broadcastQuestUpdatePos)
assert.is_true(has(comms, "GetQuestListPacketSizeLimit()"))
assert.is_true(has(comms, "GetQuestListInitialJitter()"))
assert.is_true(has(comms, "GetQuestListBlockInterval()"))
assert.is_true(has(defaults, "questieCommsEnabled = true"))
assert.is_true(has(defaults, "questieCommsQuestListPacketSize = 200"))
assert.is_true(has(defaults, "questieCommsQuestListInitialJitter = 3"))
assert.is_true(has(defaults, "questieCommsQuestListBlockInterval = 3"))
end)
it("[C2] QuestieComms performance controls are exposed in Advanced options", function()
local advancedOptions = read("Modules/Options/AdvancedTab/QuestieOptionsAdvanced.lua")
assert.is_true(has(advancedOptions, "questieCommsPerformanceHeader"))
assert.is_true(has(advancedOptions, "questieCommsEnabled"))
assert.is_true(has(advancedOptions, "questieCommsQuestListPacketSize"))
assert.is_true(has(advancedOptions, "questieCommsQuestListInitialJitter"))
assert.is_true(has(advancedOptions, "questieCommsQuestListBlockInterval"))
assert.is_true(has(advancedOptions, "Questie.db.profile.questieCommsEnabled == false"))
end)
it("[L50] loader, serializer, and stream are wired for Lua 5.0 compatibility", function()
local loader = read("Modules/Libs/QuestieLoader.lua")
local serializer = read("Modules/Libs/QuestieSerializer.lua")
local stream = read("Modules/QuestieStream.lua")
assert.is_true(has(loader, "bitlib.band = bitlib.band or band32"))
assert.is_true(has(loader, "strsplit = function(separator, text, max)"))
assert.is_true(has(serializer, "local mod = math.mod"))
assert.is_true(has(serializer, "mod(expo, 0x2)"))
assert.is_true(has(serializer, "mod(b1, 0x80)"))
assert.is_false(has(serializer, "expo % 0x2"))
assert.is_true(has(stream, "local mod = math.mod"))
assert.is_true(has(stream, "table.getn(self._bin)"))
assert.is_true(has(stream, "mod(val1, 256)"))
assert.is_true(has(stream, "mod(val2, 256)"))
assert.is_false(has(stream, "val1 % 256"))
assert.is_false(has(stream, "val2 % 256"))
end)
it("[L10] UNIT_DIED dedupe cannot suppress a later PARTY_KILL learner update", function()
local learner = read("Modules/QuestieLearner.lua")
assert.is_true(has(learner, 'local lastEventType = type(last) == "table" and last.eventType or nil'))
assert.is_true(has(learner, 'if eventType ~= "PARTY_KILL" or lastEventType == "PARTY_KILL" then'))
assert.is_true(has(learner, '_Learner.killDebounce[dstGUID] = { ts = now, eventType = eventType }'))
end)
it("[L11] object capture traces and gameobject learning are wired into the learner", function()
local learner = read("Modules/QuestieLearner.lua")
assert.is_true(has(learner, 'local function TraceLearnerEntity(source, guid, unitType, entityId, name)'))
assert.is_true(has(learner, 'TraceLearnerEntity("mouseover", guid, unitType, entityId, name)'))
assert.is_true(has(learner, 'TraceLearnerEntity("target", guid, unitType, entityId, name)'))
assert.is_true(has(learner, 'TraceLearnerEntity("loot_target", targetGuid, targetType, targetId, UnitName("target"))'))
assert.is_true(has(learner, 'GetLootSourceInfo then'))
assert.is_true(has(learner, 'TraceLearnerEntity("loot_source", sourceGuid, nil, sourceQty, lootName)'))
assert.is_true(has(learner, 'function QuestieLearner:OnGameObjectUsed(objectId)'))
assert.is_true(has(learner, 'frame:RegisterEvent("GAMEOBJECT_USED")'))
assert.is_true(has(learner, 'self:OnGameObjectUsed(arg1)'))
assert.is_true(has(learner, 'TraceLearnerEntity("gossip", npcGuid, unitType, id, name)'))
assert.is_true(has(learner, 'self:LearnObject(entityId, name)'))
assert.is_true(has(learner, 'if unitType == "GameObject" then'))
end)
end)
+16
View File
@@ -0,0 +1,16 @@
local function read(path)
local f = assert(io.open(path, "r"), "cannot open " .. path)
local c = f:read("*a")
f:close()
return c
end
describe("Questie available quests guard", function()
it("skips unavailable quests before touching tagInfoWasCached", function()
local content = read("Modules/Quest/AvailableQuests.lua")
assert.is_true(content:find("if not quest then", 1, true) ~= nil)
assert.is_true(content:find("Skipping unavailable quest during draw", 1, true) ~= nil)
assert.is_true(content:find("unavailableQuestLogged[questId]", 1, true) ~= nil)
assert.is_true(content:find("quest.tagInfoWasCached = true", 1, true) ~= nil)
end)
end)
+33
View File
@@ -0,0 +1,33 @@
describe("Questie item name safety", function()
local function read(path)
local f = assert(io.open(path, "r"), "cannot open " .. path)
local c = f:read("*a")
f:close()
return c
end
before_each(function()
dofile("Tests/wow_api_mock.lua")
GetItemInfo = function()
error("GetItemInfo should not be called for invalid item ids")
end
dofile("Database/QuestieDB.lua")
end)
it("returns a placeholder instead of calling GetItemInfo for invalid item ids", function()
local item = Item:CreateFromItemID(nil)
assert.equals("item:nil", item:GetItemName())
end)
it("also handles non-numeric item ids safely", function()
local item = Item:CreateFromItemID("bad-id")
assert.equals("item:bad-id", item:GetItemName())
end)
it("skips malformed item objectives without an item id", function()
local lib = read("Modules/Libs/QuestieLib.lua")
assert.is_true(string.find(lib, "local itemId = objectiveDB.Id", 1, true) ~= nil)
assert.is_true(string.find(lib, "if not itemId then", 1, true) ~= nil)
assert.is_true(string.find(lib, "QuestieDB.itemDataOverrides[itemId]", 1, true) ~= nil)
end)
end)
+364
View File
@@ -0,0 +1,364 @@
local function read(path)
local f = assert(io.open(path, "r"), "cannot open " .. path)
local c = f:read("*a")
f:close()
return c
end
local function has(content, needle)
return string.find(content, needle, 1, true) ~= nil
end
describe("QuestieLearner data source mode", function()
it("adds a mode selector and explicit fallback options in the database tab", function()
local dbOptions = read("Modules/Options/DatabaseTab/QuestieOptionsDatabase.lua")
assert.is_true(has(dbOptions, "Data Source Mode"))
assert.is_true(has(dbOptions, "auto = l10n(\"Auto (current behavior)\")"))
assert.is_true(has(dbOptions, "learner = l10n(\"Learner Only\")"))
assert.is_true(has(dbOptions, "static = l10n(\"Static Only\")"))
assert.is_true(has(dbOptions, "none = l10n(\"Neither (base DB only)\")"))
assert.is_true(has(dbOptions, "local function GetLearnerSelectedMode()"))
assert.is_true(has(dbOptions, "get = function() return GetLearnerSelectedMode() end"))
assert.is_true(has(dbOptions, "Runtime mode:"))
end)
it("defaults the learner mode to auto and exposes the live refresh hook", function()
local learner = read("Modules/QuestieLearner.lua")
local defaults = read("Modules/Options/QuestieOptionsDefaults.lua")
assert.is_true(has(defaults, "dataSourceMode = \"auto\""))
assert.is_true(has(learner, "function QuestieLearner:GetDataSourceMode()"))
assert.is_true(has(learner, "function QuestieLearner:IsLearnerLiveEnabled()"))
assert.is_true(has(learner, "function QuestieLearner:ApplyDataSourceMode()"))
end)
it("gates static suppression and tooltip fallback on the selected mode", function()
local quest = read("Modules/Quest/QuestieQuest.lua")
local priv = read("Modules/Quest/QuestieQuestPrivates.lua")
local tip = read("Modules/Tooltips/Tooltip.lua")
assert.is_true(has(quest, "dataSourceMode == \"auto\" or dataSourceMode == \"learner\""))
assert.is_true(has(priv, "dataSourceMode == \"none\""))
assert.is_true(has(tip, "mode ~= \"static\" and mode ~= \"none\""))
end)
it("allows learner mode to draw pins from a single learned spawn when needed", function()
local priv = read("Modules/Quest/QuestieQuestPrivates.lua")
assert.is_true(has(priv, "local staticHasSpawns = spawns and next(spawns) ~= nil"))
assert.is_true(has(priv, "local canUseLearnerSpawns = dataSourceMode == \"learner\""))
assert.is_true(has(priv, "or not staticHasSpawns"))
end)
it("keeps learner-only pin builders off the static DB lookup path", function()
local priv = read("Modules/Quest/QuestieQuestPrivates.lua")
assert.is_true(has(priv, "local npcData = QuestieDB:GetNPC(npcId)"))
assert.is_true(has(priv, "local name = npcData and npcData.name or nil"))
assert.is_true(has(priv, "local spawns = npcData and npcData.spawns or {}"))
assert.is_true(has(priv, "local rank = npcData and npcData.rank"))
assert.is_true(has(priv, "local objectData = QuestieDB:GetObject(objectId)"))
assert.is_true(has(priv, "local name = objectData and objectData.name or nil"))
assert.is_true(has(priv, "local spawns = objectData and objectData.spawns or {}"))
end)
it("maps object objectives from learner object captures before refresh", function()
local learner = read("Modules/QuestieLearner.lua")
assert.is_true(has(learner, "_Learner.recentObjects = _Learner.recentObjects or {}"))
assert.is_true(has(learner, "function QuestieLearner:LearnQuestObjectiveObject(questId, objectId, objText, objectiveIndex)"))
assert.is_true(has(learner, "objType == \"object\""))
assert.is_true(has(learner, "self:LearnQuestObjectiveObject(questId, objectId, objText, j)"))
assert.is_true(has(learner, "_Learner.recentObjects[objectId]"))
end)
end)
describe("QuestieLearner missing base DB fallback", function()
local QuestieLearner
before_each(function()
dofile("Tests/wow_api_mock.lua")
Questie.dbLearner.global.settings.enabled = false
Questie.dbLearner.global.settings.dataSourceMode = "static"
QuestieDB.baseDatabaseMissing = true
QuestieDB.IsBaseDatabaseMissing = function()
return true
end
QuestieLearner = dofile("Modules/QuestieLearner.lua")
end)
it("forces learner mode and live recording when the base DB is missing", function()
assert.equals("learner", QuestieLearner:GetDataSourceMode())
assert.is_true(QuestieLearner:IsEnabled())
end)
end)
describe("QuestieLearner learner mode activation", function()
local QuestieLearner
before_each(function()
dofile("Tests/wow_api_mock.lua")
Questie.dbLearner.global.settings.enabled = false
Questie.dbLearner.global.settings.dataSourceMode = "learner"
QuestieDB.baseDatabaseMissing = false
QuestieDB.IsBaseDatabaseMissing = function()
return false
end
QuestieLearner = dofile("Modules/QuestieLearner.lua")
end)
it("re-enables learner recording when learner mode is applied", function()
QuestieLearner:ApplyDataSourceMode()
assert.is_true(Questie.dbLearner.global.settings.enabled)
assert.is_true(QuestieLearner:IsEnabled())
end)
end)
describe("QuestieDB learner source fallback", function()
before_each(function()
dofile("Tests/wow_api_mock.lua")
dofile("Database/QuestieDB.lua")
dofile("Database/npcDB.lua")
dofile("Database/objectDB.lua")
dofile("Database/questDB.lua")
dofile("Database/itemDB.lua")
Questie.dbLearner.global.settings.enabled = true
Questie.dbLearner.global.settings.dataSourceMode = "learner"
Questie.dbLearner.global.npcs = {
[9001] = {
[1] = "Learner Whelp",
[7] = {
[44] = {
{ 12.3, 45.6 },
},
},
[8] = {
[44] = {
{ 13.3, 46.6 },
},
},
[9] = 44,
},
}
Questie.dbLearner.global.objects = {
[9002] = {
[1] = "Learner Cache",
[4] = {
[44] = {
{ 11.1, 22.2 },
},
},
[5] = 44,
},
}
QuestieDB.QueryNPC = function() return nil end
QuestieDB.QueryObject = function() return nil end
QuestieDB.QueryQuest = function() return nil end
QuestieDB.QueryItem = function() return nil end
QuestieDB.private.npcCache = {}
QuestieDB.private.objectCache = {}
QuestieDB.private.questCache = {}
QuestieDB.private.itemCache = {}
end)
it("returns learner NPC data when static queries are unavailable", function()
local npc = QuestieDB:GetNPC(9001)
assert.is_table(npc)
assert.equals("Learner Whelp", npc.name)
assert.is_table(npc.spawns)
assert.is_table(npc.waypoints)
assert.equals(44, npc.zoneID)
end)
it("turns learner kill evidence into NPC spawn coordinates immediately in learner mode", function()
Questie.dbLearner.global.npcs[9003] = {
[1] = "Learner Kill",
[8] = {
[101] = {
zoneId = 44,
x = 18.5,
y = 27.25,
},
},
}
local npc = QuestieDB:GetNPC(9003)
assert.is_table(npc)
assert.is_table(npc.spawns)
assert.is_table(npc.spawns[44])
assert.equals(1, #npc.spawns[44])
assert.equals(18.5, npc.spawns[44][1][1])
assert.equals(27.25, npc.spawns[44][1][2])
end)
it("collapses many nearby kills (distinct GUIDs) into one spawn pin", function()
-- Five kills of respawns at the same spot: distinct GUID keys, slightly
-- drifting player coords. This must render as ONE pin, not five.
Questie.dbLearner.global.npcs[9005] = {
[1] = "Respawning Boar",
[8] = {
[201] = { zoneId = 44, x = 50.0, y = 50.0 },
[202] = { zoneId = 44, x = 50.4, y = 50.3 },
[203] = { zoneId = 44, x = 49.7, y = 50.6 },
[204] = { zoneId = 44, x = 50.9, y = 49.8 },
[205] = { zoneId = 44, x = 50.2, y = 50.1 },
},
}
local npc = QuestieDB:GetNPC(9005)
assert.is_table(npc)
assert.is_table(npc.spawns[44])
assert.equals(1, #npc.spawns[44])
end)
it("keeps genuinely separate spawn locations as distinct pins", function()
Questie.dbLearner.global.npcs[9006] = {
[1] = "Field Boars",
[8] = {
[301] = { zoneId = 44, x = 20.0, y = 20.0 },
[302] = { zoneId = 44, x = 20.3, y = 20.2 }, -- same spot as 301
[303] = { zoneId = 44, x = 70.0, y = 65.0 }, -- far corner
},
}
local npc = QuestieDB:GetNPC(9006)
assert.is_table(npc)
assert.is_table(npc.spawns[44])
assert.equals(2, #npc.spawns[44])
end)
it("honors the spawn dedup radius knob (0 disables proximity merge)", function()
Questie.dbLearner.global.settings.spawnDedupRadius = 0
Questie.dbLearner.global.npcs[9007] = {
[1] = "Drifting Kills",
[8] = {
[401] = { zoneId = 44, x = 50.0, y = 50.0 },
[402] = { zoneId = 44, x = 50.4, y = 50.3 },
[403] = { zoneId = 44, x = 49.7, y = 50.6 },
[404] = { zoneId = 44, x = 50.9, y = 49.8 },
[405] = { zoneId = 44, x = 50.2, y = 50.1 },
},
}
local npc = QuestieDB:GetNPC(9007)
assert.is_table(npc)
assert.is_table(npc.spawns[44])
-- With merging off, each distinct kill coordinate stays its own pin.
assert.equals(5, #npc.spawns[44])
end)
it("widens merging when the dedup radius is increased", function()
Questie.dbLearner.global.settings.spawnDedupRadius = 12
Questie.dbLearner.global.npcs[9008] = {
[1] = "Loose Cluster",
[8] = {
[501] = { zoneId = 44, x = 40.0, y = 40.0 },
[502] = { zoneId = 44, x = 48.0, y = 46.0 }, -- ~10 away: merges at radius 12
},
}
local npc = QuestieDB:GetNPC(9008)
assert.is_table(npc)
assert.is_table(npc.spawns[44])
assert.equals(1, #npc.spawns[44])
end)
it("returns learner object data when static queries are unavailable", function()
local obj = QuestieDB:GetObject(9002)
assert.is_table(obj)
assert.equals("Learner Cache", obj.name)
assert.is_table(obj.spawns)
assert.equals(44, obj.zoneID)
end)
end)
describe("QuestieDB partial base DB missing", function()
before_each(function()
dofile("Tests/wow_api_mock.lua")
dofile("Database/QuestieDB.lua")
end)
it("does not report the base DB missing when only one store failed", function()
QuestieDB.baseDatabaseMissing = true
QuestieDB.baseDatabaseMissingKeys = { itemData = true }
assert.is_false(QuestieDB:IsBaseDatabaseMissing())
assert.is_true(QuestieDB:IsStoreMissing("itemData"))
assert.is_false(QuestieDB:IsStoreMissing("npcData"))
end)
it("reports the base DB missing only when every core store failed", function()
QuestieDB.baseDatabaseMissing = true
QuestieDB.baseDatabaseMissingKeys = {
npcData = true, objectData = true, questData = true, itemData = true,
}
assert.is_true(QuestieDB:IsBaseDatabaseMissing())
end)
it("honors the static selection for a present store even when another is missing", function()
QuestieDB.baseDatabaseMissing = true
QuestieDB.baseDatabaseMissingKeys = { itemData = true }
Questie.dbLearner.global.settings.dataSourceMode = "static"
Questie.dbLearner.global.npcs = {
[9100] = { [1] = "Should Not Win", [7] = { [44] = { { 1, 2 } } }, [9] = 44 },
}
QuestieDB.private.npcCache = {}
local queried = false
QuestieDB.QueryNPC = function() queried = true; return nil end
pcall(function() QuestieDB:GetNPC(9100) end)
assert.is_true(queried)
end)
end)
describe("QuestieDB mode cohesion", function()
before_each(function()
dofile("Tests/wow_api_mock.lua")
dofile("Database/QuestieDB.lua")
dofile("Database/npcDB.lua")
Questie.dbLearner.global.settings.enabled = true
Questie.dbLearner.global.npcs = {
[9200] = { [1] = "Learner Only NPC", [7] = { [44] = { { 5, 6 } } }, [9] = 44 },
}
QuestieDB.QueryNPC = function() return nil end
QuestieDB.baseDatabaseMissing = false
QuestieDB.baseDatabaseMissingKeys = {}
QuestieDB.private.npcCache = {}
end)
it("does NOT leak learner data into static mode when the store is present", function()
Questie.dbLearner.global.settings.dataSourceMode = "static"
QuestieDB.private.npcCache = {}
assert.is_nil(QuestieDB:GetNPC(9200))
end)
it("does NOT leak learner data into none mode when the store is present", function()
Questie.dbLearner.global.settings.dataSourceMode = "none"
QuestieDB.private.npcCache = {}
assert.is_nil(QuestieDB:GetNPC(9200))
end)
it("DOES overlay learner data in auto mode when the static DB lacks it", function()
Questie.dbLearner.global.settings.dataSourceMode = "auto"
QuestieDB.private.npcCache = {}
local npc = QuestieDB:GetNPC(9200)
assert.is_table(npc)
assert.equals("Learner Only NPC", npc.name)
end)
it("clears every cache including the zone cache on mode switch", function()
QuestieDB.private.questCache[1] = {}
QuestieDB.private.zoneCache[1] = {}
QuestieDB:ClearModeCaches()
assert.is_nil(QuestieDB.private.questCache[1])
assert.is_nil(QuestieDB.private.zoneCache[1])
end)
end)
describe("QuestieLearner mode switch redraw wiring", function()
it("drives a full real-time refresh via SmoothReset, not the mis-named event handler", function()
local function read(path)
local f = assert(io.open(path, "r")); local c = f:read("*a"); f:close(); return c
end
local dbOptions = read("Modules/Options/DatabaseTab/QuestieOptionsDatabase.lua")
assert.is_true(string.find(dbOptions, "QuestieQuest:SmoothReset()", 1, true) ~= nil)
-- The old import name resolved to nil and silently skipped the redraw.
assert.is_nil(string.find(dbOptions, "ImportModule(\"QuestieEventHandler\")", 1, true))
end)
end)
+67
View File
@@ -0,0 +1,67 @@
local function countKeys(tbl)
local n = 0
for _ in pairs(tbl or {}) do
n = n + 1
end
return n
end
describe("QuestieLearner reset all learned data", function()
local QuestieLearner
before_each(function()
dofile("Tests/wow_api_mock.lua")
Questie.dbLearner.global = {
settings = {
enabled = false,
prioritizeMyData = true,
dataSourceMode = "auto",
learnNpcs = true,
learnQuests = true,
learnItems = true,
learnObjects = true,
},
npcs = {
[1001] = { [1] = "Boar", mc = 2 },
},
quests = {
[2001] = { [1] = "Quest", mc = 1 },
},
items = {
[3001] = { [1] = "Item", mc = 1 },
},
objects = {
[4001] = { [1] = "Object", mc = 1 },
},
Ascension = {
npcs = { [9001] = { [1] = "Bucket NPC" } },
quests = { [9002] = { [1] = "Bucket Quest" } },
items = { [9003] = { [1] = "Bucket Item" } },
objects = { [9004] = { [1] = "Bucket Object" } },
},
}
dofile("Modules/QuestieLearner.lua")
QuestieLearner = _G.QuestieLearner
end)
it("clears all learned data buckets while preserving settings", function()
QuestieLearner:ClearAllData()
assert.is_table(Questie.dbLearner.global.settings)
assert.equals("auto", Questie.dbLearner.global.settings.dataSourceMode)
assert.is_true(Questie.dbLearner.global.settings.learnObjects)
local npcCount, questCount, itemCount, objectCount = QuestieLearner:GetStats()
assert.equals(0, npcCount)
assert.equals(0, questCount)
assert.equals(0, itemCount)
assert.equals(0, objectCount)
assert.equals(0, countKeys(Questie.dbLearner.global.npcs))
assert.equals(0, countKeys(Questie.dbLearner.global.quests))
assert.equals(0, countKeys(Questie.dbLearner.global.items))
assert.equals(0, countKeys(Questie.dbLearner.global.objects))
assert.is_nil(Questie.dbLearner.global.Ascension)
end)
end)
+678
View File
@@ -0,0 +1,678 @@
describe("QuestieLearner kill-path batching", function()
local queuedTimers
local broadcasts
local QuestieLearner
local simulatedTime
local function read(path)
local f = assert(io.open(path, "r"))
local content = f:read("*a")
f:close()
return content
end
local function has(text, needle)
return text and text:find(needle, 1, true) ~= nil
end
local function drainQueuedTimers()
while next(queuedTimers) do
local currentQueue = queuedTimers
queuedTimers = {}
for i = 1, table.getn(currentQueue) do
local fn = currentQueue[i]
if fn then
simulatedTime = simulatedTime + 1
fn()
end
end
end
end
before_each(function()
dofile("Tests/wow_api_mock.lua")
queuedTimers = {}
broadcasts = {}
simulatedTime = 1000
_G.GetTime = function()
return simulatedTime
end
QuestieCompat.C_Timer.After = function(_, fn)
queuedTimers[table.getn(queuedTimers) + 1] = fn
end
local originalImportModule = QuestieLoader.ImportModule
QuestieLoader.ImportModule = function(self, name)
if name == "QuestieLearnerComms" then
return {
BroadcastLearnedData = function(_, op, typ, id, data)
broadcasts[table.getn(broadcasts) + 1] = {
op = op,
typ = typ,
id = id,
data = data,
}
end,
}
end
return originalImportModule(self, name)
end
Questie.dbLearner.global.settings.enabled = true
Questie.dbLearner.global.settings.learnNpcs = true
Questie.dbLearner.global.settings.learnQuests = true
Questie.dbLearner.global.settings.learnItems = true
Questie.dbLearner.global.settings.learnObjects = true
Questie.dbLearner.global.settings.minConfidencePins = 1
Questie.dbLearner.global.npcs = {}
Questie.dbLearner.global.quests = {}
Questie.dbLearner.global.items = {}
Questie.dbLearner.global.objects = {}
QuestieDB.npcDataOverrides = {}
QuestieDB.questDataOverrides = {}
QuestieDB.itemDataOverrides = {}
QuestieDB.objectDataOverrides = {}
QuestieDB.private = {
npcCache = {
[1001] = { name = "Cached Boar" },
},
}
QuestiePlayer.currentQuestlog = {}
QuestieLearner = dofile("Modules/QuestieLearner.lua")
end)
it("coalesces repeated NPC live updates instead of invalidating DB cache per kill", function()
QuestieLearner:LearnNPC(1001, "Laggy Boar", nil, nil, nil, nil, 41.25, 52.5, 44)
QuestieLearner:LearnNPC(1001, "Laggy Boar", nil, nil, nil, nil, 41.35, 52.6, 44)
assert.is_nil(QuestieDB.npcDataOverrides[1001])
assert.is_table(QuestieDB.private.npcCache[1001])
assert.equals(2, table.getn(queuedTimers))
drainQueuedTimers()
assert.is_table(QuestieDB.npcDataOverrides[1001])
assert.is_nil(QuestieDB.private.npcCache[1001])
assert.is_table(QuestieDB.npcDataOverrides[1001][7])
local flushedSpawnCount = table.getn(QuestieDB.npcDataOverrides[1001][7][44])
QuestieLearner:LearnNPC(1001, "Laggy Boar", nil, nil, nil, nil, 80.0, 80.0, 44)
assert.equals(flushedSpawnCount, table.getn(QuestieDB.npcDataOverrides[1001][7][44]))
assert.is_true(table.getn(queuedTimers) >= 2)
end)
it("force-flushes active quest pins within the NPC live-update flush (no second debounce)", function()
local updateCount = 0
local originalUpdateQuest = QuestieQuest.UpdateQuest
QuestieQuest.UpdateQuest = function(_, questId)
updateCount = updateCount + 1
end
QuestiePlayer.currentQuestlog = { [5500] = true }
local originalGetQuest = QuestieDB.GetQuest
QuestieDB.GetQuest = function(questId)
if questId == 5500 then
return { Objectives = { [1] = { Id = 7500, spawnList = { [7500] = {} } } } }
end
return nil
end
QuestieLearner:LearnNPC(7500, "Quest Boar", nil, nil, nil, nil, 41.0, 52.0, 44)
-- Drain exactly ONE timer round (the NPC live-update flush). The pin refresh
-- must happen inside that same flush, not in a later pinRefreshDelay cycle.
local firstRound = queuedTimers
queuedTimers = {}
for i = 1, table.getn(firstRound) do
simulatedTime = simulatedTime + 1
firstRound[i]()
end
assert.is_true(updateCount >= 1)
-- And draining the rest must terminate (no infinite self-re-arming timer).
drainQueuedTimers()
QuestieDB.GetQuest = originalGetQuest
QuestieQuest.UpdateQuest = originalUpdateQuest
end)
it("coalesces repeated quest-pin refreshes into one flush", function()
local updateCount = 0
local originalUpdateQuest = QuestieQuest.UpdateQuest
QuestieQuest.UpdateQuest = function(_, questId)
updateCount = updateCount + 1
end
QuestiePlayer.currentQuestlog = { [5001] = true }
Questie.dbLearner.global.quests = {}
QuestieDB.questDataOverrides = {}
QuestieLearner:LearnQuestObjectiveNPC(5001, 7001, "Pin Boar slain", 1)
QuestieLearner:LearnQuestObjectiveNPC(5001, 7001, "Pin Boar slain", 1)
assert.is_true(table.getn(queuedTimers) >= 1)
for i = 1, table.getn(queuedTimers) do
queuedTimers[i]()
end
assert.is_table(Questie.dbLearner.global.quests[5001])
assert.is_table(QuestieDB.questDataOverrides[5001])
QuestieQuest.UpdateQuest = originalUpdateQuest
end)
it("suppresses an already queued pin refresh after switching to manual mode", function()
local updateCount = 0
local originalUpdateQuest = QuestieQuest.UpdateQuest
QuestieQuest.UpdateQuest = function(_, questId)
updateCount = updateCount + 1
end
QuestiePlayer.currentQuestlog = { [5003] = true }
Questie.dbLearner.global.quests = {}
QuestieDB.questDataOverrides = {}
QuestieLearner:LearnQuestObjectiveNPC(5003, 7003, "Manual Boar slain", 1)
assert.is_true(table.getn(queuedTimers) >= 1)
Questie.dbLearner.global.settings.pinRefreshMode = "manual"
drainQueuedTimers()
assert.equals(0, updateCount)
QuestieQuest.UpdateQuest = originalUpdateQuest
end)
it("keeps bystander UNIT_DIED eligible for learning in the source path", function()
local learner = read("Modules/QuestieLearner.lua")
assert.is_true(has(learner, "Unconditionally map the spawn position for Ascension DB building"))
assert.is_true(has(learner, "self:LearnNPC(npcId, name, nil, nil, nil, nil, px, py, zoneId)"))
assert.is_false(has(learner, 'if eventType ~= "PARTY_KILL" and not credited then'))
end)
it("still learns and batches local PARTY_KILL combat-log events", function()
QuestieLearner:OnCombatLogEvent(
1234,
"PARTY_KILL",
UnitGUID("player"),
UnitName("player"),
nil,
"Creature-0-0-0-0-7003-0000000001",
"Tagged Boar",
nil
)
assert.is_table(Questie.dbLearner.global.npcs[7003])
assert.equals("Tagged Boar", Questie.dbLearner.global.npcs[7003][1])
assert.equals(2, table.getn(queuedTimers))
end)
it("still learns credited UNIT_DIED combat-log events when PARTY_KILL is absent", function()
QuestieLearner:OnCombatLogEvent(
1234,
"UNIT_DIED",
UnitGUID("player"),
UnitName("player"),
nil,
"Creature-0-0-0-0-7004-0000000001",
"Credited Boar",
nil
)
assert.is_table(Questie.dbLearner.global.npcs[7004])
assert.equals("Credited Boar", Questie.dbLearner.global.npcs[7004][1])
assert.equals(2, table.getn(queuedTimers))
end)
it("cross-links quest giver NPCs and objects learned after the quest without duplicate pin flushes", function()
local updateCount = 0
local originalUpdateQuest = QuestieQuest.UpdateQuest
QuestieQuest.UpdateQuest = function(_, questId)
updateCount = updateCount + 1
end
Questie.db.profile.learnerBroadcast = false
QuestiePlayer.currentQuestlog = { [6001] = true }
QuestieLearner:LearnQuest(6001, {
[1] = "Cross Link Givers",
[2] = { [1] = { 7101 }, [2] = { 8101 } },
[3] = { [1] = { 7102 }, [2] = { 8102 } },
})
queuedTimers = {}
QuestieLearner:LearnNPC(7101, "Starter Guard", nil, nil, nil, nil, 10, 20, 44)
QuestieLearner:LearnNPC(7102, "Finisher Guard", nil, nil, nil, nil, 30, 40, 44)
QuestieLearner:LearnObject(8101, "Starter Chest")
QuestieLearner:LearnObject(8102, "Finisher Chest")
assert.same({ 6001 }, Questie.dbLearner.global.npcs[7101][10])
assert.same({ 6001 }, Questie.dbLearner.global.npcs[7102][11])
assert.same({ 6001 }, Questie.dbLearner.global.objects[8101][2])
assert.same({ 6001 }, Questie.dbLearner.global.objects[8102][3])
assert.same({ 6001 }, QuestieDB.npcDataOverrides[7101][10])
assert.same({ 6001 }, QuestieDB.npcDataOverrides[7102][11])
assert.same({ 6001 }, QuestieDB.objectDataOverrides[8101][2])
assert.same({ 6001 }, QuestieDB.objectDataOverrides[8102][3])
drainQueuedTimers()
assert.is_table(Questie.dbLearner.global.quests[6001])
assert.is_table(QuestieDB.questDataOverrides[6001])
QuestieQuest.UpdateQuest = originalUpdateQuest
end)
it("cross-links item objective drop NPCs into quest creature objectives", function()
Questie.db.profile.learnerBroadcast = false
QuestiePlayer.currentQuestlog = { [6002] = true }
QuestieLearner:LearnQuest(6002, {
[1] = "Cross Link Drops",
[10] = {
[3] = {
{ 2201, 0, 1, "Collect one tusk" },
},
},
})
QuestieLearner:LearnItem(2201, "Quest Tusk", 1, 1, 12, 0)
QuestieLearner:LearnItemDrop(2201, 7201)
assert.equals(7201, Questie.dbLearner.global.quests[6002][10][1][1][1])
assert.equals(7201, QuestieDB.questDataOverrides[6002][10][1][1][1])
QuestieLearner:LearnItemDrop(2201, 7201)
assert.equals(1, table.getn(Questie.dbLearner.global.quests[6002][10][1]))
assert.equals(1, table.getn(QuestieDB.questDataOverrides[6002][10][1]))
end)
it("defers learner objective tooltip registration to protected AscensionDB quest data", function()
Questie.db.profile.learnerBroadcast = false
QuestiePlayer.currentQuestlog = {}
QuestieDB.ascensionOverrideKeys = {
QUEST = {
[8334] = {
[10] = true,
},
},
}
_G._lastRegisteredTooltip = nil
QuestieLearner:LearnQuestObjectiveNPC(8334, 15271, "Tender slain", 1)
assert.is_table(Questie.dbLearner.global.quests[8334])
assert.is_nil(_G._lastRegisteredTooltip)
end)
it("coalesces repeated learner broadcasts into one latest update", function()
QuestieLearner:LearnItem(2001, "Quest Tusk", 1, 1, 12, 0)
QuestieLearner:LearnItem(2001, "Quest Tusk", 1, 1, 12, 0)
assert.equals(0, table.getn(broadcasts))
assert.equals(1, table.getn(queuedTimers))
drainQueuedTimers()
assert.equals(1, table.getn(broadcasts))
assert.equals("NEW", broadcasts[1].op)
assert.equals("ITEM", broadcasts[1].typ)
assert.equals(2001, broadcasts[1].id)
end)
it("coalesces repeated inbound network merges into one inject pass", function()
local injectCount = 0
local originalInject = QuestieLearner.InjectLearnedData
QuestieLearner.InjectLearnedData = function(self)
injectCount = injectCount + 1
QuestieLearner.data = Questie.dbLearner.global
end
QuestieLearner:HandleNetworkData("NPC", 3001, { [1] = "Net Boar", [7] = { [44] = { { 11, 22 } } } }, "NEW")
QuestieLearner:HandleNetworkData("NPC", 3001, { [1] = "Net Boar", [7] = { [44] = { { 11, 22 }, { 33, 44 } } } }, "UPDATE")
assert.equals(0, injectCount)
assert.equals(1, table.getn(queuedTimers))
drainQueuedTimers()
assert.equals(1, injectCount)
assert.equals("Net Boar", Questie.dbLearner.global.npcs[3001][1])
assert.equals(2, table.getn(Questie.dbLearner.global.npcs[3001][7][44]))
QuestieLearner.InjectLearnedData = originalInject
end)
it("forwards OnEvent payload arguments to learner handlers", function()
local originalCreateFrame = _G.CreateFrame
local originalStrsplit = _G.strsplit
local createdFrame = nil
local captured = {
questTurnedIn = nil,
questAccepted = nil,
combatLog = nil,
itemInfoReceived = nil,
questTrackingCleared = nil,
}
_G.CreateFrame = function()
createdFrame = {
RegisterEvent = function() end,
SetScript = function(self, scriptName, fn)
if scriptName == "OnEvent" then
self._onEvent = fn
end
end,
}
return createdFrame
end
_G.strsplit = function(sep, str)
local parts = {}
local pattern = string.format("([^%s]+)", sep)
for part in string.gmatch(str, pattern) do
parts[#parts + 1] = part
end
return unpack(parts)
end
QuestieLearner = dofile("Modules/QuestieLearner.lua")
local originalOnQuestTurnedIn = QuestieLearner.OnQuestTurnedIn
local originalOnQuestAccepted = QuestieLearner.OnQuestAccepted
local originalOnCombatLogEvent = QuestieLearner.OnCombatLogEvent
local originalOnGetItemInfoReceived = QuestieLearner.OnGetItemInfoReceived
local originalClearQuestObjectiveTracking = QuestieLearner.ClearQuestObjectiveTracking
QuestieLearner.OnQuestTurnedIn = function(self, questId, npcId, questFlags)
captured.questTurnedIn = { questId, npcId, questFlags }
end
QuestieLearner.OnQuestAccepted = function(self, questId, questGiver)
captured.questAccepted = { questId, questGiver }
end
QuestieLearner.OnCombatLogEvent = function(self, timestamp, eventType, srcGUID, srcName, srcFlags, dstGUID, dstName, dstFlags, spellId, spellName)
captured.combatLog = { timestamp, eventType, srcGUID, srcName, srcFlags, dstGUID, dstName, dstFlags, spellId, spellName }
end
QuestieLearner.OnGetItemInfoReceived = function(self, itemId)
captured.itemInfoReceived = { itemId }
end
QuestieLearner.ClearQuestObjectiveTracking = function(self, questId)
captured.questTrackingCleared = { questId }
end
QuestieLearner:RegisterEvents()
local frame = createdFrame
frame._onEvent(frame, "QUEST_TURNED_IN", 101, 202, 303)
frame._onEvent(frame, "QUEST_ACCEPTED", 404, 505)
frame._onEvent(frame, "COMBAT_LOG_EVENT_UNFILTERED", 1, "SPELL_DAMAGE", "src-guid", "Src", 2, "dst-guid", "Dst", 4, 777, "Fireball")
frame._onEvent(frame, "GET_ITEM_INFO_RECEIVED", 888)
frame._onEvent(frame, "QUEST_REMOVED", 999)
assert.same({ 101, 202, 303 }, captured.questTurnedIn)
assert.same({ 404, 505 }, captured.questAccepted)
assert.same({ 1, "SPELL_DAMAGE", "src-guid", "Src", 2, "dst-guid", "Dst", 4, 777, "Fireball" }, captured.combatLog)
assert.same({ 888 }, captured.itemInfoReceived)
assert.same({ 999 }, captured.questTrackingCleared)
QuestieLearner.OnQuestTurnedIn = originalOnQuestTurnedIn
QuestieLearner.OnQuestAccepted = originalOnQuestAccepted
QuestieLearner.OnCombatLogEvent = originalOnCombatLogEvent
QuestieLearner.OnGetItemInfoReceived = originalOnGetItemInfoReceived
QuestieLearner.ClearQuestObjectiveTracking = originalClearQuestObjectiveTracking
_G.CreateFrame = originalCreateFrame
_G.strsplit = originalStrsplit
end)
end)
describe("QuestieLearnerComms queue draining", function()
local sentMessages
local processedMessages
local QuestieLearnerComms
local currentTime
before_each(function()
dofile("Tests/wow_api_mock.lua")
sentMessages = {}
processedMessages = {}
currentTime = 1000
local originalImportModule = QuestieLoader.ImportModule
QuestieLoader.ImportModule = function(self, name)
if name == "QuestieLearner" then
return _G.QuestieLearner
end
return originalImportModule(self, name)
end
QuestieLoader.CreateModule = function(self, name)
_G[name] = { private = {} }
return _G[name]
end
_G.LibStub = function(name, silent)
if name == "AceComm-3.0" then
return { RegisterComm = function() end }
elseif name == "LibDeflate" then
return {
CompressDeflate = function(_, s) return s end,
EncodeForPrint = function(_, s) return s end,
DecodeForPrint = function(_, s) return s end,
DecompressDeflate = function(_, s) return s end,
}
elseif name == "AceSerializer-3.0" then
return {
Serialize = function(_, payload)
return payload.op .. ":" .. tostring(payload.id)
end,
Deserialize = function(_, serialized)
local op, id = string.match(serialized, "([^:]+):(.+)")
return true, {
_ver = 2,
op = op,
typ = "NPC",
id = tonumber(id) or id,
d = { [1] = "net" },
}
end,
}
elseif name == "XXH_Lua_Lib" then
return nil
elseif name == "HereBeDragonsQuestie-2.0" then
return {}
end
return {}
end
_G.QuestieLearner = {
HandleNetworkData = function(_, typ, id, d, op)
processedMessages[table.getn(processedMessages) + 1] = {
typ = typ,
id = id,
op = op,
}
end,
}
_G.GetChannelName = function() return 1 end
_G.JoinPermanentChannel = function() end
_G.ChatFrame_RemoveChannel = function() end
_G.DEFAULT_CHAT_FRAME = { GetID = function() return 1 end }
_G.SendChatMessage = function(msg, mode, nilarg, channel)
sentMessages[table.getn(sentMessages) + 1] = msg
end
_G.InCombatLockdown = function() return false end
_G.random = function() return 0 end
_G.GetTime = function()
return currentTime
end
QuestieCompat.C_Timer.NewTicker = function(delay, fn)
return { Cancel = function() end }
end
dofile("Modules/Network/QuestieLearnerComms.lua")
QuestieLearnerComms = _G.QuestieLearnerComms
QuestieLearnerComms:Initialize()
end)
it("drains outgoing and incoming queues in FIFO order without front-removal", function()
QuestieLearnerComms:BroadcastLearnedData("NEW", "NPC", 101, { foo = "a" })
QuestieLearnerComms:BroadcastLearnedData("UPDATE", "NPC", 102, { foo = "b" })
QuestieLearnerComms:OnCommReceived("QuestieLearner", "NEW:201", "CHANNEL", "Alice")
QuestieLearnerComms:OnCommReceived("QuestieLearner", "UPDATE:202", "CHANNEL", "Bob")
QuestieLearnerComms.private:ProcessQueues()
currentTime = currentTime + 4
QuestieLearnerComms.private:ProcessQueues()
currentTime = currentTime + 4
QuestieLearnerComms.private:ProcessQueues()
assert.equals(2, table.getn(sentMessages))
assert.equals("NEW:101", sentMessages[1])
assert.equals("UPDATE:102", sentMessages[2])
assert.equals(2, table.getn(processedMessages))
assert.equals(201, processedMessages[1].id)
assert.equals(202, processedMessages[2].id)
end)
end)
describe("QuestieComms packet sizing", function()
local serializeCount
local sendCount
local QuestieComms
before_each(function()
dofile("Tests/wow_api_mock.lua")
serializeCount = 0
sendCount = 0
local originalCreateModule = QuestieLoader.CreateModule
local originalImportModule = QuestieLoader.ImportModule
QuestieLoader.CreateModule = function(self, name)
_G[name] = { private = {} }
return _G[name]
end
QuestieLoader.ImportModule = function(self, name)
if name == "QuestieSerializer" then
return {
Serialize = function(_, payload)
serializeCount = serializeCount + 1
return "packet:" .. tostring(payload.id or payload[1] or "x")
end,
Deserialize = function() return true, {} end,
}
end
return originalImportModule(self, name)
end
_G.LibStub = function(name, silent)
if name == "AceComm-3.0" then
return { RegisterComm = function() end }
elseif name == "LibDeflate" then
return {
CompressDeflate = function(_, s) return s end,
EncodeForPrint = function(_, s) return s end,
DecodeForPrint = function(_, s) return s end,
DecompressDeflate = function(_, s) return s end,
}
elseif name == "AceSerializer-3.0" then
return {
Serialize = function(_, payload) return "packet:" .. tostring(payload.id or payload[1] or "x") end,
Deserialize = function() return true, {} end,
}
elseif name == "XXH_Lua_Lib" then
return nil
elseif name == "HereBeDragonsQuestie-2.0" then
return {}
end
return {}
end
_G.QuestiePlayer.GetGroupType = function() return "party" end
_G.QuestiePlayer.numberOfGroupMembers = 5
_G.QuestieDB.QuestPointers = {
[101] = true,
[102] = true,
[103] = true,
}
_G.QuestieDB.QueryQuestSingle = function()
return 0
end
_G.QuestieDB.GetQuest = function(questId)
return {
Objectives = {
[1] = { Id = questId * 10 },
},
}
end
_G.QuestLogCache.questLog_DO_NOT_MODIFY = {
[101] = { questTag = "Normal" },
[102] = { questTag = "Normal" },
[103] = { questTag = "Normal" },
}
_G.QuestLogCache.GetQuestObjectives = function()
return {
{ type = "monster", numFulfilled = 0, numRequired = 1 },
}
end
_G.ZoneDB.GetUiMapIdByAreaId = function()
return 1
end
_G.HBD = {
GetZoneDistance = function() return 1 end,
GetPlayerZone = function() return 1 end,
}
_G.GetChannelName = function() return 1 end
_G.SendChatMessage = function() end
_G.Questie.SendCommMessage = function()
sendCount = sendCount + 1
end
_G.UnitInBattleground = function() return false end
_G.random = function() return 0 end
_G.GetTime = function() return 0 end
_G.tinsert = table.insert
QuestieCompat.C_Timer.After = function(_, fn) end
QuestieCompat.C_Timer.NewTicker = function(_, fn)
return { Cancel = function() end }
end
QuestieComms = dofile("Modules/Network/QuestieComms.lua")
QuestieComms.private.CreatePacket = function()
return {
data = {},
write = function() end,
}
end
serializeCount = 0
end)
it("serializes each quest once while packing broadcast blocks", function()
QuestieComms.private:BroadcastQuestLog("QC_ID_BROADCAST_FULL_QUESTLIST", "WHISPER", "Tester")
assert.equals(3, serializeCount)
serializeCount = 0
QuestieComms.private:BroadcastQuestLogV2("QC_ID_BROADCAST_FULL_QUESTLIST", "WHISPER", "Tester")
assert.equals(3, serializeCount)
end)
it("suppresses outgoing QuestieComms immediately when disabled", function()
Questie.db.profile.questieCommsEnabled = false
local packet = QuestieComms.private:CreatePacket(QuestieComms.private.QC_ID_BROADCAST_QUEST_REMOVE)
packet.data.writeMode = QuestieComms.private.QC_WRITE_ALLGROUP
packet.data.priority = "NORMAL"
packet.data.id = 101
packet:write()
assert.equals(0, sendCount)
end)
end)
@@ -0,0 +1,23 @@
describe("QuestieQuest tooltip fallback", function()
local function read(path)
local f = assert(io.open(path, "r"), "cannot open " .. path)
local c = f:read("*a")
f:close()
return c
end
local function has(content, needle)
return string.find(content, needle, 1, true) ~= nil
end
it("registers direct objective tooltips when special objectives have ids but no spawnList", function()
local questieQuest = read("Modules/Quest/QuestieQuest.lua")
assert.is_true(has(questieQuest, 'tooltipKey = "m_" .. objective.Id'))
assert.is_true(has(questieQuest, 'tooltipKey = "o_" .. objective.Id'))
assert.is_true(has(questieQuest, 'tooltipKey = "i_" .. objective.Id'))
assert.is_true(has(questieQuest, 'elseif objective.Type == "killcredit" then'))
assert.is_true(has(questieQuest, 'QuestieTooltips:RegisterObjectiveTooltip(questId, "m_" .. id, objective)'))
assert.is_true(has(questieQuest, "objective.registeredItemTooltips = true"))
end)
end)
+66
View File
@@ -0,0 +1,66 @@
describe("Questie tooltip precedence", function()
local function contains(lines, needle)
for _, line in ipairs(lines or {}) do
if string.find(line, needle, 1, true) then
return true
end
end
return false
end
before_each(function()
dofile("Tests/wow_api_mock.lua")
local originalImportModule = QuestieLoader.ImportModule
QuestieLoader.ImportModule = function(self, name)
if name == "QuestieLib" then
return {
GetColoredQuestName = function(_, questId)
return "Quest " .. tostring(questId)
end,
Colorize = function(_, text)
return text
end,
}
end
return originalImportModule(self, name)
end
Questie.db.profile.showQuestsInNpcTooltip = true
Questie.db.profile.enableTooltipsQuestLevel = false
Questie.db.profile.enableTooltipsNPCID = false
QuestiePlayer.numberOfGroupMembers = 0
QuestieCompat.IsInGroup = function() return false end
QuestieCompat.UnitInParty = function() return false end
QuestieDB.GetQuest = function(_, questId)
if questId ~= 8334 then
return nil
end
return {
ObjectiveData = {
[1] = { Type = "monster", Id = 15271, Text = "Tender slain" },
[2] = { Type = "monster", Id = 15294, Text = "Feral Tender slain" },
},
}
end
dofile("Modules/Tooltips/Tooltip.lua")
QuestieTooltips.lookupByKey["m_15271"] = {
["8334 Tender 15271"] = {
questId = 8334,
name = "Tender",
starterId = 15271,
},
}
end)
it("adds AscensionDB objective text under quest titles when no objective tooltip is registered", function()
local lines = QuestieTooltips:GetTooltip("m_15271")
assert.is_true(contains(lines, "Quest 8334"))
assert.is_true(contains(lines, "Tender slain"))
assert.is_true(contains(lines, "Feral Tender slain"))
end)
end)
+29 -6
View File
@@ -176,6 +176,29 @@
</div>
<div class="container">
<h2 id="unreleased-performance-refactor">[Unreleased] &mdash; Performance Refactor Branches</h2>
<ul>
<li><strong>[QuestieLearner &mdash; Kill/Pin Refresh Throttling]</strong> Debounced learner-triggered map-pin refreshes so heavy kill streaks do not redraw pins on every event. Added a maximum wait cap so batched updates still flush predictably instead of being pushed out forever by constant activity.</li>
<li><strong>[QuestieLearner &mdash; Bystander Kill Suppression]</strong> Changed visible nearby <code>UNIT_DIED</code> handling so kills from other players can update short-lived correlation evidence without immediately running full learner injection or pin refresh work.</li>
<li><strong>[QuestieLearner &mdash; PARTY_KILL Event-Order Fix]</strong> Fixed an edge case where a <code>UNIT_DIED</code> debounce entry could suppress a later authoritative <code>PARTY_KILL</code> for the same GUID. The debounce now tracks event type and allows the player's/group's kill event through while still suppressing true duplicates.</li>
<li><strong>[QuestieLearner &mdash; Immediate Spawn Pin Rendering]</strong> Learner kill evidence now becomes spawn coordinates immediately in learner mode so learned NPC kills can spawn map pins without waiting for the later confidence merge path.</li>
<li><strong>[QuestieLearner &mdash; Live Performance Options]</strong> Added Advanced-tab controls for learner intensity, pin refresh delay, maximum pin refresh wait, minimum kills before learned pins, and live NPC update delay so users can tune the system for low-end PCs or heavy-activity zones.</li>
<li><strong>[QuestieComms &mdash; User-Controlled Intensity]</strong> Added Advanced-tab QuestieComms controls, including a full disable switch and live throttles for queue processing, quest-state broadcasts, and bulk sync pacing.</li>
<li><strong>[QuestieComms &mdash; Disable Gate Fix]</strong> Scoped the comms enable helper so the disable switch no longer calls a nil global and every send/process entry point consistently respects the setting.</li>
<li><strong>[Arrow &mdash; Low-End Performance Controls]</strong> Added live Arrow update throttles to reduce repeated nearest-target and coordinate work while preserving existing arrow behavior.</li>
<li><strong>[Measured Hot Paths &mdash; Phase 3]</strong> Landed measured optimizations on the phase 3 branch for literal localization caching, available quest redraw batching, <code>QuestieDB.IsDoable</code> batch reads, hot profile aliases, <code>GetTime()</code> hoists, NPC fallback lookup caching, and validate-cache allocation cleanup.</li>
<li><strong>[QuestieQuest &mdash; Unavailable Quest Guard]</strong> Guarded the available-quest draw thread so unresolved quest IDs are skipped safely instead of crashing the thread, and deduped the skip log so the same missing quest does not spam every redraw.</li>
<li><strong>[QuestieLearner &mdash; Data Source Mode Cohesion]</strong> Reworked the Auto / Learner / Static / Neither data-source modes so switching between them applies live and consistently. A single missing static sub-table (npc/object/quest/item) no longer locks the whole addon into learner mode &mdash; only the genuinely-missing store falls back. Static and Neither no longer silently fall back to learner records, switching modes now clears the per-zone quest cache, and the mode switch drives a full pin/tracker redraw through <code>QuestieQuest:SmoothReset()</code> (the previous redraw call imported a mis-named module and silently did nothing).</li>
<li><strong>[QuestieLearner &mdash; Pin Refresh Latency]</strong> Collapsed a redundant second debounce stage on the live-learn pin-refresh path. Newly learned spawns now redraw within a single debounce window instead of waiting out both the NPC live-update delay and a separate pin-refresh delay, roughly halving perceived pin-update latency in the Balanced and Low presets. Also fixed a latent infinite timer re-arm that could occur once the pin flush was triggered directly.</li>
<li><strong>[Map &mdash; Dense Pin Clustering Aggressiveness Knob]</strong> Re-implemented density-adaptive clustering for crowded kill objectives, now controlled by a new <em>Dense pin clustering aggressiveness</em> slider on the Advanced tab (0 = show every pin, higher = tighter consolidation where many pins share a zone). Coincident pins are always deduplicated regardless of the clustering settings, and the intentional per-zone (Sunstrider Isle) and object-icon range overrides are preserved.</li>
<li><strong>[QuestieLearner &mdash; One Pin Per Spawn, Not Per Kill]</strong> Fixed learner kill evidence rendering a separate pin for every kill. Because kill coordinates are the player's position at kill time (and respawns carry fresh GUIDs), repeated kills at the same spot drifted just enough to dodge the exact-match dedup. The immediate learner-mode spawn builder now merges evidence within a small radius into one pin per physical spawn, and the weighted spawn-evidence merge groups kills by coordinate bucket so a single spot can actually accumulate enough evidence to clear the confidence threshold. The merge distance is exposed as a new <em>Spawn Pin Dedup Radius</em> slider on the Advanced tab (0 = show every distinct position, higher = fewer/tighter pins per spawn) which redraws live.</li>
<li><strong>[Error Suppression &mdash; Debug Modes]</strong> Moved missing quest and other non-fatal database/error spam out of normal chat output and into Questie debug-critical/developer output. Fatal startup failures remain loud.</li>
<li><strong>[Tooltip Data Precedence]</strong> Updated tooltip handling so QuestieLearner defers to AscensionDB-owned tooltip/objective data instead of hiding or replacing server-plugin data for active quests.</li>
</ul>
<div class="important">
<strong>Release status:</strong> The most complete performance candidate is not yet a single branch. Merge <code>questie-learner-comms-improvements</code> with <code>phase3-measured-perf</code>, remove or revalidate the stale reverted <code>QuestieMap.ProcessQueue</code> profile-local commit, fix the unrelated Arrow asset test mismatch, and validate in game before stable release. Test learner-only/static-only switching, immediate spawn pin rendering, and available-quest scanning during real kill/loot gameplay.
</div>
<h2 id="v163">[v1.6.3] &mdash; Arrow Redesign, Minimap Pin Drift Fix (Live API + Corrected Pixel Math), Sunstrider Isle Arrow Distance, Map Pins, Tooltip Schema Fixes, QuestData String Safety</h2>
<ul>
<li><strong>[Docs &mdash; Release Metadata Sync]</strong> Updated the README badge, documentation version badges, and in-game addon version to <code>v1.6.3</code>, then tightened the release filters so handoff notes, local dev settings, and other workspace-only artifacts stay out of the exported release archive.</li>
@@ -278,9 +301,9 @@
<hr>
<h2 id="session-34">Session 34 &mdash; Turtle WoW Cleanup (2026-05-04)</h2>
<h2 id="session-34">Session 34 &mdash; Unsupported Legacy Server Cleanup (2026-05-04)</h2>
<ul>
<li><strong>[Maintenance]</strong> Removed Turtle WoW references &mdash; Deleted Turtle WoW row from the server compatibility table and removed Turtle WoW load tip from installation instructions in README.md. Turtle WoW is shutting down and no longer has a supported plugin.</li>
<li><strong>[Maintenance]</strong> Removed unsupported legacy server references &mdash; Deleted an unsupported legacy server row from the server compatibility table and removed its load tip from installation instructions in README.md.</li>
<li><strong>[Documentation]</strong> Clarified two-addon installation requirement &mdash; Updated README.md installation instructions to explicitly state that Questie-X requires BOTH the core addon AND a server-specific database plugin to function.</li>
</ul>
@@ -426,7 +449,7 @@
<li><strong>[Fix &mdash; Ascension Zone Mapping]</strong> Fixed a regression in <code>QuestieCompat</code> where <code>uiMapData</code> for Ascension-specific zones was not correctly propagating to the global mapping table, restoring map pin functionality for seasonal and custom zones.</li>
<li><strong>[Fix &mdash; QuestieLearner]</strong> Centralized zone/area ID lookup in <code>l10n</code> module to prevent <code>GetAreaIdByLocalName</code> nil errors (Fixes Project Ebonhold runtime crash).</li>
<li><strong>[Feature &mdash; Session Export]</strong> Added <code>session_export</code> skill for standardized documentation and session artifact exports.</li>
<li><strong>[Fix &mdash; Custom Server Compilation]</strong> Fixed database compilation not running on custom servers (Ascension, Ebonhold, Turtle WoW, etc.) where plugins inject data after initial load.</li>
<li><strong>[Fix &mdash; Custom Server Compilation]</strong> Fixed database compilation not running on custom servers (Ascension, Ebonhold, etc.) where plugins inject data after initial load.</li>
<li><strong>[Fix &mdash; Zone Mapping Bug]</strong> Fixed incorrect key assignment in <code>QuestiePluginAPI:InjectZoneTables()</code>. This caused zone lookups to fail, resulting in "No UiMapID or fitting parentAreaId" errors for custom zone IDs.</li>
</ul>
@@ -450,7 +473,7 @@
<h2 id="v148">v1.4.8 &mdash; Plugin Synchronization &amp; Lua 5.0 Refactor</h2>
<ul>
<li><strong>[Plugin Synchronization]</strong> Overhauled the Questie-X plugin loading architecture. Introduced <code>QuestiePluginAPI:FinishLoading()</code> and a registration handshake to resolve race conditions during addon initialization. By ensuring that database plugins report their data-injection status before <code>QuestieInit</code> Stage 3 completes, we eliminated "ghost maps" where pins and statistics would fail to render until a manual <code>/reload</code>.</li>
<li><strong>[Universal Lua 5.0 Refactor]</strong> Executed a codebase-wide transition from <code>pairs()</code> and <code>ipairs()</code> to <code>next()</code> and numeric loops. This refactor targets the Lua 5.0 engine used by legacy clients (e.g., Turtle WoW), which can exhibit inconsistent behavior or performance degradation when using standard iterators in high-frequency database sweeps. This change guarantees stable, universal performance across all WoW versions from 1.12 to 3.3.5+.</li>
<li><strong>[Universal Lua 5.0 Refactor]</strong> Executed a codebase-wide transition from <code>pairs()</code> and <code>ipairs()</code> to <code>next()</code> and numeric loops. This refactor targets the Lua 5.0 engine used by legacy clients, which can exhibit inconsistent behavior or performance degradation when using standard iterators in high-frequency database sweeps. This change guarantees stable, universal performance across supported WoW versions.</li>
<li><strong>[Fix &mdash; MapIconTooltip]</strong> Fixed a critical syntax error in <code>MapIconTooltip.lua</code> at line 239. A malformed <code>if</code> statement was trapping execution, preventing tooltips from updating when hovering over Quest objectives on the World Map.</li>
<li><strong>[Fix &mdash; AscensionDB]</strong> Enhanced realm-specific logic in <code>AscensionLoader.lua</code>. The loader now utilizes an pattern-matching check against <code>GetRealmName()</code> to correctly identify and apply custom database overrides for all Project Ascension realms, including seasonal and specialized rule-set servers.</li>
<li><strong>[Performance]</strong> Refined the background loading throttler in <code>QuestieInit</code>. The initialization sequence now yields more efficiently to the main UI thread during massive database injections, reducing "frame-stutter" during the initial login sequence while strictly maintaining loading priority for essential UI modules.</li>
@@ -594,7 +617,7 @@
<h3>Core &amp; Stability</h3>
<ul>
<li><strong>[Fix]</strong> Resolved <code>attempt to call local 'l10n' (a table value)</code> in <code>GameVersionError.lua</code> and implemented a <code>tocVersion</code> guard to prevent the "unsupported client" error on Classic-era private servers (e.g., Turtle WoW).</li>
<li><strong>[Fix]</strong> Resolved <code>attempt to call local 'l10n' (a table value)</code> in <code>GameVersionError.lua</code> and implemented a <code>tocVersion</code> guard to prevent the "unsupported client" error on Classic-era private servers.</li>
<li><strong>[Fix]</strong> Updated <code>GameVersionError.lua</code> strings to correctly identify Questie-X as supporting Classic and private servers.</li>
<li><strong>[Taint Analysis]</strong> Completed a full audit of <code>Questie-X-WotLKDB</code>. No direct taint vectors or secure function overrides were found.</li>
<li><strong>[Version Sync]</strong> Synchronized versions to v1.3.4 across all components.</li>
@@ -935,7 +958,7 @@
<h3>Core & Compatibility</h3>
<ul>
<li><strong>[Lua 5.0]</strong> Globally polyfilled <code>string.match</code> and <code>string.gmatch</code> using <code>string.find</code> and <code>string.gfind</code> to ensure universal compatibility with legacy WoW clients (e.g., Turtle WoW).</li>
<li><strong>[Lua 5.0]</strong> Globally polyfilled <code>string.match</code> and <code>string.gmatch</code> using <code>string.find</code> and <code>string.gfind</code> to ensure universal compatibility with legacy WoW clients.</li>
<li><strong>[AceTimer]</strong> Patched embedded <code>AceTimer-3.0</code> instances in ElvUI and OG-RaidHelper to resolve <code>math.mod</code> errors on Lua 5.0 clients.</li>
<li><strong>[Colors]</strong> Updated <code>CreateColor</code> polyfill with <code>SetRGB</code>, <code>SetRGBA</code>, <code>SetColor</code>, and <code>GetColor</code> methods.</li>
<li><strong>[Comm]</strong> Improved cross-client data sharing stability.</li>
+31
View File
@@ -218,6 +218,37 @@
</div>
<div class="container">
<section id="active-performance-refactor">
<h2>Active Performance Refactor</h2>
<p>Questie-X is currently being refactored to reduce heavy-zone FPS drops. Until a stable release is published, testers should use the repository version rather than the release asset.</p>
<div class="grid">
<div class="card">
<h4>QuestieLearner Throttling</h4>
<p>Learner-triggered pin refreshes are now debounced with a maximum wait cap. Nearby-player <code>UNIT_DIED</code> events no longer force full learner pin redraws, while authoritative <code>PARTY_KILL</code> events are protected so local/group kills still learn correctly.</p>
</div>
<div class="card">
<h4>Advanced Performance Controls</h4>
<p>The Advanced tab now centralizes live QuestieLearner and QuestieComms tuning, including learner intensity, pin refresh delay, max refresh wait, live NPC update delay, comms disable, queue processing, broadcast pacing, and bulk sync pacing.</p>
</div>
</div>
<div class="grid">
<div class="card">
<h4>Arrow Throttles</h4>
<p>Arrow performance controls reduce repeated nearest-target and coordinate work for low-end systems while preserving the existing arrow behavior.</p>
</div>
<div class="card">
<h4>Branch Integration Status</h4>
<p>The latest learner/comms/arrow controls live on <code>questie-learner-comms-improvements</code>. Broader measured hot-path fixes live on <code>phase3-measured-perf</code>. A final integration branch is still required before stable release testing.</p>
</div>
</div>
<div class="important">
<strong>Testing focus:</strong> Validate heavy kill zones with the minimap open, nearby players killing mobs, looting stutter, QuestieComms disabled/low/normal/fast, and Arrow throttles adjusted live from the options menu.
</div>
</section>
<section id="release-highlights">
<h2>Current Release Highlights</h2>
<p>The v1.6.3 release includes the arrow redesign, the minimap pin drift fix, and the Sunstrider map and tooltip corrections. These notes summarize the shipped behavior in the live release.</p>
+41
View File
@@ -0,0 +1,41 @@
# Questie-X v1.6.3
Questie-X v1.6.3 is the release that lands the arrow redesign, the minimap pin drift fix, the configurable minimap range cutoff, and the Sunstrider / tooltip stability pass.
## Active Performance Refactor Notice
After v1.6.3, the active repository branches contain a major performance refactor that is not yet published as a stable release asset. Users helping test performance should use the repository version until the next stable release is cut.
Current branch work includes:
- QuestieLearner kill/pin refresh debouncing with a maximum wait cap.
- Bystander `UNIT_DIED` suppression so nearby players killing mobs do not force local learner pin redraws.
- A `PARTY_KILL` event-order fix so legitimate local/group kills are not skipped after an earlier `UNIT_DIED`.
- Immediate learner spawn pin rendering so kill evidence can become map pins right away in learner mode.
- Learner-aware DB reads that can fall back to `Questie.dbLearner.global.*` when static data is missing.
- Advanced-tab performance controls for QuestieLearner, QuestieComms, and Arrow throttles.
- A full QuestieComms disable switch with live queue/broadcast/sync throttles.
- Non-fatal missing quest/database messages routed to Questie debug output instead of normal chat spam.
- Available quest draw-thread guarding so missing quest IDs skip safely instead of crashing redraws.
- A measured phase 3 branch with additional localization, quest eligibility, tooltip/map, cache, and allocation hot-path reductions.
Remaining before stable release: integrate the learner/comms branch with the measured phase 3 branch, remove or revalidate the stale reverted `QuestieMap.ProcessQueue` profile-local commit, fix unrelated Arrow asset test expectations, and validate in-game in heavy kill/loot zones with the minimap open, learner-only/static-only switching, and repeated loot/object objective testing.
## Highlights
- Arrow system rebuilt around `Arrow1`-`Arrow4` plus the legacy `arrowold` sheet style.
- Bundled non-sheet arrows now ship as uncompressed 32-bit TGA runtime assets, with preview TGAs reserved for the dropdown swatches.
- The dropdown now detects sprite sheets by texture format and alpha bounds, which keeps sheet-cell logic isolated to `arrowold` and explicitly custom sheet uploads.
- `Arrow2` was rotated 180 degrees so the pointed tip is the top-facing point of the icon instead of the base.
- Arrow dropdown now uses preview swatches and supports detached / attached objective text workflows.
- Minimap pins no longer drift; the fix uses the live view-radius API plus corrected pixel math.
- Minimap icons now have a user-adjustable range cutoff so they can be hidden or shown based on distance in real time.
- Sunstrider Isle coordinates, map pins, and arrow distance handling are normalized for the child map / parent map split.
- Learned tooltip reconstruction now matches the actual QuestieLearner data shape.
- Learner kill evidence now spawns pins immediately in learner mode, instead of waiting for the later evidence merge path.
- The in-game version badge and documentation are synced to `v1.6.3`.
## Notes
- The bundled release archive excludes developer-only docs, handoff notes, and workspace artifacts.
- This release is intended to be the clean published base for the current Questie-X branch.
File diff suppressed because it is too large Load Diff