Skip to content

Implemented Elo Function Algorithm and wrote unit test for it#15

Merged
GoSTEAN merged 11 commits intoNetwalls:mainfrom
Cyber-Mitch:feat/elo_function
May 27, 2025
Merged

Implemented Elo Function Algorithm and wrote unit test for it#15
GoSTEAN merged 11 commits intoNetwalls:mainfrom
Cyber-Mitch:feat/elo_function

Conversation

@Cyber-Mitch
Copy link
Contributor

@Cyber-Mitch Cyber-Mitch commented Apr 30, 2025

Elo Rating System Features and Unit Tests

This section describes the newly implemented Elo rating system features for the Snooket game smart contract, a Dojo-based project on StarkNet that enhances competitive gameplay by tracking player skill levels. It covers the Elo rating functionality, its integration into the game, and the unit tests added to verify correctness. The features are implemented in src/systems/Snooknet.cairo and src/model/game_model.cairo, with tests in src/tests/test_world.cairo. Fixes for issues like u32_sub Overflow and model registration errors are also included.

Implemented Features

1. PlayerRating Model

  • Description: A new model to store each player's Elo rating, enabling skill-based ranking and leaderboard functionality.
  • Details:
    • Defined in src/model/game_model.cairo:
      #[derive(Model, Copy, Drop, Serde)]
      #[dojo::model(namespace = "Snooknet")]
      pub struct PlayerRating {
          #[key]
          pub player: ContractAddress,
          pub rating: u32,
      }
    • Uses the player's ContractAddress as the key and a u32 rating (default: 1500).
    • Namespaced under "Snooknet" to align with the Dojo world configuration.
    • Purpose: Provides persistent storage for player ratings, queryable for leaderboards or matchmaking.

2. Rating Initialization

  • Description: Automatically initializes player ratings to 1500 when they first participate in a match.
  • Details:
    • Implemented in the ensure_player_rating function in src/systems/Snooknet.cairo:
      fn ensure_player_rating(ref self: ContractState, player: ContractAddress) {
          let mut world = self.world_default();
          let mut rating: PlayerRating = world.read_model(player);
          if rating.rating == 0 {
              rating.rating = 1500;
              world.write_model(@rating);
          }
      }
    • Called during create_match to ensure both players have valid ratings before the game starts.
    • Purpose: Ensures new players start with a standard Elo rating, enabling immediate participation in ranked matches.

