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};
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
41/// `pending_items` are the items already rolled in the same batch open; they
42/// are not in `character_state.inventory` yet, but the empty-slot guarantee
43/// must treat their slots as filled.
44pub 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    // Item override: mimic by code, or the first free slot of the current
54    // inventory level. `None` means "no override" (roll by weights below).
55    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        // Guaranteed/custom drops (first item per empty slot, mimic custom
86        // code) must be deterministic: no display power jitter. Finalization
87        // still lifts power_bonus to keep the item's net contribution ≥ 1.
88        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            // Fallback: relax only the rolled logical slot, broadening to the
163            // slots unlocked at the current inventory level. Keep the
164            // exclude_from_mimic and class restrictions intact.
165            // Artifact is always excluded from chest drops regardless of fallback.
166            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                // A2-BAL-003 §3.2: the rarity catalog subdivides each family
178                // into power tiers (Mythic I..V and so on). Those sub-tiers are
179                // deliberately NOT given their own item templates — they share
180                // their family's art, and authoring ~340 visually identical
181                // templates to satisfy a lookup would be content churn for
182                // nothing.
183                //
184                // Without this branch such a rarity does not degrade, it BAILS:
185                // the chest open fails outright and the player gets no item. So
186                // borrow the templates of the nearest rarity that has any,
187                // measured by `order`, which puts a sub-tier on its own family
188                // anchor. The rolled rarity is still what the item CARRIES —
189                // only the template it is built from is borrowed — so `q`,
190                // sell price and XP all follow the tier that was actually
191                // rolled.
192                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                // Ties resolve by item id so the choice stays deterministic for
207                // a given seed.
208                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
246/// Half-width of the per-item power jitter band, in raw combat-power units.
247/// Each rolled item gets a uniform integer draw from `[-POWER_JITTER_HALF,
248/// +POWER_JITTER_HALF]` using the session-seeded RNG already in scope, so:
249///
250/// - **Deterministic**: same session seed + same logical timestamp → same jitter.
251/// - **Balance-neutral in expectation**: the distribution is symmetric around 0
252///   (mean = 0), so population-average power is unchanged. Two items rolled from
253///   the same template can differ by up to `2 × POWER_JITTER_HALF` power units.
254/// - **Perceptible on early items**: at character level 1 the displayed
255///   Combat-Power is in the low hundreds to low thousands; a ±5 shift is visible
256///   to the player and guarantees the designer's requested "at least ±1" is met
257///   everywhere on the curve. Late-game items (millions of power) absorb the ±5
258///   as rounding noise — which is acceptable: the feature targets the feel
259///   problem in the early game where each (rarity, type) cell has exactly ONE
260///   eligible template.
261/// - **Applied universally** (no per-item config flag per the project rule):
262///   every rolled item gets the jitter, regardless of rarity or type.
263pub const POWER_JITTER_HALF: i32 = 5;
264
265/// `optional_count` is passed in rather than read off the template.
266///
267/// A2-BAL-003 §3.3 makes it a property of the CHEST — 0 at L1, 1 at L2..5, 2 at
268/// L6+ — so the same sword rolls a different number of extras depending on where
269/// it dropped. Ten templates also carried three, above the signed maximum of
270/// two. Paths that are not a chest roll (bundles, cheats, bot fixtures) pass the
271/// template's own count and keep their previous behaviour.
272pub 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    // Symmetric power jitter: uniform draw in [-POWER_JITTER_HALF, +POWER_JITTER_HALF].
304    // Uses the in-scope seeded RNG so the result is deterministic per session-seed +
305    // logical-timestamp. The range endpoint is inclusive on both sides.
306    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    /// Jitter assertions — verifies that the `power_bonus` field on rolled items
337    /// satisfies all design constraints:
338    ///
339    /// 1. **Bounded**: each item's `power_bonus` is within `[-POWER_JITTER_HALF,
340    ///    +POWER_JITTER_HALF]`.
341    /// 2. **Both extremes are reachable**: over N seeds, at least one item with
342    ///    `power_bonus == +POWER_JITTER_HALF` and one with `-POWER_JITTER_HALF`
343    ///    are produced (proves the range is live, not dead-clamped).
344    /// 3. **Mean-neutral in expectation**: the mean `power_bonus` across a large
345    ///    population is close to 0 (|mean| ≤ 0.5), confirming the symmetric
346    ///    distribution does not shift the population mean.
347    /// 4. **Deterministic**: rolling the same seed twice produces the same
348    ///    `power_bonus` — the jitter comes from the seeded RNG, not OS entropy.
349    /// 5. **Perceptible**: at least one pair of same-template rolls at the same
350    ///    level differs by ≥ 1 in `power_bonus` (the designer's minimum).
351    #[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            // 1. Bounded.
370            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        // 2. Both extremes reachable.
387        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        // 3. Mean-neutral: |mean| ≤ 0.5 (should be essentially 0 for n=10000).
397        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        // 4. Deterministic: same seed → same power_bonus.
404        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        // 5. Perceptible: at least one pair of consecutive seeds differs by ≥ 1.
424        // (With a uniform draw over 11 values, this is virtually guaranteed in practice.)
425        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    /// Verifies that after `try_finalize_item`, an item's effective power
447    /// (attr-derived power + power_bonus) is always ≥ 1, even for items rolled at
448    /// the lowest character level where the raw jitter could otherwise drive the
449    /// net contribution to zero or negative.
450    ///
451    /// This is the floor guarantee added in Correction B of the post-playtest
452    /// corrections: items must never contribute negative or zero power.
453    #[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            // Apply the full finalization (attr values + power_bonus floor).
466            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            // Compute the item's standalone attribute power.
475            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}