Initial commit

This commit is contained in:
Arne Dußin 2024-11-21 19:19:44 +01:00
parent 3ee50f9aff
commit bd6dbebb31
20 changed files with 2644 additions and 0 deletions

1573
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

9
Cargo.toml Normal file
View file

@ -0,0 +1,9 @@
[workspace]
resolver = "2"
members = [
"cli",
"core",
"db",
"logs_tf"
]

6
cli/Cargo.toml Normal file
View file

@ -0,0 +1,6 @@
[package]
name = "ucli"
version = "0.1.0"
edition = "2021"
[dependencies]

4
cli/src/main.rs Normal file
View file

@ -0,0 +1,4 @@
fn main()
{
println!("Hello, world!");
}

8
core/Cargo.toml Normal file
View file

@ -0,0 +1,8 @@
[package]
name = "ucore"
version = "0.1.0"
edition = "2021"
[dependencies]
num-derive = "0.4.2"
num-traits = "0.2.19"

85
core/src/class.rs Normal file
View file

@ -0,0 +1,85 @@
use std::fmt;
use std::str::FromStr;
use num_derive::FromPrimitive;
pub const NUM_CLASSES: usize = 9;
/// All TF2 classes.
#[derive(Copy, Clone, Debug, PartialEq, Eq, FromPrimitive)]
pub enum Class
{
Scout,
Soldier,
Pyro,
Demoman,
Heavy,
Engineer,
Medic,
Sniper,
Spy,
Unknown,
}
impl Class
{
/// Check if the class is considered a "main" class, which means it is under
/// consideration for main (most) played class during a game.
///
/// # Returns
/// `true` if the class is main-classeable, currently that includes
/// `Demoman`, `Scout`, `Soldier` and `Medic`. `false` for all other
/// classes.
pub fn is_main_class(self) -> bool
{
matches!(
self,
Self::Demoman | Self::Medic | Self::Scout | Self::Soldier
)
}
}
/// When creating identifying a class from a string, the class may be unknown in
/// case the string does not conform to all lowercase string as it is present in
/// the logs.tf API. In that case, this error is thrown, containing the content
/// of the string.
#[derive(Debug)]
pub struct UnknownClassError
{
class: String,
}
impl fmt::Display for UnknownClassError
{
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result
{
write!(f, "Unknown class `{}`", &self.class)
}
}
impl std::error::Error for UnknownClassError {}
impl FromStr for Class
{
// Returns the name of the class in case the class is unknown
type Err = UnknownClassError;
fn from_str(s: &str) -> Result<Self, Self::Err>
{
match s {
"demoman" => Ok(Self::Demoman),
"engineer" => Ok(Self::Engineer),
"heavy" | "heavyweapons" => Ok(Self::Heavy),
"medic" => Ok(Self::Medic),
"pyro" => Ok(Self::Pyro),
"scout" => Ok(Self::Scout),
"sniper" => Ok(Self::Sniper),
"soldier" => Ok(Self::Soldier),
"spy" => Ok(Self::Spy),
"unknown" => Ok(Self::Unknown),
unknown => Err(UnknownClassError {
class: unknown.to_string(),
}),
}
}
}

5
core/src/lib.rs Normal file
View file

@ -0,0 +1,5 @@
pub mod class;
pub use class::*;
pub mod steam_id;
pub use steam_id::*;

264
core/src/steam_id.rs Normal file
View file

