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}