backgammon 0.18.0

The Rust Backgammon library
Documentation
/*
 * BSD 2-Clause License
 *
 * Copyright (c) 2026, Carlo Strub <cs@carlostrub.ch>
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *
 * 1. Redistributions of source code must retain the above copyright notice, this
 *    list of conditions and the following disclaimer.
 *
 * 2. Redistributions in binary form must reproduce the above copyright notice,
 *    this list of conditions and the following disclaimer in the documentation
 *    and/or other materials provided with the distribution.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
 * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
 * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
 * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
 * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
 * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */
use crate::prelude::MatchRules;
use crate::rules::Player;
use crate::{Error, Match, MatchState};

const FIBS_DIVISOR: f64 = 2000.0;
const FIBS_MATCH_VALUE_MULTIPLIER: f64 = 4.0;
const FIBS_EXPERIENCE_THRESHOLD: u32 = 400;

/// Represents the Backgammon ELO rating according to FIBS, the First Internet Backgammon Server.
/// (see [FIBS rating](http://www.fibs.com/ratings.html))
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FibsRating {
    /// The rating of the player.
    pub rating: u32,
    /// The number of experience points the player has, which is the number of match lengths played.
    pub experience_points: u32,
}

impl Default for FibsRating {
    fn default() -> Self {
        FibsRating {
            rating: 1500,
            experience_points: 0,
        }
    }
}

impl FibsRating {
    fn experience_bonus(self, match_length: u64) -> f64 {
        if self.experience_points <= FIBS_EXPERIENCE_THRESHOLD {
            5.0 - ((self.experience_points as f64 + match_length as f64) / 100.0)
        } else {
            1.0
        }
    }
}

/// The Rating trait allows to calculate the rating of a player according to the FIBS rating system.
pub trait Rating {
    /// Based on the current rating between two players, calculate the new rating.
    fn fibs(
        &self,
        player0_rating: &FibsRating,
        player1_rating: &FibsRating,
    ) -> Result<(FibsRating, FibsRating), Error>;
}