@ -0,0 +1,264 @@
//! Handling of steam ids. Since there are multiple versions and the logs.tf API
//! uses steamID64 for lookups but has steamID3s in the log files, a safe
//! conversion and type safety between these two is critical.
use std::str::FromStr;
use num_derive::FromPrimitive;
use num_traits::FromPrimitive;
const ACCOUNT_INSTANCE_OFFSET_BITS: u64 = 32;
// Account instance is located at ACCOUNT_INSTANCE_OFFSET_BITS and 20 bits long
const ACCOUNT_INSTANCE_MASK: u64 = 0xfffff << ACCOUNT_INSTANCE_OFFSET_BITS;
const ACCOUNT_TYPE_OFFSET_BITS: u64 = 52;
const UNIVERSE_OFFSET_BITS: u64 = 56;
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub struct SteamID
{
id64: u64,
}
impl SteamID
{
/// Create a steam id from its steamID64 representation.
///
/// The id value is not checked and it is therefore possible to create an
/// invalid steam id with this.
pub const unsafe fn new(id64: u64) -> Self { Self { id64 } }
/// Create a steam id from its steamID64 representation.
///
/// Checks if the id is in a sane format. In case it is not, an `Err(())` is
/// returned.
///
/// # Warning
/// It does not actually make a request to check if there is a profile
/// connected to this steam id, so lookups for the profile may still fail.
pub fn new_checked(id64: u64) -> Result<Self, ()>
{
if Self::try_for_universe(id64).is_some()
&& Self::try_for_account_type(id64).is_some()
// Check for normal user account, the only one currently supported
&& (id64 & ACCOUNT_INSTANCE_MASK == 1 << ACCOUNT_INSTANCE_OFFSET_BITS)
{
Ok(Self { id64 })
}
else {
Err(())
}
}
/// Create a steam id from the parts usually present. The account type will
/// always be set to a user account.
pub fn from_parts(universe: Universe, account_type: AccountType, id: u32) -> Self
{
let mut id64 = 0;
id64 |= id as u64;
// Assume user account
id64 |= 1 << ACCOUNT_INSTANCE_OFFSET_BITS;
id64 |= (account_type as u64) << ACCOUNT_TYPE_OFFSET_BITS;
id64 |= (universe as u64) << UNIVERSE_OFFSET_BITS;
Self { id64 }
}
fn try_for_universe(id64: u64) -> Option<Universe>
{
let universe_byte: u8 =
((id64 & (0xff << UNIVERSE_OFFSET_BITS)) >> UNIVERSE_OFFSET_BITS) as u8;
Universe::from_u8(universe_byte)
}
fn try_for_account_type(id64: u64) -> Option<AccountType>
{
let account_type_nibble: u8 =
((id64 & (0xf << ACCOUNT_TYPE_OFFSET_BITS)) >> ACCOUNT_TYPE_OFFSET_BITS) as u8;
AccountType::from_u8(account_type_nibble)
}
/// Get the universe this account is part of.
///
/// # Panics
/// If there is no valid universe in this steam id, which means that the
/// internal data is corrupt, which is only possible when creating using the
/// unsafe `new` method.
pub fn universe(self) -> Universe
{
Self::try_for_universe(self.id64).expect("Corrupted steam id. Check unsafe `new` calls")
}
/// Get the type of this account
///
/// # Panics
/// If the steam id is corrupt. Can only happen with accounts created with
/// unsafe `new` method.
pub fn account_type(self) -> AccountType
{
Self::try_for_account_type(self.id64).expect("Corrupted steam id. Check unsafe `new` calls")
}
pub fn id64(self) -> u64 { self.id64 }
pub fn to_id64_string(self) -> String { self.id64.to_string() }
pub fn to_id3_string(self) -> String
{
let mut res = "[".to_owned();
res.push(self.account_type().into());
res.push(':');
let id = self.id64 as u32;
res += &(id & 1).to_string();
res.push(':');
res += &(id >> 1).to_string();
res.push(']');
res
}
pub fn to_id1_string(self) -> String
{
let mut res = "STEAM_".to_owned();
res += &(self.universe() as u8).to_string();
res.push(':');
let id = self.id64 as u32;
res += &(id & 1).to_string();
res.push(':');
res += &(id >> 1).to_string();
res
}
}
impl FromStr for SteamID
{
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err>
{
// Try known conversions
// Starting with steamid64 if it's just a number.
if let Ok(id64) = s.parse::<u64>() {
Self::new_checked(id64)
}
// Check for ID3
else if s.starts_with('[') && s.ends_with(']') {
let parts: Vec<&str> = s.split(':').collect();
if parts.len() == 3 && parts[0].len() == 2 && parts[1].len() == 1 {
let account_id = parts[2][..parts[2].len() - 1]
.parse::<u32>()
.map_err(|_| ())?;
let account_type: AccountType = parts[0].chars().nth(1).unwrap().try_into()?;
let universe: Universe = Universe::Public;
Ok(Self::from_parts(universe, account_type, account_id))
}
else {
Err(())
}
}
// Check for legacy ID format
else if s.starts_with("STEAM_") {
todo!()
}
// Not a known format
else {
Err(())
}
}
}
#[derive(Copy, Clone, Debug, FromPrimitive)]
pub enum Universe
{
Unspecified = 0,
Public = 1,
Beta = 2,
Internal = 3,
Dev = 4,
RC = 5,
}
#[derive(Copy, Clone, Debug, FromPrimitive)]
pub enum AccountType
{
Invalid = 0,
Individual = 1,
Multiseat = 2,
GameServer = 3,
AnonGameServer = 4,
Pending = 5,
ContentServer = 6,
Clan = 7,
Chat = 8,
// P2P SuperSeeder ignored
AnonUser = 10,
}
impl From<AccountType> for char
{
fn from(value: AccountType) -> Self
{
match value {
AccountType::Invalid => 'I',
AccountType::Individual => 'U',
AccountType::Multiseat => 'M',
AccountType::GameServer => 'G',
AccountType::AnonGameServer => 'A',
AccountType::Pending => 'P',
AccountType::ContentServer => 'C',
AccountType::Clan => 'g',
AccountType::Chat => 'c',
AccountType::AnonUser => 'a',
}
}
}
impl TryFrom<char> for AccountType
{
type Error = ();
fn try_from(value: char) -> Result<Self, Self::Error>
{
match value {
'I' => Ok(Self::Invalid),
'U' => Ok(Self::Individual),
'M' => Ok(Self::Multiseat),
'G' => Ok(Self::GameServer),
'A' => Ok(Self::AnonGameServer),
'P' => Ok(Self::Pending),
'C' => Ok(Self::ContentServer),
'g' => Ok(Self::Clan),
'T' | 'L' | 'c' => Ok(Self::Chat),
'a' => Ok(Self::AnonUser),
_ => Err(()),
}
}
}
#[cfg(test)]
mod test
{
use std::str::FromStr;
use crate::SteamID;
#[test]
fn from_id3()
{
assert_eq!(
SteamID::from_str("[U:1:71020853]")
.expect("Unable to parse")
.id64(),
76561198031286581
);
assert_eq!(
SteamID::from_str("[U:1:287181528]")
.expect("Unable to parse")
.id64(),
76561198247447256
);
}
}

