initial commit: reforgermods.net launcher 0.1.0

A desktop server browser for Arma Reforger, built on the reforgermods.net v2
API. Live server list with search, sort and filters; a detail inspector with
population history and mod readiness; measured ICMP latency; favorites, recents
and settings; and direct joining through Steam.

Tauri 2 and Rust, with a Preact frontend that builds to 80 KB of JavaScript in
one chunk. Logic lives in rfm-core, which has no Tauri dependency and is tested
directly; src-tauri is the window, the command surface and the wiring.

Packaged for the public beta: a per-user NSIS installer, a portable .exe and an
Arch package, with signature-verified background updates on Windows and a
release pipeline that builds from a tag. The artifacts are not yet
Authenticode-signed — that is wired and waiting on an Azure Trusted Signing
identity, and docs/distribution.md says so plainly rather than letting anyone
discover it at the SmartScreen dialog.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
SowinskiBraedenandClaude Opus 5 committed 2026-09-24 20:29:00 -07:00
commit 377f3a31b3
91 files changed
+24994

No files matched your search

+1
View File
@@ -0,0 +1 @@
{"error":{"code":"SERVER_NOT_FOUND","message":"Server was not found in the current dataset.","requestId":"5563e6b1d086dc74234b4515"}}
+1
View File
@@ -0,0 +1 @@
{"status":"success","updatedAt":"2026-09-23T21:35:42Z","sites":[{"id":"singapore","label":"Singapore","address":"ping-location-sg.nitrado.net","countryCode":"SG","regionGroup":"Asia-Pacific","latitude":1.298200011253357,"longitude":103.78399658203125},{"id":"sydney","label":"Sydney","address":"ping-location-ausy.nitrado.net","countryCode":"US","regionGroup":"Asia-Pacific","latitude":-33.94300079345703,"longitude":151.18099975585938},{"id":"tokyo","label":"Tokyo","address":"ping-location-jpto.nitrado.net","countryCode":"JP","regionGroup":"Asia-Pacific","latitude":35.67190170288086,"longitude":139.68499755859375},{"id":"frankfurt","label":"Frankfurt","address":"ping-location-de.nitrado.net","countryCode":"DE","regionGroup":"Europe","latitude":51.29930114746094,"longitude":9.491000175476074},{"id":"london","label":"London","address":"ping-location-ukln.nitrado.net","countryCode":"GB","regionGroup":"Europe","latitude":51.50640106201172,"longitude":-0.019999999552965164},{"id":"los_angeles","label":"Los Angeles","address":"ping-location-usla.nitrado.net","countryCode":"US","regionGroup":"North America","latitude":34.05440139770508,"longitude":-118.24400329589844},{"id":"miami","label":"Miami","address":"ping-location-usmi.nitrado.net","countryCode":"US","regionGroup":"North America","latitude":25.774099349975586,"longitude":-80.18170166015625},{"id":"new_york","label":"New York","address":"ping-location-usny.nitrado.net","countryCode":"US","regionGroup":"North America","latitude":40.71229934692383,"longitude":-74.00679779052734}]}
File diff suppressed because one or more lines are too long.
File diff suppressed because one or more lines are too long.
+1
View File
@@ -0,0 +1 @@
{"status":"success","meta":{"totalPages":1666,"currentPage":1,"totalServers":4997,"shownServers":3,"serverIndexStart":1,"serverIndexEnd":3},"dataset":{"warming":false,"stale":false,"snapshotAgeSeconds":24.574651156,"lastCollectionAt":"2026-09-24T07:00:59Z","serverCount":94228,"onlineServerCount":4997,"offlineServerCount":89231},"data":[{"id":"25888090-0d2b-4d0a-80b1-9145ed9c76c2","name":"[AU] FTA #2 | From The Ashes | Persistent Ranks | discord.gg/from-the-ashes","scenarioId":"{709D1B83A7C0B1DE}Missions/FTA_BelleauWood_DAAS_3.conf","scenarioName":"[FTA] DAAS #3 Belleau Wood","scenarioImageUrl":"https://ar-gcp-cdn.bistudio.com/image/e964/f82c597356e099d187570e0385d453ad27c6969190bd5cc73bcc971d3910/102095.jpg","scenarioImageWidth":1024,"scenarioImageHeight":512,"scenarioThumbnailUrl":"https://ar-gcp-cdn.bistudio.com/image/b64d/02af30860106e7097a8238852468fb85d249062ae56b43b1d6c34e70c20f/45316.jpg","scenarioThumbnailWidth":576,"scenarioThumbnailHeight":324,"gameVersion":"1.8.0.13","official":false,"passwordProtected":false,"battlEye":true,"crossPlay":true,"players":128,"maxPlayers":128,"queue":3,"queueMax":25,"memory":2121063,"pingSiteId":"sydney","region":"sydney","platform":"LINUX","platforms":["pc","xbox","psn"],"modCount":127,"scenarioModId":"695612A9E1C65671","online":true,"firstSeen":"2026-08-27T23:07:58Z","lastSeen":"2026-09-24T07:00:59Z","analytics":{"serverId":"25888090-0d2b-4d0a-80b1-9145ed9c76c2","rank":46,"modelVersion":"server-ranking-v2","activityScore":98.22795006041079,"reliabilityScore":99.3545183714002,"observedAvailabilityScore":99.3545183714002,"demandScore":98.38904550946435,"momentumScore":57.75091206694827,"confidence":0.9652677279305355,"overallScore":92.82443195672418,"calculatedAt":"2026-09-24T06:56:08Z","sourceSnapshotId":32546}},{"id":"e5d3c115-38ba-4a4f-bc2b-df7114b8a7b4","name":"[AWG Alpha1] GHOST OPS DELTA | Vanilla Everon | 2XP | Crossplay | ACTIVE ADMINS | discord.gg/hfe79Aw","scenarioId":"{ECC61978EDCC2B5A}Missions/23_Campaign.conf","scenarioName":"#AR-Campaign_ScenarioName_Everon","gameVersion":"1.8.0.13","official":false,"passwordProtected":false,"battlEye":true,"crossPlay":true,"players":128,"maxPlayers":128,"queue":3,"queueMax":40,"memory":2091768,"pingSiteId":"new_york","region":"new_york","platform":"LINUX","platforms":["pc","xbox","psn"],"modCount":0,"online":true,"firstSeen":"2026-08-27T23:07:58Z","lastSeen":"2026-09-24T07:00:59Z","analytics":{"serverId":"e5d3c115-38ba-4a4f-bc2b-df7114b8a7b4","rank":3,"modelVersion":"server-ranking-v2","activityScore":99.45630285944422,"reliabilityScore":100,"observedAvailabilityScore":100,"demandScore":99.59726137736608,"momentumScore":64.84439556932384,"confidence":0.965484180249281,"overallScore":94.49990210084458,"calculatedAt":"2026-09-24T06:56:08Z","sourceSnapshotId":32546}},{"id":"4e7d4b2f-250b-4839-ac10-3acdfbb74960","name":"[NA1] Rubber Band Rangers | Everon | Rank Sync | discord.gg/rubberbandrangers | Hosted - LoafHost","scenarioId":"{E0F172D5C5C6557D}Missions/RBR_Everon_Map_Rotation.conf","scenarioName":"[RBR] Everon Map Rotation","scenarioImageUrl":"https://ar-gcp-cdn.bistudio.com/image/cb0f/ab78a91aba43172ba52cefb7b514a22e11a660040b5387e8b0881be3131b/55058.jpg","scenarioImageWidth":1024,"scenarioImageHeight":512,"scenarioThumbnailUrl":"https://ar-gcp-cdn.bistudio.com/image/7b12/62cbf2e1dd78aa2d25c431141bb49145be8c747dd3587e19e06fb7652cde/26663.jpg","scenarioThumbnailWidth":576,"scenarioThumbnailHeight":324,"gameVersion":"1.8.0.13","official":false,"passwordProtected":false,"battlEye":true,"crossPlay":true,"players":128,"maxPlayers":128,"queue":9,"queueMax":25,"memory":2188211,"pingSiteId":"new_york","region":"new_york","platform":"LINUX","platforms":["pc","xbox","psn"],"modCount":135,"scenarioModId":"699055CF2BE4A246","online":true,"firstSeen":"2026-08-27T23:07:58Z","lastSeen":"2026-09-24T07:00:59Z","analytics":{"serverId":"4e7d4b2f-250b-4839-ac10-3acdfbb74960","rank":16,"modelVersion":"server-ranking-v2","activityScore":99.93958920660492,"reliabilityScore":99.85104270109235,"observedAvailabilityScore":99.85104270109235,"demandScore":99.89931534434152,"momentumScore":50.82972545734101,"confidence":0.9654344695151225,"overallScore":93.45545423303722,"calculatedAt":"2026-09-24T06:56:08Z","sourceSnapshotId":32546}}],"links":{"next":"https://api.reforgermods.net/v2/servers?online=true\u0026page=2\u0026perPage=3\u0026sort=players"}}
+554
View File
@@ -0,0 +1,554 @@
//! Live checks against api.reforgermods.net.
//!
//! Ignored by default so `cargo test` stays offline and deterministic. Run them
//! when changing the client or when the API contract may have moved:
//!
//! ```text
//! cargo test -p rfm-core --test live_api -- --ignored --test-threads=1
//! ```
//!
//! **Run them serially.** The public rate limit is 60 requests/minute and these
//! tests fetch server detail in loops; in parallel they trip it and fail with
//! `RATE_LIMITED`, which is the limiter working rather than a contract break.
use rfm_core::api::{ApiClient, HistoryRange, ServerQuery, ServerSort, DEFAULT_BASE_URL};
use rfm_core::launch::{steam_join_uri, ServerId};
fn client() -> ApiClient {
// A fixed id, so live runs are attributable to the test suite rather than
// looking like a new install on every run.
ApiClient::with_base_url(DEFAULT_BASE_URL, "00000000-0000-4000-8000-00000000test")
.or_else(|_| ApiClient::with_base_url(DEFAULT_BASE_URL, ""))
.expect("client builds")
}
#[tokio::test]
#[ignore = "hits the live reforgermods.net API"]
async fn server_list_returns_a_usable_page() {
let query = ServerQuery {
sort: ServerSort::Players,
per_page: 10,
..Default::default()
};
let page = client().servers(&query).await.expect("servers request");
assert_eq!(page.data.len(), 10, "asked for 10 servers");
assert!(page.meta.total_servers > 0);
assert!(
!page.dataset.warming,
"dataset should be warm in production"
);
assert!(
page.dataset.last_collection_at.is_some(),
"freshness drives the header label"
);
for server in &page.data {
// Every id the browser shows must be launchable.
ServerId::parse(&server.id)
.unwrap_or_else(|e| panic!("server id {:?} is not launchable: {e}", server.id));
assert!(!server.name.is_empty());
assert!(server.online, "default query is online-only");
assert!(server.max_players > 0);
}
// sort=players must actually be descending.
let players: Vec<u32> = page.data.iter().map(|s| s.players).collect();
let mut sorted = players.clone();
sorted.sort_unstable_by(|a, b| b.cmp(a));
assert_eq!(players, sorted, "list should be sorted by players desc");
}
#[tokio::test]
#[ignore = "hits the live reforgermods.net API"]
async fn filters_are_honoured_by_the_api() {
let client = client();
let modded = client
.servers(&ServerQuery {
has_mods: Some(true),
per_page: 10,
..Default::default()
})
.await
.expect("modded request");
assert!(
modded.data.iter().all(|s| s.mod_count > 0),
"hasMods=true returned a vanilla server"
);
let vanilla = client
.servers(&ServerQuery {
has_mods: Some(false),
per_page: 10,
..Default::default()
})
.await
.expect("vanilla request");
assert!(
vanilla.data.iter().all(|s| s.mod_count == 0),
"hasMods=false returned a modded server"
);
let frankfurt = client
.servers(&ServerQuery {
region: Some("frankfurt".into()),
per_page: 10,
..Default::default()
})
.await
.expect("region request");
assert!(
frankfurt
.data
.iter()
.all(|s| s.region.as_deref() == Some("frankfurt")),
"region filter leaked other regions"
);
}
#[tokio::test]
#[ignore = "hits the live reforgermods.net API"]
async fn search_matches_the_returned_names() {
let page = client()
.servers(&ServerQuery {
search: "conflict".into(),
per_page: 10,
..Default::default()
})
.await
.expect("search request");
assert!(
!page.data.is_empty(),
"expected some matches for 'conflict'"
);
assert!(
page.data
.iter()
.any(|s| s.name.to_lowercase().contains("conflict")
|| s.scenario_name.to_lowercase().contains("conflict")),
"search returned nothing resembling the term"
);
}
#[tokio::test]
#[ignore = "hits the live reforgermods.net API"]
async fn server_detail_carries_mods_and_activity() {
let client = client();
let page = client
.servers(&ServerQuery {
has_mods: Some(true),
per_page: 5,
..Default::default()
})
.await
.expect("list request");
let first = page.data.first().expect("at least one modded server");
let id = ServerId::parse(&first.id).expect("valid id");
let detail = client.server(&id).await.expect("detail request").server;
assert_eq!(detail.summary.id, id.as_str());
assert_eq!(detail.mods.len() as u32, detail.mod_summary.count);
assert!(!detail.mods.is_empty());
for m in &detail.mods {
// Workshop ids are 16 hex characters; the modscan matcher depends on it.
assert_eq!(m.id.len(), 16, "unexpected mod id {:?}", m.id);
assert!(m.id.bytes().all(|b| b.is_ascii_hexdigit()));
}
println!(
"{} — {} mods, {} present, fps {}",
detail.summary.name,
detail.mods.len(),
detail.present,
detail.fps
);
}
#[tokio::test]
#[ignore = "hits the live reforgermods.net API"]
async fn ping_sites_cover_the_regions_servers_report() {
let sites = client().ping_sites().await.expect("ping sites").sites;
assert!(sites.len() >= 8);
assert!(sites.iter().any(|s| s.id == "frankfurt"));
assert!(sites.iter().all(|s| !s.label.is_empty()));
}
#[tokio::test]
#[ignore = "hits the live reforgermods.net API"]
async fn a_missing_server_is_reported_as_not_found() {
// A well-formed id that is not in the dataset.
let id = ServerId::parse("00000000-0000-4000-8000-000000000000").unwrap();
let err = client().server(&id).await.expect_err("should not be found");
assert_eq!(err.kind(), "not_found", "got {err:?}");
assert!(!err.is_retryable());
}
#[tokio::test]
#[ignore = "hits the live reforgermods.net API"]
async fn launcher_status_degrades_when_v2_status_is_absent() {
// /v2/status is not deployed yet; the header must still show a usable state.
let status = client().launcher_status(None).await;
assert!(status.api_reachable, "health probe should answer");
if status.reported_status.is_none() {
assert!(
status.note.is_some(),
"an absent /v2/status must explain itself"
);
println!("status fallback: {}", status.note.unwrap_or_default());
} else {
println!("/v2/status is now deployed: {:?}", status.reported_status);
}
}
#[tokio::test]
#[ignore = "hits the live reforgermods.net API"]
async fn a_live_server_produces_a_launchable_uri() {
let page = client()
.servers(&ServerQuery {
per_page: 1,
..Default::default()
})
.await
.expect("list request");
let server = page.data.first().expect("one server");
let id = ServerId::parse(&server.id).expect("launchable id");
let uri = steam_join_uri(&id);
assert!(uri.starts_with("steam://run/1874880/"));
assert!(uri.ends_with('/'));
assert!(!uri.contains('='), "payload padding must be stripped");
println!("{} -> {uri}", server.name);
}
#[tokio::test]
#[ignore = "hits the live reforgermods.net API"]
async fn repeated_identical_requests_are_served_from_cache() {
let client = client();
let query = ServerQuery {
per_page: 5,
..Default::default()
};
let started = std::time::Instant::now();
client.servers(&query).await.expect("first request");
let cold = started.elapsed();
let started = std::time::Instant::now();
client.servers(&query).await.expect("second request");
let warm = started.elapsed();
assert!(
warm < cold,
"second identical request should hit the cache (cold {cold:?}, warm {warm:?})"
);
println!("cold {cold:?}, warm {warm:?}");
}
#[tokio::test]
#[ignore = "hits the live reforgermods.net API"]
async fn history_is_ordered_and_bucketed_per_range() {
let client = client();
let id = ServerId::parse("73e8fb7f-8789-4d88-8aa8-ea69b9aa092f").unwrap();
for range in HistoryRange::OFFERED {
let history = client
.server_history(&id, range)
.await
.unwrap_or_else(|e| panic!("history {:?} failed: {e}", range.as_param()));
assert_eq!(history.range, range.as_param());
assert!(
!history.bucket.is_empty(),
"the API names the bucket width it chose"
);
assert!(
!history.points.is_empty(),
"{} had no points",
range.as_param()
);
// The chart draws in array order, so the series must be ascending.
assert!(
history.points.windows(2).all(|w| w[0].t < w[1].t),
"{} is not ordered by time",
range.as_param()
);
for point in &history.points {
assert!(point.min <= point.avg, "{point:?}");
assert!(point.avg <= point.max, "{point:?}");
}
println!(
"{:>4} -> {:4} points, bucket {}",
range.as_param(),
history.points.len(),
history.bucket
);
}
}
#[tokio::test]
#[ignore = "hits the live reforgermods.net API"]
async fn readiness_can_be_computed_from_a_live_server() {
use rfm_core::modscan::{AddonInventory, InstalledAddon, ScanError};
use rfm_core::readiness;
/// Pretends nothing is installed, so the figures exercise the missing path
/// without depending on the machine running the test.
struct Empty;
impl AddonInventory for Empty {
fn installed(&self) -> Result<Vec<InstalledAddon>, ScanError> {
Ok(Vec::new())
}
}
let client = client();
let page = client
.servers(&ServerQuery {
has_mods: Some(true),
per_page: 1,
..Default::default()
})
.await
.expect("list request");
let id = ServerId::parse(&page.data[0].id).expect("valid id");
let detail = client.server(&id).await.expect("detail").server;
let report = readiness::report(&detail.mods, &Empty);
assert!(report.available);
assert!(!report.is_ready(), "nothing is installed in this fixture");
assert_eq!(report.required, detail.mods.len() as u32);
assert_eq!(report.missing, report.required);
// At least some sizes should resolve on a real server, so the UI has a
// number to show rather than only "download size unknown".
assert!(
report.missing_bytes > 0,
"no mod sizes resolved for {}",
detail.summary.name
);
println!(
"{}: {} mods, at least {:.2} GiB, {} unresolved",
detail.summary.name,
report.required,
report.missing_bytes as f64 / (1024.0 * 1024.0 * 1024.0),
report.unresolved_mods
);
}
/// End-to-end readiness against a real addon store.
///
/// Point `RFM_ADDONS_DIR` at an Arma Reforger `addons` directory to exercise the
/// real scanner rather than a stub; the test is skipped when it is unset, so it
/// stays runnable on a machine without the game:
///
/// ```text
/// RFM_ADDONS_DIR="$HOME/Documents/My Games/ArmaReforger/addons" \
/// cargo test -p rfm-core --test live_api -- --ignored readiness_against_a_real
/// ```
#[tokio::test]
#[ignore = "hits the live reforgermods.net API and needs RFM_ADDONS_DIR"]
async fn readiness_against_a_real_addon_store() {
use rfm_core::modscan::{AddonInventory, FilesystemInventory};
use rfm_core::readiness;
let Some(dir) = std::env::var_os("RFM_ADDONS_DIR") else {
eprintln!("RFM_ADDONS_DIR is not set; skipping");
return;
};
let inventory = FilesystemInventory::new(dir);
let installed = inventory.installed().expect("addon store is readable");
assert!(!installed.is_empty(), "no addons found");
assert!(
installed.iter().all(|a| a.id.len() == 16),
"every detected id must be a 16-hex Workshop id"
);
println!("local store: {} addons", installed.len());
let client = client();
let page = client
.servers(&ServerQuery {
has_mods: Some(true),
per_page: 4,
..Default::default()
})
.await
.expect("list request");
for summary in &page.data {
let id = ServerId::parse(&summary.id).expect("valid id");
let Ok(detail) = client.server(&id).await else {
continue;
};
let detail = detail.server;
let report = readiness::report(&detail.mods, &inventory);
assert!(report.available, "a readable store must produce a report");
assert_eq!(report.required, detail.mods.len() as u32);
// The three buckets must account for every required mod exactly once.
assert_eq!(
report.up_to_date + report.outdated + report.missing,
report.required,
"counts do not partition the mod list"
);
assert_eq!(report.installed_total, installed.len() as u32);
println!(
"{:.46} req={:3} ok={:3} old={:2} miss={:3} >= {:.2} GiB",
detail.summary.name,
report.required,
report.up_to_date,
report.outdated,
report.missing,
report.missing_bytes as f64 / (1024.0 * 1024.0 * 1024.0),
);
}
}
#[tokio::test]
#[ignore = "sends ICMP echo requests to live game servers"]
async fn ping_measures_real_servers() {
use rfm_core::ping::{self, PingUnavailable};
use std::time::Duration;
let client = client();
let page = client
.servers(&ServerQuery {
per_page: 6,
..Default::default()
})
.await
.expect("list request");
let mut measured = 0;
let mut no_host = 0;
let mut probed = 0;
for summary in &page.data {
let id = ServerId::parse(&summary.id).expect("valid id");
// A rate-limited or transient detail fetch is not a contract failure;
// skip it rather than failing the measurement check.
let Ok(detail) = client.server(&id).await else {
continue;
};
let detail = detail.server;
let host = detail.host.as_deref();
if host.is_none() {
no_host += 1;
continue;
}
probed += 1;
let result = ping::measure(host, 3, Duration::from_millis(1500)).await;
match (&result.summary, result.unavailable) {
(Some(s), None) => {
measured += 1;
// A plausible internet round trip, not a middlebox artefact.
assert!(s.best_ms >= 1 && s.best_ms < 2000, "{s:?}");
assert!(s.median_ms >= s.best_ms, "median below best: {s:?}");
assert!(s.received > 0 && s.received <= s.sent);
println!(
" {:.40} {:>12} {:>4} ms (best {} ms, {}% loss)",
detail.summary.name,
detail.summary.ping_site_id.clone().unwrap_or_default(),
s.median_ms,
s.best_ms,
s.loss_percent()
);
}
(None, Some(reason)) => {
assert_eq!(
reason,
PingUnavailable::NoReply,
"a published literal IP should only fail by not replying"
);
println!(" {:.40} no reply", detail.summary.name);
}
other => panic!("a result must be one or the other: {other:?}"),
}
}
println!("hosts published: {probed}, unmeasurable: {no_host}, answered: {measured}");
assert!(probed > 0, "no server published a host");
assert!(
measured > 0,
"no server answered ICMP; the transport is not working"
);
}
#[tokio::test]
#[ignore = "hits the live reforgermods.net API"]
async fn locally_built_join_uri_matches_the_api() {
// The API publishes `joinUrl` for each server. The launcher still builds its
// own from the validated id — an API-supplied URL must never reach the OS —
// but the two must agree, so this pins the format against the source of
// truth rather than against a single hardcoded vector.
let page = client()
.servers(&ServerQuery {
per_page: 20,
..Default::default()
})
.await
.expect("list request");
let mut compared = 0;
for server in &page.data {
let Some(published) = server.join_url.as_deref() else {
continue;
};
let id = ServerId::parse(&server.id).expect("launchable id");
assert_eq!(
steam_join_uri(&id),
published,
"locally built URI diverged for {}",
server.name
);
compared += 1;
}
assert!(
compared > 0,
"no server published a joinUrl to compare against"
);
println!("join URI matches the API for {compared} servers");
}
#[tokio::test]
#[ignore = "hits the live reforgermods.net API"]
async fn fleet_population_is_reported() {
let fleet = client().fleet_now().await.expect("fleet request").now;
assert!(fleet.players > 0, "no players reported");
assert!(fleet.online_servers > 0);
// Capacity is the sum of slots across listed servers, so it bounds players.
assert!(
fleet.players <= fleet.capacity,
"players {} exceed capacity {}",
fleet.players,
fleet.capacity
);
println!(
"{} players across {} servers (capacity {}, queue {})",
fleet.players, fleet.online_servers, fleet.capacity, fleet.queue
);
}
#[tokio::test]
#[ignore = "hits the live reforgermods.net API"]
async fn official_servers_are_joinable() {
// Official ids are not UUIDs; rejecting them would make 364 servers
// unjoinable, and the failure is invisible until someone clicks Join.
let page = client()
.servers(&ServerQuery {
official: Some(true),
per_page: 10,
..Default::default()
})
.await
.expect("official list");
assert!(!page.data.is_empty());
for server in &page.data {
let id = ServerId::parse(&server.id)
.unwrap_or_else(|e| panic!("official id {:?} not joinable: {e}", server.id));
assert!(id.is_official_form(), "{}", server.id);
let uri = steam_join_uri(&id);
assert!(uri.starts_with("steam://run/1874880//"));
}
println!("{} official servers all produce join URIs", page.data.len());
}