impl Rating for Match {
    fn fibs(
        &self,
        player0_rating: &FibsRating,
        player1_rating: &FibsRating,
    ) -> Result<(FibsRating, FibsRating), Error> {
        let match_length = self.get_points()?;

        //  1-(1/(10^((YOU-HIM)*SQRT(ML)/2000)+1))
        let winning_probability: f64 = 1.0
            - (1.0
                / (10.0_f64.powf(
                    (player0_rating.rating as f64 - player1_rating.rating as f64)
                        * (match_length as f64).sqrt()
                        / FIBS_DIVISOR,
                ) + 1.0));

        let match_value: f64 = FIBS_MATCH_VALUE_MULTIPLIER * (match_length as f64).sqrt();

        let point_gains = (
            (match_value * (1.0 - winning_probability)),
            (match_value * winning_probability),
        );

        let new_rating_win0 = (
            (point_gains.0 * player0_rating.experience_bonus(match_length)).round() as u32,
            (point_gains.0 * player1_rating.experience_bonus(match_length)).round() as u32,
        );

        let new_rating_win1 = (
            (point_gains.1 * player0_rating.experience_bonus(match_length)).round() as u32,
            (point_gains.1 * player1_rating.experience_bonus(match_length)).round() as u32,
        );

        match self.get_match_state()? {
            MatchState::End(Player::Player0) => Ok((
                FibsRating {
                    rating: player0_rating.rating + new_rating_win0.0,
                    experience_points: player0_rating.experience_points + match_length as u32,
                },
                FibsRating {
                    rating: player1_rating.rating - new_rating_win0.1,
                    experience_points: player1_rating.experience_points + match_length as u32,
                },
            )),
            MatchState::End(Player::Player1) => Ok((
                FibsRating {
                    rating: player0_rating.rating - new_rating_win1.0,
                    experience_points: player0_rating.experience_points + match_length as u32,
                },
                FibsRating {
                    rating: player1_rating.rating + new_rating_win1.1,
                    experience_points: player1_rating.experience_points + match_length as u32,
                },
            )),
            _ => Err(Error::MatchNotEnded),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_fibs_rating_player0_wins() {
        let mut m = Match::new();
        m.set_points(5).unwrap();

        // Simulate a completed match where Player0 wins
        m.set_match_state(MatchState::End(Player::Player0));

        let player0_rating = FibsRating {
            rating: 1500,
            experience_points: 0,
        };
        let player1_rating = FibsRating {
            rating: 1500,
            experience_points: 0,
        };

        let result = m.fibs(&player0_rating, &player1_rating);
        assert!(result.is_ok());

        let (new_player0, new_player1) = result.unwrap();

        // Both players should have updated experience points
        assert_eq!(
            new_player0.experience_points,
            player0_rating.experience_points + 5
        );
        assert_eq!(
            new_player1.experience_points,
            player1_rating.experience_points + 5
        );
        // Player0 rating
        assert_eq!(new_player0.rating, 1522);
        // Player1 rating
        assert_eq!(new_player1.rating, 1478);
    }

    #[test]
    fn test_fibs_rating_player0_wins_with_experience() {
        let mut m = Match::new();
        m.set_points(7).unwrap();

        // Simulate a completed match where Player0 wins
        m.set_match_state(MatchState::End(Player::Player0));

        let player0_rating = FibsRating {
            rating: 1500,
            experience_points: 500,
        };
        let player1_rating = FibsRating {
            rating: 1925,
            experience_points: 500,
        };

        let result = m.fibs(&player0_rating, &player1_rating);
        assert!(result.is_ok());

        let (new_player0, new_player1) = result.unwrap();

        // Both players should have updated experience points
        assert_eq!(
            new_player0.experience_points,
            player0_rating.experience_points + 7
        );
        assert_eq!(
            new_player1.experience_points,
            player1_rating.experience_points + 7
        );
        // Player0 rating
        assert_eq!(new_player0.rating, 1508);
        // Player1 rating
        assert_eq!(new_player1.rating, 1917);
    }

    #[test]
    fn test_fibs_rating_player0_wins_no_experience() {
        let mut m = Match::new();
        m.set_points(7).unwrap();

        // Simulate a completed match where Player0 wins
        m.set_match_state(MatchState::End(Player::Player0));

        let player0_rating = FibsRating {
            rating: 1500,
            experience_points: 0,
        };
        let player1_rating = FibsRating {
            rating: 1925,
            experience_points: 500,
        };

        let result = m.fibs(&player0_rating, &player1_rating);
        assert!(result.is_ok());

        let (new_player0, new_player1) = result.unwrap();

        // Both players should have updated experience points
        assert_eq!(
            new_player0.experience_points,
            player0_rating.experience_points + 7
        );
        assert_eq!(
            new_player1.experience_points,
            player1_rating.experience_points + 7
        );
        // Player0 rating
        assert_eq!(new_player0.rating, 1541);
        // Player1 rating
        assert_eq!(new_player1.rating, 1917);
    }

    #[test]
    fn test_fibs_rating_player1_wins_0_no_experience() {
        let mut m = Match::new();
        m.set_points(7).unwrap();

        // Simulate a completed match where Player0 wins
        m.set_match_state(MatchState::End(Player::Player1));

        let player0_rating = FibsRating {
            rating: 1500,
            experience_points: 0,
        };
        let player1_rating = FibsRating {
            rating: 1925,
            experience_points: 500,
        };

        let result = m.fibs(&player0_rating, &player1_rating);
        assert!(result.is_ok());

        let (new_player0, new_player1) = result.unwrap();

        // Both players should have updated experience points
        assert_eq!(
            new_player0.experience_points,
            player0_rating.experience_points + 7
        );
        assert_eq!(
            new_player1.experience_points,
            player1_rating.experience_points + 7
        );

        // Player 0 (1500) loses to Player 1 (1925).
        // Player 1 base win pool is small (~2.27 points) because they are the heavy favorite.
        // P0 loses ~2.27 * 4.93 (bonus) = 11 points.
        // P1 gains ~2.27 * 1.00 (bonus) = 2 points.
        assert_eq!(new_player0.rating, 1489);
        assert_eq!(new_player1.rating, 1927);
    }

    #[test]
    fn test_fibs_rating_match_not_ended() {
        let mut m = Match::new();
        m.set_points(5).unwrap();

        // Match is not in an ended state
        let player0_rating = FibsRating {
            rating: 1500,
            experience_points: 0,
        };
        let player1_rating = FibsRating {
            rating: 1500,
            experience_points: 0,
        };

        let result = m.fibs(&player0_rating, &player1_rating);
        assert!(result.is_err());
        assert_eq!(result.unwrap_err(), Error::MatchNotEnded);
    }

    #[test]
    fn test_fibs_rating_default() {
        let default_rating = FibsRating::default();
        assert_eq!(default_rating.rating, 1500);
        assert_eq!(default_rating.experience_points, 0);
    }
}