fbsim_core/game/
score.rs

1#![doc = include_str!("../../docs/game/score.md")]
2pub mod freq;
3
4use lazy_static::lazy_static;
5use rand::Rng;
6use rand_distr::{Normal, Distribution, Bernoulli};
7#[cfg(feature = "rocket_okapi")]
8use rocket_okapi::okapi::schemars;
9#[cfg(feature = "rocket_okapi")]
10use rocket_okapi::okapi::schemars::JsonSchema;
11use serde::{Serialize, Deserialize, Deserializer};
12use statrs::distribution::Categorical;
13
14use crate::game::score::freq::ScoreFrequencyLookup;
15use crate::team::{DEFAULT_TEAM_NAME};
16
17// Home score simulator model weights
18const H_MEAN_COEF: f64 = 23.14578315_f64;
19const H_MEAN_INTERCEPT: f64 = 10.9716991_f64;
20const H_STD_INTERCEPT: f64 = 7.64006156_f64;
21const H_STD_COEF_1: f64 = 5.72612946_f64;
22const H_STD_COEF_2: f64 = -4.29283414_f64;
23
24// Away score simulator model weights
25const A_MEAN_COEF: f64 = 22.14952374_f64;
26const A_MEAN_INTERCEPT: f64 = 8.92113289_f64;
27const A_STD_INTERCEPT: f64 = 6.47638621_f64;
28const A_STD_COEF_1: f64 = 8.00861267_f64;
29const A_STD_COEF_2: f64 = -5.589282_f64;
30
31// Tie probability model weights
32const P_TIE_COEF: f64 = -0.00752297_f64;
33const P_TIE_INTERCEPT: f64 = 0.01055039_f64;
34const P_TIE_BASE: f64 = 0.036_f64;
35
36// Score frequency distribution
37lazy_static!{
38    static ref SCORE_FREQ_LUT: ScoreFrequencyLookup = {
39        let mut tmp_lut = ScoreFrequencyLookup::new();
40        tmp_lut.create();
41        tmp_lut
42    };
43}
44
45/// # `ScoreSimulatable` trait
46///
47/// A `ScoreSimulatable` can return an offense and defense overall, which
48/// are the two values needed to generate the final score of a game
49pub trait ScoreSimulatable {
50    fn name(&self) -> &str { DEFAULT_TEAM_NAME }
51    fn defense_overall(&self) -> u32 { 50_u32 }
52    fn offense_overall(&self) -> u32 { 50_u32 }
53}
54
55/// # `FinalScoreRaw` struct
56///
57/// A `FinalScoreRaw` is a `FinalScore` before its properties have been
58/// validated
59#[cfg_attr(feature = "rocket_okapi", derive(JsonSchema))]
60#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Debug, Default, Serialize, Deserialize)]
61pub struct FinalScoreRaw {
62    home_team: String,
63    home_score: u32,
64    away_team: String,
65    away_score: u32
66}
67
68impl FinalScoreRaw {
69    pub fn validate(&self) -> Result<(), String> {
70        // Ensure each team name is no longer than 64 characters
71        if self.home_team.len() > 64 {
72            return Err(
73                format!(
74                    "Home team name is longer than 64 characters: {}",
75                    self.home_team
76                )
77            )
78        }
79        if self.away_team.len() > 64 {
80            return Err(
81                format!(
82                    "Away team name is longer than 64 characters: {}",
83                    self.away_team
84                )
85            )
86        }
87        Ok(())
88    }
89}
90
91/// # `FinalScore` struct
92///
93/// A `FinalScore` represents the final score result of a football game
94#[cfg_attr(feature = "rocket_okapi", derive(JsonSchema))]
95#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Debug, Serialize)]
96pub struct FinalScore {
97    home_team: String,
98    home_score: u32,
99    away_team: String,
100    away_score: u32
101}
102
103impl TryFrom<FinalScoreRaw> for FinalScore {
104    type Error = String;
105
106    fn try_from(item: FinalScoreRaw) -> Result<Self, Self::Error> {
107        // Validate the raw coach
108        match item.validate() {
109            Ok(()) => (),
110            Err(error) => return Err(error),
111        };
112
113        // If valid, then convert
114        Ok(
115            FinalScore{
116                home_team: item.home_team,
117                home_score: item.home_score,
118                away_team: item.away_team,
119                away_score: item.away_score
120            }
121        )
122    }
123}
124
125impl<'de> Deserialize<'de> for FinalScore {
126    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
127    where
128        D: Deserializer<'de>,
129    {
130        // Only deserialize if the conversion from raw succeeds
131        let raw = FinalScoreRaw::deserialize(deserializer)?;
132        FinalScore::try_from(raw).map_err(serde::de::Error::custom)
133    }
134}
135
136impl Default for FinalScore {
137    /// Default constructor for the `FinalScore` struct
138    ///
139    /// ### Example
140    /// ```
141    /// use fbsim_core::game::score::FinalScore;
142    ///
143    /// let my_score = FinalScore::default();
144    /// ```
145    fn default() -> Self {
146        FinalScore {
147            home_team: String::from(DEFAULT_TEAM_NAME),
148            home_score: 0_u32,
149            away_team: String::from(DEFAULT_TEAM_NAME),
150            away_score: 0_u32
151        }
152    }
153}
154
155impl FinalScore {
156    /// Constructor for the `FinalScore` struct in which each score
157    /// is defaulted to 0, and each team name is defaulted to
158    /// the default team name.
159    ///
160    /// ### Example
161    /// ```
162    /// use fbsim_core::game::score::FinalScore;
163    ///
164    /// let my_score = FinalScore::new();
165    /// ```
166    pub fn new() -> FinalScore {
167        FinalScore::default()
168    }
169
170    /// Getter for the home score property
171    ///
172    /// ### Example
173    /// ```
174    /// use fbsim_core::game::score::FinalScore;
175    ///
176    /// let my_score = FinalScore::new();
177    /// assert!(my_score.home_score() == 0);
178    /// ```
179    pub fn home_score(&self) -> u32 {
180        self.home_score
181    }
182
183    /// Getter for the away score property
184    ///
185    /// ### Example
186    /// ```
187    /// use fbsim_core::game::score::FinalScore;
188    ///
189    /// let my_score = FinalScore::new();
190    /// assert!(my_score.away_score() == 0);
191    /// ```
192    pub fn away_score(&self) -> u32 {
193        self.away_score
194    }
195}
196
197impl std::fmt::Display for FinalScore {
198    /// Format a `FinalScore` as a string.
199    ///
200    /// ### Example
201    ///
202    /// ```
203    /// use fbsim_core::game::score::FinalScore;
204    ///
205    /// let my_final_score = FinalScore::new();
206    /// println!("{}", my_final_score);
207    /// ```
208    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209        let score_str = format!(
210            "{} {} - {} {}",
211            self.home_team,
212            self.home_score,
213            self.away_team,
214            self.away_score
215        );
216        f.write_str(&score_str)
217    }
218}
219
220/// # `FinalScoreBuilder` struct
221///
222/// A `FinalScoreBuilder` implements the builder pattern for the `FinalScore`
223/// struct
224#[cfg_attr(feature = "rocket_okapi", derive(JsonSchema))]
225#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Debug, Serialize)]
226pub struct FinalScoreBuilder {
227    home_team: String,
228    home_score: u32,
229    away_team: String,
230    away_score: u32
231}
232
233impl Default for FinalScoreBuilder {
234    /// Default constructor for the `FinalScoreBuilder` struct
235    ///
236    /// ### Example
237    /// ```
238    /// use fbsim_core::game::score::FinalScoreBuilder;
239    ///
240    /// let my_score_builder = FinalScoreBuilder::default();
241    /// ```
242    fn default() -> Self {
243        FinalScoreBuilder {
244            home_team: String::from(DEFAULT_TEAM_NAME),
245            home_score: 0_u32,
246            away_team: String::from(DEFAULT_TEAM_NAME),
247            away_score: 0_u32
248        }
249    }
250}
251
252impl FinalScoreBuilder {
253    /// Initialize a new final score builder
254    ///
255    /// ### Example
256    /// ```
257    /// use fbsim_core::game::score::FinalScoreBuilder;
258    ///
259    /// let my_score_builder = FinalScoreBuilder::new();
260    /// ```
261    pub fn new() -> FinalScoreBuilder {
262        FinalScoreBuilder::default()
263    }
264
265    /// Set the home team property
266    ///
267    /// ### Example
268    /// ```
269    /// use fbsim_core::game::score::FinalScoreBuilder;
270    ///
271    /// let my_score = FinalScoreBuilder::new()
272    ///     .home_team("My Team")
273    ///     .build()
274    ///     .unwrap();
275    /// ```
276    pub fn home_team(mut self, home_team: &str) -> Self {
277        self.home_team = String::from(home_team);
278        self
279    }
280
281    /// Set the away team property
282    ///
283    /// ### Example
284    /// ```
285    /// use fbsim_core::game::score::FinalScoreBuilder;
286    ///
287    /// let my_score = FinalScoreBuilder::new()
288    ///     .away_team("My Team")
289    ///     .build()
290    ///     .unwrap();
291    /// ```
292    pub fn away_team(mut self, away_team: &str) -> Self {
293        self.away_team = String::from(away_team);
294        self
295    }
296
297    /// Set the home score property
298    ///
299    /// ### Example
300    /// ```
301    /// use fbsim_core::game::score::FinalScoreBuilder;
302    ///
303    /// let my_score = FinalScoreBuilder::new()
304    ///     .home_score(21)
305    ///     .build()
306    ///     .unwrap();
307    /// assert!(my_score.home_score() == 21);
308    /// ```
309    pub fn home_score(mut self, home_score: u32) -> Self {
310        self.home_score = home_score;
311        self
312    }
313
314    /// Set the away score property
315    ///
316    /// ### Example
317    /// ```
318    /// use fbsim_core::game::score::FinalScoreBuilder;
319    ///
320    /// let my_score = FinalScoreBuilder::new()
321    ///     .away_score(21)
322    ///     .build()
323    ///     .unwrap();
324    /// assert!(my_score.away_score() == 21);
325    /// ```
326    pub fn away_score(mut self, away_score: u32) -> Self {
327        self.away_score = away_score;
328        self
329    }
330
331    /// Build the coach
332    ///
333    /// ### Example
334    /// ```
335    /// use fbsim_core::game::score::FinalScoreBuilder;
336    /// 
337    /// let my_score = FinalScoreBuilder::new()
338    ///     .home_team("Team A")
339    ///     .away_team("Team B")
340    ///     .home_score(21)
341    ///     .away_score(17)
342    ///     .build()
343    ///     .unwrap();
344    /// assert!(my_score.home_score() == 21);
345    /// assert!(my_score.away_score() == 17);
346    /// ```
347    pub fn build(self) -> Result<FinalScore, String> {
348        let raw = FinalScoreRaw {
349            home_team: self.home_team,
350            home_score: self.home_score,
351            away_team: self.away_team,
352            away_score: self.away_score
353        };
354        FinalScore::try_from(raw)
355    }
356}
357
358/// # `FinalScoreSimulator` struct
359///
360/// A `FinalScoreSimulator` generates an american football final score
361/// given the normalized skill differential (in range [0, 1]) of the
362/// home offense and the away defense, and vice versa, the away
363/// offense and the home defense.
364#[derive(Copy, Clone, PartialEq, PartialOrd, Debug, Default)]
365pub struct FinalScoreSimulator;
366
367impl FinalScoreSimulator {
368    /// Constructor for the `FinalScoreSimulator` struct
369    ///
370    /// ### Example
371    /// ```
372    /// use fbsim_core::game::score::FinalScoreSimulator;
373    ///
374    /// let my_sim = FinalScoreSimulator::new();
375    /// ```
376    pub fn new() -> FinalScoreSimulator {
377        FinalScoreSimulator{}
378    }
379
380    /// Gets the mean score parameter for the score generation
381    fn get_mean_score(&self, norm_diff: f64, home: bool) -> f64 {
382        // Get the mean score parameter
383        if home {
384            H_MEAN_INTERCEPT + (H_MEAN_COEF * norm_diff)
385        } else {
386            A_MEAN_INTERCEPT + (A_MEAN_COEF * norm_diff)
387        }
388    }
389
390    /// Gets the score standard deviation parameter for the score
391    /// generation
392    fn get_std_score(&self, norm_diff: f64, home: bool) -> f64 {
393        // Get the std score parameter
394        if home {
395            H_STD_INTERCEPT + (H_STD_COEF_1 * norm_diff) + (H_STD_COEF_2 * norm_diff.powi(2))
396        } else {
397            A_STD_INTERCEPT + (A_STD_COEF_1 * norm_diff) + (A_STD_COEF_2 * norm_diff.powi(2))
398        }
399    }
400
401    /// Gets the normal distribution parameters for the score generation
402    fn get_normal_params(&self, norm_diff: f64, home: bool) -> (f64, f64) {
403        // Get the normal distribution parameters
404        (self.get_mean_score(norm_diff, home), self.get_std_score(norm_diff, home))
405    }
406
407    /// Gets the probability of a tie for the given skill differential
408    fn get_p_tie(&self, norm_diff: f64) -> f64 {
409        P_TIE_INTERCEPT + (P_TIE_COEF * norm_diff)
410    }
411
412    /// Gets the probability of a re-sim in the event of a tie in order to
413    /// achieve the desired tie probability in the end
414    fn get_p_resim(&self, p_tie: f64) -> f64 {
415        (p_tie - P_TIE_BASE) / (P_TIE_BASE.powi(2) - P_TIE_BASE)
416    }
417
418    /// Generates the away score only
419    fn gen_away_score(&self, norm_diff: f64, rng: &mut impl Rng) -> u32 {
420        // Create and sample a normal distribution for the score
421        let (mean, std): (f64, f64) = self.get_normal_params(norm_diff, false);
422        let away_dist = Normal::new(mean, std).unwrap();
423        let away_score_float = away_dist.sample(rng);
424
425        // Round to nearest integer and return
426        u32::try_from(away_score_float.round() as i32).unwrap_or_default()
427    }
428
429    /// Generates the home score only
430    fn gen_home_score(&self, norm_diff: f64, rng: &mut impl Rng) -> u32 {
431        // Create and sample a normal distribution for the score
432        let (mean, std) = self.get_normal_params(norm_diff, true);
433        let home_dist = Normal::new(mean, std).unwrap();
434        let home_score_float = home_dist.sample(rng);
435
436        // Round to nearest integer, ensure positive and return
437        u32::try_from(home_score_float.round() as i32).unwrap_or_default()
438    }
439
440    /// Generates the home and away scores, returns as a 2-tuple
441    /// in which the first value is the home score, and the second
442    /// value is the away score
443    fn gen_score(&self, ha_norm_diff: f64, ah_norm_diff: f64, rng: &mut impl Rng) -> Result<(u32, u32), String> {
444         // Ensure normalized differentials are in range [0, 1]
445         if !(0.0_f64..=1.0_f64).contains(&ha_norm_diff) {
446            return Err(
447                format!(
448                    "Home offense / away defense normalized skill differential not in range [0, 1]: {}",
449                    ha_norm_diff
450                )
451            )
452        }
453        if !(0.0_f64..=1.0_f64).contains(&ah_norm_diff) {
454           return Err(
455               format!(
456                   "Away offense / home defense normalized skill differential not in range [0, 1]: {}",
457                   ah_norm_diff
458               )
459           )
460        }
461
462        // Generate the home and away scores
463        Ok((self.gen_home_score(ha_norm_diff, rng), self.gen_away_score(ah_norm_diff, rng)))
464    }
465
466    /// Filters the final score by score frequency.  The score's nearest
467    /// neighbors and their frequency are retrieved to construct a probability
468    /// mass function for a categorical distribution.  That distribution is
469    /// then sampled for the real score.
470    fn filter_score(&self, score: u32, rng: &mut impl Rng) -> u32 {
471        // If the score is 0, just return 0 as 1 is impossible
472        if score == 0 {
473            return 0
474        }
475
476        // Get the nearest neighbors of the score
477        let low = SCORE_FREQ_LUT.frequency(score - 1).unwrap();
478        let mid = SCORE_FREQ_LUT.frequency(score).unwrap();
479        let high = SCORE_FREQ_LUT.frequency(score + 1).unwrap();
480        
481        // Construct a categorical distribution
482        let dist = Categorical::new(&[low as f64, mid as f64, high as f64]).unwrap();
483        let score_adjustment_r: f64 = dist.sample(rng);
484        let score_adjustment = (score_adjustment_r as i32) - 1_i32;
485        let adj_score = score as i32 + score_adjustment;
486        u32::try_from(adj_score).unwrap_or_default()
487    }
488
489    /// Simulates a game by generating a final score result
490    ///
491    /// ### Example
492    /// ```
493    /// use fbsim_core::game::score::{FinalScore, FinalScoreSimulator};
494    /// use fbsim_core::team::FootballTeam;
495    ///
496    /// let home = FootballTeam::new();
497    /// let away = FootballTeam::new();
498    /// let sim = FinalScoreSimulator::new();
499    /// let mut rng = rand::thread_rng();
500    /// let score = sim.sim(&home, &away, &mut rng).unwrap();
501    /// println!("{}", score);
502    /// ```
503    pub fn sim(&self, home_team: &impl ScoreSimulatable, away_team: &impl ScoreSimulatable, rng: &mut impl Rng) -> Result<FinalScore, String> {
504        // Calculate the normalized skill differentials
505        let ha_norm_diff: f64 = (home_team.offense_overall() as i32 - away_team.defense_overall() as i32 + 100_i32) as f64 / 200_f64;
506        let ah_norm_diff: f64 = (away_team.offense_overall() as i32 - home_team.defense_overall() as i32 + 100_i32) as f64 / 200_f64;
507
508        // Generate the final score, return error if error is encountered
509        let (home_score, away_score): (u32, u32) = self.gen_score(ha_norm_diff, ah_norm_diff, rng)?;
510
511        // Filter the final score by score frequency
512        let adj_home_score = self.filter_score(home_score, rng);
513        let adj_away_score = self.filter_score(away_score, rng);
514
515        // Instantiate as a FinalScore
516        let final_score: FinalScore = FinalScoreBuilder::new()
517            .home_team(home_team.name())
518            .home_score(adj_home_score)
519            .away_team(away_team.name())
520            .away_score(adj_away_score)
521            .build()
522            .unwrap();
523
524        // If not a tie, then return as-is
525        if adj_home_score != adj_away_score {
526            return Ok(final_score)
527        }
528
529        // If a tie is achieved after filtering, re-sim based on the skill
530        // differentials and their associated tie probability.  Start by
531        // calculating the average of the two skill differentials
532        let avg_norm_diff: f64 = (ha_norm_diff + ah_norm_diff) / 2_f64;
533
534        // Get the probability of a tie for the average skill differential.
535        // Use it to get the required probability of a re-sim to achieve the
536        // observed tie probability in the end
537        let p_tie: f64 = self.get_p_tie(avg_norm_diff);
538        let p_res: f64 = self.get_p_resim(p_tie);
539
540        // Sample a bernoulli distribution of p_res to determine whether
541        // to re-sim or not
542        let dst_res: Bernoulli = Bernoulli::new(p_res).unwrap();
543        let res: bool = dst_res.sample(rng);
544
545        // Re-sim and re-filter if needed
546        if res {
547            // Generate the final score, return error if error is encountered
548            let (home_score_2, away_score_2): (u32, u32) = self.gen_score(ha_norm_diff, ah_norm_diff, rng)?;
549
550            // Filter the final score by score frequency
551            let adj_home_score_2 = self.filter_score(home_score_2, rng);
552            let adj_away_score_2 = self.filter_score(away_score_2, rng);
553
554            // Instantiate as a FinalScore and return
555            let final_score_2: FinalScore = FinalScoreBuilder::new()
556                .home_team(home_team.name())
557                .home_score(adj_home_score_2)
558                .away_team(away_team.name())
559                .away_score(adj_away_score_2)
560                .build()
561                .unwrap();
562            return Ok(final_score_2)
563        }
564
565        Ok(final_score)
566    }
567}