Move CLI crate to root of workspace

Somehow I missed that this was a thing you could do? It's a much
cleaner organization, and it makes it so that you don't have to
explicitly specify the crate to build.
This commit is contained in:
Kiana Sheibani 2023-09-23 03:01:36 -04:00
parent 2c6587aca1
commit dd4f0838ab
Signed by: toki
GPG key ID: 6CB106C25E86A9F7
14 changed files with 75 additions and 83 deletions

133
src/datasets.rs Normal file
View file

@ -0,0 +1,133 @@
use crate::queries::*;
use sqlite::*;
use std::fs::{self, File};
use std::path::{Path, PathBuf};
/// Return the path to a dataset.
pub fn dataset_path(config_dir: &Path, dataset: &str) -> PathBuf {
// $config_dir/datasets/$dataset.sqlite
let mut path = config_dir.to_owned();
path.push("datasets");
// Create datasets path if it doesn't exist
fs::create_dir_all(&path).unwrap();
path.push(dataset);
path.set_extension("db");
path
}
pub fn open_dataset(dataset: &Path) -> sqlite::Result<Connection> {
let query = "
CREATE TABLE IF NOT EXISTS players (
id INTEGER PRIMARY KEY,
name TEXT,
prefix TEXT,
elo REAL NOT NULL
) STRICT;
";
File::create(dataset).map_err(|e| Error {
code: {
println!("{:?}", e);
None
},
message: Some("unable to open database file".to_owned()),
})?;
let connection = sqlite::open(dataset)?;
connection.execute(query)?;
Ok(connection)
}
// Score calculation
/// Calculate the collective expected score for each team.
fn expected_scores(ratings: &Teams<&mut f64>) -> Vec<f64> {
let qs: Vec<f64> = ratings
.into_iter()
.map(|es| 10_f64.powf(es.iter().map(|x| **x).sum::<f64>() / es.len() as f64 / 400.0))
.collect();
let sumq: f64 = qs.iter().sum();
qs.into_iter().map(|q| q / sumq).collect()
}
/// Adjust the ratings of each player based on who won.
fn adjust_ratings(ratings: Teams<&mut f64>, winner: usize) {
let exp_scores = expected_scores(&ratings);
ratings
.into_iter()
.zip(exp_scores.into_iter())
.enumerate()
.for_each(|(i, (es, exp_sc))| {
let len = es.len() as f64;
let score = f64::from(winner == i);
es.into_iter()
.for_each(|e| *e += 40.0 * (score - exp_sc) / len);
})
}
// Database Updating
pub fn add_players(connection: &Connection, teams: &Teams<PlayerData>) -> sqlite::Result<()> {
let query = "INSERT OR IGNORE INTO players VALUES (?, ?, ?, 1500)";
teams.iter().try_for_each(|team| {
team.iter().try_for_each(|PlayerData { id, name, prefix }| {
let mut statement = connection.prepare(query)?;
statement.bind((1, id.0 as i64))?;
statement.bind((2, name.as_ref().map(|x| &x[..])))?;
statement.bind((3, prefix.as_ref().map(|x| &x[..])))?;
statement.into_iter().try_for_each(|x| x.map(|_| ()))?;
Ok(())
})
})
}
pub fn get_ratings(
connection: &Connection,
teams: &Teams<PlayerData>,
) -> sqlite::Result<Teams<(PlayerId, f64)>> {
let query = "SELECT id, elo FROM players WHERE id = ?";
teams
.iter()
.map(|team| {
team.iter()
.map(|data| {
let mut statement = connection.prepare(query)?;
statement.bind((1, data.id.0 as i64))?;
statement.next()?;
Ok((data.id, statement.read::<f64, _>("elo")?))
})
.try_collect()
})
.try_collect()
}
pub fn update_ratings(connection: &Connection, elos: Teams<(PlayerId, f64)>) -> sqlite::Result<()> {
let query = "UPDATE players SET elo = :elo WHERE id = :id";
elos.into_iter().try_for_each(|team| {
team.into_iter().try_for_each(|(id, elo)| {
let mut statement = connection.prepare(query)?;
statement.bind((":elo", elo))?;
statement.bind((":id", id.0 as i64))?;
statement.into_iter().try_for_each(|x| x.map(|_| ()))?;
Ok(())
})
})
}
pub fn update_from_set(connection: &Connection, results: SetData) -> sqlite::Result<()> {
let players_data = results.teams;
add_players(connection, &players_data)?;
let mut elos = get_ratings(connection, &players_data)?;
adjust_ratings(
elos.iter_mut()
.map(|v| v.iter_mut().map(|x| &mut x.1).collect())
.collect(),
results.winner,
);
update_ratings(connection, elos)
}

35
src/main.rs Normal file
View file

