diff --git a/.luacheckrc b/.luacheckrc index f0508b3..c0932a6 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -143,6 +143,10 @@ files["tests/test_guild_item_level_import.lua"] = { globals = { "WowStub", "SideQuestDB" }, } +files["tests/test_guild_battlenet_fallback.lua"] = { + globals = { "WowStub", "SideQuestDB" }, +} + files["tests/test_minimap.lua"] = { globals = { "WowStub", "SideQuestDB" }, } diff --git a/README.md b/README.md index b76ba82..12c904b 100644 --- a/README.md +++ b/README.md @@ -54,9 +54,10 @@ The full rules and prices are on the [Ledger page](https://sidequest-guild.com/l | News | Guild announcements and recent activity. | Click someone on the Guild tab to see their level, spec, item level, professions, Onyxia attunement, tracked -reputations and cooldowns; item level comes from the site's last sync when they haven't logged in with the addon -running. Hover any item to see which guildmates craft, sell, buy or hold it, how many have it on their wishlist, and -the guild's lowest auction-house price if a sync has priced it. +reputations and cooldowns; whichever of item level, spec, professions and reputations the addon hasn't scanned live +comes from the site's last sync instead, marked "(from Battle.net)" since it can be a few hours stale. Hover any item +to see which guildmates craft, sell, buy or hold it, how many have it on their wishlist, and the guild's lowest +auction-house price if a sync has priced it. Your event sign-ups reach the website the next time an officer syncs. You don't need to do anything else. @@ -69,8 +70,9 @@ SideQuest keeps a record of your character and shares it with the guild automati your level, class, spec and item level, your craft cooldowns, your Onyxia attunement and the few reputations the guild tracks, what you sell and want to buy, and the crafting materials in your bags and bank (never gold or gear). That's how the Crafters and Market tabs work for guildmates who are offline but have run the addon recently; a site sync -fills in the rest -- crafters, item level and wishlist counts -- for guildies who are offline or have never installed -it, and officers' syncs carry it to the website, where only signed-in guild members see it. +fills in the rest -- crafters, item level, spec, professions, tracked reputations and wishlist counts -- for guildies +who are offline or have never installed it, and officers' syncs carry it to the website, where only signed-in guild +members see it. Turn any part off on the You tab, or with `/sq share on|off`. `/sq dir` shows what you share and how many guildmates the addon has heard from. Open each profession window once so the guild knows your diff --git a/SideQuest/UI/Main.lua b/SideQuest/UI/Main.lua index caeddd6..9e2813a 100755 --- a/SideQuest/UI/Main.lua +++ b/SideQuest/UI/Main.lua @@ -1637,22 +1637,60 @@ local function buildGuildDetail(content) return block end ---- A character's equipped item level from the last site import ("Game --- import" contract GameImportCharacter.item_level, additive): nil when --- the import has no entry for them, or when an import predating the --- field is all the addon has. Only consulted when the live directory --- hasn't scanned this character's gear yet. -local function importItemLevel(character) +--- A character's entry from the last site import ("Game import" contract +-- GameImportCharacter, additive fields all nil/empty when the import +-- predates them or has no record for the character). Only consulted +-- when the live directory hasn't supplied that section's data yet; a +-- live scan always wins. +local function importedCharacter(character) for _, member in ipairs((SideQuestDB.guild and SideQuestDB.guild.members) or {}) do for _, char in ipairs(member.characters or {}) do if char.name == character then - return char.item_level + return char end end end return nil end +--- Imported reputations (GameImportReputation), filtered to +-- SideQuest.Data.REPUTATIONS and ordered by that list, or nil when the +-- import has nothing on the tracked list. +local function importedTrackedReputations(imported) + local tracked = SideQuest.Data and SideQuest.Data.REPUTATIONS + if not (tracked and imported and imported.reputations and #imported.reputations > 0) then + return nil + end + local byFaction = {} + for _, rep in ipairs(imported.reputations) do + byFaction[rep.faction_id] = rep + end + local out = {} + for _, def in ipairs(tracked) do + local rep = byFaction[def.faction_id] + if rep then table.insert(out, rep) end + end + if #out == 0 then return nil end + return out +end + +--- A live reputation row: "Label: Standing (value)". +local function liveReputationLine(rep) + local label = reputationLabel(rep.faction_id) + return label .. ": " .. (STANDING_WORD[rep.standing] or "--") .. (rep.value and (" (" .. rep.value .. ")") or "") +end + +--- An imported reputation row: "Label: Standing (value/max)", or just the +-- standing word at Exalted or when the import has no max. +local function importedReputationLine(rep) + local label = reputationLabel(rep.faction_id) + local word = STANDING_WORD[rep.standing] or "--" + if rep.standing == 8 or not rep.max or rep.max == 0 then + return label .. ": " .. word + end + return label .. ": " .. word .. " (" .. (rep.value or 0) .. "/" .. rep.max .. ")" +end + --- Fills `block` with `character`'s directory record and returns the -- height it used, so the caller can offset rows below it. function UI.PaintGuildDetail(block, character) @@ -1661,12 +1699,17 @@ function UI.PaintGuildDetail(block, character) local charData = entry and entry.char and entry.char.d local profData = entry and entry.prof and entry.prof.d local cdData = entry and entry.cd and entry.cd.d + local imported = importedCharacter(character) setNameRow(block.nameRow, character, charData and charData.class) local metaParts = {} if charData and charData.level then table.insert(metaParts, "Level " .. charData.level) end - if charData and charData.spec then table.insert(metaParts, charData.spec) end - local ilvl = (charData and charData.item_level) or importItemLevel(character) + local spec, specFromImport = charData and charData.spec, false + if not spec and imported and imported.spec then + spec, specFromImport = imported.spec, true + end + if spec then table.insert(metaParts, spec .. (specFromImport and " (from Battle.net)" or "")) end + local ilvl = (charData and charData.item_level) or (imported and imported.item_level) if ilvl then table.insert(metaParts, "ilvl " .. string.format("%.1f", ilvl)) end block.metaText:SetText(table.concat(metaParts, " -- ")) local freshness = recordFreshness(entry) @@ -1705,7 +1748,12 @@ function UI.PaintGuildDetail(block, character) y = y + 6 end - listOrEmpty(block.profHeading, profData and profData.professions, block.profRows, "profEmptyText", + local profList, profFromImport = profData and profData.professions, false + if not (profList and #profList > 0) and imported and imported.professions and #imported.professions > 0 then + profList, profFromImport = imported.professions, true + end + block.profHeading.label:SetText(trackedCaps(profFromImport and "Professions (from Battle.net)" or "Professions")) + listOrEmpty(block.profHeading, profList, block.profRows, "profEmptyText", "No professions on record.", function(p) return p.name .. ": " .. (p.skill or 0) .. " / " .. (p.max_skill or "--") end) @@ -1726,11 +1774,16 @@ function UI.PaintGuildDetail(block, character) end y = y + GUILD_DETAIL_LINE + 6 - listOrEmpty(block.repHeading, charData and charData.reputations, block.repRows, "repEmptyText", - "No tracked reputations on record.", function(rep) - local label = reputationLabel(rep.faction_id) - return label .. ": " .. (STANDING_WORD[rep.standing] or "--") .. (rep.value and (" (" .. rep.value .. ")") or "") - end) + local repList, repFromImport = charData and charData.reputations, false + if not (repList and #repList > 0) then + local trackedImported = importedTrackedReputations(imported) + if trackedImported then + repList, repFromImport = trackedImported, true + end + end + block.repHeading.label:SetText(trackedCaps(repFromImport and "Reputations (from Battle.net)" or "Reputations")) + listOrEmpty(block.repHeading, repList, block.repRows, "repEmptyText", + "No tracked reputations on record.", repFromImport and importedReputationLine or liveReputationLine) listOrEmpty(block.cdHeading, cdData and cdData.cooldowns, block.cdRows, "cdEmptyText", "No tracked cooldowns on record.", function(cd) diff --git a/tests/test_guild_battlenet_fallback.lua b/tests/test_guild_battlenet_fallback.lua new file mode 100644 index 0000000..ee2f8fa --- /dev/null +++ b/tests/test_guild_battlenet_fallback.lua @@ -0,0 +1,178 @@ +-- tests/test_guild_battlenet_fallback.lua +-- GameImportCharacter.spec/professions/reputations (SQI1, additive): the Guild +-- tab's member detail falls back to each one, independently, only when the live +-- directory has no data for that section. A live scan always wins per section, +-- imported reputations are filtered to SideQuest.Data.REPUTATIONS (ordered by +-- that list), and an import predating these fields leaves the detail panel +-- working exactly as before (no error, same empty texts). + +return function(h) + local test, assertTrue, assertEq, resetAddon = h.test, h.assertTrue, h.assertEq, h.resetAddon + local TrackedCaps = SideQuest.UI.TrackedCaps + + local function setupPlayer() + WowStub.realm = "Whitemane" + WowStub.SetUnit("player", { name = "Demo1", realm = "Whitemane", class = "PRIEST", guid = "Player-1-GBF0001" }) + end + + local function openGuildDetail(character) + SideQuest.UI.main = nil + SideQuest.UI.Show() + SideQuest.UI.ShowTab("Guild") + local chamber = SideQuest.UI.panels.Guild.chamber + chamber.selectedCharacter = character + SideQuest.UI.RefreshGuild() + return chamber.detailBlock + end + + local function setRoster(character) + SideQuestDB.roster = { taken_at = WowStub.time, members = { + { character = character, online = true, rank = "Member" }, + } } + end + + test("guild detail: spec, professions and reputations come from the import when the live directory has none", function() + resetAddon() + setupPlayer() + WowStub.inCombat = false + setRoster("Imported-Whitemane") + SideQuestDB.guild.members = { + { member_id = 1, name = "Imported", main = "Imported-Whitemane", class = "WARRIOR", rank = "Member", + raid_status = nil, guild_ep = 0, attendance_rate = 0, events_attended = 0, badges = {}, + characters = { { name = "Imported-Whitemane", class = "WARRIOR", level = 60, is_main = true, + spec = "Arms", + professions = { { name = "Tailoring", skill = 300, max_skill = 300 } }, + reputations = { { faction_id = 529, standing = 7, value = 4000, max = 21000 } }, + } } }, + } + + local block = openGuildDetail("Imported-Whitemane") + assertTrue(block.metaText:GetText():find("Arms %(from Battle.net%)") ~= nil, "spec shows, marked as imported") + assertEq(block.profHeading.label:GetText(), TrackedCaps("Professions (from Battle.net)"), "professions heading marked") + assertEq(block.profRows[1]:GetText(), "Tailoring: 300 / 300", "imported profession row formatted like a live one") + assertEq(block.repHeading.label:GetText(), TrackedCaps("Reputations (from Battle.net)"), "reputations heading marked") + assertEq(block.repRows[1]:GetText(), "Argent Dawn: Revered (4000/21000)", "imported reputation row shows value/max") + end) + + test("guild detail: a live section wins over the import, independently per section", function() + resetAddon() + setupPlayer() + WowStub.inCombat = false + local charData = { level = 60 } -- no spec, no reputations live + local profData = { professions = { { name = "Mining", skill = 250, max_skill = 300 } } } + SideQuestDB.directory["Fresher-Whitemane"] = { + char = { v = SideQuest.Directory.Hash(charData), at = WowStub.time, d = charData }, + prof = { v = SideQuest.Directory.Hash(profData), at = WowStub.time, d = profData }, + seen_at = WowStub.time, + } + setRoster("Fresher-Whitemane") + SideQuestDB.guild.members = { + { member_id = 2, name = "Fresher", main = "Fresher-Whitemane", class = "WARRIOR", rank = "Member", + raid_status = nil, guild_ep = 0, attendance_rate = 0, events_attended = 0, badges = {}, + characters = { { name = "Fresher-Whitemane", class = "WARRIOR", level = 60, is_main = true, + spec = "Stale Spec", + professions = { { name = "Stale Prof", skill = 1, max_skill = 1 } }, + reputations = { { faction_id = 529, standing = 5, value = 100, max = 6000 } }, + } } }, + } + + local block = openGuildDetail("Fresher-Whitemane") + -- spec: live has none, so the import's wins + assertTrue(block.metaText:GetText():find("Stale Spec %(from Battle.net%)") ~= nil, "spec falls back to import") + -- professions: live has data, so the import is ignored and unmarked + assertEq(block.profHeading.label:GetText(), TrackedCaps("Professions"), "professions heading unmarked when live wins") + assertEq(block.profRows[1]:GetText(), "Mining: 250 / 300", "live profession wins over import") + -- reputations: live has none, so the import's wins + assertEq(block.repHeading.label:GetText(), TrackedCaps("Reputations (from Battle.net)"), "reputations heading marked") + assertEq(block.repRows[1]:GetText(), "Argent Dawn: Friendly (100/6000)", "imported reputation wins when live has none") + end) + + test("guild detail: imported reputations are filtered to the tracked list and ordered by it", function() + resetAddon() + setupPlayer() + WowStub.inCombat = false + local originalReputations = SideQuest.Data.REPUTATIONS + SideQuest.Data.REPUTATIONS = { + { faction_id = 529, label = "Argent Dawn" }, + { faction_id = 909, label = "Thorium Brotherhood" }, + } + setRoster("Tracked-Whitemane") + SideQuestDB.guild.members = { + { member_id = 4, name = "Tracked", main = "Tracked-Whitemane", class = "WARRIOR", rank = "Member", + raid_status = nil, guild_ep = 0, attendance_rate = 0, events_attended = 0, badges = {}, + characters = { { name = "Tracked-Whitemane", class = "WARRIOR", level = 60, is_main = true, + reputations = { + -- import lists them reversed, plus one faction that isn't tracked at all + { faction_id = 909, standing = 4, value = 1000, max = 6000 }, + { faction_id = 1, standing = 2, value = 0, max = 3000 }, + { faction_id = 529, standing = 6, value = 2000, max = 12000 }, + }, + } } }, + } + + local ok, err = pcall(function() + local block = openGuildDetail("Tracked-Whitemane") + assertEq(#block.repRows, 2, "only the two tracked factions show") + assertEq(block.repRows[1]:GetText(), "Argent Dawn: Honored (2000/12000)", "tracked list order, not import order") + assertEq(block.repRows[2]:GetText(), "Thorium Brotherhood: Neutral (1000/6000)", "second tracked faction follows") + end) + SideQuest.Data.REPUTATIONS = originalReputations + assertTrue(ok, err) + end) + + test("guild detail: imported reputation standing words", function() + resetAddon() + setupPlayer() + WowStub.inCombat = false + setRoster("Standing-Whitemane") + SideQuestDB.guild.members = { + { member_id = 5, name = "Standing", main = "Standing-Whitemane", class = "WARRIOR", rank = "Member", + raid_status = nil, guild_ep = 0, attendance_rate = 0, events_attended = 0, badges = {}, + characters = { { name = "Standing-Whitemane", class = "WARRIOR", level = 60, is_main = true, + reputations = { { faction_id = 529, standing = 8, value = 21000, max = 21000 } }, + } } }, + } + + local block = openGuildDetail("Standing-Whitemane") + assertEq(block.repRows[1]:GetText(), "Argent Dawn: Exalted", "Exalted shows just the standing word") + end) + + test("guild detail: an imported reputation with no max shows just the standing word", function() + resetAddon() + setupPlayer() + WowStub.inCombat = false + setRoster("Nomax-Whitemane") + SideQuestDB.guild.members = { + { member_id = 6, name = "Nomax", main = "Nomax-Whitemane", class = "WARRIOR", rank = "Member", + raid_status = nil, guild_ep = 0, attendance_rate = 0, events_attended = 0, badges = {}, + characters = { { name = "Nomax-Whitemane", class = "WARRIOR", level = 60, is_main = true, + reputations = { { faction_id = 529, standing = 5, value = 100, max = 0 } }, + } } }, + } + + local block = openGuildDetail("Nomax-Whitemane") + assertEq(block.repRows[1]:GetText(), "Argent Dawn: Friendly", "max 0 shows just the standing word") + end) + + test("guild detail: an import from before spec/professions/reputations existed shows the old empty texts, without erroring", function() + resetAddon() + setupPlayer() + WowStub.inCombat = false + setRoster("Old-Whitemane") + SideQuestDB.guild.members = { + -- Old-shaped GameImportCharacter: no spec/professions/reputations keys at all. + { member_id = 7, name = "Old", main = "Old-Whitemane", class = "WARRIOR", rank = "Member", + raid_status = nil, guild_ep = 0, attendance_rate = 0, events_attended = 0, badges = {}, + characters = { { name = "Old-Whitemane", class = "WARRIOR", level = 60, is_main = true } } }, + } + + local ok, block = pcall(openGuildDetail, "Old-Whitemane") + assertTrue(ok, "painting the detail panel for an old-shaped import doesn't error") + assertTrue(block:IsShown(), "the detail block actually painted (sanity check)") + assertTrue(not block.metaText:GetText():find("from Battle.net"), "no spec marker when neither source has one") + assertEq(block.profHeading.label:GetText(), TrackedCaps("Professions"), "professions heading stays unmarked") + assertEq(block.profEmptyText:GetText(), "No professions on record.", "same empty text as before this change") + assertEq(block.repHeading.label:GetText(), TrackedCaps("Reputations"), "reputations heading stays unmarked") + assertEq(block.repEmptyText:GetText(), "No tracked reputations on record.", "same empty text as before this change") + end) +end