overlord_event_system/
cases.rs

1use super::BehaviorRegistry;
2use crate::game_config_helpers::GameConfigLookup;
3use crate::mechanics::balance;
4use configs::game_config;
5use essences::{currency::from_es_currencies, items};
6use event_system::script::random::GameRng;
7
8/// Finalize a freshly-rolled item: price, XP, and every attribute value.
9///
10/// `random` is supplied by the caller rather than minted here. It used to be a
11/// `GameRng::from_entropy()` created per item, which made attribute values the
12/// single largest source of run-to-run spread in the simulator and meant no
13/// seed anywhere could reproduce a chest open. Callers on the authoritative
14/// path pass the session-seeded stream; cheats and bot fixtures pass their own.
15pub fn try_finalize_item(
16    item: &mut items::Item,
17    game_config: &game_config::GameConfig,
18    behaviors: &BehaviorRegistry,
19    random: &GameRng,
20) -> Result<(), String> {
21    // A native error yields no price currencies.
22    let price = crate::behaviors::items::item_price(&crate::behaviors::items::ItemPriceCtx {
23        item,
24        config: game_config,
25        lookups: behaviors.lookups(),
26    })
27    .unwrap_or_default();
28
29    item.price = from_es_currencies(&price);
30
31    let Ok(experience) = crate::behaviors::items::item_experience_eff_item(
32        &crate::behaviors::items::ItemExperienceCtx {
33            item,
34            config: game_config,
35            lookups: behaviors.lookups(),
36        },
37    ) else {
38        return Err("Couldn't calculate items experience".to_string());
39    };
40
41    item.experience = experience;
42
43    let item_clone = item.clone();
44
45    for attribute in &mut item.attributes {
46        let attr_config = game_config.attribute(attribute.attr_id).unwrap_or_else(|| {
47            panic!(
48                "Attribute with id = {} couldnt be found in config",
49                attribute.attr_id
50            )
51        });
52        // The native attribute fn is selected by the attribute's
53        // `calculation_behavior` config ref (was passed to
54        // `run_item_attribute` as `native_name`).
55        let native_name = attr_config.calculation_behavior.as_deref();
56        let Some(native_name) = native_name else {
57            return Err(format!(
58                "Attribute with id = {} has no native calculation fn",
59                attribute.attr_id
60            ));
61        };
62        let Some(attribute_fn) = behaviors.item_attribute_fn(native_name) else {
63            return Err(format!(
64                "Native attribute fn `{native_name}` not registered for attribute id = {}",
65                attribute.attr_id
66            ));
67        };
68
69        let Ok(attribute_value) = attribute_fn(&crate::behaviors::items::ItemAttributeCtx {
70            item: &item_clone,
71            attributes_quantity: item_clone.attributes.len() as i64,
72            random,
73            config: game_config,
74            lookups: behaviors.lookups(),
75        }) else {
76            return Err(format!(
77                "Couldn't calculate attribute with id = {} value",
78                attribute.attr_id
79            ));
80        };
81
82        // A rolled stat is at worst neutral. Every `(attr_eff - 1)`-shaped
83        // stat goes negative whenever `attr_eff < 1`, which is guaranteed on a
84        // level-1 Common item — and a negative does not read as zero
85        // downstream: `calculate_*_stats` floors the SUM across sources, so the
86        // item would cancel that stat from the rest of the build. Level-1 items
87        // are meant to be weak, not harmful.
88        attribute.value = (attribute_value as i32).max(0);
89    }
90
91    // A zero-valued stat contributes nothing to power or combat, so carrying it
92    // only pads the item card with empty rows. Absence and zero are already
93    // equivalent everywhere downstream — every reader resolves a missing
94    // attribute to 0.
95    item.attributes.retain(|attribute| attribute.value != 0);
96
97    // Floor: an item's effective power (attr-derived power + power_bonus) must
98    // never be ≤ 0.  At very low character levels the attribute-derived power of
99    // a newly-rolled item can be in the single digits, so a negative power_bonus
100    // drawn from the jitter range could push the net contribution below zero —
101    // which is nonsensical (a fresh item making the character weaker than having
102    // no item at all).
103    //
104    // After attributes are filled we compute the item's standalone attribute
105    // power by running `power_from_attrs` over only this item's attributes, then
106    // clamp power_bonus so that (standalone_attr_power + power_bonus) >= 1.
107    // Normal items (base power >> POWER_JITTER_HALF) are unaffected; only tiny-
108    // base items at the very start of the game have their negative tail trimmed.
109    let standalone_attr_power: i64 = {
110        let mut attrs = balance::AttrMap::new();
111        for attr in &item.attributes {
112            if let Some(a) = game_config.attribute(attr.attr_id) {
113                // Items accumulate (not overwrite) within the same attribute slot.
114                *attrs.entry(a.code.as_str().to_string()).or_insert(0.0) += attr.value as f64;
115            }
116        }
117        balance::power_from_attrs(&attrs)
118    };
119    // Minimum power_bonus such that standalone_attr_power + power_bonus >= 1.
120    let min_bonus = 1_i64 - standalone_attr_power;
121    item.power_bonus = item.power_bonus.max(min_bonus as i32);
122
123    Ok(())
124}