diff --git a/apps/desktop/addon/.luacheckrc b/apps/desktop/addon/.luacheckrc new file mode 100644 index 0000000..29c5ecd --- /dev/null +++ b/apps/desktop/addon/.luacheckrc @@ -0,0 +1,10 @@ +std = "lua51" +globals = { + "FraglinkPlayedDB", "C_Timer", "CreateFrame", "RequestTimePlayed", + "GetCurrentRegion", "GetRealmName", "UnitName", "UnitClass", "UnitLevel", + "BNGetInfo", "GetBuildInfo", "WOW_PROJECT_ID", "WOW_PROJECT_CAMELOT", + "WOW_PROJECT_MAINLINE", "IsBetaBuild", "IsPublicTestClient", "IsTestBuild", + "DEFAULT_CHAT_FRAME", "SLASH_FRAGQ1", "SlashCmdList", "NUM_CHAT_WINDOWS", + "time", +} +exclude_files = { "test_addon.lua", "test_flavours.lua" } diff --git a/apps/desktop/addon/FraglinkPlayed/CHANGELOG.md b/apps/desktop/addon/FraglinkPlayed/CHANGELOG.md index b28fd54..2b43e3c 100644 --- a/apps/desktop/addon/FraglinkPlayed/CHANGELOG.md +++ b/apps/desktop/addon/FraglinkPlayed/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 1.1.0 +- One addon for Retail and WoW Forever. It tells them apart when you log in and tags the saved file. +- Retail interface 12.1.5 and 12.1.7. Forever interface 1.60.1. +- A lower /played does not replace the total already saved on this PC. +- The saved file stays put if Forever uploads are not being accepted yet. Upload the same file again later. Retail is unchanged. + ## 1.0.1 - Correct game version tags for 12.1.5 and 12.1.7. diff --git a/apps/desktop/addon/FraglinkPlayed/FraglinkPlayed.lua b/apps/desktop/addon/FraglinkPlayed/FraglinkPlayed.lua index 42b0e4d..028e16b 100644 --- a/apps/desktop/addon/FraglinkPlayed/FraglinkPlayed.lua +++ b/apps/desktop/addon/FraglinkPlayed/FraglinkPlayed.lua @@ -49,13 +49,23 @@ local function unmute() muted = {} end +local function later(seconds, fn) + if C_Timer and C_Timer.After then + C_Timer.After(seconds, fn) + end +end + local function ask() - if pending then return end + if pending or type(RequestTimePlayed) ~= "function" then return end pending = true mute() - RequestTimePlayed() + if not pcall(RequestTimePlayed) then + pending = false + unmute() + return + end -- never leave the chat frames muted if the answer doesn't come - C_Timer.After(15, function() + later(15, function() if pending then pending = false unmute() @@ -70,12 +80,14 @@ local function client_facts() local project = tonumber(WOW_PROJECT_ID) or 0 local camelot = tonumber(WOW_PROJECT_CAMELOT) or 18 local build = "" - if GetBuildInfo then - local _, b = GetBuildInfo() - build = tostring(b or "") + if type(GetBuildInfo) == "function" then + local ok, _, b = pcall(GetBuildInfo) + if ok then build = tostring(b or "") end end local saw = (IsBetaBuild ~= nil) or (IsPublicTestClient ~= nil) or (IsTestBuild ~= nil) - local beta = (IsBetaBuild and IsBetaBuild()) or (IsPublicTestClient and IsPublicTestClient()) or (IsTestBuild and IsTestBuild()) + local beta = (IsBetaBuild and IsBetaBuild()) + or (IsPublicTestClient and IsPublicTestClient()) + or (IsTestBuild and IsTestBuild()) local flavour, confirmed = "unknown", 0 if project == camelot or project == 18 then if beta then @@ -109,9 +121,9 @@ local function save(total) local played = math.floor(total or 0) local at = time() local battletag = "" - if BNGetInfo then - local _, bt = BNGetInfo() - battletag = bt or "" + if type(BNGetInfo) == "function" then + local ok, _, bt = pcall(BNGetInfo) + if ok then battletag = bt or "" end end if name == "" or played <= 0 then return false end if not forever and realm == "" then return false end @@ -130,6 +142,11 @@ local function save(total) }) FraglinkPlayedDB.chars = FraglinkPlayedDB.chars or {} local key = forever and (region .. "/forever/" .. name) or (region .. "/" .. realm .. "/" .. name) + local prev = FraglinkPlayedDB.chars[key] + -- A lower answer must not replace the total already saved on this PC. + if type(prev) == "table" and type(prev.played) == "number" and played < prev.played then + return false + end FraglinkPlayedDB.chars[key] = e FraglinkPlayedDB.version = 2 return true, forever and name or (name .. "-" .. realm) @@ -147,7 +164,8 @@ end SLASH_FRAGQ1 = "/fragq" SlashCmdList.FRAGQ = function() FraglinkPlayedDB.quiet = not FraglinkPlayedDB.quiet - DEFAULT_CHAT_FRAME:AddMessage("|cffd4a017Fraglink|r: login message " .. (FraglinkPlayedDB.quiet and "hidden. Type /fragq to show it again." or "shown.")) + local state = FraglinkPlayedDB.quiet and "hidden. Type /fragq to show it again." or "shown." + DEFAULT_CHAT_FRAME:AddMessage("|cffd4a017Fraglink|r: login message " .. state) end local f = CreateFrame("Frame") @@ -161,16 +179,18 @@ f:SetScript("OnEvent", function(_, event, a1, a2) if pending then pending = false -- re-enable chat after this event has gone past every frame - C_Timer.After(0, unmute) + if C_Timer and C_Timer.After then later(0, unmute) else unmute() end end elseif event == "PLAYER_ENTERING_WORLD" then if a1 or a2 then -- first login or /reload if a1 then announced = false end -- the chat line is once per login, not per /reload - C_Timer.After(5, ask) + later(5, ask) end elseif event == "PLAYER_LEVEL_UP" then - C_Timer.After(2, ask) + later(2, ask) end end) -C_Timer.NewTicker(REFRESH, ask) +if C_Timer and C_Timer.NewTicker then + C_Timer.NewTicker(REFRESH, ask) +end diff --git a/apps/desktop/addon/FraglinkPlayed/FraglinkPlayed.toc b/apps/desktop/addon/FraglinkPlayed/FraglinkPlayed.toc index 1319a7a..d26a1a4 100644 --- a/apps/desktop/addon/FraglinkPlayed/FraglinkPlayed.toc +++ b/apps/desktop/addon/FraglinkPlayed/FraglinkPlayed.toc @@ -1,6 +1,8 @@ +# Retail 12.1.5 (120105), Retail test 12.1.7 (120107), Forever 1.60.1 (16001). +# One toc. Both clients load this file when no flavour toc is beside it. ## Interface: 120105, 120107, 16001 ## Title: Fraglink /played -## Notes: Saves each character's /played total for the Fraglink app. Nothing leaves your PC from the game. +## Notes: Saves each character's /played for Fraglink. Retail and WoW Forever. Nothing leaves your PC from the game. ## Author: Fraglink ## Version: 1.1.0 ## X-Website: https://playfraglink.com diff --git a/apps/desktop/addon/FraglinkPlayed/FraglinkPlayed_Camelot.toc b/apps/desktop/addon/FraglinkPlayed/FraglinkPlayed_Camelot.toc deleted file mode 100644 index 0fcbd5d..0000000 --- a/apps/desktop/addon/FraglinkPlayed/FraglinkPlayed_Camelot.toc +++ /dev/null @@ -1,11 +0,0 @@ -## Interface: 16001 -## Title: Fraglink /played -## Notes: Saves each Forever character's /played total for the Fraglink app. Nothing leaves your PC from the game. -## Author: Fraglink -## Version: 1.1.0 -## X-Website: https://playfraglink.com -## X-Curse-Project-ID: 1766775 -## SavedVariables: FraglinkPlayedDB -## IconTexture: Interface\AddOns\FraglinkPlayed\icon - -FraglinkPlayed.lua diff --git a/apps/desktop/addon/FraglinkPlayed/FraglinkPlayed_Forever.toc b/apps/desktop/addon/FraglinkPlayed/FraglinkPlayed_Forever.toc deleted file mode 100644 index 0fcbd5d..0000000 --- a/apps/desktop/addon/FraglinkPlayed/FraglinkPlayed_Forever.toc +++ /dev/null @@ -1,11 +0,0 @@ -## Interface: 16001 -## Title: Fraglink /played -## Notes: Saves each Forever character's /played total for the Fraglink app. Nothing leaves your PC from the game. -## Author: Fraglink -## Version: 1.1.0 -## X-Website: https://playfraglink.com -## X-Curse-Project-ID: 1766775 -## SavedVariables: FraglinkPlayedDB -## IconTexture: Interface\AddOns\FraglinkPlayed\icon - -FraglinkPlayed.lua diff --git a/apps/desktop/addon/curseforge/changelog-1.1.0.md b/apps/desktop/addon/curseforge/changelog-1.1.0.md new file mode 100644 index 0000000..b380c5a --- /dev/null +++ b/apps/desktop/addon/curseforge/changelog-1.1.0.md @@ -0,0 +1,6 @@ +## 1.1.0 + +- One addon for Retail and WoW Forever. It tells them apart when you log in and tags the saved file. +- Retail interface 12.1.5 and 12.1.7. Forever interface 1.60.1. +- A lower /played does not replace the total already saved on this PC. +- The saved file stays put if Forever uploads are not being accepted yet. Upload the same file again later. Retail is unchanged. diff --git a/apps/desktop/addon/curseforge/description.md b/apps/desktop/addon/curseforge/description.md new file mode 100644 index 0000000..9adef30 --- /dev/null +++ b/apps/desktop/addon/curseforge/description.md @@ -0,0 +1,19 @@ +Fraglink /played saves each character's /played time on your PC. You upload that file at [playfraglink.com](https://playfraglink.com). The addon does not send anything while you are in game. + +It is one addon for Retail and WoW Forever. When you log in, it asks the game for /played and marks the saved file Retail or Forever. + +An upload only adds time. A file with a lower total is refused, and the hours already stored are left as they are. Retail and Forever stay separate. + +## Install + +1. Download the zip. +2. Unzip it into `World of Warcraft/_retail_/Interface/AddOns` and `World of Warcraft/_forever_/Interface/AddOns`. Each should contain a `FraglinkPlayed` folder. +3. Restart the game, or type `/reload`. Enable Fraglink /played on the addon list if it is off. + +## Use + +1. Log in to each character once. Type `/reload` or log out so the game writes the file. +2. On [playfraglink.com](https://playfraglink.com), open the WoW page, pick Retail or Forever, and upload `WTF/Account//SavedVariables/FraglinkPlayed.lua`. +3. Link Battle.net so a Retail character can be verified. Forever characters stay unverified until that check is turned on. The file on your PC is kept either way, so you can upload it again later. + +A line in chat says when a character was recorded. `/fragq` hides that line. diff --git a/apps/desktop/addon/curseforge/game-versions.md b/apps/desktop/addon/curseforge/game-versions.md new file mode 100644 index 0000000..8dbc374 --- /dev/null +++ b/apps/desktop/addon/curseforge/game-versions.md @@ -0,0 +1,10 @@ +Select these on the CurseForge file. Do not publish from this note. + +- Game: World of Warcraft +- Retail: 12.1.5 (interface 120105) and 12.1.7 (interface 120107) +- Forever: 1.60.1 (interface 16001) +- Flavours: Retail and Forever + +Do not select Classic Era, Burning Crusade, Wrath, Cataclysm, or Mists. + +The numbers are from the Warcraft Wiki TOC format page (Standard 120105, Standard test 120107, Forever 16001) and the Forever 1.60.1 launch note on the same page. One zip lists all three interface numbers. CurseForge still needs both flavours ticked on the file, because the zip does not contain a separate Forever toc. diff --git a/apps/desktop/addon/curseforge/summary.txt b/apps/desktop/addon/curseforge/summary.txt new file mode 100644 index 0000000..1ccc0ce --- /dev/null +++ b/apps/desktop/addon/curseforge/summary.txt @@ -0,0 +1 @@ +Saves Retail and WoW Forever /played on your PC for Fraglink. Uploads only add time and never lower your hours. diff --git a/apps/desktop/addon/pack.sh b/apps/desktop/addon/pack.sh new file mode 100755 index 0000000..542551d --- /dev/null +++ b/apps/desktop/addon/pack.sh @@ -0,0 +1,41 @@ +#!/bin/sh +# CurseForge zip: the addon folder at the root, one shared toc, no flavour copies. +set -eu +cd "$(dirname "$0")" +ver=$(sed -n 's/^## Version: //p' FraglinkPlayed/FraglinkPlayed.toc) +test "$ver" = "1.1.0" +if ls FraglinkPlayed/*_*.toc >/dev/null 2>&1; then + echo "flavour toc files must not ship" >&2 + exit 1 +fi +stage=$(mktemp -d) +trap 'rm -rf "$stage"' EXIT +mkdir -p "$stage/FraglinkPlayed" +cp FraglinkPlayed/FraglinkPlayed.lua FraglinkPlayed/FraglinkPlayed.toc \ + FraglinkPlayed/CHANGELOG.md FraglinkPlayed/icon.tga "$stage/FraglinkPlayed/" +out="$PWD/FraglinkPlayed-$ver.zip" +rm -f "$out" +(cd "$stage" && zip -r -X "$out" FraglinkPlayed >/dev/null) +python3 - "$out" <<'PY' +import sys, zipfile +z = zipfile.ZipFile(sys.argv[1]) +names = set(z.namelist()) +need = { + "FraglinkPlayed/FraglinkPlayed.lua", + "FraglinkPlayed/FraglinkPlayed.toc", + "FraglinkPlayed/CHANGELOG.md", + "FraglinkPlayed/icon.tga", +} +missing = need - names +extra = [n for n in names if n.endswith(".toc") and n != "FraglinkPlayed/FraglinkPlayed.toc"] +if missing or extra: + raise SystemExit(f"bad zip missing={missing} extra_tocs={extra} names={sorted(names)}") +toc = z.read("FraglinkPlayed/FraglinkPlayed.toc").decode() +for needle in ("## Version: 1.1.0", "## Interface: 120105, 120107, 16001"): + if needle not in toc: + raise SystemExit("toc missing " + needle) +print("zip ok", sys.argv[1]) +PY +if [ -d /opt/cursor/artifacts ]; then + cp "$out" /opt/cursor/artifacts/ +fi diff --git a/apps/desktop/addon/test_flavours.lua b/apps/desktop/addon/test_flavours.lua new file mode 100644 index 0000000..6d0c36d --- /dev/null +++ b/apps/desktop/addon/test_flavours.lua @@ -0,0 +1,110 @@ +-- Mock both clients and check the one addon tags Retail and Forever correctly. +-- lua5.1 apps/desktop/addon/test_flavours.lua +local timers, handlers = {}, nil +local chat = { registered = true } +function chat:IsEventRegistered() return self.registered end +function chat:UnregisterEvent() self.registered = false end +function chat:RegisterEvent() self.registered = true end +ChatFrame1 = chat +NUM_CHAT_WINDOWS = 1 +DEFAULT_CHAT_FRAME = { AddMessage = function() end } +SlashCmdList = {} +C_Timer = { + After = function(_, fn) timers[#timers + 1] = fn end, + NewTicker = function() end, +} +function CreateFrame() + return { RegisterEvent = function() end, SetScript = function(_, _, fn) handlers = fn end } +end +function GetCurrentRegion() return 3 end +function GetRealmName() return "Area 52" end +function UnitClass() return "Mage", "MAGE" end +function UnitLevel() return 80 end +function BNGetInfo() return 1, "Sample#1234" end +function time() return 1793000000 end +function GetBuildInfo() return "12.1.5", "64000", "Oct 13 2026", 120105 end +WOW_PROJECT_MAINLINE = 1 +WOW_PROJECT_CAMELOT = 18 +WOW_PROJECT_ID = 1 +function IsBetaBuild() return false end +function IsPublicTestClient() return false end +function IsTestBuild() return false end +local asked = 0 +function RequestTimePlayed() asked = asked + 1 end +local function run_timers() + local t = timers; timers = {} + for _, fn in ipairs(t) do fn() end +end +local function fail(msg) + io.stderr:write(msg .. "\n") + os.exit(1) +end +local function eq(got, want, msg) + if got ~= want then fail(msg .. ": got " .. tostring(got) .. " want " .. tostring(want)) end +end + +dofile(arg and arg[0]:gsub("test_flavours%.lua$", "") .. "FraglinkPlayed/FraglinkPlayed.lua") + +local function play(name, total, project) + UnitName = function() return name end + WOW_PROJECT_ID = project + timers = {} + handlers(nil, "PLAYER_ENTERING_WORLD", true, false) + run_timers() + handlers(nil, "TIME_PLAYED_MSG", total, 10) + run_timers() +end + +play("Mockrend", 7200, 1) +local retail = FraglinkPlayedDB.chars["eu/Area 52/Mockrend"] +if not retail then fail("retail key missing") end +eq(retail.flavour, "retail", "retail flavour") +eq(retail.project, 1, "retail project") +eq(retail.realm, "Area 52", "retail realm") +eq(retail.confirmed, 1, "retail confirmed") +eq(retail.first, "", "retail has no forever first name") +eq(asked > 0, true, "retail asked for /played") + +GetBuildInfo = function() return "1.60.1", "70790", "Nov 4 2026", 16001 end +play("Vale Mocken", 3600, 18) +local forever = FraglinkPlayedDB.chars["eu/forever/Vale Mocken"] +if not forever then fail("forever key missing") end +eq(forever.flavour, "forever", "forever flavour") +eq(forever.project, 18, "forever project") +eq(forever.realm, "", "forever realm blank") +eq(forever.first, "Vale", "forever first") +eq(forever.surname, "Mocken", "forever surname") +eq(forever.beta, 0, "forever not beta") +eq(forever.confirmed, 1, "forever confirmed") +eq(retail.played, 7200, "retail row left alone") + +IsBetaBuild = function() return true end +play("Beta Kin", 1000, 18) +eq(FraglinkPlayedDB.chars["eu/forever/Beta Kin"].flavour, "forever_beta", "beta flavour") +eq(FraglinkPlayedDB.chars["eu/forever/Beta Kin"].beta, 1, "beta flag") + +IsBetaBuild, IsPublicTestClient, IsTestBuild = nil, nil, nil +play("Quiet Kin", 1000, 18) +eq(FraglinkPlayedDB.chars["eu/forever/Quiet Kin"].flavour, "forever_unconfirmed", "unconfirmed flavour") +eq(FraglinkPlayedDB.chars["eu/forever/Quiet Kin"].confirmed, 0, "unconfirmed flag") + +IsBetaBuild = function() return false end +IsPublicTestClient = function() return false end +IsTestBuild = function() return false end +play("Vale Mocken", 5000, 18) +eq(FraglinkPlayedDB.chars["eu/forever/Vale Mocken"].played, 5000, "higher total kept") +play("Vale Mocken", 4000, 18) +eq(FraglinkPlayedDB.chars["eu/forever/Vale Mocken"].played, 5000, "lower total ignored") + +local before = FraglinkPlayedDB.chars["eu/Area 52/Mockrend"].played +RequestTimePlayed = nil +C_Timer = nil +local ok = pcall(function() + handlers(nil, "PLAYER_ENTERING_WORLD", true, false) + handlers(nil, "PLAYER_LEVEL_UP") + handlers(nil, "TIME_PLAYED_MSG", 100, 1) +end) +eq(ok, true, "missing timer and request does not error") +eq(FraglinkPlayedDB.chars["eu/Area 52/Mockrend"].played, before, "retail hours unchanged") + +io.write("flavours ok\n") diff --git a/apps/server/migrations/0058_wow_default_flavour.sql b/apps/server/migrations/0058_wow_default_flavour.sql new file mode 100644 index 0000000..cddb61f --- /dev/null +++ b/apps/server/migrations/0058_wow_default_flavour.sql @@ -0,0 +1,15 @@ +-- Profile WoW display. default_flavour null means Retail opens first. +-- profile_show null means Both. Existing preference rows stay null. +-- Character rows, snapshots and hours are not touched. + +ALTER TABLE wow_prefs ADD COLUMN IF NOT EXISTS default_flavour text; +-- Null means Both: Retail and Forever, with the toggle. Existing rows stay null. +ALTER TABLE wow_prefs ADD COLUMN IF NOT EXISTS profile_show text; + +ALTER TABLE wow_prefs DROP CONSTRAINT IF EXISTS wow_prefs_default_flavour_check; +ALTER TABLE wow_prefs ADD CONSTRAINT wow_prefs_default_flavour_check + CHECK (default_flavour IS NULL OR default_flavour IN ('wow', 'wow_forever')); + +ALTER TABLE wow_prefs DROP CONSTRAINT IF EXISTS wow_prefs_profile_show_check; +ALTER TABLE wow_prefs ADD CONSTRAINT wow_prefs_profile_show_check + CHECK (profile_show IS NULL OR profile_show IN ('wow', 'wow_forever', 'both')); diff --git a/apps/server/src/profile.rs b/apps/server/src/profile.rs index e323fe2..950ac21 100644 --- a/apps/server/src/profile.rs +++ b/apps/server/src/profile.rs @@ -490,12 +490,16 @@ async fn profile( } else { Value::Null }; - // WoW characters from the addon: only Battle.net-verified ones are shown and counted + // WoW characters from the addon. Retail cards are Battle.net-verified. Forever + // cards are included without a tick until that account list is on. A profile + // set to one flavour omits the other from this response; the rows stay stored. + let wow_show = crate::wowpage::profile_show(&st, p.id).await?; let wow_characters = if can_see_games && show("wow_characters") && crate::features::on(&st, "wow_characters").await { - let mut c = crate::wow::characters(&st, p.id, is_self, true).await?; + // Hidden rows stay stored. The profile never lists them, including for the owner. + let mut c = crate::wow::characters(&st, p.id, false, true).await?; crate::wowchar::warm_guilds( &st, c.iter() @@ -508,6 +512,16 @@ async fn profile( .as_str() .is_some_and(|g| hidden.iter().any(|h| h == g)) }); + if wow_show != "both" { + c.retain(|x| x["game_id"].as_str() == Some(wow_show.as_str())); + } + // Hours stay on the manager. The public cards do not receive them. + for row in &mut c { + if let Some(o) = row.as_object_mut() { + o.remove("played_min"); + o.remove("played_at"); + } + } json!(c) } else { Value::Null @@ -621,6 +635,8 @@ async fn profile( "events": events, "achievements": achievements, "wow_characters": wow_characters, + "wow_default": crate::wowpage::default_flavour(&st, p.id).await?, + "wow_show": wow_show, "rig": if has_rig && show("rig") { rig } else { Value::Null }, "friends": friend_grid, "linked": linked, diff --git a/apps/server/src/seo.rs b/apps/server/src/seo.rs index 4d786a4..c4bcdc7 100644 --- a/apps/server/src/seo.rs +++ b/apps/server/src/seo.rs @@ -206,10 +206,14 @@ pub fn profile_head(base: &str, seo: &ProfileSeo) -> String { /// robots header and head tags. The page itself is still drawn by the site's script. async fn profile_page(State(st): State, Path(rest): Path) -> Response { let name = rest.split('/').next().unwrap_or(""); - let seo = match profile_seo(&st, name).await { + let mut seo = match profile_seo(&st, name).await { Ok(s) => s, Err(e) => return e.into_response(), }; + // /u//wow is the character gallery, not the profile search engines index. + if rest.contains('/') && !matches!(seo, ProfileSeo::Missing) { + seo = ProfileSeo::NoIndex; + } let page = match std::fs::read_to_string(st.cfg.site_dir.join("u.html")) { Ok(p) => p, Err(_) => return StatusCode::SERVICE_UNAVAILABLE.into_response(), diff --git a/apps/server/src/wow.rs b/apps/server/src/wow.rs index 3940c5b..76c4cdc 100644 --- a/apps/server/src/wow.rs +++ b/apps/server/src/wow.rs @@ -62,6 +62,7 @@ pub fn routes() -> Router { .route("/api/v1/hours/wow_played", post(upload)) .route("/api/v1/hours/wow_played/file", post(upload_file)) .route("/api/v1/me/wow", get(mine)) + .route("/api/v1/me/wow/armory", post(add_armory)) .route("/api/v1/me/wow/{id}", post(set_hidden)) .route("/api/v1/wow/verify/fetch", post(verify_fetch)) .route("/api/v1/wow/verify/match", post(verify_match)) @@ -153,6 +154,262 @@ async fn verify_signatures(State(st): State, a: AuthUser) -> ApiResult "refused": refused.into_iter().map(|(c, k)| json!({ "character": c, "reason": k })).collect::>() }))) } +#[derive(Deserialize)] +struct ArmoryAdd { + region: String, + #[serde(default)] + realm: String, + name: String, + /// `wow` (Armory) or `wow_forever` (first name and surname, no lookup). + #[serde(default)] + game: String, +} + +fn clean_label(s: &str, max: usize) -> String { + s.trim() + .chars() + .filter(|c| !c.is_control()) + .take(max) + .collect::() + .trim() + .to_string() +} + +fn class_token(v: &Value) -> String { + name_of(&v["playable_class"]) + .or_else(|| name_of(&v["character_class"])) + .unwrap_or_default() + .chars() + .filter(|c| c.is_ascii_alphabetic()) + .collect::() + .to_uppercase() +} + +/// Save a Retail character from an Armory profile. `/played` is not touched. +/// The existing account-list check sets the tick when the name and realm are on +/// the linked Battle.net account. A lookup by itself does not. +pub async fn add_armory_character( + st: &AppState, + uid: Uuid, + region: &str, + realm_input: &str, + name_input: &str, + profile: &Value, + game: &str, +) -> ApiResult { + let region = region.trim().to_lowercase(); + if !matches!(region.as_str(), "us" | "eu" | "kr" | "tw") { + return Err(AppError::bad_request( + "invalid_region", + "Choose US, EU, KR, or TW.", + )); + } + let forever = game == "wow_forever"; + let game_id = if forever { "wow_forever" } else { "wow" }; + let name = clean_label(profile["name"].as_str().unwrap_or(name_input), 64); + let (realm, slug) = if forever { + (String::new(), String::new()) + } else { + let realm = clean_label( + name_of(&profile["realm"]).as_deref().unwrap_or(realm_input), + 64, + ); + let slug = realm_slug(&realm); + if realm.chars().count() < 2 || slug.is_empty() { + return Err(AppError::bad_request( + "invalid_character", + "Enter the character's realm and name.", + )); + } + (realm, slug) + }; + if name.chars().count() < 2 { + return Err(AppError::bad_request( + "invalid_character", + "Enter the character's name.", + )); + } + let class = class_token(profile); + let level = profile["level"].as_i64().unwrap_or(0).clamp(0, 200) as i32; + let race = name_of(&profile["race"]); + let gender = profile["gender"]["type"].as_str().map(|g| g.to_lowercase()); + let spec = name_of(&profile["active_spec"]); + let bnet_id = profile["id"].as_i64(); + let tag: String = sqlx::query_scalar( + "SELECT display FROM linked_accounts + WHERE user_id = $1 AND provider = 'bnet' AND verified LIMIT 1", + ) + .bind(uid) + .fetch_optional(&st.db) + .await? + .unwrap_or_default(); + let existing: Option = sqlx::query_scalar( + "SELECT id FROM wow_characters + WHERE user_id = $1 AND game_id = $5 AND region = $2 + AND lower(realm) = lower($3) AND lower(name) = lower($4)", + ) + .bind(uid) + .bind(®ion) + .bind(&realm) + .bind(&name) + .bind(game_id) + .fetch_optional(&st.db) + .await?; + let id = if let Some(id) = existing { + sqlx::query( + "UPDATE wow_characters SET + class = CASE WHEN $2 <> '' THEN $2 ELSE class END, + level = GREATEST(level, $3), + race = coalesce(race, $4), + gender = coalesce(gender, $5), + bnet_spec = coalesce(bnet_spec, $6), + bnet_id = coalesce(bnet_id, $7), + battletag = CASE WHEN battletag = '' THEN $8 ELSE battletag END + WHERE id = $1 AND user_id = $9", + ) + .bind(id) + .bind(&class) + .bind(level) + .bind(&race) + .bind(&gender) + .bind(&spec) + .bind(bnet_id) + .bind(tag.chars().take(64).collect::()) + .bind(uid) + .execute(&st.db) + .await?; + id + } else { + let id = Uuid::new_v4(); + // played_at is early so a later addon snapshot is the newer /played, not a stale one. + let played_at = Utc.timestamp_opt(1_000_000_000, 0).single().unwrap(); + sqlx::query( + "INSERT INTO wow_characters(id, user_id, game_id, region, realm, realm_slug, name, class, + level, played_s, played_at, battletag, race, gender, bnet_spec, + bnet_id, verified) + VALUES($1, $2, $15, $3, $4, $5, $6, $7, $8, 0, $9, $10, $11, $12, $13, $14, false)", + ) + .bind(id) + .bind(uid) + .bind(®ion) + .bind(&realm) + .bind(&slug) + .bind(&name) + .bind(&class) + .bind(level) + .bind(played_at) + .bind(tag.chars().take(64).collect::()) + .bind(&race) + .bind(&gender) + .bind(&spec) + .bind(bnet_id) + .bind(game_id) + .execute(&st.db) + .await?; + id + }; + // Same check as a Battle.net refresh. Does not store a new hours total for this user. + set_verified(st, uid, None).await; + let verified: bool = + sqlx::query_scalar("SELECT verified FROM wow_characters WHERE id = $1 AND user_id = $2") + .bind(id) + .bind(uid) + .fetch_one(&st.db) + .await?; + Ok(json!({ + "ok": true, + "id": id, + "name": name, + "realm": realm, + "region": region, + "verified": verified, + "created": existing.is_none(), + })) +} + +async fn fetch_armory_profile(region: &str, realm: &str, name: &str) -> ApiResult { + let Some(tok) = app_token().await else { + return Err(AppError::new( + StatusCode::SERVICE_UNAVAILABLE, + "armory_unavailable", + "Armory lookup isn't available right now.", + )); + }; + let Some(http) = http() else { + return Err(AppError::new( + StatusCode::SERVICE_UNAVAILABLE, + "armory_unavailable", + "Armory lookup isn't available right now.", + )); + }; + let url = format!( + "https://{}.api.blizzard.com/profile/wow/character/{}/{}?namespace=profile-{}&locale=en_US", + region.trim().to_lowercase(), + realm_slug(realm), + name.trim().to_lowercase(), + region.trim().to_lowercase(), + ); + let resp = http.get(&url).bearer_auth(tok).send().await.map_err(|_| { + AppError::new( + StatusCode::BAD_GATEWAY, + "armory_unavailable", + "The Armory didn't answer. Try again in a moment.", + ) + })?; + if resp.status().as_u16() == 404 { + return Err(AppError::new( + StatusCode::NOT_FOUND, + "not_found", + "No character with that name on that realm.", + )); + } + if !resp.status().is_success() { + return Err(AppError::new( + StatusCode::BAD_GATEWAY, + "armory_unavailable", + "The Armory didn't answer. Try again in a moment.", + )); + } + resp.json::().await.map_err(|_| { + AppError::new( + StatusCode::BAD_GATEWAY, + "armory_unavailable", + "The Armory didn't answer. Try again in a moment.", + ) + }) +} + +async fn add_armory( + State(st): State, + headers: HeaderMap, + a: AuthUser, + Json(r): Json, +) -> ApiResult> { + api::check_origin(&a, &headers, &st)?; + if r.game == "wow_forever" { + let profile = json!({ "name": r.name }); + return Ok(Json( + add_armory_character( + &st, + a.user.id, + &r.region, + "", + &r.name, + &profile, + "wow_forever", + ) + .await?, + )); + } + let profile = fetch_armory_profile(&r.region, &r.realm, &r.name).await?; + Ok(Json( + add_armory_character( + &st, a.user.id, &r.region, &r.realm, &r.name, &profile, "wow", + ) + .await?, + )) +} + /// Blizzard's realm slug: "Area 52" -> "area-52", "Kel'Thas" -> "kelthas". pub fn realm_slug(realm: &str) -> String { let mut out = String::new(); @@ -1800,8 +2057,9 @@ pub(crate) fn name_of(v: &Value) -> Option { // ------------------------------------------------------------------ profile /// Characters for a profile page. `all`: the owner's own view (hidden ones too). -/// Profile cards list only Battle.net-verified characters (`verified_only`); the owner's -/// /connect/wow page lists all of them so unverified ones can be verified. +/// Profile cards list Battle.net-verified Retail characters (`verified_only`). Forever +/// characters are included too, without a tick, until `BNET_FOREVER_ACCOUNT` verifies +/// them. The owner's /connect/wow page lists every row so unverified ones can be verified. pub async fn characters( st: &AppState, uid: Uuid, @@ -1837,7 +2095,7 @@ pub async fn characters( featured FROM wow_characters WHERE user_id = $1 AND ($2 OR NOT (hidden OR coalesce((SELECT hide_all FROM wow_prefs WHERE user_id = $1), false))) - AND (NOT $3 OR verified) + AND (NOT $3 OR verified OR game_id = 'wow_forever') ORDER BY game_id, featured NULLS LAST, played_s DESC LIMIT 200"), ) .bind(uid) diff --git a/apps/server/src/wowpage.rs b/apps/server/src/wowpage.rs index 37c9c59..ea52c0b 100644 --- a/apps/server/src/wowpage.rs +++ b/apps/server/src/wowpage.rs @@ -25,6 +25,9 @@ pub fn routes() -> Router { .route("/api/v1/me/bnet", get(summary)) .route("/api/v1/me/wow/list", get(list)) .route("/api/v1/me/wow/hide_all", post(hide_all)) + .route("/api/v1/me/wow/default_flavour", post(set_default_flavour)) + .route("/api/v1/me/wow/profile_show", post(set_profile_show)) + .route("/api/v1/me/wow/review", get(review)) .route("/api/v1/me/wow/featured", get(featured).post(set_featured)) .route("/api/v1/me/wow/{id}/spec", post(set_spec)) .route("/api/v1/wow/icons/{file}", get(icon)) @@ -73,9 +76,112 @@ async fn summary(State(st): State, a: AuthUser) -> ApiResult ApiResult { + let stored: Option = sqlx::query_scalar( + "SELECT coalesce(profile_show, 'both') FROM wow_prefs WHERE user_id = $1", + ) + .bind(uid) + .fetch_optional(&st.db) + .await?; + Ok(match stored.as_deref() { + Some("wow") => "wow".into(), + Some("wow_forever") => "wow_forever".into(), + _ => "both".into(), + }) +} + +#[derive(Deserialize)] +struct ShowReq { + show: String, +} + +async fn set_profile_show( + State(st): State, + headers: HeaderMap, + a: AuthUser, + Json(r): Json, +) -> ApiResult> { + api::check_origin(&a, &headers, &st)?; + if r.show != "wow" && r.show != "wow_forever" && r.show != "both" { + return Err(AppError::bad_request( + "invalid_show", + "Choose Retail only, Forever only, or Both.", + )); + } + sqlx::query( + "INSERT INTO wow_prefs(user_id, profile_show) VALUES($1, $2) + ON CONFLICT (user_id) DO UPDATE SET profile_show = excluded.profile_show", + ) + .bind(a.user.id) + .bind(&r.show) + .execute(&st.db) + .await?; + Ok(Json(json!({ "ok": true, "profile_show": r.show }))) +} + +pub(crate) async fn default_flavour(st: &AppState, uid: Uuid) -> ApiResult { + let stored: Option = sqlx::query_scalar( + "SELECT coalesce(default_flavour, 'wow') FROM wow_prefs WHERE user_id = $1", + ) + .bind(uid) + .fetch_optional(&st.db) + .await?; + Ok(match stored.as_deref() { + Some("wow_forever") => "wow_forever".into(), + _ => "wow".into(), + }) +} + +#[derive(Deserialize)] +struct FlavourReq { + flavour: String, +} + +async fn set_default_flavour( + State(st): State, + headers: HeaderMap, + a: AuthUser, + Json(r): Json, +) -> ApiResult> { + api::check_origin(&a, &headers, &st)?; + if r.flavour != "wow" && r.flavour != "wow_forever" { + return Err(AppError::bad_request( + "invalid_flavour", + "Choose Retail or Forever.", + )); + } + sqlx::query( + "INSERT INTO wow_prefs(user_id, default_flavour) VALUES($1, $2) + ON CONFLICT (user_id) DO UPDATE SET default_flavour = excluded.default_flavour", + ) + .bind(a.user.id) + .bind(&r.flavour) + .execute(&st.db) + .await?; + Ok(Json(json!({ "ok": true, "default_flavour": r.flavour }))) +} + +async fn review(State(st): State, a: AuthUser) -> ApiResult> { + let rows: Vec<(String, String, DateTime)> = sqlx::query_as( + "SELECT reason, detail, created_at FROM wow_played_quarantine + WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20", + ) + .bind(a.user.id) + .fetch_all(&st.db) + .await?; + let items: Vec = rows + .into_iter() + .map(|(reason, detail, at)| json!({ "reason": reason, "detail": detail, "at": at })) + .collect(); + Ok(Json(json!({ "items": items }))) +} + #[derive(Deserialize)] struct ListQ { q: Option, @@ -84,6 +190,8 @@ struct ListQ { vis: Option, offset: Option, limit: Option, + /// `wow` or `wow_forever`. Empty lists both. + game: Option, } /// One page of the user's verified characters, plus the filtered count and totals. @@ -122,19 +230,43 @@ async fn list( }; let limit = p.limit.unwrap_or(25).clamp(1, 100); let offset = p.offset.unwrap_or(0).clamp(0, 1_000_000); - // `$4` hide_all: every character counts as hidden + let game = match p.game.as_deref() { + Some("wow" | "wow_forever") => p.game.clone().unwrap_or_default(), + _ => String::new(), + }; + // `$4` hide_all: every character counts as hidden. `$9` limits one version. let filter = "user_id = $1 AND (verified OR game_id = 'wow_forever') AND ($2 = '' OR lower(name) LIKE $3 OR lower(realm) LIKE $3) AND ($5 = '' OR upper(class) = upper($5)) - AND ($6 = 0 OR ($6 = 1 AND NOT (hidden OR $4)) OR ($6 = 2 AND (hidden OR $4)))"; - #[allow(clippy::type_complexity)] - let rows: Vec<(Uuid, String, String, String, String, String, i32, i64, bool, Option, Option, Option, Option, bool, bool, Option)> = - sqlx::query_as(sqlx::AssertSqlSafe(format!( + AND ($6 = 0 OR ($6 = 1 AND NOT (hidden OR $4)) OR ($6 = 2 AND (hidden OR $4))) + AND ($9 = '' OR game_id = $9)"; + #[derive(sqlx::FromRow)] + struct Listed { + id: Uuid, + game_id: String, + region: String, + realm: String, + name: String, + class: String, + level: i32, + played_s: i64, + hidden: bool, + race: Option, + gender: Option, + spec: Option, + bnet_spec: Option, + show_guild: bool, + verified: bool, + former: Option, + uploaded_at: DateTime, + } + let rows: Vec = sqlx::query_as(sqlx::AssertSqlSafe(format!( "SELECT id, game_id, region, realm, name, class, {lvl} AS level, played_s, hidden, race, gender, spec, bnet_spec, show_guild, verified, (SELECT n.name || E'\\x1f' || n.realm FROM wow_character_names n WHERE n.char_id = wow_characters.id AND (lower(n.name) <> lower(wow_characters.name) OR n.realm <> wow_characters.realm) - ORDER BY n.recorded_at DESC, n.id DESC LIMIT 1) + ORDER BY n.recorded_at DESC, n.id DESC LIMIT 1) AS former, + uploaded_at FROM wow_characters WHERE {filter} ORDER BY {order} LIMIT $7 OFFSET $8", lvl = crate::wow::shown_level!() ))) @@ -143,11 +275,12 @@ async fn list( .bind(&like) .bind(ha) .bind(&class) - .bind(vis) - .bind(limit) - .bind(offset) - .fetch_all(&st.db) - .await?; + .bind(vis) + .bind(limit) + .bind(offset) + .bind(&game) + .fetch_all(&st.db) + .await?; let (matched,): (i64,) = sqlx::query_as(sqlx::AssertSqlSafe(format!( "SELECT count(*) FROM wow_characters WHERE {filter}" ))) @@ -157,6 +290,9 @@ async fn list( .bind(ha) .bind(&class) .bind(vis) + .bind(0i64) + .bind(0i64) + .bind(&game) .fetch_one(&st.db) .await?; let (total, total_s, classes): (i64, i64, Vec) = sqlx::query_as( @@ -169,54 +305,64 @@ async fn list( .await?; let items: Vec = rows .into_iter() - .map( - |( + .map(|c| { + let Listed { id, - g, - r, + game_id: g, + region: r, realm, name, class, level, - s, - h, + played_s: s, + hidden: h, race, gender, spec, - bspec, - sg, + bnet_spec: bspec, + show_guild: sg, verified, former, - )| { - let (former_name, former_realm) = former - .as_deref() - .map(|s| { - let (n, r) = s.split_once('\u{1f}').unwrap_or((s, "")); - ( - if n.eq_ignore_ascii_case(&name) { - None - } else { - Some(n.to_string()) - }, - if r == realm { - None - } else { - Some(r.to_string()) - }, - ) - }) - .unwrap_or((None, None)); - json!({ "id": id, "game_id": g, "region": r, "realm": realm, "name": name, + uploaded_at, + } = c; + let (former_name, former_realm) = former + .as_deref() + .map(|s| { + let (n, r) = s.split_once('\u{1f}').unwrap_or((s, "")); + ( + if n.eq_ignore_ascii_case(&name) { + None + } else { + Some(n.to_string()) + }, + if r == realm { + None + } else { + Some(r.to_string()) + }, + ) + }) + .unwrap_or((None, None)); + json!({ "id": id, "game_id": g, "region": r, "realm": realm, "name": name, "class": class, "level": level, "played_min": s / 60, "hidden": h || ha, "own_hidden": h, "race": race, "gender": gender, "spec": spec.clone().or(bspec.clone()), "spec_picked": spec.is_some(), "active_spec": bspec, "show_guild": sg, "verified": verified, - "former_name": former_name, "former_realm": former_realm }) - }, - ) + "former_name": former_name, "former_realm": former_realm, + "uploaded_at": uploaded_at }) + }) .collect(); + let (retail, forever): (i64, i64) = sqlx::query_as( + "SELECT count(*) FILTER (WHERE game_id = 'wow'), + count(*) FILTER (WHERE game_id = 'wow_forever') + FROM wow_characters WHERE user_id = $1 AND (verified OR game_id = 'wow_forever')", + ) + .bind(uid) + .fetch_one(&st.db) + .await?; Ok(Json(json!({ "items": items, "matched": matched, "total": total, + "retail": retail, "forever": forever, "total_min": total_s / 60, "classes": classes, "hide_all": ha, "offset": offset, "limit": limit, }))) diff --git a/apps/server/tests/wow_cards.rs b/apps/server/tests/wow_cards.rs new file mode 100644 index 0000000..34ed817 --- /dev/null +++ b/apps/server/tests/wow_cards.rs @@ -0,0 +1,271 @@ +mod common; +use axum::http::StatusCode; +use common::*; +use serde_json::Value; +use sqlx::PgPool; +use uuid::Uuid; + +async fn names(v: &Value) -> Vec { + v["wow_characters"] + .as_array() + .unwrap() + .iter() + .map(|c| c["name"].as_str().unwrap().to_string()) + .collect() +} + +#[sqlx::test(migrator = "fraglink_server::MIGRATOR")] +async fn profile_show_filters_the_page_and_keeps_every_row(pool: PgPool) { + let sql = include_str!("../migrations/0058_wow_default_flavour.sql").to_ascii_lowercase(); + for line in sql.lines() { + let t = line.trim(); + if t.starts_with("--") { + continue; + } + assert!( + !t.contains("update ") && !t.contains("delete ") && !t.contains("insert "), + "{line}" + ); + } + let app = fraglink_server::router(state(pool.clone())); + let t = signup(&app, "cards@example.com", "cardfan").await; + sqlx::query("INSERT INTO site_settings(key, enabled) VALUES('wow_characters', true)") + .execute(&pool) + .await + .unwrap(); + fraglink_server::features::forget(); + let uid: Uuid = sqlx::query_scalar("SELECT id FROM users WHERE handle = 'cardfan'") + .fetch_one(&pool) + .await + .unwrap(); + for (name, game, verified, hidden, played) in [ + ("Mockrend", "wow", true, false, 7200_i64), + ("Samplekin", "wow", false, false, 3600_i64), + ("Hideme", "wow", true, true, 1800_i64), + ("Vale Mocken", "wow_forever", false, false, 5000_i64), + ] { + sqlx::query( + "INSERT INTO wow_characters(id, user_id, game_id, region, realm, realm_slug, name, class, played_s, played_at, verified, hidden, show_guild) + VALUES($1, $2, $3, 'us', 'Area 52', 'area-52', $4, 'MAGE', $5, now(), $6, $7, true)", + ) + .bind(Uuid::new_v4()) + .bind(uid) + .bind(game) + .bind(name) + .bind(played) + .bind(verified) + .bind(hidden) + .execute(&pool) + .await + .unwrap(); + } + let profile = || async { + let r = api(&app, "GET", "/api/v1/users/cardfan/profile", Some(&t), None).await; + assert_eq!(r.status, StatusCode::OK, "{}", r.text); + r.json() + }; + let p = profile().await; + assert_eq!(p["wow_show"], "both"); + assert_eq!(p["wow_default"], "wow"); + let mut got = names(&p).await; + got.sort(); + assert_eq!(got, vec!["Mockrend".to_string(), "Vale Mocken".to_string()]); + assert!(p["wow_characters"][0].get("played_min").is_none()); + assert!(p["wow_characters"] + .as_array() + .unwrap() + .iter() + .any(|c| c["name"] == "Mockrend" && c["verified"] == true)); + assert!(p["wow_characters"] + .as_array() + .unwrap() + .iter() + .any(|c| c["name"] == "Vale Mocken" && c["verified"] == false)); + + let me = api(&app, "GET", "/api/v1/me/bnet", Some(&t), None).await; + assert_eq!(me.status, StatusCode::OK, "{}", me.text); + assert_eq!(me.json()["profile_show"], "both"); + assert_eq!(me.json()["default_flavour"], "wow"); + + let bad = api( + &app, + "POST", + "/api/v1/me/wow/profile_show", + Some(&t), + Some(serde_json::json!({ "show": "classic" })), + ) + .await; + assert_eq!(bad.status, StatusCode::BAD_REQUEST, "{}", bad.text); + + let set = |show: &str| { + let app = &app; + let t = &t; + let show = show.to_string(); + async move { + let r = api( + app, + "POST", + "/api/v1/me/wow/profile_show", + Some(t), + Some(serde_json::json!({ "show": show })), + ) + .await; + assert_eq!(r.status, StatusCode::OK, "{}", r.text); + } + }; + set("wow").await; + let mut only = names(&profile().await).await; + only.sort(); + assert_eq!(only, vec!["Mockrend".to_string()]); + set("wow_forever").await; + assert_eq!( + names(&profile().await).await, + vec!["Vale Mocken".to_string()] + ); + set("both").await; + let r = api( + &app, + "POST", + "/api/v1/me/wow/default_flavour", + Some(&t), + Some(serde_json::json!({ "flavour": "wow_forever" })), + ) + .await; + assert_eq!(r.status, StatusCode::OK, "{}", r.text); + let p = profile().await; + assert_eq!(p["wow_show"], "both"); + assert_eq!(p["wow_default"], "wow_forever"); + assert_eq!(names(&p).await.len(), 2); + + let stored: Vec<(String, i64, bool)> = sqlx::query_as( + "SELECT name, played_s, hidden FROM wow_characters WHERE user_id = $1 ORDER BY name", + ) + .bind(uid) + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!( + stored, + vec![ + ("Hideme".into(), 1800, true), + ("Mockrend".into(), 7200, false), + ("Samplekin".into(), 3600, false), + ("Vale Mocken".into(), 5000, false), + ] + ); + let hours: i64 = sqlx::query_scalar("SELECT count(*) FROM hours_entries WHERE user_id = $1") + .bind(uid) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(hours, 0); +} + +#[sqlx::test(migrator = "fraglink_server::MIGRATOR")] +async fn armory_add_verifies_only_from_the_account_list(pool: PgPool) { + let app = fraglink_server::router(state(pool.clone())); + let _t = signup(&app, "arm@example.com", "armoryer").await; + let uid: Uuid = sqlx::query_scalar("SELECT id FROM users WHERE handle = 'armoryer'") + .fetch_one(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO linked_accounts(user_id, provider, provider_uid, display, verified, scopes) + VALUES($1, 'bnet', 'arm-1', 'Armoryer#1', true, 'openid wow.profile')", + ) + .bind(uid) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO bnet_characters(user_id, game_id, region, realm_slug, realm_name, name, bnet_id) + VALUES($1, 'wow', 'us', 'area-52', 'Area 52', 'Mockrend', 4242)", + ) + .bind(uid) + .execute(&pool) + .await + .unwrap(); + let st = state(pool.clone()); + let profile = serde_json::json!({ + "id": 4242, + "name": "Mockrend", + "level": 80, + "realm": {"name": "Area 52", "slug": "area-52"}, + "playable_class": {"name": "Mage"}, + "race": {"name": "Human"}, + "gender": {"type": "MALE"}, + "active_spec": {"name": "Frost"} + }); + let added = fraglink_server::wow::add_armory_character( + &st, uid, "us", "Area 52", "Mockrend", &profile, "wow", + ) + .await + .unwrap(); + assert_eq!(added["verified"], true); + assert_eq!(added["created"], true); + let row: (i64, i32, String, bool) = sqlx::query_as( + "SELECT played_s, level, class, verified FROM wow_characters WHERE user_id = $1", + ) + .bind(uid) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(row.0, 0); + assert_eq!((row.1, row.2.as_str(), row.3), (80, "MAGE", true)); + sqlx::query("UPDATE wow_characters SET played_s = 5000 WHERE user_id = $1") + .bind(uid) + .execute(&pool) + .await + .unwrap(); + let again = fraglink_server::wow::add_armory_character( + &st, uid, "us", "area 52", "mockrend", &profile, "wow", + ) + .await + .unwrap(); + assert_eq!(again["created"], false); + let played: i64 = sqlx::query_scalar("SELECT played_s FROM wow_characters WHERE user_id = $1") + .bind(uid) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(played, 5000, "an Armory add does not rewrite /played"); + let hours: i64 = sqlx::query_scalar("SELECT count(*) FROM hours_entries WHERE user_id = $1") + .bind(uid) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(hours, 0); + + let other = signup(&app, "arm2@example.com", "armorytwo").await; + let uid2: Uuid = sqlx::query_scalar("SELECT id FROM users WHERE handle = 'armorytwo'") + .fetch_one(&pool) + .await + .unwrap(); + let _ = other; + let stray = fraglink_server::wow::add_armory_character( + &state(pool.clone()), + uid2, + "us", + "Area 52", + "Samplekin", + &serde_json::json!({"id": 99, "name": "Samplekin", "level": 0, "realm": {"name": "Area 52"}, "playable_class": {"name": "Hunter"}}), + "wow", + ) + .await + .unwrap(); + assert_eq!( + stray["verified"], false, + "not on the account list, so no tick" + ); + let bad = fraglink_server::wow::add_armory_character( + &state(pool.clone()), + uid2, + "us", + "x", + "a", + &serde_json::json!({"name": "a"}), + "wow", + ) + .await; + assert!(bad.is_err()); +} diff --git a/apps/site-web/static/assets/app.js b/apps/site-web/static/assets/app.js index 0b6f96f..f9a43c9 100644 --- a/apps/site-web/static/assets/app.js +++ b/apps/site-web/static/assets/app.js @@ -512,7 +512,7 @@ pages.wowupload = async () => { el.innerHTML = `
${logo}🔒

