diff --git a/apps/desktop/addon/curseforge/description.md b/apps/desktop/addon/curseforge/description.md index 9adef30..a1d0ae1 100644 --- a/apps/desktop/addon/curseforge/description.md +++ b/apps/desktop/addon/curseforge/description.md @@ -1,19 +1,17 @@ -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. +Logs your /played time for each character so you can add it to your profile on [playfraglink.com](https://playfraglink.com). Works in both Retail and WoW Forever. -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. +Nothing is sent while you play. The addon saves to a file on your PC, and you upload it on the site whenever you like. ## 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. +Unzip into your `_retail_/Interface/AddOns` or `_forever_/Interface/AddOns` folder (or both), then restart WoW or type `/reload`. + +## How to use it -## Use +1. Log in to each character you want to track. +2. Type `/reload` or log out so WoW saves the file. +3. On playfraglink.com, go to your WoW page, pick Retail or Forever, and upload `WTF/Account//SavedVariables/FraglinkPlayed.lua`. -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. +Link your Battle.net account to get your Retail characters verified. -A line in chat says when a character was recorded. `/fragq` hides that line. +Type `/fragq` to hide the chat message. diff --git a/apps/desktop/addon/curseforge/summary.txt b/apps/desktop/addon/curseforge/summary.txt index 1ccc0ce..0d52546 100644 --- a/apps/desktop/addon/curseforge/summary.txt +++ b/apps/desktop/addon/curseforge/summary.txt @@ -1 +1 @@ -Saves Retail and WoW Forever /played on your PC for Fraglink. Uploads only add time and never lower your hours. +Tracks your /played time in Retail and WoW Forever so you can add it to your Fraglink profile. diff --git a/apps/server/migrations/0059_wow_profile_none.sql b/apps/server/migrations/0059_wow_profile_none.sql new file mode 100644 index 0000000..796e2c5 --- /dev/null +++ b/apps/server/migrations/0059_wow_profile_none.sql @@ -0,0 +1,6 @@ +-- Let a profile hide its WoW section. Existing rows are not updated. +-- Null still means Both. 'wow', 'wow_forever' and 'both' stay valid. + +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', 'none')); diff --git a/apps/server/migrations/0060_wow_faction.sql b/apps/server/migrations/0060_wow_faction.sql new file mode 100644 index 0000000..5c84122 --- /dev/null +++ b/apps/server/migrations/0060_wow_faction.sql @@ -0,0 +1,3 @@ +-- Profile metadata only. No backfill: populated by normal Blizzard profile refreshes. +ALTER TABLE wow_characters ADD COLUMN faction text + CHECK (faction IN ('HORDE', 'ALLIANCE')); diff --git a/apps/server/src/account.rs b/apps/server/src/account.rs index 08f4b03..8b09c85 100644 --- a/apps/server/src/account.rs +++ b/apps/server/src/account.rs @@ -118,9 +118,25 @@ pub async fn me_extra(st: &AppState, uid: Uuid) -> ApiResult { "timezone": crate::week::saved_zone(st, uid).await, "avatar_upload_allowed": avatars::upload_block_reason(st, uid).await?.is_none(), "avatar_upload_block": avatars::upload_block_reason(st, uid).await?, + "has_wow": has_wow(st, uid).await?, })) } +/// True when this account has any WoW time: a character, an import, or a tracked session. +async fn has_wow(st: &AppState, uid: Uuid) -> ApiResult { + let on: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM wow_characters WHERE user_id = $1) + OR EXISTS(SELECT 1 FROM user_game_stats WHERE user_id = $1 + AND game_id IN ('wow', 'wow_forever') AND display_min > 0) + OR EXISTS(SELECT 1 FROM sessions WHERE user_id = $1 + AND game_id IN ('wow', 'wow_forever'))", + ) + .bind(uid) + .fetch_one(&st.db) + .await?; + Ok(on) +} + fn notify_defaults(p: &Value) -> Value { let b = |k: &str, d: bool| p.get(k).and_then(Value::as_bool).unwrap_or(d); json!({ diff --git a/apps/server/src/profile.rs b/apps/server/src/profile.rs index 950ac21..a57f183 100644 --- a/apps/server/src/profile.rs +++ b/apps/server/src/profile.rs @@ -494,7 +494,18 @@ async fn profile( // 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 + let wow_default = crate::wowpage::default_flavour(&st, p.id).await?; + let wow_characters = if wow_show == "none" + || !(can_see_games + && show("wow_characters") + && crate::features::on(&st, "wow_characters").await) + { + if wow_show == "none" { + json!([]) + } else { + Value::Null + } + } else if can_see_games && show("wow_characters") && crate::features::on(&st, "wow_characters").await { @@ -526,6 +537,17 @@ async fn profile( } else { Value::Null }; + let (retail_n, forever_n) = match wow_characters.as_array() { + Some(rows) => ( + rows.iter().filter(|x| x["game_id"] == "wow").count() as i64, + rows.iter() + .filter(|x| x["game_id"] == "wow_forever") + .count() as i64, + ), + None => (0, 0), + }; + let (wow_open, wow_toggle) = + crate::wowpage::public_roster(&wow_show, &wow_default, retail_n, forever_n); if can_see_history { recent = recent_sessions(&st, p.id, is_self).await?; recent.retain(|r| { @@ -635,8 +657,10 @@ async fn profile( "events": events, "achievements": achievements, "wow_characters": wow_characters, - "wow_default": crate::wowpage::default_flavour(&st, p.id).await?, + "wow_default": wow_default, "wow_show": wow_show, + "wow_open": wow_open, + "wow_toggle": wow_toggle, "rig": if has_rig && show("rig") { rig } else { Value::Null }, "friends": friend_grid, "linked": linked, diff --git a/apps/server/src/wow.rs b/apps/server/src/wow.rs index 76c4cdc..34d5e5a 100644 --- a/apps/server/src/wow.rs +++ b/apps/server/src/wow.rs @@ -308,6 +308,14 @@ pub async fn add_armory_character( .await?; id }; + sqlx::query( + "UPDATE wow_characters SET faction = coalesce($2, faction) WHERE id = $1 AND user_id = $3", + ) + .bind(id) + .bind(profile_faction(profile)) + .bind(uid) + .execute(&st.db) + .await?; // 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 = @@ -585,7 +593,7 @@ async fn fetch_forever_profile(st: &AppState, id: Uuid, e: &CharEntry) -> bool { 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() + gender = coalesce($5, gender), bnet_checked_at = now(), faction = coalesce($6, faction) WHERE id = $1", ) .bind(id) @@ -593,6 +601,7 @@ async fn fetch_forever_profile(st: &AppState, id: Uuid, e: &CharEntry) -> bool { .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}")); @@ -2018,7 +2027,7 @@ async fn check_bnet_level(st: &AppState, uid: Uuid, gid: &str, id: Uuid, e: &Cha 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) + bnet_id = coalesce(bnet_id, $7), faction = coalesce($8, faction) WHERE id = $1", ) .bind(id) @@ -2028,6 +2037,7 @@ async fn check_bnet_level(st: &AppState, uid: Uuid, gid: &str, id: Uuid, e: &Cha .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}")); @@ -2056,6 +2066,13 @@ pub(crate) fn name_of(v: &Value) -> Option { // ------------------------------------------------------------------ profile +/// Blizzard's stable API value, not a localized faction name or a race guess. +pub fn profile_faction(profile: &Value) -> Option<&str> { + profile["faction"]["type"] + .as_str() + .filter(|f| matches!(*f, "HORDE" | "ALLIANCE")) +} + /// Characters for a profile page. `all`: the owner's own view (hidden ones too). /// Profile cards list Battle.net-verified Retail characters (`verified_only`). Forever /// characters are included too, without a tick, until `BNET_FOREVER_ACCOUNT` verifies @@ -2085,6 +2102,7 @@ pub async fn characters( show_guild: bool, guild: Option, featured: Option, + faction: Option, } let rows: Vec = sqlx::query_as( concat!("SELECT id, game_id, region, realm, name, class, ", shown_level!(), " AS level, played_s, played_at, verified, @@ -2092,7 +2110,7 @@ pub async fn characters( race, gender, coalesce(spec, bnet_spec) AS spec, show_guild, CASE WHEN show_guild THEN (SELECT d.data->'guild'->>'name' FROM wow_char_details d WHERE d.char_id = wow_characters.id AND d.status = 'ok') END AS guild, - featured + featured, coalesce(faction, (SELECT CASE upper(d.data->>'faction') WHEN 'HORDE' THEN 'HORDE' WHEN 'ALLIANCE' THEN 'ALLIANCE' END FROM wow_char_details d WHERE d.char_id = wow_characters.id AND d.status = 'ok')) AS faction 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 OR game_id = 'wow_forever') @@ -2110,7 +2128,7 @@ pub async fn characters( "name": c.name, "class": c.class, "level": c.level, "played_min": c.played_s / 60, "played_at": c.played_at, "verified": c.verified, "hidden": c.hid, "race": c.race, "gender": c.gender, "spec": c.spec, "show_guild": c.show_guild, "guild": c.guild, - "featured": c.featured }) + "featured": c.featured, "faction": c.faction }) }) .collect()) } @@ -2279,6 +2297,27 @@ mod tests { const NOW: i64 = 1_800_000_000; + #[test] + fn faction_uses_only_stable_blizzard_type() { + assert_eq!( + profile_faction(&json!({"faction": {"type": "HORDE", "name": "Horde"}})), + Some("HORDE") + ); + assert_eq!( + profile_faction(&json!({"faction": {"type": "ALLIANCE"}})), + Some("ALLIANCE") + ); + assert_eq!( + profile_faction(&json!({"faction": {"type": "NEUTRAL"}})), + None + ); + assert_eq!( + profile_faction(&json!({"faction": {"name": "Alliance"}})), + None + ); + assert_eq!(profile_faction(&json!({})), None); + } + #[test] fn forever_profile_url_has_no_realm() { let url = forever_profile_url("eu", "Vale", "Mocken"); diff --git a/apps/server/src/wowchar.rs b/apps/server/src/wowchar.rs index 317d203..bff68b3 100644 --- a/apps/server/src/wowchar.rs +++ b/apps/server/src/wowchar.rs @@ -409,6 +409,12 @@ pub async fn refresh(st: &AppState, id: Uuid) -> Result<(), ()> { .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}")); let sub = |p: &str| format!("{base}/{p}?{ns}"); let u: Vec = [ "character-media", @@ -783,7 +789,7 @@ pub fn summarise(p: &Parts) -> Value { "name": name, "region": p.region, "realm": name_of(&r["realm"]), "realm_slug": realm_slug, "level": r["level"].as_i64(), "race": name_of(&r["race"]), "class": name_of(&r["character_class"]), "class_id": r["character_class"]["id"].as_i64(), - "spec": name_of(&r["active_spec"]), "faction": name_of(&r["faction"]), + "spec": name_of(&r["active_spec"]), "faction": crate::wow::profile_faction(r).map(str::to_string).or_else(|| name_of(&r["faction"])), "title": r["active_title"]["display_string"].as_str().map(|t| t.replace("{name}", name)), "ilvl": r["equipped_item_level"].as_i64(), "achievement_points": r["achievement_points"].as_i64(), "last_login": r["last_login_timestamp"].as_i64(), diff --git a/apps/server/src/wowpage.rs b/apps/server/src/wowpage.rs index ea52c0b..882ac72 100644 --- a/apps/server/src/wowpage.rs +++ b/apps/server/src/wowpage.rs @@ -81,7 +81,7 @@ 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", @@ -92,10 +92,34 @@ pub(crate) async fn profile_show(st: &AppState, uid: Uuid) -> ApiResult Ok(match stored.as_deref() { Some("wow") => "wow".into(), Some("wow_forever") => "wow_forever".into(), + Some("none") => "none".into(), _ => "both".into(), }) } +/// Which roster the public page opens on, and whether the Retail/Forever toggle shows. +/// Counts are visible characters. A side with nobody on it is not offered. +pub fn public_roster(show: &str, default: &str, retail: i64, forever: i64) -> (String, bool) { + if show == "none" { + return ("none".into(), false); + } + if show == "wow" || show == "wow_forever" { + return (show.into(), false); + } + if retail > 0 && forever > 0 { + let open = if default == "wow_forever" { + "wow_forever" + } else { + "wow" + }; + return (open.into(), true); + } + if forever > 0 { + return ("wow_forever".into(), false); + } + ("wow".into(), false) +} + #[derive(Deserialize)] struct ShowReq { show: String, @@ -108,10 +132,10 @@ async fn set_profile_show( Json(r): Json, ) -> ApiResult> { api::check_origin(&a, &headers, &st)?; - if r.show != "wow" && r.show != "wow_forever" && r.show != "both" { + if r.show != "wow" && r.show != "wow_forever" && r.show != "both" && r.show != "none" { return Err(AppError::bad_request( "invalid_show", - "Choose Retail only, Forever only, or Both.", + "Choose Retail, Forever, Both, or None.", )); } sqlx::query( @@ -257,6 +281,7 @@ async fn list( bnet_spec: Option, show_guild: bool, verified: bool, + faction: Option, former: Option, uploaded_at: DateTime, } @@ -266,7 +291,7 @@ async fn list( 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) AS former, - uploaded_at + uploaded_at, coalesce(faction, (SELECT CASE upper(d.data->>'faction') WHEN 'HORDE' THEN 'HORDE' WHEN 'ALLIANCE' THEN 'ALLIANCE' END FROM wow_char_details d WHERE d.char_id = wow_characters.id AND d.status = 'ok')) AS faction FROM wow_characters WHERE {filter} ORDER BY {order} LIMIT $7 OFFSET $8", lvl = crate::wow::shown_level!() ))) @@ -323,6 +348,7 @@ async fn list( show_guild: sg, verified, former, + faction, uploaded_at, } = c; let (former_name, former_realm) = former @@ -349,7 +375,7 @@ async fn list( "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, - "uploaded_at": uploaded_at }) + "uploaded_at": uploaded_at, "faction": faction }) }) .collect(); let (retail, forever): (i64, i64) = sqlx::query_as( @@ -636,6 +662,27 @@ mod tests { assert!(specs_of("DEATHKNIGHT").contains(&"Unholy")); assert!(specs_of("nope").is_empty()); } + + #[test] + fn roster_toggle_only_when_both_versions_are_visible() { + assert_eq!( + public_roster("both", "wow_forever", 2, 3), + ("wow_forever".into(), true) + ); + assert_eq!( + public_roster("both", "wow_forever", 2, 0), + ("wow".into(), false) + ); + assert_eq!( + public_roster("both", "wow", 0, 4), + ("wow_forever".into(), false) + ); + assert_eq!( + public_roster("wow", "wow_forever", 1, 1), + ("wow".into(), false) + ); + assert_eq!(public_roster("none", "wow", 3, 3), ("none".into(), false)); + } } // ------------------------------------------------------------------ featured diff --git a/apps/server/tests/wow_cards.rs b/apps/server/tests/wow_cards.rs index 34ed817..b6ca672 100644 --- a/apps/server/tests/wow_cards.rs +++ b/apps/server/tests/wow_cards.rs @@ -16,7 +16,8 @@ async fn names(v: &Value) -> Vec { #[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(); + let sql = include_str!("../migrations/0058_wow_default_flavour.sql").to_ascii_lowercase() + + include_str!("../migrations/0059_wow_profile_none.sql"); for line in sql.lines() { let t = line.trim(); if t.starts_with("--") { @@ -123,6 +124,42 @@ async fn profile_show_filters_the_page_and_keeps_every_row(pool: PgPool) { vec!["Vale Mocken".to_string()] ); set("both").await; + let both = profile().await; + assert_eq!(both["wow_toggle"], true); + assert_eq!(both["wow_open"], "wow"); + sqlx::query( + "UPDATE wow_characters SET hidden = true WHERE user_id = $1 AND game_id = 'wow_forever'", + ) + .bind(uid) + .execute(&pool) + .await + .unwrap(); + let one = profile().await; + assert_eq!(one["wow_show"], "both"); + assert_eq!(one["wow_toggle"], false); + assert_eq!(one["wow_open"], "wow"); + assert!(one["wow_characters"] + .as_array() + .unwrap() + .iter() + .all(|c| c["game_id"] == "wow")); + sqlx::query( + "UPDATE wow_characters SET hidden = false WHERE user_id = $1 AND game_id = 'wow_forever'", + ) + .bind(uid) + .execute(&pool) + .await + .unwrap(); + set("none").await; + let hidden_page = profile().await; + assert_eq!(hidden_page["wow_show"], "none"); + assert_eq!(hidden_page["wow_toggle"], false); + assert_eq!(hidden_page["wow_characters"].as_array().unwrap().len(), 0); + let me_none = api(&app, "GET", "/api/v1/me/bnet", Some(&t), None).await; + assert_eq!(me_none.json()["profile_show"], "none"); + let me_user = api(&app, "GET", "/api/v1/me", Some(&t), None).await; + assert_eq!(me_user.json()["user"]["has_wow"], true); + set("both").await; let r = api( &app, "POST", @@ -135,7 +172,12 @@ async fn profile_show_filters_the_page_and_keeps_every_row(pool: PgPool) { let p = profile().await; assert_eq!(p["wow_show"], "both"); assert_eq!(p["wow_default"], "wow_forever"); + assert_eq!(p["wow_open"], "wow_forever"); + assert_eq!(p["wow_toggle"], true); assert_eq!(names(&p).await.len(), 2); + let t2 = signup(&app, "nowow@example.com", "nowow").await; + let bare = api(&app, "GET", "/api/v1/me", Some(&t2), None).await; + assert_eq!(bare.json()["user"]["has_wow"], false); let stored: Vec<(String, i64, bool)> = sqlx::query_as( "SELECT name, played_s, hidden FROM wow_characters WHERE user_id = $1 ORDER BY name", diff --git a/apps/server/tests/wow_faction.rs b/apps/server/tests/wow_faction.rs new file mode 100644 index 0000000..35a605f --- /dev/null +++ b/apps/server/tests/wow_faction.rs @@ -0,0 +1,92 @@ +mod common; +use axum::http::StatusCode; +use common::*; +use serde_json::json; +use sqlx::PgPool; +use uuid::Uuid; + +#[sqlx::test(migrator = "fraglink_server::MIGRATOR")] +async fn faction_metadata_is_optional_and_does_not_change_hours(pool: PgPool) { + let migration = include_str!("../migrations/0060_wow_faction.sql"); + assert!(!migration.to_lowercase().contains("update ")); + assert!(!migration.to_lowercase().contains("delete ")); + let st = state(pool.clone()); + let app = fraglink_server::router(st.clone()); + let token = signup(&app, "factions@example.com", "factiontest").await; + let uid: Uuid = sqlx::query_scalar("SELECT id FROM users WHERE handle = 'factiontest'") + .fetch_one(&pool) + .await + .unwrap(); + let mut ids = Vec::new(); + for (i, faction) in [Some("HORDE"), Some("ALLIANCE"), None, None] + .iter() + .enumerate() + { + let id = Uuid::new_v4(); + sqlx::query("INSERT INTO wow_characters(id,user_id,game_id,region,realm,realm_slug,name,class,played_s,played_at,verified,faction) VALUES($1,$2,'wow','us','Area 52','area-52',$3,'MAGE',7200,to_timestamp(1700000000),true,$4)") + .bind(id).bind(uid).bind(format!("Sample{i}")).bind(faction).execute(&pool).await.unwrap(); + ids.push(id); + } + // Old cached Blizzard summaries can supply a faction without updating the character. + sqlx::query( + "INSERT INTO wow_char_details(char_id,status,data,fetched_at) VALUES($1,'ok',$2,now())", + ) + .bind(ids[3]) + .bind(json!({"faction":"Alliance"})) + .execute(&pool) + .await + .unwrap(); + let before: Vec<(Uuid, i64, chrono::DateTime)> = sqlx::query_as( + "SELECT id,played_s,played_at FROM wow_characters WHERE user_id=$1 ORDER BY id", + ) + .bind(uid) + .fetch_all(&pool) + .await + .unwrap(); + let chars = fraglink_server::wow::characters(&st, uid, true, false) + .await + .unwrap(); + for (id, expected) in ids.iter().zip([ + json!("HORDE"), + json!("ALLIANCE"), + json!(null), + json!("ALLIANCE"), + ]) { + assert_eq!( + chars.iter().find(|c| c["id"] == id.to_string()).unwrap()["faction"], + expected + ); + } + let manager = api(&app, "GET", "/api/v1/me/wow/list", Some(&token), None).await; + assert_eq!(manager.status, StatusCode::OK, "{}", manager.text); + let rows = manager.json(); + let items = rows["items"].as_array().unwrap(); + assert!(items.iter().any(|c| c["faction"] == "HORDE")); + assert!(items.iter().any(|c| c["faction"].is_null())); + // A normal profile lookup fills/changes faction; absent type keeps the last value. + for profile in [ + json!({"name":"Sample0","level":80,"faction":{"type":"ALLIANCE"}}), + json!({"name":"Sample0","level":80}), + ] { + fraglink_server::wow::add_armory_character( + &st, uid, "us", "Area 52", "Sample0", &profile, "wow", + ) + .await + .unwrap(); + } + let faction: Option = + sqlx::query_scalar("SELECT faction FROM wow_characters WHERE id=$1") + .bind(ids[0]) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(faction.as_deref(), Some("ALLIANCE")); + let after: Vec<(Uuid, i64, chrono::DateTime)> = sqlx::query_as( + "SELECT id,played_s,played_at FROM wow_characters WHERE user_id=$1 ORDER BY id", + ) + .bind(uid) + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!(before, after); +} diff --git a/apps/site-web/pages/faq.html b/apps/site-web/pages/faq.html index 2b8ed52..274358c 100644 --- a/apps/site-web/pages/faq.html +++ b/apps/site-web/pages/faq.html @@ -21,7 +21,7 @@
My game isn't detected. What now?

If an unknown program runs full screen for 5 minutes, Fraglink asks "Playing X? Add it!". Choose Add it! or Not a game. Missed it? Open Options › Games: unanswered ones wait under Detected, not added, and Not seeing your game? Add it! lets you pick the exe yourself. Scan for games searches your game folders. Anything you add is sent to us for review. Until then, the name you gave it is what friends and your profile show.

Can I bring my existing hours?

Yes, from Steam, and World of Warcraft /played hours with the Fraglink /played addon after linking Battle.net. Link Steam in Settings › Connections or in the app under Options › Connections. Your playtime is imported once (press Import hours again any time to refresh it), and hours Fraglink tracks after that add on top. The same goes for WoW /played, and time the app tracks in WoW's menus and loading screens (which /played leaves out) is kept when you upload again. Your Steam game details need to be public.

What do Tracked and Imported mean?

Tracked is time Fraglink recorded, plus Steam playtime. Imported is a game's own counter. A game's total is your latest import plus whatever Fraglink tracked after it, so nothing gets counted twice. You don't have to re-import, and if a new import comes in lower, the higher total stays. Leaderboards use the same total you see. Steam doesn't sync by itself, hit Import hours to refresh it.

WoW counts each character once (by its Battle.net character id) at its highest /played. Rename it or move realms and it's still one character. Sell it or move it to another Battle.net account and it only counts for the new owner. Delete it and the hours stay. WoW Forever isn't out yet, so the app tracks Forever hours for now, and Forever /played uploads open once it launches.

-
Can I choose which WoW characters my profile shows?

Yes. In Manage WoW (Settings › Connections › Battle.net) you can hide characters, which removes them from your profile for everyone, you included, and pick up to 3 featured characters in the order you want. Featured characters show first; with none picked your most played show first. Featured picks are for Retail for now (Forever once its uploads open), and hidden characters can't be featured. Hidden characters still count toward your hours.

+
Can I choose which WoW characters my profile shows?

Yes. In Account settings › World of Warcraft you can pick Retail, Forever, both, or none, and choose which characters show first.

Can I add hours by hand?

No. Manual hour imports are turned off. You can still hide a game or reset its hours in Settings › Played games.

diff --git a/apps/site-web/static/assets/app.js b/apps/site-web/static/assets/app.js index f9a43c9..65f8c6c 100644 --- a/apps/site-web/static/assets/app.js +++ b/apps/site-web/static/assets/app.js @@ -506,38 +506,48 @@ pages.changelog = async () => { pages.wowupload = async () => { const el = $("#wu"); if (!token()) { location.href = "/login?next=" + encodeURIComponent(location.pathname); return; } - const b = BRAND.bnet, logo = ``; const plural = (n, one, many) => `${nf.format(n)} ${n === 1 ? one : (many || one + "s")}`; const STEPS = [["read", "Reading the file"], ["send", "Sending it securely to Fraglink"], ["check", "Checking signatures, BattleTag and /played history"], ["match", "Matching your Battle.net characters"]]; - 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.

+ const PATHS = { + wow: "World of Warcraft\\_retail_\\WTF\\Account\\\\SavedVariables\\FraglinkPlayed.lua", + wow_forever: "World of Warcraft\\_forever_\\WTF\\Account\\\\SavedVariables\\FraglinkPlayed.lua", + }; + const asked = new URLSearchParams(location.search); + const raw = (asked.get("version") || asked.get("game") || "retail").toLowerCase(); + let game = raw === "forever" || raw === "wow_forever" ? "wow_forever" : "wow"; + el.innerHTML = `
+
+

Upload Retail /played

+

Log in to each character with the addon on, then /reload or log out so WoW saves the file.

+
-
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.
- -
File
-
Where is FraglinkPlayed.lua? -

Log out of WoW (or type /reload) first so the game saves it, then open:

-

World of Warcraft\\_retail_\\WTF\\Account\\<ACCOUNT>\\SavedVariables\\FraglinkPlayed.lua

-

Forever uses the same file name under _forever_, not _retail_.

-

If you have several WoW accounts there (WoW1, WoW2, or a family member's), upload each account's file. Characters from a Battle.net account other than the one you linked are refused.

+ +
+

Got more than one WoW account? Upload each one's file.

+
`; 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 setGame = next => { + game = next === "wow_forever" ? "wow_forever" : "wow"; + form.game.value = game; + const label = game === "wow_forever" ? "Forever" : "Retail"; + $("[data-title]", el).textContent = `Upload ${label} /played`; + $("button[type=submit]", form).textContent = `Upload ${label} /played`; + const order = game === "wow_forever" ? ["wow_forever", "wow"] : ["wow", "wow_forever"]; + const names = { wow: "Retail", wow_forever: "Forever" }; + $("[data-paths]", el).innerHTML = order.map(id => `

${names[id]}${esc(PATHS[id])}

`).join(""); + $$("[data-ver]", el).forEach(b => { const on = b.dataset.ver === game; b.classList.toggle("on", on); b.setAttribute("aria-selected", on ? "true" : "false"); }); + }; + setGame(game); + el.addEventListener("click", e => { const b = e.target.closest("[data-ver]"); if (b) setGame(b.dataset.ver); }); 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; const CAP = 25; - const STATUS = { v: [0, "vbadge", "✓ Verified via Battle.net"], s: [1, "srbadge", "◆ Not verified, not counted"], r: [2, "badbadge", "✕ Rejected"] }; + const STATUS = { v: [0, "vbadge", "Verified"], s: [1, "srbadge", "Not verified"], r: [2, "badbadge", "Rejected"] }; const draw = () => { if (!totals) return; const key = { name: r => r.name.toLowerCase(), realm: r => r.realm.toLowerCase(), level: r => r.level || 0, played: r => r.played_min, status: r => STATUS[r.st][0] }[sortBy]; @@ -546,14 +556,13 @@ pages.wowupload = async () => { const th = (k, t) => ``; const cls = c => c ? c.charAt(0) + c.slice(1).toLowerCase().replace(/knight$/, " Knight").replace(/hunter$/, " Hunter") : ""; res.innerHTML = `

Results

-
${hours(totals.verified, { zero: true })}Counted: verified via Battle.net
${totals.unverified ? `
${hours(totals.unverified)}Not counted until verified
` : ""}
+
${hours(totals.verified, { zero: true })}Verified
${totals.unverified ? `
${hours(totals.unverified)}Not verified
` : ""}
${rows.length ? `
${th("name", "Character")}${th("realm", "Realm")}${th("level", "Class / level")}${th("played", "Hours played")}${th("status", "Status")} ${shown.map(r => ``).join("")}
${esc(r.name)}${esc(r.realm)} ${esc(r.region.toUpperCase())}${r.game_id === "wow_forever" ? " · Forever" : ""} ${esc([cls(r.class), r.level ? "level " + r.level : ""].filter(Boolean).join(" · ")) || `–`} ${hours(r.played_min)}${r.chg ? `
${esc(r.chg)}` : ""}
${STATUS[r.st][2]}${r.why ? `
${esc(r.why)}` : r.st === "s" ? `
Verify WoW characters` : ""}
${list.length > CAP ? `

` : ""}` : `

No characters uploaded yet.

`} -

Only characters Battle.net confirms are on your account count toward your hours. Unverified ones are kept and start counting once they verify: Battle.net has to list the character on your linked account, and the file has to be saved under that account's BattleTag.

-

🔒 Verify WoW characters Back to Battle.net

`; +

Verify characters Back to World of Warcraft

`; }; res.addEventListener("click", e => { const s = e.target.closest("[data-sort]"); @@ -585,19 +594,44 @@ pages.wowupload = async () => { try { m = await api("/me/wow"); } catch (x) { return fail("match", x); } const game = form.game.value, mine = m.characters.filter(c => c.game_id === game); const nv = mine.filter(c => c.verified).length; - step("match", m.bnet === "linked" ? "ok" : "warn", m.bnet === "none" ? "Battle.net isn't linked, so these aren't counted yet" : m.bnet === "reconnect" ? "Reconnect Battle.net to verify characters" : `${nf.format(nv)} of ${plural(mine.length, "character")} verified`); + step("match", m.bnet === "linked" ? "ok" : "warn", m.bnet === "none" ? "Battle.net isn't linked." : m.bnet === "reconnect" ? "Reconnect Battle.net to verify characters." : `${nf.format(nv)} of ${plural(mine.length, "character")} verified`); const ck = c => [c.region, c.realm, c.name].join("|").toLowerCase(); const chg = new Map((u.changes || []).map(c => [ck(c), c.first ? "First upload" : c.delta_s > 0 ? `+${(c.delta_s / 3600).toFixed(1)} hours since last upload` : "No change since last upload"])); rows = mine.map(c => ({ ...c, st: c.verified ? "v" : "s", chg: chg.get(ck(c)) })) .concat(u.rejected.map(r => ({ ...r, game_id: game, st: "r", why: R[r.reason] || r.detail }))); totals = { verified: mine.filter(c => c.verified).reduce((a, c) => a + c.played_min, 0), unverified: mine.filter(c => !c.verified).reduce((a, c) => a + c.played_min, 0) }; showAll = false; draw(); - btn.disabled = false; form.reset(); form.game.value = game; + btn.disabled = false; form.reset(); setGame(game); }); }; /* ---------- /connect/battlenet/verify: the real verification steps, shown as they run ---------- */ /* ---------- WoW icons (Blizzard render CDN through /api/v1/wow/icons, cached by the server) ---------- */ +// Faction comes from Blizzard, never from a character's race or class. +const factionCrest = value => { + const faction = String(value || "").toUpperCase(); + if (faction !== "HORDE" && faction !== "ALLIANCE") return ""; + const name = faction === "HORDE" ? "Horde" : "Alliance"; + const asset = `/assets/wow-factions/${faction.toLowerCase()}`; + return `${name}`; +}; +const groupWowRealms = rows => { + const groups = new Map(); + for (const c of rows) { + const realm = (c.realm || "").trim() || "Unknown server", region = (c.region || "").toUpperCase(); + const key = realm.toLowerCase() + "|" + region; + if (!groups.has(key)) groups.set(key, { realm, region, rows: [] }); + groups.get(key).rows.push(c); + } + const realms = [...groups.values()]; + const regions = new Map(); + for (const g of realms) { + const key = g.realm.toLowerCase(); + if (!regions.has(key)) regions.set(key, new Set()); + regions.get(key).add(g.region); + } + return realms.sort((a, b) => b.rows.length - a.rows.length || a.realm.localeCompare(b.realm, "en", { sensitivity: "base" }) || a.region.localeCompare(b.region)).map(g => ({ ...g, showRegion: regions.get(g.realm.toLowerCase()).size > 1 })); +}; const WOW = (() => { const S = { WARRIOR: [["Arms", "ability_warrior_savageblow"], ["Fury", "ability_warrior_innerrage"], ["Protection", "ability_warrior_defensivestance"]], PALADIN: [["Holy", "spell_holy_holybolt"], ["Protection", "ability_paladin_shieldofthetemplar"], ["Retribution", "spell_holy_auraoflight"]], @@ -637,29 +671,34 @@ const WOW = (() => { const imgOk = "data-hide-broken"; /* ---------- /connect/battlenet: the Battle.net account, verified WoW characters, spec picks, hide toggles ---------- */ -pages.bnet = async () => { - const el = $("#bn"); - if (!token()) { location.href = "/login?next=" + encodeURIComponent(location.pathname); return; } +pages.bnet = () => wowManager($("#bn")); +async function wowManager(el) { + if (!el) return; + if (!token()) { location.href = "/login?next=" + encodeURIComponent(location.pathname + location.search + location.hash); return; } + const embedded = el.dataset.embed === "1"; + const Hx = embedded ? "h3" : "h2"; + const Sec = embedded ? "bn-sec" : "panel bn-box"; const b = BRAND.bnet, logo = s => ``; 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 show = s.profile_show === "wow" || s.profile_show === "wow_forever" || s.profile_show === "none" ? 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 flavSeg = `
Show on profile
Profile opens on
`; const head = s.linked ? `
${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}
-

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

-

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

+ el.innerHTML = `
<${Hx}>Battle.net
${head}
+
<${Hx}>Profile page display${handle ? `View profile` : ""} ${flavSeg}${s.linked ? `
` : ""}
-
- +
+
<${Hx}>Add characters +
+
<${Hx}>Your characters
@@ -668,27 +707,20 @@ pages.bnet = async () => {

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, 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]; - }; + ? `
${regions}
` + : `
${regions}
`; 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(" · "); 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)}
` : ""}
+
${esc(c.name)}${factionCrest(c.faction)}${c.verified ? `✓` : ""}${was ? `
formerly ${esc(was)}
` : ""}
${c.realm ? esc(c.realm) + " " : ""}${esc((c.region || "").toUpperCase())}
${esc(cls || "—")}
${hours(c.played_min, { zero: true })}
@@ -700,14 +732,13 @@ pages.bnet = async () => {
`; }; const render = () => { - 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 ? `` : ""); + const d = st.data, label = { wow: "Retail", wow_forever: "Forever" }; + $("[data-tabs]", el).innerHTML = ["wow", "wow_forever"].map(id => ``).join(""); $("[data-addbox]", el).innerHTML = addForm(st.game); - const up = $("[data-upload]", el); if (up) up.href = "/connect/wow?game=" + st.game; + const up = $("[data-upload]", el); if (up) { up.href = "/connect/wow?version=" + (st.game === "wow_forever" ? "forever" : "retail"); up.textContent = "Upload " + (st.game === "wow_forever" ? "Forever" : "Retail") + " /played"; } + const filtered = d && d.matched === 0 && (st.q || st.class || st.vis); list.innerHTML = st.items.length ? st.items.map(row).join("") - : `

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

`; + : `

${filtered ? "No characters match." : "No characters yet. Add one above, or upload a /played file."}

`; 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"; @@ -786,9 +817,7 @@ pages.bnet = async () => { 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; } + if (tab) { st.game = tab.dataset.tab; load(false); return; } const menuBtn = e.target.closest("[data-menu]"); if (menuBtn) { const pop = menuBtn.nextElementSibling; @@ -800,7 +829,7 @@ pages.bnet = async () => { 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; } - if (!confirm("Unlink Battle.net? Your characters stop counting toward your hours until you link it again. Their data is kept.")) return; + if (!confirm("Unlink Battle.net? You can link it again later.")) return; t.disabled = true; await api("/connect/bnet", { method: "DELETE" }); location.reload(); } catch (x) { t.disabled = false; showErr(msgBox, x); } }); @@ -861,7 +890,7 @@ pages.bnetverify = async () => { const b = BRAND.bnet, logo = ``; const STEPS = [["connect", "Connecting to Battle.net"], ["fetch", "Fetching your characters"], ["match", "Matching /played data"], ["sig", "Checking signatures"]]; el.innerHTML = `
${logo}🔒

Verify WoW characters

-

Fraglink asks Battle.net which characters are yours, then checks each one against the signed /played data from the Fraglink addon. Only verified characters count on leaderboards.

+

Checks which characters are on the Battle.net account you linked.

    ${STEPS.map(([k, t]) => `
  1. ${t}
  2. `).join("")}
`; const step = (k, state, note) => { const li = $(`[data-s="${k}"]`, el); li.className = state; if (note != null) $("small", li).textContent = note; }; @@ -900,7 +929,7 @@ pages.bnetverify = async () => { res.innerHTML = `

Results

    ${me_.characters.map(c => `
  • ${c.verified ? "✓ Verified" : "◆ Not verified, not counted"} ${esc(c.name)} ${esc(c.realm)} · ${esc(c.region.toUpperCase())}${c.game_id === "wow_forever" ? " · Forever" : ""} · ${Math.floor(c.played_min / 60)}h
  • `).join("") || `
  • No characters uploaded yet.
  • `} ${s.refused.map(r => `
  • ✕ Refused ${esc(r.character)} ${esc(R[r.reason] || r.reason)}
  • `).join("")}
-

Only characters Battle.net confirms are on your account count toward your hours. A character verifies when Battle.net lists it on the same account as the BattleTag saved by the addon.

`; +

Verified means the character is on your linked Battle.net account.

`; act.innerHTML = `Done`; }; @@ -1297,15 +1326,16 @@ pages.profile = async () => { // 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 wowCard = (c, showRealm = true) => { 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 realm = showRealm && c.realm ? `
${esc(c.realm)}
` : ""; 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 line = [lvl && `${esc(lvl)}`, cls && `${esc(cls)}`].filter(Boolean).join(" "); const open = poppable(c); - return `
${ini}
${esc(c.name)}${tick}${line ? `
${line}
` : ""}${guild}
`; + return `
${ini}
${esc(c.name)}${factionCrest(c.faction)}${tick}${line ? `
${line}
` : ""}${guild}${realm}
`; }; const wireWowPop = () => { if (root._wowpop) return; @@ -1323,18 +1353,29 @@ pages.profile = async () => { go(n); }); }; - const wowShow = p.wow_show === "wow" || p.wow_show === "wow_forever" ? p.wow_show : "both"; + 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 wowOpen = wowShow === "both" ? (p.wow_default === "wow_forever" ? "wow_forever" : "wow") : wowShow; + 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 + : wowShow === "none" ? "none" + : wowShow === "wow" || wowShow === "wow_forever" ? wowShow + : bothSides ? (p.wow_default === "wow_forever" ? "wow_forever" : "wow") + : wowRows("wow_forever").length && !wowRows("wow").length ? "wow_forever" : "wow"; + const manage = p.relation === "self" ? `Manage` : ""; 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 = ``; + if (wowShow === "none" || wowOpen === "none") { + root.innerHTML = `
← ${esc(p.display_name || p.handle)}

WoW characters

No WoW characters on this profile.

${manage ? `

${manage}

` : ""}
`; + return; + } + const paint = flav => { const box = $("[data-wowgal]", root); if (box) box.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.

`; $$("[data-flav]", root).forEach(b => b.setAttribute("aria-pressed", b.dataset.flav === flav ? "true" : "false")); }; + const tog = wowToggle ? `
` : ""; + root.innerHTML = `
← ${esc(p.display_name || p.handle)}

WoW characters

${tog}
${manage ? `

${manage}

` : ""}
`; paint(wowOpen); wireWowPop(); - if (wowShow === "both") root.addEventListener("click", e => { const b = e.target.closest("[data-flav]"); if (!b) return; paint(b.dataset.flav); }); + if (wowToggle) root.addEventListener("click", e => { const b = e.target.closest("[data-flav]"); if (!b) return; paint(b.dataset.flav); }); return; } const pres = p.presence; @@ -1423,10 +1464,10 @@ pages.profile = async () => { // 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); - if (WC.length) { + if (wowShow !== "none" && 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" ? `
` : ""; + const grid = flav => { const rows = ordered(flav); return `
${rows.slice(0, 6).map(c => wowCard(c)).join("") || `

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

`}

${self ? `Manage·` : ""}View all

`; }; + const tog = wowToggle ? `
` : ""; wowc = sec("WoW characters", grid(wowOpen), tog); } @@ -1477,11 +1518,11 @@ pages.profile = async () => { wireWowPop(); root.addEventListener("click", e => { - const b = e.target.closest("[data-wflav]"); if (!b || wowShow !== "both") return; + const b = e.target.closest("[data-wflav]"); if (!b || !wowToggle) 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.

`; + box.innerHTML = rows.slice(0, 6).map(c => wowCard(c)).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); })); @@ -1622,7 +1663,7 @@ function zoneOptions(cur, term) { const auto = ``; return auto + Array.from(groups.keys()).sort().map(g => `${groups.get(g).sort((a, b) => a[1].localeCompare(b[1])).map(([z, l]) => ``).join("")}`).join(""); } -const TABS = [["account", "Account & profile"], ["rig", "Gaming rig"], ["games", "Played games"], ["privacy", "Privacy"], ["notifications", "Notifications"], ["connections", "Connections"], ["security", "Security"]]; +const BASE_TABS = [["account", "Account & profile"], ["rig", "Gaming rig"], ["games", "Played games"], ["privacy", "Privacy"], ["notifications", "Notifications"], ["connections", "Connections"], ["security", "Security"]]; pages.settings = async () => { if (!requireLogin()) return; const root = $("#settings"); @@ -1631,7 +1672,12 @@ pages.settings = async () => { let u = m.user; const keep = () => setSession(token(), Object.assign(me() || {}, { id: u.id, handle: u.handle, display_name: u.display_name, email: u.email, avatar: u.avatar, email_verified: u.email_verified, mail_live: u.mail_live, staff_role: u.staff_role || null, timezone: u.timezone || null })); keep(); renderHeader(); verifyBar(); - const tab = () => { const t = location.hash.replace("#", ""); return TABS.some(x => x[0] === t) ? t : "account"; }; + const tabs = () => { + const t = BASE_TABS.slice(); + if (u.has_wow) { const i = t.findIndex(x => x[0] === "games"); t.splice(i + 1, 0, ["wow", "World of Warcraft"]); } + return t; + }; + const tab = () => { const t = location.hash.replace("#", "").split("/")[0]; return tabs().some(x => x[0] === t) ? t : "account"; }; const row = (label, html, hint) => `
${html}${hint ? `
${hint}
` : ""}
`; const msg = ``; const ok = (el, t) => showOk($("[data-msg]", el), t), bad = (el, e) => showErr($("[data-msg]", el), e); @@ -1776,7 +1822,7 @@ pages.settings = async () => { const v = c[p] || {}, l = v.linked; if (!l && !v.off && !v.available) return ""; // not built yet: hide rather than show a "Coming soon" stub const right = l - ? `${p === "steam" && v.import ? ` ` : ""}${p === "bnet" ? `Manage WoW ` : ""}` + ? `${p === "steam" && v.import ? ` ` : ""}${p === "bnet" && u.has_wow ? `Manage WoW ` : ""}` : v.off ? `Turned off for now` : v.available ? `` : `Coming soon`; const who = l ? `${p === "steam" ? `${esc(l.display || l.uid)}` : `${esc(l.display || l.uid)}`} ${l.verified ? `✓ Verified` : `(not verified)`}${l.last_sync_at ? `
Hours imported ${fullDate.format(new Date(l.last_sync_at))}${l.sync_status === "private" ? " · Steam game details were private" : ""}
` : ""}${p === "bnet" ? (v.wow_imported_at ? `
Hours imported ${fullDate.format(new Date(v.wow_imported_at))}
` : ``) : ""}` : `${blurb}`; @@ -1786,7 +1832,7 @@ pages.settings = async () => { ${card("steam", ``, c.steam && c.steam.import ? "Verify your Steam account and import your Steam hours." : "Verify your Steam account. It shows as a badge on your profile.")} ${card("discord", ``, "Show your Discord name on your profile.")} ${card("bnet", ``, "Show your BattleTag on your profile.")} - ${c.bnet && c.bnet.needs_reconnect ? `
Reconnect Battle.net to verify characters. Manage WoW
` : ""} + ${c.bnet && c.bnet.needs_reconnect ? `
Reconnect Battle.net to verify characters. ${u.has_wow ? `Manage WoW` : ""}
` : ""}

Verified accounts show as badges on your profile. You're sent to the provider's own sign-in page; Fraglink never sees your password there.

`; if (q.get("connected")) { ok(box, `${NAMES[q.get("connected")] || "Account"} linked.`); history.replaceState(null, "", "/settings#connections"); } else if (q.get("connect_error")) { bad(box, new Error(q.get("connect_error") === "expired" ? "That link expired. Try again." : q.get("connect_error") === "cancelled" ? "Linking was cancelled." : q.get("connect_error"))); history.replaceState(null, "", "/settings#connections"); } @@ -1825,11 +1871,12 @@ pages.settings = async () => { function render() { const t = tab(); - root.innerHTML = `

Account settings

Your profile, gaming rig, privacy, notifications and linked accounts.
`; + root.innerHTML = `

Account settings

Your profile, gaming rig, privacy, notifications and linked accounts.
`; const el = $("#pane"); if (t === "account") el.innerHTML = accountTab(); else if (t === "rig") el.innerHTML = rigTab(); else if (t === "games") gamesTab(el); + else if (t === "wow") { el.dataset.embed = "1"; el.classList.add("bn"); wowManager(el); } else if (t === "privacy") el.innerHTML = privacyTab(); else if (t === "notifications") el.innerHTML = notificationsTab(); else if (t === "connections") connectionsTab(el); @@ -2054,7 +2101,7 @@ function applyFeatures() { const CRUMBS = { download: [["Download", "/download"]], forgot: [["Sign in", "/login"], ["Forgot password"]], friends: [["Friends", "/friends"]], login: [["Sign in"]], news: [["News", "/news"]], privacy: [["Privacy Policy"]], reset: [["Sign in", "/login"], ["New password"]], settings: [["Account Settings", "/settings"]], signup: [["Create an account"]], terms: [["Terms of Use"]], profile: [["Profile"]], - verify: [["Confirm your email"]], wowupload: [["Account Settings", "/settings"], ["Connections", "/settings#connections"], ["Battle.net", "/connect/battlenet"], ["Upload WoW /played"]], bnetverify: [["Account Settings", "/settings"], ["Connections", "/settings#connections"], ["Battle.net", "/connect/battlenet"], ["Verify WoW characters"]], bnet: [["Account Settings", "/settings"], ["Connections", "/settings#connections"], ["Battle.net"]], faq: [["FAQ", "/faq"]], changelog: [["Changelog", "/changelog"]], contact: [["Contact", "/contact"]], status: [["Status", "/status"]], media: [["Media", "/media"]], admin: [["Admin", "/admin"]], notfound: [["Page not found"]] }; + verify: [["Confirm your email"]], wowupload: [["Account settings", "/settings"], ["World of Warcraft", "/settings#wow"], ["Upload /played"]], bnetverify: [["Account settings", "/settings"], ["World of Warcraft", "/settings#wow"], ["Verify characters"]], bnet: [["Account settings", "/settings"], ["World of Warcraft", "/settings#wow"]], faq: [["FAQ", "/faq"]], changelog: [["Changelog", "/changelog"]], contact: [["Contact", "/contact"]], status: [["Status", "/status"]], media: [["Media", "/media"]], admin: [["Admin", "/admin"]], notfound: [["Page not found"]] }; function setCrumb(parts) { const nav = document.getElementById("ycrumb"); if (!nav || !parts) return; const all = [["Home", "/"], ...parts]; @@ -2123,7 +2170,7 @@ const WOWPOP = (() => { const slot = (g, k) => { const it = g[k]; const lk = it && it.id && WH.ok, tag = lk ? "a" : "div"; return it ? `<${tag} class="cp-slot q-${esc((it.quality || "common").toLowerCase())}"${lk ? ` href="https://www.wowhead.com/item=${Number(it.id)}" data-wowhead="${esc(whp(it))}" target="_blank" rel="noopener"` : it.id ? ` data-whi="${Number(it.id)}" data-whp="${esc(whp(it))}" role="button" tabindex="0" aria-expanded="false"` : ` title="${esc(it.name)}"`}>${it.icon ? `` : ``}
${esc(it.name)}${esc(SN[k])}${it.ilvl ? " · " + it.ilvl : ""}
` : `
Empty${esc(SN[k])}
`; }; const raid = r => r.instances.map(i => `
${esc(i.name)}
${[...i.modes].sort((a, b) => DO.indexOf(a.difficulty) - DO.indexOf(b.difficulty)).map(m => `${esc(m.label)} ${m.done}/${m.total}`).join("")}
`).join(""); const body = (d, c) => { - if (d.status === "missing") return `

${esc(c.name)}

${esc(c.realm)} ${esc(c.region.toUpperCase())}

Profile private or not found on Blizzard.

`; + if (d.status === "missing") return `

${esc(c.name)}${factionCrest(c.faction)}

${esc(c.realm)} ${esc(c.region.toUpperCase())}

Not on the Armory.

`; const g = {}; (d.gear || []).forEach(x => g[x.slot] = x); const col = CC[d.class_id] || "#eef2f6", mp = d.mplus || {}, pv = d.pvp || {}, rd = d.raids || {}, co = d.collections || {}, pr = d.professions || []; const stat = (l, v) => `
${l}${v}
`; @@ -2131,7 +2178,7 @@ const WOWPOP = (() => { return `
${d.render ? `` : ""}
${d.title ? `
${esc(d.title.replace("{name}", d.name))}
` : ""}

${esc(d.name)}

Level ${d.level} ${esc([d.race, d.spec, d.class].filter(Boolean).join(" "))}
-
${esc(d.realm)} ${esc(d.region.toUpperCase())}${d.faction ? ` · ${esc(d.faction)}` : ""}
+
${esc(d.realm)} ${esc(d.region.toUpperCase())}${factionCrest(d.faction || c.faction)}
${d.guild ? `
<${esc(d.guild.name)}>${d.guild.rank != null ? ` · ${d.guild.rank === 0 ? "Guild Master" : "Rank " + d.guild.rank}` : ""}
` : ""}
${stat("Item level", d.ilvl ? nf.format(d.ilvl) : "—")}${stat("Achievements", d.achievement_points ? nf.format(d.achievement_points) : "—")}${stat("M+ rating", mp.rating ? nf.format(Math.round(mp.rating)) : "—")}

Gear

${d.gear && d.gear.length ? `
${L.map(k => slot(g, k)).join("")}
${R.map(k => slot(g, k)).join("")}
${W.map(k => slot(g, k)).join("")}
` : `

No gear equipped.

`}
@@ -2174,7 +2221,7 @@ const WOWPOP = (() => { 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; } } } - catch (x) { if (box === me) $(".cp-in", box).innerHTML = `

Couldn't load character details right now. Try again in a minute.

`; } + catch (x) { if (box === me) $(".cp-in", box).innerHTML = `

Couldn't load this character.

`; } }; return { open, close }; })(); diff --git a/apps/site-web/static/assets/site.css b/apps/site-web/static/assets/site.css index ec5136b..5901a12 100644 --- a/apps/site-web/static/assets/site.css +++ b/apps/site-web/static/assets/site.css @@ -352,8 +352,14 @@ dl.rig{display:grid;grid-template-columns:110px 1fr;gap:4px 10px;margin:0}dl.rig .delete-bar{background:linear-gradient(#5a1d1d,#3e1010);color:#f6c9c9} .tabs a{cursor:pointer}.tabs a:hover{text-decoration:none;color:#fff} .tabpane{border:1px solid #2d4560;border-radius:0 5px 5px 5px;background:linear-gradient(#141b24,#11161d);padding:0 0 12px} -.tabpane h3{margin:0 0 10px;display:flex;align-items:center;min-height:32px;padding:7px 12px;font-size:11.5px;font-weight:700;letter-spacing:.9px;text-transform:uppercase;color:var(--hd-orange);background:linear-gradient(90deg,var(--hd1) 0,var(--hd2) 150px,#141b24 190px,#12171e);border-bottom:1px solid #0b1118;box-shadow:inset 0 1px 0 #ffffff12} -.tabpane h3:not(:first-child){border-top:1px solid #24384f;margin-top:14px} +.tabpane .bn-sec{margin:0} +.tabpane .bn-sec + .bn-sec{margin-top:16px} +.tabpane .bn-view{margin-left:auto;text-transform:none;letter-spacing:0;font-size:12px;font-weight:600} +.tabpane h3,.bn h3{margin:0 0 10px;display:flex;align-items:center;min-height:32px;padding:7px 12px;font-size:11.5px;font-weight:700;letter-spacing:.9px;text-transform:uppercase;color:var(--hd-orange);background:linear-gradient(90deg,var(--hd1) 0,var(--hd2) 150px,#141b24 190px,#12171e);border-bottom:1px solid #0b1118;box-shadow:inset 0 1px 0 #ffffff12} +.tabpane h3:not(:first-child),.bn h3:not(:first-of-type){border-top:1px solid #24384f;margin-top:14px} +.bn-addblock{padding:10px 12px 12px} +.bn-or{margin:10px 0 0} +.bn-tabs{margin-bottom:0} .frm .alert{margin:0}.frm .row{display:flex;gap:8px;align-items:center;flex-wrap:wrap} .frm input[type=checkbox]{vertical-align:middle;margin-right:6px} .presets{display:flex;flex-wrap:wrap;gap:6px}.presets button{padding:0;border:2px solid transparent;border-radius:6px;background:none;cursor:pointer}.presets button.on{border-color:#f0b060} @@ -727,6 +733,12 @@ table.newsadm td.t b{display:block}table.newsadm td.t small{color:var(--dim)}tab .wc-hid{opacity:.5} /* /connect/wow */ +.wu-page h2{margin:12px 0 6px;font-size:18px;font-weight:650} +.wu-paths{margin:10px 0 12px} +.wu-paths p{margin:0 0 8px} +.wu-paths span{display:block;color:var(--dim);font-size:11px;font-weight:650;letter-spacing:.4px;text-transform:uppercase} +.wu-paths code{display:block;margin-top:2px;word-break:break-all;color:var(--silver)} +.wu-paths p.on code{color:#eef2f6} .wu-form{margin:6px 0 14px}.wu-row{display:flex;flex-wrap:wrap;gap:8px 16px;align-items:center;padding:6px 0} .wu-l{width:48px;color:var(--dim)}.wu-help{margin:8px 0;font-size:13px}.wu-help code{word-break:break-all} .wu-tot{display:flex;gap:12px;margin:8px 0 12px}.wu-tot div{flex:1;border:1px solid rgba(255,255,255,.1);border-radius:6px;padding:10px;background:rgba(0,0,0,.15)} @@ -823,17 +835,19 @@ table.newsadm td.t b{display:block}table.newsadm td.t small{color:var(--dim)}tab .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-t{min-width:0}.wc-t b{color:#eef2f6}.wc-meta{font-size:12px;line-height:1.3}.wc-lv{color:var(--muted);font-weight:600;margin-right:4px}.wc-cl{color:var(--cls);font-weight:700}.wc-realm{color:var(--dim);font-size:11px;font-weight:400} .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-foot{display:flex;justify-content:flex-end;align-items:baseline;gap:6px;margin:0;padding:0 14px 8px;font-size:12px} +.wcs{padding-bottom:4px} .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} +.wow-back{display:inline-block;margin:4px 0} +.wow-page .bar{display:flex;justify-content:space-between;align-items:center;gap:12px;margin:10px 0 14px;height:auto;overflow:visible;background:none;border-radius:0} +.wow-page h1{margin:0;font-size:20px;line-height:1.2} .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} @@ -1267,3 +1281,10 @@ tr.hc-flag td{background:#1d1010} .hl .k{font-size:10px;letter-spacing:.8px;text-transform:uppercase;color:var(--label);font-weight:700} .hl .v{color:#fff;font-weight:700;font-size:15px;margin-top:3px}.hl .d{font-size:11px;color:#8ea2b8;margin-top:2px;overflow:hidden;text-overflow:ellipsis} @media (max-width:1100px){.hc-3,.hl-row{grid-template-columns:1fr 1fr}} + +/* WoW upload alternative and server groups. */ +.bn-upload{border-top:1px solid var(--line);margin-top:18px;padding-top:16px;display:flex;align-items:center;justify-content:space-between;gap:16px} +.bn-upload p{margin:4px 0 0}.bn-upload-actions{display:flex;align-items:center;gap:14px;flex-wrap:wrap}.bn-upload-actions>a.sub{font-size:12px} +.faction-crest,.bn-row img.faction-crest{display:inline-block;width:16px;height:18px;object-fit:contain;vertical-align:-3px;margin-left:6px;flex-shrink:0;border:0;border-radius:0;background:none;box-shadow:none;filter:none} +.wow-realms{display:grid;gap:28px}.wow-realm{min-width:0}.wow-realm h2{font-size:16px;margin:0 0 12px;font-weight:600}.wow-realm-count{font-size:12px;font-weight:400;color:var(--dim);margin-left:10px} +@media(max-width:600px){.bn-upload{align-items:flex-start;flex-direction:column}.bn-upload-actions{gap:12px}.wow-realm h2{font-size:15px}} diff --git a/apps/site-web/static/assets/wow-factions/README.md b/apps/site-web/static/assets/wow-factions/README.md new file mode 100644 index 0000000..4696ea9 --- /dev/null +++ b/apps/site-web/static/assets/wow-factions/README.md @@ -0,0 +1,28 @@ +# World of Warcraft faction emblems + +Blizzard's original Horde symbol and Alliance lion, not traced/recreated icons. +The high-resolution transparent sources were downloaded from Warcraft Wiki on +10 November 2026. The file pages credit Blizzard's **World of Warcraft 5 Year +Anniversary: BattleCry** PSD artwork. + +| Asset | Source page | Original image | +| --- | --- | --- | +| `horde-{64,128}.webp` | https://warcraft.wiki.gg/wiki/File:HordeLogo.png | https://warcraft.wiki.gg/images/HordeLogo.png | +| `alliance-{64,128}.webp` | https://warcraft.wiki.gg/wiki/File:AllianceLogo.png | https://warcraft.wiki.gg/images/AllianceLogo.png | + +Original Blizzard campaign (archived): +https://web.archive.org/web/20100207205430/http://www.worldofwarcraft.com/wowanniversary/battlecry/ + +Copyright © Blizzard Entertainment, Inc. Warcraft and World of Warcraft are +Blizzard trademarks. These third-party game identifiers are not covered by +Fraglink's repository licence. The source pages describe Blizzard's limited +personal/noncommercial-use permission and the wiki's fair-use rationale; they +are not a general open-content or commercial-use licence. Their use here identifies +characters' in-game factions and does not imply Blizzard endorsement. + +Processing: cropped transparent margins, aspect-preserving Lanczos downsampling +to fit 64×64 and 128×128, then lossless WebP compression with alpha preserved and +metadata removed. No recolouring, glow, shadow or other effects added. Sources +are raster, so no invented SVG tracing is substituted. Display is 16×18 CSS pixels +with `object-fit: contain`; assets are served locally, never hotlinked. Unknown +factions render no image. diff --git a/apps/site-web/static/assets/wow-factions/alliance-128.webp b/apps/site-web/static/assets/wow-factions/alliance-128.webp new file mode 100644 index 0000000..7fb604a Binary files /dev/null and b/apps/site-web/static/assets/wow-factions/alliance-128.webp differ diff --git a/apps/site-web/static/assets/wow-factions/alliance-64.webp b/apps/site-web/static/assets/wow-factions/alliance-64.webp new file mode 100644 index 0000000..a029c82 Binary files /dev/null and b/apps/site-web/static/assets/wow-factions/alliance-64.webp differ diff --git a/apps/site-web/static/assets/wow-factions/horde-128.webp b/apps/site-web/static/assets/wow-factions/horde-128.webp new file mode 100644 index 0000000..cf526e9 Binary files /dev/null and b/apps/site-web/static/assets/wow-factions/horde-128.webp differ diff --git a/apps/site-web/static/assets/wow-factions/horde-64.webp b/apps/site-web/static/assets/wow-factions/horde-64.webp new file mode 100644 index 0000000..afc990b Binary files /dev/null and b/apps/site-web/static/assets/wow-factions/horde-64.webp differ diff --git a/apps/site-web/tests/wow-ui.test.cjs b/apps/site-web/tests/wow-ui.test.cjs new file mode 100644 index 0000000..d8d14da --- /dev/null +++ b/apps/site-web/tests/wow-ui.test.cjs @@ -0,0 +1,50 @@ +// No browser or network needed: exercise the actual shared render/group helpers. +const { readFileSync } = require('node:fs'); +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const vm = require('node:vm'); +const source = readFileSync(__dirname + '/../static/assets/app.js', 'utf8'); +const helpers = source.slice(source.indexOf('const factionCrest ='), source.indexOf('const WOW =')); +const { factionCrest, groupWowRealms } = vm.runInNewContext(helpers + '; ({ factionCrest, groupWowRealms })'); + +test('servers sorted by count then alphabetically, same-name regions distinguished', () => { + const rows = [ + {realm:'Zuljin',region:'us',name:'a'}, {realm:'Zuljin',region:'us',name:'b'}, + {realm:'Area 52',region:'us',name:'c'}, {realm:'Area 52',region:'eu',name:'d'}, + {realm:'Frostmourne',region:'us',name:'e'}, + ]; + const before = JSON.stringify(rows); + const groups = groupWowRealms(rows); + assert.equal(JSON.stringify(groups.map(g => [g.realm, g.region, g.rows.length, g.showRegion])), JSON.stringify([ + ['Zuljin','US',2,false],['Area 52','EU',1,true],['Area 52','US',1,true],['Frostmourne','US',1,false], + ])); + assert.equal(JSON.stringify(rows),before); + assert.equal(groupWowRealms([]).length,0); +}); +test('case variants group into the same server', () => { + const groups = groupWowRealms([{realm:'Area 52',region:'us'},{realm:'area 52',region:'US'}]); + assert.equal(groups.length,1); assert.equal(groups[0].rows.length,2); +}); +test('crests are accessible self-hosted artwork; unknown factions render nothing', () => { + for (const name of ['Horde','Alliance']) { + assert.match(factionCrest(name), /']) assert.equal(factionCrest(value),''); +}); +test('gallery omits realm on cards; profile retains it; upload follows active tab', () => { + assert.ok(source.includes('g.rows.map(c => wowCard(c, false))')); + assert.ok(source.includes('const wowCard = (c, showRealm = true)')); + assert.ok(source.includes('const realm = showRealm && c.realm')); + assert.ok(source.includes('up.textContent = "Upload " + (st.game === "wow_forever" ? "Forever" : "Retail") + " /played"')); + assert.ok(source.includes('up.href = "/connect/wow?version=" + (st.game === "wow_forever" ? "forever" : "retail")')); +});