overlord_event_system/gacha/
item_case.rs1use configs::game_config::GameConfig;
2use essences::character_state::CharacterState;
3use essences::item_case::ItemCasesSettingsByLevel;
4use essences::items::{Item, ItemAttribute, ItemRarity, ItemRarityId, ItemTemplate, ItemType};
5
6use crate::BehaviorRegistry;
7use crate::behaviors::items::ChestItemChooseCtx;
8use crate::game_config_helpers::GameConfigLookup;
9use rand::TryRngCore;
10use rand::seq::IndexedRandom;
11use rand::seq::SliceRandom;
12use rand::{
13 Rng, SeedableRng,
14 rngs::{OsRng, StdRng},
15};
16
17fn get_item_rarity_id(rng: &mut StdRng, level_settings: &ItemCasesSettingsByLevel) -> ItemRarityId {
18 let total_weight: f64 = level_settings
19 .rarity_weights
20 .iter()
21 .map(|rarity_weight| rarity_weight.weight)
22 .sum();
23
24 if total_weight < 1e-10 {
25 panic!("Sum of weights is too low: {total_weight}");
26 }
27
28 let rnd_weight = rng.random_range(0.0..total_weight);
29
30 let mut cumulative_weight = 0.0;
31 for rarity_weight in &level_settings.rarity_weights {
32 cumulative_weight += rarity_weight.weight;
33 if rnd_weight < cumulative_weight {
34 return rarity_weight.rarity_id;
35 }
36 }
37
38 panic!("Failed to get item rarity id for weight {rnd_weight}");
39}
40
41pub fn try_open_item_case(
42 character_state: &CharacterState,
43 config: &GameConfig,
44 seed: Option<u64>,
45 behaviors: &BehaviorRegistry,
46) -> anyhow::Result<Item> {
47 let mut rng = StdRng::seed_from_u64(seed.unwrap_or(OsRng.try_next_u64()?));
48
49 let item_template_id = crate::behaviors::items::chest_item_choose(&ChestItemChooseCtx {
52 character: character_state,
53 config,
54 lookups: behaviors.lookups(),
55 })?;
56
57 if let Some(item_template_id) = item_template_id {
58 tracing::debug!("Got item_template_id from script: {item_template_id}");
59 let Some(item_template) = config.item_template(item_template_id) else {
60 anyhow::bail!("Failed to get item_template with id={}", item_template_id);
61 };
62
63 let Some(rarity) = config.item_rarity(item_template.rarity_id) else {
64 anyhow::bail!(
65 "Failed to get rarity with rarity_id={}",
66 item_template.rarity_id
67 );
68 };
69
70 let item = generate_item_from_template(
71 item_template,
72 rarity.clone(),
73 character_state.character.character_level,
74 config,
75 &mut rng,
76 );
77
78 return Ok(item);
79 };
80
81 open_item_case(character_state, config, &mut rng)
82}
83
84pub fn open_item_case(
85 character_state: &CharacterState,
86 config: &GameConfig,
87 rng: &mut rand::rngs::StdRng,
88) -> anyhow::Result<Item> {
89 let Some(level_settings) =
90 config.item_case_settings_by_level(character_state.character.item_case_level)
91 else {
92 anyhow::bail!(
93 "Failed to get case settings for item_case_level={}",
94 character_state.character.item_case_level
95 );
96 };
97
98 let rarity_id = get_item_rarity_id(rng, level_settings);
99
100 let Some(rarity) = config.item_rarity(rarity_id) else {
101 anyhow::bail!("Failed to get rarity with rarity_id={}", rarity_id);
102 };
103
104 let Some(inventory_level) = config
105 .inventory_levels
106 .iter()
107 .rev()
108 .find(|l| l.from_chapter_level <= character_state.character.current_chapter_level)
109 else {
110 anyhow::bail!(
111 "Failed to get inventory level for current_chapter_level={}",
112 character_state.character.current_chapter_level
113 );
114 };
115
116 let chest_eligible_types: Vec<ItemType> = inventory_level
118 .item_types
119 .iter()
120 .copied()
121 .filter(|t| *t != ItemType::Artifact)
122 .collect();
123 let item_type = *chest_eligible_types.choose(rng).ok_or(anyhow::anyhow!(
124 "No item slots specified for inventory_level={:?}",
125 inventory_level
126 ))?;
127
128 let character_class_id = character_state.character.class;
129 let class_match = |item: &&ItemTemplate| {
130 item.required_class
131 .is_none_or(|required| required == character_class_id)
132 };
133
134 let items_pool: Vec<&ItemTemplate> = {
135 let result = config
136 .items
137 .iter()
138 .filter(|item| {
139 item.rarity_id == rarity.id
140 && item.item_type == item_type
141 && !item.exclude_from_mimic
142 && class_match(item)
143 })
144 .collect::<Vec<_>>();
145
146 if result.is_empty() {
147 tracing::error!(
148 "No items found for rarity_id={:?} and item_type={:?}",
149 rarity.id,
150 item_type
151 );
152 let result = config
157 .items
158 .iter()
159 .filter(|item| {
160 item.rarity_id == rarity.id
161 && inventory_level.item_types.contains(&item.item_type)
162 && item.item_type != ItemType::Artifact
163 && !item.exclude_from_mimic
164 && class_match(item)
165 })
166 .collect::<Vec<_>>();
167 if result.is_empty() {
168 anyhow::bail!("No items found for rariy_id={:?}", rarity.id);
169 }
170 result
171 } else {
172 result
173 }
174 };
175
176 let Some(&item_template) = items_pool.choose(rng) else {
177 anyhow::bail!("Failed to choose random element from items");
178 };
179
180 let item = generate_item_from_template(
181 item_template,
182 rarity.clone(),
183 character_state.character.character_level,
184 config,
185 rng,
186 );
187
188 Ok(item)
189}
190
191pub const POWER_JITTER_HALF: i32 = 5;
209
210pub fn generate_item_from_template(
211 template: &ItemTemplate,
212 rarity: ItemRarity,
213 level: i64,
214 game_config: &GameConfig,
215 rng: &mut rand::rngs::StdRng,
216) -> Item {
217 let mut attributes: Vec<ItemAttribute> = Vec::new();
218
219 let mut optional_attribute_ids = template.attributes_settings.optional_attributes_ids.clone();
220 optional_attribute_ids.shuffle(rng);
221 optional_attribute_ids = optional_attribute_ids
222 .into_iter()
223 .take(template.attributes_settings.optional_attributes_count as usize)
224 .collect();
225
226 for attribute in &game_config.attributes {
227 if game_config
228 .game_settings
229 .required_attributes
230 .contains(&attribute.id)
231 || optional_attribute_ids.contains(&attribute.id)
232 {
233 attributes.push(ItemAttribute {
234 attr_id: attribute.id,
235 value: 0,
236 });
237 }
238 }
239
240 let power_bonus: i32 = rng.random_range((-POWER_JITTER_HALF)..=(POWER_JITTER_HALF));
244
245 Item {
246 id: uuid::Uuid::now_v7(),
247 item_template_id: template.id,
248 item_type: template.item_type,
249 rarity,
250 level,
251 name: template.name.clone(),
252 icon_url: template.icon_url.clone(),
253 icon_path: template.icon_path.clone(),
254 is_equipped: false,
255 price: vec![],
256 experience: 0,
257 attributes,
258 power_bonus,
259 expires_at: None,
260 }
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266 use crate::BehaviorRegistry;
267 use crate::cases::try_finalize_item;
268 use configs::tests_game_config::generate_game_config_for_tests;
269 use essences::character_state::CharacterState;
270 use rand::SeedableRng;
271
272 #[test]
278 fn test_open_item_case_never_drops_artifact() {
279 let config = generate_game_config_for_tests();
280 let mut character_state = CharacterState::default();
281 character_state.character.item_case_level = 1;
284
285 for seed in 0u64..1000 {
287 let mut rng = StdRng::seed_from_u64(seed);
288 let item = open_item_case(&character_state, &config, &mut rng)
289 .expect("open_item_case should not fail");
290 assert_ne!(
291 item.item_type,
292 ItemType::Artifact,
293 "seed {seed}: Artifact must never be rolled from a chest"
294 );
295 }
296 }
297
298 #[test]
314 fn test_power_jitter_bounded_mean_neutral_and_deterministic() {
315 let config = generate_game_config_for_tests();
316 let mut character_state = CharacterState::default();
317 character_state.character.item_case_level = 1;
318
319 let n = 10_000u64;
320 let mut sum: i64 = 0;
321 let mut saw_max = false;
322 let mut saw_min = false;
323
324 for seed in 0..n {
325 let mut rng = StdRng::seed_from_u64(seed);
326 let item = open_item_case(&character_state, &config, &mut rng)
327 .expect("open_item_case should not fail");
328
329 let pb = item.power_bonus;
330
331 assert!(
333 (-POWER_JITTER_HALF..=POWER_JITTER_HALF).contains(&pb),
334 "seed {seed}: power_bonus {pb} out of [{}, {}]",
335 -POWER_JITTER_HALF,
336 POWER_JITTER_HALF
337 );
338
339 sum += pb as i64;
340 if pb == POWER_JITTER_HALF {
341 saw_max = true;
342 }
343 if pb == -POWER_JITTER_HALF {
344 saw_min = true;
345 }
346 }
347
348 assert!(
350 saw_max,
351 "power_bonus == +{POWER_JITTER_HALF} was never produced (range not live)"
352 );
353 assert!(
354 saw_min,
355 "power_bonus == -{POWER_JITTER_HALF} was never produced (range not live)"
356 );
357
358 let mean = sum as f64 / n as f64;
360 assert!(
361 mean.abs() < 0.5,
362 "population mean {mean:.4} is too far from 0 — distribution is not symmetric"
363 );
364
365 for seed in [0u64, 42, 999, 5000] {
367 let pb_first = {
368 let mut rng = StdRng::seed_from_u64(seed);
369 open_item_case(&character_state, &config, &mut rng)
370 .expect("first roll")
371 .power_bonus
372 };
373 let pb_second = {
374 let mut rng = StdRng::seed_from_u64(seed);
375 open_item_case(&character_state, &config, &mut rng)
376 .expect("second roll")
377 .power_bonus
378 };
379 assert_eq!(
380 pb_first, pb_second,
381 "seed {seed}: power_bonus not deterministic ({pb_first} ≠ {pb_second})"
382 );
383 }
384
385 let pair_differs = (0u64..100).any(|seed| {
388 let pb_a = {
389 let mut rng = StdRng::seed_from_u64(seed);
390 open_item_case(&character_state, &config, &mut rng)
391 .expect("roll A")
392 .power_bonus
393 };
394 let pb_b = {
395 let mut rng = StdRng::seed_from_u64(seed + 1);
396 open_item_case(&character_state, &config, &mut rng)
397 .expect("roll B")
398 .power_bonus
399 };
400 pb_a != pb_b
401 });
402 assert!(
403 pair_differs,
404 "no consecutive-seed pair differed in power_bonus — jitter is not perceptible"
405 );
406 }
407
408 #[test]
416 fn test_finalized_item_effective_power_never_below_one() {
417 let config = generate_game_config_for_tests();
418 let behaviors = BehaviorRegistry::new(&config);
419 let mut character_state = CharacterState::default();
420 character_state.character.item_case_level = 1;
421
422 for seed in 0u64..2000 {
423 let mut rng = StdRng::seed_from_u64(seed);
424 let mut item = open_item_case(&character_state, &config, &mut rng)
425 .expect("open_item_case should not fail");
426
427 try_finalize_item(&mut item, &config, &behaviors)
429 .unwrap_or_else(|e| panic!("seed {seed}: try_finalize_item failed: {e}"));
430
431 let standalone_attr_power: i64 = {
433 let mut attrs = crate::mechanics::balance::AttrMap::new();
434 for attr in &item.attributes {
435 if let Some(a) = config.attribute(attr.attr_id) {
436 *attrs.entry(a.code.as_str().to_string()).or_insert(0.0) +=
437 attr.value as f64;
438 }
439 }
440 crate::mechanics::balance::power_from_attrs(&attrs)
441 };
442
443 let effective = standalone_attr_power + item.power_bonus as i64;
444 assert!(
445 effective >= 1,
446 "seed {seed}: finalized item effective power {effective} < 1 \
447 (attr_power={standalone_attr_power}, power_bonus={})",
448 item.power_bonus,
449 );
450 }
451 }
452}