From 35164c175aa4a511a02acf6d54eafd6ec2eb7395 Mon Sep 17 00:00:00 2001
From: Jacobtread <jacobtread@gmail.com>
Date: Tue, 5 Dec 2023 14:52:15 +1300
Subject: [PATCH 1/5] Working basic db leaderboard implementation

---
 src/database/entities/leaderboard_data.rs     | 227 ++++++++++++++++++
 src/database/entities/mod.rs                  |   2 +
 src/database/entities/player_data.rs          |   2 +-
 src/database/entities/players.rs              |  14 +-
 .../m20231205_121139_leaderboard_data.rs      |  76 ++++++
 src/database/migration/mod.rs                 |   2 +
 src/routes/leaderboard.rs                     |  46 ++--
 src/services/leaderboard/mod.rs               |   2 +-
 src/services/leaderboard/models.rs            |  21 --
 src/session/models/stats.rs                   |  51 +++-
 src/session/routes/other.rs                   |  51 +++-
 11 files changed, 442 insertions(+), 52 deletions(-)
 create mode 100644 src/database/entities/leaderboard_data.rs
 create mode 100644 src/database/migration/m20231205_121139_leaderboard_data.rs

diff --git a/src/database/entities/leaderboard_data.rs b/src/database/entities/leaderboard_data.rs
new file mode 100644
index 00000000..006c9849
--- /dev/null
+++ b/src/database/entities/leaderboard_data.rs
@@ -0,0 +1,227 @@
+//! SeaORM Entity. Generated by sea-orm-codegen 0.9.3
+
+use crate::database::DbResult;
+use crate::utils::types::PlayerID;
+use sea_orm::sea_query::OnConflict;
+use sea_orm::ActiveValue::NotSet;
+use sea_orm::{
+    prelude::*, FromQueryResult, InsertResult, QueryOrder, QuerySelect, RelationBuilder,
+};
+use sea_orm::{ActiveValue::Set, DatabaseConnection, EntityTrait};
+use serde::{Deserialize, Serialize};
+use std::future::Future;
+
+#[derive(Serialize, Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
+#[sea_orm(table_name = "leaderboard_data")]
+pub struct Model {
+    /// Unique Identifier for the entry
+    #[sea_orm(primary_key)]
+    #[serde(skip)]
+    pub id: u32,
+    /// The type of leaderboard this data is for
+    #[serde(skip)]
+    pub ty: LeaderboardType,
+    /// ID of the player this data is for
+    pub player_id: PlayerID,
+    /// The value of this leaderboard data
+    pub value: u32,
+}
+
+/// Type of leaderboard entity
+#[derive(Debug, Hash, Clone, Copy, PartialEq, Eq, Deserialize, EnumIter, DeriveActiveEnum)]
+#[sea_orm(rs_type = "u8", db_type = "TinyUnsigned")]
+#[repr(u8)]
+pub enum LeaderboardType {
+    /// Leaderboard based on the player N7 ratings
+    #[serde(rename = "n7")]
+    #[sea_orm(num_value = 0)]
+    N7Rating = 0,
+    /// Leaderboard based on the player challenge point number
+    #[serde(rename = "cp")]
+    #[sea_orm(num_value = 1)]
+    ChallengePoints = 1,
+}
+
+impl From<&str> for LeaderboardType {
+    fn from(value: &str) -> Self {
+        if value.starts_with("N7Rating") {
+            Self::N7Rating
+        } else {
+            Self::ChallengePoints
+        }
+    }
+}
+
+#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
+pub enum Relation {
+    #[sea_orm(
+        belongs_to = "super::players::Entity",
+        from = "Column::PlayerId",
+        to = "super::players::Column::Id"
+    )]
+    Player,
+}
+
+// `Related` trait has to be implemented by hand
+impl Related<super::players::Entity> for Entity {
+    fn to() -> RelationDef {
+        Relation::Player.def()
+    }
+}
+
+impl ActiveModelBehavior for ActiveModel {}
+
+#[derive(FromQueryResult, Serialize)]
+pub struct LeaderboardDataAndRank {
+    /// Unique Identifier for the entry
+    #[serde(skip)]
+    pub id: u32,
+    /// ID of the player this data is for
+    pub player_id: PlayerID,
+    /// The name of the player this entry is for
+    pub player_name: String,
+    /// The value of this leaderboard data
+    pub value: u32,
+    /// The ranking of this entry (Position in the leaderboard)
+    pub rank: u32,
+}
+
+impl Model {
+    pub async fn total(db: &DatabaseConnection, ty: LeaderboardType) -> DbResult<u64> {
+        Entity::find().filter(Column::Ty.eq(ty)).count(db).await
+    }
+
+    pub async fn get_offset(
+        db: &DatabaseConnection,
+        ty: LeaderboardType,
+        start: u32,
+        count: u32,
+    ) -> DbResult<Vec<LeaderboardDataAndRank>> {
+        Entity::find()
+            // Ranking by the values
+            .expr(Expr::cust("RANK () OVER (ORDER BY value DESC) rank"))
+            // Filter by the type
+            .filter(Column::Ty.eq(ty))
+            // Order highest to lowest
+            .order_by_desc(Expr::cust("rank"))
+            // Offset to the starting position
+            .offset(start as u64)
+            // Only take the requested amouont
+            .limit(count as u64)
+            // Join the playe rname
+            // Inner join on the player and use the player name
+            .join(sea_orm::JoinType::InnerJoin, Relation::Player.def())
+            .column_as(super::players::Column::DisplayName, "player_name")
+            // Turn it into the new model
+            .into_model::<LeaderboardDataAndRank>()
+            // Collect all the matching entities
+            .all(db)
+            .await
+    }
+    pub async fn get_centered(
+        db: &DatabaseConnection,
+        ty: LeaderboardType,
+        player_id: PlayerID,
+        count: u32,
+    ) -> DbResult<Option<Vec<LeaderboardDataAndRank>>> {
+        // let value = match Self::get_entry(db, ty, player_id).await? {
+        //     Some(value) => value,
+        //     None =>
+        // }
+
+        Ok(None)
+    }
+
+    pub async fn get_entry(
+        db: &DatabaseConnection,
+        ty: LeaderboardType,
+        player_id: PlayerID,
+    ) -> DbResult<Option<LeaderboardDataAndRank>> {
+        Entity::find()
+            // Ranking by the values
+            .expr(Expr::cust("RANK () OVER (ORDER BY value DESC) rank"))
+            // Filter by the type
+            .filter(Column::Ty.eq(ty).and(Column::PlayerId.eq(player_id)))
+            // Order highest to lowest
+            .order_by_desc(Expr::cust("rank"))
+            // Join the playe rname
+            // Inner join on the player and use the player name
+            .join(sea_orm::JoinType::InnerJoin, Relation::Player.def())
+            .column_as(super::players::Column::DisplayName, "player_name")
+            // Turn it into the new model
+            .into_model::<LeaderboardDataAndRank>()
+            // Collect all the matching entities
+            .one(db)
+            .await
+    }
+
+    pub async fn get_filtered(
+        db: &DatabaseConnection,
+        ty: LeaderboardType,
+        player_ids: Vec<PlayerID>,
+    ) -> DbResult<Vec<LeaderboardDataAndRank>> {
+        Entity::find()
+            // Ranking by the values
+            .expr(Expr::cust("RANK () OVER (ORDER BY value DESC) rank"))
+            // Filter by the type
+            .filter(Column::Ty.eq(ty).and(Column::PlayerId.is_in(player_ids)))
+            // Order highest to lowest
+            .order_by_desc(Expr::cust("rank"))
+            // Join the playe rname
+            // Inner join on the player and use the player name
+            .join(sea_orm::JoinType::InnerJoin, Relation::Player.def())
+            .column_as(super::players::Column::DisplayName, "player_name")
+            // Turn it into the new model
+            .into_model::<LeaderboardDataAndRank>()
+            // Collect all the matching entities
+            .all(db)
+            .await
+    }
+
+    pub fn set(
+        db: &DatabaseConnection,
+        ty: LeaderboardType,
+        player_id: PlayerID,
+        value: u32,
+    ) -> impl Future<Output = DbResult<InsertResult<ActiveModel>>> + Send + '_ {
+        Entity::insert(ActiveModel {
+            id: NotSet,
+            ty: Set(ty),
+            player_id: Set(player_id),
+            value: Set(value),
+        })
+        .on_conflict(
+            // Update the value column if a key already exists
+            OnConflict::columns([Column::PlayerId, Column::Ty])
+                .update_column(Column::Value)
+                .to_owned(),
+        )
+        .exec(db)
+    }
+
+    /// Bulk updates the values for each player ID -> value pair on
+    /// the provided `ty` leaderboard
+    pub fn set_ty_bulk(
+        db: &DatabaseConnection,
+        ty: LeaderboardType,
+        data: impl Iterator<Item = (PlayerID, u32)>,
+    ) -> impl Future<Output = DbResult<InsertResult<ActiveModel>>> + Send + '_ {
+        // Insert all the models
+        Entity::insert_many(
+            // Transform the key value pairs into insertable models
+            data.map(|(player_id, value)| ActiveModel {
+                id: NotSet,
+                ty: Set(ty),
+                player_id: Set(player_id),
+                value: Set(value),
+            }),
+        )
+        .on_conflict(
+            // Update the value column if a key already exists
+            OnConflict::columns([Column::PlayerId, Column::Ty])
+                .update_column(Column::Value)
+                .to_owned(),
+        )
+        .exec(db)
+    }
+}
diff --git a/src/database/entities/mod.rs b/src/database/entities/mod.rs
index 1c15938e..86e02b2d 100644
--- a/src/database/entities/mod.rs
+++ b/src/database/entities/mod.rs
@@ -1,8 +1,10 @@
 pub mod galaxy_at_war;
