Files
benilla-octo/crates/benilla-ui/src/script/skills.rs
T
SamandClaude Fable 5 2ad0043523 benilla: initial public release
A from-scratch World of Warcraft 1.12.1 client in Rust + Bevy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 14:27:01 +07:00

653 lines
28 KiB
Rust

//! The skills-pane bindings (decision 0437 phase 4) — the Era-shaped `SkillFrame` surface driving
//! a faithful port of the real 1.12 Skills tab (extracted from `interface.MPQ`: FrameXML
//! `SkillFrame.{xml,lua}`). Unlike [`super::tradeskill`]'s deliberately FLAT v1 recipe list, this
//! pane needs the trainer's own GROUP/TREE machinery ([`super::trainer`], decision 0247): the app
//! pushes a flat, unordered snapshot ([`UiScript::set_skills`] — [`SkillsState::entries`], each
//! already resolved to name/category by the app from `SkillLine.dbc`/`SkillLineCategory.dbc`), and
//! the ENGINE groups by category, sorts, and folds — the trainer's synthesized-tree pattern, minus
//! the state filter (a skill line carries no green/red/gray category) and minus the wire skill-line
//! id doing double duty as both the row's identity and the group key (here the GROUP key is the
//! category, the row's identity is the skill id).
//!
//! ## The engine grouping law (INTERIM — the 0437 §5 dispatch resolved (decision 0446), but never
//! adjudicated this law; it stays unpinned, its own follow-up named in decision 0530)
//!
//! Groups ordered by `category_order` ascending (`category_id` breaks a tie, for determinism);
//! one header row per non-empty group (text = `category_name`); entries within a group sorted by
//! name ascending (the trainer's own [`collate`] — case-insensitive, raw-byte tie-break). Every
//! group starts EXPANDED (the trainer's own default-expanded rule). Visible rows are headers
//! always, plus the entries of expanded groups; every index the Lua API takes/returns is 1-based
//! into that visible list.
//!
//! ## The Era API shape (matched to the real `SkillFrame.lua`, transcribed onto this engine)
//!
//! `GetNumSkillLines()` → the visible row count. `GetSkillLineInfo(i)` → the ref's own 13-tuple
//! (`name, isHeader, isExpanded, skillRank, numTempPoints, skillModifier, skillMaxRank,
//! isAbandonable, stepCost, rankCost, minLevel, skillCostType, skillDescription`): a header row
//! shapes `(category_name, 1, expanded, 0, 0, 0, 0, nil, 0, 0, 0, nil, nil)`, an entry row shapes
//! `(name, nil, nil, value, 0, modifier, max, abandonable, 0, 0, 0, nil, description)`.
//! `numTempPoints` is always `0` (1.12 training points are dead data — `PLAYER_SKILL_INFO`
//! carries no temp/perm split the client would animate through a "buy a rank" flow);
//! `skillDescription` is REAL (`SkillLine.dbc` col 12 through the app feed — the detail pane's
//! body text); `isAbandonable` is REAL too ([`SkillEntry::abandonable`], the unlearn button's
//! gate — and `AbandonSkill(i)` is its outbound half, a VISIBLE index queued out by skill id for
//! the app's `CMSG_UNLEARN_SKILL`, [`UiScript::take_skill_abandons`]); the remaining
//! `stepCost`/`rankCost`/`minLevel`/`skillCostType` are vestigial stubs backing the ref's
//! *training-up* branches (a trainer-taught skill step), which never apply to a line that only
//! ever changes as a server descriptor delta.
//!
//! `ExpandSkillHeader(i)`/`CollapseSkillHeader(i)` take a header's VISIBLE index (`0` = all
//! groups, the trainer's own collapse-all shape). `SetSelectedSkill(i)`/`GetSelectedSkill()` are a
//! VISIBLE index too, but the engine holds the selection BY SKILL ID internally (the tradeskill's
//! own by-spell-id persistence pattern, [`super::tradeskill::UiScript::set_trade_skill`]) so it
//! survives a re-push that reorders or regroups; selecting a header (or an out-of-range index)
//! clears it. `GetAdjustedSkillPoints()` is a vestigial 1.12 leftover the ref reads; it always
//! returns `0` — there is no training-point economy behind a skill line in this client.
//!
//! The ref Lua's other globals (`SkillBar_OnClick`'s `RemoveSkillUp`/`AddSkillUp`/`BuySkillTier`,
//! `UnitCharacterPoints`) back the training-up machinery this pane doesn't ship (0437's named
//! out-of-scope) — none are transcribed, engine-side or XML-side, rather than stubbing dead call
//! sites nothing in [`SkillFrame.xml`] ever reaches. (The `UNLEARN_SKILL` popup, once in that
//! list, ships for real now — the abandon slice above.)
use std::collections::{HashMap, HashSet};
use mlua::{Lua, MultiValue, Value};
use super::Model;
/// One known skill line off the player's `PLAYER_SKILL_INFO` block, app-resolved (0437 phase 4).
/// EXACT shape the app feed (`crates/benilla/src/ui_char.rs`) is written against — do not rename.
#[derive(Clone, Debug, PartialEq)]
pub struct SkillEntry {
pub skill_id: u32,
/// `SkillLine.dbc` name ("First Aid").
pub name: String,
/// Current rank.
pub value: u32,
/// Max rank (a 1.12 proficiency line can be `0/0` — render barless, the ref's own shape).
pub max: u32,
/// Temp+perm bonus (the green "+n"; negative possible).
pub modifier: i32,
/// `SkillLine.dbc` categoryId.
pub category_id: u32,
/// Resolved category name ("Professions") — the header text.
pub category_name: String,
/// `SkillLineCategory` displayOrder — the group sort key.
pub category_order: u32,
/// `SkillLine.dbc` description (enUS column 12) — `GetSkillLineInfo`'s 13th return, the
/// detail pane's body text (empty when the row carries none; the XML's `SKILL_DESCRIPTION`
/// format renders it verbatim).
pub description: String,
/// Whether the line can be unlearned — `GetSkillLineInfo`'s 8th return (`isAbandonable`), the
/// detail pane's unlearn-button gate. App-resolved from `SkillRaceClassInfo.flags & 0x20`
/// (`SKILL_FLAG_UNLEARNABLE` — the server's own `CMSG_UNLEARN_SKILL` gate, vmangos
/// `SkillHandler.cpp`).
pub abandonable: bool,
}
/// A flat, unordered push of every known skill line (0437 phase 4) — the ENGINE groups and sorts
/// (see the module doc's grouping law). EXACT shape the app feed is written against — do not
/// rename.
#[derive(Clone, Debug, PartialEq, Default)]
pub struct SkillsState {
pub entries: Vec<SkillEntry>,
}
/// One category group in the synthesized display tree: the header's id/name/sort key and the
/// positions (into [`SkillsState::entries`]) of the category's lines, pre-sorted by name —
/// mirrors [`super::trainer::TrainerGroup`], but the group key is a *category*, never the wire
/// skill line itself. Engine-internal (never crosses the app seam, unlike [`SkillsState`]);
/// `pub(crate)` only because [`super::model::Model`] stores a `Vec` of them.
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct SkillGroup {
category_id: u32,
name: String,
order: u32,
/// Positions into [`SkillsState::entries`], sorted by [`collate`]d name.
entries: Vec<usize>,
}
impl super::UiScript {
/// Replace the skills snapshot (0437 phase 4). Builds the display tree ([`build_groups`]) from
/// the flat entries, prunes the collapsed set to categories that still exist in the new push
/// (the trainer's own collapse-survives-an-update rule, [`super::trainer::UiScript::set_trainer`]),
/// and re-anchors the selection to the SAME skill id if it's still present — else clears it
/// (the tradeskill's own by-spell-id selection-persistence precedent).
pub fn set_skills(&mut self, state: SkillsState) {
let mut model = self.model_mut();
let groups = build_groups(&state.entries);
let live: HashSet<u32> = groups.iter().map(|g| g.category_id).collect();
model.skills_collapsed.retain(|c| live.contains(c));
if let Some(sid) = model.skills_selected {
if !state.entries.iter().any(|e| e.skill_id == sid) {
model.skills_selected = None;
}
}
model.skills_groups = groups;
model.skills = state;
}
/// Drain the skill line ids `AbandonSkill` queued (the unlearn seam) — the app sends each as
/// one `CMSG_UNLEARN_SKILL` and otherwise does nothing: the removal arrives back as a server
/// skill-field update, never a local mutation ([`Model::skill_abandons`]).
pub fn take_skill_abandons(&mut self) -> Vec<u32> {
std::mem::take(&mut self.model_mut().skill_abandons)
}
}
/// One visible row of the display tree: a category **header** (carrying its group index) or an
/// **entry** (carrying its position into [`SkillsState::entries`]).
#[derive(Clone, Copy)]
enum Row {
Header(usize),
Entry(usize),
}
/// The WoW enUS collator, approximated (the trainer's own [`super::trainer`] helper, duplicated
/// here rather than shared — each seam module keeps its own copy, the established local
/// convention): case-insensitive alphabetical, raw bytes as a stable tie-break.
fn collate(a: &str, b: &str) -> std::cmp::Ordering {
a.to_lowercase()
.cmp(&b.to_lowercase())
.then_with(|| a.cmp(b))
}
/// Build the display tree from the flat entries (the module doc's grouping law): group by
/// category, sort each group's entries by name, sort the groups by `category_order` (category id
/// breaks a tie). Entry positions index back into the unchanged `entries` slice.
fn build_groups(entries: &[SkillEntry]) -> Vec<SkillGroup> {
let mut map: HashMap<u32, SkillGroup> = HashMap::new();
for (i, e) in entries.iter().enumerate() {
map.entry(e.category_id)
.or_insert_with(|| SkillGroup {
category_id: e.category_id,
name: e.category_name.clone(),
order: e.category_order,
entries: Vec::new(),
})
.entries
.push(i);
}
let mut groups: Vec<SkillGroup> = map.into_values().collect();
for g in &mut groups {
g.entries
.sort_by(|&a, &b| collate(&entries[a].name, &entries[b].name));
}
groups.sort_by(|a, b| {
a.order
.cmp(&b.order)
.then(a.category_id.cmp(&b.category_id))
});
groups
}
/// The visible rows in display order: each group's header (always shown), then — when the group
/// isn't collapsed — its entries. The Lua's 1-based `index` is a position in *this* list.
fn rows(model: &Model) -> Vec<Row> {
let mut out = Vec::new();
for (gi, g) in model.skills_groups.iter().enumerate() {
out.push(Row::Header(gi));
if !model.skills_collapsed.contains(&g.category_id) {
for &ei in &g.entries {
out.push(Row::Entry(ei));
}
}
}
out
}
/// The count of visible rows (headers + the entries of expanded groups).
fn num_rows(model: &Model) -> usize {
rows(model).len()
}
/// The entry at a 1-based VISIBLE index, or `None` when that row is a header (or OOB) — so the
/// selection/info getters that read an entry safely no-op on a header row.
fn entry_at(model: &Model, index: usize) -> Option<&SkillEntry> {
let n = index.checked_sub(1)?;
match rows(model).get(n)? {
Row::Entry(ei) => model.skills.entries.get(*ei),
Row::Header(_) => None,
}
}
/// Collapse (`collapse = true`) or expand a category by the **display index of its header row**
/// (the trainer's own `Collapse/ExpandTrainerSkillLine` shape). `id == 0` targets ALL groups (the
/// collapse-all button); `id > 0` resolves the header at that visible index to its category. A
/// non-header (or OOB) index is a no-op.
fn set_collapsed(model: &mut Model, id: usize, collapse: bool) {
if id == 0 && !collapse {
model.skills_collapsed.clear();
return;
}
let targets: Vec<u32> = if id == 0 {
model.skills_groups.iter().map(|g| g.category_id).collect()
} else {
match rows(model).get(id - 1) {
Some(Row::Header(gi)) => model
.skills_groups
.get(*gi)
.map(|g| g.category_id)
.into_iter()
.collect(),
_ => Vec::new(),
}
};
for c in targets {
if collapse {
model.skills_collapsed.insert(c);
} else {
model.skills_collapsed.remove(&c);
}
}
}
/// `SetSelectedSkill(index)` — resolve the 1-based VISIBLE index to a skill id and hold THAT (the
/// module doc's by-id persistence); a header row or an out-of-range index clears the selection.
fn set_selected(model: &mut Model, index: u32) {
model.skills_selected = entry_at(model, index as usize).map(|e| e.skill_id);
}
/// `GetSelectedSkill()` — the selection's CURRENT visible index (`0` when nothing is selected, or
/// the selected id isn't visible right now, e.g. its group just got collapsed).
fn selected_index(model: &Model) -> u32 {
let Some(sid) = model.skills_selected else {
return 0;
};
rows(model)
.iter()
.position(|r| matches!(r, Row::Entry(ei) if model.skills.entries[*ei].skill_id == sid))
.map_or(0, |p| (p + 1) as u32)
}
/// A `bool` as the Era `1`/`nil` shape (the trainer/tradeskill's own helper, duplicated per the
/// established per-module convention).
fn era_bool(b: bool) -> Value {
if b {
Value::Integer(1)
} else {
Value::Nil
}
}
/// Register the skills-pane globals.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// GetNumSkillLines() → the visible row count (0 before any push).
g.set(
"GetNumSkillLines",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(num_rows(&model) as i64)
})?,
)?;
// GetSkillLineInfo(index) → the ref's own 13-tuple (module doc). `index` 1-based into the
// visible tree; out of range → a single nil.
g.set(
"GetSkillLineInfo",
lua.create_function(|lua, index: usize| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(n) = index.checked_sub(1) else {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
};
let Some(row) = rows(&model).get(n).copied() else {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
};
match row {
Row::Header(gi) => {
let grp = &model.skills_groups[gi];
let expanded = !model.skills_collapsed.contains(&grp.category_id);
Ok(MultiValue::from_vec(vec![
Value::String(lua.create_string(&grp.name)?),
Value::Integer(1), // isHeader
era_bool(expanded),
Value::Integer(0), // skillRank
Value::Integer(0), // numTempPoints — always 0 (module doc)
Value::Integer(0), // skillModifier
Value::Integer(0), // skillMaxRank
Value::Nil, // isAbandonable
Value::Integer(0), // stepCost
Value::Integer(0), // rankCost
Value::Integer(0), // minLevel
Value::Nil, // skillCostType
Value::Nil, // skillDescription
]))
}
Row::Entry(ei) => {
let e = &model.skills.entries[ei];
Ok(MultiValue::from_vec(vec![
Value::String(lua.create_string(&e.name)?),
Value::Nil, // isHeader
Value::Nil, // isExpanded
Value::Integer(i64::from(e.value)),
Value::Integer(0), // numTempPoints — always 0 (module doc)
Value::Integer(i64::from(e.modifier)),
Value::Integer(i64::from(e.max)),
era_bool(e.abandonable), // isAbandonable
Value::Integer(0), // stepCost
Value::Integer(0), // rankCost
Value::Integer(0), // minLevel
Value::Nil, // skillCostType
Value::String(lua.create_string(&e.description)?), // skillDescription
]))
}
}
})?,
)?;
// Collapse/ExpandSkillHeader(id) — fold a category by the display index of its header row (id
// 0 = all groups); a non-header (or OOB) index no-ops.
g.set(
"CollapseSkillHeader",
lua.create_function(|lua, id: usize| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
set_collapsed(&mut model, id, true);
Ok(())
})?,
)?;
g.set(
"ExpandSkillHeader",
lua.create_function(|lua, id: usize| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
set_collapsed(&mut model, id, false);
Ok(())
})?,
)?;
// SetSelectedSkill(index) / GetSelectedSkill() — the engine-held selection, VISIBLE index in,
// VISIBLE index out, held BY SKILL ID internally (module doc).
g.set(
"SetSelectedSkill",
lua.create_function(|lua, index: u32| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
set_selected(&mut model, index);
Ok(())
})?,
)?;
g.set(
"GetSelectedSkill",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(i64::from(selected_index(&model)))
})?,
)?;
// GetAdjustedSkillPoints() → 0 — a vestigial 1.12 leftover the ref reads (module doc); this
// client has no training-point economy behind a skill line.
g.set(
"GetAdjustedSkillPoints",
lua.create_function(|_, ()| Ok(0i64))?,
)?;
// AbandonSkill(index) — VISIBLE index in (the ref's UNLEARN_SKILL popup passes the row it was
// opened for), queued out BY SKILL ID for the app's CMSG_UNLEARN_SKILL send. Mutates NOTHING
// locally: the real client waits for the server's skill-field update (vmangos SetSkill(id,0,0)
// → our descriptor watcher → a fresh set_skills push → SKILL_LINES_CHANGED). A header or
// out-of-range index no-ops (only entries carry the unlearn button).
g.set(
"AbandonSkill",
lua.create_function(|lua, index: usize| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if let Some(id) = entry_at(&model, index).map(|e| e.skill_id) {
model.skill_abandons.push(id);
}
Ok(())
})?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::script::UiScript;
#[allow(clippy::too_many_arguments)]
fn entry(
skill_id: u32,
name: &str,
value: u32,
max: u32,
modifier: i32,
category_id: u32,
category_name: &str,
category_order: u32,
) -> SkillEntry {
SkillEntry {
skill_id,
name: name.into(),
value,
max,
modifier,
category_id,
category_name: category_name.into(),
category_order,
description: format!("About {name}."),
// Fixture rule: the Professions category (id 2) is abandonable, weapons are not —
// the real SkillRaceClassInfo 0x20 split's shape.
abandonable: category_id == 2,
}
}
/// Two categories: "Weapon Skills" (order 1: Defense, Swords) and "Professions" (order 2:
/// First Aid, Fishing) — a flat, unordered push (Swords precedes Defense in push order; the
/// engine's own name sort must still show Defense first).
fn state() -> SkillsState {
SkillsState {
entries: vec![
entry(43, "Swords", 200, 300, 0, 1, "Weapon Skills", 1),
entry(95, "Defense", 180, 300, 5, 1, "Weapon Skills", 1),
entry(129, "First Aid", 57, 75, 3, 2, "Professions", 2),
entry(356, "Fishing", 1, 300, 0, 2, "Professions", 2),
],
}
}
/// Read `(name, type)` at a visible index, `type` = "header"/"entry".
fn row_kind(s: &mut UiScript, i: i64) -> (String, String) {
s.eval::<(String, String)>(&format!(
"local n,h = GetSkillLineInfo({i}) local t = h and 'header' or 'entry' return n,t"
))
.unwrap()
}
#[test]
fn grouped_visible_rows_interleave_headers_ordered_by_category() {
let mut s = UiScript::new().unwrap();
assert_eq!(s.eval::<i64>("return GetNumSkillLines()").unwrap(), 0);
s.set_skills(state());
// 2 headers + 4 entries = 6 visible rows, category_order ascending, name ascending within.
assert_eq!(s.eval::<i64>("return GetNumSkillLines()").unwrap(), 6);
assert_eq!(
row_kind(&mut s, 1),
("Weapon Skills".into(), "header".into())
);
assert_eq!(row_kind(&mut s, 2), ("Defense".into(), "entry".into()));
assert_eq!(row_kind(&mut s, 3), ("Swords".into(), "entry".into()));
assert_eq!(row_kind(&mut s, 4), ("Professions".into(), "header".into()));
assert_eq!(row_kind(&mut s, 5), ("First Aid".into(), "entry".into()));
assert_eq!(row_kind(&mut s, 6), ("Fishing".into(), "entry".into()));
// Every group starts EXPANDED (the module doc's default rule).
let (_, h1, e1) = s
.eval::<(String, i64, Option<i64>)>("local n,h,e = GetSkillLineInfo(1) return n,h,e")
.unwrap();
assert_eq!((h1, e1), (1, Some(1)));
}
#[test]
fn abandon_skill_queues_the_entrys_skill_id_and_mutates_nothing() {
let mut s = UiScript::new().unwrap();
s.set_skills(state());
// The 8th return: Professions rows are abandonable (fixture rule), weapon rows and
// headers are not — 1/nil, the 1.12 boolean shape.
let ab = |s: &mut UiScript, i: i64| {
s.eval::<Option<i64>>(&format!("return (select(8, GetSkillLineInfo({i})))"))
.unwrap()
};
assert_eq!(ab(&mut s, 2), None, "Defense is not abandonable");
assert_eq!(ab(&mut s, 5), Some(1), "First Aid is abandonable");
assert_eq!(ab(&mut s, 1), None, "a header never is");
// AbandonSkill queues BY SKILL ID; headers and out-of-range indices no-op; the list
// itself is untouched (the server round trip owns the removal).
s.run("AbandonSkill(5)").unwrap();
s.run("AbandonSkill(1)").unwrap();
s.run("AbandonSkill(99)").unwrap();
assert_eq!(s.take_skill_abandons(), vec![129]);
assert!(
s.take_skill_abandons().is_empty(),
"drain empties the queue"
);
assert_eq!(
s.eval::<i64>("return GetNumSkillLines()").unwrap(),
6,
"no local removal — the visible tree is unchanged"
);
}
#[test]
fn collapse_hides_a_groups_entries_and_remaps_indices() {
let mut s = UiScript::new().unwrap();
s.set_skills(state());
// Fold "Weapon Skills" (header at visible index 1): its two entries vanish, the header
// stays and now reports isExpanded=nil. 6 → 4.
s.run("CollapseSkillHeader(1)").unwrap();
assert_eq!(s.eval::<i64>("return GetNumSkillLines()").unwrap(), 4);
let (name, _, expanded) = s
.eval::<(String, i64, Option<i64>)>("local n,h,e = GetSkillLineInfo(1) return n,h,e")
.unwrap();
assert_eq!((name.as_str(), expanded), ("Weapon Skills", None));
assert_eq!(
row_kind(&mut s, 2),
("Professions".into(), "header".into()),
"Weapon Skills' entries are folded; Professions is now row 2"
);
// Expand it back.
s.run("ExpandSkillHeader(1)").unwrap();
assert_eq!(s.eval::<i64>("return GetNumSkillLines()").unwrap(), 6);
// Collapse-all (id 0), then expand-all (id 0).
s.run("CollapseSkillHeader(0)").unwrap();
assert_eq!(s.eval::<i64>("return GetNumSkillLines()").unwrap(), 2);
s.run("ExpandSkillHeader(0)").unwrap();
assert_eq!(s.eval::<i64>("return GetNumSkillLines()").unwrap(), 6);
}
#[test]
fn selection_and_expansion_persist_across_a_repush() {
let mut s = UiScript::new().unwrap();
s.set_skills(state());
// Selecting a HEADER clears the selection (module doc).
s.run("SetSelectedSkill(1)").unwrap();
assert_eq!(s.eval::<i64>("return GetSelectedSkill()").unwrap(), 0);
// Select Swords (row 3), fold Professions (row 4).
s.run("SetSelectedSkill(3)").unwrap();
assert_eq!(s.eval::<i64>("return GetSelectedSkill()").unwrap(), 3);
s.run("CollapseSkillHeader(4)").unwrap();
assert_eq!(s.eval::<i64>("return GetNumSkillLines()").unwrap(), 4);
// A re-push (values ticked up) keeps BOTH the fold and the selection's skill identity —
// Swords is still row 3, Professions is still collapsed.
let mut ticked = state();
ticked.entries[0].value = 201; // Swords
s.set_skills(ticked);
assert_eq!(s.eval::<i64>("return GetNumSkillLines()").unwrap(), 4);
assert_eq!(s.eval::<i64>("return GetSelectedSkill()").unwrap(), 3);
let (name, rank) = s
.eval::<(String, i64)>("local n,_,_,r = GetSkillLineInfo(3) return n,r")
.unwrap();
assert_eq!((name.as_str(), rank), ("Swords", 201));
// A re-push that drops the selected skill entirely clears the selection.
let mut without_swords = state();
without_swords.entries.remove(0);
s.set_skills(without_swords);
assert_eq!(s.eval::<i64>("return GetSelectedSkill()").unwrap(), 0);
}
#[test]
fn header_and_entry_tuple_shapes() {
let mut s = UiScript::new().unwrap();
s.set_skills(state());
// Header row 1 ("Weapon Skills"): (name, 1, expanded, 0, 0, 0, 0, nil, 0, 0, 0, nil, nil).
let (name, is_header, is_expanded, rank, temp, modifier, max) = s
.eval::<(String, i64, Option<i64>, i64, i64, i64, i64)>(
"local n,h,e,r,t,m,mx = GetSkillLineInfo(1) return n,h,e,r,t,m,mx",
)
.unwrap();
assert_eq!(
(
name.as_str(),
is_header,
is_expanded,
rank,
temp,
modifier,
max
),
("Weapon Skills", 1, Some(1), 0, 0, 0, 0)
);
let (abandon_nil, step, rank_cost, min_level, cost_type_nil, desc_nil) = s
.eval::<(bool, i64, i64, i64, bool, bool)>(
"local _,_,_,_,_,_,_,a,st,rc,ml,ct,d = GetSkillLineInfo(1) \
return a==nil, st, rc, ml, ct==nil, d==nil",
)
.unwrap();
assert!(abandon_nil);
assert_eq!((step, rank_cost, min_level), (0, 0, 0));
assert!(cost_type_nil);
assert!(desc_nil);
// Entry row 2 ("Defense", value 180, max 300, modifier +5): (name, nil, nil, 180, 0, 5,
// 300, nil, 0, 0, 0, nil, nil).
let (name, is_header, is_expanded, rank, temp, modifier, max) = s
.eval::<(String, Option<i64>, Option<i64>, i64, i64, i64, i64)>(
"local n,h,e,r,t,m,mx = GetSkillLineInfo(2) return n,h,e,r,t,m,mx",
)
.unwrap();
assert_eq!(
(
name.as_str(),
is_header,
is_expanded,
rank,
temp,
modifier,
max
),
("Defense", None, None, 180, 0, 5, 300)
);
// The 13th return is the REAL description now (SkillLine.dbc col 12 through the feed) —
// a string on an entry row, still nil on a header (asserted above).
assert_eq!(
s.eval::<String>("return select(13, GetSkillLineInfo(2))")
.unwrap(),
"About Defense."
);
}
#[test]
fn no_push_reports_zero_rows() {
let s = UiScript::new().unwrap();
assert_eq!(s.eval::<i64>("return GetNumSkillLines()").unwrap(), 0);
assert!(s.eval::<bool>("return GetSkillLineInfo(1) == nil").unwrap());
assert_eq!(s.eval::<i64>("return GetSelectedSkill()").unwrap(), 0);
assert_eq!(s.eval::<i64>("return GetAdjustedSkillPoints()").unwrap(), 0);
// Collapse/expand/select on an empty pane are harmless no-ops.
s.run("CollapseSkillHeader(0) ExpandSkillHeader(1) SetSelectedSkill(1)")
.unwrap();
assert_eq!(s.eval::<i64>("return GetNumSkillLines()").unwrap(), 0);
}
}