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::{RngExt, 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_pity(
55 character_state: &mut CharacterState,
56 config: &GameConfig,
57 rng: &mut StdRng,
58 wishlist: &[PetId],
59) -> anyhow::Result<PetId> {
60 let threshold = config.game_settings.pet_gacha.first_copy_pity_pulls;
61 let owned: HashSet<PetId> = character_state
62 .all_pets
63 .iter()
64 .map(|pet| pet.template_id)
65 .collect();
66
67 let pity_due = threshold > 0 && character_state.character.pet_gacha_pity_pulls >= threshold;
68 let forced = if pity_due {
69 let unlocked_rarities: HashSet<_> = config
74 .pet_cases_settings
75 .iter()
76 .find(|settings| settings.level == character_state.character.pet_case_level)
77 .map(|settings| {
78 settings
79 .rarity_weights
80 .iter()
81 .map(|row| row.rarity_id)
82 .collect()
83 })
84 .unwrap_or_default();
85 let mut unowned: Vec<PetId> = config
86 .pet_templates
87 .iter()
88 .filter(|pet| {
89 pet.is_gacha_pet
90 && !owned.contains(&pet.id)
91 && unlocked_rarities.contains(&pet.rarity_id)
92 })
93 .map(|pet| pet.id)
94 .collect();
95 if unowned.is_empty() {
98 None
99 } else {
100 unowned.sort();
101 let wishlisted: Vec<PetId> = unowned
112 .iter()
113 .copied()
114 .filter(|id| wishlist.contains(id))
115 .collect();
116 let pool = if wishlisted.is_empty() {
117 &unowned
118 } else {
119 &wishlisted
120 };
121 let index = RngExt::random_range(rng, 0..pool.len());
122 pool.get(index).copied()
123 }
124 } else {
125 None
126 };
127
128 let pet_id = match forced {
129 Some(id) => id,
130 None => open_pet_case_with_wishlist(character_state, config, rng, wishlist)?,
131 };
132
133 if owned.contains(&pet_id) {
134 character_state.character.pet_gacha_pity_pulls += 1;
135 } else {
136 character_state.character.pet_gacha_pity_pulls = 0;
137 }
138 Ok(pet_id)
139}
140
141pub fn open_pet_case_with_rarity_and_wishlist(
142 config: &GameConfig,
143 rarity_id: PetRarityId,
144 rng: &mut StdRng,
145 wishlist: &[PetId],
146) -> anyhow::Result<PetId> {
147 let wishlist: HashSet<_> = wishlist.iter().copied().collect();
148 let wishlist_multiplier = config
149 .game_settings
150 .pet_gacha
151 .wishlist_weight_multiplier
152 .get();
153
154 let mut pets_pool: Vec<PetTemplate> = config
155 .pet_templates
156 .iter()
157 .filter(|pet| pet.is_gacha_pet && pet.rarity_id == rarity_id)
158 .cloned()
159 .collect();
160
161 if pets_pool.is_empty() {
162 let requested_order = config
168 .pet_rarity(rarity_id)
169 .ok_or_else(|| anyhow::anyhow!("Unknown pet rarity_id={rarity_id}"))?
170 .order;
171 let fallback_rarity = config
172 .pet_templates
173 .iter()
174 .filter(|pet| pet.is_gacha_pet)
175 .filter_map(|pet| {
176 config
177 .pet_rarity(pet.rarity_id)
178 .map(|r| (r.order, pet.rarity_id))
179 })
180 .filter(|(order, _)| *order <= requested_order)
181 .max_by_key(|(order, _)| *order)
182 .map(|(_, rid)| rid);
183 let Some(fallback_rarity) = fallback_rarity else {
184 anyhow::bail!("No gacha pets at or below rarity_id={rarity_id}");
185 };
186 pets_pool = config
187 .pet_templates
188 .iter()
189 .filter(|pet| pet.is_gacha_pet && pet.rarity_id == fallback_rarity)
190 .cloned()
191 .collect();
192 }
193
194 let total_weight: f64 = pets_pool
195 .iter()
196 .map(|pet| {
197 if wishlist.contains(&pet.id) {
198 wishlist_multiplier
199 } else {
200 1.0
201 }
202 })
203 .sum();
204
205 if total_weight <= 0.0 {
206 anyhow::bail!("Failed to compute pet pool weights for rarity_id={rarity_id}");
207 }
208
209 let mut pick = rng.random_range(0.0..total_weight);
210
211 for pet in &pets_pool {
212 let weight = if wishlist.contains(&pet.id) {
213 wishlist_multiplier
214 } else {
215 1.0
216 };
217 if pick <= weight {
218 return Ok(pet.id);
219 }
220 pick -= weight;
221 }
222
223 anyhow::bail!("Failed to pick pet for rarity_id={rarity_id}")
224}
225
226pub fn get_pet_rarity_id(
227 rng: &mut StdRng,
228 level_settings: &PetCasesSettingsByLevel,
229) -> PetRarityId {
230 let total_weight: f64 = level_settings
231 .rarity_weights
232 .iter()
233 .map(|rarity_weight| rarity_weight.weight.get())
234 .sum();
235
236 if total_weight < 1e-10 {
237 panic!("Sum of weights is too low: {total_weight}");
238 }
239
240 let rnd_weight = rng.random_range(0.0..total_weight);
241
242 let mut cumulative_weight = 0.0;
243 for rarity_weight in &level_settings.rarity_weights {
244 cumulative_weight += rarity_weight.weight.get();
245 if rnd_weight < cumulative_weight {
246 return rarity_weight.rarity_id;
247 }
248 }
249
250 panic!("Failed to get pet rarity id for weight {rnd_weight}");
251}
252
253pub fn roll_rarity_with_minimum(
256 config: &GameConfig,
257 level_settings: &PetCasesSettingsByLevel,
258 min_rarity_id: PetRarityId,
259 rng: &mut StdRng,
260) -> anyhow::Result<PetRarityId> {
261 let min_order = config
262 .pet_rarity(min_rarity_id)
263 .map(|r| r.order)
264 .unwrap_or(0);
265
266 let filtered_weights: Vec<_> = level_settings
267 .rarity_weights
268 .iter()
269 .filter(|rw| {
270 config
271 .pet_rarity(rw.rarity_id)
272 .map(|r| r.order >= min_order)
273 .unwrap_or(false)
274 })
275 .collect();
276
277 if filtered_weights.is_empty() {
278 anyhow::bail!(
279 "No rarity weights found at or above min_rarity_id={min_rarity_id} for level={}",
280 level_settings.level
281 );
282 }
283
284 let total_weight: f64 = filtered_weights.iter().map(|rw| rw.weight.get()).sum();
285
286 if total_weight < 1e-10 {
287 anyhow::bail!("Sum of filtered rarity weights is too low: {total_weight}");
288 }
289
290 let rnd_weight = rng.random_range(0.0..total_weight);
291
292 let mut cumulative_weight = 0.0;
293 for rw in &filtered_weights {
294 cumulative_weight += rw.weight.get();
295 if rnd_weight < cumulative_weight {
296 return Ok(rw.rarity_id);
297 }
298 }
299
300 Ok(filtered_weights.last().unwrap().rarity_id)
301}
302
303pub fn get_pet_rarity_id_by_weights(
304 rng: &mut StdRng,
305 rarity_weights: &[configs::pets::PetCaseRarityWeight],
306) -> anyhow::Result<PetRarityId> {
307 if rarity_weights.is_empty() {
308 anyhow::bail!("Rarity weights are empty");
309 }
310
311 let total_weight: f64 = rarity_weights
312 .iter()
313 .map(|rarity_weight| rarity_weight.weight.get())
314 .sum();
315
316 if total_weight < 1e-10 {
317 anyhow::bail!("Sum of rarity weights is too low: {total_weight}");
318 }
319
320 let rnd_weight = rng.random_range(0.0..total_weight);
321
322 let mut cumulative_weight = 0.0;
323 for rarity_weight in rarity_weights {
324 cumulative_weight += rarity_weight.weight.get();
325 if rnd_weight < cumulative_weight {
326 return Ok(rarity_weight.rarity_id);
327 }
328 }
329
330 anyhow::bail!("Failed to pick rarity by weight")
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336 use configs::pets::PetCaseRarityWeight;
337 use configs::tests_game_config::generate_game_config_for_tests;
338 use rand::SeedableRng;
339 use std::collections::HashMap;
340 use uuid::uuid;
341
342 const COMMON: PetRarityId = uuid!("a0000000-0000-0000-0000-000000000001");
346 const UNCOMMON: PetRarityId = uuid!("a0000000-0000-0000-0000-000000000002");
347
348 fn make_weights(pairs: &[(PetRarityId, f64)]) -> Vec<PetCaseRarityWeight> {
349 pairs
350 .iter()
351 .map(|(id, w)| PetCaseRarityWeight {
352 rarity_id: *id,
353 weight: configs::validated_types::PositiveF64::new(*w),
354 })
355 .collect()
356 }
357
358 #[test]
359 fn test_get_pet_rarity_id_respects_weights() {
360 let config = generate_game_config_for_tests();
361 let level_settings = config.pet_case_settings_by_level(1).unwrap();
362 let mut rng = StdRng::seed_from_u64(42);
363
364 let mut counts: HashMap<PetRarityId, usize> = HashMap::new();
365 let n = 10_000;
366 for _ in 0..n {
367 let id = get_pet_rarity_id(&mut rng, level_settings);
368 *counts.entry(id).or_default() += 1;
369 }
370
371 let common_ratio = *counts.get(&COMMON).unwrap_or(&0) as f64 / n as f64;
373 let uncommon_ratio = *counts.get(&UNCOMMON).unwrap_or(&0) as f64 / n as f64;
374
375 approx::assert_abs_diff_eq!(common_ratio, 256.0 / 384.0, epsilon = 0.02);
376 approx::assert_abs_diff_eq!(uncommon_ratio, 128.0 / 384.0, epsilon = 0.02);
377 }
378
379 #[test]
380 fn test_roll_rarity_with_minimum_filters_lower_rarities() {
381 let config = generate_game_config_for_tests();
382 let level_settings = config.pet_case_settings_by_level(1).unwrap();
383 let mut rng = StdRng::seed_from_u64(99);
384
385 for _ in 0..1_000 {
387 let id = roll_rarity_with_minimum(&config, level_settings, UNCOMMON, &mut rng).unwrap();
388 assert_ne!(id, COMMON, "Should never roll below minimum rarity");
389 }
390 }
391
392 #[test]
393 fn test_roll_rarity_with_minimum_respects_weights() {
394 let config = generate_game_config_for_tests();
395 let level_settings = config.pet_case_settings_by_level(1).unwrap();
396 let mut rng = StdRng::seed_from_u64(77);
397
398 let mut counts: HashMap<PetRarityId, usize> = HashMap::new();
399 let n = 10_000;
400 for _ in 0..n {
401 let id = roll_rarity_with_minimum(&config, level_settings, UNCOMMON, &mut rng).unwrap();
402 *counts.entry(id).or_default() += 1;
403 }
404
405 assert!(!counts.contains_key(&COMMON));
407 let uncommon_ratio = *counts.get(&UNCOMMON).unwrap_or(&0) as f64 / n as f64;
408 approx::assert_abs_diff_eq!(uncommon_ratio, 1.0, epsilon = 0.001);
409 }
410
411 #[test]
412 fn test_get_pet_rarity_id_by_weights_distribution() {
413 let weights = make_weights(&[(COMMON, 3.0), (UNCOMMON, 1.0)]);
414 let mut rng = StdRng::seed_from_u64(42);
415
416 let mut counts: HashMap<PetRarityId, usize> = HashMap::new();
417 let n = 10_000;
418 for _ in 0..n {
419 let id = get_pet_rarity_id_by_weights(&mut rng, &weights).unwrap();
420 *counts.entry(id).or_default() += 1;
421 }
422
423 let common_ratio = *counts.get(&COMMON).unwrap_or(&0) as f64 / n as f64;
424 approx::assert_abs_diff_eq!(common_ratio, 0.75, epsilon = 0.02);
425 }
426
427 #[test]
428 fn test_get_pet_rarity_id_by_weights_empty_errors() {
429 let mut rng = StdRng::seed_from_u64(1);
430 assert!(get_pet_rarity_id_by_weights(&mut rng, &[]).is_err());
431 }
432
433 #[test]
434 fn test_open_pet_case_with_rarity_and_wishlist_returns_correct_rarity() {
435 let config = generate_game_config_for_tests();
436 let mut rng = StdRng::seed_from_u64(42);
437
438 for _ in 0..100 {
439 let id =
440 open_pet_case_with_rarity_and_wishlist(&config, COMMON, &mut rng, &[]).unwrap();
441 let template = config.pet_template(id).unwrap();
442 assert_eq!(template.rarity_id, COMMON);
443 }
444 }
445
446 #[test]
447 fn test_open_pet_case_with_rarity_and_wishlist_invalid_rarity_errors() {
448 let config = generate_game_config_for_tests();
449 let mut rng = StdRng::seed_from_u64(1);
450 let fake_rarity = uuid!("00000000-0000-0000-0000-000000000001");
451
452 assert!(
453 open_pet_case_with_rarity_and_wishlist(&config, fake_rarity, &mut rng, &[]).is_err()
454 );
455 }
456
457 #[test]
462 fn test_empty_rarity_pool_rolls_down_instead_of_erroring() {
463 let mut config = generate_game_config_for_tests();
464 config
466 .pet_templates
467 .retain(|p| !(p.is_gacha_pet && p.rarity_id == UNCOMMON));
468 let mut rng = StdRng::seed_from_u64(7);
469 for _ in 0..100 {
470 let id = open_pet_case_with_rarity_and_wishlist(&config, UNCOMMON, &mut rng, &[])
471 .expect("empty pool must roll down, not error");
472 let tpl = config.pet_template(id).unwrap();
473 assert_eq!(
474 tpl.rarity_id, COMMON,
475 "empty UNCOMMON pool must roll down to COMMON"
476 );
477 }
478 }
479}