overlord_event_system/gacha/
item_case.rs

1use 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    // Item override: mimic by code, or the first free slot of the current
50    // inventory level. `None` means "no override" (roll by weights below).
51    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    // Artifact is never rolled from chests — it is granted via bundles only.
117    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            // Fallback: relax only the rolled item_type, broadening to the
153            // item types unlocked at the current inventory level. Keep the
154            // exclude_from_mimic and class restrictions intact.
155            // Artifact is always excluded from chest drops regardless of fallback.
156            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
191/// Half-width of the per-item power jitter band, in raw combat-power units.
192/// Each rolled item gets a uniform integer draw from `[-POWER_JITTER_HALF,
193/// +POWER_JITTER_HALF]` using the session-seeded RNG already in scope, so:
194///
195/// - **Deterministic**: same session seed + same logical timestamp → same jitter.
196/// - **Balance-neutral in expectation**: the distribution is symmetric around 0
197///   (mean = 0), so population-average power is unchanged. Two items rolled from
198///   the same template can differ by up to `2 × POWER_JITTER_HALF` power units.
199/// - **Perceptible on early items**: at character level 1 the displayed
200///   Combat-Power is in the low hundreds to low thousands; a ±5 shift is visible
201///   to the player and guarantees the designer's requested "at least ±1" is met
202///   everywhere on the curve. Late-game items (millions of power) absorb the ±5
203///   as rounding noise — which is acceptable: the feature targets the feel
204///   problem in the early game where each (rarity, type) cell has exactly ONE
205///   eligible template.
206/// - **Applied universally** (no per-item config flag per the project rule):
207///   every rolled item gets the jitter, regardless of rarity or type.
208pub 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    // Symmetric power jitter: uniform draw in [-POWER_JITTER_HALF, +POWER_JITTER_HALF].
241    // Uses the in-scope seeded RNG so the result is deterministic per session-seed +
242    // logical-timestamp. The range endpoint is inclusive on both sides.
243    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    /// Opens 1000 chests at the highest inventory level (chapter 0 in the test
273    /// config, which uses `all::<ItemType>().collect()` and therefore includes
274    /// `Artifact` in `item_types`) and asserts that no Artifact is ever rolled.
275    ///
276    /// This test pins the `ItemType::Artifact` exclusion filter in `open_item_case`.
277    #[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        // The test config's minimum item_case_level is 1; default() uses 0
282        // which has no settings entry and would immediately bail.
283        character_state.character.item_case_level = 1;
284
285        // Run many iterations with different seeds to cover the RNG space.
286        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    /// Jitter assertions — verifies that the `power_bonus` field on rolled items
299    /// satisfies all design constraints:
300    ///
301    /// 1. **Bounded**: each item's `power_bonus` is within `[-POWER_JITTER_HALF,
302    ///    +POWER_JITTER_HALF]`.
303    /// 2. **Both extremes are reachable**: over N seeds, at least one item with
304    ///    `power_bonus == +POWER_JITTER_HALF` and one with `-POWER_JITTER_HALF`
305    ///    are produced (proves the range is live, not dead-clamped).
306    /// 3. **Mean-neutral in expectation**: the mean `power_bonus` across a large
307    ///    population is close to 0 (|mean| ≤ 0.5), confirming the symmetric
308    ///    distribution does not shift the population mean.
309    /// 4. **Deterministic**: rolling the same seed twice produces the same
310    ///    `power_bonus` — the jitter comes from the seeded RNG, not OS entropy.
311    /// 5. **Perceptible**: at least one pair of same-template rolls at the same
312    ///    level differs by ≥ 1 in `power_bonus` (the designer's minimum).
313    #[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            // 1. Bounded.
332            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        // 2. Both extremes reachable.
349        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        // 3. Mean-neutral: |mean| ≤ 0.5 (should be essentially 0 for n=10000).
359        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        // 4. Deterministic: same seed → same power_bonus.
366        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        // 5. Perceptible: at least one pair of consecutive seeds differs by ≥ 1.
386        // (With a uniform draw over 11 values, this is virtually guaranteed in practice.)
387        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    /// Verifies that after `try_finalize_item`, an item's effective power
409    /// (attr-derived power + power_bonus) is always ≥ 1, even for items rolled at
410    /// the lowest character level where the raw jitter could otherwise drive the
411    /// net contribution to zero or negative.
412    ///
413    /// This is the floor guarantee added in Correction B of the post-playtest
414    /// corrections: items must never contribute negative or zero power.
415    #[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            // Apply the full finalization (attr values + power_bonus floor).
428            try_finalize_item(&mut item, &config, &behaviors)
429                .unwrap_or_else(|e| panic!("seed {seed}: try_finalize_item failed: {e}"));
430
431            // Compute the item's standalone attribute power.
432            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}