StartRNR/src/main.rs
2023-12-02 13:20:19 -05:00

676 lines
21 KiB
Rust

#![feature(iterator_try_collect)]
#![feature(extend_one)]
use clap::{Parser, Subcommand};
use sqlite::*;
use std::path::PathBuf;
use time_format::strftime_utc;
mod queries;
use queries::*;
mod database;
use database::*;
mod sync;
use sync::*;
mod util;
use util::*;
/// ## CLI Structs
#[derive(Parser)]
#[command(name = "StartRNR")]
#[command(author = "Kiana Sheibani <kiana.a.sheibani@gmail.com>")]
#[command(version = "0.2.0")]
#[command(about = "StartRNR - Rating system for competitive video games based on start.gg",
long_about = None)]
struct Cli {
#[command(subcommand)]
subcommand: Subcommands,
#[arg(
short = 'A',
long = "auth",
value_name = "TOKEN",
global = true,
help = "Authentication token",
long_help = "The authentication token for accessing start.gg.
A token can be specified using this argument, in the environment variable
AUTH_TOKEN, or in a text file '<CONFIG_DIR>/auth.txt'."
)]
auth_token: Option<String>,
#[arg(
short,
long = "config",
value_name = "DIR",
global = true,
help = "Config directory",
long_help = "This flag overrides the default config directory.
If this directory does not exist, it will be created and a database file will
be initialized within it."
)]
config_dir: Option<PathBuf>,
}
#[derive(Subcommand)]
enum Subcommands {
#[command(about = "Manipulate stored datasets")]
Dataset {
#[command(subcommand)]
subcommand: DatasetSC,
},
#[command(
about = "Sync player ratings",
long_about = "Pull recent tournament data off of start.gg and use it to
update player ratings. This command will automatically keep track of the last time
each dataset was synced to ensure that each tournament is only accounted for once."
)]
Sync {
#[arg(
help = "The datasets to sync",
long_help = "A list of datasets to sync.
If no datasets are given, then the dataset 'default' is synced. This dataset is
created if it does not already exist."
)]
datasets: Vec<String>,
#[arg(short, long, help = "Sync all stored databases")]
all: bool,
},
#[command(about = "Access player information")]
Player {
#[command(subcommand)]
subcommand: PlayerSC,
#[arg(short, long, global = true, help = "The dataset to access")]
dataset: Option<String>,
},
}
#[derive(Subcommand)]
enum DatasetSC {
#[command(about = "List datasets")]
List,
#[command(about = "Create a new dataset")]
New { name: Option<String> },
#[command(about = "Delete a dataset")]
Delete { name: Option<String> },
#[command(about = "Rename a dataset")]
Rename {
old: Option<String>,
new: Option<String>,
},
}
#[derive(Subcommand)]
enum PlayerSC {
#[command(about = "Get info about a player")]
Info { player: String },
#[command(about = "Matchup data between two players")]
Matchup { player1: String, player2: String },
}
fn main() {
let cli = Cli::parse();
let config_dir = cli
.config_dir
.unwrap_or_else(|| dirs::config_dir().expect("Could not determine config directory"));
let connection =
open_datasets(&config_dir).unwrap_or_else(|_| error("Could not open datasets file", 2));
#[allow(unreachable_patterns)]
match cli.subcommand {
Subcommands::Dataset {
subcommand: DatasetSC::List,
} => dataset_list(&connection),
Subcommands::Dataset {
subcommand: DatasetSC::New { name },
} => dataset_new(&connection, get_auth_token(&config_dir), name),
Subcommands::Dataset {
subcommand: DatasetSC::Delete { name },
} => dataset_delete(&connection, name),
Subcommands::Dataset {
subcommand: DatasetSC::Rename { old, new },
} => dataset_rename(&connection, old, new),
Subcommands::Player {
subcommand: PlayerSC::Info { player },
dataset,
} => player_info(&connection, dataset, player),
Subcommands::Player {
subcommand: PlayerSC::Matchup { player1, player2 },
dataset,
} => player_matchup(&connection, dataset, player1, player2),
Subcommands::Sync { datasets, all } => {
sync(&connection, get_auth_token(&config_dir), datasets, all)
}
_ => println!("This feature is currently unimplemented."),
}
}
// Datasets
fn dataset_list(connection: &Connection) {
let datasets = list_datasets(&connection).expect("Error communicating with SQLite");
for (name, metadata) in datasets {
print!(
"\n· \x1b[1m\x1b[34m{}\x1b[0m
\x1b[4m\x1b]8;;https://www.start.gg/{}\x1b\\{}\x1b]8;;\x1b\\\x1b[0m ",
name, metadata.game_slug, metadata.game_name
);
if let Some(country) = metadata.country {
if let Some(state) = metadata.state {
println!("(in {}, {})", country, state);
} else {
println!("(in {})", country);
}
} else {
println!("(Global)");
}
if metadata.last_sync.0 == 1 {
print!("\x1b[1m\x1b[91mUnsynced\x1b[0m");
} else {
print!(
"\x1b[1mLast synced:\x1b[0m {}",
strftime_utc("%b %e, %Y %I:%M %p", metadata.last_sync.0 as i64).unwrap()
);
}
if current_time().0 - metadata.last_sync.0 > SECS_IN_WEEK {
if name == "default" {
print!(" - \x1b[33mRun 'startrnr sync' to update!\x1b[0m");
} else {
print!(
" - \x1b[33mRun 'startrnr sync {:?}' to update!\x1b[0m",
name
);
}
}
println!();
if metadata.set_limit != 0 && metadata.decay_rate != metadata.adj_decay_rate {
println!("\x1b[1mSet Limit:\x1b[0m {}", metadata.set_limit);
println!(
"\x1b[1mNetwork Decay Rate:\x1b[0m {} (adjusted {})",
metadata.decay_rate, metadata.adj_decay_rate
);
} else {
println!("\x1b[1mNetwork Decay Rate:\x1b[0m {}", metadata.decay_rate);
}
println!(
"\x1b[1mRating Period:\x1b[0m {} days",
metadata.period / SECS_IN_DAY as f64
);
println!("\x1b[1mTau Constant:\x1b[0m {}", metadata.tau);
}
}
fn dataset_new(connection: &Connection, auth: String, name: Option<String>) {
// Name
let name = name.unwrap_or_else(|| {
print!("Name of new dataset: ");
read_string()
});
// Game
print!("Search games: ");
let games = run_query::<VideogameSearch, _>(
VideogameSearchVars {
name: &read_string(),
},
&auth,
)
.unwrap_or_else(|| error("Could not access start.gg", 1));
if games.is_empty() {
issue("No games found!", 0);
}
println!("\nSearch results:");
for (i, game) in games.iter().enumerate() {
println!("{} - {}", i, game.name);
}
print!("\nGame to track ratings for (0-{}): ", games.len() - 1);
let index = read_string()
.parse::<usize>()
.unwrap_or_else(|_| error("Not an integer", 1));
if index >= games.len() {
error("Out of range!", 1);
}
let VideogameData {
id: game_id,
name: game_name,
slug: game_slug,
} = games[index].clone();
// Location
print!(
"
\x1b[1mCountry\x1b[0m
Enter the two-letter code for the country you want to track ratings in, e.g.
\"US\" for the United States. See \x1b[4m\x1b]8;;https://www.ups.com/worldshiphelp/\
WSA/ENU/AppHelp/mergedProjects/CORE/Codes/Country_Territory_and_Currency_Codes.htm\
\x1b\\this site\x1b]8;;\x1b\\\x1b[0m for a list of these codes.
If no code is entered, then the dataset will track all players globally.
Country to track ratings for (leave empty for none): "
);
let country = {
let mut string = read_string();
if string.is_empty() {
None
} else if string.len() == 2 && string.chars().all(|c| c.is_ascii_alphabetic()) {
string.make_ascii_uppercase();
Some(string)
} else {
error("Input is not a two-letter code", 1);
}
};
let state = if country.as_ref().is_some_and(|s| s == "US" || s == "CA") {
print!(
"
\x1b[1mState/Province\x1b[0m
Enter the two-letter code for the US state or Canadian province you want to track
ratings in, e.g. \"CA\" for California. See \x1b[4m\x1b]8;;https://www.ups.com/worldshiphelp/\
WSA/ENU/AppHelp/mergedProjects/CORE/Codes/State_Province_Codes.htm\x1b\\this site\
\x1b]8;;\x1b\\\x1b[0m for a list of these codes.
If no code is entered, then the dataset will track all players within the country.
State/province to track ratings for (leave empty for none): "
);
let mut string = read_string();
if string.is_empty() {
None
} else if string.len() == 2 && string.chars().all(|c| c.is_ascii_alphabetic()) {
string.make_ascii_uppercase();
Some(string)
} else {
error("Input is not a two-letter code", 1);
}
} else {
None
};
// Set Limit
let mut set_limit = 0;
print!(
"
\x1b[1mSet Limit\x1b[0m
The set limit is an optional feature of the rating system that defines how many
sets must be played between two players for their rating data to be considered
trustworthy.
This value should be set low, i.e. not more than 5 or 6.
Set limit (default 0): "
);
let set_limit_input = read_string();
if !set_limit_input.is_empty() {
set_limit = set_limit_input
.parse::<u64>()
.unwrap_or_else(|_| error("Input is not an integer", 1));
}
// Advanced Options
// Defaults
let mut decay_rate = 0.8;
let mut adj_decay_rate = 0.6;
let mut period_days = 40.0;
let mut tau = 0.4;
print!("\nConfigure advanced options? (y/n) ");
if let Some('y') = read_string().chars().next() {
// Decay Rate
print!(
"
\x1b[1mNetwork Decay Rate\x1b[0m
The network decay rate is a number between 0 and 1 that controls how the
advantage network reacts to player wins and losses. If the decay rate is 1,
then it is assumed that a player's skill against one opponent always carries
over to all other opponents. If the decay rate is 0, then all player match-ups
are assumed to be independent of each other.
Network decay rate (default 0.8): "
);
let decay_rate_input = read_string();
if !decay_rate_input.is_empty() {
decay_rate = decay_rate_input
.parse::<f64>()
.unwrap_or_else(|_| error("Input is not a number", 1));
if decay_rate < 0.0 || decay_rate > 1.0 {
error("Input is not between 0 and 1", 1);
}
}
// Adjusted Decay Rate
if set_limit != 0 {
print!(
"
\x1b[1mAdjusted Network Decay Rate\x1b[0m
If the number of sets played between two players is less than the set limit,
then this value is used instead of the regular network decay rate.
This value should be \x1b[1mlower\x1b[0m than the network decay rate.
Adjusted network decay rate (default 0.6): "
);
let adj_decay_rate_input = read_string();
if !adj_decay_rate_input.is_empty() {
adj_decay_rate = adj_decay_rate_input
.parse::<f64>()
.unwrap_or_else(|_| error("Input is not a number", 1));
if decay_rate < 0.0 || decay_rate > 1.0 {
error("Input is not between 0 and 1", 1);
}
}
}
// Rating Period
print!(
"
\x1b[1mRating Period\x1b[0m
The rating period is an interval of time that dictates how player ratings change
during inactivity. Ideally the rating period should be somewhat long, long
enough to expect almost every player in the dataset to have played at least a
few sets.
Rating period (in days, default 40): "
);
let period_input = read_string();
if !period_input.is_empty() {
period_days = period_input
.parse::<f64>()
.unwrap_or_else(|_| error("Input is not a number", 1));
}
// Tau coefficient
print!(
"
\x1b[1mTau Constant\x1b[0m
The tau constant is an internal system constant that roughly represents how
much random chance and luck play a role in game outcomes. In games where match
results are highly predictable, and a player's skill is the sole factor for
whether they will win, the tau constant should be high (0.9 - 1.2). In games
where luck matters, and more improbable victories can occur, the tau constant
should be low (0.2 - 0.4).
The tau constant is set low by default, since skill-based competitive video
games tend to be on the more luck-heavy side.
Tau constant (default 0.4): "
);
let tau_input = read_string();
if !tau_input.is_empty() {
tau = tau_input
.parse::<f64>()
.unwrap_or_else(|_| error("Input is not a number", 1));
}
}
// Done configuring
new_dataset(
connection,
&name,
DatasetMetadata {
last_sync: Timestamp(1),
game_id,
game_name,
game_slug,
country,
state,
set_limit,
decay_rate,
adj_decay_rate,
period: SECS_IN_DAY as f64 * period_days,
tau,
},
)
.expect("Error communicating with SQLite");
println!("\nCreated dataset {}", name);
}
fn dataset_delete(connection: &Connection, name: Option<String>) {
let name = name.unwrap_or_else(|| {
print!("Dataset to delete: ");
read_string()
});
delete_dataset(connection, &name).unwrap_or_else(|_| error("That dataset does not exist!", 1));
}
fn dataset_rename(connection: &Connection, old: Option<String>, new: Option<String>) {
let old = old.unwrap_or_else(|| {
print!("Dataset to rename: ");
read_string()
});
let new = new.unwrap_or_else(|| {
print!("Rename to: ");
read_string()
});
match rename_dataset(connection, &old, &new) {
Ok(()) => (),
Err(sqlite::Error {
code: Some(1),
message: _,
}) => error(&format!("Dataset {:?} does not exist", &old), 1),
Err(sqlite::Error {
code: Some(19),
message: _,
}) => error(&format!("Dataset {:?} already exists", &new), 1),
Err(_) => error("Unknown error occurred", 2),
};
}
// Players
fn player_info(connection: &Connection, dataset: Option<String>, player: String) {
let dataset = dataset.unwrap_or_else(|| String::from("default"));
let PlayerData {
id,
name,
prefix,
discrim,
} = get_player_from_input(connection, player)
.unwrap_or_else(|_| error("Could not find player", 1));
let (deviation, volatility, _) = get_player_rating_data(connection, &dataset, id)
.unwrap_or_else(|_| error("Could not find player", 1));
let (won, lost) = get_player_set_counts(connection, &dataset, id)
.unwrap_or_else(|_| error("Could not find player", 1));
println!();
if let Some(pre) = prefix {
print!("\x1b[2m{}\x1b[22m ", pre);
}
println!(
"\x1b[4m\x1b]8;;https://www.start.gg/user/{1}\x1b\\\
\x1b[1m{0}\x1b[22m\x1b]8;;\x1b\\\x1b[0m ({1})",
name, discrim
);
println!("\x1b[1mID:\x1b[0m {}", id.0);
println!(
"\n\x1b[1mSet Count:\x1b[0m {} - {} ({:.3}%)",
won,
lost,
(won as f64 / (won + lost) as f64) * 100.0
);
println!("\n\x1b[1mDeviation:\x1b[0m {}", deviation);
println!("\x1b[1mVolatility:\x1b[0m {}", volatility);
}
// TODO: Finish
fn player_matchup(
connection: &Connection,
dataset: Option<String>,
player1: String,
player2: String,
) {
let dataset = dataset.unwrap_or_else(|| String::from("default"));
let PlayerData {
id: player1,
name: name1,
prefix: prefix1,
discrim: discrim1,
} = get_player_from_input(connection, player1)
.unwrap_or_else(|_| error("Could not find player", 1));
let (deviation1, _, _) = get_player_rating_data(connection, &dataset, player1)
.unwrap_or_else(|_| error("Could not find player", 1));
let PlayerData {
id: player2,
name: name2,
prefix: prefix2,
discrim: discrim2,
} = get_player_from_input(connection, player2)
.unwrap_or_else(|_| error("Could not find player", 1));
let (deviation2, _, _) = get_player_rating_data(connection, &dataset, player2)
.unwrap_or_else(|_| error("Could not find player", 1));
let (hypothetical, advantage) = get_advantage(connection, &dataset, player1, player2)
.expect("Error communicating with SQLite")
.map(|x| (false, x))
.unwrap_or_else(|| {
let metadata = get_metadata(connection, &dataset)
.expect("Error communicating with SQLite")
.unwrap_or_else(|| error("Dataset not found", 1));
(
true,
hypothetical_advantage(
connection,
&dataset,
player1,
player2,
metadata.set_limit,
metadata.decay_rate,
metadata.adj_decay_rate,
)
.expect("Error communicating with SQLite"),
)
});
let probability = 1.0
/ (1.0
+ f64::exp(
g_func((deviation1 * deviation1 + deviation2 * deviation2).sqrt()) * advantage,
));
let color = ansi_num_color(advantage, 0.2, 2.0);
let other_color = ansi_num_color(-advantage, 0.2, 2.0);
let len1 = prefix1.as_deref().map(|s| s.len() + 1).unwrap_or(0) + name1.len();
let len2 = prefix2.as_deref().map(|s| s.len() + 1).unwrap_or(0) + name2.len();
println!();
if let Some(pre) = prefix1 {
print!("\x1b[2m{}\x1b[22m ", pre);
}
print!(
"\x1b[4m\x1b]8;;https://www.start.gg/user/{}\x1b\\\
\x1b[1m{}\x1b[22m\x1b]8;;\x1b\\\x1b[0m - ",
discrim1, name1
);
if let Some(pre) = prefix2 {
print!("\x1b[2m{}\x1b[22m ", pre);
}
println!(
"\x1b[4m\x1b]8;;https://www.start.gg/user/{}\x1b\\\
\x1b[1m{}\x1b[22m\x1b]8;;\x1b\\\x1b[0m",
discrim2, name2
);
println!(
"\x1b[1m\x1b[{4}m{0:>2$}\x1b[0m - \x1b[1m\x1b[{5}m{1:<3$}\x1b[0m",
format!("{:.1}%", probability * 100.0),
format!("{:.1}%", (1.0 - probability) * 100.0),
len1,
len2,
other_color,
color
);
if hypothetical {
println!(
"\n\x1b[1mHypothetical Advantage: \x1b[{1}m{0:+.4}\x1b[0m",
advantage, color
);
} else {
println!(
"\n\x1b[1mAdvantage: \x1b[{1}m{0:+.4}\x1b[0m",
advantage, color
);
let (a, b) = get_matchup_set_counts(connection, &dataset, player1, player2)
.expect("Error communicating with SQLite");
println!(
"\n\x1b[1mSet Count:\x1b[0m {} - {} ({:.3}% - {:.3}%)",
a,
b,
(a as f64 / (a + b) as f64) * 100.0,
(b as f64 / (a + b) as f64) * 100.0
);
}
}
// Sync
fn sync(connection: &Connection, auth: String, datasets: Vec<String>, all: bool) {
let all_datasets = list_dataset_names(connection).unwrap();
let datasets = if all {
all_datasets
} else if datasets.is_empty() {
if all_datasets.is_empty() {
print!("No datasets exist; create one? (y/n) ");
if let Some('y') = read_string().chars().next() {
dataset_new(connection, auth.clone(), Some(String::from("default")));
vec![String::from("default")]
} else {
error("No datasets specified and no default dataset", 1)
}
} else if all_datasets.iter().any(|x| x == "default") {
vec![String::from("default")]
} else {
error("No datasets specified and no default dataset", 1);
}
} else {
datasets
};
let current_time = current_time();
for dataset in datasets {
let dataset_config = get_metadata(connection, &dataset)
.expect("Error communicating with SQLite")
.unwrap_or_else(|| error(&format!("Dataset {} does not exist!", dataset), 1));
sync_dataset(connection, &dataset, dataset_config, current_time, &auth)
.expect("Error communicating with SQLite");
update_last_sync(connection, &dataset, current_time)
.expect("Error communicating with SQLite");
}
}