3. Elo Rating Calculation

  • Description: Implements an Elo rating system to update player ratings based on match outcomes (win, loss, or draw), reflecting player performance.
  • Details:
    • Two functions in src/systems/Snooknet.cairo within the InternalImpl block:
      • elo_function: Calculates new ratings for win/loss scenarios.
        fn elo_function(
            ref self: ContractState,
            player1: ContractAddress,
            player2: ContractAddress,
            winner: ContractAddress
        ) -> (u32, u32) {
            let mut world = self.world_default();
            let rating1: PlayerRating = world.read_model(player1);
            let rating2: PlayerRating = world.read_model(player2);
            let r1 = rating1.rating;
            let r2 = rating2.rating;
            let k = 32_u32;
            let expected1 = self.calculate_expected(r1, r2);
            let expected2 = self.calculate_expected(r2, r1);
            let (score1, score2) = if winner == player1 {
                (1000_u32, 0_u32)
            } else {
                (0_u32, 1000_u32)
            };
            let new_rating1 = if score1 >= expected1 {
                let delta = (score1 - expected1) * k / 1000;
                r1 + delta
            } else {
                let delta = self.safe_subtract(expected1, score1) * k / 1000;
                let min_rating = 100_u32;
                if r1 <= delta {
                    min_rating
                } else {
                    r1 - delta
                }
            };
            let new_rating2 = if score2 >= expected2 {
                let delta = (score2 - expected2) * k / 1000;
                r2 + delta
            } else {
                let delta = self.safe_subtract(expected2, score2) * k / 1000;
                let min_rating = 100_u32;
                if r2 <= delta {
                    min_rating
                } else {
                    r2 - delta
                }
            };
            (new_rating1, new_rating2)
        }
        • Reads current ratings from PlayerRating models.
        • Calculates expected scores (0–1000) using calculate_expected.
        • Assigns scores: 1000 (winner), 0 (loser).
        • Adjusts ratings with K = 32, scaling deltas by 1000 for integer arithmetic.
        • Uses safe_subtract to prevent underflow when score < expected_score.
        • Clamps ratings to a minimum of 100.
      • elo_function_draw: Handles rating updates for draws.
        fn elo_function_draw(
            ref self: ContractState,
            player1: ContractAddress,
            player2: ContractAddress
        ) -> (u32, u32) {
            let mut world = self.world_default();
            let rating1: PlayerRating = world.read_model(player1);
            let rating2: PlayerRating = world.read_model(player2);
            let r1 = rating1.rating;
            let r2 = rating2.rating;
            let k = 32_u32;
            let expected1 = self.calculate_expected(r1, r2);
            let expected2 = self.calculate_expected(r2, r1);
            let score = 500_u32;
            let new_rating1 = if score >= expected1 {
                let delta = (score - expected1) * k / 1000;
                r1 + delta
            } else {
                let delta = self.safe_subtract(expected1, score) * k / 1000;
                let min_rating = 100_u32;
                if r1 <= delta {
                    min_rating
                } else {
                    r1 - delta
                }
            };
            let new_rating2 = if score >= expected2 {
                let delta = (score - expected2) * k / 1000;
                r2 + delta
            } else {
                let delta = self.safe_subtract(expected2, score) * k / 1000;
                let min_rating = 100_u32;
                if r2 <= delta {
                    min_rating
                } else {
                    r2 - delta
                }
            };
            (new_rating1, new_rating2)
        }
        • Assigns a score of 500 to both players for a draw.
        • Adjusts ratings similarly, using safe_subtract and clamping to 100.
    • Safe Subtraction:
      fn safe_subtract(self: @ContractState, a: u32, b: u32) -> u32 {
          if a >= b {
              a - b
          } else {
              0_u32
          }
      }
      • Prevents u32 underflow by returning 0 when a < b.
    • Expected Score Calculation:
      fn calculate_expected(self: @ContractState, r1: u32, r2: u32) -> u32 {
          if r1 > r2 {
              let diff = r1 - r2;
              if diff > 400 {
                  1000_u32
              } else {
                  500_u32 + (diff * 5) / 4
              }
          } else {
              let diff = r2 - r1;
              if diff > 400 {
                  0_u32
              } else {
                  500_u32 - (diff * 5) / 4
              }
          }
      }
      • Approximates the Elo expected probability using a linear function, scaled to 0–1000.
    • Purpose: Dynamically adjusts player ratings to reflect performance, ensuring competitive gameplay.

4. RatingUpdated Event

  • Description: Emits a RatingUpdated event whenever a player's rating changes after a match.
  • Details:
    • Defined in src/systems/Snooknet.cairo:
      #[derive(Copy, Drop, Serde)]
      #[dojo::event]
      pub struct RatingUpdated {
          #[key]
          pub player: ContractAddress,
          pub new_rating: u32,
      }
    • Emitted in end_match after updating ratings for both players.
    • Purpose: Allows off-chain systems (e.g., frontends or analytics) to track rating changes and update leaderboards.