+pub mod leaderboard_data;
 pub mod player_data;
 pub mod players;
 
 pub type GalaxyAtWar = galaxy_at_war::Model;
 pub type Player = players::Model;
 pub type PlayerData = player_data::Model;
+pub type LeaderboardData = leaderboard_data::Model;
 pub use players::PlayerRole;
diff --git a/src/database/entities/player_data.rs b/src/database/entities/player_data.rs
index 097f7aca..099fef5d 100644
--- a/src/database/entities/player_data.rs
+++ b/src/database/entities/player_data.rs
@@ -97,7 +97,7 @@ impl Model {
             }),
         )
         .on_conflict(
-            // Update the valume column if a key already exists
+            // Update the value column if a key already exists
             OnConflict::columns([Column::PlayerId, Column::Key])
                 .update_column(Column::Value)
                 .to_owned(),
diff --git a/src/database/entities/players.rs b/src/database/entities/players.rs
index 8a2a06b1..c940ab82 100644
--- a/src/database/entities/players.rs
+++ b/src/database/entities/players.rs
@@ -4,7 +4,7 @@ use crate::config::RuntimeConfig;
 use crate::database::DbResult;
 use crate::utils::hashing::hash_password;
 use futures_util::future::BoxFuture;
-use sea_orm::prelude::*;
+use sea_orm::{prelude::*, FromQueryResult};
 use sea_orm::{
     ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection, DeleteResult, EntityTrait,
     IntoActiveModel, QueryFilter,
@@ -30,7 +30,17 @@ pub struct Model {
 }
 
 #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
-pub enum Relation {}
+pub enum Relation {
+    #[sea_orm(has_many = "super::leaderboard_data::Entity")]
+    LeaderboardData,
+}
+
+// `Related` trait has to be implemented by hand
+impl Related<super::leaderboard_data::Entity> for Entity {
+    fn to() -> RelationDef {
+        Relation::LeaderboardData.def()
+    }
+}
 
 impl ActiveModelBehavior for ActiveModel {}
 
diff --git a/src/database/migration/m20231205_121139_leaderboard_data.rs b/src/database/migration/m20231205_121139_leaderboard_data.rs
new file mode 100644
index 00000000..e38e4edc
--- /dev/null
+++ b/src/database/migration/m20231205_121139_leaderboard_data.rs
@@ -0,0 +1,76 @@
+use sea_orm_migration::prelude::*;
+
+use super::m20221015_142649_players_table::Players;
+
+#[derive(DeriveMigrationName)]
+pub struct Migration;
+
+#[async_trait::async_trait]
+impl MigrationTrait for Migration {
+    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
+        manager
+            .create_table(
+                Table::create()
+                    .table(LeaderboardData::Table)
+                    .if_not_exists()
+                    .col(
+                        ColumnDef::new(LeaderboardData::Id)
+                            .unsigned()
+                            .not_null()
+                            .auto_increment()
+                            .primary_key(),
+                    )
+                    .col(
+                        ColumnDef::new(LeaderboardData::PlayerId)
+                            .unsigned()
+                            .not_null(),
+                    )
+                    .col(ColumnDef::new(LeaderboardData::Ty).unsigned().not_null())
+                    .col(ColumnDef::new(LeaderboardData::Value).unsigned().not_null())
+                    .foreign_key(
+                        ForeignKey::create()
+                            .from(LeaderboardData::Table, LeaderboardData::PlayerId)
+                            .to(Players::Table, Players::Id)
+                            .on_delete(ForeignKeyAction::Cascade),
+                    )
+                    .to_owned(),
+            )
+            .await?;
+
+        manager
+            .create_index(
+                Index::create()
+                    .unique()
+                    .name("idx-pid-ty-key")
+                    .table(LeaderboardData::Table)
+                    .col(LeaderboardData::Ty)
+                    .col(LeaderboardData::PlayerId)
+                    .to_owned(),
+            )
+            .await
+    }
+
+    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
+        manager
+            .drop_table(Table::drop().table(LeaderboardData::Table).to_owned())
+            .await?;
+
+        manager
+            .drop_index(
+                Index::drop()
+                    .table(LeaderboardData::Table)
+                    .name("idx-pid-ty-key")
+                    .to_owned(),
+            )
+            .await
+    }
+}
+
+#[derive(Iden)]
+enum LeaderboardData {
+    Table,
+    Id,
+    Ty,
+    PlayerId,
+    Value,
+}
diff --git a/src/database/migration/mod.rs b/src/database/migration/mod.rs
index bc0939a6..f6f05e04 100644
--- a/src/database/migration/mod.rs
+++ b/src/database/migration/mod.rs
@@ -4,6 +4,7 @@ mod m20221015_142649_players_table;
 mod m20221015_153750_galaxy_at_war_table;
 mod m20221222_174733_player_data;
 mod m20230913_185124_player_data_unique;
+mod m20231205_121139_leaderboard_data;
 
 pub struct Migrator;
 
@@ -15,6 +16,7 @@ impl MigratorTrait for Migrator {
             Box::new(m20221015_153750_galaxy_at_war_table::Migration),
             Box::new(m20221222_174733_player_data::Migration),
             Box::new(m20230913_185124_player_data_unique::Migration),
+            Box::new(m20231205_121139_leaderboard_data::Migration),
         ]
     }
 }
diff --git a/src/routes/leaderboard.rs b/src/routes/leaderboard.rs
index 02e36ef4..b6390b68 100644
--- a/src/routes/leaderboard.rs
+++ b/src/routes/leaderboard.rs
@@ -1,6 +1,10 @@
 use std::sync::Arc;
 
 use crate::{
+    database::entities::{
+        leaderboard_data::{LeaderboardDataAndRank, LeaderboardType},
+        LeaderboardData,
+    },
     services::leaderboard::{models::*, Leaderboard},
     utils::types::PlayerID,
 };
@@ -10,6 +14,7 @@ use axum::{
     response::{IntoResponse, Response},
     Extension, Json,
 };
+use log::debug;
 use sea_orm::DatabaseConnection;
 use serde::{Deserialize, Serialize};
 use thiserror::Error;
