Files
benilla-octo/crates/benilla-ui/src/script/quest.rs
T
SamandClaude Fable 5.1 b1294ce9a1 the quest windows go stock, and the 20 ms frames were mis-split pairs
- the quest giver's dialog and the quest log are the reference's own `QuestFrame.xml` and `QuestLogFrame.xml`, run off the player's chain at the reference's own toc seats; our 1366-line and 1999-line copies are deleted, and the verbs the stock files call are built — the four `GameTooltip` quest-item and reward-spell setters, the reward spell itself (parsed on all three packets and dropped by the model until now), and two verbs; the detail pane could never scroll because `UpdateScrollChildRect` measured a frame late where the reference's measure is synchronous, so it measures through the installed font engine now; and ten things the reference does that ours did not, each found by a test that had enshrined ours — a shift-click never selects, an empty log hides the whole detail pane and says "No Active Quests", the abandon confirm lives in the popup registry (1944)
- the quest bindings' law, read off the binary and folded back: no 1.12 packet carries a quest background material — `GetQuestBackgroundMaterial` (`0x502230`) forks on the dialog's SOURCE object, an item answering its template's PageMaterial, a GameObject its template data, a creature nil (FrameXML's "Parchment"), the id naming a `PageTextMaterial.dbc` row whose six words are nowhere in the binary; its twin `ItemTextGetMaterial` has the same body, so the reader window already held every piece and the giver's feed runs the same resolve; `QuestChooseRewardError` is game-error row `0x98`, and the row plays `igQuestFailed` first, which the verb now queues; the reward-spell getters' third return is `Spell.dbc` Attributes bit 5 (1161 of 22357 rows set — craft-cast spells set, `Pattern:` teaching spells clear), read off the catalog now (1946)
- the "20 ms frames" of a raid, chased to their number: the probe's worst frames now carry their churn (entities, assets, first-play sound decodes, live particles, the GPU reading) and in a Blackrock raid with sound on every one of those read flat; then the same window on the PACED clock — under vsync with pipelined rendering the raw delta is mis-split into long/short pairs that sum to exact refresh multiples, so an alone Stormwind leg read 115 of 300 frames over budget with cpu and gpu both at their means, while the paced clock read 0 skipped frames in every leg; what remains real in the raid is `cpu_p99` at 22–23 ms with sound on; and every submesh the streamer spawns now carries why it is an entity, counted per reason — 5.8k resident ADT-doodad submeshes at the Stormwind auction house, 5.3k of them hidden, three prop families the retained pass declines by rule, each riding every per-`Mesh3d` sweep for ~0.4 ms of the alone frame (1947)
- a raid's dozen voices cost the mixer ~1.5 ms of every 60 Hz frame: upstream kira spatializes every frame — normalizes, the attenuation curve and a dB→amplitude `powf` per frame per spatial track, two more `powf`s for the volume tween, ~1.7 M `powf`s a second at 48 kHz × 12 tracks for gains that change imperceptibly across a chunk — so kira 0.12.1 is vendored with one patch: the spatial gains and the volume × fade amplitude are evaluated at the chunk's two ends and interpolated across its ~10.7 ms, the granularity FMOD 3, the reference's own mixer, updates them at; nothing a listener can name changes (1945); and rig poses are evaluated in parallel over rigs — each reads its shared clips and writes only its own pose, and a raid's ~400 live rigs were 0.4 ms of one thread walked serially on the main thread's critical path (1947)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 20:55:12 +07:00

776 lines
33 KiB
Rust

