//! The questgiver bindings: the app pushes the open panel ([`UiScript::set_quest`]), and the panel //! verbs queue selects and button intents it drains. One [`QuestState`] serves the flat getters //! `QuestFrame.lua` reads on each panel; the app fires `QUEST_GREETING`, `QUEST_DETAIL`, //! `QUEST_PROGRESS` or `QUEST_COMPLETE` for the live one. use mlua::{Lua, MultiValue, Value}; use super::binding_abi::flag; use super::Model; use crate::script::binding_abi::Idx; /// Which questgiver panel a [`QuestState`] is for, set from the wire packet. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum QuestPanel { /// `SMSG_QUESTGIVER_QUEST_LIST`: the greeting, with the active and available lists. Greeting, /// `SMSG_QUESTGIVER_QUEST_DETAILS`: the accept panel. Detail, /// `SMSG_QUESTGIVER_REQUEST_ITEMS`: the turn-in progress panel. Progress, /// `SMSG_QUESTGIVER_OFFER_REWARD`: the reward panel. Reward, } /// One choice, reward or required item row; its 1-based index is its place in its list. #[derive(Clone, Debug, Default, PartialEq)] pub struct QuestItemView { /// `None` while the item template is in flight: `GetQuestItemInfo` answers an empty name, the /// log getters their miss. pub name: Option, pub texture: Option, pub count: u32, /// 0 poor to 5 legendary; 1 until the template lands. pub quality: u32, /// The item id the quest tooltips render by; 0 until the wire row resolves. pub item_id: u32, /// Whether the player can use the item, `isUsable` as 1 or nil; stock tints the row red when /// not (`QuestFrame.lua:393`). The app always sends `true`. pub usable: bool, /// The escaped item link `GetQuestItemLink` and `GetQuestLogItemLink` serve; `None` until the /// template lands, since it embeds the name and the quality. pub link: Option, } /// The reward spell, `rewSpell` on the giver packets and `SMSG_QUEST_QUERY_RESPONSE`, as /// `GetRewardSpell` and `GetQuestLogRewardSpell` answer it; stock counts it as one more reward slot /// (`QuestFrame.lua:332`). `tradeskill` is the third return, `isTradeskillSpell`. #[derive(Clone, Debug, Default, PartialEq)] pub struct QuestRewardSpell { pub spell_id: u32, pub name: Option, pub texture: Option, pub tradeskill: bool, } /// One open questgiver panel, with every field the four panels read; pushed whole by the app. #[derive(Clone, Debug, PartialEq)] pub struct QuestState { pub panel: QuestPanel, // Greeting panel. pub greeting: String, pub active_titles: Vec, pub available_titles: Vec, // Detail, progress and reward panels. pub title: String, /// The text of whichever panel is live: quest, progress or reward. pub body: String, pub objectives: String, /// Rewards the player picks one of. pub choices: Vec, /// Rewards all granted. pub rewards: Vec, /// Items the progress panel asks for. pub required: Vec, /// In copper. pub reward_money: u32, /// In copper. pub required_money: u32, pub completable: bool, pub reward_spell: Option, /// `GetQuestBackgroundMaterial`, which the app fills from an item or GameObject source, never /// from the wire; nil reads as "Parchment" (`QuestFrame.lua:598-604`). pub background_material: Option, } impl Default for QuestState { fn default() -> Self { QuestState { panel: QuestPanel::Detail, greeting: String::new(), active_titles: Vec::new(), available_titles: Vec::new(), title: String::new(), body: String::new(), objectives: String::new(), choices: Vec::new(), rewards: Vec::new(), required: Vec::new(), reward_money: 0, required_money: 0, completable: false, reward_spell: None, background_material: None, } } } /// A greeting row picked by `SelectActiveQuest` or `SelectAvailableQuest`, 1-based in its list. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct QuestSelect { pub active: bool, pub index: u32, } /// A questgiver button intent; the app sends the matching message for its NPC and quest. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum QuestAction { /// Detail panel Accept → `CMSG_QUESTGIVER_ACCEPT_QUEST`. Accept, /// Progress panel Continue → `CMSG_QUESTGIVER_REQUEST_REWARD`. Continue, /// Reward panel Complete → `CMSG_QUESTGIVER_CHOOSE_REWARD` with the 0-based choice. Reward(u32), /// `DeclineQuest()`: the detail panel's Decline and the progress and reward panels' Cancel. /// Its binding (`0x501d30`) re-opens the giver through `0x5013f0`: `CMSG_GOSSIP_HELLO` for a /// gossip unit, `CMSG_QUESTGIVER_HELLO` for another unit, the teardown `0x501130(0,1)` for an /// item or a player (or with `0xbe0824` set), a GameObject's interact call. The app owns this. Decline, /// `CloseQuest()`, from `QuestFrame_OnHide`: ESC, the close button, the greeting's Goodbye, a /// panel eviction. Its binding (`0x501a10`) calls only the teardown `0x501130(0,1)`, whose one /// send is `MSG_QUEST_PUSH_RESULT` DECLINE for a player source, so closing an NPC's window /// sends nothing. It stays apart from `Decline`: the re-open is `DeclineQuest`'s alone. Close, } impl super::UiScript { /// Push (or clear, with `None`) the open questgiver panel snapshot. pub fn set_quest(&mut self, state: Option) { self.model_mut().quest = state; } /// Drain the greeting-panel row selects queued since the last call. pub fn take_quest_selects(&mut self) -> Vec { std::mem::take(&mut self.model_mut().quest_selects) } /// Drain the button intents queued since the last call. pub fn take_quest_actions(&mut self) -> Vec { std::mem::take(&mut self.model_mut().quest_actions) } } /// `GetRewardSpell`'s three returns: `texture, name, isTradeskillSpell`, the third 1 or nil. pub(super) fn reward_spell_returns( lua: &Lua, spell: Option, ) -> mlua::Result { let Some(sp) = spell else { return Ok(MultiValue::from_vec(vec![ Value::Nil, Value::Nil, Value::Nil, ])); }; let texture = match &sp.texture { Some(t) => Value::String(lua.create_string(t)?), None => Value::Nil, }; let name = match &sp.name { Some(n) => Value::String(lua.create_string(n)?), None => Value::Nil, }; let tradeskill = if sp.tradeskill { Value::Integer(1) } else { Value::Nil }; Ok(MultiValue::from_vec(vec![texture, name, tradeskill])) } /// The icon an empty offer row answers: display 0 has no `ItemDisplayInfo` row, so the icon /// lookup falls back to the question mark (`0x5d88b0`, `0x847fe4`) under the icon folder. const EMPTY_ROW_ICON: &str = "Interface\\Icons\\INV_Misc_QuestionMark"; /// The list `GetQuestItemInfo(type, index)` reads for `type`. pub(super) fn item_vec<'a>(state: &'a QuestState, kind: &str) -> Option<&'a Vec> { match kind { "choice" => Some(&state.choices), "reward" => Some(&state.rewards), "required" => Some(&state.required), _ => None, } } /// Register the questgiver globals. pub(super) fn install(lua: &Lua) -> mlua::Result<()> { let g = lua.globals(); // ── Text getters ── fn install_text(lua: &Lua, name: &str, pick: fn(&QuestState) -> String) -> mlua::Result<()> { lua.globals().set( name, lua.create_function(move |lua, ()| { let text = { let model = lua.app_data_ref::().expect("model app_data"); model.quest.as_ref().map(pick).unwrap_or_default() }; Ok(Value::String(lua.create_string(&text)?)) })?, ) } install_text(lua, "GetGreetingText", |q| q.greeting.clone())?; install_text(lua, "GetTitleText", |q| q.title.clone())?; install_text(lua, "GetQuestText", |q| q.body.clone())?; install_text(lua, "GetProgressText", |q| q.body.clone())?; install_text(lua, "GetRewardText", |q| q.body.clone())?; install_text(lua, "GetObjectiveText", |q| q.objectives.clone())?; // ── Count and money getters ── fn install_count(lua: &Lua, name: &str, pick: fn(&QuestState) -> i64) -> mlua::Result<()> { lua.globals().set( name, lua.create_function(move |lua, ()| { let model = lua.app_data_ref::().expect("model app_data"); Ok(model.quest.as_ref().map(pick).unwrap_or(0)) })?, ) } install_count(lua, "GetNumActiveQuests", |q| q.active_titles.len() as i64)?; install_count(lua, "GetNumAvailableQuests", |q| { q.available_titles.len() as i64 })?; install_count(lua, "GetNumQuestChoices", |q| q.choices.len() as i64)?; install_count(lua, "GetNumQuestRewards", |q| q.rewards.len() as i64)?; install_count(lua, "GetNumQuestItems", |q| q.required.len() as i64)?; install_count(lua, "GetRewardMoney", |q| i64::from(q.reward_money))?; install_count(lua, "GetQuestMoneyToGet", |q| i64::from(q.required_money))?; g.set( "IsQuestCompletable", lua.create_function(|lua, ()| { let model = lua.app_data_ref::().expect("model app_data"); Ok(flag(model.quest.as_ref().is_some_and(|q| q.completable))) })?, )?; fn install_title(lua: &Lua, name: &str, active: bool) -> mlua::Result<()> { lua.globals().set( name, lua.create_function(move |lua, Idx(i): Idx| { let title = { let model = lua.app_data_ref::().expect("model app_data"); model.quest.as_ref().and_then(|q| { let v = if active { &q.active_titles } else { &q.available_titles }; i.checked_sub(1).and_then(|n| v.get(n)).cloned() }) }; Ok(Value::String(lua.create_string(title.unwrap_or_default())?)) })?, ) } install_title(lua, "GetActiveTitle", true)?; install_title(lua, "GetAvailableTitle", false)?; // GetQuestItemInfo(type, index) → name, texture, numItems, quality, isUsable (`0x501f80`): five // values, `isUsable` 1 or nil. A type other than `"reward"`, `"choice"` or `"required"` (any // case, `0x64a4c0`) or an index outside 1..7 (`0x501719`) raises `0x84edd8`, as does a // non-string type or non-number index. An index inside that range with no item answers the // zeroed row the offer publisher leaves (`0x500ef0`): no name, the question-mark icon of // display 0, count 0, quality 0 and usable. A template in flight answers an empty name. g.set( "GetQuestItemInfo", lua.create_function(|lua, (kind, index): (Value, Value)| { let invalid = || mlua::Error::runtime(r#"Invalid quest item in GetQuestItemInfo("type", index)"#); let kind = match &kind { Value::String(_) | Value::Integer(_) | Value::Number(_) => lua .coerce_string(kind.clone())? .map(|s| s.to_string_lossy().to_ascii_lowercase()), _ => None, } .ok_or_else(invalid)?; let index = lua.coerce_number(index)?.ok_or_else(invalid)?; // `_ftol` then `dec`, compared unsigned against 6. let row = index.trunc() as i64 - 1; if !(0..=6).contains(&row) { return Err(invalid()); } let item = { let model = lua.app_data_ref::().expect("model app_data"); let empty = Vec::new(); let list = match model.quest.as_ref() { Some(q) => item_vec(q, &kind), None => ["reward", "choice", "required"] .contains(&kind.as_str()) .then_some(&empty), }; let Some(list) = list else { return Err(invalid()); }; list.get(row as usize).cloned() }; let (name, texture, count, quality, usable) = match item { Some(it) => ( it.name.unwrap_or_default(), it.texture, it.count, it.quality, it.usable, ), None => (String::new(), Some(EMPTY_ROW_ICON.to_string()), 0, 0, true), }; Ok(MultiValue::from_vec(vec![ Value::String(lua.create_string(&name)?), match texture { Some(t) => Value::String(lua.create_string(&t)?), None => Value::Nil, }, Value::Integer(i64::from(count)), Value::Integer(i64::from(quality)), super::binding_abi::flag(usable), ])) })?, )?; // GetQuestItemLink(type, index), for a row's ctrl and shift clicks (`QuestFrame.lua:118`). g.set( "GetQuestItemLink", lua.create_function(|lua, (kind, index): (String, Value)| { // `index - 1` is compared unsigned against 6 (`0x501924`), so a zero or negative index // is no item; every miss pushes one nil (`0x50216a`). let index = super::binding_abi::index_arg( lua, index, "Usage: GetQuestItemLink(\"type\", index)", )?; let link = { let model = lua.app_data_ref::().expect("model app_data"); model.quest.as_ref().and_then(|q| { item_vec(q, &kind) .and_then(|v| index.checked_sub(1).and_then(|n| v.get(n))) .and_then(|it| it.link.clone()) }) }; match link { Some(l) => Ok(Value::String(lua.create_string(&l)?)), None => Ok(Value::Nil), } })?, )?; // GetRewardSpell() (`0x501df0`): three nils without a spell. g.set( "GetRewardSpell", lua.create_function(|lua, ()| { let spell = { let model = lua.app_data_ref::().expect("model app_data"); model.quest.as_ref().and_then(|q| q.reward_spell.clone()) }; reward_spell_returns(lua, spell) })?, )?; // ── Intents ── fn install_select(lua: &Lua, name: &str, active: bool) -> mlua::Result<()> { lua.globals().set( name, lua.create_function(move |lua, i: u32| { let mut model = lua.app_data_mut::().expect("model app_data"); model .quest_selects .push(super::QuestSelect { active, index: i }); Ok(()) })?, ) } install_select(lua, "SelectActiveQuest", true)?; install_select(lua, "SelectAvailableQuest", false)?; fn install_action(lua: &Lua, name: &str, action: super::QuestAction) -> mlua::Result<()> { lua.globals().set( name, lua.create_function(move |lua, ()| { let mut model = lua.app_data_mut::().expect("model app_data"); model.quest_actions.push(action); Ok(()) })?, ) } install_action(lua, "AcceptQuest", super::QuestAction::Accept)?; install_action(lua, "DeclineQuest", super::QuestAction::Decline)?; install_action(lua, "CloseQuest", super::QuestAction::Close)?; install_action(lua, "CompleteQuest", super::QuestAction::Continue)?; // GetQuestBackgroundMaterial() → nil or a material name (`0x502230`). g.set( "GetQuestBackgroundMaterial", lua.create_function(|lua, ()| { let material = { let model = lua.app_data_ref::().expect("model app_data"); model .quest .as_ref() .and_then(|q| q.background_material.clone()) }; match material { Some(m) => Ok(Value::String(lua.create_string(&m)?)), None => Ok(Value::Nil), } })?, )?; // QuestChooseRewardError() (`0x5021a0`), no returns: Complete pressed with choices and none // picked (`QuestFrame.lua:98`). It shows game error `0x98` through `0x496720`: the sound // `igQuestFailed`, then `ERR_QUEST_MUST_CHOOSE` through `UI_ERROR_MESSAGE`, synchronously. g.set( "QuestChooseRewardError", lua.create_function(|lua, ()| { { let mut model = lua.app_data_mut::().expect("model app_data"); model .sound_queue .push(super::SoundRequest::KitName("igQuestFailed".to_string())); } let text: String = lua .globals() .get::>("ERR_QUEST_MUST_CHOOSE")? .unwrap_or_else(|| "You must choose a reward.".to_string()); super::tick::fire_event_into( lua, "UI_ERROR_MESSAGE", vec![super::ScriptValue::Str(text)], ); Ok(()) })?, )?; // ConfirmAcceptQuest(): the escort confirm's Yes (`StaticPopup.lua:731-733`). It takes no quest // id, as the client answers the confirm it holds, so calls are counted and the app adds the id. g.set( "ConfirmAcceptQuest", lua.create_function(|lua, ()| { let mut model = lua.app_data_mut::().expect("model app_data"); model.quest_confirms += 1; Ok(()) })?, )?; // GetQuestReward(choice): stock passes the 1-based row (`QuestFrame.lua:100`), and the wire // takes it less one, floored at 0, as in the reference (`0x501d80`); vmangos indexes // `RewChoiceItemId` with it. g.set( "GetQuestReward", lua.create_function(|lua, choice: Option| { // `is-number` failing leaves the choice at -1 (`0x501d89`) and raises nothing; a // number is `trunc(n) - 1` (`0x501da8`), read as signed. let choice = match choice { Some(v) => lua .coerce_number(v)? .map_or(-1, |n| super::binding_abi::ftol(n).wrapping_sub(1)), None => -1, }; let mut model = lua.app_data_mut::().expect("model app_data"); // Off the reward panel the reference does nothing and answers success (`0x5015b0`: // `[0xbe0818] != 3`). let Some(q) = model .quest .as_ref() .filter(|q| q.panel == QuestPanel::Reward) else { return Ok(()); }; // With choices on offer an index outside `[0, n)` fails (`0x501608`..`0x50160e`) and // the binding raises; without any, the wire gets `max(choice, 0)` (`0x501627`). let n = q.choices.len() as i32; if n > 0 && !(0..n).contains(&choice) { return Err(mlua::Error::RuntimeError( "Invalid reward choice in GetQuestReward([choice])".into(), )); } model .quest_actions .push(super::QuestAction::Reward(choice.max(0) as u32)); Ok(()) })?, )?; Ok(()) } #[cfg(test)] mod tests { use super::{QuestAction, QuestItemView, QuestPanel, QuestSelect, QuestState}; use crate::script::UiScript; fn detail() -> QuestState { QuestState { panel: QuestPanel::Detail, title: "A Threat Within".into(), body: "Kill the kobolds infesting the mine.".into(), objectives: "Slay 10 Kobold Vermin.".into(), rewards: vec![QuestItemView { item_id: 7278, name: Some("Brdle Leather Boots".into()), texture: Some("Interface\\Icons\\INV_Boots_01".into()), count: 1, quality: 2, usable: true, link: Some("|cff1eff00|Hitem:7278:0:0:0|h[Brdle Leather Boots]|h|r".into()), }], choices: vec![ QuestItemView { item_id: 0, name: Some("Cudgel".into()), texture: None, count: 1, quality: 1, usable: true, ..Default::default() }, QuestItemView { item_id: 0, name: None, // template still in flight texture: None, count: 1, quality: 1, usable: true, ..Default::default() }, ], reward_money: 1234, ..Default::default() } } #[test] fn background_material_answers_the_pushed_material_or_nil() { let mut s = UiScript::new().unwrap(); s.set_quest(Some(QuestState::default())); assert!(s .eval::("return GetQuestBackgroundMaterial() == nil") .unwrap()); s.set_quest(Some(QuestState { background_material: Some("Stone".into()), ..QuestState::default() })); assert_eq!( s.eval::("return GetQuestBackgroundMaterial()") .unwrap(), "Stone" ); } #[test] fn choose_reward_error_fires_the_error_event_with_the_global_string() { let mut s = UiScript::new().unwrap(); s.run( "ERR_QUEST_MUST_CHOOSE = 'Pick one.' \ local f = CreateFrame('Frame') f:RegisterEvent('UI_ERROR_MESSAGE') \ f:SetScript('OnEvent', function() SEEN = arg1 end) \ N = table.getn({QuestChooseRewardError()})", ) .unwrap(); assert_eq!(s.eval::("return SEEN").unwrap(), "Pick one."); assert_eq!(s.eval::("return N").unwrap(), 0, "zero return values"); assert_eq!( s.take_sounds(), vec![crate::script::SoundRequest::KitName("igQuestFailed".into())], "row 0x98's cue plays with the message" ); } #[test] fn detail_panel_getters_read() { let mut s = UiScript::new().unwrap(); // No window: text empty, counts zero. assert_eq!(s.eval::("return GetTitleText()").unwrap(), ""); assert_eq!(s.eval::("return GetNumQuestChoices()").unwrap(), 0); s.set_quest(Some(detail())); assert_eq!( s.eval::("return GetTitleText()").unwrap(), "A Threat Within" ); assert_eq!( s.eval::("return GetObjectiveText()").unwrap(), "Slay 10 Kobold Vermin." ); assert_eq!(s.eval::("return GetNumQuestChoices()").unwrap(), 2); assert_eq!(s.eval::("return GetNumQuestRewards()").unwrap(), 1); assert_eq!(s.eval::("return GetRewardMoney()").unwrap(), 1234); // Five values, `isUsable` 1 or nil (`0x501f80`), the type matched in any case. assert_eq!(s.arity(r#"GetQuestItemInfo("reward", 1)"#).unwrap(), 5); let (name, texture, count, quality, usable) = s .eval::<(String, String, i64, i64, i64)>(r#"return GetQuestItemInfo("REWARD", 1)"#) .unwrap(); assert_eq!(name, "Brdle Leather Boots"); assert_eq!(texture, "Interface\\Icons\\INV_Boots_01"); assert_eq!((count, quality, usable), (1, 2, 1)); // Choice row 2 is in flight: an empty name, the rest present. assert!(s .eval::( "local n, t, c = GetQuestItemInfo(\"choice\", 2)\n\ return n == '' and t == nil and c == 1" ) .unwrap()); // Inside 1..7 with no item: the zeroed row, display 0's question mark. assert_eq!( s.eval::<(String, String, i64, i64, i64)>(r#"return GetQuestItemInfo("reward", 7)"#) .unwrap(), ( String::new(), "Interface\\Icons\\INV_Misc_QuestionMark".to_string(), 0, 0, 1 ) ); // Outside 1..7, an unknown type or a missing argument raises `0x84edd8`. for call in [ r#"GetQuestItemInfo("reward", 8)"#, r#"GetQuestItemInfo("reward", 0)"#, r#"GetQuestItemInfo("bogus", 1)"#, r#"GetQuestItemInfo("reward")"#, "GetQuestItemInfo()", ] { let e = s.run(call).expect_err(call).to_string(); assert!( e.contains(r#"Invalid quest item in GetQuestItemInfo("type", index)"#), "{call}: {e}" ); } // The link is nil for the in-flight row, out of range, and for an unknown type. assert_eq!( s.eval::("return GetQuestItemLink(\"reward\", 1)") .unwrap(), "|cff1eff00|Hitem:7278:0:0:0|h[Brdle Leather Boots]|h|r" ); assert!(s .eval::("return GetQuestItemLink(\"choice\", 2) == nil") .unwrap()); assert!(s .eval::("return GetQuestItemLink(\"reward\", 9) == nil") .unwrap()); assert!(s .eval::("return GetQuestItemLink(\"bogus\", 1) == nil") .unwrap()); } #[test] fn greeting_panel_lists_and_selects() { let mut s = UiScript::new().unwrap(); s.set_quest(Some(QuestState { panel: QuestPanel::Greeting, greeting: "What can I do for you?".into(), active_titles: vec!["Report to Goldshire".into()], available_titles: vec!["A Threat Within".into(), "Kobold Camp Cleanup".into()], ..Default::default() })); assert_eq!( s.eval::("return GetGreetingText()").unwrap(), "What can I do for you?" ); assert_eq!(s.eval::("return GetNumActiveQuests()").unwrap(), 1); assert_eq!(s.eval::("return GetNumAvailableQuests()").unwrap(), 2); assert_eq!( s.eval::("return GetActiveTitle(1)").unwrap(), "Report to Goldshire" ); assert_eq!( s.eval::("return GetAvailableTitle(2)").unwrap(), "Kobold Camp Cleanup" ); s.run("SelectActiveQuest(1)").unwrap(); s.run("SelectAvailableQuest(2)").unwrap(); assert_eq!( s.take_quest_selects(), vec![ QuestSelect { active: true, index: 1 }, QuestSelect { active: false, index: 2 }, ] ); assert!(s.take_quest_selects().is_empty(), "drained"); } #[test] fn progress_panel_completability() { let mut s = UiScript::new().unwrap(); s.set_quest(Some(QuestState { panel: QuestPanel::Progress, body: "Do you have the tusks?".into(), required: vec![QuestItemView { item_id: 0, name: Some("Chipped Boar Tusk".into()), texture: None, count: 8, quality: 0, usable: true, ..Default::default() }], required_money: 500, completable: true, ..Default::default() })); assert_eq!( s.eval::("return GetProgressText()").unwrap(), "Do you have the tusks?" ); assert_eq!(s.eval::("return GetNumQuestItems()").unwrap(), 1); assert_eq!(s.eval::("return GetQuestMoneyToGet()").unwrap(), 500); assert!(s.eval::("return IsQuestCompletable()").unwrap()); } #[test] fn button_intents_queue() { let mut s = UiScript::new().unwrap(); s.set_quest(Some(detail())); s.run("AcceptQuest()").unwrap(); assert_eq!(s.take_quest_actions(), vec![QuestAction::Accept]); s.run("CompleteQuest()").unwrap(); // progress -> reward let mut offer = detail(); offer.panel = QuestPanel::Reward; s.set_quest(Some(offer)); // the app pushes the reward panel s.run("GetQuestReward(2)").unwrap(); // row 2 (1-based) → wire choice 1 (0-based) s.run("DeclineQuest()").unwrap(); assert_eq!( s.take_quest_actions(), vec![ QuestAction::Continue, QuestAction::Reward(1), QuestAction::Decline, ] ); assert!(s.take_quest_actions().is_empty(), "drained"); } /// `GetQuestReward` (`0x501d80` → `0x5015b0`): with choices on offer an index outside `[0, n)` /// raises "Invalid reward choice" (`0x84eda4`), the default for no argument being -1; with /// none the wire gets `max(index, 0)`; off the reward panel it does nothing. #[test] fn get_quest_reward_validates_the_choice_against_the_offer() { let mut s = UiScript::new().unwrap(); s.set_quest(Some(detail())); let n_choices = s.eval::("return GetNumQuestChoices()").unwrap(); assert!(n_choices >= 2); // Not on the reward panel yet: nothing happens, and nothing raises. s.run("GetQuestReward(99)").unwrap(); assert!(s.take_quest_actions().is_empty()); s.run("CompleteQuest()").unwrap(); s.take_quest_actions(); let mut offer = detail(); offer.panel = QuestPanel::Reward; s.set_quest(Some(offer)); for call in [ "GetQuestReward()", "GetQuestReward(0)", "GetQuestReward(-3)", &format!("GetQuestReward({})", n_choices + 1), "GetQuestReward('x')", ] { let err = s.run(call).unwrap_err().to_string(); assert!( err.contains("Invalid reward choice in GetQuestReward([choice])"), "{call}: {err}" ); } assert!( s.take_quest_actions().is_empty(), "a failed pick sends nothing" ); s.run(&format!("GetQuestReward({n_choices})")).unwrap(); s.run("GetQuestReward(1.9)").unwrap(); assert_eq!( s.take_quest_actions(), vec![ QuestAction::Reward(n_choices as u32 - 1), QuestAction::Reward(0) ] ); // No choices on offer: any index goes, as `max(index, 0)`. let mut bare = detail(); bare.choices.clear(); bare.panel = QuestPanel::Reward; let mut s = UiScript::new().unwrap(); s.set_quest(Some(bare)); for call in [ "GetQuestReward()", "GetQuestReward(-3)", "GetQuestReward(4)", ] { s.run(call).unwrap(); } assert_eq!( s.take_quest_actions(), vec![ QuestAction::Reward(0), QuestAction::Reward(0), QuestAction::Reward(3) ] ); } #[test] fn clearing_the_quest_empties_it() { let mut s = UiScript::new().unwrap(); s.set_quest(Some(detail())); s.set_quest(None); assert_eq!(s.eval::("return GetTitleText()").unwrap(), ""); assert_eq!(s.eval::("return GetNumQuestRewards()").unwrap(), 0); assert!(s.eval::("return GetRewardSpell() == nil").unwrap()); } #[test] fn decline_and_close_are_two_intents() { let mut s = UiScript::new().unwrap(); s.set_quest(Some(detail())); s.run("DeclineQuest()").unwrap(); s.run("CloseQuest()").unwrap(); assert_eq!( s.take_quest_actions(), vec![QuestAction::Decline, QuestAction::Close] ); } #[test] fn confirm_accept_quest_counts_and_drains() { let mut s = UiScript::new().unwrap(); assert_eq!(s.take_quest_confirms(), 0); s.run("ConfirmAcceptQuest() ConfirmAcceptQuest()").unwrap(); assert_eq!(s.take_quest_confirms(), 2); assert_eq!(s.take_quest_confirms(), 0, "drained"); } }