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')); +});