From 24928a67a4feec378b4aa98322c6c66e4ab6c8e1 Mon Sep 17 00:00:00 2001 From: Lucrona <113807308+Lucrona@users.noreply.github.com> Date: Wed, 11 Nov 2026 22:39:19 +1100 Subject: [PATCH] Write Blizzard levels from the popup back onto WoW cards The character popup already fetched a fresh profile, but Forever never copied that level onto wow_characters.bnet_level, which is what the profile and gallery cards read. Retail already wrote it from the popup. Both versions now share that write-back (level, class, race, spec, faction) without touching played_s or the addon's level claim. A profile or manager view also refreshes characters not checked in the last 30 minutes, account sync copies Forever levels as well as Retail, and the cards update as soon as the popup (or a short profile refetch) has the new data. --- apps/server/src/wow.rs | 210 +++++++++++++++++++++++----- apps/server/src/wowchar.rs | 34 ++--- apps/server/src/wowpage.rs | 17 +-- apps/server/tests/wow.rs | 145 +++++++++++++++++++ apps/site-web/static/assets/app.js | 56 +++++++- apps/site-web/tests/wow-ui.test.cjs | 10 ++ 6 files changed, 396 insertions(+), 76 deletions(-) diff --git a/apps/server/src/wow.rs b/apps/server/src/wow.rs index 65136d7..1f516f7 100644 --- a/apps/server/src/wow.rs +++ b/apps/server/src/wow.rs @@ -699,13 +699,16 @@ async fn fetch_forever_profile(st: &AppState, id: Uuid, e: &CharEntry) -> bool { let Ok(resp) = http.get(&url).bearer_auth(tok).send().await else { return false; }; - if !resp.status().is_success() { + if resp.status().as_u16() == 404 { let _ = sqlx::query("UPDATE wow_characters SET bnet_checked_at = now() WHERE id = $1") .bind(id) .execute(&st.db) .await; return false; } + if !resp.status().is_success() { + return false; + } let Ok(v) = resp.json::().await else { return false; }; @@ -717,22 +720,9 @@ async fn fetch_forever_profile(st: &AppState, id: Uuid, e: &CharEntry) -> bool { }; let _ = assign_forever_ruleset(&st.db, id, region, rid).await; } - let level = v["level"].as_i64(); - let _ = sqlx::query( - "UPDATE wow_characters SET bnet_level = coalesce($2, bnet_level), - bnet_spec = coalesce($3, bnet_spec), race = coalesce($4, race), - gender = coalesce($5, gender), bnet_checked_at = now(), faction = coalesce($6, faction) - WHERE id = $1", - ) - .bind(id) - .bind(level.map(|l| l as i32)) - .bind(name_of(&v["active_spec"])) - .bind(name_of(&v["race"])) - .bind(v["gender"]["type"].as_str().map(|g| g.to_lowercase())) - .bind(profile_faction(&v)) - .execute(&st.db) - .await - .inspect_err(|err| tracing::warn!("forever profile: update {id}: {err}")); + let _ = apply_blizzard_profile(&st.db, id, &v) + .await + .inspect_err(|err| tracing::warn!("forever profile: update {id}: {err}")); true } @@ -1538,10 +1528,16 @@ pub async fn sync_characters( /// The listing is fetched live, so it is the newest level Blizzard has. pub async fn copy_bnet_levels(db: &sqlx::PgPool, uid: Uuid) { let _ = sqlx::query( - "UPDATE wow_characters c SET bnet_level = b.level FROM bnet_characters b - WHERE c.game_id = 'wow' AND c.user_id = $1 AND b.user_id = c.user_id AND b.game_id = c.game_id AND b.region = c.region - AND (b.bnet_id = c.bnet_id OR (b.realm_slug = c.realm_slug AND lower(b.name) = lower(c.name))) - AND b.level > 0 AND c.bnet_level IS DISTINCT FROM b.level", + "UPDATE wow_characters c SET bnet_level = b.level, + class = coalesce(b.class_name, c.class) + FROM bnet_characters b + WHERE c.user_id = $1 AND b.user_id = c.user_id AND b.game_id = c.game_id AND b.region = c.region + AND ( + (c.game_id = 'wow' AND (b.bnet_id = c.bnet_id OR (b.realm_slug = c.realm_slug AND lower(b.name) = lower(c.name)))) + OR (c.game_id = 'wow_forever' AND (b.bnet_id = c.bnet_id OR lower(b.name) = lower(c.name))) + ) + AND b.level > 0 AND (c.bnet_level IS DISTINCT FROM b.level + OR (b.class_name IS NOT NULL AND b.class_name <> '' AND c.class IS DISTINCT FROM b.class_name))", ) .bind(uid) .execute(db) @@ -2191,23 +2187,9 @@ async fn check_bnet_level(st: &AppState, uid: Uuid, gid: &str, id: Uuid, e: &Cha let last = v["last_login_timestamp"] .as_i64() .and_then(|ms| Utc.timestamp_millis_opt(ms).single()); - let _ = sqlx::query( - "UPDATE wow_characters SET bnet_level = $2, bnet_last_login = $3, bnet_checked_at = now(), - bnet_spec = coalesce($4, bnet_spec), race = coalesce($5, race), gender = coalesce($6, gender), - bnet_id = coalesce(bnet_id, $7), faction = coalesce($8, faction) - WHERE id = $1", - ) - .bind(id) - .bind(level.map(|l| l as i32)) - .bind(last) - .bind(name_of(&v["active_spec"])) - .bind(name_of(&v["race"])) - .bind(v["gender"]["type"].as_str().map(|g| g.to_lowercase())) - .bind(v["id"].as_i64()) - .bind(profile_faction(&v)) - .execute(&st.db) - .await - .inspect_err(|e| tracing::warn!("wow armory: update character {id}: {e}")); + let _ = apply_blizzard_profile(&st.db, id, &v) + .await + .inspect_err(|e| tracing::warn!("wow armory: update character {id}: {e}")); if let (Some(l), Some(t)) = (level, last) { if t.timestamp() > e.at && e.level > l { let r = rej( @@ -2244,6 +2226,157 @@ pub fn profile_faction(profile: &Value) -> Option<&str> { /// 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. +/// Copy a successful Blizzard character profile onto the card fields +/// (`shown_level!`, class, race, spec, faction). Never writes the addon's +/// `level` claim or `played_s`. +pub async fn apply_blizzard_profile( + db: &sqlx::PgPool, + id: Uuid, + root: &Value, +) -> Result<(), sqlx::Error> { + let level = root["level"].as_i64().filter(|l| *l > 0).map(|l| l as i32); + let last = root["last_login_timestamp"] + .as_i64() + .and_then(|ms| Utc.timestamp_millis_opt(ms).single()); + let class = class_token(root); + sqlx::query( + "UPDATE wow_characters SET + bnet_level = coalesce($2, bnet_level), + bnet_last_login = CASE WHEN $3::timestamptz IS NULL THEN bnet_last_login + ELSE greatest(bnet_last_login, $3) END, + class = CASE WHEN $4 <> '' THEN $4 ELSE class END, + race = coalesce($5, race), + gender = coalesce($6, gender), + bnet_spec = coalesce($7, bnet_spec), + faction = coalesce($8, faction), + bnet_id = coalesce(bnet_id, $9), + bnet_checked_at = now() + WHERE id = $1", + ) + .bind(id) + .bind(level) + .bind(last) + .bind(&class) + .bind(name_of(&root["race"])) + .bind(root["gender"]["type"].as_str().map(|g| g.to_lowercase())) + .bind(name_of(&root["active_spec"])) + .bind(profile_faction(root)) + .bind(root["id"].as_i64()) + .execute(db) + .await?; + Ok(()) +} + +/// Kick off a background refresh of stale Blizzard metadata for this user's +/// cards (level, class, race, spec, faction). Public profile views are +/// rate-limited so visitors cannot stampede the API. +pub fn schedule_stale_refresh(st: &AppState, uid: Uuid) { + use std::collections::HashMap; + use std::sync::{Mutex, OnceLock}; + use std::time::Instant; + static SEEN: OnceLock>> = OnceLock::new(); + { + let mut m = SEEN + .get_or_init(Default::default) + .lock() + .unwrap_or_else(|e| e.into_inner()); + m.retain(|_, t| t.elapsed() < Duration::from_secs(45)); + if m.contains_key(&uid) { + return; + } + m.insert(uid, Instant::now()); + } + let st = st.clone(); + tokio::spawn(async move { + refresh_stale_for_user(&st, uid).await; + }); +} + +async fn refresh_stale_for_user(st: &AppState, uid: Uuid) { + let rows: Vec<(Uuid, String, String, String, String)> = sqlx::query_as( + "SELECT id, game_id, region, realm_slug, name FROM wow_characters + WHERE user_id = $1 + AND (game_id = 'wow_forever' OR verified) + AND (bnet_checked_at IS NULL + OR bnet_checked_at < now() - interval '30 minutes' + OR bnet_spec IS NULL + OR race IS NULL) + ORDER BY featured NULLS LAST, played_s DESC + LIMIT 40", + ) + .bind(uid) + .fetch_all(&st.db) + .await + .unwrap_or_default(); + if rows.is_empty() { + return; + } + let mut ok = 0usize; + let total = rows.len(); + for (id, game, region, slug, name) in rows { + if refresh_character_profile(st, id, &game, ®ion, &slug, &name).await { + ok += 1; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + tracing::info!("wow profile refresh: {ok}/{total} characters for {uid}"); +} + +/// One public Blizzard character profile. A miss or error does not change +/// hours or the addon's stored level. 404s are remembered so we don't retry +/// every page view (Forever under 10 has no public profile). +pub async fn refresh_character_profile( + st: &AppState, + id: Uuid, + game: &str, + region: &str, + slug: &str, + name: &str, +) -> bool { + let url = if game == "wow_forever" { + if !forever_lookup_enabled() { + return false; + } + let Some((first, surname)) = forever_parts(name) else { + return false; + }; + forever_profile_url(region, &first, &surname) + } else if game == "wow" { + let Some(ns) = namespace(game, region) else { + return false; + }; + format!( + "https://{region}.api.blizzard.com/profile/wow/character/{slug}/{}?namespace={ns}&locale=en_US", + name.to_lowercase() + ) + } else { + return false; + }; + let (Some(tok), Some(http)) = (app_token().await, http()) else { + return false; + }; + let Ok(resp) = http.get(&url).bearer_auth(&tok).send().await else { + return false; + }; + if resp.status().as_u16() == 404 { + let _ = sqlx::query("UPDATE wow_characters SET bnet_checked_at = now() WHERE id = $1") + .bind(id) + .execute(&st.db) + .await; + return false; + } + if !resp.status().is_success() { + return false; + } + let Ok(v) = resp.json::().await else { + return false; + }; + apply_blizzard_profile(&st.db, id, &v) + .await + .inspect_err(|e| tracing::warn!("wow profile: apply {id}: {e}")) + .is_ok() +} + pub async fn characters( st: &AppState, uid: Uuid, @@ -2272,6 +2405,7 @@ pub async fn characters( faction: Option, ruleset: Option, } + schedule_stale_refresh(st, uid); let rows: Vec = sqlx::query_as( concat!("SELECT id, game_id, region, realm, name, class, ", shown_level!(), " AS level, played_s, played_at, verified, hidden OR coalesce((SELECT hide_all FROM wow_prefs WHERE user_id = $1), false) AS hid, diff --git a/apps/server/src/wowchar.rs b/apps/server/src/wowchar.rs index 0326966..b800e1f 100644 --- a/apps/server/src/wowchar.rs +++ b/apps/server/src/wowchar.rs @@ -17,7 +17,7 @@ use axum::extract::{Path, State}; use axum::http::{header, HeaderMap, Response, StatusCode}; use axum::routing::{get, post}; use axum::{Json, Router}; -use chrono::{DateTime, TimeZone, Utc}; +use chrono::{DateTime, Utc}; use serde::Deserialize; use serde_json::{json, Value}; use std::collections::HashMap; @@ -530,31 +530,11 @@ pub async fn refresh(st: &AppState, id: Uuid) -> Result<(), ()> { } Err(_) => return Err(()), }; - // the armory's level onto the card (`wow::shown_level!`). `bnet_checked_at` stays with - // the upload's cheat check so a refresh never pushes that check back. - if let Some(lvl) = root["level"].as_i64().filter(|l| *l > 0) { - let last = root["last_login_timestamp"] - .as_i64() - .and_then(|ms| Utc.timestamp_millis_opt(ms).single()); - let _ = sqlx::query( - "UPDATE wow_characters SET bnet_level = $2, - bnet_last_login = CASE WHEN $3::timestamptz IS NULL THEN bnet_last_login - ELSE greatest(bnet_last_login, $3) END - WHERE id = $1", - ) - .bind(id) - .bind(lvl as i32) - .bind(last) - .execute(&st.db) + // Write the armory's level, class, guild-facing fields onto the card + // (`wow::shown_level!`). Never touches played_s or the addon's level claim. + let _ = crate::wow::apply_blizzard_profile(&st.db, id, &root) .await - .inspect_err(|e| tracing::warn!("wowchar: level for {id}: {e}")); - } - let _ = sqlx::query("UPDATE wow_characters SET faction = coalesce($2, faction) WHERE id = $1") - .bind(id) - .bind(crate::wow::profile_faction(&root)) - .execute(&st.db) - .await - .inspect_err(|e| tracing::warn!("wowchar: faction for {id}: {e}")); + .inspect_err(|e| tracing::warn!("wowchar: profile for {id}: {e}")); let sub = |p: &str| format!("{base}/{p}?{ns}"); let u: Vec = [ "character-media", @@ -836,6 +816,10 @@ async fn refresh_forever(st: &AppState, id: Uuid, region: &str, name: &str) -> R if bid.is_some() && bid != root["id"].as_i64() { return Err(()); } + // Same card write-back as Retail: the popup already has the fresh profile. + let _ = crate::wow::apply_blizzard_profile(&st.db, id, &root) + .await + .inspect_err(|e| tracing::warn!("wowchar: forever profile for {id}: {e}")); let sub = |part: &str| format!("{base}/{part}?namespace=profile-forever-us&locale=en_US"); let urls = [ "equipment", diff --git a/apps/server/src/wowpage.rs b/apps/server/src/wowpage.rs index b9726c1..182dea8 100644 --- a/apps/server/src/wowpage.rs +++ b/apps/server/src/wowpage.rs @@ -7,7 +7,7 @@ use crate::api; use crate::auth::AuthUser; use crate::error::{ApiResult, AppError}; -use crate::wow::{app_token, bnet_link, http, name_of, namespace}; +use crate::wow::{app_token, bnet_link, http, namespace}; use crate::AppState; use axum::body::Body; use axum::extract::{Path, Query, State}; @@ -227,6 +227,7 @@ async fn list( let uid = a.user.id; if p.offset.unwrap_or(0) == 0 { backfill(&st, uid); + crate::wow::schedule_stale_refresh(&st, uid); } let ha = hide_all_on(&st, uid).await?; let q = p.q.unwrap_or_default().trim().to_lowercase(); @@ -555,17 +556,9 @@ async fn fill_specs(st: &AppState, uid: Uuid) { continue; }; ok += 1; - let _ = sqlx::query( - "UPDATE wow_characters SET bnet_spec = coalesce($2, bnet_spec), race = coalesce($3, race), - gender = coalesce($4, gender) WHERE id = $1", - ) - .bind(id) - .bind(name_of(&v["active_spec"])) - .bind(name_of(&v["race"])) - .bind(v["gender"]["type"].as_str().map(|g| g.to_lowercase())) - .execute(&st.db) - .await - .inspect_err(|e| tracing::warn!("wowpage: spec for {id}: {e}")); + let _ = crate::wow::apply_blizzard_profile(&st.db, id, &v) + .await + .inspect_err(|e| tracing::warn!("wowpage: spec for {id}: {e}")); tokio::time::sleep(Duration::from_millis(150)).await; } tracing::info!("wow backfill: {ok}/{total} characters for {uid}"); diff --git a/apps/server/tests/wow.rs b/apps/server/tests/wow.rs index 47f38bf..49483d5 100644 --- a/apps/server/tests/wow.rs +++ b/apps/server/tests/wow.rs @@ -1071,3 +1071,148 @@ async fn cards_show_the_newer_of_armory_and_played_level(pool: PgPool) { .unwrap(); assert_eq!(raw, [80, 80]); } + +#[sqlx::test(migrator = "fraglink_server::MIGRATOR")] +async fn stale_blizzard_levels_update_cards_not_played(pool: PgPool) { + use std::collections::BTreeMap; + let app = fraglink_server::router(state(pool.clone())); + let t = signup(&app, "stalelevel@example.com", "stalelevel").await; + sqlx::query("INSERT INTO site_settings(key, enabled) VALUES('wow_characters', true)") + .execute(&pool) + .await + .unwrap(); + fraglink_server::features::forget(); + let uid: uuid::Uuid = sqlx::query_scalar("SELECT id FROM users WHERE handle = 'stalelevel'") + .fetch_one(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO games(id, name) VALUES ('wow', 'World of Warcraft'), + ('wow_forever', 'World of Warcraft: Forever') ON CONFLICT DO NOTHING", + ) + .execute(&pool) + .await + .unwrap(); + + let retail: uuid::Uuid = uuid::Uuid::new_v4(); + let forever: uuid::Uuid = uuid::Uuid::new_v4(); + sqlx::query( + "INSERT INTO wow_characters(id, user_id, game_id, region, realm, realm_slug, name, class, level, played_s, played_at, verified) + VALUES ($1, $2, 'wow', 'us', 'Frostmourne', 'frostmourne', 'Lucroner', 'PALADIN', 80, 72000, now() - interval '2 days', true)", + ) + .bind(retail) + .bind(uid) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO wow_characters(id, user_id, game_id, region, realm, realm_slug, name, class, level, played_s, played_at, verified) + VALUES ($1, $2, 'wow_forever', 'us', '', '', 'Vale Ashborne', 'PALADIN', 22, 9876, now() - interval '1 day', true)", + ) + .bind(forever) + .bind(uid) + .execute(&pool) + .await + .unwrap(); + + let levels = |v: &Value| { + v.as_array() + .unwrap() + .iter() + .map(|c| { + ( + c["name"].as_str().unwrap().to_string(), + c["level"].as_i64().unwrap(), + ) + }) + .collect::>() + }; + let profile = |app: axum::Router, t: String| async move { + let r = api( + &app, + "GET", + "/api/v1/users/stalelevel/profile", + Some(&t), + None, + ) + .await; + assert_eq!(r.status, 200, "{}", r.text); + levels(&serde_json::from_str::(&r.text).unwrap()["wow_characters"]) + }; + + assert_eq!( + profile(app.clone(), t.clone()).await, + BTreeMap::from([("Lucroner".into(), 80), ("Vale Ashborne".into(), 22)]) + ); + + // The popup / profile fetch writes Blizzard's current level onto the card, + // without touching the addon's claim or /played seconds. + let later = chrono::Utc::now().timestamp_millis(); + fraglink_server::wow::apply_blizzard_profile( + &pool, + retail, + &json!({ + "id": 176815246i64, + "level": 81, + "character_class": {"name": "Paladin"}, + "race": {"name": "Human"}, + "gender": {"type": "MALE"}, + "faction": {"type": "ALLIANCE"}, + "active_spec": {"name": "Protection"}, + "last_login_timestamp": later + }), + ) + .await + .unwrap(); + fraglink_server::wow::apply_blizzard_profile( + &pool, + forever, + &json!({ + "id": 1395710, + "level": 27, + "character_class": {"name": "Paladin"}, + "race": {"name": "Undead"}, + "gender": {"type": "FEMALE"}, + "faction": {"type": "HORDE"}, + "last_login_timestamp": later + }), + ) + .await + .unwrap(); + + let shown = profile(app.clone(), t.clone()).await; + assert_eq!(shown[&"Lucroner".to_string()], 81, "{shown:?}"); + assert_eq!(shown[&"Vale Ashborne".to_string()], 27, "{shown:?}"); + + let raw: Vec<(String, i32, i64, Option, String)> = sqlx::query_as( + "SELECT name, level, played_s, bnet_level, class FROM wow_characters ORDER BY name", + ) + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!( + raw, + vec![ + ("Lucroner".into(), 80, 72000, Some(81), "PALADIN".into()), + ("Vale Ashborne".into(), 22, 9876, Some(27), "PALADIN".into()), + ] + ); + + // Account listing copy also covers Forever, still without rewriting played_s. + sqlx::query( + "INSERT INTO bnet_characters(user_id, game_id, region, realm_slug, name, bnet_id, level, class_name) + VALUES ($1, 'wow_forever', 'us', '', 'Vale Ashborne', 1395710, 28, 'PALADIN')", + ) + .bind(uid) + .execute(&pool) + .await + .unwrap(); + fraglink_server::wow::copy_bnet_levels(&pool, uid).await; + assert_eq!(profile(app, t).await[&"Vale Ashborne".to_string()], 28); + let played: i64 = sqlx::query_scalar("SELECT played_s FROM wow_characters WHERE id = $1") + .bind(forever) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(played, 9876); +} diff --git a/apps/site-web/static/assets/app.js b/apps/site-web/static/assets/app.js index 0f32904..613c38c 100644 --- a/apps/site-web/static/assets/app.js +++ b/apps/site-web/static/assets/app.js @@ -618,6 +618,30 @@ const factionCrest = value => { }; // Forever characters have no realm; the ruleset (PvP, Normal, …) is the server name. const wowRealm = c => (c.realm || c.ruleset || "").trim(); +// The popup's fresh Blizzard profile should show on the same character's cards. +const syncWowCardFromDetails = (c, d) => { + if (!c || !d || d.status === "missing") return; + if (d.level != null) c.level = d.level; + if (d.class) c.class = d.class; + if (d.race) c.race = d.race; + if (d.spec) c.spec = d.spec; + if (d.faction) c.faction = String(d.faction).toUpperCase(); + if (d.guild && d.guild.name && c.show_guild) c.guild = d.guild.name; + const id = String(c.id || ""); + if (!id || typeof document === "undefined" || !document.querySelectorAll) return; + document.querySelectorAll(`[data-wcid="${id}"]`).forEach(n => { + const lv = n.querySelector(".wc-lv"); + if (lv && c.level != null) lv.textContent = "Level " + c.level; + const cl = n.querySelector(".wc-cl"); + if (cl && c.class) { + cl.textContent = WOW.cls(c.class); + n.style.setProperty("--cls", WOW.color(c.class)); + } + const g = n.querySelector(".wc-g"); + if (g && c.guild) g.textContent = "<" + c.guild + ">"; + }); +}; + const groupWowRealms = rows => { const groups = new Map(); for (const c of rows) { @@ -1405,6 +1429,34 @@ pages.profile = async () => { }; const wowShow = p.wow_show === "wow" || p.wow_show === "wow_forever" || p.wow_show === "none" ? p.wow_show : "both"; const wowRows = flav => (p.wow_characters || []).filter(c => !c.hidden && (c.game_id === "wow_forever" ? "wow_forever" : "wow") === flav); + const currentWowFlav = () => { + const b = $("[data-wflav][aria-pressed='true'], [data-flav][aria-pressed='true']", root); + return (b && (b.dataset.wflav || b.dataset.flav)) || wowOpen || "wow"; + }; + const refreshVisibleWow = () => { + const flav = currentWowFlav(); + const gal = $("[data-wowgal]", root); + if (gal) { + gal.innerHTML = groupWowRealms(wowRows(flav)).map(g => `