Upload WoW /played

Upload a character's /played from the addon. Retail and Forever each store that total on their own game. Shown hours use the imported total plus play after that snapshot, or the app's tracked time if that is higher. The two are not added together.

-
Game
+
Game

Retail and Forever are separate. A Forever character stays unverified until Battle.net's Forever account list confirms it. That check is off until it has been tested.

Before you upload: log in to every character. The addon only saves a character's /played when you log in to it (then log out or /reload). Characters you skip keep the hours from their last upload, and Fraglink always keeps your higher total, so a partial upload never lowers your hours.
@@ -527,6 +527,12 @@ pages.wowupload = async () => {
`; const step = (k, state, note) => { const li = $(`[data-s="${k}"]`, el); li.className = state; if (note != null) $("small", li).textContent = note; }; const res = $("[data-res]", el), form = $("[data-form]", el), steps = $("[data-steps]", el); + const preset = new URLSearchParams(location.search).get("game"); + if (preset === "wow" || preset === "wow_forever") { + form.game.value = preset; + const pick = $("[data-gamepick]", form); + if (pick) pick.hidden = true; + } const R = { wow_checksum: "Edited after the game saved it", wow_battletag: "From a different Battle.net account", wow_old_addon: "Addon too old, update it", wow_decrease: "/played went down", wow_impossible: "/played grew faster than real time", wow_clock: "Saved with a wrong clock" }; let rows = [], sortBy = "played", dir = -1, showAll = false, totals = null; @@ -617,6 +623,7 @@ const WOW = (() => { return { url, specs: c => S[key(c)] || [], cls: c => NAMES[key(c)] || (c ? key(c).charAt(0) + key(c).slice(1).toLowerCase() : ""), + color: c => ({ MAGE: "#69ccf0", HUNTER: "#abd473", WARRIOR: "#c79c6e", ROGUE: "#fff569", PRIEST: "#ffffff", PALADIN: "#f58cba", DRUID: "#ff7d0a", WARLOCK: "#9482c9", SHAMAN: "#0070de", MONK: "#00ff96", DEATHKNIGHT: "#c41f3b", DEMONHUNTER: "#a330c9", EVOKER: "#33937f" }[key(c)] || "#8a939e"), clsIcon: c => key(c) ? url("classicon_" + key(c).toLowerCase()) : "", specIcon: (c, s) => { const x = (S[key(c)] || []).find(p => p[0] === s); return x ? url(x[1]) : ""; }, raceIcon: (race, gender, c) => { @@ -637,46 +644,76 @@ pages.bnet = async () => { let s; try { s = await api("/me/bnet"); } catch (x) { el.innerHTML = `

${esc(x.message)}

`; return; } const ago = iso => { const m = Math.round((Date.now() - new Date(iso)) / 60000); return m < 1 ? "just now" : m < 60 ? plural(m, "minute") + " ago" : m < 1440 ? plural(Math.round(m / 60), "hour") + " ago" : plural(Math.round(m / 1440), "day") + " ago"; }; + const show = s.profile_show === "wow" || s.profile_show === "wow_forever" ? s.profile_show : "both"; + const def = s.default_flavour === "wow_forever" ? "wow_forever" : "wow"; + const handle = (me() || {}).handle || ""; + const flavSeg = `
Show on profile
Profile opens on
`; const head = s.linked - ? `
${logo(26)}
${esc(s.battletag)} ${s.needs_reconnect ? `Reconnect to verify` : `✓ Verified`}
-
${[s.linked_at ? "Linked " + fullDate.format(new Date(s.linked_at)) : "", s.regions.length ? s.regions.map(r => r.toUpperCase()).join(", ") + (s.regions.length === 1 ? " region" : " regions") : "", s.last_verified ? "last verified " + ago(s.last_verified) : "not verified yet"].filter(Boolean).join(" · ")}
-
-

Verify asks Battle.net which characters are on your account. Upload /played once to bring in past hours; hours Fraglink tracks after that add on top.

` - : `
${logo(26)}
Not linked
Link Battle.net to verify your WoW characters. Only verified characters count toward your hours.${s.unverified ? ` You have ${plural(s.unverified, "uploaded character")} waiting.` : ""}
-
Upload /played
`; + ? `
${logo(26)}
${esc(s.battletag)} ${s.needs_reconnect ? `Reconnect` : `Verified`}
+
${[s.linked_at ? "Linked " + fullDate.format(new Date(s.linked_at)) : "", s.regions.length ? s.regions.map(r => r.toUpperCase()).join(", ") : "", s.last_verified ? "checked " + ago(s.last_verified) : ""].filter(Boolean).join(" · ")}
+
` + : `
${logo(26)}
Not linked
Link Battle.net to verify Retail characters.
+
`; el.innerHTML = `

Battle.net

${head}
- ${s.linked ? `

Featured on profile

Pick up to 3 characters to show first on your profile, in this order. Leave empty to show your most played. Hidden characters can't be featured.

Loading…

` : ""} -

WoW characters

-
- - -
-

Loading…

-
-

Retail characters Battle.net confirms are listed with a tick. Forever characters are listed from the addon without that tick until the Forever account list has been tested. Shown hours follow the same rule for both: the imported total is not added on top of tracked time.${s.unverified ? ` ${plural(s.unverified, "uploaded character")} ${s.unverified === 1 ? "isn't" : "aren't"} verified yet.` : ""} Hidden characters are not shown on your profile.${FEATURES.wow_characters ? "" : " Character details aren't shown on profiles right now; your profile shows only your WoW hours."}

`; +

Profile page display${handle ? `View profile` : ""}

+

Choose which WoW characters show on your public profile. This doesn't change your hours or characters here.

+ ${flavSeg}${s.linked ? `
` : ""}
+
+ +
+ + + +
+ +

Loading…

+
+ `; const msgBox = $("[data-msg]", el), list = $("[data-list]", el), more = $("[data-more]", el), cnt = $("[data-cnt]", el); - const st = { q: "", sort: "hours", class: "", vis: "", items: [], data: null, seq: 0 }; + const st = { q: "", sort: "hours", class: "", vis: "", items: [], data: null, seq: 0, game: def, extra: "" }; + const regions = ``; + const addForm = game => game === "wow_forever" + ? `

Add by first name and surname. It's verified automatically when it matches your linked Battle.net account.

${regions}
` + : `

Add by name and realm. It's verified automatically when it matches your linked Battle.net account.

${regions}
`; + const tabIds = d => { + const retail = d ? d.retail || 0 : 0, forever = d ? d.forever || 0 : 0, none = !d || retail + forever === 0; + const ids = []; + if (retail > 0 || def === "wow" || st.extra === "wow" || (none && st.game === "wow")) ids.push("wow"); + if (forever > 0 || def === "wow_forever" || st.extra === "wow_forever" || (none && st.game === "wow_forever")) ids.push("wow_forever"); + return ids.length ? ids : [def]; + }; const row = c => { const hid = c.hidden, specs = WOW.specs(c.class); const was = [c.former_name && c.former_name.toLowerCase() !== c.name.toLowerCase() ? c.former_name : "", c.former_realm && c.former_realm !== c.realm ? c.former_realm : ""].filter(Boolean).join(" · "); - return `
${c.class ? `` : ""}
-
${esc(c.name)} ${c.verified === false ? "" : `✓`}
${c.realm ? esc(c.realm) + " " : ""}${esc(c.region.toUpperCase())}${c.game_id === "wow_forever" ? " · Forever" : ""} · Level ${c.level}${c.spec || c.class ? " " + esc([c.spec, WOW.cls(c.class)].filter(Boolean).join(" ")) : ""}
${was ? `
formerly ${esc(was)}
` : ""}
-
${specs.map(p => ``).join("")}
-
${hours(c.played_min, { zero: true })}/played
- ${c.game_id === "wow" ? `` : ""} -
`; + const cls = [Number(c.level) > 0 ? "Level " + c.level : "", WOW.cls(c.class)].filter(Boolean).join(" "); + const imp = c.played_min > 0 && c.uploaded_at ? ago(c.uploaded_at) : "—"; + return `
+
${esc(c.name)}${c.verified ? `✓` : ""}${was ? `
formerly ${esc(was)}
` : ""}
+
${c.realm ? esc(c.realm) + " " : ""}${esc((c.region || "").toUpperCase())}
+
${esc(cls || "—")}
+
${hours(c.played_min, { zero: true })}
+
${imp}
+
`; }; const render = () => { - const d = st.data; - $("[data-ttl]", el).textContent = `WoW characters (${nf.format(d.total)})`; + const d = st.data, ids = tabIds(d), label = { wow: "Retail", wow_forever: "Forever" }; + if (!ids.includes(st.game)) st.game = ids[0]; + $("[data-tabs]", el).innerHTML = ids.map(id => ``).join("") + + (ids.length < 2 ? `` : ""); + $("[data-addbox]", el).innerHTML = addForm(st.game); + const up = $("[data-upload]", el); if (up) up.href = "/connect/wow?game=" + st.game; list.innerHTML = st.items.length ? st.items.map(row).join("") - : `

${d.total ? "No characters match." : s.linked ? "No verified characters yet. Verify your WoW characters, or upload /played if you haven't." : "Link Battle.net to verify your characters."}

`; - cnt.textContent = d.total && !d.matched ? `No characters match (${nf.format(d.total)} in all) · ${hours(d.total_min, { zero: true })} total` : d.total ? `Showing ${nf.format(st.items.length)} of ${plural(d.matched, "character")}${d.matched === d.total ? "" : ` (${nf.format(d.total)} in all)`} · ${hours(d.total_min, { zero: true })} total` : ""; - const left = d.matched - st.items.length; - more.hidden = left <= 0; more.textContent = `Show ${nf.format(Math.min(25, left))} more`; + : `

${d && d.matched === 0 && st.q ? "No characters match." : "No characters yet."}

`; + cnt.textContent = d && d.matched ? plural(d.matched, "character") : ""; + const left = d ? d.matched - st.items.length : 0; + more.hidden = left <= 0; more.textContent = "Show more"; }; const load = async append => { - const seq = ++st.seq, p = new URLSearchParams({ q: st.q, sort: st.sort, class: st.class, vis: st.vis, offset: append ? st.items.length : 0, limit: 25 }); + const seq = ++st.seq, p = new URLSearchParams({ q: st.q, sort: st.sort, class: st.class, vis: st.vis, game: st.game, offset: append ? st.items.length : 0, limit: 25 }); let d; try { d = await api("/me/wow/list?" + p); } catch (x) { if (seq === st.seq) showErr(msgBox, x); return; } if (seq !== st.seq) return; if (!st.data) { const cs = $("[data-class]", el); d.classes.slice().sort((a, b) => WOW.cls(a).localeCompare(WOW.cls(b))).forEach(c => cs.insertAdjacentHTML("beforeend", ``)); } @@ -692,11 +729,11 @@ pages.bnet = async () => { }; list.addEventListener("change", async e => { const gb = e.target.closest("[data-guild]"); - if (gb) { const c = st.items.find(x => x.id === gb.closest(".bn-ch").dataset.id), on = gb.checked; gb.disabled = true; + if (gb) { const c = st.items.find(x => x.id === gb.closest(".bn-row").dataset.id), on = gb.checked; gb.disabled = true; try { await api(`/me/wow/${c.id}/guild`, { method: "POST", body: { show_guild: on } }); c.show_guild = on; } catch (x) { gb.checked = !on; showErr(msgBox, x); } finally { gb.disabled = false; } return; } const cb = e.target.closest("[data-hide]"); if (!cb) return; - const r = cb.closest(".bn-ch"), c = st.items.find(x => x.id === r.dataset.id), hidden = !cb.checked; cb.disabled = true; + const r = cb.closest(".bn-row"), c = st.items.find(x => x.id === r.dataset.id), hidden = !cb.checked; cb.disabled = true; try { await api("/me/wow/" + c.id, { method: "POST", body: { hidden } }); c.hidden = c.own_hidden = hidden; if (hidden) el.dispatchEvent(new CustomEvent("wowhid", { detail: c.id })); if (st.vis) await load(false); else { r.outerHTML = row(c); } } @@ -704,11 +741,62 @@ pages.bnet = async () => { }); list.addEventListener("click", async e => { const bt = e.target.closest("[data-spec]"); if (!bt) return; - const r = bt.closest(".bn-ch"), c = st.items.find(x => x.id === r.dataset.id); bt.disabled = true; + const r = bt.closest(".bn-row"), c = st.items.find(x => x.id === r.dataset.id); bt.disabled = true; try { const x = await api(`/me/wow/${c.id}/spec`, { method: "POST", body: { spec: bt.dataset.spec } }); c.spec = x.spec; c.spec_picked = true; r.outerHTML = row(c); } catch (x) { bt.disabled = false; showErr(msgBox, x); } }); + el.addEventListener("submit", async e => { + const f = e.target.closest("[data-addchar]"); if (!f) return; + e.preventDefault(); + const btn = $("button[type=submit]", f); btn.disabled = true; + try { + const body = f.surname ? { region: f.region.value, game: "wow_forever", name: (f.first.value.trim() + " " + f.surname.value.trim()).replace(/\s+/g, " ") } : { region: f.region.value, realm: f.realm.value.trim(), name: f.name.value.trim(), game: "wow" }; + const r = await api("/me/wow/armory", { method: "POST", body }); + showOk(msgBox, r.verified ? `${r.name} added.` : `${r.name} added.`); + if (f.name) f.name.value = ""; + if (f.first) f.first.value = ""; + if (f.surname) f.surname.value = ""; + await load(false); + } catch (x) { showErr(msgBox, x); } + finally { btn.disabled = false; } + }); el.addEventListener("click", async e => { + const sh = e.target.closest("[data-show]"); + if (sh) { + const showTo = sh.dataset.show; + $$("[data-show]", el).forEach(b => { b.disabled = true; }); + try { + await api("/me/wow/profile_show", { method: "POST", body: { show: showTo } }); + $$("[data-show]", el).forEach(b => b.setAttribute("aria-pressed", b === sh ? "true" : "false")); + const opener = $("[data-opener]", el); if (opener) opener.hidden = showTo !== "both"; + if (el._feat) el._feat(); + } catch (x) { showErr(msgBox, x); } + finally { $$("[data-show]", el).forEach(b => { b.disabled = false; }); } + return; + } + const df = e.target.closest("[data-def]"); + if (df) { + const flavour = df.dataset.def; + $$("[data-def]", el).forEach(b => { b.disabled = true; }); + try { + await api("/me/wow/default_flavour", { method: "POST", body: { flavour } }); + $$("[data-def]", el).forEach(b => b.setAttribute("aria-pressed", b === df ? "true" : "false")); + } catch (x) { showErr(msgBox, x); } + finally { $$("[data-def]", el).forEach(b => { b.disabled = false; }); } + return; + } + const tab = e.target.closest("[data-tab]"); + if (tab) { st.game = tab.dataset.tab; st.extra = ""; load(false); return; } + const addTab = e.target.closest("[data-addtab]"); + if (addTab) { st.game = addTab.dataset.addtab; st.extra = st.game; load(false); return; } + const menuBtn = e.target.closest("[data-menu]"); + if (menuBtn) { + const pop = menuBtn.nextElementSibling; + $$(".bn-pop", el).forEach(p => { if (p !== pop) p.hidden = true; }); + pop.hidden = !pop.hidden; + return; + } + if (!e.target.closest(".bn-pop")) $$(".bn-pop", el).forEach(p => p.hidden = true); const t = e.target.closest("[data-unlink],[data-link]"); if (!t) return; try { if (t.matches("[data-link]")) { t.disabled = true; const r = await api("/connect/bnet/start", { method: "POST" }); location.href = r.url; return; } @@ -717,21 +805,31 @@ pages.bnet = async () => { } catch (x) { t.disabled = false; showErr(msgBox, x); } }); load(false); + api("/me/wow/review").then(d => { + const box = $("[data-review]", el); if (!box || !d.items || !d.items.length) return; + $("[data-rev]", box).innerHTML = d.items.map(i => `
${esc(i.detail || i.reason)}
`).join(""); + box.hidden = false; + }).catch(() => {}); // Featured top 3 per WoW version: search picker (fine with 1000+ characters), up/down, remove - const fbox = $("[data-fbox]", el); + const fbox = $("[data-feat]", el); if (fbox) { - const FL = [["wow", "World of Warcraft"]]; // Forever: once its /played uploads open let F; try { F = await api("/me/wow/featured"); } catch (x) { fbox.innerHTML = `

${esc(x.message)}

`; return; } - const lbl = c => `${esc(c.name)} ${esc(c.realm)} ${esc(c.region.toUpperCase())} · ${hours(c.played_min, { zero: true })}`; - const draw = () => { fbox.innerHTML = FL.map(([g, nm]) => `
${nm} -
    ${F[g].map((c, i) => `
  1. ${lbl(c)}
  2. `).join("") || `
  3. None picked: your most played show first.
  4. `}
- ${F[g].length < 3 ? `
` : ""}
`).join(""); }; + const lbl = c => esc(c.name); + const shownGames = () => { + const on = $("[data-show][aria-pressed=true]", el); + const which = on ? on.dataset.show : show; + if (which === "wow") return [["wow", "Retail"]]; + if (which === "wow_forever") return [["wow_forever", "Forever"]]; + return [["wow", "Retail"], ["wow_forever", "Forever"]]; + }; + const draw = () => { fbox.innerHTML = `
First on profile${shownGames().map(([g, nm]) => `
${nm}${(F[g] || []).map((c, i) => `${lbl(c)} `).join("") || `Most played`}${(F[g] || []).length < 3 ? `
` : ""}
`).join("")}
`; }; const save = async (g, next) => { try { await api("/me/wow/featured", { method: "POST", body: { game_id: g, ids: next.map(c => c.id) } }); F[g] = next; draw(); } catch (x) { showErr(msgBox, x); } }; draw(); - el.addEventListener("wowhid", e => { FL.forEach(([g]) => { F[g] = F[g].filter(c => c.id !== e.detail); }); draw(); }); + el._feat = draw; + el.addEventListener("wowhid", e => { ["wow", "wow_forever"].forEach(g => { if (F[g]) F[g] = F[g].filter(c => c.id !== e.detail); }); draw(); }); let tf; fbox.addEventListener("input", e => { const q = e.target.closest("[data-fq]"); if (!q) return; @@ -749,7 +847,7 @@ pages.bnet = async () => { const b = e.target.closest("button"); if (!b) return; const box = b.closest("[data-g]"), g = box.dataset.g, cur = F[g].slice(); if (b.matches("[data-add]")) { const c = $("[data-fr]", box)._hits[+b.dataset.add]; return save(g, cur.concat([c]).slice(0, 3)); } - const i = +b.closest("li").dataset.i; + const i = +b.closest("[data-i]").dataset.i; if (b.matches("[data-rm]")) cur.splice(i, 1); else { const j = b.matches("[data-up]") ? i - 1 : i + 1; [cur[i], cur[j]] = [cur[j], cur[i]]; } save(g, cur); @@ -1153,8 +1251,11 @@ const FLAGS = new Set("AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD function flag(c) { return c && FLAGS.has(c) ? `` : ""; } pages.profile = async () => { const root = $("#profile"); - const handle = decodeURIComponent(location.pathname.replace(/^\/u\/?/, "").replace(/\/$/, "")); + const bits = location.pathname.replace(/^\/u\/?/, "").replace(/\/$/, "").split("/").filter(Boolean); + const handle = decodeURIComponent(bits[0] || ""); + const wowAll = bits.length === 2 && bits[1] === "wow"; if (!handle) { location.replace(me() ? "/u/" + encodeURIComponent(me().handle) : "/login"); return; } + if (bits.length > 1 && !wowAll) { root.innerHTML = `

Not found

That page isn't on Fraglink.

`; return; } document.title = handle + " · Fraglink"; let p; try { p = await api("/users/" + encodeURIComponent(handle) + "/profile?limit=" + GPAGE + "&tz=" + encodeURIComponent(TZ), { keepSession: true }); } @@ -1169,7 +1270,7 @@ pages.profile = async () => { return; } // old username (renamed by staff): show the new address - if (p.handle && p.handle.toLowerCase() !== String(handle).toLowerCase()) history.replaceState(null, "", "/u/" + encodeURIComponent(p.handle) + location.search + location.hash); + if (p.handle && p.handle.toLowerCase() !== String(handle).toLowerCase()) history.replaceState(null, "", "/u/" + encodeURIComponent(p.handle) + (wowAll ? "/wow" : "") + location.search + location.hash); if (p.private) { const nm = esc(p.display_name || p.handle), next = encodeURIComponent(location.pathname + location.search); // Friend actions: hidden for yourself, friends and test accounts (server says can_add). @@ -1193,6 +1294,49 @@ pages.profile = async () => { wire(); return; } + // Gear, Mythic+, raids and collections open only for a verified Retail character, and + // only while Admin has "Show WoW character details" on. Hidden and Forever cards stay plain. + const poppable = c => FEATURES.wow_characters && c.verified && c.game_id === "wow" && !c.hidden; + const wowCard = c => { + const col = WOW.color(c.class), cls = WOW.cls(c.class); + const tick = c.verified ? `✓` : ""; + const guild = c.show_guild && c.guild ? `
<${esc(c.guild)}>
` : ""; + const ini = esc(String(c.name || "?").slice(0, 2)); + const lvl = Number(c.level) > 0 ? `Level ${c.level}` : ""; + const line = [lvl && `${esc(lvl)}`, cls && esc(cls)].filter(Boolean).join(" "); + const open = poppable(c); + return `
${ini}
${esc(c.name)}${tick}${line ? `
${line}
` : ""}${guild}
`; + }; + const wireWowPop = () => { + if (root._wowpop) return; + root._wowpop = true; + const go = n => { + const c = (p.wow_characters || []).find(x => String(x.id) === n.dataset.wcid); + if (c && poppable(c)) WOWPOP.open(p.handle, c); + }; + root.addEventListener("click", e => { const n = e.target.closest("[data-wcid]"); if (n && root.contains(n)) go(n); }); + root.addEventListener("keydown", e => { + if (e.key !== "Enter" && e.key !== " ") return; + const n = e.target.closest("[data-wcid]"); + if (!n || e.target !== n) return; + e.preventDefault(); + go(n); + }); + }; + const wowShow = p.wow_show === "wow" || p.wow_show === "wow_forever" ? p.wow_show : "both"; + const wowRows = flav => (p.wow_characters || []).filter(c => !c.hidden && (c.game_id === "wow_forever" ? "wow_forever" : "wow") === flav); + const wowOpen = wowShow === "both" ? (p.wow_default === "wow_forever" ? "wow_forever" : "wow") : wowShow; + if (wowAll) { + document.title = "WoW characters · " + (p.display_name || p.handle); + window.flCrumb([[p.display_name || p.handle, "/u/" + encodeURIComponent(p.handle)], ["WoW characters"]]); + const paint = flav => { const box = $("[data-wowgal]", root); if (box) box.innerHTML = wowRows(flav).map(wowCard).join("") || `

No ${flav === "wow_forever" ? "Forever" : "Retail"} characters.

`; $$("[data-flav]", root).forEach(b => b.setAttribute("aria-pressed", b.dataset.flav === flav ? "true" : "false")); }; + const tog = wowShow === "both" ? `
` : ""; + root.innerHTML = ``; + paint(wowOpen); + wireWowPop(); + if (wowShow === "both") root.addEventListener("click", e => { const b = e.target.closest("[data-flav]"); if (!b) return; paint(b.dataset.flav); }); + return; + } const pres = p.presence; const ids = [].concat((p.games || []).map(g => g.game_id), (p.recent || []).map(r => r.game_id), pres && pres.game_id ? [pres.game_id] : []); await loadIcons(ids); @@ -1276,15 +1420,14 @@ pages.profile = async () => { gamesRow = row; } - // WoW characters from the Fraglink addon: the server sends only Battle.net-verified ones + // Public cards: name, guild, class, avatar, verified tick. No hours and no past names. let wowc = ""; - const WC = (p.wow_characters || []).filter(c => !c.hidden); // hidden ones never show here, even to the owner; unhide on /connect/battlenet - if (WC && WC.length) { - const pop = c => FEATURES.wow_characters && c.game_id === "wow" && !c.hidden; - const card = c => `
${c.class ? `` : ""}${c.spec && WOW.specIcon(c.class, c.spec) ? `` : ""}
-
${esc(c.name)}
${c.level} ${esc([c.spec, WOW.cls(c.class)].filter(Boolean).join(" "))} · ${esc(c.realm)} ${esc(c.region.toUpperCase())}${c.game_id === "wow_forever" ? " · Forever" : ""}
${c.show_guild && c.guild ? `
<${esc(c.guild)}>
` : ""}
`; - const WS = [...WC].sort((a, b) => (a.featured || 9) - (b.featured || 9) || (b.played_min || 0) - (a.played_min || 0)), WMAX = 3; // featured picks first, in order - wowc = sec("WoW characters", `
${WS.slice(0, WMAX).map(card).join("")}
${WS.length > WMAX ? `

` : ""}${self ? `

Only you see hidden characters. Manage on your Battle.net page

` : ""}`); + const WC = (p.wow_characters || []).filter(c => !c.hidden); + if (WC.length) { + const ordered = flav => wowRows(flav).slice().sort((a, b) => (a.featured || 9) - (b.featured || 9)); + const grid = flav => { const rows = ordered(flav); return `
${rows.slice(0, 6).map(wowCard).join("") || `

No ${flav === "wow_forever" ? "Forever" : "Retail"} characters.

`}

View all

${self ? `

Hidden characters stay off this page. Manage characters

` : ""}`; }; + const tog = wowShow === "both" ? `
` : ""; + wowc = sec("WoW characters", grid(wowOpen), tog); } // Achievements @@ -1332,7 +1475,14 @@ pages.profile = async () => { : self ? sec("Gaming rig", `
Show off your setup. Add your gaming rig.
`) : ""; const discord = sec("Discord server", `
Fraglink on DiscordChat, feedback and release news
`); - setTimeout(() => { const w = root.querySelectorAll("[data-wcid]"); w.forEach(n => { const go = () => WOWPOP.open(p.handle, WC.find(c => c.id === n.dataset.wcid)); n.onclick = go; n.onkeydown = e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); go(); } }; }); }); + wireWowPop(); + root.addEventListener("click", e => { + const b = e.target.closest("[data-wflav]"); if (!b || wowShow !== "both") return; + $$("[data-wflav]", root).forEach(x => x.setAttribute("aria-pressed", x === b ? "true" : "false")); + const box = $("[data-wowcards]", root); if (!box) return; + const rows = wowRows(b.dataset.wflav).slice().sort((a, c) => (a.featured || 9) - (c.featured || 9)); + box.innerHTML = rows.slice(0, 6).map(wowCard).join("") || `

No ${b.dataset.wflav === "wow_forever" ? "Forever" : "Retail"} characters.

`; + }); root.innerHTML = `
` + head + pendingDelete + welcome + `
${details}${games}${wowc}${ach}${miniprofile}
`; $$('a[href="#friends"]', root).forEach(a => a.addEventListener("click", e => { const f = $("#friends", root); if (!f) return; e.preventDefault(); f.scrollIntoView({ behavior: "smooth", block: "center" }); f.classList.add("flash"); setTimeout(() => f.classList.remove("flash"), 1200); })); const sm = $("[data-seemore]", root); diff --git a/apps/site-web/static/assets/site.css b/apps/site-web/static/assets/site.css index 6c44304..ec5136b 100644 --- a/apps/site-web/static/assets/site.css +++ b/apps/site-web/static/assets/site.css @@ -718,8 +718,10 @@ table.newsadm td.t b{display:block}table.newsadm td.t small{color:var(--dim)}tab .bv-act{margin-top:14px} .srbadge{color:#c9a227;font-size:12px;font-weight:600}.badbadge{color:#e55;font-size:12px;font-weight:600} .conn-sub{padding:2px 0 10px 40px}.sub.warn{color:#e0a040} -.wcs{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:10px;padding:12px 14px} -.wc{border:1px solid rgba(255,255,255,.1);border-radius:6px;padding:10px;background:rgba(0,0,0,.15)} +.wcs,.wow-gal{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:10px;padding:12px 14px} +.wow-gal{grid-template-columns:repeat(auto-fill,minmax(240px,1fr));padding:0} +.wc{--cls:#8a939e;display:flex;gap:10px;align-items:center;border:1px solid color-mix(in srgb,var(--cls) 38%,var(--line));border-radius:6px;padding:10px;background:linear-gradient(180deg,color-mix(in srgb,var(--cls) 16%,#141920),#10141a);box-shadow:inset 3px 0 0 var(--cls);transition:border-color .15s,box-shadow .15s,transform .15s} +.wc:hover{border-color:var(--cls);box-shadow:inset 3px 0 0 var(--cls),0 0 16px color-mix(in srgb,var(--cls) 42%,transparent);transform:translateY(-1px)} .wc-top{display:flex;justify-content:space-between;gap:6px;align-items:center} .wc-h{font-size:20px;font-weight:700;margin:6px 0}.wc-h small{font-size:11px;font-weight:400;opacity:.7} .wc-hid{opacity:.5} @@ -744,7 +746,7 @@ table.newsadm td.t b{display:block}table.newsadm td.t small{color:var(--dim)}tab /* Battle.net page (/connect/battlenet) */ .bn .bn-box{margin:14px 0 16px} .bn-box>h2{display:flex;justify-content:space-between;align-items:center;gap:10px} -.bn-acct{display:flex;align-items:center;gap:12px;padding:12px 14px;flex-wrap:wrap} +.bn-acct{display:flex;align-items:center;gap:10px;padding:8px 14px;flex-wrap:wrap} .bn-av{width:40px;height:40px;border-radius:4px;background:#22374d;display:grid;place-items:center;flex:none} .bn-nm{font-weight:600;color:#eef2f6;overflow-wrap:anywhere} .bn-acts{display:flex;gap:6px;margin-left:auto;flex-wrap:wrap} @@ -760,7 +762,39 @@ table.newsadm td.t b{display:block}table.newsadm td.t small{color:var(--dim)}tab .bn-sw input:checked+.bn-tg{background:#2f5a2a}.bn-sw input:checked+.bn-tg::after{left:16px;background:#8fdc79} .bn-sw input:focus-visible+.bn-tg{outline:2px solid var(--link);outline-offset:2px} .bn-sw input:disabled+.bn-tg{opacity:.5;cursor:not-allowed} -.bn-ch{display:grid;grid-template-columns:auto minmax(0,1fr) auto auto 86px;gap:14px;align-items:center;padding:8px 12px;border-bottom:1px solid var(--line)} +.bn-tabs{display:flex;align-items:stretch;border-bottom:1px solid var(--line);background:#12161b} +.bn-tabs button{background:transparent;border:0;border-bottom:2px solid transparent;color:var(--silver);font:inherit;font-weight:650;padding:8px 14px;cursor:pointer} +.bn-tabs button.on{color:#fff;border-bottom-color:var(--orange)} +.bn-tabs .bn-addtab{margin-left:auto;color:var(--link);font-weight:600;border-bottom-color:transparent} +.bn-actrow{display:flex;justify-content:space-between;gap:12px;align-items:flex-end;padding:10px 12px;border-bottom:1px solid var(--line)} +.bn-actrow .sub,.bn-add .sub{flex:1 0 100%;margin:0 0 6px} +.bn-k{color:var(--dim);font-size:11px;font-weight:650;letter-spacing:.4px;text-transform:uppercase} +.bn-headrow,.bn-row{display:grid;grid-template-columns:28px minmax(0,1.5fr) minmax(0,1fr) minmax(0,1.1fr) 5.5em 6.5em 36px;gap:8px;align-items:center;padding:6px 12px} +.bn-headrow{color:var(--dim);font-size:11px;letter-spacing:.4px;text-transform:uppercase;border-bottom:1px solid var(--line)} +.bn-row{border-bottom:1px solid var(--line)} +.bn-row:nth-child(even){background:var(--row-alt)} +.bn-row img{width:28px;height:28px;border-radius:3px;border:1px solid #000;background:#111} +.bn-row.hid .bn-nm,.bn-row.hid img{opacity:.45} +.bn-nm{font-weight:650;color:#eef2f6;min-width:0} +.bn-tick{color:var(--online);margin-left:4px;font-weight:700} +.bn-row .num{text-align:right;font-variant-numeric:tabular-nums} +.bn-menu{position:relative;justify-self:end} +.bn-morebtn{background:transparent;border:1px solid var(--line);color:var(--silver);width:28px;height:24px;border-radius:3px;cursor:pointer;font:inherit} +.bn-pop{position:absolute;right:0;top:26px;z-index:4;background:#15181d;border:1px solid var(--line2);min-width:160px;padding:8px;display:flex;flex-direction:column;gap:6px} +.bn-pop .bn-specs{flex-wrap:wrap} +.bn-pop .bn-specs button{background:transparent;border:0;color:var(--text);font:inherit;padding:2px 0;cursor:pointer;text-align:left} +.bn-pop .bn-specs button.on{color:#fff;font-weight:700} +.bn-lead{margin:0;padding:2px 14px 0;color:var(--muted);font-size:13px;line-height:1.4} +.bn-box>h2 .bn-view{margin-left:auto;text-transform:none;letter-spacing:0;font-size:12px;font-weight:600} +.bn-feat{display:flex;flex-direction:column;gap:6px;padding:0 14px 12px} +.bn-featrow{display:flex;flex-wrap:wrap;gap:8px;align-items:center} +.bn-featlist{display:flex;flex-wrap:wrap;gap:8px} +.bn-feat button{background:transparent;border:0;color:var(--link);font:inherit;cursor:pointer;padding:0 2px} +.bn-feat input[type="search"]{background:#0f1216;border:1px solid var(--line2);color:var(--text);border-radius:3px;padding:5px 8px;font:inherit;min-width:10em;color-scheme:dark;-webkit-appearance:none;appearance:none} +.bn-feat input[type="search"]::placeholder{color:var(--dim)} +.bn-feat [data-fr]{flex:1 0 100%} +.bn-empty{padding:12px} +.bn-ch{display:grid;grid-template-columns:auto minmax(0,1fr) auto auto 86px;gap:10px;align-items:center;padding:6px 10px;border-bottom:1px solid var(--line)} .bn-ch:nth-child(even){background:var(--row-alt)} .bn-port{position:relative;width:44px;height:44px;flex:none} .bn-port img.r{width:44px;height:44px;border-radius:4px;border:1px solid #000;background:#111} @@ -775,13 +809,46 @@ table.newsadm td.t b{display:block}table.newsadm td.t small{color:var(--dim)}tab .bn-ch.hid .bn-nm,.bn-ch.hid .bn-port{opacity:.45} .bn-ch .bn-sw{justify-content:flex-end} .bn-more{padding:10px 12px;display:flex;justify-content:space-between;align-items:center;gap:10px} -@media(max-width:640px){.bn-ch{grid-template-columns:auto minmax(0,1fr) auto;row-gap:8px}.bn-ch .bn-specs{grid-column:1/3}.bn-ch .bn-sw{grid-column:3}.bn-acts{margin-left:0;width:100%}.bn-tools select{flex:1 1 40%}} +@media(max-width:640px){ + .bn-ch{grid-template-columns:auto minmax(0,1fr) auto;row-gap:8px} + .bn-ch .bn-specs{grid-column:1/3}.bn-ch .bn-sw{grid-column:3} + .bn-acts{margin-left:0} + .bn-tools select{flex:1 1 40%} + .bn-actrow{flex-direction:column;align-items:stretch} + .bn-headrow .c-realm,.bn-headrow .c-cls,.bn-headrow .c-imp,.bn-row .c-realm,.bn-row .c-cls,.bn-row .c-imp{display:none} + .bn-headrow,.bn-row{grid-template-columns:28px minmax(0,1fr) auto 36px} +} /* profile WoW cards with portraits */ -.wc{display:flex;gap:10px;align-items:center} -.wc-p{position:relative;width:48px;height:48px;flex:none}.wc-p .r{width:48px;height:48px;border-radius:4px;border:1px solid #000;background:#111} -.wc-ic{position:absolute;right:-6px;bottom:-6px;display:flex}.wc-ic img{width:20px;height:20px;border-radius:50%;border:1px solid #000;margin-left:-4px} -.wc-t{min-width:0}.wc-t .sub{overflow-wrap:anywhere} +.wc-p{position:relative;width:48px;height:48px;flex:none;border-radius:5px;overflow:hidden;box-shadow:0 0 0 1px #000,0 0 10px color-mix(in srgb,var(--cls) 55%,transparent)} +.wc-p .r{width:48px;height:48px;border-radius:4px;border:0;background:transparent;position:relative;z-index:1} +.wc-ini{position:absolute;inset:0;display:grid;place-items:center;font-weight:700;color:#fff;background:color-mix(in srgb,var(--cls) 62%,#1a120c);letter-spacing:.3px} +.wc-ic{position:absolute;right:-6px;bottom:-6px;display:flex;z-index:2}.wc-ic img{width:20px;height:20px;border-radius:50%;border:1px solid #000;margin-left:-4px} +.wc-t{min-width:0}.wc-t b{color:#eef2f6}.wc-cls{color:var(--cls);font-weight:700;font-size:12px}.wc-lv{font-weight:600;opacity:.8;margin-right:3px} +.wc-tick{color:#8fdc79;font-weight:700;margin-left:4px} .wc .wc-h{font-size:12px;font-weight:400;margin:2px 0 0;color:var(--label)} +.seg{display:inline-flex;border:1px solid #3d5168;border-radius:3px;overflow:hidden;text-transform:none;letter-spacing:0} +.seg button{background:transparent;color:#c5d0db;border:0;border-right:1px solid #3d5168;padding:4px 10px;font:inherit;font-size:12px;font-weight:650;cursor:pointer} +.seg button:last-child{border-right:0} +.seg button[aria-pressed="true"]{background:#2c4d6b;color:#fff} +.wow-foot{display:flex;justify-content:flex-end;padding:0 14px 10px} +.wow-page{max-width:980px;margin:16px auto;padding:0 16px 28px} +.wow-page .bar{display:flex;justify-content:space-between;align-items:center;gap:12px;margin:8px 0 14px} +.wow-page h1{margin:8px 0 0;font-size:20px} +.bn-head{display:flex;justify-content:space-between;gap:12px;align-items:center;flex-wrap:wrap} +.bn-def{display:flex;align-items:center;gap:10px;padding:10px 14px;flex-wrap:wrap} +.bn-split{display:grid;grid-template-columns:1fr 1fr;gap:10px;padding:10px 14px} +.bn-add{display:flex;flex-wrap:wrap;gap:6px;align-items:center} +.bn-add input,.bn-add select{background:#0f1216;border:1px solid var(--line2);color:var(--text);border-radius:3px;padding:5px 8px;font:inherit} +.bn-add input{width:9em} +.bn-split .sub{margin:4px 0 8px} +.bn-review{padding:8px 14px 12px} +@media(max-width:640px){ + .wcs,.wow-gal{grid-template-columns:1fr} + .bn-split{grid-template-columns:1fr} + .bn-head{align-items:flex-start} + .wow-page .bar{align-items:flex-start;flex-direction:column} + .seg button{padding:8px 14px} +} .bn-sw.bn-hd{text-transform:none;letter-spacing:0;font-weight:600}.btn.bn-unlink{color:var(--bad)} /* WoW character popup */ diff --git a/crates/fraglink-core/src/wow_played.rs b/crates/fraglink-core/src/wow_played.rs index fea7f21..3ec5ecc 100644 --- a/crates/fraglink-core/src/wow_played.rs +++ b/crates/fraglink-core/src/wow_played.rs @@ -441,6 +441,8 @@ mod tests { assert!(lua.contains(&format!("local KEY = \"{k}\"")), "addon KEY"); assert!(ADDON_TOC.contains(&format!("## Version: {ADDON_VERSION}"))); assert!(ADDON_TOC.contains("## X-Curse-Project-ID: 1766775")); + // warcraft.wiki.gg TOC format: Standard 120105, Standard test 120107, Forever 16001. + assert!(ADDON_TOC.contains("## Interface: 120105, 120107, 16001")); } /// The fixture is what the real addon wrote under Lua 5.1 (see diff --git a/design/curseforge-page/index.html b/design/curseforge-page/index.html new file mode 100644 index 0000000..084d4e2 --- /dev/null +++ b/design/curseforge-page/index.html @@ -0,0 +1,80 @@ + + + + + +Fraglink /played · project page preview + + + +
Preview only. This is not the live CurseForge page, and nothing here has been published.
+
+
+ +
+

Fraglink /played

+
by Fraglink · 1.1.0
+

Saves Retail and WoW Forever /played on your PC for Fraglink. Uploads only add time and never lower your hours.

+
Retail 12.1.5Retail 12.1.7Forever 1.60.1
+
+
+
+
+

Description

+

Fraglink /played saves each character's /played time on your PC. You upload that file at playfraglink.com. The addon does not send anything while you are in game.

+

It is one addon for Retail and WoW Forever. When you log in, it asks the game for /played and marks the saved file Retail or Forever.

+

An upload only adds time. A file with a lower total is refused, and the hours already stored are left as they are. Retail and Forever stay separate.

+

Install

+
    +
  1. Download the zip.
  2. +
  3. Unzip it into World of Warcraft/_retail_/Interface/AddOns and World of Warcraft/_forever_/Interface/AddOns. Each should contain a FraglinkPlayed folder.
  4. +
  5. Restart the game, or type /reload. Enable Fraglink /played on the addon list if it is off.
  6. +
+

Use

+
    +
  1. Log in to each character once. Type /reload or log out so the game writes the file.
  2. +
  3. On playfraglink.com, open the WoW page, pick Retail or Forever, and upload WTF/Account/<ACCOUNT>/SavedVariables/FraglinkPlayed.lua.
  4. +
  5. Link Battle.net so a Retail character can be verified. Forever characters stay unverified until that check is turned on. The file on your PC is kept either way, so you can upload it again later.
  6. +
+

A line in chat says when a character was recorded. /fragq hides that line.

+
+ +
+
+ + diff --git a/design/release-overview/img/cards.png b/design/release-overview/img/cards.png new file mode 100644 index 0000000..f6c4d6e Binary files /dev/null and b/design/release-overview/img/cards.png differ diff --git a/design/release-overview/img/curseforge.png b/design/release-overview/img/curseforge.png new file mode 100644 index 0000000..70a3365 Binary files /dev/null and b/design/release-overview/img/curseforge.png differ diff --git a/design/release-overview/img/display.png b/design/release-overview/img/display.png new file mode 100644 index 0000000..7eb07ba Binary files /dev/null and b/design/release-overview/img/display.png differ diff --git a/design/release-overview/img/manager-forever.png b/design/release-overview/img/manager-forever.png new file mode 100644 index 0000000..7b427be Binary files /dev/null and b/design/release-overview/img/manager-forever.png differ diff --git a/design/release-overview/img/manager-retail.png b/design/release-overview/img/manager-retail.png new file mode 100644 index 0000000..a456c5f Binary files /dev/null and b/design/release-overview/img/manager-retail.png differ diff --git a/design/release-overview/img/popup.png b/design/release-overview/img/popup.png new file mode 100644 index 0000000..5a5efd4 Binary files /dev/null and b/design/release-overview/img/popup.png differ diff --git a/design/release-overview/img/upload.png b/design/release-overview/img/upload.png new file mode 100644 index 0000000..cb38b53 Binary files /dev/null and b/design/release-overview/img/upload.png differ diff --git a/design/release-overview/img/view-all.png b/design/release-overview/img/view-all.png new file mode 100644 index 0000000..22c2b7d Binary files /dev/null and b/design/release-overview/img/view-all.png differ diff --git a/design/release-overview/index.html b/design/release-overview/index.html new file mode 100644 index 0000000..4da3a85 --- /dev/null +++ b/design/release-overview/index.html @@ -0,0 +1,187 @@ + + + + + +Release overview + + + +
Release overview
Samplemock
+
+

Fixture only. Samplemock, Mockrend and Vale Mocken are made-up characters. This page is a review sheet, not a live screen.

+ +
+

Goes live on release PR 26 + 28 + 29

+ +
+

Forever /played imports

+

Forever /played counts the same way as Retail and keeps its own total: a repeat upload does not add the hours again, a lower total is rejected, and a bad file is quarantined. The Forever endpoint flags stay off until the checks after release pass.

+ + + + + + +
CharacterRetailForever
Mockrend21 hours—
Vale Mocken—1 hour
+
Upload page with a Retail and Forever choice
The upload page keeps the two files apart. Forever stays unverified until its account list is switched on.
+
+ +
+

Rename merge

+

A verified character with the same Blizzard id is one character after a rename. The hours and the past name stay, and every merge is recorded.

+ + + + + +
NamePast nameHoursRecord
Mockrend ✓Mossrend21 hoursKept
+
+ +
+

Profile cards

+

Each card shows the name, guild, “Level 80 Mage” in the class colour, the avatar, and a Verified tick. Clicking a card opens the gear popup.

+
+
Samplemock profile cards, Retail roster
Samplemock’s profile. Retail is the roster that opens.
+
Gear popup for Mockrend, opened from View all
Mockrend’s gear, raids and Mythic+, opened from a card.
+
+
+ +
+

View all

+

The full roster is a gallery at /u/samplemock/wow.

+
View all gallery for Samplemock
/u/samplemock/wow. Same cards, no hours and no past names.
+
+ +
+

Profile page display

+

The owner picks Retail only, Forever only, or Both, and which roster the profile opens on.

+
Profile page display setting on the manager
This only changes the public profile. Hours and the character list stay as they are.
+
+ +
+

Character manager

+

Retail and Forever each have a tab. Add by name and realm uses the Armory and verifies the character when it is on the linked Battle.net account. Upload /played follows the open tab.

+
+
Manager on the Retail tab
Retail tab. Name, realm, Add character.
+
Manager on the Forever tab
Forever tab. First name and surname. Same upload button, Forever file.
+
+
+ +
+

Fraglink /played 1.1.0

+

One addon for Retail and Forever. It saves /played on your PC, tags which client it came from, and an upload never lowers hours.

+
Preview of the CurseForge project page for Fraglink /played 1.1.0
Project page preview. Not published.
+
+ +
+

Migrations

+

0056, 0057 and 0058 only add columns and tables. No existing hours change.

+ + + + + + + +
MigrationAdds
0056Forever import columns on the character row
0057Past names, and a row for each rename merge
0058Profile page display: what shows, and what opens first
+
+
+ +
+

Coming next Not built yet

+ +
+

After release

+
    +
  1. Forever endpoint checks.
  2. +
  3. Lucrona’s in-game Forever /played test.
  4. +
+
+ +
+

Manager polish

+
    +
  • Show the import as a date.
  • +
  • Remember the last tab.
  • +
  • After an upload, open the tab that matches the file.
  • +
  • Move the Verified explanation into the row menu.
  • +
+
+ +
+

Blizzard API

+
    +
  • Item level, or the Mythic+ score, on the cards.
  • +
  • The same popup for Forever characters.
  • +
  • Guild pages.
  • +
  • Picking a main.
  • +
+
+ +
+

Desktop

+
    +
  • The game name under your own name is cut off. Show the full width, and the rest on hover.
  • +
  • Curated icons.
  • +
+
+
+
+ +