use std::{collections::HashMap, error::Error, fmt::Display};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::{elo::EloRating, Outcomes};
#[derive(Copy, Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct DWZRating {
pub rating: f64,
pub index: usize,
pub age: usize,
}
impl DWZRating {
#[must_use]
pub const fn new(age: usize) -> Self {
Self {
rating: 1000.0,
index: 1,
age,
}
}
}
impl Default for DWZRating {
fn default() -> Self {
Self::new(26)
}
}
impl From<(f64, usize, usize)> for DWZRating {
fn from((r, i, a): (f64, usize, usize)) -> Self {
Self {
rating: r,
index: i,
age: a,
}
}
}
impl From<(f64, usize)> for DWZRating {
fn from((r, i): (f64, usize)) -> Self {
Self {
rating: r,
index: i,
age: 26,
}
}
}
impl From<EloRating> for DWZRating {
fn from(e: EloRating) -> Self {
Self {
rating: e.rating,
index: 6,
..Default::default()
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GetFirstDWZError {
NotEnoughGames,
InvalidWinRate,
}
impl Display for GetFirstDWZError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotEnoughGames => {
write!(f, "You need at least 5 games to calculate a DWZ Rating.")
}
Self::InvalidWinRate => write!(f, "Your winrate cannot be 0% or 100%."),
}
}
}
impl Error for GetFirstDWZError {}
#[must_use]
pub fn dwz(
player_one: &DWZRating,
player_two: &DWZRating,
outcome: &Outcomes,
) -> (DWZRating, DWZRating) {
let outcome1 = outcome.to_chess_points();
let outcome2 = 1.0 - outcome1;
let (exp1, exp2) = expected_score(player_one, player_two);
let r1 = new_rating(
player_one.rating,
e_value(
player_one.rating,
player_one.age,
outcome1,
exp1,
player_one.index,
),
outcome1,
exp1,
1.0,
);
let r2 = new_rating(
player_two.rating,
e_value(
player_two.rating,
player_two.age,
outcome2,
exp2,
player_two.index,
),
outcome2,
exp2,
1.0,
);
(
DWZRating {
rating: r1,
index: player_one.index + 1,
age: player_one.age,
},
{
DWZRating {
rating: r2,
index: player_two.index + 1,
age: player_two.age,
}
},
)
}
#[must_use]
pub fn dwz_rating_period(player: &DWZRating, results: &[(DWZRating, Outcomes)]) -> DWZRating {
let points = results.iter().map(|r| r.1.to_chess_points()).sum();
let expected_points = results.iter().map(|r| expected_score(player, &r.0).0).sum();
let new_rating = (800.0
/ (e_value(
player.rating,
player.age,
points,
expected_points,
player.index,
) + results.len() as f64))
.mul_add(points - expected_points, player.rating);
DWZRating {
rating: new_rating,
index: player.index + 1,
age: player.age,
}
}
#[must_use]
pub fn expected_score(player_one: &DWZRating, player_two: &DWZRating) -> (f64, f64) {
let exp_one = (1.0
+ 10.0_f64.powf(-(400.0_f64.recip()) * (player_one.rating - player_two.rating)))
.recip();
let exp_two = 1.0 - exp_one;
(exp_one, exp_two)
}
pub fn get_first_dwz(
player_age: usize,
results: &[(DWZRating, Outcomes)],
) -> Result<DWZRating, GetFirstDWZError> {
if results.len() < 5 {
return Err(GetFirstDWZError::NotEnoughGames);
}
let points: f64 = results.iter().map(|r| r.1.to_chess_points()).sum();
if (points - results.len() as f64).abs() < f64::EPSILON || points == 0.0 {
return Err(GetFirstDWZError::InvalidWinRate);
}
let average_rating = results.iter().map(|r| r.0.rating).sum::<f64>() / results.len() as f64;
#[allow(clippy::cast_possible_truncation)]
let p = ((points / results.len() as f64) * 100.0).round() as i64;
let probability_table = HashMap::from([
(0, -728.),
(1, -614.),
(2, -555.),
(3, -513.),
(4, -480.),
(5, -453.),
(6, -429.),
(7, -408.),
(8, -389.),
(9, -371.),
(10, -355.),
(11, -340.),
(12, -326.),
(13, -312.),
(14, -300.),
(15, -288.),
(16, -276.),
(17, -265.),
(18, -254.),
(19, -244.),
(20, -234.),
(21, -224.),
(22, -214.),
(23, -205.),
(24, -196.),
(25, -187.),
(26, -178.),
(27, -170.),
(28, -161.),
(29, -153.),
(30, -145.),
(31, -137.),
(32, -129.),
(33, -121.),
(34, -113.),
(35, -106.),
(36, -98.),
(37, -91.),
(38, -83.),
(39, -76.),
(40, -69.),
(41, -61.),
(42, -54.),
(43, -47.),
(44, -40.),
(45, -32.),
(46, -25.),
(47, -18.),
(48, -11.),
(49, -4.),
(50, -0.),
]);
let mut new_rating = if p > 50 {
let temp = probability_table.get(&(p - 100).abs()).unwrap_or(&0.);
f64::abs(*temp) + average_rating
} else {
probability_table.get(&p).unwrap_or(&0.) + average_rating
};
if new_rating <= 800.0 {
new_rating = 700.0 + (new_rating / 8.0);
}
Ok(DWZRating {
rating: new_rating,
index: 1,
age: player_age,
})
}
fn e_value(rating: f64, age: usize, score: f64, expected_score: f64, index: usize) -> f64 {
let j = match age {
0..=20 => 5.0,
21..=25 => 10.0,
_ => 15.0,
};
let e0 = (rating / 1000.0).powi(4) + j;
let a = if age < 20 && score >= expected_score {
rating / 2000.0
} else {
1.0
};
let b = if rating < 1300.0 && score <= expected_score {
((1300.0 - rating) / 150.0_f64).exp_m1()
} else {
0.0
};
let mut e = a.mul_add(e0, b);
if e <= 5.0 {
e = 5.0;
} else if b == 0.0 {
if e >= 30.0_f64.min(5.0 * index as f64) {
e = 30.0_f64.min(5.0 * index as f64);
}
} else if e >= 150.0 {
e = 150.0;
}
e
}
fn new_rating(
old_rating: f64,
e: f64,
score: f64,
expected_score: f64,
matches_played: f64,
) -> f64 {
(800.0 / (e + matches_played)).mul_add(score - expected_score, old_rating)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dwz() {
let mut player_one = DWZRating {
rating: 1530.0,
index: 22,
age: 26,
};
let mut player_two = DWZRating {
rating: 1930.0,
index: 103,
age: 39,
};
(player_one, player_two) = dwz(&player_one, &player_two, &Outcomes::WIN);
assert!((player_one.rating.round() - 1564.0).abs() < f64::EPSILON);
assert_eq!(player_one.index, 23);
assert!((player_two.rating.round() - 1906.0).abs() < f64::EPSILON);
assert_eq!(player_two.index, 104);
(player_one, player_two) = dwz(&player_one, &player_two, &Outcomes::DRAW);
assert!((player_one.rating.round() - 1578.0).abs() < f64::EPSILON);
assert_eq!(player_one.index, 24);
assert!((player_two.rating.round() - 1895.0).abs() < f64::EPSILON);
assert_eq!(player_two.index, 105);
player_two.age = 12;
(player_one, player_two) = dwz(&player_one, &player_two, &Outcomes::LOSS);
assert!((player_one.rating.round() - 1573.0).abs() < f64::EPSILON);
assert_eq!(player_one.index, 25);
assert!((player_two.rating.round() - 1901.0).abs() < f64::EPSILON);
assert_eq!(player_two.index, 106);
}
#[test]
fn test_dwz_rating_period() {
let player = DWZRating {
rating: 1530.0,
index: 17,
age: 9,
};
let opponent1 = DWZRating {
rating: 1930.0,
index: 103,
age: 39,
};
let opponent2 = DWZRating {
rating: 1930.0,
index: 92,
age: 14,
};
let results = vec![
(opponent1, Outcomes::WIN),
(opponent2, Outcomes::DRAW),
(opponent1, Outcomes::LOSS),
];
let new_player = dwz_rating_period(&player, &results);
assert!((new_player.rating.round() - 1619.0).abs() < f64::EPSILON);
assert_eq!(new_player.index, 18);
}
#[test]
fn test_large_delta() {
let mut really_good_player = DWZRating {
rating: 3210.0,
index: 143,
age: 25,
};
let mut really_bad_player = DWZRating {
rating: 90.0,
index: 1,
age: 12,
};
(really_good_player, really_bad_player) =
dwz(&really_good_player, &really_bad_player, &Outcomes::WIN);
assert!((really_good_player.rating.round() - 3210.0).abs() < f64::EPSILON);
assert_eq!(really_good_player.index, 144);
assert!((really_bad_player.rating.round() - 90.0).abs() < f64::EPSILON);
assert_eq!(really_bad_player.index, 2);
really_bad_player.rating = 1.0;
really_good_player.rating = 32_477_324_874_238.0;
(really_good_player, really_bad_player) =
dwz(&really_good_player, &really_bad_player, &Outcomes::WIN);
assert!((really_good_player.rating.round() - 32_477_324_874_238.0).abs() < f64::EPSILON);
assert!((really_bad_player.rating.round() - 1.0).abs() < f64::EPSILON);
really_good_player.rating = 2.0;
really_good_player.age = 5;
really_bad_player.rating = 1.0;
really_bad_player.age = 5;
(really_good_player, really_bad_player) =
dwz(&really_good_player, &really_bad_player, &Outcomes::LOSS);
assert!((really_good_player.rating.round() + 1.0).abs() < f64::EPSILON);
assert!((really_bad_player.rating.round() - 68.0).abs() < f64::EPSILON);
}
#[test]
fn test_expected_score() {
let player_one = DWZRating {
rating: 1530.0,
index: 22,
age: 26,
};
let player_two = DWZRating {
rating: 1930.0,
index: 103,
age: 39,
};
let (exp1, exp2) = expected_score(&player_one, &player_two);
assert!(((exp1 * 100.0).round() - 9.0).abs() < f64::EPSILON);
assert!(((exp2 * 100.0).round() - 91.0).abs() < f64::EPSILON);
}
#[test]
fn test_first_dwz() {
let o1 = DWZRating {
rating: 1300.0,
index: 23,
age: 17,
};
let o2 = DWZRating {
rating: 1540.0,
index: 2,
age: 29,
};
let o3 = DWZRating {
rating: 1200.0,
index: 10,
age: 7,
};
let o4 = DWZRating {
rating: 1290.0,
index: 76,
age: 55,
};
let o5 = DWZRating {
rating: 1400.0,
index: 103,
age: 11,
};
#[allow(clippy::unwrap_used)]
let player = get_first_dwz(
26,
&[
(o1, Outcomes::WIN),
(o2, Outcomes::DRAW),
(o3, Outcomes::LOSS),
(o4, Outcomes::WIN),
(o5, Outcomes::WIN),
],
)
.unwrap();
assert!((player.rating - 1491.0).abs() < f64::EPSILON);
assert_eq!(player.index, 1);
let all_win_player = get_first_dwz(
17,
&[
(o1, Outcomes::WIN),
(o2, Outcomes::WIN),
(o3, Outcomes::WIN),
(o4, Outcomes::WIN),
(o5, Outcomes::WIN),
],
);
assert_eq!(all_win_player, Err(GetFirstDWZError::InvalidWinRate));
let all_lose_player = get_first_dwz(
17,
&[
(o1, Outcomes::LOSS),
(o2, Outcomes::LOSS),
(o3, Outcomes::LOSS),
(o4, Outcomes::LOSS),
(o5, Outcomes::LOSS),
],
);
assert_eq!(all_lose_player, Err(GetFirstDWZError::InvalidWinRate));
let less_than_5 = get_first_dwz(
32,
&[
(o1, Outcomes::LOSS),
(o2, Outcomes::WIN),
(o3, Outcomes::DRAW),
(o4, Outcomes::LOSS),
],
);
assert_eq!(less_than_5, Err(GetFirstDWZError::NotEnoughGames));
}
#[test]
fn test_new_dwz_bad_players() {
let o1 = DWZRating {
rating: 1300.0,
index: 23,
age: 17,
};
let o2 = DWZRating {
rating: 1540.0,
index: 2,
age: 29,
};
let o3 = DWZRating {
rating: 1200.0,
index: 10,
age: 7,
};
let o4 = DWZRating {
rating: 1290.0,
index: 76,
age: 55,
};
let o5 = DWZRating {
rating: 1400.0,
index: 103,
age: 11,
};
#[allow(clippy::unwrap_used)]
let bad_player = get_first_dwz(
26,
&[
(o1, Outcomes::LOSS),
(o2, Outcomes::DRAW),
(o3, Outcomes::LOSS),
(o4, Outcomes::LOSS),
(o5, Outcomes::LOSS),
],
)
.unwrap();
assert!((bad_player.rating.round() - 991.0).abs() < f64::EPSILON);
assert_eq!(bad_player.index, 1);
let o4 = DWZRating {
rating: 430.0,
index: 76,
age: 55,
};
let o5 = DWZRating {
rating: 520.0,
index: 103,
age: 11,
};
#[allow(clippy::unwrap_used)]
let really_bad_player = get_first_dwz(
26,
&vec![
(o1, Outcomes::LOSS),
(o2, Outcomes::DRAW),
(o3, Outcomes::LOSS),
(o4, Outcomes::LOSS),
(o5, Outcomes::LOSS),
(o3, Outcomes::LOSS),
(o4, Outcomes::LOSS),
(o5, Outcomes::LOSS),
(o3, Outcomes::LOSS),
(o4, Outcomes::LOSS),
(o5, Outcomes::LOSS),
(o4, Outcomes::LOSS),
(o5, Outcomes::LOSS),
(o4, Outcomes::LOSS),
(o5, Outcomes::LOSS),
(o4, Outcomes::LOSS),
(o5, Outcomes::LOSS),
(o4, Outcomes::LOSS),
(o5, Outcomes::LOSS),
],
)
.unwrap();
assert!((really_bad_player.rating.round() - 722.0).abs() < f64::EPSILON);
assert_eq!(really_bad_player.index, 1);
}
#[test]
fn elo_conversion() {
let player_one = EloRating { rating: 1200.0 };
let player_one_dwz = DWZRating::from(player_one);
assert!((player_one_dwz.rating.round() - 1200.0).abs() < f64::EPSILON);
assert_eq!(player_one_dwz.index, 6);
assert_eq!(player_one_dwz.age, 26);
let player_one_back = EloRating::from(player_one_dwz);
assert!((player_one_back.rating.round() - 1200.0).abs() < f64::EPSILON);
}
#[test]
fn test_misc_stuff() {
let player_one = DWZRating::default();
let player_two = DWZRating::new(26);
assert_eq!(player_one, player_two);
assert_eq!(player_one, player_one.clone());
assert!(!format!("{:?}", player_one).is_empty());
assert_eq!(
DWZRating::from((1400.0, 20)),
DWZRating::from((1400.0, 20, 26))
);
assert!(!format!("{:?}", GetFirstDWZError::NotEnoughGames).is_empty());
assert!(!format!("{:?}", GetFirstDWZError::InvalidWinRate).is_empty());
assert!(!format!("{}", GetFirstDWZError::NotEnoughGames).is_empty());
assert!(!format!("{}", GetFirstDWZError::InvalidWinRate).is_empty());
assert_eq!(
GetFirstDWZError::NotEnoughGames,
GetFirstDWZError::NotEnoughGames.clone()
);
}
}