${esc(g.realm)}${g.showRegion ? ` ${esc(g.region)}` : ""}${g.rows.length}

${g.rows.map(c => wowCard(c, false)).join("")}
`).join("") || `

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

`; + WOWAV.fill(root, p.handle); + return; + } + const box = $("[data-wowcards]", root); + if (!box) return; + const rows = wowRows(flav).slice().sort((a, b) => (a.featured || 9) - (b.featured || 9)); + box.innerHTML = rows.slice(0, 6).map(c => wowCard(c)).join("") || `

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

`; + WOWAV.fill(box, p.handle); + }; + const pullFreshWowCards = () => { + setTimeout(async () => { + try { + const fresh = await api("/users/" + encodeURIComponent(p.handle) + "/profile?limit=" + GPAGE + "&tz=" + encodeURIComponent(TZ), { keepSession: true }); + if (!fresh || !Array.isArray(fresh.wow_characters)) return; + p.wow_characters = fresh.wow_characters; + refreshVisibleWow(); + } catch (e) {} + }, 2500); + }; const bothSides = wowRows("wow").length > 0 && wowRows("wow_forever").length > 0; const wowToggle = typeof p.wow_toggle === "boolean" ? p.wow_toggle : wowShow === "both" && bothSides; const wowOpen = p.wow_open === "wow" || p.wow_open === "wow_forever" || p.wow_open === "none" ? p.wow_open @@ -1426,6 +1478,7 @@ pages.profile = async () => { paint(wowOpen); wireWowPop(); if (wowToggle) root.addEventListener("click", e => { const b = e.target.closest("[data-flav]"); if (!b) return; paint(b.dataset.flav); }); + pullFreshWowCards(); return; } const pres = p.presence; @@ -1577,6 +1630,7 @@ pages.profile = async () => { }); root.innerHTML = `
` + head + pendingDelete + welcome + `
${details}${games}${wowc}${ach}${miniprofile}
`; WOWAV.fill(root, p.handle); + if (wowc) pullFreshWowCards(); $$('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); if (sm) sm.addEventListener("click", e => { e.preventDefault(); $$(".olda", root).forEach(x => x.hidden = false); sm.parentNode.remove(); }); @@ -2293,7 +2347,7 @@ const WOWPOP = (() => { $(".cp-in", box).innerHTML = body({ name: c.name, level: c.level, class: c.class, race: c.race, region: c.region, realm: wowRealm(c), game_id: "wow_forever", armory_unavailable: true, gear: [], stats: [], avatar_src: c.avatar_src }, { ...c, handle }); WOWAV.fill(box, handle); WH.scan(); return; } - $(".cp-in", box).innerHTML = body(d, { ...c, handle }); WOWAV.fill(box, handle); WH.scan(); + $(".cp-in", box).innerHTML = body(d, { ...c, handle }); WOWAV.fill(box, handle); WH.scan(); if (typeof syncWowCardFromDetails === "function") syncWowCardFromDetails(c, d); const im = $(".cp-rd", box); if (im) { const t = localStorage.getItem(TK), r = await fetch(`${API}/users/${encodeURIComponent(handle)}/wow/${encodeURIComponent(c.id)}/render.png`, { headers: t ? { authorization: "Bearer " + t } : {} }); if (r.ok && box === me) { im.src = URL.createObjectURL(await r.blob()); im.onload = () => URL.revokeObjectURL(im.src); im.hidden = false; } } } diff --git a/apps/site-web/tests/wow-ui.test.cjs b/apps/site-web/tests/wow-ui.test.cjs index c189862..b65a570 100644 --- a/apps/site-web/tests/wow-ui.test.cjs +++ b/apps/site-web/tests/wow-ui.test.cjs @@ -74,3 +74,13 @@ test('Forever characters group under the ruleset name, never Unknown server', () assert.equal(retail[0].realm,'Area 52'); assert.equal(retail[1].realm,'PvP'); }); + +test('popup details update the matching profile and gallery card level', () => { + const {readFileSync}=require('node:fs'); + const source=readFileSync(__dirname+'/../static/assets/app.js','utf8'); + assert.ok(source.includes('const syncWowCardFromDetails')); + assert.ok(source.includes('syncWowCardFromDetails(c, d)')); + assert.ok(source.includes('pullFreshWowCards')); + assert.ok(source.includes('c.level = d.level')); + assert.ok(source.includes('.wc-lv') && source.includes('Level " + c.level')); +});