From d0e29635d0ab1a731882bf3ef6a2e762a4efb2ef Mon Sep 17 00:00:00 2001 From: Sam Date: Sat, 19 Sep 2026 17:11:59 +0700 Subject: [PATCH] the NPC, loot and quest windows register their handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- crates/benilla-app/src/game_plugins.rs | 8 +- crates/benilla-app/src/net/apply.rs | 232 +--------- crates/benilla-app/src/net/apply/npc.rs | 420 ------------------ crates/benilla-app/src/net/apply/params.rs | 35 +- crates/benilla-app/src/net/apply/session.rs | 31 -- crates/benilla-app/src/poi_marker.rs | 46 ++ .../src/{ui_bank.rs => ui_bank/mod.rs} | 3 + crates/benilla-app/src/ui_bank/net.rs | 97 ++++ .../src/{ui_gossip.rs => ui_gossip/mod.rs} | 4 + crates/benilla-app/src/ui_gossip/net.rs | 146 ++++++ crates/benilla-app/src/ui_items/mod.rs | 2 + crates/benilla-app/src/ui_items/net.rs | 118 +++++ .../src/{ui_loot.rs => ui_loot/mod.rs} | 3 + .../src/{net/apply/loot.rs => ui_loot/net.rs} | 275 ++++++------ crates/benilla-app/src/ui_loot_roll.rs | 75 ++++ .../{ui_merchant.rs => ui_merchant/mod.rs} | 3 + crates/benilla-app/src/ui_merchant/net.rs | 119 +++++ .../src/{ui_quest.rs => ui_quest/mod.rs} | 3 + .../{net/apply/quests.rs => ui_quest/net.rs} | 212 ++++++--- crates/benilla-app/src/ui_quest_log.rs | 39 ++ crates/benilla-app/src/ui_quest_share.rs | 66 +++ .../src/ui_script/merchant_tests.rs | 2 +- .../benilla-app/src/ui_script/panel_tests.rs | 2 +- .../src/{ui_stable.rs => ui_stable/mod.rs} | 3 + crates/benilla-app/src/ui_stable/net.rs | 135 ++++++ crates/benilla-app/src/ui_taxi/mod.rs | 3 + crates/benilla-app/src/ui_taxi/net.rs | 102 +++++ crates/benilla-app/src/ui_trainer/mod.rs | 3 + crates/benilla-app/src/ui_trainer/net.rs | 101 +++++ crates/benilla-app/tests/auction_frame.rs | 84 ++++ crates/benilla-ui/src/widget/propagation.rs | 13 +- 31 files changed, 1465 insertions(+), 920 deletions(-) delete mode 100644 crates/benilla-app/src/net/apply/npc.rs rename crates/benilla-app/src/{ui_bank.rs => ui_bank/mod.rs} (99%) create mode 100644 crates/benilla-app/src/ui_bank/net.rs rename crates/benilla-app/src/{ui_gossip.rs => ui_gossip/mod.rs} (99%) create mode 100644 crates/benilla-app/src/ui_gossip/net.rs create mode 100644 crates/benilla-app/src/ui_items/net.rs rename crates/benilla-app/src/{ui_loot.rs => ui_loot/mod.rs} (99%) rename crates/benilla-app/src/{net/apply/loot.rs => ui_loot/net.rs} (83%) rename crates/benilla-app/src/{ui_merchant.rs => ui_merchant/mod.rs} (99%) create mode 100644 crates/benilla-app/src/ui_merchant/net.rs rename crates/benilla-app/src/{ui_quest.rs => ui_quest/mod.rs} (99%) rename crates/benilla-app/src/{net/apply/quests.rs => ui_quest/net.rs} (76%) rename crates/benilla-app/src/{ui_stable.rs => ui_stable/mod.rs} (99%) create mode 100644 crates/benilla-app/src/ui_stable/net.rs create mode 100644 crates/benilla-app/src/ui_taxi/net.rs create mode 100644 crates/benilla-app/src/ui_trainer/net.rs diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index 6ad9e665..76c0a8cb 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -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`); 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)"), diff --git a/crates/benilla-app/src/net/apply.rs b/crates/benilla-app/src/net/apply.rs index 7313b609..f25facb1 100644 --- a/crates/benilla-app/src/net/apply.rs +++ b/crates/benilla-app/src/net/apply.rs @@ -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, diff --git a/crates/benilla-app/src/net/apply/npc.rs b/crates/benilla-app/src/net/apply/npc.rs deleted file mode 100644 index 8dc12da2..00000000 --- a/crates/benilla-app/src/net/apply/npc.rs +++ /dev/null @@ -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, - 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, - 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, 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, - 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, - 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)); - } -} diff --git a/crates/benilla-app/src/net/apply/params.rs b/crates/benilla-app/src/net/apply/params.rs index 90d3937e..daecefe9 100644 --- a/crates/benilla-app/src/net/apply/params.rs +++ b/crates/benilla-app/src/net/apply/params.rs @@ -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 `$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>, pub exploration_sounds: Option>, - /// The map the guard's directions marker is stamped with. - pub current_map: Option>, } diff --git a/crates/benilla-app/src/net/apply/session.rs b/crates/benilla-app/src/net/apply/session.rs index f8b1833c..2f71bb4d 100644 --- a/crates/benilla-app/src/net/apply/session.rs +++ b/crates/benilla-app/src/net/apply/session.rs @@ -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, @@ -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 diff --git a/crates/benilla-app/src/poi_marker.rs b/crates/benilla-app/src/poi_marker.rs index c147136e..8ba611ad 100644 --- a/crates/benilla-app/src/poi_marker.rs +++ b/crates/benilla-app/src/poi_marker.rs @@ -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, + mut marker: ResMut, + current_map: Option>, + real_clock: Res>, +) { + 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::() .add_systems(Update, (expire_marker, clear_on_world_entry)); } diff --git a/crates/benilla-app/src/ui_bank.rs b/crates/benilla-app/src/ui_bank/mod.rs similarity index 99% rename from crates/benilla-app/src/ui_bank.rs rename to crates/benilla-app/src/ui_bank/mod.rs index 42d1c045..9d6e5103 100644 --- a/crates/benilla-app/src/ui_bank.rs +++ b/crates/benilla-app/src/ui_bank/mod.rs @@ -83,10 +83,13 @@ impl NpcSession for BankOpen { #[derive(Resource, Default)] pub(crate) struct BankErrors(pub Vec); +mod net; + pub(crate) struct UiBankPlugin; impl Plugin for UiBankPlugin { fn build(&self, app: &mut App) { + net::register(app); app.init_resource::() .init_resource::() .add_systems( diff --git a/crates/benilla-app/src/ui_bank/net.rs b/crates/benilla-app/src/ui_bank/net.rs new file mode 100644 index 00000000..e2157b8f --- /dev/null +++ b/crates/benilla-app/src/ui_bank/net.rs @@ -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, + mut bank: ResMut, + mut gossip: ResMut, + mut quest: ResMut, +) { + if let SessionEvent::ShowBank { banker } = ev { + show_bank(banker, &mut bank, &mut gossip, &mut quest); + } +} + +fn on_buy_slot_result(In(ev): In, mut errors: ResMut) { + 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, mut bank: ResMut) { + 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)); + } +} diff --git a/crates/benilla-app/src/ui_gossip.rs b/crates/benilla-app/src/ui_gossip/mod.rs similarity index 99% rename from crates/benilla-app/src/ui_gossip.rs rename to crates/benilla-app/src/ui_gossip/mod.rs index 32c7e830..2c4709db 100644 --- a/crates/benilla-app/src/ui_gossip.rs +++ b/crates/benilla-app/src/ui_gossip/mod.rs @@ -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::().add_systems( Update, ( diff --git a/crates/benilla-app/src/ui_gossip/net.rs b/crates/benilla-app/src/ui_gossip/net.rs new file mode 100644 index 00000000..d4dd41e3 --- /dev/null +++ b/crates/benilla-app/src/ui_gossip/net.rs @@ -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, + mut gossip: ResMut, + commands: Res, + index: Res, + 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, + mut gossip: ResMut, + index: Res, + 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, + mut gossip: ResMut, + mut quest: ResMut, +) { + 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, mut gossip: ResMut) { + 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, + 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, + 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(); +} diff --git a/crates/benilla-app/src/ui_items/mod.rs b/crates/benilla-app/src/ui_items/mod.rs index 1e38fd77..910afdbf 100644 --- a/crates/benilla-app/src/ui_items/mod.rs +++ b/crates/benilla-app/src/ui_items/mod.rs @@ -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::() diff --git a/crates/benilla-app/src/ui_items/net.rs b/crates/benilla-app/src/ui_items/net.rs new file mode 100644 index 00000000..3a45739d --- /dev/null +++ b/crates/benilla-app/src/ui_items/net.rs @@ -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, + mut equip_errors: ResMut, + mut pending: ResMut, + mut lock_cleared: ResMut, + mut latch: ResMut, +) { + 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, + 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); + } +} diff --git a/crates/benilla-app/src/ui_loot.rs b/crates/benilla-app/src/ui_loot/mod.rs similarity index 99% rename from crates/benilla-app/src/ui_loot.rs rename to crates/benilla-app/src/ui_loot/mod.rs index a7035baf..f6dcccc7 100644 --- a/crates/benilla-app/src/ui_loot.rs +++ b/crates/benilla-app/src/ui_loot/mod.rs @@ -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, mut loot: ResMut() .init_resource::() diff --git a/crates/benilla-app/src/net/apply/loot.rs b/crates/benilla-app/src/ui_loot/net.rs similarity index 83% rename from crates/benilla-app/src/net/apply/loot.rs rename to crates/benilla-app/src/ui_loot/net.rs index 2d952fe0..730d9874 100644 --- a/crates/benilla-app/src/net/apply/loot.rs +++ b/crates/benilla-app/src/ui_loot/net.rs @@ -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, + mut loot: ResMut, + mut latch: ResMut, + commands: Res, +) { + 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, + mut errors: ResMut, + mut latch: ResMut, +) { + if let SessionEvent::LootError { guid, error } = ev { + loot_error(guid, error, &mut errors, &mut latch); + } +} + +fn on_removed(In(ev): In, mut loot: ResMut) { + if let SessionEvent::LootRemoved { slot } = ev { + loot_removed(slot, &mut loot); + } +} + +fn on_money_notify(In(ev): In) { + if let SessionEvent::LootMoneyNotify { amount } = ev { + loot_money_notify(amount); + } +} + +fn on_clear_money(In(ev): In, mut loot: ResMut) { + if let SessionEvent::LootClearMoney = ev { + loot_clear_money(&mut loot); + } +} + +fn on_release_response( + In(ev): In, + mut loot: ResMut, + mut latch: ResMut, +) { + if let SessionEvent::LootReleaseResponse { guid } = ev { + loot_release_response(guid, &mut loot, &mut latch); + } +} + +fn on_master_list(In(ev): In, mut loot: ResMut) { + if let SessionEvent::LootMasterList { candidates } = ev { + loot_master_list(candidates, &mut loot); + } +} + +fn on_fish_verdict(In(ev): In, mut errors: ResMut) { + match ev { + SessionEvent::FishNotHooked => fish_verdict(false, &mut errors), + SessionEvent::FishEscaped => fish_verdict(true, &mut errors), + _ => {} + } +} + +fn on_item_push_result( + In(ev): In, + self_guid: Res, + mut loot: ResMut, + mut tutorials: ResMut, +) { + 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, + mut loot: ResMut, + mut latch: ResMut, +) { + 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, 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, loot: &mut LootState) { +fn loot_master_list(candidates: Vec, 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, 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, - 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; diff --git a/crates/benilla-app/src/ui_loot_roll.rs b/crates/benilla-app/src/ui_loot_roll.rs index 7d6d4f79..3cfc5e01 100644 --- a/crates/benilla-app/src/ui_loot_roll.rs +++ b/crates/benilla-app/src/ui_loot_roll.rs @@ -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, mut rolls: ResMut) { + 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, mut rolls: ResMut) { + 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::().add_systems( Update, ( diff --git a/crates/benilla-app/src/ui_merchant.rs b/crates/benilla-app/src/ui_merchant/mod.rs similarity index 99% rename from crates/benilla-app/src/ui_merchant.rs rename to crates/benilla-app/src/ui_merchant/mod.rs index f7e7ea91..9bb36ea5 100644 --- a/crates/benilla-app/src/ui_merchant.rs +++ b/crates/benilla-app/src/ui_merchant/mod.rs @@ -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::() .init_resource::() .add_systems( diff --git a/crates/benilla-app/src/ui_merchant/net.rs b/crates/benilla-app/src/ui_merchant/net.rs new file mode 100644 index 00000000..d8440fa9 --- /dev/null +++ b/crates/benilla-app/src/ui_merchant/net.rs @@ -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, mut merchant: ResMut) { + if let SessionEvent::VendorInventory { vendor, items } = ev { + vendor_inventory(vendor, items, &mut merchant); + } +} + +fn on_buy_result(In(ev): In, mut merchant: ResMut) { + 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, + mut merchant: ResMut, + mut errors: ResMut, +) { + 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, mut errors: ResMut) { + 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, mut merchant: ResMut) { + 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, 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)); +} diff --git a/crates/benilla-app/src/ui_quest.rs b/crates/benilla-app/src/ui_quest/mod.rs similarity index 99% rename from crates/benilla-app/src/ui_quest.rs rename to crates/benilla-app/src/ui_quest/mod.rs index 7cfa6928..31e9cbc8 100644 --- a/crates/benilla-app/src/ui_quest.rs +++ b/crates/benilla-app/src/ui_quest/mod.rs @@ -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::().add_systems( Update, ( diff --git a/crates/benilla-app/src/net/apply/quests.rs b/crates/benilla-app/src/ui_quest/net.rs similarity index 76% rename from crates/benilla-app/src/net/apply/quests.rs rename to crates/benilla-app/src/ui_quest/net.rs index e65d5e57..a174c717 100644 --- a/crates/benilla-app/src/net/apply/quests.rs +++ b/crates/benilla-app/src/ui_quest/net.rs @@ -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, mut quest: ResMut) { + if let SessionEvent::QuestGiverStatus { npc, status } = ev { + quest_giver_status(npc, status, &mut quest); + } +} + +fn on_greeting(In(ev): In, mut quest: ResMut) { + if let SessionEvent::QuestGreeting(list) = ev { + quest_greeting(list, &mut quest); + } +} + +fn on_detail(In(ev): In, mut quest: ResMut, commands: Res) { + if let SessionEvent::QuestDetail(d) = ev { + quest_detail(d, &mut quest, &commands); + } +} + +fn on_progress(In(ev): In, mut quest: ResMut) { + if let SessionEvent::QuestProgress(p) = ev { + quest_progress(p, &mut quest); + } +} + +fn on_offer(In(ev): In, mut quest: ResMut) { + if let SessionEvent::QuestOffer(o) = ev { + quest_offer(o, &mut quest); + } +} + +fn on_complete(In(ev): In, mut quest: ResMut) { + if let SessionEvent::QuestComplete(c) = ev { + quest_complete(c, &mut quest); + } +} + +fn on_objective_kill(In(ev): In, mut quest: ResMut) { + 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, mut quest: ResMut) { + if let SessionEvent::QuestObjectiveItem { item_id, count } = ev { + quest_objective_item(item_id, count, &mut quest); + } +} + +fn on_objectives_complete(In(ev): In, mut quest: ResMut) { + if let SessionEvent::QuestObjectivesComplete { quest_id } = ev { + quest_objectives_complete(quest_id, &mut quest); + } +} + +fn on_failed( + In(ev): In, + mut quest: ResMut, + mut quest_log: ResMut, + commands: Res, +) { + 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, mut quest: ResMut) { + if let SessionEvent::QuestLogFull = ev { + quest_log_full(&mut quest); + } +} + +fn on_giver_invalid(In(ev): In, mut quest: ResMut) { + if let SessionEvent::QuestGiverInvalid { reason } = ev { + quest_giver_invalid(reason, &mut quest); + } +} + +fn on_giver_failed( + In(ev): In, + mut quest: ResMut, + mut quest_log: ResMut, + commands: Res, +) { + 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, mut quest: ResMut) { + 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, 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, 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::*; diff --git a/crates/benilla-app/src/ui_quest_log.rs b/crates/benilla-app/src/ui_quest_log.rs index dee3e84d..a2c1cdec 100644 --- a/crates/benilla-app/src/ui_quest_log.rs +++ b/crates/benilla-app/src/ui_quest_log.rs @@ -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, mut quest_log: ResMut) { + 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, mut quest_log: ResMut) { + 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, 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::(app); app.init_resource::() .add_systems( diff --git a/crates/benilla-app/src/ui_quest_share.rs b/crates/benilla-app/src/ui_quest_share.rs index ee9fc878..4631ae3f 100644 --- a/crates/benilla-app/src/ui_quest_share.rs +++ b/crates/benilla-app/src/ui_quest_share.rs @@ -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, mut share: ResMut) { + if let SessionEvent::QuestPushResult { member, msg } = ev { + quest_push_result(member, msg, &mut share); + } + } + + fn on_confirm_accept(In(ev): In, mut share: ResMut) { + 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, mut share: ResMut) { + 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. diff --git a/crates/benilla-app/src/ui_script/merchant_tests.rs b/crates/benilla-app/src/ui_script/merchant_tests.rs index 4b195f40..6921e3bd 100644 --- a/crates/benilla-app/src/ui_script/merchant_tests.rs +++ b/crates/benilla-app/src/ui_script/merchant_tests.rs @@ -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), }], diff --git a/crates/benilla-app/src/ui_script/panel_tests.rs b/crates/benilla-app/src/ui_script/panel_tests.rs index 7a2f5e59..9905bb8a 100644 --- a/crates/benilla-app/src/ui_script/panel_tests.rs +++ b/crates/benilla-app/src/ui_script/panel_tests.rs @@ -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, diff --git a/crates/benilla-app/src/ui_stable.rs b/crates/benilla-app/src/ui_stable/mod.rs similarity index 99% rename from crates/benilla-app/src/ui_stable.rs rename to crates/benilla-app/src/ui_stable/mod.rs index a2d1adf4..d6c2d362 100644 --- a/crates/benilla-app/src/ui_stable.rs +++ b/crates/benilla-app/src/ui_stable/mod.rs @@ -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::() .init_resource::() .add_systems( diff --git a/crates/benilla-app/src/ui_stable/net.rs b/crates/benilla-app/src/ui_stable/net.rs new file mode 100644 index 00000000..35b84f86 --- /dev/null +++ b/crates/benilla-app/src/ui_stable/net.rs @@ -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, + mut stable_open: ResMut, + mut names: ResMut, +) { + 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, + mut stable_open: ResMut, + mut errors: ResMut, + commands: Res, +) { + 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, + 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"), + } +} diff --git a/crates/benilla-app/src/ui_taxi/mod.rs b/crates/benilla-app/src/ui_taxi/mod.rs index 71447d52..e4a1e28c 100644 --- a/crates/benilla-app/src/ui_taxi/mod.rs +++ b/crates/benilla-app/src/ui_taxi/mod.rs @@ -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::() .init_resource::() .add_systems( diff --git a/crates/benilla-app/src/ui_taxi/net.rs b/crates/benilla-app/src/ui_taxi/net.rs new file mode 100644 index 00000000..e323dd96 --- /dev/null +++ b/crates/benilla-app/src/ui_taxi/net.rs @@ -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, mut taxi: ResMut) { + 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, mut commands: Commands, index: Res) { + if let SessionEvent::TaxiNodeStatus { guid, known } = ev { + taxi_node_status(guid, known, &mut commands, &index); + } +} + +fn on_activate_reply(In(ev): In, mut taxi: ResMut) { + if let SessionEvent::ActivateTaxiReply { code } = ev { + taxi_activate_reply(code, &mut taxi); + } +} + +fn on_new_path(In(ev): In, mut taxi: ResMut) { + 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, mut taxi: ResMut) { + 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; +} diff --git a/crates/benilla-app/src/ui_trainer/mod.rs b/crates/benilla-app/src/ui_trainer/mod.rs index a9240c20..d4eac0bf 100644 --- a/crates/benilla-app/src/ui_trainer/mod.rs +++ b/crates/benilla-app/src/ui_trainer/mod.rs @@ -130,10 +130,13 @@ impl TrainerOpen { #[derive(Resource, Default)] pub(crate) struct TrainerErrors(pub Vec); +mod net; + pub(crate) struct UiTrainerPlugin; impl Plugin for UiTrainerPlugin { fn build(&self, app: &mut App) { + net::register(app); app.init_resource::() .init_resource::() .add_systems( diff --git a/crates/benilla-app/src/ui_trainer/net.rs b/crates/benilla-app/src/ui_trainer/net.rs new file mode 100644 index 00000000..a76231fd --- /dev/null +++ b/crates/benilla-app/src/ui_trainer/net.rs @@ -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, mut trainer_open: ResMut) { + 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, + mut trainer_open: ResMut, + commands: Res, +) { + 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, mut errors: ResMut) { + 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, mut trainer_open: ResMut) { + 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, + 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); +} diff --git a/crates/benilla-app/tests/auction_frame.rs b/crates/benilla-app/tests/auction_frame.rs index 9f55dd43..d9280a5e 100644 --- a/crates/benilla-app/tests/auction_frame.rs +++ b/crates/benilla-app/tests/auction_frame.rs @@ -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 ``, 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::("return table.concat(ShowOrder, \",\")") + .unwrap() + }; + let page = |s: &UiScript| { + s.eval::("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() { diff --git a/crates/benilla-ui/src/widget/propagation.rs b/crates/benilla-ui/src/widget/propagation.rs index 1ac190b5..1aef18e9 100644 --- a/crates/benilla-ui/src/widget/propagation.rs +++ b/crates/benilla-ui/src/widget/propagation.rs @@ -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 {