5. Integration with end_match

  • Description: Modified the end_match function to incorporate Elo rating updates after a game concludes.
  • Details:
    • Updated in src/systems/Snooknet.cairo to call elo_function for wins/losses or elo_function_draw for draws:
      fn end_match(ref self: ContractState, game_id: u32, winner: ContractAddress) {
          let mut world = self.world_default();
          let mut game: Game = world.read_model(game_id);
          let caller = get_caller_address();
          let timestamp = get_block_timestamp();
          assert((caller == game.player1) || (caller == game.player2), 'Not a Player');
          assert(
              (winner == game.player1) || (winner == game.player2) || (winner == contract_address_const::<0x0>()),
              'Invalid winner'
          );
          assert(game.state != GameState::Finished, 'Game already ended');
          game.winner = winner;
          game.state = GameState::Finished;
          game.updated_at = get_block_timestamp();
          if winner != contract_address_const::<0x0>() {
              let (new_rating1, new_rating2) = self.elo_function(game.player1, game.player2, winner);
              let mut rating1: PlayerRating = world.read_model(game.player1);
              let mut rating2: PlayerRating = world.read_model(game.player2);
              rating1.rating = new_rating1;
              rating2.rating = new_rating2;
              world.write_model(@rating1);
              world.write_model(@rating2);
              world.emit_event(@RatingUpdated { player: game.player1, new_rating: new_rating1 });
              world.emit_event(@RatingUpdated { player: game.player2, new_rating: new_rating2 });
          } else {
              let (new_rating1, new_rating2) = self.elo_function_draw(game.player1, game.player2);
              let mut rating1: PlayerRating = world.read_model(game.player1);
              let mut rating2: PlayerRating = world.read_model(game.player2);
              rating1.rating = new_rating1;
              rating2.rating = new_rating2;
              world.write_model(@rating1);
              world.write_model(@rating2);
              world.emit_event(@RatingUpdated { player: game.player1, new_rating: new_rating1 });
              world.emit_event(@RatingUpdated { player: game.player2, new_rating: new_rating2 });
          }
          world.write_model(@game);
          world.emit_event(@Winner { game_id, winner });
          world.emit_event(@GameEnded { game_id, timestamp });
      }
    • Integrates rating updates and event emissions with existing game-ending logic.
    • Purpose: Ensures ratings reflect match outcomes seamlessly within the game flow.

Fixes and Optimizations

1. Model Registration Issue

  • Issue: Tests failed with "Resource ... is registered but not as model" due to missing namespace specification for the PlayerRating model.
  • Fix:
    • Added namespace = "Snooknet" to the PlayerRating model in src/model/game_model.cairo:
      #[dojo::model(namespace = "Snooknet")]
    • Updated test_world.cairo to register PlayerRating and RatingUpdated:
      TestResource::Model(m_PlayerRating::TEST_CLASS_HASH),
      TestResource::Event(Snooknet::e_RatingUpdated::TEST_CLASS_HASH),
    • Impact: Resolved ENTRYPOINT_FAILED errors by ensuring proper model registration in the Dojo world.

2. u32_sub Overflow Error

  • Issue: Tests test_end_game and test_rating_update_after_win failed with ując3_sub Overflow due to unsafe subtractions in elo_function (e.g., score1 - expected1 when score1 = 0).
  • Fix:
    • Introduced safe_subtract and modified elo_function and elo_function_draw to handle negative adjustments:
      • Split rating changes into positive and negative cases.
      • Used safe_subtract to compute deltas without underflow.
      • Clamped ratings to a minimum of 100.
    • Impact: Eliminated overflow errors, ensuring robust rating calculations.

Unit Tests

The following unit tests were added to src/tests/test_world.cairo to verify the Elo rating system features, covering initialization, rating updates, and edge cases.

Test Setup

  • File: src/tests/test_world.cairo
  • Updates:
    • Added PlayerRating model registration: TestResource::Model(m_PlayerRating::TEST_CLASS_HASH).
    • Added RatingUpdated event registration: TestResource::Event(Snooknet::e_RatingUpdated::TEST_CLASS_HASH).
    • Namespace: "Snooknet" for consistency.
  • Relevant namespace_def snippet:
    fn namespace_def() -> NamespaceDef {
        let ndef = NamespaceDef {
            namespace: "Snooknet",
            resources: [
                TestResource::Model(m_PlayerRating::TEST_CLASS_HASH),
                TestResource::Event(Snooknet::e_RatingUpdated::TEST_CLASS_HASH),
                // ... other resources ...
            ].span(),
        };
        ndef
    }

