overlord_event_system/gacha/
ability_case.rs

1use configs::abilities::AbilityCasesSettingsByLevel;
2use configs::game_config::GameConfig;
3use essences::abilities::{AbilityId, AbilityRarityId, AbilityTemplate};
4use essences::character_state::CharacterState;
5
6use rand::{Rng, rngs::StdRng};
7use std::collections::HashSet;
8
9use crate::game_config_helpers::GameConfigLookup;
10
11pub fn open_ability_case(
12    character_state: &CharacterState,
13    config: &GameConfig,
14    rng: &mut StdRng,
15) -> anyhow::Result<AbilityId> {
16    open_ability_case_with_wishlist(character_state, config, rng, &[])
17}
18
19pub fn open_ability_case_with_wishlist(
20    character_state: &CharacterState,
21    config: &GameConfig,
22    rng: &mut StdRng,
23    wishlist: &[AbilityId],
24) -> anyhow::Result<AbilityId> {
25    let ability_cases_settings = &config.ability_cases_settings;
26
27    let Some(level_settings) = ability_cases_settings
28        .iter()
29        .find(|settings| settings.level == character_state.character.ability_case_level)
30    else {
31        anyhow::bail!(
32            "Failed to get case settings for ability_case_level={}",
33            character_state.character.ability_case_level
34        );
35    };
36
37    let rarity_id = get_ability_rarity_id(rng, level_settings);
38
39    for class in &config.classes {
40        if rarity_id == class.ability_rarity_id {
41            anyhow::bail!("Case ability can not be class rarity");
42        }
43    }
44
45    let ability_id = open_ability_case_with_rarity_and_wishlist(config, rarity_id, rng, wishlist)?;
46
47    Ok(ability_id)
48}
49
50pub fn open_ability_case_with_rarity_and_wishlist(
51    config: &GameConfig,
52    rarity_id: AbilityRarityId,
53    rng: &mut StdRng,
54    wishlist: &[AbilityId],
55) -> anyhow::Result<AbilityId> {
56    let wishlist: HashSet<_> = wishlist.iter().copied().collect();
57    let wishlist_multiplier = config
58        .game_settings
59        .ability_gacha
60        .wishlist_weight_multiplier
61        .get();
62
63    let abilities_pool: Vec<AbilityTemplate> = config
64        .abilities
65        .iter()
66        .filter(|ability| ability.is_gacha_ability && ability.rarity_id == rarity_id)
67        .cloned()
68        .collect();
69
70    if abilities_pool.is_empty() {
71        anyhow::bail!("No gacha abilities found for rarity_id={rarity_id}");
72    }
73
74    let total_weight: f64 = abilities_pool
75        .iter()
76        .map(|ability| {
77            if wishlist.contains(&ability.id) {
78                wishlist_multiplier
79            } else {
80                1.0
81            }
82        })
83        .sum();
84
85    if total_weight <= 0.0 {
86        anyhow::bail!("Failed to compute ability pool weights for rarity_id={rarity_id}");
87    }
88
89    let mut pick = rng.random_range(0.0..total_weight);
90
91    for ability in &abilities_pool {
92        let weight = if wishlist.contains(&ability.id) {
93            wishlist_multiplier
94        } else {
95            1.0
96        };
97        if pick <= weight {
98            return Ok(ability.id);
99        }
100        pick -= weight;
101    }
102
103    anyhow::bail!("Failed to pick ability for rarity_id={rarity_id}")
104}
105
106pub fn get_ability_rarity_id(
107    rng: &mut StdRng,
108    level_settings: &AbilityCasesSettingsByLevel,
109) -> AbilityRarityId {
110    let total_weight: f64 = level_settings
111        .rarity_weights
112        .iter()
113        .map(|rarity_weight| rarity_weight.weight.get())
114        .sum();
115
116    if total_weight < 1e-10 {
117        panic!("Sum of weights is too low: {total_weight}");
118    }
119
120    let rnd_weight = rng.random_range(0.0..total_weight);
121
122    let mut cumulative_weight = 0.0;
123    for rarity_weight in &level_settings.rarity_weights {
124        cumulative_weight += rarity_weight.weight.get();
125        if rnd_weight < cumulative_weight {
126            return rarity_weight.rarity_id;
127        }
128    }
129
130    panic!("Failed to get ability rarity id for weight {rnd_weight}");
131}
132
133/// Roll a rarity from the level's weights, but exclude rarities below `min_rarity_id`.
134/// The "order" field on AbilityRarity determines the ordering (higher = rarer).
135pub fn roll_rarity_with_minimum(
136    config: &GameConfig,
137    level_settings: &AbilityCasesSettingsByLevel,
138    min_rarity_id: AbilityRarityId,
139    rng: &mut StdRng,
140) -> anyhow::Result<AbilityRarityId> {
141    let min_order = config
142        .ability_rarity(min_rarity_id)
143        .map(|r| r.order)
144        .unwrap_or(0);
145
146    let filtered_weights: Vec<_> = level_settings
147        .rarity_weights
148        .iter()
149        .filter(|rw| {
150            config
151                .ability_rarity(rw.rarity_id)
152                .map(|r| r.order >= min_order)
153                .unwrap_or(false)
154        })
155        .collect();
156
157    if filtered_weights.is_empty() {
158        anyhow::bail!(
159            "No rarity weights found at or above min_rarity_id={min_rarity_id} for level={}",
160            level_settings.level
161        );
162    }
163
164    let total_weight: f64 = filtered_weights.iter().map(|rw| rw.weight.get()).sum();
165
166    if total_weight < 1e-10 {
167        anyhow::bail!("Sum of filtered rarity weights is too low: {total_weight}");
168    }
169
170    let rnd_weight = rng.random_range(0.0..total_weight);
171
172    let mut cumulative_weight = 0.0;
173    for rw in &filtered_weights {
174        cumulative_weight += rw.weight.get();
175        if rnd_weight < cumulative_weight {
176            return Ok(rw.rarity_id);
177        }
178    }
179
180    Ok(filtered_weights.last().unwrap().rarity_id)
181}
182
183pub fn maybe_get_ability_rarity_id_by_chance(
184    rng: &mut StdRng,
185    rarity_weights: &[configs::abilities::AbilityCaseRarityWeight],
186) -> Option<AbilityRarityId> {
187    if rarity_weights.is_empty() {
188        return None;
189    }
190
191    let total_weight: f64 = rarity_weights
192        .iter()
193        .map(|rarity_weight| rarity_weight.weight.get())
194        .sum();
195
196    if total_weight <= 0.0 {
197        return None;
198    }
199
200    let rnd_weight = rng.random_range(0.0..1.0);
201
202    let mut cumulative_weight = 0.0;
203    for rarity_weight in rarity_weights {
204        cumulative_weight += rarity_weight.weight.get();
205        if rnd_weight < cumulative_weight {
206            return Some(rarity_weight.rarity_id);
207        }
208    }
209
210    None
211}
212
213pub fn get_ability_rarity_id_by_weights(
214    rng: &mut StdRng,
215    rarity_weights: &[configs::abilities::AbilityCaseRarityWeight],
216) -> anyhow::Result<AbilityRarityId> {
217    if rarity_weights.is_empty() {
218        anyhow::bail!("Rarity weights are empty");
219    }
220
221    let total_weight: f64 = rarity_weights
222        .iter()
223        .map(|rarity_weight| rarity_weight.weight.get())
224        .sum();
225
226    if total_weight < 1e-10 {
227        anyhow::bail!("Sum of rarity weights is too low: {total_weight}");
228    }
229
230    let rnd_weight = rng.random_range(0.0..total_weight);
231
232    let mut cumulative_weight = 0.0;
233    for rarity_weight in rarity_weights {
234        cumulative_weight += rarity_weight.weight.get();
235        if rnd_weight < cumulative_weight {
236            return Ok(rarity_weight.rarity_id);
237        }
238    }
239
240    anyhow::bail!("Failed to pick rarity by weight")
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use configs::abilities::AbilityCaseRarityWeight;
247    use configs::tests_game_config::generate_game_config_for_tests;
248    use rand::SeedableRng;
249    use std::collections::HashMap;
250    use uuid::uuid;
251
252    // Test config rarity IDs:
253    // common  = 33a0af72  (order 1, weight 256)
254    // uncommon = 6f0f1099 (order 2, weight 128)
255    // rare    = 81b082cc  (order 3, weight 64)
256    const COMMON: AbilityRarityId = uuid!("33a0af72-2390-4df9-bd4f-f66b27ed7792");
257    const UNCOMMON: AbilityRarityId = uuid!("6f0f1099-8ab1-4378-aea5-ac6bcbe6ec3e");
258    const RARE: AbilityRarityId = uuid!("81b082cc-2736-4545-87d1-301f5de8951a");
259
260    fn make_weights(pairs: &[(AbilityRarityId, f64)]) -> Vec<AbilityCaseRarityWeight> {
261        pairs
262            .iter()
263            .map(|(id, w)| AbilityCaseRarityWeight {
264                rarity_id: *id,
265                weight: configs::validated_types::PositiveF64::new(*w),
266            })
267            .collect()
268    }
269
270    #[test]
271    fn test_get_ability_rarity_id_respects_weights() {
272        let config = generate_game_config_for_tests();
273        let level_settings = config.ability_case_settings_by_level(1).unwrap();
274        let mut rng = StdRng::seed_from_u64(42);
275
276        let mut counts: HashMap<AbilityRarityId, usize> = HashMap::new();
277        let n = 10_000;
278        for _ in 0..n {
279            let id = get_ability_rarity_id(&mut rng, level_settings);
280            *counts.entry(id).or_default() += 1;
281        }
282
283        // weights: common 256, uncommon 128, rare 64 → total 448
284        let common_ratio = *counts.get(&COMMON).unwrap_or(&0) as f64 / n as f64;
285        let uncommon_ratio = *counts.get(&UNCOMMON).unwrap_or(&0) as f64 / n as f64;
286        let rare_ratio = *counts.get(&RARE).unwrap_or(&0) as f64 / n as f64;
287
288        approx::assert_abs_diff_eq!(common_ratio, 256.0 / 448.0, epsilon = 0.02);
289        approx::assert_abs_diff_eq!(uncommon_ratio, 128.0 / 448.0, epsilon = 0.02);
290        approx::assert_abs_diff_eq!(rare_ratio, 64.0 / 448.0, epsilon = 0.02);
291    }
292
293    #[test]
294    fn test_roll_rarity_with_minimum_filters_lower_rarities() {
295        let config = generate_game_config_for_tests();
296        let level_settings = config.ability_case_settings_by_level(1).unwrap();
297        let mut rng = StdRng::seed_from_u64(99);
298
299        // min = uncommon (order 2) → should never return common (order 1)
300        for _ in 0..1_000 {
301            let id = roll_rarity_with_minimum(&config, level_settings, UNCOMMON, &mut rng).unwrap();
302            assert_ne!(id, COMMON, "Should never roll below minimum rarity");
303        }
304    }
305
306    #[test]
307    fn test_roll_rarity_with_minimum_respects_weights() {
308        let config = generate_game_config_for_tests();
309        let level_settings = config.ability_case_settings_by_level(1).unwrap();
310        let mut rng = StdRng::seed_from_u64(77);
311
312        let mut counts: HashMap<AbilityRarityId, usize> = HashMap::new();
313        let n = 10_000;
314        for _ in 0..n {
315            let id = roll_rarity_with_minimum(&config, level_settings, UNCOMMON, &mut rng).unwrap();
316            *counts.entry(id).or_default() += 1;
317        }
318
319        // Only uncommon (128) and rare (64) → total 192
320        assert!(!counts.contains_key(&COMMON));
321        let uncommon_ratio = *counts.get(&UNCOMMON).unwrap_or(&0) as f64 / n as f64;
322        approx::assert_abs_diff_eq!(uncommon_ratio, 128.0 / 192.0, epsilon = 0.02);
323    }
324
325    #[test]
326    fn test_get_ability_rarity_id_by_weights_distribution() {
327        let weights = make_weights(&[(COMMON, 3.0), (UNCOMMON, 1.0)]);
328        let mut rng = StdRng::seed_from_u64(42);
329
330        let mut counts: HashMap<AbilityRarityId, usize> = HashMap::new();
331        let n = 10_000;
332        for _ in 0..n {
333            let id = get_ability_rarity_id_by_weights(&mut rng, &weights).unwrap();
334            *counts.entry(id).or_default() += 1;
335        }
336
337        let common_ratio = *counts.get(&COMMON).unwrap_or(&0) as f64 / n as f64;
338        approx::assert_abs_diff_eq!(common_ratio, 0.75, epsilon = 0.02);
339    }
340
341    #[test]
342    fn test_get_ability_rarity_id_by_weights_empty_errors() {
343        let mut rng = StdRng::seed_from_u64(1);
344        assert!(get_ability_rarity_id_by_weights(&mut rng, &[]).is_err());
345    }
346
347    #[test]
348    fn test_open_ability_case_with_rarity_and_wishlist_returns_correct_rarity() {
349        let config = generate_game_config_for_tests();
350        let mut rng = StdRng::seed_from_u64(42);
351
352        for _ in 0..100 {
353            let id =
354                open_ability_case_with_rarity_and_wishlist(&config, COMMON, &mut rng, &[]).unwrap();
355            let template = config.ability_template(id).unwrap();
356            assert_eq!(template.rarity_id, COMMON);
357        }
358    }
359
360    #[test]
361    fn test_open_ability_case_with_rarity_and_wishlist_invalid_rarity_errors() {
362        let config = generate_game_config_for_tests();
363        let mut rng = StdRng::seed_from_u64(1);
364        let fake_rarity = uuid!("00000000-0000-0000-0000-000000000001");
365
366        assert!(
367            open_ability_case_with_rarity_and_wishlist(&config, fake_rarity, &mut rng, &[])
368                .is_err()
369        );
370    }
371}