6
db/Cargo.toml Normal file
View file

@ -0,0 +1,6 @@
[package]
name = "udb"
version = "0.1.0"
edition = "2021"
[dependencies]

14
db/src/lib.rs Normal file
View file

@ -0,0 +1,14 @@
pub fn add(left: u64, right: u64) -> u64 { left + right }
#[cfg(test)]
mod tests
{
use super::*;
#[test]
fn it_works()
{
let result = add(2, 2);
assert_eq!(result, 4);
}
}

10
logs_tf/Cargo.toml Normal file
View file

@ -0,0 +1,10 @@
[package]
name = "logs_tf"
version = "0.1.0"
edition = "2021"
[dependencies]
chrono = "0.4.38"
json = "0.12.4"
reqwest = { version = "0.12.9", features = ["blocking"] }
ucore = { path = "../core" }

83
logs_tf/src/lib.rs Normal file
View file

@ -0,0 +1,83 @@
pub mod log;
pub mod performance;
pub mod query_error;
pub mod search_params;
use std::thread;
use std::time::Duration;
use json::JsonValue;
pub use log::*;
pub use query_error::*;
use reqwest::blocking as reqwest;
use self::search_params::SearchParams;
const LOGS_TF_API_BASE: &str = "https://logs.tf/api/v1/log";
/// Function that tries to execute something that returns a result. If it does
/// not work the first time, it will keep trying num_retries times until it
/// either returns Ok() or all the tries have been used up.
pub(self) fn keep_trying<A, R, E>(action: A, num_retries: u8) -> Result<R, E>
where
A: Fn() -> Result<R, E>,
{
let mut num_tries = 0;
loop {
let res = action();
num_tries += 1;
if res.is_ok() || num_tries > num_retries + 1 {
return res;
}
}
}
/// Sleep for a little time before making a request to logs.tf. The API is very
/// sensitive to quickly making queries to it and will respond with invalid
/// responses otherwise.
pub(self) fn log_delay() { thread::sleep(Duration::from_millis(500)) }
/// Checks for the `"success": true` field in the json value, which is always
/// set by logs.tf. If `"success": false` is set, it will parse the error and
/// return a `QueryError`.
fn check_json_success(json: &JsonValue) -> QueryResult<()>
{
let success = json["success"].as_bool().unwrap();
if success {
Ok(())
}
else {
let error = json["error"].as_str().unwrap();
Err(QueryError::Unsuccessful(error.to_owned()))
}
}
fn search_logs_once(search_params: &SearchParams) -> QueryResult<Vec<LogMetadata>>
{
log_delay();
let request = reqwest::Client::builder().build()?.get(LOGS_TF_API_BASE);
let request = search_params.add_params_to_request(request);
let response = request.send()?;
let json = json::parse(&(response.text()?)).unwrap();
check_json_success(&json)?;
Ok(json["logs"]
.members()
.map(|meta| LogMetadata::from_json(meta))
.collect())
}
/// Query logs.tf for logs with the given parameters. Takes a number of retries.
/// Should the first query fail, this is the number of tries it will take until
/// it gives up querying.
///
/// # Returns
/// The metadata of all logs that fit the search parameters
pub fn search_logs(search_params: SearchParams, num_retries: u8) -> QueryResult<Vec<LogMetadata>>
{
keep_trying(|| search_logs_once(&search_params), num_retries)
}