@ -0,0 +1,35 @@
#![feature(iterator_try_collect)]
use std::io::{self, Write};
mod queries;
use queries::*;
mod datasets;
use datasets::*;
fn main() {
let mut config_dir = dirs::config_dir().unwrap();
config_dir.push("ggelo");
let path = dataset_path(&config_dir, "test");
let connection = open_dataset(&path).unwrap();
let set_data = SetData {
teams: vec![
vec![PlayerData {
id: PlayerId(1),
name: Some("player1".to_owned()),
prefix: None,
}],
vec![PlayerData {
id: PlayerId(2),
name: Some("player2".to_owned()),
prefix: None,
}],
],
winner: 0,
};
update_from_set(&connection, set_data.clone()).unwrap();
println!("{:?}", get_ratings(&connection, &set_data.teams).unwrap());
}

84
src/queries.rs Normal file
View file

@ -0,0 +1,84 @@
use cynic::{GraphQlResponse, QueryBuilder};
use serde::{Deserialize, Serialize};
use std::path::Path;
pub mod search_games;
pub use search_games::*;
pub mod tournament_sets;
pub use tournament_sets::*;
pub mod player_info;
pub use player_info::*;
use schema::schema;
// Auth key
pub fn get_auth_key(config_dir: &Path) -> Option<String> {
use std::env::{var, VarError};
use std::fs::read_to_string;
match var("AUTH_KEY") {
Ok(key) => Some(key),
Err(VarError::NotUnicode(_)) => panic!("Invalid authorization key"),
Err(VarError::NotPresent) => {
let mut auth_file = config_dir.to_owned();
auth_file.push("auth.txt");
read_to_string(auth_file).ok().and_then(|s| {
let trimmed = s.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_owned())
}
})
}
}
}
// Types
// HACK: Unfortunately, start.gg seems to use integers for its ID type, whereas
// cynic always assumes that IDs are strings. To get around that, we define new
// scalar types that deserialize to u64.
#[derive(cynic::Scalar, Debug, Copy, Clone)]
#[cynic(graphql_type = "ID")]
pub struct VideogameId(pub u64);
#[derive(cynic::Scalar, Debug, Copy, Clone)]
#[cynic(graphql_type = "ID")]
pub struct EntrantId(pub u64);
#[derive(cynic::Scalar, Debug, Copy, Clone)]
#[cynic(graphql_type = "ID")]
pub struct PlayerId(pub u64);
#[derive(cynic::Scalar, Debug, Clone)]
pub struct Timestamp(pub u64);
// Query machinery
pub trait QueryUnwrap<Vars>: 'static + QueryBuilder<Vars> {
type Unwrapped;
fn unwrap_response(response: GraphQlResponse<Self>) -> Option<Self::Unwrapped>;
}
// Generic function for running start.gg queries
pub fn run_query<Builder, Vars>(vars: Vars, auth: &str) -> Option<Builder::Unwrapped>
where
Builder: QueryUnwrap<Vars>,
Vars: Serialize,
for<'de> Builder: Deserialize<'de>,
{
use cynic::http::ReqwestBlockingExt;
let query = Builder::build(vars);
let response = reqwest::blocking::Client::new()
.post("https://api.start.gg/gql/alpha")
.header("Authorization", String::from("Bearer ") + auth)
.run_graphql(query);
Builder::unwrap_response(response.unwrap())
}

View file

@ -0,0 +1,48 @@
use super::{PlayerId, QueryUnwrap};
use cynic::GraphQlResponse;
use schema::schema;
// Variables
#[derive(cynic::QueryVariables, Debug)]
pub struct PlayerInfoVars {
pub id: PlayerId,
}
// Query
#[derive(cynic::QueryFragment, Debug)]
#[cynic(graphql_type = "Query", variables = "PlayerInfoVars")]
pub struct PlayerInfo {
#[arguments(id: $id)]
player: Option<Player>,
}
#[derive(cynic::QueryFragment, Debug)]
struct Player {
id: Option<PlayerId>,
gamer_tag: Option<String>,
prefix: Option<String>,
}
// Unwrapping
#[derive(Debug, Clone)]
pub struct PlayerData {
pub id: PlayerId,
pub name: Option<String>,
pub prefix: Option<String>,
}
impl QueryUnwrap<PlayerInfoVars> for PlayerInfo {
type Unwrapped = PlayerData;
fn unwrap_response(response: GraphQlResponse<PlayerInfo>) -> Option<PlayerData> {
let player = response.data?.player?;
Some(PlayerData {
id: player.id?,
name: player.gamer_tag,
prefix: player.prefix.filter(|pr| !pr.is_empty()),
})
}
}

View file

@ -0,0 +1,60 @@
use super::{QueryUnwrap, VideogameId};
use cynic::GraphQlResponse;
use schema::schema;
// Variables
#[derive(cynic::QueryVariables)]
pub struct VideogameSearchVars<'a> {
pub name: &'a str,
}
// Query
#[derive(cynic::QueryFragment, Debug)]
#[cynic(graphql_type = "Query", variables = "VideogameSearchVars")]
pub struct VideogameSearch {
#[arguments(query: { filter: { name: $name }, page: 1, perPage: 10 })]
videogames: Option<VideogameConnection>,
}
#[derive(cynic::QueryFragment, Debug)]
struct VideogameConnection {
#[cynic(flatten)]
nodes: Vec<Videogame>,
}
#[derive(cynic::QueryFragment, Debug)]
struct Videogame {
id: Option<VideogameId>,
name: Option<String>,
}
// Unwrapping
#[derive(Debug, Clone)]
pub struct VideogameData {
pub id: VideogameId,
pub name: String,
}
impl<'a> QueryUnwrap<VideogameSearchVars<'a>> for VideogameSearch {
type Unwrapped = Vec<VideogameData>;
fn unwrap_response(response: GraphQlResponse<VideogameSearch>) -> Option<Vec<VideogameData>> {
Some(
response
.data?
.videogames?
.nodes
.into_iter()
.filter_map(|game| {
Some(VideogameData {
id: game.id?,
name: game.name?,
})
})
.collect(),
)
}
}