//! The questgiver bindings (decision 0088) — the Era-shaped quest-dialog surface, the same two-way
//! seam as [`super::gossip`]/[`super::merchant`]: the app pushes a **quest panel snapshot**
//! ([`UiScript::set_quest`] — the greeting/detail/progress/reward text + item rows already resolved
//! from the wire), and the Lua `AcceptQuest`/`CompleteQuest`/`GetQuestReward`/`SelectActiveQuest`/…
//! calls queue outbound **intents** the app drains ([`UiScript::take_quest_selects`] /
//! [`UiScript::take_quest_actions`]). The engine holds no quest knowledge — a row is "a name, an
//! icon, a count, a quality, and whether it's usable"; a button press is a bare intent the app maps
//! to `(npc, questId)` from its own live state.
//!
//! ## The Era API shape
//!
//! 1.12's `QuestFrame.lua` reads flat getters positionally, one panel at a time: the greeting panel
//! walks `GetNumActiveQuests()`/`GetActiveTitle(i)` (+ the available twins), the detail panel reads
//! `GetTitleText()`/`GetQuestText()`/`GetObjectiveText()`, the progress panel reads
//! `GetProgressText()`/`IsQuestCompletable()`/`GetNumQuestItems()`/`GetQuestItemInfo("required",i)`,
//! the reward panel reads `GetRewardText()`/`GetNumQuestChoices()`/`GetNumQuestRewards()`/
//! `GetRewardMoney()`/`GetQuestItemInfo("choice"|"reward",i)`. Benilla keeps those exact names on a
//! single pushed [`QuestState`]; which panel is live is [`QuestState::panel`], surfaced to the XML
//! through the `QUEST_GREETING`/`QUEST_DETAIL`/`QUEST_PROGRESS`/`QUEST_COMPLETE` events the app
//! fires. `GetRewardSpell()` returns `nil` in v1 (spell-reward rows are out of scope, decision 0088).
use mlua::{Lua, MultiValue, Value};
use super::Model;
/// Which of the four questgiver sub-panels a [`QuestState`] is for (the app sets it from the wire
/// packet; the XML's event routing mirrors it).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum QuestPanel {
/// `SMSG_QUESTGIVER_QUEST_LIST` — the multi-quest greeting (active + 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 quest item row (choice reward / fixed reward / required item), resolved by the app from the
/// wire triple + its item stores. Plain data — 1-based order is its position in the owning vector.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct QuestItemView {
/// Item name (`GetQuestItemInfo`'s first return); `None` while the ask-once template query is in
/// flight (the API reports `nil`, the XML shows a placeholder).
pub name: Option<String>,
/// Icon texture path (`Interface\Icons\…`); `None` while the template answer is in flight (the
/// icon comes from the wire display id, so it's usually present immediately).
pub texture: Option<String>,
/// Stack count for the row (`numItems`).
pub count: u32,
/// Item quality (0 poor .. 5 legendary); colours the reward name. `1` (common/white) when the
/// template hasn't landed.
pub quality: u32,
/// The item id — the shared item-tooltip store's key (`BenillaGetItemStats`); `0` while the
/// wire row hasn't resolved. A benilla extension: the era 5-tuple never carried it (tooltip
/// content was C++'s alone), so it rides as a TRAILING 6th return, invisible to era callers.
pub item_id: u32,
/// Whether the reward is usable by the player's class/race — v1 always `true` (soft gray only,
/// the server stays authoritative).
pub usable: bool,
/// The full escaped `|cff…|Hitem:…|h[Name]|h|r` link (`GetQuestItemLink` /
/// `GetQuestLogItemLink` serve it) — the ctrl/shift click arms' payload (decisions 1059/1060).
/// `None` until the ask-once item template lands: the link is built from the name **and** the
/// quality, and neither is known before then, exactly like [`super::InvSlotView::link`].
pub link: Option<String>,
}
/// One open questgiver panel: the active sub-panel plus every field the four panels might read.
/// Pushed whole by the app; `None` means no quest window is open.
/// The quest's reward spell — `rewSpell` on `SMSG_QUESTGIVER_QUEST_DETAILS` /
/// `SMSG_QUESTGIVER_OFFER_REWARD` and on `SMSG_QUEST_QUERY_RESPONSE` — resolved by the app to
/// the name and icon `GetRewardSpell` / `GetQuestLogRewardSpell` answer (stock
/// `QuestFrameItems_Update` counts it as one more reward slot, `rewardType = "spell"`, and the
/// slot's hover is `GameTooltip:SetQuestRewardSpell()`). `tradeskill` is the third return the
/// reference derives from the spell itself (1944 — the derivation is wow-re's to pin).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct QuestRewardSpell {
pub spell_id: u32,
pub name: Option<String>,
pub texture: Option<String>,
pub tradeskill: bool,
}
#[derive(Clone, Debug, PartialEq)]
pub struct QuestState {
pub panel: QuestPanel,
// Greeting panel.
pub greeting: String,
pub active_titles: Vec<String>,
pub available_titles: Vec<String>,
// Detail / progress / reward.
/// The quest title (`GetTitleText`).
pub title: String,
/// The panel body text: quest description (detail), request text (progress), or reward text
/// (reward). One field — only one of the three panels is live at a time.
pub body: String,
/// The quest objectives line (`GetObjectiveText`, detail panel only).
pub objectives: String,
/// Choice rewards (the player picks one) — reward/detail panel.
pub choices: Vec<QuestItemView>,
/// Fixed rewards (all granted) — reward/detail panel.
pub rewards: Vec<QuestItemView>,
/// Required items — progress panel.
pub required: Vec<QuestItemView>,
/// Reward money in copper (`GetRewardMoney`, detail/reward panels).
pub reward_money: u32,
/// Required money in copper (`GetQuestMoneyToGet`, progress panel).
pub required_money: u32,
/// Whether the turn-in is completable (`IsQuestCompletable`, progress panel).
pub completable: bool,
/// The reward spell, if the quest teaches one (`GetRewardSpell`; detail/reward panels).
pub reward_spell: Option<QuestRewardSpell>,
/// The panel's background material (`GetQuestBackgroundMaterial` — nil is the reference's own
/// "Parchment" fallback in `QuestFrame_GetMaterial`). What fills it on the wire is 1944's
/// wow-re question; until the binary says, nothing does, and the verb answers nil honestly.
pub background_material: Option<String>,
}
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-panel row click queued by `SelectActiveQuest`/`SelectAvailableQuest`: the 1-based row
/// in its list. `active` picks between the active and available lists (the app maps the index to the
/// quest id).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct QuestSelect {
pub active: bool,
pub index: u32,
}
/// A questgiver button intent queued by the Lua panel verbs; the app maps each to `(npc, questId)`
/// from its own live state and the matching `CMSG_QUESTGIVER_*`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum QuestAction {
/// Detail panel Accept → `CMSG_QUESTGIVER_ACCEPT_QUEST`.
Accept,
/// Progress panel Continue → `CMSG_QUESTGIVER_REQUEST_REWARD` (advance to the reward panel).
Continue,
/// Reward panel Complete → `CMSG_QUESTGIVER_CHOOSE_REWARD` with the chosen choice index (0 when
/// the quest has no choice rewards).
Reward(u32),
/// **The quest session's end**, however it was reached: `DeclineQuest()` (the detail panel's
/// Decline, the progress/reward panels' Cancel, the greeting's Goodbye) and `CloseQuest()`
/// (the window's own OnHide — ESC, a UIPanel eviction) alike.
///
/// **One action, because the reference has one routine.** 1733 briefly split this into
/// `Decline` and `Close` on the assumption that only the first was an answer on the wire; the
/// §5 that landed after it found both Lua verbs calling `0x501130`, along with the walk-away
/// watchdog and the leave-world teardown, and that routine both answers a share
/// (`MSG_QUEST_PUSH_RESULT{DECLINE_QUEST}`) and re-opens an NPC's list
/// (`CMSG_QUESTGIVER_HELLO`/`CMSG_GOSSIP_HELLO`). Which of the two it does is decided by the
/// GIVER, never by the button — so the app owns that fork and the engine reports one intent
/// (decision 1738).
Close,
}
impl super::UiScript {
/// Push (or clear, with `None`) the open questgiver panel snapshot.
pub fn set_quest(&mut self, state: Option<QuestState>) {
self.model_mut().quest = state;
}
/// Drain the greeting-panel row selects queued since the last call.
pub fn take_quest_selects(&mut self) -> Vec<QuestSelect> {
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<QuestAction> {
std::mem::take(&mut self.model_mut().quest_actions)
}
}
/// `GetQuestItemInfo(type, index)` reads from this vector by `type`; unknown types → `None`.
/// The three returns of `GetRewardSpell` / `GetQuestLogRewardSpell`: `texture, name,
/// isTradeskillSpell` — the third a 1/nil boolean in the reference's convention.
pub(super) fn reward_spell_returns(
lua: &Lua,
spell: Option<QuestRewardSpell>,
) -> mlua::Result<MultiValue> {
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]))
}
pub(super) fn item_vec<'a>(state: &'a QuestState, kind: &str) -> Option<&'a Vec<QuestItemView>> {
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 (one per panel field; nil/"" when no window is open) ─────────────────────────
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::<Model>().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 + 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::<Model>().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))?;
// IsQuestCompletable() → bool (progress panel gate for the Continue button).
g.set(
"IsQuestCompletable",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model.quest.as_ref().is_some_and(|q| q.completable))
})?,
)?;
// GetActiveTitle(i) / GetAvailableTitle(i) — 1-based; "" when out of range.
fn install_title(lua: &Lua, name: &str, active: bool) -> mlua::Result<()> {
lua.globals().set(
name,
lua.create_function(move |lua, i: usize| {
let title = {
let model = lua.app_data_ref::<Model>().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 (the Era tuple).
// `type` is "choice" / "reward" / "required"; `index` 1-based; out of range → nil.
g.set(
"GetQuestItemInfo",
lua.create_function(|lua, (kind, index): (String, usize)| {
let item = {
let model = lua.app_data_ref::<Model>().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)))
.cloned()
})
};
let Some(it) = item else {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
};
let name = match &it.name {
Some(n) => Value::String(lua.create_string(n)?),
None => Value::Nil,
};
let texture = match &it.texture {
Some(t) => Value::String(lua.create_string(t)?),
None => Value::Nil,
};
Ok(MultiValue::from_vec(vec![
name,
texture,
Value::Integer(i64::from(it.count)),
Value::Integer(i64::from(it.quality)),
Value::Boolean(it.usable),
// Benilla extension (6th): the item id, the shared tooltip store's key.
Value::Integer(i64::from(it.item_id)),
]))
})?,
)?;
// GetQuestItemLink(type, index) → the full escaped `|cff…|Hitem:…|h[Name]|h|r` | nil. The
// questgiver panels' ctrl/shift click arms read it — `DressUpItemLink(GetQuestItemLink(
// this.type, this:GetID()))` (ref QuestFrame.lua:118/130) and the shift-click
// `ChatFrameEditBox:Insert(...)` beside it (l.122/134). Same `type`/`index` addressing as
// `GetQuestItemInfo` above; nil for an out-of-range row, an unknown type, or a row whose
// template answer is still in flight (see [`QuestItemView::link`]). Decisions 1059/1060.
g.set(
"GetQuestItemLink",
lua.create_function(|lua, (kind, index): (String, usize)| {
let link = {
let model = lua.app_data_ref::<Model>().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() → texture, name, isTradeskillSpell (three returns, 0x501df0; three nils on
// every empty path, the reference's own `(nil,nil,nil)` kind — 1842). Real since 1944: the
// app resolves `rewSpell` off the giver packets; stock's `if ( GetRewardSpell() ) then` counts
// the slot.
g.set(
"GetRewardSpell",
lua.create_function(|lua, ()| {
let spell = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.quest.as_ref().and_then(|q| q.reward_spell.clone())
};
reward_spell_returns(lua, spell)
})?,
)?;
// ── Intents ───────────────────────────────────────────────────────────────────────────────────
// SelectActiveQuest(i) / SelectAvailableQuest(i) — queue a greeting-row select (1-based).
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::<Model>().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::<Model>().expect("model app_data");
model.quest_actions.push(action);
Ok(())
})?,
)
}
// AcceptQuest → Accept; DeclineQuest / CloseQuest → Close (the ONE session-end routine, see
// `QuestAction::Close`); CompleteQuest (the progress panel's Continue) → Continue.
install_action(lua, "AcceptQuest", super::QuestAction::Accept)?;
install_action(lua, "DeclineQuest", super::QuestAction::Close)?;
install_action(lua, "CloseQuest", super::QuestAction::Close)?;
install_action(lua, "CompleteQuest", super::QuestAction::Continue)?;
// GetQuestBackgroundMaterial() → nil | string (0x502230; 0 args, 1 return). The reference's
// `QuestFrame_GetMaterial` (QuestFrame.lua:597-603) treats nil as "Parchment" and any string
// as the name of a material whose four `$parentMaterial*` quadrants and text colours
// (`GetMaterialTextColors`) the panel switches to. Answered from the pushed panel state.
g.set(
"GetQuestBackgroundMaterial",
lua.create_function(|lua, ()| {
let material = {
let model = lua.app_data_ref::<Model>().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, 0 args, 0 returns: the Complete button pressed with
// choices on offer and none picked (stock `QuestRewardCompleteButton_OnClick`,
// QuestFrame.lua:96-100). Sixteen bytes — `push 0x98; call 0x496720` — the game-error row
// `ERR_QUEST_MUST_CHOOSE` (wow-re quest-material-reward-spell-bindings.md §2): the row's sound
// cue `igQuestFailed` plays first, then kind 2 routes the GlobalStrings text through
// `UI_ERROR_MESSAGE` — synchronously, as SignalEvent is — and UIErrorsFrame prints it. The
// text is read from the same global the FrameXML shows, so a localised chain shows its own
// string; the shipped English is the fallback when no GlobalStrings is loaded.
g.set(
"QuestChooseRewardError",
lua.create_function(|lua, ()| {
{
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model
.sound_queue
.push(super::SoundRequest::KitName("igQuestFailed".to_string()));
}
let text: String = lua
.globals()
.get::<Option<String>>("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 (ref StaticPopup.lua:731-733, raised by
// QUEST_ACCEPT_CONFIRM). No argument and no quest id: the client answers whichever confirm it
// is holding, so the engine counts the calls and the app supplies the id (decision 1733).
g.set(
"ConfirmAcceptQuest",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.quest_confirms += 1;
Ok(())
})?,
)?;
// GetQuestReward(choice) — the reward panel's Complete button. FrameXML passes the 1-based row
// (`QuestFrameRewardPanel.itemChoice = this:GetID()`, stock QuestFrame.lua:97-101) and the
// wire wants the 0-based choice (`CMSG_QUESTGIVER_CHOOSE_REWARD`'s reward index — vmangos
// indexes `RewChoiceItemId[reward]` with it), so the conversion is the binding's, as it is the
// client's; our own Lua used to do the -1 itself (1944). A quest with no choice rewards
// passes 0, which stays 0.
g.set(
"GetQuestReward",
lua.create_function(|lua, choice: Option<u32>| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.quest_actions.push(super::QuestAction::Reward(
choice.unwrap_or(0).saturating_sub(1),
));
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::<bool>("return GetQuestBackgroundMaterial() == nil")
.unwrap());
s.set_quest(Some(QuestState {
background_material: Some("Stone".into()),
..QuestState::default()
}));
assert_eq!(
s.eval::<String>("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::<String>("return SEEN").unwrap(), "Pick one.");
assert_eq!(s.eval::<i64>("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::<String>("return GetTitleText()").unwrap(), "");
assert_eq!(s.eval::<i64>("return GetNumQuestChoices()").unwrap(), 0);
s.set_quest(Some(detail()));
assert_eq!(
s.eval::<String>("return GetTitleText()").unwrap(),
"A Threat Within"
);
assert_eq!(
s.eval::<String>("return GetObjectiveText()").unwrap(),
"Slay 10 Kobold Vermin."
);
assert_eq!(s.eval::<i64>("return GetNumQuestChoices()").unwrap(), 2);
assert_eq!(s.eval::<i64>("return GetNumQuestRewards()").unwrap(), 1);
assert_eq!(s.eval::<i64>("return GetRewardMoney()").unwrap(), 1234);
// Row 1 (resolved): the Era tuple.
let (name, texture, count, quality, usable) = s
.eval::<(String, String, i64, i64, bool)>("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, true));
// Choice row 2 (in flight): name + texture nil, the rest present.
assert!(s
.eval::<bool>(
"local n, t, c = GetQuestItemInfo(\"choice\", 2)\n\
return n == nil and t == nil and c == 1"
)
.unwrap());
// Out of range / unknown type → nil.
assert!(s
.eval::<bool>("return GetQuestItemInfo(\"reward\", 9) == nil")
.unwrap());
assert!(s
.eval::<bool>("return GetQuestItemInfo(\"bogus\", 1) == nil")
.unwrap());
// GetQuestItemLink: the resolved row's full escaped link; nil for a row still waiting on
// its template (choice 2 has no name yet, so no link either), and nil out of range /
// unknown type — the three nils the ref's ctrl/shift arms hand to DressUpItemLink.
assert_eq!(
s.eval::<String>("return GetQuestItemLink(\"reward\", 1)")
.unwrap(),
"|cff1eff00|Hitem:7278:0:0:0|h[Brdle Leather Boots]|h|r"
);
assert!(s
.eval::<bool>("return GetQuestItemLink(\"choice\", 2) == nil")
.unwrap());
assert!(s
.eval::<bool>("return GetQuestItemLink(\"reward\", 9) == nil")
.unwrap());
assert!(s
.eval::<bool>("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::<String>("return GetGreetingText()").unwrap(),
"What can I do for you?"
);
assert_eq!(s.eval::<i64>("return GetNumActiveQuests()").unwrap(), 1);
assert_eq!(s.eval::<i64>("return GetNumAvailableQuests()").unwrap(), 2);
assert_eq!(
s.eval::<String>("return GetActiveTitle(1)").unwrap(),
"Report to Goldshire"
);
assert_eq!(
s.eval::<String>("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::<String>("return GetProgressText()").unwrap(),
"Do you have the tusks?"
);
assert_eq!(s.eval::<i64>("return GetNumQuestItems()").unwrap(), 1);
assert_eq!(s.eval::<i64>("return GetQuestMoneyToGet()").unwrap(), 500);
assert!(s.eval::<bool>("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
s.run("GetQuestReward(2)").unwrap(); // row 2 (1-based) → wire choice 1 (0-based)
s.run("GetQuestReward()").unwrap(); // no-choice quest -> 0
s.run("DeclineQuest()").unwrap();
assert_eq!(
s.take_quest_actions(),
vec![
QuestAction::Continue,
QuestAction::Reward(1),
QuestAction::Reward(0),
QuestAction::Close,
]
);
assert!(s.take_quest_actions().is_empty(), "drained");
}
#[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::<String>("return GetTitleText()").unwrap(), "");
assert_eq!(s.eval::<i64>("return GetNumQuestRewards()").unwrap(), 0);
assert!(s.eval::<bool>("return GetRewardSpell() == nil").unwrap());
}
/// `DeclineQuest` and `CloseQuest` are the SAME intent — one reference routine, `0x501130`
/// (decision 1738, correcting 1733's split). What differs between a decline and a close is
/// nothing; what differs between an NPC's quest and a shared one is everything, and that fork
/// belongs to the app, which knows the giver.
#[test]
fn decline_and_close_are_one_intent() {
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::Close, QuestAction::Close]
);
}
/// `ConfirmAcceptQuest` counts rather than carrying a quest id — the reference's verb takes no
/// argument and answers whichever confirm the client holds (decision 1733).
#[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");
}
}