Skip to content

Commit 9163d74

Browse files
authored
Merge pull request #100 from SM-Obstacle/86-player-of-the-week
feat: player and map ranking ladder scores manager
2 parents 7aee4a3 + 3c13797 commit 9163d74

File tree

11 files changed

+487
-4
lines changed

11 files changed

+487
-4
lines changed

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ members = [
1111
"crates/entity",
1212
"crates/graphql-api",
1313
"crates/graphql-schema-generator",
14+
"crates/player-map-ranking",
15+
"crates/compute-player-map-ranking",
1416
]
1517

1618
[workspace.dependencies]
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
[package]
2+
name = "compute-player-map-ranking"
3+
version = "0.1.0"
4+
edition = "2024"
5+
6+
[dependencies]
7+
anyhow = { workspace = true }
8+
clap = { version = "4.5.48", features = ["derive"] }
9+
dotenvy = { workspace = true }
10+
mkenv = { workspace = true }
11+
player-map-ranking = { path = "../player-map-ranking" }
12+
records-lib = { path = "../records_lib" }
13+
sea-orm = { workspace = true }
14+
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] }
15+
chrono = { workspace = true }
16+
17+
[features]
18+
default = []
19+
mysql = ["records-lib/mysql"]
20+
postgres = ["records-lib/postgres"]
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
use std::{
2+
cmp::Ordering,
3+
fmt,
4+
fs::{File, OpenOptions},
5+
io::{self, Write},
6+
path::Path,
7+
str::FromStr,
8+
time::Instant,
9+
};
10+
11+
use anyhow::Context as _;
12+
use chrono::{DateTime, Days, Months, Utc};
13+
use clap::Parser as _;
14+
use mkenv::Env as _;
15+
use records_lib::{DbUrlEnv, time::Time};
16+
use sea_orm::Database;
17+
18+
mkenv::make_env! {AppEnv includes [DbUrlEnv as db_env]:}
19+
20+
#[derive(Clone)]
21+
struct SinceDuration {
22+
date: DateTime<Utc>,
23+
}
24+
25+
#[derive(Debug)]
26+
struct InvalidSinceDuration;
27+
28+
impl fmt::Display for InvalidSinceDuration {
29+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30+
f.write_str("invalid \"since duration\" argument")
31+
}
32+
}
33+
34+
impl std::error::Error for InvalidSinceDuration {}
35+
36+
impl FromStr for SinceDuration {
37+
type Err = InvalidSinceDuration;
38+
39+
fn from_str(s: &str) -> Result<Self, Self::Err> {
40+
let (n, unit) = s.split_at(s.len() - 1);
41+
let n = n.parse::<u32>().map_err(|_| InvalidSinceDuration)?;
42+
let now = Utc::now();
43+
let date = match unit {
44+
"d" => now - Days::new(n as _),
45+
"w" => now - Days::new(n as u64 * 7),
46+
"m" => now - Months::new(n),
47+
"y" => now - Months::new(n * 12),
48+
_ => return Err(InvalidSinceDuration),
49+
};
50+
Ok(Self { date })
51+
}
52+
}
53+
54+
#[derive(clap::Parser)]
55+
struct Args {
56+
#[arg(
57+
short = 'p',
58+
long = "player-file",
59+
default_value = "player_ranking.csv"
60+
)]
61+
player_ranking_file: String,
62+
#[arg(short = 'm', long = "map-file", default_value = "map_ranking.csv")]
63+
map_ranking_file: String,
64+
#[arg(long = "since", value_parser = clap::value_parser!(SinceDuration))]
65+
from_date: Option<SinceDuration>,
66+
}
67+
68+
fn open_file<P: AsRef<Path>>(path: P) -> io::Result<File> {
69+
OpenOptions::new()
70+
.write(true)
71+
.create(true)
72+
.truncate(true)
73+
.open(path)
74+
}
75+
76+
#[tokio::main]
77+
async fn main() -> anyhow::Result<()> {
78+
let now = Instant::now();
79+
80+
dotenvy::dotenv().context("couldn't get environment file")?;
81+
let args = Args::parse();
82+
83+
let mut player_ranking_file =
84+
open_file(args.player_ranking_file).context("couldn't open player ranking output file")?;
85+
let mut map_ranking_file =
86+
open_file(args.map_ranking_file).context("couldn't open map ranking output file")?;
87+
88+
player_ranking_file
89+
.write(b"id,login,name,score,player_link\n")
90+
.context("couldn't write header to player ranking file")?;
91+
map_ranking_file
92+
.write(b"id,map_uid,name,score,average_score,min_record,")
93+
.and_then(|_| {
94+
map_ranking_file
95+
.write(b"max_record,average_record,median_record,records_count,map_link\n")
96+
})
97+
.context("couldn't write header to map ranking file")?;
98+
99+
let db_url = AppEnv::try_get()
100+
.context("couldn't initialize environment")?
101+
.db_env
102+
.db_url;
103+
let db = Database::connect(db_url)
104+
.await
105+
.context("couldn't connect to database")?;
106+
107+
println!(
108+
"Calculating scores{}...",
109+
match &args.from_date {
110+
Some(SinceDuration { date }) => format!(" since {}", date.format("%d/%m/%Y")),
111+
None => "".to_owned(),
112+
}
113+
);
114+
115+
let scores = player_map_ranking::compute_scores(&db, args.from_date.map(|d| d.date))
116+
.await
117+
.context("couldn't compute the scores")?;
118+
119+
println!("Sorting them...");
120+
121+
let mut player_ranking = scores.player_scores.into_iter().collect::<Vec<_>>();
122+
player_ranking.sort_by(|(_, a), (_, b)| b.partial_cmp(a).unwrap_or(Ordering::Equal));
123+
let mut map_ranking = scores.map_scores.into_iter().collect::<Vec<_>>();
124+
map_ranking.sort_by(|(_, a), (_, b)| b.partial_cmp(a).unwrap_or(Ordering::Equal));
125+
126+
println!("Writing to files...");
127+
128+
for (player, score) in player_ranking {
129+
writeln!(
130+
player_ranking_file,
131+
"{},{login},{},{score},https://obstacle.titlepack.io/player/{login}",
132+
player.inner.id,
133+
player.inner.name,
134+
login = player.inner.login,
135+
)
136+
.context("couldn't write a row to player ranking file")?;
137+
}
138+
139+
for (map, score) in map_ranking {
140+
writeln!(
141+
map_ranking_file,
142+
"{},{map_uid},{},{score},{},{},{},{},{},{},https://obstacle.titlepack.io/map/{map_uid}",
143+
map.inner.id,
144+
map.inner.name,
145+
score / map.stats.records_count,
146+
map.stats.min_record,
147+
map.stats.max_record,
148+
map.stats.average_record,
149+
map.stats.median_record,
150+
map.stats.records_count,
151+
map_uid = map.inner.game_id,
152+
)
153+
.context("couldn't write a row to map ranking file")?;
154+
}
155+
156+
println!(
157+
"Finished. Time taken: {}",
158+
Time(now.elapsed().as_millis() as _)
159+
);
160+
161+
Ok(())
162+
}

