use std::{io::Read, sync::Arc, time::Duration};
use reqwest::{Client, StatusCode};
use serde::de::DeserializeOwned;
use tracing::warn;
use xbox::models::Xuid;
use xbox::util::wrap_xuid;
use super::InfiniteClientError;
use super::endpoints::HaloEndpoints;
use super::film::{FilmEvent, FilmEventReport, decode_events, decode_players, validate_events};
use super::models::{
AppearanceCustomization, BanMessage, BanSummary, CareerRanks, CareerRewardTrack, CsrRecords,
CsrSeason, CsrSeasonCalendar, CurrentUser, CustomizationItemMetadata, EmblemMapping,
EmblemMetadata, FilmChunk, FilmChunkData, FilmManifest, GameModeId, GameVariantAsset,
HipcSettings, MapAsset, MapId, MapModePairAsset, MatchCount, MatchHistoryType, MatchSkill,
MatchStats, MatchType, MatchesPrivacy, MedalMetadata, OperationRewardTrack, PlayerCareerRank,
PlayerChallengeDecks, PlayerCustomizationCollection, PlayerMatchHistory, PlayerOperationPasses,
PlaylistAsset, PlaylistId, PlaylistMetadata, RankedArenaMapMode, RankedArenaSeason,
SeasonCalendar, ServiceRecord, ServiceRecordFilter, UgcAsset, UgcAssetKind, UgcSearchResults,
UserInfo,
};
use super::pager::MatchHistoryPager;
use super::player::Player;
use super::rate_limit::RateLimiter;
use crate::auth::{HaloAuthClient, HaloCredentials};
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
const DEFAULT_REQUESTS_PER_SECOND: u32 = 9;
const DEFAULT_RATE_LIMIT_RETRIES: u32 = 3;
const HALO_PC_USER_AGENT: &str = "SHIVA-2043073184/6.10021.18539.0 (release; PC)";
const HALO_WAYPOINT_USER_AGENT: &str =
"HaloWaypoint/2021112313511900 CFNetwork/1327.0.4 Darwin/21.2.0";
fn origin_of(url: &str) -> &str {
match url.split_once("://") {
Some((scheme, rest)) => {
let host_len = rest.find('/').unwrap_or(rest.len());
&url[..scheme.len() + 3 + host_len]
}
None => url,
}
}
fn retry_delay(response: &reqwest::Response, attempt: u32) -> Duration {
response
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<u64>().ok())
.map(Duration::from_secs)
.unwrap_or_else(|| Duration::from_secs(1u64 << attempt.min(63)))
}
#[derive(Clone)]
pub struct HaloInfiniteClient {
auth: HaloAuthClient,
http: Client,
endpoints: HaloEndpoints,
limiter: RateLimiter,
timeout: Duration,
rate_limit_retries: u32,
}
impl HaloInfiniteClient {
pub fn new(auth: HaloAuthClient) -> Self {
Self::builder().build(auth)
}
pub fn builder() -> HaloInfiniteClientBuilder {
HaloInfiniteClientBuilder::default()
}
#[cfg(test)]
pub(crate) fn with_endpoints(auth: HaloAuthClient, endpoints: HaloEndpoints) -> Self {
Self::builder().build_with_endpoints(auth, endpoints)
}
async fn get<T: DeserializeOwned>(
&self,
base: &str,
path: &str,
query: &[(&str, String)],
) -> Result<T, InfiniteClientError> {
self.get_authenticated(base, path, query, false).await
}
async fn get_with_clearance<T: DeserializeOwned>(
&self,
base: &str,
path: &str,
query: &[(&str, String)],
) -> Result<T, InfiniteClientError> {
self.get_authenticated(base, path, query, true).await
}
async fn get_with_clearance_query<T: DeserializeOwned>(
&self,
base: &str,
path: &str,
query: &[(&str, String)],
) -> Result<T, InfiniteClientError> {
self.get_with_clearance_named_query(base, path, query, "clearanceId")
.await
}
async fn get_with_clearance_named_query<T: DeserializeOwned>(
&self,
base: &str,
path: &str,
query: &[(&str, String)],
clearance_query_name: &'static str,
) -> Result<T, InfiniteClientError> {
let credentials = self.auth.credentials(true).await?;
let mut query = query.to_vec();
if let Some(clearance) = &credentials.clearance {
query.push((clearance_query_name, clearance.clone()));
}
let url = format!("{base}{path}");
match self
.get_once(&url, &query, &credentials, HALO_PC_USER_AGENT)
.await
{
Err(error) if error.is_unauthorized() => {
self.auth.invalidate().await;
let credentials = self.auth.credentials(true).await?;
let mut query = query
.into_iter()
.filter(|(name, _)| *name != clearance_query_name)
.collect::<Vec<_>>();
if let Some(clearance) = &credentials.clearance {
query.push((clearance_query_name, clearance.clone()));
}
self.get_once(&url, &query, &credentials, HALO_PC_USER_AGENT)
.await
}
result => result,
}
}
async fn get_authenticated<T: DeserializeOwned>(
&self,
base: &str,
path: &str,
query: &[(&str, String)],
require_clearance: bool,
) -> Result<T, InfiniteClientError> {
self.get_authenticated_with_user_agent(
base,
path,
query,
require_clearance,
HALO_PC_USER_AGENT,
)
.await
}
async fn get_authenticated_with_user_agent<T: DeserializeOwned>(
&self,
base: &str,
path: &str,
query: &[(&str, String)],
require_clearance: bool,
user_agent: &'static str,
) -> Result<T, InfiniteClientError> {
let url = format!("{base}{path}");
let first = self.auth.credentials(require_clearance).await?;
match self.get_once(&url, query, &first, user_agent).await {
Err(error) if error.is_unauthorized() => {
self.auth.invalidate().await;
let second = self.auth.credentials(require_clearance).await?;
self.get_once(&url, query, &second, user_agent).await
}
result => result,
}
}
async fn get_once<T: DeserializeOwned>(
&self,
url: &str,
query: &[(&str, String)],
credentials: &HaloCredentials,
user_agent: &'static str,
) -> Result<T, InfiniteClientError> {
let mut attempt = 0;
loop {
self.limiter.acquire(origin_of(url)).await;
let mut request = self
.http
.get(url)
.query(query)
.header("X-343-Authorization-Spartan", &credentials.spartan_token)
.header("Accept", "application/json")
.header("User-Agent", user_agent)
.timeout(self.timeout);
if let Some(clearance) = &credentials.clearance {
request = request.header("343-Clearance", clearance);
}
let response = request.send().await?;
if response.status() == StatusCode::TOO_MANY_REQUESTS && attempt < self.rate_limit_retries
{
let delay = retry_delay(&response, attempt);
warn!(
url = %response.url(),
attempt = attempt + 1,
retry_after_seconds = delay.as_secs(),
"Halo Waypoint request was rate limited; backing off"
);
self.limiter.backoff(origin_of(url), delay).await;
attempt += 1;
continue;
}
let url = response.url().to_string();
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
break Err(InfiniteClientError::HttpStatus { url, status, body });
}
let body = response.text().await?;
break serde_json::from_str(&body).map_err(|source| InfiniteClientError::Decode {
url,
source: Arc::new(source),
body,
});
}
}
async fn get_bytes_with_clearance(
&self,
base: &str,
path: &str,
) -> Result<Vec<u8>, InfiniteClientError> {
let url = format!("{base}{path}");
let first = self.auth.credentials(true).await?;
match self.get_bytes_once(&url, &first).await {
Err(error) if error.is_unauthorized() => {
self.auth.invalidate().await;
let second = self.auth.credentials(true).await?;
self.get_bytes_once(&url, &second).await
}
result => result,
}
}
async fn get_bytes_once(
&self,
url: &str,
credentials: &HaloCredentials,
) -> Result<Vec<u8>, InfiniteClientError> {
let mut attempt = 0;
loop {
self.limiter.acquire(origin_of(url)).await;
let mut request = self
.http
.get(url)
.header("X-343-Authorization-Spartan", &credentials.spartan_token)
.header("User-Agent", HALO_PC_USER_AGENT)
.timeout(self.timeout);
if let Some(clearance) = &credentials.clearance {
request = request.header("343-Clearance", clearance);
}
let response = request.send().await?;
if response.status() == StatusCode::TOO_MANY_REQUESTS && attempt < self.rate_limit_retries
{
let delay = retry_delay(&response, attempt);
warn!(
url = %response.url(),
attempt = attempt + 1,
retry_after_seconds = delay.as_secs(),
"Halo Waypoint request was rate limited; backing off"
);
self.limiter.backoff(origin_of(url), delay).await;
attempt += 1;
continue;
}
if response.status().is_success() {
break Ok(response.bytes().await?.to_vec());
}
let url = response.url().to_string();
let status = response.status();
let body = response.text().await.unwrap_or_default();
break Err(InfiniteClientError::HttpStatus { url, status, body });
}
}
async fn resolve_xuid(&self, player: &Player) -> Result<Xuid, InfiniteClientError> {
match player {
Player::Xuid(xuid) => Ok(xuid.clone()),
Player::Gamertag(_) => Ok(Xuid::from(self.user(player).await?.xuid)),
}
}
async fn resolve_xuids(&self, players: &[Player]) -> Result<Vec<Xuid>, InfiniteClientError> {
let mut xuids = Vec::with_capacity(players.len());
for player in players {
xuids.push(self.resolve_xuid(player).await?);
}
Ok(xuids)
}
fn join_wrapped_xuids(xuids: &[Xuid]) -> String {
xuids
.iter()
.map(|xuid| wrap_xuid(xuid.as_str()))
.collect::<Vec<_>>()
.join(",")
}
fn gt_or_xuid_segment(player: &Player) -> String {
match player {
Player::Gamertag(gamertag) => format!("gt({gamertag})"),
Player::Xuid(xuid) => wrap_xuid(xuid.as_str()),
}
}
pub async fn playlist_csr(
&self,
playlist: PlaylistId,
player: &Player,
) -> Result<CsrRecords, InfiniteClientError> {
self.playlist_csr_batch(playlist, std::slice::from_ref(player))
.await
}
pub async fn playlist_csr_batch(
&self,
playlist: PlaylistId,
players: &[Player],
) -> Result<CsrRecords, InfiniteClientError> {
let xuids = self.resolve_xuids(players).await?;
self.get_with_clearance(
&self.endpoints.skill_base_url,
&format!("/hi/playlist/{playlist}/csrs"),
&[("players", Self::join_wrapped_xuids(&xuids))],
)
.await
}
pub async fn service_record(
&self,
player: &Player,
) -> Result<ServiceRecord, InfiniteClientError> {
self.service_record_with(
player,
MatchType::Matchmade,
&ServiceRecordFilter::default(),
)
.await
}
pub async fn service_record_with(
&self,
player: &Player,
match_type: MatchType,
filter: &ServiceRecordFilter,
) -> Result<ServiceRecord, InfiniteClientError> {
let not_found = || InfiniteClientError::GamertagNotFound(player.to_string());
let xuid = match self.resolve_xuid(player).await {
Err(InfiniteClientError::HttpStatus {
status: StatusCode::BAD_REQUEST | StatusCode::NOT_FOUND,
..
}) => return Err(not_found()),
other => other?,
};
let result = self
.get(
&self.endpoints.halostats_base_url,
&format!(
"/hi/players/{}/{}/servicerecord",
wrap_xuid(xuid.as_str()),
match_type.as_str()
),
&filter.to_query(),
)
.await;
match result {
Err(InfiniteClientError::HttpStatus {
status: StatusCode::BAD_REQUEST | StatusCode::NOT_FOUND,
..
}) => Err(not_found()),
other => other,
}
}
pub async fn player_matches(
&self,
player: &Player,
start: u32,
count: u32,
) -> Result<PlayerMatchHistory, InfiniteClientError> {
self.player_matches_of_type(player, start, count, MatchHistoryType::All)
.await
}
pub async fn player_matches_of_type(
&self,
player: &Player,
start: u32,
count: u32,
match_type: MatchHistoryType,
) -> Result<PlayerMatchHistory, InfiniteClientError> {
let xuid = self.resolve_xuid(player).await?;
self.get(
&self.endpoints.halostats_base_url,
&format!("/hi/players/{}/matches", wrap_xuid(xuid.as_str())),
&[
("start", start.to_string()),
("count", count.to_string()),
("type", match_type.as_str().to_string()),
],
)
.await
}
pub fn player_matches_pager(
&self,
player: Player,
match_type: MatchHistoryType,
) -> MatchHistoryPager {
MatchHistoryPager::new(self.clone(), player, match_type)
}
pub async fn match_stats(&self, match_id: &str) -> Result<MatchStats, InfiniteClientError> {
self.get(
&self.endpoints.halostats_base_url,
&format!("/hi/matches/{match_id}/stats"),
&[],
)
.await
}
pub async fn match_film(&self, match_id: &str) -> Result<FilmManifest, InfiniteClientError> {
self.get_with_clearance(
&self.endpoints.ugc_base_url,
&format!("/hi/films/matches/{match_id}/spectate"),
&[],
)
.await
}
pub async fn film_chunk(
&self,
film: &FilmManifest,
chunk: &FilmChunk,
) -> Result<FilmChunkData, InfiniteClientError> {
let base = film.blob_storage_path_prefix.trim_end_matches('/');
let path = format!("/{}", chunk.file_relative_path.trim_start_matches('/'));
let compressed = self.get_bytes_with_clearance(base, &path).await?;
let mut decoder = flate2::read::ZlibDecoder::new(compressed.as_slice());
let mut data = Vec::new();
decoder
.read_to_end(&mut data)
.map_err(|error| InfiniteClientError::FilmDecompression(Arc::new(error)))?;
Ok(FilmChunkData {
metadata: chunk.clone(),
data,
})
}
pub async fn film_chunks(
&self,
film: &FilmManifest,
) -> Result<Vec<FilmChunkData>, InfiniteClientError> {
let mut chunks = Vec::with_capacity(film.custom_data.chunks.len());
for chunk in &film.custom_data.chunks {
chunks.push(self.film_chunk(film, chunk).await?);
}
Ok(chunks)
}
pub async fn match_highlight_events(
&self,
match_id: &str,
) -> Result<Vec<FilmEvent>, InfiniteClientError> {
Ok(self.decoded_highlight_events(match_id).await?.0)
}
async fn decoded_highlight_events(
&self,
match_id: &str,
) -> Result<(Vec<FilmEvent>, Vec<super::film::FilmPlayer>), InfiniteClientError> {
let film = self.match_film(match_id).await?;
let chunks = self.film_chunks(&film).await?;
let players = decode_players(&chunks);
let events = decode_events(&chunks, &players, film.custom_data.film_major_version);
Ok((events, players))
}
pub async fn match_highlight_events_with_validation(
&self,
match_id: &str,
) -> Result<FilmEventReport, InfiniteClientError> {
let ((events, players), match_stats) = tokio::try_join!(
self.decoded_highlight_events(match_id),
self.match_stats(match_id)
)?;
Ok(FilmEventReport {
validation: validate_events(&events, &players, &match_stats),
events,
})
}
pub async fn match_skill(
&self,
match_id: &str,
players: &[Player],
) -> Result<MatchSkill, InfiniteClientError> {
let xuids = self.resolve_xuids(players).await?;
self.get(
&self.endpoints.skill_base_url,
&format!("/hi/matches/{match_id}/skill"),
&[("players", Self::join_wrapped_xuids(&xuids))],
)
.await
}
pub async fn user(&self, player: &Player) -> Result<UserInfo, InfiniteClientError> {
self.get(
&self.endpoints.profile_base_url,
&format!("/users/{}", Self::gt_or_xuid_segment(player)),
&[],
)
.await
}
pub async fn users(&self, players: &[Player]) -> Result<Vec<UserInfo>, InfiniteClientError> {
let xuids = self.resolve_xuids(players).await?;
self.get(
&self.endpoints.profile_base_url,
"/users",
&[(
"xuids",
xuids
.iter()
.map(|xuid| xuid.as_str())
.collect::<Vec<_>>()
.join(","),
)],
)
.await
}
pub async fn appearance(
&self,
player: &Player,
) -> Result<AppearanceCustomization, InfiniteClientError> {
let xuid = self.resolve_xuid(player).await?;
self.get_with_clearance(
&self.endpoints.economy_base_url,
&format!(
"/hi/players/{}/customization/appearance",
wrap_xuid(xuid.as_str())
),
&[],
)
.await
}
pub async fn player_customizations(
&self,
players: &[Player],
) -> Result<PlayerCustomizationCollection, InfiniteClientError> {
let xuids = self.resolve_xuids(players).await?;
self.get_authenticated_with_user_agent(
&self.endpoints.economy_base_url,
"/hi/customization",
&[("players", Self::join_wrapped_xuids(&xuids))],
true,
HALO_WAYPOINT_USER_AGENT,
)
.await
}
pub async fn map(&self, map: MapId) -> Result<MapAsset, InfiniteClientError> {
self.ugc_version("maps", map.asset_id(), map.version_id(), false)
.await
}
pub async fn mode(&self, mode: GameModeId) -> Result<GameVariantAsset, InfiniteClientError> {
self.ugc_version("ugcGameVariants", mode.asset_id(), mode.version_id(), false)
.await
}
pub async fn playlist(
&self,
asset_id: &str,
version_id: &str,
) -> Result<PlaylistAsset, InfiniteClientError> {
self.ugc_version("playlists", asset_id, version_id, true)
.await
}
pub async fn map_mode_pair(
&self,
asset_id: &str,
version_id: &str,
) -> Result<MapModePairAsset, InfiniteClientError> {
self.ugc_version("mapModePairs", asset_id, version_id, true)
.await
}
pub async fn asset(
&self,
kind: UgcAssetKind,
asset_id: &str,
) -> Result<UgcAsset, InfiniteClientError> {
self.get_with_clearance(
&self.endpoints.ugc_base_url,
&format!("/hi/{}/{asset_id}", kind.path_segment()),
&[],
)
.await
}
pub async fn film_asset(&self, asset_id: &str) -> Result<UgcAsset, InfiniteClientError> {
self.asset(UgcAssetKind::Film, asset_id).await
}
pub async fn prefab_asset(&self, asset_id: &str) -> Result<UgcAsset, InfiniteClientError> {
self.asset(UgcAssetKind::Prefab, asset_id).await
}
pub async fn engine_game_variant(
&self,
asset_id: &str,
) -> Result<UgcAsset, InfiniteClientError> {
self.asset(UgcAssetKind::EngineGameVariant, asset_id).await
}
pub async fn search_assets(
&self,
kind: UgcAssetKind,
start: u32,
count: u32,
) -> Result<UgcSearchResults, InfiniteClientError> {
self.get_authenticated_with_user_agent(
&self.endpoints.ugc_base_url,
"/hi/search",
&[
("start", start.to_string()),
("count", count.to_string()),
("include-times", "false".to_string()),
("sort", "PlaysRecent".to_string()),
("order", "Desc".to_string()),
("assetKind", kind.as_str().to_string()),
],
true,
HALO_WAYPOINT_USER_AGENT,
)
.await
}
async fn ugc_version<T: DeserializeOwned>(
&self,
kind: &str,
asset_id: &str,
version_id: &str,
clearance_query: bool,
) -> Result<T, InfiniteClientError> {
let path = format!("/hi/{kind}/{asset_id}/versions/{version_id}");
if clearance_query {
self.get_with_clearance_query(&self.endpoints.ugc_base_url, &path, &[])
.await
} else {
self.get_with_clearance(&self.endpoints.ugc_base_url, &path, &[])
.await
}
}
pub async fn playlist_metadata(
&self,
playlist_id: &str,
) -> Result<PlaylistMetadata, InfiniteClientError> {
self.get_with_clearance(
&self.endpoints.game_cms_base_url,
&format!("/hi/multiplayer/file/playlists/assets/{playlist_id}.json"),
&[],
)
.await
}
pub async fn season_calendar(&self) -> Result<SeasonCalendar, InfiniteClientError> {
self.get_with_clearance(
&self.endpoints.game_cms_base_url,
"/hi/progression/file/calendars/seasons/seasoncalendar.json",
&[],
)
.await
}
pub async fn medals(&self) -> Result<MedalMetadata, InfiniteClientError> {
self.get_with_clearance(
&self.endpoints.game_cms_base_url,
"/hi/Waypoint/file/medals/metadata.json",
&[],
)
.await
}
pub async fn emblem_mapping(&self) -> Result<EmblemMapping, InfiniteClientError> {
self.get_with_clearance(
&self.endpoints.game_cms_base_url,
"/hi/Waypoint/file/images/emblems/mapping.json",
&[],
)
.await
}
pub async fn emblem_metadata(
&self,
emblem_path: &str,
) -> Result<EmblemMetadata, InfiniteClientError> {
self.customization_metadata(emblem_path).await
}
pub async fn customization_metadata(
&self,
item_path: &str,
) -> Result<CustomizationItemMetadata, InfiniteClientError> {
self.get_with_clearance(
&self.endpoints.game_cms_base_url,
&format!("/hi/progression/file/{}", item_path.trim_start_matches('/')),
&[],
)
.await
}
pub async fn customization_image(
&self,
metadata: &CustomizationItemMetadata,
) -> Result<Option<Vec<u8>>, InfiniteClientError> {
let Some(path) = metadata.image_cms_path() else {
return Ok(None);
};
self.game_cms_image(path).await.map(Some)
}
pub async fn rank_icon_image(&self, cms_path: &str) -> Result<Vec<u8>, InfiniteClientError> {
self.game_cms_image(cms_path).await
}
async fn game_cms_image(&self, cms_path: &str) -> Result<Vec<u8>, InfiniteClientError> {
self.get_bytes_with_clearance(
&self.endpoints.game_cms_base_url,
&format!("/hi/images/file/{}", cms_path.trim_start_matches('/')),
)
.await
}
pub async fn emblem_image(
&self,
assets: &super::models::EmblemImageAssets,
) -> Result<Vec<u8>, InfiniteClientError> {
self.waypoint_image(&assets.emblem_cms_path).await
}
pub async fn emblem_nameplate(
&self,
assets: &super::models::EmblemImageAssets,
) -> Result<Vec<u8>, InfiniteClientError> {
self.waypoint_image(&assets.nameplate_cms_path).await
}
async fn waypoint_image(&self, cms_path: &str) -> Result<Vec<u8>, InfiniteClientError> {
self.get_bytes_with_clearance(
&self.endpoints.game_cms_base_url,
&format!("/hi/Waypoint/file/{}", cms_path.trim_start_matches('/')),
)
.await
}
pub async fn ban_summary(&self, players: &[Player]) -> Result<BanSummary, InfiniteClientError> {
let xuids = self.resolve_xuids(players).await?;
self.get(
&self.endpoints.ban_base_url,
"/hi/bansummary",
&[
("auth", "st".to_string()),
("targets", Self::join_wrapped_xuids(&xuids)),
],
)
.await
}
pub async fn ban_message(&self, message_path: &str) -> Result<BanMessage, InfiniteClientError> {
self.get_with_clearance_named_query(
&self.endpoints.game_cms_base_url,
&format!("/hi/Banning/file/{}", message_path.trim_start_matches('/')),
&[],
"flight",
)
.await
}
pub async fn matches_privacy(
&self,
player: &Player,
) -> Result<MatchesPrivacy, InfiniteClientError> {
let xuid = self.resolve_xuid(player).await?;
self.get(
&self.endpoints.halostats_base_url,
&format!("/hi/players/{}/matches-privacy", wrap_xuid(xuid.as_str())),
&[],
)
.await
}
pub async fn current_user(&self) -> Result<CurrentUser, InfiniteClientError> {
self.get(&self.endpoints.current_user_url, "", &[]).await
}
pub async fn player_match_count(
&self,
player: &Player,
) -> Result<MatchCount, InfiniteClientError> {
let xuid = self.resolve_xuid(player).await?;
self.get(
&self.endpoints.halostats_base_url,
&format!("/hi/players/{}/matches/count", wrap_xuid(xuid.as_str())),
&[],
)
.await
}
pub async fn challenge_decks(
&self,
player: &Player,
) -> Result<PlayerChallengeDecks, InfiniteClientError> {
let xuid = self.resolve_xuid(player).await?;
self.get(
&self.endpoints.halostats_base_url,
&format!("/hi/players/{}/decks", wrap_xuid(xuid.as_str())),
&[],
)
.await
}
pub async fn career_rank_with_track(
&self,
player: &Player,
reward_track_id: &str,
) -> Result<PlayerCareerRank, InfiniteClientError> {
let xuid = self.resolve_xuid(player).await?;
self.get_with_clearance(
&self.endpoints.economy_base_url,
&format!(
"/hi/players/{}/rewardtracks/careerranks/{reward_track_id}",
wrap_xuid(xuid.as_str())
),
&[],
)
.await
}
pub async fn career_rank(
&self,
player: &Player,
) -> Result<PlayerCareerRank, InfiniteClientError> {
self.career_rank_with_track(player, "careerRank1").await
}
pub async fn career_rank_of(
&self,
player: &Player,
) -> Result<PlayerCareerRank, InfiniteClientError> {
let ranks = self.career_ranks(std::slice::from_ref(player)).await?;
ranks
.records
.into_iter()
.next()
.map(|record| record.result)
.ok_or_else(|| InfiniteClientError::CareerRankNotFound(player.to_string()))
}
pub async fn career_ranks(
&self,
players: &[Player],
) -> Result<CareerRanks, InfiniteClientError> {
let xuids = self.resolve_xuids(players).await?;
self.get_with_clearance(
&self.endpoints.economy_base_url,
"/hi/careerranks/careerRank1",
&[("players", Self::join_wrapped_xuids(&xuids))],
)
.await
}
pub async fn reward_track_operations(
&self,
player: &Player,
) -> Result<PlayerOperationPasses, InfiniteClientError> {
let xuid = self.resolve_xuid(player).await?;
self.get_with_clearance(
&self.endpoints.economy_base_url,
&format!(
"/hi/players/{}/rewardtracks/operations",
wrap_xuid(xuid.as_str())
),
&[],
)
.await
}
pub async fn career_reward_track(&self) -> Result<CareerRewardTrack, InfiniteClientError> {
self.get_with_clearance(
&self.endpoints.game_cms_base_url,
"/hi/Progression/file/RewardTracks/CareerRanks/careerRank1.json",
&[],
)
.await
}
pub async fn operation_reward_track(
&self,
reward_track_path: &str,
) -> Result<OperationRewardTrack, InfiniteClientError> {
self.get_with_clearance(
&self.endpoints.game_cms_base_url,
&format!(
"/hi/Progression/file/{}",
reward_track_path.trim_start_matches('/')
),
&[],
)
.await
}
pub async fn csr_season_calendar(&self) -> Result<CsrSeasonCalendar, InfiniteClientError> {
self.get_with_clearance(
&self.endpoints.game_cms_base_url,
"/hi/Progression/file/Csr/Calendars/CsrSeasonCalendar.json",
&[],
)
.await
}
pub async fn csr_season_file(
&self,
file_path: &str,
) -> Result<serde_json::Value, InfiniteClientError> {
let path = format!("/hi/Progression/file/{}", file_path.trim_start_matches('/'));
self.get(&self.endpoints.game_cms_base_url, &path, &[])
.await
}
pub async fn current_csr_season(&self) -> Result<Option<CsrSeason>, InfiniteClientError> {
let calendar = self.csr_season_calendar().await?;
Ok(calendar.current(chrono::Utc::now()).cloned())
}
pub async fn current_ranked_arena(
&self,
) -> Result<Option<RankedArenaSeason>, InfiniteClientError> {
let Some(season) = self.current_csr_season().await? else {
return Ok(None);
};
let playlist_id = PlaylistId::RANKED_ARENA.as_str();
let metadata = self.playlist_metadata(playlist_id).await?;
let playlist = self
.playlist(playlist_id, &metadata.ugc_playlist_version)
.await?;
let tasks = playlist
.rotation_entries
.into_iter()
.map(|rotation| {
let client = self.clone();
tokio::spawn(async move {
let pair = client
.map_mode_pair(&rotation.asset.asset_id, &rotation.asset.version_id)
.await?;
let (map, mode) = tokio::try_join!(
client.map(MapId::new(
pair.map.asset_id.clone(),
pair.map.version_id.clone(),
)),
client.mode(GameModeId::new(
pair.mode.asset_id.clone(),
pair.mode.version_id.clone(),
))
)?;
Ok::<_, InfiniteClientError>(RankedArenaMapMode {
weight: rotation.metadata.weight,
pair,
map,
mode,
})
})
})
.collect::<Vec<_>>();
let mut map_modes = Vec::with_capacity(tasks.len());
for task in tasks {
map_modes.push(task.await.map_err(|_| InfiniteClientError::TaskJoin)??);
}
Ok(Some(RankedArenaSeason { season, map_modes }))
}
pub async fn settings(&self) -> Result<HipcSettings, InfiniteClientError> {
self.get(
&self.endpoints.settings_base_url,
"/settings/hipc/e2a0a7c6-6efe-42af-9283-c2ab73250c48",
&[],
)
.await
}
}
#[derive(Debug, Clone)]
pub struct HaloInfiniteClientBuilder {
timeout: Duration,
requests_per_second: u32,
rate_limit_retries: u32,
http: Option<Client>,
}
impl Default for HaloInfiniteClientBuilder {
fn default() -> Self {
Self {
timeout: DEFAULT_TIMEOUT,
requests_per_second: DEFAULT_REQUESTS_PER_SECOND,
rate_limit_retries: DEFAULT_RATE_LIMIT_RETRIES,
http: None,
}
}
}
impl HaloInfiniteClientBuilder {
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn requests_per_second(mut self, requests_per_second: u32) -> Self {
self.requests_per_second = requests_per_second;
self
}
pub fn rate_limit_retries(mut self, rate_limit_retries: u32) -> Self {
self.rate_limit_retries = rate_limit_retries;
self
}
pub fn http_client(mut self, http: Client) -> Self {
self.http = Some(http);
self
}
pub fn build(self, auth: HaloAuthClient) -> HaloInfiniteClient {
self.build_with_endpoints(auth, HaloEndpoints::default())
}
pub(crate) fn build_with_endpoints(
self,
auth: HaloAuthClient,
endpoints: HaloEndpoints,
) -> HaloInfiniteClient {
HaloInfiniteClient {
auth,
http: self.http.unwrap_or_default(),
endpoints,
limiter: RateLimiter::per_second(self.requests_per_second),
timeout: self.timeout,
rate_limit_retries: self.rate_limit_retries,
}
}
}