1
// This file is part of hnefatafl-copenhagen.
2
//
3
// hnefatafl-copenhagen is free software: you can redistribute it and/or modify
4
// it under the terms of the GNU Affero General Public License as published by
5
// the Free Software Foundation, either version 3 of the License, or
6
// (at your option) any later version.
7
//
8
// hnefatafl-copenhagen is distributed in the hope that it will be useful,
9
// but WITHOUT ANY WARRANTY; without even the implied warranty of
10
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11
// GNU Affero General Public License for more details.
12
//
13
// You should have received a copy of the GNU Affero General Public License
14
// along with this program.  If not, see <https://www.gnu.org/licenses/>.
15
//
16
// SPDX-License-Identifier: AGPL-3.0-or-later
17
// SPDX-FileCopyrightText: 2025 David Campbell <david@hnefatafl.org>
18

            
19
use std::{
20
    f64::consts::{LOG10_2, PI},
21
    fmt,
22
};
23

            
24
use serde::{Deserialize, Serialize};
25

            
26
/// ln 10 / 400
27
const Q: f64 = 0.005_756_5;
28
pub const CONFIDENCE_INTERVAL_95: f64 = 1.96;
29

            
30
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
31
pub struct Rating {
32
    pub rating: f64,
33
    /// Ratings Deviation
34
    pub rd: f64,
35
}
36

            
37
impl Rating {
38
    #[must_use]
39
228
    pub fn rd_sq(&self) -> f64 {
40
228
        self.rd * self.rd
41
228
    }
42

            
43
    #[must_use]
44
    pub fn to_string_rounded(&self) -> String {
45
        // Note: We use a FIGURE SPACE before and after the ± so
46
        // .split_ascii_whitespace() does not treat it as a space.
47
        format!(
48
            "{} ± {}",
49
            self.rating.round(),
50
            (CONFIDENCE_INTERVAL_95 * self.rd).round()
51
        )
52
    }
53

            
54
    /// This assumes 30 2 month periods must pass before one's rating
55
    /// deviation is the same as a new player and that a typical RD is 50.
56
12
    pub fn update_rd(&mut self) {
57
12
        let c = 63.2;
58

            
59
12
        let rd_new = f64::sqrt(self.rd_sq() + (c * c));
60
12
        self.rd = rd_new.clamp(30.0, 350.0);
61
12
    }
62

            
63
18
    pub fn update_rating(&mut self, rating: f64, outcome: &Outcome) {
64
18
        self.rating += (Q / (1.0 / self.rd_sq()) + (1.0 / self.d_sq(rating)))
65
18
            * self.g()
66
18
            * (outcome.score() - self.e(rating));
67

            
68
18
        self.rd = f64::sqrt(1.0 / ((1.0 / self.rd_sq()) + (1.0 / self.d_sq(rating))));
69
18
    }
70

            
71
    #[must_use]
72
36
    fn d_sq(&self, rating: f64) -> f64 {
73
36
        1.0 / ((Q * Q) * (self.g() * self.g()) * self.e(rating) * (1.0 - self.e(rating)))
74
36
    }
75

            
76
    #[must_use]
77
90
    fn e(&self, rating: f64) -> f64 {
78
90
        1.0 / (1.0 + exp10(-self.g() * ((self.rating - rating) / 400.0)))
79
90
    }
80

            
81
    #[must_use]
82
180
    fn g(&self) -> f64 {
83
180
        1.0 / f64::sqrt(1.0 + ((3.0 * Q * Q * self.rd_sq()) / (PI * PI)))
84
180
    }
85
}
86

            
87
impl Default for Rating {
88
27
    fn default() -> Self {
89
27
        Self {
90
27
            rating: 1_500.0,
91
27
            rd: 350.0,
92
27
        }
93
27
    }
94
}
95

            
96
impl fmt::Display for Rating {
97
    // Note: We use a FIGURE SPACE before and after the ± so
98
    // .split_ascii_whitespace() does not treat it as a space.
99
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100
        write!(f, "{} ± {}", self.rating, CONFIDENCE_INTERVAL_95 * self.rd)
101
    }
102
}
103

            
104
#[derive(Clone, Debug)]
105
pub enum Outcome {
106
    Draw,
107
    Loss,
108
    Win,
109
}
110

            
111
impl Outcome {
112
    #[must_use]
113
18
    pub fn score(&self) -> f64 {
114
18
        match self {
115
6
            Outcome::Draw => 0.5,
116
6
            Outcome::Loss => 0.0,
117
6
            Outcome::Win => 1.0,
118
        }
119
18
    }
120
}
121

            
122
#[must_use]
123
93
pub fn exp10(mut exponent: f64) -> f64 {
124
93
    exponent /= LOG10_2;
125
93
    exponent.exp2()
126
93
}
127

            
128
#[cfg(test)]
129
mod tests {
130
    use crate::glicko::Outcome;
131

            
132
    use super::{Rating, exp10};
133

            
134
    #[allow(clippy::float_cmp)]
135
    #[test]
136
3
    fn pow_10() {
137
3
        assert_eq!(exp10(2.0).round(), 100.0);
138
3
    }
139

            
140
    #[allow(clippy::float_cmp)]
141
    #[test]
142
3
    fn rd_increases() {
143
3
        let mut rating = Rating::default();
144
3
        assert_eq!(rating.rd, 350.0);
145

            
146
3
        rating.update_rd();
147
3
        assert_eq!(rating.rd, 350.0);
148

            
149
3
        rating.rd = 30.0;
150
3
        rating.update_rd();
151
3
        assert_eq!(rating.rd.round(), 70.0);
152

            
153
3
        rating.rd = 300.0;
154
3
        rating.update_rd();
155
3
        assert_eq!(rating.rd.round(), 307.0);
156
3
    }
157

            
158
    #[allow(clippy::float_cmp)]
159
    #[test]
160
3
    fn rating_and_rd_changes() {
161
3
        let rating = Rating::default();
162
3
        assert_eq!(rating.rating, 1_500.0);
163

            
164
3
        let mut rating_1 = rating.clone();
165
3
        rating_1.update_rating(1_600.0, &Outcome::Win);
166
3
        assert_eq!(rating_1.rating.round(), 1_781.0);
167

            
168
3
        let mut rating_2 = rating.clone();
169
3
        rating_2.update_rating(1_500.0, &Outcome::Win);
170
3
        assert_eq!(rating_2.rating.round(), 1_736.0);
171

            
172
3
        let mut rating_3 = rating.clone();
173
3
        rating_3.update_rating(1_500.0, &Outcome::Loss);
174
3
        assert_eq!(rating_3.rating.round(), 1_264.0);
175

            
176
3
        let mut rating_4 = rating.clone();
177
3
        rating_4.update_rating(1_500.0, &Outcome::Loss);
178
3
        assert_eq!(rating_4.rating.round(), 1_264.0);
179
3
        assert_eq!(rating_4.rd.round(), 299.0);
180
3
        rating_4.update_rd();
181
3
        assert_eq!(rating_4.rd.round(), 305.0);
182

            
183
3
        let mut rating_5 = rating.clone();
184
3
        rating_5.update_rating(1_500.0, &Outcome::Draw);
185
3
        assert_eq!(rating_5.rating.round(), 1_500.0);
186

            
187
3
        let mut rating_6 = rating.clone();
188
3
        rating_6.update_rating(1_600.0, &Outcome::Draw);
189
3
        assert_eq!(rating_6.rating.round(), 1545.0);
190
3
    }
191
}