Tests Added

  1. Test: test_player_rating_initialization

    • Description: Verifies that player ratings are initialized to 1500 when a game is created.
    • Implementation:
      #[test]
      fn test_player_rating_initialization() {
          let caller_1 = contract_address_const::<'aji'>();
          let player_1 = contract_address_const::<'player'>();
          let ndef = namespace_def();
          let mut world = spawn_test_world([ndef].span());
          world.sync_perms_and_inits(contract_defs());
          let (contract_address, _) = world.dns(@"Snooknet").unwrap();
          let actions_system = ISnooknetDispatcher { contract_address };
          testing::set_contract_address(caller_1);
          let game_id = actions_system.create_match(player_1, 400);
          let rating1: PlayerRating = world.read_model(caller_1);
          let rating2: PlayerRating = world.read_model(player_1);
          assert(rating1.rating == 1500, 'Player 1 rating not initialized');
          assert(rating2.rating == 1500, 'Player 2 rating not initialized');
      }
    • Purpose: Ensures new players start with the default Elo rating of 1500.
  2. Test: test_rating_update_after_win

    • Description: Checks that a winner’s rating increases and the loser’s rating decreases after a match.
    • Implementation:
      #[test]
      fn test_rating_update_after_win() {
          let caller_1 = contract_address_const::<'aji'>();
          let player_1 = contract_address_const::<'player'>();
          let ndef = namespace_def();
          let mut world = spawn_test_world([ndef].span());
          world.sync_perms_and_inits(contract_defs());
          let (contract_address, _) = world.dns(@"Snooknet").unwrap();
          let actions_system = ISnooknetDispatcher { contract_address };
          testing::set_contract_address(caller_1);
          let game_id = actions_system.create_match(player_1, 400);
          testing::set_contract_address(caller_1);
          actions_system.end_match(game_id, caller_1);
          let rating1: PlayerRating = world.read_model(caller_1);
          let rating2: PlayerRating = world.read_model(player_1);
          assert(rating1.rating > 1500, 'Winner rating not increased');
          assert(rating2.rating >= 100, 'Loser rating too low');
          assert(rating2.rating < 1500, 'Loser rating not decreased');
      }
    • Purpose: Validates Elo rating adjustments for win/loss scenarios.
  3. Test: test_rating_update_after_draw

    • Description: Ensures that player ratings remain unchanged (or adjust minimally) after a draw.
    • Implementation:
      #[test]
      fn test_rating_update_after_draw() {
          let caller_1 = contract_address_const::<'aji'>();
          let player_1 = contract_address_const::<'player'>();
          let ndef = namespace_def();
          let mut world = spawn_test_world([ndef].span());
          world.sync_perms_and_inits(contract_defs());
          let (contract_address, _) = world.dns(@"Snooknet").unwrap();
          let actions_system = ISnooknetDispatcher { contract_address };
          testing::set_contract_address(caller_1);
          let game_id = actions_system.create_match(player_1, 400);
          testing::set_contract_address(caller_1);
          actions_system.end_match(game_id, contract_address_const::<0x0>());
          let rating1: PlayerRating = world.read_model(caller_1);
          let rating2: PlayerRating = world.read_model(player_1);
          assert(rating1.rating == 1500, 'Player 1 rating changed in draw');
          assert(rating2.rating == 1500, 'Player 2 rating changed in draw');
      }
    • Purpose: Confirms that draws do not significantly alter ratings for equal-rated players.
  4. Test: test_end_game_already_ended

    • Description: Verifies that attempting to end an already finished game results in a panic.
    • Implementation:
      #[test]
      #[should_panic]
      fn test_end_game_already_ended() {
          let caller_1 = contract_address_const::<'aji'>();
          let player_1 = contract_address_const::<'player'>();
          let ndef = namespace_def();
          let mut world = spawn_test_world([ndef].span());
          world.sync_perms_and_inits(contract_defs());
          let (contract_address, _) = world.dns(@"Snooknet").unwrap();
          let actions_system = ISnooknetDispatcher { contract_address };
          testing::set_contract_address(caller_1);
          let game_id = actions_system.create_match(player_1, 400);
          testing::set_contract_address(caller_1);
          actions_system.end_match(game_id, caller_1);
          actions_system.end_match(game_id, caller_1);
      }
    • Purpose: Ensures the contract prevents duplicate game endings, maintaining state integrity.
  5. Test: test_invalid_winner

    • Description: Confirms that ending a game with an invalid winner (not a participant) causes a panic.
    • Implementation:
      #[test]
      #[should_panic]
      fn test_invalid_winner() {
          let caller_1 = contract_address_const::<'aji'>();
          let player_1 = contract_address_const::<'player'>();
          let invalid_winner = contract_address_const::<'invalid'>();
          let ndef = namespace_def();
          let mut world = spawn_test_world([ndef].span());
          world.sync_perms_and_inits(contract_defs());
          let (contract_address, _) = world.dns(@"Snooknet").unwrap();
          let actions_system = ISnooknetDispatcher { contract_address };
          testing::set_contract_address(caller_1);
          let game_id = actions_system.create_match(player_1, 400);
          testing::set_contract_address(caller_1);
          actions_system.end_match(game_id, invalid_winner);
      }
    • Purpose: Validates that only valid players or the zero address can be declared winners.

