diff --git a/crates/benilla-ui/src/script/compat/classic_api/spellbook.rs b/crates/benilla-ui/src/script/compat/classic_api/spellbook.rs index 895888b8..79c37463 100644 --- a/crates/benilla-ui/src/script/compat/classic_api/spellbook.rs +++ b/crates/benilla-ui/src/script/compat/classic_api/spellbook.rs @@ -39,6 +39,31 @@ fn tab_index_of(book: &SpellBookState, slot_0: usize) -> Option { pub(super) fn install(lua: &Lua) -> mlua::Result { let t = lua.create_table()?; + // `C_SpellBook.GetSkillLineRank(skillLineID)` -> `rank, maxRank`, the modern client's pair. + // 1.12 reaches a skill only by its **index into the skills window** (`GetSkillLineInfo`), + // which moves as lines are learnt and as categories collapse; a `SkillLine.dbc` id is stable, + // which is why pfQuest asks by one. + // + // The two numbers are the same ones `GetSkillLineInfo` reports for that line, bonuses folded + // the way the reference folds them (`script/skills.rs` carries that arithmetic) — so the + // stable-id getter and the index getter cannot disagree. A line the player does not have is + // `nil, nil`, not `0, 0`: untrained and unknown are different answers, and the modern API + // returns nothing for an id the character has no row for. + t.set( + "GetSkillLineRank", + lua.create_function(|lua, skill_id: Option| { + let model = lua.app_data_ref::().expect("model app_data"); + let Some(entry) = skill_id + .filter(|&id| id != 0) + .and_then(|id| model.skills.entries.iter().find(|e| e.skill_id == id)) + else { + return Ok((None, None)); + }; + let (rank, max) = crate::script::skills::displayed_ranks(entry); + Ok((Some(rank), Some(max))) + })?, + )?; + // FindSpellBookSlotByID(spellID) -> slotIndex, spellBank ("spell"/"pet"), or nil. The id→slot // direction every other function in this namespace needs. t.set( @@ -138,6 +163,75 @@ pub(super) fn install(lua: &Lua) -> mlua::Result
{ mod tests { use crate::script::{SpellBookState, SpellSlotView, SpellTabView, UiScript}; + /// `C_SpellBook.GetSkillLineRank(skillLineID)`: the stable-id way into a skill, where 1.12 + /// has only an index into the skills window that moves as lines are learnt and categories + /// collapse — which is why pfQuest asks by id. Must agree with `GetSkillLineInfo` for the + /// same line, bonuses and the mono override included. + #[test] + fn get_skill_line_rank_agrees_with_the_index_getter() { + // `SkillEntry` has no `Default` on purpose — every column is server data — so the test + // names all of them once here. + let skill = + |skill_id: u32, name: &str, value: u32, max: u32, perm_bonus: i32, mono: bool| { + crate::script::SkillEntry { + skill_id, + name: name.to_string(), + value, + max, + mono, + temp_bonus: 0, + perm_bonus, + min_level: 0, + cost_index: 0, + category_id: 6, + category_name: String::new(), + category_order: 0, + description: String::new(), + abandonable: false, + } + }; + let mut s = UiScript::new().unwrap(); + assert_eq!( + s.eval::<(Option, Option)>("return C_SpellBook.GetSkillLineRank(164)") + .unwrap(), + (None, None), + "no skills fed yet" + ); + + s.set_skills(crate::script::SkillsState { + entries: vec![ + skill(164, "Blacksmithing", 210, 300, 5, false), + skill(5, "Beast Mastery", 300, 300, 0, true), + ], + }); + + // The permanent bonus folds into both ranks, as the index getter folds it. + assert_eq!( + s.eval::<(Option, Option)>("return C_SpellBook.GetSkillLineRank(164)") + .unwrap(), + (Some(215), Some(305)) + ); + // A mono line reports max 1 whatever the server said — the same override. + assert_eq!( + s.eval::<(Option, Option)>("return C_SpellBook.GetSkillLineRank(5)") + .unwrap(), + (Some(1), Some(1)) + ); + // A line the character has no row for is nil, not 0: untrained and unknown differ. + for call in [ + "C_SpellBook.GetSkillLineRank(999)", + "C_SpellBook.GetSkillLineRank(0)", + "C_SpellBook.GetSkillLineRank()", + ] { + assert_eq!( + s.eval::<(Option, Option)>(&format!("return {call}")) + .unwrap(), + (None, None), + "{call}" + ); + } + } + fn book() -> SpellBookState { SpellBookState { tabs: vec![ diff --git a/crates/benilla-ui/src/script/compat/classic_api/unit.rs b/crates/benilla-ui/src/script/compat/classic_api/unit.rs index 43d1c87a..dcc7f2ec 100644 --- a/crates/benilla-ui/src/script/compat/classic_api/unit.rs +++ b/crates/benilla-ui/src/script/compat/classic_api/unit.rs @@ -9,6 +9,55 @@ use crate::script::Model; pub(super) fn install(lua: &Lua) -> mlua::Result<()> { let g = lua.globals(); + // `GetPlayerInfoByGUID(guid)` -> `localizedClass, englishClass, localizedRace, englishRace, + // sex, name, realm`, the modern client's seven; 1.12 has no verb that takes a GUID at all, so + // nothing here is derivable from the stock API. pfUI calls it bare. + // + // Answered for a unit this client holds a snapshot of, through the same `Model::unit` the + // whole unit surface uses — so a raw GUID string, a `0x`-prefixed one, or any other token + // spelling resolves identically, and an unknown GUID is seven nils rather than a raise. + // + // `realm` is **nil**, not the player's own realm name: the field means the realm the *named* + // character is from, which only a cross-realm client can know and which 1.12 has no concept + // of. The modern API returns an empty string for same-realm, and the consumers treat nil and + // "" alike; claiming our realm for an arbitrary GUID would be a guess that reads as data. + g.set( + "GetPlayerInfoByGUID", + lua.create_function(|lua, guid: Option| { + let model = lua.app_data_ref::().expect("model app_data"); + let found = guid.as_deref().and_then(|g| model.unit(g)); + let Some(u) = found.filter(|u| u.exists) else { + return Ok((None, None, None, None, None, None, None::)); + }; + Ok(( + u.class.clone(), + u.class_file.clone(), + u.race.clone(), + u.race_file.clone(), + Some(i64::from(u.sex)), + u.name.clone(), + None, + )) + })?, + )?; + + // `CastSpellNoToggle(spellId)`: cast without the stock toggle-off, for a spell whose aura is + // already up. The toggle lives in the client's cast path (`0x6e5100`'s autorepeat and aura + // tests), and benilla's cast queue does not toggle anything — a press is a send — so this is + // the plain cast by id, which is exactly what the caller asked for. Same reasoning as + // nampower's `CastSpellNoQueue` pair (`compat/nampower.rs`), and the same queue, so a cast + // from here and one from a bar are one send. + g.set( + "CastSpellNoToggle", + lua.create_function(|lua, spell_id: Option| { + if let Some(spell_id) = spell_id.filter(|&id| id != 0) { + let mut model = lua.app_data_mut::().expect("model app_data"); + model.spell_casts.push(spell_id); + } + Ok(()) + })?, + )?; + // UnitGUID(unit): reuses SuperWoW's own `guid_token`/`Model::unit` plumbing, so a GUID from // either round-trips through both. g.set( @@ -86,6 +135,74 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { mod tests { use crate::script::{UiScript, UnitState}; + /// `GetPlayerInfoByGUID`'s seven, for a unit this client holds. 1.12 has no verb that takes a + /// GUID at all, so every column here comes from the snapshot the unit surface already carries + /// — and the GUID resolves through the same `Model::unit` as every other token spelling, so a + /// raw GUID and a `0x`-prefixed one answer alike. + #[test] + fn get_player_info_by_guid_answers_from_the_unit_snapshot() { + let mut s = UiScript::new().unwrap(); + s.set_unit( + "target", + Some(UnitState { + exists: true, + guid: 0x4242, + name: Some("Gromgosh".into()), + class: Some("Warrior".into()), + class_file: Some("WARRIOR".into()), + race: Some("Orc".into()), + race_file: Some("Orc".into()), + sex: 2, + is_player: true, + ..Default::default() + }), + ); + + type Info = ( + Option, + Option, + Option, + Option, + Option, + Option, + Option, + ); + let token = crate::script::guid_token(0x4242); + let got = s + .eval::(&format!(r#"return GetPlayerInfoByGUID("{token}")"#)) + .unwrap(); + assert_eq!( + got, + ( + Some("Warrior".into()), + Some("WARRIOR".into()), + Some("Orc".into()), + Some("Orc".into()), + Some(2), + Some("Gromgosh".into()), + // `realm` is the *named* character's realm, which 1.12 has no concept of; our own + // realm name here would be a guess that reads as data. + None, + ) + ); + // The same answer without the `0x`, since the token resolver takes both spellings. + assert_eq!( + s.eval::(r#"return GetPlayerInfoByGUID("0000000000004242")"#) + .unwrap(), + got + ); + // A GUID nothing holds: seven nils, not a raise. + assert_eq!( + s.eval::(r#"return GetPlayerInfoByGUID("0x00000000DEADBEEF")"#) + .unwrap(), + (None, None, None, None, None, None, None) + ); + assert_eq!( + s.eval::("return GetPlayerInfoByGUID()").unwrap(), + (None, None, None, None, None, None, None) + ); + } + #[test] fn unit_guid_and_unit_token_from_guid_round_trip() { let mut s = UiScript::new().unwrap(); diff --git a/crates/benilla-ui/src/script/skills.rs b/crates/benilla-ui/src/script/skills.rs index 978fe5f1..cad99791 100644 --- a/crates/benilla-ui/src/script/skills.rs +++ b/crates/benilla-ui/src/script/skills.rs @@ -246,7 +246,7 @@ fn selected_index(model: &Model) -> u32 { /// ``` /// /// Saturating at 0 keeps a negative permanent bonus from wrapping. -fn displayed_ranks(e: &SkillEntry) -> (i64, i64) { +pub(in crate::script) fn displayed_ranks(e: &SkillEntry) -> (i64, i64) { let fold = |v: u32| { if v > 0 { (i64::from(v) + i64::from(e.perm_bonus)).max(0) diff --git a/crates/benilla-ui/src/script/tests/reference_surface.rs b/crates/benilla-ui/src/script/tests/reference_surface.rs index cc340c2e..61b13e7f 100644 --- a/crates/benilla-ui/src/script/tests/reference_surface.rs +++ b/crates/benilla-ui/src/script/tests/reference_surface.rs @@ -319,6 +319,12 @@ fn allowed_beyond_1_12() -> HashSet<&'static str> { // `GetQuestLogTitle` answers the title and never the id, so neither the id nor a chat // link for a log row is derivable from the stock API. The id agrees with // `C_QuestLog.GetQuestIDForLogIndex`, which ClassicAPI publishes over the same rows. + // ClassicAPI's `GetPlayerInfoByGUID(guid)` and `CastSpellNoToggle(spellId)` + // (script/compat/classic_api/unit.rs). 1.12 has no verb that takes a GUID at all, and no + // cast path that skips its own toggle — benilla's queue does not toggle, so the second is + // the plain cast by id, the same reasoning nampower's `CastSpellNoQueue` carries. + "CastSpellNoToggle", + "GetPlayerInfoByGUID", "GetQuestIDForLogIndex", "GetQuestLinkForLogIndex", "CancelPlayerAuraSlot",