Skip to main content

wowlab_engine_application/settings/
consumables.rs

1// #t(file: rust_inline_test_module_size) private consumable parsing, ranking, and spell-mapping tests remain cohesive with their implementation
2
3//! Resolves consumable settings to buff spells.
4
5use std::collections::BTreeMap;
6
7use wowlab_engine_domain::dbc::{ConsumableSubclass, resolve_item_budget};
8use wowlab_engine_gamedata::{
9    ConsumableBuff, ConsumableSpells, FoodBuff, ItemBudget, RatingMultiplierSlot,
10    ResolvedGameTables,
11};
12use wowlab_engine_ports::{DataResolver, DynDataResolver, EngineError, SpellId, tokenize_name};
13use wowlab_types::{
14    constants::{
15        AUGMENT_RUNE_SPELL_ID, AUGMENT_RUNE_SPELL_IDS, FLASK_ALCHEMICAL_CHAOS_SPELL_ID,
16        FOOD_PRIMARY_STAT_COEFFICIENT_EFFECT_INDEX, FOOD_PRIMARY_STAT_COEFFICIENT_SPELL_ID,
17        PRIMARY_FOOD_BUFF_SPELL_IDS, TEMPERED_POTION_SPELL_ID,
18    },
19    data::ItemDataFlat,
20    game::RaceId,
21};
22
23/// Legacy `potion` value selecting the TWW default prepot.
24const TEMPERED_POTION_KEY: &str = "tempered";
25
26const TEMPERED_POTION_NAME: &str = "Tempered Potion";
27const FLASK_ALCHEMICAL_CHAOS_NAME: &str = "Flask of Alchemical Chaos";
28const AUGMENT_RUNE_NAME: &str = "Crystallized Augment Rune";
29
30/// One consumable slot as written in the intent settings.
31#[derive(Clone, Debug, Eq, PartialEq)]
32#[non_exhaustive]
33pub(crate) enum ConsumableSetting {
34    Off,
35    LegacyDefault,
36    Named(String),
37}
38
39/// The four consumable slots parsed from intent settings, before data resolution.
40#[derive(Clone, Debug, Eq, PartialEq)]
41pub(crate) struct ConsumableSelection {
42    pub potion: ConsumableSetting,
43    pub flask: ConsumableSetting,
44    pub food: ConsumableSetting,
45    pub augment_rune: ConsumableSetting,
46}
47
48fn parse_bool_or_name(
49    settings: &BTreeMap<String, toml::Value>,
50    key: &str,
51    default_on: bool,
52) -> Result<ConsumableSetting, EngineError> {
53    match settings.get(key) {
54        None => Ok(if default_on {
55            ConsumableSetting::LegacyDefault
56        } else {
57            ConsumableSetting::Off
58        }),
59        Some(toml::Value::Boolean(true)) => Ok(ConsumableSetting::LegacyDefault),
60        Some(toml::Value::Boolean(false)) => Ok(ConsumableSetting::Off),
61        Some(toml::Value::String(s)) => Ok(ConsumableSetting::Named(s.clone())),
62        Some(other) => Err(EngineError::intent_validation(format!(
63            "settings[\"{key}\"] must be a boolean or a consumable name string, got {other:?}"
64        ))),
65    }
66}
67
68/// Parses consumable selections, including legacy boolean settings.
69pub(crate) fn parse_consumable_selection(
70    settings: &BTreeMap<String, toml::Value>,
71) -> Result<ConsumableSelection, EngineError> {
72    let pre_pot = settings
73        .get("pre_pot")
74        .and_then(toml::Value::as_bool)
75        .unwrap_or(false);
76    let potion = match settings.get("potion") {
77        _ if !pre_pot => ConsumableSetting::Off,
78        None => ConsumableSetting::Off,
79        Some(toml::Value::String(s)) if s == TEMPERED_POTION_KEY => {
80            ConsumableSetting::LegacyDefault
81        }
82        Some(toml::Value::String(s)) => ConsumableSetting::Named(s.clone()),
83        Some(other) => {
84            return Err(EngineError::intent_validation(format!(
85                "settings[\"potion\"] must be a consumable name string, got {other:?}"
86            )));
87        }
88    };
89
90    let food = match settings.get("food") {
91        None | Some(toml::Value::Boolean(false)) => ConsumableSetting::Off,
92        Some(toml::Value::String(s)) => ConsumableSetting::Named(s.clone()),
93        Some(other) => {
94            return Err(EngineError::intent_validation(format!(
95                "settings[\"food\"] must be a consumable name string, got {other:?}"
96            )));
97        }
98    };
99
100    Ok(ConsumableSelection {
101        potion,
102        flask: parse_bool_or_name(settings, "flask", true)?,
103        food,
104        augment_rune: parse_bool_or_name(settings, "augment_rune", true)?,
105    })
106}
107
108fn split_quality_rank(token: &str) -> (&str, u8) {
109    match token.rsplit_once('_') {
110        Some((base, rank)) if rank.len() == 1 && rank.chars().all(|c| c.is_ascii_digit()) => {
111            (base, rank.parse().unwrap_or(1).max(1))
112        }
113        _ => (token, 1),
114    }
115}
116
117/// Pick the item for `token` at crafting-quality `rank`: exact tokenized-name matches win over the shortest containing name; same-named rank variants order by item level (rank 1 = lowest), with `rank` clamped to the available variants.
118fn pick_item<'a>(items: &'a [ItemDataFlat], token: &str, rank: u8) -> Option<&'a ItemDataFlat> {
119    let mut group: Vec<&ItemDataFlat> = items
120        .iter()
121        .filter(|item| tokenize_name(&item.name) == token)
122        .collect();
123
124    if group.is_empty() {
125        let best = items
126            .iter()
127            .filter(|item| tokenize_name(&item.name).contains(token))
128            .min_by_key(|item| (item.name.len(), item.id))?;
129
130        group = items.iter().filter(|item| item.name == best.name).collect();
131    }
132
133    group.sort_by_key(|item| (item.item_level, item.id));
134    let index = usize::from(rank - 1).min(group.len() - 1);
135
136    group.get(index).copied()
137}
138
139/// The item's on-use spell.
140fn use_spell_id(item: &ItemDataFlat) -> Option<u32> {
141    item.effects
142        .iter()
143        .find(|effect| {
144            wowlab_engine_domain::dbc::ItemEffectTrigger::try_from(effect.trigger_type).ok()
145                == Some(wowlab_engine_domain::dbc::ItemEffectTrigger::OnUse)
146                && effect.spell_id > 0
147        })
148        .map(|effect| wowlab_types::numeric::i32_to_u32_nonnegative(effect.spell_id))
149}
150
151struct ResolvedItemSpell {
152    spell_id: u32,
153    item_name: String,
154    item_level: i32,
155    quality: i32,
156}
157
158async fn resolve_named_consumable(
159    resolver: &DynDataResolver<'_>,
160    name: &str,
161    subclass: i32,
162    slot: &str,
163) -> Result<ResolvedItemSpell, EngineError> {
164    let token = tokenize_name(name);
165    let (base, rank) = split_quality_rank(&token);
166    let items = resolver
167        .find_consumable_items(base, subclass)
168        .await
169        .map_err(|e| {
170            EngineError::intent_validation(format!("failed to look up {slot} '{name}': {e}"))
171        })?;
172    let item = pick_item(&items, base, rank).ok_or_else(|| {
173        EngineError::intent_validation(format!(
174            "unknown {slot} '{name}' (no consumable item matches '{base}')"
175        ))
176    })?;
177    let spell_id = use_spell_id(item).ok_or_else(|| {
178        EngineError::intent_validation(format!(
179            "{slot} '{name}' (item {}) has no on-use spell",
180            item.id
181        ))
182    })?;
183
184    Ok(ResolvedItemSpell {
185        spell_id,
186        item_name: item.name.to_string(),
187        item_level: item.item_level,
188        quality: item.quality,
189    })
190}
191
192/// Resolves the consuming item's ilvl budget; coefficient-valued consumable effects are item-budget-scaled, literal base-points effects are not.
193async fn consumable_item_budget(
194    resolver: &DynDataResolver<'_>,
195    resolved: &ResolvedItemSpell,
196) -> Result<Option<ItemBudget>, EngineError> {
197    let scaling = resolver.get_scaling_data().await.map_err(|e| {
198        EngineError::spec_construction(format!("failed to resolve scaling data: {e}"))
199    })?;
200    let tables = ResolvedGameTables::from_scaling(&scaling);
201    let budget = resolve_item_budget(
202        &tables,
203        resolved.item_level,
204        resolved.quality,
205        RatingMultiplierSlot::Armor,
206    )
207    .ok_or_else(|| {
208        EngineError::spec_construction(format!(
209            "RandPropPoints missing for consumable ilvl {}",
210            resolved.item_level
211        ))
212    })?;
213
214    Ok(Some(budget))
215}
216
217async fn resolve_buff_slot(
218    resolver: &DynDataResolver<'_>,
219    setting: &ConsumableSetting,
220    subclass: i32,
221    slot: &str,
222    legacy: (u32, &str),
223) -> Result<Option<ConsumableBuff>, EngineError> {
224    match setting {
225        ConsumableSetting::Off => Ok(None),
226        ConsumableSetting::LegacyDefault => Ok(Some(ConsumableBuff {
227            spell_id: legacy.0,
228            name: legacy.1.to_string(),
229            item_budget: None,
230        })),
231        ConsumableSetting::Named(name) => {
232            let resolved = resolve_named_consumable(resolver, name, subclass, slot).await?;
233            let item_budget = consumable_item_budget(resolver, &resolved).await?;
234
235            Ok(Some(ConsumableBuff {
236                spell_id: resolved.spell_id,
237                name: resolved.item_name,
238                item_budget,
239            }))
240        }
241    }
242}
243
244async fn resolve_augment_rune_slot(
245    resolver: &DynDataResolver<'_>,
246    setting: &ConsumableSetting,
247) -> Result<Option<ConsumableBuff>, EngineError> {
248    let name = match setting {
249        ConsumableSetting::Off => return Ok(None),
250        ConsumableSetting::LegacyDefault => {
251            return Ok(Some(ConsumableBuff {
252                spell_id: AUGMENT_RUNE_SPELL_ID,
253                name: AUGMENT_RUNE_NAME.to_string(),
254                item_budget: None,
255            }));
256        }
257        ConsumableSetting::Named(name) => name,
258    };
259    let token = tokenize_name(name);
260    let matching_spell = AUGMENT_RUNE_SPELL_IDS
261        .iter()
262        .find(|(fragment, _)| token.contains(fragment))
263        .map(|(_, id)| *id);
264    let spell_id = matching_spell
265        .ok_or_else(|| EngineError::intent_validation(format!("unknown augment rune '{name}'")))?;
266    let display_name = resolver
267        .get_spell(SpellId::new(wowlab_types::numeric::u32_to_i32_saturating(
268            spell_id,
269        )))
270        .await
271        .map_or_else(|_| name.clone(), |s| s.name.to_string());
272
273    Ok(Some(ConsumableBuff {
274        spell_id,
275        name: display_name,
276        item_budget: None,
277    }))
278}
279
280const PANDAREN_FOOD_MULTIPLIER: f64 = 2.0;
281
282const fn food_amount_multiplier(race: RaceId) -> f64 {
283    match race {
284        RaceId::PandarenA | RaceId::PandarenH => PANDAREN_FOOD_MULTIPLIER,
285        _ => 1.0,
286    }
287}
288
289fn food_buff_for_use_spell(use_spell: u32, name: String, race: RaceId) -> Option<FoodBuff> {
290    let (_, buff_spell) = PRIMARY_FOOD_BUFF_SPELL_IDS
291        .iter()
292        .find(|(use_id, _)| *use_id == use_spell)?;
293
294    Some(FoodBuff {
295        spell_id: *buff_spell,
296        name,
297        coeff_spell_id: FOOD_PRIMARY_STAT_COEFFICIENT_SPELL_ID,
298        coeff_effect: FOOD_PRIMARY_STAT_COEFFICIENT_EFFECT_INDEX,
299        amount_multiplier: food_amount_multiplier(race),
300    })
301}
302
303async fn resolve_food_slot(
304    resolver: &DynDataResolver<'_>,
305    setting: &ConsumableSetting,
306    race: RaceId,
307) -> Result<Option<FoodBuff>, EngineError> {
308    let name = match setting {
309        ConsumableSetting::Off => return Ok(None),
310        ConsumableSetting::LegacyDefault => {
311            return Err(EngineError::intent_validation(
312                "settings[\"food\"] has no boolean default; pass a food name".to_string(),
313            ));
314        }
315        ConsumableSetting::Named(name) => name,
316    };
317    let resolved =
318        resolve_named_consumable(resolver, name, ConsumableSubclass::Food as i32, "food").await?;
319    let food = food_buff_for_use_spell(resolved.spell_id, resolved.item_name, race);
320
321    if food.is_none() {
322        tracing::warn!(
323            food = name,
324            use_spell = resolved.spell_id,
325            "food is not in the modelled primary-stat food table; skipping its Well Fed buff"
326        );
327    }
328
329    Ok(food)
330}
331
332/// Resolve a parsed [`ConsumableSelection`] to buff spells via the data resolver.
333pub(crate) async fn resolve_consumable_spells(
334    resolver: &DynDataResolver<'_>,
335    selection: &ConsumableSelection,
336    race: RaceId,
337) -> Result<ConsumableSpells, EngineError> {
338    Ok(ConsumableSpells {
339        potion: resolve_buff_slot(
340            resolver,
341            &selection.potion,
342            ConsumableSubclass::Potion as i32,
343            "potion",
344            (TEMPERED_POTION_SPELL_ID, TEMPERED_POTION_NAME),
345        )
346        .await?,
347        flask: resolve_buff_slot(
348            resolver,
349            &selection.flask,
350            ConsumableSubclass::Flask as i32,
351            "flask",
352            (FLASK_ALCHEMICAL_CHAOS_SPELL_ID, FLASK_ALCHEMICAL_CHAOS_NAME),
353        )
354        .await?,
355        food: resolve_food_slot(resolver, &selection.food, race).await?,
356        augment_rune: resolve_augment_rune_slot(resolver, &selection.augment_rune).await?,
357    })
358}
359
360#[cfg(test)]
361mod tests {
362    use googletest::prelude::*;
363    use rstest::rstest;
364    use wowlab_engine_domain::dbc::{SpellAttributeKind, spell_attribute_is};
365
366    use super::*;
367
368    fn settings(pairs: Vec<(&str, toml::Value)>) -> BTreeMap<String, toml::Value> {
369        pairs.into_iter().map(|(k, v)| (k.to_string(), v)).collect()
370    }
371
372    #[gtest]
373    fn defaults_are_legacy_flask_and_rune_no_potion_no_food() -> Result<()> {
374        let sel = parse_consumable_selection(&settings(vec![]))?;
375
376        verify_that!(
377            sel,
378            matches_pattern!(ConsumableSelection {
379                potion: eq(&ConsumableSetting::Off),
380                flask: eq(&ConsumableSetting::LegacyDefault),
381                food: eq(&ConsumableSetting::Off),
382                augment_rune: eq(&ConsumableSetting::LegacyDefault),
383            })
384        )
385    }
386
387    #[gtest]
388    #[rstest]
389    #[case::tempered_prepot(Some("tempered"), true, ConsumableSetting::LegacyDefault)]
390    #[case::named_prepot(
391        Some("lights_potential_2"),
392        true,
393        ConsumableSetting::Named("lights_potential_2".to_string())
394    )]
395    #[case::no_prepot(Some("lights_potential_2"), false, ConsumableSetting::Off)]
396    #[case::prepot_without_potion_stays_off(None, true, ConsumableSetting::Off)]
397    fn potion_gated_on_pre_pot(
398        #[case] potion: Option<&str>,
399        #[case] pre_pot: bool,
400        #[case] expected: ConsumableSetting,
401    ) -> Result<()> {
402        let mut pairs = vec![("pre_pot", toml::Value::Boolean(pre_pot))];
403
404        if let Some(p) = potion {
405            pairs.push(("potion", toml::Value::String(p.to_string())));
406        }
407
408        let sel = parse_consumable_selection(&settings(pairs))?;
409
410        verify_that!(sel.potion, eq(&expected))
411    }
412
413    #[gtest]
414    #[rstest]
415    #[case::bool_false(toml::Value::Boolean(false), ConsumableSetting::Off)]
416    #[case::bool_true(toml::Value::Boolean(true), ConsumableSetting::LegacyDefault)]
417    #[case::named(
418        toml::Value::String("flask_of_the_shattered_sun_2".to_string()),
419        ConsumableSetting::Named("flask_of_the_shattered_sun_2".to_string())
420    )]
421    fn flask_accepts_bool_or_name(
422        #[case] value: toml::Value,
423        #[case] expected: ConsumableSetting,
424    ) -> Result<()> {
425        let sel = parse_consumable_selection(&settings(vec![("flask", value)]))?;
426
427        verify_that!(sel.flask, eq(&expected))
428    }
429
430    #[gtest]
431    fn flask_rejects_non_bool_non_string() -> Result<()> {
432        let result =
433            parse_consumable_selection(&settings(vec![("flask", toml::Value::Integer(1))]));
434
435        verify_that!(result, err(anything()))
436    }
437
438    #[gtest]
439    fn food_rejects_boolean_true() -> Result<()> {
440        let result =
441            parse_consumable_selection(&settings(vec![("food", toml::Value::Boolean(true))]));
442
443        verify_that!(result, err(anything()))
444    }
445
446    #[gtest]
447    #[rstest]
448    #[case::rank_stripped("flask_of_the_shattered_sun_2", "flask_of_the_shattered_sun")]
449    #[case::rank_three("lights_potential_3", "lights_potential")]
450    #[case::no_rank("void_touched", "void_touched")]
451    #[case::trailing_word_not_rank("royal_roast", "royal_roast")]
452    fn split_quality_rank_arms(#[case] input: &str, #[case] expected: &str) -> Result<()> {
453        verify_that!(split_quality_rank(input).0, eq(expected))
454    }
455
456    fn item(id: i32, name: &str, use_spell: i32) -> ItemDataFlat {
457        ItemDataFlat {
458            id,
459            name: name.into(),
460            effects: vec![wowlab_types::data::ItemEffect {
461                spell_id: use_spell,
462                trigger_type: 0,
463                charges: 0,
464                cooldown: 0,
465                category_cooldown: 0,
466            }],
467            ..ItemDataFlat::default()
468        }
469    }
470
471    fn item_at_ilvl(id: i32, name: &str, use_spell: i32, item_level: i32) -> ItemDataFlat {
472        ItemDataFlat {
473            item_level,
474            ..item(id, name, use_spell)
475        }
476    }
477
478    #[gtest]
479    fn pick_item_prefers_exact_token_over_fleeting_variant() -> Result<()> {
480        let items = vec![
481            item(245_928, "Fleeting Flask of the Shattered Sun", 1_235_111),
482            item(241_326, "Flask of the Shattered Sun", 1_235_111),
483        ];
484        let picked = pick_item(&items, "flask_of_the_shattered_sun", 1).or_fail()?;
485
486        verify_that!(picked.id, eq(241_326))
487    }
488
489    #[gtest]
490    fn pick_item_rank_selects_by_item_level() -> Result<()> {
491        let items = vec![
492            item_at_ilvl(241_326, "Flask of the Shattered Sun", 1_235_111, 295),
493            item_at_ilvl(241_327, "Flask of the Shattered Sun", 1_235_111, 278),
494        ];
495        let rank1 = pick_item(&items, "flask_of_the_shattered_sun", 1).or_fail()?;
496        let rank2 = pick_item(&items, "flask_of_the_shattered_sun", 2).or_fail()?;
497        let rank9 = pick_item(&items, "flask_of_the_shattered_sun", 9).or_fail()?;
498
499        verify_that!(rank1.item_level, eq(278))?;
500        verify_that!(rank2.item_level, eq(295))?;
501
502        verify_that!(rank9.item_level, eq(295))
503    }
504
505    #[gtest]
506    fn pick_item_falls_back_to_shortest_containing_name() -> Result<()> {
507        let items = vec![
508            item(2, "Quel'dorei Medley Deluxe", 11),
509            item(1, "Quel'dorei Medley", 10),
510        ];
511        let picked = pick_item(&items, "medley", 1).or_fail()?;
512
513        verify_that!(picked.id, eq(1))
514    }
515
516    #[gtest]
517    fn pick_item_no_match_is_none() -> Result<()> {
518        let items = vec![item(1, "Flask of the Magisters", 5)];
519
520        verify_that!(pick_item(&items, "lights_potential", 1), none())
521    }
522
523    #[gtest]
524    #[rstest]
525    #[case::rank_two("flask_of_the_shattered_sun_2", 2)]
526    #[case::no_rank("void_touched", 1)]
527    fn split_quality_rank_parses_rank(#[case] input: &str, #[case] expected: u8) -> Result<()> {
528        verify_that!(split_quality_rank(input).1, eq(expected))
529    }
530
531    #[gtest]
532    fn scale_ilevel_attribute_uses_shared_registry_coordinates() -> Result<()> {
533        let mut attributes = vec![0i32; 16];
534
535        verify_that!(
536            spell_attribute_is(&attributes, SpellAttributeKind::ScalesWithCastingItemLevel),
537            eq(false)
538        )?;
539        let kind = SpellAttributeKind::ScalesWithCastingItemLevel;
540
541        attributes[kind.block()] = kind.mask();
542
543        verify_that!(spell_attribute_is(&attributes, kind), eq(true))
544    }
545
546    #[gtest]
547    fn food_buff_map_covers_harandar_and_misses_unknown() -> Result<()> {
548        let food =
549            food_buff_for_use_spell(1_259_658, "Harandar Celebration".to_string(), RaceId::Human)
550                .or_fail()?;
551
552        verify_that!(
553            food,
554            matches_pattern!(FoodBuff {
555                spell_id: eq(&1_232_582),
556                coeff_spell_id: eq(&FOOD_PRIMARY_STAT_COEFFICIENT_SPELL_ID),
557                coeff_effect: eq(&FOOD_PRIMARY_STAT_COEFFICIENT_EFFECT_INDEX),
558                amount_multiplier: near(1.0, 1e-12),
559                ..
560            })
561        )?;
562
563        verify_that!(
564            food_buff_for_use_spell(999, "x".to_string(), RaceId::Human),
565            none()
566        )
567    }
568
569    #[gtest]
570    fn pandaren_food_is_doubled() -> Result<()> {
571        let food = food_buff_for_use_spell(
572            1_259_658,
573            "Harandar Celebration".to_string(),
574            RaceId::PandarenA,
575        )
576        .or_fail()?;
577
578        verify_that!(food.amount_multiplier, near(2.0, 1e-12))
579    }
580}