- the last app-side lines become GlobalStrings keys — tooltip cells, party, petition, duel, quest, PvP and rest lines read the install's own wording, so a non-enUS chain shows its own text (2045, 2080) - a locked chest says Requires Pick Lock, ammo and thrown weapons drop their invented slot word, a met set bonus reads Set: without the count, and a 100 s cooldown reads 1.67 min as the template prints it - the level-up block and the free-professions line printed twice in chat and print once; a declined invite, a friend coming online, a duel request and a failed quest play the sound 1.12 gives them - Duel cancelled. is the yellow line at the top of the screen, and a trainer refusal or a failed quest with no cached template shows nothing, as on 1.12 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1198 lines
54 KiB
Rust
1198 lines
54 KiB
Rust
//! The app-side **questgiver feed** (decision 0088) — the inward half of the quest seam around
|
|
//! [`benilla_ui::script`]'s `quest` module, the twin of [`crate::ui_gossip`]/[`crate::ui_merchant`].
|
|
//!
|
|
//! The net bridge fills [`QuestGiver`] from the wire: `SMSG_QUESTGIVER_QUEST_LIST` → the greeting
|
|
//! panel, `_QUEST_DETAILS` → the accept panel, `_REQUEST_ITEMS` → the progress panel,
|
|
//! `_OFFER_REWARD` → the reward panel, `_QUEST_COMPLETE` → the turn-in result (closes the window),
|
|
//! and `SMSG_QUESTGIVER_STATUS` → the per-guid dialog-status store (world markers are a later
|
|
//! slice — decision 0088). Each frame [`feed_quest`] resolves the open view into a
|
|
//! [`QuestState`] snapshot (item names via the ask-once template cache, icons straight from the
|
|
//! wire display id — the merchant's pattern), pushes it ([`UiScript::set_quest`]), and fires the
|
|
//! matching FrameXML event (`QUEST_GREETING`/`QUEST_DETAIL`/`QUEST_PROGRESS`/`QUEST_COMPLETE` on a
|
|
//! panel change, `QUEST_ITEM_UPDATE` on an in-place content change, `QUEST_FINISHED` on clear).
|
|
//! [`drain_quest`] pulls the Lua intents back out and maps each to a `CMSG_QUESTGIVER_*` addressed
|
|
//! to `(npc, questId)` from the open view.
|
|
|
|
use std::collections::HashMap;
|
|
|
|
use benilla_protocol::messages::{
|
|
QuestDetails, QuestGiverList, QuestOfferReward, QuestRequestItems, QuestRewardItem,
|
|
QuestShareMsg,
|
|
};
|
|
use bevy::prelude::*;
|
|
|
|
use benilla_ui::script::{
|
|
QuestAction, QuestItemView, QuestPanel, QuestRewardSpell, QuestState, UiScript,
|
|
};
|
|
|
|
use crate::entities::ItemDisplays;
|
|
use crate::items::Items;
|
|
use crate::names::NameCache;
|
|
use crate::net::{ClientCommand, Guid, GuidIndex, NetCommands, ObjectStore, SelfPlayer};
|
|
use crate::ui_action::{show_messages, ui_error_text, MessageSink, Shown, Spells, UiError};
|
|
use crate::ui_script::UiInput;
|
|
use crate::ui_session::{close_npc_session_out_of_range, npc_switched, NpcSession};
|
|
|
|
/// The open questgiver view — exactly the wire packet that opened the current panel. The feed turns
|
|
/// it into the Lua snapshot; the drain reads the npc/quest ids off it for the outbound CMSG.
|
|
pub(crate) enum QuestView {
|
|
Greeting(QuestGiverList),
|
|
Detail(QuestDetails),
|
|
Progress(QuestRequestItems),
|
|
Reward(QuestOfferReward),
|
|
}
|
|
|
|
/// The open questgiver window, filled by the net bridge ([`crate::net`]) and read by [`feed_quest`].
|
|
/// Cleared on the turn-in result, a client-side close, and disconnect. The `statuses` map is the
|
|
/// DIALOG_STATUS store-now/render-later surface (decision 0088) — it survives the window closing
|
|
/// (a per-guid fact, like the gossip greeting cache).
|
|
#[derive(Resource, Default)]
|
|
pub(crate) struct QuestGiver {
|
|
/// Set by the net apply on `SMSG_QUESTGIVER_QUEST_COMPLETE`; drained by the feed
|
|
/// ([`Self::take_completed_fanfare`]).
|
|
pub(crate) completed_fanfare: bool,
|
|
/// The questgiver whose window is open; `None` = no window open.
|
|
pub(crate) npc: Option<u64>,
|
|
/// The open panel's wire view.
|
|
pub(crate) view: Option<QuestView>,
|
|
/// The trailing `u32` of the last `SMSG_QUESTGIVER_QUEST_DETAILS`
|
|
/// ([`benilla_protocol::messages::QuestDetails::auto_finish`]) — the reference's `0xbe0824`,
|
|
/// a **latch** written by the DETAILS handler rather than a field of the open view, because
|
|
/// that is the shape the binary has and because its one reader ([`end_quest_session`]) runs
|
|
/// whichever panel is up. Non-zero suppresses the giver re-open on close (decision 1738).
|
|
pub(crate) detail_flag: u32,
|
|
/// Per-guid dialog status (`SMSG_QUESTGIVER_STATUS`) — the `!`/`?` marker's value, stored for a
|
|
/// later world-marker slice.
|
|
statuses: HashMap<u64, u32>,
|
|
/// Client messages the net apply queued for [`feed_quest`] to resolve and show — the
|
|
/// reference's `DisplayError(msgId)` split into (surface, GlobalStrings key + fills), so the
|
|
/// line comes from the VM's own strings and never from a hardcoded English literal
|
|
/// (decision 0669).
|
|
messages: Vec<UiError>,
|
|
/// The re-ask epoch — see [`Self::bump_reask`].
|
|
reask: u32,
|
|
/// What each **refused** `SMSG_QUESTGIVER_STATUS` said, per guid, and how many we have refused
|
|
/// this session.
|
|
///
|
|
/// The only trace of a packet class benilla deliberately throws on the floor. benilla asks the
|
|
/// server about quest-flagged GameObjects because the reference does, and vmangos — unlike the
|
|
/// real 1.12 service, which never answered one — replies; the reference's own handler drops
|
|
/// that reply at the typemask-8 lookup and so does ours (decision 1872,
|
|
/// [`crate::net::apply`]'s `quest_giver_status`). Without this, the *whole* GameObject half of
|
|
/// the feature is invisible from inside the client: a correct drop and a query that was never
|
|
/// sent both read as "no status", which is exactly the pair a live probe has to tell apart.
|
|
/// Keyed by guid, and pruned with [`Self::retain_statuses`] — **not** a single last-writer
|
|
/// slot, which is what this was and why it read as a regression the first time several quest
|
|
/// objects were in view at once: every one of them is answered in the same packet drain, so
|
|
/// only the last survived to be looked at, and which object that was is archetype order.
|
|
refused: HashMap<u64, u32>,
|
|
refused_count: u32,
|
|
}
|
|
|
|
impl QuestGiver {
|
|
/// Open (or replace) the window with a fresh wire view for `npc`.
|
|
pub(crate) fn open(&mut self, npc: u64, view: QuestView) {
|
|
self.npc = Some(npc);
|
|
self.view = Some(view);
|
|
}
|
|
|
|
/// Whether a quest window is currently open (a predicate for callers + the module tests).
|
|
#[allow(dead_code)]
|
|
pub(crate) fn is_open(&self) -> bool {
|
|
self.view.is_some()
|
|
}
|
|
|
|
/// Close the open window (turn-in result / client-side close). Keeps the status store.
|
|
/// One-shot: the turn-in result landed (`SMSG_QUESTGIVER_QUEST_COMPLETE`) — the giver feed
|
|
/// drains it into the QUESTCOMPLETED kit (the fanfare the client's C++ fires on this packet,
|
|
/// not any Lua handler).
|
|
pub(crate) fn take_completed_fanfare(&mut self) -> bool {
|
|
std::mem::take(&mut self.completed_fanfare)
|
|
}
|
|
|
|
pub(crate) fn clear(&mut self) {
|
|
self.npc = None;
|
|
self.view = None;
|
|
}
|
|
|
|
/// Record a dialog status for an NPC guid (store-now; the world marker renders in a later slice).
|
|
pub(crate) fn set_status(&mut self, npc: u64, status: u32) {
|
|
self.statuses.insert(npc, status);
|
|
}
|
|
|
|
/// Every stored dialog status, per guid — the overhead-marker renderer's read
|
|
/// ([`crate::quest_markers`]).
|
|
pub(crate) fn statuses(&self) -> &HashMap<u64, u32> {
|
|
&self.statuses
|
|
}
|
|
|
|
/// Drop the cached status of every guid `live` rejects. The reference caches its answer on the
|
|
/// unit object itself (`unit+0xcb8`), so the cache dies with the object; ours is a map keyed by
|
|
/// guid and needs the explicit prune, or a unit re-entering view renders its stale marker until
|
|
/// the fresh answer lands (decision 0647).
|
|
pub(crate) fn retain_statuses(&mut self, live: impl Fn(u64) -> bool) {
|
|
self.statuses.retain(|&npc, _| live(npc));
|
|
// The refusal readout follows the same lifetime, for the same reason and to stay bounded.
|
|
self.refused.retain(|&npc, _| live(npc));
|
|
}
|
|
|
|
/// Drop one guid's cached status — the NPC stopped being a questgiver, so its marker goes with
|
|
/// it (the reference's own teardown branch; decision 0647).
|
|
pub(crate) fn clear_status(&mut self, npc: u64) {
|
|
self.statuses.remove(&npc);
|
|
}
|
|
|
|
/// Re-ask every visible questgiver's status: a packet landed that can change the server's
|
|
/// answer for everyone. The reference sweeps from four such handlers — reputation
|
|
/// (`SMSG_SET_FACTION_STANDING`), the party/raid roster (`SMSG_GROUP_LIST`), the
|
|
/// `SMSG_QUESTGIVER_*` demux (turn-ins) and `SMSG_QUESTUPDATE_*` — the packet half of the
|
|
/// refresh law whose descriptor half is `quest_markers::self_generation` (decision 0654).
|
|
///
|
|
/// A counter, not a flag: `quest_markers::query_statuses` folds it into its generation, so a
|
|
/// bump can't be lost to system ordering or to a frame that coalesced two packets.
|
|
pub(crate) fn bump_reask(&mut self) {
|
|
self.reask = self.reask.wrapping_add(1);
|
|
}
|
|
|
|
/// The current re-ask epoch — see [`Self::bump_reask`].
|
|
pub(crate) fn reask_epoch(&self) -> u32 {
|
|
self.reask
|
|
}
|
|
|
|
/// Record a dialog status the typemask gate refused — see [`Self::refused`].
|
|
pub(crate) fn refuse_status(&mut self, npc: u64, status: u32) {
|
|
self.refused.insert(npc, status);
|
|
self.refused_count = self.refused_count.saturating_add(1);
|
|
}
|
|
|
|
/// What the server last answered for `npc` before we refused it, if anything.
|
|
pub(crate) fn refused_for(&self, npc: u64) -> Option<u32> {
|
|
self.refused.get(&npc).copied()
|
|
}
|
|
|
|
/// How many answers we have refused this session — the summary half of [`Self::refused`].
|
|
pub(crate) fn refused_count(&self) -> u32 {
|
|
self.refused_count
|
|
}
|
|
|
|
/// The stored dialog status for `npc`, if any. The store-now half of DIALOG_STATUS
|
|
/// (decision 0088): no consumer yet — the `!`/`?` world marker is a later nameplate slice — so
|
|
/// this accessor is deliberately unused for now.
|
|
#[allow(dead_code)]
|
|
pub(crate) fn status(&self, npc: u64) -> Option<u32> {
|
|
self.statuses.get(&npc).copied()
|
|
}
|
|
|
|
/// The open panel's title when the panel IS `quest_id`'s — the `%s` fill for a refusal that
|
|
/// names the quest (decision 0669). The reference reads that title off the quest record it
|
|
/// looked the refusal up in; the panel being refused is the same quest, already in hand.
|
|
pub(crate) fn view_title(&self, quest_id: u32) -> Option<String> {
|
|
match self.view.as_ref()? {
|
|
QuestView::Detail(d) if d.quest_id == quest_id => Some(d.title.clone()),
|
|
QuestView::Progress(p) if p.quest_id == quest_id => Some(p.title.clone()),
|
|
QuestView::Reward(o) if o.quest_id == quest_id => Some(o.title.clone()),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Queue one client message for [`feed_quest`] to resolve and show — the net apply's half of
|
|
/// the reference's `DisplayError(msgId)` (decision 0669). The message carries its own surface
|
|
/// in its key; the caller does not choose one (decision 1770).
|
|
pub(crate) fn push_message(&mut self, msg: UiError) {
|
|
self.messages.push(msg);
|
|
}
|
|
|
|
/// Take the queued client messages (drained by [`feed_quest`], which owns the VM).
|
|
pub(crate) fn take_messages(&mut self) -> Vec<UiError> {
|
|
std::mem::take(&mut self.messages)
|
|
}
|
|
|
|
/// Disconnect: drop the open window (mirrors the gossip/merchant session clears).
|
|
pub(crate) fn clear_session(&mut self) {
|
|
self.clear();
|
|
self.statuses.clear();
|
|
self.messages.clear();
|
|
}
|
|
}
|
|
|
|
/// `SMSG_QUESTGIVER_QUEST_INVALID`'s `QuestFailedReason` → its GlobalStrings key, resolved to text
|
|
/// through the VM's own `GlobalStrings.lua` at the feed. The FULL build-5875 table, read off the
|
|
/// handler `0x5dbca0` (the `0x18f` arm of the quest demux `0x5e5910`): `lea eax,[reason-1]` /
|
|
/// `cmp eax,0x15` / `movzx edx,[eax+0x5dbd30]` / `jmp [edx*4+0x5dbd14]` — a 22-byte case index into
|
|
/// a 7-way jump table, every arm a `DisplayError(msgId)`. **Everything unlisted — including
|
|
/// reason 0 and anything past 22 — falls to `ERR_QUEST_NEED_PREREQS`**, which is the ref's own
|
|
/// `ja` default, not a guess. Cross-checks against vmangos `QuestDef.h`'s own per-value comments
|
|
/// (decision 0669).
|
|
pub(crate) fn questgiver_invalid_key(reason: u32) -> &'static str {
|
|
match reason {
|
|
1 => "ERR_QUEST_FAILED_LOW_LEVEL", // msgId 142
|
|
6 => "ERR_QUEST_FAILED_WRONG_RACE", // msgId 144
|
|
12 => "ERR_QUEST_ONLY_ONE_TIMED", // msgId 146
|
|
13 => "ERR_QUEST_ALREADY_ON", // msgId 148
|
|
20 => "ERR_QUEST_FAILED_MISSING_ITEMS", // msgId 143
|
|
22 => "ERR_QUEST_FAILED_NOT_ENOUGH_MONEY", // msgId 145
|
|
_ => "ERR_QUEST_NEED_PREREQS", // msgId 147 — the `ja` default
|
|
}
|
|
}
|
|
|
|
/// `SMSG_QUESTGIVER_QUEST_FAILED`'s reason → its GlobalStrings key. The full table, read off the
|
|
/// handler `0x5dc840` (the `0x192` arm of the same demux): a three-way `cmp` chain on the reason —
|
|
/// `4`/`0x32` → BAG_FULL, `0x11` → MAX_COUNT, everything else → the plain FAILED line. All three
|
|
/// strings carry a `%s` the caller fills with the quest title (the ref pushes `questRecord+0x9c`
|
|
/// alongside the msgId). Decision 0669.
|
|
///
|
|
/// The wording is the player's own and is never restated here (decision 2045) — the msgId is what
|
|
/// identifies each arm, and `every_quest_refusal_key_resolves_in_the_real_global_strings` is what
|
|
/// checks the three against the shipped table.
|
|
pub(crate) fn questgiver_failed_key(reason: u32) -> &'static str {
|
|
match reason {
|
|
4 | 50 => "ERR_QUEST_FAILED_BAG_FULL_S", // msgId 140 — the bag-full wording
|
|
17 => "ERR_QUEST_FAILED_MAX_COUNT_S", // msgId 141 — the duplicate-item wording
|
|
_ => "ERR_QUEST_FAILED_S", // msgId 139 — the plain form
|
|
}
|
|
}
|
|
|
|
pub(crate) struct UiQuestPlugin;
|
|
|
|
impl Plugin for UiQuestPlugin {
|
|
fn build(&self, app: &mut App) {
|
|
app.init_resource::<QuestGiver>().add_systems(
|
|
Update,
|
|
(
|
|
// Range-close before the feed so the clear turns into the panel-close the same
|
|
// frame; push before the input pass so an open/close is on screen the same frame;
|
|
// drain after it so a click's intent goes out the same frame (mirrors
|
|
// ui_gossip/merchant).
|
|
close_npc_session_out_of_range::<QuestGiver>.before(feed_quest),
|
|
feed_quest.before(UiInput),
|
|
drain_quest.after(UiInput),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The questgiver panel is an NPC session: the standardized range guard ([`crate::ui_session`])
|
|
/// client-side-closes it — the same no-packet clear as its close button — when the player walks out
|
|
/// of the giver's service range or the giver despawns. The per-guid `statuses` store survives, like
|
|
/// every other close.
|
|
impl NpcSession for QuestGiver {
|
|
/// `None` when the open panel's giver is an **item** (decision 0664): a quest-starter item
|
|
/// opens its detail panel with the ITEM's own guid as the giver, and an item is not a world
|
|
/// unit — there is no range to walk out of and no portrait to bake. Reporting the item guid
|
|
/// here would close the panel the frame it opened, since the range guard reads a guid it can't
|
|
/// resolve to a world entity as "the giver despawned". The drain still addresses its
|
|
/// `CMSG_QUESTGIVER_*` to `self.npc` (the item guid is exactly what the server wants back —
|
|
/// vmangos resolves an `HIGHGUID_ITEM` giver through `TYPEMASK_CREATURE_GAMEOBJECT_OR_ITEM`);
|
|
/// only the *session* face, whose whole meaning is "the live NPC this window is bound to",
|
|
/// says there is none.
|
|
fn npc(&self) -> Option<u64> {
|
|
self.npc.filter(|g| !benilla_protocol::guid::is_item(*g))
|
|
}
|
|
|
|
/// Walking away from a party member's SHARED quest still owes them the decline
|
|
/// (decision 1741). The distance that triggers it is not this method's business — the guard
|
|
/// takes the leash from the giver's own type (`crate::ui_session::leash_sq`: 14.0 yd for a
|
|
/// player, the 5.56 yd service range for an NPC), which is what 1733 got wrong by exempting
|
|
/// the share panel outright.
|
|
///
|
|
/// **The walk-away sends strictly LESS than the button does**, and that is the reference's,
|
|
/// not a simplification: its watchdog calls the session end directly (`0x4933da` → `0x501130`)
|
|
/// rather than going through `DeclineQuest`, so an NPC's panel closes silently here — no
|
|
/// giver re-open — while the button's path still re-opens it.
|
|
fn walk_away_send(&self, npc: u64) -> Option<ClientCommand> {
|
|
benilla_protocol::guid::is_player(npc).then_some(ClientCommand::QuestPushResult {
|
|
sharer: npc,
|
|
msg: QuestShareMsg::DECLINE_QUEST,
|
|
})
|
|
}
|
|
|
|
fn close(&mut self) {
|
|
self.clear();
|
|
}
|
|
}
|
|
|
|
/// The greeting panel's active-vs-available split — decision 0088's deferred item, now resolved by
|
|
/// the **wire icon**, which is the reference's own and only predicate.
|
|
///
|
|
/// VERIFIED at the bytes (wow-re `system/ui/scratch/questgiver-quest-pool.md`, dispatched for this;
|
|
/// the split is `0x5dbbfe-0x5dbc08`): `icon == 3 || icon == 4` → ACTIVE, and **every other `u32`** →
|
|
/// AVAILABLE. A flat two-way `cmp`/`je`, no range test and no third arm — and the client never
|
|
/// consults its own quest log on this path. The values are vmangos's `__QuestGiverStatus`
|
|
/// (`QuestDef.h:118-130`): 3 = `DIALOG_STATUS_INCOMPLETE` (held, unfinished), 4 =
|
|
/// `DIALOG_STATUS_REWARD_REP` (hand it in). The gossip packet's quest rows use the identical test,
|
|
/// applied lazily (`0x4e2430`/`0x4e2580`), so both seams share this helper.
|
|
///
|
|
/// **This reverses an earlier call of ours, and the reversal is the whole point.** benilla used to
|
|
/// read the icon, saw an auto-complete quest arrive carrying REWARD_REP while absent from the quest
|
|
/// log, read that as a misclassification, and switched to log membership. It was not a
|
|
/// misclassification: an auto-complete quest is *never* in the log — that is what auto-complete
|
|
/// means — and `Player::PrepareQuestMenu` (vmangos `Objects/Player.cpp:12501`) marks it REWARD_REP
|
|
/// deliberately, so the client sends COMPLETE_QUEST and gets the request-items panel. Deriving the
|
|
/// pool from the log instead made every such quest permanently un-turn-in-able and rendered its
|
|
/// (empty) detail text as a blank window — ledger B95, decision 0758.
|
|
pub(crate) fn row_is_active(icon: u32) -> bool {
|
|
matches!(icon, 3 | 4)
|
|
}
|
|
|
|
/// An AVAILABLE row's **one-click** flag: `icon == 0` makes the *available* select send
|
|
/// COMPLETE_QUEST rather than QUERY_QUEST.
|
|
///
|
|
/// VERIFIED: `test ebx,ebx; sete cl` at `0x5dbc13` stores it into the pool record's `+0x48`, which
|
|
/// `SelectAvailableQuest` (`0x5012a0`) reads at `0x5012db` to pick opcode `0x18a` over `0x186`. (The
|
|
/// ACTIVE pool's `+0x48` is written literal `0` and read nowhere in the binary — `SelectActiveQuest`
|
|
/// always sends `0x18a`.)
|
|
///
|
|
/// wow-re flags exactly one thing here as open: whether a real 1.12 server ever emitted `icon == 0`
|
|
/// on this packet at all — vmangos emits only 2..=5, and its `DIALOG_STATUS_NONE` belongs to the
|
|
/// separate `SMSG_QUESTGIVER_STATUS` path. The arm is byte-verified and correctly wired; only its
|
|
/// live *reachability* is unknown, and the client predicate is total over `u32`, so implementing it
|
|
/// as written is faithful either way.
|
|
pub(crate) fn row_is_one_click(icon: u32) -> bool {
|
|
icon == 0
|
|
}
|
|
|
|
/// One greeting pool as the drain re-walks it: `(quest_id, wire icon)` per row. The icon rides along
|
|
/// because the AVAILABLE arm needs it a second time, for [`row_is_one_click`].
|
|
type Pool = Vec<(u32, u32)>;
|
|
|
|
/// Resolve one wire reward/required triple into a Lua-facing [`QuestItemView`]: icon from the wire
|
|
/// display id (immediate), name + quality from the ask-once item-template cache (`None`/white while
|
|
/// in flight — the row shows a placeholder and fills in, exactly like a bag slot / vendor row).
|
|
fn resolve_item(
|
|
it: &QuestRewardItem,
|
|
items: &mut Items,
|
|
icons: Option<&ItemDisplays>,
|
|
commands: &NetCommands,
|
|
) -> QuestItemView {
|
|
let template = items.template(it.item_id, 0, commands);
|
|
let name = template.map(|t| t.name.clone());
|
|
let quality = template.map(|t| t.quality).unwrap_or(1);
|
|
let texture = icons
|
|
.and_then(|i| i.catalog.get(it.display_id))
|
|
.and_then(|d| d.icon.clone());
|
|
// The ctrl/shift click arms' payload (`GetQuestItemLink`, decisions 1059/1060) — built through
|
|
// THE link builder, never a hand-rolled format (`ui_items::item_link`'s own doc). `None` until
|
|
// the template lands: the link needs both the name and the quality.
|
|
let link = name
|
|
.as_ref()
|
|
.map(|n| crate::ui_items::item_link(it.item_id, n, quality));
|
|
QuestItemView {
|
|
name,
|
|
texture,
|
|
count: it.count,
|
|
quality,
|
|
item_id: it.item_id,
|
|
usable: true, // v1: soft gray only, server authoritative (decision 0088)
|
|
link,
|
|
}
|
|
}
|
|
|
|
fn resolve_items(
|
|
src: &[QuestRewardItem],
|
|
items: &mut Items,
|
|
icons: Option<&ItemDisplays>,
|
|
commands: &NetCommands,
|
|
) -> Vec<QuestItemView> {
|
|
src.iter()
|
|
.map(|it| resolve_item(it, items, icons, commands))
|
|
.collect()
|
|
}
|
|
|
|
/// Build the Lua-facing snapshot from the open view — `None` when no window is open. Every
|
|
/// server-authored text runs the shared chat-macro substitution (`$N`/`$B`/`$G` —
|
|
/// [`crate::npc_text`]): the wire delivers quest text un-expanded, the client substitutes.
|
|
/// The quest's reward spell as the getters answer it: `rewSpell` off the packet, resolved to the
|
|
/// spell's name and icon through the spell catalog (`Spell.dbc`). Zero — no spell — is `None`; an
|
|
/// id the catalog cannot place still counts (the id is real), with nothing to paint.
|
|
pub(crate) fn reward_spell_view(
|
|
spell_id: u32,
|
|
spells: Option<&Spells>,
|
|
) -> Option<QuestRewardSpell> {
|
|
if spell_id == 0 {
|
|
return None;
|
|
}
|
|
let d = spells.and_then(|s| s.catalog.get(spell_id));
|
|
Some(QuestRewardSpell {
|
|
spell_id,
|
|
name: d.map(|d| d.name.clone()),
|
|
texture: d.and_then(|d| d.icon.clone()),
|
|
// `isTradeskillSpell` is `Spell.dbc` col 6 `Attributes` bit 5 (`0x20`) — `0x501e59`,
|
|
// wow-re quest-material-reward-spell-bindings.md §3 (1161 of 22357 rows set; craft-cast
|
|
// spells set, `Pattern:` teaching spells clear).
|
|
tradeskill: d.is_some_and(|d| d.attributes & 0x20 != 0),
|
|
})
|
|
}
|
|
|
|
fn snapshot(
|
|
giver: &QuestGiver,
|
|
items: &mut Items,
|
|
icons: Option<&ItemDisplays>,
|
|
commands: &NetCommands,
|
|
macros: &crate::npc_text::MacroContext,
|
|
spells: Option<&Spells>,
|
|
) -> Option<QuestState> {
|
|
let sub = |t: &str| crate::npc_text::substitute(t, macros);
|
|
Some(match giver.view.as_ref()? {
|
|
QuestView::Greeting(l) => {
|
|
let mut active_titles = Vec::new();
|
|
let mut available_titles = Vec::new();
|
|
for q in &l.quests {
|
|
if row_is_active(q.icon) {
|
|
active_titles.push(q.title.clone());
|
|
} else {
|
|
available_titles.push(q.title.clone());
|
|
}
|
|
}
|
|
QuestState {
|
|
panel: QuestPanel::Greeting,
|
|
greeting: sub(&l.greeting),
|
|
active_titles,
|
|
available_titles,
|
|
..Default::default()
|
|
}
|
|
}
|
|
QuestView::Detail(d) => QuestState {
|
|
panel: QuestPanel::Detail,
|
|
title: sub(&d.title),
|
|
body: sub(&d.details),
|
|
objectives: sub(&d.objectives),
|
|
choices: resolve_items(&d.choices, items, icons, commands),
|
|
rewards: resolve_items(&d.rewards, items, icons, commands),
|
|
reward_money: d.money.max(0) as u32,
|
|
reward_spell: reward_spell_view(d.reward_spell, spells),
|
|
..Default::default()
|
|
},
|
|
QuestView::Progress(p) => QuestState {
|
|
panel: QuestPanel::Progress,
|
|
title: sub(&p.title),
|
|
body: sub(&p.request_text),
|
|
required: resolve_items(&p.required_items, items, icons, commands),
|
|
required_money: p.required_money,
|
|
completable: p.is_complete,
|
|
..Default::default()
|
|
},
|
|
QuestView::Reward(o) => QuestState {
|
|
panel: QuestPanel::Reward,
|
|
title: sub(&o.title),
|
|
body: sub(&o.offer_text),
|
|
choices: resolve_items(&o.choices, items, icons, commands),
|
|
rewards: resolve_items(&o.rewards, items, icons, commands),
|
|
reward_money: o.money.max(0) as u32,
|
|
reward_spell: reward_spell_view(o.reward_spell, spells),
|
|
..Default::default()
|
|
},
|
|
})
|
|
}
|
|
|
|
/// The FrameXML event a panel opens with (the ref `QuestFrame.lua` names).
|
|
fn panel_event(panel: QuestPanel) -> &'static str {
|
|
match panel {
|
|
QuestPanel::Greeting => "QUEST_GREETING",
|
|
QuestPanel::Detail => "QUEST_DETAIL",
|
|
QuestPanel::Progress => "QUEST_PROGRESS",
|
|
QuestPanel::Reward => "QUEST_COMPLETE",
|
|
}
|
|
}
|
|
|
|
/// Push the current quest view into the VM and fire the FrameXML events on a transition (panel
|
|
/// change → the panel's open event; same panel, content changed → `QUEST_ITEM_UPDATE`; closed →
|
|
/// `QUEST_FINISHED`). Diffed against a `Local`, exactly like the gossip/merchant feeds. The NPC
|
|
/// name rides as arg1 (resolved through the NameCache, ask-once — the merchant's pattern).
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn feed_quest(
|
|
script: Option<NonSendMut<UiScript>>,
|
|
mut giver: ResMut<QuestGiver>,
|
|
mut items: ResMut<Items>,
|
|
icons: Option<Res<ItemDisplays>>,
|
|
commands: Res<NetCommands>,
|
|
mut names: ResMut<NameCache>,
|
|
states: Res<crate::world_state::WorldStates>,
|
|
self_q: Query<(&ObjectStore, &Guid), With<SelfPlayer>>,
|
|
spells: Option<Res<Spells>>,
|
|
mut go_templates: ResMut<crate::go_templates::GameObjectTemplates>,
|
|
materials: Option<Res<crate::ui_item_text::PageMaterials>>,
|
|
mut sink: MessageSink,
|
|
mut last: Local<crate::ui_script::VmMemo<Option<QuestState>>>,
|
|
mut last_name: Local<crate::ui_script::VmMemo<Option<String>>>,
|
|
mut last_npc: Local<crate::ui_script::VmMemo<Option<u64>>>,
|
|
) {
|
|
let Some(mut script) = script else {
|
|
return;
|
|
};
|
|
let last = last.get(&script);
|
|
let last_name = last_name.get(&script);
|
|
let last_npc = last_npc.get(&script);
|
|
// The turn-in fanfare (QUESTCOMPLETED → iQuestComplete.wav): the client's C++ plays it on the
|
|
// QUEST_COMPLETE packet itself — no Lua handler owns it, so the feed queues it directly.
|
|
if giver.take_completed_fanfare() {
|
|
script.queue_sound_kit("QUESTCOMPLETED");
|
|
}
|
|
// The queued client messages (the refusal lines the net apply staged): resolve against the
|
|
// VM's own GlobalStrings first (immutable script), then show each on the surface its message
|
|
// record names — decision 0669's `DisplayError` kind. Drained BEFORE the snapshot's early-out
|
|
// so a refusal that also closes the panel still gets its line out this frame.
|
|
let lines: Vec<Shown> = giver
|
|
.take_messages()
|
|
.into_iter()
|
|
.filter_map(|msg| {
|
|
let get = |key: &str| script.lua().globals().get::<String>(key).ok();
|
|
ui_error_text(&msg, &get).map(|text| Shown::keyed(msg.key, text))
|
|
})
|
|
.collect();
|
|
show_messages(&mut script, &mut sink, "ui_quest", lines);
|
|
let player = crate::npc_text::player_identity(&self_q, &mut names, &commands);
|
|
let fresh = snapshot(
|
|
&giver,
|
|
&mut items,
|
|
icons.as_deref(),
|
|
&commands,
|
|
&crate::npc_text::MacroContext {
|
|
subject: player.as_ref(),
|
|
states: &states,
|
|
},
|
|
spells.as_deref(),
|
|
);
|
|
// The panel's background material is the SOURCE object's — an item's page material, a
|
|
// GameObject's template data — never a creature's, and never on the wire (1946; wow-re
|
|
// quest-material-reward-spell-bindings.md §1). Resolved the way the reader window resolves its
|
|
// own, since the two bindings share a body.
|
|
let fresh = fresh.map(|mut st| {
|
|
st.background_material = giver.npc.and_then(|source| {
|
|
crate::ui_item_text::object_material(
|
|
source,
|
|
&mut items,
|
|
&mut go_templates,
|
|
materials.as_deref(),
|
|
&commands,
|
|
)
|
|
});
|
|
st
|
|
});
|
|
let npc_name = giver
|
|
.npc
|
|
.and_then(|g| names.resolve(g, &commands).map(str::to_string));
|
|
let name_changed = *last_name != npc_name;
|
|
// A different giver while a panel is already open is a real close+open (decision 0096 /
|
|
// [`crate::ui_session::npc_switched`]); a cross-window switch is handled by OnHide → CloseX on
|
|
// panel displacement (decision 0095).
|
|
let switched = npc_switched(*last_npc, giver.npc);
|
|
if fresh == *last && !name_changed && !switched {
|
|
return;
|
|
}
|
|
script.set_quest(fresh.clone());
|
|
// The reference's QUEST_GREETING/DETAIL/PROGRESS/COMPLETE carry no argument: the window
|
|
// reads `UnitName("npc")` for its title (stock QuestFrame.lua:63), and the "npc" unit is
|
|
// the session's (ui_unit.rs). Ours used to pass the name as arg1 (1944 dropped it).
|
|
match (&*last, &fresh) {
|
|
(_, Some(f)) if switched => {
|
|
// A different giver → close the old panel, open the new (both kits play). QUEST_FINISHED
|
|
// routes through OnHide → CloseQuest (decision 0095), which queues a `Close` action —
|
|
// drain the pending actions so it does NOT clear the giver we just re-opened. Safe: a
|
|
// switch is net-driven, so no user action is queued this frame to lose.
|
|
script.fire_event("QUEST_FINISHED", vec![]);
|
|
script.fire_event(panel_event(f.panel), vec![]);
|
|
let _ = script.take_quest_actions();
|
|
}
|
|
(_, Some(f)) => {
|
|
// Same panel + already open → an in-place content refresh (a name landed); a new panel
|
|
// (or a fresh open) → the panel's open event.
|
|
let same_panel = last.as_ref().is_some_and(|l| l.panel == f.panel);
|
|
let event = if same_panel {
|
|
"QUEST_ITEM_UPDATE"
|
|
} else {
|
|
panel_event(f.panel)
|
|
};
|
|
script.fire_event(event, vec![]);
|
|
}
|
|
(Some(_), None) => script.fire_event("QUEST_FINISHED", vec![]),
|
|
(None, None) => {}
|
|
}
|
|
*last = fresh;
|
|
*last_name = npc_name;
|
|
*last_npc = giver.npc;
|
|
}
|
|
|
|
/// Drain the Lua intents: the greeting-row selects (map to the row's quest id → QUERY_QUEST for an
|
|
/// available quest, COMPLETE_QUEST for an active one) and the button actions (Accept/Continue/Reward
|
|
/// → the matching CMSG; Close → a local clear, no packet).
|
|
/// **The quest session's one end** — the reference's `0x501130`, which every way out of the
|
|
/// questgiver window funnels through: `DeclineQuest()`, `CloseQuest()` (ESC, the window's own
|
|
/// OnHide), the walk-away watchdog and the leave-world teardown are four of its eleven callers, and
|
|
/// they all do the same thing. That is why benilla models one [`QuestAction::Close`] and not the
|
|
/// `Decline`/`Close` pair 1733 briefly split it into: two Lua verbs, one routine (decision 1738,
|
|
/// VERIFIED by the wow-re §5 dispatched for 1733).
|
|
///
|
|
/// It sends, and what it sends depends on **the giver's object type**, not on which button was
|
|
/// pressed:
|
|
///
|
|
/// - **A player** — a party member whose shared quest we are turning down. The answer they are
|
|
/// waiting on: `MSG_QUEST_PUSH_RESULT{sharerGuid, DECLINE_QUEST}`. This is the only verdict the
|
|
/// client ever originates besides `BUSY`.
|
|
/// - **A unit** — an ordinary questgiver, and the reference **re-opens its list**:
|
|
/// `CMSG_GOSSIP_HELLO` for a gossip-flagged NPC, `CMSG_QUESTGIVER_HELLO` otherwise. Declining a
|
|
/// quest putting you back in the NPC's menu is not a courtesy the server does; it is this send.
|
|
/// benilla asserted the opposite in `QuestFrame.xml` ("vanilla's client-side decline sends no
|
|
/// packet") from 0088 until 1738 refuted it at the bytes.
|
|
///
|
|
/// `detail_flag` suppresses the unit re-open when non-zero — the trailing `u32` of
|
|
/// `SMSG_QUESTGIVER_QUEST_DETAILS` ([`QuestDetails::auto_finish`]), whose only reader in the whole
|
|
/// image is this routine. `npc_flags` is `None` when the giver is not a streamed unit, which is
|
|
/// also how an item giver (0664) and a player fall out of the unit branch.
|
|
fn end_quest_session(npc: u64, detail_flag: u32, npc_flags: Option<u32>, commands: &NetCommands) {
|
|
if benilla_protocol::guid::is_player(npc) {
|
|
debug!("ui_quest: declining {npc:#x}'s shared quest");
|
|
let _ = commands.0.send(ClientCommand::QuestPushResult {
|
|
sharer: npc,
|
|
msg: QuestShareMsg::DECLINE_QUEST,
|
|
});
|
|
return;
|
|
}
|
|
let Some(flags) = npc_flags else {
|
|
// Not a streamed unit: an item giver, or an NPC that left view under the open window.
|
|
// Nothing to re-open and nobody to answer.
|
|
debug!("ui_quest: close on non-unit giver {npc:#x} (no packet)");
|
|
return;
|
|
};
|
|
if detail_flag != 0 {
|
|
debug!("ui_quest: close on {npc:#x} — detail flag {detail_flag} suppresses the re-open");
|
|
return;
|
|
}
|
|
if flags & crate::target::cursor_mode::npc_flags::GOSSIP != 0 {
|
|
debug!("ui_quest: close on {npc:#x} — re-opening the gossip menu");
|
|
let _ = commands.0.send(ClientCommand::GossipHello { guid: npc });
|
|
} else {
|
|
debug!("ui_quest: close on {npc:#x} — re-opening the quest list");
|
|
let _ = commands.0.send(ClientCommand::QuestgiverHello { npc });
|
|
}
|
|
}
|
|
|
|
fn drain_quest(
|
|
script: Option<NonSendMut<UiScript>>,
|
|
mut giver: ResMut<QuestGiver>,
|
|
commands: Res<NetCommands>,
|
|
index: Res<GuidIndex>,
|
|
stores: Query<&ObjectStore>,
|
|
mut tutorials: Option<MessageWriter<crate::tutorial::TutorialEvent>>,
|
|
) {
|
|
let Some(mut script) = script else {
|
|
return;
|
|
};
|
|
let Some(npc) = giver.npc else {
|
|
// Still drain the VM so intents don't queue against a closed window.
|
|
script.take_quest_selects();
|
|
script.take_quest_actions();
|
|
return;
|
|
};
|
|
// The giver's live `UNIT_NPC_FLAGS`, for [`end_quest_session`]'s gossip/quest-list fork.
|
|
// `None` for anything that is not a streamed unit — an item giver, a player, a despawn.
|
|
let npc_flags = index
|
|
.0
|
|
.get(&npc)
|
|
.and_then(|e| stores.get(*e).ok())
|
|
.map(|s| s.0.unit_npc_flags());
|
|
|
|
// Greeting-row selects: resolve the 1-based row to its quest id off the open greeting view.
|
|
for sel in script.take_quest_selects() {
|
|
let Some(QuestView::Greeting(list)) = giver.view.as_ref() else {
|
|
continue;
|
|
};
|
|
// Re-walk the same split the snapshot used (`row_is_active`, off the wire icon), keeping the
|
|
// id AND the icon this time — the available arm needs the icon again for its one-click flag.
|
|
let (mut active, mut available): (Pool, Pool) = (Vec::new(), Vec::new());
|
|
for q in &list.quests {
|
|
if row_is_active(q.icon) {
|
|
active.push((q.quest_id, q.icon));
|
|
} else {
|
|
available.push((q.quest_id, q.icon));
|
|
}
|
|
}
|
|
let pool = if sel.active { &active } else { &available };
|
|
let Some(&(quest, icon)) = sel.index.checked_sub(1).and_then(|i| pool.get(i as usize))
|
|
else {
|
|
debug!("ui_quest: greeting select {sel:?} out of range — ignored");
|
|
continue;
|
|
};
|
|
// `SelectActiveQuest` (`0x501320`) sends COMPLETE_QUEST unconditionally. `SelectAvailableQuest`
|
|
// (`0x5012a0`) picks by the row's one-click flag: COMPLETE_QUEST when set, else QUERY_QUEST.
|
|
let cmd = if sel.active || row_is_one_click(icon) {
|
|
ClientCommand::QuestgiverComplete { npc, quest }
|
|
} else {
|
|
ClientCommand::QuestgiverQuery { npc, quest }
|
|
};
|
|
let _ = commands.0.send(cmd);
|
|
}
|
|
|
|
// Button actions, addressed to the open view's quest id.
|
|
let view_quest = giver.view.as_ref().and_then(|v| match v {
|
|
QuestView::Detail(d) => Some(d.quest_id),
|
|
QuestView::Progress(p) => Some(p.quest_id),
|
|
QuestView::Reward(o) => Some(o.quest_id),
|
|
QuestView::Greeting(_) => None,
|
|
});
|
|
for action in script.take_quest_actions() {
|
|
match action {
|
|
QuestAction::Close => {
|
|
end_quest_session(npc, giver.detail_flag, npc_flags, &commands);
|
|
giver.clear();
|
|
}
|
|
QuestAction::Accept => {
|
|
if let Some(quest) = view_quest {
|
|
let _ = commands
|
|
.0
|
|
.send(ClientCommand::QuestgiverAccept { npc, quest });
|
|
// `AcceptQuest`'s tail (`0x5013df`): the Quest Log tutorial (1976).
|
|
if let Some(t) = tutorials.as_mut() {
|
|
t.write(crate::tutorial::TutorialEvent::trigger(
|
|
crate::tutorial::id::QUEST_LOG,
|
|
));
|
|
}
|
|
// `Script::AcceptQuest` (`0x501380`) closes the window on the CLICK, not on any
|
|
// answer: send `0x189` (`0x5eac10`), then `0x501130(0,0)` — the same clear the
|
|
// refusal handlers call, firing `QUEST_FINISHED`. Ours used to leave the panel
|
|
// up waiting for a packet that never comes on a refused accept (decision 0669).
|
|
giver.clear();
|
|
}
|
|
}
|
|
QuestAction::Continue => {
|
|
if let Some(quest) = view_quest {
|
|
let _ = commands
|
|
.0
|
|
.send(ClientCommand::QuestgiverRequestReward { npc, quest });
|
|
}
|
|
}
|
|
QuestAction::Reward(choice) => {
|
|
if let Some(quest) = view_quest {
|
|
let _ = commands.0.send(ClientCommand::QuestgiverChooseReward {
|
|
npc,
|
|
quest,
|
|
choice,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use benilla_protocol::messages::dialog_status;
|
|
|
|
fn triple(id: u32) -> QuestRewardItem {
|
|
QuestRewardItem {
|
|
item_id: id,
|
|
count: 1,
|
|
display_id: 100 + id,
|
|
}
|
|
}
|
|
|
|
/// The greeting split is the reference's flat two-way test on the WIRE ICON — `{3,4}` ACTIVE,
|
|
/// everything else AVAILABLE (`0x5dbbfe-0x5dbc08`, decision 0758). Pinned because this reverses
|
|
/// an earlier call that read the same icon and concluded the opposite.
|
|
#[test]
|
|
fn greeting_split_reads_the_wire_icon() {
|
|
// 3 = DIALOG_STATUS_INCOMPLETE, 4 = DIALOG_STATUS_REWARD_REP.
|
|
assert!(row_is_active(3));
|
|
assert!(row_is_active(4));
|
|
// 5 = DIALOG_STATUS_AVAILABLE (a genuinely new quest), 2 = DIALOG_STATUS_CHAT.
|
|
assert!(!row_is_active(5));
|
|
assert!(!row_is_active(2));
|
|
// No range test and no third arm: every other u32 is AVAILABLE, including 0, the ones
|
|
// vmangos never emits, and anything a future server invents.
|
|
for icon in [0, 1, 6, 7, 100, u32::MAX] {
|
|
assert!(!row_is_active(icon), "icon {icon} must fall to AVAILABLE");
|
|
}
|
|
}
|
|
|
|
/// **The B95 case.** An auto-complete quest (`QuestMethod == 0`) is never in the player's quest
|
|
/// log, and vmangos marks its greeting row `DIALOG_STATUS_REWARD_REP` anyway
|
|
/// (`Objects/Player.cpp:12501`) precisely so the client treats it as a turn-in. Binning that row
|
|
/// off log membership sent `QUERY_QUEST`, which vmangos answers with DETAILS unconditionally —
|
|
/// and quest 7793 "A Donation of Silk" has empty `Details`/`Objectives`, so the window came up
|
|
/// blank. The row must be ACTIVE on the icon alone.
|
|
#[test]
|
|
fn an_autocomplete_row_is_active_though_it_is_not_in_the_log() {
|
|
const REWARD_REP: u32 = 4;
|
|
assert!(
|
|
row_is_active(REWARD_REP),
|
|
"an auto-complete turn-in must bin ACTIVE without ever entering the quest log"
|
|
);
|
|
assert!(!row_is_one_click(REWARD_REP), "it is active, not one-click");
|
|
}
|
|
|
|
/// The AVAILABLE pool's one-click flag: `icon == 0` alone (`test ebx,ebx; sete cl` @ `0x5dbc13`),
|
|
/// which makes the *available* select send COMPLETE_QUEST instead of QUERY_QUEST.
|
|
#[test]
|
|
fn the_one_click_flag_is_icon_zero_alone() {
|
|
assert!(row_is_one_click(0));
|
|
for icon in [1, 2, 3, 4, 5, u32::MAX] {
|
|
assert!(!row_is_one_click(icon));
|
|
}
|
|
}
|
|
|
|
/// An item-sourced quest window (decision 0664) reports **no** NPC to the session face, so the
|
|
/// range guard can't close it the frame it opens — while the drain's own `self.npc` keeps the
|
|
/// item guid the `CMSG_QUESTGIVER_*` sends must carry.
|
|
#[test]
|
|
fn an_item_giver_is_not_an_npc_session() {
|
|
let item = 0x4000_0000_0000_0BAD_u64; // HIGHGUID_ITEM
|
|
let creature = 0xF130_0000_00C5_0001_u64; // HIGHGUID_UNIT
|
|
let detail = |npc: u64| {
|
|
QuestView::Detail(QuestDetails {
|
|
npc,
|
|
quest_id: 373,
|
|
title: "The Unsent Letter".into(),
|
|
details: String::new(),
|
|
objectives: String::new(),
|
|
auto_finish: 0,
|
|
choices: Vec::new(),
|
|
rewards: Vec::new(),
|
|
money: 0,
|
|
reward_spell: 0,
|
|
})
|
|
};
|
|
let mut giver = QuestGiver::default();
|
|
giver.open(item, detail(item));
|
|
assert_eq!(NpcSession::npc(&giver), None, "an item is not a live NPC");
|
|
assert_eq!(giver.npc, Some(item), "the wire address is unchanged");
|
|
giver.open(creature, detail(creature));
|
|
assert_eq!(NpcSession::npc(&giver), Some(creature));
|
|
}
|
|
|
|
#[test]
|
|
fn detail_snapshot_carries_text_and_rows() {
|
|
let mut giver = QuestGiver::default();
|
|
giver.open(
|
|
0x42,
|
|
QuestView::Detail(QuestDetails {
|
|
npc: 0x42,
|
|
quest_id: 100,
|
|
title: "A Threat Within".into(),
|
|
details: "Kill kobolds, $N.".into(),
|
|
objectives: "Slay 10.".into(),
|
|
auto_finish: 1,
|
|
choices: vec![triple(2000)],
|
|
rewards: vec![triple(3000)],
|
|
money: 1234,
|
|
reward_spell: 0,
|
|
}),
|
|
);
|
|
assert!(giver.is_open());
|
|
let mut items = Items::default();
|
|
let (tx, _rx) = crossbeam_channel::unbounded();
|
|
let commands = NetCommands(tx);
|
|
let player = crate::npc_text::Subject {
|
|
name: "Tri".into(),
|
|
race: 1,
|
|
class: 1,
|
|
gender: 0,
|
|
};
|
|
let snap = snapshot(
|
|
&giver,
|
|
&mut items,
|
|
None,
|
|
&commands,
|
|
&crate::npc_text::MacroContext {
|
|
subject: Some(&player),
|
|
states: &crate::world_state::WorldStates::default(),
|
|
},
|
|
None,
|
|
)
|
|
.expect("open");
|
|
assert_eq!(snap.panel, QuestPanel::Detail);
|
|
assert_eq!(snap.title, "A Threat Within");
|
|
// The wire text's $N substituted through the shared expander (crate::npc_text).
|
|
assert_eq!(snap.body, "Kill kobolds, Tri.");
|
|
assert_eq!(snap.objectives, "Slay 10.");
|
|
assert_eq!(snap.choices.len(), 1);
|
|
assert_eq!(snap.rewards.len(), 1);
|
|
assert_eq!(snap.reward_money, 1234);
|
|
// Name in flight (no template answer) → nil; quality defaults to white.
|
|
assert!(snap.rewards[0].name.is_none());
|
|
assert_eq!(snap.rewards[0].quality, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn progress_snapshot_carries_completability() {
|
|
let mut giver = QuestGiver::default();
|
|
giver.open(
|
|
0x42,
|
|
QuestView::Progress(QuestRequestItems {
|
|
npc: 0x42,
|
|
quest_id: 100,
|
|
title: "A Threat Within".into(),
|
|
request_text: "Bring me the tusks.".into(),
|
|
emote: 0,
|
|
close_on_cancel: 1,
|
|
required_money: 500,
|
|
required_items: vec![triple(2001)],
|
|
is_complete: true,
|
|
}),
|
|
);
|
|
let mut items = Items::default();
|
|
let (tx, _rx) = crossbeam_channel::unbounded();
|
|
let commands = NetCommands(tx);
|
|
let snap = snapshot(
|
|
&giver,
|
|
&mut items,
|
|
None,
|
|
&commands,
|
|
&crate::npc_text::MacroContext {
|
|
subject: None,
|
|
states: &crate::world_state::WorldStates::default(),
|
|
},
|
|
None,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(snap.panel, QuestPanel::Progress);
|
|
assert_eq!(snap.required.len(), 1);
|
|
assert_eq!(snap.required_money, 500);
|
|
assert!(snap.completable);
|
|
}
|
|
|
|
#[test]
|
|
fn status_store_survives_close() {
|
|
let mut giver = QuestGiver::default();
|
|
giver.set_status(0x99, dialog_status::AVAILABLE);
|
|
giver.open(
|
|
0x99,
|
|
QuestView::Greeting(QuestGiverList {
|
|
npc: 0x99,
|
|
greeting: "Hi".into(),
|
|
emote_delay: 0,
|
|
emote: 0,
|
|
quests: vec![],
|
|
}),
|
|
);
|
|
giver.clear();
|
|
assert!(!giver.is_open());
|
|
assert_eq!(giver.status(0x99), Some(dialog_status::AVAILABLE));
|
|
}
|
|
|
|
/// The `0x18f` table, arm for arm off `0x5dbca0` — including the two edges that are easy to
|
|
/// get wrong: reason 0 and anything past the `cmp eax,0x15` bound fall to NEED_PREREQS (the
|
|
/// ref's `ja` default), and 2..=5 do too even though they sit INSIDE the jump table's range.
|
|
#[test]
|
|
fn questgiver_invalid_keys_match_the_reference_table() {
|
|
assert_eq!(questgiver_invalid_key(13), "ERR_QUEST_ALREADY_ON");
|
|
assert_eq!(questgiver_invalid_key(1), "ERR_QUEST_FAILED_LOW_LEVEL");
|
|
assert_eq!(questgiver_invalid_key(6), "ERR_QUEST_FAILED_WRONG_RACE");
|
|
assert_eq!(questgiver_invalid_key(12), "ERR_QUEST_ONLY_ONE_TIMED");
|
|
assert_eq!(questgiver_invalid_key(20), "ERR_QUEST_FAILED_MISSING_ITEMS");
|
|
assert_eq!(
|
|
questgiver_invalid_key(22),
|
|
"ERR_QUEST_FAILED_NOT_ENOUGH_MONEY"
|
|
);
|
|
for reason in [0, 2, 3, 4, 5, 7, 11, 14, 19, 21, 23, 99, u32::MAX] {
|
|
assert_eq!(
|
|
questgiver_invalid_key(reason),
|
|
"ERR_QUEST_NEED_PREREQS",
|
|
"reason {reason} must take the ref's `ja` default"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The `0x192` table off `0x5dc840`: the two named reasons plus the shared default.
|
|
#[test]
|
|
fn questgiver_failed_keys_match_the_reference_table() {
|
|
assert_eq!(questgiver_failed_key(4), "ERR_QUEST_FAILED_BAG_FULL_S");
|
|
assert_eq!(questgiver_failed_key(50), "ERR_QUEST_FAILED_BAG_FULL_S");
|
|
assert_eq!(questgiver_failed_key(17), "ERR_QUEST_FAILED_MAX_COUNT_S");
|
|
for reason in [0, 1, 5, 16, 18, 49, 51] {
|
|
assert_eq!(questgiver_failed_key(reason), "ERR_QUEST_FAILED_S");
|
|
}
|
|
}
|
|
|
|
/// The open panel is the `%s` fill for a refusal that names the quest — and only for ITS OWN
|
|
/// quest id (a stale panel must not name the wrong quest).
|
|
#[test]
|
|
fn view_title_answers_only_for_the_open_quest() {
|
|
let mut giver = QuestGiver::default();
|
|
assert_eq!(giver.view_title(100), None);
|
|
giver.open(
|
|
0x42,
|
|
QuestView::Detail(QuestDetails {
|
|
npc: 0x42,
|
|
quest_id: 100,
|
|
title: "A Threat Within".into(),
|
|
details: String::new(),
|
|
objectives: String::new(),
|
|
auto_finish: 0,
|
|
choices: vec![],
|
|
rewards: vec![],
|
|
money: 0,
|
|
reward_spell: 0,
|
|
}),
|
|
);
|
|
assert_eq!(giver.view_title(100).as_deref(), Some("A Threat Within"));
|
|
assert_eq!(giver.view_title(101), None);
|
|
}
|
|
|
|
/// The RUNTIME leg on the real client data (`equip_error`'s pattern): every key either table
|
|
/// can emit resolves to a non-empty string in the shipped 1.12 `GlobalStrings.lua`, the guard
|
|
/// against a typo'd key degrading a real refusal to silence. Also pins the director's repro
|
|
/// (reason 13 → "You are already on that quest") and the `%s` the FAILED family fills.
|
|
/// Skips without client data.
|
|
#[test]
|
|
fn every_quest_refusal_key_resolves_in_the_real_global_strings() {
|
|
let data = benilla_formats::wow_data_or_skip!();
|
|
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
|
let src = chain
|
|
.read_file("Interface\\FrameXML\\GlobalStrings.lua")
|
|
.expect("GlobalStrings.lua in the chain");
|
|
let s = benilla_ui::script::UiScript::new().expect("VM");
|
|
s.run(&String::from_utf8_lossy(&src)).expect("runs clean");
|
|
let g = |key: &str| s.lua().globals().get::<String>(key).ok();
|
|
|
|
for reason in 0..=60u32 {
|
|
for key in [
|
|
questgiver_invalid_key(reason),
|
|
questgiver_failed_key(reason),
|
|
] {
|
|
let text = g(key).unwrap_or_default();
|
|
assert!(!text.is_empty(), "{key} (reason {reason}) missing");
|
|
}
|
|
// The FAILED family names the quest; the INVALID family never does.
|
|
assert!(g(questgiver_failed_key(reason))
|
|
.unwrap_or_default()
|
|
.contains("%s"));
|
|
assert!(!g(questgiver_invalid_key(reason))
|
|
.unwrap_or_default()
|
|
.contains("%s"));
|
|
}
|
|
// The director's line, end to end.
|
|
assert_eq!(
|
|
ui_error_text(&UiError::key(questgiver_invalid_key(13)), &g).as_deref(),
|
|
Some("You are already on that quest")
|
|
);
|
|
// …and the named one, filled.
|
|
assert_eq!(
|
|
ui_error_text(&UiError::s(questgiver_failed_key(4), "A Threat Within"), &g).as_deref(),
|
|
Some("A Threat Within failed: Inventory is full.")
|
|
);
|
|
// The log-full line is the RED one and carries no fill.
|
|
assert_eq!(
|
|
ui_error_text(&UiError::key("ERR_QUEST_LOG_FULL"), &g).as_deref(),
|
|
Some("Your quest log is full.")
|
|
);
|
|
}
|
|
|
|
// ── The party share's one client-originated verdict (decision 1733) ──────────────────────────
|
|
|
|
/// Run `lua` against a quest window open on `giver`, and return what the drain sent.
|
|
/// `npc_flags` seats the giver as a streamed unit with those `UNIT_NPC_FLAGS`; `None` leaves it
|
|
/// unstreamed (an item giver, or an NPC that left view).
|
|
fn drain_after(
|
|
giver: u64,
|
|
npc_flags: Option<u32>,
|
|
detail_flag: u32,
|
|
lua: &str,
|
|
) -> Vec<ClientCommand> {
|
|
let (tx, rx) = crossbeam_channel::unbounded();
|
|
let mut app = App::new();
|
|
app.insert_resource(NetCommands(tx));
|
|
app.init_resource::<GuidIndex>();
|
|
if let Some(flags) = npc_flags {
|
|
// 147 = `UNIT_NPC_FLAGS` (the descriptor index `unit_npc_flags()` reads).
|
|
let fields = benilla_protocol::ObjectFields::from_pairs(&[(147, flags)]);
|
|
let e = app.world_mut().spawn(ObjectStore(fields)).id();
|
|
app.world_mut()
|
|
.resource_mut::<GuidIndex>()
|
|
.0
|
|
.insert(giver, e);
|
|
}
|
|
let mut quest = QuestGiver {
|
|
detail_flag,
|
|
..Default::default()
|
|
};
|
|
quest.open(
|
|
giver,
|
|
QuestView::Detail(QuestDetails {
|
|
npc: giver,
|
|
quest_id: 1,
|
|
title: "A Threat Within".into(),
|
|
details: String::new(),
|
|
objectives: String::new(),
|
|
auto_finish: detail_flag,
|
|
choices: Vec::new(),
|
|
rewards: Vec::new(),
|
|
money: 0,
|
|
reward_spell: 0,
|
|
}),
|
|
);
|
|
app.insert_resource(quest);
|
|
let script = UiScript::new().unwrap();
|
|
script.run(lua).unwrap();
|
|
app.insert_non_send_resource(script);
|
|
app.add_systems(Update, drain_quest);
|
|
app.update();
|
|
rx.try_iter().collect()
|
|
}
|
|
|
|
const SHARER: u64 = 0x0000_0000_0000_002A; // HIGHGUID_PLAYER: a zero high word
|
|
const NPC: u64 = 0xF130_0000_0000_0007; // HIGHGUID_UNIT
|
|
|
|
/// **Ending the session on a SHARED quest answers the sharer — and `CloseQuest` does it too.**
|
|
/// 1733 shipped these as two actions on the assumption that only `DeclineQuest` answered; the
|
|
/// §5 found both Lua verbs calling one routine (`0x501130`), so ESC-ing a share panel reports
|
|
/// the decline exactly as the button does. This test is the corrected form of 1733's
|
|
/// `closing_a_shared_quest_panel_is_not_a_decline`, which asserted the opposite.
|
|
#[test]
|
|
fn every_way_out_of_a_shared_quest_answers_the_sharer() {
|
|
for verb in ["DeclineQuest()", "CloseQuest()"] {
|
|
let sent = drain_after(SHARER, None, 0, verb);
|
|
assert!(
|
|
matches!(
|
|
sent.as_slice(),
|
|
[ClientCommand::QuestPushResult {
|
|
sharer: SHARER,
|
|
msg: QuestShareMsg::DECLINE_QUEST,
|
|
}]
|
|
),
|
|
"{verb} must answer the sharer: {sent:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// **Ending it on an NPC RE-OPENS the giver**, which benilla asserted for years that it did not
|
|
/// ("vanilla's client-side decline sends no packet"). The fork is on the NPC's own gossip flag:
|
|
/// `CMSG_GOSSIP_HELLO` for a gossip-flagged NPC, `CMSG_QUESTGIVER_HELLO` otherwise. This is the
|
|
/// mechanism behind the reference putting you back in the questgiver's menu after a decline.
|
|
#[test]
|
|
fn ending_the_session_on_an_npc_reopens_its_list() {
|
|
use crate::target::cursor_mode::npc_flags;
|
|
|
|
let sent = drain_after(NPC, Some(0), 0, "DeclineQuest()");
|
|
assert!(
|
|
matches!(
|
|
sent.as_slice(),
|
|
[ClientCommand::QuestgiverHello { npc: NPC }]
|
|
),
|
|
"a plain questgiver gets QUESTGIVER_HELLO: {sent:?}"
|
|
);
|
|
|
|
let sent = drain_after(NPC, Some(npc_flags::GOSSIP), 0, "DeclineQuest()");
|
|
assert!(
|
|
matches!(sent.as_slice(), [ClientCommand::GossipHello { guid: NPC }]),
|
|
"a gossip-flagged NPC gets GOSSIP_HELLO: {sent:?}"
|
|
);
|
|
}
|
|
|
|
/// The DETAILS packet's trailing `u32` **suppresses** that re-open when non-zero — the field
|
|
/// benilla parsed and ignored until the §5 found its one reader. Ignoring it meant a
|
|
/// suppressed giver was re-opened anyway.
|
|
#[test]
|
|
fn a_non_zero_detail_flag_suppresses_the_reopen() {
|
|
let sent = drain_after(NPC, Some(0), 1, "DeclineQuest()");
|
|
assert!(sent.is_empty(), "flag 1 suppresses the re-open: {sent:?}");
|
|
}
|
|
|
|
/// A giver that is not a streamed unit sends nothing at all: an item giver (0664) has no list
|
|
/// to re-open, and neither does an NPC that walked out of view under the open window.
|
|
#[test]
|
|
fn an_unstreamed_giver_ends_the_session_silently() {
|
|
let sent = drain_after(NPC, None, 0, "CloseQuest()");
|
|
assert!(sent.is_empty(), "no unit, no re-open: {sent:?}");
|
|
}
|
|
}
|