1
0
mirror of https://git.ngram.ca/OpenJam/rc-servers synced 2026-08-23 23:08:52 +00:00

Add basic currency support and plumb multiplayer rewards screen #70

This commit is contained in:
NG (Graham)
2025-12-28 18:13:14 -05:00
parent 5e05cbdb59
commit 4ec64a9cd9
26 changed files with 802 additions and 48 deletions

View File

@@ -121,3 +121,47 @@ impl AuthErrorCode {
(self as u16).to_string()
}
}
#[repr(u16)] // doesn't really matter
#[derive(Debug)]
pub enum SocialErrorCode {
None = 0,
UnexpectedError = 1,
UserDoesNotExist = 2,
UserAlreadyFriends = 3,
MaxFriends = 4,
TargetMaxFriends = 5,
UserNotFriend = 6,
InviteAlreadySent = 7,
UserIsSelf = 8,
DatabaseError = 9,
UserNotOnline = 10,
AutoDeclinedFriendOrClan = 11,
UserAcceptsPlatoonInvitesFromFriendsAndClansOnly = 12,
PlatoonIsFull = 13,
UserNotInPlatoon = 14,
UserNotPlatoonFound = 15,
NoInvite = 16,
AlreadyInvited = 17,
TheyNotInPlatoon = 18,
TheyNotPlatoonLeader = 19,
NoClansFound = 20,
UserNotInClan = 21,
AlreadyInClan = 22,
ClanRankTooLow = 23,
ClanFull = 24,
ClanNotFound = 25,
ClanClosed = 26,
UserNotFoundInClan = 28,
NotClanLeader = 29,
TargetAlreadyInClan = 30,
ClanAlreadyExists = 31,
InvalidClanName = 32,
InvalidUsername = 33,
WaitingForInviteResponse = 34,
AlreadyInPlatoon = 35,
NotInPlatoon = 36,
NotPlatoonLeader = 37,
UserBlockedYou = 38,
NoConnection = 39
}

View File

@@ -624,6 +624,57 @@ impl UserData {
}
Ok(players)
}
pub(super) async fn currency_op(&self, ty: super::CurrencyType, op: super::CurrencyOp) -> Result<u64, oj_rc_database::sea_orm::DbErr> {
let desc = match ty {
super::CurrencyType::Free => oj_rc_database::schema::user_aux::Descriptor::UserFreeCurrency,
super::CurrencyType::Paid => oj_rc_database::schema::user_aux::Descriptor::UserPaidCurrency,
super::CurrencyType::TechPoints => oj_rc_database::schema::user_aux::Descriptor::TechPoints,
super::CurrencyType::Experience => oj_rc_database::schema::user_aux::Descriptor::UserXP,
};
let model_opt = match op {
super::CurrencyOp::Get => {
self.db.update_user_aux_by_user_id_and_descriptor_custom(
self.account.id,
desc.clone(),
|_model| None
).await?
},
super::CurrencyOp::Add(to_add) => {
self.db.update_user_aux_by_user_id_and_descriptor_custom(
self.account.id,
desc.clone(),
move |model| {
use oj_rc_database::sea_orm::IntoActiveModel;
let new_currency = model.data.parse::<u64>().unwrap_or_default() + to_add;
let mut am = model.to_owned().into_active_model();
am.data = oj_rc_database::sea_orm::ActiveValue::Set(new_currency.to_string());
Some(am)
}
).await?
},
super::CurrencyOp::Sub(to_sub) => {
self.db.update_user_aux_by_user_id_and_descriptor_custom(
self.account.id,
desc.clone(),
move |model| {
use oj_rc_database::sea_orm::IntoActiveModel;
let new_currency = model.data.parse::<u64>().unwrap_or_default() - to_sub;
let mut am = model.to_owned().into_active_model();
am.data = oj_rc_database::sea_orm::ActiveValue::Set(new_currency.to_string());
Some(am)
}
).await?
},
};
let num: u64 = if let Some(model) = model_opt {
model.data.parse().unwrap_or_default()
} else {
log::warn!("No {:?} user_aux found for user {}", desc, self.account.id);
0
};
Ok(num)
}
}
const INVALID_ROBOT_ERR: i16 = crate::data::error_codes::WebServicesError::InvalidRobot as i16; // 140

View File

