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 mut 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 let requested_order = config
71 .pet_rarity(rarity_id)
72 .ok_or_else(|| anyhow::anyhow!("Unknown pet rarity_id={rarity_id}"))?
73 .order;
74 let fallback_rarity = config
75 .pet_templates
76 .iter()
77 .filter(|pet| pet.is_gacha_pet)
78 .filter_map(|pet| {
79 config
80 .pet_rarity(pet.rarity_id)
81 .map(|r| (r.order, pet.rarity_id))
82 })
83 .filter(|(order, _)| *order <= requested_order)
84 .max_by_key(|(order, _)| *order)
85 .map(|(_, rid)| rid);
86 let Some(fallback_rarity) = fallback_rarity else {
87 anyhow::bail!("No gacha pets at or below rarity_id={rarity_id}");
88 };
89 pets_pool = config
90 .pet_templates
91 .iter()
92 .filter(|pet| pet.is_gacha_pet && pet.rarity_id == fallback_rarity)
93 .cloned()
94 .collect();
95 }
96
97 let total_weight: f64 = pets_pool
98 .iter()
99 .map(|pet| {
100 if wishlist.contains(&pet.id) {
101 wishlist_multiplier
102 } else {
103 1.0
104 }
105 })
106 .sum();
107
108 if total_weight <= 0.0 {
109 anyhow::bail!("Failed to compute pet pool weights for rarity_id={rarity_id}");
110 }
111
112 let mut pick = rng.random_range(0.0..total_weight);
113
114 for pet in &pets_pool {
115 let weight = if wishlist.contains(&pet.id) {
116 wishlist_multiplier
117 } else {
118 1.0
119 };
120 if pick <= weight {
121 return Ok(pet.id);
122 }
123 pick -= weight;
124 }
125
126 anyhow::bail!("Failed to pick pet for rarity_id={rarity_id}")
127}
128
129pub fn get_pet_rarity_id(
130 rng: &mut StdRng,
131 level_settings: &PetCasesSettingsByLevel,
132) -> PetRarityId {
133 let total_weight: f64 = level_settings
134 .rarity_weights
135 .iter()
136 .map(|rarity_weight| rarity_weight.weight.get())
137 .sum();
138
139 if total_weight < 1e-10 {
140 panic!("Sum of weights is too low: {total_weight}");
141 }
142
143 let rnd_weight = rng.random_range(0.0..total_weight);
144
145 let mut cumulative_weight = 0.0;
146 for rarity_weight in &level_settings.rarity_weights {
147 cumulative_weight += rarity_weight.weight.get();
148 if rnd_weight < cumulative_weight {
149 return rarity_weight.rarity_id;
150 }
151 }
152
153 panic!("Failed to get pet rarity id for weight {rnd_weight}");
154}
155
156pub fn roll_rarity_with_minimum(
159 config: &GameConfig,
160 level_settings: &PetCasesSettingsByLevel,
161 min_rarity_id: PetRarityId,
162 rng: &mut StdRng,
163) -> anyhow::Result<PetRarityId> {
164 let min_order = config
165 .pet_rarity(min_rarity_id)
166 .map(|r| r.order)
167 .unwrap_or(0);
168
169 let filtered_weights: Vec<_> = level_settings
170 .rarity_weights
171 .iter()
172 .filter(|rw| {
173 config
174 .pet_rarity(rw.rarity_id)
175 .map(|r| r.order >= min_order)
176 .unwrap_or(false)
177 })
178 .collect();
179
180 if filtered_weights.is_empty() {
181 anyhow::bail!(
182 "No rarity weights found at or above min_rarity_id={min_rarity_id} for level={}",
183 level_settings.level
184 );
185 }
186
187 let total_weight: f64 = filtered_weights.iter().map(|rw| rw.weight.get()).sum();
188
189 if total_weight < 1e-10 {
190 anyhow::bail!("Sum of filtered rarity weights is too low: {total_weight}");
191 }
192
193 let rnd_weight = rng.random_range(0.0..total_weight);
194
195 let mut cumulative_weight = 0.0;
196 for rw in &filtered_weights {
197 cumulative_weight += rw.weight.get();
198 if rnd_weight < cumulative_weight {
199 return Ok(rw.rarity_id);
200 }
201 }
202
203 Ok(filtered_weights.last().unwrap().rarity_id)
204}
205
206pub fn get_pet_rarity_id_by_weights(
207 rng: &mut StdRng,
208 rarity_weights: &[configs::pets::PetCaseRarityWeight],
209) -> anyhow::Result<PetRarityId> {
210 if rarity_weights.is_empty() {
211 anyhow::bail!("Rarity weights are empty");
212 }
213
214 let total_weight: f64 = rarity_weights
215 .iter()
216 .map(|rarity_weight| rarity_weight.weight.get())
217 .sum();
218
219 if total_weight < 1e-10 {
220 anyhow::bail!("Sum of rarity weights is too low: {total_weight}");
221 }
222
223 let rnd_weight = rng.random_range(0.0..total_weight);
224
225 let mut cumulative_weight = 0.0;
226 for rarity_weight in rarity_weights {
227 cumulative_weight += rarity_weight.weight.get();
228 if rnd_weight < cumulative_weight {
229 return Ok(rarity_weight.rarity_id);
230 }
231 }
232
233 anyhow::bail!("Failed to pick rarity by weight")
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239 use configs::pets::PetCaseRarityWeight;
240 use configs::tests_game_config::generate_game_config_for_tests;
241 use rand::SeedableRng;
242 use std::collections::HashMap;
243 use uuid::uuid;
244
245 const COMMON: PetRarityId = uuid!("a0000000-0000-0000-0000-000000000001");
249 const UNCOMMON: PetRarityId = uuid!("a0000000-0000-0000-0000-000000000002");
250
251 fn make_weights(pairs: &[(PetRarityId, f64)]) -> Vec<PetCaseRarityWeight> {
252 pairs
253 .iter()
254 .map(|(id, w)| PetCaseRarityWeight {
255 rarity_id: *id,
256 weight: configs::validated_types::PositiveF64::new(*w),
257 })
258 .collect()
259 }
260
261 #[test]
262 fn test_get_pet_rarity_id_respects_weights() {
263 let config = generate_game_config_for_tests();
264 let level_settings = config.pet_case_settings_by_level(1).unwrap();
265 let mut rng = StdRng::seed_from_u64(42);
266
267 let mut counts: HashMap<PetRarityId, usize> = HashMap::new();
268 let n = 10_000;
269 for _ in 0..n {
270 let id = get_pet_rarity_id(&mut rng, level_settings);
271 *counts.entry(id).or_default() += 1;
272 }
273
274 let common_ratio = *counts.get(&COMMON).unwrap_or(&0) as f64 / n as f64;
276 let uncommon_ratio = *counts.get(&UNCOMMON).unwrap_or(&0) as f64 / n as f64;
277
278 approx::assert_abs_diff_eq!(common_ratio, 256.0 / 384.0, epsilon = 0.02);
279 approx::assert_abs_diff_eq!(uncommon_ratio, 128.0 / 384.0, epsilon = 0.02);
280 }
281
282 #[test]
283 fn test_roll_rarity_with_minimum_filters_lower_rarities() {
284 let config = generate_game_config_for_tests();
285 let level_settings = config.pet_case_settings_by_level(1).unwrap();
286 let mut rng = StdRng::seed_from_u64(99);
287
288 for _ in 0..1_000 {
290 let id = roll_rarity_with_minimum(&config, level_settings, UNCOMMON, &mut rng).unwrap();
291 assert_ne!(id, COMMON, "Should never roll below minimum rarity");
292 }
293 }
294
295 #[test]
296 fn test_roll_rarity_with_minimum_respects_weights() {
297 let config = generate_game_config_for_tests();
298 let level_settings = config.pet_case_settings_by_level(1).unwrap();
299 let mut rng = StdRng::seed_from_u64(77);
300
301 let mut counts: HashMap<PetRarityId, usize> = HashMap::new();
302 let n = 10_000;
303 for _ in 0..n {
304 let id = roll_rarity_with_minimum(&config, level_settings, UNCOMMON, &mut rng).unwrap();
305 *counts.entry(id).or_default() += 1;
306 }
307
308 assert!(!counts.contains_key(&COMMON));
310 let uncommon_ratio = *counts.get(&UNCOMMON).unwrap_or(&0) as f64 / n as f64;
311 approx::assert_abs_diff_eq!(uncommon_ratio, 1.0, epsilon = 0.001);
312 }
313
314 #[test]
315 fn test_get_pet_rarity_id_by_weights_distribution() {
316 let weights = make_weights(&[(COMMON, 3.0), (UNCOMMON, 1.0)]);
317 let mut rng = StdRng::seed_from_u64(42);
318
319 let mut counts: HashMap<PetRarityId, usize> = HashMap::new();
320 let n = 10_000;
321 for _ in 0..n {
322 let id = get_pet_rarity_id_by_weights(&mut rng, &weights).unwrap();
323 *counts.entry(id).or_default() += 1;
324 }
325
326 let common_ratio = *counts.get(&COMMON).unwrap_or(&0) as f64 / n as f64;
327 approx::assert_abs_diff_eq!(common_ratio, 0.75, epsilon = 0.02);
328 }
329
330 #[test]
331 fn test_get_pet_rarity_id_by_weights_empty_errors() {
332 let mut rng = StdRng::seed_from_u64(1);
333 assert!(get_pet_rarity_id_by_weights(&mut rng, &[]).is_err());
334 }
335
336 #[test]
337 fn test_open_pet_case_with_rarity_and_wishlist_returns_correct_rarity() {
338 let config = generate_game_config_for_tests();
339 let mut rng = StdRng::seed_from_u64(42);
340
341 for _ in 0..100 {
342 let id =
343 open_pet_case_with_rarity_and_wishlist(&config, COMMON, &mut rng, &[]).unwrap();
344 let template = config.pet_template(id).unwrap();
345 assert_eq!(template.rarity_id, COMMON);
346 }
347 }
348
349 #[test]
350 fn test_open_pet_case_with_rarity_and_wishlist_invalid_rarity_errors() {
351 let config = generate_game_config_for_tests();
352 let mut rng = StdRng::seed_from_u64(1);
353 let fake_rarity = uuid!("00000000-0000-0000-0000-000000000001");
354
355 assert!(
356 open_pet_case_with_rarity_and_wishlist(&config, fake_rarity, &mut rng, &[]).is_err()
357 );
358 }
359
360 #[test]
365 fn test_empty_rarity_pool_rolls_down_instead_of_erroring() {
366 let mut config = generate_game_config_for_tests();
367 config
369 .pet_templates
370 .retain(|p| !(p.is_gacha_pet && p.rarity_id == UNCOMMON));
371 let mut rng = StdRng::seed_from_u64(7);
372 for _ in 0..100 {
373 let id = open_pet_case_with_rarity_and_wishlist(&config, UNCOMMON, &mut rng, &[])
374 .expect("empty pool must roll down, not error");
375 let tpl = config.pet_template(id).unwrap();
376 assert_eq!(
377 tpl.rarity_id, COMMON,
378 "empty UNCOMMON pool must roll down to COMMON"
379 );
380 }
381 }
382}