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};
5
6use crate::BehaviorRegistry;
7use crate::behaviors::items::ChestItemChooseCtx;
8use crate::game_config_helpers::GameConfigLookup;
9use rand::TryRng;
10use rand::seq::IndexedRandom;
11use rand::seq::SliceRandom;
12use rand::{
13 RngExt, SeedableRng,
14 rngs::{StdRng, SysRng},
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(
45 character_state: &CharacterState,
46 config: &GameConfig,
47 seed: Option<u64>,
48 behaviors: &BehaviorRegistry,
49 pending_items: &[Item],
50) -> anyhow::Result<Item> {
51 let mut rng = StdRng::seed_from_u64(seed.unwrap_or(SysRng.try_next_u64()?));
52
53 let item_template_id = crate::behaviors::items::chest_item_choose(&ChestItemChooseCtx {
56 character: character_state,
57 config,
58 lookups: behaviors.lookups(),
59 pending_items,
60 })?;
61
62 if let Some(item_template_id) = item_template_id {
63 tracing::debug!("Got item_template_id from script: {item_template_id}");
64 let Some(item_template) = config.item_template(item_template_id) else {
65 anyhow::bail!("Failed to get item_template with id={}", item_template_id);
66 };
67
68 let Some(rarity) = config.item_rarity(item_template.rarity_id) else {
69 anyhow::bail!(
70 "Failed to get rarity with rarity_id={}",
71 item_template.rarity_id
72 );
73 };
74
75 let mut item = generate_item_from_template(
76 item_template,
77 rarity.clone(),
78 character_state.character.character_level,
79 crate::mechanics::balance::optional_attributes_for_chest_level(
80 character_state.character.item_case_level,
81 ),
82 config,
83 &mut rng,
84 );
85 item.power_bonus = 0;
89
90 return Ok(item);
91 };
92
93 open_item_case(character_state, config, &mut rng)
94}
95
96pub fn open_item_case(
97 character_state: &CharacterState,
98 config: &GameConfig,
99 rng: &mut rand::rngs::StdRng,
100) -> anyhow::Result<Item> {
101 let Some(level_settings) =
102 config.item_case_settings_by_level(character_state.character.item_case_level)
103 else {
104 anyhow::bail!(
105 "Failed to get case settings for item_case_level={}",
106 character_state.character.item_case_level
107 );
108 };
109
110 let rarity_id = get_item_rarity_id(rng, level_settings);
111
112 let Some(rarity) = config.item_rarity(rarity_id) else {
113 anyhow::bail!("Failed to get rarity with rarity_id={}", rarity_id);
114 };
115
116 let Some(inventory_level) = config
117 .inventory_levels
118 .iter()
119 .rev()
120 .find(|l| l.from_chapter_level <= character_state.character.current_chapter_level)
121 else {
122 anyhow::bail!(
123 "Failed to get inventory level for current_chapter_level={}",
124 character_state.character.current_chapter_level
125 );
126 };
127
128 let chest_eligible_slots: Vec<_> = inventory_level
129 .slots
130 .iter()
131 .map(|slot| slot.equipment_slot_key())
132 .collect();
133 let equipment_slot_key = *chest_eligible_slots.choose(rng).ok_or(anyhow::anyhow!(
134 "No item slots specified for inventory_level={:?}",
135 inventory_level
136 ))?;
137
138 let character_class_id = character_state.character.class;
139 let class_match = |item: &&ItemTemplate| {
140 item.required_class
141 .is_none_or(|required| required == character_class_id)
142 };
143
144 let items_pool: Vec<&ItemTemplate> = {
145 let result = config
146 .items
147 .iter()
148 .filter(|item| {
149 item.rarity_id == rarity.id
150 && item.equipment_slot_key() == equipment_slot_key
151 && !item.exclude_from_mimic
152 && class_match(item)
153 })
154 .collect::<Vec<_>>();
155
156 if result.is_empty() {
157 tracing::error!(
158 "No items found for rarity_id={:?} and equipment_slot={:?}",
159 rarity.id,
160 equipment_slot_key
161 );
162 let result = config
167 .items
168 .iter()
169 .filter(|item| {
170 item.rarity_id == rarity.id
171 && chest_eligible_slots.contains(&item.equipment_slot_key())
172 && !item.exclude_from_mimic
173 && class_match(item)
174 })
175 .collect::<Vec<_>>();
176 if result.is_empty() {
177 let rolled_order = rarity.order;
193 let mut by_distance: Vec<(u64, uuid::Uuid, &ItemTemplate)> = config
194 .items
195 .iter()
196 .filter(|item| {
197 chest_eligible_slots.contains(&item.equipment_slot_key())
198 && !item.exclude_from_mimic
199 && class_match(item)
200 })
201 .filter_map(|item| {
202 let order = config.item_rarity(item.rarity_id)?.order;
203 Some((order.abs_diff(rolled_order), item.id, item))
204 })
205 .collect();
206 by_distance.sort_by_key(|(distance, id, _)| (*distance, *id));
209 let nearest = by_distance
210 .first()
211 .map(|(distance, _, _)| *distance)
212 .ok_or_else(|| {
213 anyhow::anyhow!("No items found for rarity_id={:?}", rarity.id)
214 })?;
215 by_distance
216 .into_iter()
217 .take_while(|(distance, _, _)| *distance == nearest)
218 .map(|(_, _, item)| item)
219 .collect::<Vec<_>>()
220 } else {
221 result
222 }
223 } else {
224 result
225 }
226 };
227
228 let Some(&item_template) = items_pool.choose(rng) else {
229 anyhow::bail!("Failed to choose random element from items");
230 };
231
232 let item = generate_item_from_template(
233 item_template,
234 rarity.clone(),
235 character_state.character.character_level,
236 crate::mechanics::balance::optional_attributes_for_chest_level(
237 character_state.character.item_case_level,
238 ),
239 config,
240 rng,
241 );
242
243 Ok(item)
244}
245
246pub const POWER_JITTER_HALF: i32 = 5;
264
265pub fn generate_item_from_template(
273 template: &ItemTemplate,
274 rarity: ItemRarity,
275 level: i64,
276 optional_count: u64,
277 game_config: &GameConfig,
278 rng: &mut rand::rngs::StdRng,
279) -> Item {
280 let mut attributes: Vec<ItemAttribute> = Vec::new();
281
282 let mut optional_attribute_ids = template.attributes_settings.optional_attributes_ids.clone();
283 optional_attribute_ids.shuffle(rng);
284 optional_attribute_ids = optional_attribute_ids
285 .into_iter()
286 .take(optional_count as usize)
287 .collect();
288
289 for attribute in &game_config.attributes {
290 if game_config
291 .game_settings
292 .required_attributes
293 .contains(&attribute.id)
294 || optional_attribute_ids.contains(&attribute.id)
295 {
296 attributes.push(ItemAttribute {
297 attr_id: attribute.id,
298 value: 0,
299 });
300 }
301 }
302
303 let power_bonus: i32 = rng.random_range((-POWER_JITTER_HALF)..=(POWER_JITTER_HALF));
307
308 Item {
309 id: uuid::Uuid::now_v7(),
310 item_template_id: template.id,
311 item_type: template.item_type,
312 world_side: template.world_side,
313 rarity,
314 level,
315 name: template.name.clone(),
316 icon_url: template.icon_url.clone(),
317 icon_path: template.icon_path.clone(),
318 is_equipped: false,
319 price: vec![],
320 experience: 0,
321 attributes,
322 power_bonus,
323 expires_at: None,
324 }
325}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330 use crate::BehaviorRegistry;
331 use crate::cases::try_finalize_item;
332 use configs::tests_game_config::generate_game_config_for_tests;
333 use essences::character_state::CharacterState;
334 use rand::SeedableRng;
335
336 #[test]
352 fn test_power_jitter_bounded_mean_neutral_and_deterministic() {
353 let config = generate_game_config_for_tests();
354 let mut character_state = CharacterState::default();
355 character_state.character.item_case_level = 1;
356
357 let n = 10_000u64;
358 let mut sum: i64 = 0;
359 let mut saw_max = false;
360 let mut saw_min = false;
361
362 for seed in 0..n {
363 let mut rng = StdRng::seed_from_u64(seed);
364 let item = open_item_case(&character_state, &config, &mut rng)
365 .expect("open_item_case should not fail");
366
367 let pb = item.power_bonus;
368
369 assert!(
371 (-POWER_JITTER_HALF..=POWER_JITTER_HALF).contains(&pb),
372 "seed {seed}: power_bonus {pb} out of [{}, {}]",
373 -POWER_JITTER_HALF,
374 POWER_JITTER_HALF
375 );
376
377 sum += pb as i64;
378 if pb == POWER_JITTER_HALF {
379 saw_max = true;
380 }
381 if pb == -POWER_JITTER_HALF {
382 saw_min = true;
383 }
384 }
385
386 assert!(
388 saw_max,
389 "power_bonus == +{POWER_JITTER_HALF} was never produced (range not live)"
390 );
391 assert!(
392 saw_min,
393 "power_bonus == -{POWER_JITTER_HALF} was never produced (range not live)"
394 );
395
396 let mean = sum as f64 / n as f64;
398 assert!(
399 mean.abs() < 0.5,
400 "population mean {mean:.4} is too far from 0 — distribution is not symmetric"
401 );
402
403 for seed in [0u64, 42, 999, 5000] {
405 let pb_first = {
406 let mut rng = StdRng::seed_from_u64(seed);
407 open_item_case(&character_state, &config, &mut rng)
408 .expect("first roll")
409 .power_bonus
410 };
411 let pb_second = {
412 let mut rng = StdRng::seed_from_u64(seed);
413 open_item_case(&character_state, &config, &mut rng)
414 .expect("second roll")
415 .power_bonus
416 };
417 assert_eq!(
418 pb_first, pb_second,
419 "seed {seed}: power_bonus not deterministic ({pb_first} ≠ {pb_second})"
420 );
421 }
422
423 let pair_differs = (0u64..100).any(|seed| {
426 let pb_a = {
427 let mut rng = StdRng::seed_from_u64(seed);
428 open_item_case(&character_state, &config, &mut rng)
429 .expect("roll A")
430 .power_bonus
431 };
432 let pb_b = {
433 let mut rng = StdRng::seed_from_u64(seed + 1);
434 open_item_case(&character_state, &config, &mut rng)
435 .expect("roll B")
436 .power_bonus
437 };
438 pb_a != pb_b
439 });
440 assert!(
441 pair_differs,
442 "no consecutive-seed pair differed in power_bonus — jitter is not perceptible"
443 );
444 }
445
446 #[test]
454 fn test_finalized_item_effective_power_never_below_one() {
455 let config = generate_game_config_for_tests();
456 let behaviors = BehaviorRegistry::new(&config);
457 let mut character_state = CharacterState::default();
458 character_state.character.item_case_level = 1;
459
460 for seed in 0u64..2000 {
461 let mut rng = StdRng::seed_from_u64(seed);
462 let mut item = open_item_case(&character_state, &config, &mut rng)
463 .expect("open_item_case should not fail");
464
465 try_finalize_item(
467 &mut item,
468 &config,
469 &behaviors,
470 &event_system::script::random::GameRng::new(StdRng::seed_from_u64(seed)),
471 )
472 .unwrap_or_else(|e| panic!("seed {seed}: try_finalize_item failed: {e}"));
473
474 let standalone_attr_power: i64 = {
476 let mut attrs = crate::mechanics::balance::AttrMap::new();
477 for attr in &item.attributes {
478 if let Some(a) = config.attribute(attr.attr_id) {
479 *attrs.entry(a.code.as_str().to_string()).or_insert(0.0) +=
480 attr.value as f64;
481 }
482 }
483 crate::mechanics::balance::power_from_attrs(&attrs)
484 };
485
486 let effective = standalone_attr_power + item.power_bonus as i64;
487 assert!(
488 effective >= 1,
489 "seed {seed}: finalized item effective power {effective} < 1 \
490 (attr_power={standalone_attr_power}, power_bonus={})",
491 item.power_bonus,
492 );
493 }
494 }
495}