overlord_event_system/mechanics/content.rs
1//! Native `content` script-module dispatchers plus the per-ability
2//! `ability_info(level)` composition for the `content_raw`-style ability map.
3//!
4//! These are `pub` Rust fns that the native script ports
5//! (`behaviors::description_values`, `behaviors::expression`'s
6//! `chest_item_choose`) call. The balance primitives they use
7//! (`balance::ability_damage_from_id`, `balance::effect_duration`, consts
8//! `AOE_COEF`/`OT_COEF`/`HEAL_COEF`) live in [`crate::mechanics::balance`].
9
10use configs::game_config::GameConfig;
11use essences::items::ItemTemplate;
12use uuid::Uuid;
13
14use crate::game_config_helpers::GameConfigLookup;
15use crate::mechanics::balance;
16use crate::mechanics::content_lookups::ContentLookups;
17
18/// The typed result of the per-ability `ability_info(level)` closures. Each
19/// struct holds every key that appears across the 18 bespoke ability infos, with
20/// `None` for keys a given ability does not set.
21///
22/// `PartialEq + Serialize` so it can be diffed/logged if ever surfaced directly.
23/// The downstream `description_values` fn reads individual fields, computing
24/// `floor(ability_info.<field> * 100)`.
25#[derive(Debug, Clone, PartialEq, serde::Serialize)]
26pub struct AbilityInfo {
27 /// `damage` — instant damage component. Present on all damage abilities.
28 pub damage: Option<f64>,
29 /// `dot` — damage-over-time component.
30 pub dot: Option<f64>,
31 /// `hot` — heal-over-time component.
32 pub hot: Option<f64>,
33 /// `effect_duration` — buff/debuff duration (seconds), from
34 /// `balance::effect_duration`.
35 pub effect_duration: Option<f64>,
36 /// `duration` — integer tick/second duration literal.
37 pub duration: Option<i64>,
38 /// `crit_chance_bonus` — added crit chance (fraction).
39 pub crit_chance_bonus: Option<f64>,
40 /// `vampiric` — life-steal fraction.
41 pub vampiric: Option<f64>,
42 /// `projectiles` — projectile count (integer).
43 pub projectiles: Option<i64>,
44}
45
46impl AbilityInfo {
47 /// All-`None` base; closures set the keys they produce.
48 fn empty() -> Self {
49 AbilityInfo {
50 damage: None,
51 dot: None,
52 hot: None,
53 effect_duration: None,
54 duration: None,
55 crit_chance_bonus: None,
56 vampiric: None,
57 projectiles: None,
58 }
59 }
60}
61
62/// Native port of the `content` module's `get_ability_info(id, level)`:
63/// dispatch to the per-ability `ability_info(level)` closure baked into the
64/// ability map. Returns `None` for ability ids that have no `ability_info`
65///
66/// Faithfully mirrors each of the 18 closures'
67/// arithmetic; the per-ability constants are ported verbatim from
68pub fn ability_info(
69 config: &GameConfig,
70 lookups: &ContentLookups,
71 ability_id: Uuid,
72 level: i64,
73) -> anyhow::Result<AbilityInfo> {
74 // `balance::ability_damage(uuid(id), level)` in every closure.
75 let dmg = |id: Uuid| -> anyhow::Result<f64> {
76 balance::ability_damage_from_id(config, lookups, id, level)
77 .map_err(|e| anyhow::anyhow!("balance::ability_damage({id}): {e}"))
78 };
79
80 let id_str = ability_id.to_string();
81 let mut info = AbilityInfo::empty();
82
83 match id_str.as_str() {
84 // damage: power
85 "0194d64e-20f2-75e5-89c8-4cb812672485" => {
86 info.damage = Some(dmg(ability_id)?);
87 }
88 // DOT split: damage = base*K*INSTANT, dot = base*K*DOT_PART
89 "01958172-9e65-7061-9d15-56b2c33cc13e" => {
90 const DOT_PART: f64 = 0.5;
91 const INSTANT: f64 = 1.0 - DOT_PART;
92 let base_dmg = dmg(ability_id)?;
93 let k = INSTANT + DOT_PART * balance::OT_COEF;
94 info.damage = Some(base_dmg * k * INSTANT);
95 info.dot = Some(base_dmg * k * DOT_PART);
96 }
97 // crit_chance_bonus + de-rated damage
98 "019584aa-5bde-7ac2-8850-076dafdc4603" => {
99 const CRIT_CHANCE_BONUS: f64 = 0.3;
100 let overall = dmg(ability_id)?;
101 let raw = overall / (1.0 + CRIT_CHANCE_BONUS);
102 info.crit_chance_bonus = Some(CRIT_CHANCE_BONUS);
103 info.damage = Some(raw);
104 }
105 // AoE-scaled damage
106 "019584f4-2c99-72bf-bcd3-bfc02fb33977" => {
107 info.damage = Some(dmg(ability_id)? * balance::AOE_COEF);
108 }
109 // PROJECTILES split
110 "019589e6-f9dd-7b22-8d39-5350e95aaf69" => {
111 const PROJECTILES: i64 = 3;
112 info.projectiles = Some(PROJECTILES);
113 info.damage = Some(dmg(ability_id)? / (PROJECTILES as f64));
114 }
115 // vampiric, damage de-rated by VAMPIRIC/HEAL_COEF
116 "019589f7-f4a3-701c-bc0f-f60d980ae250" => {
117 const VAMPIRIC: f64 = 0.3;
118 info.damage = Some(dmg(ability_id)? / (1.0 + VAMPIRIC / balance::HEAL_COEF));
119 info.vampiric = Some(VAMPIRIC);
120 }
121 // empower budget → effect_duration + damage
122 "01958a30-8f18-745f-928a-75028cb3ee99" => {
123 const EMPOWER_BUDGET: f64 = 0.3;
124 let raw_power = dmg(ability_id)?;
125 info.effect_duration = Some(balance::effect_duration(raw_power * EMPOWER_BUDGET));
126 info.damage = Some(raw_power * (1.0 - EMPOWER_BUDGET));
127 }
128 // hot only. Blessing of Life: a HoT, so it gets HEAL_COEF (like all
129 // healing) — but NOT also OT_COEF. The old `* HEAL_COEF * OT_COEF`
130 // double-counted (×1.2×1.2 = ×1.44), giving this one ability a unique
131 // 44% bonus that no peer heal/HoT had (vs e.g. vampiric touch), making it
132 // imbalanced. A HoT's over-time nature is already its identity; it should
133 // not also be paid the over-time coefficient on top of the heal one.
134 "01958ed0-d45f-7cad-b086-8f11962d3859" => {
135 info.hot = Some(dmg(ability_id)? * balance::HEAL_COEF);
136 }
137 // vulnerability budget → effect_duration + damage
138 "01958ef2-dff5-76dd-89f1-d9c2707b2ffc" => {
139 const VULNERABILITY_BUDGET: f64 = 0.3;
140 let raw_power = dmg(ability_id)?;
141 info.effect_duration = Some(balance::effect_duration(raw_power * VULNERABILITY_BUDGET));
142 info.damage = Some(raw_power * (1.0 - VULNERABILITY_BUDGET));
143 }
144 // OT-scaled damage + integer duration
145 "01958efd-77f9-7dec-8444-c9d759549225" => {
146 let damage = dmg(ability_id)?;
147 info.damage = Some(damage * balance::OT_COEF);
148 info.duration = Some(5);
149 }
150 // plain damage abilities
151 "019a0245-ff0c-7964-b345-ded525c71e74"
152 | "019a0246-5aaf-7c01-9a1a-3969e04129ec"
153 | "019a0246-cf87-73b0-b701-f8788cf9cc08"
154 | "019bff40-af44-75b7-940c-6074097a2925"
155 | "019c00a4-38c9-7859-a642-fd82c25ef285"
156 | "019cc464-e752-71c1-a9dd-8fda9f212801"
157 | "019cc465-14b8-7dbc-9799-4691b91805d3"
158 | "019cc465-2f63-7b54-8ddc-fcbcb483fe81" => {
159 info.damage = Some(dmg(ability_id)?);
160 }
161 other => {
162 anyhow::bail!("content::ability_info: no ability_info closure for ability id {other}");
163 }
164 }
165
166 Ok(info)
167}
168
169/// Native port of `content::get_item_by_code(code)`: find the item template
170/// whose `next_mimic_item_code` equals `code`, via
171/// [`ContentLookups::item_id_by_mimic_code`] (built at init from
172/// `ItemTemplate::next_mimic_item_code`). Returns the matching config
173/// returns the `content_raw` item map (the chest script then reads `item.id`).
174pub fn get_item_by_code<'a>(
175 config: &'a GameConfig,
176 lookups: &ContentLookups,
177 code: i64,
178) -> Option<&'a ItemTemplate> {
179 let id = lookups.item_id_by_mimic_code.get(&code).copied()?;
180 config.item_template(id)
181}
182
183/// One entry of `content_raw::inventory_levels`, mirroring the fields the
184/// `chest_item_choose_script` reads (`from_chapter_level`, `item_types`). This
185/// is exactly [`essences::item_case::InventoryLevel`].
186pub type InventoryLevel = essences::item_case::InventoryLevel;
187
188/// Native port of `content::get_inventory_levels()`: the configured inventory
189/// levels (`from_chapter_level`, `item_types`). Borrowed from config rather than
190/// cloned; callers sort/filter a local view.
191pub fn get_inventory_levels(config: &GameConfig) -> &[InventoryLevel] {
192 &config.inventory_levels
193}