diff --git a/crates/benilla-app/assets/ui/QuestFrame.xml b/crates/benilla-app/assets/ui/QuestFrame.xml index ea108cd4..f553b537 100644 --- a/crates/benilla-app/assets/ui/QuestFrame.xml +++ b/crates/benilla-app/assets/ui/QuestFrame.xml @@ -484,9 +484,12 @@ end function QuestDetailDeclineButton_OnClick() - -- vanilla's client-side decline sends no packet; hide immediately (no one-frame lag, - -- the same convention every NPC window's close button already uses — e.g. - -- BenillaMerchantCloseButton_OnClick). + -- DeclineQuest() DOES send (decision 1738, byte-verified — this comment used to claim + -- the opposite for years): the app's session-end routine re-opens the giver's list + -- (CMSG_QUESTGIVER_HELLO / CMSG_GOSSIP_HELLO), which is what puts you back in an NPC's + -- menu after a decline, or answers a party member's share. Either way the panel is + -- hidden here immediately rather than waiting on a packet — the same convention every + -- NPC window's close button already uses (e.g. BenillaMerchantCloseButton_OnClick). DeclineQuest() PlaySound("igQuestCancel") -- ref QuestDetailDeclineButton_OnClick, QuestFrame.lua:577 HideUIPanel(getglobal("QuestFrame")) diff --git a/crates/benilla-app/assets/ui/QuestLogFrame.xml b/crates/benilla-app/assets/ui/QuestLogFrame.xml index d12d4301..4ac711a8 100644 --- a/crates/benilla-app/assets/ui/QuestLogFrame.xml +++ b/crates/benilla-app/assets/ui/QuestLogFrame.xml @@ -297,6 +297,15 @@ abandonButton:Enable() end + -- Share Quest (ref QuestLog_Update's tail, QuestLogFrame.lua:299-305) — the predicate + -- verbatim, in the ref's own guard order: an EMPTY log disables outright, otherwise the + -- quest must be sharable AND we must be in a party. Both halves matter and neither is + -- redundant. GetQuestLogPushable() reads the SELECTION's QUEST_FLAGS_SHARABLE bit off + -- the template cache, which is the client's own test and the server's only reliance: + -- vmangos never re-checks the bit on the push, only on the receiver's accept, so a + -- button we wrongly lit would push a quest the party is then refused (decision 1733). + BenillaQuestLogPushButton_Update(numEntries) + -- Clamp the faux-scroll offset (pin §2's FauxScrollFrame_Update clamp). local maxOffset = numEntries - QUESTS_DISPLAYED if maxOffset < 0 then maxOffset = 0 end @@ -1091,8 +1100,32 @@ end end + -- The Share Quest button's enable state (ref QuestLogFrame.lua:299-305 and :74-79, which + -- are the same three lines twice: the full update runs it with the entry count to hand, + -- and PARTY_MEMBERS_CHANGED runs it alone because a party you just joined or left changes + -- the answer without changing the log). `numEntries` is optional — omitted, it is read + -- here, which is what the event path wants. + function BenillaQuestLogPushButton_Update(numEntries) + if not numEntries then + numEntries = GetNumQuestLogEntries() + end + local pushButton = getglobal("QuestLogPushQuestButton") + if numEntries == 0 then + pushButton:Disable() + elseif GetQuestLogPushable() and GetNumPartyMembers() > 0 then + pushButton:Enable() + else + pushButton:Disable() + end + end + function BenillaQuestLogFrame_OnLoad(frame) frame:RegisterEvent("QUEST_LOG_UPDATE") + -- ref QuestLog_OnLoad (QuestLogFrame.lua:44-54) registers this for exactly one reason: + -- the Share Quest button's second conjunct. Joining or leaving a party changes whether + -- the selected quest can be shared without changing the quest log at all, so + -- QUEST_LOG_UPDATE alone would leave the button stale (decision 1733). + frame:RegisterEvent("PARTY_MEMBERS_CHANGED") end function BenillaQuestLogFrame_OnEvent(frame) @@ -1101,6 +1134,11 @@ -- passes `1`) — a quest updating while already being read must not yank the reader's -- scroll position back to the top. BenillaQuestLogFrame_Update(true) + elseif event == "PARTY_MEMBERS_CHANGED" then + -- The ref runs a whole QuestLog_Update here (its else-branch, QuestLogFrame.lua:71-81) + -- and then re-tests the button; the list cannot have changed, so only the button is + -- repainted. Same observable state, no scroll or selection churn. + BenillaQuestLogPushButton_Update() end end @@ -1813,14 +1851,24 @@ HideUIPanel(getglobal("QuestLogFrame")) + LEFT of Exit. Live since decision 1733; it starts DISABLED because that is the + solo/no-selection state the ref's own predicate produces, and + BenillaQuestLogPushButton_Update re-tests it on every list update and every + PARTY_MEMBERS_CHANGED. The newbie tooltip is the ref's (NEWBIE_TOOLTIP_SHAREQUEST, + GlobalStrings.lua:2764), through the same GameTooltip_AddNewbieTip the ref calls. --> diff --git a/crates/benilla-app/assets/ui/QuestShareFrame.xml b/crates/benilla-app/assets/ui/QuestShareFrame.xml new file mode 100644 index 00000000..2000a050 --- /dev/null +++ b/crates/benilla-app/assets/ui/QuestShareFrame.xml @@ -0,0 +1,64 @@ + + + + + + + + + self:RegisterEvent("QUEST_ACCEPT_CONFIRM") + BenillaQuestShareDriver_OnEvent() + + + diff --git a/crates/benilla-app/assets/ui/benilla.toc b/crates/benilla-app/assets/ui/benilla.toc index 08be2ac3..405c4021 100644 --- a/crates/benilla-app/assets/ui/benilla.toc +++ b/crates/benilla-app/assets/ui/benilla.toc @@ -102,6 +102,10 @@ DeathFrame.xml # The two duel dialogs + their event driver (decision 0633): registry entries on the same # StaticPopup engine, and DUEL_OUTOFBOUNDS leans on its per-tick countdown branch. DuelFrame.xml +# The escort-quest confirm + its event driver (decision 1733): one more registry entry on the same +# StaticPopup engine. Not QuestFrame.xml's or QuestLogFrame.xml's — it belongs to no window, the +# way the duel dialogs above belong to none. +QuestShareFrame.xml # The two enchant-apply confirms + their event driver (decision 0928): the same StaticPopup # registry again. Not CraftFrame's — the gate that raises them is the generic item-target # bind, reached by a poison or a sharpening stone with no profession window open. diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs index 2ab45bff..b9da3c3f 100644 --- a/crates/benilla-app/src/lib.rs +++ b/crates/benilla-app/src/lib.rs @@ -145,6 +145,7 @@ mod ui_pet_stats; mod ui_petition; mod ui_quest; mod ui_quest_log; +mod ui_quest_share; mod ui_reputation; mod ui_saved; mod ui_script; @@ -222,6 +223,7 @@ use ui_pet_stats::UiPetStatsPlugin; use ui_petition::UiPetitionPlugin; use ui_quest::UiQuestPlugin; use ui_quest_log::UiQuestLogPlugin; +use ui_quest_share::QuestSharePlugin; use ui_saved::UiSavedPlugin; use ui_script::UiScriptPlugin; use ui_shapeshift::UiShapeshiftPlugin; @@ -764,6 +766,10 @@ pub fn run(build: BuildId) -> AppExit { // PLAYER_QUEST_LOG descriptor slots + the SMSG_QUEST_QUERY_RESPONSE template cache, and drives // QuestLogFrame.xml over the Era quest-log API. .add_plugins(UiQuestLogPlugin) + // The party quest-share (decision 1733): the verdict lines on a quest we pushed, and the + // escort-quest confirm. Neither is bound to a window, so it is its own plugin rather than a + // lodger in either quest plugin above. + .add_plugins(QuestSharePlugin) .add_plugins(UiChatPlugin) // The layout cache: the geometry of every window the player has dragged or resized, restored // at world entry and written back a quiet second after the last drag diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs index 3ca0fb22..d8555461 100644 --- a/crates/benilla-app/src/net.rs +++ b/crates/benilla-app/src/net.rs @@ -1464,12 +1464,38 @@ pub(crate) enum ClientCommand { QuestgiverStatusQuery { npc: u64, }, + /// Greet a questgiver (`CMSG_QUESTGIVER_HELLO`) — the quest session's END on a non-gossip + /// NPC, which is what re-opens its quest list after a decline (decision 1738). The gossip + /// twin of this send is [`Self::GossipHello`]. + QuestgiverHello { + npc: u64, + }, /// Abandon a quest-log slot (`CMSG_QUESTLOG_REMOVE_QUEST`): the log window's confirmed abandon /// (`AbandonQuest()`'s two-step). No ack SMSG — the server clears the `PLAYER_QUEST_LOG` slot /// fields directly, which the next feed pass reads as the slot going empty. QuestlogRemove { slot: u8, }, + // ── The party quest-share (decision 1733; bodies in benilla-protocol `messages/quest/share.rs`). ── + /// Share the selected quest with the party (`CMSG_PUSHQUESTTOPARTY`) — the quest log's *Share + /// Quest* button. The server walks the group itself; the answers come back as one + /// `MSG_QUEST_PUSH_RESULT` per member (usually two: the opener, then the outcome). + PushQuestToParty { + quest: u32, + }, + /// Answer the escort confirm with Yes (`CMSG_QUEST_CONFIRM_ACCEPT`) — a + /// `QUEST_FLAGS_PARTY_ACCEPT` quest a party member started. There is no No command: the + /// reference sends nothing at all when the popup is dismissed. + QuestConfirmAccept { + quest: u32, + }, + /// Report our verdict on a quest a party member shared with us (`MSG_QUEST_PUSH_RESULT`) — + /// today only the decline; every other verdict on the share is the server's own. `sharer` is + /// the giver guid the shared `SMSG_QUESTGIVER_QUEST_DETAILS` arrived under. + QuestPushResult { + sharer: u64, + msg: benilla_protocol::messages::QuestShareMsg, + }, // ── The mail arc (decision 0544; writer bodies in benilla-protocol `world/writer/mail.rs`). ── /// Ask the mailbox's inbox page (`CMSG_GET_MAIL_LIST`) — the window's open verb and its /// refresh. Answered by `SMSG_MAIL_LIST_RESULT` (a `MailList` event). diff --git a/crates/benilla-app/src/net/apply.rs b/crates/benilla-app/src/net/apply.rs index 4a192f2b..d49f453f 100644 --- a/crates/benilla-app/src/net/apply.rs +++ b/crates/benilla-app/src/net/apply.rs @@ -45,9 +45,10 @@ use loot::{ loot_response, loot_roll, loot_roll_won, loot_start_roll, }; use quests::{ - quest_complete, quest_detail, quest_failed, quest_giver_failed, quest_giver_invalid, - quest_giver_status, quest_greeting, quest_log_full, quest_objective_item, quest_objective_kill, - quest_objectives_complete, quest_offer, quest_progress, quest_template, + quest_complete, quest_confirm_accept, quest_detail, quest_failed, quest_giver_failed, + quest_giver_invalid, quest_giver_status, quest_greeting, quest_log_full, quest_objective_item, + quest_objective_kill, quest_objectives_complete, quest_offer, quest_progress, + quest_push_result, quest_template, }; use spells::{ action_buttons, aura_duration, cancel_auto_repeat, cast_result, channel_start, channel_update, @@ -166,7 +167,14 @@ pub(super) fn apply_net_updates( ), ResMut, ResMut, - ResMut, + // Nested pair (the tuple is at the 16-param ceiling): the quest-log template cache, and + // the party quest-share state (decision 1733) — the verdicts on a quest we pushed and the + // escort confirm. Same family, one slot; unlike most nestings here these two genuinely + // belong together, since a share is a quest-log verb. + ( + ResMut, + ResMut, + ), ResMut, ResMut, ResMut, @@ -404,7 +412,7 @@ pub(super) fn apply_net_updates( (mut loot, mut loot_latch, mut loot_rolls), mut chat_log, mut quest, - mut quest_log, + (mut quest_log, mut quest_share), mut go_templates, mut home_bind, mut proficiencies, @@ -549,6 +557,7 @@ pub(super) fn apply_net_updates( &mut chat_log, &mut quest, &mut quest_log, + &mut quest_share, &mut death_net, &mut group, &mut taxi, @@ -1658,7 +1667,7 @@ pub(super) fn apply_net_updates( quest_giver_status(npc, status, &mut quest) } SessionEvent::QuestGreeting(list) => quest_greeting(list, &mut quest), - SessionEvent::QuestDetail(d) => quest_detail(d, &mut quest), + SessionEvent::QuestDetail(d) => quest_detail(d, &mut quest, &net_commands), SessionEvent::QuestProgress(p) => quest_progress(p, &mut quest), SessionEvent::QuestOffer(o) => quest_offer(o, &mut quest), SessionEvent::QuestComplete(c) => quest_complete(c, &mut quest), @@ -1690,6 +1699,13 @@ pub(super) fn apply_net_updates( &mut quest, ), SessionEvent::QuestLogFull => quest_log_full(&mut quest), + // The party quest-share (decision 1733): one member's verdict on a quest we pushed, + // and the escort-quest confirm. Both park in `QuestShare` for `crate::ui_quest_share` + // to name and raise — the guid needs a name query the apply pass has no VM to await. + SessionEvent::QuestPushResult { member, msg } => { + quest_push_result(member, msg, &mut quest_share) + } + SessionEvent::QuestConfirmAccept(c) => quest_confirm_accept(c, &mut quest_share), SessionEvent::QuestGiverInvalid { reason } => quest_giver_invalid(reason, &mut quest), SessionEvent::QuestGiverFailed { quest_id, reason } => { quest_giver_failed(quest_id, reason, &mut quest, &mut quest_log, &net_commands) diff --git a/crates/benilla-app/src/net/apply/quests.rs b/crates/benilla-app/src/net/apply/quests.rs index 502d311d..9783c922 100644 --- a/crates/benilla-app/src/net/apply/quests.rs +++ b/crates/benilla-app/src/net/apply/quests.rs @@ -4,7 +4,8 @@ //! dispatcher, one call per arm. use benilla_protocol::messages::{ - QuestComplete, QuestDetails, QuestGiverList, QuestOfferReward, QuestRequestItems, QuestTemplate, + QuestComplete, QuestConfirmAccept, QuestDetails, QuestGiverList, QuestOfferReward, + QuestRequestItems, QuestShareMsg, QuestTemplate, }; use bevy::prelude::*; @@ -12,8 +13,9 @@ use crate::ui_action::{MsgSurface, UiError}; use crate::ui_chat::ChatLog; use crate::ui_quest::QuestGiver; use crate::ui_quest_log::QuestLog; +use crate::ui_quest_share::QuestShare; -use super::super::NetCommands; +use super::super::{ClientCommand, NetCommands}; /// A questgiver dialog status for one NPC (`SMSG_QUESTGIVER_STATUS`) — the `!`/`?` marker's /// [`crate::messages::dialog_status`] value. Stored per guid now; the world marker is a later @@ -33,8 +35,26 @@ pub(super) fn quest_greeting(list: QuestGiverList, quest: &mut QuestGiver) { } /// The accept panel: full quest text + rewards on offer (`SMSG_QUESTGIVER_QUEST_DETAILS`). -pub(super) fn quest_detail(d: QuestDetails, quest: &mut QuestGiver) { +pub(super) fn quest_detail(d: QuestDetails, quest: &mut QuestGiver, commands: &NetCommands) { debug!("net: quest detail — quest {} on {:#x}", d.quest_id, d.npc); + // **A share that arrives on top of an open window is refused, by the client** (`0x5dbf85`, + // decision 1738): `MSG_QUEST_PUSH_RESULT{sharer, BUSY}` and the panel we are reading stays. + // `BUSY` is the second and last verdict the client originates. The server has a busy test of + // its own (`Player::GetQuestShareInfo`), but it only knows about shares — it cannot see that we + // are mid-turn-in at an NPC, which is exactly the case this covers. + if benilla_protocol::guid::is_player(d.npc) && quest.is_open() { + debug!( + "net: busy — refusing {:#x}'s shared quest {}", + d.npc, d.quest_id + ); + let _ = commands.0.send(ClientCommand::QuestPushResult { + sharer: d.npc, + msg: QuestShareMsg::BUSY, + }); + return; + } + // The trailing flag is a latch, not a field of the view (see `QuestGiver::detail_flag`). + quest.detail_flag = d.auto_finish; quest.open(d.npc, crate::ui_quest::QuestView::Detail(d)); } @@ -205,6 +225,30 @@ pub(super) fn quest_giver_failed( quest.clear(); } +/// One party member's verdict on a quest we shared (`MSG_QUEST_PUSH_RESULT`, decision 1733). +/// +/// Parked rather than shown: the line's `%s` is the member's NAME, which may still need a +/// `CMSG_NAME_QUERY` round trip, and this pass has no VM to resolve GlobalStrings through either. +/// [`crate::ui_quest_share`] owns both. +pub(super) fn quest_push_result(member: u64, msg: QuestShareMsg, share: &mut QuestShare) { + debug!( + "net: quest push result — member {member:#x} verdict {}", + msg.0 + ); + share.push_verdict(member, msg); +} + +/// A party member started a `QUEST_FLAGS_PARTY_ACCEPT` (escort) quest and we are being asked +/// whether to start it too (`SMSG_QUEST_CONFIRM_ACCEPT`). Parked for the same reason: the popup +/// names the member, and the name may not be cached yet. +pub(super) fn quest_confirm_accept(c: QuestConfirmAccept, share: &mut QuestShare) { + debug!( + "net: quest confirm accept — quest {} ({:?}) from {:#x}", + c.quest_id, c.title, c.sender + ); + share.set_confirm(c); +} + #[cfg(test)] mod tests { use super::*; @@ -299,4 +343,82 @@ mod tests { assert_eq!(msgs[0].1.key, "ERR_QUEST_FAILED_MAX_COUNT_S"); assert_eq!(msgs[0].1.fill_s.as_deref(), Some("")); } + + // ── The share's BUSY refusal (decision 1738) ───────────────────────────────────────────────── + + fn detail(npc: u64, quest_id: u32) -> QuestDetails { + QuestDetails { + npc, + quest_id, + title: "A Threat Within".into(), + details: String::new(), + objectives: String::new(), + auto_finish: 0, + choices: Vec::new(), + rewards: Vec::new(), + money: 0, + reward_spell: 0, + } + } + + /// **A share arriving on top of an open window is refused by the CLIENT**, with `BUSY` — the + /// second and last verdict the client originates (`0x5dbf85`). The window we are already + /// reading is not replaced, which is the point: the server's own busy test only knows about + /// other shares, so a player mid-turn-in at an NPC is invisible to it. + #[test] + fn a_share_on_top_of_an_open_window_is_refused_as_busy() { + const SHARER: u64 = 0x0000_0000_0000_002A; + const NPC: u64 = 0xF130_0000_0000_0007; + let (tx, rx) = crossbeam_channel::unbounded(); + let commands = NetCommands(tx); + let mut quest = QuestGiver::default(); + + // An NPC's panel is up… + quest_detail(detail(NPC, 100), &mut quest, &commands); + assert_eq!(quest.npc, Some(NPC)); + assert!(rx.try_iter().next().is_none(), "opening sends nothing"); + + // …and a party member's share lands on top of it. + quest_detail(detail(SHARER, 200), &mut quest, &commands); + assert_eq!(quest.npc, Some(NPC), "the open window survives the refusal"); + let sent: Vec<_> = rx.try_iter().collect(); + assert!( + matches!( + sent.as_slice(), + [ClientCommand::QuestPushResult { + sharer: SHARER, + msg: QuestShareMsg::BUSY, + }] + ), + "the sharer is told we are busy: {sent:?}" + ); + } + + /// With nothing open, the same share opens the panel normally and answers nothing — the + /// control the refusal above would pass without. + #[test] + fn a_share_with_no_window_open_just_opens() { + const SHARER: u64 = 0x0000_0000_0000_002A; + let (tx, rx) = crossbeam_channel::unbounded(); + let commands = NetCommands(tx); + let mut quest = QuestGiver::default(); + + quest_detail(detail(SHARER, 200), &mut quest, &commands); + assert_eq!(quest.npc, Some(SHARER)); + assert!(rx.try_iter().next().is_none(), "no verdict, no refusal"); + } + + /// The DETAILS trailing flag is LATCHED on the packet, not read off the open view — the + /// reference's `0xbe0824`, whose one reader runs whichever panel is up. + #[test] + fn the_detail_flag_latches_from_the_packet() { + let (tx, _rx) = crossbeam_channel::unbounded(); + let commands = NetCommands(tx); + let mut quest = QuestGiver::default(); + + let mut d = detail(0xF130_0000_0000_0007, 100); + d.auto_finish = 3; + quest_detail(d, &mut quest, &commands); + assert_eq!(quest.detail_flag, 3); + } } diff --git a/crates/benilla-app/src/net/apply/session.rs b/crates/benilla-app/src/net/apply/session.rs index 9722c879..bab925f8 100644 --- a/crates/benilla-app/src/net/apply/session.rs +++ b/crates/benilla-app/src/net/apply/session.rs @@ -160,6 +160,7 @@ pub(super) fn disconnected( chat_log: &mut ChatLog, quest: &mut QuestGiver, quest_log: &mut QuestLog, + quest_share: &mut crate::ui_quest_share::QuestShare, death_net: &mut crate::death::DeathNet, group: &mut crate::ui_party::GroupState, taxi: &mut TaxiState, @@ -226,6 +227,9 @@ pub(super) fn disconnected( chat_log.clear_session(); quest.clear_session(); quest_log.clear_session(); + // A verdict on a share nobody is listening for any more, and a confirm whose server-side + // latch died with the socket (decision 1733). + quest_share.clear_session(); group.clear_session(); taxi.clear_session(); mail.clear_session(); diff --git a/crates/benilla-app/src/net/io.rs b/crates/benilla-app/src/net/io.rs index df730dd5..04fdf41f 100644 --- a/crates/benilla-app/src/net/io.rs +++ b/crates/benilla-app/src/net/io.rs @@ -917,7 +917,13 @@ fn writer_loop( } ClientCommand::QuestQuery { quest } => w.quest_query(quest), ClientCommand::QuestgiverStatusQuery { npc } => w.questgiver_status_query(npc), + ClientCommand::QuestgiverHello { npc } => w.questgiver_hello(npc), ClientCommand::QuestlogRemove { slot } => w.questlog_remove_quest(slot), + ClientCommand::PushQuestToParty { quest } => w.push_quest_to_party(quest), + ClientCommand::QuestConfirmAccept { quest } => w.quest_confirm_accept(quest), + ClientCommand::QuestPushResult { sharer, msg } => { + w.quest_push_result(sharer, msg) + } ClientCommand::GetMailList { mailbox } => w.get_mail_list(mailbox), ClientCommand::SendMail { mailbox, diff --git a/crates/benilla-app/src/ui_action/errors.rs b/crates/benilla-app/src/ui_action/errors.rs index 148bf299..322a060e 100644 --- a/crates/benilla-app/src/ui_action/errors.rs +++ b/crates/benilla-app/src/ui_action/errors.rs @@ -21,10 +21,12 @@ //! absent key shows nothing (the reference's data-suppression face) and localization rides //! for free. -use crate::ui_items::{count_of, InventoryScope}; +use benilla_ui::script::{ScriptValue, UiScript}; use bevy::prelude::*; use crate::net::ObjectStore; +use crate::ui_chat::{ChatEvent, ChatEventKind, ChatLog}; +use crate::ui_items::{count_of, InventoryScope}; /// Cast failures queued for the UI error line — the wire triple from `SMSG_CAST_RESULT` and the /// local refusals alike. The spell id rides along because the display layer keys several messages @@ -179,6 +181,34 @@ pub(crate) fn ui_error_text(e: &UiError, get: &dyn Fn(&str) -> Option) - (!text.is_empty()).then_some(text) } +/// Show already-resolved [`UiError`] lines on the surface each message record names — the second +/// half of every [`MsgSurface`] tenant's drain, factored out once it had three (the questgiver +/// refusals 0669, the auction house 1523, the party quest-share 1733). The tenants differ only in +/// how they *build* their lines (a window's queue, a deferred item template, a guid awaiting a +/// name); what they do with a resolved one is this, identically, down to the debug line — which +/// exists because the chat path keeps no log of its own, so without it a live probe can count +/// lines but never read one (0669's in-app leg). +/// +/// `who` is the caller's module tag for that debug line. +pub(crate) fn show_messages( + script: &mut UiScript, + chat: &mut ChatLog, + who: &str, + lines: impl IntoIterator, +) { + for (surface, text) in lines { + debug!("{who}: message ({surface:?}) {text:?}"); + match surface { + MsgSurface::Chat => { + chat.push_event(ChatEvent::text_only(ChatEventKind::System, text)); + } + MsgSurface::Error => { + script.fire_event("UI_ERROR_MESSAGE", vec![ScriptValue::Str(text)]); + } + } + } +} + /// `UNIT_FIELD_FLAGS` bits the attack-start validator refuses on, paired with the message each /// raises — read at `0x612eec`+ in the binary's own test order (wow-re /// `object-layer/scratch/pet-command-validators.md` §2). All four are crowd control: the actor is diff --git a/crates/benilla-app/src/ui_action/mod.rs b/crates/benilla-app/src/ui_action/mod.rs index a45d3fa7..01827de1 100644 --- a/crates/benilla-app/src/ui_action/mod.rs +++ b/crates/benilla-app/src/ui_action/mod.rs @@ -65,8 +65,8 @@ pub(crate) struct CooldownEvents; // `cast_send`, so a second send path cannot be written by accident (decision 0914). pub(crate) use cast_send::{CastCommit, CastLadder}; pub(crate) use errors::{ - attack_actor_refusal, reagent_totem_refusal, ui_error_text, CastErrors, CastFail, MountErrors, - MsgSurface, UiError, UiErrorKeys, UiErrorTexts, + attack_actor_refusal, reagent_totem_refusal, show_messages, ui_error_text, CastErrors, + CastFail, MountErrors, MsgSurface, UiError, UiErrorKeys, UiErrorTexts, }; // `pub(crate)`: the target chain registers the cursor pre-empt + the click commit, and the // spellbook/stance/craft drains thread the mode through the one cast-send path (decision 0792). diff --git a/crates/benilla-app/src/ui_auction/mod.rs b/crates/benilla-app/src/ui_auction/mod.rs index b2f69d90..080f0c0c 100644 --- a/crates/benilla-app/src/ui_auction/mod.rs +++ b/crates/benilla-app/src/ui_auction/mod.rs @@ -40,16 +40,15 @@ use bevy::prelude::*; use benilla_protocol::messages::{auction_filter, AuctionListEntry}; use benilla_ui::script::{ - AuctionCategory, AuctionItemRow, AuctionListState, AuctionState, AuctionSubCategory, - ScriptValue, UiScript, BIDDER, LIST, OWNER, + AuctionCategory, AuctionItemRow, AuctionListState, AuctionState, AuctionSubCategory, UiScript, + BIDDER, LIST, OWNER, }; use crate::entities::ItemDisplays; use crate::items::Items; use crate::names::NameCache; use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer}; -use crate::ui_action::{ui_error_text, MsgSurface, UiError}; -use crate::ui_chat::{ChatEvent, ChatEventKind}; +use crate::ui_action::{show_messages, ui_error_text, MsgSurface, UiError}; use crate::ui_script::UiInput; use crate::ui_session::{close_npc_session_out_of_range, NpcSession}; @@ -630,19 +629,7 @@ fn feed_auction( } } auction.messages = deferred; - for (surface, text) in lines { - // Greppable, because the chat path has no log of its own — without this a live probe - // can count lines but never read one (0669's in-app leg). - debug!("ui_auction: message ({surface:?}) {text:?}"); - match surface { - MsgSurface::Chat => { - chat.push_event(ChatEvent::text_only(ChatEventKind::System, text)); - } - MsgSurface::Error => { - script.fire_event("UI_ERROR_MESSAGE", vec![ScriptValue::Str(text)]); - } - } - } + show_messages(&mut script, &mut chat, "ui_auction", lines); } let self_guid = self_q.iter().next().map(|g| g.0); diff --git a/crates/benilla-app/src/ui_quest.rs b/crates/benilla-app/src/ui_quest.rs index 2cd497b6..bea4a97a 100644 --- a/crates/benilla-app/src/ui_quest.rs +++ b/crates/benilla-app/src/ui_quest.rs @@ -17,6 +17,7 @@ use std::collections::HashMap; use benilla_protocol::messages::{ QuestDetails, QuestGiverList, QuestOfferReward, QuestRequestItems, QuestRewardItem, + QuestShareMsg, }; use bevy::prelude::*; @@ -27,9 +28,9 @@ use benilla_ui::script::{ use crate::entities::ItemDisplays; use crate::items::Items; use crate::names::NameCache; -use crate::net::{ClientCommand, Guid, NetCommands, ObjectStore, SelfPlayer}; -use crate::ui_action::{ui_error_text, MsgSurface, UiError}; -use crate::ui_chat::{ChatEvent, ChatEventKind, ChatLog}; +use crate::net::{ClientCommand, Guid, GuidIndex, NetCommands, ObjectStore, SelfPlayer}; +use crate::ui_action::{show_messages, ui_error_text, MsgSurface, UiError}; +use crate::ui_chat::ChatLog; use crate::ui_script::UiInput; use crate::ui_session::{close_npc_session_out_of_range, npc_switched, NpcSession}; @@ -55,6 +56,12 @@ pub(crate) struct QuestGiver { pub(crate) npc: Option, /// The open panel's wire view. pub(crate) view: Option, + /// 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, @@ -244,6 +251,18 @@ impl NpcSession for QuestGiver { self.npc.filter(|g| !benilla_protocol::guid::is_item(*g)) } + /// **A party share is not range-guarded** (decision 1733). Its giver is a live player with a + /// position, a name and a portrait, so it stays a full session for [`crate::ui_session`]'s + /// *other* consumer — but the guard's threshold is the NPC **service** range + /// ([`crate::target::SERVICE_RANGE_SQ`], 5.56 yd), a rule about reaching an NPC's counter, and + /// a share has no counter to reach. The server's own bound is `QUEST_SHARE_DISTANCE`, 14.0 yd + /// (vmangos `Object.h:72`), tested at ACCEPT and answered with `QUEST_PARTY_MSG_TOO_FAR` — + /// so a sharer standing six yards off would have had the panel yanked out from under a quest + /// the server was perfectly willing to grant. + fn range_guarded(&self) -> bool { + !self.npc.is_some_and(benilla_protocol::guid::is_player) + } + fn close(&mut self) { self.clear(); } @@ -449,19 +468,7 @@ fn feed_quest( ui_error_text(&msg, &get).map(|text| (surface, text)) }) .collect(); - for (surface, text) in lines { - // The resolved line, greppable: the chat path has no log of its own, so without this a - // live probe can count lines but never read one (decision 0669's in-app leg). - debug!("ui_quest: message ({surface:?}) {text:?}"); - match surface { - MsgSurface::Chat => { - chat.push_event(ChatEvent::text_only(ChatEventKind::System, text)); - } - MsgSurface::Error => { - script.fire_event("UI_ERROR_MESSAGE", vec![ScriptValue::Str(text)]); - } - } - } + show_messages(&mut script, &mut chat, "ui_quest", lines); let player = crate::npc_text::player_identity(&self_q, &mut names, &commands); let fresh = snapshot( &giver, @@ -518,10 +525,63 @@ fn feed_quest( /// 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, 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>, mut giver: ResMut, commands: Res, + index: Res, + stores: Query<&ObjectStore>, ) { let Some(mut script) = script else { return; @@ -532,6 +592,13 @@ fn drain_quest( 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() { @@ -574,7 +641,7 @@ fn drain_quest( for action in script.take_quest_actions() { match action { QuestAction::Close => { - debug!("ui_quest: client-side close (no packet)"); + end_quest_session(npc, giver.detail_flag, npc_flags, &commands); giver.clear(); } QuestAction::Accept => { @@ -919,4 +986,122 @@ mod tests { 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, + detail_flag: u32, + lua: &str, + ) -> Vec { + let (tx, rx) = crossbeam_channel::unbounded(); + let mut app = App::new(); + app.insert_resource(NetCommands(tx)); + app.init_resource::(); + 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::() + .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:?}"); + } } diff --git a/crates/benilla-app/src/ui_quest_log.rs b/crates/benilla-app/src/ui_quest_log.rs index 07eb0a63..97627052 100644 --- a/crates/benilla-app/src/ui_quest_log.rs +++ b/crates/benilla-app/src/ui_quest_log.rs @@ -40,7 +40,8 @@ use std::collections::{HashMap, HashSet}; use bevy::prelude::*; use benilla_protocol::messages::{ - quest_slot_state, QuestLogSlot, QuestObjective, QuestTemplate, PLAYER_QUEST_LOG_SLOTS, + quest_flags, quest_slot_state, QuestLogSlot, QuestObjective, QuestTemplate, + PLAYER_QUEST_LOG_SLOTS, }; use benilla_protocol::ObjectFields; use benilla_ui::script::{ @@ -147,6 +148,7 @@ impl Plugin for UiQuestLogPlugin { // frame's clock (the `minimap::feed_game_time` shape). feed_server_clock.before(UiInput), drain_quest_log_abandons.after(UiInput), + drain_quest_log_pushes.after(UiInput), drain_quest_log_collapses.after(UiInput), ), ); @@ -503,7 +505,8 @@ fn feed_quest_log( is_header: true, collapsed, complete: 0, - timer: 0, // a header row is not a quest and never carries a timer + timer: 0, // a header row is not a quest and never carries a timer + pushable: false, // nor is it shareable — there is no quest under the row objectives: Vec::new(), }); entry_slots.push(None); @@ -513,15 +516,26 @@ fn feed_quest_log( if collapsed { continue; // folded: the quest stays in the log, just not in the visible list } - let (title, level, objectives) = match quest_log.template(r.quest_id, &commands) { + let (title, level, pushable, objectives) = match quest_log + .template(r.quest_id, &commands) + { Some(t) => ( t.title.clone(), t.level, + // `QUEST_FLAGS_SHARABLE` (vmangos `QuestDef.h:153`) — the whole of the + // shareable test, and it is the CLIENT's alone: vmangos's + // `HandlePushQuestToParty` never checks the bit, it only re-tests it on the + // receiver's accept (`Player::CanShareQuest`). So an unshareable quest whose + // button we wrongly enabled would push, and the party would get a detail panel + // for a quest the server then refuses (decision 1733). + t.flags & quest_flags::SHARABLE != 0, build_objectives(t, &r.log_slot, &store.0, &mut items, &mut names, &commands), ), // Title/level/objectives placeholder while the template is in flight — the - // QUEST_LOG_UPDATE refresh on landing fills the row in. - None => ("...".to_string(), 0, Vec::new()), + // QUEST_LOG_UPDATE refresh on landing fills the row in. `pushable` false with it: + // the reference reads the same cache, so its button is dark until the answer lands + // too. + None => ("...".to_string(), 0, false, Vec::new()), }; let complete = if r.log_slot.state & quest_slot_state::COMPLETE != 0 { 1 @@ -538,6 +552,7 @@ fn feed_quest_log( is_header: false, collapsed: false, complete, + pushable, // The slot's raw deadline (absolute unix seconds; 0 = untimed) — carried, never // converted here. The countdown is subtracted per Lua call against the server // clock, so this snapshot stays stable while the number on screen ticks @@ -781,6 +796,19 @@ fn drain_quest_log_collapses( } } +/// `QuestLogPushQuest()` — the *Share Quest* button (decision 1733). The engine already resolved +/// the selection to a quest id at click time, so this is a straight relay; the server addresses the +/// party itself. +fn drain_quest_log_pushes(script: Option>, commands: Res) { + let Some(mut script) = script else { + return; + }; + for quest in script.take_quest_log_pushes() { + debug!("ui_quest_log: sharing quest {quest} with the party"); + let _ = commands.0.send(ClientCommand::PushQuestToParty { quest }); + } +} + fn drain_quest_log_abandons( script: Option>, quest_log: Res, @@ -816,6 +844,7 @@ mod tests { collapsed: false, complete: 0, timer: 0, + pushable: false, objectives: Vec::new(), } } @@ -830,6 +859,7 @@ mod tests { collapsed: false, complete: 0, timer: 0, + pushable: false, objectives: Vec::new(), } } diff --git a/crates/benilla-app/src/ui_quest_share.rs b/crates/benilla-app/src/ui_quest_share.rs new file mode 100644 index 00000000..85e1a280 --- /dev/null +++ b/crates/benilla-app/src/ui_quest_share.rs @@ -0,0 +1,316 @@ +//! **Party quest sharing** — the *Share Quest* button's two answer paths (decision 1733). +//! +//! Everything about a share that is not already the questgiver panel's job lives here, and that +//! turns out to be exactly the two things the sharer's own client cannot see: what each party +//! member *did* with the quest, and — for an escort quest — the confirm box a member gets when +//! somebody else starts it. +//! +//! **What is NOT here, deliberately.** The receiving half of an ordinary share needs no code of its +//! own: the server sends the receiver a plain `SMSG_QUESTGIVER_QUEST_DETAILS` whose giver guid is +//! the *sharer's player guid* (vmangos `QuestHandler.cpp:450`), so the shared quest arrives on the +//! questgiver panel already built, and Accept answers it with the ordinary +//! `CMSG_QUESTGIVER_ACCEPT_QUEST` addressed to that player. [`crate::ui_quest`] needs to know a +//! share from an NPC offer for exactly two things — the decline packet and the range guard — and it +//! reads both off the giver guid's own type bits ([`benilla_protocol::guid::is_player`]). +//! +//! **The two flows, and why one resource holds both.** They are one server-side latch +//! (`Player::SetQuestShareInfo` — one `{sharer, quest}` pair per player, set by the push and by an +//! escort accept alike), so a client that modelled them separately would be modelling one thing +//! twice. +//! +//! 1. **The verdicts.** `MSG_QUEST_PUSH_RESULT` comes back to the SHARER, once per member and +//! usually twice (`SHARING_QUEST` when the push goes out, then the outcome). Each is an +//! `ERR_QUEST_PUSH_*_S` line naming the member — `%s`-filled from the guid in the packet, which +//! is the member the verdict is *about*, never the sharer (the direction trap on +//! [`benilla_protocol::messages::QuestPushResult`]). +//! 2. **The escort confirm.** `SMSG_QUEST_CONFIRM_ACCEPT` arrives when a party member starts a +//! `QUEST_FLAGS_PARTY_ACCEPT` quest. It raises `QUEST_ACCEPT_CONFIRM(memberName, questTitle)`, +//! the reference's own event (`UIParent.lua:353` → `StaticPopup_Show("QUEST_ACCEPT", arg1, +//! arg2)`); Yes calls `ConfirmAcceptQuest()`, and **No sends nothing at all** — which is why +//! there is no decline command here. +//! +//! **A name we do not have yet holds its line — a NAMED divergence, not fidelity.** Both paths +//! address a party member by guid and display a *name*, so both go through the [`NameCache`]'s +//! ask-once resolve. The reference does not: it reads the object-name cache `0xc0e228` through +//! `0x55f080` with a **null callback**, so a miss returns 0 and the message is **silently dropped** +//! — no query, no defer, no line (decision 1738's §5). That is a quirk of a synchronous object +//! manager that always has its party members to hand, not a behaviour worth reproducing: benilla's +//! cache may genuinely not hold a member yet, and losing "Thrall has declined your quest" because a +//! name query was in flight is strictly worse than showing it a frame later. So a verdict whose +//! name has not landed is kept and retried, with [`crate::ui_loot`]'s bounded budget so a guid the +//! server will never name cannot pin a line forever. [`crate::ui_duel`] takes the same divergence +//! against the same reference behaviour, and says so in its own header. + +use benilla_protocol::messages::{QuestConfirmAccept, QuestShareMsg}; +use benilla_ui::script::{ScriptValue, UiScript}; +use bevy::prelude::*; + +use crate::names::NameCache; +use crate::net::{ClientCommand, NetCommands}; +use crate::ui_action::{show_messages, ui_error_text, MsgSurface, UiError}; +use crate::ui_chat::ChatLog; +use crate::ui_script::UiInput; + +/// How many frames a queued line waits for its `%s` before it is dropped — [`crate::ui_loot`]'s +/// `RECEIVE_MAX_TRIES`, and the same reasoning: a name query is one round trip, so any real answer +/// lands inside a couple of frames, and a guid the server declines to name (it caches the negative) +/// would otherwise hold its line for the rest of the session. +const NAME_MAX_TRIES: u16 = 120; + +/// One `MSG_QUEST_PUSH_RESULT` verdict waiting for the member's name. +struct PendingVerdict { + member: u64, + msg: QuestShareMsg, + tries: u16, +} + +/// The share state: the verdicts on quests we pushed, and the escort confirm we owe an answer. +#[derive(Resource, Default)] +pub(crate) struct QuestShare { + verdicts: Vec, + /// The confirm the server sent, held until its sender's name resolves and the event fires. + pending_confirm: Option, + /// The quest `ConfirmAcceptQuest()` answers — latched when the popup's event fires and kept + /// after it, because the Lua verb takes no argument (ref `StaticPopup.lua:731-733`): the + /// client answers the confirm it was last asked, and a *second* confirm replaces the first + /// exactly as it replaces the server's own one-slot latch. + confirm_quest: Option, +} + +impl QuestShare { + /// Queue one verdict on a quest we shared (`MSG_QUEST_PUSH_RESULT`). + pub(crate) fn push_verdict(&mut self, member: u64, msg: QuestShareMsg) { + self.verdicts.push(PendingVerdict { + member, + msg, + tries: 0, + }); + } + + /// Hold an escort confirm (`SMSG_QUEST_CONFIRM_ACCEPT`) for the feed to raise. A second + /// confirm replaces a first that has not fired yet — the server keeps one latch, so the older + /// question is already dead. + pub(crate) fn set_confirm(&mut self, c: QuestConfirmAccept) { + self.pending_confirm = Some(c); + } + + /// The socket died: no verdict is worth showing after it and no confirm can still be answered + /// (the server's latch went with the session). + pub(crate) fn clear_session(&mut self) { + self.verdicts.clear(); + self.pending_confirm = None; + self.confirm_quest = None; + } +} + +/// The `ERR_QUEST_PUSH_*` GlobalStrings key one verdict shows, and the surface it shows on. +/// +/// The value→key column is vmangos `QuestDef.h:62-70`, whose enum comments name the client's own +/// error constants one-for-one — the same corroboration decision 0669's refusal table started +/// from. `None` for an unmapped byte is the reference's data-suppression face, not a gap: an +/// unknown verdict shows nothing rather than an English guess. +/// +/// **Both columns are now VERIFIED at the bytes** (decision 1738's fold-back of the §5 dispatched +/// with 1733). The inbound `0x276` arm at `0x5e4781` maps `msg 0..8` onto message ids `0x181`-`0x189` +/// — contiguous, in exactly this order — and all nine records carry **`kind 0`** (the nine +/// `push 0x0` at `0x487dc9`-`0x487e69`), which `CGGameUI::DisplayError` dispatches to the chat +/// window as `CHAT_MSG_SYSTEM`. Not one of them reaches `UIErrorsFrame`, which is what 1733 +/// guessed and is why the guess is recorded as having been a guess. +fn verdict_message(msg: QuestShareMsg) -> Option<(MsgSurface, &'static str)> { + let key = match msg { + QuestShareMsg::SHARING_QUEST => "ERR_QUEST_PUSH_SUCCESS_S", + QuestShareMsg::CANT_TAKE_QUEST => "ERR_QUEST_PUSH_INVALID_S", + QuestShareMsg::ACCEPT_QUEST => "ERR_QUEST_PUSH_ACCEPTED_S", + QuestShareMsg::DECLINE_QUEST => "ERR_QUEST_PUSH_DECLINED_S", + QuestShareMsg::TOO_FAR => "ERR_QUEST_PUSH_TOO_FAR_S", + QuestShareMsg::BUSY => "ERR_QUEST_PUSH_BUSY_S", + QuestShareMsg::LOG_FULL => "ERR_QUEST_PUSH_LOG_FULL_S", + QuestShareMsg::HAVE_QUEST => "ERR_QUEST_PUSH_ONQUEST_S", + QuestShareMsg::FINISH_QUEST => "ERR_QUEST_PUSH_ALREADY_DONE_S", + _ => return None, + }; + Some((MsgSurface::Chat, key)) +} + +/// Owns [`QuestShare`] and its two systems — a plugin of its own rather than a lodger in +/// [`crate::ui_quest`]'s, because nothing here is bound to the questgiver window: the verdicts are +/// fired from the quest LOG and the confirm has no window at all. +pub(crate) struct QuestSharePlugin; + +impl Plugin for QuestSharePlugin { + fn build(&self, app: &mut App) { + // The [`crate::ui_duel`] shape exactly: feed before the input pass so a verdict line and + // a confirm are on screen the same frame the packet landed, drain after it so the popup's + // Yes goes out the same frame it was clicked. + app.init_resource::().add_systems( + Update, + ( + feed_quest_share.before(UiInput), + drain_quest_share.after(UiInput), + ), + ); + } +} + +/// Resolve the queued names, then show what they unlocked: the verdict lines, and the escort +/// confirm's `QUEST_ACCEPT_CONFIRM`. +fn feed_quest_share( + script: Option>, + mut share: ResMut, + mut names: ResMut, + commands: Res, + mut chat: ResMut, +) { + let Some(mut script) = script else { + return; + }; + + // The verdicts: one line per resolved name, the rest kept for the next frame. + let mut lines: Vec<(MsgSurface, String)> = Vec::new(); + let mut waiting = Vec::new(); + for mut v in std::mem::take(&mut share.verdicts) { + let Some((surface, key)) = verdict_message(v.msg) else { + debug!( + "ui_quest_share: unmapped push verdict {} — no line", + v.msg.0 + ); + continue; + }; + match names.resolve(v.member, &commands).map(str::to_string) { + Some(name) => { + let err = UiError { + key, + fill_s: Some(name), + fill_d: None, + info: false, + }; + let get = |k: &str| script.lua().globals().get::(k).ok(); + if let Some(text) = ui_error_text(&err, &get) { + lines.push((surface, text)); + } + } + None => { + v.tries += 1; + if v.tries < NAME_MAX_TRIES { + waiting.push(v); + } else { + debug!( + "ui_quest_share: gave up naming {:#x} for verdict {}", + v.member, v.msg.0 + ); + } + } + } + } + share.verdicts = waiting; + show_messages(&mut script, &mut chat, "ui_quest_share", lines); + + // The escort confirm — held until the member's name lands, then raised with the reference's own + // two args: `QUEST_ACCEPT = "%s is starting %s\nWould you like to as well?"`, player first. + if let Some(c) = share.pending_confirm.as_ref() { + if let Some(name) = names.resolve(c.sender, &commands).map(str::to_string) { + let title = c.title.clone(); + share.confirm_quest = Some(c.quest_id); + share.pending_confirm = None; + script.fire_event( + "QUEST_ACCEPT_CONFIRM", + vec![ScriptValue::Str(name), ScriptValue::Str(title)], + ); + } + } +} + +/// `ConfirmAcceptQuest()` — the confirm popup's Yes, answered against the latched quest id. +fn drain_quest_share( + script: Option>, + share: Res, + commands: Res, +) { + let Some(mut script) = script else { + return; + }; + for _ in 0..script.take_quest_confirms() { + let Some(quest) = share.confirm_quest else { + debug!("ui_quest_share: ConfirmAcceptQuest with no confirm held — ignored"); + continue; + }; + let _ = commands.0.send(ClientCommand::QuestConfirmAccept { quest }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_wire_verdict_has_a_key_and_an_unknown_has_none() { + for v in 0u8..=8 { + assert!( + verdict_message(QuestShareMsg(v)).is_some(), + "verdict {v} unmapped" + ); + } + assert!(verdict_message(QuestShareMsg(9)).is_none()); + assert!(verdict_message(QuestShareMsg(0xFF)).is_none()); + } + + /// The nine keys are distinct — a copy-paste in the table would silently show one member's + /// outcome as another's. + #[test] + fn verdict_keys_are_distinct() { + let mut keys: Vec<&str> = (0u8..=8) + .filter_map(|v| verdict_message(QuestShareMsg(v)).map(|(_, k)| k)) + .collect(); + keys.sort_unstable(); + let n = keys.len(); + keys.dedup(); + assert_eq!(keys.len(), n, "duplicate ERR_QUEST_PUSH_* key"); + } + + /// The RUNTIME leg on the real client data ([`crate::ui_quest`]'s refusal-table test, same + /// shape and same reason): every `ERR_QUEST_PUSH_*` key the table can emit resolves to a + /// non-empty string in the shipped 1.12 `GlobalStrings.lua`, and every one of them carries the + /// `%s` the member's name fills — a key that resolved but had no token would silently show the + /// same line for every member. Skips without client data. + #[test] + fn every_verdict_key_resolves_and_names_a_member() { + 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::(key).ok(); + + for raw in 0u8..=8 { + let (_, key) = verdict_message(QuestShareMsg(raw)).expect("mapped"); + let text = g(key).unwrap_or_default(); + assert!(!text.is_empty(), "{key} (verdict {raw}) missing"); + assert!(text.contains("%s"), "{key} names no member: {text:?}"); + } + + // The two ends of the flow, filled: the opener the sharer sees the instant they click, and + // the answer a decline produces. + let line = |raw: u8| { + let (_, key) = verdict_message(QuestShareMsg(raw)).unwrap(); + ui_error_text( + &UiError { + key, + fill_s: Some("Mate".into()), + fill_d: None, + info: false, + }, + &g, + ) + }; + assert_eq!( + line(QuestShareMsg::SHARING_QUEST.0).as_deref(), + Some("Sharing quest with Mate...") + ); + assert_eq!( + line(QuestShareMsg::DECLINE_QUEST.0).as_deref(), + Some("Mate has declined your quest") + ); + } +} diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs index 82a59c3d..77eb375e 100644 --- a/crates/benilla-app/src/ui_script/mod.rs +++ b/crates/benilla-app/src/ui_script/mod.rs @@ -957,6 +957,9 @@ mod petition_tests; #[cfg(test)] mod raid_tests; +#[cfg(test)] +mod quest_share_tests; + #[cfg(test)] mod quest_tests; diff --git a/crates/benilla-app/src/ui_script/quest_share_tests.rs b/crates/benilla-app/src/ui_script/quest_share_tests.rs new file mode 100644 index 00000000..9c26a25d --- /dev/null +++ b/crates/benilla-app/src/ui_script/quest_share_tests.rs @@ -0,0 +1,118 @@ +//! The escort-quest confirm (decision 1733, `QuestShareFrame.xml`): the Lua wiring between the +//! `QUEST_ACCEPT_CONFIRM` event `ui_quest_share`'s feed fires and the shared StaticPopup engine, +//! driven exactly as that feed drives it. +//! +//! The asymmetry these tests pin is the reference's: **Yes sends, No does not**. There is no +//! decline packet for an escort confirm, so a test that only checked "the popup closes" would pass +//! against a version that silently answered No on the wire. + +use benilla_ui::script::{ScriptValue, UiScript}; + +/// Load one shipped `assets/ui/` into `s`, panicking on any loader error (the duel tests' +/// loader, duplicated so this file is self-contained). +fn load_xml(s: &UiScript, file: &str) -> usize { + let text = std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("assets/ui") + .join(file), + ) + .unwrap(); + let doc = benilla_ui::framexml::parse(&text).unwrap(); + let report = benilla_ui::loader::load(s, &doc, &|_| None); + assert!( + report.errors.is_empty(), + "{file}: loader errors: {:?}", + report.errors + ); + report.frames +} + +fn setup() -> UiScript { + let mut s = UiScript::new().unwrap(); + s.set_screen_size(1024.0, 768.0); + load_xml(&s, "Fonts.xml"); + load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "UiPanels.xml"); + load_xml(&s, "QuestShareFrame.xml"); + s +} + +fn confirm(s: &mut UiScript, who: &str, quest: &str) { + s.fire_event( + "QUEST_ACCEPT_CONFIRM", + vec![ + ScriptValue::Str(who.to_string()), + ScriptValue::Str(quest.to_string()), + ], + ); +} + +/// The event raises the popup with BOTH args filled, in the reference's order: the player first, +/// the quest title second (`QUEST_ACCEPT = "%s is starting %s\nWould you like to as well?"`). A +/// swap would read "Escort Duty is starting Thrall", which is exactly the kind of wrong that looks +/// right at a glance. +#[test] +fn the_confirm_names_the_player_first_and_the_quest_second() { + let mut s = setup(); + confirm(&mut s, "Thrall", "Escort Duty"); + assert!( + s.eval::("return StaticPopup1:IsVisible()").unwrap(), + "QUEST_ACCEPT_CONFIRM shows the confirm popup" + ); + assert_eq!( + s.eval::("return StaticPopup1Text:GetText()") + .unwrap(), + "Thrall is starting Escort Duty\nWould you like to as well?" + ); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} + +/// Yes queues exactly one `ConfirmAcceptQuest()` and closes the popup. +#[test] +fn yes_answers_once() { + let mut s = setup(); + confirm(&mut s, "Thrall", "Escort Duty"); + s.run("StaticPopup_OnClick(StaticPopup1, 1)").unwrap(); + assert_eq!(s.take_quest_confirms(), 1); + assert_eq!(s.take_quest_confirms(), 0, "drained"); + assert!( + !s.eval::("return StaticPopup1:IsVisible()").unwrap(), + "answering closes it" + ); +} + +/// **No sends nothing.** The reference's `QUEST_ACCEPT` entry has an `OnAccept` and no `OnCancel` +/// at all (`StaticPopup.lua:727-737`), because there is no decline packet — the server's pending +/// latch is cleared by the next thing that touches it. Same for ESC. +#[test] +fn no_and_escape_send_nothing() { + let mut s = setup(); + confirm(&mut s, "Thrall", "Escort Duty"); + s.run("StaticPopup_OnClick(StaticPopup1, 2)").unwrap(); + assert_eq!(s.take_quest_confirms(), 0, "No must not answer the server"); + assert!(!s.eval::("return StaticPopup1:IsVisible()").unwrap()); + + confirm(&mut s, "Thrall", "Escort Duty"); + s.run("StaticPopup_EscapePressed()").unwrap(); + assert_eq!(s.take_quest_confirms(), 0, "ESC must not answer the server"); + assert!(!s.eval::("return StaticPopup1:IsVisible()").unwrap()); +} + +/// A second confirm replaces the first rather than stacking — the server keeps ONE share latch per +/// player, so an older question is already dead by the time a newer one arrives, and answering the +/// stale popup would send the wrong quest id. +#[test] +fn a_second_confirm_replaces_the_first() { + let mut s = setup(); + confirm(&mut s, "Thrall", "Escort Duty"); + confirm(&mut s, "Jaina", "Deeper Still"); + assert_eq!( + s.eval::("return StaticPopup1Text:GetText()") + .unwrap(), + "Jaina is starting Deeper Still\nWould you like to as well?" + ); + assert!( + !s.eval::("return StaticPopup2:IsVisible()").unwrap(), + "the confirm is exclusive — it reuses the one slot" + ); +} diff --git a/crates/benilla-app/src/ui_script/questlog_tests.rs b/crates/benilla-app/src/ui_script/questlog_tests.rs index dfd6acce..4a600800 100644 --- a/crates/benilla-app/src/ui_script/questlog_tests.rs +++ b/crates/benilla-app/src/ui_script/questlog_tests.rs @@ -4,8 +4,8 @@ //! `quest_tests.rs`/`bag_tests.rs`'s engine-only harness for the quest-log slice (decision 0088 arc). use benilla_ui::script::{ - ExtractedQuad, QuadContent, QuestItemView, QuestLogDetail, QuestLogEntryView, - QuestLogObjectiveView, QuestLogState, SoundRequest, UiScript, + ExtractedQuad, PartyMemberInfo, PartyState, QuadContent, QuestItemView, QuestLogDetail, + QuestLogEntryView, QuestLogObjectiveView, QuestLogState, SoundRequest, UiScript, }; /// Load one shipped `assets/ui/` into `s`, panicking on any loader error (the panel/quest @@ -1172,3 +1172,144 @@ fn shift_click_on_a_title_posts_the_quest_name_with_chat_open_and_watches_with_i assert_eq!(s.eval::("return GetQuestLogSelection()").unwrap(), 1); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } + +// ── The Share Quest button (decision 1733) ─────────────────────────────────────────────────────── + +/// A party of `n` others; `GetNumPartyMembers` is that list's length. +fn party(n: usize) -> PartyState { + PartyState { + members: (0..n) + .map(|i| PartyMemberInfo { + name: format!("Mate{i}"), + guid: 0x300 + i as u64, + }) + .collect(), + ..PartyState::default() + } +} + +/// The 8-entry fixture with `pushable` set on the entries named by `ids` — the app sets it from +/// the cached template's `QUEST_FLAGS_SHARABLE` bit, so a fixture is how the button's predicate is +/// exercised without a wire. +fn entries_sharable(ids: &[u32]) -> QuestLogState { + let mut state = eight_entries(); + for e in &mut state.entries { + e.pushable = ids.contains(&e.quest_id); + } + state +} + +/// The button's predicate is a CONJUNCTION, and each half is tested by moving only itself: a +/// sharable quest with no party is dark, a party with an unsharable selection is dark, and only +/// both together light it (ref `QuestLogFrame.lua:299-305`). +#[test] +fn share_quest_needs_both_a_sharable_selection_and_a_party() { + let mut s = UiScript::new().unwrap(); + s.set_screen_size(1024.0, 768.0); + load_xml(&s, "Fonts.xml"); + load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "UiPanels.xml"); + load_xml(&s, "MerchantFrame.xml"); + load_xml(&s, "QuestLogFrame.xml"); + + // Solo, quest 1 sharable and selected by the auto-selection. + s.set_quest_log(entries_sharable(&[1])); + s.set_party(PartyState::default()); + s.run("ToggleQuestLog()").unwrap(); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); + assert_eq!(s.eval::("return GetQuestLogSelection()").unwrap(), 1); + // `1`/nil, never true/false (decision 1738) — compared the way the reference's own predicate + // does, which is why the button below reads the same either way. + assert!( + s.eval::("return GetQuestLogPushable() ~= nil") + .unwrap(), + "quest 1 carries the sharable bit" + ); + assert!( + !s.eval::("return QuestLogPushQuestButton:IsEnabled()") + .unwrap(), + "solo: sharable is not enough" + ); + + // Join a party — the event alone must light it, with no quest-log change at all. + s.set_party(party(2)); + s.fire_event("PARTY_MEMBERS_CHANGED", vec![]); + assert!( + s.eval::("return QuestLogPushQuestButton:IsEnabled()") + .unwrap(), + "PARTY_MEMBERS_CHANGED alone re-tests the button" + ); + + // Select an unsharable quest: party unchanged, button goes dark. + s.run("SelectQuestLogEntry(2)").unwrap(); + s.run("BenillaQuestLogFrame_Update()").unwrap(); + assert!( + s.eval::("return GetQuestLogPushable() == nil") + .unwrap(), + "quest 2 has no sharable bit" + ); + assert!( + !s.eval::("return QuestLogPushQuestButton:IsEnabled()") + .unwrap(), + "in a party: an unsharable selection is still dark" + ); + + // Leaving the party darkens it again from the other side. + s.run("SelectQuestLogEntry(1)").unwrap(); + s.run("BenillaQuestLogFrame_Update()").unwrap(); + assert!(s + .eval::("return QuestLogPushQuestButton:IsEnabled()") + .unwrap()); + s.set_party(PartyState::default()); + s.fire_event("PARTY_MEMBERS_CHANGED", vec![]); + assert!(!s + .eval::("return QuestLogPushQuestButton:IsEnabled()") + .unwrap()); +} + +/// An EMPTY log disables the button outright, ahead of both other conjuncts — the ref's own first +/// guard, and the one that matters because `GetQuestLogPushable()` on no selection is false anyway +/// but `GetNumPartyMembers() > 0` is not. +#[test] +fn share_quest_is_dark_on_an_empty_log_even_in_a_party() { + let mut s = UiScript::new().unwrap(); + s.set_screen_size(1024.0, 768.0); + load_xml(&s, "Fonts.xml"); + load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "UiPanels.xml"); + load_xml(&s, "MerchantFrame.xml"); + load_xml(&s, "QuestLogFrame.xml"); + + s.set_quest_log(QuestLogState::default()); + s.set_party(party(4)); + s.run("ToggleQuestLog()").unwrap(); + assert!(!s + .eval::("return QuestLogPushQuestButton:IsEnabled()") + .unwrap()); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} + +/// The click queues the SELECTION's quest id — not its list index, and not the row under the +/// mouse. The fixture's ids are deliberately not equal to their 1-based positions after a +/// selection move, so an index/id confusion shows up here. +#[test] +fn share_quest_click_queues_the_selected_quests_id() { + let mut s = UiScript::new().unwrap(); + s.set_screen_size(1024.0, 768.0); + load_xml(&s, "Fonts.xml"); + load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "UiPanels.xml"); + load_xml(&s, "MerchantFrame.xml"); + load_xml(&s, "QuestLogFrame.xml"); + + s.set_quest_log(entries_sharable(&[1, 5])); + s.set_party(party(1)); + s.run("ToggleQuestLog()").unwrap(); + + s.run("SelectQuestLogEntry(5)").unwrap(); + s.run("BenillaQuestLogFrame_Update()").unwrap(); + s.run("QuestLogPushQuestButton:Click()").unwrap(); + assert_eq!(s.take_quest_log_pushes(), vec![5]); + assert!(s.take_quest_log_pushes().is_empty(), "drained"); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} diff --git a/crates/benilla-app/src/ui_session.rs b/crates/benilla-app/src/ui_session.rs index 9f5bb57f..746d58f7 100644 --- a/crates/benilla-app/src/ui_session.rs +++ b/crates/benilla-app/src/ui_session.rs @@ -57,6 +57,17 @@ pub(crate) trait NpcSession: Resource { fn npc(&self) -> Option; /// The window's client-side close (no packet) — the same clear its close button does. fn close(&mut self); + /// Whether the OPEN session is subject to the service-range guard below. Almost always yes, + /// which is why it is defaulted — but "bound to a live unit" and "must stay inside NPC service + /// range" are two claims, and a party quest-share separates them: its giver is a live *player* + /// with a name and a portrait (so [`Self::npc`] must report it) whose distance rule is the + /// server's 14 yd share bound, not this 5.56 yd counter reach (decision 1733). + /// + /// Contrast [`crate::ui_quest::QuestGiver`]'s item filter, which answers the *other* question + /// by returning `None`: a quest-starter item is not a unit at all (decision 0664). + fn range_guarded(&self) -> bool { + true + } } /// Did an NPC-bound window switch to a **different** NPC of the same kind while it was already open @@ -78,6 +89,9 @@ pub(crate) fn close_npc_session_out_of_range( transforms: Query<&Transform>, ) { let Some(npc) = session.npc() else { return }; + if !session.range_guarded() { + return; + } let Some(self_tf) = self_q.iter().next() else { return; }; @@ -208,6 +222,89 @@ mod tests { assert!(!app.world().resource::().is_open()); } + /// **A party quest-share is exempt from the guard, and still a full session.** The two are one + /// test because they are the two halves of decision 1733's split: the giver is a live player, + /// so `npc()` must keep reporting it (the QuestFrame portrait and name banner read it through + /// [`InteractNpc`]) — while the *distance* rule is the server's 14 yd share bound, not this + /// 5.56 yd NPC-counter reach. Before the split, a sharer standing six yards off had the panel + /// yanked away from a quest the server would have granted. + #[test] + fn a_shared_quest_panel_survives_the_service_range_but_stays_a_session() { + use crate::ui_quest::{QuestGiver, QuestView}; + + // The panel's wire view, whichever giver opened it — only the GUID differs between an NPC + // offer and a share, which is the whole point. + fn detail(npc: u64) -> QuestView { + QuestView::Detail(benilla_protocol::messages::QuestDetails { + npc, + quest_id: 1, + title: "A Threat Within".into(), + details: String::new(), + objectives: String::new(), + auto_finish: 1, + choices: Vec::new(), + rewards: Vec::new(), + money: 0, + reward_spell: 0, + }) + } + + const SHARER: u64 = 0x0000_0000_0000_002A; // HIGHGUID_PLAYER: a zero high word + const VENDOR: u64 = 0xF130_0000_0000_0007; // HIGHGUID_UNIT + + let mut app = App::new(); + app.init_resource::(); + app.init_resource::(); + app.init_resource::(); + app.add_systems( + Update, + ( + close_npc_session_out_of_range::, + feed_interact_npc, + ) + .chain(), + ); + app.world_mut() + .spawn((SelfPlayer, Transform::from_xyz(0.0, 0.0, 0.0))); + // Ten yards away: well past the NPC service gate, well inside the share bound. + let sharer = app + .world_mut() + .spawn(Transform::from_xyz(10.0, 0.0, 0.0)) + .id(); + app.world_mut() + .resource_mut::() + .0 + .insert(SHARER, sharer); + + app.world_mut() + .resource_mut::() + .open(SHARER, detail(SHARER)); + app.update(); + assert!( + app.world().resource::().is_open(), + "a shared quest's panel is not NPC-range-guarded" + ); + assert_eq!( + app.world().resource::().1, + Some(SHARER), + "it is still a session: the panel's portrait and name banner point at the sharer" + ); + + // The control: the identical distance with an NPC giver closes, as it always has. + app.world_mut() + .resource_mut::() + .0 + .insert(VENDOR, sharer); + app.world_mut() + .resource_mut::() + .open(VENDOR, detail(VENDOR)); + app.update(); + assert!( + !app.world().resource::().is_open(), + "an NPC giver ten yards off still closes" + ); + } + /// The `"npc"` portrait token's resolver ([`feed_interact_npc`]): the open NPC session's guid, /// mapped through the [`GuidIndex`] to its world entity. No window → None; an unindexed /// (not-yet-streamed) NPC → None, never a stale entity; the gossip session wins the `.or` chain diff --git a/crates/benilla-protocol/src/events.rs b/crates/benilla-protocol/src/events.rs index 8b80b655..4f84c3b1 100644 --- a/crates/benilla-protocol/src/events.rs +++ b/crates/benilla-protocol/src/events.rs @@ -19,11 +19,11 @@ use crate::messages::{ LootRoll, LootRollWon, LootStartRoll, MailListEntry, MirrorTimerStart, MonsterMoveFacing, ObjectFields, PartyKillLog, PartyMemberStatsInfo, PeriodicAuraLog, PetMode, PetSpells, PetitionQueryResponse, PetitionRename, PetitionShowList, PetitionShowSignatures, - PetitionSignResults, PvpCredit, QuestComplete, QuestDetails, QuestGiverList, QuestOfferReward, - QuestRequestItems, QuestTemplate, SpellDamageLog, SpellDispelLog, SpellEnergizeLog, - SpellHealLog, SpellInstaKillLog, SpellLogExecute, SpellLogMiss, SpellOutcomeLog, StabledPet, - TaxiMask, TradeStatus, TradeStatusExtended, TrainerSpell, TransportPose, VendorItem, - WhoResults, XpGain, + PetitionSignResults, PvpCredit, QuestComplete, QuestConfirmAccept, QuestDetails, + QuestGiverList, QuestOfferReward, QuestRequestItems, QuestShareMsg, QuestTemplate, + SpellDamageLog, SpellDispelLog, SpellEnergizeLog, SpellHealLog, SpellInstaKillLog, + SpellLogExecute, SpellLogMiss, SpellOutcomeLog, StabledPet, TaxiMask, TradeStatus, + TradeStatusExtended, TrainerSpell, TransportPose, VendorItem, WhoResults, XpGain, }; /// Coarse entity classification, free of wire types so the app can branch on it without depending on @@ -850,6 +850,13 @@ pub enum SessionEvent { QuestFailed { quest_id: u32, timed: bool }, /// The log refused a new quest — no free slot (`SMSG_QUESTLOG_FULL`). QuestLogFull, + /// One party member's verdict on a quest WE shared (`MSG_QUEST_PUSH_RESULT`) — decision 1733. + /// `member` is the member the verdict is about (never the sharer; see the direction trap on + /// [`crate::messages::QuestPushResult`]), and fills the `%s` of the `ERR_QUEST_PUSH_*` line. + QuestPushResult { member: u64, msg: QuestShareMsg }, + /// A party member started a `QUEST_FLAGS_PARTY_ACCEPT` (escort) quest and the server is asking + /// whether we want it too (`SMSG_QUEST_CONFIRM_ACCEPT`) — the `QUEST_ACCEPT` confirm box. + QuestConfirmAccept(QuestConfirmAccept), /// The giver won't OFFER the quest (`SMSG_QUESTGIVER_QUEST_INVALID`): one `QuestFailedReason` /// msg code and no quest id — vmangos' `SendCanTakeQuestResponse`, the answer to a query or an /// accept that fails `CanTakeQuest` ("already on that quest", "not high enough level"). diff --git a/crates/benilla-protocol/src/events/decode.rs b/crates/benilla-protocol/src/events/decode.rs index 8e15e475..2222f88e 100644 --- a/crates/benilla-protocol/src/events/decode.rs +++ b/crates/benilla-protocol/src/events/decode.rs @@ -347,6 +347,11 @@ pub fn decode(packet: ServerPacket) -> Vec { timed: true, }], ServerPacket::QuestLogFull => vec![SessionEvent::QuestLogFull], + ServerPacket::QuestPushResult(r) => vec![SessionEvent::QuestPushResult { + member: r.member, + msg: r.msg, + }], + ServerPacket::QuestConfirmAccept(c) => vec![SessionEvent::QuestConfirmAccept(c)], ServerPacket::QuestGiverInvalid { msg } => { vec![SessionEvent::QuestGiverInvalid { reason: msg }] } diff --git a/crates/benilla-protocol/src/messages/mod.rs b/crates/benilla-protocol/src/messages/mod.rs index 443d79c4..39482a09 100644 --- a/crates/benilla-protocol/src/messages/mod.rs +++ b/crates/benilla-protocol/src/messages/mod.rs @@ -188,12 +188,13 @@ pub use progression::{ }; pub use pvp::{inspect_honor_stats, InspectHonorStats, PvpCredit}; pub use quest::{ - dialog_status, quest_query, questgiver_accept_quest, questgiver_choose_reward, - questgiver_complete_quest, questgiver_hello, questgiver_query_quest, questgiver_request_reward, - questgiver_status_query, questlog_remove_quest, questlog_swap_quest, QuestComplete, - QuestDetails, QuestGiverList, QuestListEntry, QuestObjective, QuestOfferReward, - QuestRequestItems, QuestRequiredItem, QuestRewardItem, QuestTemplate, QUEST_EMOTE_COUNT, - QUEST_OBJECTIVES_COUNT, QUEST_REWARDS_COUNT, QUEST_REWARD_CHOICES_COUNT, + dialog_status, push_quest_to_party, quest_confirm_accept, quest_flags, quest_push_result, + quest_query, questgiver_accept_quest, questgiver_choose_reward, questgiver_complete_quest, + questgiver_hello, questgiver_query_quest, questgiver_request_reward, questgiver_status_query, + questlog_remove_quest, questlog_swap_quest, QuestComplete, QuestConfirmAccept, QuestDetails, + QuestGiverList, QuestListEntry, QuestObjective, QuestOfferReward, QuestPushResult, + QuestRequestItems, QuestRequiredItem, QuestRewardItem, QuestShareMsg, QuestTemplate, + QUEST_EMOTE_COUNT, QUEST_OBJECTIVES_COUNT, QUEST_REWARDS_COUNT, QUEST_REWARD_CHOICES_COUNT, }; pub use reputation::{ set_faction_at_war, set_faction_inactive, set_watched_faction, WATCHED_FACTION_NONE, diff --git a/crates/benilla-protocol/src/messages/opcode.rs b/crates/benilla-protocol/src/messages/opcode.rs index 78bbf8a0..9ac56dc8 100644 --- a/crates/benilla-protocol/src/messages/opcode.rs +++ b/crates/benilla-protocol/src/messages/opcode.rs @@ -689,6 +689,15 @@ pub const SMSG_QUESTUPDATE_COMPLETE: u16 = 0x0198; // 408 pub const SMSG_QUESTUPDATE_ADD_KILL: u16 = 0x0199; // 409 pub const SMSG_QUESTUPDATE_ADD_ITEM: u16 = 0x019A; // 410 +// The party quest-**share** set (VERIFIED vmangos `Opcodes_1_12_1.h`: 411-413 + 630) — decision +// 1733. `MSG_QUEST_PUSH_RESULT` is the one bidirectional opcode of the quest family: the receiver +// sends its own verdict up, the server relays every verdict (its own and the receiver's) back down +// to the SHARER. Bodies in [`super::quest`]'s `share` submodule. +pub const CMSG_QUEST_CONFIRM_ACCEPT: u16 = 0x019B; // 411 +pub const SMSG_QUEST_CONFIRM_ACCEPT: u16 = 0x019C; // 412 +pub const CMSG_PUSHQUESTTOPARTY: u16 = 0x019D; // 413 +pub const MSG_QUEST_PUSH_RESULT: u16 = 0x0276; // 630 + pub const CMSG_LIST_INVENTORY: u16 = 0x019E; // 414 pub const SMSG_LIST_INVENTORY: u16 = 0x019F; // 415 pub const CMSG_SELL_ITEM: u16 = 0x01A0; // 416 diff --git a/crates/benilla-protocol/src/messages/packet.rs b/crates/benilla-protocol/src/messages/packet.rs index 9135576f..138a1f42 100644 --- a/crates/benilla-protocol/src/messages/packet.rs +++ b/crates/benilla-protocol/src/messages/packet.rs @@ -14,12 +14,13 @@ use super::{ LevelUpInfo, LootAllPassed, LootItem, LootRoll, LootRollWon, LootStartRoll, MailListEntry, MirrorTimerStart, MoveMode, Object, PartyKillLog, PartyMemberStatsInfo, PeriodicAuraLog, PetMode, PetSpells, PetitionQueryResponse, PetitionRename, PetitionShowList, - PetitionShowSignatures, PetitionSignResults, PvpCredit, QuestComplete, QuestDetails, - QuestGiverList, QuestOfferReward, QuestOption, QuestRequestItems, QuestTemplate, - ResurrectRequestBody, SpeedKind, SpellChainTargets, SpellCooldown, SpellDamageLog, - SpellDispelLog, SpellEnergizeLog, SpellGo, SpellHealLog, SpellInstaKillLog, SpellLogExecute, - SpellLogMiss, SpellOutcomeLog, SpellStart, StabledPet, TaxiMask, TradeStatus, - TradeStatusExtended, TrainerSpell, TransportPose, VendorItem, WhoResults, XpGain, + PetitionShowSignatures, PetitionSignResults, PvpCredit, QuestComplete, QuestConfirmAccept, + QuestDetails, QuestGiverList, QuestOfferReward, QuestOption, QuestPushResult, + QuestRequestItems, QuestTemplate, ResurrectRequestBody, SpeedKind, SpellChainTargets, + SpellCooldown, SpellDamageLog, SpellDispelLog, SpellEnergizeLog, SpellGo, SpellHealLog, + SpellInstaKillLog, SpellLogExecute, SpellLogMiss, SpellOutcomeLog, SpellStart, StabledPet, + TaxiMask, TradeStatus, TradeStatusExtended, TrainerSpell, TransportPose, VendorItem, + WhoResults, XpGain, }; /// The **final facing** a `SMSG_MONSTER_MOVE` dictates (its `moveType`): the unit snaps to face this @@ -747,6 +748,14 @@ pub enum ServerPacket { /// `SMSG_QUESTLOG_FULL` — the log has no free slot for a new quest; empty body (vmangos /// `Quest.cpp:87`). QuestLogFull, + /// `MSG_QUEST_PUSH_RESULT` — one party member's verdict on a quest we shared, relayed to the + /// SHARER (layout + the direction trap on [`QuestPushResult`], decision 1733). Arrives once per + /// member per push, and more than once per member: the server opens with `SHARING_QUEST` and + /// then sends the real outcome. + QuestPushResult(QuestPushResult), + /// `SMSG_QUEST_CONFIRM_ACCEPT` — a party member started a `QUEST_FLAGS_PARTY_ACCEPT` (escort) + /// quest and we are being asked whether to start it too (layout in [`QuestConfirmAccept`]). + QuestConfirmAccept(QuestConfirmAccept), /// `SMSG_QUESTUPDATE_COMPLETE` — every objective on this quest is now complete (vmangos /// `Quest.cpp:91`); the log slot's state byte gets `QUEST_STATE_COMPLETE`. QuestUpdateComplete { @@ -1516,6 +1525,8 @@ impl ServerPacket { ServerPacket::QuestGiverFailed { .. } => "SMSG_QUESTGIVER_QUEST_FAILED".into(), ServerPacket::QuestQueryResponse(_) => "SMSG_QUEST_QUERY_RESPONSE".into(), ServerPacket::QuestLogFull => "SMSG_QUESTLOG_FULL".into(), + ServerPacket::QuestPushResult(_) => "MSG_QUEST_PUSH_RESULT".into(), + ServerPacket::QuestConfirmAccept(_) => "SMSG_QUEST_CONFIRM_ACCEPT".into(), ServerPacket::QuestUpdateComplete { .. } => "SMSG_QUESTUPDATE_COMPLETE".into(), ServerPacket::QuestUpdateFailed { .. } => "SMSG_QUESTUPDATE_FAILED".into(), ServerPacket::QuestUpdateFailedTimer { .. } => "SMSG_QUESTUPDATE_FAILEDTIMER".into(), diff --git a/crates/benilla-protocol/src/messages/parse.rs b/crates/benilla-protocol/src/messages/parse.rs index e9cc2513..8446a9d4 100644 --- a/crates/benilla-protocol/src/messages/parse.rs +++ b/crates/benilla-protocol/src/messages/parse.rs @@ -665,6 +665,12 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result { ServerPacket::QuestQueryResponse(Box::new(quest::read_quest_query_response(&mut r)?)) } opcode::SMSG_QUESTLOG_FULL => ServerPacket::QuestLogFull, + opcode::MSG_QUEST_PUSH_RESULT => { + ServerPacket::QuestPushResult(quest::read_quest_push_result(&mut r)?) + } + opcode::SMSG_QUEST_CONFIRM_ACCEPT => { + ServerPacket::QuestConfirmAccept(quest::read_quest_confirm_accept(&mut r)?) + } opcode::SMSG_QUESTUPDATE_COMPLETE => ServerPacket::QuestUpdateComplete { quest_id: quest::read_quest_update_complete(&mut r)?, }, diff --git a/crates/benilla-protocol/src/messages/quest/log.rs b/crates/benilla-protocol/src/messages/quest/log.rs index 008e6c4e..3f5753dc 100644 --- a/crates/benilla-protocol/src/messages/quest/log.rs +++ b/crates/benilla-protocol/src/messages/quest/log.rs @@ -50,6 +50,38 @@ pub struct QuestObjective { pub text: String, } +/// `QuestFlags` (vmangos `QuestDef.h:145-160`) — the [`QuestTemplate::flags`] word, which is the +/// only place the client learns a quest's *kind* (the giver panels carry text and rewards, never +/// flags). Two of these are load-bearing for the party share (decision 1733) and the rest are +/// pinned beside them so the word is documented once: +/// +/// - [`PARTY_ACCEPT`] — an escort quest. When a party member accepts one, every other eligible +/// member gets `SMSG_QUEST_CONFIRM_ACCEPT`. +/// - [`SHARABLE`] — the quest log's *Share Quest* button. **The client owns this test**: the +/// server's `HandlePushQuestToParty` does not check the bit at all, it only re-tests it when the +/// receiver accepts (`Player::CanShareQuest`, `Player.cpp:13959`). +/// - [`HIDDEN_REWARDS`] documents a wire shape this parser already survives (see the module's +/// uniform-rewards note): the rewards block is written as three zero `u32`s rather than omitted. +pub mod quest_flags { + /// Not used by 1.12 data. + pub const STAY_ALIVE: u32 = 0x0000_0001; + /// Escort: accepting it asks the rest of the party whether they want it too. + pub const PARTY_ACCEPT: u32 = 0x0000_0002; + /// Not used by 1.12 data. + pub const EXPLORATION: u32 = 0x0000_0004; + /// The quest may be shared with the party. + pub const SHARABLE: u32 = 0x0000_0008; + /// Not used by 1.12 data. + pub const EPIC: u32 = 0x0000_0020; + /// Not used by 1.12 data. + pub const RAID: u32 = 0x0000_0040; + /// Rewards ship only in `SMSG_QUESTGIVER_OFFER_REWARD` — the DETAILS panel and this template + /// write an empty rewards block instead. + pub const HIDDEN_REWARDS: u32 = 0x0000_0200; + /// Granted and completed in one step; never appears in the client's quest log. + pub const AUTO_REWARDED: u32 = 0x0000_0400; +} + /// `SMSG_QUEST_QUERY_RESPONSE` (vmangos `Quest.cpp:393-528`, the `>1_9_4` branch — live on 5875): /// the full quest template, answering `CMSG_QUEST_QUERY` and feeding the quest log's detail view. /// Unlike the giver panels this carries **fixed-count** reward/choice/objective arrays (no count diff --git a/crates/benilla-protocol/src/messages/quest/mod.rs b/crates/benilla-protocol/src/messages/quest/mod.rs index 203d9b58..f9056d8e 100644 --- a/crates/benilla-protocol/src/messages/quest/mod.rs +++ b/crates/benilla-protocol/src/messages/quest/mod.rs @@ -4,12 +4,16 @@ //! `SMSG_QUEST_QUERY_RESPONSE` (92/93), `CMSG_QUESTLOG_SWAP_QUEST`/`CMSG_QUESTLOG_REMOVE_QUEST`/ //! `SMSG_QUESTLOG_FULL` (403-405), and the `SMSG_QUESTUPDATE_*` progress pushes (406-410). CMSG //! bodies from vmangos `Server/Packets/Quest.cpp:6-78`; SMSG layouts from the same file's -//! `AppendBodyTo` writers (line citations inline in each submodule). Out of scope for both slices: +//! `AppendBodyTo` writers (line citations inline in each submodule). The third slice — `share`, //! `CMSG_QUEST_CONFIRM_ACCEPT` / `SMSG_QUEST_CONFIRM_ACCEPT` / `CMSG_PUSHQUESTTOPARTY` / -//! `MSG_QUEST_PUSH_RESULT` — the party quest-share flow, a separate solo slice. +//! `MSG_QUEST_PUSH_RESULT` (411-413 + 630) — is the party quest-share flow (decision 1733). It is +//! its own file and not `giver`'s, even though a shared quest lands on the giver's own DETAILS +//! panel: what is new here is the *verdict* wire between sharer and receiver, and `giver` never +//! learns that a share exists — it only ever sees a giver guid that happens to be a player. mod giver; mod log; +mod share; pub use giver::{ dialog_status, questgiver_accept_quest, questgiver_choose_reward, questgiver_complete_quest, @@ -18,8 +22,12 @@ pub use giver::{ QuestRequestItems, QuestRequiredItem, QuestRewardItem, QUEST_EMOTE_COUNT, }; pub use log::{ - quest_query, questlog_remove_quest, questlog_swap_quest, QuestObjective, QuestTemplate, - QUEST_OBJECTIVES_COUNT, QUEST_REWARDS_COUNT, QUEST_REWARD_CHOICES_COUNT, + quest_flags, quest_query, questlog_remove_quest, questlog_swap_quest, QuestObjective, + QuestTemplate, QUEST_OBJECTIVES_COUNT, QUEST_REWARDS_COUNT, QUEST_REWARD_CHOICES_COUNT, +}; +pub use share::{ + push_quest_to_party, quest_confirm_accept, quest_push_result, QuestConfirmAccept, + QuestPushResult, QuestShareMsg, }; pub(super) use giver::{ @@ -31,3 +39,4 @@ pub(super) use log::{ read_quest_query_response, read_quest_update_add_item, read_quest_update_add_kill, read_quest_update_complete, read_quest_update_failed, read_quest_update_failedtimer, }; +pub(super) use share::{read_quest_confirm_accept, read_quest_push_result}; diff --git a/crates/benilla-protocol/src/messages/quest/share.rs b/crates/benilla-protocol/src/messages/quest/share.rs new file mode 100644 index 00000000..f2e07e7b --- /dev/null +++ b/crates/benilla-protocol/src/messages/quest/share.rs @@ -0,0 +1,199 @@ +//! The **party quest-share** wire (opcodes 411-413 + 630, VERIFIED vmangos `Opcodes_1_12_1.h`) — +//! decision 1733, the third slice of the quest arc after [`super::giver`] and [`super::log`]. +//! +//! Two independent flows share this file because they share the same server-side latch (vmangos +//! `Player::SetQuestShareInfo`/`ClearQuestShareInfo`, one `{sharerGuid, questId}` pair per player): +//! +//! 1. **The push** — the quest log's *Share Quest* button. `CMSG_PUSHQUESTTOPARTY{questId}` → the server +//! walks the group and, per member, sends the SHARER a `MSG_QUEST_PUSH_RESULT` +//! verdict; an eligible member additionally gets an ordinary `SMSG_QUESTGIVER_QUEST_DETAILS` +//! whose `npcGuid` is the **sharer's player guid** (vmangos `QuestHandler.cpp:403-459`). That is +//! the whole reason the receiver needs no new inbound opcode: a shared quest arrives on the +//! detail panel already built, and Accept answers it with the ordinary +//! `CMSG_QUESTGIVER_ACCEPT_QUEST` addressed to that player guid (the server's own +//! `TYPEID_PLAYER` arm, `QuestHandler.cpp:111-114`, gated on `Player::CanShareQuest`). +//! A **decline** is the one verdict the client itself originates: `MSG_QUEST_PUSH_RESULT` back +//! up, which the server relays to the sharer with the *receiver's* guid substituted +//! (`QuestHandler.cpp:461-478`). +//! 2. **The escort confirm** — `QUEST_FLAGS_PARTY_ACCEPT` (0x2). When a party member accepts such +//! a quest, every other eligible member gets `SMSG_QUEST_CONFIRM_ACCEPT` and answers with +//! `CMSG_QUEST_CONFIRM_ACCEPT` if they say yes (vmangos `QuestHandler.cpp:172-193`, +//! `Player.cpp:14571-14594`). Saying *no* sends nothing at all — the server's latch is cleared +//! by the next thing that touches it. +//! +//! **Who the guid names flips direction on `MSG_QUEST_PUSH_RESULT`, and that is the one trap here.** +//! The opcode is `MSG_*` — the same number both ways — but the `u64` never means the same thing +//! twice: going *up* it is the sharer the receiver is answering; coming *down* it is the OTHER +//! party (`Player::SendPushToPartyResponse` writes `pPlayer`'s guid, the member the verdict is +//! *about*, `Player.cpp:14596-14608`), so the sharer's `%s` fills from it directly. + +use std::io; + +use crate::wire::{read_cstring, read_u32_le, read_u64_le, read_u8}; + +/// `QuestShareMessages` (vmangos `QuestDef.h:62-70`) — the `u8` verdict `MSG_QUEST_PUSH_RESULT` +/// carries in both directions. The server originates 0/1/4/5/6/7/8 while walking the group, and 2 +/// when the receiver accepts; the client originates 3 when the receiver declines. +/// +/// Kept as a plain `u8` newtype rather than an enum with a `TryFrom` because the *display* mapping +/// (which `ERR_QUEST_PUSH_*` message id each value selects, and on which surface) belongs to the +/// app layer's message table, not to the wire — and an unknown value must survive the parse the way +/// every other unmapped code in this crate does, not become a parse error that drops the packet. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct QuestShareMsg(pub u8); + +impl QuestShareMsg { + /// `QUEST_PARTY_MSG_SHARING_QUEST` — the server's opening "the push went out to this member". + pub const SHARING_QUEST: Self = Self(0); + /// `QUEST_PARTY_MSG_CANT_TAKE_QUEST` — the member fails `CanTakeQuest` (level, race, prereqs). + pub const CANT_TAKE_QUEST: Self = Self(1); + /// `QUEST_PARTY_MSG_ACCEPT_QUEST` — the member accepted (server-originated, on their + /// `CMSG_QUESTGIVER_ACCEPT_QUEST`). + pub const ACCEPT_QUEST: Self = Self(2); + /// `QUEST_PARTY_MSG_DECLINE_QUEST` — **the one value the client sends**. + pub const DECLINE_QUEST: Self = Self(3); + /// `QUEST_PARTY_MSG_TOO_FAR` — outside `QUEST_SHARE_DISTANCE` (14.0 yd, vmangos `Object.h:72`). + pub const TOO_FAR: Self = Self(4); + /// `QUEST_PARTY_MSG_BUSY` — the member already has a share latched. + pub const BUSY: Self = Self(5); + /// `QUEST_PARTY_MSG_LOG_FULL` — the member's quest log is full. + pub const LOG_FULL: Self = Self(6); + /// `QUEST_PARTY_MSG_HAVE_QUEST` — the member is already on the quest. + pub const HAVE_QUEST: Self = Self(7); + /// `QUEST_PARTY_MSG_FINISH_QUEST` — the member has already completed the quest. + pub const FINISH_QUEST: Self = Self(8); +} + +/// `MSG_QUEST_PUSH_RESULT` as the **server** sends it (vmangos +/// `Server/Packets/Quest.cpp:81-85`): `u64 guid, u8 msg`. +/// +/// `guid` is the party member the verdict is *about* — never the sharer, and never us (see the +/// module header's direction trap). It is what fills the `%s` of every `ERR_QUEST_PUSH_*` string. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct QuestPushResult { + /// The party member the verdict concerns. + pub member: u64, + /// The verdict. + pub msg: QuestShareMsg, +} + +/// `SMSG_QUEST_CONFIRM_ACCEPT` (vmangos `Server/Packets/Quest.cpp:131-136`): `u32 questId, cstr +/// questTitle, u64 senderGuid` — the `QUEST_FLAGS_PARTY_ACCEPT` (escort) confirm box. +/// +/// The **title travels on the wire** rather than being looked up from the client's template cache, +/// because the server localizes it per receiver session (`Player::SendQuestConfirmAccept`, +/// `Player.cpp:14575-14586`) — and because the receiver may never have queried this quest at all. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct QuestConfirmAccept { + /// The quest being started — echoed back verbatim in [`quest_confirm_accept`]. + pub quest_id: u32, + /// The quest title, already localized by the server. + pub title: String, + /// The party member who accepted it — the first `%s` of `QUEST_ACCEPT`. + pub sender: u64, +} + +// ── CMSG encoders ───────────────────────────────────────────────────────────────────────────────── + +/// Body of `CMSG_PUSHQUESTTOPARTY` (vmangos `Quest.cpp:68-71`): one `u32` quest id. The quest log's +/// *Share Quest* button; the server addresses the group itself, so no member guid rides along. +pub fn push_quest_to_party(quest_id: u32) -> Vec { + quest_id.to_le_bytes().to_vec() +} + +/// Body of `CMSG_QUEST_CONFIRM_ACCEPT` (vmangos `Quest.cpp:63-66`): one `u32` quest id — the +/// escort confirm's Yes. +pub fn quest_confirm_accept(quest_id: u32) -> Vec { + quest_id.to_le_bytes().to_vec() +} + +/// Body of `MSG_QUEST_PUSH_RESULT` as the **client** sends it (vmangos `Quest.cpp:73-77`): `u64 +/// guid, u8 msg`. `guid` is the *sharer* we are answering — the `npcGuid` the shared +/// `SMSG_QUESTGIVER_QUEST_DETAILS` arrived under. +/// +/// vmangos ignores the guid entirely (it re-derives the sharer from its own latch, +/// `QuestHandler.cpp:461-467`), so a wrong value here would go unnoticed against this server. It is +/// sent correctly anyway: the field is the protocol's, not the server's. +pub fn quest_push_result(sharer: u64, msg: QuestShareMsg) -> Vec { + let mut b = Vec::with_capacity(9); + b.extend_from_slice(&sharer.to_le_bytes()); + b.push(msg.0); + b +} + +// ── SMSG readers ────────────────────────────────────────────────────────────────────────────────── + +/// Read `MSG_QUEST_PUSH_RESULT` (see [`QuestPushResult`]). +pub(in crate::messages) fn read_quest_push_result(r: &mut &[u8]) -> io::Result { + Ok(QuestPushResult { + member: read_u64_le(r)?, + msg: QuestShareMsg(read_u8(r)?), + }) +} + +/// Read `SMSG_QUEST_CONFIRM_ACCEPT` (see [`QuestConfirmAccept`]). +pub(in crate::messages) fn read_quest_confirm_accept( + r: &mut &[u8], +) -> io::Result { + Ok(QuestConfirmAccept { + quest_id: read_u32_le(r)?, + title: read_cstring(r)?, + sender: read_u64_le(r)?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn push_and_confirm_bodies_are_bare_quest_ids() { + assert_eq!( + push_quest_to_party(0x0123_4567), + vec![0x67, 0x45, 0x23, 0x01] + ); + assert_eq!(quest_confirm_accept(9), vec![9, 0, 0, 0]); + } + + #[test] + fn push_result_body_is_guid_then_msg() { + let b = quest_push_result(0x0000_0000_0000_002A, QuestShareMsg::DECLINE_QUEST); + assert_eq!(b, vec![0x2A, 0, 0, 0, 0, 0, 0, 0, 3]); + } + + #[test] + fn push_result_reads_member_then_msg() { + // The server's `AppendBodyTo`: guid first, verdict byte last. + let mut b = 0x0000_0000_0000_00AAu64.to_le_bytes().to_vec(); + b.push(8); + let r = read_quest_push_result(&mut b.as_slice()).unwrap(); + assert_eq!( + r, + QuestPushResult { + member: 0xAA, + msg: QuestShareMsg::FINISH_QUEST, + } + ); + } + + /// An unmapped verdict byte must survive the parse — the display table decides what (if + /// anything) to show, exactly as every other unmapped wire code in this crate is handled. + #[test] + fn unknown_verdict_byte_parses() { + let mut b = 1u64.to_le_bytes().to_vec(); + b.push(0x7F); + let r = read_quest_push_result(&mut b.as_slice()).unwrap(); + assert_eq!(r.msg, QuestShareMsg(0x7F)); + } + + #[test] + fn confirm_accept_reads_id_title_sender() { + let mut b = 1234u32.to_le_bytes().to_vec(); + b.extend_from_slice(b"Escort Duty\0"); + b.extend_from_slice(&0xF130_0000_0000_0001u64.to_le_bytes()); + let c = read_quest_confirm_accept(&mut b.as_slice()).unwrap(); + assert_eq!(c.quest_id, 1234); + assert_eq!(c.title, "Escort Duty"); + assert_eq!(c.sender, 0xF130_0000_0000_0001); + } +} diff --git a/crates/benilla-protocol/src/world/writer/quest.rs b/crates/benilla-protocol/src/world/writer/quest.rs index 523f6fb1..03befbaa 100644 --- a/crates/benilla-protocol/src/world/writer/quest.rs +++ b/crates/benilla-protocol/src/world/writer/quest.rs @@ -16,6 +16,17 @@ use crate::messages::{self, opcode}; use super::WorldWriter; impl WorldWriter { + /// Greet a questgiver (`CMSG_QUESTGIVER_HELLO`, layout in [`messages::questgiver_hello`]) — + /// answered with `SMSG_QUESTGIVER_QUEST_LIST` (or the gossip menu, server's choice). Sent by + /// the *end* of a quest session on a non-gossip NPC, which is how the reference puts the + /// player back in the giver's list after a decline (decision 1738). + pub fn questgiver_hello(&mut self, npc: u64) -> Result<()> { + self.send( + opcode::CMSG_QUESTGIVER_HELLO, + &messages::questgiver_hello(npc), + ) + } + /// Ask a quest's detail panel (`CMSG_QUESTGIVER_QUERY_QUEST`, layout in /// [`messages::questgiver_query_quest`]) — the click a greeting/gossip quest row makes. /// Answered by `SMSG_QUESTGIVER_QUEST_DETAILS` (a `QuestDetail` event). @@ -94,4 +105,38 @@ impl WorldWriter { &messages::questlog_remove_quest(slot), ) } + + /// Share the quest with the party (`CMSG_PUSHQUESTTOPARTY`, layout in + /// [`messages::push_quest_to_party`]) — the quest log's *Share Quest* button (decision 1733). + /// No member guid: the server walks the group itself and answers with one + /// `MSG_QUEST_PUSH_RESULT` per member (often two — the `SHARING_QUEST` opener, then the + /// outcome). + pub fn push_quest_to_party(&mut self, quest_id: u32) -> Result<()> { + self.send( + opcode::CMSG_PUSHQUESTTOPARTY, + &messages::push_quest_to_party(quest_id), + ) + } + + /// Answer the escort confirm with Yes (`CMSG_QUEST_CONFIRM_ACCEPT`, layout in + /// [`messages::quest_confirm_accept`]) — a `QUEST_FLAGS_PARTY_ACCEPT` quest a party member + /// started. Saying **No** sends nothing at all, so there is no counterpart method. + pub fn quest_confirm_accept(&mut self, quest_id: u32) -> Result<()> { + self.send( + opcode::CMSG_QUEST_CONFIRM_ACCEPT, + &messages::quest_confirm_accept(quest_id), + ) + } + + /// Report our verdict on a quest a party member shared with us (`MSG_QUEST_PUSH_RESULT`, + /// layout in [`messages::quest_push_result`]) — the receiver's leg of the share, relayed by the + /// server to the sharer. `sharer` is the giver guid the shared + /// `SMSG_QUESTGIVER_QUEST_DETAILS` arrived under (a *player* guid, which is what makes that + /// panel a share). + pub fn quest_push_result(&mut self, sharer: u64, msg: messages::QuestShareMsg) -> Result<()> { + self.send( + opcode::MSG_QUEST_PUSH_RESULT, + &messages::quest_push_result(sharer, msg), + ) + } } diff --git a/crates/benilla-protocol/tests/quest_share.rs b/crates/benilla-protocol/tests/quest_share.rs new file mode 100644 index 00000000..032904cd --- /dev/null +++ b/crates/benilla-protocol/tests/quest_share.rs @@ -0,0 +1,112 @@ +//! Golden tests for the **party quest-share** opcode family (decision 1733) — the quest log's +//! *Share Quest* push, the escort confirm, and the two-way verdict relay. CMSG bodies are asserted +//! byte-exact against the builder output; SMSG bodies are hand-built per the vmangos layouts (cited +//! inline) and round-tripped through `parse_server` + `decode`. See `tests/common` for the shared +//! `hx()` fixture helper and methodology note. +//! +//! The property worth a test file of its own is the **direction flip** on `MSG_QUEST_PUSH_RESULT`: +//! one opcode number, two meanings for its `u64`. Going up it is the sharer we are answering; +//! coming down it is the party member the verdict is *about*. Nothing in the type system can catch +//! a swap, so it is pinned here from both ends. + +mod common; + +use benilla_protocol::events::{decode, SessionEvent}; +use benilla_protocol::messages::{self, opcode, quest_flags, QuestShareMsg}; +use benilla_protocol::ServerPacket; +use common::hx; + +/// The three CMSG builders, byte-exact (vmangos `Server/Packets/Quest.cpp:63-77`). +#[test] +fn cmsg_bodies_golden() { + // CMSG_PUSHQUESTTOPARTY / CMSG_QUEST_CONFIRM_ACCEPT: one u32 quest id, nothing else — the + // server addresses the group itself, so no member guid rides along. + assert_eq!( + messages::push_quest_to_party(0x1234), + hx("34120000"), + "CMSG_PUSHQUESTTOPARTY body" + ); + assert_eq!( + messages::quest_confirm_accept(0x1234), + hx("34120000"), + "CMSG_QUEST_CONFIRM_ACCEPT body" + ); + + // MSG_QUEST_PUSH_RESULT going UP: u64 guid, then the u8 verdict. The guid is the SHARER we are + // answering (`Quest.cpp:73-77`). + assert_eq!( + messages::quest_push_result(0x1234_5678_9abc_def0, QuestShareMsg::DECLINE_QUEST), + hx("f0debc9a7856341203"), + "MSG_QUEST_PUSH_RESULT (client) body" + ); +} + +/// `MSG_QUEST_PUSH_RESULT` coming DOWN (`Quest.cpp:81-85`, written by +/// `Player::SendPushToPartyResponse`): the same two fields, but the guid is now the party MEMBER +/// the verdict concerns — never the sharer, and never us. +#[test] +fn push_result_decodes_member_and_verdict() { + let body = hx("f0debc9a7856341200"); + let p = messages::parse_server(opcode::MSG_QUEST_PUSH_RESULT, &body).unwrap(); + match &p { + ServerPacket::QuestPushResult(r) => { + assert_eq!(r.member, 0x1234_5678_9abc_def0); + assert_eq!(r.msg, QuestShareMsg::SHARING_QUEST); + } + other => panic!("expected QuestPushResult, got {}", other.name()), + } + match decode(p).as_slice() { + [SessionEvent::QuestPushResult { member, msg }] => { + assert_eq!(*member, 0x1234_5678_9abc_def0); + assert_eq!(*msg, QuestShareMsg::SHARING_QUEST); + } + other => panic!("push result decoded to {} events", other.len()), + } +} + +/// The whole verdict enum survives the round trip, including a value the client has no message +/// for: an unmapped byte must reach the app as data, not kill the packet (the display table is +/// where "show nothing" is decided). +#[test] +fn every_verdict_byte_round_trips() { + for raw in [0u8, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0xFF] { + let mut body = 1u64.to_le_bytes().to_vec(); + body.push(raw); + match messages::parse_server(opcode::MSG_QUEST_PUSH_RESULT, &body).unwrap() { + ServerPacket::QuestPushResult(r) => { + assert_eq!(r.msg, QuestShareMsg(raw), "verdict {raw}"); + assert_eq!(r.member, 1); + } + other => panic!("verdict {raw} parsed as {}", other.name()), + } + } +} + +/// `SMSG_QUEST_CONFIRM_ACCEPT` (`Quest.cpp:131-136`): `u32 questId, cstr questTitle, u64 +/// senderGuid` — note the title sits BETWEEN the two numbers, so a reader that grouped the +/// scalars would decode the guid out of the title's tail. +#[test] +fn confirm_accept_decodes_id_title_sender() { + let mut body = 1234u32.to_le_bytes().to_vec(); + body.extend_from_slice(b"Escort Duty\0"); + body.extend_from_slice(&0x0000_0000_0000_002Au64.to_le_bytes()); + + let p = messages::parse_server(opcode::SMSG_QUEST_CONFIRM_ACCEPT, &body).unwrap(); + match decode(p).as_slice() { + [SessionEvent::QuestConfirmAccept(c)] => { + assert_eq!(c.quest_id, 1234); + assert_eq!(c.title, "Escort Duty"); + assert_eq!(c.sender, 0x2A); + } + other => panic!("confirm accept decoded to {} events", other.len()), + } +} + +/// The two flag bits the share flow reads, pinned against vmangos `QuestDef.h:145-160`. They are +/// the only route by which the client learns a quest is shareable or an escort — the giver panels +/// carry no flags at all. +#[test] +fn share_quest_flags_are_the_documented_bits() { + assert_eq!(quest_flags::PARTY_ACCEPT, 0x2); + assert_eq!(quest_flags::SHARABLE, 0x8); +} diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 6bc50456..ede1e80f 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -1082,6 +1082,15 @@ pub(crate) struct Model { pub(crate) quest_log_selection: u32, pub(crate) quest_log_abandon_mark: u32, pub(crate) quest_log_abandons: Vec, + /// Quest ids `QuestLogPushQuest()` queued for the app to push to the party (decision 1733). + /// **Ids, not entry indices** — unlike the abandon mark above, whose two-step confirm is the + /// whole reason it pins an index: a push is one click, so the id is resolved right then and a + /// log shuffle between click and drain cannot retarget it. + pub(crate) quest_log_pushes: Vec, + /// How many times `ConfirmAcceptQuest()` was called — the escort confirm's Yes. A counter, not + /// a quest id: the reference's verb takes no argument and answers whatever confirm the client + /// is holding, so the id lives app-side with the pending confirm (decision 1733). + pub(crate) quest_confirms: u32, /// The shared item-template store: `item id → full tooltip view` ([`item_stats`] module doc, /// decision 0274 P1). pub(crate) item_templates: HashMap, @@ -1669,6 +1678,8 @@ impl Model { quest_log_selection: 0, quest_log_abandon_mark: 0, quest_log_abandons: Vec::new(), + quest_log_pushes: Vec::new(), + quest_confirms: 0, item_templates: HashMap::new(), item_sets: HashMap::new(), item_set_asks: HashSet::new(), diff --git a/crates/benilla-ui/src/script/quest.rs b/crates/benilla-ui/src/script/quest.rs index d1dc1640..9ec9c300 100644 --- a/crates/benilla-ui/src/script/quest.rs +++ b/crates/benilla-ui/src/script/quest.rs @@ -137,7 +137,18 @@ pub enum QuestAction { /// Reward panel Complete → `CMSG_QUESTGIVER_CHOOSE_REWARD` with the chosen choice index (0 when /// the quest has no choice rewards). Reward(u32), - /// Decline / Cancel / window close → a local clear (vanilla's client-side close sends no packet). + /// **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, } @@ -337,13 +348,25 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { })?, ) } - // AcceptQuest → Accept; DeclineQuest / CloseQuest → Close (no packet, vanilla); CompleteQuest - // (the progress panel's Continue) → Continue (request-reward). + // 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)?; + // 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::().expect("model app_data"); + model.quest_confirms += 1; + Ok(()) + })?, + )?; + // GetQuestReward(choice) — the reward panel's Complete button: queue the chosen choice index // (Era passes the 1-based item id; the app maps it to the 0-based wire index). A quest with no // choice rewards passes 0/nil. @@ -571,4 +594,31 @@ mod tests { assert_eq!(s.eval::("return GetNumQuestRewards()").unwrap(), 0); assert!(s.eval::("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"); + } } diff --git a/crates/benilla-ui/src/script/quest_log.rs b/crates/benilla-ui/src/script/quest_log.rs index 72aab267..cff38c79 100644 --- a/crates/benilla-ui/src/script/quest_log.rs +++ b/crates/benilla-ui/src/script/quest_log.rs @@ -27,8 +27,13 @@ //! from its OnUpdate without the log snapshot changing under it. [`seconds_left`] carries the //! reference's byte-verified formula and the ways a row has no timer to show (decision 1154). //! -//! v1 scope (the deliberate `nil`/false stubs, each a later slice): party share -//! (`GetQuestLogPushable`/`IsUnitOnQuest`), reward spells (`GetQuestLogRewardSpell`), and zone +//! A fifth is the **share** (`GetQuestLogPushable`/`QuestLogPushQuest`, decision 1733): the +//! pushable bit rides each entry (from the app's template cache) and the click resolves the +//! selection to a quest id before it queues, so a log shuffle cannot retarget it. +//! +//! v1 scope (the deliberate `nil`/false stubs, each a later slice): the party quest-log tooltip +//! (`IsUnitOnQuest` — it needs the party members' own logs, which is a different wire), +//! reward spells (`GetQuestLogRewardSpell`), and zone //! **headers** ([`QuestLogEntryView::is_header`] is plumbed but the app pushes a flat list — //! headers need the QuestSort/AreaTable DBC join). @@ -55,6 +60,13 @@ pub struct QuestLogEntryView { pub tag: Option, /// A zone header row (the app synthesizes these from each quest's ZoneOrSort). pub is_header: bool, + /// Whether this quest may be SHARED with the party — `GetQuestLogPushable`'s answer for the + /// row (decision 1733). Computed app-side from the cached `SMSG_QUEST_QUERY_RESPONSE` + /// template's `QUEST_FLAGS_SHARABLE` (`0x8`), so a row whose template has not answered yet is + /// `false` and turns true when it lands — the reference's own shape, since it reads the same + /// cache. Never surfaced as a per-index Lua getter: the Era API asks only about the current + /// selection. + pub pushable: bool, /// A COLLAPSED header (`GetQuestLogTitle`'s isCollapsed; meaningless on quest rows). The app /// owns the collapse set and omits a collapsed header's quests from `entries` — the engine /// only reports the flag and drains the toggle intents ([`super::UiScript::take_quest_log_collapses`]). @@ -168,6 +180,19 @@ impl super::UiScript { std::mem::take(&mut self.model_mut().quest_log_abandons) } + /// Drain the share intents: the quest ids `QuestLogPushQuest()` queued, already resolved from + /// the selection at click time. The app sends one `CMSG_PUSHQUESTTOPARTY` per id. + pub fn take_quest_log_pushes(&mut self) -> Vec { + std::mem::take(&mut self.model_mut().quest_log_pushes) + } + + /// Drain the escort-confirm answers: how many times `ConfirmAcceptQuest()` was called. A + /// count, because the verb carries no quest id (decision 1733) — the app answers the confirm + /// it is holding. + pub fn take_quest_confirms(&mut self) -> u32 { + std::mem::take(&mut self.model_mut().quest_confirms) + } + /// Drain the header collapse/expand intents: `(1-based entry index, collapse)` from /// `CollapseQuestHeader`/`ExpandQuestHeader` — index `0` = ALL headers (the ref's /// collapse-all button passes 0, QuestLogFrame.lua:557/:561). The app owns the collapse @@ -629,10 +654,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { "GetQuestLogRewardSpell", lua.create_function(|_, ()| Ok(Value::Nil))?, )?; - g.set( - "GetQuestLogPushable", - lua.create_function(|_, ()| Ok(false))?, - )?; g.set( "IsUnitOnQuest", lua.create_function(|_, (_q, _unit): (Value, Value)| Ok(false))?, @@ -643,12 +664,61 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { // indices; set_quest_log prunes watches whose quest left the log. MAX_WATCHABLE_QUESTS = 5 // (ref QuestLogFrame.lua:494) — AddQuestWatch past the cap is a no-op (the Lua shows the // QUEST_WATCH_TOO_MANY error itself, mirroring the ref's guard order). + /// The entry the current selection points at — what every no-argument getter on this surface + /// reads (`GetQuestLogPushable`, and the C side of `QuestLogPushQuest`). `None` when nothing + /// is selected or the selection is out of range. + fn selected_quest(model: &Model) -> Option<&QuestLogEntryView> { + (model.quest_log_selection as usize) + .checked_sub(1) + .and_then(|n| model.quest_log.entries.get(n)) + } + fn watch_id_at(model: &Model, index: u32) -> Option { (index as usize) .checked_sub(1) .and_then(|n| model.quest_log.entries.get(n)) .map(|e| e.quest_id) } + // ── The party share (decision 1733) — the ref's `QuestFramePushQuestButton`, whose enable + // predicate is `GetQuestLogPushable() and GetNumPartyMembers() > 0` (QuestLogFrame.lua:299-305) + // and whose OnClick is `QuestLogPushQuest()` (QuestLogFrame.xml:511-513). Both speak about the + // CURRENT SELECTION and take no argument, exactly as the reference declares them. + // `GetQuestLogPushable` returns `1` or **nil**, never `false` (`0x4e12b0` tail-calls + // `0x6f3810`/`0x6f37f0`, decision 1738). Both are falsy to the `and` in the reference's own + // predicate, so the button reads the same either way — but an addon testing `== nil` would + // not, and this is the Era idiom the rest of this surface already speaks (`GetPartyMember`). + g.set( + "GetQuestLogPushable", + lua.create_function(|lua, ()| { + let model = lua.app_data_ref::().expect("model app_data"); + Ok(match selected_quest(&model).is_some_and(|e| e.pushable) { + true => Value::Integer(1), + false => Value::Nil, + }) + })?, + )?; + g.set( + "QuestLogPushQuest", + lua.create_function(|lua, ()| { + let mut model = lua.app_data_mut::().expect("model app_data"); + // The C verb re-tests everything the Lua predicate tests **and one thing it does not**: + // a party check of its own (`0x4e13a6`, decision 1738). The reference does not trust + // its own button — an addon or a macro can call this solo, and the send is refused + // here rather than reaching the server. Resolve the id HERE rather than queueing the + // index (see `Model::quest_log_pushes`); a HEADER row carries `quest_id` 0, so an + // unguarded resolve would queue a push for quest 0. + if model.party.members.is_empty() { + return Ok(()); + } + let id = selected_quest(&model) + .filter(|e| !e.is_header && e.pushable) + .map(|e| e.quest_id); + if let Some(id) = id { + model.quest_log_pushes.push(id); + } + Ok(()) + })?, + )?; g.set( "GetNumQuestWatches", lua.create_function(|lua, ()| { @@ -1067,4 +1137,134 @@ mod tests { assert_eq!(s.eval::("return GetNumQuestWatches()").unwrap(), 5); assert_eq!(s.quest_log_watched(), vec![1, 2, 3, 4, 5]); } + + /// `GetQuestLogPushable` answers about the SELECTION, and only about the selection — the Era + /// API has no per-index form (decision 1733). Selecting the other quest changes the answer. + #[test] + fn pushable_follows_the_selection() { + let mut s = UiScript::new().unwrap(); + let mut state = two_quests(); + state.entries[0].pushable = true; // 783 is sharable, 7 is not + s.set_quest_log(state); + + // `1` or nil, never true/false (`0x4e12b0`, decision 1738) — an addon testing `== nil` + // sees the reference's own shape. + assert_eq!( + s.eval::>("return GetQuestLogPushable()") + .unwrap(), + None, + "no selection yet" + ); + s.run("SelectQuestLogEntry(1)").unwrap(); + assert_eq!( + s.eval::>("return GetQuestLogPushable()") + .unwrap(), + Some(1) + ); + s.run("SelectQuestLogEntry(2)").unwrap(); + assert_eq!( + s.eval::>("return GetQuestLogPushable()") + .unwrap(), + None + ); + } + + /// `QuestLogPushQuest` queues the selected entry's **quest id**, resolved at click time — not + /// its index, so a log that reshuffles between the click and the app's drain cannot retarget + /// the push. (Contrast the abandon mark, which pins an index on purpose: its two-step confirm + /// is what makes the index the right thing to hold.) + /// A party of one other, so the C verb's own party check (below) passes. + fn in_a_party(s: &mut UiScript) { + s.set_party(crate::script::PartyState { + members: vec![crate::script::PartyMemberInfo { + name: "Mate".into(), + guid: 0x300, + }], + ..Default::default() + }); + } + + #[test] + fn push_queues_the_selected_quest_id_not_its_index() { + let mut s = UiScript::new().unwrap(); + let mut state = two_quests(); + state.entries[1].pushable = true; // quest id 7 + s.set_quest_log(state); + in_a_party(&mut s); + s.run("SelectQuestLogEntry(2)").unwrap(); + s.run("QuestLogPushQuest()").unwrap(); + assert_eq!(s.take_quest_log_pushes(), vec![7], "entry 2 is quest id 7"); + assert!(s.take_quest_log_pushes().is_empty(), "drained"); + } + + /// A push with nothing selected is silently nothing — the window's button is disabled in that + /// state, so this is only reachable from a macro or an addon, and the client has no quest id + /// to send. + #[test] + fn push_without_a_selection_queues_nothing() { + let mut s = UiScript::new().unwrap(); + s.set_quest_log(two_quests()); + in_a_party(&mut s); + s.run("QuestLogPushQuest()").unwrap(); + assert!(s.take_quest_log_pushes().is_empty()); + } + + /// **The C verb re-tests the party and the sharable bit itself** (`0x4e13a6`, decision 1738) — + /// the reference does not trust its own button, because a macro or an addon can call this with + /// the window shut. Each guard is moved alone, against an otherwise-valid push. + #[test] + fn push_refuses_solo_and_refuses_an_unsharable_quest() { + let sharable = || { + let mut state = two_quests(); + state.entries[0].pushable = true; // quest id 783 + state + }; + + // Solo: everything else is right and it still sends nothing. + let mut s = UiScript::new().unwrap(); + s.set_quest_log(sharable()); + s.run("SelectQuestLogEntry(1)").unwrap(); + s.run("QuestLogPushQuest()").unwrap(); + assert!(s.take_quest_log_pushes().is_empty(), "solo pushes nothing"); + + // In a party, the same call goes. + in_a_party(&mut s); + s.run("QuestLogPushQuest()").unwrap(); + assert_eq!(s.take_quest_log_pushes(), vec![783]); + + // In a party, on a quest without the sharable bit: nothing again. + s.run("SelectQuestLogEntry(2)").unwrap(); + s.run("QuestLogPushQuest()").unwrap(); + assert!( + s.take_quest_log_pushes().is_empty(), + "an unsharable quest pushes nothing even in a party" + ); + } + + /// A HEADER row carries `quest_id` 0, so pushing one must queue nothing at all — an unguarded + /// id resolve would send `CMSG_PUSHQUESTTOPARTY{0}`. The window's button is disabled on a + /// header, so only a macro or an addon can get here. + #[test] + fn push_on_a_header_row_queues_nothing() { + let mut s = UiScript::new().unwrap(); + in_a_party(&mut s); + let mut state = two_quests(); + state.entries.insert( + 0, + QuestLogEntryView { + quest_id: 0, + title: "Elwynn Forest".into(), + is_header: true, + pushable: true, // even if something wrongly marked it so + ..Default::default() + }, + ); + s.set_quest_log(state); + s.run("SelectQuestLogEntry(1)").unwrap(); + s.run("QuestLogPushQuest()").unwrap(); + assert!( + s.take_quest_log_pushes().is_empty(), + "a header is not a quest" + ); + } }