overlord_event_system/gacha/
pet_case.rs1use configs::game_config::GameConfig;
2use configs::pets::PetCasesSettingsByLevel;
3use essences::character_state::CharacterState;
4use essences::pets::{PetId, PetRarityId, PetTemplate};
5
6use rand::{Rng, rngs::StdRng};
7use std::collections::HashSet;
8
9use crate::game_config_helpers::GameConfigLookup;
10
11pub fn open_pet_case(
12 character_state: &CharacterState,
13 config: &GameConfig,
14 rng: &mut StdRng,
15) -> anyhow::Result<PetId> {
16 open_pet_case_with_wishlist(character_state, config, rng, &[])
17}
18
19pub fn open_pet_case_with_wishlist(
20 character_state: &CharacterState,
21 config: &GameConfig,
22 rng: &mut StdRng,
23 wishlist: &[PetId],
24) -> anyhow::Result<PetId> {
25 let pet_cases_settings = &config.pet_cases_settings;
26
27 let Some(level_settings) = pet_cases_settings
28 .iter()
29 .find(|settings| settings.level == character_state.character.pet_case_level)
30 else {
31 anyhow::bail!(
32 "Failed to get case settings for pet_case_level={}",
33 character_state.character.pet_case_level
34 );
35 };
36
37 let rarity_id = get_pet_rarity_id(rng, level_settings);
38
39 let pet_id = open_pet_case_with_rarity_and_wishlist(config, rarity_id, rng, wishlist)?;
40
41 Ok(pet_id)
42}
43
44pub fn open_pet_case_with_rarity_and_wishlist(
45 config: &GameConfig,
46 rarity_id: PetRarityId,
47 rng: &mut StdRng,
48 wishlist: &[PetId],
49) -> anyhow::Result<PetId> {
50 let wishlist: HashSet<_> = wishlist.iter().copied().collect();
51 let wishlist_multiplier = config
52 .game_settings
53 .pet_gacha
54 .wishlist_weight_multiplier
55 .get();
56
57 let pets_pool: Vec<PetTemplate> = config
58 .pet_templates
59 .iter()
60 .filter(|pet| pet.is_gacha_pet && pet.rarity_id == rarity_id)
61 .cloned()
62 .collect();
63
64 if pets_pool.is_empty() {
65 anyhow::bail!("No gacha pets found for rarity_id={rarity_id}");
66 }
67
68 let total_weight: f64 = pets_pool
69 .iter()
70 .map(|pet| {
71 if wishlist.contains(&pet.id) {
72 wishlist_multiplier
73 } else {
74 1.0
75 }
76 })
77 .sum();
78
79 if total_weight <= 0.0 {
80 anyhow::bail!("Failed to compute pet pool weights for rarity_id={rarity_id}");
81 }
82
83 let mut pick = rng.random_range(0.0..total_weight);
84
85 for pet in &pets_pool {
86 let weight = if wishlist.contains(&pet.id) {
87 wishlist_multiplier
88 } else {
89 1.0
90 };
91 if pick <= weight {
92 return Ok(pet.id);
93 }
94 pick -= weight;
95 }
96
97 anyhow::bail!("Failed to pick pet for rarity_id={rarity_id}")
98}
99
100pub fn get_pet_rarity_id(
101 rng: &mut StdRng,
102 level_settings: &PetCasesSettingsByLevel,
103) -> PetRarityId {
104 let total_weight: f64 = level_settings
105 .rarity_weights
106 .iter()
107 .map(|rarity_weight| rarity_weight.weight.get())
108 .sum();
109
110 if total_weight < 1e-10 {
111 panic!("Sum of weights is too low: {total_weight}");
112 }
113
114 let rnd_weight = rng.random_range(0.0..total_weight);
115
116 let mut cumulative_weight = 0.0;
117 for rarity_weight in &level_settings.rarity_weights {
118 cumulative_weight += rarity_weight.weight.get();
119 if rnd_weight < cumulative_weight {
120 return rarity_weight.rarity_id;
121 }
122 }
123
124 panic!("Failed to get pet rarity id for weight {rnd_weight}");
125}
126
127pub fn roll_rarity_with_minimum(
130 config: &GameConfig,
131 level_settings: &PetCasesSettingsByLevel,
132 min_rarity_id: PetRarityId,
133 rng: &mut StdRng,
134) -> anyhow::Result<PetRarityId> {
135 let min_order = config
136 .pet_rarity(min_rarity_id)
137 .map(|r| r.order)
138 .unwrap_or(0);
139
140 let filtered_weights: Vec<_> = level_settings
141 .rarity_weights
142 .iter()
143 .filter(|rw| {
144 config
145 .pet_rarity(rw.rarity_id)
146 .map(|r| r.order >= min_order)
147 .unwrap_or(false)
148 })
149 .collect();
150
151 if filtered_weights.is_empty() {
152 anyhow::bail!(
153 "No rarity weights found at or above min_rarity_id={min_rarity_id} for level={}",
154 level_settings.level
155 );
156 }
157
158 let total_weight: f64 = filtered_weights.iter().map(|rw| rw.weight.get()).sum();
159
160 if total_weight < 1e-10 {
161 anyhow::bail!("Sum of filtered rarity weights is too low: {total_weight}");
162 }
163
164 let rnd_weight = rng.random_range(0.0..total_weight);
165
166 let mut cumulative_weight = 0.0;
167 for rw in &filtered_weights {
168 cumulative_weight += rw.weight.get();
169 if rnd_weight < cumulative_weight {
170 return Ok(rw.rarity_id);
171 }
172 }
173
174 Ok(filtered_weights.last().unwrap().rarity_id)
175}
176
177pub fn get_pet_rarity_id_by_weights(
178 rng: &mut StdRng,
179 rarity_weights: &[configs::pets::PetCaseRarityWeight],
180) -> anyhow::Result<PetRarityId> {
181 if rarity_weights.is_empty() {
182 anyhow::bail!("Rarity weights are empty");
183 }
184
185 let total_weight: f64 = rarity_weights
186 .iter()
187 .map(|rarity_weight| rarity_weight.weight.get())
188 .sum();
189
190 if total_weight < 1e-10 {
191 anyhow::bail!("Sum of rarity weights is too low: {total_weight}");
192 }
193
194 let rnd_weight = rng.random_range(0.0..total_weight);
195
196 let mut cumulative_weight = 0.0;
197 for rarity_weight in rarity_weights {
198 cumulative_weight += rarity_weight.weight.get();
199 if rnd_weight < cumulative_weight {
200 return Ok(rarity_weight.rarity_id);
201 }
202 }
203
204 anyhow::bail!("Failed to pick rarity by weight")
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210 use configs::pets::PetCaseRarityWeight;
211 use configs::tests_game_config::generate_game_config_for_tests;
212 use rand::SeedableRng;
213 use std::collections::HashMap;
214 use uuid::uuid;
215
216 const COMMON: PetRarityId = uuid!("a0000000-0000-0000-0000-000000000001");
220 const UNCOMMON: PetRarityId = uuid!("a0000000-0000-0000-0000-000000000002");
221
222 fn make_weights(pairs: &[(PetRarityId, f64)]) -> Vec<PetCaseRarityWeight> {
223 pairs
224 .iter()
225 .map(|(id, w)| PetCaseRarityWeight {
226 rarity_id: *id,
227 weight: configs::validated_types::PositiveF64::new(*w),
228 })
229 .collect()
230 }
231
232 #[test]
233 fn test_get_pet_rarity_id_respects_weights() {
234 let config = generate_game_config_for_tests();
235 let level_settings = config.pet_case_settings_by_level(1).unwrap();
236 let mut rng = StdRng::seed_from_u64(42);
237
238 let mut counts: HashMap<PetRarityId, usize> = HashMap::new();
239 let n = 10_000;
240 for _ in 0..n {
241 let id = get_pet_rarity_id(&mut rng, level_settings);
242 *counts.entry(id).or_default() += 1;
243 }
244
245 let common_ratio = *counts.get(&COMMON).unwrap_or(&0) as f64 / n as f64;
247 let uncommon_ratio = *counts.get(&UNCOMMON).unwrap_or(&0) as f64 / n as f64;
248
249 approx::assert_abs_diff_eq!(common_ratio, 256.0 / 384.0, epsilon = 0.02);
250 approx::assert_abs_diff_eq!(uncommon_ratio, 128.0 / 384.0, epsilon = 0.02);
251 }
252
253 #[test]
254 fn test_roll_rarity_with_minimum_filters_lower_rarities() {
255 let config = generate_game_config_for_tests();
256 let level_settings = config.pet_case_settings_by_level(1).unwrap();
257 let mut rng = StdRng::seed_from_u64(99);
258
259 for _ in 0..1_000 {
261 let id = roll_rarity_with_minimum(&config, level_settings, UNCOMMON, &mut rng).unwrap();
262 assert_ne!(id, COMMON, "Should never roll below minimum rarity");
263 }
264 }
265
266 #[test]
267 fn test_roll_rarity_with_minimum_respects_weights() {
268 let config = generate_game_config_for_tests();
269 let level_settings = config.pet_case_settings_by_level(1).unwrap();
270 let mut rng = StdRng::seed_from_u64(77);
271
272 let mut counts: HashMap<PetRarityId, usize> = HashMap::new();
273 let n = 10_000;
274 for _ in 0..n {
275 let id = roll_rarity_with_minimum(&config, level_settings, UNCOMMON, &mut rng).unwrap();
276 *counts.entry(id).or_default() += 1;
277 }
278
279 assert!(!counts.contains_key(&COMMON));
281 let uncommon_ratio = *counts.get(&UNCOMMON).unwrap_or(&0) as f64 / n as f64;
282 approx::assert_abs_diff_eq!(uncommon_ratio, 1.0, epsilon = 0.001);
283 }
284
285 #[test]
286 fn test_get_pet_rarity_id_by_weights_distribution() {
287 let weights = make_weights(&[(COMMON, 3.0), (UNCOMMON, 1.0)]);
288 let mut rng = StdRng::seed_from_u64(42);
289
290 let mut counts: HashMap<PetRarityId, usize> = HashMap::new();
291 let n = 10_000;
292 for _ in 0..n {
293 let id = get_pet_rarity_id_by_weights(&mut rng, &weights).unwrap();
294 *counts.entry(id).or_default() += 1;
295 }
296
297 let common_ratio = *counts.get(&COMMON).unwrap_or(&0) as f64 / n as f64;
298 approx::assert_abs_diff_eq!(common_ratio, 0.75, epsilon = 0.02);
299 }
300
301 #[test]
302 fn test_get_pet_rarity_id_by_weights_empty_errors() {
303 let mut rng = StdRng::seed_from_u64(1);
304 assert!(get_pet_rarity_id_by_weights(&mut rng, &[]).is_err());
305 }
306
307 #[test]
308 fn test_open_pet_case_with_rarity_and_wishlist_returns_correct_rarity() {
309 let config = generate_game_config_for_tests();
310 let mut rng = StdRng::seed_from_u64(42);
311
312 for _ in 0..100 {
313 let id =
314 open_pet_case_with_rarity_and_wishlist(&config, COMMON, &mut rng, &[]).unwrap();
315 let template = config.pet_template(id).unwrap();
316 assert_eq!(template.rarity_id, COMMON);
317 }
318 }
319
320 #[test]
321 fn test_open_pet_case_with_rarity_and_wishlist_invalid_rarity_errors() {
322 let config = generate_game_config_for_tests();
323 let mut rng = StdRng::seed_from_u64(1);
324 let fake_rarity = uuid!("00000000-0000-0000-0000-000000000001");
325
326 assert!(
327 open_pet_case_with_rarity_and_wishlist(&config, fake_rarity, &mut rng, &[]).is_err()
328 );
329 }
330}