From 31d7dd462f7f74774d6478072be33a0bd1d6abec Mon Sep 17 00:00:00 2001 From: "NG (Graham)" Date: Sat, 7 Feb 2026 12:27:48 -0500 Subject: [PATCH] Allow promo codes to only be redeemed once --- rc_core/src/persist/config/cubes_json.rs | 1 + rc_core/src/persist/config/traits.rs | 1 + rc_core/src/persist/item_shop.rs | 13 ++++ rc_core/src/persist/user/account_json.rs | 65 +++++++++++++++++++ rc_core/src/persist/user/traits.rs | 1 + rc_database/src/schema/user_aux.rs | 1 + .../src/operations/code_redeem.rs | 16 +++++ 7 files changed, 98 insertions(+) diff --git a/rc_core/src/persist/config/cubes_json.rs b/rc_core/src/persist/config/cubes_json.rs index 5181823..25296db 100644 --- a/rc_core/src/persist/config/cubes_json.rs +++ b/rc_core/src/persist/config/cubes_json.rs @@ -544,6 +544,7 @@ impl super::ConfigProvider for CubeConfig { bundle_id: val.bundle_id.to_owned().unwrap_or_else(|| key.to_owned()), promo_id: val.promo_id.to_owned().unwrap_or_else(|| key.to_owned()), is_serial: val.is_serial, + is_repeatable: val.is_repeatable, value: val.value, transaction: tx, }); diff --git a/rc_core/src/persist/config/traits.rs b/rc_core/src/persist/config/traits.rs index 46d3201..6c0b52a 100644 --- a/rc_core/src/persist/config/traits.rs +++ b/rc_core/src/persist/config/traits.rs @@ -498,6 +498,7 @@ pub struct PromoCode { pub bundle_id: String, pub promo_id: String, pub is_serial: bool, + pub is_repeatable: bool, pub value: f32, pub transaction: ShopAction, } diff --git a/rc_core/src/persist/item_shop.rs b/rc_core/src/persist/item_shop.rs index 7eb66c3..2645612 100644 --- a/rc_core/src/persist/item_shop.rs +++ b/rc_core/src/persist/item_shop.rs @@ -213,6 +213,8 @@ pub struct ItemCode { #[serde(default)] pub is_serial: bool, #[serde(default)] + pub is_repeatable: bool, + #[serde(default)] pub value: f32, pub gives: Vec, } @@ -389,6 +391,16 @@ pub fn default_codes() -> std::collections::HashMap { bundle_id: None, promo_id: None, is_serial: false, + is_repeatable: true, + value: 1.5, + gives: vec![] + }); + map.insert("TEST-ONCE".to_owned(), ItemCode { + message: Some("Test passed".to_owned()), + bundle_id: None, + promo_id: None, + is_serial: false, + is_repeatable: false, value: 1.5, gives: vec![] }); @@ -397,6 +409,7 @@ pub fn default_codes() -> std::collections::HashMap { bundle_id: None, promo_id: None, is_serial: false, + is_repeatable: false, value: 1.5, gives: vec![ ItemPurchase::Experience { diff --git a/rc_core/src/persist/user/account_json.rs b/rc_core/src/persist/user/account_json.rs index 6bae82a..99b0534 100644 --- a/rc_core/src/persist/user/account_json.rs +++ b/rc_core/src/persist/user/account_json.rs @@ -1398,6 +1398,71 @@ impl super::User for UserData { )) } } + + async fn mark_code_redeemed(&self, code: String) -> Result { + // TODO support serial (single global use) codes + // FIXME this should probably be a transaction + let codes_opt = self.db.user_aux_by_user_id_and_descriptor(self.account.id, oj_rc_database::schema::user_aux::Descriptor::RedeemedPromoCodes).await + .map_err(|e| { + log::error!("Failed to retrieve RedeemedPromoCodes (user_aux) for user_id {}: {}", self.account.id, e); + polariton_server::operations::SimpleOpError::with_message( + crate::data::error_codes::WebServicesError::DatabaseError as i16, + "Failed to retrieve RedeemedPromoCodes".to_owned(), + ) + })?; + if let Some(codes_entity) = codes_opt { + match serde_json::from_str::>(&codes_entity.data) { + Ok(mut codes) => { + if codes.contains(&code) { + Ok(false) + } else { + codes.push(code); + let active_model = oj_rc_database::schema::user_aux::ActiveModel { + data: oj_rc_database::sea_orm::ActiveValue::Set(serde_json::to_string(&codes).unwrap()), + ..Default::default() + }; + self.db.update_user_aux_by_user_id_and_descriptor( + active_model, + self.account.id, + oj_rc_database::schema::user_aux::Descriptor::RedeemedPromoCodes, + ).await.map_err(|e| { + log::error!("Failed to update RedeemedPromoCodes (user_aux id {}) for user_id {}: {}", codes_entity.id, self.account.id, e); + polariton_server::operations::SimpleOpError::with_message( + crate::data::error_codes::WebServicesError::DatabaseError as i16, + "Failed to update RedeemedPromoCodes".to_owned(), + ) + })?; + Ok(true) + } + }, + Err(e) => { + log::error!("Failed to parse RedeemedPromoCodes (user_aux id {}) for user_id {}: {}", self.account.id, codes_entity.id, e); + return Err(polariton_server::operations::SimpleOpError::with_message( + crate::data::error_codes::WebServicesError::DatabaseError as i16, + "Failed to parse RedeemedPromoCodes JSON".to_owned(), + )); + } + } + } else { + // user_aux entry needs to be created + let new_codes = oj_rc_database::schema::user_aux::ActiveModel { + id: oj_rc_database::sea_orm::ActiveValue::NotSet, + user_id: oj_rc_database::sea_orm::ActiveValue::Set(self.account.id), + creation_time: oj_rc_database::sea_orm::ActiveValue::Set(chrono::Utc::now().timestamp()), + descriptor: oj_rc_database::sea_orm::ActiveValue::Set(oj_rc_database::schema::user_aux::Descriptor::RedeemedPromoCodes), + data: oj_rc_database::sea_orm::ActiveValue::Set(serde_json::to_string(&vec![code]).unwrap()), + }; + self.db.insert_user_aux(vec![new_codes]).await + .map_err(|e| { + log::error!("Failed to insert RedeemedPromoCodes (user_aux) for user_id {}: {}", self.account.id, e); + polariton_server::operations::SimpleOpError::with_message( + crate::data::error_codes::WebServicesError::DatabaseError as i16, + "Failed to insert updated RedeemedPromoCodes".to_owned(), + ) + })?; + Ok(true) + } + } } struct GameEventSetterImpl { diff --git a/rc_core/src/persist/user/traits.rs b/rc_core/src/persist/user/traits.rs index d9e378e..4bbc18c 100644 --- a/rc_core/src/persist/user/traits.rs +++ b/rc_core/src/persist/user/traits.rs @@ -85,6 +85,7 @@ pub trait User: ChatUser + SocialUser + LobbyUser + MultiplayerUser + Singlep fn current_game_event_setter(&self) -> Box; async fn apply_purchase(&self, action: &crate::persist::config::ShopAction) -> Result; async fn currency_debit(&self, ty: CurrencyType, to_sub: u64) -> Result<(), polariton_server::operations::SimpleOpError>; + async fn mark_code_redeemed(&self, code: String) -> Result; } #[async_trait::async_trait] diff --git a/rc_database/src/schema/user_aux.rs b/rc_database/src/schema/user_aux.rs index e94cc6b..f9c9e79 100644 --- a/rc_database/src/schema/user_aux.rs +++ b/rc_database/src/schema/user_aux.rs @@ -43,4 +43,5 @@ pub enum Descriptor { LastSeen, // u64, seconds since Unix epoch SubscribedChannels, // Vec, JSON AvatarId, // u32, u32::MAX means custom avatar + RedeemedPromoCodes, // Vec, JSON } diff --git a/rc_services_room/src/operations/code_redeem.rs b/rc_services_room/src/operations/code_redeem.rs index a036528..ba5dea5 100644 --- a/rc_services_room/src/operations/code_redeem.rs +++ b/rc_services_room/src/operations/code_redeem.rs @@ -41,6 +41,22 @@ impl SimpleOperation<()> for PromoCodeApplier { if let Some(Typed::Str(promo_code)) = params.remove(&CODE_NAME_PARAM_KEY) { let user_info = user.user()?; if let Some(code_info) = self.code_map.get(&promo_code.string) { + if !code_info.is_repeatable { + if !user_info.mark_code_redeemed(promo_code.string.clone()).await? { + params.insert(SUCCESS_PARAM_KEY, Typed::Bool(false)); + params.insert(RESULT_CODE_PARAM_KEY, Typed::Int(PromoResultCode::AlreadyAwarded as _)); + params.insert(IS_SERIAL_PARAM_KEY, Typed::Bool(false)); + params.insert(VALUE_PARAM_KEY, Typed::Float(0.0)); + params.insert(PROMO_ID_PARAM_KEY, Typed::Str(promo_code.clone())); + params.insert(CUBES_AWARDED_PARAM_KEY, Typed::Str("{}".into())); + params.insert(MSG_PARAM_KEY, Typed::Str("".into())); + params.insert(BUNDLE_ID_PARAM_KEY, Typed::Str(code_info.bundle_id.clone().into())); + params.insert(ROBOPASS_PARAM_KEY, Typed::Bool(false)); + params.insert(PAID_CURRENCY_PARAM_KEY, Typed::Long(0)); + log::debug!("Code \"{}\" not redeemed by {} (code already redeemed)", promo_code.string, user_info.public_id()); + return Ok(params); + } + } let result = user_info.apply_purchase(&code_info.transaction).await?; params.insert(SUCCESS_PARAM_KEY, Typed::Bool(result.success)); params.insert(RESULT_CODE_PARAM_KEY, Typed::Int(PromoResultCode::Success as _));