Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 10 additions & 12 deletions apps/desktop/addon/curseforge/description.md
Original file line number Diff line number Diff line change
@@ -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/<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/<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.
2 changes: 1 addition & 1 deletion apps/desktop/addon/curseforge/summary.txt
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions apps/server/migrations/0059_wow_profile_none.sql
Original file line number Diff line number Diff line change
@@ -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'));
3 changes: 3 additions & 0 deletions apps/server/migrations/0060_wow_faction.sql
Original file line number Diff line number Diff line change
@@ -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'));
16 changes: 16 additions & 0 deletions apps/server/src/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,25 @@ pub async fn me_extra(st: &AppState, uid: Uuid) -> ApiResult<Value> {
"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<bool> {
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!({
Expand Down
28 changes: 26 additions & 2 deletions apps/server/src/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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| {
Expand Down Expand Up @@ -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,
Expand Down
47 changes: 43 additions & 4 deletions apps/server/src/wow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -585,14 +593,15 @@ 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)
.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}"));
Expand Down Expand Up @@ -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)
Expand All @@ -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}"));
Expand Down Expand Up @@ -2056,6 +2066,13 @@ pub(crate) fn name_of(v: &Value) -> Option<String> {

// ------------------------------------------------------------------ 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
Expand Down Expand Up @@ -2085,14 +2102,15 @@ pub async fn characters(
show_guild: bool,
guild: Option<String>,
featured: Option<i16>,
faction: Option<String>,
}
let rows: Vec<Row> = 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,
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')
Expand All @@ -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())
}
Expand Down Expand Up @@ -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");
Expand Down
8 changes: 7 additions & 1 deletion apps/server/src/wowchar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = [
"character-media",
Expand Down Expand Up @@ -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(),
Expand Down
57 changes: 52 additions & 5 deletions apps/server/src/wowpage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ async fn summary(State(st): State<AppState>, a: AuthUser) -> ApiResult<Json<Valu
})))
}

/// `both`, `wow`, or `wow_forever`. A missing preference is Both.
/// `both`, `wow`, `wow_forever`, or `none`. A missing preference is Both.
pub(crate) async fn profile_show(st: &AppState, uid: Uuid) -> ApiResult<String> {
let stored: Option<String> = sqlx::query_scalar(
"SELECT coalesce(profile_show, 'both') FROM wow_prefs WHERE user_id = $1",
Expand All @@ -92,10 +92,34 @@ pub(crate) async fn profile_show(st: &AppState, uid: Uuid) -> ApiResult<String>
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,
Expand All @@ -108,10 +132,10 @@ async fn set_profile_show(
Json(r): Json<ShowReq>,
) -> ApiResult<Json<Value>> {
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(
Expand Down Expand Up @@ -257,6 +281,7 @@ async fn list(
bnet_spec: Option<String>,
show_guild: bool,
verified: bool,
faction: Option<String>,
former: Option<String>,
uploaded_at: DateTime<Utc>,
}
Expand All @@ -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!()
)))
Expand Down Expand Up @@ -323,6 +348,7 @@ async fn list(
show_guild: sg,
verified,
former,
faction,
uploaded_at,
} = c;
let (former_name, former_realm) = former
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading