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}