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:
parent
2c6587aca1
commit
dd4f0838ab
14 changed files with 75 additions and 83 deletions
133
src/datasets.rs
Normal file
133
src/datasets.rs
Normal 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
35
src/main.rs
Normal 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
84
src/queries.rs
Normal 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())
|
||||
}
|
||||
48
src/queries/player_info.rs
Normal file
48
src/queries/player_info.rs
Normal 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()),
|
||||
})
|
||||
}
|
||||
}
|
||||
60
src/queries/search_games.rs
Normal file
60
src/queries/search_games.rs
Normal 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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
177
src/queries/tournament_sets.rs
Normal file
177
src/queries/tournament_sets.rs
Normal 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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue