use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use anyhow::{Context, Result};
use secrecy::ExposeSecret;
use tokio::fs;
use tokio::io::AsyncWriteExt;
use tokio::task::JoinSet;
use crate::auth::AuthManager;
use crate::auth::EosTokenResponse;
use crate::config::{AccountConfig, Config, RankUploadConfig, UploadDestinationConfig};
use crate::paths::AppPaths;
use crate::psynet::{MatchEntry, PlayerId, PlayerSkill, PsyNetClient};
use crate::upload::{
CurrentSkill, MmrPlayer, MmrSkill, MmrUpload, RankBundle, RankBundlePlayer, RankSnapshot,
ReplayUploader, UploadCache, UploadOutcome,
};
pub const MAX_CONCURRENT_UPLOADS: usize = 3;
#[derive(Debug, Clone, Default)]
struct PlayerSkillIndex {
fetched_at: Option<i64>,
skills: HashMap<String, Vec<PlayerSkill>>,
}
static DOWNLOAD_COUNTER: AtomicU64 = AtomicU64::new(0);
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SyncSummary {
pub accounts_seen: usize,
pub matches_seen: usize,
pub uploaded: usize,
pub duplicates: usize,
pub cached: usize,
pub skipped: usize,
pub failed: usize,
pub failed_match_ids: Vec<String>,
pub failed_uploads: Vec<FailedUpload>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FailedUpload {
pub target_name: String,
pub match_id: String,
pub reason: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SyncOptions {
pub include_online: bool,
pub target_name: Option<String>,
pub force: bool,
pub match_ids: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HistoryEntry {
pub account_name: String,
pub match_id: String,
pub record_start_timestamp: i64,
pub map_name: String,
pub playlist: i64,
pub team0_score: i64,
pub team1_score: i64,
pub replay_url: String,
pub upload_states: Vec<HistoryUploadState>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HistoryUploadState {
pub target_name: String,
pub upload_enabled: bool,
pub cached: bool,
pub location: Option<String>,
}
#[derive(Debug, Clone)]
pub struct SyncService {
paths: AppPaths,
config: Config,
psynet: PsyNetClient,
uploader: ReplayUploader,
http: reqwest::Client,
}
impl SyncService {
pub fn new(paths: AppPaths, config: Config) -> Self {
Self {
paths,
config,
psynet: PsyNetClient::new(),
uploader: ReplayUploader::new(),
http: reqwest::Client::new(),
}
}
pub async fn run_once(&self) -> Result<SyncSummary> {
self.run_once_with_options(SyncOptions::default()).await
}
pub async fn run_once_with_options(&self, options: SyncOptions) -> Result<SyncSummary> {
let mut summary = SyncSummary::default();
let mut active_accounts = Vec::new();
for account in self
.config
.accounts
.iter()
.filter(|account| account.sync_enabled)
{
let auth = AuthManager::for_account(&self.paths, account);
let token = auth
.restore_or_refresh()
.await
.with_context(|| format!("failed to restore auth for account {}", account.name))?;
active_accounts.push(AuthenticatedAccount { account, token });
}
let accounts_to_upload = if options.include_online {
active_accounts
} else {
self.filter_connected_accounts(active_accounts).await?
};
for account in accounts_to_upload {
summary.accounts_seen += 1;
let account_summary = self
.sync_account(account.account, &account.token, &options)
.await
.with_context(|| format!("failed to sync account {}", account.account.name))?;
summary.merge(account_summary);
}
Ok(summary)
}
pub async fn current_history(&self, target_name: Option<&str>) -> Result<Vec<HistoryEntry>> {
let targets = self.upload_destinations(target_name)?;
let caches = targets
.iter()
.map(|target| {
let cache = UploadCache::load(
self.paths.upload_cache_path(&target.name),
self.config.accounts.len(),
)?;
Ok((target, cache))
})
.collect::<Result<Vec<_>>>()?;
let mut entries = Vec::new();
for account in self
.config
.accounts
.iter()
.filter(|account| account.sync_enabled)
{
let auth = AuthManager::for_account(&self.paths, account);
let token = auth
.restore_or_refresh()
.await
.with_context(|| format!("failed to restore auth for account {}", account.name))?;
let rpc = self
.psynet
.auth_player(&token.account_id, token.access_token.expose_secret())
.await?;
let matches = rpc.get_match_history().await?;
let _ = rpc.close().await;
entries.extend(matches.into_iter().map(|entry| {
let match_id = entry.match_info.match_guid.clone();
HistoryEntry {
account_name: account.name.clone(),
match_id: match_id.clone(),
record_start_timestamp: entry.match_info.record_start_timestamp,
map_name: entry.match_info.map_name,
playlist: entry.match_info.playlist,
team0_score: entry.match_info.team0_score,
team1_score: entry.match_info.team1_score,
replay_url: entry.replay_url,
upload_states: caches
.iter()
.map(|(target, cache)| HistoryUploadState {
target_name: target.name.clone(),
upload_enabled: target.replay_upload.enabled,
cached: cache.contains(&match_id),
location: cache.location(&match_id).map(ToOwned::to_owned),
})
.collect(),
}
}));
}
entries.sort_by(|left, right| {
right
.record_start_timestamp
.cmp(&left.record_start_timestamp)
.then_with(|| left.account_name.cmp(&right.account_name))
});
Ok(entries)
}
async fn filter_connected_accounts<'a>(
&self,
accounts: Vec<AuthenticatedAccount<'a>>,
) -> Result<Vec<AuthenticatedAccount<'a>>> {
if !self.config.behavior.no_upload_while_connected {
return Ok(accounts);
}
let Some(presence_account) = self
.config
.accounts
.iter()
.find(|account| !account.sync_enabled)
else {
return Ok(accounts);
};
let auth = AuthManager::for_account(&self.paths, presence_account);
let token = auth.restore_or_refresh().await.with_context(|| {
format!(
"failed to restore presence-check account auth for {}",
presence_account.name
)
})?;
let rpc = self
.psynet
.auth_player(&token.account_id, token.access_token.expose_secret())
.await?;
let profile_ids = accounts
.iter()
.map(|account| {
PlayerId::new(account.account.platform.clone(), &account.token.account_id)
})
.collect::<Vec<_>>();
let profiles = rpc.get_profiles(profile_ids).await?;
let _ = rpc.close().await;
let online = profiles
.iter()
.filter(|profile| profile.presence_state == "Online")
.map(|profile| profile.player_id.as_str())
.collect::<std::collections::HashSet<_>>();
Ok(accounts
.into_iter()
.filter(|account| {
let player_id =
PlayerId::new(account.account.platform.clone(), &account.token.account_id)
.to_string();
let should_upload = !online.contains(player_id.as_str());
if !should_upload {
tracing::info!(
account = %account.account.name,
"skipping upload because account is online"
);
}
should_upload
})
.collect())
}
async fn sync_account(
&self,
account: &AccountConfig,
token: &EosTokenResponse,
options: &SyncOptions,
) -> Result<SyncSummary> {
let rpc = self
.psynet
.auth_player(&token.account_id, token.access_token.expose_secret())
.await?;
let profiles = rpc
.get_profiles(vec![PlayerId::new(
account.platform.clone(),
&token.account_id,
)])
.await
.unwrap_or_default();
if let Some(profile) = profiles.first() {
tracing::info!(
player_name = %profile.player_name,
presence = %profile.presence_state,
"connected to PsyNet account"
);
}
let matches = rpc.get_match_history().await?;
let player_skills = fetch_player_skills(&rpc, &matches).await;
let _ = rpc.close().await;
self.upload_matches(matches, &player_skills, options, account, &token.account_id)
.await
}
async fn upload_matches(
&self,
matches: Vec<MatchEntry>,
player_skills: &PlayerSkillIndex,
options: &SyncOptions,
account: &AccountConfig,
account_id: &str,
) -> Result<SyncSummary> {
let account_player_id = PlayerId::new(account.platform.clone(), account_id).to_string();
let name_template = &self.config.behavior.upload_name_template;
let mut summary = SyncSummary {
matches_seen: selected_match_count(&matches, &options.match_ids),
..SyncSummary::default()
};
let requested_match_ids = normalized_match_ids(&options.match_ids);
let mut targets = self.upload_target_states(options.target_name.as_deref())?;
let mut upload_jobs = JoinSet::new();
for replay in &matches {
let match_id = &replay.match_info.match_guid;
if !requested_match_ids.is_empty()
&& !requested_match_ids.contains(&normalize_match_id(match_id))
{
continue;
}
let mut pending_target_indexes = Vec::new();
for (index, target) in targets.iter_mut().enumerate() {
if !target.config.replay_upload.enabled {
summary.skipped += 1;
continue;
}
if !options.force && target.cache.contains(match_id) {
summary.cached += 1;
continue;
}
match &target.auth {
UploadTargetAuth::Available(_) => pending_target_indexes.push(index),
UploadTargetAuth::Unavailable(reason) => record_failed_upload(
&mut summary,
&target.config.name,
match_id,
upload_auth_unavailable_reason(&target.config.name, reason),
),
}
}
if pending_target_indexes.is_empty() {
continue;
}
let upload_name = crate::upload_name::render_upload_name(
name_template,
replay,
&account_player_id,
&account.name,
);
let target_jobs = pending_target_indexes
.into_iter()
.filter_map(|index| {
let target = &targets[index];
let UploadTargetAuth::Available(auth_header) = &target.auth else {
return None;
};
let rank_bundle = match &target.config.rank_upload {
RankUploadConfig::Bundled { .. } => {
Some(build_rank_bundle(replay, player_skills))
}
_ => None,
};
Some(ReplayUploadTargetJob {
target_index: index,
target: target.config.clone(),
auth_header: auth_header.clone(),
rank_bundle,
})
})
.collect::<Vec<_>>();
if target_jobs.is_empty() {
continue;
}
let job = ReplayUploadJob {
replay: replay.clone(),
upload_name,
targets: target_jobs,
};
self.spawn_upload_job(&mut upload_jobs, job);
if upload_jobs.len() >= MAX_CONCURRENT_UPLOADS {
complete_next_upload_job(&mut upload_jobs, &mut summary, &mut targets).await?;
}
}
while !upload_jobs.is_empty() {
complete_next_upload_job(&mut upload_jobs, &mut summary, &mut targets).await?;
}
Ok(summary)
}
fn spawn_upload_job(
&self,
upload_jobs: &mut JoinSet<ReplayUploadJobResult>,
job: ReplayUploadJob,
) {
let service = self.clone();
upload_jobs.spawn(async move { service.run_upload_job(job).await });
}
async fn run_upload_job(&self, job: ReplayUploadJob) -> ReplayUploadJobResult {
let match_id = job.replay.match_info.match_guid.clone();
let downloaded_replay = match self.download_replay(&job.replay).await {
Ok(path) => path,
Err(error) => {
let reason = format!("download failed: {}", error_chain(&error));
tracing::warn!(%error, match_id, "failed to download replay");
return ReplayUploadJobResult {
match_id,
results: job
.targets
.into_iter()
.map(|target| ReplayUploadTargetResult::Failed {
target_name: target.target.name,
reason: reason.clone(),
})
.collect(),
};
}
};
let mut results = Vec::with_capacity(job.targets.len());
for target in job.targets {
let result = self
.uploader
.upload_replay_with_auth_header(
&target.target,
downloaded_replay.path(),
Some(&match_id),
job.upload_name.as_deref(),
target.auth_header.clone(),
target.rank_bundle.as_ref(),
)
.await;
let upload_result = match result {
Ok(result) => {
let stored = matches!(
result.outcome,
UploadOutcome::Uploaded | UploadOutcome::Duplicate
);
let outcome = result.outcome;
let location = result.location;
if stored {
if let RankUploadConfig::Endpoint { .. } = &target.target.rank_upload {
let payload = build_mmr_upload(&job.replay);
if !payload.players.is_empty() {
if let Err(error) = self
.uploader
.upload_mmr_with_auth_header(
&target.target,
&payload,
&match_id,
target.auth_header,
)
.await
{
tracing::warn!(
%error,
match_id,
target = %target.target.name,
"failed to upload player rank metadata"
);
}
}
}
}
ReplayUploadTargetResult::Completed {
target_index: target.target_index,
outcome,
location,
}
}
Err(error) => {
tracing::warn!(
%error,
match_id,
target = %target.target.name,
"failed to upload replay"
);
ReplayUploadTargetResult::Failed {
target_name: target.target.name,
reason: format!("upload failed: {}", error_chain(&error)),
}
}
};
results.push(upload_result);
}
ReplayUploadJobResult { match_id, results }
}
fn upload_target_states(
&self,
target_name: Option<&str>,
) -> Result<Vec<UploadTargetState<'_>>> {
self.upload_destinations(target_name)?
.into_iter()
.map(|target| {
let cache = UploadCache::load(
self.paths.upload_cache_path(&target.name),
self.config.accounts.len(),
)?;
let auth = match target.auth.header_value() {
Ok(header) => UploadTargetAuth::Available(header),
Err(error) => {
let reason = error.to_string();
tracing::warn!(
%reason,
target = %target.name,
"skipping replay uploads because upload auth is unavailable"
);
UploadTargetAuth::Unavailable(reason)
}
};
Ok(UploadTargetState {
config: target,
cache,
auth,
})
})
.collect()
}
fn upload_destinations(
&self,
target_name: Option<&str>,
) -> Result<Vec<&crate::config::UploadDestinationConfig>> {
match target_name {
Some(name) => {
let target = self
.config
.upload_destination(name)
.with_context(|| format!("unknown upload destination {name:?}"))?;
Ok(vec![target])
}
None => Ok(self
.config
.upload_destinations
.iter()
.filter(|target| target.replay_upload.enabled)
.collect()),
}
}
async fn download_replay(&self, replay: &MatchEntry) -> Result<DownloadedReplay> {
let response = self
.http
.get(&replay.replay_url)
.send()
.await
.with_context(|| {
format!("failed to download replay {}", replay.match_info.match_guid)
})?;
let status = response.status();
if !status.is_success() {
anyhow::bail!(
"replay download for {} failed with {status}",
replay.match_info.match_guid
);
}
let download_stem = unique_replay_download_stem(&replay.match_info.match_guid);
let path = self.paths.cache_dir.join(format!("{download_stem}.part"));
let final_path = self.paths.cache_dir.join(download_stem);
let bytes = response
.bytes()
.await
.context("failed to read replay body")?;
let mut file = fs::File::create(&path)
.await
.with_context(|| format!("failed to create replay temp file {}", path.display()))?;
file.write_all(&bytes).await?;
file.flush().await?;
drop(file);
fs::rename(&path, &final_path).await.with_context(|| {
format!(
"failed to move replay temp file {} to {}",
path.display(),
final_path.display()
)
})?;
Ok(DownloadedReplay::new(final_path))
}
}
fn build_mmr_upload(replay: &MatchEntry) -> MmrUpload {
let players = replay
.match_info
.players
.iter()
.filter(|player| player.skills.valid)
.filter_map(|player| {
let (platform, id) = parse_player_id(&player.player_id)?;
Some(MmrPlayer {
platform_id: online_platform_id(platform),
id,
before: MmrSkill {
tier: player.skills.prev_tier,
division: player.skills.prev_division,
matches_played: 0,
mmr: player.skills.prev_mmr(),
},
after: MmrSkill {
tier: player.skills.tier,
division: player.skills.division,
matches_played: 0,
mmr: player.skills.mmr(),
},
debug: player.player_name.clone(),
})
})
.collect();
MmrUpload {
game: replay.match_info.match_guid.clone(),
players,
}
}
fn build_rank_bundle(replay: &MatchEntry, player_skills: &PlayerSkillIndex) -> RankBundle {
let playlist = replay.match_info.playlist;
let players = replay
.match_info
.players
.iter()
.filter(|player| player.skills.valid)
.filter_map(|player| {
let (platform, id) = parse_player_id(&player.player_id)?;
let skills = &player.skills;
Some(RankBundlePlayer {
platform_player_id: id,
player_name: non_empty_string(&player.player_name),
platform: platform.to_string(),
playlist,
valid: skills.valid,
after: RankSnapshot {
tier: skills.tier,
division: skills.division,
mu: skills.mu,
sigma: skills.sigma,
mmr: skills.mmr(),
},
before: RankSnapshot {
tier: skills.prev_tier,
division: skills.prev_division,
mu: skills.prev_mu,
sigma: skills.prev_sigma,
mmr: skills.prev_mmr(),
},
current: current_skill(player_skills, &player.player_id, playlist),
})
})
.collect();
RankBundle { players }
}
fn current_skill(
player_skills: &PlayerSkillIndex,
player_id: &str,
playlist: i64,
) -> Option<CurrentSkill> {
let fetched_at = player_skills.fetched_at?;
let skill = player_skills
.skills
.get(player_id)?
.iter()
.find(|skill| skill.playlist == playlist)?;
Some(CurrentSkill {
mmr: skill.mmr,
win_streak: skill.win_streak,
matches_played: skill.matches_played,
placement_matches_played: skill.placement_matches_played,
fetched_at,
})
}
fn non_empty_string(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
async fn fetch_player_skills(
rpc: &crate::psynet::PsyNetRpc,
matches: &[MatchEntry],
) -> PlayerSkillIndex {
let player_ids: Vec<PlayerId> = matches
.iter()
.flat_map(|entry| entry.match_info.players.iter())
.map(|player| player.player_id.clone())
.collect::<HashSet<_>>()
.into_iter()
.map(PlayerId::from_psynet)
.collect();
if player_ids.is_empty() {
return PlayerSkillIndex::default();
}
match rpc.get_players_skills(player_ids).await {
Ok(players) => PlayerSkillIndex {
fetched_at: Some(unix_now_seconds()),
skills: players
.into_iter()
.map(|player| (player.player_id, player.skills))
.collect(),
},
Err(error) => {
tracing::warn!(%error, "failed to fetch player skills; uploading ranks without current-skill counters");
PlayerSkillIndex::default()
}
}
}
fn unix_now_seconds() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
fn parse_player_id(player_id: &str) -> Option<(&str, String)> {
let mut parts = player_id.split('|');
let platform = parts.next()?;
let id = parts.next()?;
if platform.is_empty() || id.is_empty() {
return None;
}
Some((platform, id.to_string()))
}
fn online_platform_id(platform: &str) -> i64 {
match platform {
"Steam" => 1,
"PS4" | "PS3" => 2,
"Dingo" | "XboxOne" => 4,
"NNX" | "Switch" => 7,
"PsyNet" => 8,
"WeGame" => 10,
"Epic" => 11,
_ => 0,
}
}
fn record_failed_upload(
summary: &mut SyncSummary,
target_name: &str,
match_id: &str,
reason: String,
) {
summary.failed += 1;
summary.failed_match_ids.push(match_id.to_string());
summary.failed_uploads.push(FailedUpload {
target_name: target_name.to_string(),
match_id: match_id.to_string(),
reason,
});
}
async fn complete_next_upload_job(
upload_jobs: &mut JoinSet<ReplayUploadJobResult>,
summary: &mut SyncSummary,
targets: &mut [UploadTargetState<'_>],
) -> Result<()> {
let result = upload_jobs
.join_next()
.await
.context("upload job set unexpectedly empty")?
.context("upload job failed to complete")?;
for upload_result in result.results {
match upload_result {
ReplayUploadTargetResult::Completed {
target_index,
outcome,
location,
} => match outcome {
UploadOutcome::Uploaded => {
summary.uploaded += 1;
targets[target_index]
.cache
.add_with_location(result.match_id.clone(), location)?;
}
UploadOutcome::Duplicate => {
summary.duplicates += 1;
targets[target_index]
.cache
.add_with_location(result.match_id.clone(), location)?;
}
UploadOutcome::Skipped => {
summary.skipped += 1;
}
},
ReplayUploadTargetResult::Failed {
target_name,
reason,
} => record_failed_upload(summary, &target_name, &result.match_id, reason),
}
}
Ok(())
}
fn upload_auth_unavailable_reason(target_name: &str, reason: &str) -> String {
format!(
"{target_name} upload auth is unavailable: {reason}. Set the configured token source before uploading."
)
}
fn error_chain(error: &anyhow::Error) -> String {
error
.chain()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(": ")
}
#[derive(Debug)]
struct AuthenticatedAccount<'a> {
account: &'a AccountConfig,
token: EosTokenResponse,
}
#[derive(Debug)]
struct UploadTargetState<'a> {
config: &'a UploadDestinationConfig,
cache: UploadCache,
auth: UploadTargetAuth,
}
#[derive(Debug)]
enum UploadTargetAuth {
Available(Option<String>),
Unavailable(String),
}
#[derive(Debug)]
struct ReplayUploadJob {
replay: MatchEntry,
upload_name: Option<String>,
targets: Vec<ReplayUploadTargetJob>,
}
#[derive(Debug)]
struct ReplayUploadTargetJob {
target_index: usize,
target: UploadDestinationConfig,
auth_header: Option<String>,
rank_bundle: Option<RankBundle>,
}
#[derive(Debug)]
struct ReplayUploadJobResult {
match_id: String,
results: Vec<ReplayUploadTargetResult>,
}
#[derive(Debug)]
enum ReplayUploadTargetResult {
Completed {
target_index: usize,
outcome: UploadOutcome,
location: Option<String>,
},
Failed {
target_name: String,
reason: String,
},
}
#[derive(Debug)]
struct DownloadedReplay {
path: PathBuf,
}
impl DownloadedReplay {
fn new(path: PathBuf) -> Self {
Self { path }
}
fn path(&self) -> &Path {
&self.path
}
}
impl Drop for DownloadedReplay {
fn drop(&mut self) {
if let Err(error) = std::fs::remove_file(&self.path) {
tracing::debug!(
%error,
path = %self.path.display(),
"failed to remove downloaded replay cache file"
);
}
}
}
impl SyncSummary {
fn merge(&mut self, other: Self) {
self.accounts_seen += other.accounts_seen;
self.matches_seen += other.matches_seen;
self.uploaded += other.uploaded;
self.duplicates += other.duplicates;
self.cached += other.cached;
self.skipped += other.skipped;
self.failed += other.failed;
self.failed_match_ids.extend(other.failed_match_ids);
self.failed_uploads.extend(other.failed_uploads);
}
}
impl std::fmt::Display for SyncSummary {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(formatter, "accounts: {}", self.accounts_seen)?;
writeln!(formatter, "matches: {}", self.matches_seen)?;
writeln!(formatter, "uploaded: {}", self.uploaded)?;
writeln!(formatter, "duplicates: {}", self.duplicates)?;
writeln!(formatter, "cached: {}", self.cached)?;
writeln!(formatter, "skipped: {}", self.skipped)?;
writeln!(formatter, "failed: {}", self.failed)?;
if !self.failed_match_ids.is_empty() {
writeln!(
formatter,
"failed_match_ids: {}",
self.failed_match_ids.join(",")
)?;
}
for failure in &self.failed_uploads {
writeln!(
formatter,
"failed_upload: {} {}: {}",
failure.target_name, failure.match_id, failure.reason
)?;
}
Ok(())
}
}
fn selected_match_count(matches: &[MatchEntry], match_ids: &[String]) -> usize {
let requested_match_ids = normalized_match_ids(match_ids);
if requested_match_ids.is_empty() {
return matches.len();
}
matches
.iter()
.filter(|replay| {
requested_match_ids.contains(&normalize_match_id(&replay.match_info.match_guid))
})
.count()
}
fn normalized_match_ids(match_ids: &[String]) -> HashSet<String> {
match_ids
.iter()
.map(|match_id| normalize_match_id(match_id))
.collect()
}
fn normalize_match_id(match_id: &str) -> String {
match_id.trim().to_ascii_uppercase()
}
fn unique_replay_download_stem(match_id: &str) -> String {
let sequence = DOWNLOAD_COUNTER.fetch_add(1, Ordering::Relaxed);
format!("{match_id}-{}-{sequence}.replay", std::process::id())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::psynet::{Match, MatchPlayer, MatchSkills, PlayerSkill};
#[test]
fn build_mmr_upload_maps_skills_and_platforms() {
let mut replay = match_entry("MATCH-GUID");
replay.match_info.players = vec![
MatchPlayer {
player_id: "Epic|epic-account|0".to_string(),
player_name: "Blue".to_string(),
skills: MatchSkills {
mu: 30.0,
tier: 12,
division: 3,
prev_mu: 29.0,
prev_tier: 12,
prev_division: 2,
valid: true,
..MatchSkills::default()
},
..MatchPlayer::default()
},
MatchPlayer {
player_id: "Steam|76561198000000000|0".to_string(),
player_name: "Orange".to_string(),
skills: MatchSkills {
valid: false,
..MatchSkills::default()
},
..MatchPlayer::default()
},
];
let payload = build_mmr_upload(&replay);
assert_eq!(payload.game, "MATCH-GUID");
assert_eq!(payload.players.len(), 1);
let player = &payload.players[0];
assert_eq!(player.platform_id, 11);
assert_eq!(player.id, "epic-account");
assert_eq!(player.debug, "Blue");
assert_eq!(player.after.tier, 12);
assert_eq!(player.after.division, 3);
assert_eq!(player.after.mmr, 30.0 * 20.0 + 100.0);
assert_eq!(player.before.division, 2);
assert_eq!(player.before.mmr, 29.0 * 20.0 + 100.0);
}
#[test]
fn build_rank_bundle_captures_full_snapshot() {
let mut replay = match_entry("MATCH-GUID");
replay.match_info.playlist = 11;
replay.match_info.players = vec![
MatchPlayer {
player_id: "Epic|epic-account|0".to_string(),
player_name: "Blue".to_string(),
skills: MatchSkills {
mu: 30.0,
sigma: 2.5,
tier: 12,
division: 3,
prev_mu: 29.0,
prev_sigma: 2.6,
prev_tier: 12,
prev_division: 2,
valid: true,
},
..MatchPlayer::default()
},
MatchPlayer {
player_id: "Steam|76561198000000000|0".to_string(),
skills: MatchSkills {
valid: false,
..MatchSkills::default()
},
..MatchPlayer::default()
},
];
let mut player_skills = PlayerSkillIndex {
fetched_at: Some(1_700_000_000),
..PlayerSkillIndex::default()
};
player_skills.skills.insert(
"Epic|epic-account|0".to_string(),
vec![
PlayerSkill {
playlist: 10,
win_streak: 99,
..PlayerSkill::default()
},
PlayerSkill {
playlist: 11,
mmr: 760.0,
win_streak: 3,
matches_played: 250,
placement_matches_played: 10,
..PlayerSkill::default()
},
],
);
let bundle = build_rank_bundle(&replay, &player_skills);
assert_eq!(bundle.players.len(), 1);
let player = &bundle.players[0];
assert_eq!(player.platform_player_id, "epic-account");
assert_eq!(player.player_name.as_deref(), Some("Blue"));
assert_eq!(player.platform, "Epic");
assert_eq!(player.playlist, 11);
assert!(player.valid);
assert_eq!(player.after.tier, 12);
assert_eq!(player.after.division, 3);
assert_eq!(player.after.mu, 30.0);
assert_eq!(player.after.sigma, 2.5);
assert_eq!(player.after.mmr, 30.0 * 20.0 + 100.0);
assert_eq!(player.before.tier, 12);
assert_eq!(player.before.division, 2);
assert_eq!(player.before.mu, 29.0);
assert_eq!(player.before.mmr, 29.0 * 20.0 + 100.0);
let current = player.current.as_ref().expect("current skill present");
assert_eq!(current.mmr, 760.0);
assert_eq!(current.win_streak, 3);
assert_eq!(current.matches_played, 250);
assert_eq!(current.placement_matches_played, 10);
assert_eq!(current.fetched_at, 1_700_000_000);
}
#[test]
fn build_rank_bundle_omits_current_when_skills_missing() {
let mut replay = match_entry("MATCH-GUID");
replay.match_info.playlist = 11;
replay.match_info.players = vec![MatchPlayer {
player_id: "Epic|epic-account|0".to_string(),
skills: MatchSkills {
valid: true,
..MatchSkills::default()
},
..MatchPlayer::default()
}];
let bundle = build_rank_bundle(&replay, &PlayerSkillIndex::default());
assert_eq!(bundle.players.len(), 1);
assert!(bundle.players[0].current.is_none());
}
#[test]
fn online_platform_id_maps_known_platforms() {
assert_eq!(online_platform_id("Steam"), 1);
assert_eq!(online_platform_id("PS4"), 2);
assert_eq!(online_platform_id("XboxOne"), 4);
assert_eq!(online_platform_id("Switch"), 7);
assert_eq!(online_platform_id("Epic"), 11);
assert_eq!(online_platform_id("Mystery"), 0);
}
#[test]
fn parse_player_id_splits_platform_and_id() {
assert_eq!(
parse_player_id("Epic|abc123|0"),
Some(("Epic", "abc123".to_string()))
);
assert_eq!(parse_player_id("Epic||0"), None);
assert_eq!(parse_player_id("nonsense"), None);
}
#[test]
fn selected_match_count_matches_case_insensitively() {
let matches = vec![match_entry("ABC123"), match_entry("def456")];
assert_eq!(
selected_match_count(&matches, &[" abc123 ".to_string(), "DEF456".to_string()]),
2
);
assert_eq!(selected_match_count(&matches, &["missing".to_string()]), 0);
}
#[test]
fn sync_summary_display_matches_cli_output_shape() {
let summary = SyncSummary {
accounts_seen: 1,
matches_seen: 2,
uploaded: 3,
duplicates: 4,
cached: 5,
skipped: 6,
failed: 1,
failed_match_ids: vec!["match-1".to_string()],
failed_uploads: vec![FailedUpload {
target_name: "Rocket Sense".to_string(),
match_id: "match-1".to_string(),
reason: "token missing".to_string(),
}],
};
assert_eq!(
summary.to_string(),
concat!(
"accounts: 1\n",
"matches: 2\n",
"uploaded: 3\n",
"duplicates: 4\n",
"cached: 5\n",
"skipped: 6\n",
"failed: 1\n",
"failed_match_ids: match-1\n",
"failed_upload: Rocket Sense match-1: token missing\n",
)
);
}
#[test]
fn downloaded_replay_removes_file_on_drop() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("match.replay");
std::fs::write(&path, "replay bytes").unwrap();
let replay = DownloadedReplay::new(path.clone());
assert!(replay.path().exists());
drop(replay);
assert!(!path.exists());
}
#[test]
fn replay_download_stems_are_unique_and_keep_match_id() {
let first = unique_replay_download_stem("match-1");
let second = unique_replay_download_stem("match-1");
assert_ne!(first, second);
assert!(first.starts_with("match-1-"));
assert!(first.ends_with(".replay"));
}
fn match_entry(match_guid: &str) -> MatchEntry {
MatchEntry {
replay_url: "https://example.com/replay".to_string(),
match_info: Match {
match_guid: match_guid.to_string(),
record_start_timestamp: 0,
map_name: "DFH Stadium".to_string(),
playlist: 10,
team0_score: 1,
team1_score: 2,
players: Vec::new(),
},
}
}
}