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 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
127/// Roll a rarity from the level's weights, but exclude rarities below `min_rarity_id`.
128/// The "order" field on PetRarity determines the ordering (higher = rarer).
129pub 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    // Test config rarity IDs:
217    // common   = a0000000-...-000000000001 (order 1, weight 256)
218    // uncommon = a0000000-...-000000000002 (order 2, weight 128)
219    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        // weights: common 256, uncommon 128 → total 384
246        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        // min = uncommon (order 2) → should never return common (order 1)
260        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        // Only uncommon (128) → all should be uncommon since it's the only rarity at or above
280        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}