diff --git a/assets/GachaConfig/gacha.jsonc b/assets/GachaConfig/gacha.jsonc new file mode 100644 index 0000000..e69de29 diff --git a/nap_data/Cargo.toml b/nap_data/Cargo.toml index 9c6161f..cd7aced 100644 --- a/nap_data/Cargo.toml +++ b/nap_data/Cargo.toml @@ -18,3 +18,5 @@ tracing.workspace = true # Internal proto.workspace = true +jsonc-parser = { version = "0.23.0", features = ["serde"] } +chrono = { version = "0.4.38", features = ["serde"] } diff --git a/nap_data/src/gacha/gacha_config.rs b/nap_data/src/gacha/gacha_config.rs new file mode 100644 index 0000000..fa777ed --- /dev/null +++ b/nap_data/src/gacha/gacha_config.rs @@ -0,0 +1,229 @@ +use std::collections::{HashMap, HashSet}; + +use chrono::{prelude::Local, DateTime}; +use serde::Deserialize; +use tracing; + +#[derive(Debug, Default, Deserialize)] +pub struct ExtraItemsPolicy { + pub id: u32, + pub count: u32, + #[serde(default)] + pub apply_on_owned_count: u32, +} + +#[derive(Debug, Default, Deserialize)] +pub struct ProbabilityPoint { + pub start_pity: u32, + pub start_chance_percent: f64, + #[serde(default)] + pub increment_percent: f64, +} + +#[derive(Debug, Default, Deserialize)] +pub struct ProbabilityModel { + #[serde(default)] + pub clear_status_on_higher_rarity_pulled: bool, + pub points: Vec, + // This value is for display only, so it's set when + // the maximum guarantee is not equal to the + // automatically calculated value (commonly, less than). + #[serde(default)] + pub maximum_guarantee_pity: u32, + + #[serde(skip_deserializing)] + probability_percents: Vec, +} + +impl ProbabilityModel { + fn get_maximum_guarantee(&self) -> u32 { + self.probability_percents.len() as u32 - 1 + } + + pub fn post_configure(&mut self, tag: &String) { + self.points.sort_by_key(|point| point.start_pity); + + let mut probability_percents: Vec = vec![0.0]; + for (i, point) in self.points.iter().enumerate() { + if i > 0 { + let last_point = &self.points[i - 1]; + let last_stop_percent = last_point.start_chance_percent + + last_point.increment_percent + * (point.start_pity - last_point.start_pity) as f64; + if last_stop_percent > point.start_chance_percent { + tracing::warn!("Gacha - ProbabilityModel '{tag}': The start chance of '{point:?}' is less than the value inherited from the previous point."); + } + } + + let mut max_pity = 2000; + if i < self.points.len() - 1 { + let next_point = &self.points[i + 1]; + max_pity = next_point.start_pity - 1; + let max_probability = point.start_chance_percent + + point.increment_percent + * (next_point.start_pity - 1 - point.start_pity) as f64; + assert!(max_probability < 100.0, "Gacha - ProbabilityModel '{tag}': Probability already reached 100% in '{point:?}' (though points with higher pity left)"); + } + + let mut pity = point.start_pity; + let mut percent = point.start_chance_percent; + while pity <= max_pity { + if max_pity >= 2000 && percent >= 100.0 { + probability_percents.push(100.0); + break; + } + probability_percents.push(percent); + percent += point.increment_percent; + pity += 1; + } + assert!(pity <= 2000, "Gacha - ProbabilityModel '{tag}' (point {i}): Haven't reached 100% guarantee probability at Pity 2001. The current probability is {percent}%. Crazy."); + } + + self.probability_percents = probability_percents; + if self.maximum_guarantee_pity <= 0 { + self.maximum_guarantee_pity = self.get_maximum_guarantee(); + } + } + + pub fn get_chance_percent(&self, pity: &u32) -> f64 { + // The vec length is 1 bigger than the maximum pity (1-based) + let guarantee_pity = self.probability_percents.len() - 1; + let idx = *pity as usize; + if idx > guarantee_pity { + return self.probability_percents[guarantee_pity]; + } + self.probability_percents[idx] + } +} + +#[allow(dead_code)] +#[derive(Debug, Default, Deserialize)] +pub struct CategoryGuaranteePolicy { + pub included_category_tags: HashSet, + pub trigger_on_failure_times: u32, + pub clear_status_on_target_changed: bool, + pub chooseable: bool, +} + +#[derive(Debug, Default, Deserialize)] +pub struct TenPullDiscount { + pub use_limit: u32, + pub discounted_prize: u32, +} + +#[derive(Debug, Default, Deserialize)] +pub struct AdvancedGuarantee { + pub use_limit: u32, + pub rarity: u32, + pub guarantee_pity: u32, +} + +#[derive(Debug, Default, Deserialize)] +pub struct MustGainItem { + pub use_limit: u32, + pub rarity: u32, + pub category_tag: String, +} + +#[derive(Debug, Default, Deserialize)] +pub struct DiscountPolicyCollection { + pub ten_pull_discount_map: HashMap, + pub must_gain_item_map: HashMap, + pub advanced_guarantee_map: HashMap, +} + +impl DiscountPolicyCollection { + pub fn post_configure(&mut self) { + for (tag, ten_pull_discount) in self.ten_pull_discount_map.iter() { + let discounted_prize = ten_pull_discount.discounted_prize; + assert!(discounted_prize < 10, "Gacha - DiscountPolicy '{tag}': ten_pull_discount's value should be smaller than 10 (read {discounted_prize})."); + } + } +} + +#[derive(Debug, Default, Deserialize)] +pub struct GachaCategoryInfo { + #[serde(default)] + pub is_promotional_items: bool, + pub item_ids: Vec, + pub category_weight: u32, +} + +#[derive(Debug, Default, Deserialize)] +pub struct GachaAvailableItemsInfo { + pub rarity: u32, + #[serde(default)] + pub extra_items_policy_tags: Vec, + pub categories: HashMap, + pub probability_model_tag: String, + #[serde(default)] + pub category_guarantee_policy_tags: Vec, +} + +#[allow(dead_code)] +#[derive(Debug, Default, Deserialize)] +pub struct CharacterGachaPool { + pub gacha_schedule_id: u32, + pub gacha_parent_schedule_id: u32, + pub comment: String, + pub gacha_type: u32, + pub cost_item_id: u32, + pub start_time: DateTime, + pub end_time: DateTime, + #[serde(default)] + pub discount_policy_tags: Vec, + pub sharing_guarantee_info_category: String, + pub gacha_items: Vec, +} + +impl CharacterGachaPool { + pub fn is_still_open(&self, now: &DateTime) -> bool { + self.start_time <= *now && *now <= self.end_time + } + + pub fn post_configure(&mut self, probability_model_map: &HashMap) { + self.gacha_items + .sort_by_key(|item_list| u32::MAX - item_list.rarity); + for items_info in self.gacha_items.iter_mut() { + assert!(probability_model_map.contains_key(&items_info.probability_model_tag), "Gacha - CharacterGachaPool '{}': Specified ProbabilityModel tag '{}' that does not exist.", self.gacha_schedule_id, items_info.probability_model_tag); + } + } +} + +#[derive(Debug, Default, Deserialize)] +pub struct GachaCommonProperties { + pub up_item_category_tag: String, + pub s_item_rarity: u32, + pub a_item_rarity: u32, + // TODO: PostConfigure check + pub ten_pull_discount_tag: String, + pub newcomer_advanced_s_tag: String, +} + +#[derive(Debug, Default, Deserialize)] +pub struct GachaConfiguration { + pub character_gacha_pool_list: Vec, + pub probability_model_map: HashMap, + pub category_guarantee_policy_map: HashMap, + pub extra_items_policy_map: HashMap, + pub discount_policies: DiscountPolicyCollection, + pub common_properties: GachaCommonProperties, +} + +impl GachaConfiguration { + pub fn post_configure(&mut self) { + assert!( + self.category_guarantee_policy_map + .contains_key(&self.common_properties.up_item_category_tag), + "The UP category should be valid in policy map." + ); + + for (tag, policy) in self.probability_model_map.iter_mut() { + policy.post_configure(&tag); + } + self.discount_policies.post_configure(); + for character_pool in self.character_gacha_pool_list.iter_mut() { + character_pool.post_configure(&self.probability_model_map); + } + } +} diff --git a/nap_data/src/gacha/mod.rs b/nap_data/src/gacha/mod.rs new file mode 100644 index 0000000..4b64edc --- /dev/null +++ b/nap_data/src/gacha/mod.rs @@ -0,0 +1,45 @@ +use std::sync::OnceLock; + +use gacha_config::GachaConfiguration; +use jsonc_parser::{parse_to_serde_value, ParseOptions}; +use serde::Deserialize; + +use crate::{action::ActionConfig, DataLoadError}; + +pub mod gacha_config; + +const GACHA_CONFIG_NAME: &str = "gacha.jsonc"; +static GACHACONF: OnceLock = OnceLock::new(); + +#[derive(Deserialize, Debug)] +pub struct EventGraphConfig { + pub event_id: u32, + pub actions: Vec, +} + +pub(crate) fn load_gacha_config(path: &str) -> Result<(), DataLoadError> { + let jsonc_data = std::fs::read_to_string(format!("{path}/{GACHA_CONFIG_NAME}")) + .map_err(|err| DataLoadError::IoError(err))?; + + let json_value = parse_to_serde_value( + &jsonc_data, + &ParseOptions { + allow_comments: true, + allow_loose_object_property_names: false, + allow_trailing_commas: true, + }, + ) + .map_err(|err| DataLoadError::JsoncParseError(err))? + .unwrap(); + + let mut result = serde_json::from_value::(json_value) + .map_err(|err| DataLoadError::FromJsonError(String::from("GachaConfiguration"), err))?; + result.post_configure(); + + GACHACONF.set(result).unwrap(); + Ok(()) +} + +pub fn global_gacha_config() -> &'static GachaConfiguration { + GACHACONF.get().unwrap() +} diff --git a/nap_data/src/lib.rs b/nap_data/src/lib.rs index 5e30e4d..7aaf0d4 100644 --- a/nap_data/src/lib.rs +++ b/nap_data/src/lib.rs @@ -1,9 +1,11 @@ pub mod action; pub mod event; +pub mod gacha; pub mod tables; use std::{collections::HashMap, sync::OnceLock}; +use jsonc_parser::errors::ParseError; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -12,6 +14,7 @@ pub struct AssetsConfig { pub filecfg_path: String, pub event_config_path: String, pub usm_keys_path: String, + pub gacha_config_path: String, } #[derive(Error, Debug)] @@ -20,6 +23,8 @@ pub enum DataLoadError { IoError(#[from] std::io::Error), #[error("from_json failed for type {0}, error: {1}")] FromJsonError(String, serde_json::Error), + #[error("jsonc_parser parse as json error: {0}")] + JsoncParseError(#[from] ParseError), } static USM_KEY_MAP: OnceLock> = OnceLock::new(); @@ -31,6 +36,7 @@ pub fn init_data(config: &AssetsConfig) -> Result<(), DataLoadError> { tracing::warn!("failed to load USM keys, in-game cutscenes will not work! Reason: {err}"); USM_KEY_MAP.set(HashMap::new()).unwrap(); } + gacha::load_gacha_config(&config.gacha_config_path)?; Ok(()) } diff --git a/nap_gameserver/src/config.rs b/nap_gameserver/src/config.rs index b2555f8..7a5104a 100644 --- a/nap_gameserver/src/config.rs +++ b/nap_gameserver/src/config.rs @@ -21,6 +21,7 @@ impl Default for NapGSConfig { filecfg_path: String::from("assets/FileCfg"), event_config_path: String::from("assets/EventConfig"), usm_keys_path: String::from("assets/VideoUSMEncKeys.json"), + gacha_config_path: String::from("assets/GachaConfig"), }, } } diff --git a/nap_gameserver/src/logic/gacha/gacha_model.rs b/nap_gameserver/src/logic/gacha/gacha_model.rs new file mode 100644 index 0000000..0798875 --- /dev/null +++ b/nap_gameserver/src/logic/gacha/gacha_model.rs @@ -0,0 +1,18 @@ +use proto::GachaModelBin; + +#[derive(Default)] +pub struct GachaModel { + pub gacha_bin: GachaModelBin, +} + +impl GachaModel { + pub fn from_bin(gacha_bin: GachaModelBin) -> Self { + Self { + gacha_bin + } + } + + pub fn to_bin(&self) -> GachaModelBin { + self.gacha_bin.clone() + } +} \ No newline at end of file diff --git a/nap_gameserver/src/logic/gacha/mod.rs b/nap_gameserver/src/logic/gacha/mod.rs new file mode 100644 index 0000000..a90cce2 --- /dev/null +++ b/nap_gameserver/src/logic/gacha/mod.rs @@ -0,0 +1,4 @@ +mod gacha_model; +mod record; + +pub use gacha_model::GachaModel; \ No newline at end of file diff --git a/nap_gameserver/src/logic/gacha/record.rs b/nap_gameserver/src/logic/gacha/record.rs new file mode 100644 index 0000000..e69de29 diff --git a/nap_gameserver/src/logic/mod.rs b/nap_gameserver/src/logic/mod.rs index fdb810e..79390cb 100644 --- a/nap_gameserver/src/logic/mod.rs +++ b/nap_gameserver/src/logic/mod.rs @@ -1,5 +1,6 @@ pub mod battle; mod enums; +pub mod gacha; pub mod game; pub mod item; pub mod math; diff --git a/nap_gameserver/src/logic/player/player.rs b/nap_gameserver/src/logic/player/player.rs index 583d822..294444e 100644 --- a/nap_gameserver/src/logic/player/player.rs +++ b/nap_gameserver/src/logic/player/player.rs @@ -1,6 +1,7 @@ use data::tables::{self, AvatarBaseID}; use proto::{ItemStatic, PlayerDataBin, Retcode}; +use super::gacha::GachaModel; use super::game::{FrontendGame, FrontendGameError, GameInstance, LogicError}; use super::item::{ItemModel, ItemUID}; use super::main_city_model::MainCityModel; @@ -16,6 +17,7 @@ pub struct Player { pub role_model: RoleModel, pub item_model: ItemModel, pub main_city_model: MainCityModel, + pub gacha_model: GachaModel, } impl Player { @@ -28,6 +30,7 @@ impl Player { role_model: Some(self.role_model.to_bin()), item_model: Some(self.item_model.to_bin()), main_city_model: Some(self.main_city_model.to_bin()), + gacha_model: Some(self.gacha_model.to_bin()), } } @@ -44,6 +47,7 @@ impl Player { .main_city_model .map(MainCityModel::from_bin) .unwrap_or_default(), + gacha_model: bin.gacha_model.map(GachaModel::from_bin).unwrap_or_default(), ..Default::default() } } diff --git a/nap_proto/out/bin.rs b/nap_proto/out/bin.rs index 29587ca..7f93d83 100644 --- a/nap_proto/out/bin.rs +++ b/nap_proto/out/bin.rs @@ -119,6 +119,77 @@ pub struct MainCityModelBin { } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] +pub struct GachaProgressBin { + /// The pity (counting how many pulls) of this pull (in previous record) or the next pull (in status). + #[prost(uint32, tag = "1")] + pub pity: u32, + /// The failure times of this category. + #[prost(map = "string, uint32", tag = "2")] + pub categories_progress_map: ::std::collections::HashMap< + ::prost::alloc::string::String, + u32, + >, + /// The selected priority for a Chooseable category. + #[prost(map = "string, string", tag = "3")] + pub category_chosen_guarantee_map: ::std::collections::HashMap< + ::prost::alloc::string::String, + ::prost::alloc::string::String, + >, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GachaExtraItemBin { + #[prost(uint32, tag = "1")] + pub extra_item_id: u32, + #[prost(uint32, tag = "2")] + pub extra_item_count: u32, + /// How many objects of the main item obtained in gacha is present in the player's bag (before gacha). + /// This is used for something like converting when there're extra characters. + #[prost(uint32, tag = "3")] + pub currently_gained: u32, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GachaRecordBin { + #[prost(int64, tag = "1")] + pub pull_timestamp: i64, + #[prost(uint32, tag = "2")] + pub obtained_item_id: u32, + #[prost(uint32, tag = "3")] + pub gacha_id: u32, + /// The progress BEFORE this gacha is performed. uint32 is rarity. + #[prost(map = "uint32, message", tag = "4")] + pub progress_map: ::std::collections::HashMap, + #[prost(message, optional, tag = "5")] + pub extra_item_bin: ::core::option::Option, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GachaStatusBin { + #[prost(map = "uint32, message", tag = "1")] + pub rarity_status_map: ::std::collections::HashMap, + #[prost(map = "string, uint32", tag = "2")] + pub discount_usage_map: ::std::collections::HashMap< + ::prost::alloc::string::String, + u32, + >, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GachaModelBin { + /// Gacha Status query. string is sharing_guarantee_info_category. + #[prost(map = "string, message", tag = "1")] + pub gacha_status_map: ::std::collections::HashMap< + ::prost::alloc::string::String, + GachaStatusBin, + >, + #[prost(message, repeated, tag = "2")] + pub gacha_records: ::prost::alloc::vec::Vec, + #[prost(uint32, tag = "3")] + pub random_number: u32, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] pub struct PlayerDataBin { #[prost(message, optional, tag = "1")] pub basic_data_model: ::core::option::Option, @@ -130,4 +201,6 @@ pub struct PlayerDataBin { pub item_model: ::core::option::Option, #[prost(message, optional, tag = "5")] pub main_city_model: ::core::option::Option, + #[prost(message, optional, tag = "6")] + pub gacha_model: ::core::option::Option, }