View file

@ -0,0 +1,177 @@
use super::{EntrantId, PlayerData, PlayerId, QueryUnwrap, Timestamp, VideogameId};
use cynic::GraphQlResponse;
use schema::schema;
pub type Teams<T> = Vec<Vec<T>>;
// Variables
#[derive(cynic::QueryVariables, Debug)]
pub struct TournamentSetsVars<'a> {
// HACK: This should really be an optional variable, but there seems to be a
// server-side bug that completely breaks everything when this isn't passed.
// We can use a dummy value of 1 when we don't want to filter by time.
pub last_query: Timestamp,
pub game_id: VideogameId,
pub country: Option<&'a str>,
pub state: Option<&'a str>,
}
// Query
#[derive(cynic::QueryFragment, Debug)]
#[cynic(graphql_type = "Query", variables = "TournamentSetsVars")]
pub struct TournamentSets {
#[arguments(query: {
page: 1,
perPage: 1,
sortBy: "endAt desc",
filter: {
past: true,
afterDate: $last_query,
videogameIds: [$game_id],
countryCode: $country,
addrState: $state
}})]
tournaments: Option<TournamentConnection>,
}
#[derive(cynic::QueryFragment, Debug)]
#[cynic(variables = "TournamentSetsVars")]
struct TournamentConnection {
#[cynic(flatten)]
nodes: Vec<Tournament>,
}
#[derive(cynic::QueryFragment, Debug)]
#[cynic(variables = "TournamentSetsVars")]
struct Tournament {
name: Option<String>,
#[arguments(limit: 99999, filter: { videogameId: [$game_id] })]
#[cynic(flatten)]
events: Vec<Event>,
}
#[derive(cynic::QueryFragment, Debug)]
struct Event {
#[arguments(page: 1, perPage: 999)]
sets: Option<SetConnection>,
}
#[derive(cynic::QueryFragment, Debug)]
struct SetConnection {
#[cynic(flatten)]
nodes: Vec<Set>,
}
#[derive(cynic::QueryFragment, Debug)]
struct Set {
#[arguments(includeByes: true)]
#[cynic(flatten)]
slots: Vec<SetSlot>,
winner_id: Option<i32>,
}
#[derive(cynic::QueryFragment, Debug)]
struct SetSlot {
entrant: Option<Entrant>,
}
#[derive(cynic::QueryFragment, Debug)]
struct Entrant {
id: Option<EntrantId>,
#[cynic(flatten)]
participants: Vec<Participant>,
}
#[derive(cynic::QueryFragment, Debug)]
struct Participant {
player: Option<Player>,
}
#[derive(cynic::QueryFragment, Debug)]
struct Player {
id: Option<PlayerId>,
gamer_tag: Option<String>,
prefix: Option<String>,
}
// Unwrap
#[derive(Debug, Clone)]
pub struct TournamentData {
pub name: String,
pub sets: Vec<SetData>,
}
#[derive(Debug, Clone)]
pub struct SetData {
pub teams: Teams<PlayerData>,
pub winner: usize,
}
impl<'a> QueryUnwrap<TournamentSetsVars<'a>> for TournamentSets {
type Unwrapped = Vec<TournamentData>;
// This might be the most spaghetti code I've ever written
fn unwrap_response(response: GraphQlResponse<TournamentSets>) -> Option<Vec<TournamentData>> {
Some(
response
.data?
.tournaments?
.nodes
.into_iter()
.filter_map(|tour| {
let sets = tour
.events
.into_iter()
.filter_map(|event| {
Some(
event
.sets?
.nodes
.into_iter()
.filter_map(|set| {
let winner_id = set.winner_id?;
let winner = set.slots.iter().position(|slot| {
slot.entrant
.as_ref()
.and_then(|x| x.id)
.map(|id| id.0 == winner_id as u64)
.unwrap_or(false)
})?;
let teams = set
.slots
.into_iter()
.map(|slot| {
slot.entrant?
.participants
.into_iter()
.map(|p| {
let p_ = p.player?;
Some(PlayerData {
id: p_.id?,
name: p_.gamer_tag,
prefix: p_.prefix,
})
})
.try_collect()
})
.try_collect()?;
Some(SetData { teams, winner })
})
.collect::<Vec<_>>(),
)
})
.flatten()
.collect();
Some(TournamentData {
name: tour.name?,
sets,
})
})
.collect(),
)
}
}