overlord_event_system/gacha/
pet_case.rs

1use 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        // A valid rarity was rolled but has no gacha pets (a content gap — e.g.
66        // the case weights still include Rare while no Rare gacha pet exists).
67        // Roll DOWN to the nearest populated rarity (by `order`) at or below the
68        // requested one rather than failing the player's paid pull. An UNKNOWN
69        // rarity id still errors (genuine misconfig, not a content gap).
70        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
156/// Roll a rarity from the level's weights, but exclude rarities below `min_rarity_id`.
157/// The "order" field on PetRarity determines the ordering (higher = rarer).
158pub 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    // Test config rarity IDs:
246    // common   = a0000000-...-000000000001 (order 1, weight 256)
247    // uncommon = a0000000-...-000000000002 (order 2, weight 128)
248    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        // weights: common 256, uncommon 128 → total 384
275        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        // min = uncommon (order 2) → should never return common (order 1)
289        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        // Only uncommon (128) → all should be uncommon since it's the only rarity at or above
309        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    /// A VALID rarity whose gacha-pet pool is empty (a content gap, e.g. Rare
361    /// weight present but no Rare pets) must NOT fail the pull — it rolls down to
362    /// the nearest populated lower rarity. (Regression: the live pet gacha rolled
363    /// Rare ~9% with no Rare pets and every such pull `bail!`ed.)
364    #[test]
365    fn test_empty_rarity_pool_rolls_down_instead_of_erroring() {
366        let mut config = generate_game_config_for_tests();
367        // Drain the UNCOMMON gacha pool so requesting UNCOMMON has no pets.
368        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}