diff --git a/rc_core/src/data/game_mode.rs b/rc_core/src/data/game_mode.rs index 3e670f1..082f439 100644 --- a/rc_core/src/data/game_mode.rs +++ b/rc_core/src/data/game_mode.rs @@ -107,7 +107,7 @@ impl GameMode { } #[inline] - pub fn from_persist(mode: crate::persist::config::GameType) -> Self { + pub(crate) fn from_persist(mode: crate::persist::config::GameType) -> Self { match mode { crate::persist::config::GameType::BattleArena => Self::BattleArena, crate::persist::config::GameType::SuddenDeath => Self::SuddenDeath, @@ -144,6 +144,19 @@ impl GameMode { Self::Campaign => oj_rc_database::schema::multiplayer_game::GameMode::Campaign, } } + + pub fn from_u8(b: u8) -> Option { + match b { + 0 => Some(Self::BattleArena), + 1 => Some(Self::SuddenDeath), + 2 => Some(Self::Pit), + 3 => Some(Self::TestMode), + 4 => Some(Self::SinglePlayer), + 5 => Some(Self::TeamDeathmatch), + 6 => Some(Self::Campaign), + _ => None, + } + } } #[repr(u8)] diff --git a/rc_core/src/data/game_result.rs b/rc_core/src/data/game_result.rs new file mode 100644 index 0000000..1d577a8 --- /dev/null +++ b/rc_core/src/data/game_result.rs @@ -0,0 +1,172 @@ +pub struct GameResult { + pub mode: super::game_mode::GameMode, + pub is_custom: bool, + pub winners: Vec, + pub losers: Vec, +} + +impl GameResult { + pub fn parse(r: &mut dyn std::io::Read) -> std::io::Result { + let mut bytes_buf = [0u8; 2]; + r.read_exact(&mut bytes_buf)?; + let mode = super::game_mode::GameMode::from_u8(bytes_buf[0]) + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, format!("Invalid game mode {}", bytes_buf[0])))?; + let is_custom = bytes_buf[1] != 0; + let mut winners_count_buf = [0u8; 4]; + r.read_exact(&mut winners_count_buf)?; + let winners_count = i32::from_le_bytes(winners_count_buf); + let mut winners = Vec::with_capacity(winners_count as _); + for _ in 0..winners_count { + let award = PlayerAward::parse(r)?; + winners.push(award); + } + let mut losers_count_buf = [0u8; 4]; + r.read_exact(&mut losers_count_buf)?; + let losers_count = i32::from_le_bytes(losers_count_buf); + let mut losers = Vec::with_capacity(losers_count as _); + for _ in 0..losers_count { + let award = PlayerAward::parse(r)?; + losers.push(award); + } + Ok(Self { + mode, + is_custom, + winners, + losers, + }) + } +} + +pub struct PlayerAward { + pub player_name: String, + pub mastery: u8, + pub is_alive: bool, + pub is_disconnected: bool, + pub party_members: u8, + pub score: i32, + pub score_position: u8, + pub score_position_in_team: u8, + pub awards: std::collections::HashMap, + pub weapons: Vec, + pub players_died_before: u8, +} + +impl PlayerAward { + pub fn parse(r: &mut dyn std::io::Read) -> std::io::Result { + let player_name = super::read_str_for_binwriter(r)?; + let mut bytes_buf = [0u8; 4]; + r.read_exact(&mut bytes_buf)?; + let mastery = bytes_buf[0]; + let is_alive = bytes_buf[1] != 0; + let is_disconnected = bytes_buf[2] != 0; + let party_members = bytes_buf[3]; + let mut score_buf = [0u8; 4]; + r.read_exact(&mut score_buf)?; + let score = i32::from_le_bytes(score_buf); + let mut bytes_buf = [0u8; 3]; + r.read_exact(&mut bytes_buf)?; + let score_position = bytes_buf[0]; + let score_position_in_team = bytes_buf[1]; + let awards_count = bytes_buf[2]; + let mut awards = std::collections::HashMap::with_capacity(awards_count as _); + for _ in 0..awards_count { + let mut bytes_buf = [0u8; 1]; + r.read_exact(&mut bytes_buf)?; + let key = PlayerAwardId::from_u8(bytes_buf[0]) + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, format!("Invalid player award {}", bytes_buf[0])))?; + let mut value_buf = [0u8; 4]; + r.read_exact(&mut value_buf)?; + let value = i32::from_le_bytes(value_buf); + awards.insert(key, value); + } + let mut bytes_buf = [0u8; 1]; + r.read_exact(&mut bytes_buf)?; + let weapons_count = bytes_buf[0]; + let mut weapons = Vec::with_capacity(weapons_count as _); + for _ in 0..weapons_count { + let weapon = WeaponUsage::parse(r)?; + weapons.push(weapon); + } + let mut bytes_buf = [0u8; 1]; + r.read_exact(&mut bytes_buf)?; + let players_died_before = bytes_buf[0]; + Ok(Self { + player_name, + mastery, + is_alive, + is_disconnected, + party_members, + score, + score_position, + score_position_in_team, + awards, + weapons, + players_died_before, + }) + } +} + +#[repr(u8)] +#[derive(Hash, Eq, PartialEq, Clone, Copy)] +pub enum PlayerAwardId { + DestroyedCubes = 1, + DestroyedCubesInProtection = 2, + DestroyedCubesDefendingBase = 3, + Kill = 4, + KillAssist = 5, + HealCubes = 6, + HealAssist = 7, + // why are there gaps? + Score = 11, + CompletionBonus = 17, +} + +impl PlayerAwardId { + pub fn from_u8(b: u8) -> Option { + match b { + 1 => Some(Self::DestroyedCubes), + 2 => Some(Self::DestroyedCubesInProtection), + 3 => Some(Self::DestroyedCubesDefendingBase), + 4 => Some(Self::Kill), + 5 => Some(Self::KillAssist), + 6 => Some(Self::HealCubes), + 7 => Some(Self::HealAssist), + 11 => Some(Self::Score), + 17 => Some(Self::CompletionBonus), + _ => None, + } + } + + pub fn as_u8(&self) -> u8 { + *self as u8 + } +} + +pub struct WeaponUsage { + pub category: super::weapon_list::ItemCategory, + pub size: super::cube_list::ItemTier, + pub usage_ratio: f32, +} + +impl WeaponUsage { + pub fn parse(r: &mut dyn std::io::Read) -> std::io::Result { + let mut category_buf = [0u8; 4]; + r.read_exact(&mut category_buf)?; + let category_num = i32::from_le_bytes(category_buf); + let category = super::weapon_list::ItemCategory::from_smaller(category_num) + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, format!("Invalid item category {}", category_num)))?; + let mut size_buf = [0u8; 4]; + r.read_exact(&mut size_buf)?; + let size_num = i32::from_le_bytes(size_buf); + let size = super::cube_list::ItemTier::from_u32(size_num as _) + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, format!("Invalid item size/tier {}", size_num)))?; + let mut ratio_buf = [0u8; 4]; + r.read_exact(&mut ratio_buf)?; + let usage_ratio = f32::from_le_bytes(ratio_buf); + Ok(Self { + category, + size, + usage_ratio, + }) + } +} diff --git a/rc_core/src/data/mod.rs b/rc_core/src/data/mod.rs index c31ccf9..7b62dda 100644 --- a/rc_core/src/data/mod.rs +++ b/rc_core/src/data/mod.rs @@ -16,6 +16,7 @@ pub mod sanction; pub mod robot_data; pub mod lobby; pub mod battle_arena_config; +pub mod game_result; pub mod error_codes; diff --git a/rc_core/src/persist/user/mod.rs b/rc_core/src/persist/user/mod.rs index 1c90fdb..f444415 100644 --- a/rc_core/src/persist/user/mod.rs +++ b/rc_core/src/persist/user/mod.rs @@ -11,7 +11,7 @@ mod inventory; pub use inventory::UnlockedParts; mod traits; -pub use traits::{UserProvider, User, UserToken, UserSlots, UserSlotData, VehicleData, UserInfo, UserLoginInfo, ExtraUserInfo, UserAuthenticator, NewSlotData, UserId, RegistrationInfo, VehicleUploadData, ChatUser, AvatarInfo, GetAvatarInfo, ControlData, ControlType, CustomisationData, GetCustomisationData, SetSanction, SanctionType, LobbyUser, GameDescriptor, PlayerLobbyDescriptor, MultiplayerUser, PlayerScore, MultiplayerError, MultiplayerErrorCode, PlayerDescriptor, GameEventSetter, CurrentGameEvent, AuthError, IntercomUser, FakePlayers, ResolvedVehicle, CommonUser, IntercomListener, UserRole, SocialUser, CurrencyType, CurrencyOp, MatchRewards}; +pub use traits::{UserProvider, User, UserToken, UserSlots, UserSlotData, VehicleData, UserInfo, UserLoginInfo, ExtraUserInfo, UserAuthenticator, NewSlotData, UserId, RegistrationInfo, VehicleUploadData, ChatUser, AvatarInfo, GetAvatarInfo, ControlData, ControlType, CustomisationData, GetCustomisationData, SetSanction, SanctionType, LobbyUser, GameDescriptor, PlayerLobbyDescriptor, MultiplayerUser, PlayerScore, MultiplayerError, MultiplayerErrorCode, PlayerDescriptor, GameEventSetter, CurrentGameEvent, AuthError, IntercomUser, FakePlayers, ResolvedVehicle, CommonUser, IntercomListener, UserRole, SocialUser, CurrencyType, CurrencyOp, MatchRewards, SingleplayerUser}; pub mod intercom; pub use intercom::generate_token as generate_intercom_token; @@ -22,6 +22,7 @@ pub use lobby::TeamChooser; mod common; mod chat; mod social; +mod singleplayer; pub const TOKEN_SECRET_FILENAME: &str = "token_secret.key"; diff --git a/rc_core/src/persist/user/singleplayer.rs b/rc_core/src/persist/user/singleplayer.rs new file mode 100644 index 0000000..04520bf --- /dev/null +++ b/rc_core/src/persist/user/singleplayer.rs @@ -0,0 +1,85 @@ +use super::account_json::UserData; + +#[inline] +fn award_to_active_value(opt: Option<&i32>) -> oj_rc_database::sea_orm::ActiveValue { + match opt { + Some(t) => oj_rc_database::sea_orm::ActiveValue::Set(*t), + None => oj_rc_database::sea_orm::ActiveValue::Set(0), + } +} + +#[async_trait::async_trait] +impl super::SingleplayerUser for UserData { + async fn save_game_result(&self, guid: &str, result: crate::data::game_result::GameResult) -> Result<(), polariton_server::operations::SimpleOpError> { + let now = chrono::Utc::now().timestamp(); + // TODO make this a single transaction + let game = self.db.insert_game(oj_rc_database::schema::multiplayer_game::ActiveModel { + id: oj_rc_database::sea_orm::ActiveValue::NotSet, + creation_time: oj_rc_database::sea_orm::ActiveValue::Set(now), + guid: oj_rc_database::sea_orm::ActiveValue::Set(0), + map: oj_rc_database::sea_orm::ActiveValue::Set(format!("singleplayer|guid:{}", guid)), // invalid by design + mode: oj_rc_database::sea_orm::ActiveValue::Set(result.mode.to_db()), + visibility: oj_rc_database::sea_orm::ActiveValue::Set(oj_rc_database::schema::multiplayer_game::MapVisibility::Good), + auto_heal: oj_rc_database::sea_orm::ActiveValue::Set(false), + variant: oj_rc_database::sea_orm::ActiveValue::Set(oj_rc_database::schema::multiplayer_game::GameType::Standard), + is_complete: oj_rc_database::sea_orm::ActiveValue::Set(true), + }).await + .map_err(|e| { + log::error!("Failed to create singleplayer game {} for user {}: {}", guid, self.account.id, e); + polariton_server::operations::SimpleOpError::with_message( + crate::data::error_codes::SingleplayerErrorCode::DatabaseError as i16, + format!("Failed to create singleplayer game {}: {}", guid, e), + ) + })?; + let iter = result.winners.iter().map(|p| (0, p)).chain(result.losers.iter().map(|p| (1, p))); + for (player_team, player_result) in iter { + if player_result.player_name != self.account.display_name { continue; } + let player = self.db.insert_player(oj_rc_database::schema::multiplayer_game_player::ActiveModel { + id: oj_rc_database::sea_orm::ActiveValue::NotSet, + user_id: oj_rc_database::sea_orm::ActiveValue::Set(Some(self.account.id)), + game_id: oj_rc_database::sea_orm::ActiveValue::Set(game.id), + creation_time: oj_rc_database::sea_orm::ActiveValue::Set(now), + player_id: oj_rc_database::sea_orm::ActiveValue::Set(0), + team: oj_rc_database::sea_orm::ActiveValue::Set(player_team), + group: oj_rc_database::sea_orm::ActiveValue::Set(None), + is_claimed: oj_rc_database::sea_orm::ActiveValue::Set(true), + public_id: oj_rc_database::sea_orm::ActiveValue::Set("".to_owned()), // no point in adding this info + display_name: oj_rc_database::sea_orm::ActiveValue::Set("".to_owned()), + variant: oj_rc_database::sea_orm::ActiveValue::Set(oj_rc_database::schema::multiplayer_game_player::ClientType::Client), + }).await + .map_err(|e| { + log::error!("Failed to create singleplayer game {} player for user {}: {}", guid, self.account.id, e); + polariton_server::operations::SimpleOpError::with_message( + crate::data::error_codes::SingleplayerErrorCode::DatabaseError as i16, + format!("Failed to create singleplayer game {} player: {}", guid, e), + ) + })?; + self.db.insert_score(oj_rc_database::schema::multiplayer_game_score::ActiveModel { + id: oj_rc_database::sea_orm::ActiveValue::NotSet, + player_id: oj_rc_database::sea_orm::ActiveValue::Set(player.id), + creation_time: oj_rc_database::sea_orm::ActiveValue::Set(now), + is_claimed: oj_rc_database::sea_orm::ActiveValue::Set(false), + kills: award_to_active_value(player_result.awards.get(&crate::data::game_result::PlayerAwardId::Kill)), + deaths: oj_rc_database::sea_orm::ActiveValue::Set(0), + assists: award_to_active_value(player_result.awards.get(&crate::data::game_result::PlayerAwardId::KillAssist)), + heal_assists: award_to_active_value(player_result.awards.get(&crate::data::game_result::PlayerAwardId::HealAssist)), + healed: award_to_active_value(player_result.awards.get(&crate::data::game_result::PlayerAwardId::HealCubes)), + received_healed: oj_rc_database::sea_orm::ActiveValue::Set(0), + damaged: award_to_active_value(player_result.awards.get(&crate::data::game_result::PlayerAwardId::DestroyedCubes)), + received_damaged: oj_rc_database::sea_orm::ActiveValue::Set(0), + crystals: oj_rc_database::sea_orm::ActiveValue::Set(0), + total: award_to_active_value(player_result.awards.get(&crate::data::game_result::PlayerAwardId::Score)), + }).await + .map_err(|e| { + log::error!("Failed to create singleplayer game {} score for user {}: {}", guid, self.account.id, e); + polariton_server::operations::SimpleOpError::with_message( + crate::data::error_codes::SingleplayerErrorCode::DatabaseError as i16, + format!("Failed to create singleplayer game {} score: {}", guid, e), + ) + })?; + break; + } + + Ok(()) + } +} diff --git a/rc_core/src/persist/user/traits.rs b/rc_core/src/persist/user/traits.rs index 261327a..4914b8c 100644 --- a/rc_core/src/persist/user/traits.rs +++ b/rc_core/src/persist/user/traits.rs @@ -61,7 +61,7 @@ pub trait UserAuthenticator { } #[async_trait::async_trait] -pub trait User: ChatUser + SocialUser + LobbyUser + MultiplayerUser + IntercomUser + CommonUser { +pub trait User: ChatUser + SocialUser + LobbyUser + MultiplayerUser + SingleplayerUser + IntercomUser + CommonUser { async fn unlocked_parts(&self) -> Vec; async fn selected_garage(&self) -> (String, u32); async fn select_garage(&self, slot: i32) -> Result<(), i16>; @@ -438,3 +438,9 @@ pub struct MatchRewards { pub robits_earned: i32, pub premium_robits_earned: i32, } + +#[async_trait::async_trait] +pub trait SingleplayerUser: Send + Sync { + // regular singleplayer and campaign mode + async fn save_game_result(&self, guid: &str, result: crate::data::game_result::GameResult) -> Result<(), polariton_server::operations::SimpleOpError>; +} diff --git a/rc_database/src/wrapper.rs b/rc_database/src/wrapper.rs index 013cc95..1e699bf 100644 --- a/rc_database/src/wrapper.rs +++ b/rc_database/src/wrapper.rs @@ -455,6 +455,10 @@ impl Database { .and_then(|(_, player)| player)) } + pub async fn insert_player(&self, entity: crate::schema::multiplayer_game_player::ActiveModel) -> Result { + entity.insert(&self.orm).await + } + pub async fn insert_players(&self, entities: Vec) -> Result<(), sea_orm::DbErr> { crate::schema::multiplayer_game_player::Entity::insert_many(entities.into_iter()).exec(&self.orm).await?; Ok(()) diff --git a/rc_services_room/src/operations/campaign_save_result.rs b/rc_services_room/src/operations/campaign_save_result.rs new file mode 100644 index 0000000..904f3a7 --- /dev/null +++ b/rc_services_room/src/operations/campaign_save_result.rs @@ -0,0 +1,46 @@ +use polariton_server::operations::{SimpleOpError, SimpleOperation, SimpleOpImpl}; +use polariton::operation::{ParameterTable, Typed}; + +const GAME_RESULT_PARAM_KEY: u8 = 82; // bytes; in +const CAMPAIGN_ID_PARAM_KEY: u8 = 22; // string; in +const DIFFICULTY_PARAM_KEY: u8 = 23; // int; in +const LONG_PLAY_PARAM_KEY: u8 = 84; // float; in +const GUID_PARAM_KEY: u8 = 85; // string; in + +const CODE: u8 = 78; + +// SaveCampaignGameAwardsRequest +pub(super) struct CampaignGameAwardsSaver; + +#[async_trait::async_trait] +impl SimpleOperation for CampaignGameAwardsSaver { + type User = crate::UserTy; + const CODE: u8 = CODE; + + async fn handle(&self, mut params: ParameterTable, user: &Self::User) -> Result, SimpleOpError> { + if let Some(Typed::Bytes(game_result)) = params.remove(&GAME_RESULT_PARAM_KEY) { + let mut result_cursor = std::io::Cursor::new(game_result.vec); + let game_result = oj_rc_core::data::game_result::GameResult::parse(&mut result_cursor) + .map_err(|e| SimpleOpError::with_message( + oj_rc_core::data::error_codes::SingleplayerErrorCode::UnexpectedError as i16, + format!("Bad game result serialization: {}", e), + ))?; + if let Some(Typed::Float(_long_play)) = params.remove(&LONG_PLAY_PARAM_KEY) { + if let Some(Typed::Str(_campaign_id)) = params.remove(&CAMPAIGN_ID_PARAM_KEY) { + if let Some(Typed::Int(_difficulty)) = params.remove(&DIFFICULTY_PARAM_KEY) { + if let Some(Typed::Str(game_guid)) = params.remove(&GUID_PARAM_KEY) { + let user_info = user.user()?; + user_info.save_game_result(&game_guid.string, game_result).await?; + } + } + } + } + } + Ok(ParameterTable::with_capacity(1)) // parameter-less response + } +} + +pub(super) fn campaign_save_awards_provider() -> SimpleOpImpl { + SimpleOpImpl::new(CampaignGameAwardsSaver) +} + diff --git a/rc_services_room/src/operations/mod.rs b/rc_services_room/src/operations/mod.rs index 475a102..0bbf1a3 100644 --- a/rc_services_room/src/operations/mod.rs +++ b/rc_services_room/src/operations/mod.rs @@ -98,6 +98,7 @@ mod garage_slot_set_customisations; mod garage_slot_name; mod garage_slot_copy; mod steam_promo; +mod campaign_save_result; use polariton_server::operations::OperationsHandler; @@ -199,7 +200,7 @@ pub fn handler(init_ctx: &crate::InitConfig) -> OperationsHandler .add(player_robot_rank::player_robot_rank_provider()) .add(validate_machine::validate_campaign_robot_provider()) .add(singleplayer_campaigns::singleplayer_complete_campaign_provider(init_ctx)) - .add(polariton_server::operations::Ack::<78, _>::default()) // TODO handle SaveCampaignGameAwardsRequest instead of ignoring it + .add(campaign_save_result::campaign_save_awards_provider()) .add(singleplayer_campaigns::singleplayer_save_complete_campaign_provider()) // TODO handle UpdatePlayerCompletedCampaignWaveRequest saving .add(garage_slot_limit::garage_slots_limit(&init_ctx.cubes)) .add(garage_slot_add::garage_slot_add_provider()) diff --git a/rc_singleplayer_room/src/operations/mod.rs b/rc_singleplayer_room/src/operations/mod.rs index 7a3bdb7..bbbb1b3 100644 --- a/rc_singleplayer_room/src/operations/mod.rs +++ b/rc_singleplayer_room/src/operations/mod.rs @@ -1,6 +1,7 @@ mod more_auth; mod eac; mod load_ai_robots; +mod save_result; use polariton_server::operations::OperationsHandler; @@ -11,5 +12,6 @@ pub fn handler(init_ctx: &crate::InitConfig) -> OperationsHandler .add(eac::EacChallengeIgnorer) .add(load_ai_robots::tdm_machines_provider(&init_ctx.factory, init_ctx.parsers.weapon_order(), &init_ctx.config, init_ctx.parsers.cpu_counter())) //.add(polariton_server::operations::Ack::<33, _>::default()) // get user clan info (this is equivalent to not being in a clan) - .add(polariton_server::operations::Ack::<2, _>::default()) // Save singleplayer result (parameter-less response) + //.add(polariton_server::operations::Ack::<2, _>::default()) // Save singleplayer result (parameter-less response) + .add(save_result::save_result_provider()) } diff --git a/rc_singleplayer_room/src/operations/save_result.rs b/rc_singleplayer_room/src/operations/save_result.rs new file mode 100644 index 0000000..69bcbec --- /dev/null +++ b/rc_singleplayer_room/src/operations/save_result.rs @@ -0,0 +1,39 @@ +use polariton_server::operations::{SimpleOpError, SimpleOperation, SimpleOpImpl}; +use polariton::operation::{ParameterTable, Typed}; + +const GAME_RESULT_PARAM_KEY: u8 = 9; // bytes; in +const LONG_PLAY_PARAM_KEY: u8 = 12; // float; in +const GUID_PARAM_KEY: u8 = 18; // string; in + +const CODE: u8 = 2; + +pub(super) struct GameResultSaver; + +#[async_trait::async_trait] +impl SimpleOperation for GameResultSaver { + type User = crate::UserTy; + const CODE: u8 = CODE; + + async fn handle(&self, mut params: ParameterTable, user: &Self::User) -> Result, SimpleOpError> { + if let Some(Typed::Bytes(game_result)) = params.remove(&GAME_RESULT_PARAM_KEY) { + let mut result_cursor = std::io::Cursor::new(game_result.vec); + let game_result = oj_rc_core::data::game_result::GameResult::parse(&mut result_cursor) + .map_err(|e| SimpleOpError::with_message( + oj_rc_core::data::error_codes::SingleplayerErrorCode::UnexpectedError as i16, + format!("Bad game result serialization: {}", e), + ))?; + if let Some(Typed::Float(_long_play)) = params.remove(&LONG_PLAY_PARAM_KEY) { + if let Some(Typed::Str(game_guid)) = params.remove(&GUID_PARAM_KEY) { + let user_info = user.user()?; + user_info.save_game_result(&game_guid.string, game_result).await?; + } + } + } + Ok(ParameterTable::with_capacity(1)) // parameter-less response + } +} + +pub(super) fn save_result_provider() -> SimpleOpImpl { + SimpleOpImpl::new(GameResultSaver) +} +