From 35164c175aa4a511a02acf6d54eafd6ec2eb7395 Mon Sep 17 00:00:00 2001 From: Jacobtread 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 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 { + Entity::find().filter(Column::Ty.eq(ty)).count(db).await + } + + pub async fn get_offset( + db: &DatabaseConnection, + ty: LeaderboardType, + start: u32, + count: u32, + ) -> DbResult> { + 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::() + // Collect all the matching entities + .all(db) + .await + } + pub async fn get_centered( + db: &DatabaseConnection, + ty: LeaderboardType, + player_id: PlayerID, + count: u32, + ) -> DbResult>> { + // 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> { + 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::() + // Collect all the matching entities + .one(db) + .await + } + + pub async fn get_filtered( + db: &DatabaseConnection, + ty: LeaderboardType, + player_ids: Vec, + ) -> DbResult> { + 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::() + // Collect all the matching entities + .all(db) + .await + } + + pub fn set( + db: &DatabaseConnection, + ty: LeaderboardType, + player_id: PlayerID, + value: u32, + ) -> impl Future>> + 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, + ) -> impl Future>> + 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 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, + /// 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, Extension(db): Extension, - Extension(leaderboard): Extension>, Query(LeaderboardQuery { offset, count }): Query, -) -> Result { +) -> Result, 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, +} + +#[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, + Blaze(SubmitGameReportRequest { report }): Blaze, +) { + 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 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 { - Entity::find().filter(Column::Ty.eq(ty)).count(db).await + pub fn total( + db: &DatabaseConnection, + ty: LeaderboardType, + ) -> impl Future> + 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> { + ) -> impl Future>> + 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::() // Collect all the matching entities .all(db) - .await } + pub async fn get_centered( db: &DatabaseConnection, ty: LeaderboardType, player_id: PlayerID, count: u32, ) -> DbResult>> { - // 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> { + ) -> impl Future>> + 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::() // Collect all the matching entities .one(db) - .await } - pub async fn get_filtered( + pub fn get_filtered( db: &DatabaseConnection, ty: LeaderboardType, player_ids: Vec, - ) -> DbResult> { + ) -> impl Future>> + 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::() // 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::(); 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, Extension(db): Extension, Query(LeaderboardQuery { offset, count }): Query, -) -> Result, LeaderboardError> { +) -> Result, 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, - Extension(leaderboard): Extension>, -) -> Result { - let group = leaderboard.query(ty, &db).await; - - let entry = match group.get_entry(player_id) { +) -> Result, 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>, -} - -/// 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, -} - -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 { - { - 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 = 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>; - -/// 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::all(&db, player.id).await?; - - let mut classes: Vec = Vec::new(); - let mut characters: Vec = 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 { - 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, - /// 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, } -impl TdfSerialize for LeaderboardEntry { +impl TdfSerialize for LeaderboardDataAndRank { fn serialize(&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(&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: 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(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 { - 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 BlazeWithHeader { - pub fn response(&self, res: E) -> Packet - where - E: TdfSerialize, - { - Packet { - frame: self.frame.response(), - contents: Bytes::from(serialize_vec(&res)), - } - } -} - -impl FromPacketRequest for BlazeWithHeader -where - for<'a> V: TdfDeserialize<'a> + Send + 'a, -{ - type Rejection = BlazeError; - - fn from_packet_request<'a>( - req: &'a mut PacketRequest, - ) -> BoxFuture<'a, Result> - 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>, Extension(db): Extension, - req: BlazeWithHeader, -) -> 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, +) -> Blaze { + 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>, Extension(db): Extension, - req: BlazeWithHeader, -) -> 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, +) -> Blaze { + 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>, Extension(db): Extension, - req: BlazeWithHeader, -) -> 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, +) -> Blaze { + 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>, Extension(db): Extension, Blaze(req): Blaze, ) -> Blaze { - 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::().ok() } - pub fn next_bool(&mut self) -> Option { - 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> { - 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 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> + 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>> + 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::() // Collect all the matching entities .all(db) } - pub async fn get_centered( - db: &DatabaseConnection, - ty: LeaderboardType, - player_id: PlayerID, - count: u32, - ) -> DbResult>> { - 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>> + 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::() // 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, ) -> impl Future>> + 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::() // 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>> { + // 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, Blaze(req): Blaze, ) -> Blaze { - 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 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 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"]