1use 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
23const 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#[derive(Clone, Debug, Eq, PartialEq)]
32#[non_exhaustive]
33pub(crate) enum ConsumableSetting {
34 Off,
35 LegacyDefault,
36 Named(String),
37}
38
39#[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
68pub(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
117fn 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
139fn 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
192async 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
332pub(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}