Code health in `benilla-ui`, found while its comments were rewritten. No behaviour a player can see changes.
- **Unused globals and queues removed.**
- The Rust `ChatFrame_OpenChat` and `ChatFrame_SendTell` queued text that nothing drained. The stock `ChatFrame.lua` defines both globals and replaces them when it loads, as the reference's FrameXML does.
- `BenillaGetItemStats` had no Lua caller. Its test of the item cache's miss, ask and push now runs through `GetItemInfo`, which shares that path.
- `UiScript::queue_social_request` had no caller.
- **One true/nil helper.** Seven modules each carried their own copy of `binding_abi::flag`, the reference's `1`-or-nil boolean. They call it now.
- **Three tests test their claim.**
- `click_focuses_regardless_of_autofocus_…` built both boxes with autoFocus on. One is now off.
- `level_shift_saturates_at_zero` never saturated. Its child now drops past 0.
- The layout profiler's `plan` bucket was never set, so it always printed 0. It is gone.
- **Smaller fixes.** An empty `if buyback {}` branch is gone, and a doc comment no longer sits on a `use` line instead of on `install_justify`.
Checked with `cargo clippy -p benilla-ui --all-targets -D warnings` and `cargo test -p benilla-ui` (1513 passed) before the land gates.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
810 lines
31 KiB
Rust
810 lines
31 KiB
Rust
//! The craft bindings the stock `Blizzard_CraftUI` window calls, over the recipe snapshot the app
|
|
//! pushes ([`UiScript::set_craft`]); `DoCraft` and `CloseCraft` queue intents the app drains. The
|
|
//! window has one action button, `DoCraft(GetCraftSelectionIndex())` (`Blizzard_CraftUI.xml:667`),
|
|
//! and no Create All, so a craft intent is a bare spell id.
|
|
//!
|
|
//! Not built: the reference's header rows (`0x4f60c0`). The list is flat, `GetCraftInfo` never
|
|
//! answers `"header"` and `Expand`/`CollapseCraftSkillLine` do nothing; Enchanting, with a single
|
|
//! group, renders flat in the reference too.
|
|
|
|
use mlua::{Lua, MultiValue, Value, Variadic};
|
|
|
|
use super::binding_abi::{self, number_arg};
|
|
use super::item_stats::item_link;
|
|
use super::Model;
|
|
|
|
/// One reagent a recipe consumes (`GetCraftReagentInfo`); name and icon are `None` until the item
|
|
/// template answer lands.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct CraftReagent {
|
|
/// The item entry `GameTooltip:SetCraftItem` resolves through.
|
|
pub item: u32,
|
|
pub name: Option<String>,
|
|
pub icon: Option<String>,
|
|
pub need: u32,
|
|
pub have: u32,
|
|
}
|
|
|
|
/// What `GameTooltip:SetCraftSpell` (`0x533e90`) describes for a row, resolved by the app from the
|
|
/// recipe's own `Effect[i]`: 24 `CREATE_ITEM` picks the item, 36 `LEARN_SPELL` the taught spell.
|
|
/// Unlike the trainer's, it never tests 57 (`LEARN_PET_SPELL`), sets no altCaster and never checks
|
|
/// that the target resolves.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub enum CraftTooltip {
|
|
/// The item builder (`0x52b650`) on the matched `CREATE_ITEM` slot's `EffectItemType`.
|
|
Item(u32),
|
|
/// The spell builder (`0x52e610`) on the matched `LEARN_SPELL` slot's `EffectTriggerSpell`, or
|
|
/// on the recipe itself when no slot matches, as for every enchant.
|
|
Spell(u32),
|
|
}
|
|
|
|
impl Default for CraftTooltip {
|
|
fn default() -> Self {
|
|
CraftTooltip::Spell(0)
|
|
}
|
|
}
|
|
|
|
/// One recipe row: a known `SPELL_ATTR_IS_TRADESKILL` spell, resolved by the app.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct CraftRecipe {
|
|
/// What `DoCraft` queues for `CMSG_CAST_SPELL`.
|
|
pub spell_id: u32,
|
|
pub name: String,
|
|
/// The rank subtext (`craftSubSpellName`), usually empty for an enchant; the stock window
|
|
/// paints it in parentheses when set (`Blizzard_CraftUI.lua:217-221`).
|
|
pub sub_name: String,
|
|
/// `GetCraftInfo`'s `craftType`: one of the four keys `CraftTypeColor` colours as
|
|
/// `TradeSkillTypeColor` does (`Blizzard_CraftUI.lua:6-13`). The reference's table
|
|
/// (`0x807e00`) adds `"none"` and `"used"` (a still-optimal Beast Training recipe teaching a
|
|
/// learnable spell); Enchanting never produces either, and neither is built.
|
|
pub difficulty: super::TradeSkillDifficulty,
|
|
/// `numAvailable`: the least `floor(have / need)` over the reagents.
|
|
pub num_available: u32,
|
|
pub icon: Option<String>,
|
|
/// `GetCraftDescription`, `$`-tokens substituted by the app; `None` paints a blank line
|
|
/// (`Blizzard_CraftUI.lua:316`).
|
|
pub description: Option<String>,
|
|
/// An `ENCHANT_ITEM` effect (`TARGET_FLAG_ITEM`, `TARGET_FLAG_TRADE_ITEM`): `DoCraft` then arms
|
|
/// an app-side item pick. No Lua getter reads it.
|
|
pub needs_item_target: bool,
|
|
pub reagents: Vec<CraftReagent>,
|
|
/// The Requirements line's `(tool name, have)` pairs (`GetCraftSpellFocus`).
|
|
pub tools: Vec<(String, bool)>,
|
|
pub tooltip: CraftTooltip,
|
|
/// `Spell.dbc` `spellLevel` (`+0x74`), the Beast Training comparator's rank key. The
|
|
/// reference's `requiredLevel` return comes from it; here that return is 0.
|
|
pub spell_level: u32,
|
|
}
|
|
|
|
/// The open craft window, pushed whole by the app.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct CraftState {
|
|
/// The title `GetCraftName` answers, also `GetCraftDisplaySkillLine`'s name.
|
|
pub name: String,
|
|
pub rank: u32,
|
|
pub max_rank: u32,
|
|
/// The opener spell's `EffectMiscValue[0]`, stored at `ds:0xbdcfb8`: 1 Beast Training, 3
|
|
/// Enchanting. It picks the row comparator (`0x4f6765`).
|
|
pub craft_type: u32,
|
|
/// In any order; [`UiScript::set_craft`] sorts them.
|
|
pub recipes: Vec<CraftRecipe>,
|
|
}
|
|
|
|
/// Beast Training's craft type, the `EffectMiscValue[0]` of spell 5149.
|
|
const CRAFT_TYPE_BEAST_TRAINING: u32 = 1;
|
|
|
|
/// The craft row order: difficulty tier (`[+0xc]`) ascending, then name (collator `0x64a4c0`),
|
|
/// then, at Beast Training only (`0x4f6920`; every other type uses `0x4f67a0`), `spellLevel`
|
|
/// ascending, which orders a pet ability's ranks.
|
|
///
|
|
/// Deviation: a last `spell_id` key breaks the remaining ties (Charge's six ranks all have
|
|
/// `spellLevel` 0), because the reference leaves them to its qsort over an array benilla does not
|
|
/// reproduce; ascending id is ascending rank in every 1.12 pet-ability family.
|
|
fn recipe_order(a: &CraftRecipe, b: &CraftRecipe, craft_type: u32) -> std::cmp::Ordering {
|
|
a.difficulty
|
|
.tier()
|
|
.cmp(&b.difficulty.tier())
|
|
.then_with(|| collate(&a.name, &b.name))
|
|
.then_with(|| {
|
|
if craft_type == CRAFT_TYPE_BEAST_TRAINING {
|
|
a.spell_level.cmp(&b.spell_level)
|
|
} else {
|
|
std::cmp::Ordering::Equal
|
|
}
|
|
})
|
|
.then_with(|| a.spell_id.cmp(&b.spell_id))
|
|
}
|
|
|
|
/// Approximates the case-insensitive enUS collator the craft comparators call (`0x64a4c0`; the
|
|
/// trainer rows use `0x64a480`): case-folded order, raw bytes breaking ties.
|
|
fn collate(a: &str, b: &str) -> std::cmp::Ordering {
|
|
a.to_lowercase()
|
|
.cmp(&b.to_lowercase())
|
|
.then_with(|| a.cmp(b))
|
|
}
|
|
|
|
impl super::UiScript {
|
|
/// Push the open craft window's snapshot, or clear it with `None`. A push sorts the rows and
|
|
/// keeps the selection on its spell id, clearing it when that recipe is gone.
|
|
pub fn set_craft(&mut self, state: Option<CraftState>) {
|
|
let mut model = self.model_mut();
|
|
match state {
|
|
None => {
|
|
model.craft_selection = 0;
|
|
model.craft = None;
|
|
}
|
|
Some(mut s) => {
|
|
let craft_type = s.craft_type;
|
|
s.recipes.sort_by(|a, b| recipe_order(a, b, craft_type));
|
|
let prev_spell_id = model
|
|
.craft_selection
|
|
.checked_sub(1)
|
|
.and_then(|i| model.craft.as_ref().and_then(|c| c.recipes.get(i as usize)))
|
|
.map(|r| r.spell_id);
|
|
model.craft_selection = prev_spell_id
|
|
.and_then(|sid| s.recipes.iter().position(|r| r.spell_id == sid))
|
|
.map_or(0, |i| (i + 1) as u32);
|
|
model.craft = Some(s);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Drain the spell ids `DoCraft` queued, one per call, with no count.
|
|
pub fn take_craft_dos(&mut self) -> Vec<u32> {
|
|
std::mem::take(&mut self.model_mut().craft_dos)
|
|
}
|
|
|
|
/// Whether `CloseCraft` was called since the last drain; the close sends no packet.
|
|
pub fn take_craft_close(&mut self) -> bool {
|
|
std::mem::take(&mut self.model_mut().craft_close)
|
|
}
|
|
}
|
|
|
|
/// The recipe at a 1-based index.
|
|
fn recipe(model: &Model, index: usize) -> Option<&CraftRecipe> {
|
|
let n = index.checked_sub(1)?;
|
|
model.craft.as_ref()?.recipes.get(n)
|
|
}
|
|
|
|
fn num_recipes(model: &Model) -> usize {
|
|
model.craft.as_ref().map_or(0, |c| c.recipes.len())
|
|
}
|
|
|
|
fn opt_str(lua: &Lua, s: Option<&String>) -> mlua::Result<Value> {
|
|
Ok(match s {
|
|
Some(s) => Value::String(lua.create_string(s)?),
|
|
None => Value::Nil,
|
|
})
|
|
}
|
|
|
|
/// Register the craft globals.
|
|
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
|
let g = lua.globals();
|
|
|
|
// GetCraftName() → the title, or "UNKNOWN" when closed (the reference's closed answer is
|
|
// untraced); the stock window sets its title unconditionally (`Blizzard_CraftUI.lua:101`).
|
|
g.set(
|
|
"GetCraftName",
|
|
lua.create_function(|lua, ()| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let name = model
|
|
.craft
|
|
.as_ref()
|
|
.map_or_else(|| "UNKNOWN".to_string(), |c| c.name.clone());
|
|
Ok(Value::String(lua.create_string(&name)?))
|
|
})?,
|
|
)?;
|
|
|
|
// GetCraftDisplaySkillLine() → name, rank, maxRank; the name is nil when closed, which hides
|
|
// the rank bar (`Blizzard_CraftUI.lua:108-109`).
|
|
g.set(
|
|
"GetCraftDisplaySkillLine",
|
|
lua.create_function(|lua, ()| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let (name, rank, max_rank) = match &model.craft {
|
|
Some(c) => (Some(c.name.clone()), c.rank, c.max_rank),
|
|
None => (None, 0, 0),
|
|
};
|
|
Ok(MultiValue::from_vec(vec![
|
|
opt_str(lua, name.as_ref())?,
|
|
Value::Integer(i64::from(rank)),
|
|
Value::Integer(i64::from(max_rank)),
|
|
]))
|
|
})?,
|
|
)?;
|
|
|
|
// GetNumCrafts() → the recipe count, 0 when closed.
|
|
g.set(
|
|
"GetNumCrafts",
|
|
lua.create_function(|lua, ()| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
Ok(num_recipes(&model) as i64)
|
|
})?,
|
|
)?;
|
|
|
|
// GetCraftItemLink(index) (`0x4f72a0`): a non-number raises its usage. The reference keys on
|
|
// the spell's `castUI`: 3, Enchanting's craft type, for which the type stands in here, answers
|
|
// `|cffffffff|Henchant:<spell id>|h[<spell name>]|h|r` and cannot miss, even for the twenty
|
|
// recipes that create an item; any other type scans for a `CREATE_ITEM` effect no shipped row
|
|
// has, so Beast Training answers zero values.
|
|
g.set(
|
|
"GetCraftItemLink",
|
|
lua.create_function(|lua, index: Value| {
|
|
let index = number_arg(lua, index, "Usage: GetCraftItemLink(index)")?;
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let link = usize::try_from(index)
|
|
.ok()
|
|
.and_then(|i| recipe(&model, i))
|
|
.filter(|_| model.craft.as_ref().is_some_and(|c| c.craft_type == 3))
|
|
.map(|r| format!("|cffffffff|Henchant:{}|h[{}]|h|r", r.spell_id, r.name));
|
|
Ok(match link {
|
|
Some(l) => MultiValue::from_vec(vec![Value::String(lua.create_string(&l)?)]),
|
|
None => MultiValue::new(),
|
|
})
|
|
})?,
|
|
)?;
|
|
|
|
// GetCraftReagentItemLink(index, reagentIndex) (`0x4f7730`): both arguments number-gated,
|
|
// `reagentIndex` counting the non-empty slots from 1; always one value, the item link or nil.
|
|
g.set(
|
|
"GetCraftReagentItemLink",
|
|
lua.create_function(|lua, (index, reagent): (Value, Value)| {
|
|
let usage = "Usage: GetCraftReagentItemLink(index, reagentIndex)";
|
|
let index = number_arg(lua, index, usage)?;
|
|
let reagent = number_arg(lua, reagent, usage)?;
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let link = usize::try_from(index)
|
|
.ok()
|
|
.and_then(|i| recipe(&model, i))
|
|
.zip(usize::try_from(reagent).ok().and_then(|r| r.checked_sub(1)))
|
|
.and_then(|(r, ri)| r.reagents.get(ri))
|
|
.and_then(|re| {
|
|
model
|
|
.item_templates
|
|
.get(&re.item)
|
|
.map(|t| item_link(re.item, &t.name, t.quality))
|
|
});
|
|
Ok(match link {
|
|
Some(l) => Value::String(lua.create_string(&l)?),
|
|
None => Value::Nil,
|
|
})
|
|
})?,
|
|
)?;
|
|
|
|
// GetCraftInfo(index) → craftName, craftSubSpellName, craftType, numAvailable, isExpanded,
|
|
// trainingPointCost, requiredLevel (`Blizzard_CraftUI.lua:168`); one nil past the end.
|
|
// `isExpanded` is always nil; Beast Training's cost and level answer 0, not built.
|
|
g.set(
|
|
"GetCraftInfo",
|
|
lua.create_function(|lua, index: usize| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let Some(r) = recipe(&model, index) else {
|
|
return Ok(MultiValue::from_vec(vec![Value::Nil]));
|
|
};
|
|
Ok(MultiValue::from_vec(vec![
|
|
Value::String(lua.create_string(&r.name)?),
|
|
Value::String(lua.create_string(&r.sub_name)?),
|
|
Value::String(lua.create_string(r.difficulty.as_str())?),
|
|
Value::Integer(i64::from(r.num_available)),
|
|
Value::Nil,
|
|
Value::Integer(0),
|
|
Value::Integer(0),
|
|
]))
|
|
})?,
|
|
)?;
|
|
|
|
// GetCraftIcon(index) → the icon path, nil while in flight or past the end.
|
|
g.set(
|
|
"GetCraftIcon",
|
|
lua.create_function(|lua, index: usize| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
opt_str(lua, recipe(&model, index).and_then(|r| r.icon.as_ref()))
|
|
})?,
|
|
)?;
|
|
|
|
// GetCraftDescription(index) → the description or nil, which the stock window tests before
|
|
// painting (`Blizzard_CraftUI.lua:312`).
|
|
g.set(
|
|
"GetCraftDescription",
|
|
lua.create_function(|lua, index: usize| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
opt_str(
|
|
lua,
|
|
recipe(&model, index).and_then(|r| r.description.as_ref()),
|
|
)
|
|
})?,
|
|
)?;
|
|
|
|
// GetCraftNumReagents(index) → the reagent count, 0 past the end or when closed.
|
|
g.set(
|
|
"GetCraftNumReagents",
|
|
lua.create_function(|lua, index: usize| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
Ok(recipe(&model, index).map_or(0, |r| r.reagents.len()) as i64)
|
|
})?,
|
|
)?;
|
|
|
|
// GetCraftReagentInfo(index, reagentIndex) → name, icon, need, have; one nil past the end.
|
|
g.set(
|
|
"GetCraftReagentInfo",
|
|
lua.create_function(|lua, (index, reagent_index): (usize, usize)| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let Some(reagent) = recipe(&model, index)
|
|
.and_then(|r| reagent_index.checked_sub(1).and_then(|n| r.reagents.get(n)))
|
|
else {
|
|
return Ok(MultiValue::from_vec(vec![Value::Nil]));
|
|
};
|
|
Ok(MultiValue::from_vec(vec![
|
|
opt_str(lua, reagent.name.as_ref())?,
|
|
opt_str(lua, reagent.icon.as_ref())?,
|
|
Value::Integer(i64::from(reagent.need)),
|
|
Value::Integer(i64::from(reagent.have)),
|
|
]))
|
|
})?,
|
|
)?;
|
|
|
|
// GetCraftSpellFocus(index) → name, has for each required tool, alternating, the shape
|
|
// `BuildColoredListString` takes (`Blizzard_CraftUI.lua:361`); nothing when there are none.
|
|
g.set(
|
|
"GetCraftSpellFocus",
|
|
lua.create_function(|lua, index: usize| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let mut out = Vec::new();
|
|
if let Some(r) = recipe(&model, index) {
|
|
for (name, have) in &r.tools {
|
|
out.push(Value::String(lua.create_string(name)?));
|
|
out.push(binding_abi::flag(*have));
|
|
}
|
|
}
|
|
Ok(MultiValue::from_vec(out))
|
|
})?,
|
|
)?;
|
|
|
|
// GetCraftButtonToken() → "CREATE", the global naming the button's label
|
|
// (`Blizzard_CraftUI.lua:106`); Beast Training's "TRAIN" is not built.
|
|
g.set(
|
|
"GetCraftButtonToken",
|
|
lua.create_function(|lua, ()| Ok(Value::String(lua.create_string("CREATE")?)))?,
|
|
)?;
|
|
|
|
// SelectCraft(index) / GetCraftSelectionIndex(): the selection, 1-based with 0 for none; an
|
|
// out-of-range select clears it, and the getter answers 0 past the recipe count.
|
|
g.set(
|
|
"SelectCraft",
|
|
lua.create_function(|lua, index: u32| {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
let count = num_recipes(&model) as u32;
|
|
model.craft_selection = if index >= 1 && index <= count {
|
|
index
|
|
} else {
|
|
0
|
|
};
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
g.set(
|
|
"GetCraftSelectionIndex",
|
|
lua.create_function(|lua, ()| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let count = num_recipes(&model) as u32;
|
|
let sel = model.craft_selection;
|
|
Ok(i64::from(if sel >= 1 && sel <= count { sel } else { 0 }))
|
|
})?,
|
|
)?;
|
|
|
|
// DoCraft(index): queue the row's spell id; out of range is ignored.
|
|
g.set(
|
|
"DoCraft",
|
|
lua.create_function(|lua, index: usize| {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
if let Some(spell_id) = recipe(&model, index).map(|r| r.spell_id) {
|
|
model.craft_dos.push(spell_id);
|
|
}
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// CloseCraft(): no packet; the flag tells the app to clear its state.
|
|
g.set(
|
|
"CloseCraft",
|
|
lua.create_function(|lua, ()| {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
model.craft_close = true;
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// Expand/CollapseCraftSkillLine(index): no-ops over the flat list (module doc).
|
|
g.set(
|
|
"ExpandCraftSkillLine",
|
|
lua.create_function(|_, _args: Variadic<Value>| Ok(()))?,
|
|
)?;
|
|
g.set(
|
|
"CollapseCraftSkillLine",
|
|
lua.create_function(|_, _args: Variadic<Value>| Ok(()))?,
|
|
)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::script::UiScript;
|
|
|
|
/// Enchanting's craft type, whose comparator has no `spellLevel` key.
|
|
const CRAFT_TYPE_ENCHANTING: u32 = 3;
|
|
|
|
fn recipe(
|
|
spell_id: u32,
|
|
name: &str,
|
|
difficulty: super::super::TradeSkillDifficulty,
|
|
num_available: u32,
|
|
) -> CraftRecipe {
|
|
CraftRecipe {
|
|
spell_id,
|
|
tooltip: CraftTooltip::Spell(spell_id),
|
|
name: name.into(),
|
|
sub_name: String::new(),
|
|
difficulty,
|
|
num_available,
|
|
icon: Some(format!("Interface\\Icons\\Spell_{spell_id}")),
|
|
description: Some(format!("{name} description.")),
|
|
needs_item_target: true,
|
|
reagents: vec![CraftReagent {
|
|
item: 10940,
|
|
name: Some("Illusion Dust".into()),
|
|
icon: Some("Interface\\Icons\\INV_Enchant_EssenceMagicSmall".into()),
|
|
need: 1,
|
|
have: 3,
|
|
}],
|
|
tools: vec![("Runed Copper Rod".into(), true)],
|
|
spell_level: 0,
|
|
}
|
|
}
|
|
|
|
/// Two Enchanting recipes, pushed trivial first; the sort puts Minor Health at index 1.
|
|
fn state() -> CraftState {
|
|
CraftState {
|
|
name: "Enchanting".into(),
|
|
rank: 57,
|
|
max_rank: 75,
|
|
craft_type: CRAFT_TYPE_ENCHANTING,
|
|
recipes: vec![
|
|
recipe(
|
|
7418,
|
|
"Enchant Weapon - Minor Beastslaying",
|
|
super::super::TradeSkillDifficulty::Trivial,
|
|
5,
|
|
),
|
|
recipe(
|
|
683,
|
|
"Enchant Weapon - Minor Health",
|
|
super::super::TradeSkillDifficulty::Optimal,
|
|
0,
|
|
),
|
|
],
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn snapshot_feeds_the_api_tuples() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_craft(Some(state()));
|
|
|
|
assert_eq!(
|
|
s.eval::<String>("return GetCraftName()").unwrap(),
|
|
"Enchanting"
|
|
);
|
|
let (name, rank, max_rank) = s
|
|
.eval::<(String, i64, i64)>("return GetCraftDisplaySkillLine()")
|
|
.unwrap();
|
|
assert_eq!((name.as_str(), rank, max_rank), ("Enchanting", 57, 75));
|
|
assert_eq!(s.eval::<i64>("return GetNumCrafts()").unwrap(), 2);
|
|
|
|
let (n, sub, kind, avail, expanded, tp, lvl) = s
|
|
.eval::<(String, String, String, i64, Option<i64>, i64, i64)>(
|
|
"local n,s,t,a,e,tp,l = GetCraftInfo(1) return n,s,t,a,e,tp,l",
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
(
|
|
n.as_str(),
|
|
sub.as_str(),
|
|
kind.as_str(),
|
|
avail,
|
|
expanded,
|
|
tp,
|
|
lvl
|
|
),
|
|
(
|
|
"Enchant Weapon - Minor Health",
|
|
"",
|
|
"optimal",
|
|
0,
|
|
None,
|
|
0,
|
|
0
|
|
)
|
|
);
|
|
let (_, _, kind2, avail2, _, _, _) = s
|
|
.eval::<(String, String, String, i64, Option<i64>, i64, i64)>(
|
|
"local n,s,t,a,e,tp,l = GetCraftInfo(2) return n,s,t,a,e,tp,l",
|
|
)
|
|
.unwrap();
|
|
assert_eq!((kind2.as_str(), avail2), ("trivial", 5));
|
|
|
|
assert_eq!(
|
|
s.eval::<String>("return GetCraftIcon(1)").unwrap(),
|
|
"Interface\\Icons\\Spell_683"
|
|
);
|
|
assert_eq!(
|
|
s.eval::<String>("return GetCraftDescription(1)").unwrap(),
|
|
"Enchant Weapon - Minor Health description."
|
|
);
|
|
assert_eq!(s.eval::<i64>("return GetCraftNumReagents(1)").unwrap(), 1);
|
|
let (rname, ricon, need, have) = s
|
|
.eval::<(String, String, i64, i64)>("return GetCraftReagentInfo(1, 1)")
|
|
.unwrap();
|
|
assert_eq!(
|
|
(rname.as_str(), ricon.as_str(), need, have),
|
|
(
|
|
"Illusion Dust",
|
|
"Interface\\Icons\\INV_Enchant_EssenceMagicSmall",
|
|
1,
|
|
3
|
|
)
|
|
);
|
|
assert_eq!(
|
|
s.eval::<String>("return GetCraftButtonToken()").unwrap(),
|
|
"CREATE"
|
|
);
|
|
|
|
assert!(s
|
|
.eval::<bool>("local _,_,t = GetCraftInfo(1) return t ~= 'header'")
|
|
.unwrap());
|
|
// Expand and collapse do nothing.
|
|
s.run("ExpandCraftSkillLine(1) CollapseCraftSkillLine(1)")
|
|
.unwrap();
|
|
assert_eq!(s.eval::<i64>("return GetNumCrafts()").unwrap(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn selection_persists_across_a_repush_by_spell_id() {
|
|
let mut s = UiScript::new().unwrap();
|
|
// Minor Beastslaying (spell 7418) sorts to index 2 (trivial, behind the optimal row).
|
|
s.set_craft(Some(state()));
|
|
s.run("SelectCraft(2)").unwrap();
|
|
assert_eq!(s.eval::<i64>("return GetCraftSelectionIndex()").unwrap(), 2);
|
|
|
|
// Turning 7418 optimal moves it to index 1, and the selection follows the spell id.
|
|
let mut promoted = state();
|
|
promoted.recipes[0].difficulty = super::super::TradeSkillDifficulty::Optimal;
|
|
promoted.recipes[0].name = "Aaa Beastslaying".into();
|
|
s.set_craft(Some(promoted));
|
|
assert_eq!(s.eval::<i64>("return GetCraftSelectionIndex()").unwrap(), 1);
|
|
|
|
let mut without_7418 = state();
|
|
without_7418.recipes.remove(0);
|
|
s.set_craft(Some(without_7418));
|
|
assert_eq!(s.eval::<i64>("return GetCraftSelectionIndex()").unwrap(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn do_craft_drains_bare_spell_ids_no_count() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_craft(Some(state()));
|
|
|
|
s.run("DoCraft(1) DoCraft(2)").unwrap();
|
|
assert_eq!(
|
|
s.take_craft_dos(),
|
|
vec![683, 7418],
|
|
"display order, not push order"
|
|
);
|
|
assert!(s.take_craft_dos().is_empty(), "drained");
|
|
|
|
s.run("DoCraft(9)").unwrap();
|
|
assert!(s.take_craft_dos().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn no_snapshot_shapes_unknown_name_and_nil_info() {
|
|
let mut s = UiScript::new().unwrap();
|
|
assert_eq!(
|
|
s.eval::<String>("return GetCraftName()").unwrap(),
|
|
"UNKNOWN"
|
|
);
|
|
assert!(s
|
|
.eval::<bool>("return GetCraftDisplaySkillLine() == nil")
|
|
.unwrap());
|
|
assert_eq!(s.eval::<i64>("return GetNumCrafts()").unwrap(), 0);
|
|
assert!(s.eval::<bool>("return GetCraftInfo(1) == nil").unwrap());
|
|
assert!(s.eval::<bool>("return GetCraftIcon(1) == nil").unwrap());
|
|
assert!(s
|
|
.eval::<bool>("return GetCraftDescription(1) == nil")
|
|
.unwrap());
|
|
assert_eq!(s.eval::<i64>("return GetCraftNumReagents(1)").unwrap(), 0);
|
|
assert!(s
|
|
.eval::<bool>("return GetCraftReagentInfo(1, 1) == nil")
|
|
.unwrap());
|
|
assert_eq!(s.eval::<i64>("return GetCraftSelectionIndex()").unwrap(), 0);
|
|
|
|
s.run("DoCraft(1)").unwrap();
|
|
assert!(s.take_craft_dos().is_empty(), "no window, no intent");
|
|
}
|
|
|
|
#[test]
|
|
fn get_craft_spell_focus_multivalue_shape() {
|
|
let mut s = UiScript::new().unwrap();
|
|
let mut c = state();
|
|
// recipes[1] (Minor Health, optimal) is the row the sort puts at index 1.
|
|
c.recipes[1].tools = vec![
|
|
("Runed Copper Rod".into(), true),
|
|
("Arcanite Rod".into(), false),
|
|
];
|
|
s.set_craft(Some(c));
|
|
|
|
let (a, b, cc, d) = s
|
|
.eval::<(String, Option<i64>, String, Option<i64>)>(
|
|
"local a,b,c,d = GetCraftSpellFocus(1) return a,b,c,d",
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
(a.as_str(), b, cc.as_str(), d),
|
|
("Runed Copper Rod", Some(1), "Arcanite Rod", None)
|
|
);
|
|
|
|
// No tools: zero values, not one nil.
|
|
let mut c2 = state();
|
|
c2.recipes[1].tools.clear();
|
|
s.set_craft(Some(c2));
|
|
assert_eq!(s.arity("GetCraftSpellFocus(1)").unwrap(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn close_and_clear_reset_the_window() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_craft(Some(state()));
|
|
s.run("SelectCraft(1)").unwrap();
|
|
|
|
assert!(!s.take_craft_close());
|
|
s.run("CloseCraft()").unwrap();
|
|
assert!(s.take_craft_close());
|
|
assert!(!s.take_craft_close(), "drained");
|
|
|
|
s.set_craft(None);
|
|
assert_eq!(s.eval::<i64>("return GetNumCrafts()").unwrap(), 0);
|
|
assert_eq!(s.eval::<i64>("return GetCraftSelectionIndex()").unwrap(), 0);
|
|
}
|
|
|
|
/// The expected order is an emulated run of `0x4f6920` over the shipped `Spell.dbc` values.
|
|
#[test]
|
|
fn beast_training_orders_ranks_by_spell_level() {
|
|
let rank = |spell_id: u32, name: &str, level: u32| CraftRecipe {
|
|
spell_id,
|
|
tooltip: CraftTooltip::Spell(spell_id),
|
|
name: name.into(),
|
|
sub_name: format!("Rank {}", level / 10 - 1),
|
|
difficulty: super::super::TradeSkillDifficulty::Trivial,
|
|
num_available: 0,
|
|
icon: None,
|
|
description: None,
|
|
needs_item_target: false,
|
|
reagents: vec![],
|
|
tools: vec![],
|
|
spell_level: level,
|
|
};
|
|
// Shuffled on purpose.
|
|
let recipes = vec![
|
|
rank(24508, "Arcane Resistance", 30),
|
|
rank(24441, "Fire Resistance", 30),
|
|
rank(24495, "Arcane Resistance", 20),
|
|
rank(24464, "Fire Resistance", 50),
|
|
rank(24510, "Arcane Resistance", 50),
|
|
rank(24440, "Fire Resistance", 20),
|
|
rank(24509, "Arcane Resistance", 40),
|
|
rank(24463, "Fire Resistance", 40),
|
|
];
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_craft(Some(CraftState {
|
|
name: "Beast Training".into(),
|
|
rank: 0,
|
|
max_rank: 0,
|
|
craft_type: CRAFT_TYPE_BEAST_TRAINING,
|
|
recipes: recipes.clone(),
|
|
}));
|
|
let seen: Vec<(String, String)> = (1..=8)
|
|
.map(|i| {
|
|
s.eval::<(String, String)>(&format!("local n,sub = GetCraftInfo({i}) return n,sub"))
|
|
.unwrap()
|
|
})
|
|
.collect();
|
|
let got: Vec<String> = seen.iter().map(|(n, sub)| format!("{n} ({sub})")).collect();
|
|
assert_eq!(
|
|
got,
|
|
[
|
|
"Arcane Resistance (Rank 1)",
|
|
"Arcane Resistance (Rank 2)",
|
|
"Arcane Resistance (Rank 3)",
|
|
"Arcane Resistance (Rank 4)",
|
|
"Fire Resistance (Rank 1)",
|
|
"Fire Resistance (Rank 2)",
|
|
"Fire Resistance (Rank 3)",
|
|
"Fire Resistance (Rank 4)",
|
|
]
|
|
);
|
|
|
|
// The same rows at the Enchanting type take `0x4f67a0`, with no `spellLevel` key: the ranks
|
|
// fall to the spell-id tie-break, benilla's order rather than the reference's.
|
|
s.set_craft(Some(CraftState {
|
|
name: "Beast Training".into(),
|
|
rank: 0,
|
|
max_rank: 0,
|
|
craft_type: CRAFT_TYPE_ENCHANTING,
|
|
recipes,
|
|
}));
|
|
let ids: Vec<i64> = (1..=8)
|
|
.map(|i| {
|
|
s.eval::<i64>(&format!("DoCraft({i}) return 0")).unwrap();
|
|
0
|
|
})
|
|
.collect();
|
|
assert_eq!(ids.len(), 8);
|
|
assert_eq!(
|
|
s.take_craft_dos(),
|
|
vec![24495, 24508, 24509, 24510, 24440, 24441, 24463, 24464],
|
|
"no spellLevel key at type 3 — the order is name then the deterministic id tie-break"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_craft_link_verbs_answer_the_clients_shapes() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_craft(Some(state()));
|
|
// Row 1 is whichever recipe sorts first.
|
|
let first = s.eval::<String>("return (GetCraftInfo(1))").unwrap();
|
|
let spell = state()
|
|
.recipes
|
|
.iter()
|
|
.find(|r| r.name == first)
|
|
.map(|r| r.spell_id)
|
|
.expect("row 1 is a pushed recipe");
|
|
assert_eq!(
|
|
s.eval::<String>("return (GetCraftItemLink(1))").unwrap(),
|
|
format!("|cffffffff|Henchant:{spell}|h[{first}]|h|r")
|
|
);
|
|
assert!(s
|
|
.eval::<bool>("return GetCraftReagentItemLink(1, 1) == nil")
|
|
.unwrap());
|
|
s.set_item_template(
|
|
10940,
|
|
crate::script::ItemTemplateView {
|
|
name: "Illusion Dust".into(),
|
|
quality: 2,
|
|
..Default::default()
|
|
},
|
|
);
|
|
assert_eq!(
|
|
s.eval::<String>("return GetCraftReagentItemLink(1, 1)")
|
|
.unwrap(),
|
|
"|cff1eff00|Hitem:10940:0:0:0|h[Illusion Dust]|h|r"
|
|
);
|
|
assert_eq!(s.arity("GetCraftReagentItemLink(1, 5)").unwrap(), 1);
|
|
assert!(s
|
|
.eval::<bool>("return GetCraftReagentItemLink(1, 5) == nil")
|
|
.unwrap());
|
|
for bad in ["GetCraftItemLink(nil)", "GetCraftReagentItemLink(1)"] {
|
|
let err = s.run(bad).expect_err(bad).to_string();
|
|
assert!(err.contains("Usage: GetCraft"), "{bad}: {err}");
|
|
}
|
|
let mut beasts = state();
|
|
beasts.craft_type = 1;
|
|
s.set_craft(Some(beasts));
|
|
assert_eq!(
|
|
s.arity("GetCraftItemLink(1)").unwrap(),
|
|
0,
|
|
"a non-Enchanting craft answers zero values"
|
|
);
|
|
}
|
|
}
|