quest sharing is in, and declining at an NPC reopens its list
- quest sharing lands whole: four opcodes, the Share button live with the reference's own predicate (registering the one party event it does for the one reason it does — joining a party changes the answer without changing the log), nine verdict lines for the sharer, and the escort confirm as its own dialog whose No sends nothing at all, because there is no decline packet for it; the push result's u64 means two things by direction — the sharer being answered going up, the member the verdict is ABOUT coming down — and the verdict stays a raw byte so an unmapped value shows the reference's own nothing rather than killing the packet - the receiving half needs almost no code, and that is the design: the server sends an eligible member an ORDINARY quest-details packet whose giver guid is the sharing PLAYER, and Accept answers with the ordinary accept — a shared quest lands on the questgiver panel we already have, and the guid's own type bits drive the two real differences: declining a share owes the sharer a DECLINE verdict, and the panel is not held to the 5.56-yard NPC service range (the server's bound for a share is 14 yards, tested at accept) - the fold-back corrected three things days-old code had just shipped: DeclineQuest and CloseQuest are ONE routine in the reference — what varies is the giver, not which button you pressed; declining an NPC's quest RE-OPENS its list — the session end sends the giver a fresh hello, refuting our five-month-old claim that "vanilla's client-side decline sends no packet" (and the details packet's trailing u32 we had parsed and ignored is exactly what suppresses that re-open) — so declining at an NPC now returns you to its menu instead of closing the window; and BUSY is the client's OWN verdict — a share arriving while any panel is open is refused client-side, covering the mid-turn-in case the server cannot see Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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"))
|
||||
|
||||
@@ -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 @@
|
||||
<Scripts><OnClick>HideUIPanel(getglobal("QuestLogFrame"))</OnClick></Scripts>
|
||||
</Button>
|
||||
<!-- Share Quest (ref QuestFramePushQuestButton, QuestLogFrame.xml:499-521): 123×21 flush
|
||||
LEFT of Exit. Party quest-sharing is a named deferral (no QuestLogPushQuest wire yet),
|
||||
so this ships permanently DISABLED — exactly how the ref presents it solo (its lua
|
||||
disables it whenever the party can't receive the quest); the button existing is what
|
||||
fills the bottom row's middle slot the frame art reserves for it. -->
|
||||
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. -->
|
||||
<Button name="QuestLogPushQuestButton" inherits="BenillaQuestLogPanelButtonTemplate" text="Share Quest">
|
||||
<Size><AbsDimension x="123" y="21"/></Size>
|
||||
<Anchors><Anchor point="RIGHT" relativeTo="QuestLogExitButton" relativePoint="LEFT"/></Anchors>
|
||||
<Scripts><OnLoad>self:Disable()</OnLoad></Scripts>
|
||||
<Scripts>
|
||||
<OnLoad>self:Disable()</OnLoad>
|
||||
<OnClick>QuestLogPushQuest()</OnClick>
|
||||
<OnEnter>
|
||||
if GameTooltip_AddNewbieTip then
|
||||
GameTooltip_AddNewbieTip(SHARE_QUEST, 1.0, 1.0, 1.0, NEWBIE_TOOLTIP_SHAREQUEST, 1)
|
||||
end
|
||||
</OnEnter>
|
||||
<OnLeave>GameTooltip:Hide()</OnLeave>
|
||||
</Scripts>
|
||||
</Button>
|
||||
</Frames>
|
||||
<Scripts>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<Ui xmlns="http://www.blizzard.com/wow/ui/">
|
||||
<!--
|
||||
The escort-quest confirm (decision 1733): one StaticPopup entry on our shared engine
|
||||
(UiPanels.xml) plus its event driver. The patch-chain FrameXML is the SPECIFICATION —
|
||||
StaticPopup.lua l.727-737 (the QUEST_ACCEPT entry) and UIParent.lua l.93 + l.353-356 (the
|
||||
registration and the one-line arm) — and it fixes the dialog key, the field names and the
|
||||
one verb by name, so those are reproduced exactly.
|
||||
|
||||
WHY THIS IS ITS OWN FILE, next to QuestFrame.xml and QuestLogFrame.xml rather than inside
|
||||
either. It belongs to neither window. The confirm has no window at all — it is raised by
|
||||
`crate::ui_quest_share` off `SMSG_QUEST_CONFIRM_ACCEPT`, exactly as DuelFrame.xml's two
|
||||
dialogs are raised off the duel packets, and answering it sends a packet neither quest
|
||||
window knows about. Putting it in QuestFrame.xml would say the questgiver panel owns it
|
||||
(it does not — no panel is open when this arrives) and putting it in QuestLogFrame.xml
|
||||
would say the log window does (it does not either: the log is closed for most players when
|
||||
a party member starts an escort).
|
||||
|
||||
WHAT RAISES IT. `QUEST_FLAGS_PARTY_ACCEPT` (0x2) quests — escorts. When a party member
|
||||
accepts one, the server asks every other eligible member whether they want it too
|
||||
(vmangos `QuestHandler.cpp:172-193`). That is a DIFFERENT flow from the quest-log's *Share
|
||||
Quest* button, which needs no dialog at all: a pushed quest arrives on the ordinary
|
||||
questgiver detail panel (QuestFrame.xml) with the sharer's player guid where an NPC's
|
||||
would be, and Accept/Decline there are the whole answer.
|
||||
|
||||
THE ONE ASYMMETRY, and it is the reference's, not ours: Yes calls ConfirmAcceptQuest() and
|
||||
sends `CMSG_QUEST_CONFIRM_ACCEPT`; **No sends nothing at all**. There is no OnCancel below
|
||||
because there is no packet for one — the server's pending-share latch is simply cleared by
|
||||
the next thing that touches it. The same is true of ESC (hideOnEscape).
|
||||
-->
|
||||
<Script><![CDATA[
|
||||
-- The shipped enUS GlobalStrings.lua values, quoted as data (l.3227, and the YES/NO pair).
|
||||
-- First %s is the party member's name, second is the quest title — the argument order
|
||||
-- QUEST_ACCEPT_CONFIRM fires with.
|
||||
local QUEST_ACCEPT = "%s is starting %s\nWould you like to as well?"
|
||||
|
||||
StaticPopupDialogs["QUEST_ACCEPT"] = {
|
||||
text = QUEST_ACCEPT,
|
||||
button1 = "Yes", -- YES
|
||||
button2 = "No", -- NO
|
||||
OnAccept = function()
|
||||
ConfirmAcceptQuest()
|
||||
end,
|
||||
timeout = 0,
|
||||
exclusive = 1,
|
||||
hideOnEscape = 1,
|
||||
}
|
||||
|
||||
-- spec: UIParent.lua l.353-356 — one arm, both args straight through.
|
||||
function BenillaQuestShareDriver_OnEvent()
|
||||
if event == "QUEST_ACCEPT_CONFIRM" then
|
||||
StaticPopup_Show("QUEST_ACCEPT", arg1, arg2)
|
||||
end
|
||||
end
|
||||
]]></Script>
|
||||
|
||||
<Frame name="BenillaQuestShareDriver" hidden="true">
|
||||
<Size><AbsDimension x="1" y="1"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT"/></Anchors>
|
||||
<Scripts>
|
||||
<OnLoad>self:RegisterEvent("QUEST_ACCEPT_CONFIRM")</OnLoad>
|
||||
<OnEvent>BenillaQuestShareDriver_OnEvent()</OnEvent>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
</Ui>
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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<crate::ui_chat::ChatLog>,
|
||||
ResMut<crate::ui_quest::QuestGiver>,
|
||||
ResMut<crate::ui_quest_log::QuestLog>,
|
||||
// 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<crate::ui_quest_log::QuestLog>,
|
||||
ResMut<crate::ui_quest_share::QuestShare>,
|
||||
),
|
||||
ResMut<crate::go_templates::GameObjectTemplates>,
|
||||
ResMut<crate::net::HomeBind>,
|
||||
ResMut<crate::net::Proficiencies>,
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String>) -
|
||||
(!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<Item = (MsgSurface, String)>,
|
||||
) {
|
||||
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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<u64>,
|
||||
/// The open panel's wire view.
|
||||
pub(crate) view: Option<QuestView>,
|
||||
/// The trailing `u32` of the last `SMSG_QUESTGIVER_QUEST_DETAILS`
|
||||
/// ([`benilla_protocol::messages::QuestDetails::auto_finish`]) — the reference's `0xbe0824`,
|
||||
/// a **latch** written by the DETAILS handler rather than a field of the open view, because
|
||||
/// that is the shape the binary has and because its one reader ([`end_quest_session`]) runs
|
||||
/// whichever panel is up. Non-zero suppresses the giver re-open on close (decision 1738).
|
||||
pub(crate) detail_flag: u32,
|
||||
/// Per-guid dialog status (`SMSG_QUESTGIVER_STATUS`) — the `!`/`?` marker's value, stored for a
|
||||
/// later world-marker slice.
|
||||
statuses: HashMap<u64, u32>,
|
||||
@@ -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<u32>, commands: &NetCommands) {
|
||||
if benilla_protocol::guid::is_player(npc) {
|
||||
debug!("ui_quest: declining {npc:#x}'s shared quest");
|
||||
let _ = commands.0.send(ClientCommand::QuestPushResult {
|
||||
sharer: npc,
|
||||
msg: QuestShareMsg::DECLINE_QUEST,
|
||||
});
|
||||
return;
|
||||
}
|
||||
let Some(flags) = npc_flags else {
|
||||
// Not a streamed unit: an item giver, or an NPC that left view under the open window.
|
||||
// Nothing to re-open and nobody to answer.
|
||||
debug!("ui_quest: close on non-unit giver {npc:#x} (no packet)");
|
||||
return;
|
||||
};
|
||||
if detail_flag != 0 {
|
||||
debug!("ui_quest: close on {npc:#x} — detail flag {detail_flag} suppresses the re-open");
|
||||
return;
|
||||
}
|
||||
if flags & crate::target::cursor_mode::npc_flags::GOSSIP != 0 {
|
||||
debug!("ui_quest: close on {npc:#x} — re-opening the gossip menu");
|
||||
let _ = commands.0.send(ClientCommand::GossipHello { guid: npc });
|
||||
} else {
|
||||
debug!("ui_quest: close on {npc:#x} — re-opening the quest list");
|
||||
let _ = commands.0.send(ClientCommand::QuestgiverHello { npc });
|
||||
}
|
||||
}
|
||||
|
||||
fn drain_quest(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut giver: ResMut<QuestGiver>,
|
||||
commands: Res<NetCommands>,
|
||||
index: Res<GuidIndex>,
|
||||
stores: Query<&ObjectStore>,
|
||||
) {
|
||||
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<u32>,
|
||||
detail_flag: u32,
|
||||
lua: &str,
|
||||
) -> Vec<ClientCommand> {
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
let mut app = App::new();
|
||||
app.insert_resource(NetCommands(tx));
|
||||
app.init_resource::<GuidIndex>();
|
||||
if let Some(flags) = npc_flags {
|
||||
// 147 = `UNIT_NPC_FLAGS` (the descriptor index `unit_npc_flags()` reads).
|
||||
let fields = benilla_protocol::ObjectFields::from_pairs(&[(147, flags)]);
|
||||
let e = app.world_mut().spawn(ObjectStore(fields)).id();
|
||||
app.world_mut()
|
||||
.resource_mut::<GuidIndex>()
|
||||
.0
|
||||
.insert(giver, e);
|
||||
}
|
||||
let mut quest = QuestGiver {
|
||||
detail_flag,
|
||||
..Default::default()
|
||||
};
|
||||
quest.open(
|
||||
giver,
|
||||
QuestView::Detail(QuestDetails {
|
||||
npc: giver,
|
||||
quest_id: 1,
|
||||
title: "A Threat Within".into(),
|
||||
details: String::new(),
|
||||
objectives: String::new(),
|
||||
auto_finish: detail_flag,
|
||||
choices: Vec::new(),
|
||||
rewards: Vec::new(),
|
||||
money: 0,
|
||||
reward_spell: 0,
|
||||
}),
|
||||
);
|
||||
app.insert_resource(quest);
|
||||
let script = UiScript::new().unwrap();
|
||||
script.run(lua).unwrap();
|
||||
app.insert_non_send_resource(script);
|
||||
app.add_systems(Update, drain_quest);
|
||||
app.update();
|
||||
rx.try_iter().collect()
|
||||
}
|
||||
|
||||
const SHARER: u64 = 0x0000_0000_0000_002A; // HIGHGUID_PLAYER: a zero high word
|
||||
const NPC: u64 = 0xF130_0000_0000_0007; // HIGHGUID_UNIT
|
||||
|
||||
/// **Ending the session on a SHARED quest answers the sharer — and `CloseQuest` does it too.**
|
||||
/// 1733 shipped these as two actions on the assumption that only `DeclineQuest` answered; the
|
||||
/// §5 found both Lua verbs calling one routine (`0x501130`), so ESC-ing a share panel reports
|
||||
/// the decline exactly as the button does. This test is the corrected form of 1733's
|
||||
/// `closing_a_shared_quest_panel_is_not_a_decline`, which asserted the opposite.
|
||||
#[test]
|
||||
fn every_way_out_of_a_shared_quest_answers_the_sharer() {
|
||||
for verb in ["DeclineQuest()", "CloseQuest()"] {
|
||||
let sent = drain_after(SHARER, None, 0, verb);
|
||||
assert!(
|
||||
matches!(
|
||||
sent.as_slice(),
|
||||
[ClientCommand::QuestPushResult {
|
||||
sharer: SHARER,
|
||||
msg: QuestShareMsg::DECLINE_QUEST,
|
||||
}]
|
||||
),
|
||||
"{verb} must answer the sharer: {sent:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// **Ending it on an NPC RE-OPENS the giver**, which benilla asserted for years that it did not
|
||||
/// ("vanilla's client-side decline sends no packet"). The fork is on the NPC's own gossip flag:
|
||||
/// `CMSG_GOSSIP_HELLO` for a gossip-flagged NPC, `CMSG_QUESTGIVER_HELLO` otherwise. This is the
|
||||
/// mechanism behind the reference putting you back in the questgiver's menu after a decline.
|
||||
#[test]
|
||||
fn ending_the_session_on_an_npc_reopens_its_list() {
|
||||
use crate::target::cursor_mode::npc_flags;
|
||||
|
||||
let sent = drain_after(NPC, Some(0), 0, "DeclineQuest()");
|
||||
assert!(
|
||||
matches!(
|
||||
sent.as_slice(),
|
||||
[ClientCommand::QuestgiverHello { npc: NPC }]
|
||||
),
|
||||
"a plain questgiver gets QUESTGIVER_HELLO: {sent:?}"
|
||||
);
|
||||
|
||||
let sent = drain_after(NPC, Some(npc_flags::GOSSIP), 0, "DeclineQuest()");
|
||||
assert!(
|
||||
matches!(sent.as_slice(), [ClientCommand::GossipHello { guid: NPC }]),
|
||||
"a gossip-flagged NPC gets GOSSIP_HELLO: {sent:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The DETAILS packet's trailing `u32` **suppresses** that re-open when non-zero — the field
|
||||
/// benilla parsed and ignored until the §5 found its one reader. Ignoring it meant a
|
||||
/// suppressed giver was re-opened anyway.
|
||||
#[test]
|
||||
fn a_non_zero_detail_flag_suppresses_the_reopen() {
|
||||
let sent = drain_after(NPC, Some(0), 1, "DeclineQuest()");
|
||||
assert!(sent.is_empty(), "flag 1 suppresses the re-open: {sent:?}");
|
||||
}
|
||||
|
||||
/// A giver that is not a streamed unit sends nothing at all: an item giver (0664) has no list
|
||||
/// to re-open, and neither does an NPC that walked out of view under the open window.
|
||||
#[test]
|
||||
fn an_unstreamed_giver_ends_the_session_silently() {
|
||||
let sent = drain_after(NPC, None, 0, "CloseQuest()");
|
||||
assert!(sent.is_empty(), "no unit, no re-open: {sent:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<NonSendMut<UiScript>>, commands: Res<NetCommands>) {
|
||||
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<NonSendMut<UiScript>>,
|
||||
quest_log: Res<QuestLog>,
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PendingVerdict>,
|
||||
/// The confirm the server sent, held until its sender's name resolves and the event fires.
|
||||
pending_confirm: Option<QuestConfirmAccept>,
|
||||
/// 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<u32>,
|
||||
}
|
||||
|
||||
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::<QuestShare>().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<NonSendMut<UiScript>>,
|
||||
mut share: ResMut<QuestShare>,
|
||||
mut names: ResMut<NameCache>,
|
||||
commands: Res<NetCommands>,
|
||||
mut chat: ResMut<ChatLog>,
|
||||
) {
|
||||
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::<String>(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<NonSendMut<UiScript>>,
|
||||
share: Res<QuestShare>,
|
||||
commands: Res<NetCommands>,
|
||||
) {
|
||||
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::<String>(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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -957,6 +957,9 @@ mod petition_tests;
|
||||
#[cfg(test)]
|
||||
mod raid_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod quest_share_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod quest_tests;
|
||||
|
||||
|
||||
@@ -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/<file>` 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::<bool>("return StaticPopup1:IsVisible()").unwrap(),
|
||||
"QUEST_ACCEPT_CONFIRM shows the confirm popup"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("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::<bool>("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::<bool>("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::<bool>("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::<String>("return StaticPopup1Text:GetText()")
|
||||
.unwrap(),
|
||||
"Jaina is starting Deeper Still\nWould you like to as well?"
|
||||
);
|
||||
assert!(
|
||||
!s.eval::<bool>("return StaticPopup2:IsVisible()").unwrap(),
|
||||
"the confirm is exclusive — it reuses the one slot"
|
||||
);
|
||||
}
|
||||
@@ -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/<file>` 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::<i64>("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::<i64>("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::<bool>("return GetQuestLogPushable() ~= nil")
|
||||
.unwrap(),
|
||||
"quest 1 carries the sharable bit"
|
||||
);
|
||||
assert!(
|
||||
!s.eval::<bool>("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::<bool>("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::<bool>("return GetQuestLogPushable() == nil")
|
||||
.unwrap(),
|
||||
"quest 2 has no sharable bit"
|
||||
);
|
||||
assert!(
|
||||
!s.eval::<bool>("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::<bool>("return QuestLogPushQuestButton:IsEnabled()")
|
||||
.unwrap());
|
||||
s.set_party(PartyState::default());
|
||||
s.fire_event("PARTY_MEMBERS_CHANGED", vec![]);
|
||||
assert!(!s
|
||||
.eval::<bool>("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::<bool>("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());
|
||||
}
|
||||
|
||||
@@ -57,6 +57,17 @@ pub(crate) trait NpcSession: Resource {
|
||||
fn npc(&self) -> Option<u64>;
|
||||
/// 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<T: NpcSession>(
|
||||
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::<MerchantOpen>().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::<QuestGiver>();
|
||||
app.init_resource::<GuidIndex>();
|
||||
app.init_resource::<InteractNpc>();
|
||||
app.add_systems(
|
||||
Update,
|
||||
(
|
||||
close_npc_session_out_of_range::<QuestGiver>,
|
||||
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::<GuidIndex>()
|
||||
.0
|
||||
.insert(SHARER, sharer);
|
||||
|
||||
app.world_mut()
|
||||
.resource_mut::<QuestGiver>()
|
||||
.open(SHARER, detail(SHARER));
|
||||
app.update();
|
||||
assert!(
|
||||
app.world().resource::<QuestGiver>().is_open(),
|
||||
"a shared quest's panel is not NPC-range-guarded"
|
||||
);
|
||||
assert_eq!(
|
||||
app.world().resource::<InteractNpc>().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::<GuidIndex>()
|
||||
.0
|
||||
.insert(VENDOR, sharer);
|
||||
app.world_mut()
|
||||
.resource_mut::<QuestGiver>()
|
||||
.open(VENDOR, detail(VENDOR));
|
||||
app.update();
|
||||
assert!(
|
||||
!app.world().resource::<QuestGiver>().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
|
||||
|
||||
@@ -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").
|
||||
|
||||
@@ -347,6 +347,11 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
|
||||
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 }]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -665,6 +665,12 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
|
||||
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)?,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<QuestPushResult> {
|
||||
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<QuestConfirmAccept> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<u32>,
|
||||
/// 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<u32>,
|
||||
/// 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<u32, ItemTemplateView>,
|
||||
@@ -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(),
|
||||
|
||||
@@ -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::<Model>().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::<i64>("return GetNumQuestRewards()").unwrap(), 0);
|
||||
assert!(s.eval::<bool>("return GetRewardSpell() == nil").unwrap());
|
||||
}
|
||||
|
||||
/// `DeclineQuest` and `CloseQuest` are the SAME intent — one reference routine, `0x501130`
|
||||
/// (decision 1738, correcting 1733's split). What differs between a decline and a close is
|
||||
/// nothing; what differs between an NPC's quest and a shared one is everything, and that fork
|
||||
/// belongs to the app, which knows the giver.
|
||||
#[test]
|
||||
fn decline_and_close_are_one_intent() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_quest(Some(detail()));
|
||||
s.run("DeclineQuest()").unwrap();
|
||||
s.run("CloseQuest()").unwrap();
|
||||
assert_eq!(
|
||||
s.take_quest_actions(),
|
||||
vec![QuestAction::Close, QuestAction::Close]
|
||||
);
|
||||
}
|
||||
|
||||
/// `ConfirmAcceptQuest` counts rather than carrying a quest id — the reference's verb takes no
|
||||
/// argument and answers whichever confirm the client holds (decision 1733).
|
||||
#[test]
|
||||
fn confirm_accept_quest_counts_and_drains() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
assert_eq!(s.take_quest_confirms(), 0);
|
||||
s.run("ConfirmAcceptQuest() ConfirmAcceptQuest()").unwrap();
|
||||
assert_eq!(s.take_quest_confirms(), 2);
|
||||
assert_eq!(s.take_quest_confirms(), 0, "drained");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<u32> {
|
||||
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<u32> {
|
||||
(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::<Model>().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::<Model>().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::<i64>("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::<Option<i64>>("return GetQuestLogPushable()")
|
||||
.unwrap(),
|
||||
None,
|
||||
"no selection yet"
|
||||
);
|
||||
s.run("SelectQuestLogEntry(1)").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<Option<i64>>("return GetQuestLogPushable()")
|
||||
.unwrap(),
|
||||
Some(1)
|
||||
);
|
||||
s.run("SelectQuestLogEntry(2)").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<Option<i64>>("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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user