@@ -33,4 +33,11 @@ impl super::CommonUser for UserData {
async fn db_metrics(&self) -> oj_rc_database::DatabaseMetrics {
self.db.metrics().await
}
async fn currency(&self, ty: super::CurrencyType, op: super::CurrencyOp) -> Result<u64, polariton_server::operations::SimpleOpError> {
self.currency_op(ty, op).await.map_err(|e| polariton_server::operations::SimpleOpError::with_message(
crate::data::error_codes::WebServicesError::DatabaseError as i16,
format!("Currency operation failed for user {}: {}", self.account.id, e),
))
}
}

View File

@@ -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, MultiplayerError, MultiplayerErrorCode, PlayerDescriptor, GameEventSetter, CurrentGameEvent, AuthError, IntercomUser, FakePlayers, ResolvedVehicle, CommonUser, IntercomListener, UserRole};
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 mod intercom;
pub use intercom::generate_token as generate_intercom_token;
@@ -21,6 +21,7 @@ mod lobby;
pub use lobby::TeamChooser;
mod common;
mod chat;
mod social;
pub const TOKEN_SECRET_FILENAME: &str = "token_secret.key";

View File

@@ -167,4 +167,117 @@ impl super::MultiplayerUser for UserData {
})
}
}
async fn update_game_score(&self, guid: &str, score: super::PlayerScore) -> Result<i32, super::MultiplayerError> {
if let Some(guid) = crate::persist::user::str_to_i64(guid) {
if let Some(score_id) = score.id {
let model = oj_rc_database::schema::multiplayer_game_score::ActiveModel {
id: oj_rc_database::sea_orm::Set(score_id),
player_id: oj_rc_database::sea_orm::NotSet,
creation_time: oj_rc_database::sea_orm::NotSet,
is_claimed: oj_rc_database::sea_orm::NotSet,
kills: oj_rc_database::sea_orm::Set(score.kills as i32),
deaths: oj_rc_database::sea_orm::Set(score.deaths as i32),
assists: oj_rc_database::sea_orm::Set(score.assists as i32),
heal_assists: oj_rc_database::sea_orm::Set(score.heal_assists as i32),
healed: oj_rc_database::sea_orm::Set(score.healed as i32),
received_healed: oj_rc_database::sea_orm::Set(score.received_healed as i32),
damaged: oj_rc_database::sea_orm::Set(score.damaged as i32),
received_damaged: oj_rc_database::sea_orm::Set(score.received_damaged as i32),
crystals: oj_rc_database::sea_orm::Set(score.crystals as i32),
total: oj_rc_database::sea_orm::Set(score.total as i32),
};
let persisted_model = self.db.update_score(model).await
.map_err(|e| {
log::error!("Failed to update player score for user {} in game {}: {}", self.account.id, guid, e);
super::MultiplayerError {
code: super::MultiplayerErrorCode::CustomString,
message: format!("Failed to update player score for user {} in game {}: {}", self.account.id, guid, e),
}
})?;
Ok(persisted_model.id)
} else {
let player_opt = self.db.player_by_user_id_and_game_guid(self.account.id, guid).await
.map_err(|e| {
log::error!("Failed to retrieve player for user {} in game {}: {}", self.account.id, guid, e);
super::MultiplayerError {
code: super::MultiplayerErrorCode::CustomString,
message: format!("Failed to retrieve player for user {} in game {}: {}", self.account.id, guid, e),
}
})?;
if let Some(player) = player_opt {
let model = oj_rc_database::schema::multiplayer_game_score::ActiveModel {
id: oj_rc_database::sea_orm::NotSet,
player_id: oj_rc_database::sea_orm::Set(player.id),
creation_time: oj_rc_database::sea_orm::Set(chrono::Utc::now().timestamp()),
is_claimed: oj_rc_database::sea_orm::Set(false),
kills: oj_rc_database::sea_orm::Set(score.kills as i32),
deaths: oj_rc_database::sea_orm::Set(score.deaths as i32),
assists: oj_rc_database::sea_orm::Set(score.assists as i32),
heal_assists: oj_rc_database::sea_orm::Set(score.heal_assists as i32),
healed: oj_rc_database::sea_orm::Set(score.healed as i32),
received_healed: oj_rc_database::sea_orm::Set(score.received_healed as i32),
damaged: oj_rc_database::sea_orm::Set(score.damaged as i32),
received_damaged: oj_rc_database::sea_orm::Set(score.received_damaged as i32),
crystals: oj_rc_database::sea_orm::Set(score.crystals as i32),
total: oj_rc_database::sea_orm::Set(score.total as i32),
};
let persisted_model = self.db.insert_score(model).await
.map_err(|e| {
log::error!("Failed to insert player score for user {} in game {}: {}", self.account.id, guid, e);
super::MultiplayerError {
code: super::MultiplayerErrorCode::CustomString,
message: format!("Failed to insert player score for user {} in game {}: {}", self.account.id, guid, e),
}
})?;
Ok(persisted_model.id)
} else {
Err(super::MultiplayerError {
code: super::MultiplayerErrorCode::IncorrectGameGuid,
message: format!("Failed to find user {} in game {}", self.account.id, guid),
})
}
}
} else {
Err(super::MultiplayerError {
code: super::MultiplayerErrorCode::IncorrectGameGuid,
message: format!("Failed to parse game GUID {}", guid),
})
}
}
async fn save_player_connected_status(&self, guid: &str, is_connected: bool) -> Result<(), super::MultiplayerError> {
if let Some(guid) = crate::persist::user::str_to_i64(guid) {
let player_opt = self.db.player_by_user_id_and_game_guid(self.account.id, guid).await
.map_err(|e| {
log::error!("Failed to retrieve player for game {} and user {}: {}", guid, self.account.id, e);
super::MultiplayerError {
code: super::MultiplayerErrorCode::CustomString,
message: format!("Failed to retrieve user's player for game {}: {}", guid, e),
}
})?;
if let Some(player) = player_opt {
self.db.player_claim(player.id, is_connected).await
.map_err(|e| {
log::error!("Failed to claim player for game {} and user {}: {}", guid, self.account.id, e);
super::MultiplayerError {
code: super::MultiplayerErrorCode::CustomString,
message: format!("Failed to claim user's player for game {}: {}", guid, e),
}
})?;
Ok(())
} else {
log::warn!("Failed to find to-be-claimed player for user {} in game {}", self.account.id, guid);
Err(super::MultiplayerError {
code: super::MultiplayerErrorCode::CustomString,
message: "Failed to find user's player to update connected status".to_string(),
})
}
} else {
Err(super::MultiplayerError {
code: super::MultiplayerErrorCode::IncorrectGameGuid,
message: format!("Failed to parse game GUID {}", guid),
})
}
}
}

