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::{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
44/// One roll with the BAL-032 first-copy pity applied, advancing the counter.
45///
46/// The pity is a safety net over the ordinary weights, not a replacement for
47/// them: the rarity roll and the wishlist `×2` multiplier are untouched. Only
48/// when `first_copy_pity_pulls` consecutive rolls have produced no NEW template
49/// does the next roll get forced onto one the player does not own.
50///
51/// The counter resets on ANY first copy — including one that arrived by luck
52/// rather than by pity — so a lucky streak legitimately postpones the guarantee
53/// instead of banking toward a double reward.
54pub 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        // The pity is bounded by the same content gates as the ordinary roll:
70        // only rarities the CURRENT case level actually rolls (its authored
71        // `rarity_weights`) are eligible, so pity can never hand out a Rare
72        // ahead of the L5 unlock.
73        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        // Owning the whole catalog is a real state, not an error: fall through
96        // to the ordinary roll and let the counter keep climbing harmlessly.
97        if unowned.is_empty() {
98            None
99        } else {
100            unowned.sort();
101            // A2-BAL-003 §3.6: the pity pays the WISHLIST first.
102            //
103            // It used to pick uniformly among every unowned eligible pet, so
104            // the one guarantee a player is given for choosing a target ignored
105            // that choice — with a large catalog the odds of the pity landing
106            // on the wishlisted pet were no better than chance. The signed
107            // contract is that 30 pulls guarantee the first copy of the pet the
108            // player selected, which only holds if the wishlist is preferred
109            // here. Falling back to the full unowned pool keeps an empty or
110            // fully-owned wishlist working exactly as before.
111            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        // A valid rarity was rolled but has no gacha pets (a content gap — e.g.
163        // the case weights still include Rare while no Rare gacha pet exists).
164        // Roll DOWN to the nearest populated rarity (by `order`) at or below the
165        // requested one rather than failing the player's paid pull. An UNKNOWN
166        // rarity id still errors (genuine misconfig, not a content gap).
167        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
253/// Roll a rarity from the level's weights, but exclude rarities below `min_rarity_id`.
254/// The "order" field on PetRarity determines the ordering (higher = rarer).
255pub 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    // Test config rarity IDs:
343    // common   = a0000000-...-000000000001 (order 1, weight 256)
344    // uncommon = a0000000-...-000000000002 (order 2, weight 128)
345    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        // weights: common 256, uncommon 128 → total 384
372        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        // min = uncommon (order 2) → should never return common (order 1)
386        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        // Only uncommon (128) → all should be uncommon since it's the only rarity at or above
406        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    /// A VALID rarity whose gacha-pet pool is empty (a content gap, e.g. Rare
458    /// weight present but no Rare pets) must NOT fail the pull — it rolls down to
459    /// the nearest populated lower rarity. (Regression: the live pet gacha rolled
460    /// Rare ~9% with no Rare pets and every such pull `bail!`ed.)
461    #[test]
462    fn test_empty_rarity_pool_rolls_down_instead_of_erroring() {
463        let mut config = generate_game_config_for_tests();
464        // Drain the UNCOMMON gacha pool so requesting UNCOMMON has no pets.
465        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}