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    /// `max_targets` — AoE cap (from the ability template config field). `None`
45    /// = unlimited; the band/cone AoE behaviors take the first N band-eligible
46    /// targets in deterministic order.
47    pub max_targets: Option<i64>,
48}
49
50impl AbilityInfo {
51    /// Rescales the ability's numbers by the stones active on it. Damage-like
52    /// keys share one multiplier, applied-effect durations share another, and
53    /// the AoE cap grows by the socketed target bonus. The multipliers are
54    /// identity when nothing is socketed, so a stoneless character reads exactly
55    /// the config numbers.
56    pub fn apply_stone_mods(&mut self, mods: &essences::ability_stones::AbilityStoneMods) {
57        if mods.is_identity() {
58            return;
59        }
60        // Condense folds the ability's own copies (Arcane Missiles' projectiles,
61        // Thousand Cuts' flurry) into one hit that carries their whole budget,
62        // BEFORE the payload multipliers — the collapsed hit is then multiplied
63        // like any other payload.
64        if mods.condense
65            && let Some(copies) = self.projectiles.filter(|c| *c > 1)
66        {
67            for value in [&mut self.damage, &mut self.dot, &mut self.hot]
68                .into_iter()
69                .flatten()
70            {
71                *value *= copies as f64;
72            }
73            self.projectiles = Some(1);
74        }
75        for value in [&mut self.damage, &mut self.dot, &mut self.hot]
76            .into_iter()
77            .flatten()
78        {
79            *value *= mods.damage_mult;
80        }
81        if let Some(duration) = &mut self.effect_duration {
82            *duration *= mods.effect_duration_mult;
83        }
84        if let Some(duration) = &mut self.duration {
85            *duration = ((*duration as f64) * mods.effect_duration_mult).round() as i64;
86        }
87        self.max_targets = mods.apply_coverage(self.max_targets);
88    }
89
90    /// All-`None` base; closures set the keys they produce.
91    pub fn none() -> Self {
92        Self::empty()
93    }
94
95    /// All-`None` base; closures set the keys they produce.
96    fn empty() -> Self {
97        AbilityInfo {
98            damage: None,
99            dot: None,
100            hot: None,
101            effect_duration: None,
102            duration: None,
103            crit_chance_bonus: None,
104            vampiric: None,
105            projectiles: None,
106            max_targets: None,
107        }
108    }
109}
110
111/// Native port of the `content` module's `get_ability_info(id, level)`:
112/// dispatch to the per-ability `ability_info(level)` closure baked into the
113/// ability map. Returns `None` for ability ids that have no `ability_info`
114///
115/// Faithfully mirrors each of the 18 closures'
116/// arithmetic; the per-ability constants are ported verbatim from
117pub fn ability_info(
118    config: &GameConfig,
119    lookups: &ContentLookups,
120    ability_id: Uuid,
121    level: i64,
122) -> anyhow::Result<AbilityInfo> {
123    // `balance::ability_damage(uuid(id), level)` in every closure.
124    let dmg = |id: Uuid| -> anyhow::Result<f64> {
125        balance::ability_damage_from_id(config, lookups, id, level)
126            .map_err(|e| anyhow::anyhow!("balance::ability_damage({id}): {e}"))
127    };
128
129    let id_str = ability_id.to_string();
130    let mut info = AbilityInfo::empty();
131
132    match id_str.as_str() {
133        // damage: power
134        "0194d64e-20f2-75e5-89c8-4cb812672485" => {
135            info.damage = Some(dmg(ability_id)?);
136        }
137        // DOT split: damage = base*K*INSTANT, dot = base*K*DOT_PART
138        "01958172-9e65-7061-9d15-56b2c33cc13e" => {
139            const DOT_PART: f64 = 0.5;
140            const INSTANT: f64 = 1.0 - DOT_PART;
141            let base_dmg = dmg(ability_id)?;
142            let k = INSTANT + DOT_PART * balance::OT_COEF;
143            info.damage = Some(base_dmg * k * INSTANT);
144            info.dot = Some(base_dmg * k * DOT_PART);
145        }
146        // crit_chance_bonus + de-rated damage
147        "019584aa-5bde-7ac2-8850-076dafdc4603" => {
148            const CRIT_CHANCE_BONUS: f64 = 0.3;
149            let overall = dmg(ability_id)?;
150            let raw = overall / (1.0 + CRIT_CHANCE_BONUS);
151            info.crit_chance_bonus = Some(CRIT_CHANCE_BONUS);
152            info.damage = Some(raw);
153        }
154        // AoE-scaled damage
155        "019584f4-2c99-72bf-bcd3-bfc02fb33977" => {
156            info.damage = Some(dmg(ability_id)? * balance::AOE_COEF);
157        }
158        // PROJECTILES split
159        "019589e6-f9dd-7b22-8d39-5350e95aaf69" => {
160            const PROJECTILES: i64 = 3;
161            info.projectiles = Some(PROJECTILES);
162            info.damage = Some(dmg(ability_id)? / (PROJECTILES as f64));
163        }
164        // vampiric, damage de-rated by VAMPIRIC/HEAL_COEF
165        "019589f7-f4a3-701c-bc0f-f60d980ae250" => {
166            const VAMPIRIC: f64 = 0.3;
167            info.damage = Some(dmg(ability_id)? / (1.0 + VAMPIRIC / balance::HEAL_COEF));
168            info.vampiric = Some(VAMPIRIC);
169        }
170        // empower budget → effect_duration + damage
171        "01958a30-8f18-745f-928a-75028cb3ee99" => {
172            const EMPOWER_BUDGET: f64 = 0.3;
173            let raw_power = dmg(ability_id)?;
174            info.effect_duration = Some(balance::effect_duration(raw_power * EMPOWER_BUDGET));
175            info.damage = Some(raw_power * (1.0 - EMPOWER_BUDGET));
176        }
177        // hot only. Blessing of Life: a HoT, so it gets HEAL_COEF (like all
178        // healing) — but NOT also OT_COEF. The old `* HEAL_COEF * OT_COEF`
179        // double-counted (×1.2×1.2 = ×1.44), giving this one ability a unique
180        // 44% bonus that no peer heal/HoT had (vs e.g. vampiric touch), making it
181        // imbalanced. A HoT's over-time nature is already its identity; it should
182        // not also be paid the over-time coefficient on top of the heal one.
183        "01958ed0-d45f-7cad-b086-8f11962d3859" => {
184            info.hot = Some(dmg(ability_id)? * balance::HEAL_COEF);
185        }
186        // vulnerability budget → effect_duration + damage
187        "01958ef2-dff5-76dd-89f1-d9c2707b2ffc" => {
188            const VULNERABILITY_BUDGET: f64 = 0.3;
189            let raw_power = dmg(ability_id)?;
190            info.effect_duration = Some(balance::effect_duration(raw_power * VULNERABILITY_BUDGET));
191            info.damage = Some(raw_power * (1.0 - VULNERABILITY_BUDGET));
192        }
193        // OT-scaled damage + integer duration
194        "01958efd-77f9-7dec-8444-c9d759549225" => {
195            let damage = dmg(ability_id)?;
196            info.damage = Some(damage * balance::OT_COEF);
197            info.duration = Some(5);
198        }
199        // plain damage abilities
200        "019a0245-ff0c-7964-b345-ded525c71e74"
201        | "019a0246-5aaf-7c01-9a1a-3969e04129ec"
202        | "019a0246-cf87-73b0-b701-f8788cf9cc08"
203        | "019bff40-af44-75b7-940c-6074097a2925"
204        | "019c00a4-38c9-7859-a642-fd82c25ef285"
205        | "019cc464-e752-71c1-a9dd-8fda9f212801"
206        | "019cc465-14b8-7dbc-9799-4691b91805d3"
207        | "019cc465-2f63-7b54-8ddc-fcbcb483fe81" => {
208            info.damage = Some(dmg(ability_id)?);
209        }
210
211        // --- Class kits -----------------------------------------------------
212        // Unlike gacha abilities, class kits do NOT price off the shared
213        // eff·k·cd budget: BAL-033 authors their payloads outright, as shares of
214        // ATK (damage) or of the RECIPIENT's Max HP (heals). Rank moves the
215        // payload and nothing else — `class_rank_payload_mult` is `×1.0…1.6`
216        // over ranks 1…7, and durations, buff magnitudes, target caps,
217        // cooldowns and Mana costs stay flat by design.
218        //
219        // `level` here IS the ability's rank: class abilities rank on Class
220        // Level, not on the shard ladder.
221
222        // Backstab (Rogue): `400% ATK`, guaranteed crit, `×1.70` against a
223        // target under 30% HP. The crit is unconditional in
224        // `backstab_cast`, so no crit-chance bonus is priced here.
225        "019dfc4c-75ea-716c-a453-801c968be604" => {
226            const BASE_ATK_SHARE: f64 = 4.00;
227            info.damage = Some(BASE_ATK_SHARE * balance::class_rank_payload_mult(level));
228        }
229        // Thousand Cuts (Rogue): `10 × 50% ATK` on one target — ten separate
230        // hit events under ONE cast and one Mana payment (BAL-034).
231        "019dfc4f-5c4f-7829-affa-11b21a735f78" => {
232            const CUTS: i64 = 10;
233            const CUT_ATK_SHARE: f64 = 0.50;
234            info.projectiles = Some(CUTS);
235            info.damage = Some(CUT_ATK_SHARE * balance::class_rank_payload_mult(level));
236        }
237        // Arcane Blast (Mage): `500% ATK` on the target; `arcane_blast_cast`
238        // washes `SECONDARY_SHARE` of it over every other living enemy, with no
239        // target cap, for the authored `200%`.
240        "019dfcfb-7b13-7cf2-b8e3-ab9546310c2b" => {
241            const BASE_ATK_SHARE: f64 = 5.00;
242            info.damage = Some(BASE_ATK_SHARE * balance::class_rank_payload_mult(level));
243        }
244        // Rewind (Mage): `200% ATK` AoE plus Weakness (`−50% ATK`) for `5s`.
245        // The duration is authored flat — rank buys damage only.
246        "019dfcfe-704c-73cf-b6e8-60f85e86799d" => {
247            const BASE_ATK_SHARE: f64 = 2.00;
248            const WEAKNESS_SECONDS: f64 = 5.0;
249            info.damage = Some(BASE_ATK_SHARE * balance::class_rank_payload_mult(level));
250            info.effect_duration = Some(WEAKNESS_SECONDS);
251        }
252        // Fortify (Warrior): `50%` damage reduction for `4s` (the Protection
253        // effect's own magnitude) plus a heal for `10%` of the Warrior's Max HP.
254        // `hot` carries the Max-HP SHARE, not an ATK share — which is also what
255        // the tooltip's `floor(hot * 100)` wants to print.
256        "019dfd00-594e-755f-8132-1c320fb2b5e9" => {
257            const PROTECTION_SECONDS: f64 = 4.0;
258            const HEAL_MAX_HP_SHARE: f64 = 0.10;
259            info.effect_duration = Some(PROTECTION_SECONDS);
260            info.hot = Some(HEAL_MAX_HP_SHARE * balance::class_rank_payload_mult(level));
261        }
262        // War Cry (Warrior): `120% ATK` to up to three enemies plus `+30% final
263        // damage` on self for `5s` (the War Fury effect carries the magnitude).
264        "019dfd01-073e-7aee-a993-74e20b3c439c" => {
265            const BASE_ATK_SHARE: f64 = 1.20;
266            const FURY_SECONDS: f64 = 5.0;
267            info.damage = Some(BASE_ATK_SHARE * balance::class_rank_payload_mult(level));
268            info.effect_duration = Some(FURY_SECONDS);
269        }
270        // Battle Heal (Priest): the whole team heals for `12%` of EACH
271        // recipient's own Max HP and gains `+15% ATK / +15% Armor` for `6s`.
272        "019dfd02-0c6a-7f4d-bb45-4ec2a5fc231a" => {
273            const HEAL_MAX_HP_SHARE: f64 = 0.12;
274            const BLESSING_SECONDS: f64 = 6.0;
275            info.hot = Some(HEAL_MAX_HP_SHARE * balance::class_rank_payload_mult(level));
276            info.effect_duration = Some(BLESSING_SECONDS);
277        }
278        // Holy Nova (Priest): `180% ATK` to up to three enemies plus ONE heal
279        // for `10%` of the recipient's Max HP — the ally with the lowest HP
280        // share, which in solo is the Priest. It is a flat share now, not
281        // lifesteal, so `vampiric` is deliberately absent.
282        "019dfd02-add4-7373-912c-8483150fd341" => {
283            const BASE_ATK_SHARE: f64 = 1.80;
284            const HEAL_MAX_HP_SHARE: f64 = 0.10;
285            let rank = balance::class_rank_payload_mult(level);
286            info.damage = Some(BASE_ATK_SHARE * rank);
287            info.hot = Some(HEAL_MAX_HP_SHARE * rank);
288        }
289
290        // --- Pet-ult clones ---------------------------------------------------
291        // Dedicated ability entities so a pet's ult never collides with the
292        // player's equipped copy of the donor gacha ability (the cast pipeline
293        // keys by template id). Combat behaviors hardcode the DONOR uuid, so
294        // the clone's numbers ARE the donor's at the pet's level — delegate.
295        "11db667a-a0d8-4d31-81a7-2e479d260cdc" => {
296            // Albite → Vampiric Touch
297            return ability_info(
298                config,
299                lookups,
300                Uuid::parse_str("019589f7-f4a3-701c-bc0f-f60d980ae250").unwrap(),
301                level,
302            );
303        }
304        "781e3e5f-f6e6-47d9-b4ec-b23ccc45ca10" => {
305            // Nyx → Strike
306            return ability_info(
307                config,
308                lookups,
309                Uuid::parse_str("019584aa-5bde-7ac2-8850-076dafdc4603").unwrap(),
310                level,
311            );
312        }
313        "defe3366-5f0f-4efb-a20b-a768e32308c9" => {
314            // Glimmer → Fireball
315            return ability_info(
316                config,
317                lookups,
318                Uuid::parse_str("01958172-9e65-7061-9d15-56b2c33cc13e").unwrap(),
319                level,
320            );
321        }
322        "6ade394c-78de-4bed-ac67-ef85f2525126" => {
323            // Nugget → Blessing of Life
324            return ability_info(
325                config,
326                lookups,
327                Uuid::parse_str("01958ed0-d45f-7cad-b086-8f11962d3859").unwrap(),
328                level,
329            );
330        }
331        "4342f487-315b-441d-b224-1676f67c1a72" => {
332            // Rusty → Nova
333            return ability_info(
334                config,
335                lookups,
336                Uuid::parse_str("019584f4-2c99-72bf-bcd3-bfc02fb33977").unwrap(),
337                level,
338            );
339        }
340        "15f66404-77f7-48d7-8ecd-9d9369fcd263" => {
341            // Breezy → Arcane Missiles
342            return ability_info(
343                config,
344                lookups,
345                Uuid::parse_str("019589e6-f9dd-7b22-8d39-5350e95aaf69").unwrap(),
346                level,
347            );
348        }
349        "cd3dee36-9285-41ff-9f55-49102f133367" => {
350            // Thingy → Immolate
351            return ability_info(
352                config,
353                lookups,
354                Uuid::parse_str("01958efd-77f9-7dec-8444-c9d759549225").unwrap(),
355                level,
356            );
357        }
358        other => {
359            anyhow::bail!("content::ability_info: no ability_info closure for ability id {other}");
360        }
361    }
362
363    // AoE cap comes straight from the ability template config field (§1). Absent
364    // ⇒ unlimited (legacy). Pet-ult clones return early above and inherit the
365    // donor's cap via delegation.
366    info.max_targets = config
367        .ability_template(ability_id)
368        .and_then(|t| t.max_targets);
369
370    Ok(info)
371}
372
373/// Native port of `content::get_item_by_code(code)`: find the item template
374/// whose `next_mimic_item_code` equals `code`, via
375/// [`ContentLookups::item_id_by_mimic_code`] (built at init from
376/// `ItemTemplate::next_mimic_item_code`). Returns the matching config
377/// returns the `content_raw` item map (the chest script then reads `item.id`).
378pub fn get_item_by_code<'a>(
379    config: &'a GameConfig,
380    lookups: &ContentLookups,
381    code: i64,
382) -> Option<&'a ItemTemplate> {
383    let id = lookups.item_id_by_mimic_code.get(&code).copied()?;
384    config.item_template(id)
385}
386
387/// One entry of `content_raw::inventory_levels`, mirroring the fields the
388/// `chest_item_choose_script` reads (`from_chapter_level`, `item_types`). This
389/// is exactly [`essences::item_case::InventoryLevel`].
390pub type InventoryLevel = essences::item_case::InventoryLevel;
391
392/// Native port of `content::get_inventory_levels()`: the configured inventory
393/// levels (`from_chapter_level`, `item_types`). Borrowed from config rather than
394/// cloned; callers sort/filter a local view.
395pub fn get_inventory_levels(config: &GameConfig) -> &[InventoryLevel] {
396    &config.inventory_levels
397}