View File

@@ -0,0 +1,91 @@
use super::account_json::UserData;
#[async_trait::async_trait]
impl super::SocialUser for UserData {
async fn has_unclaimed_match_rewards(&self) -> Result<bool, polariton_server::operations::SimpleOpError> {
let count = self.db.count_score_by_user_id_and_claimed(self.account.id, false).await
.map_err(|e| {
log::error!("Failed to count unclaimed matches for user {} : {}", self.account.id, e);
polariton_server::operations::SimpleOpError::with_message(
crate::data::error_codes::SocialErrorCode::DatabaseError as i16,
format!("Failed to count unclaimed matches: {}", e),
)
})?;
Ok(count > 0)
}
async fn get_unclaimed_match_rewards(&self) -> Result<super::MatchRewards, polariton_server::operations::SimpleOpError> {
let unclaimed_score_opt = self.db.score_by_user_id_and_claimed_oldest(self.account.id, false).await
.map_err(|e| {
log::error!("Failed to retrieve unclaimed player score for user {} : {}", self.account.id, e);
polariton_server::operations::SimpleOpError::with_message(
crate::data::error_codes::SocialErrorCode::DatabaseError as i16,
format!("Failed to retrieve unclaimed player score: {}", e),
)
})?;
if let Some(unclaimed_score) = unclaimed_score_opt {
// TODO calculate these values
return Ok(super::MatchRewards {
season_experience: unclaimed_score.total,
experience_award_base: unclaimed_score.total,
experience_award_premium: unclaimed_score.total, // FIXME actually figure out if player has premium
experience_award_party: 0,
experience_award_tier: 0,
robits_total: unclaimed_score.total,
average_experience: unclaimed_score.total,
clan_experience: unclaimed_score.total,
robits_earned: unclaimed_score.total,
premium_robits_earned: unclaimed_score.total,
});
}
log::warn!("No unclaimed match rewards found for user {}", self.account.id);
Err(polariton_server::operations::SimpleOpError::with_message(
crate::data::error_codes::SocialErrorCode::UnexpectedError as i16,
"No remaining unclaimed match rewards for current user".to_owned(),
))
}
async fn claim_match_rewards(&self) -> Result<bool, polariton_server::operations::SimpleOpError> {
let unclaimed_score_opt = self.db.score_by_user_id_and_claimed_oldest(self.account.id, false).await
.map_err(|e| {
log::error!("Failed to retrieve to-be-claimed player score for user {}: {}", self.account.id, e);
polariton_server::operations::SimpleOpError::with_message(
crate::data::error_codes::SocialErrorCode::DatabaseError as i16,
format!("Failed to retrieve unclaimed player score: {}", e),
)
})?;
if let Some(unclaimed_score) = unclaimed_score_opt {
self.db.score_claim(unclaimed_score.id).await
.map_err(|e| {
log::error!("Failed to claim player score {} for user {} : {}", unclaimed_score.id, self.account.id, e);
polariton_server::operations::SimpleOpError::with_message(
crate::data::error_codes::SocialErrorCode::DatabaseError as i16,
format!("Failed to claim player score: {}", e),
)
})?;
// TODO calculate currency rewards
let reward = unclaimed_score.total as u64;
self.currency_op(super::CurrencyType::Free, super::CurrencyOp::Add(reward)).await
.map_err(|e| {
log::error!("Failed to save currency reward for user {}: {}", self.account.id, e);
polariton_server::operations::SimpleOpError::with_message(
crate::data::error_codes::SocialErrorCode::DatabaseError as i16,
format!("Failed to save currency reward: {}", e),
)
})?;
let experience = unclaimed_score.total as u64 * 4;
self.currency_op(super::CurrencyType::Experience, super::CurrencyOp::Add(experience)).await
.map_err(|e| {
log::error!("Failed to save experience reward for user {}: {}", self.account.id, e);
polariton_server::operations::SimpleOpError::with_message(
crate::data::error_codes::SocialErrorCode::DatabaseError as i16,
format!("Failed to save experience reward: {}", e),
)
})?;
Ok(self.db.count_score_by_user_id_and_claimed(self.account.id, false).await.is_ok_and(|x| x > 0))
} else {
Ok(false)
}
}
}