@@ -34,7 +39,7 @@ pub enum LeaderboardError {
 pub struct LeaderboardQuery {
     /// The number of ranks to offset by
     #[serde(default)]
-    offset: usize,
+    offset: u32,
     /// The number of items to query for count has a maximum limit
     /// of 255 entries to prevent server strain from querying the
     /// entire list of leaderboard entries
@@ -53,6 +58,18 @@ pub struct LeaderboardResponse<'a> {
     more: bool,
 }
 
+/// The different types of respones that can be created
+/// from a leaderboard request
+#[derive(Serialize)]
+pub struct LeaderboardResponse2 {
+    /// The total number of players in the entire leaderboard
+    total: usize,
+    /// The entries retrieved at the provided offset
+    entries: Vec<LeaderboardDataAndRank>,
+    /// Whether there is more entries past the provided offset
+    more: bool,
+}
+
 /// GET /api/leaderboard/:name
 ///
 /// Retrieves the leaderboard query for the provided leaderboard
@@ -63,32 +80,29 @@ pub struct LeaderboardResponse<'a> {
 pub async fn get_leaderboard(
     Path(ty): Path<LeaderboardType>,
     Extension(db): Extension<DatabaseConnection>,
-    Extension(leaderboard): Extension<Arc<Leaderboard>>,
     Query(LeaderboardQuery { offset, count }): Query<LeaderboardQuery>,
-) -> Result<Response, LeaderboardError> {
+) -> Result<Json<LeaderboardResponse2>, LeaderboardError> {
     /// The default number of entries to return in a leaderboard response
     const DEFAULT_COUNT: u8 = 40;
 
     // The number of entries to return
-    let count: usize = count.unwrap_or(DEFAULT_COUNT) as usize;
+    let count: u32 = count.unwrap_or(DEFAULT_COUNT) as u32;
     // Calculate the start and ending indexes
-    let start: usize = offset * count;
+    let start: u32 = offset * count;
 
-    let group = leaderboard.query(ty, &db).await;
+    let values = LeaderboardData::get_offset(&db, ty, start, count)
+        .await
+        .expect("Ofs");
 
-    let entries = group
-        .get_normal(start, count)
-        .ok_or(LeaderboardError::InvalidRange)?;
+    let total = LeaderboardData::total(&db, ty).await.unwrap() as u64;
 
-    let more = group.has_more(start, count);
+    let more = false; /* Todo: more */
 
-    let response = Json(LeaderboardResponse {
-        total: group.values.len(),
-        entries,
+    Ok(Json(LeaderboardResponse2 {
+        total: total as usize,
+        entries: values,
         more,
-    });
-
-    Ok(response.into_response())
+    }))
 }
 
 /// GET /api/leaderboard/:name/:player_id
diff --git a/src/services/leaderboard/mod.rs b/src/services/leaderboard/mod.rs
index a62a29ff..2fef32fc 100644
--- a/src/services/leaderboard/mod.rs
+++ b/src/services/leaderboard/mod.rs
@@ -1,7 +1,7 @@
 use self::models::*;
 use crate::{
     database::{
-        entities::players,
+        entities::{leaderboard_data::LeaderboardType, players},
         entities::{Player, PlayerData},
         DatabaseConnection, DbResult,
     },
diff --git a/src/services/leaderboard/models.rs b/src/services/leaderboard/models.rs
index 07c8cd94..81c2a6b4 100644
--- a/src/services/leaderboard/models.rs
+++ b/src/services/leaderboard/models.rs
@@ -119,24 +119,3 @@ impl LeaderboardGroup {
         self.values.get(start_index..end_index)
     }
 }
-
-/// Type of leaderboard entity
-#[derive(Debug, Hash, Clone, Copy, PartialEq, Eq, Deserialize)]
-pub enum LeaderboardType {
-    /// Leaderboard based on the player N7 ratings
-    #[serde(rename = "n7")]
-    N7Rating,
-    /// Leaderboard based on the player challenge point number
-    #[serde(rename = "cp")]
-    ChallengePoints,
-}
-
-impl From<&str> for LeaderboardType {
-    fn from(value: &str) -> Self {
-        if value.starts_with("N7Rating") {
-            Self::N7Rating
-        } else {
-            Self::ChallengePoints
-        }
-    }
-}
diff --git a/src/session/models/stats.rs b/src/session/models/stats.rs
index 671409aa..c08174f6 100644
--- a/src/session/models/stats.rs
+++ b/src/session/models/stats.rs
@@ -1,9 +1,54 @@
-use tdf::{TdfDeserialize, TdfSerialize, TdfType, TdfTyped};
-
 use crate::{
-    services::leaderboard::models::{LeaderboardEntry, LeaderboardType},
+    database::entities::leaderboard_data::LeaderboardType,
+    services::leaderboard::models::LeaderboardEntry,
     utils::{components::user_sessions::PLAYER_TYPE, types::PlayerID},
 };
+use tdf::{TdfDeserialize, TdfMap, TdfSerialize, TdfType, TdfTyped, VarIntList};
+
+#[derive(TdfDeserialize)]
+pub struct SubmitGameReportRequest {
+    #[tdf(tag = "RPRT")]
+    pub report: GameReport,
+}
+
+#[derive(TdfDeserialize, TdfTyped)]
+#[tdf(group)]
+pub struct GameReport {
+    // Must be read since it uses the same duplicate tag
+    #[tdf(tag = "GAME")]
+    pub game_ids: VarIntList,
+
+    #[tdf(tag = "GAME")]
+    pub game: GameReportGame,
+}
+
+#[derive(TdfDeserialize, TdfTyped)]
+#[tdf(group)]
+pub struct GameReportGame {
+    /// The details for each specific player
+    #[tdf(tag = "PLYR")]
+    pub players: TdfMap<PlayerID, GameReportPlayerData>,
+}
+
+#[derive(TdfDeserialize, TdfTyped)]
+#[tdf(group)]
+pub struct GameReportPlayerData {
+    /// Locale string encoded as int
+    #[tdf(tag = "CTRY")]
+    pub country: u32,
+    /// Number of challenge points the player has
+    #[tdf(tag = "NCHP")]
+    pub challenge_points: u32,
+    /// N7 Rating value for the player
+    #[tdf(tag = "NRAT")]
+    pub n7_rating: u32,
+}
+
+#[test]
+fn test() {
+    let bytes = 17477u32.to_be_bytes();
+    println!("{}", String::from_utf8_lossy(&bytes));
+}
 
 /// Structure for the request to retrieve the entity count
 /// of a leaderboard
diff --git a/src/session/routes/other.rs b/src/session/routes/other.rs
index 7df14e97..7e6e9a31 100644
--- a/src/session/routes/other.rs
+++ b/src/session/routes/other.rs
@@ -1,9 +1,20 @@
+use log::error;
+use sea_orm::DatabaseConnection;
+use tokio::try_join;
+
 use crate::{
-    session::{models::other::*, packet::Packet, router::Blaze, SessionLink},
+    database::entities::{leaderboard_data::LeaderboardType, LeaderboardData},
+    session::{
+        models::{other::*, stats::SubmitGameReportRequest},
+        packet::Packet,
+        router::{Blaze, Extension, SessionAuth},
+        SessionLink,
+    },
     utils::components::game_reporting,
 };
 
-/// Handles submission of offline game reports from clients.
+/// Handles submission of offline game reports from clients. This contains
+/// the new leaderboard information for the player
 ///
 /// ```
 /// Route: GameReporting(SubmitOfflineGameReport)
@@ -12,14 +23,14 @@ use crate::{
 ///     "FNSH": 0,
 ///     "PRVT": VarList [],
 ///     "RPVT": {
-///         "GAME": VarList [1],
+///         "GAME": VarList [1 /* Game ID */],
 ///         "GAME": {
 ///             "GAME": {},
 ///             "PLYR": Map {
-///                 1: {
-///                     "CTRY": 16725,
-///                     "NCHP": 0,
-///                     "NRAT": 1
+///                 1 /* The player */: {
+///                     "CTRY": 16725, /* Player country */
+///                     "NCHP": 0, /* Challenge points */
+///                     "NRAT": 1 /* N7 Rating */
 ///                 }
 ///             }
 ///         }
@@ -28,7 +39,31 @@ use crate::{
 ///     "GTYP": "massEffectReport"
 /// }
 /// ```
-pub async fn handle_submit_offline(session: SessionLink) {
+pub async fn handle_submit_offline(
+    session: SessionLink,
+    SessionAuth(_): SessionAuth,
+    Extension(db): Extension<DatabaseConnection>,
+    Blaze(SubmitGameReportRequest { report }): Blaze<SubmitGameReportRequest>,
+) {
+    let game = report.game;
+    let players = game.players;
+
+    let n7_data = players
+        .iter()
+        .map(|(player_id, player_data)| (*player_id, player_data.n7_rating));
+    let cp_data = players
+        .iter()
+        .map(|(player_id, player_data)| (*player_id, player_data.challenge_points));
+
+    if let Err(err) = try_join!(
+        LeaderboardData::set_ty_bulk(&db, LeaderboardType::N7Rating, n7_data),
+        LeaderboardData::set_ty_bulk(&db, LeaderboardType::ChallengePoints, cp_data),
+    ) {
+        // TODO: Handle failed to update leaderboards
+        error!("Failed to update leaderboards: {}", err);
+        return;
+    }
+
     session.notify_handle().notify(Packet::notify(
         game_reporting::COMPONENT,
         game_reporting::GAME_REPORT_SUBMITTED,

From 31eb869966bc9d3b361b5c20c9e5ddacc86563f3 Mon Sep 17 00:00:00 2001
From: Jacobtread <jacobtread@gmail.com>
Date: Tue, 5 Dec 2023 15:18:04 +1300
Subject: [PATCH 2/5] Replaced leaderboard with new database bound leaderboard

---
 src/database/entities/leaderboard_data.rs |  50 +++--
 src/database/entities/players.rs          |   2 +-
 src/main.rs                               |   8 +-
 src/routes/leaderboard.rs                 |  53 ++---
 src/services/leaderboard/mod.rs           | 236 ----------------------
 src/services/leaderboard/models.rs        | 121 -----------
 src/services/mod.rs                       |   1 -
 src/session/models/stats.rs               |  31 +--
 src/session/router.rs                     |  58 +-----
 src/session/routes/stats.rs               |  59 +++---
 src/utils/parsing.rs                      |  32 ---
 11 files changed, 90 insertions(+), 561 deletions(-)
 delete mode 100644 src/services/leaderboard/mod.rs
 delete mode 100644 src/services/leaderboard/models.rs

diff --git a/src/database/entities/leaderboard_data.rs b/src/database/entities/leaderboard_data.rs
index 006c9849..febbdf79 100644
--- a/src/database/entities/leaderboard_data.rs
+++ b/src/database/entities/leaderboard_data.rs
@@ -4,9 +4,7 @@ use crate::database::DbResult;
 use crate::utils::types::PlayerID;
 use sea_orm::sea_query::OnConflict;
 use sea_orm::ActiveValue::NotSet;
-use sea_orm::{
-    prelude::*, FromQueryResult, InsertResult, QueryOrder, QuerySelect, RelationBuilder,
-};
+use sea_orm::{prelude::*, FromQueryResult, InsertResult, QueryOrder, QuerySelect};
 use sea_orm::{ActiveValue::Set, DatabaseConnection, EntityTrait};
 use serde::{Deserialize, Serialize};
 use std::future::Future;
@@ -87,16 +85,19 @@ pub struct LeaderboardDataAndRank {
 }
 
 impl Model {
-    pub async fn total(db: &DatabaseConnection, ty: LeaderboardType) -> DbResult<u64> {
-        Entity::find().filter(Column::Ty.eq(ty)).count(db).await
+    pub fn total(
+        db: &DatabaseConnection,
+        ty: LeaderboardType,
+    ) -> impl Future<Output = DbResult<u64>> + Send + '_ {
+        Entity::find().filter(Column::Ty.eq(ty)).count(db)
     }
 
-    pub async fn get_offset(
+    pub fn get_offset(
         db: &DatabaseConnection,
         ty: LeaderboardType,
         start: u32,
         count: u32,
-    ) -> DbResult<Vec<LeaderboardDataAndRank>> {
+    ) -> impl Future<Output = DbResult<Vec<LeaderboardDataAndRank>>> + Send + '_ {
         Entity::find()
             // Ranking by the values
             .expr(Expr::cust("RANK () OVER (ORDER BY value DESC) rank"))
@@ -116,27 +117,40 @@ impl Model {
             .into_model::<LeaderboardDataAndRank>()
             // Collect all the matching entities
             .all(db)
-            .await
     }
+
     pub async fn get_centered(
         db: &DatabaseConnection,
         ty: LeaderboardType,
         player_id: PlayerID,
         count: u32,
     ) -> DbResult<Option<Vec<LeaderboardDataAndRank>>> {
-        // let value = match Self::get_entry(db, ty, player_id).await? {
-        //     Some(value) => value,
-        //     None =>
-        // }
+        let value = match Self::get_entry(db, ty, player_id).await? {
+            Some(value) => value,
+            None => return Ok(None),
+        };
+
+        if count == 0 {
+            return Ok(None);
+        }
+
+        // The number of items before the center index
+        let before = if count % 2 == 0 {
+            (count / 2).saturating_add(1)
+        } else {
+            count / 2
+        };
 
-        Ok(None)
+        let start = value.rank.saturating_sub(before);
+        let values = Self::get_offset(db, ty, start, count).await?;
+        Ok(Some(values))
     }
 
-    pub async fn get_entry(
+    pub fn get_entry(
         db: &DatabaseConnection,
         ty: LeaderboardType,
         player_id: PlayerID,
-    ) -> DbResult<Option<LeaderboardDataAndRank>> {
+    ) -> impl Future<Output = DbResult<Option<LeaderboardDataAndRank>>> + Send + '_ {
         Entity::find()
             // Ranking by the values
             .expr(Expr::cust("RANK () OVER (ORDER BY value DESC) rank"))
@@ -152,14 +166,13 @@ impl Model {
             .into_model::<LeaderboardDataAndRank>()
             // Collect all the matching entities
             .one(db)
-            .await
     }
 
-    pub async fn get_filtered(
+    pub fn get_filtered(
         db: &DatabaseConnection,
         ty: LeaderboardType,
         player_ids: Vec<PlayerID>,
-    ) -> DbResult<Vec<LeaderboardDataAndRank>> {
+    ) -> impl Future<Output = DbResult<Vec<LeaderboardDataAndRank>>> + Send + '_ {
         Entity::find()
             // Ranking by the values
             .expr(Expr::cust("RANK () OVER (ORDER BY value DESC) rank"))
@@ -175,7 +188,6 @@ impl Model {
             .into_model::<LeaderboardDataAndRank>()
             // Collect all the matching entities
             .all(db)
-            .await
     }
 
     pub fn set(
diff --git a/src/database/entities/players.rs b/src/database/entities/players.rs
index c940ab82..551b302e 100644
--- a/src/database/entities/players.rs
+++ b/src/database/entities/players.rs
@@ -4,7 +4,7 @@ use crate::config::RuntimeConfig;
 use crate::database::DbResult;
 use crate::utils::hashing::hash_password;
 use futures_util::future::BoxFuture;
-use sea_orm::{prelude::*, FromQueryResult};
+use sea_orm::prelude::*;
 use sea_orm::{
     ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection, DeleteResult, EntityTrait,
     IntoActiveModel, QueryFilter,
diff --git a/src/main.rs b/src/main.rs
index 635f5e10..fa62e515 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,9 +1,6 @@
 use crate::{
     config::{RuntimeConfig, VERSION},
-    services::{
-        game::manager::GameManager, leaderboard::Leaderboard, retriever::Retriever,
-        sessions::Sessions,
-    },
+    services::{game::manager::GameManager, retriever::Retriever, sessions::Sessions},
     utils::signing::SigningKey,
 };
 use axum::{Extension, Server};
@@ -57,7 +54,6 @@ async fn main() {
     );
 
     let game_manager = Arc::new(GameManager::new());
-    let leaderboard = Arc::new(Leaderboard::new());
     let sessions = Arc::new(Sessions::new(signing_key));
     let config = Arc::new(runtime_config);
     let retriever = Arc::new(retriever);
@@ -69,7 +65,6 @@ async fn main() {
     router.add_extension(config.clone());
     router.add_extension(retriever);
     router.add_extension(game_manager.clone());
-    router.add_extension(leaderboard.clone());
     router.add_extension(sessions.clone());
 
     let router = router.build();
@@ -81,7 +76,6 @@ async fn main() {
         .layer(Extension(config))
         .layer(Extension(router))
         .layer(Extension(game_manager))
-        .layer(Extension(leaderboard))
         .layer(Extension(sessions))
         .into_make_service_with_connect_info::<SocketAddr>();
 
diff --git a/src/routes/leaderboard.rs b/src/routes/leaderboard.rs
index b6390b68..d4235d2d 100644
--- a/src/routes/leaderboard.rs
+++ b/src/routes/leaderboard.rs
@@ -1,11 +1,8 @@
-use std::sync::Arc;
-
 use crate::{
     database::entities::{
         leaderboard_data::{LeaderboardDataAndRank, LeaderboardType},
         LeaderboardData,
     },
-    services::leaderboard::{models::*, Leaderboard},
     utils::types::PlayerID,
 };
 use axum::{
@@ -14,8 +11,7 @@ use axum::{
     response::{IntoResponse, Response},
     Extension, Json,
 };
-use log::debug;
-use sea_orm::DatabaseConnection;
+use sea_orm::{DatabaseConnection, DbErr};
 use serde::{Deserialize, Serialize};
 use thiserror::Error;
 
@@ -24,12 +20,13 @@ use thiserror::Error;
 /// searching for a specific player.
 #[derive(Debug, Error)]
 pub enum LeaderboardError {
-    /// The provided query range was out of bounds on the underlying query
-    #[error("Unacceptable query range")]
-    InvalidRange,
     /// The requested player was not found in the leaderboard
     #[error("Player not found")]
     PlayerNotFound,
+
+    /// Database error occurred
+    #[error("Internal server error")]
+    Database(#[from] DbErr),
 }
 
 /// Structure of a query requesting a specific leaderboard contains
@@ -49,19 +46,7 @@ pub struct LeaderboardQuery {
 /// The different types of respones that can be created
 /// from a leaderboard request
 #[derive(Serialize)]
-pub struct LeaderboardResponse<'a> {
-    /// The total number of players in the entire leaderboard
-    total: usize,
-    /// The entries retrieved at the provided offset
-    entries: &'a [LeaderboardEntry],
-    /// Whether there is more entries past the provided offset
-    more: bool,
-}
-
-/// The different types of respones that can be created
-/// from a leaderboard request
-#[derive(Serialize)]
-pub struct LeaderboardResponse2 {
+pub struct LeaderboardResponse {
     /// The total number of players in the entire leaderboard
     total: usize,
     /// The entries retrieved at the provided offset
@@ -81,7 +66,7 @@ pub async fn get_leaderboard(
     Path(ty): Path<LeaderboardType>,
     Extension(db): Extension<DatabaseConnection>,
     Query(LeaderboardQuery { offset, count }): Query<LeaderboardQuery>,
-) -> Result<Json<LeaderboardResponse2>, LeaderboardError> {
+) -> Result<Json<LeaderboardResponse>, LeaderboardError> {
     /// The default number of entries to return in a leaderboard response
     const DEFAULT_COUNT: u8 = 40;
 
@@ -90,15 +75,13 @@ pub async fn get_leaderboard(
     // Calculate the start and ending indexes
     let start: u32 = offset * count;
 
-    let values = LeaderboardData::get_offset(&db, ty, start, count)
-        .await
-        .expect("Ofs");
-
-    let total = LeaderboardData::total(&db, ty).await.unwrap() as u64;
+    let values = LeaderboardData::get_offset(&db, ty, start, count).await?;
+    let total = LeaderboardData::total(&db, ty).await? as u32;
 
-    let more = false; /* Todo: more */
+    // There are more if the end < the total number of values
+    let more = (start + count) < (total + 1);
 
-    Ok(Json(LeaderboardResponse2 {
+    Ok(Json(LeaderboardResponse {
         total: total as usize,
         entries: values,
         more,
@@ -115,17 +98,13 @@ pub async fn get_leaderboard(
 pub async fn get_player_ranking(
     Path((ty, player_id)): Path<(LeaderboardType, PlayerID)>,
     Extension(db): Extension<DatabaseConnection>,
-    Extension(leaderboard): Extension<Arc<Leaderboard>>,
-) -> Result<Response, LeaderboardError> {
-    let group = leaderboard.query(ty, &db).await;
-
-    let entry = match group.get_entry(player_id) {
+) -> Result<Json<LeaderboardDataAndRank>, LeaderboardError> {
+    let entry = match LeaderboardData::get_entry(&db, ty, player_id).await? {
         Some(value) => value,
         None => return Err(LeaderboardError::PlayerNotFound),
     };
 
-    let response = Json(entry);
-    Ok(response.into_response())
+    Ok(Json(entry))
 }
 
 /// IntoResponse implementation for LeaderboardError to allow it to be
@@ -135,7 +114,7 @@ impl IntoResponse for LeaderboardError {
     fn into_response(self) -> Response {
         let status = match &self {
             Self::PlayerNotFound => StatusCode::NOT_FOUND,
-            Self::InvalidRange => StatusCode::BAD_REQUEST,
+            Self::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
         };
         (status, self.to_string()).into_response()
     }
diff --git a/src/services/leaderboard/mod.rs b/src/services/leaderboard/mod.rs
deleted file mode 100644
index 2fef32fc..00000000
--- a/src/services/leaderboard/mod.rs
+++ /dev/null
@@ -1,236 +0,0 @@
-use self::models::*;
-use crate::{
-    database::{
-        entities::{leaderboard_data::LeaderboardType, players},
-        entities::{Player, PlayerData},
-        DatabaseConnection, DbResult,
-    },
-    utils::{
-        parsing::{KitNameDeployed, PlayerClass},
-        types::PlayerID,
-    },
-};
-use futures_util::future::BoxFuture;
-use log::{debug, error};
-use parking_lot::Mutex;
-use sea_orm::{EntityTrait, PaginatorTrait, QueryOrder};
-use std::{collections::HashMap, sync::Arc, time::Instant};
-use tokio::task::JoinSet;
-
-pub mod models;
-
-pub struct Leaderboard {
-    /// Map between the group types and the actual leaderboard group content
-    groups: Mutex<HashMap<LeaderboardType, GroupState>>,
-}
-
-/// Extra state wrapper around a leaderboard group which
-/// holds the state of whether the group is being actively
-/// recomputed
-struct GroupState {
-    /// Whether the group is being computed
-    computing: bool,
-    /// The underlying group
-    group: Arc<LeaderboardGroup>,
-}
-
-impl Leaderboard {
-    /// Starts a new leaderboard service
-    pub fn new() -> Leaderboard {
-        Leaderboard {
-            groups: Default::default(),
-        }
-    }
-
-    pub async fn query(
-        &self,
-        ty: LeaderboardType,
-        db: &DatabaseConnection,
-    ) -> Arc<LeaderboardGroup> {
-        {
-            let groups = &mut *self.groups.lock();
-            // If the group already exists and is not expired we can respond with it
-            if let Some(group) = groups.get_mut(&ty) {
-                let inner = &group.group;
-
-                // Response with current values if the group isn't expired or is computing
-                if group.computing || !inner.is_expired() {
-                    // Value is not expired respond immediately
-                    return inner.clone();
-                }
-
-                // Mark the group as currently being computed
-                group.computing = true;
-            } else {
-                // Create dummy empty group to hand out while computing (Prevents multiple computes)
-                let dummy = GroupState {
-                    computing: true,
-                    group: Arc::new(LeaderboardGroup::dummy()),
-                };
-                groups.insert(ty, dummy);
-            }
-        }
-
-        // Compute new leaderboard values
-        let values = Self::compute(&ty, db).await;
-        let group = Arc::new(LeaderboardGroup::new(values));
-
-        // Store the updated group
-        {
-            let groups = &mut *self.groups.lock();
-            groups.insert(
-                ty,
-                GroupState {
-                    computing: false,
-                    group: group.clone(),
-                },
-            );
-        }
-
-        group
-    }
-
-    /// Computes the ranking values for the provided `ty` this consists of
-    /// streaming the values from the database in chunks of 20, processing the
-    /// chunks converting them into entries then sorting the entries based
-    /// on their value.
-    ///
-    /// `ty` The leaderboard type
-    async fn compute(ty: &LeaderboardType, db: &DatabaseConnection) -> Box<[LeaderboardEntry]> {
-        let start_time = Instant::now();
-
-        // The amount of players to process in each database request
-        const BATCH_COUNT: u64 = 20;
-
-        let mut values: Vec<LeaderboardEntry> = Vec::new();
-
-        let mut join_set = JoinSet::new();
-
-        let mut paginator = players::Entity::find()
-            .order_by_asc(players::Column::Id)
-            .paginate(db, BATCH_COUNT);
-
-        // Function pointer to the computing function for the desired type
-        let fun: fn(DatabaseConnection, Player) -> Lf = match ty {
-            LeaderboardType::N7Rating => compute_n7_player,
-            LeaderboardType::ChallengePoints => compute_cp_player,
-        };
-
-        loop {
-            let players = match paginator.fetch_and_next().await {
-                Ok(None) => break,
-                Ok(Some(value)) => value,
-                Err(err) => {
-                    error!("Unable to load players for leaderboard: {:?}", err);
-                    break;
-                }
-            };
-
-            // Add the futures for all the players
-            for player in players {
-                join_set.spawn(fun(db.clone(), player));
-            }
-
-            // Await computed results
-            while let Some(value) = join_set.join_next().await {
-                if let Ok(Ok(value)) = value {
-                    values.push(value)
-                }
-            }
-        }
-
-        // Sort the values based on their value
-        values.sort_by(|a, b| b.value.cmp(&a.value));
-
-        // Apply the new rank order to the rank values
-        let mut rank = 1;
-        for value in &mut values {
-            value.rank = rank;
-            rank += 1;
-        }
-
-        debug!("Computed leaderboard took: {:.2?}", start_time.elapsed());
-
-        values.into_boxed_slice()
-    }
-}
-
-type Lf = BoxFuture<'static, DbResult<LeaderboardEntry>>;
-
-/// Computes a ranking for the provided player based on the N7 ranking
-/// of that player.
-///
-/// `db`     The database connection
-/// `player` The player to rank
-fn compute_n7_player(db: DatabaseConnection, player: Player) -> Lf {
-    Box::pin(async move {
-        let mut total_promotions: u32 = 0;
-        let mut total_level: u32 = 0;
-
-        let data: Vec<PlayerData> = PlayerData::all(&db, player.id).await?;
-
-        let mut classes: Vec<PlayerClass> = Vec::new();
-        let mut characters: Vec<KitNameDeployed> = Vec::new();
-
-        for datum in &data {
-            if datum.key.starts_with("class") {
-                if let Some(value) = PlayerClass::parse(&datum.value) {
-                    classes.push(value);
-                }
-            } else if datum.key.starts_with("char") {
-                if let Some(value) = KitNameDeployed::parse(&datum.value) {
-                    characters.push(value);
-                }
-            }
-        }
-
-        for class in classes {
-            // Classes are active if atleast one character from the class is deployed
-            let is_active = characters
-                .iter()
-                .any(|char| char.kit_name.contains(class.name) && char.deployed);
-            if is_active {
-                total_level += class.level as u32;
-            }
-            total_promotions += class.promotions;
-        }
-
-        // 30 -> 20 from leveling class + 10 bonus for promoting
-        let rating: u32 = total_promotions * 30 + total_level;
-        Ok(LeaderboardEntry {
-            player_id: player.id,
-            player_name: player.display_name.into_boxed_str(),
-            // Rank is not computed yet at this stage
-            rank: 0,
-            value: rating,
-        })
-    })
-}
-
-/// Computes a ranking for the provided player based on the number of
-/// challenge points the player has
-///
-/// `db`     The database connection
-/// `player` The player to rank
-fn compute_cp_player(db: DatabaseConnection, player: Player) -> Lf {
-    Box::pin(async move {
-        let value = get_challenge_points(&db, player.id).await.unwrap_or(0);
-        Ok(LeaderboardEntry {
-            player_id: player.id,
-            player_name: player.display_name.into_boxed_str(),
-            // Rank is not computed yet at this stage
-            rank: 0,
-            value,
-        })
-    })
-}
-
-async fn get_challenge_points(db: &DatabaseConnection, player_id: PlayerID) -> Option<u32> {
-    let list = PlayerData::get(db, player_id, "Completion")
-        .await
-        .ok()??
-        .value;
-    let part = list.split(',').nth(1)?;
-    let value: u32 = part.parse().ok()?;
-    Some(value)
-}
diff --git a/src/services/leaderboard/models.rs b/src/services/leaderboard/models.rs
deleted file mode 100644
index 81c2a6b4..00000000
--- a/src/services/leaderboard/models.rs
+++ /dev/null
@@ -1,121 +0,0 @@
-use crate::utils::types::PlayerID;
-use serde::{Deserialize, Serialize};
-use std::time::{Duration, SystemTime};
-
-/// Structure for an entry in a leaderboard group
-#[derive(Serialize)]
-pub struct LeaderboardEntry {
-    /// The ID of the player this entry is for
-    pub player_id: PlayerID,
-    /// The name of the player this entry is for
-    pub player_name: Box<str>,
-    /// The ranking of this entry (Position in the leaderboard)
-    pub rank: usize,
-    /// The value this ranking is based on
-    pub value: u32,
-}
-
-/// Structure for a group of leaderboard entities ranked based
-/// on a certain value the expires indicates when the value will
-/// no longer be considered valid
-pub struct LeaderboardGroup {
-    /// The values stored in this entity group
-    pub values: Box<[LeaderboardEntry]>,
-    /// The time at which this entity group will become expired
-    pub expires: SystemTime,
-}
-
-impl LeaderboardGroup {
-    /// Leaderboard contents are cached for 1 hour
-    const LIFETIME: Duration = Duration::from_secs(60 * 60);
-
-    /// Creates a new leaderboard group which has an expiry time set
-    /// to the LIFETIME and uses the provided values
-    pub fn new(values: Box<[LeaderboardEntry]>) -> Self {
-        let expires = SystemTime::now() + Self::LIFETIME;
-        Self { expires, values }
-    }
-
-    /// Creates a dummy leaderboard group which has no values and
-    /// is already considered to be expired. Used to hand out
-    /// a value while computed to prevent mulitple computes happening
-    pub fn dummy() -> Self {
-        Self {
-            expires: SystemTime::UNIX_EPOCH,
-            values: Box::new([]),
-        }
-    }
-
-    /// Checks whether this group is expired
-    pub fn is_expired(&self) -> bool {
-        let now = SystemTime::now();
-        now.ge(&self.expires)
-    }
-
-    /// Checks whether there are more items after the provided offset and size
-    pub fn has_more(&self, start: usize, count: usize) -> bool {
-        let length = self.values.len();
-        start + count < length
-    }
-
-    /// Gets a normal collection of leaderboard entries at the start offset of the
-    /// provided count. Will return the slice of entires as well as whether there are
-    /// more entries after the desired offset
-    ///
-    /// `start` The start offset index
-    /// `count` The number of leaderboard entries
-    pub fn get_normal(&self, start: usize, count: usize) -> Option<&[LeaderboardEntry]> {
-        let end_index = (start + count).min(self.values.len());
-        self.values.get(start..end_index)
-    }
-
-    /// Gets a leaderboard entry for the provided player ID if one is present
-    ///
-    /// `player_id` The ID of the player to find the entry for
-    pub fn get_entry(&self, player_id: PlayerID) -> Option<&LeaderboardEntry> {
-        self.values
-            .iter()
-            .find(|value| value.player_id == player_id)
-    }
-
-    pub fn get_filtered(&self, players: &[PlayerID]) -> Vec<&LeaderboardEntry> {
-        self.values
-            .iter()
-            .filter(move |entry| players.contains(&entry.player_id))
-            .collect()
-    }
-
-    /// Gets a collection of leaderboard entries centered on the provided player with
-    /// half `count` items before and after if possible.
-    ///
-    /// `player_id` The ID of the player to center on
-    /// `count`     The total number of players to center on
-    pub fn get_centered(&self, player_id: PlayerID, count: usize) -> Option<&[LeaderboardEntry]> {
-        if count == 0 {
-            return None;
-        }
-
-        // The number of items before the center index
-        let before = if count % 2 == 0 {
-            (count / 2).saturating_add(1)
-        } else {
-            count / 2
-        };
-
-        // The number of items after the center index
-        let after = count / 2;
-
-        // The index of the centered player
-        let player_index = self
-            .values
-            .iter()
-            .position(|value| value.player_id == player_id)?;
-
-        // The index of the first item
-        let start_index = player_index.saturating_sub(before).min(player_index);
-        // The index of the last item
-        let end_index = player_index.saturating_add(after).min(self.values.len());
-
-        self.values.get(start_index..end_index)
-    }
-}
diff --git a/src/services/mod.rs b/src/services/mod.rs
index 3b905893..b497ce6b 100644
--- a/src/services/mod.rs
+++ b/src/services/mod.rs
@@ -1,4 +1,3 @@
 pub mod game;
-pub mod leaderboard;
 pub mod retriever;
 pub mod sessions;
diff --git a/src/session/models/stats.rs b/src/session/models/stats.rs
index c08174f6..fd373895 100644
--- a/src/session/models/stats.rs
+++ b/src/session/models/stats.rs
@@ -1,6 +1,5 @@
 use crate::{
-    database::entities::leaderboard_data::LeaderboardType,
-    services::leaderboard::models::LeaderboardEntry,
+    database::entities::leaderboard_data::{LeaderboardDataAndRank, LeaderboardType},
     utils::{components::user_sessions::PLAYER_TYPE, types::PlayerID},
 };
 use tdf::{TdfDeserialize, TdfMap, TdfSerialize, TdfType, TdfTyped, VarIntList};
@@ -96,23 +95,22 @@ pub struct CenteredLeaderboardRequest {
     pub center: PlayerID,
     /// The entity count
     #[tdf(tag = "COUN")]
-    pub count: usize,
+    pub count: u32,
     /// The leaderboard name
     #[tdf(tag = "NAME", into = &str)]
     pub name: LeaderboardType,
 }
 
-pub enum LeaderboardResponse<'a> {
-    Owned(Vec<&'a LeaderboardEntry>),
-    Borrowed(&'a [LeaderboardEntry]),
+pub struct LeaderboardResponse {
+    pub values: Vec<LeaderboardDataAndRank>,
 }
 
-impl TdfSerialize for LeaderboardEntry {
+impl TdfSerialize for LeaderboardDataAndRank {
     fn serialize<S: tdf::TdfSerializer>(&self, w: &mut S) {
         w.group_body(|w| {
             w.tag_str(b"ENAM", &self.player_name);
             w.tag_u32(b"ENID", self.player_id);
-            w.tag_usize(b"RANK", self.rank);
+            w.tag_u32(b"RANK", self.rank);
 
             let value_str = self.value.to_string();
             w.tag_str(b"RSTA", &value_str);
@@ -126,20 +124,13 @@ impl TdfSerialize for LeaderboardEntry {
     }
 }
 
-impl TdfTyped for LeaderboardEntry {
+impl TdfTyped for LeaderboardDataAndRank {
     const TYPE: TdfType = TdfType::Group;
 }
 
-impl TdfSerialize for LeaderboardResponse<'_> {
+impl TdfSerialize for LeaderboardResponse {
     fn serialize<S: tdf::TdfSerializer>(&self, w: &mut S) {
-        match self {
-            LeaderboardResponse::Owned(value) => {
-                w.tag_list_slice_ref(b"LDLS", value);
-            }
-            LeaderboardResponse::Borrowed(value) => {
-                w.tag_list_slice(b"LDLS", value);
-            }
-        }
+        w.tag_list_slice(b"LDLS", &self.values);
     }
 }
 
@@ -167,13 +158,13 @@ impl TdfSerialize for LeaderboardResponse<'_> {
 pub struct LeaderboardRequest {
     /// The entity count
     #[tdf(tag = "COUN")]
-    pub count: usize,
+    pub count: u32,
     /// The leaderboard name
     #[tdf(tag = "NAME", into = &str)]
     pub name: LeaderboardType,
     /// The rank offset to start at
     #[tdf(tag = "STRT")]
-    pub start: usize,
+    pub start: u32,
 }
 
 /// Structure for a request to get a leaderboard only
diff --git a/src/session/router.rs b/src/session/router.rs
index 2f51ce12..4f526cd7 100644
--- a/src/session/router.rs
+++ b/src/session/router.rs
@@ -1,11 +1,7 @@
 //! Router implementation for routing packet components to different functions
 //! and automatically decoding the packet contents to the function type
 
-use super::{
-    models::errors::BlazeError,
-    packet::{FireFrame, Packet},
-    SessionLink,
-};
+use super::{models::errors::BlazeError, packet::Packet, SessionLink};
 use crate::{
     database::entities::Player,
     services::game::GamePlayer,
@@ -26,7 +22,7 @@ use std::{
     marker::PhantomData,
     sync::Arc,
 };
-use tdf::{serialize_vec, TdfDeserialize, TdfDeserializer, TdfSerialize};
+use tdf::{serialize_vec, TdfDeserialize, TdfSerialize};
 
 pub trait Handler<Args, Res>: Send + Sync + 'static {
     fn handle(&self, req: PacketRequest) -> BoxFuture<'_, Packet>;
@@ -175,16 +171,6 @@ pub trait FromPacketRequest: Sized {
 /// serialization [IntoPacketResponse] for TDF contents
 pub struct Blaze<V>(pub V);
 
-/// Wrapper for providing deserialization [FromPacketRequest] and
-/// serialization [IntoPacketResponse] for TDF contents
-///
-/// Stores the packet header so that it can be used for generating
-/// responses
-pub struct BlazeWithHeader<V> {
-    pub req: V,
-    pub frame: FireFrame,
-}
-
 /// [Blaze] tdf type for contents that have already been
 /// serialized ahead of time
 pub struct RawBlaze(Bytes);
@@ -298,46 +284,6 @@ where
     }
 }
 
-impl<V> BlazeWithHeader<V> {
-    pub fn response<E>(&self, res: E) -> Packet
-    where
-        E: TdfSerialize,
-    {
-        Packet {
-            frame: self.frame.response(),
-            contents: Bytes::from(serialize_vec(&res)),
-        }
-    }
-}
-
-impl<V> FromPacketRequest for BlazeWithHeader<V>
-where
-    for<'a> V: TdfDeserialize<'a> + Send + 'a,
-{
-    type Rejection = BlazeError;
-
-    fn from_packet_request<'a>(
-        req: &'a mut PacketRequest,
-    ) -> BoxFuture<'a, Result<Self, Self::Rejection>>
-    where
-        Self: 'a,
-    {
-        let mut r = TdfDeserializer::new(&req.packet.contents);
-
-        Box::pin(ready(
-            V::deserialize(&mut r)
-                .map(|value| BlazeWithHeader {
-                    req: value,
-                    frame: req.packet.frame.clone(),
-                })
-                .map_err(|err| {
-                    error!("Error while decoding packet: {:?}", err);
-                    GlobalError::System.into()
-                }),
-        ))
-    }
-}
-
 impl FromPacketRequest for SessionLink {
     type Rejection = Infallible;
 
diff --git a/src/session/routes/stats.rs b/src/session/routes/stats.rs
index 19c63686..82009106 100644
--- a/src/session/routes/stats.rs
+++ b/src/session/routes/stats.rs
@@ -1,49 +1,43 @@
 use crate::{
-    services::leaderboard::Leaderboard,
+    database::entities::LeaderboardData,
     session::{
         models::stats::*,
-        packet::Packet,
-        router::{Blaze, BlazeWithHeader, Extension},
+        router::{Blaze, Extension},
     },
 };
 use sea_orm::DatabaseConnection;
-use std::sync::Arc;
 
 pub async fn handle_normal_leaderboard(
-    Extension(leaderboard): Extension<Arc<Leaderboard>>,
     Extension(db): Extension<DatabaseConnection>,
-    req: BlazeWithHeader<LeaderboardRequest>,
-) -> Packet {
-    let query = &req.req;
-    let group = leaderboard.query(query.name, &db).await;
-    let slice = group
-        .get_normal(query.start, query.count)
+    Blaze(query): Blaze<LeaderboardRequest>,
+) -> Blaze<LeaderboardResponse> {
+    let values = LeaderboardData::get_offset(&db, query.name, query.start, query.count)
+        .await
         .unwrap_or_default();
-    req.response(LeaderboardResponse::Borrowed(slice))
+    Blaze(LeaderboardResponse { values })
 }
 
 pub async fn handle_centered_leaderboard(
-    Extension(leaderboard): Extension<Arc<Leaderboard>>,
     Extension(db): Extension<DatabaseConnection>,
-    req: BlazeWithHeader<CenteredLeaderboardRequest>,
-) -> Packet {
-    let query = &req.req;
-    let group = leaderboard.query(query.name, &db).await;
-    let slice = group
-        .get_centered(query.center, query.count)
+    Blaze(query): Blaze<CenteredLeaderboardRequest>,
+) -> Blaze<LeaderboardResponse> {
+    let values = LeaderboardData::get_centered(&db, query.name, query.center, query.count)
+        .await
+        .unwrap_or_default()
         .unwrap_or_default();
-    req.response(LeaderboardResponse::Borrowed(slice))
+
+    Blaze(LeaderboardResponse { values })
 }
 
 pub async fn handle_filtered_leaderboard(
-    Extension(leaderboard): Extension<Arc<Leaderboard>>,
     Extension(db): Extension<DatabaseConnection>,
-    req: BlazeWithHeader<FilteredLeaderboardRequest>,
-) -> Packet {
-    let query = &req.req;
-    let group = leaderboard.query(query.name, &db).await;
-    let response = group.get_filtered(&query.ids);
-    req.response(LeaderboardResponse::Owned(response))
+    Blaze(query): Blaze<FilteredLeaderboardRequest>,
+) -> Blaze<LeaderboardResponse> {
+    let values = LeaderboardData::get_filtered(&db, query.name, query.ids)
+        .await
+        .unwrap_or_default();
+
+    Blaze(LeaderboardResponse { values })
 }
 
 /// Handles returning the number of leaderboard objects present.
@@ -62,13 +56,16 @@ pub async fn handle_filtered_leaderboard(
 /// }
 /// ```
 pub async fn handle_leaderboard_entity_count(
-    Extension(leaderboard): Extension<Arc<Leaderboard>>,
     Extension(db): Extension<DatabaseConnection>,
     Blaze(req): Blaze<EntityCountRequest>,
 ) -> Blaze<EntityCountResponse> {
-    let group = leaderboard.query(req.name, &db).await;
-    let count = group.values.len();
-    Blaze(EntityCountResponse { count })
+    let total = LeaderboardData::total(&db, req.name)
+        .await
+        .unwrap_or_default();
+
+    Blaze(EntityCountResponse {
+        count: total as usize,
+    })
 }
 
 fn get_locale_name(code: &str) -> &str {
diff --git a/src/utils/parsing.rs b/src/utils/parsing.rs
index f9df6836..71ceb3e6 100644
--- a/src/utils/parsing.rs
+++ b/src/utils/parsing.rs
@@ -31,17 +31,6 @@ impl<'a> MEParser<'a> {
         next.parse::<F>().ok()
     }
 
-    pub fn next_bool(&mut self) -> Option<bool> {
-        let next = self.next()?;
-        if next == "True" {
-            Some(true)
-        } else if next == "False" {
-            Some(false)
-        } else {
-            None
-        }
-    }
-
     pub fn skip(&mut self, n: usize) -> Option<()> {
         for _ in 0..n {
             self.next()?;
@@ -84,27 +73,6 @@ impl PlayerClass<'_> {
     }
 }
 
-/// Structure for holding the parsed kit_name and deployed state
-/// for a player character as the result of parsing
-pub struct KitNameDeployed<'a> {
-    pub kit_name: &'a str,
-    pub deployed: bool,
-}
-
-impl KitNameDeployed<'_> {
-    pub fn parse(value: &str) -> Option<KitNameDeployed<'_>> {
-        let mut parser = MEParser::new(value)?;
-        let kit_name = parser.next()?;
-
-        // Skip the 17 other items
-        parser.skip(17)?;
-
-        let deployed: bool = parser.next_bool()?;
-
-        Some(KitNameDeployed { kit_name, deployed })
-    }
-}
-
 // Unused full format declaration for the player character data
 //
 // /// Structure for a player character model stored in the database

From 0ce16f672e6f318bcbe14b511c78d61b511b3ff0 Mon Sep 17 00:00:00 2001
From: Jacobtread <jacobtread@gmail.com>
Date: Tue, 5 Dec 2023 16:55:24 +1300
Subject: [PATCH 3/5] Fixed leaderboard ranking ordering, added constants for
 duplicated values, documented functions

---
 src/database/entities/leaderboard_data.rs | 131 +++++++++++++---------
 src/routes/leaderboard.rs                 |   2 +-
 src/session/routes/stats.rs               |   2 +-
 3 files changed, 81 insertions(+), 54 deletions(-)

diff --git a/src/database/entities/leaderboard_data.rs b/src/database/entities/leaderboard_data.rs
index febbdf79..09c2be86 100644
--- a/src/database/entities/leaderboard_data.rs
+++ b/src/database/entities/leaderboard_data.rs
@@ -85,13 +85,29 @@ pub struct LeaderboardDataAndRank {
 }
 
 impl Model {
-    pub fn total(
+    /// Expression used to rank the leaderboard data
+    const RANK_EXPR: &'static str = "RANK() OVER (ORDER BY value DESC) rank";
+    /// The name of the column used for the rank value
+    const RANK_COL: &'static str = "rank";
+    /// The name of the column to store the loaded player name
+    const PLAYER_NAME_COL: &'static str = "player_name";
+
+    /// Counts the number of leaderboard data models for the
+    /// specific `ty` type of leaderboard
+    pub fn count(
         db: &DatabaseConnection,
         ty: LeaderboardType,
     ) -> impl Future<Output = DbResult<u64>> + Send + '_ {
-        Entity::find().filter(Column::Ty.eq(ty)).count(db)
+        Entity::find()
+            // Filter by the type
+            .filter(Column::Ty.eq(ty))
+            // Get the number of items
+            .count(db)
     }
 
+    /// Gets a collection of leaderboard data for the specific
+    /// `ty` type of leaderboard starting with the `start` rank
+    /// and including maximum of `count` entries
     pub fn get_offset(
         db: &DatabaseConnection,
         ty: LeaderboardType,
@@ -99,97 +115,108 @@ impl Model {
         count: u32,
     ) -> impl Future<Output = DbResult<Vec<LeaderboardDataAndRank>>> + Send + '_ {
         Entity::find()
-            // Ranking by the values
-            .expr(Expr::cust("RANK () OVER (ORDER BY value DESC) rank"))
+            // Add the ranking expression
+            .expr(Expr::cust(Self::RANK_EXPR))
             // Filter by the type
             .filter(Column::Ty.eq(ty))
-            // Order highest to lowest
-            .order_by_desc(Expr::cust("rank"))
+            // Order lowest to highest ranking
+            .order_by_asc(Expr::cust(Self::RANK_COL))
             // Offset to the starting position
             .offset(start as u64)
             // Only take the requested amouont
             .limit(count as u64)
-            // Join the playe rname
-            // Inner join on the player and use the player name
+            // Inner join on the players table
             .join(sea_orm::JoinType::InnerJoin, Relation::Player.def())
-            .column_as(super::players::Column::DisplayName, "player_name")
+            // Use the player name from the players table
+            .column_as(super::players::Column::DisplayName, Self::PLAYER_NAME_COL)
             // Turn it into the new model
             .into_model::<LeaderboardDataAndRank>()
             // Collect all the matching entities
             .all(db)
     }
 
-    pub async fn get_centered(
-        db: &DatabaseConnection,
-        ty: LeaderboardType,
-        player_id: PlayerID,
-        count: u32,
-    ) -> DbResult<Option<Vec<LeaderboardDataAndRank>>> {
-        let value = match Self::get_entry(db, ty, player_id).await? {
-            Some(value) => value,
-            None => return Ok(None),
-        };
-
-        if count == 0 {
-            return Ok(None);
-        }
-
-        // The number of items before the center index
-        let before = if count % 2 == 0 {
-            (count / 2).saturating_add(1)
-        } else {
-            count / 2
-        };
-
-        let start = value.rank.saturating_sub(before);
-        let values = Self::get_offset(db, ty, start, count).await?;
-        Ok(Some(values))
-    }
-
+    /// Gets the leaderboard data for a specific player on a
+    /// specific leaderboard type
     pub fn get_entry(
         db: &DatabaseConnection,
         ty: LeaderboardType,
         player_id: PlayerID,
     ) -> impl Future<Output = DbResult<Option<LeaderboardDataAndRank>>> + Send + '_ {
         Entity::find()
-            // Ranking by the values
-            .expr(Expr::cust("RANK () OVER (ORDER BY value DESC) rank"))
-            // Filter by the type
+            // Add the ranking expression
+            .expr(Expr::cust(Self::RANK_EXPR))
+            // Filter by the type and the specific player ID
             .filter(Column::Ty.eq(ty).and(Column::PlayerId.eq(player_id)))
-            // Order highest to lowest
-            .order_by_desc(Expr::cust("rank"))
-            // Join the playe rname
-            // Inner join on the player and use the player name
+            // Order lowest to highest ranking
+            .order_by_asc(Expr::cust(Self::RANK_COL))
+            // Inner join on the players table
             .join(sea_orm::JoinType::InnerJoin, Relation::Player.def())
-            .column_as(super::players::Column::DisplayName, "player_name")
+            // Use the player name from the players table
+            .column_as(super::players::Column::DisplayName, Self::PLAYER_NAME_COL)
             // Turn it into the new model
             .into_model::<LeaderboardDataAndRank>()
             // Collect all the matching entities
             .one(db)
     }
 
+    /// Gets a collection of leaderboard data for the specific
+    /// `ty` type of leaderboard including only the players
+    /// in the provided `player_ids` collection
     pub fn get_filtered(
         db: &DatabaseConnection,
         ty: LeaderboardType,
         player_ids: Vec<PlayerID>,
     ) -> impl Future<Output = DbResult<Vec<LeaderboardDataAndRank>>> + Send + '_ {
         Entity::find()
-            // Ranking by the values
-            .expr(Expr::cust("RANK () OVER (ORDER BY value DESC) rank"))
-            // Filter by the type
+            // Add the ranking expression
+            .expr(Expr::cust(Self::RANK_EXPR))
+            // Filter by the type and the requested player IDs
             .filter(Column::Ty.eq(ty).and(Column::PlayerId.is_in(player_ids)))
-            // Order highest to lowest
-            .order_by_desc(Expr::cust("rank"))
-            // Join the playe rname
-            // Inner join on the player and use the player name
+            // Order lowest to highest ranking
+            .order_by_asc(Expr::cust(Self::RANK_COL))
+            // Inner join on the players table
             .join(sea_orm::JoinType::InnerJoin, Relation::Player.def())
-            .column_as(super::players::Column::DisplayName, "player_name")
+            // Use the player name from the players table
+            .column_as(super::players::Column::DisplayName, Self::PLAYER_NAME_COL)
             // Turn it into the new model
             .into_model::<LeaderboardDataAndRank>()
             // Collect all the matching entities
             .all(db)
     }
 
+    /// Gets a collection of leaderboard data for the specific
+    /// `ty` type of leaderboard including maximum of `count` entries
+    /// centering the results around the rank of the provided `player_id`
+    pub async fn get_centered(
+        db: &DatabaseConnection,
+        ty: LeaderboardType,
+        player_id: PlayerID,
+        count: u32,
+    ) -> DbResult<Option<Vec<LeaderboardDataAndRank>>> {
+        // Find the entry we are centering on
+        let value = match Self::get_entry(db, ty, player_id).await? {
+            Some(value) => value,
+            None => return Ok(None),
+        };
+
+        if count == 0 {
+            return Ok(None);
+        }
+
+        // The number of items before the center index
+        let before = if count % 2 == 0 {
+            (count / 2).saturating_add(1)
+        } else {
+            count / 2
+        };
+
+        let start = value.rank.saturating_sub(before);
+        let values = Self::get_offset(db, ty, start, count).await?;
+        Ok(Some(values))
+    }
+
+    /// Sets the leaderboard value for the specified `player_id` on
+    /// a specific leaderboard `ty` type to the provided `value`
     pub fn set(
         db: &DatabaseConnection,
         ty: LeaderboardType,
diff --git a/src/routes/leaderboard.rs b/src/routes/leaderboard.rs
index d4235d2d..3edc8eb2 100644
--- a/src/routes/leaderboard.rs
+++ b/src/routes/leaderboard.rs
@@ -76,7 +76,7 @@ pub async fn get_leaderboard(
     let start: u32 = offset * count;
 
     let values = LeaderboardData::get_offset(&db, ty, start, count).await?;
-    let total = LeaderboardData::total(&db, ty).await? as u32;
+    let total = LeaderboardData::count(&db, ty).await? as u32;
 
     // There are more if the end < the total number of values
     let more = (start + count) < (total + 1);
diff --git a/src/session/routes/stats.rs b/src/session/routes/stats.rs
index 82009106..400e56b7 100644
--- a/src/session/routes/stats.rs
+++ b/src/session/routes/stats.rs
@@ -59,7 +59,7 @@ pub async fn handle_leaderboard_entity_count(
     Extension(db): Extension<DatabaseConnection>,
     Blaze(req): Blaze<EntityCountRequest>,
 ) -> Blaze<EntityCountResponse> {
-    let total = LeaderboardData::total(&db, req.name)
+    let total = LeaderboardData::count(&db, req.name)
         .await
         .unwrap_or_default();
 

From dab0458513efff7b93b91afcf5e018612e5c68c8 Mon Sep 17 00:00:00 2001
From: Jacobtread <jacobtread@gmail.com>
Date: Tue, 5 Dec 2023 17:26:29 +1300
Subject: [PATCH 4/5] Cleaned up leaderboard centering and upsert conflict
 handling

---
 src/database/entities/leaderboard_data.rs | 41 +++++++++++------------
 1 file changed, 19 insertions(+), 22 deletions(-)

diff --git a/src/database/entities/leaderboard_data.rs b/src/database/entities/leaderboard_data.rs
index 09c2be86..317cd672 100644
--- a/src/database/entities/leaderboard_data.rs
+++ b/src/database/entities/leaderboard_data.rs
@@ -196,25 +196,32 @@ impl Model {
         // Find the entry we are centering on
         let value = match Self::get_entry(db, ty, player_id).await? {
             Some(value) => value,
+            // The specified player hasn't been ranked
             None => return Ok(None),
         };
 
-        if count == 0 {
-            return Ok(None);
-        }
-
-        // The number of items before the center index
-        let before = if count % 2 == 0 {
-            (count / 2).saturating_add(1)
-        } else {
-            count / 2
-        };
+        // The number of ranks to start at before the centered rank
+        let before = (count / 2)
+            // Add 1 when the count is even
+            .saturating_add((count % 2 == 0) as u32);
 
+        // Determine the starting rank saturating zero bounds
         let start = value.rank.saturating_sub(before);
+
         let values = Self::get_offset(db, ty, start, count).await?;
         Ok(Some(values))
     }
 
+    /// Function providing the conflict handling for upserting
+    /// values into the leaderboard data
+    #[inline(always)]
+    fn conflict_handle() -> OnConflict {
+        // Update the value column if the player ID in that type already exists
+        OnConflict::columns([Column::PlayerId, Column::Ty])
+            .update_column(Column::Value)
+            .to_owned()
+    }
+
     /// Sets the leaderboard value for the specified `player_id` on
     /// a specific leaderboard `ty` type to the provided `value`
     pub fn set(
@@ -229,12 +236,7 @@ impl Model {
             player_id: Set(player_id),
             value: Set(value),
         })
-        .on_conflict(
-            // Update the value column if a key already exists
-            OnConflict::columns([Column::PlayerId, Column::Ty])
-                .update_column(Column::Value)
-                .to_owned(),
-        )
+        .on_conflict(Self::conflict_handle())
         .exec(db)
     }
 
@@ -255,12 +257,7 @@ impl Model {
                 value: Set(value),
             }),
         )
-        .on_conflict(
-            // Update the value column if a key already exists
-            OnConflict::columns([Column::PlayerId, Column::Ty])
-                .update_column(Column::Value)
-                .to_owned(),
-        )
+        .on_conflict(Self::conflict_handle())
         .exec(db)
     }
 }

From fd435e205b965521a12b394d325654e3e26a85d2 Mon Sep 17 00:00:00 2001
From: Jacobtread <jacobtread@gmail.com>
Date: Tue, 5 Dec 2023 17:40:51 +1300
Subject: [PATCH 5/5] Bumped minor version for breaking change

---
 Cargo.lock | 2 +-
 Cargo.toml | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index a1622db7..8cca7296 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1488,7 +1488,7 @@ checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964"
 
 [[package]]
 name = "pocket-relay"
-version = "0.5.11-beta"
+version = "0.6.0-beta"
 dependencies = [
  "argon2",
  "axum",
diff --git a/Cargo.toml b/Cargo.toml
index 57cca9ce..08777f9a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "pocket-relay"
-version = "0.5.11-beta"
+version = "0.6.0-beta"
 description = "Pocket Relay Server"
 readme = "README.md"
 keywords = ["EA", "PocketRelay", "MassEffect"]