127
logs_tf/src/log.rs Normal file
View file

@ -0,0 +1,127 @@
use std::collections::HashMap;
use std::str::FromStr;
use chrono::{DateTime, Utc};
use json::JsonValue;
use ucore::SteamID;
use super::{keep_trying, QueryResult, LOGS_TF_API_BASE};
use crate::performance::{Performance, Score};
pub struct LogMetadata
{
pub id: u32,
pub date_time: DateTime<Utc>,
pub map: String,
pub num_players: u8,
}
pub struct Log
{
meta: LogMetadata,
performances: HashMap<SteamID, Vec<Performance>>,
duration_secs: u32,
}
impl LogMetadata
{
pub fn from_json(json: &JsonValue) -> Self
{
Self {
id: json["id"].as_u32().unwrap(),
date_time: DateTime::from_timestamp(json["date"].as_i64().unwrap(), 0)
.expect("Failed to parse datetime"),
map: json["map"].as_str().unwrap().to_owned(),
num_players: json["players"].as_u8().unwrap(),
}
}
}
impl Log
{
pub(crate) fn new(meta: LogMetadata, duration_secs: u32) -> Self
{
Self {
meta,
performances: HashMap::new(),
duration_secs,
}
}
fn download_once(id: u32) -> QueryResult<Self>
{
let log = reqwest::blocking::get(format!("{}/{}", LOGS_TF_API_BASE, id))?
.text()
.expect("Unable to read response body");
let json = json::parse(&log)?;
super::check_json_success(&json)?;
Ok(Self::from_json(id, &json))
}
/// Download the log with the given id from logs.tf and turn it into a
/// format that can be processed by a rating system easily.
pub fn download(id: u32, num_retries: u8) -> QueryResult<Self>
{
keep_trying(|| Self::download_once(id), num_retries)
}
/// Parse the json information as found on logs.tf into a format easily
/// digestible by the rating system.
// XXX: Check presumed logs.tf json for any format deviances
pub fn from_json(id: u32, json: &JsonValue) -> Self
{
let info = &json["info"];
let duration_secs = info["total_length"]
.as_u32()
.expect("Duration is not an unsigned int");
let map = info["map"]
.as_str()
.expect("Unable to read map of log")
.to_owned();
let timestamp = info["date"]
.as_u32()
.expect("Unable to read date as Unix timestamp") as i64;
let date_time = DateTime::from_timestamp(timestamp, 0).expect("Invalid timestamp");
let num_players = json["names"].members().len() as u8;
let meta = LogMetadata {
id,
date_time,
map,
num_players,
};
let score = Score::from_json(json);
let mut performances = HashMap::new();
for (player_id, stats) in json["players"].entries() {
let player_id =
SteamID::from_str(player_id).expect("Player id is not a valid steam id");
let player_performances = Performance::extract_all_from_json(&score, stats);
performances.insert(player_id, player_performances);
}
Self {
meta,
performances,
duration_secs,
}
}
pub fn meta(&self) -> &LogMetadata { &self.meta }
pub fn duration_secs(&self) -> u32 { self.duration_secs }
pub fn performances(&self) -> &HashMap<SteamID, Vec<Performance>> { &self.performances }
pub(crate) fn add_performance(&mut self, player: SteamID, performance: Performance)
{
match self.performances.get_mut(&player) {
Some(perfs) => perfs.push(performance),
None => {
self.performances.insert(player, vec![performance]);
},
}
}
}