View File

@@ -61,7 +61,7 @@ pub trait UserAuthenticator {
}
#[async_trait::async_trait]
pub trait User<C>: ChatUser + LobbyUser + MultiplayerUser + IntercomUser + CommonUser {
pub trait User<C>: ChatUser + SocialUser + LobbyUser + MultiplayerUser + IntercomUser + CommonUser {
async fn unlocked_parts(&self) -> Vec<u32>;
async fn selected_garage(&self) -> (String, u32);
async fn select_garage(&self, slot: i32) -> Result<(), i16>;
@@ -339,6 +339,22 @@ pub trait MultiplayerUser: IntercomUser + CommonUser {
async fn game_players(&self, guid: &str) -> Result<Vec<PlayerDescriptor>, MultiplayerError>;
async fn complete_game(&self, guid: &str) -> Result<(), MultiplayerError>;
async fn game_info(&self, guid: &str) -> Result<Option<GameDescriptor>, MultiplayerError>;
async fn update_game_score(&self, guid: &str, score: PlayerScore) -> Result<i32, MultiplayerError>;
async fn save_player_connected_status(&self, guid: &str, is_connected: bool) -> Result<(), MultiplayerError>;
}
pub struct PlayerScore {
pub id: Option<i32>,
pub kills: u32,
pub deaths: u32,
pub assists: u32,
pub heal_assists: u32,
pub healed: u32,
pub received_healed: u32,
pub damaged: u32,
pub received_damaged: u32,
pub crystals: u32, // crystals destroyed
pub total: u32,
}
#[async_trait::async_trait]
@@ -387,4 +403,38 @@ pub trait CommonUser: Send + Sync {
fn is_royal(&self) -> bool;
fn is_banned(&self) -> bool;
async fn db_metrics(&self) -> oj_rc_database::DatabaseMetrics;
async fn currency(&self, ty: CurrencyType, op: CurrencyOp) -> Result<u64, polariton_server::operations::SimpleOpError>;
}
pub enum CurrencyType {
Free,
Paid,
TechPoints,
Experience,
}
pub enum CurrencyOp {
Get,
Add(u64),
Sub(u64),
}
#[async_trait::async_trait]
pub trait SocialUser: Send + Sync {
async fn has_unclaimed_match_rewards(&self) -> Result<bool, polariton_server::operations::SimpleOpError>;
async fn get_unclaimed_match_rewards(&self) -> Result<MatchRewards, polariton_server::operations::SimpleOpError>;
async fn claim_match_rewards(&self) -> Result<bool, polariton_server::operations::SimpleOpError>;
}
pub struct MatchRewards {
pub season_experience: i32,
pub experience_award_base: i32,
pub experience_award_premium: i32,
pub experience_award_party: i32,
pub experience_award_tier: i32,
pub robits_total: i32,
pub average_experience: i32,
pub clan_experience: i32,
pub robits_earned: i32,
pub premium_robits_earned: i32,
}