overlord_event_system/gacha/
ability_case.rs1use 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
133pub 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 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 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 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 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}