View file

@ -0,0 +1,80 @@
use std::str::FromStr;
use json::JsonValue;
use ucore::Class;
use super::Performance;
pub struct DMPerformance
{
pub class: Class,
pub kills: u8,
pub assists: u8,
pub deaths: u8,
pub damage: u32,
pub time_played_secs: u32,
}
impl DMPerformance
{
pub fn extract_all_from_json(json: &JsonValue) -> Vec<Self>
{
json["class_stats"]
.members()
.map(|class_stats| Self {
class: Class::from_str(class_stats["type"].as_str().unwrap()).unwrap(),
kills: class_stats["kills"].as_u8().unwrap(),
assists: class_stats["assists"].as_u8().unwrap(),
deaths: class_stats["deaths"].as_u8().unwrap(),
damage: class_stats["dmg"].as_u32().unwrap(),
time_played_secs: class_stats["total_time"].as_u32().unwrap(),
})
.collect()
}
}
impl From<DMPerformance> for Performance
{
fn from(value: DMPerformance) -> Self { Self::DM(value) }
}
#[cfg(test)]
mod tests
{
use std::fs::File;
use std::io::Read;
use super::*;
#[test]
fn extract_all_from_json()
{
let mut json = String::new();
File::open("test_data/log_3094861.json")
.expect("Unable to open test file")
.read_to_string(&mut json)
.expect("Unable to read file to string");
let json = json::parse(&json).expect("Unable to parse json");
let perfs = DMPerformance::extract_all_from_json(&json["players"]["[U:1:886717065]"]);
assert_eq!(perfs.len(), 3);
let scout_perf = &perfs[0];
let engi_perf = &perfs[1];
let _pyro_perf = &perfs[2];
assert_eq!(scout_perf.class, Class::Scout);
assert_eq!(scout_perf.kills, 19);
assert_eq!(scout_perf.assists, 14);
assert_eq!(scout_perf.deaths, 16);
assert_eq!(scout_perf.damage, 6671);
assert_eq!(scout_perf.time_played_secs, 1618);
assert_eq!(engi_perf.class, Class::Engineer);
assert_eq!(engi_perf.kills, 0);
assert_eq!(engi_perf.assists, 2);
assert_eq!(engi_perf.deaths, 0);
assert_eq!(engi_perf.damage, 293);
assert_eq!(engi_perf.time_played_secs, 99);
}
}

View file

