the NPC, loot and quest windows register their handlers
- the six NPC windows — gossip, vendor, bank, trainer, stable master, taxi — register their own packet handlers and net/apply/npc.rs leaves the drain; a handler call measures ~25 ns, so the table can carry the busy families as it is (2318) - the loot window, the group rolls and the inventory refusal register theirs (2319), then the questgiver panels, the quest log and the party share (2320): 273 → 149 kinds in the dispatch match since the table opened - the show cascade is pinned against the reference's: ours notifies a parent before its children and snapshots the list, theirs is post-order and re-reads — a recorded gap, held by a test on the auction window (2317) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -1393,7 +1393,7 @@ pub(crate) mod schedule_tests {
|
||||
"filled only by a world right-click on a GameObject; the drain sends casts to the wire (2232's own bucket)"),
|
||||
("ui_auction/mod.rs", "drain_auction", Because::FilledByVm,
|
||||
"VM verbs; the refresh flags act only under an open window and become wire re-asks"),
|
||||
("ui_bank.rs", "feed_bank", Because::PlayerRoundTrip,
|
||||
("ui_bank/mod.rs", "feed_bank", Because::PlayerRoundTrip,
|
||||
"`BankErrors` answers a `BuyBankSlot` click; the OPENED edge rides `VmMemo`s"),
|
||||
("ui_battlefield.rs", "feed_battlefield", Because::SelfHealing,
|
||||
"`reset_on_world_enter` runs before it, clears the session and re-sends `BattlefieldStatusRequest`"),
|
||||
@@ -1425,7 +1425,7 @@ pub(crate) mod schedule_tests {
|
||||
"`take_container_uses` and `take_container_repairs` are Lua intents held by the VM"),
|
||||
("ui_logout.rs", "feed_logout", Because::PlayerRoundTrip,
|
||||
"both packets answer the `CMSG_LOGOUT_REQUEST`/cancel the game menu sent"),
|
||||
("ui_loot.rs", "drain_loot", Because::Deliberate,
|
||||
("ui_loot/mod.rs", "drain_loot", Because::Deliberate,
|
||||
"the pre-VM take of `LootMoveStart` is documented at the line and publishes nothing to the VM; the event-firing takes are Lua's queues"),
|
||||
("ui_loot_roll.rs", "drain_loot_rolls", Because::FilledByVm,
|
||||
"only a Need/Greed/Pass click queues a confirm or vote, held in the VM"),
|
||||
@@ -1437,7 +1437,7 @@ pub(crate) mod schedule_tests {
|
||||
"`take_macros_dirty` is raised only by the macro window's Lua"),
|
||||
("ui_mail/mod.rs", "feed_mail", Because::SelfHealing,
|
||||
"the window queues are mailbox-click replies; the one server push (`SMSG_RECEIVED_MAIL`) is re-asked by `send_query_next_mail_time_on_enter`, whose reply sets `notify` unconditionally"),
|
||||
("ui_merchant.rs", "feed_merchant", Because::PlayerRoundTrip,
|
||||
("ui_merchant/mod.rs", "feed_merchant", Because::PlayerRoundTrip,
|
||||
"a buy/sell refusal answers a click on an open merchant; show/update ride `VmMemo`s"),
|
||||
("ui_petition/feed.rs", "feed_petition", Because::PlayerRoundTrip,
|
||||
"every line answers a charter action of ours; the window edges ride the `fed` memo"),
|
||||
@@ -1447,7 +1447,7 @@ pub(crate) mod schedule_tests {
|
||||
"per-frame paint and cost state, not a queue (2232)"),
|
||||
("ui_social/feed.rs", "feed_social", Because::MemoLatched,
|
||||
"the login-burst lists are re-announced to a new VM by `fed.seeded` (`VmMemo<FedSocial>`); the show flag is Lua's; a status line waits on the name resolve"),
|
||||
("ui_stable.rs", "feed_stable", Because::PlayerRoundTrip,
|
||||
("ui_stable/mod.rs", "feed_stable", Because::PlayerRoundTrip,
|
||||
"both packets follow a stable master's gossip and a click in its window"),
|
||||
("ui_summon.rs", "feed_summon", Because::PlayerRoundTrip,
|
||||
"only another player's Ritual of Summoning latches the ask (2232's duel shape; coincidence-only residual)"),
|
||||
|
||||
@@ -20,14 +20,11 @@ mod combat_chat;
|
||||
mod combat_log;
|
||||
mod death;
|
||||
mod group;
|
||||
mod loot;
|
||||
mod mount;
|
||||
mod names;
|
||||
mod npc;
|
||||
mod objects;
|
||||
mod params;
|
||||
mod pet;
|
||||
mod quests;
|
||||
#[cfg(test)]
|
||||
mod seam_tests;
|
||||
mod session;
|
||||
@@ -38,17 +35,6 @@ use params::{ActionStores, AnimWriters, Catalogs, Clocks, ObjectQueries, Session
|
||||
|
||||
// The arm families, split out of the dispatch match below (each `pub(super)` fn is one arm's
|
||||
// body; the match stays the dispatcher, one call per arm — see the child modules).
|
||||
use loot::{
|
||||
inventory_failure, item_push_result, item_template, loot_all_passed, loot_clear_money,
|
||||
loot_error, loot_master_list, loot_money_notify, loot_release_response, loot_removed,
|
||||
loot_response, loot_roll, loot_roll_won, loot_start_roll,
|
||||
};
|
||||
use quests::{
|
||||
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,
|
||||
clear_cooldown, cooldown_cheat, cooldown_event, item_cooldown, learned_spell, removed_spell,
|
||||
@@ -146,13 +132,7 @@ fn apply_unpeeled(
|
||||
mut chain_casts,
|
||||
mut pet_tame_failures,
|
||||
mut learned_in_tab,
|
||||
mut equip_errors,
|
||||
mut merchant_errors,
|
||||
mut cast_bar,
|
||||
mut pending_item_ops,
|
||||
mut lock_transitions,
|
||||
mut trainer_errors,
|
||||
mut stable_errors,
|
||||
mut pending_cast,
|
||||
cooldowns: mut cooldown_store,
|
||||
mut auto_repeat,
|
||||
@@ -193,7 +173,6 @@ fn apply_unpeeled(
|
||||
env_damage: env_damage_table,
|
||||
area_table,
|
||||
exploration_sounds,
|
||||
current_map,
|
||||
} = catalogs;
|
||||
// A `&mut` to the counter itself (deref-coerced through the `ResMut`), so the arms that stamp
|
||||
// it *conditionally* can take it by reference and only advance it when they emit.
|
||||
@@ -201,26 +180,15 @@ fn apply_unpeeled(
|
||||
let WindowStores {
|
||||
mut names,
|
||||
mut items,
|
||||
mut gossip,
|
||||
mut merchant,
|
||||
mut trainer_open,
|
||||
mut stable_open,
|
||||
mut loot,
|
||||
mut loot_latch,
|
||||
mut loot_rolls,
|
||||
mut chat_log,
|
||||
mut quest,
|
||||
mut quest_log,
|
||||
mut quest_share,
|
||||
mut go_templates,
|
||||
mut home_bind,
|
||||
mut proficiencies,
|
||||
mut dropped,
|
||||
mut death_net,
|
||||
mut group,
|
||||
mut taxi,
|
||||
mut bank_open,
|
||||
mut bank_errors,
|
||||
mut world_states,
|
||||
social,
|
||||
mut logout,
|
||||
@@ -228,7 +196,6 @@ fn apply_unpeeled(
|
||||
mut ui_error_keys,
|
||||
mut played_time_answer,
|
||||
mut tutorials,
|
||||
mut poi_marker,
|
||||
} = windows;
|
||||
let Session {
|
||||
mut teleports,
|
||||
@@ -348,20 +315,9 @@ fn apply_unpeeled(
|
||||
&mut status,
|
||||
&mut names,
|
||||
&mut items,
|
||||
&mut gossip,
|
||||
&mut merchant,
|
||||
&mut trainer_open,
|
||||
&mut loot,
|
||||
&mut loot_latch,
|
||||
&mut loot_rolls,
|
||||
&mut chat_log,
|
||||
&mut quest,
|
||||
&mut quest_log,
|
||||
&mut quest_share,
|
||||
&mut death_net,
|
||||
&mut group,
|
||||
&mut taxi,
|
||||
&mut bank_open,
|
||||
&mut cooldown_store,
|
||||
&mut pending_transfer,
|
||||
&mut disconnects,
|
||||
@@ -680,8 +636,6 @@ fn apply_unpeeled(
|
||||
SessionEvent::GameObjectDespawnAnim { guid } => {
|
||||
objects::gameobject_despawn_anim(guid, &mut commands, &index)
|
||||
}
|
||||
SessionEvent::FishNotHooked => loot::fish_verdict(false, &mut ui_error_keys),
|
||||
SessionEvent::FishEscaped => loot::fish_verdict(true, &mut ui_error_keys),
|
||||
SessionEvent::PlaySound { sound_id } => world::play_sound(sound_id, &mut server_sounds),
|
||||
SessionEvent::PlayMusic { music_id } => world::play_music(music_id, &mut server_sounds),
|
||||
SessionEvent::PlayObjectSound { sound_id, guid } => {
|
||||
@@ -770,21 +724,6 @@ fn apply_unpeeled(
|
||||
&mut chain_casts,
|
||||
play_seq.next(),
|
||||
),
|
||||
SessionEvent::InventoryFailure {
|
||||
reason,
|
||||
required_level,
|
||||
item_guid,
|
||||
bag_slot,
|
||||
} => inventory_failure(
|
||||
reason,
|
||||
required_level,
|
||||
item_guid,
|
||||
bag_slot,
|
||||
&mut equip_errors,
|
||||
&mut pending_item_ops,
|
||||
&mut lock_transitions,
|
||||
&mut loot_latch,
|
||||
),
|
||||
SessionEvent::Chat(m) => {
|
||||
chat::chat(m, &mut chat_log, &social, &net_commands, &mut server_said)
|
||||
}
|
||||
@@ -892,39 +831,6 @@ fn apply_unpeeled(
|
||||
SessionEvent::ReadyCheckAnswer { guid, ready } => {
|
||||
group.apply_ready_check_answer(guid, ready != 0)
|
||||
}
|
||||
SessionEvent::LootResponse {
|
||||
guid,
|
||||
loot_type,
|
||||
gold,
|
||||
items,
|
||||
} => loot_response(
|
||||
guid,
|
||||
loot_type,
|
||||
gold,
|
||||
items,
|
||||
&mut loot,
|
||||
&mut loot_latch,
|
||||
&net_commands,
|
||||
),
|
||||
SessionEvent::LootError { guid, error } => {
|
||||
loot_error(guid, error, &mut ui_error_keys, &mut loot_latch)
|
||||
}
|
||||
SessionEvent::LootRemoved { slot } => loot_removed(slot, &mut loot),
|
||||
SessionEvent::LootMoneyNotify { amount } => loot_money_notify(amount),
|
||||
SessionEvent::LootClearMoney => loot_clear_money(&mut loot),
|
||||
SessionEvent::LootReleaseResponse { guid } => {
|
||||
loot_release_response(guid, &mut loot, &mut loot_latch)
|
||||
}
|
||||
SessionEvent::ItemPushResult(p) => {
|
||||
item_push_result(p, &self_guid, &mut loot, &mut tutorials)
|
||||
}
|
||||
// ── The group-loot roll family (decision 0591) — the GroupLootFrame feed ───────────
|
||||
SessionEvent::LootStartRoll(p) => loot_start_roll(p, &mut loot_rolls),
|
||||
SessionEvent::LootRoll(p) => loot_roll(p, &mut loot_rolls),
|
||||
SessionEvent::LootRollWon(p) => loot_roll_won(p, &mut loot_rolls),
|
||||
SessionEvent::LootAllPassed(p) => loot_all_passed(p, &mut loot_rolls),
|
||||
// ── Master loot (decision 1675) — the candidate list, ahead of its LootResponse ───
|
||||
SessionEvent::LootMasterList { candidates } => loot_master_list(candidates, &mut loot),
|
||||
// ── The death arc (decision 0308) — arm bodies in `death` ─────────────────────────
|
||||
SessionEvent::CorpseQuery {
|
||||
found,
|
||||
@@ -976,8 +882,14 @@ fn apply_unpeeled(
|
||||
counter,
|
||||
launch,
|
||||
} => session::knock_back(guid, counter, launch, &self_guid, &mut knockbacks),
|
||||
// An item template's display head (`SMSG_ITEM_QUERY_SINGLE_RESPONSE`, answering our
|
||||
// `CMSG_ITEM_QUERY_SINGLE`): fill the ask-once template cache (decisions 0068/0072 —
|
||||
// one cache serves held-item resolution and the container layer); a server miss
|
||||
// records `None` so the entry is never re-asked. Consumers re-read it next frame.
|
||||
SessionEvent::ItemTemplate { entry, info } => {
|
||||
item_template(entry, info.map(|b| *b), &mut items)
|
||||
let info = info.map(|b| *b);
|
||||
debug!("net: item template {entry} → {info:?}");
|
||||
items.insert_template(entry, info);
|
||||
}
|
||||
SessionEvent::AttackStart { attacker, victim } => {
|
||||
combat::attack_start(attacker, victim, &mut commands, &index)
|
||||
@@ -1376,137 +1288,7 @@ fn apply_unpeeled(
|
||||
&mut kit_pushes,
|
||||
)
|
||||
}
|
||||
// The gossip/vendor/trainer NPC-interaction family — arm bodies in `npc`.
|
||||
SessionEvent::GossipMenu {
|
||||
npc,
|
||||
text_id,
|
||||
options,
|
||||
quests,
|
||||
} => npc::gossip_menu(
|
||||
npc,
|
||||
text_id,
|
||||
options,
|
||||
quests,
|
||||
&mut gossip,
|
||||
&net_commands,
|
||||
&index,
|
||||
&stores,
|
||||
),
|
||||
SessionEvent::NpcGreeting { text_id, blocks } => {
|
||||
npc::npc_greeting(text_id, blocks, &mut gossip, &index, &stores)
|
||||
}
|
||||
SessionEvent::GossipComplete => npc::gossip_complete(&mut gossip, &mut quest),
|
||||
SessionEvent::GossipPoi(poi) => npc::gossip_poi(
|
||||
&poi,
|
||||
&mut poi_marker,
|
||||
current_map.as_ref().map_or(0, |m| m.0),
|
||||
real_clock.elapsed_secs_f64(),
|
||||
),
|
||||
// Questgiver panels (decision 0088): fill the `QuestGiver` the quest feed
|
||||
// (`crate::ui_quest`) reads. Each panel packet replaces the open view; the greeting/gossip
|
||||
// quest-row clicks and the panel buttons flow back out through the quest/gossip drains.
|
||||
SessionEvent::QuestGiverStatus { npc, status } => {
|
||||
quest_giver_status(npc, status, &mut quest)
|
||||
}
|
||||
SessionEvent::QuestGreeting(list) => quest_greeting(list, &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),
|
||||
// Quest log (decision 0088's deferred second slice): the full template feeds the log
|
||||
// window's ask-once detail cache; the `SMSG_QUESTUPDATE_*` toasts have no dedicated
|
||||
// window of their own on this server (no ErrorsFrame-style transient panel yet), so they
|
||||
// route through the chat window's system-line seam ([`crate::ui_chat::ChatLog`]) — the
|
||||
// same seam the loot feed's refusal/receive lines use — colored SYSTEM yellow, the
|
||||
// GM-feedback color.
|
||||
SessionEvent::QuestTemplate(t) => quest_template(t, &mut quest_log),
|
||||
SessionEvent::QuestObjectiveKill {
|
||||
quest_id: _,
|
||||
entry,
|
||||
count,
|
||||
required,
|
||||
} => quest_objective_kill(entry, count, required, &mut quest),
|
||||
SessionEvent::QuestObjectiveItem { item_id, count } => {
|
||||
quest_objective_item(item_id, count, &mut quest)
|
||||
}
|
||||
SessionEvent::QuestObjectivesComplete { quest_id } => {
|
||||
quest_objectives_complete(quest_id, &mut quest)
|
||||
}
|
||||
SessionEvent::QuestFailed { quest_id, timed } => {
|
||||
quest_failed(quest_id, timed, &mut quest_log, &net_commands, &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)
|
||||
}
|
||||
SessionEvent::VendorInventory { vendor, items } => {
|
||||
npc::vendor_inventory(vendor, items, &mut merchant)
|
||||
}
|
||||
SessionEvent::ShowBank { banker } => {
|
||||
npc::show_bank(banker, &mut bank_open, &mut gossip, &mut quest)
|
||||
}
|
||||
SessionEvent::BuyBankSlotResult { result } => {
|
||||
npc::bank_buy_slot_result(result, &mut bank_errors)
|
||||
}
|
||||
SessionEvent::TrainerList {
|
||||
trainer,
|
||||
trainer_type,
|
||||
services,
|
||||
greeting,
|
||||
} => npc::trainer_list(trainer, trainer_type, services, greeting, &mut trainer_open),
|
||||
SessionEvent::TrainerBuySucceeded { trainer, spell_id } => {
|
||||
npc::trainer_buy_succeeded(trainer, spell_id, &mut trainer_open, &net_commands)
|
||||
}
|
||||
SessionEvent::TrainerBuyFailed { error, .. } => {
|
||||
npc::trainer_buy_failed(error, &mut trainer_errors)
|
||||
}
|
||||
SessionEvent::InvalidatePlayer { guid } => names::invalidate_player(guid, &mut names),
|
||||
SessionEvent::ListStabledPets {
|
||||
npc,
|
||||
num_stable_slots,
|
||||
pets,
|
||||
} => npc::list_stabled_pets(npc, num_stable_slots, pets, &mut stable_open, &mut names),
|
||||
SessionEvent::StableResult { result } => {
|
||||
npc::stable_result(result, &mut stable_open, &mut stable_errors, &net_commands)
|
||||
}
|
||||
SessionEvent::TaxiNodesShown {
|
||||
flightmaster,
|
||||
nearest_node,
|
||||
known_mask,
|
||||
} => npc::taxi_nodes_shown(flightmaster, nearest_node, known_mask, &mut taxi),
|
||||
SessionEvent::TaxiNodeStatus { guid, known } => {
|
||||
npc::taxi_node_status(guid, known, &mut commands, &index)
|
||||
}
|
||||
SessionEvent::ActivateTaxiReply { code } => npc::taxi_activate_reply(code, &mut taxi),
|
||||
SessionEvent::NewTaxiPath => npc::taxi_new_path(&mut taxi),
|
||||
SessionEvent::VendorBuyResult {
|
||||
vendor,
|
||||
slot,
|
||||
new_count,
|
||||
..
|
||||
} => npc::vendor_buy_result(vendor, slot, new_count, &mut merchant),
|
||||
SessionEvent::VendorBuyFailed {
|
||||
vendor,
|
||||
item_entry,
|
||||
reason,
|
||||
} => npc::vendor_buy_failed(
|
||||
vendor,
|
||||
item_entry,
|
||||
reason,
|
||||
&mut merchant,
|
||||
&mut merchant_errors,
|
||||
),
|
||||
SessionEvent::VendorSellFailed { reason, .. } => {
|
||||
npc::vendor_sell_failed(reason, &mut merchant_errors)
|
||||
}
|
||||
SessionEvent::ForceSpeedChange {
|
||||
guid,
|
||||
kind,
|
||||
|
||||
@@ -1,420 +0,0 @@
|
||||
//! NPC-interaction arm bodies for [`super::apply_net_updates`]'s dispatch match — the gossip
|
||||
//! window (decision 0081 phase 3), the vendor window (phase 4), the trainer window (decision
|
||||
//! 0237), and the taxi-map session (decision 0484 phase 1). Each `pub(super)` fn here is exactly
|
||||
//! one arm's body; the match at the call site stays the dispatcher, one call per arm.
|
||||
|
||||
use benilla_protocol::messages::{
|
||||
GossipOption, NpcTextBlock, StabledPet, TaxiMask, TrainerSpell, VendorItem,
|
||||
};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::names::NameCache;
|
||||
use crate::ui_bank::{BankErrors, BankOpen};
|
||||
use crate::ui_gossip::GossipState;
|
||||
use crate::ui_merchant::{MerchantErrors, MerchantOpen, MerchantRefusal};
|
||||
use crate::ui_quest::QuestGiver;
|
||||
use crate::ui_stable::{StableErrors, StableOpen};
|
||||
use crate::ui_taxi::TaxiState;
|
||||
use crate::ui_trainer::{TrainerErrors, TrainerOpen};
|
||||
|
||||
use super::super::{ClientCommand, GuidIndex, NetCommands, ObjectStore};
|
||||
|
||||
/// A streamed unit's gender (`UNIT_FIELD_BYTES_0` byte 2) by guid — the gossip greeting's column
|
||||
/// selector (wow-re `gossip-npctext-law.md`: tested `== 1` for female, so genderless `2` reads as
|
||||
/// male). `0` when the guid isn't streamed in or carries no descriptor yet, which is the same
|
||||
/// column the reference takes for a gossip target that isn't a unit at all.
|
||||
fn npc_gender(guid: u64, index: &GuidIndex, stores: &Query<&mut ObjectStore>) -> u8 {
|
||||
index
|
||||
.0
|
||||
.get(&guid)
|
||||
.and_then(|&e| stores.get(e).ok())
|
||||
.and_then(|s| s.0.unit_gender())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// A gossip menu opened (`SMSG_GOSSIP_MESSAGE`): fill the [`GossipState`] the gossip feed
|
||||
/// (`crate::ui_gossip`) reads. A first visit to the text id sends the ask-once
|
||||
/// `CMSG_NPC_TEXT_QUERY` and **the feed fires nothing until [`npc_greeting`] answers it** — a
|
||||
/// hidden frame stays hidden, an open one keeps its previous menu painted (B292's hold and
|
||||
/// 1994's no-event edge; the mechanics and the reference law live on
|
||||
/// [`GossipState::open_menu`]); a revisit serves from the cache and repaints right away.
|
||||
/// [`gossip_complete`] closes it.
|
||||
///
|
||||
/// The greeting is **drawn here**, not at the packet — this is the reference's own moment for it
|
||||
/// (`0x4e2010`), and the draw needs both this NPC's gender ([`npc_gender`]) and a fresh roll.
|
||||
pub(super) fn gossip_menu(
|
||||
npc: u64,
|
||||
text_id: u32,
|
||||
options: Vec<GossipOption>,
|
||||
quests: Vec<(u32, u32, u32, String)>,
|
||||
gossip: &mut GossipState,
|
||||
net_commands: &NetCommands,
|
||||
index: &GuidIndex,
|
||||
stores: &Query<&mut ObjectStore>,
|
||||
) {
|
||||
let npc_gender = npc_gender(npc, index, stores);
|
||||
debug!(
|
||||
"net: gossip menu on {npc:#x} — {} options, {} quests",
|
||||
options.len(),
|
||||
quests.len()
|
||||
);
|
||||
if gossip.open_menu(npc, text_id, options, quests, npc_gender) {
|
||||
let _ = net_commands
|
||||
.0
|
||||
.send(ClientCommand::NpcTextQuery { text_id, guid: npc });
|
||||
}
|
||||
}
|
||||
|
||||
/// The NPC-text answer (`SMSG_NPC_TEXT_UPDATE`) — seed the cache with the whole record, and open
|
||||
/// the menu still waiting on it (a late answer for a menu we already closed or switched just
|
||||
/// seeds the cache; the next open draws its own line).
|
||||
///
|
||||
/// The record answers a query we sent for the waiting menu, so its NPC is the one whose gender
|
||||
/// picks the column (decision 0081's ask-once flow).
|
||||
pub(super) fn npc_greeting(
|
||||
text_id: u32,
|
||||
blocks: Vec<NpcTextBlock>,
|
||||
gossip: &mut GossipState,
|
||||
index: &GuidIndex,
|
||||
stores: &Query<&mut ObjectStore>,
|
||||
) {
|
||||
let npc_gender = gossip.npc.map_or(0, |npc| npc_gender(npc, index, stores));
|
||||
gossip.text_arrived(text_id, blocks, npc_gender);
|
||||
}
|
||||
|
||||
/// `SMSG_GOSSIP_COMPLETE` ends the whole interaction (e.g. right after a quest accept), so the
|
||||
/// quest window closes with the gossip menu (decision 0088).
|
||||
pub(super) fn gossip_complete(gossip: &mut GossipState, quest: &mut QuestGiver) {
|
||||
debug!("net: gossip complete — closing the menu");
|
||||
gossip.clear();
|
||||
quest.clear();
|
||||
}
|
||||
|
||||
/// The guard's directions (`SMSG_GOSSIP_POI`): drop the marker at that spot. Volunteered by the
|
||||
/// server for a gossip option carrying an `action_poi_id` — it answers nothing we asked for, and
|
||||
/// it does **not** end the gossip session on its own (vmangos `Player::OnGossipSelect`'s
|
||||
/// `GOSSIP_OPTION_GOSSIP` arm sends the POI *before* it decides whether to move the menu on, leave
|
||||
/// it, or close it).
|
||||
///
|
||||
/// `map_id` is the map the player is standing on, which is the only place the marker can mean
|
||||
/// anything — the wire carries no map field, and the reference reads its own current-map global at
|
||||
/// exactly this point. `now_secs` starts the marker's 8-minute clock ([`crate::poi_marker`]); it
|
||||
/// is the same real clock the corpse reclaim delay is stamped against (decision 0846).
|
||||
pub(super) fn gossip_poi(
|
||||
poi: &benilla_protocol::messages::GossipPoi,
|
||||
marker: &mut crate::poi_marker::PoiMarker,
|
||||
map_id: u32,
|
||||
now_secs: f64,
|
||||
) {
|
||||
debug!(
|
||||
"net: directions to \"{}\" at ({:.1}, {:.1}) — icon {}, flags {:#x}",
|
||||
poi.name, poi.pos[0], poi.pos[1], poi.icon, poi.flags
|
||||
);
|
||||
marker.set(poi, map_id, now_secs);
|
||||
}
|
||||
|
||||
/// A vendor's stock (`SMSG_LIST_INVENTORY`): fill the [`MerchantOpen`] the merchant feed
|
||||
/// (`crate::ui_merchant`) reads. A successful buy updates the stock display via
|
||||
/// [`vendor_buy_result`] (the item itself lands via item-create); a successful sell is silent
|
||||
/// (never a packet here — only the error path is).
|
||||
pub(super) fn vendor_inventory(vendor: u64, items: Vec<VendorItem>, merchant: &mut MerchantOpen) {
|
||||
debug!("net: vendor {vendor:#x} listed {} items", items.len());
|
||||
merchant.open(vendor, items);
|
||||
}
|
||||
|
||||
/// The bank opened (`SMSG_SHOW_BANK`): point the [`BankOpen`] session the bank feed
|
||||
/// (`crate::ui_bank`) reads at the banker. Sent for our own `CMSG_BANKER_ACTIVATE` *and*
|
||||
/// volunteered by the server for the gossip menu's bank option (`GOSSIP_OPTION_BANKER` →
|
||||
/// `SendShowBank`, decision 0604) — so this never assumes we asked. The vault's contents are
|
||||
/// descriptor fields already streamed; the window renders from local state.
|
||||
///
|
||||
/// Opening the bank ends any open gossip interaction (the [`gossip_complete`] clear): vmangos
|
||||
/// sends no `SMSG_GOSSIP_COMPLETE` for the gossip menu's bank option (VERIFIED
|
||||
/// `Player::OnGossipSelect` — BANKER only calls `SendShowBank`), and the panel slots alone can't
|
||||
/// close the menu — the bank's `pushable = 6` sends it to the *center* slot beside a pushable-0
|
||||
/// gossip instead of replacing it, so the menu would linger beside the vault
|
||||
/// (director-observed). The real client ends the old NPC interaction when the new one starts
|
||||
/// (INFERRED — the exact C++ `GOSSIP_CLOSED` fire isn't RE'd; the observable is vanilla's: the
|
||||
/// menu is gone once the vault is up).
|
||||
pub(super) fn show_bank(
|
||||
banker: u64,
|
||||
bank: &mut BankOpen,
|
||||
gossip: &mut GossipState,
|
||||
quest: &mut QuestGiver,
|
||||
) {
|
||||
debug!("net: bank opened at {banker:#x}");
|
||||
if gossip.npc.is_some() {
|
||||
gossip_complete(gossip, quest);
|
||||
}
|
||||
bank.open(banker);
|
||||
}
|
||||
|
||||
/// A bank-slot purchase refusal (`SMSG_BUY_BANK_SLOT_RESULT` — vmangos sends it only on failure;
|
||||
/// success is the PLAYER_BYTES_2 delta): queue it for the bank feed's red error line.
|
||||
pub(super) fn bank_buy_slot_result(result: u32, errors: &mut BankErrors) {
|
||||
debug!("net: bank slot purchase refused (code {result})");
|
||||
errors.0.push(result);
|
||||
}
|
||||
|
||||
/// A trainer's service list (`SMSG_TRAINER_LIST`): fill the [`TrainerOpen`] the trainer feed
|
||||
/// (`crate::ui_trainer`) reads.
|
||||
pub(super) fn trainer_list(
|
||||
trainer: u64,
|
||||
trainer_type: u32,
|
||||
services: Vec<TrainerSpell>,
|
||||
greeting: String,
|
||||
trainer_open: &mut TrainerOpen,
|
||||
) {
|
||||
debug!(
|
||||
"net: trainer {trainer:#x} (type {trainer_type}) listed {} services",
|
||||
services.len()
|
||||
);
|
||||
trainer_open.open(trainer, trainer_type, services, greeting);
|
||||
}
|
||||
|
||||
/// A trainer taught a service — confirmation only; the spell already landed via
|
||||
/// `SMSG_LEARNED_SPELL`. Re-request the list to repaint the bought row green→gray (and unlock
|
||||
/// any next rank); the server never auto-resends on a buy (VERIFIED vmangos
|
||||
/// `HandleTrainerBuySpellOpcode`). Guard on the window still being open for this trainer so a
|
||||
/// late reply for a closed/switched window doesn't re-open it.
|
||||
///
|
||||
/// This re-request is **benilla's**, not the reference's — the reference repaints a purchase by
|
||||
/// re-deriving every service's state client-side (`0x4d7d40`, decision 1128 §4.2) and receives no
|
||||
/// packet at all. So the answering list is marked as a *refresh* ([`TrainerOpen::refresh_pending`]):
|
||||
/// it repaints the open window, and must not carry the reference's per-packet filter/collapse reset,
|
||||
/// which is what dropped the player's "Available only" choice the moment they learned a spell
|
||||
/// (B256).
|
||||
pub(super) fn trainer_buy_succeeded(
|
||||
trainer: u64,
|
||||
spell_id: u32,
|
||||
trainer_open: &mut TrainerOpen,
|
||||
net_commands: &NetCommands,
|
||||
) {
|
||||
debug!("net: trainer {trainer:#x} taught spell {spell_id} — re-listing");
|
||||
if trainer_open.trainer == Some(trainer) {
|
||||
trainer_open.refresh_pending = true;
|
||||
let _ = net_commands.0.send(ClientCommand::TrainerList { trainer });
|
||||
}
|
||||
}
|
||||
|
||||
/// A stable master's pet list (`MSG_LIST_STABLED_PETS`): fill the [`StableOpen`] the stable feed
|
||||
/// (`crate::ui_stable`) reads. Arrives unprompted off the gossip stable option — that is how the
|
||||
/// window opens — and again in answer to our own refresh send.
|
||||
pub(super) fn list_stabled_pets(
|
||||
npc: u64,
|
||||
num_stable_slots: u8,
|
||||
pets: Vec<StabledPet>,
|
||||
stable_open: &mut StableOpen,
|
||||
names: &mut NameCache,
|
||||
) {
|
||||
debug!(
|
||||
"net: stable master {npc:#x} listed {} pets ({num_stable_slots} slots bought)",
|
||||
pets.len()
|
||||
);
|
||||
// **Seed the pet-name cache from the list** (decision 1688). Every row carries the pet's own
|
||||
// number and the name its owner gave it — the exact `(pet_number, name)` pair
|
||||
// `SMSG_PET_NAME_QUERY_RESPONSE` would answer with — so the pet a player unstables has a
|
||||
// resolvable `UnitName("pet")` the moment it is summoned, instead of after a round trip.
|
||||
//
|
||||
// The window this closes is a real one the director hit: `PetStable_Update` sets the current
|
||||
// pet's button tooltip to a bare `UnitName("pet")` (`PetStable.lua:161`, transcribed
|
||||
// unguarded because the reference is unguarded), and hands it to `GameTooltip:SetText`, whose
|
||||
// byte-pinned signature REQUIRES a string (`0x531b90`) and raises otherwise. Nil name ⇒ Lua
|
||||
// error dialog, once per unstable.
|
||||
//
|
||||
// Why the reference does not trip over its own unguarded line: its pet-name cache is
|
||||
// `petnamecache.wdb`, which **persists across sessions**, so a pet you have owned before is
|
||||
// already warm before the packet arrives. benilla has no such file, so a window the reference
|
||||
// has practically closed is wide open for us. Seeding is not a workaround for the missing
|
||||
// cache — it is using the answer the server has already sent us in this very packet.
|
||||
for pet in &pets {
|
||||
if !pet.name.is_empty() {
|
||||
names.insert_pet(pet.pet_number, pet.name.clone());
|
||||
}
|
||||
}
|
||||
stable_open.open(npc, num_stable_slots, pets);
|
||||
}
|
||||
|
||||
/// The answer to a stable verb (`SMSG_STABLE_RESULT`) — one byte, and the client's whole response
|
||||
/// to it is a five-way jump table (wow-re `system/ui/scratch/stable-master-window.md` §5, VERIFIED
|
||||
/// off the raw remap/jump bytes at `0x4cadac`/`0x4cad98`; decision 1677):
|
||||
///
|
||||
/// | code | what the client does |
|
||||
/// |---|---|
|
||||
/// | 1 | `DisplayError(0x25)` = **`ERR_NOT_ENOUGH_MONEY`** — the only code that says anything |
|
||||
/// | 2–7 | **absolutely nothing** — vmangos's catch-all `STABLE_ERR_STABLE = 6` included |
|
||||
/// | 8, 9 | re-request the list |
|
||||
/// | 10 | **`inc` the local purchased-slot count**, then re-request |
|
||||
/// | 11 | fire `PET_STABLE_UPDATE` (no vmangos counterpart — nothing in 1.12 sends it) |
|
||||
/// | 0, ≥12 | nothing |
|
||||
///
|
||||
/// The re-request is not a benilla convenience: no success carries an updated list, so without it
|
||||
/// the window would go on showing the pre-action arrangement.
|
||||
///
|
||||
/// **Code 10's local increment happens BEFORE the guid test**, so a buy-slot success arriving with
|
||||
/// no stable master open still bumps the count without refreshing. That ordering is reproduced
|
||||
/// deliberately — it is what makes the purchase row correct if the window is reopened, and the
|
||||
/// alternative would be to invent a tidier client than the one being reimplemented.
|
||||
pub(super) fn stable_result(
|
||||
result: u8,
|
||||
stable_open: &mut StableOpen,
|
||||
errors: &mut StableErrors,
|
||||
net_commands: &NetCommands,
|
||||
) {
|
||||
use benilla_protocol::messages::stable_result as code;
|
||||
// Ahead of the guid test, exactly as `0x4cacf3` sits ahead of `0x4cad05`.
|
||||
if result == code::SUCCESS_BUY_SLOT {
|
||||
stable_open.num_stable_slots = stable_open.num_stable_slots.saturating_add(1);
|
||||
}
|
||||
match result {
|
||||
// The one code that speaks. Its text is the reference's own `GlobalStrings` value for
|
||||
// `ERR_NOT_ENOUGH_MONEY`, reached through `DisplayError` row 0x25.
|
||||
code::ERR_MONEY => {
|
||||
debug!("net: stable purchase refused — not enough money");
|
||||
errors.0.push("ERR_NOT_ENOUGH_MONEY");
|
||||
}
|
||||
code::SUCCESS_STABLE | code::SUCCESS_UNSTABLE | code::SUCCESS_BUY_SLOT => {
|
||||
let Some(npc) = stable_open.npc else {
|
||||
debug!("net: stable success {result} with no open stable — no re-list");
|
||||
return;
|
||||
};
|
||||
debug!("net: stable action succeeded (code {result}) — re-listing");
|
||||
let _ = net_commands.0.send(ClientCommand::ListStabledPets { npc });
|
||||
}
|
||||
// Codes 2–7 (the generic ERR_STABLE among them), 0 and ≥12: the client shows NOTHING.
|
||||
// Not an omission — the catch-all is one code for six causes, and the reference declines to
|
||||
// guess which.
|
||||
_ => debug!("net: stable result {result} — no client-visible effect"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A trainer refused a purchase — the trainer window's error line.
|
||||
pub(super) fn trainer_buy_failed(error: u32, errors: &mut TrainerErrors) {
|
||||
debug!("net: trainer buy failed (code {error})");
|
||||
errors.0.push(error);
|
||||
}
|
||||
|
||||
/// A purchase updated the vendor's stock (`SMSG_BUY_ITEM`). Only touch stock for the open
|
||||
/// vendor (a late answer for a closed window is stale).
|
||||
pub(super) fn vendor_buy_result(
|
||||
vendor: u64,
|
||||
slot: u32,
|
||||
new_count: u32,
|
||||
merchant: &mut MerchantOpen,
|
||||
) {
|
||||
if merchant.vendor == Some(vendor) {
|
||||
merchant.update_stock(slot, new_count);
|
||||
}
|
||||
}
|
||||
|
||||
/// A purchase was refused (`SMSG_BUY_FAILED`) — the merchant window's error line, and for the
|
||||
/// out-of-stock code the refusing row's own count.
|
||||
///
|
||||
/// **`ITEM_ALREADY_SOLD` zeroes the row** (`0x5dcda7`..`0x5dcdd6`, decision 1821): the reference
|
||||
/// walks its 128-row vendor cache, writes 0 into the matching row's count word and repaints —
|
||||
/// gated, as every other stale-answer path here is, on the packet naming the vendor still open.
|
||||
///
|
||||
/// **NAMED DIVERGENCE in the key.** The reference matches the row's `+0x00`, the vendor *slot* —
|
||||
/// the same word `SMSG_BUY_ITEM` keys its stock update by. vmangos puts the item *entry* in that
|
||||
/// field (`Player::SendBuyError`, `Player.cpp:11637`: `packet->itemEntry = item`), so matching by
|
||||
/// slot would find nothing on the server we actually talk to; matching by entry is the same row.
|
||||
/// vmangos raises this code from exactly the condition the zeroing models —
|
||||
/// `GetVendorItemCurrentCount(crItem) < totalCount` under `crItem->maxcount != 0`
|
||||
/// (`Player.cpp:18508`).
|
||||
pub(super) fn vendor_buy_failed(
|
||||
vendor: u64,
|
||||
item_entry: u32,
|
||||
reason: u8,
|
||||
merchant: &mut MerchantOpen,
|
||||
errors: &mut MerchantErrors,
|
||||
) {
|
||||
debug!("net: buy failed (entry {item_entry}, reason {reason})");
|
||||
if reason == benilla_protocol::messages::buy_result::ITEM_ALREADY_SOLD
|
||||
&& merchant.vendor == Some(vendor)
|
||||
{
|
||||
merchant.sold_out(item_entry);
|
||||
}
|
||||
errors.0.push(MerchantRefusal::Buy(reason));
|
||||
}
|
||||
|
||||
/// A sell was refused (`SMSG_SELL_ITEM`'s error path) — the merchant window's error line.
|
||||
pub(super) fn vendor_sell_failed(reason: u8, errors: &mut MerchantErrors) {
|
||||
debug!("net: sell failed (reason {reason})");
|
||||
errors.0.push(MerchantRefusal::Sell(reason));
|
||||
}
|
||||
|
||||
/// The taxi map (`SMSG_SHOWTAXINODES`): fill the [`TaxiState`] phase 2's feed will read — the
|
||||
/// [`trainer_list`] shape.
|
||||
pub(super) fn taxi_nodes_shown(
|
||||
flightmaster: u64,
|
||||
nearest_node: u32,
|
||||
known_mask: TaxiMask,
|
||||
taxi: &mut TaxiState,
|
||||
) {
|
||||
debug!("net: taxi map on {flightmaster:#x} — nearest node {nearest_node}");
|
||||
taxi.open(flightmaster, nearest_node, known_mask);
|
||||
}
|
||||
|
||||
/// A taxi node's known status (`SMSG_TAXINODE_STATUS`) — answers `CMSG_TAXINODE_STATUS_QUERY`
|
||||
/// ([`crate::ui_taxi`]'s per-flight-master ask), and also rides the first-visit learn pair
|
||||
/// alongside `SMSG_NEW_TAXI_PATH` ([`taxi_new_path`] carries that signal into `discovered`; the
|
||||
/// paired `known = true` here is what clears the icon live on a learn). Upserts
|
||||
/// [`crate::ui_taxi::FlightMasterStatus`] on the flight master: `known = false` shows the green
|
||||
/// `TalkToMeGreen` overhead icon — the client's `0x5ecdd0` handler → `0x607480` marker swap
|
||||
/// (resource table `0xc4d9d8` index 4; the 0497 §5). The client also gates the reply on the
|
||||
/// unit's NPC_FLAGS bit 3 — moot here: only flight masters are ever queried or answered.
|
||||
pub(super) fn taxi_node_status(
|
||||
guid: u64,
|
||||
known: bool,
|
||||
commands: &mut Commands,
|
||||
index: &crate::net::GuidIndex,
|
||||
) {
|
||||
debug!("net: taxi node status — {guid:#x} known={known}");
|
||||
if let Some(&e) = index.0.get(&guid) {
|
||||
commands
|
||||
.entity(e)
|
||||
.insert(crate::ui_taxi::FlightMasterStatus { known });
|
||||
}
|
||||
}
|
||||
|
||||
/// The activate verdict (`SMSG_ACTIVATETAXIREPLY`) — staged for phase 2's window to surface as
|
||||
/// the red error line and clear (the trainer/merchant error-line pattern, folded into
|
||||
/// `TaxiState.reply` since there's no other consumer yet).
|
||||
pub(super) fn taxi_activate_reply(code: u32, taxi: &mut TaxiState) {
|
||||
debug!("net: activate taxi reply — code {code}");
|
||||
taxi.reply = Some(code);
|
||||
}
|
||||
|
||||
/// A first-visit "learn" landed (`SMSG_NEW_TAXI_PATH`, empty body) — vmangos's only send site
|
||||
/// (`SendLearnNewTaxiNode`) always pairs this with a `SMSG_TAXINODE_STATUS(known=true)` for the
|
||||
/// same flight master, so the empty body alone is the discovery signal; phase 2's presentation
|
||||
/// reads the flag.
|
||||
pub(super) fn taxi_new_path(taxi: &mut TaxiState) {
|
||||
debug!("net: taxi — first-visit node learned (SMSG_NEW_TAXI_PATH)");
|
||||
taxi.discovered = true;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `SMSG_SHOW_BANK` ends an open gossip interaction (the gossip menu's bank option arrives
|
||||
/// with no `SMSG_GOSSIP_COMPLETE` — `show_bank`'s doc): the gossip/quest sessions clear and
|
||||
/// the bank session opens. A direct right-click (no gossip open) just opens the bank.
|
||||
#[test]
|
||||
fn show_bank_ends_the_gossip_interaction() {
|
||||
let mut bank = BankOpen::default();
|
||||
let mut gossip = GossipState::default();
|
||||
let mut quest = QuestGiver::default();
|
||||
gossip.npc = Some(0x42);
|
||||
|
||||
show_bank(0x42, &mut bank, &mut gossip, &mut quest);
|
||||
assert_eq!(bank.banker, Some(0x42));
|
||||
assert_eq!(gossip.npc, None, "the gossip session ended with the menu");
|
||||
|
||||
// No gossip open: a plain open, nothing else touched.
|
||||
bank.clear();
|
||||
show_bank(0x43, &mut bank, &mut gossip, &mut quest);
|
||||
assert_eq!(bank.banker, Some(0x43));
|
||||
}
|
||||
}
|
||||
@@ -133,22 +133,12 @@ pub(crate) struct Session<'w> {
|
||||
pub(crate) struct WindowStores<'w> {
|
||||
pub names: ResMut<'w, crate::names::NameCache>,
|
||||
pub items: ResMut<'w, crate::items::Items>,
|
||||
pub gossip: ResMut<'w, crate::ui_gossip::GossipState>,
|
||||
pub merchant: ResMut<'w, crate::ui_merchant::MerchantOpen>,
|
||||
/// The two gossip-reached NPC service sessions whose state is a whole open window.
|
||||
pub trainer_open: ResMut<'w, crate::ui_trainer::TrainerOpen>,
|
||||
pub stable_open: ResMut<'w, crate::ui_stable::StableOpen>,
|
||||
/// The loot window state, the client-local loot-target latch (the kneel's self trigger,
|
||||
/// decision 0515), and the open group-loot rolls (decision 0591).
|
||||
pub loot: ResMut<'w, crate::ui_loot::LootState>,
|
||||
/// The client-local loot-target latch (the kneel's self trigger, decision 0515). The loot
|
||||
/// window's handlers own it since 2319 (`ui_loot::net`); it stays here for the one arm that
|
||||
/// still arms it — `SMSG_SPELL_GO` on a chest (decision 1477) — and leaves with the spells.
|
||||
pub loot_latch: ResMut<'w, crate::ui_loot::LootLatch>,
|
||||
pub loot_rolls: ResMut<'w, crate::ui_loot_roll::LootRolls>,
|
||||
pub chat_log: ResMut<'w, crate::ui_chat::ChatLog>,
|
||||
pub quest: ResMut<'w, crate::ui_quest::QuestGiver>,
|
||||
/// The quest-log template cache, and the party quest-share state (decision 1733) — the
|
||||
/// verdicts on a quest we pushed and the escort confirm: a share is a quest-log verb.
|
||||
pub quest_log: ResMut<'w, crate::ui_quest_log::QuestLog>,
|
||||
pub quest_share: ResMut<'w, crate::ui_quest_share::QuestShare>,
|
||||
pub go_templates: ResMut<'w, crate::go_templates::GameObjectTemplates>,
|
||||
pub home_bind: ResMut<'w, crate::net::HomeBind>,
|
||||
pub proficiencies: ResMut<'w, crate::net::Proficiencies>,
|
||||
@@ -158,11 +148,6 @@ pub(crate) struct WindowStores<'w> {
|
||||
pub death_net: ResMut<'w, crate::death::DeathNet>,
|
||||
/// The party/raid roster mirror + its composed system lines (decision 0434).
|
||||
pub group: ResMut<'w, crate::ui_party::GroupState>,
|
||||
/// The taxi-map session (decision 0484 phase 1).
|
||||
pub taxi: ResMut<'w, crate::ui_taxi::TaxiState>,
|
||||
/// The bank session and its purchase-refusal queue (decision 0604).
|
||||
pub bank_open: ResMut<'w, crate::ui_bank::BankOpen>,
|
||||
pub bank_errors: ResMut<'w, crate::ui_bank::BankErrors>,
|
||||
/// The world-state table the NPC-text `$<n>w` tokens read.
|
||||
pub world_states: ResMut<'w, crate::world_state::WorldStates>,
|
||||
/// Read-only here since 2312 — the social handlers own it (`ui_social::net`); the chat arm
|
||||
@@ -179,10 +164,6 @@ pub(crate) struct WindowStores<'w> {
|
||||
pub ui_error_keys: ResMut<'w, crate::ui_action::UiErrorKeys>,
|
||||
pub played_time_answer: ResMut<'w, crate::net::PlayedTimeAnswer>,
|
||||
pub tutorials: ResMut<'w, crate::tutorial::Tutorials>,
|
||||
/// The guard's directions marker (`SMSG_GOSSIP_POI`) — the wire carries no map field, so
|
||||
/// "where you were standing when the guard told you" is the client's to remember
|
||||
/// (`crate::poi_marker`).
|
||||
pub poi_marker: ResMut<'w, crate::poi_marker::PoiMarker>,
|
||||
}
|
||||
|
||||
/// The action bar's family: the cast/cooldown state and every error queue the red line drains
|
||||
@@ -207,15 +188,7 @@ pub(crate) struct ActionStores<'w> {
|
||||
/// reason the reference sorts before it fires: the tab index is only correct against the
|
||||
/// rebuilt tab list, which is the spellbook feed's, not this apply's.
|
||||
pub learned_in_tab: ResMut<'w, crate::ui_spellbook::LearnedInTab>,
|
||||
pub equip_errors: ResMut<'w, crate::ui_items::EquipErrors>,
|
||||
pub merchant_errors: ResMut<'w, crate::ui_merchant::MerchantErrors>,
|
||||
pub cast_bar: ResMut<'w, crate::ui_cast::CastBarFeed>,
|
||||
pub pending_item_ops: ResMut<'w, crate::pending_item_ops::PendingItemOps>,
|
||||
pub lock_transitions: ResMut<'w, crate::pending_item_ops::LockTransitions>,
|
||||
/// The two NPC-service windows' error queues, each drained onto its window's red line by its
|
||||
/// own feed.
|
||||
pub trainer_errors: ResMut<'w, crate::ui_trainer::TrainerErrors>,
|
||||
pub stable_errors: ResMut<'w, crate::ui_stable::StableErrors>,
|
||||
pub pending_cast: ResMut<'w, crate::ui_cast::PendingCast>,
|
||||
/// The player's cooldown store (decision 0137 phase 4); the pet's is in its bar, and
|
||||
/// `addressed_store` picks between them.
|
||||
@@ -307,6 +280,4 @@ pub(crate) struct Catalogs<'w> {
|
||||
/// 0828) — and the race-keyed discovery-jingle catalog (decision 0829).
|
||||
pub area_table: Option<Res<'w, crate::area::AreaTableRes>>,
|
||||
pub exploration_sounds: Option<Res<'w, crate::sound::ExplorationSounds>>,
|
||||
/// The map the guard's directions marker is stamped with.
|
||||
pub current_map: Option<Res<'w, benilla_world::world_map::CurrentMap>>,
|
||||
}
|
||||
|
||||
@@ -11,13 +11,7 @@ use bevy::prelude::*;
|
||||
use crate::items::Items;
|
||||
use crate::names::NameCache;
|
||||
use crate::ui_chat::ChatLog;
|
||||
use crate::ui_gossip::GossipState;
|
||||
use crate::ui_loot::LootState;
|
||||
use crate::ui_merchant::MerchantOpen;
|
||||
use crate::ui_quest::QuestGiver;
|
||||
use crate::ui_quest_log::QuestLog;
|
||||
use crate::ui_taxi::TaxiState;
|
||||
use crate::ui_trainer::TrainerOpen;
|
||||
|
||||
use super::super::{
|
||||
CharActionResultMessage, CharListMessage, CharacterLoginFailedMessage,
|
||||
@@ -177,20 +171,9 @@ pub(super) fn disconnected(
|
||||
status: &mut NetStatus,
|
||||
names: &mut NameCache,
|
||||
items: &mut Items,
|
||||
gossip: &mut GossipState,
|
||||
merchant: &mut MerchantOpen,
|
||||
trainer_open: &mut TrainerOpen,
|
||||
loot: &mut LootState,
|
||||
loot_latch: &mut crate::ui_loot::LootLatch,
|
||||
loot_rolls: &mut crate::ui_loot_roll::LootRolls,
|
||||
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,
|
||||
bank: &mut crate::ui_bank::BankOpen,
|
||||
cooldowns: &mut crate::cooldowns::Cooldowns,
|
||||
pending_transfer: &mut PendingTransfer,
|
||||
disconnects: &mut MessageWriter<DisconnectedMessage>,
|
||||
@@ -236,22 +219,8 @@ pub(super) fn disconnected(
|
||||
// In-flight name queries died with the socket; let the next resolve re-ask.
|
||||
names.clear_pending();
|
||||
items.clear_session();
|
||||
gossip.clear_session();
|
||||
merchant.clear_session();
|
||||
trainer_open.clear_session();
|
||||
loot.clear_session();
|
||||
loot_latch.0 = None; // the kneel latch dies with the socket (unconditional here)
|
||||
loot_rolls.clear(); // open group rolls die with the socket (decision 0591)
|
||||
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();
|
||||
// The bank window dies with the socket (decision 0604) — a reconnect re-opens via the banker.
|
||||
bank.clear_session();
|
||||
// The cooldown list is session-scoped — the next login may be a different character — and
|
||||
// had been missing from this sweep since it was built (decision 2116). `SMSG_INITIAL_SPELLS` carries every cooldown
|
||||
// still running at every world entry and `seed_initial` APPENDS, so a list that outlives the
|
||||
|
||||
@@ -138,11 +138,57 @@ fn clear_on_world_entry(
|
||||
}
|
||||
}
|
||||
|
||||
/// The marker's packet handler (in the net handler table since 2318, moved out of the drain's npc
|
||||
/// arm file) — registered from [`PoiMarkerPlugin`].
|
||||
fn on_gossip_poi(
|
||||
In(ev): In<benilla_protocol::SessionEvent>,
|
||||
mut marker: ResMut<PoiMarker>,
|
||||
current_map: Option<Res<benilla_world::world_map::CurrentMap>>,
|
||||
real_clock: Res<Time<Real>>,
|
||||
) {
|
||||
if let benilla_protocol::SessionEvent::GossipPoi(poi) = ev {
|
||||
gossip_poi(
|
||||
&poi,
|
||||
&mut marker,
|
||||
current_map.as_ref().map_or(0, |m| m.0),
|
||||
real_clock.elapsed_secs_f64(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The guard's directions (`SMSG_GOSSIP_POI`): drop the marker at that spot. Volunteered by the
|
||||
/// server for a gossip option carrying an `action_poi_id` — it answers nothing we asked for, and
|
||||
/// it does **not** end the gossip session on its own (vmangos `Player::OnGossipSelect`'s
|
||||
/// `GOSSIP_OPTION_GOSSIP` arm sends the POI *before* it decides whether to move the menu on, leave
|
||||
/// it, or close it).
|
||||
///
|
||||
/// `map_id` is the map the player is standing on, which is the only place the marker can mean
|
||||
/// anything — the wire carries no map field, and the reference reads its own current-map global at
|
||||
/// exactly this point. `now_secs` starts the marker's 8-minute clock (this module); it
|
||||
/// is the same real clock the corpse reclaim delay is stamped against (decision 0846).
|
||||
fn gossip_poi(
|
||||
poi: &benilla_protocol::messages::GossipPoi,
|
||||
marker: &mut PoiMarker,
|
||||
map_id: u32,
|
||||
now_secs: f64,
|
||||
) {
|
||||
debug!(
|
||||
"net: directions to \"{}\" at ({:.1}, {:.1}) — icon {}, flags {:#x}",
|
||||
poi.name, poi.pos[0], poi.pos[1], poi.icon, poi.flags
|
||||
);
|
||||
marker.set(poi, map_id, now_secs);
|
||||
}
|
||||
|
||||
/// The guard's directions marker — see the module doc.
|
||||
pub(crate) struct PoiMarkerPlugin;
|
||||
|
||||
impl Plugin for PoiMarkerPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
{
|
||||
use crate::net::NetHandlerApp;
|
||||
use benilla_protocol::SessionEventKind as K;
|
||||
app.net_handler(K::GossipPoi, on_gossip_poi);
|
||||
}
|
||||
app.init_resource::<PoiMarker>()
|
||||
.add_systems(Update, (expire_marker, clear_on_world_entry));
|
||||
}
|
||||
|
||||
@@ -83,10 +83,13 @@ impl NpcSession for BankOpen {
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct BankErrors(pub Vec<u32>);
|
||||
|
||||
mod net;
|
||||
|
||||
pub(crate) struct UiBankPlugin;
|
||||
|
||||
impl Plugin for UiBankPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
net::register(app);
|
||||
app.init_resource::<BankOpen>()
|
||||
.init_resource::<BankErrors>()
|
||||
.add_systems(
|
||||
@@ -0,0 +1,97 @@
|
||||
//! The bank's packet handlers (decision 0604; in the net handler table since 2318, moved out of
|
||||
//! the drain's npc arm file) — the [`BankOpen`] session and the [`BankErrors`] line queue the bank
|
||||
//! feed ([`super`]) reads.
|
||||
|
||||
use benilla_protocol::{SessionEvent, SessionEventKind};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::{BankErrors, BankOpen};
|
||||
use crate::net::NetHandlerApp;
|
||||
use crate::ui_gossip::GossipState;
|
||||
use crate::ui_quest::QuestGiver;
|
||||
|
||||
/// Register the bank handlers — called from [`super::UiBankPlugin`]. One per kind, plus the
|
||||
/// session-end listener.
|
||||
pub(super) fn register(app: &mut App) {
|
||||
use SessionEventKind as K;
|
||||
app.net_handler(K::ShowBank, on_show_bank)
|
||||
.net_handler(K::BuyBankSlotResult, on_buy_slot_result)
|
||||
.net_handler(K::Disconnected, on_session_end);
|
||||
}
|
||||
|
||||
fn on_show_bank(
|
||||
In(ev): In<SessionEvent>,
|
||||
mut bank: ResMut<BankOpen>,
|
||||
mut gossip: ResMut<GossipState>,
|
||||
mut quest: ResMut<QuestGiver>,
|
||||
) {
|
||||
if let SessionEvent::ShowBank { banker } = ev {
|
||||
show_bank(banker, &mut bank, &mut gossip, &mut quest);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_buy_slot_result(In(ev): In<SessionEvent>, mut errors: ResMut<BankErrors>) {
|
||||
if let SessionEvent::BuyBankSlotResult { result } = ev {
|
||||
bank_buy_slot_result(result, &mut errors);
|
||||
}
|
||||
}
|
||||
|
||||
/// The bank window dies with the socket (decision 0604) — a reconnect re-opens via the banker.
|
||||
/// A listener on the session end ([`crate::net::handlers::BROADCAST`]).
|
||||
fn on_session_end(In(_): In<SessionEvent>, mut bank: ResMut<BankOpen>) {
|
||||
bank.clear_session();
|
||||
}
|
||||
|
||||
/// The bank opened (`SMSG_SHOW_BANK`): point the [`BankOpen`] session the bank feed
|
||||
/// ([`super`]) reads at the banker. Sent for our own `CMSG_BANKER_ACTIVATE` *and*
|
||||
/// volunteered by the server for the gossip menu's bank option (`GOSSIP_OPTION_BANKER` →
|
||||
/// `SendShowBank`, decision 0604) — so this never assumes we asked. The vault's contents are
|
||||
/// descriptor fields already streamed; the window renders from local state.
|
||||
///
|
||||
/// Opening the bank ends any open gossip interaction (the `SMSG_GOSSIP_COMPLETE` clear): vmangos
|
||||
/// sends no `SMSG_GOSSIP_COMPLETE` for the gossip menu's bank option (VERIFIED
|
||||
/// `Player::OnGossipSelect` — BANKER only calls `SendShowBank`), and the panel slots alone can't
|
||||
/// close the menu — the bank's `pushable = 6` sends it to the *center* slot beside a pushable-0
|
||||
/// gossip instead of replacing it, so the menu would linger beside the vault
|
||||
/// (director-observed). The real client ends the old NPC interaction when the new one starts
|
||||
/// (INFERRED — the exact C++ `GOSSIP_CLOSED` fire isn't RE'd; the observable is vanilla's: the
|
||||
/// menu is gone once the vault is up).
|
||||
fn show_bank(banker: u64, bank: &mut BankOpen, gossip: &mut GossipState, quest: &mut QuestGiver) {
|
||||
debug!("net: bank opened at {banker:#x}");
|
||||
if gossip.npc.is_some() {
|
||||
crate::ui_gossip::end_interaction(gossip, quest);
|
||||
}
|
||||
bank.open(banker);
|
||||
}
|
||||
|
||||
/// A bank-slot purchase refusal (`SMSG_BUY_BANK_SLOT_RESULT` — vmangos sends it only on failure;
|
||||
/// success is the PLAYER_BYTES_2 delta): queue it for the bank feed's red error line.
|
||||
fn bank_buy_slot_result(result: u32, errors: &mut BankErrors) {
|
||||
debug!("net: bank slot purchase refused (code {result})");
|
||||
errors.0.push(result);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `SMSG_SHOW_BANK` ends an open gossip interaction (the gossip menu's bank option arrives
|
||||
/// with no `SMSG_GOSSIP_COMPLETE` — `show_bank`'s doc): the gossip/quest sessions clear and
|
||||
/// the bank session opens. A direct right-click (no gossip open) just opens the bank.
|
||||
#[test]
|
||||
fn show_bank_ends_the_gossip_interaction() {
|
||||
let mut bank = BankOpen::default();
|
||||
let mut gossip = GossipState::default();
|
||||
let mut quest = QuestGiver::default();
|
||||
gossip.npc = Some(0x42);
|
||||
|
||||
show_bank(0x42, &mut bank, &mut gossip, &mut quest);
|
||||
assert_eq!(bank.banker, Some(0x42));
|
||||
assert_eq!(gossip.npc, None, "the gossip session ended with the menu");
|
||||
|
||||
// No gossip open: a plain open, nothing else touched.
|
||||
bank.clear();
|
||||
show_bank(0x43, &mut bank, &mut gossip, &mut quest);
|
||||
assert_eq!(bank.banker, Some(0x43));
|
||||
}
|
||||
}
|
||||
@@ -189,11 +189,15 @@ impl GossipState {
|
||||
}
|
||||
}
|
||||
|
||||
mod net;
|
||||
pub(crate) use net::gossip_complete as end_interaction;
|
||||
|
||||
/// The gossip window's feed + drain (decision 0081), cloned from [`crate::ui_items::UiItemsPlugin`].
|
||||
pub(crate) struct UiGossipPlugin;
|
||||
|
||||
impl Plugin for UiGossipPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
net::register(app);
|
||||
app.init_resource::<GossipState>().add_systems(
|
||||
Update,
|
||||
(
|
||||
@@ -0,0 +1,146 @@
|
||||
//! The gossip window's packet handlers (decision 0081 phase 3; in the net handler table since
|
||||
//! 2318, moved out of the drain's npc arm file) — each fills the [`GossipState`] the gossip feed
|
||||
//! ([`super`]) reads; nothing here touches the VM.
|
||||
|
||||
use benilla_protocol::messages::{GossipOption, NpcTextBlock};
|
||||
use benilla_protocol::{SessionEvent, SessionEventKind};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::GossipState;
|
||||
use crate::net::{ClientCommand, GuidIndex, NetCommands, NetHandlerApp, ObjectStore};
|
||||
use crate::ui_quest::QuestGiver;
|
||||
|
||||
/// Register the gossip handlers — called from [`super::UiGossipPlugin`]. One per kind, plus the
|
||||
/// session-end listener.
|
||||
pub(super) fn register(app: &mut App) {
|
||||
use SessionEventKind as K;
|
||||
app.net_handler(K::GossipMenu, on_gossip_menu)
|
||||
.net_handler(K::NpcGreeting, on_npc_greeting)
|
||||
.net_handler(K::GossipComplete, on_gossip_complete)
|
||||
.net_handler(K::Disconnected, on_session_end);
|
||||
}
|
||||
|
||||
fn on_gossip_menu(
|
||||
In(ev): In<SessionEvent>,
|
||||
mut gossip: ResMut<GossipState>,
|
||||
commands: Res<NetCommands>,
|
||||
index: Res<GuidIndex>,
|
||||
stores: Query<&ObjectStore>,
|
||||
) {
|
||||
if let SessionEvent::GossipMenu {
|
||||
npc,
|
||||
text_id,
|
||||
options,
|
||||
quests,
|
||||
} = ev
|
||||
{
|
||||
gossip_menu(
|
||||
npc,
|
||||
text_id,
|
||||
options,
|
||||
quests,
|
||||
&mut gossip,
|
||||
&commands,
|
||||
&index,
|
||||
&stores,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_npc_greeting(
|
||||
In(ev): In<SessionEvent>,
|
||||
mut gossip: ResMut<GossipState>,
|
||||
index: Res<GuidIndex>,
|
||||
stores: Query<&ObjectStore>,
|
||||
) {
|
||||
if let SessionEvent::NpcGreeting { text_id, blocks } = ev {
|
||||
npc_greeting(text_id, blocks, &mut gossip, &index, &stores);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_gossip_complete(
|
||||
In(ev): In<SessionEvent>,
|
||||
mut gossip: ResMut<GossipState>,
|
||||
mut quest: ResMut<QuestGiver>,
|
||||
) {
|
||||
if let SessionEvent::GossipComplete = ev {
|
||||
gossip_complete(&mut gossip, &mut quest);
|
||||
}
|
||||
}
|
||||
|
||||
/// An open gossip menu dies with the socket. A listener on the session end
|
||||
/// ([`crate::net::handlers::BROADCAST`]).
|
||||
fn on_session_end(In(_): In<SessionEvent>, mut gossip: ResMut<GossipState>) {
|
||||
gossip.clear_session();
|
||||
}
|
||||
|
||||
/// A streamed unit's gender (`UNIT_FIELD_BYTES_0` byte 2) by guid — the gossip greeting's column
|
||||
/// selector (wow-re `gossip-npctext-law.md`: tested `== 1` for female, so genderless `2` reads as
|
||||
/// male). `0` when the guid isn't streamed in or carries no descriptor yet, which is the same
|
||||
/// column the reference takes for a gossip target that isn't a unit at all.
|
||||
fn npc_gender(guid: u64, index: &GuidIndex, stores: &Query<&ObjectStore>) -> u8 {
|
||||
index
|
||||
.0
|
||||
.get(&guid)
|
||||
.and_then(|&e| stores.get(e).ok())
|
||||
.and_then(|s| s.0.unit_gender())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// A gossip menu opened (`SMSG_GOSSIP_MESSAGE`): fill the [`GossipState`] the gossip feed
|
||||
/// ([`super`]) reads. A first visit to the text id sends the ask-once
|
||||
/// `CMSG_NPC_TEXT_QUERY` and **the feed fires nothing until [`npc_greeting`] answers it** — a
|
||||
/// hidden frame stays hidden, an open one keeps its previous menu painted (B292's hold and
|
||||
/// 1994's no-event edge; the mechanics and the reference law live on
|
||||
/// [`GossipState::open_menu`]); a revisit serves from the cache and repaints right away.
|
||||
/// [`gossip_complete`] closes it.
|
||||
///
|
||||
/// The greeting is **drawn here**, not at the packet — this is the reference's own moment for it
|
||||
/// (`0x4e2010`), and the draw needs both this NPC's gender ([`npc_gender`]) and a fresh roll.
|
||||
fn gossip_menu(
|
||||
npc: u64,
|
||||
text_id: u32,
|
||||
options: Vec<GossipOption>,
|
||||
quests: Vec<(u32, u32, u32, String)>,
|
||||
gossip: &mut GossipState,
|
||||
net_commands: &NetCommands,
|
||||
index: &GuidIndex,
|
||||
stores: &Query<&ObjectStore>,
|
||||
) {
|
||||
let npc_gender = npc_gender(npc, index, stores);
|
||||
debug!(
|
||||
"net: gossip menu on {npc:#x} — {} options, {} quests",
|
||||
options.len(),
|
||||
quests.len()
|
||||
);
|
||||
if gossip.open_menu(npc, text_id, options, quests, npc_gender) {
|
||||
let _ = net_commands
|
||||
.0
|
||||
.send(ClientCommand::NpcTextQuery { text_id, guid: npc });
|
||||
}
|
||||
}
|
||||
|
||||
/// The NPC-text answer (`SMSG_NPC_TEXT_UPDATE`) — seed the cache with the whole record, and open
|
||||
/// the menu still waiting on it (a late answer for a menu we already closed or switched just
|
||||
/// seeds the cache; the next open draws its own line).
|
||||
///
|
||||
/// The record answers a query we sent for the waiting menu, so its NPC is the one whose gender
|
||||
/// picks the column (decision 0081's ask-once flow).
|
||||
fn npc_greeting(
|
||||
text_id: u32,
|
||||
blocks: Vec<NpcTextBlock>,
|
||||
gossip: &mut GossipState,
|
||||
index: &GuidIndex,
|
||||
stores: &Query<&ObjectStore>,
|
||||
) {
|
||||
let npc_gender = gossip.npc.map_or(0, |npc| npc_gender(npc, index, stores));
|
||||
gossip.text_arrived(text_id, blocks, npc_gender);
|
||||
}
|
||||
|
||||
/// `SMSG_GOSSIP_COMPLETE` ends the whole interaction (e.g. right after a quest accept), so the
|
||||
/// quest window closes with the gossip menu (decision 0088).
|
||||
pub(crate) fn gossip_complete(gossip: &mut GossipState, quest: &mut QuestGiver) {
|
||||
debug!("net: gossip complete — closing the menu");
|
||||
gossip.clear();
|
||||
quest.clear();
|
||||
}
|
||||
@@ -50,6 +50,7 @@ use crate::ui_unit::UnitFeed;
|
||||
mod drain;
|
||||
mod equip_error;
|
||||
pub(crate) mod feed;
|
||||
mod net;
|
||||
|
||||
pub(crate) use drain::send_auto_equip;
|
||||
use drain::{
|
||||
@@ -1143,6 +1144,7 @@ pub(crate) struct UiItemsPlugin;
|
||||
|
||||
impl Plugin for UiItemsPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
net::register(app);
|
||||
// The icon source — `ItemDisplayInfo.dbc` — is the `ItemDisplays` resource the equipment
|
||||
// renderer already loads (one parse serves the world and the bags).
|
||||
app.init_resource::<EquipErrors>()
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
//! The inventory refusal's packet handler (in the net handler table since 2319, moved out of the
|
||||
//! drain's loot arm file) — `SMSG_INVENTORY_CHANGE_FAILURE` onto the equip-error line, the pending
|
||||
//! item locks, and the loot latch's sixth clear.
|
||||
|
||||
use benilla_protocol::{SessionEvent, SessionEventKind};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::{EquipError, EquipErrors};
|
||||
use crate::net::NetHandlerApp;
|
||||
use crate::pending_item_ops::{LockTransitions, PendingItemOps};
|
||||
use crate::ui_loot::LootLatch;
|
||||
|
||||
/// Register the handler — called from [`super::UiItemsPlugin`].
|
||||
pub(super) fn register(app: &mut App) {
|
||||
app.net_handler(SessionEventKind::InventoryFailure, on_inventory_failure);
|
||||
}
|
||||
|
||||
fn on_inventory_failure(
|
||||
In(ev): In<SessionEvent>,
|
||||
mut equip_errors: ResMut<EquipErrors>,
|
||||
mut pending: ResMut<PendingItemOps>,
|
||||
mut lock_cleared: ResMut<LockTransitions>,
|
||||
mut latch: ResMut<LootLatch>,
|
||||
) {
|
||||
if let SessionEvent::InventoryFailure {
|
||||
reason,
|
||||
required_level,
|
||||
item_guid,
|
||||
bag_slot,
|
||||
} = ev
|
||||
{
|
||||
inventory_failure(
|
||||
reason,
|
||||
required_level,
|
||||
item_guid,
|
||||
bag_slot,
|
||||
&mut equip_errors,
|
||||
&mut pending,
|
||||
&mut lock_cleared,
|
||||
&mut latch,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The server refused an inventory operation (`SMSG_INVENTORY_CHANGE_FAILURE` — equip level,
|
||||
/// proficiency, bag full, …): the UI error line's inventory vocabulary, the equip twin of the
|
||||
/// cast-result failure path. Also the pending-lock's failure-driven clear (decision 0216 §4 /
|
||||
/// 0218 §3): every arrival here already has reason ≠ 0 (reason 0 is filtered before this event
|
||||
/// exists at all — `benilla_protocol::events`'s `if reason != 0` guard), so it always tries a
|
||||
/// [`PendingItemOps::clear_by_failure`]. This site has no `UiScript` to fire `ITEM_LOCK_CHANGED`
|
||||
/// through, so the transitioned slots queue in [`LockTransitions`] for the container feed
|
||||
/// ([`super::feed::feed_containers`]) to drain and fire next time it runs.
|
||||
fn inventory_failure(
|
||||
reason: u8,
|
||||
required_level: Option<u32>,
|
||||
item_guid: u64,
|
||||
bag_slot: u8,
|
||||
equip_errors: &mut EquipErrors,
|
||||
pending: &mut PendingItemOps,
|
||||
lock_cleared: &mut LockTransitions,
|
||||
latch: &mut LootLatch,
|
||||
) {
|
||||
debug!("net: inventory failure {reason:#04x} (item {item_guid:#x}, bag slot {bag_slot})");
|
||||
equip_errors.0.push(EquipError {
|
||||
reason,
|
||||
required_level,
|
||||
bag_slot,
|
||||
});
|
||||
lock_cleared.0.extend(pending.clear_by_failure(item_guid));
|
||||
// The sixth loot-latch clear (`0x5e3a84`, wow-re `loot-anim-leg.md` §5; decision 1477): when
|
||||
// the packet's **first item guid** is the object we are looting, the session ends here. It is
|
||||
// how an item-container loot (a lockbox) closes when the move out of it fails — the one clear
|
||||
// the 1471 census was missing. Guid-matched, as the bytes are.
|
||||
if item_guid != 0 {
|
||||
latch.clear_for(item_guid);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The sixth clear (`0x5e3a84`): an inventory-move failure whose first item guid IS the loot
|
||||
/// target ends that session. Guid-matched, so an unrelated bag failure leaves it alone.
|
||||
#[test]
|
||||
fn an_inventory_failure_on_the_looted_object_clears_the_latch() {
|
||||
const LOCKBOX: u64 = 0x4000_0000_0000_0007;
|
||||
let mut errs = EquipErrors::default();
|
||||
let mut pending = PendingItemOps::default();
|
||||
let mut cleared = LockTransitions::default();
|
||||
let mut latch = LootLatch(Some(LOCKBOX));
|
||||
|
||||
// An unrelated item's failure must not end the session.
|
||||
inventory_failure(
|
||||
2,
|
||||
None,
|
||||
0x1234,
|
||||
0,
|
||||
&mut errs,
|
||||
&mut pending,
|
||||
&mut cleared,
|
||||
&mut latch,
|
||||
);
|
||||
assert_eq!(latch.0, Some(LOCKBOX));
|
||||
|
||||
inventory_failure(
|
||||
2,
|
||||
None,
|
||||
LOCKBOX,
|
||||
0,
|
||||
&mut errs,
|
||||
&mut pending,
|
||||
&mut cleared,
|
||||
&mut latch,
|
||||
);
|
||||
assert_eq!(latch.0, None);
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,8 @@ use crate::ui_items::KEYRING_CONTAINER;
|
||||
use crate::ui_party::{GroupState, GROUPTYPE_RAID, GROUP_MEMBER_SUBGROUP};
|
||||
use crate::ui_script::{UiFeed, UiInput};
|
||||
|
||||
mod net;
|
||||
|
||||
/// The coin-pile row icons (direct `Interface\Icons` paths — `SetTexture` takes them as-is, no DBC),
|
||||
/// **six of them, one per decade of copper**, all VERIFIED to extract from `interface.MPQ`.
|
||||
///
|
||||
@@ -630,6 +632,7 @@ pub(crate) fn on_cvar(ev: On<crate::cvars::CvarChanged>, mut loot: ResMut<LootCo
|
||||
|
||||
impl Plugin for UiLootPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
net::register(app);
|
||||
app.add_observer(on_cvar);
|
||||
app.init_resource::<LootState>()
|
||||
.init_resource::<LootConfig>()
|
||||
+131
-144
@@ -1,23 +1,126 @@
|
||||
//! Loot-window + item-template/inventory-failure arm bodies for [`super::apply_net_updates`]'s
|
||||
//! dispatch match — one of the largest arm families, split out on its own (decision 0084's loot
|
||||
//! feed, plus the item-template ask-once cache and the equip-refusal UI line). Each `pub(super)` fn
|
||||
//! here is exactly one arm's body; the match at the call site stays the dispatcher, one call per
|
||||
//! arm.
|
||||
//! The loot window's packet handlers (decision 0084; in the net handler table since 2319, moved
|
||||
//! out of the drain's loot arm file) — the [`LootState`] session and the [`LootLatch`] the loot
|
||||
//! feed ([`super`]) reads, the fishing verdicts, and the item push that prints "You receive …".
|
||||
//! The group rolls are [`crate::ui_loot_roll`]'s and the inventory refusal is
|
||||
//! [`crate::ui_items`]'s.
|
||||
|
||||
use benilla_protocol::messages::{
|
||||
ItemPushResult, LootAllPassed, LootItem, LootRoll, LootRollWon, LootStartRoll,
|
||||
};
|
||||
use benilla_protocol::ItemInfo;
|
||||
use benilla_protocol::messages::{ItemPushResult, LootItem};
|
||||
use benilla_protocol::{SessionEvent, SessionEventKind};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::super::SelfGuid;
|
||||
use crate::items::Items;
|
||||
use crate::net::ClientCommand;
|
||||
use crate::pending_item_ops::{LockTransitions, PendingItemOps};
|
||||
use super::{LootLatch, LootState};
|
||||
use crate::net::{ClientCommand, NetCommands, NetHandlerApp, SelfGuid};
|
||||
use crate::ui_action::{UiError, UiErrorKeys};
|
||||
use crate::ui_items::{EquipError, EquipErrors};
|
||||
use crate::ui_loot::{LootLatch, LootState};
|
||||
use crate::ui_loot_roll::LootRolls;
|
||||
|
||||
/// Register the loot handlers — called from [`super::UiLootPlugin`]. One per kind, plus the
|
||||
/// session-end listener.
|
||||
pub(super) fn register(app: &mut App) {
|
||||
use SessionEventKind as K;
|
||||
app.net_handler(K::LootResponse, on_response)
|
||||
.net_handler(K::LootError, on_error)
|
||||
.net_handler(K::LootRemoved, on_removed)
|
||||
.net_handler(K::LootMoneyNotify, on_money_notify)
|
||||
.net_handler(K::LootClearMoney, on_clear_money)
|
||||
.net_handler(K::LootReleaseResponse, on_release_response)
|
||||
.net_handler(K::LootMasterList, on_master_list)
|
||||
.net_handler(K::FishNotHooked, on_fish_verdict)
|
||||
.net_handler(K::FishEscaped, on_fish_verdict)
|
||||
.net_handler(K::ItemPushResult, on_item_push_result)
|
||||
.net_handler(K::Disconnected, on_session_end);
|
||||
}
|
||||
|
||||
fn on_response(
|
||||
In(ev): In<SessionEvent>,
|
||||
mut loot: ResMut<LootState>,
|
||||
mut latch: ResMut<LootLatch>,
|
||||
commands: Res<NetCommands>,
|
||||
) {
|
||||
if let SessionEvent::LootResponse {
|
||||
guid,
|
||||
loot_type,
|
||||
gold,
|
||||
items,
|
||||
} = ev
|
||||
{
|
||||
loot_response(
|
||||
guid, loot_type, gold, items, &mut loot, &mut latch, &commands,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_error(
|
||||
In(ev): In<SessionEvent>,
|
||||
mut errors: ResMut<UiErrorKeys>,
|
||||
mut latch: ResMut<LootLatch>,
|
||||
) {
|
||||
if let SessionEvent::LootError { guid, error } = ev {
|
||||
loot_error(guid, error, &mut errors, &mut latch);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_removed(In(ev): In<SessionEvent>, mut loot: ResMut<LootState>) {
|
||||
if let SessionEvent::LootRemoved { slot } = ev {
|
||||
loot_removed(slot, &mut loot);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_money_notify(In(ev): In<SessionEvent>) {
|
||||
if let SessionEvent::LootMoneyNotify { amount } = ev {
|
||||
loot_money_notify(amount);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_clear_money(In(ev): In<SessionEvent>, mut loot: ResMut<LootState>) {
|
||||
if let SessionEvent::LootClearMoney = ev {
|
||||
loot_clear_money(&mut loot);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_release_response(
|
||||
In(ev): In<SessionEvent>,
|
||||
mut loot: ResMut<LootState>,
|
||||
mut latch: ResMut<LootLatch>,
|
||||
) {
|
||||
if let SessionEvent::LootReleaseResponse { guid } = ev {
|
||||
loot_release_response(guid, &mut loot, &mut latch);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_master_list(In(ev): In<SessionEvent>, mut loot: ResMut<LootState>) {
|
||||
if let SessionEvent::LootMasterList { candidates } = ev {
|
||||
loot_master_list(candidates, &mut loot);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_fish_verdict(In(ev): In<SessionEvent>, mut errors: ResMut<UiErrorKeys>) {
|
||||
match ev {
|
||||
SessionEvent::FishNotHooked => fish_verdict(false, &mut errors),
|
||||
SessionEvent::FishEscaped => fish_verdict(true, &mut errors),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_item_push_result(
|
||||
In(ev): In<SessionEvent>,
|
||||
self_guid: Res<SelfGuid>,
|
||||
mut loot: ResMut<LootState>,
|
||||
mut tutorials: ResMut<crate::tutorial::Tutorials>,
|
||||
) {
|
||||
if let SessionEvent::ItemPushResult(p) = ev {
|
||||
item_push_result(p, &self_guid, &mut loot, &mut tutorials);
|
||||
}
|
||||
}
|
||||
|
||||
/// An open loot window and the kneel latch die with the socket (unconditionally). A listener on
|
||||
/// the session end ([`crate::net::handlers::BROADCAST`]).
|
||||
fn on_session_end(
|
||||
In(_): In<SessionEvent>,
|
||||
mut loot: ResMut<LootState>,
|
||||
mut latch: ResMut<LootLatch>,
|
||||
) {
|
||||
loot.clear_session();
|
||||
latch.0 = None;
|
||||
}
|
||||
|
||||
/// The wire `loot_type` values `SMSG_LOOT_RESPONSE`'s **cold-latch** admission accepts
|
||||
/// (`0x5eb94b`/`0x5eb953`/`0x5eb95b`) — vmangos `LootType`'s `PICKPOCKETING(2)` · `FISHING(3)` ·
|
||||
@@ -46,14 +149,14 @@ const SERVER_STARTED_LOOT: [u8; 3] = [2, 3, 4];
|
||||
/// The accepted arm writes the latch verbatim (`0x5ebb60`) and — verified over the whole success
|
||||
/// path — calls **neither** the base-anim recompute nor the Loot-50 force-play. A chest is already
|
||||
/// kneeling by now: its arm was `SMSG_SPELL_GO` (§6, [`super::spells`]), one packet earlier.
|
||||
pub(super) fn loot_response(
|
||||
fn loot_response(
|
||||
guid: u64,
|
||||
loot_type: u8,
|
||||
gold: u32,
|
||||
items: Vec<LootItem>,
|
||||
loot: &mut LootState,
|
||||
latch: &mut LootLatch,
|
||||
net: &crate::net::NetCommands,
|
||||
net: &NetCommands,
|
||||
) {
|
||||
// `0x5eb924`–`0x5eb95b`, transcribed.
|
||||
let accept = match latch.0 {
|
||||
@@ -75,7 +178,7 @@ pub(super) fn loot_response(
|
||||
return;
|
||||
}
|
||||
// The loot window opens (decision 0084): fill LootState from the wire; the feed
|
||||
// ([`crate::ui_loot`]) resolves rows + fires LOOT_OPENED next frame. `loot_type` rides along
|
||||
// ([`super`]) resolves rows + fires LOOT_OPENED next frame. `loot_type` rides along
|
||||
// for `IsFishingLoot()` (decision 1086).
|
||||
debug!(
|
||||
"net: loot response {guid:#x} type {loot_type} gold {gold} {} item(s)",
|
||||
@@ -85,7 +188,7 @@ pub(super) fn loot_response(
|
||||
// `0x5ebb60` — the packet's guid, verbatim. Re-arming a corpse's already-matching latch is a
|
||||
// no-op; a chest re-arms what `SMSG_SPELL_GO` armed; a **fishing** bobber arms here for the
|
||||
// first time and still does not kneel, because the pose is predicate B's call, not the
|
||||
// latch's ([`crate::ui_loot::LootKneel`]).
|
||||
// latch's ([`super::LootKneel`]).
|
||||
latch.0 = Some(guid);
|
||||
}
|
||||
|
||||
@@ -96,7 +199,7 @@ pub(super) fn loot_response(
|
||||
/// red: the reference handlers (`0x5e3fc5`/`0x5e3fe2` → `DisplayError` ids `0x13e`/`0x13f`) are
|
||||
/// **type-1** registry entries, which fire `UI_INFO_MESSAGE` — byte-verified in wow-re
|
||||
/// `fish-msg-handlers.md`, correcting 1086's shipped guess (the fold-back record).
|
||||
pub(super) fn fish_verdict(escaped: bool, errors: &mut UiErrorKeys) {
|
||||
fn fish_verdict(escaped: bool, errors: &mut UiErrorKeys) {
|
||||
let key = if escaped {
|
||||
"ERR_FISH_ESCAPED"
|
||||
} else {
|
||||
@@ -216,7 +319,7 @@ fn loot_refusal(reason: u8) -> LootRefusal {
|
||||
/// `[item+0x314]` bit 0. That matters only when the loot source is an ITEM guid — a lockbox —
|
||||
/// where a refused open should drop the item's pending lock; for a corpse or chest guid there
|
||||
/// is no item to unlock. benilla does not touch [`LockTransitions`] here.
|
||||
pub(super) fn loot_error(guid: u64, error: u8, errors: &mut UiErrorKeys, latch: &mut LootLatch) {
|
||||
fn loot_error(guid: u64, error: u8, errors: &mut UiErrorKeys, latch: &mut LootLatch) {
|
||||
let LootRefusal { key, releases } = loot_refusal(error);
|
||||
debug!("net: loot error {error} on {guid:#x} → {key} (releases: {releases})");
|
||||
errors.0.push(UiError::key(key));
|
||||
@@ -228,21 +331,21 @@ pub(super) fn loot_error(guid: u64, error: u8, errors: &mut UiErrorKeys, latch:
|
||||
}
|
||||
|
||||
/// One loot-window row was taken, by anyone (`SMSG_LOOT_REMOVED`) — the UI clears that row.
|
||||
pub(super) fn loot_removed(slot: u8, loot: &mut LootState) {
|
||||
fn loot_removed(slot: u8, loot: &mut LootState) {
|
||||
// A row was taken (by anyone): drop it; the feed repaints via LOOT_UPDATE.
|
||||
debug!("net: loot slot {slot} removed");
|
||||
loot.remove_slot(slot);
|
||||
}
|
||||
|
||||
/// Our share of the loot's coin pile (`SMSG_LOOT_MONEY_NOTIFY`), answering our `CMSG_LOOT_MONEY`.
|
||||
pub(super) fn loot_money_notify(amount: u32) {
|
||||
fn loot_money_notify(amount: u32) {
|
||||
// Our share of the coin pile — informational; the coin row drops on CLEAR_MONEY and
|
||||
// the purse rides the ordinary COINAGE flush. Solo looting rarely sends this.
|
||||
debug!("net: loot money {amount}");
|
||||
}
|
||||
|
||||
/// The coin line disappears for every current looter (`SMSG_LOOT_CLEAR_MONEY`).
|
||||
pub(super) fn loot_clear_money(loot: &mut LootState) {
|
||||
fn loot_clear_money(loot: &mut LootState) {
|
||||
// The coin line disappears for everyone → drop the coin row.
|
||||
debug!("net: loot coin line cleared");
|
||||
loot.clear_money();
|
||||
@@ -252,52 +355,16 @@ pub(super) fn loot_clear_money(loot: &mut LootState) {
|
||||
/// Idempotent — a client-side close already cleared. The latch clear is **guid-matched**: under
|
||||
/// the corpse-switch race (loot B requested while A was open) the old window's release response
|
||||
/// must not drop the latch the new request just armed (decision 0515).
|
||||
pub(super) fn loot_release_response(guid: u64, loot: &mut LootState, latch: &mut LootLatch) {
|
||||
fn loot_release_response(guid: u64, loot: &mut LootState, latch: &mut LootLatch) {
|
||||
debug!("net: loot released {guid:#x}");
|
||||
loot.clear();
|
||||
latch.clear_for(guid);
|
||||
}
|
||||
|
||||
/// A group roll opened on one drop (`SMSG_LOOT_START_ROLL`) — a `GroupLootFrame` goes up with
|
||||
/// Need/Greed/Pass and the countdown bar (decision 0591).
|
||||
pub(super) fn loot_start_roll(p: LootStartRoll, rolls: &mut LootRolls) {
|
||||
debug!(
|
||||
"net: loot roll opened on item {} ({:#x} slot {}), {} ms",
|
||||
p.item_id, p.looted_target, p.item_slot, p.countdown_ms
|
||||
);
|
||||
rolls.start(p);
|
||||
}
|
||||
|
||||
/// One roller's vote or dice result (`SMSG_LOOT_ROLL`) — the chat announcement line. The
|
||||
/// `(roll_number, roll_type)` pair is overloaded; `LootRoll::is_dice`/`vote` disentangle it.
|
||||
pub(super) fn loot_roll(p: LootRoll, rolls: &mut LootRolls) {
|
||||
debug!(
|
||||
"net: loot roll announce — roller {:#x} number {} type {}",
|
||||
p.roller, p.roll_number, p.roll_type
|
||||
);
|
||||
rolls.announce(p);
|
||||
}
|
||||
|
||||
/// A group roll resolved (`SMSG_LOOT_ROLL_WON`) — the "won" line, and that roll's frame closes.
|
||||
pub(super) fn loot_roll_won(p: LootRollWon, rolls: &mut LootRolls) {
|
||||
debug!(
|
||||
"net: loot roll won by {:#x} with {} (type {})",
|
||||
p.winner, p.roll_number, p.roll_type
|
||||
);
|
||||
rolls.won(p);
|
||||
}
|
||||
|
||||
/// Everyone passed (`SMSG_LOOT_ALL_PASSED`) — the frame closes and the item returns to the corpse
|
||||
/// as an ordinary lootable row.
|
||||
pub(super) fn loot_all_passed(p: LootAllPassed, rolls: &mut LootRolls) {
|
||||
debug!("net: loot roll — everyone passed on item {}", p.item_id);
|
||||
rolls.all_passed(p);
|
||||
}
|
||||
|
||||
/// The master-loot candidate list (`SMSG_LOOT_MASTER_LIST`, decision 1675) — who the master looter
|
||||
/// may hand a row to. It arrives from inside the server's `SendLoot`, so it lands just AHEAD of the
|
||||
/// `SMSG_LOOT_RESPONSE` it belongs to; `LootState` stages it and the open claims it.
|
||||
pub(super) fn loot_master_list(candidates: Vec<u64>, loot: &mut LootState) {
|
||||
fn loot_master_list(candidates: Vec<u64>, loot: &mut LootState) {
|
||||
debug!("net: master-loot candidates: {} eligible", candidates.len());
|
||||
loot.set_master_candidates(candidates);
|
||||
}
|
||||
@@ -325,7 +392,7 @@ fn is_our_push(p: &ItemPushResult, self_guid: &SelfGuid) -> bool {
|
||||
/// self check being the only thing that can stop a push here. The reference's
|
||||
/// `CGGameUI::OnItemPush 0x491a60` emits both from this one packet: it returns early only on a guid
|
||||
/// mismatch, and tests `showInChat` further down, after the `ITEM_PUSH` fire.
|
||||
pub(super) fn item_push_result(
|
||||
fn item_push_result(
|
||||
p: ItemPushResult,
|
||||
self_guid: &SelfGuid,
|
||||
loot: &mut LootState,
|
||||
@@ -346,50 +413,6 @@ pub(super) fn item_push_result(
|
||||
loot.push_receive(&p);
|
||||
}
|
||||
|
||||
/// An item template's display head (`SMSG_ITEM_QUERY_SINGLE_RESPONSE`, answering our
|
||||
/// `CMSG_ITEM_QUERY_SINGLE`).
|
||||
pub(super) fn item_template(entry: u32, info: Option<ItemInfo>, items: &mut Items) {
|
||||
// Fill the ask-once template cache (decisions 0068/0072 — one cache serves held-
|
||||
// item resolution and the container layer); a server miss records `None` so the
|
||||
// entry is never re-asked. Consumers re-read it next frame.
|
||||
debug!("net: item template {entry} → {info:?}");
|
||||
items.insert_template(entry, info);
|
||||
}
|
||||
|
||||
/// The server refused an inventory operation (`SMSG_INVENTORY_CHANGE_FAILURE` — equip level,
|
||||
/// proficiency, bag full, …): the UI error line's inventory vocabulary, the equip twin of the
|
||||
/// cast-result failure path. Also the pending-lock's failure-driven clear (decision 0216 §4 /
|
||||
/// 0218 §3): every arrival here already has reason ≠ 0 (reason 0 is filtered before this event
|
||||
/// exists at all — `benilla_protocol::events`'s `if reason != 0` guard), so it always tries a
|
||||
/// [`PendingItemOps::clear_by_failure`]. This site has no `UiScript` to fire `ITEM_LOCK_CHANGED`
|
||||
/// through, so the transitioned slots queue in [`LockTransitions`] for the container feed
|
||||
/// (`ui_items::feed::feed_containers`) to drain and fire next time it runs.
|
||||
pub(super) fn inventory_failure(
|
||||
reason: u8,
|
||||
required_level: Option<u32>,
|
||||
item_guid: u64,
|
||||
bag_slot: u8,
|
||||
equip_errors: &mut EquipErrors,
|
||||
pending: &mut PendingItemOps,
|
||||
lock_cleared: &mut LockTransitions,
|
||||
latch: &mut LootLatch,
|
||||
) {
|
||||
debug!("net: inventory failure {reason:#04x} (item {item_guid:#x}, bag slot {bag_slot})");
|
||||
equip_errors.0.push(EquipError {
|
||||
reason,
|
||||
required_level,
|
||||
bag_slot,
|
||||
});
|
||||
lock_cleared.0.extend(pending.clear_by_failure(item_guid));
|
||||
// The sixth loot-latch clear (`0x5e3a84`, wow-re `loot-anim-leg.md` §5; decision 1477): when
|
||||
// the packet's **first item guid** is the object we are looting, the session ends here. It is
|
||||
// how an item-container loot (a lockbox) closes when the move out of it fails — the one clear
|
||||
// the 1471 census was missing. Guid-matched, as the bytes are.
|
||||
if item_guid != 0 {
|
||||
latch.clear_for(item_guid);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -669,42 +692,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The sixth clear (`0x5e3a84`): an inventory-move failure whose first item guid IS the loot
|
||||
/// target ends that session. Guid-matched, so an unrelated bag failure leaves it alone.
|
||||
#[test]
|
||||
fn an_inventory_failure_on_the_looted_object_clears_the_latch() {
|
||||
const LOCKBOX: u64 = 0x4000_0000_0000_0007;
|
||||
let mut errs = EquipErrors::default();
|
||||
let mut pending = PendingItemOps::default();
|
||||
let mut cleared = LockTransitions::default();
|
||||
let mut latch = LootLatch(Some(LOCKBOX));
|
||||
|
||||
// An unrelated item's failure must not end the session.
|
||||
inventory_failure(
|
||||
2,
|
||||
None,
|
||||
0x1234,
|
||||
0,
|
||||
&mut errs,
|
||||
&mut pending,
|
||||
&mut cleared,
|
||||
&mut latch,
|
||||
);
|
||||
assert_eq!(latch.0, Some(LOCKBOX));
|
||||
|
||||
inventory_failure(
|
||||
2,
|
||||
None,
|
||||
LOCKBOX,
|
||||
0,
|
||||
&mut errs,
|
||||
&mut pending,
|
||||
&mut cleared,
|
||||
&mut latch,
|
||||
);
|
||||
assert_eq!(latch.0, None);
|
||||
}
|
||||
|
||||
const ME: u64 = 0x0000_0000_0000_002A;
|
||||
const THEM: u64 = 0x0000_0000_0000_00FF;
|
||||
|
||||
@@ -289,10 +289,85 @@ impl LootRolls {
|
||||
}
|
||||
}
|
||||
|
||||
/// The group rolls' packet handlers (decision 0591; in the net handler table since 2319, moved out
|
||||
/// of the drain's loot arm file).
|
||||
mod net {
|
||||
use benilla_protocol::messages::{LootAllPassed, LootRoll, LootRollWon, LootStartRoll};
|
||||
use benilla_protocol::{SessionEvent, SessionEventKind};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::LootRolls;
|
||||
use crate::net::NetHandlerApp;
|
||||
|
||||
/// Register the roll handlers — called from [`super::UiLootRollPlugin`]. One for the four
|
||||
/// kinds, plus the session-end listener.
|
||||
pub(super) fn register(app: &mut App) {
|
||||
use SessionEventKind as K;
|
||||
app.net_handler(K::LootStartRoll, on_packet)
|
||||
.net_handler(K::LootRoll, on_packet)
|
||||
.net_handler(K::LootRollWon, on_packet)
|
||||
.net_handler(K::LootAllPassed, on_packet)
|
||||
.net_handler(K::Disconnected, on_session_end);
|
||||
}
|
||||
|
||||
fn on_packet(In(ev): In<SessionEvent>, mut rolls: ResMut<LootRolls>) {
|
||||
match ev {
|
||||
SessionEvent::LootStartRoll(p) => loot_start_roll(p, &mut rolls),
|
||||
SessionEvent::LootRoll(p) => loot_roll(p, &mut rolls),
|
||||
SessionEvent::LootRollWon(p) => loot_roll_won(p, &mut rolls),
|
||||
SessionEvent::LootAllPassed(p) => loot_all_passed(p, &mut rolls),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open group rolls die with the socket (decision 0591). A listener on the session end
|
||||
/// ([`crate::net::handlers::BROADCAST`]).
|
||||
fn on_session_end(In(_): In<SessionEvent>, mut rolls: ResMut<LootRolls>) {
|
||||
rolls.clear();
|
||||
}
|
||||
|
||||
/// A group roll opened on one drop (`SMSG_LOOT_START_ROLL`) — a `GroupLootFrame` goes up with
|
||||
/// Need/Greed/Pass and the countdown bar (decision 0591).
|
||||
fn loot_start_roll(p: LootStartRoll, rolls: &mut LootRolls) {
|
||||
debug!(
|
||||
"net: loot roll opened on item {} ({:#x} slot {}), {} ms",
|
||||
p.item_id, p.looted_target, p.item_slot, p.countdown_ms
|
||||
);
|
||||
rolls.start(p);
|
||||
}
|
||||
|
||||
/// One roller's vote or dice result (`SMSG_LOOT_ROLL`) — the chat announcement line. The
|
||||
/// `(roll_number, roll_type)` pair is overloaded; `LootRoll::is_dice`/`vote` disentangle it.
|
||||
fn loot_roll(p: LootRoll, rolls: &mut LootRolls) {
|
||||
debug!(
|
||||
"net: loot roll announce — roller {:#x} number {} type {}",
|
||||
p.roller, p.roll_number, p.roll_type
|
||||
);
|
||||
rolls.announce(p);
|
||||
}
|
||||
|
||||
/// A group roll resolved (`SMSG_LOOT_ROLL_WON`) — the "won" line, and that roll's frame closes.
|
||||
fn loot_roll_won(p: LootRollWon, rolls: &mut LootRolls) {
|
||||
debug!(
|
||||
"net: loot roll won by {:#x} with {} (type {})",
|
||||
p.winner, p.roll_number, p.roll_type
|
||||
);
|
||||
rolls.won(p);
|
||||
}
|
||||
|
||||
/// Everyone passed (`SMSG_LOOT_ALL_PASSED`) — the frame closes and the item returns to the corpse
|
||||
/// as an ordinary lootable row.
|
||||
fn loot_all_passed(p: LootAllPassed, rolls: &mut LootRolls) {
|
||||
debug!("net: loot roll — everyone passed on item {}", p.item_id);
|
||||
rolls.all_passed(p);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct UiLootRollPlugin;
|
||||
|
||||
impl Plugin for UiLootRollPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
net::register(app);
|
||||
app.init_resource::<LootRolls>().add_systems(
|
||||
Update,
|
||||
(
|
||||
|
||||
@@ -127,10 +127,13 @@ pub(crate) enum MerchantRefusal {
|
||||
Sell(u8),
|
||||
}
|
||||
|
||||
mod net;
|
||||
|
||||
pub(crate) struct UiMerchantPlugin;
|
||||
|
||||
impl Plugin for UiMerchantPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
net::register(app);
|
||||
app.init_resource::<MerchantOpen>()
|
||||
.init_resource::<MerchantErrors>()
|
||||
.add_systems(
|
||||
@@ -0,0 +1,119 @@
|
||||
//! The vendor window's packet handlers (decision 0081 phase 4; in the net handler table since
|
||||
//! 2318, moved out of the drain's npc arm file) — each fills the [`MerchantOpen`] session or the
|
||||
//! [`MerchantErrors`] line queue the merchant feed ([`super`]) reads.
|
||||
|
||||
use benilla_protocol::messages::VendorItem;
|
||||
use benilla_protocol::{SessionEvent, SessionEventKind};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::{MerchantErrors, MerchantOpen, MerchantRefusal};
|
||||
use crate::net::NetHandlerApp;
|
||||
|
||||
/// Register the vendor handlers — called from [`super::UiMerchantPlugin`]. One per kind, plus the
|
||||
/// session-end listener.
|
||||
pub(super) fn register(app: &mut App) {
|
||||
use SessionEventKind as K;
|
||||
app.net_handler(K::VendorInventory, on_inventory)
|
||||
.net_handler(K::VendorBuyResult, on_buy_result)
|
||||
.net_handler(K::VendorBuyFailed, on_buy_failed)
|
||||
.net_handler(K::VendorSellFailed, on_sell_failed)
|
||||
.net_handler(K::Disconnected, on_session_end);
|
||||
}
|
||||
|
||||
fn on_inventory(In(ev): In<SessionEvent>, mut merchant: ResMut<MerchantOpen>) {
|
||||
if let SessionEvent::VendorInventory { vendor, items } = ev {
|
||||
vendor_inventory(vendor, items, &mut merchant);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_buy_result(In(ev): In<SessionEvent>, mut merchant: ResMut<MerchantOpen>) {
|
||||
if let SessionEvent::VendorBuyResult {
|
||||
vendor,
|
||||
slot,
|
||||
new_count,
|
||||
..
|
||||
} = ev
|
||||
{
|
||||
vendor_buy_result(vendor, slot, new_count, &mut merchant);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_buy_failed(
|
||||
In(ev): In<SessionEvent>,
|
||||
mut merchant: ResMut<MerchantOpen>,
|
||||
mut errors: ResMut<MerchantErrors>,
|
||||
) {
|
||||
if let SessionEvent::VendorBuyFailed {
|
||||
vendor,
|
||||
item_entry,
|
||||
reason,
|
||||
} = ev
|
||||
{
|
||||
vendor_buy_failed(vendor, item_entry, reason, &mut merchant, &mut errors);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_sell_failed(In(ev): In<SessionEvent>, mut errors: ResMut<MerchantErrors>) {
|
||||
if let SessionEvent::VendorSellFailed { reason, .. } = ev {
|
||||
vendor_sell_failed(reason, &mut errors);
|
||||
}
|
||||
}
|
||||
|
||||
/// An open vendor window dies with the socket. A listener on the session end
|
||||
/// ([`crate::net::handlers::BROADCAST`]).
|
||||
fn on_session_end(In(_): In<SessionEvent>, mut merchant: ResMut<MerchantOpen>) {
|
||||
merchant.clear_session();
|
||||
}
|
||||
|
||||
/// A vendor's stock (`SMSG_LIST_INVENTORY`): fill the [`MerchantOpen`] the merchant feed
|
||||
/// ([`super`]) reads. A successful buy updates the stock display via
|
||||
/// [`vendor_buy_result`] (the item itself lands via item-create); a successful sell is silent
|
||||
/// (never a packet here — only the error path is).
|
||||
fn vendor_inventory(vendor: u64, items: Vec<VendorItem>, merchant: &mut MerchantOpen) {
|
||||
debug!("net: vendor {vendor:#x} listed {} items", items.len());
|
||||
merchant.open(vendor, items);
|
||||
}
|
||||
|
||||
/// A purchase updated the vendor's stock (`SMSG_BUY_ITEM`). Only touch stock for the open
|
||||
/// vendor (a late answer for a closed window is stale).
|
||||
fn vendor_buy_result(vendor: u64, slot: u32, new_count: u32, merchant: &mut MerchantOpen) {
|
||||
if merchant.vendor == Some(vendor) {
|
||||
merchant.update_stock(slot, new_count);
|
||||
}
|
||||
}
|
||||
|
||||
/// A purchase was refused (`SMSG_BUY_FAILED`) — the merchant window's error line, and for the
|
||||
/// out-of-stock code the refusing row's own count.
|
||||
///
|
||||
/// **`ITEM_ALREADY_SOLD` zeroes the row** (`0x5dcda7`..`0x5dcdd6`, decision 1821): the reference
|
||||
/// walks its 128-row vendor cache, writes 0 into the matching row's count word and repaints —
|
||||
/// gated, as every other stale-answer path here is, on the packet naming the vendor still open.
|
||||
///
|
||||
/// **NAMED DIVERGENCE in the key.** The reference matches the row's `+0x00`, the vendor *slot* —
|
||||
/// the same word `SMSG_BUY_ITEM` keys its stock update by. vmangos puts the item *entry* in that
|
||||
/// field (`Player::SendBuyError`, `Player.cpp:11637`: `packet->itemEntry = item`), so matching by
|
||||
/// slot would find nothing on the server we actually talk to; matching by entry is the same row.
|
||||
/// vmangos raises this code from exactly the condition the zeroing models —
|
||||
/// `GetVendorItemCurrentCount(crItem) < totalCount` under `crItem->maxcount != 0`
|
||||
/// (`Player.cpp:18508`).
|
||||
fn vendor_buy_failed(
|
||||
vendor: u64,
|
||||
item_entry: u32,
|
||||
reason: u8,
|
||||
merchant: &mut MerchantOpen,
|
||||
errors: &mut MerchantErrors,
|
||||
) {
|
||||
debug!("net: buy failed (entry {item_entry}, reason {reason})");
|
||||
if reason == benilla_protocol::messages::buy_result::ITEM_ALREADY_SOLD
|
||||
&& merchant.vendor == Some(vendor)
|
||||
{
|
||||
merchant.sold_out(item_entry);
|
||||
}
|
||||
errors.0.push(MerchantRefusal::Buy(reason));
|
||||
}
|
||||
|
||||
/// A sell was refused (`SMSG_SELL_ITEM`'s error path) — the merchant window's error line.
|
||||
fn vendor_sell_failed(reason: u8, errors: &mut MerchantErrors) {
|
||||
debug!("net: sell failed (reason {reason})");
|
||||
errors.0.push(MerchantRefusal::Sell(reason));
|
||||
}
|
||||
@@ -252,10 +252,13 @@ pub(crate) fn questgiver_failed_key(reason: u32) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
mod net;
|
||||
|
||||
pub(crate) struct UiQuestPlugin;
|
||||
|
||||
impl Plugin for UiQuestPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
net::register(app);
|
||||
app.init_resource::<QuestGiver>().add_systems(
|
||||
Update,
|
||||
(
|
||||
+152
-60
@@ -1,20 +1,140 @@
|
||||
//! Questgiver-panel + quest-log arm bodies for [`super::apply_net_updates`]'s dispatch match — the
|
||||
//! largest arm family, split out on its own (decision 0088's panels + its deferred quest-log/toast
|
||||
//! slice). Each `pub(super)` fn here is exactly one arm's body; the match at the call site stays the
|
||||
//! dispatcher, one call per arm.
|
||||
//! The questgiver panels' packet handlers (decision 0088; in the net handler table since 2320,
|
||||
//! moved out of the drain's quests arm file) — each fills the [`QuestGiver`] the quest feed
|
||||
//! ([`super`]) reads; each panel packet replaces the open view, and the greeting/gossip quest-row
|
||||
//! clicks and the panel buttons flow back out through the quest/gossip drains. The quest log's
|
||||
//! template answer is [`crate::ui_quest_log`]'s and the party share's pair is
|
||||
//! [`crate::ui_quest_share`]'s.
|
||||
|
||||
use benilla_protocol::messages::{
|
||||
QuestComplete, QuestConfirmAccept, QuestDetails, QuestGiverList, QuestOfferReward,
|
||||
QuestRequestItems, QuestShareMsg, QuestTemplate,
|
||||
QuestComplete, QuestDetails, QuestGiverList, QuestOfferReward, QuestRequestItems, QuestShareMsg,
|
||||
};
|
||||
use benilla_protocol::{SessionEvent, SessionEventKind};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::QuestGiver;
|
||||
use crate::net::{ClientCommand, NetCommands, NetHandlerApp};
|
||||
use crate::ui_action::UiError;
|
||||
use crate::ui_quest::QuestGiver;
|
||||
use crate::ui_quest_log::QuestLog;
|
||||
use crate::ui_quest_share::QuestShare;
|
||||
|
||||
use super::super::{ClientCommand, NetCommands};
|
||||
/// Register the questgiver handlers — called from [`super::UiQuestPlugin`]. One per kind, plus
|
||||
/// the session-end listener.
|
||||
pub(super) fn register(app: &mut App) {
|
||||
use SessionEventKind as K;
|
||||
app.net_handler(K::QuestGiverStatus, on_giver_status)
|
||||
.net_handler(K::QuestGreeting, on_greeting)
|
||||
.net_handler(K::QuestDetail, on_detail)
|
||||
.net_handler(K::QuestProgress, on_progress)
|
||||
.net_handler(K::QuestOffer, on_offer)
|
||||
.net_handler(K::QuestComplete, on_complete)
|
||||
.net_handler(K::QuestObjectiveKill, on_objective_kill)
|
||||
.net_handler(K::QuestObjectiveItem, on_objective_item)
|
||||
.net_handler(K::QuestObjectivesComplete, on_objectives_complete)
|
||||
.net_handler(K::QuestFailed, on_failed)
|
||||
.net_handler(K::QuestLogFull, on_log_full)
|
||||
.net_handler(K::QuestGiverInvalid, on_giver_invalid)
|
||||
.net_handler(K::QuestGiverFailed, on_giver_failed)
|
||||
.net_handler(K::Disconnected, on_session_end);
|
||||
}
|
||||
|
||||
fn on_giver_status(In(ev): In<SessionEvent>, mut quest: ResMut<QuestGiver>) {
|
||||
if let SessionEvent::QuestGiverStatus { npc, status } = ev {
|
||||
quest_giver_status(npc, status, &mut quest);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_greeting(In(ev): In<SessionEvent>, mut quest: ResMut<QuestGiver>) {
|
||||
if let SessionEvent::QuestGreeting(list) = ev {
|
||||
quest_greeting(list, &mut quest);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_detail(In(ev): In<SessionEvent>, mut quest: ResMut<QuestGiver>, commands: Res<NetCommands>) {
|
||||
if let SessionEvent::QuestDetail(d) = ev {
|
||||
quest_detail(d, &mut quest, &commands);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_progress(In(ev): In<SessionEvent>, mut quest: ResMut<QuestGiver>) {
|
||||
if let SessionEvent::QuestProgress(p) = ev {
|
||||
quest_progress(p, &mut quest);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_offer(In(ev): In<SessionEvent>, mut quest: ResMut<QuestGiver>) {
|
||||
if let SessionEvent::QuestOffer(o) = ev {
|
||||
quest_offer(o, &mut quest);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_complete(In(ev): In<SessionEvent>, mut quest: ResMut<QuestGiver>) {
|
||||
if let SessionEvent::QuestComplete(c) = ev {
|
||||
quest_complete(c, &mut quest);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_objective_kill(In(ev): In<SessionEvent>, mut quest: ResMut<QuestGiver>) {
|
||||
if let SessionEvent::QuestObjectiveKill {
|
||||
quest_id: _,
|
||||
entry,
|
||||
count,
|
||||
required,
|
||||
} = ev
|
||||
{
|
||||
quest_objective_kill(entry, count, required, &mut quest);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_objective_item(In(ev): In<SessionEvent>, mut quest: ResMut<QuestGiver>) {
|
||||
if let SessionEvent::QuestObjectiveItem { item_id, count } = ev {
|
||||
quest_objective_item(item_id, count, &mut quest);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_objectives_complete(In(ev): In<SessionEvent>, mut quest: ResMut<QuestGiver>) {
|
||||
if let SessionEvent::QuestObjectivesComplete { quest_id } = ev {
|
||||
quest_objectives_complete(quest_id, &mut quest);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_failed(
|
||||
In(ev): In<SessionEvent>,
|
||||
mut quest: ResMut<QuestGiver>,
|
||||
mut quest_log: ResMut<QuestLog>,
|
||||
commands: Res<NetCommands>,
|
||||
) {
|
||||
if let SessionEvent::QuestFailed { quest_id, timed } = ev {
|
||||
quest_failed(quest_id, timed, &mut quest_log, &commands, &mut quest);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_log_full(In(ev): In<SessionEvent>, mut quest: ResMut<QuestGiver>) {
|
||||
if let SessionEvent::QuestLogFull = ev {
|
||||
quest_log_full(&mut quest);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_giver_invalid(In(ev): In<SessionEvent>, mut quest: ResMut<QuestGiver>) {
|
||||
if let SessionEvent::QuestGiverInvalid { reason } = ev {
|
||||
quest_giver_invalid(reason, &mut quest);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_giver_failed(
|
||||
In(ev): In<SessionEvent>,
|
||||
mut quest: ResMut<QuestGiver>,
|
||||
mut quest_log: ResMut<QuestLog>,
|
||||
commands: Res<NetCommands>,
|
||||
) {
|
||||
if let SessionEvent::QuestGiverFailed { quest_id, reason } = ev {
|
||||
quest_giver_failed(quest_id, reason, &mut quest, &mut quest_log, &commands);
|
||||
}
|
||||
}
|
||||
|
||||
/// An open questgiver panel dies with the socket. A listener on the session end
|
||||
/// ([`crate::net::handlers::BROADCAST`]).
|
||||
fn on_session_end(In(_): In<SessionEvent>, mut quest: ResMut<QuestGiver>) {
|
||||
quest.clear_session();
|
||||
}
|
||||
|
||||
/// A questgiver dialog status for one NPC (`SMSG_QUESTGIVER_STATUS`) — the `!`/`?` marker's
|
||||
/// [`crate::messages::dialog_status`] value, stored per guid for the marker layer and the minimap
|
||||
@@ -33,14 +153,17 @@ use super::super::{ClientCommand, NetCommands};
|
||||
/// So the drop is what keeps a vmangos-only answer from putting a `!` over a wanted poster that
|
||||
/// the reference client leaves bare.
|
||||
///
|
||||
/// The test is the GUID's own shape rather than a live type lookup on purpose: it gives the same
|
||||
/// partition as typemask 8 for anything a server can send us, and it cannot be defeated by a status
|
||||
/// that arrives in the same drain as its object's create block (descriptors are flushed at the end
|
||||
/// of the drain, so a type test would read a not-yet-seeded store and drop a *unit's* answer that
|
||||
/// would then never be re-asked for). The reference's second conjunct — `UNIT_NPC_FLAGS & 0x2` on
|
||||
/// the resolved unit — is deliberately not modelled here for that same ordering reason;
|
||||
/// [`crate::quest_markers::query`]'s teardown leg covers the flag-clearing case from the other end.
|
||||
pub(super) fn quest_giver_status(npc: u64, status: u32, quest: &mut QuestGiver) {
|
||||
/// The test is the GUID's own shape rather than a live type lookup: it gives the same partition
|
||||
/// as typemask 8 for anything a server can send us. It was chosen when this was an arm of the
|
||||
/// drain's match, where a status arriving in the same drain as its object's create block would
|
||||
/// have read a not-yet-seeded store (descriptors were flushed at the end of the drain) and
|
||||
/// dropped a *unit's* answer that would then never be re-asked for. As a packet handler it runs
|
||||
/// after the create has landed (decision 2306), so that reason is gone; the shape test stays
|
||||
/// because it is the same partition. The reference's second conjunct — `UNIT_NPC_FLAGS & 0x2` on
|
||||
/// the resolved unit — was left unmodelled for the same ordering reason and is buildable now;
|
||||
/// until it is, [`crate::quest_markers::query`]'s teardown leg covers the flag-clearing case from
|
||||
/// the other end.
|
||||
fn quest_giver_status(npc: u64, status: u32, quest: &mut QuestGiver) {
|
||||
use benilla_protocol::guid;
|
||||
if !(guid::is_player(npc) || guid::is_creature_or_pet(npc)) {
|
||||
debug!("net: dropping a non-unit questgiver status ({npc:#x} → {status}) — typemask 8");
|
||||
@@ -51,7 +174,7 @@ pub(super) fn quest_giver_status(npc: u64, status: u32, quest: &mut QuestGiver)
|
||||
}
|
||||
|
||||
/// The greeting panel: an NPC's offered/active quest rows (`SMSG_QUESTGIVER_QUEST_LIST`).
|
||||
pub(super) fn quest_greeting(list: QuestGiverList, quest: &mut QuestGiver) {
|
||||
fn quest_greeting(list: QuestGiverList, quest: &mut QuestGiver) {
|
||||
debug!(
|
||||
"net: quest greeting on {:#x} — {} quests",
|
||||
list.npc,
|
||||
@@ -61,7 +184,7 @@ 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, commands: &NetCommands) {
|
||||
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.
|
||||
@@ -86,7 +209,7 @@ pub(super) fn quest_detail(d: QuestDetails, quest: &mut QuestGiver, commands: &N
|
||||
|
||||
/// The progress panel: "bring me these" text + required items/money + completability
|
||||
/// (`SMSG_QUESTGIVER_REQUEST_ITEMS`).
|
||||
pub(super) fn quest_progress(p: QuestRequestItems, quest: &mut QuestGiver) {
|
||||
fn quest_progress(p: QuestRequestItems, quest: &mut QuestGiver) {
|
||||
debug!(
|
||||
"net: quest progress — quest {} on {:#x} (complete: {})",
|
||||
p.quest_id, p.npc, p.is_complete
|
||||
@@ -95,7 +218,7 @@ pub(super) fn quest_progress(p: QuestRequestItems, quest: &mut QuestGiver) {
|
||||
}
|
||||
|
||||
/// The reward panel: turn-in text + rewards to grant (`SMSG_QUESTGIVER_OFFER_REWARD`).
|
||||
pub(super) fn quest_offer(o: QuestOfferReward, quest: &mut QuestGiver) {
|
||||
fn quest_offer(o: QuestOfferReward, quest: &mut QuestGiver) {
|
||||
debug!(
|
||||
"net: quest reward offer — quest {} on {:#x}",
|
||||
o.quest_id, o.npc
|
||||
@@ -104,7 +227,7 @@ pub(super) fn quest_offer(o: QuestOfferReward, quest: &mut QuestGiver) {
|
||||
}
|
||||
|
||||
/// The turn-in result: XP/money granted + fixed items (`SMSG_QUESTGIVER_QUEST_COMPLETE`).
|
||||
pub(super) fn quest_complete(c: QuestComplete, quest: &mut QuestGiver) {
|
||||
fn quest_complete(c: QuestComplete, quest: &mut QuestGiver) {
|
||||
// The completion fanfare (QUESTCOMPLETED kit → iQuestComplete.wav) — the client's C++ plays
|
||||
// it on exactly this packet; the giver feed drains the flag into the UI sound path.
|
||||
quest.completed_fanfare = true;
|
||||
@@ -123,13 +246,6 @@ pub(super) fn quest_complete(c: QuestComplete, quest: &mut QuestGiver) {
|
||||
quest.bump_reask();
|
||||
}
|
||||
|
||||
/// The full quest template (`SMSG_QUEST_QUERY_RESPONSE`, answering our `CMSG_QUEST_QUERY`) — the
|
||||
/// quest log's ask-once detail source, cached by `quest_id`.
|
||||
pub(super) fn quest_template(t: Box<QuestTemplate>, quest_log: &mut QuestLog) {
|
||||
debug!("net: quest template {} ({})", t.quest_id, t.title);
|
||||
quest_log.insert_template(*t);
|
||||
}
|
||||
|
||||
/// A kill/use objective ticked (`SMSG_QUESTUPDATE_ADD_KILL`) / an item-collection tick
|
||||
/// (`SMSG_QUESTUPDATE_ADD_ITEM`). The visible surface — the yellow `UI_INFO_MESSAGE` toast, the
|
||||
/// ref's `ERR_QUEST_ADD_*_SII` popups — no longer fires from here: it rides the quest-log
|
||||
@@ -139,13 +255,13 @@ pub(super) fn quest_template(t: Box<QuestTemplate>, quest_log: &mut QuestLog) {
|
||||
/// chat-line stopgap here is retired: the reference shows no chat echo for objective progress
|
||||
/// (INFERRED from ref screenshots; the dispatched §5 adjudicates, and this fn is the fold-back
|
||||
/// seat if the real handler does more — a sound, a distinct format).
|
||||
pub(super) fn quest_objective_kill(entry: u32, count: u32, required: u32, quest: &mut QuestGiver) {
|
||||
fn quest_objective_kill(entry: u32, count: u32, required: u32, quest: &mut QuestGiver) {
|
||||
debug!("net: quest kill/use objective {entry:#x} at {count}/{required}");
|
||||
quest.bump_reask();
|
||||
}
|
||||
|
||||
/// See [`quest_objective_kill`] — same surface, item flavor.
|
||||
pub(super) fn quest_objective_item(item_id: u32, count: u32, quest: &mut QuestGiver) {
|
||||
fn quest_objective_item(item_id: u32, count: u32, quest: &mut QuestGiver) {
|
||||
debug!("net: quest item objective {item_id} +{count}");
|
||||
quest.bump_reask();
|
||||
}
|
||||
@@ -155,7 +271,7 @@ pub(super) fn quest_objective_item(item_id: u32, count: u32, quest: &mut QuestGi
|
||||
/// kind-1 → UI_INFO_MESSAGE, never a chat line) — rides the quest-log diff's COMPLETE-flip
|
||||
/// detection (`crate::ui_quest_log::feed_quest_log`), same as the progress toasts; the slot's
|
||||
/// state byte carries the durable fact.
|
||||
pub(super) fn quest_objectives_complete(quest_id: u32, quest: &mut QuestGiver) {
|
||||
fn quest_objectives_complete(quest_id: u32, quest: &mut QuestGiver) {
|
||||
debug!("net: quest {quest_id} objectives complete");
|
||||
// The turn-in `?` can go gold with no quest-log field change of its own, so the reference
|
||||
// sweeps from these `SMSG_QUESTUPDATE_*` handlers (0654).
|
||||
@@ -178,7 +294,7 @@ pub(super) fn quest_objectives_complete(quest_id: u32, quest: &mut QuestGiver) {
|
||||
/// own `GlobalStrings.lua` and `show_messages` reads the row. **That also lands the sound the old
|
||||
/// path was dropping**: row 139 names the cue `igQuestFailed`, which the reference plays here and
|
||||
/// benilla had noted as an unbuilt follow-up.
|
||||
pub(super) fn quest_failed(
|
||||
fn quest_failed(
|
||||
quest_id: u32,
|
||||
timed: bool,
|
||||
quest_log: &mut QuestLog,
|
||||
@@ -200,7 +316,7 @@ pub(super) fn quest_failed(
|
||||
/// bare `DisplayError(153)` and nothing else (no panel close): `ERR_QUEST_LOG_FULL` is a kind-2
|
||||
/// record, so it is the RED line, not a chat line (decision 0669 — it used to be a hardcoded
|
||||
/// English chat push here).
|
||||
pub(super) fn quest_log_full(quest: &mut QuestGiver) {
|
||||
fn quest_log_full(quest: &mut QuestGiver) {
|
||||
debug!("net: quest log full");
|
||||
quest.push_message(UiError::key("ERR_QUEST_LOG_FULL"));
|
||||
}
|
||||
@@ -214,7 +330,7 @@ pub(super) fn quest_log_full(quest: &mut QuestGiver) {
|
||||
/// the current questgiver guid (`0xbe0810`) and signals Lua event `0x130` = `QUEST_FINISHED` —
|
||||
/// our [`QuestGiver::clear`] plus the feed's own `QUEST_FINISHED` on the cleared view. Without
|
||||
/// this the panel sat open on a refused accept (decision 0669).
|
||||
pub(super) fn quest_giver_invalid(reason: u32, quest: &mut QuestGiver) {
|
||||
fn quest_giver_invalid(reason: u32, quest: &mut QuestGiver) {
|
||||
debug!("net: questgiver refused to offer the quest (reason {reason})");
|
||||
quest.push_message(UiError::key(crate::ui_quest::questgiver_invalid_key(
|
||||
reason,
|
||||
@@ -228,7 +344,7 @@ pub(super) fn quest_giver_invalid(reason: u32, quest: &mut QuestGiver) {
|
||||
/// is refusing IS this quest) and fall back to the template cache. A full-bag refusal shows a
|
||||
/// SECOND line, the ref's bare `DisplayError(0)` = `ERR_INV_FULL` on the red surface. Closes the
|
||||
/// window like [`quest_giver_invalid`].
|
||||
pub(super) fn quest_giver_failed(
|
||||
fn quest_giver_failed(
|
||||
quest_id: u32,
|
||||
reason: u32,
|
||||
quest: &mut QuestGiver,
|
||||
@@ -254,30 +370,6 @@ 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::*;
|
||||
@@ -129,10 +129,49 @@ impl QuestLog {
|
||||
}
|
||||
}
|
||||
|
||||
/// The quest log's packet handler (decision 0088's second slice; in the net handler table since
|
||||
/// 2320, moved out of the drain's quests arm file).
|
||||
mod net {
|
||||
use benilla_protocol::messages::QuestTemplate;
|
||||
use benilla_protocol::{SessionEvent, SessionEventKind};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::QuestLog;
|
||||
use crate::net::NetHandlerApp;
|
||||
|
||||
/// Register the handler and the session-end listener — called from
|
||||
/// [`super::UiQuestLogPlugin`].
|
||||
pub(super) fn register(app: &mut App) {
|
||||
use SessionEventKind as K;
|
||||
app.net_handler(K::QuestTemplate, on_template)
|
||||
.net_handler(K::Disconnected, on_session_end);
|
||||
}
|
||||
|
||||
fn on_template(In(ev): In<SessionEvent>, mut quest_log: ResMut<QuestLog>) {
|
||||
if let SessionEvent::QuestTemplate(t) = ev {
|
||||
quest_template(t, &mut quest_log);
|
||||
}
|
||||
}
|
||||
|
||||
/// The log's session state dies with the socket. A listener on the session end
|
||||
/// ([`crate::net::handlers::BROADCAST`]).
|
||||
fn on_session_end(In(_): In<SessionEvent>, mut quest_log: ResMut<QuestLog>) {
|
||||
quest_log.clear_session();
|
||||
}
|
||||
|
||||
/// The full quest template (`SMSG_QUEST_QUERY_RESPONSE`, answering our `CMSG_QUEST_QUERY`) — the
|
||||
/// quest log's ask-once detail source, cached by `quest_id`.
|
||||
fn quest_template(t: Box<QuestTemplate>, quest_log: &mut QuestLog) {
|
||||
debug!("net: quest template {} ({})", t.quest_id, t.title);
|
||||
quest_log.insert_template(*t);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct UiQuestLogPlugin;
|
||||
|
||||
impl Plugin for UiQuestLogPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
net::register(app);
|
||||
crate::query_cache::register::<QuestLog>(app);
|
||||
app.init_resource::<QuestLog>()
|
||||
.add_systems(
|
||||
|
||||
@@ -131,6 +131,71 @@ fn verdict_message(msg: QuestShareMsg) -> Option<&'static str> {
|
||||
Some(key)
|
||||
}
|
||||
|
||||
/// The party quest-share's packet handlers (decision 1733; in the net handler table since 2320,
|
||||
/// moved out of the drain's quests arm file): one member's verdict on a quest we pushed, and the
|
||||
/// escort-quest confirm. Both park in [`QuestShare`] for this module to name and raise — the guid
|
||||
/// needs a name query a packet handler cannot await.
|
||||
mod net {
|
||||
use benilla_protocol::messages::{QuestConfirmAccept, QuestShareMsg};
|
||||
use benilla_protocol::{SessionEvent, SessionEventKind};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::QuestShare;
|
||||
use crate::net::NetHandlerApp;
|
||||
|
||||
/// Register the handlers and the session-end listener — called from
|
||||
/// [`super::QuestSharePlugin`].
|
||||
pub(super) fn register(app: &mut App) {
|
||||
use SessionEventKind as K;
|
||||
app.net_handler(K::QuestPushResult, on_push_result)
|
||||
.net_handler(K::QuestConfirmAccept, on_confirm_accept)
|
||||
.net_handler(K::Disconnected, on_session_end);
|
||||
}
|
||||
|
||||
fn on_push_result(In(ev): In<SessionEvent>, mut share: ResMut<QuestShare>) {
|
||||
if let SessionEvent::QuestPushResult { member, msg } = ev {
|
||||
quest_push_result(member, msg, &mut share);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_confirm_accept(In(ev): In<SessionEvent>, mut share: ResMut<QuestShare>) {
|
||||
if let SessionEvent::QuestConfirmAccept(c) = ev {
|
||||
quest_confirm_accept(c, &mut share);
|
||||
}
|
||||
}
|
||||
|
||||
/// A verdict on a share nobody is listening for any more, and a confirm whose server-side
|
||||
/// latch died with the socket (decision 1733). A listener on the session end
|
||||
/// ([`crate::net::handlers::BROADCAST`]).
|
||||
fn on_session_end(In(_): In<SessionEvent>, mut share: ResMut<QuestShare>) {
|
||||
share.clear_session();
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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.
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -138,6 +203,7 @@ pub(crate) struct QuestSharePlugin;
|
||||
|
||||
impl Plugin for QuestSharePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
net::register(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.
|
||||
|
||||
@@ -1179,7 +1179,7 @@ fn ctrl_and_shift_on_a_vendor_row_preview_and_post_without_buying() {
|
||||
num_available: -1,
|
||||
item_id: 159,
|
||||
stats: None,
|
||||
// Fed exactly as `ui_merchant.rs` builds it off the row's template answer.
|
||||
// Fed exactly as `ui_merchant` builds it off the row's template answer.
|
||||
link: Some(WATER_LINK.into()),
|
||||
max_stack: Some(1),
|
||||
}],
|
||||
|
||||
@@ -296,7 +296,7 @@ fn shipped_gossip_frame_renders_quest_rows_above_options() {
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
|
||||
// Rows 1-2 carry the quest titles (active first, matching the order the menu supplied them —
|
||||
// benilla's seam already flattens available/active into one ordered list, ui_gossip.rs), row 3
|
||||
// benilla's seam already flattens available/active into one ordered list, `ui_gossip`), row 3
|
||||
// the option, row 4+ hidden.
|
||||
let (r1_text, r1_vis, r2_vis, r3_text, r3_vis, r4_vis, r5_text, r5_vis, r6_vis): (
|
||||
String,
|
||||
|
||||
@@ -44,10 +44,13 @@ use crate::ui_pet_stats::{PetFamilyTables, PetStatTables};
|
||||
use crate::ui_script::{UiFeed, UiInput};
|
||||
use crate::ui_session::{close_npc_session_out_of_range, npc_switched, NpcSession};
|
||||
|
||||
mod net;
|
||||
|
||||
pub(crate) struct UiStablePlugin;
|
||||
|
||||
impl Plugin for UiStablePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
net::register(app);
|
||||
app.init_resource::<StableOpen>()
|
||||
.init_resource::<StableErrors>()
|
||||
.add_systems(
|
||||
@@ -0,0 +1,135 @@
|
||||
//! The stable master's packet handlers (decisions 1677, 1688; in the net handler table since
|
||||
//! 2318, moved out of the drain's npc arm file) — the [`StableOpen`] session and the
|
||||
//! [`StableErrors`] line queue the stable feed ([`super`]) reads.
|
||||
|
||||
use benilla_protocol::messages::StabledPet;
|
||||
use benilla_protocol::{SessionEvent, SessionEventKind};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::{StableErrors, StableOpen};
|
||||
use crate::names::NameCache;
|
||||
use crate::net::{ClientCommand, NetCommands, NetHandlerApp};
|
||||
|
||||
/// Register the stable handlers — called from [`super::UiStablePlugin`].
|
||||
pub(super) fn register(app: &mut App) {
|
||||
use SessionEventKind as K;
|
||||
app.net_handler(K::ListStabledPets, on_list)
|
||||
.net_handler(K::StableResult, on_result);
|
||||
}
|
||||
|
||||
fn on_list(
|
||||
In(ev): In<SessionEvent>,
|
||||
mut stable_open: ResMut<StableOpen>,
|
||||
mut names: ResMut<NameCache>,
|
||||
) {
|
||||
if let SessionEvent::ListStabledPets {
|
||||
npc,
|
||||
num_stable_slots,
|
||||
pets,
|
||||
} = ev
|
||||
{
|
||||
list_stabled_pets(npc, num_stable_slots, pets, &mut stable_open, &mut names);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_result(
|
||||
In(ev): In<SessionEvent>,
|
||||
mut stable_open: ResMut<StableOpen>,
|
||||
mut errors: ResMut<StableErrors>,
|
||||
commands: Res<NetCommands>,
|
||||
) {
|
||||
if let SessionEvent::StableResult { result } = ev {
|
||||
stable_result(result, &mut stable_open, &mut errors, &commands);
|
||||
}
|
||||
}
|
||||
|
||||
/// A stable master's pet list (`MSG_LIST_STABLED_PETS`): fill the [`StableOpen`] the stable feed
|
||||
/// ([`super`]) reads. Arrives unprompted off the gossip stable option — that is how the
|
||||
/// window opens — and again in answer to our own refresh send.
|
||||
fn list_stabled_pets(
|
||||
npc: u64,
|
||||
num_stable_slots: u8,
|
||||
pets: Vec<StabledPet>,
|
||||
stable_open: &mut StableOpen,
|
||||
names: &mut NameCache,
|
||||
) {
|
||||
debug!(
|
||||
"net: stable master {npc:#x} listed {} pets ({num_stable_slots} slots bought)",
|
||||
pets.len()
|
||||
);
|
||||
// **Seed the pet-name cache from the list** (decision 1688). Every row carries the pet's own
|
||||
// number and the name its owner gave it — the exact `(pet_number, name)` pair
|
||||
// `SMSG_PET_NAME_QUERY_RESPONSE` would answer with — so the pet a player unstables has a
|
||||
// resolvable `UnitName("pet")` the moment it is summoned, instead of after a round trip.
|
||||
//
|
||||
// The window this closes is a real one the director hit: `PetStable_Update` sets the current
|
||||
// pet's button tooltip to a bare `UnitName("pet")` (`PetStable.lua:161`, transcribed
|
||||
// unguarded because the reference is unguarded), and hands it to `GameTooltip:SetText`, whose
|
||||
// byte-pinned signature REQUIRES a string (`0x531b90`) and raises otherwise. Nil name ⇒ Lua
|
||||
// error dialog, once per unstable.
|
||||
//
|
||||
// Why the reference does not trip over its own unguarded line: its pet-name cache is
|
||||
// `petnamecache.wdb`, which **persists across sessions**, so a pet you have owned before is
|
||||
// already warm before the packet arrives. benilla has no such file, so a window the reference
|
||||
// has practically closed is wide open for us. Seeding is not a workaround for the missing
|
||||
// cache — it is using the answer the server has already sent us in this very packet.
|
||||
for pet in &pets {
|
||||
if !pet.name.is_empty() {
|
||||
names.insert_pet(pet.pet_number, pet.name.clone());
|
||||
}
|
||||
}
|
||||
stable_open.open(npc, num_stable_slots, pets);
|
||||
}
|
||||
|
||||
/// The answer to a stable verb (`SMSG_STABLE_RESULT`) — one byte, and the client's whole response
|
||||
/// to it is a five-way jump table (wow-re `system/ui/scratch/stable-master-window.md` §5, VERIFIED
|
||||
/// off the raw remap/jump bytes at `0x4cadac`/`0x4cad98`; decision 1677):
|
||||
///
|
||||
/// | code | what the client does |
|
||||
/// |---|---|
|
||||
/// | 1 | `DisplayError(0x25)` = **`ERR_NOT_ENOUGH_MONEY`** — the only code that says anything |
|
||||
/// | 2–7 | **absolutely nothing** — vmangos's catch-all `STABLE_ERR_STABLE = 6` included |
|
||||
/// | 8, 9 | re-request the list |
|
||||
/// | 10 | **`inc` the local purchased-slot count**, then re-request |
|
||||
/// | 11 | fire `PET_STABLE_UPDATE` (no vmangos counterpart — nothing in 1.12 sends it) |
|
||||
/// | 0, ≥12 | nothing |
|
||||
///
|
||||
/// The re-request is not a benilla convenience: no success carries an updated list, so without it
|
||||
/// the window would go on showing the pre-action arrangement.
|
||||
///
|
||||
/// **Code 10's local increment happens BEFORE the guid test**, so a buy-slot success arriving with
|
||||
/// no stable master open still bumps the count without refreshing. That ordering is reproduced
|
||||
/// deliberately — it is what makes the purchase row correct if the window is reopened, and the
|
||||
/// alternative would be to invent a tidier client than the one being reimplemented.
|
||||
fn stable_result(
|
||||
result: u8,
|
||||
stable_open: &mut StableOpen,
|
||||
errors: &mut StableErrors,
|
||||
net_commands: &NetCommands,
|
||||
) {
|
||||
use benilla_protocol::messages::stable_result as code;
|
||||
// Ahead of the guid test, exactly as `0x4cacf3` sits ahead of `0x4cad05`.
|
||||
if result == code::SUCCESS_BUY_SLOT {
|
||||
stable_open.num_stable_slots = stable_open.num_stable_slots.saturating_add(1);
|
||||
}
|
||||
match result {
|
||||
// The one code that speaks. Its text is the reference's own `GlobalStrings` value for
|
||||
// `ERR_NOT_ENOUGH_MONEY`, reached through `DisplayError` row 0x25.
|
||||
code::ERR_MONEY => {
|
||||
debug!("net: stable purchase refused — not enough money");
|
||||
errors.0.push("ERR_NOT_ENOUGH_MONEY");
|
||||
}
|
||||
code::SUCCESS_STABLE | code::SUCCESS_UNSTABLE | code::SUCCESS_BUY_SLOT => {
|
||||
let Some(npc) = stable_open.npc else {
|
||||
debug!("net: stable success {result} with no open stable — no re-list");
|
||||
return;
|
||||
};
|
||||
debug!("net: stable action succeeded (code {result}) — re-listing");
|
||||
let _ = net_commands.0.send(ClientCommand::ListStabledPets { npc });
|
||||
}
|
||||
// Codes 2–7 (the generic ERR_STABLE among them), 0 and ≥12: the client shows NOTHING.
|
||||
// Not an omission — the catch-all is one code for six causes, and the reference declines to
|
||||
// guess which.
|
||||
_ => debug!("net: stable result {result} — no client-visible effect"),
|
||||
}
|
||||
}
|
||||
@@ -323,10 +323,13 @@ fn drain_taxi(
|
||||
}
|
||||
}
|
||||
|
||||
mod net;
|
||||
|
||||
pub(crate) struct UiTaxiPlugin;
|
||||
|
||||
impl Plugin for UiTaxiPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
net::register(app);
|
||||
app.init_resource::<TaxiState>()
|
||||
.init_resource::<TaxiRouteCache>()
|
||||
.add_systems(
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
//! The taxi map's packet handlers (decision 0484 phase 1; in the net handler table since 2318,
|
||||
//! moved out of the drain's npc arm file) — the [`TaxiState`] session the taxi feed ([`super`])
|
||||
//! reads, and the flight masters' overhead status.
|
||||
|
||||
use benilla_protocol::messages::TaxiMask;
|
||||
use benilla_protocol::{SessionEvent, SessionEventKind};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::TaxiState;
|
||||
use crate::net::{GuidIndex, NetHandlerApp};
|
||||
|
||||
/// Register the taxi handlers — called from [`super::UiTaxiPlugin`]. One per kind, plus the
|
||||
/// session-end listener.
|
||||
pub(super) fn register(app: &mut App) {
|
||||
use SessionEventKind as K;
|
||||
app.net_handler(K::TaxiNodesShown, on_nodes_shown)
|
||||
.net_handler(K::TaxiNodeStatus, on_node_status)
|
||||
.net_handler(K::ActivateTaxiReply, on_activate_reply)
|
||||
.net_handler(K::NewTaxiPath, on_new_path)
|
||||
.net_handler(K::Disconnected, on_session_end);
|
||||
}
|
||||
|
||||
fn on_nodes_shown(In(ev): In<SessionEvent>, mut taxi: ResMut<TaxiState>) {
|
||||
if let SessionEvent::TaxiNodesShown {
|
||||
flightmaster,
|
||||
nearest_node,
|
||||
known_mask,
|
||||
} = ev
|
||||
{
|
||||
taxi_nodes_shown(flightmaster, nearest_node, known_mask, &mut taxi);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_node_status(In(ev): In<SessionEvent>, mut commands: Commands, index: Res<GuidIndex>) {
|
||||
if let SessionEvent::TaxiNodeStatus { guid, known } = ev {
|
||||
taxi_node_status(guid, known, &mut commands, &index);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_activate_reply(In(ev): In<SessionEvent>, mut taxi: ResMut<TaxiState>) {
|
||||
if let SessionEvent::ActivateTaxiReply { code } = ev {
|
||||
taxi_activate_reply(code, &mut taxi);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_new_path(In(ev): In<SessionEvent>, mut taxi: ResMut<TaxiState>) {
|
||||
if let SessionEvent::NewTaxiPath = ev {
|
||||
taxi_new_path(&mut taxi);
|
||||
}
|
||||
}
|
||||
|
||||
/// An open taxi map dies with the socket. A listener on the session end
|
||||
/// ([`crate::net::handlers::BROADCAST`]).
|
||||
fn on_session_end(In(_): In<SessionEvent>, mut taxi: ResMut<TaxiState>) {
|
||||
taxi.clear_session();
|
||||
}
|
||||
|
||||
/// The taxi map (`SMSG_SHOWTAXINODES`): fill the [`TaxiState`] phase 2's feed will read — the
|
||||
/// trainer list's shape.
|
||||
fn taxi_nodes_shown(
|
||||
flightmaster: u64,
|
||||
nearest_node: u32,
|
||||
known_mask: TaxiMask,
|
||||
taxi: &mut TaxiState,
|
||||
) {
|
||||
debug!("net: taxi map on {flightmaster:#x} — nearest node {nearest_node}");
|
||||
taxi.open(flightmaster, nearest_node, known_mask);
|
||||
}
|
||||
|
||||
/// A taxi node's known status (`SMSG_TAXINODE_STATUS`) — answers `CMSG_TAXINODE_STATUS_QUERY`
|
||||
/// ([`super`]'s per-flight-master ask), and also rides the first-visit learn pair
|
||||
/// alongside `SMSG_NEW_TAXI_PATH` ([`taxi_new_path`] carries that signal into `discovered`; the
|
||||
/// paired `known = true` here is what clears the icon live on a learn). Upserts
|
||||
/// [`super::FlightMasterStatus`] on the flight master: `known = false` shows the green
|
||||
/// `TalkToMeGreen` overhead icon — the client's `0x5ecdd0` handler → `0x607480` marker swap
|
||||
/// (resource table `0xc4d9d8` index 4; the 0497 §5). The client also gates the reply on the
|
||||
/// unit's NPC_FLAGS bit 3 — moot here: only flight masters are ever queried or answered.
|
||||
fn taxi_node_status(guid: u64, known: bool, commands: &mut Commands, index: &GuidIndex) {
|
||||
debug!("net: taxi node status — {guid:#x} known={known}");
|
||||
if let Some(&e) = index.0.get(&guid) {
|
||||
commands
|
||||
.entity(e)
|
||||
.insert(super::FlightMasterStatus { known });
|
||||
}
|
||||
}
|
||||
|
||||
/// The activate verdict (`SMSG_ACTIVATETAXIREPLY`) — staged for phase 2's window to surface as
|
||||
/// the red error line and clear (the trainer/merchant error-line pattern, folded into
|
||||
/// `TaxiState.reply` since there's no other consumer yet).
|
||||
fn taxi_activate_reply(code: u32, taxi: &mut TaxiState) {
|
||||
debug!("net: activate taxi reply — code {code}");
|
||||
taxi.reply = Some(code);
|
||||
}
|
||||
|
||||
/// A first-visit "learn" landed (`SMSG_NEW_TAXI_PATH`, empty body) — vmangos's only send site
|
||||
/// (`SendLearnNewTaxiNode`) always pairs this with a `SMSG_TAXINODE_STATUS(known=true)` for the
|
||||
/// same flight master, so the empty body alone is the discovery signal; phase 2's presentation
|
||||
/// reads the flag.
|
||||
fn taxi_new_path(taxi: &mut TaxiState) {
|
||||
debug!("net: taxi — first-visit node learned (SMSG_NEW_TAXI_PATH)");
|
||||
taxi.discovered = true;
|
||||
}
|
||||
@@ -130,10 +130,13 @@ impl TrainerOpen {
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct TrainerErrors(pub Vec<u32>);
|
||||
|
||||
mod net;
|
||||
|
||||
pub(crate) struct UiTrainerPlugin;
|
||||
|
||||
impl Plugin for UiTrainerPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
net::register(app);
|
||||
app.init_resource::<TrainerOpen>()
|
||||
.init_resource::<TrainerErrors>()
|
||||
.add_systems(
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
//! The trainer window's packet handlers (decision 0237; in the net handler table since 2318,
|
||||
//! moved out of the drain's npc arm file) — the [`TrainerOpen`] session and the
|
||||
//! [`TrainerErrors`] line queue the trainer feed ([`super`]) reads.
|
||||
|
||||
use benilla_protocol::messages::TrainerSpell;
|
||||
use benilla_protocol::{SessionEvent, SessionEventKind};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::{TrainerErrors, TrainerOpen};
|
||||
use crate::net::{ClientCommand, NetCommands, NetHandlerApp};
|
||||
|
||||
/// Register the trainer handlers — called from [`super::UiTrainerPlugin`]. One per kind, plus
|
||||
/// the session-end listener.
|
||||
pub(super) fn register(app: &mut App) {
|
||||
use SessionEventKind as K;
|
||||
app.net_handler(K::TrainerList, on_list)
|
||||
.net_handler(K::TrainerBuySucceeded, on_buy_succeeded)
|
||||
.net_handler(K::TrainerBuyFailed, on_buy_failed)
|
||||
.net_handler(K::Disconnected, on_session_end);
|
||||
}
|
||||
|
||||
fn on_list(In(ev): In<SessionEvent>, mut trainer_open: ResMut<TrainerOpen>) {
|
||||
if let SessionEvent::TrainerList {
|
||||
trainer,
|
||||
trainer_type,
|
||||
services,
|
||||
greeting,
|
||||
} = ev
|
||||
{
|
||||
trainer_list(trainer, trainer_type, services, greeting, &mut trainer_open);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_buy_succeeded(
|
||||
In(ev): In<SessionEvent>,
|
||||
mut trainer_open: ResMut<TrainerOpen>,
|
||||
commands: Res<NetCommands>,
|
||||
) {
|
||||
if let SessionEvent::TrainerBuySucceeded { trainer, spell_id } = ev {
|
||||
trainer_buy_succeeded(trainer, spell_id, &mut trainer_open, &commands);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_buy_failed(In(ev): In<SessionEvent>, mut errors: ResMut<TrainerErrors>) {
|
||||
if let SessionEvent::TrainerBuyFailed { error, .. } = ev {
|
||||
trainer_buy_failed(error, &mut errors);
|
||||
}
|
||||
}
|
||||
|
||||
/// An open trainer window dies with the socket. A listener on the session end
|
||||
/// ([`crate::net::handlers::BROADCAST`]).
|
||||
fn on_session_end(In(_): In<SessionEvent>, mut trainer_open: ResMut<TrainerOpen>) {
|
||||
trainer_open.clear_session();
|
||||
}
|
||||
|
||||
/// A trainer's service list (`SMSG_TRAINER_LIST`): fill the [`TrainerOpen`] the trainer feed
|
||||
/// ([`super`]) reads.
|
||||
fn trainer_list(
|
||||
trainer: u64,
|
||||
trainer_type: u32,
|
||||
services: Vec<TrainerSpell>,
|
||||
greeting: String,
|
||||
trainer_open: &mut TrainerOpen,
|
||||
) {
|
||||
debug!(
|
||||
"net: trainer {trainer:#x} (type {trainer_type}) listed {} services",
|
||||
services.len()
|
||||
);
|
||||
trainer_open.open(trainer, trainer_type, services, greeting);
|
||||
}
|
||||
|
||||
/// A trainer taught a service — confirmation only; the spell already landed via
|
||||
/// `SMSG_LEARNED_SPELL`. Re-request the list to repaint the bought row green→gray (and unlock
|
||||
/// any next rank); the server never auto-resends on a buy (VERIFIED vmangos
|
||||
/// `HandleTrainerBuySpellOpcode`). Guard on the window still being open for this trainer so a
|
||||
/// late reply for a closed/switched window doesn't re-open it.
|
||||
///
|
||||
/// This re-request is **benilla's**, not the reference's — the reference repaints a purchase by
|
||||
/// re-deriving every service's state client-side (`0x4d7d40`, decision 1128 §4.2) and receives no
|
||||
/// packet at all. So the answering list is marked as a *refresh* ([`TrainerOpen::refresh_pending`]):
|
||||
/// it repaints the open window, and must not carry the reference's per-packet filter/collapse reset,
|
||||
/// which is what dropped the player's "Available only" choice the moment they learned a spell
|
||||
/// (B256).
|
||||
fn trainer_buy_succeeded(
|
||||
trainer: u64,
|
||||
spell_id: u32,
|
||||
trainer_open: &mut TrainerOpen,
|
||||
net_commands: &NetCommands,
|
||||
) {
|
||||
debug!("net: trainer {trainer:#x} taught spell {spell_id} — re-listing");
|
||||
if trainer_open.trainer == Some(trainer) {
|
||||
trainer_open.refresh_pending = true;
|
||||
let _ = net_commands.0.send(ClientCommand::TrainerList { trainer });
|
||||
}
|
||||
}
|
||||
|
||||
/// A trainer refused a purchase — the trainer window's error line.
|
||||
fn trainer_buy_failed(error: u32, errors: &mut TrainerErrors) {
|
||||
debug!("net: trainer buy failed (code {error})");
|
||||
errors.0.push(error);
|
||||
}
|
||||
@@ -375,6 +375,90 @@ fn only_the_bids_pane_gets_its_page_without_being_opened() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// The show cascade, against the reference's own order — the mechanism the Bids pane's `page`
|
||||
/// rests on, checked rather than assumed. **Every outcome matches; the order does not** (2317).
|
||||
///
|
||||
/// wow-re (`system/ui/scratch/propagation.md`, "The show/hide visibility cascade") reads
|
||||
/// `0x76ae10` as **post-order**: a frame marks itself visible (`0x76ae7b`), walks its children, and
|
||||
/// fires its **own** `OnShow` last (`0x76aef5`, past both child loops), with no snapshot anywhere —
|
||||
/// each loop re-reads the live links, so a `Hide()` issued from a sibling's handler suppresses a
|
||||
/// later sibling by clearing its shown flag before the walk reaches it.
|
||||
///
|
||||
/// benilla fires the parent's own handler **first** and still notifies the descendant the parent's
|
||||
/// handler just hid. For this window the two routes land on the same state, which is why the
|
||||
/// assertions below are the reference's outcomes and only the order line is ours: the
|
||||
/// `hidden="false"` Bids pane gets exactly one `OnShow`, on the first open, taking the `page` its
|
||||
/// repaint needs and sending the one `GetBidderAuctionItems()` that pane's handler owes — and the
|
||||
/// reopen notifies neither, because the tab click left its own shown flag clear.
|
||||
///
|
||||
/// If the cascade is ever made post-order, the `ShowOrder` assertions are what should change here;
|
||||
/// nothing else in this test should have to.
|
||||
#[test]
|
||||
fn the_show_cascade_notifies_the_bids_pane_once_and_keeps_its_page() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = UiScript::new().unwrap();
|
||||
load_ui_with_classes(&mut s);
|
||||
seat_player(&mut s, 500_000);
|
||||
s.set_auction(Some(state(vec![row(
|
||||
"Linen Cloth",
|
||||
1000,
|
||||
5000,
|
||||
0,
|
||||
"Seller",
|
||||
)])));
|
||||
|
||||
// Both handlers are called by global name from their `<OnShow>`, so wrapping the globals
|
||||
// records the order the cascade actually fired them in.
|
||||
s.run(
|
||||
"ShowOrder = {} local pane, window = AuctionFrameBid_OnShow, AuctionFrame_OnShow AuctionFrameBid_OnShow = function() table.insert(ShowOrder, \"pane\") pane() end AuctionFrame_OnShow = function() table.insert(ShowOrder, \"window\") window() end",
|
||||
)
|
||||
.unwrap();
|
||||
let order = |s: &UiScript| {
|
||||
s.eval::<String>("return table.concat(ShowOrder, \",\")")
|
||||
.unwrap()
|
||||
};
|
||||
let page = |s: &UiScript| {
|
||||
s.eval::<String>("return tostring(AuctionFrameBid.page)")
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
s.fire_event("AUCTION_HOUSE_SHOW", vec![]);
|
||||
assert_eq!(
|
||||
order(&s),
|
||||
"window,pane",
|
||||
"OURS, and a known deviation (2317): the reference's cascade is post-order, \"pane,window\""
|
||||
);
|
||||
assert_eq!(
|
||||
page(&s),
|
||||
"0",
|
||||
"the pane the XML leaves shown takes its page riding the window up — this is what keeps a \
|
||||
mis-aimed AUCTION_BIDDER_LIST_UPDATE off the nil-page path"
|
||||
);
|
||||
assert_eq!(
|
||||
s.take_auction_bidder_query(),
|
||||
Some(0),
|
||||
"and that OnShow is what asks for the bids list"
|
||||
);
|
||||
|
||||
// Close and reopen. The tab click left the pane's own shown flag clear, so it is skipped:
|
||||
// the page is kept from the first open rather than re-assigned, and nothing new goes out.
|
||||
s.run("HideUIPanel(AuctionFrame)").unwrap();
|
||||
let _ = s.take_auction_close();
|
||||
s.fire_event("AUCTION_HOUSE_SHOW", vec![]);
|
||||
assert_eq!(
|
||||
order(&s),
|
||||
"window,pane,window",
|
||||
"the reopen notifies the window and not the pane it left hidden"
|
||||
);
|
||||
assert_eq!(page(&s), "0", "kept from the first open, not re-assigned");
|
||||
assert_eq!(
|
||||
s.take_auction_bidder_query(),
|
||||
None,
|
||||
"and the reopen puts no second bids query on the wire"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The part that proves it works: a fed snapshot paints the Browse rows.
|
||||
#[test]
|
||||
fn the_browse_list_populates_from_the_fed_snapshot() {
|
||||
|
||||
@@ -12,10 +12,19 @@ use super::{FrameHandle, RegionHandle, WidgetArena, SCALE_EPS};
|
||||
impl WidgetArena {
|
||||
// ── Visibility (effective_visible_show/_hide) ────────────────────────────────────────────────
|
||||
|
||||
/// Set a frame's own `shown` bit and propagate effective visibility through its subtree, per
|
||||
/// Set a frame's own `shown` bit and propagate effective visibility through its subtree, after
|
||||
/// `effective_visible_show 0x76ae10` / `_hide 0x76ad50` (`propagation.md`). Returns, in
|
||||
/// pre-order (a node before its descendants), **every frame whose `effective_visible` actually
|
||||
/// changed** — the caller fires `OnShow`/`OnHide` for those, in order. A no-op `shown` write, or
|
||||
/// changed** — the caller fires `OnShow`/`OnHide` for those, in order.
|
||||
///
|
||||
/// **Two recorded deviations from `0x76ae10`, both in this signature** (decision 2317). The
|
||||
/// reference is **post-order** — it marks itself visible, recurses into its children, and fires
|
||||
/// its own notify last (`0x76aef5`, past both child loops) — so descendants are notified before
|
||||
/// ancestors. And it keeps **no snapshot**: each loop re-reads the live links after every
|
||||
/// per-node call, so a frame hidden by a handler mid-cascade refuses at its own gate
|
||||
/// (`0x76ae1d`) and is never notified, where the list returned here already holds it. The
|
||||
/// auction window exercises exactly that case and lands on the same state by the other road;
|
||||
/// 2317 has the measurements and the risk for whoever changes this. A no-op `shown` write, or
|
||||
/// a change that does not move any effective visibility (e.g. hiding an already-invisible frame),
|
||||
/// returns empty.
|
||||
pub fn set_shown(&mut self, h: FrameHandle, shown: bool) -> Vec<FrameHandle> {
|
||||
|
||||
Reference in New Issue
Block a user