crates/graphql-schema-generator/src/main.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ use anyhow::Context as _;
1111
use clap::{Parser as _, ValueHint};
1212

1313
#[derive(clap::Parser)]
14-
1514
struct Args {
1615
#[arg(short, long, value_hint = ValueHint::DirPath)]
1716
directory: Option<String>,
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
[package]
2+
name = "player-map-ranking"
3+
version = "0.1.0"
4+
edition = "2024"
5+
6+
[dependencies]
7+
anyhow = { workspace = true }
8+
entity = { path = "../entity" }
9+
sea-orm = { workspace = true }
10+
11+
[features]
12+
default = []
13+
mysql = ["sea-orm/sqlx-mysql"]
14+
postgres = ["sea-orm/sqlx-postgres"]
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
use std::{collections::HashMap, hash::Hash};
2+
3+
use anyhow::Context as _;
4+
use entity::{global_records, maps, players};
5+
use sea_orm::{
6+
ColumnTrait as _, ConnectionTrait, EntityTrait as _, QueryFilter as _, QueryOrder, QueryTrait,
7+
sqlx::types::chrono::{DateTime, Utc},
8+
};
9+
10+
#[derive(Default)]
11+
pub struct MapStats {
12+
pub records_count: f64,
13+
pub min_record: f64,
14+
pub average_record: f64,
15+
pub median_record: f64,
16+
pub max_record: f64,
17+
}
18+
19+
fn ms_to_sec(time: i32) -> f64 {
20+
time as f64 / 1000.
21+
}
22+
23+
fn compute_score(r: f64, rn: f64, t: f64, average_record: f64) -> f64 {
24+
let record_score = (1000.0 * (rn * rn)).log10() + ((average_record - t).powi(2) + 1.0).log10();
25+
record_score * ((rn / r) + 1.0).log10().powi(3)
26+
}
27+
28+
pub struct HashablePlayer {
29+
pub inner: players::Model,
30+
}
31+
32+
impl Hash for HashablePlayer {
33+
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
34+
self.inner.id.hash(state);
35+
}
36+
}
37+
38+
impl PartialEq for HashablePlayer {
39+
fn eq(&self, other: &Self) -> bool {
40+
self.inner.id == other.inner.id
41+
}
42+
}
43+
44+
impl Eq for HashablePlayer {}
45+
46+
pub struct HashableMap {
47+
pub inner: maps::Model,
48+
pub stats: MapStats,
49+
}
50+
51+
impl Hash for HashableMap {
52+
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
53+
self.inner.id.hash(state);
54+
}
55+
}
56+
57+
impl PartialEq for HashableMap {
58+
fn eq(&self, other: &Self) -> bool {
59+
self.inner.id == other.inner.id
60+
}
61+
}
62+
63+
impl Eq for HashableMap {}
64+
65+
pub struct Scores {
66+
pub player_scores: HashMap<HashablePlayer, f64>,
67+
pub map_scores: HashMap<HashableMap, f64>,
68+
}
69+
70+
pub async fn compute_scores<C: ConnectionTrait>(
71+
conn: &C,
72+
from: Option<DateTime<Utc>>,
73+
) -> anyhow::Result<Scores> {
74+
let mut maps = maps::Entity::find()
75+
.all(conn)
76+
.await
77+
.context("couldn't retrieve all maps")?
78+
.into_iter()
79+
.map(|map| (map.id, map))
80+
.collect::<HashMap<_, _>>();
81+
82+
let mut players = players::Entity::find()
83+
.all(conn)
84+
.await
85+
.context("couldn't retrieve all players")?
86+
.into_iter()
87+
.map(|player| (player.id, player))
88+
.collect::<HashMap<_, _>>();
89+
90+
let mut map_stats = HashMap::<u32, MapStats>::new();
91+
let mut map_scores = HashMap::<u32, f64>::new();
92+
let mut player_scores = HashMap::<u32, f64>::new();
93+
94+
for map in maps.values() {
95+
let map_records = global_records::Entity::find()
96+
.filter(global_records::Column::MapId.eq(map.id))
97+
.apply_if(from, |query, from| {
98+
query.filter(global_records::Column::RecordDate.gte(from))
99+
})
100+
.order_by_asc(global_records::Column::Time)
101+
.all(conn)
102+
.await
103+
.with_context(|| format!("couldn't get records of map ID: {}", map.id))?;
104+
105+
if map_records.is_empty() {
106+
continue;
107+
}
108+
109+
let mut stats = MapStats {
110+
records_count: map_records.len() as _,
111+
min_record: ms_to_sec(map_records[0].time),
112+
max_record: ms_to_sec(map_records[0].time),
113+
..Default::default()
114+
};
115+
116+
for record in &map_records {
117+
stats.min_record = stats.min_record.min(ms_to_sec(record.time));
118+
stats.max_record = stats.max_record.max(ms_to_sec(record.time));
119+
stats.average_record += ms_to_sec(record.time);
120+
}
121+
122+
stats.average_record /= stats.records_count;
123+
stats.median_record = ms_to_sec(map_records[map_records.len() / 2].time);
124+
125+
for (i, record) in map_records.iter().enumerate() {
126+
let r = (i + 1) as f64;
127+
let t = ms_to_sec(record.time).max(stats.average_record);
128+
let score = compute_score(r, stats.records_count, t, stats.average_record);
129+
130+
let map_score = map_scores.entry(record.map_id).or_insert(0.);
131+
let player_score = player_scores.entry(record.record_player_id).or_insert(0.);
132+
*map_score += score;
133+
*player_score += score;
134+
}
135+
136+
map_stats.insert(map.id, stats);
137+
}
138+
139+
let output = Scores {
140+
player_scores: player_scores
141+
.into_iter()
142+
.map(|(player_id, score)| {
143+
(
144+
HashablePlayer {
145+
inner: players.remove(&player_id).unwrap(),
146+
},
147+
score,
148+
)
149+
})
150+
.collect(),
151+
map_scores: map_scores
152+
.into_iter()
153+
.map(|(map_id, score)| {
154+
(
155+
HashableMap {
156+
inner: maps.remove(&map_id).unwrap(),
157+
stats: map_stats.remove(&map_id).unwrap(),
158+
},
159+
score,
160+
)
161+
})
162+
.collect(),
163+
};
164+
165+
Ok(output)
166+
}

0 commit comments

Comments
 (0)