@ -0,0 +1,76 @@
use std::str::FromStr;
use json::JsonValue;
use ucore::Class;
use super::Performance;
pub struct MedicPerformance
{
pub healing: u32,
pub average_uber_length_secs: f32,
pub num_ubers: u8,
pub num_drops: u8,
pub deaths: u8,
pub time_played_secs: u32,
}
impl MedicPerformance
{
pub fn extract_from_json(json: &JsonValue) -> Option<Self>
{
let class_stats = json["class_stats"].members().find(|class_stats| {
Class::from_str(class_stats["type"].as_str().unwrap()).unwrap() == Class::Medic
});
if !json.has_key("medicstats") || class_stats.is_none() {
return None;
}
let class_stats = class_stats.unwrap();
Some(Self {
healing: json["heal"].as_u32().unwrap_or(0),
average_uber_length_secs: json["medicstats"]["avg_uber_length"]
.as_f32()
.unwrap_or(0.0),
num_ubers: json["ubers"].as_u8().unwrap_or(0),
num_drops: json["drops"].as_u8().unwrap_or(0),
deaths: class_stats["deaths"].as_u8().unwrap_or(0),
time_played_secs: class_stats["total_time"].as_u32().unwrap_or(0),
})
}
}
impl From<MedicPerformance> for Performance
{
fn from(value: MedicPerformance) -> Self { Self::Med(value) }
}
#[cfg(test)]
mod tests
{
use std::fs::File;
use std::io::Read;
use super::*;
#[test]
fn extract_from_json()
{
let mut json = String::new();
File::open("test_data/log_3094861.json")
.expect("Unable to open test file")
.read_to_string(&mut json)
.expect("Unable to read file to string");
let json = json::parse(&json).expect("Unable to parse json");
let stats = MedicPerformance::extract_from_json(&json["players"]["[U:1:71020853]"])
.expect("Unable to find medic performance");
assert_eq!(stats.healing, 22732);
assert_eq!(stats.average_uber_length_secs, 6.875);
assert_eq!(stats.num_ubers, 12);
assert_eq!(stats.num_drops, 0);
assert_eq!(stats.deaths, 10);
assert_eq!(stats.time_played_secs, 1738);
}
}

View file

@ -0,0 +1,44 @@
pub mod dm_performance;
pub mod medic_performance;
pub mod overall_performance;
pub mod score;
use dm_performance::DMPerformance;
use json::JsonValue;
use medic_performance::MedicPerformance;
use overall_performance::OverallPerformance;
pub use self::score::Score;
/// A `Performance` contains what a player has done in the course of a game. It
/// contains either a generic performance, where data is not available on a per
/// class basis and the specific performance with information of that class,
/// being either a DM class or the medic.
pub enum Performance
{
Overall(OverallPerformance),
DM(DMPerformance),
Med(MedicPerformance),
}
impl Performance
{
pub fn extract_all_from_json(score: &Score, json: &JsonValue) -> Vec<Performance>
{
let overall_performance = OverallPerformance::from_json(score, json);
let dm_performances = DMPerformance::extract_all_from_json(json);
let med_performance = MedicPerformance::extract_from_json(json);
let mut performances = vec![overall_performance.into()];
for dm_perf in dm_performances {
performances.push(dm_perf.into());
}
if let Some(med_performance) = med_performance {
performances.push(med_performance.into());
}
performances
}
}

View file

@ -0,0 +1,53 @@
use std::str::FromStr;
use json::JsonValue;
use super::score::{Score, Team};
use super::Performance;
#[derive(Clone)]
pub struct OverallPerformance
{
pub won_rounds: u8,
pub num_rounds: u8,
pub damage: u32,
pub damage_taken: u32,
pub kills: u8,
pub deaths: u8,
pub num_medkits: u16,
pub medkits_hp: u32,
}
impl OverallPerformance
{
pub fn from_json(score: &Score, json: &JsonValue) -> Self
{
let team = Team::from_str(json["team"].as_str().unwrap()).unwrap();
let won_rounds = score.get_score(team);
let lost_rounds = score.get_score(team.other());
let num_rounds = won_rounds + lost_rounds;
let damage = json["dmg"].as_u32().unwrap_or(0);
let damage_taken = json["dt"].as_u32().unwrap_or(0);
let kills = json["kills"].as_u8().unwrap_or(0);
let deaths = json["deaths"].as_u8().unwrap_or(0);
let num_medkits = json["medkits"].as_u16().unwrap_or(0);
let medkits_hp = json["medkits_hp"].as_u32().unwrap_or(0);
Self {
won_rounds,
num_rounds,
damage,
damage_taken,
kills,
deaths,
num_medkits,
medkits_hp,
}
}
}
impl From<OverallPerformance> for Performance
{
fn from(value: OverallPerformance) -> Self { Self::Overall(value) }
}