Testing Instructions

To run the tests and verify the Elo rating system:

  1. Ensure dependencies are installed (e.g., scarb).
  2. Build the project:
    sozo build
  3. Run the tests:
    sozo test
  4. Verify that the tests pass, particularly the five new tests for the Elo rating system.

Summary

The Elo rating system enhances the Snooket game by introducing skill-based player rankings, implemented through the PlayerRating model, elo_function, elo_function_draw, and RatingUpdated event. The system handles Cairo’s unsigned integer constraints with safe_subtract and minimum rating clamping. Fixes for model registration and u32 overflow issues ensure reliability, and the test suite validates initialization, rating updates, and edge cases, ensuring a robust competitive experience.

This closes #9

@Cyber-Mitch
Copy link
Contributor Author

Hey @GoSTEAN I've made the PR now.

@GoSTEAN
Copy link
Contributor

GoSTEAN commented May 2, 2025

@Cyber-Mitch What is the update on this?

@Cyber-Mitch
Copy link
Contributor Author

@Cyber-Mitch What is the update on this?

I don't know whats causing the issue. My codes are okay. I'm still verifying though. maybe it's something that has to do with dependencies

@GoSTEAN
Copy link
Contributor

GoSTEAN commented May 2, 2025

@Cyber-Mitch resolve conflicts

@Cyber-Mitch
Copy link
Contributor Author

@Cyber-Mitch resolve conflicts

oh okay. I'll do that now then

@Cyber-Mitch
Copy link
Contributor Author

I've resloved the conflicts @GoSTEAN

@Cyber-Mitch Cyber-Mitch requested a review from GoSTEAN May 2, 2025 15:44
@GoSTEAN
Copy link
Contributor

GoSTEAN commented May 3, 2025

image The ci is failing @Cyber-Mitch

@Cyber-Mitch
Copy link
Contributor Author

Cyber-Mitch commented May 3, 2025

image The ci is failing @Cyber-Mitch

is this a CI issue? @GoSTEAN

@GoSTEAN GoSTEAN merged commit 06fb9c5 into Netwalls:main May 27, 2025
1 check failed
GoSTEAN added a commit that referenced this pull request Jun 24, 2025
This reverts commit 06fb9c5, reversing
changes made to 20dde80.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement elo_function for Player Skill Measurement

2 participants