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}