View file

@ -0,0 +1,62 @@
use std::str::FromStr;
use json::JsonValue;
pub struct Score
{
red: u8,
blue: u8,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Team
{
Red,
Blue,
}
impl Score
{
pub fn new(red: u8, blue: u8) -> Self { Self { red, blue } }
pub fn from_json(json: &JsonValue) -> Self
{
let red = json["teams"]["Red"]["score"].as_u8().unwrap();
let blue = json["teams"]["Blue"]["score"].as_u8().unwrap();
Self { red, blue }
}
pub fn get_score(&self, team: Team) -> u8
{
match team {
Team::Red => self.red,
Team::Blue => self.blue,
}
}
}
impl Team
{
pub fn other(self) -> Self
{
match self {
Self::Red => Self::Blue,
Self::Blue => Self::Red,
}
}
}
impl FromStr for Team
{
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err>
{
match s.trim().to_lowercase().as_str() {
"red" => Ok(Self::Red),
"blue" => Ok(Self::Blue),
_ => Err(()),
}
}
}

View file

@ -0,0 +1,56 @@
use std::error::Error;
use std::fmt;
use json::JsonError;
use reqwest::Error as HttpError;
/// Any error that may occur when querying data from logs.tf
#[derive(Debug)]
pub enum QueryError
{
/// An error that can occur when the connection to logs.tf is unstable or
/// the service is down.
HttpResponse(HttpError),
/// If for whatever reason an invalid Json file is returned by logs.tf or it
/// is corrupted.
JsonParseError(JsonError),
/// The Json object returned always contains `"success": true` or
/// `"success": false` to let the other party know if the query succeeded.
/// If it is false, this error is returned.
Unsuccessful(String),
}
pub type QueryResult<T> = Result<T, QueryError>;
impl From<HttpError> for QueryError
{
fn from(e: HttpError) -> Self { Self::HttpResponse(e) }
}
impl From<JsonError> for QueryError
{
fn from(e: JsonError) -> Self { Self::JsonParseError(e) }
}
impl fmt::Display for QueryError
{
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result
{
match self {
Self::HttpResponse(http_e) => {
write!(f, "An error occured contacting logs.tf: {}", http_e)
},
Self::JsonParseError(json_e) => {
write!(f, "logs.tf did not return valid json: {}", json_e)
},
Self::Unsuccessful(e) => {
write!(
f,
"logs.tf could not successfully complete the query: {}",
e
)
},
}
}
}
impl Error for QueryError {}

View file

@ -0,0 +1,79 @@
use std::cmp;
use reqwest::blocking::RequestBuilder;
use ucore::SteamID;
pub struct SearchParams
{
pub player_id: Option<SteamID>,
pub title: Option<String>,
pub limit: Option<u16>,
}
impl SearchParams
{
pub fn player_id(id: SteamID) -> Self
{
Self {
player_id: Some(id),
title: None,
limit: None,
}
}
pub fn log_title(title: String) -> Self
{
Self {
player_id: None,
title: Some(title),
limit: None,
}
}
pub fn limit(limit: u16) -> Self
{
let limit = cmp::min(limit, 10000);
Self {
player_id: None,
title: None,
limit: Some(limit),
}
}
pub fn add_player_id(mut self, id: SteamID) -> Self
{
self.player_id.replace(id);
self
}
pub fn add_log_title(mut self, title: String) -> Self
{
self.title.replace(title);
self
}
pub fn add_limit(mut self, limit: u16) -> Self
{
self.limit.replace(cmp::min(limit, 10000));
self
}
pub fn add_params_to_request(&self, request_builder: RequestBuilder) -> RequestBuilder
{
let request_builder = match &self.player_id {
Some(id) => request_builder.query(&[("player", &id.to_id64_string())]),
None => request_builder,
};
let request_builder = match &self.title {
Some(name) => request_builder.query(&[("title", &name)]),
None => request_builder,
};
match &self.limit {
Some(limit) => request_builder.query(&[("limit", &limit.to_string())]),
None => request_builder,
}
}
}