diff --git a/CONTEXT.md b/CONTEXT.md index 54ac64fa5..07c94df6e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1254,7 +1254,7 @@ _Avoid_: client patch, distribution, release bundle, player package The interface where session or protocol handling hands work to durable state, expressed as a trait so tests can substitute the far side. Not the Shard Boundary, not any arbitrary trait. **Protocol Family**: -A group of client opcodes one Gateway handler owns, with the Store trait that handler calls (for example the vendor family and `VendorActionStore`). `WorldStore` is the umbrella over every family; a handler takes only its family's Store. +A group of client opcodes one Gateway handler owns, with the Store traits that handler calls. The opcode ownership table selects that handler once. Each handler implements `ProtocolFamily`, reads or changes shared protocol state, and returns its outbound messages. `WorldStore` holds all Store traits for the World Session; each handler names only the Stores it needs. _Avoid_: family (unqualified), domain **Store**: diff --git a/CORE_TERMS.md b/CORE_TERMS.md index 11782121e..806870f68 100644 --- a/CORE_TERMS.md +++ b/CORE_TERMS.md @@ -130,7 +130,7 @@ _Avoid_: SDK, plugin API, public API, allowlist The interface where session or protocol handling hands work to durable state, expressed as a trait so tests can substitute the far side. Not the Shard Boundary, not any arbitrary trait. **Protocol Family**: -A group of client opcodes one Gateway handler owns, with the Store trait that handler calls (for example the vendor family and `VendorActionStore`). `WorldStore` is the umbrella over every family; a handler takes only its family's Store. +A group of client opcodes one Gateway handler owns, with the Store traits that handler calls. The opcode ownership table selects that handler once. Each handler implements `ProtocolFamily`, reads or changes shared protocol state, and returns its outbound messages. `WorldStore` holds all Store traits for the World Session; each handler names only the Stores it needs. _Avoid_: family (unqualified), domain **Store**: diff --git a/docs/recipes/add-an-opcode.md b/docs/recipes/add-an-opcode.md index 4f150b74e..23d1f893a 100644 --- a/docs/recipes/add-an-opcode.md +++ b/docs/recipes/add-an-opcode.md @@ -86,25 +86,25 @@ or another family also calls is an `impl Coordinator` method in `gateway/src/std in the Coordinator cache by a unique index. The SDK cache has no other index, and a whole-table `iter()` holds the lock the pump needs. -## 7. Write the family dispatcher - -File: `gateway/src/world/handlers/.rs`, declared in `gateway/src/world/handlers/mod.rs` with -a `pub(crate) use` of its public items. For an opcode in an existing family, add an arm to that -family's dispatcher and skip to step 10. - -- A Store trait, `ActionStore`. Each method's doc says whether it is a Durable Request or a - Durable Read and on which Shard. A method that acts for a Character takes `actor: Actor`. Steps 5 - and 6 implement it. -- The dispatcher resolves the session's Character to `Option` once, with `Actor::new`. When - it is `None`, make no Durable Request. The client usually gets no answer. -- When a Store call fails, match on `classify(&error)`. A `DurableFailure::Refusal { reason }` gets - the family's answer to the client and the session continues. A `DurableFailure::TransportLoss` - returns the `Err`, which ends the World Session. -- A player struct with the session facts the family needs, and an outcome enum with - `Handled { outbound }` and `PassThrough(msg)`. -- `dispatch__action(store, player, msg)` matches the family's `ClientOpcodeMessage` variants - and passes every other message through. -- A local Fake of the Store and the family's tests in the same file. +## 7. Write the Protocol Family handler + +File: `gateway/src/world/handlers/.rs`, declared and re-exported in +`gateway/src/world/handlers/mod.rs`. For an existing Protocol Family, add its request arm and +ownership entry, then continue with the packet and tests. + +- Keep Durable Reads and Durable Requests on the Protocol Family's Store trait. A method that acts for a + Character takes `actor: Actor`. Steps 5 and 6 implement it. +- Implement `ProtocolFamily` on the handler. Its `handle` method takes the Store, shared + `ProtocolSession`, and `ProtocolRequest`, and returns `ProtocolReply`. +- Resolve the Character with `session.actor()`. Before world entry it is `None`; preserve the + opcode's specified reply without inventing an Actor. +- Classify Store errors with `classify`. A Refusal gets the Protocol Family's reply. A Transport Loss + returns `Err`, which ends the World Session. +- Update local protocol state after the Durable Request succeeds and return outbound messages in + their required order. The World Session queues them. Existing operations that must follow that + queue step, such as taxi arming, use `WorldSessionAction`. +- Keep a local Fake and test through `handle`. The shared `ProtocolSession` needs no socket or + routed Store. Do not add a separate context for each Protocol Family, dispatch outcome, or pass-through result. ## 8. Join the family to `WorldStore` @@ -113,15 +113,19 @@ family's dispatcher and skip to step 10. - File: `gateway/src/world/test_support/world_fake/.rs`. Implement the trait for `WorldFake`, the shared Fake that implements every family, and keep the family's state in its own struct there. -## 9. Link the dispatcher into the chain +## 9. Declare opcode ownership -File: `gateway/src/world/mod.rs`. In `dispatch`, add a `match dispatch__action(...)` link -that sends `Handled` messages and returns, and passes `PassThrough` on. Re-export the family's items -in the `pub(crate) use handlers::{...}` list. +File: `gateway/src/world/routing.rs`. Add the opcode type to its owner's entry in +`protocol_families!`. The declaration supplies both the ownership table and the handler call. +A new Protocol Family needs one entry with its handler type and opcodes. -The order of the links matters. The first dispatcher that matches an opcode consumes it, and the -links before yours must not claim your opcodes. A message no link takes reaches the debug log -`world: ignoring`. +The ownership test refuses duplicate entries. Declaration order has no routing meaning. A handler +receives only its owned requests; an unexpected opcode is a routing defect. If one opcode serves +several interactions, its owner selects the operation from the payload or Durable Read. GameObject +use and chat are examples. + +The reader supplies the numeric opcode, including requests whose packet layout needs a custom +reader. Movement coalescing and the Chat Flood Limiter run before handler dispatch. ## 10. Build the packet diff --git a/gateway/src/stdb/subscriptions_meeting_stone_durable_tests.rs b/gateway/src/stdb/subscriptions_meeting_stone_durable_tests.rs index fdd64153f..bc26d1393 100644 --- a/gateway/src/stdb/subscriptions_meeting_stone_durable_tests.rs +++ b/gateway/src/stdb/subscriptions_meeting_stone_durable_tests.rs @@ -9,8 +9,8 @@ use crate::accept::BlockingTaskCapacity; use crate::config::GatewayConfig; use crate::durable_test_support::{module_bytes, poll_until, Standalone, POLL_TIMEOUT}; use crate::world::{ - dispatch_meeting_stone_action, MeetingStoneActionOutcome, MeetingStoneActionStore, - MeetingStonePlayer, Outbound, WorldSessionToken, + MeetingStone, MeetingStoneActionStore, Outbound, ProtocolFamily, ProtocolSession, + WorldSessionToken, }; use lyracore_shared::meeting_stone::queue_status::{JOINED_QUEUE, NONE}; use std::sync::mpsc::Receiver; @@ -106,14 +106,13 @@ fn use_stone(session: &Coordinator, guid: u64, stone: u64) -> Vec { } fn send(session: &Coordinator, guid: u64, msg: ClientOpcodeMessage) -> Vec { - let player = MeetingStonePlayer { - account_id: guid, - self_guid: Some(guid), - }; - match dispatch_meeting_stone_action(session, player, msg).unwrap() { - MeetingStoneActionOutcome::Handled { outbound } => outbound, - MeetingStoneActionOutcome::PassThrough(_) => panic!("the meeting stone seam passed it on"), - } + MeetingStone::handle( + session, + &mut ProtocolSession::in_world(guid, guid), + msg.into(), + ) + .unwrap() + .outbound } /// The meeting stone messages in `outbound`, running each relay job the way the session writer diff --git a/gateway/src/world/auction_tests.rs b/gateway/src/world/auction_tests.rs index 5323be5ba..81e07af19 100644 --- a/gateway/src/world/auction_tests.rs +++ b/gateway/src/world/auction_tests.rs @@ -1,19 +1,16 @@ -//! The auction house: the views through their dispatcher, and the raw browse path over an -//! encrypted World Session. +//! Auction replies through the Protocol Family and encrypted World Session. -use super::handlers::{store_with, InMemoryAuctionActions}; +use super::handlers::{store_with, Auction, InMemoryAuctionActions}; use super::*; -const PLAYER: AuctionActionPlayer = AuctionActionPlayer { self_guid: Some(1) }; - -/// Dispatch one auction message and return the packets the session would send for it. fn run(actions: &InMemoryAuctionActions, msg: impl Into) -> Vec { - match dispatch_auction_action(actions, PLAYER, msg.into()).unwrap() { - AuctionActionOutcome::Handled { outbound } => outbound, - AuctionActionOutcome::PassThrough(_) => { - panic!("the auction dispatcher passed the message on") - } - } + Auction::handle( + actions, + &mut ProtocolSession::in_world(7, 1), + ProtocolRequest::Message(msg.into()), + ) + .unwrap() + .outbound } /// The one typed packet a request answered with. diff --git a/gateway/src/world/bank_tests.rs b/gateway/src/world/bank_tests.rs index 7da868612..586abb5d8 100644 --- a/gateway/src/world/bank_tests.rs +++ b/gateway/src/world/bank_tests.rs @@ -1,7 +1,6 @@ -//! Bank opcodes, run through `handle_bank` against a Fake that holds only the Bank and Npc Stores -//! the handler is bounded on. +//! Bank replies and durable outcomes through the Bank and Npc Stores. -use super::handlers::{handle_bank, BankStore, NpcStore}; +use super::handlers::{Bank, BankStore, NpcStore}; use super::*; use crate::stdb::ReducerCallError; use std::collections::BTreeSet; @@ -86,24 +85,24 @@ impl BankStore for BankFake { } } -/// Send `msg` as Character 1 of account 7 and return what the handler sent, in order. -/// Phase 5 retargets only this helper. +/// Handle `msg` as Character 1 of Account 7 and return the replies in order. fn run(store: &BankFake, msg: impl Into) -> Vec { - let (tx, rx) = SessionTx::with_depth(0); - let mut conn = in_world_conn(7, 1); - let passed_on = handle_bank(&tx, store, &mut conn, msg.into()).unwrap(); - assert!(passed_on.is_none(), "the bank family owns this opcode"); - drain_outbound(&rx) + let mut session = ProtocolSession::in_world(7, 1); + let reply = Bank::handle(store, &mut session, ProtocolRequest::Message(msg.into())).unwrap(); + outbound_messages(reply.outbound) } /// What the handler returns when the Store loses the transport. -fn run_transport_lost(msg: impl Into) -> Result> { +fn run_transport_lost(msg: impl Into) -> Result { let store = BankFake { transport_lost: true, ..Default::default() }; - let (tx, _rx) = SessionTx::with_depth(0); - handle_bank(&tx, &store, &mut in_world_conn(7, 1), msg.into()) + Bank::handle( + &store, + &mut ProtocolSession::in_world(7, 1), + ProtocolRequest::Message(msg.into()), + ) } fn kinds(sent: &[ServerOpcodeMessage]) -> String { diff --git a/gateway/src/world/cast_tests.rs b/gateway/src/world/cast_tests.rs index fd2e0d3d2..f4553994b 100644 --- a/gateway/src/world/cast_tests.rs +++ b/gateway/src/world/cast_tests.rs @@ -3,6 +3,7 @@ //! session sees: the ordered batch, the ranged auto-repeat state it carries between casts, and //! the effect of each cancellation. +use super::family::{ProtocolFamily, ProtocolSession}; use super::handlers::InMemoryCasts; use super::*; use std::sync::Mutex; @@ -11,52 +12,18 @@ use std::sync::Mutex; /// contract under test. const OP_CAST_RESULT: u16 = 0x0130; -/// What the session keeps between two casts. -struct Caster { - self_guid: Option, - ranged_repeat: bool, -} - -impl Caster { - fn in_world() -> Self { - Self { - self_guid: Some(1), - ranged_repeat: false, - } - } - - fn at_character_select() -> Self { - Self { - self_guid: None, - ranged_repeat: false, - } - } -} - -/// Dispatch one cast message, apply the session transition it asks for, and return the packets -/// the session would send for it. fn run( store: &InMemoryCasts, - caster: &mut Caster, + session: &mut ProtocolSession, msg: impl Into, ) -> Vec { - let player = CastPlayer { - account_id: 7, - self_guid: caster.self_guid, - ranged_repeat: caster.ranged_repeat, - }; - match dispatch_cast(store, player, msg.into()).unwrap() { - CastOutcome::Handled { - transition, - outbound, - } => { - if let Some(armed) = transition.ranged_repeat { - caster.ranged_repeat = armed; - } - outbound - } - CastOutcome::PassThrough(_) => panic!("the cast dispatcher passed the message on"), - } + handlers::Cast::handle(store, session, msg.into().into()) + .unwrap() + .outbound +} + +fn repeating(session: &ProtocolSession) -> bool { + matches!(&session.state, WorldState::InWorld(world) if world.ranged_repeat) } /// `SpellCastTargets` carrying a UNIT target (the client's selected mob). @@ -79,7 +46,7 @@ fn instant_cast_sends_start_then_raw_cast_result_ok_then_go_and_threads_the_targ let store = InMemoryCasts::instant(); let sent = run( &store, - &mut Caster::in_world(), + &mut ProtocolSession::in_world(7, 1), CMSG_CAST_SPELL { spell: 100, targets: unit_targets(77), @@ -119,7 +86,7 @@ fn auto_shot_intercept_starts_the_ranged_attack_instead_of_casting() { ranged_auto_repeat: vec![75, 5019], ..InMemoryCasts::instant() }; - let mut caster = Caster::in_world(); + let mut caster = ProtocolSession::in_world(7, 1); let sent = run( &store, &mut caster, @@ -152,7 +119,7 @@ fn auto_shot_intercept_starts_the_ranged_attack_instead_of_casting() { ), } assert!( - caster.ranged_repeat, + repeating(&caster), "spell {spell}: the session arms the loop" ); assert_eq!( @@ -171,7 +138,7 @@ fn a_cast_before_entering_the_world_makes_no_request_and_does_not_fail() { let store = InMemoryCasts::instant(); let sent = run( &store, - &mut Caster::at_character_select(), + &mut ProtocolSession::new(7, "TESTER".into()), CMSG_CAST_SPELL { spell: 100, targets: unit_targets(77), @@ -195,7 +162,7 @@ fn cancelling_auto_repeat_still_tears_the_ranged_loop_down_through_stop_attack() ranged_auto_repeat: vec![75], ..InMemoryCasts::instant() }; - let mut caster = Caster::in_world(); + let mut caster = ProtocolSession::in_world(7, 1); run( &store, &mut caster, @@ -209,7 +176,7 @@ fn cancelling_auto_repeat_still_tears_the_ranged_loop_down_through_stop_attack() // The cancel sends no ack of its own (the on_delete relay does). let sent = run(&store, &mut caster, CMSG_CANCEL_AUTO_REPEAT_SPELL {}); assert!(sent.is_empty()); - assert!(!caster.ranged_repeat); + assert!(!repeating(&caster)); assert!(store.engaged.lock().unwrap().is_empty()); } @@ -221,7 +188,7 @@ fn cancel_aura_removes_the_aura_the_wire_spell_id_names() { }; let sent = run( &store, - &mut Caster::in_world(), + &mut ProtocolSession::in_world(7, 1), CMSG_CANCEL_AURA { id: 5555 }, ); assert!(sent.is_empty()); @@ -236,7 +203,7 @@ fn cancel_cast_drops_the_pending_cast_of_the_caller_only() { }; let sent = run( &store, - &mut Caster::in_world(), + &mut ProtocolSession::in_world(7, 1), CMSG_CANCEL_CAST { id: 133 }, ); assert!(sent.is_empty()); diff --git a/gateway/src/world/combat_tests.rs b/gateway/src/world/combat_tests.rs index e9dd8d2cc..428979c42 100644 --- a/gateway/src/world/combat_tests.rs +++ b/gateway/src/world/combat_tests.rs @@ -1,7 +1,6 @@ -//! Targeting and sheathing, run through `handle_combat` against a Fake that holds only the Combat -//! Store the handler is bounded on. +//! Targeting and sheathing through the Combat Store. -use super::handlers::{handle_combat, CombatStore}; +use super::handlers::{Combat, CombatStore}; use super::*; use crate::stdb::ReducerCallError; use std::collections::BTreeMap; @@ -61,11 +60,11 @@ impl CombatStore for CombatFake { } } -/// Send `msg` as Character 1 of account 7. Phase 5 retargets only this helper. +/// Handle `msg` as Character 1 of Account 7. fn run(store: &CombatFake, msg: impl Into) { - let mut conn = in_world_conn(7, 1); - let passed_on = handle_combat(store, &mut conn, msg.into()).unwrap(); - assert!(passed_on.is_none(), "the combat family owns this opcode"); + let mut session = ProtocolSession::in_world(7, 1); + let reply = Combat::handle(store, &mut session, ProtocolRequest::Message(msg.into())).unwrap(); + assert!(reply.outbound.is_empty()); } #[test] @@ -106,18 +105,25 @@ fn sheathe() -> CMSG_SETSHEATHED { #[test] fn transport_loss_ends_the_world_session() { let store = CombatFake::failing(ReducerCallError::transport_lost("gw_set_sheathed")); - let mut conn = in_world_conn(7, 1); - assert!(handle_combat(&store, &mut conn, sheathe().into()).is_err()); + let mut session = ProtocolSession::in_world(7, 1); + assert!(Combat::handle( + &store, + &mut session, + ProtocolRequest::Message(sheathe().into()) + ) + .is_err()); } #[test] fn refusal_is_ignored_and_the_world_session_continues() { let store = CombatFake::failing(ReducerCallError::refused("gw_set_sheathed", "not in world")); - let mut conn = in_world_conn(7, 1); - let passed_on = handle_combat(&store, &mut conn, sheathe().into()).unwrap(); - assert!( - passed_on.is_none(), - "the combat family still owns the opcode" - ); + let mut session = ProtocolSession::in_world(7, 1); + let reply = Combat::handle( + &store, + &mut session, + ProtocolRequest::Message(sheathe().into()), + ) + .unwrap(); + assert!(reply.outbound.is_empty()); assert_eq!(store.sheath_of(1), None); } diff --git a/gateway/src/world/death_tests.rs b/gateway/src/world/death_tests.rs index 34dce197c..4e3b5419d 100644 --- a/gateway/src/world/death_tests.rs +++ b/gateway/src/world/death_tests.rs @@ -1,5 +1,6 @@ //! Death and resurrection requests, handled against a Fake that models only who is alive. +use super::family::{ProtocolFamily, ProtocolSession}; use super::*; use handle_loot_fake::{HandleLootFake, Life}; @@ -8,48 +9,21 @@ pub(crate) mod handle_loot_fake; pub(crate) const SELF_GUID: u64 = 1; -/// A World Session connection in the world as `SELF_GUID`. `handle_loot` never reads the -/// connection's Store, but `WorldConn` needs one. -pub(crate) fn in_world_conn() -> WorldConn { - let (_, crypto) = ProofSeed::new().into_client_header_crypto(&ns("TESTER"), K, 0); - let (_, decrypt) = crypto.split(); - WorldConn { - session_claim: None, - account_id: 7, - account_name: "TESTER".into(), - decrypt, - state: WorldState::InWorld(InWorld { - self_guid: SELF_GUID, - subs: PlayerSubscriptions::empty(), - attacking_target: None, - open_loot: OpenLootState::default(), - ranged_repeat: false, - }), - move_coalesce: Default::default(), - gossip_menu: None, - unavailable_notices: Default::default(), - store: RoutedStore::new(std::sync::Arc::new(WorldFake::default())), - session_key: None, - guild_signed_on: None, - move_desync_drops: 0, - who_throttled_until: None, - group_broadcast_cooldowns: Default::default(), - chat_flood: Default::default(), - } +/// Protocol state for the Character named by `SELF_GUID`. +pub(crate) fn in_world_conn() -> ProtocolSession { + ProtocolSession::in_world(7, SELF_GUID) } /// Handle one request and return what the client is sent. Fails when the handler ends the session /// or passes the request on. pub(crate) fn run( store: &HandleLootFake, - conn: &mut WorldConn, + session: &mut ProtocolSession, msg: ClientOpcodeMessage, ) -> Vec { - let (tx, rx) = SessionTx::with_depth(0); - let passed_on = handle_loot(&tx, store, conn, msg).expect("the request ends the session"); - assert!(passed_on.is_none(), "the request was passed on"); - drop(tx); - rx.try_iter().collect() + handlers::Loot::handle(store, session, msg.into()) + .expect("the request ends the World Session") + .outbound } #[test] @@ -179,13 +153,11 @@ fn transport_loss_ends_the_world_session() { let store = HandleLootFake::default() .with_life(SELF_GUID, Life::Dead) .with_transport_loss(); - let (tx, _rx) = SessionTx::with_depth(0); - let result = handle_loot( - &tx, + let result = handlers::Loot::handle( &store, &mut in_world_conn(), - ClientOpcodeMessage::CMSG_REPOP_REQUEST, + ClientOpcodeMessage::CMSG_REPOP_REQUEST.into(), ); assert!(result.is_err()); diff --git a/gateway/src/world/entry.rs b/gateway/src/world/entry.rs new file mode 100644 index 000000000..58c4df355 --- /dev/null +++ b/gateway/src/world/entry.rs @@ -0,0 +1,252 @@ +//! World Session entry keeps queue, viewer registration, and Transfer operations in order. + +use super::handlers::build_buyback_view_replay; +use super::*; + +pub(super) fn login( + tx: &SessionTx, + store: &dyn WorldStore, + conn: &mut WorldConn, + character: Actor, +) -> Result<()> { + if conn.session_claim.is_some() { + return Err(anyhow!("ACCOUNT_IN_USE")); + } + let character_guid = character.guid(); + let token = store.claim_session(conn.account_id, character_guid)?; + conn.session_claim = Some(token); + if let Some(bound) = store.bind_session(token)? { + conn.store.pin(bound); + } + // Route before login and viewer registration so both use the Character's Home Shard. + conn.route_home(character_guid)?; + let home = conn.store.current(); + enter_world(tx, &*home, conn, character, codec::WorldEntry::FreshLogin) +} + +pub(super) fn world_port_ack( + tx: &SessionTx, + store: &dyn WorldStore, + conn: &mut WorldConn, +) -> Result<()> { + let Some(character) = conn.protocol.actor() else { + return Ok(()); + }; + let character_guid = character.guid(); + if store.entity_in_world(character_guid) { + log::debug!("world: spurious WORLDPORT_ACK ignored (guid {character_guid} still in world)"); + return Ok(()); + } + // Transfer cleanup must not publish source-shard changes to this viewer. Retain InWorld + // until entry succeeds so a failed Transfer still follows the existing logout policy. + if let WorldState::InWorld(iw) = &mut conn.state { + iw.subs.unregister_viewer(); + } + let mut ported = conn.route_home(character_guid); + if ported.is_ok() { + let home = conn.store.current(); + ported = enter_world(tx, &*home, conn, character, codec::WorldEntry::WorldPort); + } + if let Err(error) = ported { + abort_pending_transfer(tx, store, character_guid, &error); + return Err(error); + } + Ok(()) +} + +/// Queue the Transfer abort when possible, preserving the original failure. +fn abort_pending_transfer( + tx: &SessionTx, + store: &St, + character_guid: u64, + cause: &anyhow::Error, +) { + let dest_map = store + .character_destination(character_guid) + .map(|p| p.dest_map_id); + log::error!( + "world: world-port for guid {character_guid} cannot complete ({cause:#}); aborting the \ + client's transfer to map {dest_map:?}. The character is unharmed: the escrow is idempotent \ + and the next login re-drives it." + ); + let Some(map_id) = dest_map else { + log::warn!( + "world: no durable destination for guid {character_guid}; the client gets no \ + SMSG_TRANSFER_ABORTED and will need to reconnect" + ); + return; + }; + use wow_world_messages::vanilla::TransferAbortReason; + match codec::build_transfer_aborted(map_id, TransferAbortReason::NotFound) { + Ok(msg) => { + let _ = send( + tx, + Outbound::One(ServerOpcodeMessage::SMSG_TRANSFER_ABORTED(msg)), + ); + } + Err(e) => { + log::warn!("world: could not build SMSG_TRANSFER_ABORTED for map {map_id}: {e:#}") + } + } +} + +/// Queue self creation before registering the viewer; initial AOI Relays must follow it. +fn enter_world( + tx: &SessionTx, + store: &dyn WorldStore, + conn: &mut WorldConn, + character: Actor, + entry: codec::WorldEntry, +) -> Result<()> { + conn.state = WorldState::CharSelect; + + let character_guid = character.guid(); + let mut entity = store.player_login(conn.account_id, character, entry)?; + entity.effective_armor = store.effective_armor(character_guid); + entity.magic_resistances = store.effective_magic_resistances(character_guid); + (entity.guild_id, entity.guild_rank) = handlers::guild_projection(store, character_guid); + log::info!( + "world: entering world guid={character_guid} -> entity at map {} ({:.1},{:.1},{:.1}); subscribing + sending login sequence + self-spawn", + entity.map_id, entity.x, entity.y, entity.z + ); + let items = store.player_items(character_guid).unwrap_or_default(); + let learned = store + .player_learned_spells(character_guid) + .unwrap_or_default(); + let skills = store.player_skills(character_guid).unwrap_or_default(); + let reputations = store.player_reputations(character_guid).unwrap_or_default(); + let player_actions = store.player_actions(character_guid).unwrap_or_default(); + let mut batch = + codec::login_sequence_messages(&entity, &learned, &reputations, &player_actions, entry)?; + for item in &items { + batch.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( + codec::build_item_create_object(item), + ))); + } + let quests_enabled = crate::config::quest_log_fields_enabled(); + let quests = if quests_enabled { + store.player_quest_log(character_guid)? + } else { + Vec::new() + }; + batch.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( + codec::build_self_create_object(&entity, &items, &skills, &quests)?, + ))); + batch.push(zone_weather_message( + store, + entity.zone_id, + WeatherChangeType::Instant, + )); + // The login sequence and self creation stay contiguous on the writer. + send(tx, Outbound::Batch(batch))?; + for (opcode, body) in items.iter().filter_map(codec::build_random_property_values) { + send(tx, Outbound::Raw { opcode, body })?; + } + for item in &items { + if let Some((bag_slot, position)) = codec::bag_content_parts(item.slot) { + if let Some(bag) = items.iter().find(|bag| bag.slot == bag_slot) { + let (opcode, body) = + codec::build_container_slot_values(bag.guid, position, item.guid); + send(tx, Outbound::Raw { opcode, body })?; + } + } + } + // Registration may queue initial AOI objects immediately. + let subs = + store.subscribe_player_events(conn.account_id, character_guid, &entity, tx.clone())?; + if quests_enabled { + // Read on the writer so reconciliation cannot overwrite newer queued quest progress. + let store = conn.store.current(); + let close = tx.clone(); + send( + tx, + Outbound::Job(Box::new(move || { + match store.player_quest_log(character_guid) { + Ok(current) if current != quests => { + let mask = codec::update_mask::full_quest_log_mask(¤t); + let (opcode, body) = codec::build_values_update_raw(character_guid, &mask); + vec![Outbound::Raw { opcode, body }] + } + Ok(_) => Vec::new(), + Err(error) => { + log::error!("world: quest log reconciliation failed for guid {character_guid}: {error:#}"); + close.close(); + Vec::new() + } + } + })), + )?; + } + for message in build_buyback_view_replay(store, character_guid) { + send(tx, message)?; + } + for message in store.pending_system_messages(character_guid) { + send( + tx, + Outbound::One(ServerOpcodeMessage::SMSG_MESSAGECHAT(Box::new( + codec::build_gm_system_message(message), + ))), + )?; + } + // Record successful sign-on so cleanup owes a sign-off even if later entry work fails. + if entry == codec::WorldEntry::FreshLogin && handlers::guild_sign_on(store, character_guid) { + conn.guild_signed_on = Some(character_guid); + } + let created_projection = (entity.guild_id, entity.guild_rank); + for message in handlers::guild_world_entry(store, character_guid, entry, created_projection) { + send(tx, message)?; + } + if let Err(e) = party::on_world_entry(tx, store, character) { + log::warn!("world: party sync at world entry failed for guid {character_guid}: {e:#}"); + } + crate::world::guild_fee::redrive(store, character_guid); + let charters: Vec = items + .iter() + .filter(|item| item.entry == lyracore_shared::guild::GUILD_CHARTER_ENTRY) + .map(|item| item.guid) + .collect(); + handlers::destroy_inert_charters(store, character_guid, &charters); + crate::world::mail::redrive(store, character_guid); + // Forget snapshots after the party frame so the next tick supplies every field. + if let Some(record) = subs.member_stats_record().cloned() { + let forget = move || { + record.forget_all(); + Vec::new() + }; + send(tx, Outbound::Job(Box::new(forget)))?; + } + conn.state = WorldState::InWorld(InWorld { + self_guid: character_guid, + subs, + attacking_target: None, + open_loot: OpenLootState::default(), + ranged_repeat: false, + }); + if let Some(ammo_entry) = items.iter().map(|i| i.entry).find(|&e| { + store + .item_template(e) + .ok() + .flatten() + .is_some_and(|t| t.class == 6) + }) { + send( + tx, + Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( + codec::build_player_ammo_id_values(character_guid, ammo_entry), + ))), + )?; + } + if store.talent_points_spent(character_guid) > 0 { + let (_, _, remaining) = store.talent_pane_sync(character_guid, 0); + send( + tx, + Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( + codec::build_talent_points_values(character_guid, remaining), + ))), + )?; + } + for m in codec::build_spell_modifier_msgs(&store.spell_modifiers(character_guid)) { + send(tx, Outbound::One(m))?; + } + Ok(()) +} diff --git a/gateway/src/world/family.rs b/gateway/src/world/family.rs new file mode 100644 index 000000000..615fc6cfd --- /dev/null +++ b/gateway/src/world/family.rs @@ -0,0 +1,207 @@ +//! Protocol Family handlers share session state and return replies to the World Session. + +use super::*; + +pub(crate) trait ProtocolFamily { + fn handle( + store: &St, + session: &mut ProtocolSession, + request: ProtocolRequest, + ) -> Result; +} + +pub(crate) enum ProtocolRequest { + Message(ClientOpcodeMessage), + AuctionBrowse(AuctionBrowseRequest), + FactionAtWar(Vec), + WatchedFaction(Vec), +} + +impl From for ProtocolRequest { + fn from(message: ClientOpcodeMessage) -> Self { + Self::Message(message) + } +} + +impl ProtocolRequest { + pub(crate) fn message(self) -> Result { + match self { + Self::Message(message) => Ok(message), + _ => Err(anyhow!("raw request routed to a typed Protocol Family")), + } + } +} + +#[derive(Default)] +pub(crate) struct ProtocolReply { + pub(crate) outbound: Vec, + pub(crate) after_queue: Option, +} + +impl From> for ProtocolReply { + fn from(outbound: Vec) -> Self { + Self { + outbound, + after_queue: None, + } + } +} + +impl ProtocolReply { + /// Queue all replies before running the next World Session operation on the reader thread. + pub(super) fn complete( + self, + tx: &SessionTx, + execute: impl FnOnce(WorldSessionAction) -> Result<()>, + ) -> Result<()> { + for message in self.outbound { + send(tx, message)?; + } + match self.after_queue { + Some(action) => execute(action), + None => Ok(()), + } + } +} + +pub(crate) enum WorldSessionAction { + Login(Actor), + WorldPortAck, + Logout, + ArmTaxi(Actor), + RedriveMail(Actor), +} + +/// Protocol state shared by handlers. Crypto, routing and Account Claims stay on WorldConn. +pub struct ProtocolSession { + pub(crate) account_id: u64, + pub(crate) account_name: String, + pub(crate) state: WorldState, + pub(crate) gossip_menu: Option, + pub(crate) who_throttled_until: Option, + pub(crate) group_broadcast_cooldowns: party::GroupBroadcastCooldowns, +} + +impl ProtocolSession { + pub(crate) fn new(account_id: u64, account_name: String) -> Self { + Self { + account_id, + account_name, + state: WorldState::CharSelect, + gossip_menu: None, + who_throttled_until: None, + group_broadcast_cooldowns: Default::default(), + } + } + + pub(crate) fn actor(&self) -> Option { + self.self_guid().and_then(Actor::new) + } + + pub(crate) fn self_guid(&self) -> Option { + match &self.state { + WorldState::CharSelect => None, + WorldState::InWorld(world) => Some(world.self_guid), + } + } + + /// Admit one `CMSG_WHO`, or refuse it because the last one this session sent was inside + /// [`WHO_THROTTLE`]. Advances the cooldown on every admitted request, including a malformed + /// one that answers nothing — the request itself is what cost the scan. + pub(super) fn admit_who(&mut self) -> bool { + let now = Instant::now(); + if self.who_throttled_until.is_some_and(|until| now < until) { + return false; + } + self.who_throttled_until = Some(now + WHO_THROTTLE); + true + } + + /// [`party::GroupBroadcastCooldowns::admit_at`] now. + pub(super) fn admit_group_broadcast(&mut self, op: party::Op) -> bool { + self.group_broadcast_cooldowns.admit_at(op, Instant::now()) + } + + #[cfg(test)] + pub(crate) fn in_world(account_id: u64, self_guid: u64) -> Self { + Self { + state: WorldState::InWorld(InWorld { + self_guid, + subs: PlayerSubscriptions::empty(), + attacking_target: None, + open_loot: OpenLootState::default(), + ranged_repeat: false, + }), + ..Self::new(account_id, "TESTER".into()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn taxi_reply() -> ProtocolReply { + ProtocolReply { + outbound: vec![Outbound::One(ServerOpcodeMessage::SMSG_ACTIVATETAXIREPLY( + codec::build_activate_taxi_reply(codec::TaxiActivationResult { + result_code: lyracore_shared::constants::taxi_protocol::ACTIVATE_OK, + }), + ))], + after_queue: Some(WorldSessionAction::ArmTaxi(Actor::new(42).unwrap())), + } + } + + #[test] + fn taxi_reply_is_queued_before_arming() { + let (tx, rx) = SessionTx::with_depth(0); + let mut armed = false; + taxi_reply() + .complete(&tx, |action| { + assert!(matches!(action, WorldSessionAction::ArmTaxi(actor) if actor.guid() == 42)); + assert!(matches!( + rx.try_recv().unwrap(), + Outbound::One(ServerOpcodeMessage::SMSG_ACTIVATETAXIREPLY(_)) + )); + armed = true; + Ok(()) + }) + .unwrap(); + assert!(armed); + } + + #[test] + fn failed_reply_queue_does_not_arm() { + let (tx, rx) = SessionTx::with_depth(0); + drop(rx); + let mut armed = false; + assert!(taxi_reply() + .complete(&tx, |_| { + armed = true; + Ok(()) + }) + .is_err()); + assert!(!armed); + } + + #[test] + fn a_reply_without_a_following_operation_only_queues_messages() { + let (tx, rx) = SessionTx::with_depth(0); + let mut reply = taxi_reply(); + reply.after_queue = None; + reply + .complete(&tx, |_| panic!("a refused activation cannot arm a flight")) + .unwrap(); + assert!(rx.try_recv().is_ok()); + } + + #[test] + fn arming_failure_propagates_after_the_reply() { + let (tx, rx) = SessionTx::with_depth(0); + let error = taxi_reply() + .complete(&tx, |_| Err(anyhow!("flight could not be armed"))) + .unwrap_err(); + assert_eq!(error.to_string(), "flight could not be armed"); + assert!(rx.try_recv().is_ok()); + } +} diff --git a/gateway/src/world/handle_loot_fake.rs b/gateway/src/world/handle_loot_fake.rs index 1fd775671..c742d6d33 100644 --- a/gateway/src/world/handle_loot_fake.rs +++ b/gateway/src/world/handle_loot_fake.rs @@ -1,4 +1,4 @@ -//! A Fake for the traits `handle_loot` is bounded on: Death, Loot Window, Npc, Shard Routing and +//! A Fake for the traits `Loot::handle` is bounded on: Death, Loot Window, Npc, Shard Routing and //! Loot Roll. Death and the Loot Window carry state. The other three are not reachable from the //! tests that use it. @@ -187,7 +187,7 @@ impl DeathStore for HandleLootFake { impl LootWindowStore for HandleLootFake { fn loot_target_money(&self, _target_guid: u64) -> Result { - unreachable!("handle_loot reads no loot money") + unreachable!("Loot reads no loot money") } fn loot_target_items( @@ -211,15 +211,15 @@ impl LootWindowStore for HandleLootFake { _actor: Actor, _corpse_guid: u64, ) -> Result { - unreachable!("handle_loot opens no creature loot") + unreachable!("Loot opens no creature loot") } fn skin_corpse(&self, _actor: Actor, _target_guid: u64) -> Result { - unreachable!("handle_loot skins no corpse") + unreachable!("Loot skins no corpse") } fn loot_money(&self, _actor: Actor, _target_guid: u64) -> Result { - unreachable!("handle_loot takes no money") + unreachable!("Loot takes no money") } fn take_loot( @@ -228,7 +228,7 @@ impl LootWindowStore for HandleLootFake { _target_guid: u64, _loot_slot: u8, ) -> Result { - unreachable!("handle_loot takes no item") + unreachable!("Loot takes no item") } } diff --git a/gateway/src/world/handlers/auction.rs b/gateway/src/world/handlers/auction.rs index f0193e54e..241292666 100644 --- a/gateway/src/world/handlers/auction.rs +++ b/gateway/src/world/handlers/auction.rs @@ -172,16 +172,6 @@ pub(crate) trait AuctionActionStore: Send + Sync { ) -> Result; } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) struct AuctionActionPlayer { - pub(crate) self_guid: Option, -} - -pub(crate) enum AuctionActionOutcome { - Handled { outbound: Vec }, - PassThrough(ClientOpcodeMessage), -} - enum AuctionRequest { Hello(wow_world_messages::vanilla::Guid), Owner(u32), @@ -244,41 +234,39 @@ pub(crate) fn decode_auction_browse(body: &[u8]) -> Result }) } -pub(crate) fn dispatch_auction_browse_action( +fn browse( store: &St, - player: AuctionActionPlayer, + player: &ProtocolSession, request: AuctionBrowseRequest, -) -> Result { +) -> Result { let Some((actor, interaction)) = auction_actor_interaction(store, player, request.auctioneer_guid)? .filter(|(_, interaction)| !interaction.refuses_interaction) else { - return Ok(AuctionActionOutcome::Handled { - outbound: vec![Outbound::One( - ServerOpcodeMessage::SMSG_AUCTION_LIST_RESULT(Box::new( - codec::build_auction_list_result(&[], 0, 0), - )), - )], - }); - }; - let page = store.auction_query(actor, interaction.house.id, AuctionQuery::Browse(request))?; - Ok(AuctionActionOutcome::Handled { - outbound: vec![Outbound::One( + return Ok(ProtocolReply::from(vec![Outbound::One( ServerOpcodeMessage::SMSG_AUCTION_LIST_RESULT(Box::new( - codec::build_auction_list_result(&page.rows, page.total, page.now_micros), + codec::build_auction_list_result(&[], 0, 0), )), - )], - }) + )])); + }; + let page = store.auction_query(actor, interaction.house.id, AuctionQuery::Browse(request))?; + Ok(ProtocolReply::from(vec![Outbound::One( + ServerOpcodeMessage::SMSG_AUCTION_LIST_RESULT(Box::new(codec::build_auction_list_result( + &page.rows, + page.total, + page.now_micros, + ))), + )])) } /// Resolve the Character and auction house. Read paths apply the faction verdict; Durable /// Requests leave the interaction Gate to the Module. fn auction_actor_interaction( store: &St, - player: AuctionActionPlayer, + player: &ProtocolSession, auctioneer_guid: u64, ) -> Result> { - let Some(actor) = player.self_guid.and_then(Actor::new) else { + let Some(actor) = player.self_guid().and_then(Actor::new) else { return Ok(None); }; // A missing auctioneer or house is `None`; a failed Durable Read is a failure, not a Refusal. @@ -286,7 +274,7 @@ fn auction_actor_interaction( Ok(interaction.map(|interaction| (actor, interaction))) } -fn create_result(outcome: CreateAuctionOutcome) -> AuctionActionOutcome { +fn create_result(outcome: CreateAuctionOutcome) -> ProtocolReply { use wow_world_messages::vanilla::{ SMSG_AUCTION_COMMAND_RESULT_AuctionCommandAction as Action, SMSG_AUCTION_COMMAND_RESULT_AuctionCommandResultTwo as ResultTwo, @@ -298,19 +286,15 @@ fn create_result(outcome: CreateAuctionOutcome) -> AuctionActionOutcome { CreateAuctionOutcome::NotEnoughMoney => (0, ResultTwo::ErrNotEnoughMoney), CreateAuctionOutcome::Database => (0, ResultTwo::ErrDatabase), }; - AuctionActionOutcome::Handled { - outbound: vec![Outbound::One( - ServerOpcodeMessage::SMSG_AUCTION_COMMAND_RESULT(Box::new( - SMSG_AUCTION_COMMAND_RESULT { - auction_id, - action: Action::Started { result2 }, - }, - )), - )], - } + ProtocolReply::from(vec![Outbound::One( + ServerOpcodeMessage::SMSG_AUCTION_COMMAND_RESULT(Box::new(SMSG_AUCTION_COMMAND_RESULT { + auction_id, + action: Action::Started { result2 }, + })), + )]) } -fn bid_result(auction_id: u32, outcome: PlaceBidOutcome) -> AuctionActionOutcome { +fn bid_result(auction_id: u32, outcome: PlaceBidOutcome) -> ProtocolReply { use wow_world_messages::{ vanilla::{ SMSG_AUCTION_COMMAND_RESULT_AuctionCommandAction as Action, @@ -338,22 +322,18 @@ fn bid_result(auction_id: u32, outcome: PlaceBidOutcome) -> AuctionActionOutcome PlaceBidOutcome::BidOwn => ResultOne::ErrBidOwn, PlaceBidOutcome::Database => ResultOne::ErrDatabase, }; - AuctionActionOutcome::Handled { - outbound: vec![Outbound::One( - ServerOpcodeMessage::SMSG_AUCTION_COMMAND_RESULT(Box::new( - SMSG_AUCTION_COMMAND_RESULT { - auction_id, - action: Action::BidPlaced { result }, - }, - )), - )], - } + ProtocolReply::from(vec![Outbound::One( + ServerOpcodeMessage::SMSG_AUCTION_COMMAND_RESULT(Box::new(SMSG_AUCTION_COMMAND_RESULT { + auction_id, + action: Action::BidPlaced { result }, + })), + )]) } /// `SMSG_AUCTION_COMMAND_RESULT` for a Cancellation (`cm:AuctionHouseHandler.cpp:423-428,441-442, /// 459`): the auction id with Removed and Ok, id 0 with Removed and ErrDatabase, or nothing for a /// seller who cannot pay the cut. -fn cancel_result(auction_id: u32, outcome: CancelAuctionOutcome) -> AuctionActionOutcome { +fn cancel_result(auction_id: u32, outcome: CancelAuctionOutcome) -> ProtocolReply { use wow_world_messages::vanilla::{ SMSG_AUCTION_COMMAND_RESULT_AuctionCommandAction as Action, SMSG_AUCTION_COMMAND_RESULT_AuctionCommandResultTwo as ResultTwo, @@ -363,172 +343,175 @@ fn cancel_result(auction_id: u32, outcome: CancelAuctionOutcome) -> AuctionActio CancelAuctionOutcome::Cancelled => (auction_id, ResultTwo::Ok), CancelAuctionOutcome::NotFound | CancelAuctionOutcome::Stale => (0, ResultTwo::ErrDatabase), CancelAuctionOutcome::CannotAfford => { - return AuctionActionOutcome::Handled { - outbound: Vec::new(), - }; + return ProtocolReply::from(Vec::new()); } }; - AuctionActionOutcome::Handled { - outbound: vec![Outbound::One( - ServerOpcodeMessage::SMSG_AUCTION_COMMAND_RESULT(Box::new( - SMSG_AUCTION_COMMAND_RESULT { - auction_id, - action: Action::Removed { result2 }, - }, - )), - )], - } + ProtocolReply::from(vec![Outbound::One( + ServerOpcodeMessage::SMSG_AUCTION_COMMAND_RESULT(Box::new(SMSG_AUCTION_COMMAND_RESULT { + auction_id, + action: Action::Removed { result2 }, + })), + )]) } -pub(crate) fn dispatch_auction_action( - store: &St, - player: AuctionActionPlayer, - msg: ClientOpcodeMessage, -) -> Result { - let (auctioneer, request) = match msg { - ClientOpcodeMessage::MSG_AUCTION_HELLO(message) => ( - message.auctioneer, - AuctionRequest::Hello(message.auctioneer), - ), - ClientOpcodeMessage::CMSG_AUCTION_LIST_ITEMS(message) => { - return dispatch_auction_browse_action( - store, - player, - AuctionBrowseRequest { - auctioneer_guid: message.auctioneer.guid(), - offset: message.list_start_item, - name: message.searched_name, - minimum_level: (message.minimum_level != 0).then_some(message.minimum_level), - maximum_level: (message.maximum_level != 0).then_some(message.maximum_level), - inventory_type: (message.auction_slot_id != u32::MAX) - .then_some(message.auction_slot_id), - item_class: (message.auction_main_category != u32::MAX) - .then_some(message.auction_main_category), - item_subclass: (message.auction_sub_category != u32::MAX) - .then_some(message.auction_sub_category), - quality: Some(message.auction_quality.as_int()), - usable_only: message.usable != 0, +pub(crate) struct Auction; + +impl ProtocolFamily for Auction { + fn handle( + store: &St, + session: &mut ProtocolSession, + request: ProtocolRequest, + ) -> Result { + let player = &*session; + let msg = match request { + ProtocolRequest::AuctionBrowse(request) => return browse(store, player, request), + request => request.message()?, + }; + let (auctioneer, request) = match msg { + ClientOpcodeMessage::MSG_AUCTION_HELLO(message) => ( + message.auctioneer, + AuctionRequest::Hello(message.auctioneer), + ), + ClientOpcodeMessage::CMSG_AUCTION_LIST_ITEMS(message) => { + return browse( + store, + player, + AuctionBrowseRequest { + auctioneer_guid: message.auctioneer.guid(), + offset: message.list_start_item, + name: message.searched_name, + minimum_level: (message.minimum_level != 0) + .then_some(message.minimum_level), + maximum_level: (message.maximum_level != 0) + .then_some(message.maximum_level), + inventory_type: (message.auction_slot_id != u32::MAX) + .then_some(message.auction_slot_id), + item_class: (message.auction_main_category != u32::MAX) + .then_some(message.auction_main_category), + item_subclass: (message.auction_sub_category != u32::MAX) + .then_some(message.auction_sub_category), + quality: Some(message.auction_quality.as_int()), + usable_only: message.usable != 0, + }, + ); + } + ClientOpcodeMessage::CMSG_AUCTION_LIST_OWNER_ITEMS(message) => { + (message.auctioneer, AuctionRequest::Owner(message.list_from)) + } + ClientOpcodeMessage::CMSG_AUCTION_LIST_BIDDER_ITEMS(message) => ( + message.auctioneer, + AuctionRequest::Bidder { + offset: message.start_from_page, + outbid_auction_ids: message.outbid_item_ids, }, - ); - } - ClientOpcodeMessage::CMSG_AUCTION_LIST_OWNER_ITEMS(message) => { - (message.auctioneer, AuctionRequest::Owner(message.list_from)) + ), + ClientOpcodeMessage::CMSG_AUCTION_PLACE_BID(message) => { + let auction_id = message.auction_id; + let auctioneer_guid = message.auctioneer.guid(); + let Some((actor, interaction)) = + auction_actor_interaction(store, player, auctioneer_guid)? + else { + return Ok(bid_result(auction_id, PlaceBidOutcome::Database)); + }; + // A Refusal arrives as an outcome. An error is a failure with an unknown durable + // result, so it ends the session instead of posing as a gameplay answer. + let outcome = store.place_bid(PlaceBidRequest { + actor, + auctioneer_guid, + auction_id, + offer: message.price.as_int(), + house_id: interaction.house.id, + })?; + return Ok(bid_result(auction_id, outcome)); + } + ClientOpcodeMessage::CMSG_AUCTION_SELL_ITEM(message) => { + let auctioneer_guid = message.auctioneer.guid(); + let Some((actor, interaction)) = + auction_actor_interaction(store, player, auctioneer_guid)? + else { + return Ok(create_result(CreateAuctionOutcome::Database)); + }; + let outcome = store.create_auction(CreateAuctionRequest { + actor, + auctioneer_guid, + item_guid: message.item.guid(), + start_bid: message.starting_bid, + buyout: message.buyout, + duration_minutes: message.auction_duration_in_minutes, + house_id: interaction.house.id, + })?; + return Ok(create_result(outcome)); + } + ClientOpcodeMessage::CMSG_AUCTION_REMOVE_ITEM(message) => { + let auction_id = message.auction_id; + let auctioneer_guid = message.auctioneer.guid(); + // Vanilla ignores a Cancellation away from an auctioneer + // (`cm:AuctionHouseHandler.cpp:413-415`). + let Some((actor, interaction)) = + auction_actor_interaction(store, player, auctioneer_guid)? + else { + return Ok(ProtocolReply::from(Vec::new())); + }; + let outcome = store.cancel_auction(CancelAuctionRequest { + actor, + auctioneer_guid, + auction_id, + house_id: interaction.house.id, + })?; + return Ok(cancel_result(auction_id, outcome)); + } + other => return Err(anyhow!("opcode routed to wrong Protocol Family: {other}")), + }; + if let (AuctionRequest::Hello(_), Some(actor)) = + (&request, player.self_guid().and_then(Actor::new)) + { + store.resume_auction_holds(actor)?; } - ClientOpcodeMessage::CMSG_AUCTION_LIST_BIDDER_ITEMS(message) => ( - message.auctioneer, + let auctioneer_guid = auctioneer.guid(); + let Some((actor, interaction)) = auction_actor_interaction(store, player, auctioneer_guid)? + .filter(|(_, interaction)| !interaction.refuses_interaction) + else { + return Ok(ProtocolReply::from(Vec::new())); + }; + let house = interaction.house; + use wow_world_messages::vanilla::{AuctionHouse, MSG_AUCTION_HELLO_Server}; + let message = match request { + AuctionRequest::Hello(auctioneer) => { + ServerOpcodeMessage::MSG_AUCTION_HELLO(Box::new(MSG_AUCTION_HELLO_Server { + auctioneer, + auction_house: AuctionHouse::try_from(house.id) + .map_err(|error| anyhow!("imported auction house {}: {error}", house.id))?, + })) + } + AuctionRequest::Owner(offset) => { + let page = store.auction_query(actor, house.id, AuctionQuery::Owner { offset })?; + ServerOpcodeMessage::SMSG_AUCTION_OWNER_LIST_RESULT(Box::new( + codec::build_auction_owner_list_result(&page.rows, page.total, page.now_micros), + )) + } AuctionRequest::Bidder { - offset: message.start_from_page, - outbid_auction_ids: message.outbid_item_ids, - }, - ), - ClientOpcodeMessage::CMSG_AUCTION_PLACE_BID(message) => { - let auction_id = message.auction_id; - let auctioneer_guid = message.auctioneer.guid(); - let Some((actor, interaction)) = - auction_actor_interaction(store, player, auctioneer_guid)? - else { - return Ok(bid_result(auction_id, PlaceBidOutcome::Database)); - }; - // A Refusal arrives as an outcome. An error is a failure with an unknown durable - // result, so it ends the session instead of posing as a gameplay answer. - let outcome = store.place_bid(PlaceBidRequest { - actor, - auctioneer_guid, - auction_id, - offer: message.price.as_int(), - house_id: interaction.house.id, - })?; - return Ok(bid_result(auction_id, outcome)); - } - ClientOpcodeMessage::CMSG_AUCTION_SELL_ITEM(message) => { - let auctioneer_guid = message.auctioneer.guid(); - let Some((actor, interaction)) = - auction_actor_interaction(store, player, auctioneer_guid)? - else { - return Ok(create_result(CreateAuctionOutcome::Database)); - }; - let outcome = store.create_auction(CreateAuctionRequest { - actor, - auctioneer_guid, - item_guid: message.item.guid(), - start_bid: message.starting_bid, - buyout: message.buyout, - duration_minutes: message.auction_duration_in_minutes, - house_id: interaction.house.id, - })?; - return Ok(create_result(outcome)); - } - ClientOpcodeMessage::CMSG_AUCTION_REMOVE_ITEM(message) => { - let auction_id = message.auction_id; - let auctioneer_guid = message.auctioneer.guid(); - // Vanilla ignores a Cancellation away from an auctioneer - // (`cm:AuctionHouseHandler.cpp:413-415`). - let Some((actor, interaction)) = - auction_actor_interaction(store, player, auctioneer_guid)? - else { - return Ok(AuctionActionOutcome::Handled { - outbound: Vec::new(), - }); - }; - let outcome = store.cancel_auction(CancelAuctionRequest { - actor, - auctioneer_guid, - auction_id, - house_id: interaction.house.id, - })?; - return Ok(cancel_result(auction_id, outcome)); - } - other => return Ok(AuctionActionOutcome::PassThrough(other)), - }; - if let (AuctionRequest::Hello(_), Some(actor)) = - (&request, player.self_guid.and_then(Actor::new)) - { - store.resume_auction_holds(actor)?; + offset, + outbid_auction_ids, + } => { + let page = store.auction_query( + actor, + house.id, + AuctionQuery::Bidder { + offset, + outbid_auction_ids, + }, + )?; + ServerOpcodeMessage::SMSG_AUCTION_BIDDER_LIST_RESULT(Box::new( + codec::build_auction_bidder_list_result( + &page.rows, + page.total, + page.now_micros, + ), + )) + } + }; + Ok(ProtocolReply::from(vec![Outbound::One(message)])) } - let auctioneer_guid = auctioneer.guid(); - let Some((actor, interaction)) = auction_actor_interaction(store, player, auctioneer_guid)? - .filter(|(_, interaction)| !interaction.refuses_interaction) - else { - return Ok(AuctionActionOutcome::Handled { - outbound: Vec::new(), - }); - }; - let house = interaction.house; - use wow_world_messages::vanilla::{AuctionHouse, MSG_AUCTION_HELLO_Server}; - let message = match request { - AuctionRequest::Hello(auctioneer) => { - ServerOpcodeMessage::MSG_AUCTION_HELLO(Box::new(MSG_AUCTION_HELLO_Server { - auctioneer, - auction_house: AuctionHouse::try_from(house.id) - .map_err(|error| anyhow!("imported auction house {}: {error}", house.id))?, - })) - } - AuctionRequest::Owner(offset) => { - let page = store.auction_query(actor, house.id, AuctionQuery::Owner { offset })?; - ServerOpcodeMessage::SMSG_AUCTION_OWNER_LIST_RESULT(Box::new( - codec::build_auction_owner_list_result(&page.rows, page.total, page.now_micros), - )) - } - AuctionRequest::Bidder { - offset, - outbid_auction_ids, - } => { - let page = store.auction_query( - actor, - house.id, - AuctionQuery::Bidder { - offset, - outbid_auction_ids, - }, - )?; - ServerOpcodeMessage::SMSG_AUCTION_BIDDER_LIST_RESULT(Box::new( - codec::build_auction_bidder_list_result(&page.rows, page.total, page.now_micros), - )) - } - }; - Ok(AuctionActionOutcome::Handled { - outbound: vec![Outbound::One(message)], - }) } #[cfg(test)] @@ -746,15 +729,13 @@ pub(super) mod tests { refuses_interaction: true, ..valid_interaction() })); - let outcome = dispatch_auction_browse_action( + let outcome = Auction::handle( &store, - AuctionActionPlayer { self_guid: Some(7) }, - decode_auction_browse(&raw_browse(u32::MAX)).unwrap(), + &mut ProtocolSession::in_world(7, 7), + ProtocolRequest::AuctionBrowse(decode_auction_browse(&raw_browse(u32::MAX)).unwrap()), ) .unwrap(); - let AuctionActionOutcome::Handled { outbound } = outcome else { - panic!("browse must be handled") - }; + let outbound = outcome.outbound; assert!(matches!( outbound.as_slice(), [Outbound::One(ServerOpcodeMessage::SMSG_AUCTION_LIST_RESULT(message))] @@ -796,19 +777,17 @@ pub(super) mod tests { usable_only: false, }; - let outcome = dispatch_auction_browse_action( + let outcome = Auction::handle( &store, - AuctionActionPlayer { self_guid: Some(7) }, - request.clone(), + &mut ProtocolSession::in_world(7, 7), + ProtocolRequest::AuctionBrowse(request.clone()), ) .unwrap(); assert_eq!( store.queries.lock().unwrap().as_slice(), &[(7, 4, AuctionQuery::Browse(request))] ); - let AuctionActionOutcome::Handled { outbound } = outcome else { - panic!("browse must be handled") - }; + let outbound = outcome.outbound; assert!(matches!( outbound.as_slice(), [Outbound::One(ServerOpcodeMessage::SMSG_AUCTION_LIST_RESULT(message))] @@ -839,14 +818,16 @@ pub(super) mod tests { total: 52, now_micros: 1_000_000, }); - let outcome = dispatch_auction_action( + let outcome = Auction::handle( &store, - AuctionActionPlayer { self_guid: Some(7) }, - CMSG_AUCTION_LIST_OWNER_ITEMS { - auctioneer: Guid::new(42), - list_from: 50, - } - .into(), + &mut ProtocolSession::in_world(7, 7), + ProtocolRequest::Message( + CMSG_AUCTION_LIST_OWNER_ITEMS { + auctioneer: Guid::new(42), + list_from: 50, + } + .into(), + ), ) .unwrap(); @@ -854,9 +835,7 @@ pub(super) mod tests { store.queries.lock().unwrap().as_slice(), &[(7, 4, AuctionQuery::Owner { offset: 50 })] ); - let AuctionActionOutcome::Handled { outbound } = outcome else { - panic!("owner view must be handled") - }; + let outbound = outcome.outbound; assert!(matches!( outbound.as_slice(), [Outbound::One(ServerOpcodeMessage::SMSG_AUCTION_OWNER_LIST_RESULT(message))] @@ -888,15 +867,17 @@ pub(super) mod tests { total: 52, now_micros: 1_000_000, }); - let outcome = dispatch_auction_action( + let outcome = Auction::handle( &store, - AuctionActionPlayer { self_guid: Some(8) }, - CMSG_AUCTION_LIST_BIDDER_ITEMS { - auctioneer: Guid::new(42), - start_from_page: 50, - outbid_item_ids: vec![19, 88], - } - .into(), + &mut ProtocolSession::in_world(7, 8), + ProtocolRequest::Message( + CMSG_AUCTION_LIST_BIDDER_ITEMS { + auctioneer: Guid::new(42), + start_from_page: 50, + outbid_item_ids: vec![19, 88], + } + .into(), + ), ) .unwrap(); @@ -911,9 +892,7 @@ pub(super) mod tests { }, )] ); - let AuctionActionOutcome::Handled { outbound } = outcome else { - panic!("bidder view must be handled") - }; + let outbound = outcome.outbound; assert!(matches!( outbound.as_slice(), [Outbound::One(ServerOpcodeMessage::SMSG_AUCTION_BIDDER_LIST_RESULT(message))] @@ -927,39 +906,35 @@ pub(super) mod tests { } fn hello_outbound(store: &InMemoryAuctionActions) -> Result> { - match dispatch_auction_action( + Ok(Auction::handle( store, - AuctionActionPlayer { self_guid: Some(7) }, - MSG_AUCTION_HELLO_Client { - auctioneer: Guid::new(42), - } - .into(), - )? { - AuctionActionOutcome::Handled { outbound } => Ok(outbound), - AuctionActionOutcome::PassThrough(_) => { - panic!("auction hello must never pass beyond its focused seam") - } - } + &mut ProtocolSession::in_world(7, 7), + ProtocolRequest::Message( + MSG_AUCTION_HELLO_Client { + auctioneer: Guid::new(42), + } + .into(), + ), + )? + .outbound) } fn sell_outbound(store: &InMemoryAuctionActions) -> Result> { - match dispatch_auction_action( + Ok(Auction::handle( store, - AuctionActionPlayer { self_guid: Some(7) }, - CMSG_AUCTION_SELL_ITEM { - auctioneer: Guid::new(42), - item: Guid::new(70), - starting_bid: 100, - buyout: 500, - auction_duration_in_minutes: 480, - } - .into(), - )? { - AuctionActionOutcome::Handled { outbound } => Ok(outbound), - AuctionActionOutcome::PassThrough(_) => { - panic!("auction sell must never pass beyond its focused seam") - } - } + &mut ProtocolSession::in_world(7, 7), + ProtocolRequest::Message( + CMSG_AUCTION_SELL_ITEM { + auctioneer: Guid::new(42), + item: Guid::new(70), + starting_bid: 100, + buyout: 500, + auction_duration_in_minutes: 480, + } + .into(), + ), + )? + .outbound) } fn session_error(result: Result>, what: &str) -> anyhow::Error { @@ -970,39 +945,37 @@ pub(super) mod tests { } fn bid_outbound(store: &InMemoryAuctionActions) -> Result> { - match dispatch_auction_action( + Ok(Auction::handle( store, - AuctionActionPlayer { self_guid: Some(8) }, - CMSG_AUCTION_PLACE_BID { - auctioneer: Guid::new(42), - auction_id: 41, - price: Gold::new(107), - } - .into(), - )? { - AuctionActionOutcome::Handled { outbound } => Ok(outbound), - AuctionActionOutcome::PassThrough(_) => { - panic!("auction bid must never pass beyond its focused seam") - } - } + &mut ProtocolSession::in_world(7, 8), + ProtocolRequest::Message( + CMSG_AUCTION_PLACE_BID { + auctioneer: Guid::new(42), + auction_id: 41, + price: Gold::new(107), + } + .into(), + ), + )? + .outbound) } #[test] fn a_reachable_auctioneer_opens_its_imported_house() { let store = store_with(Some(valid_interaction())); - let outcome = dispatch_auction_action( + let outcome = Auction::handle( &store, - AuctionActionPlayer { self_guid: Some(7) }, - MSG_AUCTION_HELLO_Client { - auctioneer: Guid::new(42), - } - .into(), + &mut ProtocolSession::in_world(7, 7), + ProtocolRequest::Message( + MSG_AUCTION_HELLO_Client { + auctioneer: Guid::new(42), + } + .into(), + ), ) .unwrap(); - let AuctionActionOutcome::Handled { outbound } = outcome else { - panic!("auction hello must be handled by the auction seam"); - }; + let outbound = outcome.outbound; assert_eq!(outbound.len(), 1); let Outbound::One(ServerOpcodeMessage::MSG_AUCTION_HELLO(message)) = &outbound[0] else { panic!("auction hello must reply with MSG_AUCTION_HELLO"); @@ -1034,15 +1007,13 @@ pub(super) mod tests { ]; for (index, request) in requests.into_iter().enumerate() { - let outcome = dispatch_auction_action( + let outcome = Auction::handle( &store_with(Some(valid_interaction())), - AuctionActionPlayer { self_guid: Some(7) }, - request, + &mut ProtocolSession::in_world(7, 7), + request.into(), ) .unwrap(); - let AuctionActionOutcome::Handled { outbound } = outcome else { - panic!("auction query {index} must be handled by the auction seam"); - }; + let outbound = outcome.outbound; assert_eq!(outbound.len(), 1, "query {index} has one ordered reply"); match (index, &outbound[0]) { (0, Outbound::One(ServerOpcodeMessage::SMSG_AUCTION_LIST_RESULT(result))) => { @@ -1363,20 +1334,18 @@ pub(super) mod tests { } fn cancel_outbound(store: &InMemoryAuctionActions) -> Result> { - match dispatch_auction_action( + Ok(Auction::handle( store, - AuctionActionPlayer { self_guid: Some(7) }, - wow_world_messages::vanilla::CMSG_AUCTION_REMOVE_ITEM { - auctioneer: Guid::new(42), - auction_id: 41, - } - .into(), - )? { - AuctionActionOutcome::Handled { outbound } => Ok(outbound), - AuctionActionOutcome::PassThrough(_) => { - panic!("auction cancel must never pass beyond its focused seam") - } - } + &mut ProtocolSession::in_world(7, 7), + ProtocolRequest::Message( + wow_world_messages::vanilla::CMSG_AUCTION_REMOVE_ITEM { + auctioneer: Guid::new(42), + auction_id: 41, + } + .into(), + ), + )? + .outbound) } /// The encoded `SMSG_AUCTION_COMMAND_RESULT` body of the one packet in `outbound`. diff --git a/gateway/src/world/handlers/bank.rs b/gateway/src/world/handlers/bank.rs index 66e53e12a..aba0a1e49 100644 --- a/gateway/src/world/handlers/bank.rs +++ b/gateway/src/world/handlers/bank.rs @@ -2,7 +2,7 @@ //! the vendor family. use super::super::*; -use super::send_show_bank; +use super::show_bank; use crate::stdb::{classify, DurableFailure}; /// Bank Durable Requests. @@ -21,13 +21,13 @@ pub(crate) trait BankStore: Send + Sync { /// Move the item in `slot_index` between carry space and the bank. A Refusal, or no Character yet, /// answers `SMSG_INVENTORY_CHANGE_FAILURE`; a Transport Loss ends the World Session. fn auto_bank( - tx: &SessionTx, + outbound: &mut Vec, store: &St, - conn: &WorldConn, + conn: &ProtocolSession, slot_index: u8, direction: &str, ) -> Result<()> { - if let Some(actor) = social::self_guid(conn).and_then(Actor::new) { + if let Some(actor) = conn.self_guid().and_then(Actor::new) { let Err(error) = store.auto_bank_item(actor, slot_index) else { return Ok(()); }; @@ -39,12 +39,14 @@ fn auto_bank( conn.account_id ); } - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_INVENTORY_CHANGE_FAILURE( - Box::new(codec::build_inventory_change_failure()), - )), - ) + { + outbound.push(Outbound::One( + ServerOpcodeMessage::SMSG_INVENTORY_CHANGE_FAILURE(Box::new( + codec::build_inventory_change_failure(), + )), + )); + Ok(()) + } } /// Bank family: `CMSG_BANKER_ACTIVATE` opens the bank window (a standing-refusing banker gets no @@ -53,80 +55,85 @@ fn auto_bank( /// into the first free carry slot — both resolve to the same module entry point, which infers the /// direction from the source slot and carries the banker-proximity gate for free (it reuses the /// move core). Only the main pseudo-bag (255) is addressed, matching the item handler's restriction. -pub(crate) fn handle_bank( - tx: &SessionTx, - store: &St, - conn: &mut WorldConn, - msg: ClientOpcodeMessage, -) -> Result> { - const MAIN_BAG: u8 = 255; // INVENTORY_SLOT_BAG_0 — same restriction as the item handler - match msg { - ClientOpcodeMessage::CMSG_BANKER_ACTIVATE(c) => { - let banker_guid = c.guid.guid(); - if let WorldState::InWorld(iw) = &conn.state { - if store - .npc_refuses_interaction(banker_guid, iw.self_guid) - .unwrap_or(false) - { - return Ok(None); +pub(crate) struct Bank; + +impl ProtocolFamily for Bank { + fn handle( + store: &St, + conn: &mut ProtocolSession, + request: ProtocolRequest, + ) -> Result { + let msg = request.message()?; + let mut outbound = Vec::new(); + const MAIN_BAG: u8 = 255; // INVENTORY_SLOT_BAG_0 — same restriction as the item handler + match msg { + ClientOpcodeMessage::CMSG_BANKER_ACTIVATE(c) => { + let banker_guid = c.guid.guid(); + if let WorldState::InWorld(iw) = &conn.state { + if store + .npc_refuses_interaction(banker_guid, iw.self_guid) + .unwrap_or(false) + { + return Ok(outbound.into()); + } } + outbound.push(show_bank(banker_guid)); } - send_show_bank(tx, banker_guid)?; - } - // Right-click a bag item with the bank open → deposit into the first free bank slot. - ClientOpcodeMessage::CMSG_AUTOBANK_ITEM(c) => { - if c.bag_index == MAIN_BAG { - auto_bank(tx, store, conn, c.slot_index, "deposit")?; - } else { - log::debug!( - "world: autobank from sub-bag {} unsupported (account {})", - c.bag_index, - conn.account_id - ); + // Right-click a bag item with the bank open → deposit into the first free bank slot. + ClientOpcodeMessage::CMSG_AUTOBANK_ITEM(c) => { + if c.bag_index == MAIN_BAG { + auto_bank(&mut outbound, store, conn, c.slot_index, "deposit")?; + } else { + log::debug!( + "world: autobank from sub-bag {} unsupported (account {})", + c.bag_index, + conn.account_id + ); + } } - } - // Buy the next bank bag slot from the named banker. Success and every refusal relay - // `SMSG_BUY_BANK_SLOT_RESULT`; the refusal code rides the module's `[N]` error tag. - ClientOpcodeMessage::CMSG_BUY_BANK_SLOT(c) => { - let banker_guid = c.guid.guid(); - // No Character yet gets an untagged reason, which answers NotBanker. - let refusal = match social::self_guid(conn) - .and_then(Actor::new) - .map(|actor| store.buy_bank_slot(actor, banker_guid)) - { - Some(Ok(())) => None, - None => Some(String::new()), - Some(Err(error)) => match classify(&error) { - DurableFailure::Refusal { reason } => Some(reason.to_string()), - DurableFailure::TransportLoss => return Err(error), - }, - }; - if let Some(reason) = &refusal { - log::debug!( - "world: buy_bank_slot rejected (account {}): {reason}", - conn.account_id - ); + // Buy the next bank bag slot from the named banker. Success and every refusal relay + // `SMSG_BUY_BANK_SLOT_RESULT`; the refusal code rides the module's `[N]` error tag. + ClientOpcodeMessage::CMSG_BUY_BANK_SLOT(c) => { + let banker_guid = c.guid.guid(); + // No Character yet gets an untagged reason, which answers NotBanker. + let refusal = match conn + .self_guid() + .and_then(Actor::new) + .map(|actor| store.buy_bank_slot(actor, banker_guid)) + { + Some(Ok(())) => None, + None => Some(String::new()), + Some(Err(error)) => match classify(&error) { + DurableFailure::Refusal { reason } => Some(reason.to_string()), + DurableFailure::TransportLoss => return Err(error), + }, + }; + if let Some(reason) = &refusal { + log::debug!( + "world: buy_bank_slot rejected (account {}): {reason}", + conn.account_id + ); + } + outbound.push(Outbound::One( + ServerOpcodeMessage::SMSG_BUY_BANK_SLOT_RESULT( + codec::build_buy_bank_slot_reply(refusal.as_deref().map_or(Ok(()), Err)), + ), + )); } - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_BUY_BANK_SLOT_RESULT( - codec::build_buy_bank_slot_reply(refusal.as_deref().map_or(Ok(()), Err)), - )), - )?; - } - // Right-click a banked item → withdraw into the first free backpack/bag slot. - ClientOpcodeMessage::CMSG_AUTOSTORE_BANK_ITEM(c) => { - if c.bag_index == MAIN_BAG { - auto_bank(tx, store, conn, c.slot_index, "withdraw")?; - } else { - log::debug!( - "world: autostore-bank from sub-bag {} unsupported (account {})", - c.bag_index, - conn.account_id - ); + // Right-click a banked item → withdraw into the first free backpack/bag slot. + ClientOpcodeMessage::CMSG_AUTOSTORE_BANK_ITEM(c) => { + if c.bag_index == MAIN_BAG { + auto_bank(&mut outbound, store, conn, c.slot_index, "withdraw")?; + } else { + log::debug!( + "world: autostore-bank from sub-bag {} unsupported (account {})", + c.bag_index, + conn.account_id + ); + } } + _ => return Err(anyhow!("opcode routed to the wrong Protocol Family")), } - other => return Ok(Some(other)), + Ok(outbound.into()) } - Ok(None) } diff --git a/gateway/src/world/handlers/cast/cancel.rs b/gateway/src/world/handlers/cast/cancel.rs index fb3488ae3..14c68ed5f 100644 --- a/gateway/src/world/handlers/cast/cancel.rs +++ b/gateway/src/world/handlers/cast/cancel.rs @@ -7,19 +7,19 @@ use super::*; /// spell id is unused: the caller has at most one pending cast, which names it. pub(super) fn cancel_cast( store: &St, - player: CastPlayer, -) -> Result { - best_effort(player, "cancel_cast", |actor| store.cancel_cast(actor)) + session: &mut ProtocolSession, +) -> Result { + best_effort(session, "cancel_cast", |actor| store.cancel_cast(actor)) } /// `CMSG_CANCEL_AURA`: remove the caller's own aura named by the wire spell id. The aura relay then /// re-syncs the buff bar. pub(super) fn cancel_aura( store: &St, - player: CastPlayer, + session: &mut ProtocolSession, spell_id: u32, -) -> Result { - best_effort(player, "cancel_aura", |actor| { +) -> Result { + best_effort(session, "cancel_aura", |actor| { store.cancel_aura(actor, spell_id) }) } @@ -28,21 +28,18 @@ pub(super) fn cancel_aura( /// is logged rather than raised. Only a Transport Loss ends the session, because no later request /// could be served either. A session with no Actor has nothing to cancel. fn best_effort( - player: CastPlayer, + session: &mut ProtocolSession, what: &str, request: impl FnOnce(Actor) -> Result<()>, -) -> Result { - if let Some(Err(e)) = player.actor().map(request) { +) -> Result { + if let Some(Err(e)) = session.actor().map(request) { let reason = refusal_reason(e)?; log::debug!( "world: {what} ignored (account {}): {reason}", - player.account_id + session.account_id ); } - Ok(CastOutcome::Handled { - transition: CastTransition::default(), - outbound: Vec::new(), - }) + Ok(ProtocolReply::default()) } #[cfg(test)] @@ -71,8 +68,8 @@ mod tests { fn cancelling_a_cast_asks_for_the_callers_pending_cast_and_answers_nothing() { let store = InMemoryCasts::default(); - let (transition, outbound) = - handled(dispatch_cast(&store, player(), cancel_cast_msg()).unwrap()); + let (repeating, outbound) = + handled(run_cast(&store, session(), cancel_cast_msg()).unwrap()); assert_eq!( store.cancel_cast_calls.lock().unwrap().as_slice(), @@ -83,15 +80,14 @@ mod tests { outbound.is_empty(), "the client already dropped its cast bar" ); - assert_eq!(transition, CastTransition::default()); + assert!(!repeating); } #[test] fn cancelling_an_aura_passes_the_wire_spell_id() { let store = InMemoryCasts::default(); - let (_, outbound) = - handled(dispatch_cast(&store, player(), cancel_aura_msg(5555)).unwrap()); + let (_, outbound) = handled(run_cast(&store, session(), cancel_aura_msg(5555)).unwrap()); assert_eq!( store.cancel_aura_calls.lock().unwrap().as_slice(), @@ -106,7 +102,7 @@ mod tests { let store = refusing_store("nothing to cancel"); let (_, outbound) = handled( - dispatch_cast(&store, player(), msg) + run_cast(&store, session(), msg) .unwrap_or_else(|_| panic!("a losing {what} race must not end the session")), ); @@ -121,18 +117,15 @@ mod tests { ..Default::default() }; - assert!(dispatch_cast(&store, player(), cancel_cast_msg()).is_err()); + assert!(run_cast(&store, session(), cancel_cast_msg()).is_err()); } #[test] fn a_player_with_no_character_in_world_has_nothing_to_cancel() { let store = InMemoryCasts::default(); - let player = CastPlayer { - self_guid: None, - ..player() - }; + let player = ProtocolSession::new(ACCOUNT, "TESTER".into()); - handled(dispatch_cast(&store, player, cancel_aura_msg(5555)).unwrap()); + handled(run_cast(&store, player, cancel_aura_msg(5555)).unwrap()); assert!(store.cancel_aura_calls.lock().unwrap().is_empty()); } diff --git a/gateway/src/world/handlers/cast/manual.rs b/gateway/src/world/handlers/cast/manual.rs index 1334a3ffd..dc203d8c3 100644 --- a/gateway/src/world/handlers/cast/manual.rs +++ b/gateway/src/world/handlers/cast/manual.rs @@ -40,17 +40,17 @@ fn gameobject_target(c: &CMSG_CAST_SPELL) -> u64 { /// Enchant, disenchant, fishing and lock opening: decode the route's target, request the durable /// operation, then send the shared completion sequence on success or the shared simple failure on /// refusal. None of these reducers emit a cast event, so the gateway is the only sender either way. -pub(crate) fn manual_completion_cast( +pub(super) fn manual_completion_cast( store: &St, - player: CastPlayer, + session: &mut ProtocolSession, c: &CMSG_CAST_SPELL, route: ManualRoute, -) -> Result { +) -> Result { let spell = c.spell; // `Err` carries the reason the request ended in a Refusal: the Module's, or the Gateway's own // when the cast names no usable target. let completed: std::result::Result = 'request: { - let Some(actor) = player.actor() else { + let Some(actor) = session.actor() else { break 'request Err(NO_ACTOR.to_string()); }; let result = match route { @@ -60,7 +60,9 @@ pub(crate) fn manual_completion_cast( break 'request Err("enchant: no item target in cast".to_string()); } let Some(slot) = store.item_slot_by_guid(item_guid) else { - break 'request Err(format!("enchant: item {item_guid} not in player bag")); + break 'request Err(format!( + "enchant: item {item_guid} not in the Character inventory" + )); }; match enchant_route { EnchantRoute::Disenchant => store.disenchant_item(actor, slot), @@ -88,7 +90,7 @@ pub(crate) fn manual_completion_cast( Err(reason) => { log::debug!( "world: manual-completion cast {spell} rejected (account {}): {reason}", - player.account_id + session.account_id ); vec![Outbound::One(ServerOpcodeMessage::SMSG_CAST_RESULT( Box::new(SMSG_CAST_RESULT { @@ -110,8 +112,5 @@ pub(crate) fn manual_completion_cast( ))), ], }; - Ok(CastOutcome::Handled { - transition: CastTransition::default(), - outbound, - }) + Ok(outbound.into()) } diff --git a/gateway/src/world/handlers/cast/mod.rs b/gateway/src/world/handlers/cast/mod.rs index 5ef8113b3..9c7fde662 100644 --- a/gateway/src/world/handlers/cast/mod.rs +++ b/gateway/src/world/handlers/cast/mod.rs @@ -18,6 +18,7 @@ mod ranged; use super::super::*; use super::MeleeActionStore; use crate::stdb::{classify, DurableFailure}; +use crate::world::family::{ProtocolFamily, ProtocolReply, ProtocolRequest, ProtocolSession}; use wow_world_messages::vanilla::CMSG_CAST_SPELL; /// `SMSG_CAST_RESULT`. Both bodies are hand-rolled (gtker's typed message inverts the status @@ -69,7 +70,7 @@ pub(crate) trait CastStore: MeleeActionStore + Send + Sync { fn cast_item_target(&self, actor: Actor, spell_id: u32, slot: u8) -> Result<()>; /// Arm the ranged auto-repeat loop on `target_guid` with `spell_id`. The module requires an - /// equipped ranged weapon; `Err` is the refusal the player sees as a cast failure. + /// equipped ranged weapon; `Err` is the Refusal the client sees as a cast failure. fn start_ranged_attack(&self, actor: Actor, target_guid: u64, spell_id: u32) -> Result<()>; /// The bag slot holding the item instance a client spell-target names, so the enchant and @@ -88,7 +89,7 @@ pub(crate) trait CastStore: MeleeActionStore + Send + Sync { fn fish(&self, actor: Actor) -> Result<()>; /// Pick the lock on GameObject `go_guid`. The module gates range, the lock requirement and the - /// caller's skill; `Err` is the refusal the player sees as a cast failure. + /// caller's skill; `Err` is the Refusal the client sees as a cast failure. fn pick_lock(&self, actor: Actor, go_guid: u64) -> Result<()>; /// Drop the caller's pending cast, so a scheduled completion cannot fire later. Under the @@ -112,41 +113,6 @@ pub(crate) trait CastStore: MeleeActionStore + Send + Sync { fn item_template(&self, entry: u32) -> Result>; } -/// Everything the cast module knows about the caller. `self_guid` is `None` when the session has no -/// character in the world: no durable request is made and no synchronous message is sent. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) struct CastPlayer { - pub(crate) account_id: u64, - pub(crate) self_guid: Option, - /// A ranged auto-repeat loop is armed. Only the ranged route reads it. - pub(crate) ranged_repeat: bool, -} - -impl CastPlayer { - fn actor(self) -> Option { - self.self_guid.and_then(Actor::new) - } -} - -/// Session state the dispatcher applies before it sends the outbound batch. The ordinary cast route -/// never sets one; ranged auto-repeat activation and cancellation do. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub(crate) struct CastTransition { - /// `Some(armed)` writes the session's ranged auto-repeat flag; `None` leaves it alone. - pub(crate) ranged_repeat: Option, -} - -/// The result of one cast request. `Handled` carries the complete, ordered client-visible batch — -/// exact message order is external behaviour for the vanilla client, so the outcome states it -/// rather than leaving it to the caller. -pub(crate) enum CastOutcome { - Handled { - transition: CastTransition, - outbound: Vec, - }, - PassThrough(ClientOpcodeMessage), -} - /// Which route a `CMSG_CAST_SPELL` takes, selected from imported taxonomy alone. enum CastRoute { /// Auto Shot and wand Shoot: an auto-repeat attack loop, not a one-shot cast. @@ -193,7 +159,7 @@ fn unit_target(c: &CMSG_CAST_SPELL) -> u64 { .map_or(0, |u| u.unit_target.guid()) } -/// The ground point the player clicked (`DEST_LOCATION`), if the cast carries one. +/// The ground point the client clicked (`DEST_LOCATION`), if the cast carries one. fn dest_target(c: &CMSG_CAST_SPELL) -> Option<(f32, f32, f32)> { c.targets .target_flags @@ -201,35 +167,41 @@ fn dest_target(c: &CMSG_CAST_SPELL) -> Option<(f32, f32, f32)> { .map(|d| (d.destination.x, d.destination.y, d.destination.z)) } -/// Route one client message through the cast seam. Anything that is not a cast request, and every -/// route a sibling module does not own yet, passes through untouched. -pub(crate) fn dispatch_cast( - store: &St, - player: CastPlayer, - msg: ClientOpcodeMessage, -) -> Result { - match msg { - ClientOpcodeMessage::CMSG_CAST_SPELL(c) => match route_for(store, c.spell) { - CastRoute::Ordinary => ordinary_cast(store, player, &c), - CastRoute::RangedAutoRepeat => ranged::activate(store, player, &c), - CastRoute::ManualCompletion(route) => { - manual::manual_completion_cast(store, player, &c, route) +/// Handle cast requests, cancellations and ranged auto-repeat state. +pub(crate) struct Cast; + +impl ProtocolFamily for Cast { + fn handle( + store: &St, + session: &mut ProtocolSession, + request: ProtocolRequest, + ) -> Result { + let msg = request.message()?; + match msg { + ClientOpcodeMessage::CMSG_CAST_SPELL(c) => match route_for(store, c.spell) { + CastRoute::Ordinary => ordinary_cast(store, session, &c), + CastRoute::RangedAutoRepeat => ranged::activate(store, session, &c), + CastRoute::ManualCompletion(route) => { + manual::manual_completion_cast(store, session, &c, route) + } + }, + ClientOpcodeMessage::CMSG_CANCEL_AUTO_REPEAT_SPELL => ranged::cancel(store, session), + ClientOpcodeMessage::CMSG_CANCEL_CAST(_) + | ClientOpcodeMessage::CMSG_CANCEL_CHANNELLING(_) => { + cancel::cancel_cast(store, session) } - }, - ClientOpcodeMessage::CMSG_CANCEL_AUTO_REPEAT_SPELL => ranged::cancel(store, player), - ClientOpcodeMessage::CMSG_CANCEL_CAST(_) - | ClientOpcodeMessage::CMSG_CANCEL_CHANNELLING(_) => cancel::cancel_cast(store, player), - ClientOpcodeMessage::CMSG_CANCEL_AURA(c) => cancel::cancel_aura(store, player, c.id), - other => Ok(CastOutcome::PassThrough(other)), + ClientOpcodeMessage::CMSG_CANCEL_AURA(c) => cancel::cancel_aura(store, session, c.id), + other => Err(anyhow!("request routed to Cast: {other}")), + } } } /// The ordinary cast route: instant, timed, next-swing, ground-area and ground-targeted casts. fn ordinary_cast( store: &St, - player: CastPlayer, + session: &mut ProtocolSession, c: &CMSG_CAST_SPELL, -) -> Result { +) -> Result { let spell = c.spell; // Thread the client's unit target so target-keyed effects — combo finishers, enemy spells — // see the real target. No unit target stays 0 so the module applies its own self-cast rule. @@ -247,7 +219,7 @@ fn ordinary_cast( // sequence below would un-light the button and resolve the cast at queue time. let queues_swing = instant && store.spell_queues_next_swing(spell); if instant && !queues_swing { - if let Some(caster) = player.actor().map(Actor::guid) { + if let Some(caster) = session.actor().map(Actor::guid) { // vmangos order: START(0) then the raw CAST_RESULT(OK) then GO. The 5875 client needs // that 5-byte ack before GO to make m_currentSpells clearable. // A cast at a clicked ground point echoes the point in both target blocks. @@ -288,13 +260,13 @@ fn ordinary_cast( _ => 0, }; let refusal = 'request: { - let Some(actor) = player.actor() else { + let Some(actor) = session.actor() else { break 'request Some(NO_ACTOR.to_string()); }; let result = if item_guid != 0 { let Some(slot) = store.item_slot_by_guid(item_guid) else { break 'request Some(format!( - "item target {item_guid} is not in the player's bag" + "item target {item_guid} is not in the Character's bag" )); }; store.cast_item_target(actor, spell, slot) @@ -315,17 +287,14 @@ fn ordinary_cast( // server-only gates — behind, stealth, stance, react window — invisible. log::debug!( "world: cast {spell} rejected (account {}): {reason}", - player.account_id + session.account_id ); outbound.push(Outbound::Raw { opcode: OP_CAST_RESULT, body: codec::build_cast_result_failed(spell, codec::cast_failure_reason_for(&reason)), }); } - Ok(CastOutcome::Handled { - transition: CastTransition::default(), - outbound, - }) + Ok(outbound.into()) } /// The focused in-memory cast adapter and the shared seam-test helpers. Every route module's tests @@ -340,12 +309,12 @@ pub(super) mod tests { SpellCastTargets_SpellCastTargetFlags_DestLocation, SpellCastTargets_SpellCastTargetFlags_Gameobject, SpellCastTargets_SpellCastTargetFlags_Item, SpellCastTargets_SpellCastTargetFlags_Unit, - Vector3d, CMSG_PING, + Vector3d, }; /// One recorded durable cast: caster, spell and unit target. - pub(crate) type Cast = (u64, u32, u64); - /// One recorded durable ground cast: a [`Cast`] plus the click point. + pub(crate) type RecordedCast = (u64, u32, u64); + /// One recorded durable ground cast: a [`RecordedCast`] plus the click point. pub(crate) type GroundCast = (u64, u32, u64, f32, f32, f32); /// One recorded ranged activation: caster, unit target and spell. pub(crate) type RangedAttack = (u64, u64, u32); @@ -383,7 +352,7 @@ pub(super) mod tests { pub(crate) manual_error: Option, /// Shared refusal for both cancellation operations. pub(crate) cancel_error: Option, - pub(crate) casts: Mutex>, + pub(crate) casts: Mutex>, pub(crate) ground_casts: Mutex>, pub(crate) item_target_casts: Mutex>, pub(crate) ranged_attacks: Mutex>, @@ -583,12 +552,8 @@ pub(super) mod tests { pub(crate) const ACCOUNT: u64 = 7; pub(crate) const CASTER: u64 = 42; - pub(crate) fn player() -> CastPlayer { - CastPlayer { - account_id: ACCOUNT, - self_guid: Some(CASTER), - ranged_repeat: false, - } + pub(crate) fn session() -> ProtocolSession { + ProtocolSession::in_world(ACCOUNT, CASTER) } pub(crate) fn cast(spell: u32, targets: SpellCastTargets) -> ClientOpcodeMessage { @@ -642,15 +607,19 @@ pub(super) mod tests { } } - /// The handled outcome, or a panic naming what came back instead. - pub(crate) fn handled(outcome: CastOutcome) -> (CastTransition, Vec) { - match outcome { - CastOutcome::Handled { - transition, - outbound, - } => (transition, outbound), - CastOutcome::PassThrough(_) => panic!("expected a handled cast"), - } + /// The ranged state and client messages after one request. + pub(crate) fn handled(result: (bool, ProtocolReply)) -> (bool, Vec) { + (result.0, result.1.outbound) + } + + pub(crate) fn run_cast( + store: &St, + mut session: ProtocolSession, + message: ClientOpcodeMessage, + ) -> Result<(bool, ProtocolReply)> { + let reply = Cast::handle(store, &mut session, message.into())?; + let repeating = matches!(&session.state, WorldState::InWorld(world) if world.ranged_repeat); + Ok((repeating, reply)) } /// One label per outbound unit, in order — the synchronous sequence is the contract, and it @@ -696,15 +665,15 @@ pub(super) mod tests { fn instant_unit_target_cast_clears_the_client_then_requests_the_durable_cast() { let store = InMemoryCasts::instant(); - let (transition, outbound) = - handled(dispatch_cast(&store, player(), cast(100, unit_targets(77))).unwrap()); + let (repeating, outbound) = + handled(run_cast(&store, session(), cast(100, unit_targets(77))).unwrap()); assert_eq!( sequence(&outbound), ["START", "CAST_RESULT(OK)", "GO"], "the 5875 client needs the OK ack between START and GO" ); - assert_eq!(transition, CastTransition::default()); + assert!(!repeating); assert_eq!(store.casts.lock().unwrap().as_slice(), &[(CASTER, 100, 77)]); } @@ -712,7 +681,7 @@ pub(super) mod tests { fn instant_cast_with_no_unit_target_passes_target_zero_to_the_durable_cast() { let store = InMemoryCasts::instant(); - handled(dispatch_cast(&store, player(), cast(100, SpellCastTargets::default())).unwrap()); + handled(run_cast(&store, session(), cast(100, SpellCastTargets::default())).unwrap()); assert_eq!( store.casts.lock().unwrap().as_slice(), @@ -726,7 +695,7 @@ pub(super) mod tests { let store = InMemoryCasts::default(); let (_, outbound) = - handled(dispatch_cast(&store, player(), cast(42, unit_targets(77))).unwrap()); + handled(run_cast(&store, session(), cast(42, unit_targets(77))).unwrap()); assert_eq!(sequence(&outbound), ["START", "CAST_RESULT(OK)", "GO"]); } @@ -736,7 +705,7 @@ pub(super) mod tests { let store = InMemoryCasts::instant(); let (_, outbound) = - handled(dispatch_cast(&store, player(), cast(100, unit_targets(77))).unwrap()); + handled(run_cast(&store, session(), cast(100, unit_targets(77))).unwrap()); assert_eq!( spell_go(&outbound) @@ -756,7 +725,7 @@ pub(super) mod tests { }; let (_, outbound) = - handled(dispatch_cast(&store, player(), cast(118, unit_targets(77))).unwrap()); + handled(run_cast(&store, session(), cast(118, unit_targets(77))).unwrap()); assert_eq!(sequence(&outbound), ["START", "CAST_RESULT(OK)", "GO"]); assert!( @@ -770,9 +739,9 @@ pub(super) mod tests { let store = InMemoryCasts::instant(); let (_, outbound) = handled( - dispatch_cast( + run_cast( &store, - player(), + session(), cast(1725, dest_targets(-8913.5, 554.25, 93.75)), ) .unwrap(), @@ -820,7 +789,7 @@ pub(super) mod tests { }; let (_, outbound) = - handled(dispatch_cast(&store, player(), cast(100, unit_targets(77))).unwrap()); + handled(run_cast(&store, session(), cast(100, unit_targets(77))).unwrap()); assert!( outbound.is_empty(), @@ -837,7 +806,7 @@ pub(super) mod tests { }; let (_, outbound) = - handled(dispatch_cast(&store, player(), cast(78, unit_targets(77))).unwrap()); + handled(run_cast(&store, session(), cast(78, unit_targets(77))).unwrap()); assert!( outbound.is_empty(), @@ -856,9 +825,9 @@ pub(super) mod tests { }; handled( - dispatch_cast( + run_cast( &store, - player(), + session(), cast(2120, dest_targets(-8913.5, 554.25, 93.75)), ) .unwrap(), @@ -881,7 +850,7 @@ pub(super) mod tests { }; let (_, outbound) = - handled(dispatch_cast(&store, player(), cast(100, unit_targets(77))).unwrap()); + handled(run_cast(&store, session(), cast(100, unit_targets(77))).unwrap()); assert_eq!( sequence(&outbound), @@ -898,7 +867,7 @@ pub(super) mod tests { }; let (_, outbound) = - handled(dispatch_cast(&store, player(), cast(100, unit_targets(77))).unwrap()); + handled(run_cast(&store, session(), cast(100, unit_targets(77))).unwrap()); assert_eq!(sequence(&outbound), ["CAST_RESULT(FAILED 0x59)"]); } @@ -911,7 +880,7 @@ pub(super) mod tests { ..Default::default() }; - let error = match dispatch_cast(&store, player(), cast(100, unit_targets(77))) { + let error = match run_cast(&store, session(), cast(100, unit_targets(77))) { Err(error) => error, Ok(_) => panic!("a dead reducer transport must end the session"), }; @@ -919,39 +888,19 @@ pub(super) mod tests { assert!(matches!(classify(&error), DurableFailure::TransportLoss)); } - // ── Player context ─────────────────────────────────────────────────────── + // ── World Session context ─────────────────────────────────────────────────────── #[test] fn a_player_with_no_character_in_world_makes_no_request_and_gets_a_failed_cast() { let store = InMemoryCasts::instant(); - let player = CastPlayer { - account_id: ACCOUNT, - self_guid: None, - ranged_repeat: false, - }; + let player = ProtocolSession::new(ACCOUNT, "TESTER".into()); - let (_, outbound) = - handled(dispatch_cast(&store, player, cast(100, unit_targets(77))).unwrap()); + let (_, outbound) = handled(run_cast(&store, player, cast(100, unit_targets(77))).unwrap()); assert_eq!(sequence(&outbound), ["CAST_RESULT(FAILED 0x17)"]); assert!(store.casts.lock().unwrap().is_empty()); } - #[test] - fn unrelated_opcodes_pass_through_to_the_next_dispatcher() { - let store = InMemoryCasts::default(); - - assert!(matches!( - dispatch_cast( - &store, - player(), - ClientOpcodeMessage::CMSG_PING(CMSG_PING::default()) - ) - .unwrap(), - CastOutcome::PassThrough(ClientOpcodeMessage::CMSG_PING(_)) - )); - } - // ── Manual completion: enchant, disenchant, fishing, lock opening ──────── #[test] @@ -962,11 +911,11 @@ pub(super) mod tests { ..Default::default() }; - let (transition, outbound) = - handled(dispatch_cast(&store, player(), cast(7418, item_targets(500))).unwrap()); + let (repeating, outbound) = + handled(run_cast(&store, session(), cast(7418, item_targets(500))).unwrap()); assert_eq!(sequence(&outbound), ["START", "CAST_RESULT(OK)", "GO"]); - assert_eq!(transition, CastTransition::default()); + assert!(!repeating); assert_eq!(store.enchant_calls.lock().unwrap().as_slice(), &[(4, 777)]); assert!(store.disenchant_calls.lock().unwrap().is_empty()); assert!( @@ -984,7 +933,7 @@ pub(super) mod tests { }; let (_, outbound) = - handled(dispatch_cast(&store, player(), cast(13262, item_targets(500))).unwrap()); + handled(run_cast(&store, session(), cast(13262, item_targets(500))).unwrap()); assert_eq!(sequence(&outbound), ["START", "CAST_RESULT(OK)", "GO"]); assert_eq!(store.disenchant_calls.lock().unwrap().as_slice(), &[9]); @@ -998,9 +947,8 @@ pub(super) mod tests { ..Default::default() }; - let (_, outbound) = handled( - dispatch_cast(&store, player(), cast(7418, SpellCastTargets::default())).unwrap(), - ); + let (_, outbound) = + handled(run_cast(&store, session(), cast(7418, SpellCastTargets::default())).unwrap()); assert_eq!( sequence(&outbound), @@ -1019,7 +967,7 @@ pub(super) mod tests { }; let (_, outbound) = - handled(dispatch_cast(&store, player(), cast(13262, item_targets(500))).unwrap()); + handled(run_cast(&store, session(), cast(13262, item_targets(500))).unwrap()); assert_eq!(sequence(&outbound), ["CAST_RESULT(FAILURE)"]); assert!(store.disenchant_calls.lock().unwrap().is_empty()); @@ -1032,9 +980,8 @@ pub(super) mod tests { ..Default::default() }; - let (_, outbound) = handled( - dispatch_cast(&store, player(), cast(7620, SpellCastTargets::default())).unwrap(), - ); + let (_, outbound) = + handled(run_cast(&store, session(), cast(7620, SpellCastTargets::default())).unwrap()); assert_eq!(sequence(&outbound), ["START", "CAST_RESULT(OK)", "GO"]); assert_eq!(store.fish_calls.lock().unwrap().as_slice(), &[CASTER]); @@ -1048,9 +995,8 @@ pub(super) mod tests { ..Default::default() }; - let (_, outbound) = handled( - dispatch_cast(&store, player(), cast(7620, SpellCastTargets::default())).unwrap(), - ); + let (_, outbound) = + handled(run_cast(&store, session(), cast(7620, SpellCastTargets::default())).unwrap()); assert_eq!(sequence(&outbound), ["CAST_RESULT(FAILURE)"]); } @@ -1063,7 +1009,7 @@ pub(super) mod tests { ..Default::default() }; - let error = match dispatch_cast(&store, player(), cast(7620, SpellCastTargets::default())) { + let error = match run_cast(&store, session(), cast(7620, SpellCastTargets::default())) { Err(error) => error, Ok(_) => panic!("a dead reducer transport must end the session"), }; @@ -1079,9 +1025,9 @@ pub(super) mod tests { }; let (_, outbound) = handled( - dispatch_cast( + run_cast( &store, - player(), + session(), cast(1804, gameobject_targets(0xABCD, unk_shape)), ) .unwrap(), @@ -1103,9 +1049,8 @@ pub(super) mod tests { ..Default::default() }; - let (_, outbound) = handled( - dispatch_cast(&store, player(), cast(1804, SpellCastTargets::default())).unwrap(), - ); + let (_, outbound) = + handled(run_cast(&store, session(), cast(1804, SpellCastTargets::default())).unwrap()); assert_eq!(sequence(&outbound), ["CAST_RESULT(FAILURE)"]); assert!(store.pick_lock_calls.lock().unwrap().is_empty()); @@ -1119,7 +1064,7 @@ pub(super) mod tests { ..Default::default() }; let (_, outbound) = - handled(dispatch_cast(&store, player(), cast(6991, item_targets(500))).unwrap()); + handled(run_cast(&store, session(), cast(6991, item_targets(500))).unwrap()); assert_eq!(sequence(&outbound), ["START", "CAST_RESULT(OK)", "GO"]); assert_eq!( *store.item_target_casts.lock().unwrap(), diff --git a/gateway/src/world/handlers/cast/ranged.rs b/gateway/src/world/handlers/cast/ranged.rs index 32123b698..515a9e61d 100644 --- a/gateway/src/world/handlers/cast/ranged.rs +++ b/gateway/src/world/handlers/cast/ranged.rs @@ -2,36 +2,35 @@ //! //! Activation arms the durable loop and parks a cast in the client's auto-repeat slot; each real //! shot belongs to the durable swing tick and its combat-event relay. Cancellation only clears the -//! loop. Both directions return a session transition — whether a loop is armed is gateway session -//! state, and melee start, melee stop and this route all read it. +//! loop. Both directions update the shared ranged state that melee requests also read. use super::*; /// Arm or refuse a ranged auto-repeat loop (`CMSG_CAST_SPELL` for Auto Shot / wand Shoot). pub(super) fn activate( store: &St, - player: CastPlayer, + session: &mut ProtocolSession, c: &CMSG_CAST_SPELL, -) -> Result { +) -> Result { let spell = c.spell; // The shot's target rides the cast's UNIT block. No current-selection fallback: Auto Shot and // Shoot are cast ON a target, so the client always sends one. let target = unit_target(c); log::info!( "world[autoshot]: activate spell={spell} target={target} already_repeating={} (account {})", - player.ranged_repeat, - player.account_id + matches!(&session.state, WorldState::InWorld(world) if world.ranged_repeat), + session.account_id ); // Arm the durable loop FIRST, before any success outcome exists. A refusal then answers only // the raw failure result, and the 5875 client drops its auto-repeat toggle on that — client and // server stay in lockstep. Acknowledging first and failing after left the client toggled ON // over a dead loop, so the next press sent a cancel instead of a cast. - let Some(actor) = player.actor() else { - return refuse(store, player, spell, NO_ACTOR); + let Some(actor) = session.actor() else { + return refuse(store, session, spell, NO_ACTOR); }; if let Err(e) = store.start_ranged_attack(actor, target, spell) { - return refuse(store, player, spell, &refusal_reason(e)?); + return refuse(store, session, spell, &refusal_reason(e)?); } // The activation ack is `SMSG_SPELL_START` alone: timer 0 (the wind-up is an attack timer, not @@ -48,24 +47,24 @@ pub(super) fn activate( ammo_display(store, caster), )), ))]; - Ok(CastOutcome::Handled { - transition: CastTransition { - ranged_repeat: Some(true), - }, - outbound, + Ok({ + if let WorldState::InWorld(world) = &mut session.state { + world.ranged_repeat = true; + } + ProtocolReply::from(outbound) }) } /// A refused activation. A Refusal is a handled outcome; a Transport Loss never reaches here. fn refuse( store: &St, - player: CastPlayer, + session: &mut ProtocolSession, spell: u32, reason: &str, -) -> Result { +) -> Result { log::info!( "world[autoshot]: start_ranged_attack refused spell={spell} (account {}): {reason}", - player.account_id + session.account_id ); let outbound = vec![Outbound::Raw { opcode: OP_CAST_RESULT, @@ -74,57 +73,58 @@ fn refuse( // A refused RETARGET drops the client's toggle on that failure result, so the still-firing OLD // loop has to go too — otherwise the server keeps shooting a target the client believes it // stopped. A fresh activation has no older loop, and must not touch unrelated engagement state. - if !player.ranged_repeat { - return Ok(CastOutcome::Handled { - transition: CastTransition::default(), - outbound, - }); + if !matches!(&session.state, WorldState::InWorld(world) if world.ranged_repeat) { + return Ok(outbound.into()); } - stop_loop(store, player, "reject-teardown")?; - Ok(CastOutcome::Handled { - transition: CastTransition { - ranged_repeat: Some(false), - }, - outbound, + stop_loop(store, session, "reject-teardown")?; + Ok({ + if let WorldState::InWorld(world) = &mut session.state { + world.ranged_repeat = false; + } + ProtocolReply::from(outbound) }) } /// `CMSG_CANCEL_AUTO_REPEAT_SPELL`: the client toggled the loop off, or auto-switched to melee. pub(super) fn cancel( store: &St, - player: CastPlayer, -) -> Result { + session: &mut ProtocolSession, +) -> Result { log::info!( "world[autoshot]: cancel auto-repeat active={} (account {})", - player.ranged_repeat, - player.account_id + matches!(&session.state, WorldState::InWorld(world) if world.ranged_repeat), + session.account_id ); // The durable stop runs ONLY when a ranged loop was armed. A melee press sends CMSG_ATTACKSWING // and this cancel back to back, and the swing handler has already overwritten the shared // engagement row to melee — an unconditional stop deleted that just-armed melee row. No inline // ack either: the engagement's on_delete relay is the one sender of SMSG_CANCEL_AUTO_REPEAT. - if player.ranged_repeat { - stop_loop(store, player, "cancel_auto_repeat")?; + if matches!(&session.state, WorldState::InWorld(world) if world.ranged_repeat) { + stop_loop(store, session, "cancel_auto_repeat")?; } - Ok(CastOutcome::Handled { - transition: CastTransition { - ranged_repeat: Some(false), - }, - outbound: Vec::new(), + Ok({ + if let WorldState::InWorld(world) = &mut session.state { + world.ranged_repeat = false; + } + ProtocolReply::from(Vec::new()) }) } /// Ask the module to tear the engagement down. A Refusal (nothing armed, a race with the swing /// tick) must not end the session or reach the client; a Transport Loss ends the session. -fn stop_loop(store: &St, player: CastPlayer, context: &str) -> Result<()> { - let Some(actor) = player.actor() else { +fn stop_loop( + store: &St, + session: &mut ProtocolSession, + context: &str, +) -> Result<()> { + let Some(actor) = session.actor() else { return Ok(()); }; if let Err(e) = store.stop_attack(actor) { let reason = refusal_reason(e)?; log::debug!( "world: {context} stop_attack ignored (account {}): {reason}", - player.account_id + session.account_id ); } Ok(()) @@ -211,11 +211,12 @@ mod tests { } /// A player with an older ranged loop already armed. - fn repeating() -> CastPlayer { - CastPlayer { - ranged_repeat: true, - ..player() + fn repeating() -> ProtocolSession { + let mut session = session(); + if let WorldState::InWorld(world) = &mut session.state { + world.ranged_repeat = true; } + session } /// The single `SMSG_SPELL_START` in the batch. @@ -243,20 +244,15 @@ mod tests { fn activation_arms_the_durable_loop_and_acknowledges_with_a_zero_timer_start() { let store = ranged_store(); - let (transition, outbound) = - handled(dispatch_cast(&store, player(), cast(AUTO_SHOT, unit_targets(88))).unwrap()); + let (repeating, outbound) = + handled(run_cast(&store, session(), cast(AUTO_SHOT, unit_targets(88))).unwrap()); assert_eq!( store.ranged_attacks.lock().unwrap().as_slice(), &[(CASTER, 88, AUTO_SHOT)], "the cast's unit target arms the loop, with no selection fallback" ); - assert_eq!( - transition, - CastTransition { - ranged_repeat: Some(true) - } - ); + assert!(repeating); assert_eq!( sequence(&outbound), ["START"], @@ -286,13 +282,12 @@ mod tests { ..ranged_store() }; - let (transition, outbound) = - handled(dispatch_cast(&store, player(), cast(AUTO_SHOT, unit_targets(88))).unwrap()); + let (repeating, outbound) = + handled(run_cast(&store, session(), cast(AUTO_SHOT, unit_targets(88))).unwrap()); assert_eq!(sequence(&outbound), ["CAST_RESULT(FAILED 0x17)"]); - assert_eq!( - transition, - CastTransition::default(), + assert!( + !repeating, "a refused activation must not arm ranged-repeat state" ); assert!( @@ -308,16 +303,11 @@ mod tests { ..ranged_store() }; - let (transition, outbound) = - handled(dispatch_cast(&store, repeating(), cast(AUTO_SHOT, unit_targets(99))).unwrap()); + let (repeating, outbound) = + handled(run_cast(&store, repeating(), cast(AUTO_SHOT, unit_targets(99))).unwrap()); assert_eq!(sequence(&outbound), ["CAST_RESULT(FAILED 0x59)"]); - assert_eq!( - transition, - CastTransition { - ranged_repeat: Some(false) - } - ); + assert!(!repeating); assert_eq!( store.stop_attacks.lock().unwrap().as_slice(), &[CASTER], @@ -332,7 +322,7 @@ mod tests { ..ranged_store() }; - assert!(dispatch_cast(&store, player(), cast(AUTO_SHOT, unit_targets(88))).is_err()); + assert!(run_cast(&store, session(), cast(AUTO_SHOT, unit_targets(88))).is_err()); } #[test] @@ -342,7 +332,7 @@ mod tests { ..ranged_store() }; - assert!(dispatch_cast( + assert!(run_cast( &store, repeating(), ClientOpcodeMessage::CMSG_CANCEL_AUTO_REPEAT_SPELL @@ -356,8 +346,8 @@ mod tests { fn cancelling_an_active_loop_clears_the_state_and_stops_it_with_no_inline_ack() { let store = ranged_store(); - let (transition, outbound) = handled( - dispatch_cast( + let (repeating, outbound) = handled( + run_cast( &store, repeating(), ClientOpcodeMessage::CMSG_CANCEL_AUTO_REPEAT_SPELL, @@ -365,12 +355,7 @@ mod tests { .unwrap(), ); - assert_eq!( - transition, - CastTransition { - ranged_repeat: Some(false) - } - ); + assert!(!repeating); assert_eq!(store.stop_attacks.lock().unwrap().as_slice(), &[CASTER]); assert!( outbound.is_empty(), @@ -382,21 +367,16 @@ mod tests { fn cancelling_with_no_active_loop_clears_the_state_without_a_durable_stop() { let store = ranged_store(); - let (transition, outbound) = handled( - dispatch_cast( + let (repeating, outbound) = handled( + run_cast( &store, - player(), + session(), ClientOpcodeMessage::CMSG_CANCEL_AUTO_REPEAT_SPELL, ) .unwrap(), ); - assert_eq!( - transition, - CastTransition { - ranged_repeat: Some(false) - } - ); + assert!(!repeating); assert!( store.stop_attacks.lock().unwrap().is_empty(), "a late cancel must not delete a just-armed melee engagement" @@ -423,7 +403,7 @@ mod tests { fn activate_ammo(store: &InMemoryCasts) -> Option<(u32, u32)> { let (_, outbound) = - handled(dispatch_cast(store, player(), cast(AUTO_SHOT, unit_targets(88))).unwrap()); + handled(run_cast(store, session(), cast(AUTO_SHOT, unit_targets(88))).unwrap()); ammo_block(&outbound) } diff --git a/gateway/src/world/handlers/channel.rs b/gateway/src/world/handlers/channel.rs index b4adcf386..0089661a7 100644 --- a/gateway/src/world/handlers/channel.rs +++ b/gateway/src/world/handlers/channel.rs @@ -5,7 +5,7 @@ //! `chat.rs`. use super::super::*; -use super::chat::{drop_unrecognised_refusal, ChatActionPlayer, ChatActionStore, SpeakerFacts}; +use super::chat::{drop_unrecognised_refusal, ChatActionStore, SpeakerFacts}; use lyracore_shared::channel::{channel_op, notice, ChannelRefusal}; use lyracore_shared::faction::team_for_race; @@ -90,152 +90,159 @@ pub(crate) fn resolve_online_character }, - PassThrough(ClientOpcodeMessage), -} - /// What CHANNEL_OWNER names when a channel has no owner (vm:Channel.cpp:881-890). const NO_OWNER: &str = "Nobody"; /// What CHANNEL_OWNER names when the owner's Character cannot be found (cm:Channel.cpp:817-824). const OWNER_NOT_FOUND: &str = "PLAYER_NOT_FOUND"; -/// Consume the channel opcodes and pass everything else on. -pub(crate) fn dispatch_channel_action( - store: &St, - player: ChatActionPlayer, - msg: ClientOpcodeMessage, -) -> Result { - let outbound = match msg { - // An empty name is dropped (cm:ChannelHandler.cpp:66-67, cm:ChannelHandler.cpp:88-89). - ClientOpcodeMessage::CMSG_JOIN_CHANNEL(join) if join.channel_name.is_empty() => Vec::new(), - ClientOpcodeMessage::CMSG_JOIN_CHANNEL(join) => run_op( - store, - player, - channel_op::JOIN, - join.channel_name, - join.channel_password, - )?, - ClientOpcodeMessage::CMSG_LEAVE_CHANNEL(leave) if leave.channel_name.is_empty() => { - Vec::new() - } - ClientOpcodeMessage::CMSG_LEAVE_CHANNEL(leave) => run_op( - store, - player, - channel_op::LEAVE, - leave.channel_name, - String::new(), - )?, - ClientOpcodeMessage::CMSG_CHANNEL_PASSWORD(password) => run_op( - store, - player, - channel_op::PASSWORD, - password.channel_name, - password.channel_password, - )?, - ClientOpcodeMessage::CMSG_CHANNEL_SET_OWNER(set_owner) => run_targeted_op( - store, - player, - channel_op::SET_OWNER, - set_owner.channel_name, - set_owner.new_owner, - )?, - ClientOpcodeMessage::CMSG_CHANNEL_MODERATOR(moderator) => run_targeted_op( - store, - player, - channel_op::MODERATOR, - moderator.channel_name, - moderator.player_name, - )?, - ClientOpcodeMessage::CMSG_CHANNEL_UNMODERATOR(unmoderator) => run_targeted_op( - store, - player, - channel_op::UNMODERATOR, - unmoderator.channel_name, - unmoderator.player_name, - )?, - ClientOpcodeMessage::CMSG_CHANNEL_MUTE(mute) => run_targeted_op( - store, - player, - channel_op::MUTE, - mute.channel_name, - mute.player_name, - )?, - ClientOpcodeMessage::CMSG_CHANNEL_UNMUTE(unmute) => run_targeted_op( - store, - player, - channel_op::UNMUTE, - unmute.channel_name, - unmute.player_name, - )?, - ClientOpcodeMessage::CMSG_CHANNEL_KICK(kick) => run_targeted_op( - store, - player, - channel_op::KICK, - kick.channel_name, - kick.player_name, - )?, - ClientOpcodeMessage::CMSG_CHANNEL_BAN(ban) => run_targeted_op( - store, - player, - channel_op::BAN, - ban.channel_name, - ban.player_name, - )?, - ClientOpcodeMessage::CMSG_CHANNEL_UNBAN(unban) => run_targeted_op( - store, - player, - channel_op::UNBAN, - unban.channel_name, - unban.player_name, - )?, - ClientOpcodeMessage::CMSG_CHANNEL_INVITE(invite) => run_targeted_op( - store, - player, - channel_op::INVITE, - invite.channel_name, - invite.player_name, - )?, - ClientOpcodeMessage::CMSG_CHANNEL_ANNOUNCEMENTS(announcements) => run_op( - store, - player, - channel_op::ANNOUNCEMENTS, - announcements.channel_name, - String::new(), - )?, - ClientOpcodeMessage::CMSG_CHANNEL_MODERATE(moderate) => run_op( - store, - player, - channel_op::MODERATE, - moderate.channel_name, - String::new(), - )?, - ClientOpcodeMessage::CMSG_CHANNEL_LIST(list) => { - read_roster(store, player, list.channel_name, |roster| { - Outbound::One(ServerOpcodeMessage::SMSG_CHANNEL_LIST(Box::new( - codec::channel::build_channel_list(roster.name, roster.flags, &roster.members), - ))) - })? - } - ClientOpcodeMessage::CMSG_CHANNEL_OWNER(owner) => { - let typed = owner.channel_name.clone(); - read_roster(store, player, owner.channel_name, |roster| { - let owner_name = match (roster.owner_guid, roster.owner_name) { - (0, _) => NO_OWNER.to_string(), - (_, name) if name.is_empty() => OWNER_NOT_FOUND.to_string(), - (_, name) => name, - }; - notice_outbound(codec::channel::ChannelNoticeView { - notice: notice::CHANNEL_OWNER, - channel_name: typed, - text: owner_name, - ..Default::default() - }) - })? - } - other => return Ok(ChannelActionOutcome::PassThrough(other)), - }; - Ok(ChannelActionOutcome::Handled { outbound }) +/// Handle Chat Channel requests and map Module outcomes to client notices. +pub(crate) struct Channel; + +impl ProtocolFamily for Channel { + fn handle( + store: &St, + session: &mut ProtocolSession, + request: ProtocolRequest, + ) -> Result { + let player = &*session; + let msg = request.message()?; + let outbound = match msg { + // An empty name is dropped (cm:ChannelHandler.cpp:66-67, cm:ChannelHandler.cpp:88-89). + ClientOpcodeMessage::CMSG_JOIN_CHANNEL(join) if join.channel_name.is_empty() => { + Vec::new() + } + ClientOpcodeMessage::CMSG_JOIN_CHANNEL(join) => run_op( + store, + player, + channel_op::JOIN, + join.channel_name, + join.channel_password, + )?, + ClientOpcodeMessage::CMSG_LEAVE_CHANNEL(leave) if leave.channel_name.is_empty() => { + Vec::new() + } + ClientOpcodeMessage::CMSG_LEAVE_CHANNEL(leave) => run_op( + store, + player, + channel_op::LEAVE, + leave.channel_name, + String::new(), + )?, + ClientOpcodeMessage::CMSG_CHANNEL_PASSWORD(password) => run_op( + store, + player, + channel_op::PASSWORD, + password.channel_name, + password.channel_password, + )?, + ClientOpcodeMessage::CMSG_CHANNEL_SET_OWNER(set_owner) => run_targeted_op( + store, + player, + channel_op::SET_OWNER, + set_owner.channel_name, + set_owner.new_owner, + )?, + ClientOpcodeMessage::CMSG_CHANNEL_MODERATOR(moderator) => run_targeted_op( + store, + player, + channel_op::MODERATOR, + moderator.channel_name, + moderator.player_name, + )?, + ClientOpcodeMessage::CMSG_CHANNEL_UNMODERATOR(unmoderator) => run_targeted_op( + store, + player, + channel_op::UNMODERATOR, + unmoderator.channel_name, + unmoderator.player_name, + )?, + ClientOpcodeMessage::CMSG_CHANNEL_MUTE(mute) => run_targeted_op( + store, + player, + channel_op::MUTE, + mute.channel_name, + mute.player_name, + )?, + ClientOpcodeMessage::CMSG_CHANNEL_UNMUTE(unmute) => run_targeted_op( + store, + player, + channel_op::UNMUTE, + unmute.channel_name, + unmute.player_name, + )?, + ClientOpcodeMessage::CMSG_CHANNEL_KICK(kick) => run_targeted_op( + store, + player, + channel_op::KICK, + kick.channel_name, + kick.player_name, + )?, + ClientOpcodeMessage::CMSG_CHANNEL_BAN(ban) => run_targeted_op( + store, + player, + channel_op::BAN, + ban.channel_name, + ban.player_name, + )?, + ClientOpcodeMessage::CMSG_CHANNEL_UNBAN(unban) => run_targeted_op( + store, + player, + channel_op::UNBAN, + unban.channel_name, + unban.player_name, + )?, + ClientOpcodeMessage::CMSG_CHANNEL_INVITE(invite) => run_targeted_op( + store, + player, + channel_op::INVITE, + invite.channel_name, + invite.player_name, + )?, + ClientOpcodeMessage::CMSG_CHANNEL_ANNOUNCEMENTS(announcements) => run_op( + store, + player, + channel_op::ANNOUNCEMENTS, + announcements.channel_name, + String::new(), + )?, + ClientOpcodeMessage::CMSG_CHANNEL_MODERATE(moderate) => run_op( + store, + player, + channel_op::MODERATE, + moderate.channel_name, + String::new(), + )?, + ClientOpcodeMessage::CMSG_CHANNEL_LIST(list) => { + read_roster(store, player, list.channel_name, |roster| { + Outbound::One(ServerOpcodeMessage::SMSG_CHANNEL_LIST(Box::new( + codec::channel::build_channel_list( + roster.name, + roster.flags, + &roster.members, + ), + ))) + })? + } + ClientOpcodeMessage::CMSG_CHANNEL_OWNER(owner) => { + let typed = owner.channel_name.clone(); + read_roster(store, player, owner.channel_name, |roster| { + let owner_name = match (roster.owner_guid, roster.owner_name) { + (0, _) => NO_OWNER.to_string(), + (_, name) if name.is_empty() => OWNER_NOT_FOUND.to_string(), + (_, name) => name, + }; + notice_outbound(codec::channel::ChannelNoticeView { + notice: notice::CHANNEL_OWNER, + channel_name: typed, + text: owner_name, + ..Default::default() + }) + })? + } + other => return Err(anyhow!("opcode routed to wrong Protocol Family: {other}")), + }; + Ok(ProtocolReply::from(outbound)) + } } /// The actor and Speaker Facts every channel op needs. `None` when the caller has no live entity @@ -243,7 +250,7 @@ pub(crate) fn dispatch_channel_action( /// channel op takes for that case. fn actor_and_speaker( store: &St, - player: ChatActionPlayer, + player: &ProtocolSession, ) -> Result> { let Some(actor) = player.actor() else { return Ok(None); @@ -268,7 +275,7 @@ struct RefusalContext { /// logged at debug and dropped. A Transport Loss ends the World Session. fn submit_channel_op( store: &St, - player: ChatActionPlayer, + player: &ProtocolSession, actor: Actor, op: u8, request: ChannelRequest, @@ -293,7 +300,7 @@ fn submit_channel_op( /// name another Character. fn run_op( store: &St, - player: ChatActionPlayer, + player: &ProtocolSession, op: u8, channel_name: String, password: String, @@ -334,7 +341,7 @@ fn run_op( /// the same way. Any other failed target read ends the World Session. fn run_targeted_op( store: &St, - player: ChatActionPlayer, + player: &ProtocolSession, op: u8, channel_name: String, typed_name: String, @@ -377,7 +384,7 @@ fn run_targeted_op( /// with the name the client typed (cm:Channel.cpp:446-456, cm:Channel.cpp:479-488). fn read_roster( store: &St, - player: ChatActionPlayer, + player: &ProtocolSession, channel_name: String, answer: impl FnOnce(ChannelRoster) -> Outbound, ) -> Result> { @@ -436,7 +443,6 @@ mod tests { CMSG_CHANNEL_LIST, CMSG_CHANNEL_MODERATE, CMSG_CHANNEL_MODERATOR, CMSG_CHANNEL_MUTE, CMSG_CHANNEL_OWNER, CMSG_CHANNEL_PASSWORD, CMSG_CHANNEL_SET_OWNER, CMSG_CHANNEL_UNBAN, CMSG_CHANNEL_UNMODERATOR, CMSG_CHANNEL_UNMUTE, CMSG_JOIN_CHANNEL, CMSG_LEAVE_CHANNEL, - CMSG_PING, }; /// How a Fake's Durable Request or read fails. @@ -572,11 +578,8 @@ mod tests { } } - fn player() -> ChatActionPlayer { - ChatActionPlayer { - account_id: 7, - self_guid: Some(ACTOR), - } + fn player() -> ProtocolSession { + ProtocolSession::in_world(7, ACTOR) } fn join(name: &str, password: &str) -> ClientOpcodeMessage { @@ -679,11 +682,8 @@ mod tests { })) } - fn handled(outcome: ChannelActionOutcome) -> Vec { - match outcome { - ChannelActionOutcome::Handled { outbound } => outbound, - ChannelActionOutcome::PassThrough(msg) => panic!("expected Handled, got {msg}"), - } + fn handled(outcome: ProtocolReply) -> Vec { + outcome.outbound } /// The single raw packet `outbound` holds, as `(opcode, body)`. @@ -708,8 +708,9 @@ mod tests { #[test] fn a_join_conveys_the_typed_name_the_password_and_the_speaker() { let store = store(None); - let outbound = - handled(dispatch_channel_action(&store, player(), join("Raiders", "hunter2")).unwrap()); + let outbound = handled( + Channel::handle(&store, &mut player(), join("Raiders", "hunter2").into()).unwrap(), + ); assert!(outbound.is_empty(), "YOU_JOINED returns on the Relay"); assert_eq!( store.ops.lock().unwrap().as_slice(), @@ -732,13 +733,13 @@ mod tests { #[test] fn a_leave_and_a_password_change_run_their_ops() { let store = store(None); - handled(dispatch_channel_action(&store, player(), leave("Raiders")).unwrap()); + handled(Channel::handle(&store, &mut player(), leave("Raiders").into()).unwrap()); let password = ClientOpcodeMessage::CMSG_CHANNEL_PASSWORD(Box::new(CMSG_CHANNEL_PASSWORD { channel_name: "Raiders".to_string(), channel_password: "sesame".to_string(), })); - handled(dispatch_channel_action(&store, player(), password).unwrap()); + handled(Channel::handle(&store, &mut player(), password.into()).unwrap()); let ops = store.ops.lock().unwrap(); assert_eq!(ops.len(), 2); assert_eq!((ops[0].1, ops[0].2.channel_name.as_str()), (1, "Raiders")); @@ -753,7 +754,7 @@ mod tests { fn an_empty_name_does_nothing() { let store = store(None); for msg in [join("", ""), leave("")] { - let outbound = handled(dispatch_channel_action(&store, player(), msg).unwrap()); + let outbound = handled(Channel::handle(&store, &mut player(), msg.into()).unwrap()); assert!(outbound.is_empty()); } assert!(store.ops.lock().unwrap().is_empty()); @@ -764,7 +765,7 @@ mod tests { fn leaving_a_channel_you_are_not_on_answers_not_member_with_the_typed_name() { let store = store(Some(Ok(ChannelOutcome::Refused(ChannelRefusal::NotMember)))); let outbound = - handled(dispatch_channel_action(&store, player(), leave("rAiders")).unwrap()); + handled(Channel::handle(&store, &mut player(), leave("rAiders").into()).unwrap()); assert_eq!( only_raw(outbound), ( @@ -783,7 +784,7 @@ mod tests { ] { let store = store(Some(Ok(ChannelOutcome::Refused(refusal)))); let outbound = - handled(dispatch_channel_action(&store, player(), join("1x", "")).unwrap()); + handled(Channel::handle(&store, &mut player(), join("1x", "").into()).unwrap()); assert_eq!( only_raw(outbound), (0x0099, vec![code, b'1', b'x', 0]), @@ -798,7 +799,8 @@ mod tests { let store = store(Some(Ok(ChannelOutcome::Refused( ChannelRefusal::PlayerAlreadyMember, )))); - let outbound = handled(dispatch_channel_action(&store, player(), join("Rx", "")).unwrap()); + let outbound = + handled(Channel::handle(&store, &mut player(), join("Rx", "").into()).unwrap()); assert_eq!( only_raw(outbound), (0x0099, vec![0x17, b'R', b'x', 0, 42, 0, 0, 0, 0, 0, 0, 0]) @@ -815,14 +817,14 @@ mod tests { channel_name: "Rx".to_string(), channel_password: "x".to_string(), })); - let outbound = handled(dispatch_channel_action(&store, player(), password).unwrap()); + let outbound = handled(Channel::handle(&store, &mut player(), password.into()).unwrap()); assert_eq!(only_raw(outbound), (0x0099, vec![0x06, b'R', b'x', 0])); } #[test] fn a_lost_reducer_transport_ends_the_session() { let store = store(Some(Err(Failure::TransportLost))); - let error = dispatch_channel_action(&store, player(), join("Rx", "")) + let error = Channel::handle(&store, &mut player(), join("Rx", "").into()) .err() .expect("transport loss is fatal"); assert_eq!(classify(&error), DurableFailure::TransportLoss); @@ -831,14 +833,16 @@ mod tests { #[test] fn an_unrecognised_refusal_drops_the_op_and_keeps_the_session() { let store = store(Some(Err(Failure::Refused))); - let outbound = handled(dispatch_channel_action(&store, player(), join("Rx", "")).unwrap()); + let outbound = + handled(Channel::handle(&store, &mut player(), join("Rx", "").into()).unwrap()); assert!(outbound.is_empty()); } #[test] fn an_actor_without_a_live_entity_sends_nothing() { let store = InMemoryChannelActions::default(); - let outbound = handled(dispatch_channel_action(&store, player(), join("Rx", "")).unwrap()); + let outbound = + handled(Channel::handle(&store, &mut player(), join("Rx", "").into()).unwrap()); assert!(outbound.is_empty()); assert!(store.ops.lock().unwrap().is_empty()); } @@ -851,7 +855,8 @@ mod tests { roster: Some(roster(&[(9, 0x03), (ACTOR, 0x08)], 9, "Thrall")), ..store(None) }; - let outbound = handled(dispatch_channel_action(&store, player(), list("raiders")).unwrap()); + let outbound = + handled(Channel::handle(&store, &mut player(), list("raiders").into()).unwrap()); assert_eq!( store.roster_reads.lock().unwrap().as_slice(), &[(67, "raiders".to_string())] @@ -879,14 +884,15 @@ mod tests { roster: Some(roster(&[(9, 0x03)], 9, "Thrall")), ..store(None) }; - let outbound = handled(dispatch_channel_action(&store, player(), msg).unwrap()); + let outbound = handled(Channel::handle(&store, &mut player(), msg.into()).unwrap()); assert_eq!( only_raw(outbound), (0x0099, [&[0x05][..], b"Raiders\0"].concat()) ); } let store = store(None); - let outbound = handled(dispatch_channel_action(&store, player(), list("Nowhere")).unwrap()); + let outbound = + handled(Channel::handle(&store, &mut player(), list("Nowhere").into()).unwrap()); assert_eq!( only_raw(outbound), (0x0099, [&[0x05][..], b"Nowhere\0"].concat()), @@ -901,7 +907,7 @@ mod tests { ..store(None) }; let outbound = - handled(dispatch_channel_action(&store, player(), owner("Raiders")).unwrap()); + handled(Channel::handle(&store, &mut player(), owner("Raiders").into()).unwrap()); assert_eq!( only_raw(outbound), (0x0099, [&[0x0B][..], b"Raiders\0Thrall\0"].concat()) @@ -916,7 +922,7 @@ mod tests { ..store(None) }; let outbound = - handled(dispatch_channel_action(&store, player(), owner("Trade - City")).unwrap()); + handled(Channel::handle(&store, &mut player(), owner("Trade - City").into()).unwrap()); assert_eq!( only_raw(outbound), (0x0099, [&[0x0B][..], b"Trade - City\0Nobody\0"].concat()) @@ -929,22 +935,7 @@ mod tests { roster_failure: true, ..store(None) }; - assert!(dispatch_channel_action(&store, player(), list("Raiders")).is_err()); - } - - #[test] - fn another_opcode_passes_through() { - let store = store(None); - let outcome = dispatch_channel_action( - &store, - player(), - ClientOpcodeMessage::CMSG_PING(CMSG_PING::default()), - ) - .unwrap(); - assert!(matches!( - outcome, - ChannelActionOutcome::PassThrough(ClientOpcodeMessage::CMSG_PING(_)) - )); + assert!(Channel::handle(&store, &mut player(), list("Raiders").into()).is_err()); } const TARGET: u64 = 9; @@ -980,7 +971,7 @@ mod tests { ]; for (msg, op) in cases { let store = resolved(true); - let outbound = handled(dispatch_channel_action(&store, player(), msg).unwrap()); + let outbound = handled(Channel::handle(&store, &mut player(), msg.into()).unwrap()); assert!(outbound.is_empty(), "op {op}"); let ops = store.ops.lock().unwrap(); assert_eq!(ops.len(), 1, "op {op}"); @@ -1005,8 +996,8 @@ mod tests { #[test] fn announcements_and_moderate_carry_no_target() { let store = store(None); - handled(dispatch_channel_action(&store, player(), announcements("Raiders")).unwrap()); - handled(dispatch_channel_action(&store, player(), moderate("Raiders")).unwrap()); + handled(Channel::handle(&store, &mut player(), announcements("Raiders").into()).unwrap()); + handled(Channel::handle(&store, &mut player(), moderate("Raiders").into()).unwrap()); let ops = store.ops.lock().unwrap(); assert_eq!( ops.iter() @@ -1025,8 +1016,9 @@ mod tests { outcome: Some(Ok(ChannelOutcome::Refused(ChannelRefusal::PlayerNotFound))), ..store(None) // `online` defaults to `None`. }; - let outbound = - handled(dispatch_channel_action(&store, player(), kick("Raiders", "Ghost")).unwrap()); + let outbound = handled( + Channel::handle(&store, &mut player(), kick("Raiders", "Ghost").into()).unwrap(), + ); assert_eq!( only_raw(outbound), (0x0099, [&[0x09][..], b"Raiders\0Ghost\0"].concat()) @@ -1044,7 +1036,7 @@ mod tests { lookup_failure: Some(Failure::PresenceUnknown), ..store(None) }; - handled(dispatch_channel_action(&store, player(), kick("Raiders", "Ghost")).unwrap()); + handled(Channel::handle(&store, &mut player(), kick("Raiders", "Ghost").into()).unwrap()); let ops = store.ops.lock().unwrap(); assert_eq!(ops.len(), 1); assert_eq!(ops[0].2.target_guid, 0); @@ -1058,7 +1050,7 @@ mod tests { lookup_failure: Some(Failure::TransportLost), ..store(None) }; - let error = dispatch_channel_action(&store, player(), kick("Raiders", "Ghost")) + let error = Channel::handle(&store, &mut player(), kick("Raiders", "Ghost").into()) .err() .expect("a lost read is fatal"); assert_eq!(classify(&error), DurableFailure::TransportLoss); @@ -1074,7 +1066,7 @@ mod tests { outcome: Some(Ok(ChannelOutcome::Refused(ChannelRefusal::PlayerNotFound))), ..store(None) }; - handled(dispatch_channel_action(&store, player(), invite("Raiders", "Ghost")).unwrap()); + handled(Channel::handle(&store, &mut player(), invite("Raiders", "Ghost").into()).unwrap()); let ops = store.ops.lock().unwrap(); assert_eq!(ops.len(), 1); assert_eq!(ops[0].2.target_guid, 0); @@ -1093,7 +1085,9 @@ mod tests { ignore_failure: Some(Failure::TransportLost), ..resolved(false) }; - assert!(dispatch_channel_action(&store, player(), invite("Raiders", "Thrall")).is_err()); + assert!( + Channel::handle(&store, &mut player(), invite("Raiders", "Thrall").into()).is_err() + ); assert!(store.ops.lock().unwrap().is_empty()); } @@ -1107,7 +1101,7 @@ mod tests { ..resolved(false) }; let outbound = handled( - dispatch_channel_action(&store, player(), invite("Raiders", "Thrall")).unwrap(), + Channel::handle(&store, &mut player(), invite("Raiders", "Thrall").into()).unwrap(), ); assert_eq!( only_raw(outbound), @@ -1125,8 +1119,9 @@ mod tests { outcome: Some(Ok(ChannelOutcome::Refused(ChannelRefusal::PlayerNotBanned))), ..resolved(false) }; - let outbound = - handled(dispatch_channel_action(&store, player(), unban("Raiders", "thrall")).unwrap()); + let outbound = handled( + Channel::handle(&store, &mut player(), unban("Raiders", "thrall").into()).unwrap(), + ); assert_eq!( only_raw(outbound), (0x0099, [&[0x16][..], b"Raiders\0", b"Thrall\0"].concat()) diff --git a/gateway/src/world/handlers/char.rs b/gateway/src/world/handlers/char.rs index 5b3a7d704..3b932a88f 100644 --- a/gateway/src/world/handlers/char.rs +++ b/gateway/src/world/handlers/char.rs @@ -1,7 +1,6 @@ //! Character selection, world entry, and connection-level ping and Realm Clock replies. use super::super::*; -use super::vendor::build_buyback_view_replay; /// Character select, and the per-Character reads world entry builds the self CREATE from. pub(crate) trait CharacterStore: Send + Sync { @@ -69,530 +68,146 @@ pub(crate) trait CharacterStore: Send + Sync { fn player_actions(&self, player_guid: u64) -> Result>; } -/// Tell a client whose world-port cannot complete that it is off, so its loading screen ends with an -/// error instead of never ending. Best-effort and infallible by design: it runs on -/// a path that is already failing, and every one of its own failure modes (an unmapped destination -/// map, a dead socket) is strictly less bad than the hang it replaces, so none of them may mask the -/// original error the caller is about to propagate. -/// -/// The destination map comes from the character's own durable row — the same row -/// `world::teleport_player` wrote the destination into before it despawned the entity, i.e. the map -/// the client is loading right now. `TransferAbortReason::NotFound` is the closest vanilla reason to -/// "the shard that owns this instance would not take you"; the operator-facing detail is the log line. -fn abort_pending_transfer( - tx: &SessionTx, - store: &St, - character_guid: u64, - cause: &anyhow::Error, -) { - let dest_map = store - .character_destination(character_guid) - .map(|p| p.dest_map_id); - log::error!( - "world: world-port for guid {character_guid} cannot complete ({cause:#}) — aborting the \ - client's transfer to map {dest_map:?}. The character is unharmed: the escrow is idempotent \ - and the next login re-drives it." - ); - let Some(map_id) = dest_map else { - log::warn!( - "world: no durable destination for guid {character_guid} — the client gets no \ - SMSG_TRANSFER_ABORTED and will need to reconnect" - ); - return; - }; - use wow_world_messages::vanilla::TransferAbortReason; - match codec::build_transfer_aborted(map_id, TransferAbortReason::NotFound) { - Ok(msg) => { - let _ = send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_TRANSFER_ABORTED(msg)), - ); - } - Err(e) => { - log::warn!("world: could not build SMSG_TRANSFER_ABORTED for map {map_id}: {e:#}") - } - } -} - -/// Rebuild the Character's entity and subscriptions, then send its entry batch. -/// The bound Store retains Account ownership across a map change. Entry after a map change omits -/// `SMSG_LOGIN_VERIFY_WORLD`, which would tell the client to load the map again. -fn enter_world( - tx: &SessionTx, - store: &dyn WorldStore, - conn: &mut WorldConn, - character: Actor, - entry: codec::WorldEntry, -) -> Result<()> { - conn.state = WorldState::CharSelect; - - let character_guid = character.guid(); - let mut entity = store.player_login(conn.account_id, character, entry)?; - // Character sheet (UNIT_FIELD_RESISTANCES[0]): override the BASE armor `player_login` set with - // the EFFECTIVE armor (base + worn gear) so the Armor readout is correct at relog. Armor auras - // self-correct via the on_aura relay; combat mitigation is unchanged (the module still folds its - // own effective_armor on demand — this only feeds the display descriptor). - entity.effective_armor = store.effective_armor(character_guid); - entity.magic_resistances = store.effective_magic_resistances(character_guid); - (entity.guild_id, entity.guild_rank) = super::guild_projection(store, character_guid); - log::info!( - "world: entering world guid={character_guid} -> entity at map {} ({:.1},{:.1},{:.1}); subscribing + sending login sequence + self-spawn", - entity.map_id, entity.x, entity.y, entity.z - ); - // Items: the character's owned items. Each becomes an item CREATE_OBJECT sent - // BEFORE the player self-spawn (so the inventory-slot guid resolves to an object the - // client already has), and the (slot, guid) pairs seed the player's PLAYER_FIELD_INV_SLOT - // descriptors. Empty for a character that owns nothing — login is otherwise unchanged. - let items = store.player_items(character_guid).unwrap_or_default(); - let learned = store - .player_learned_spells(character_guid) - .unwrap_or_default(); - let skills = store.player_skills(character_guid).unwrap_or_default(); - let reputations = store.player_reputations(character_guid).unwrap_or_default(); - // Imported action-bar rows (empty pre-import — the login codec falls back - // to synthesizing the bar from `learned` in that case, byte-identical to before). - let player_actions = store.player_actions(character_guid).unwrap_or_default(); - let mut batch = - codec::login_sequence_messages(&entity, &learned, &reputations, &player_actions, entry)?; - for item in &items { - batch.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( - codec::build_item_create_object(item), - ))); - } - let quests_enabled = crate::config::quest_log_fields_enabled(); - let quests = if quests_enabled { - store.player_quest_log(character_guid)? - } else { - Vec::new() - }; - batch.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( - codec::build_self_create_object(&entity, &items, &skills, &quests)?, - ))); - // The environment, closing the same contiguous batch: the client must land with the right sky - // rather than the last one it rendered, so this is Instant and is sent on EVERY world entry — - // fresh login, reconnect and cross-map world-port alike. The zone is the Module's answer, - // resolved from terrain when it built the live entity. A zone with no weather is fine weather - // and still sends its packet: an arriving client that is told nothing keeps whatever it had. - batch.push(zone_weather_message( - store, - entity.zone_id, - WeatherChangeType::Instant, - )); - send(tx, Outbound::Batch(batch))?; - // A typed batch cannot hold a raw update, so Random Property enchant ids follow the batch. - for (opcode, body) in items.iter().filter_map(codec::build_random_property_values) { - send(tx, Outbound::Raw { opcode, body })?; - } - for item in &items { - if let Some((bag_slot, position)) = codec::bag_content_parts(item.slot) { - if let Some(bag) = items.iter().find(|bag| bag.slot == bag_slot) { - let (opcode, body) = - codec::build_container_slot_values(bag.guid, position, item.guid); - send(tx, Outbound::Raw { opcode, body })?; - } - } - } - // Subscribe AFTER the self-spawn batch is on the wire — so the AOI initial-apply creates for - // entities ALREADY in view (notably a questgiver you spawn right next to) arrive AFTER the - // client is in-world. Spawning ON a questgiver otherwise left it targetable but with no '!' / - // right-click: its CREATE raced the login sequence and the client registered it as a plain - // unit, never polling its quest status. The streaming path (a peer entering view later) was - // always fine — this makes the login case match it. (Missing a peer that - // inserts in the µs window between this send and the subscribe is negligible.) The dedup set - // is seeded with self_guid so the player's own row (delivered on initial apply) is skipped. - let subs = - store.subscribe_player_events(conn.account_id, character_guid, &entity, tx.clone())?; - if quests_enabled { - // A quest can change before viewer registration. Read again on the writer, where live - // quest Relays also read, so reconciliation cannot overwrite newer queued progress. - let store = conn.store.current(); - let close = tx.clone(); - send( - tx, - Outbound::Job(Box::new(move || { - match store.player_quest_log(character_guid) { - Ok(current) if current != quests => { - let mask = codec::update_mask::full_quest_log_mask(¤t); - let (opcode, body) = codec::build_values_update_raw(character_guid, &mask); - vec![Outbound::Raw { opcode, body }] - } - Ok(_) => Vec::new(), - Err(error) => { - log::error!("world: quest log reconciliation failed for guid {character_guid}: {error:#}"); - close.close(); - Vec::new() - } - } - })), - )?; - } - // Replay the buyback-tab view (the ring persists across sessions; without this the tab - // is empty until the first in-session sell). - for message in build_buyback_view_replay(store, character_guid) { - send(tx, message)?; - } - // Replay private System Messages emitted inside `player_login` (a Package `on_login` hook): - // their insert relayed before this session was registered, so nobody was addressable and the - // rows are still parked in the shard cache. (A message landing in the µs window between the - // registration above and this replay can arrive twice; a duplicate line beats a swallowed one.) - for message in store.pending_system_messages(character_guid) { - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_MESSAGECHAT(Box::new( - codec::build_gm_system_message(message), - ))), - )?; - } - // The guild step needs the registered viewer: its SIGNED_ON reaches the other members, and - // its Guild Projection re-send covers a membership change that landed after the CREATE read. - // A sign-on is owed a sign-off from here on, even if this entry or a later world-port fails. - if entry == codec::WorldEntry::FreshLogin && super::guild_sign_on(store, character_guid) { - conn.guild_signed_on = Some(character_guid); - } - let created_projection = (entity.guild_id, entity.guild_rank); - for message in super::guild_world_entry(store, character_guid, entry, created_projection) { - send(tx, message)?; - } - // Put realm-core's party roster onto the shard this character just entered - // and re-render the party frame. THIS is what carries a party across a shard boundary now that - // the old interim blob mirror is gone — and it runs on every world entry, so a party formed while the - // player was on the loading screen lands too. A no-op on a single-database gateway. - // - // Failures are logged, not propagated: a party frame that renders late is a cosmetic defect, - // and failing the login over it would be strictly worse for the player. - if let Err(e) = party::on_world_entry(tx, store, character) { - log::warn!("world: party sync at world entry failed for guid {character_guid}: {e:#}"); - } - // A Fee Hold that an earlier session or a Transfer left behind is finished here, on the Home - // Shard that holds the Character now. The purse change reaches the client through its entity. - crate::world::guild_fee::redrive(store, character_guid); - // A Guild Charter left in the bags after its Petition closed is destroyed once the Fee Hold is - // finished, so it no longer blocks the next Charter purchase. - let charters: Vec = items - .iter() - .filter(|item| item.entry == lyracore_shared::guild::GUILD_CHARTER_ENTRY) - .map(|item| item.guid) - .collect(); - super::destroy_inert_charters(store, character_guid, &charters); - // A Reward Letter or a send that an earlier session left as Escrow on this Home Shard is - // delivered here, so a Gateway restart after a turn-in loses no letter. - crate::world::mail::redrive(store, character_guid); - // A Member Stats tick can run between the registration above and that party frame, for a party - // the client does not know yet. Forget it behind the frame so the next tick sends every field. - if let Some(record) = subs.member_stats_record().cloned() { - let forget = move || { - record.forget_all(); - Vec::new() - }; - send(tx, Outbound::Job(Box::new(forget)))?; - } - // Enter the world: CharSelect → InWorld (a reused connection has no open loot/attack — a world-port - // re-entry likewise starts clean, since whatever the player was attacking/looting on the old map is - // meaningless on the new one). - conn.state = WorldState::InWorld(InWorld { - self_guid: character_guid, - subs, - attacking_target: None, - open_loot: OpenLootState::default(), - ranged_repeat: false, - }); - // If the player carries ammo (a Projectile item, class 6), tell the client it's loaded - // (PLAYER_AMMO_ID) so Auto Shot is usable. Deliberate simplification: login-time only — no - // live re-send on pickup/runout (the next login re-syncs; the shot itself gates on the bag - // having ammo). - if let Some(ammo_entry) = items.iter().map(|i| i.entry).find(|&e| { - store - .item_template(e) - .ok() - .flatten() - .is_some_and(|t| t.class == 6) - }) { - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( - codec::build_player_ammo_id_values(character_guid, ammo_entry), - ))), - )?; - } - // Talent-pane points correction: the CREATE's PLAYER_CHARACTER_POINTS1 counts points EARNED - // (level−9; codec/entity.rs), so a character with SPENT points over-reports until a pick. - // Push the true remaining once, post-CREATE (same partial-VALUES mechanism as the live pick). - // Skipped for spent == 0 → a fresh character's login stays byte-identical. - if store.talent_points_spent(character_guid) > 0 { - let (_, _, remaining) = store.talent_pane_sync(character_guid, 0); - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( - codec::build_talent_points_values(character_guid, remaining), - ))), - )?; - } - // Spell-modifier mirror: tell the client which of its spells the learned passives modify - // (Improved Fireball's cast-time cut etc.) so ITS cast bars/tooltips match the server's folded - // timings. One packet per (op, mask-bit) total, the mangos convention; none learned → nothing. - for m in codec::build_spell_modifier_msgs(&store.spell_modifiers(character_guid)) { - send(tx, Outbound::One(m))?; - } - Ok(()) -} - -/// Char / world-entry family (§4/§5): character enum + creation (character-select), then enter world -/// (`CMSG_PLAYER_LOGIN`) + graceful logout — the session-lifecycle opcodes. -pub(crate) fn handle_char< - St: CharacterStore + GuildActionStore + SessionStore + TransferStore + ?Sized, ->( - tx: &SessionTx, - store: &St, - conn: &mut WorldConn, - msg: ClientOpcodeMessage, -) -> Result> { - match msg { - ClientOpcodeMessage::CMSG_PING(ping) => { - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_PONG( - wow_world_messages::vanilla::SMSG_PONG { - sequence_id: ping.sequence_id, - }, - )), - )?; - } - ClientOpcodeMessage::CMSG_QUERY_TIME => { - let seconds = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH)? - .as_secs(); - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_QUERY_TIME_RESPONSE( +pub(crate) struct Character; + +impl ProtocolFamily + for Character +{ + #[allow(clippy::too_many_lines)] // One match arm per Character opcode. + fn handle( + store: &St, + session: &mut ProtocolSession, + request: ProtocolRequest, + ) -> Result { + let mut reply = ProtocolReply::default(); + let response = match request.message()? { + ClientOpcodeMessage::CMSG_PING(ping) => Some(ServerOpcodeMessage::SMSG_PONG( + wow_world_messages::vanilla::SMSG_PONG { + sequence_id: ping.sequence_id, + }, + )), + ClientOpcodeMessage::CMSG_QUERY_TIME => { + let seconds = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs(); + Some(ServerOpcodeMessage::SMSG_QUERY_TIME_RESPONSE( wow_world_messages::vanilla::SMSG_QUERY_TIME_RESPONSE { time: u32::try_from(seconds)?, }, - )), - )?; - } - // Character-select screen. - ClientOpcodeMessage::CMSG_CHAR_ENUM => { - let characters = store.characters(conn.account_id)?; - let enum_msg = codec::build_char_enum(&characters)?; - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_CHAR_ENUM(Box::new(enum_msg))), - )?; - } - // Character creation. Create the row, reply SMSG_CHAR_CREATE; on success the client - // re-sends CMSG_CHAR_ENUM and the new character appears. A Refusal is NOT session-fatal — - // report it as a result. A Transport Loss ends the session. - ClientOpcodeMessage::CMSG_CHAR_CREATE(c) => { - let appearance = codec::Appearance { - skin: c.skin_color, - face: c.face, - hair_style: c.hair_style, - hair_color: c.hair_color, - facial_hair: c.facial_hair, - }; - let outcome = store.create_character( - conn.account_id, - c.name.as_str(), - c.race.as_int(), - c.class.as_int(), - c.gender.as_int(), - appearance, - )?; - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_CHAR_CREATE( - codec::build_char_create_response(outcome), - )), - )?; - } - // Character deletion. Per the wire doc SMSG_CHAR_DELETE alone updates the - // character-select screen — no re-sent CMSG_CHAR_ENUM needed. Ownership is enforced module- - // side; a Refusal is NOT session-fatal, same treatment as CMSG_CHAR_CREATE above. Guid 0 - // names no Character and fails like a Refusal. - // - // A Guild Leader is not deleted: 1.12 answers CHAR_DELETE_FAILED, the same 0x3A mangos sends - // as FAILED_GUILD_LEADER (`cm:CharacterHandler.cpp:540-546`). Realm-core holds the Guild and - // the Home Shard cannot read it, so the Gateway asks first. An unreadable answer deletes - // nothing either. - ClientOpcodeMessage::CMSG_CHAR_DELETE(d) => { - let character_guid = d.guid.guid(); - let outcome = match ( - Actor::new(character_guid), - super::leads_a_guild(store, character_guid), - ) { - (None, _) => codec::CharDeleteOutcome::Failed, - (Some(character), Ok(false)) => { - store.delete_character(conn.account_id, character)? - } - (Some(_), Ok(true)) => { - log::info!("world: Guild Leader {character_guid} is not deleted"); - codec::CharDeleteOutcome::Failed - } - (Some(_), Err(error)) => { - log::warn!( - "world: Guild Leader check for {character_guid} failed, not deleted: \ - {error:#}" - ); - codec::CharDeleteOutcome::Failed - } - }; - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_CHAR_DELETE( - codec::build_char_delete_response(outcome), - )), - )?; - } - // Enter world -> register peer subscriptions, then login sequence + self - // CREATE_OBJECT2 as one contiguous batch (so an async peer event can't splice into it). - ClientOpcodeMessage::CMSG_PLAYER_LOGIN(p) => { - let character_guid = p.guid.guid(); - if conn.session_claim.is_some() { - return Err(anyhow::anyhow!("ACCOUNT_IN_USE")); + )) } - let Some(character) = Actor::new(character_guid) else { - return Err(anyhow::anyhow!("CMSG_PLAYER_LOGIN names no Character")); - }; - let token = store.claim_session(conn.account_id, character_guid)?; - conn.session_claim = Some(token); - if let Some(bound) = store.bind_session(token)? { - conn.store.pin(bound); + ClientOpcodeMessage::CMSG_CHAR_ENUM => { + let characters = store.characters(session.account_id)?; + Some(ServerOpcodeMessage::SMSG_CHAR_ENUM(Box::new( + codec::build_char_enum(&characters)?, + ))) } - // Multi-shard routing: pin this session to the shard that owns the character's - // location BEFORE `player_login` runs, so the login reducer and viewer registration - // land on the home shard — and so does every message after this one - // (`run_world_session` reads `conn.store` per frame). - // A single-entry shard map never pins anything → `enter_world` runs on `store`. - conn.route_home(character_guid)?; - let home = conn.store.current(); - enter_world(tx, &*home, conn, character, codec::WorldEntry::FreshLogin)?; - } - // Cross-map teleport: the client's ack that it finished loading the map named - // by our `SMSG_NEW_WORLD` (sent from the `on_teleport` relay when `teleport_player` despawned - // the live entity for a cross-map hop). Per gtker's own doc comment on this opcode — "The server - // should reply with what it normally does to log players into the world" — so this reuses the - // EXACT same `enter_world` path CMSG_PLAYER_LOGIN uses: rebuild the entity (`player_login` is - // idempotent here — the ghost-relog branch no-ops because the entity is ALREADY gone), tear down - // the OLD map's subscriptions and register fresh ones at the new map/position (a brand new - // `created` dedup set — the full AOI reset a cross-map re-entry requires, the same "initial-subscribe" - // precedent already established), and re-send the (now new-map) login sequence + self CREATE_OBJECT — - // minus SMSG_LOGIN_VERIFY_WORLD (`WorldEntry::WorldPort`): a verify-world resend would command a - // second load of the map the ack says is already loaded. - // A spurious/late ack while not mid-transfer (e.g. a double-send) is a no-op — CharSelect has no - // `self_guid` to re-enter with, so it's silently accepted-and-ignored like every other unsolicited - // client ack in this dispatch. The bound Store retains the same Account ownership. - ClientOpcodeMessage::MSG_MOVE_WORLDPORT_ACK => { - let resume = match &conn.state { - WorldState::InWorld(iw) => Actor::new(iw.self_guid), - WorldState::CharSelect => None, - }; - if let Some(character) = resume { - let character_guid = character.guid(); - // Gate on a REAL pending transfer: cross-map teleport - // despawns the entity until this ack; a live entity means no transfer is in - // flight and the ack is spurious — ignore it instead of re-entering the world. - // `store` is ALREADY the home-shard handle — the read loop routes every frame - // through `conn.store` — so this reads the cache the entity actually lives in. - if store.entity_in_world(character_guid) { - log::debug!("world: spurious WORLDPORT_ACK ignored (guid {character_guid} still in world)"); - } else { - // Stop source-shard owner dispatch before `route_home` can drive a transfer. - // `finish_transfer` cascade-deletes quest and item rows, and their shared - // callbacks must find no source viewer to enqueue for. A source delta the pump - // applies only after destination registration is dropped by the shard-scoped - // owner lookup instead. Keep `InWorld` intact: - // if routing fails, the existing abort path terminates the socket and - // `leave_world` retains its logout/error policy. The guard's later Drop is - // idempotent, and destination entry retains the same Character and Account ownership. - if let WorldState::InWorld(iw) = &mut conn.state { - iw.subs.unregister_viewer(); + ClientOpcodeMessage::CMSG_CHAR_CREATE(character) => { + let appearance = codec::Appearance { + skin: character.skin_color, + face: character.face, + hair_style: character.hair_style, + hair_color: character.hair_color, + facial_hair: character.facial_hair, + }; + let outcome = store.create_character( + session.account_id, + character.name.as_str(), + character.race.as_int(), + character.class.as_int(), + character.gender.as_int(), + appearance, + )?; + Some(ServerOpcodeMessage::SMSG_CHAR_CREATE( + codec::build_char_create_response(outcome), + )) + } + ClientOpcodeMessage::CMSG_CHAR_DELETE(request) => { + let character_guid = request.guid.guid(); + // Realm-core holds Guild leadership. An unreadable answer must prevent deletion. + let outcome = match ( + Actor::new(character_guid), + super::leads_a_guild(store, character_guid), + ) { + (None, _) => codec::CharDeleteOutcome::Failed, + (Some(character), Ok(false)) => { + store.delete_character(session.account_id, character)? } - // A world-port changes the map, which can change the owning shard — - // re-resolve before re-entering, exactly as a fresh login does. This is also - // where the escrowed cross-database transfer actually RUNS. - // - // FAIL LOUDLY, NEVER HANG. The client is on a loading screen it - // entered because we sent it `SMSG_TRANSFER_PENDING`, and the only thing that - // ends that screen is us finishing the world entry. Propagating the error here - // closes the socket mid-load, which the 1.12 client renders as an infinite - // loading bar — the player is stranded with no message and no recourse, which - // is strictly worse than any error. So: tell the client the transfer is off - // (`SMSG_TRANSFER_ABORTED`), THEN end the session. Nothing durable is lost — - // the escrow is idempotent and the next login re-drives it from the same rows. - // - - let mut ported = conn.route_home(character_guid); - if ported.is_ok() { - let home = conn.store.current(); - ported = - enter_world(tx, &*home, conn, character, codec::WorldEntry::WorldPort); + (Some(_), Ok(true)) => { + log::info!("world: Guild Leader {character_guid} is not deleted"); + codec::CharDeleteOutcome::Failed } - if let Err(e) = ported { - abort_pending_transfer(tx, store, character_guid, &e); - return Err(e); + (Some(_), Err(error)) => { + log::warn!( + "world: Guild Leader check for {character_guid} failed, not deleted: \ + {error:#}" + ); + codec::CharDeleteOutcome::Failed } - } + }; + Some(ServerOpcodeMessage::SMSG_CHAR_DELETE( + codec::build_char_delete_response(outcome), + )) } - } - // Graceful in-game Logout/Exit. Deny if in combat (vanilla behaviour); otherwise - // ack instantly + complete, remove the entity (observers see DESTROY), drop the peer - // subscriptions, and return to character-select with the connection still open. - ClientOpcodeMessage::CMSG_LOGOUT_CANCEL => { - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_LOGOUT_CANCEL_ACK), - )?; - } - ClientOpcodeMessage::CMSG_LOGOUT_REQUEST | ClientOpcodeMessage::CMSG_PLAYER_LOGOUT => { - // In-combat gate: deny logout while combat_until_ms is still in the future. We read the - // wall-clock here (the gateway is a normal Rust process) and compare against the entity - // row's ms-epoch timestamp written by `enter_combat`. 0 = never in combat → allowed. - if let WorldState::InWorld(iw) = &conn.state { - let now_ms = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64; - if store.player_combat_until_ms(iw.self_guid) > now_ms { - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_LOGOUT_RESPONSE( - codec::logout_denied_in_combat(), - )), - )?; - return Ok(None); - } + ClientOpcodeMessage::CMSG_PLAYER_LOGIN(request) => { + let character = Actor::new(request.guid.guid()) + .ok_or_else(|| anyhow!("CMSG_PLAYER_LOGIN names no Character"))?; + reply.after_queue = Some(WorldSessionAction::Login(character)); + None } - send(tx, Outbound::Batch(codec::logout_sequence()))?; - // Leave the world: InWorld → CharSelect drops the relay subs; delete the entity only if - // we still own it — a newer login on this account supersedes us, and deleting then would - // vanish them. A `logout` failure here is session-fatal (propagated), as before. - conn.leave_world()?; - } - // /played: read the durable total + the live session stamp off the - // character row and fold them in `build_played_time` so an online player's total keeps - // ticking without a periodic write. A no-op (no reply) if somehow not in-world or the row - // vanished — never session-fatal for a display-only command. - ClientOpcodeMessage::CMSG_PLAYED_TIME => { - if let WorldState::InWorld(iw) = &conn.state { - if let Some(c) = store.character_by_guid(iw.self_guid)? { - let now_micros = std::time::SystemTime::now() + ClientOpcodeMessage::MSG_MOVE_WORLDPORT_ACK => { + reply.after_queue = Some(WorldSessionAction::WorldPortAck); + None + } + ClientOpcodeMessage::CMSG_LOGOUT_CANCEL => { + Some(ServerOpcodeMessage::SMSG_LOGOUT_CANCEL_ACK) + } + ClientOpcodeMessage::CMSG_LOGOUT_REQUEST | ClientOpcodeMessage::CMSG_PLAYER_LOGOUT => { + if let WorldState::InWorld(world) = &session.state { + let now_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() - .as_micros() as u64; - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_PLAYED_TIME( - codec::build_played_time( - c.played_total_secs, - c.session_start_micros, - now_micros, - ), - )), - )?; + .as_millis() as u64; + if store.player_combat_until_ms(world.self_guid) > now_ms { + return Ok( + vec![Outbound::One(ServerOpcodeMessage::SMSG_LOGOUT_RESPONSE( + codec::logout_denied_in_combat(), + ))] + .into(), + ); + } + } + reply + .outbound + .push(Outbound::Batch(codec::logout_sequence())); + reply.after_queue = Some(WorldSessionAction::Logout); + None + } + ClientOpcodeMessage::CMSG_PLAYED_TIME => { + if let Some(guid) = session.self_guid() { + if let Some(character) = store.character_by_guid(guid)? { + let now_micros = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_micros() as u64; + reply + .outbound + .push(Outbound::One(ServerOpcodeMessage::SMSG_PLAYED_TIME( + codec::build_played_time( + character.played_total_secs, + character.session_start_micros, + now_micros, + ), + ))); + } } + None } + message => return Err(anyhow!("Character received an unowned opcode: {message}")), + }; + if let Some(response) = response { + reply.outbound.push(Outbound::One(response)); } - other => return Ok(Some(other)), + Ok(reply) } - Ok(None) } diff --git a/gateway/src/world/handlers/chat.rs b/gateway/src/world/handlers/chat.rs index 8e4d7f070..0c4354466 100644 --- a/gateway/src/world/handlers/chat.rs +++ b/gateway/src/world/handlers/chat.rs @@ -1,9 +1,6 @@ -//! Realm Chat dispatcher: the `CMSG_MESSAGECHAT` kinds that become Realm Chat Lines, `/afk` and -//! `/dnd`, and `CMSG_CHAT_IGNORED`. The Gateway reads the Speaker Facts on the Home Shard, the -//! Module decides the audience on Realm-core, and the Relay -//! (`stdb::world_view::realm_chat_appeared`) delivers the line. Party, Raid, Raid Leader, Raid -//! Warning, Channel, Guild, Officer and Whisper lines are all Realm Chat Lines. Say, yell and -//! every kind this file does not own pass through. +//! Realm Chat Lines, away status and ignored whispers. The Gateway reads Speaker Facts on the +//! Home Shard; the Module decides the audience on Realm-core. Query owns `CMSG_MESSAGECHAT` +//! and routes its Realm Chat Kinds here. use super::super::*; use crate::stdb::{classify, DurableFailure}; @@ -118,24 +115,6 @@ pub(crate) trait SpeechStore: Send + Sync { fn gm_command(&self, account_name: &str, actor: Actor, text: String) -> Result<()>; } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) struct ChatActionPlayer { - pub(crate) account_id: u64, - pub(crate) self_guid: Option, -} - -impl ChatActionPlayer { - /// The Character this player speaks as. `None` before the World Session has one. - pub(crate) fn actor(self) -> Option { - self.self_guid.and_then(Actor::new) - } -} - -pub(crate) enum ChatActionOutcome { - Handled { outbound: Vec }, - PassThrough(ClientOpcodeMessage), -} - /// cm mangos.sql:4044, sent by cm:ChatHandler.cpp:107-110. const UNKNOWN_LANGUAGE_NOTICE: &str = "You don't know that language"; @@ -156,95 +135,93 @@ fn silent_refusal_chat_kind(chat_type: &CMSG_MESSAGECHAT_ChatType) -> Option } } -/// Consume the `CMSG_MESSAGECHAT` kinds this file owns and `CMSG_CHAT_IGNORED`, and pass -/// everything else on. A new Chat Kind adds one arm here and answers its own Refusals before the -/// shared ones. -pub(crate) fn dispatch_chat_action( - store: &St, - player: ChatActionPlayer, - msg: ClientOpcodeMessage, -) -> Result { - let CMSG_MESSAGECHAT { - chat_type, - language, - message, - } = match msg { - ClientOpcodeMessage::CMSG_MESSAGECHAT(chat) => *chat, - ClientOpcodeMessage::CMSG_CHAT_IGNORED(CMSG_CHAT_IGNORED { guid }) => { - let outbound = chat_ignored(store, player, guid.guid())?; - return Ok(ChatActionOutcome::Handled { outbound }); - } - other => return Ok(ChatActionOutcome::PassThrough(other)), - }; - let outbound = match chat_type { - CMSG_MESSAGECHAT_ChatType::Whisper { target_player } => { - whisper(store, player, language.as_int(), message, target_player)? - } - CMSG_MESSAGECHAT_ChatType::Afk => set_away(store, player, chat_kind::AFK, message)?, - CMSG_MESSAGECHAT_ChatType::Dnd => set_away(store, player, chat_kind::DND, message)?, - CMSG_MESSAGECHAT_ChatType::Party => { - match send_line(store, player, |speaker| RealmChatRequest { - kind: chat_kind::PARTY, - language: language.as_int(), - channel_name: String::new(), - target_guid: 0, - message, - speaker, - })? { - // Speaking from no party is today's "You aren't in a party" answer. - Some(ChatRefusal::NotInGroup) => vec![Outbound::One( - ServerOpcodeMessage::SMSG_PARTY_COMMAND_RESULT(Box::new( - codec::build_party_command_result( - PartyOperation::Leave, - String::new(), - PartyResult::NotInGroup, - ), - )), - )], - refusal => refusal_outbound(player, refusal), +/// Handle Realm Chat Lines and away status. Query routes the matching Chat Kinds here. +pub(crate) struct Chat; + +impl ProtocolFamily for Chat { + fn handle( + store: &St, + session: &mut ProtocolSession, + request: ProtocolRequest, + ) -> Result { + let player = &*session; + let msg = request.message()?; + let CMSG_MESSAGECHAT { + chat_type, + language, + message, + } = match msg { + ClientOpcodeMessage::CMSG_MESSAGECHAT(chat) => *chat, + ClientOpcodeMessage::CMSG_CHAT_IGNORED(CMSG_CHAT_IGNORED { guid }) => { + let outbound = chat_ignored(store, player, guid.guid())?; + return Ok(ProtocolReply::from(outbound)); } - } - CMSG_MESSAGECHAT_ChatType::Channel { channel } => { - let typed = channel.clone(); - match send_line(store, player, |speaker| RealmChatRequest { - kind: chat_kind::CHANNEL, - language: language.as_int(), - channel_name: channel, - target_guid: 0, - message, - speaker, - })? { - Some(ChatRefusal::Channel(refusal)) => vec![super::channel::refusal_notice( - refusal, - typed, - player.self_guid.unwrap_or(0), - String::new(), - )], - refusal => refusal_outbound(player, refusal), + other => return Err(anyhow!("opcode routed to wrong Protocol Family: {other}")), + }; + let outbound = match chat_type { + CMSG_MESSAGECHAT_ChatType::Whisper { target_player } => { + whisper(store, player, language.as_int(), message, target_player)? } - } - chat_type => { - let Some(kind) = silent_refusal_chat_kind(&chat_type) else { - return Ok(ChatActionOutcome::PassThrough( - ClientOpcodeMessage::CMSG_MESSAGECHAT(Box::new(CMSG_MESSAGECHAT { - chat_type, - language, - message, - })), - )); - }; - let refusal = send_line(store, player, |speaker| RealmChatRequest { - kind, - language: language.as_int(), - channel_name: String::new(), - target_guid: 0, - message, - speaker, - })?; - refusal_outbound(player, refusal) - } - }; - Ok(ChatActionOutcome::Handled { outbound }) + CMSG_MESSAGECHAT_ChatType::Afk => set_away(store, player, chat_kind::AFK, message)?, + CMSG_MESSAGECHAT_ChatType::Dnd => set_away(store, player, chat_kind::DND, message)?, + CMSG_MESSAGECHAT_ChatType::Party => { + match send_line(store, player, |speaker| RealmChatRequest { + kind: chat_kind::PARTY, + language: language.as_int(), + channel_name: String::new(), + target_guid: 0, + message, + speaker, + })? { + // Speaking from no party is today's "You aren't in a party" answer. + Some(ChatRefusal::NotInGroup) => vec![Outbound::One( + ServerOpcodeMessage::SMSG_PARTY_COMMAND_RESULT(Box::new( + codec::build_party_command_result( + PartyOperation::Leave, + String::new(), + PartyResult::NotInGroup, + ), + )), + )], + refusal => refusal_outbound(player, refusal), + } + } + CMSG_MESSAGECHAT_ChatType::Channel { channel } => { + let typed = channel.clone(); + match send_line(store, player, |speaker| RealmChatRequest { + kind: chat_kind::CHANNEL, + language: language.as_int(), + channel_name: channel, + target_guid: 0, + message, + speaker, + })? { + Some(ChatRefusal::Channel(refusal)) => vec![super::channel::refusal_notice( + refusal, + typed, + player.self_guid().unwrap_or(0), + String::new(), + )], + refusal => refusal_outbound(player, refusal), + } + } + chat_type => { + let Some(kind) = silent_refusal_chat_kind(&chat_type) else { + return Ok(ProtocolReply::default()); + }; + let refusal = send_line(store, player, |speaker| RealmChatRequest { + kind, + language: language.as_int(), + channel_name: String::new(), + target_guid: 0, + message, + speaker, + })?; + refusal_outbound(player, refusal) + } + }; + Ok(ProtocolReply::from(outbound)) + } } /// One whisper: the target is resolved realm-wide, the Module applies the Gates on Realm-core, and @@ -253,7 +230,7 @@ pub(crate) fn dispatch_chat_action( /// target whose Realm Presence is unknown. fn whisper( store: &St, - player: ChatActionPlayer, + player: &ProtocolSession, language: u32, message: String, typed_name: String, @@ -299,7 +276,7 @@ fn player_not_found(name: String) -> Outbound { /// change on the entity Relay, so nothing answers here. fn set_away( store: &St, - player: ChatActionPlayer, + player: &ProtocolSession, kind: u8, message: String, ) -> Result> { @@ -316,7 +293,7 @@ fn set_away( /// "X is ignoring you." (cm:ChatHandler.cpp:801-815). The notice carries the ignorer's own name. fn chat_ignored( store: &St, - player: ChatActionPlayer, + player: &ProtocolSession, dropped_speaker: u64, ) -> Result> { let refusal = send_line(store, player, |speaker| RealmChatRequest { @@ -335,7 +312,7 @@ fn chat_ignored( /// calling arm to answer. A Transport Loss is fatal. fn send_line( store: &St, - player: ChatActionPlayer, + player: &ProtocolSession, request: impl FnOnce(SpeakerFacts) -> RealmChatRequest, ) -> Result> { let Some(actor) = player.actor() else { @@ -357,7 +334,7 @@ fn send_line( /// a Refusal this Gateway does not know; a Refusal it knows comes back for the caller to answer. /// A Transport Loss is fatal. pub(crate) fn settle( - player: ChatActionPlayer, + player: &ProtocolSession, line: impl std::fmt::Display, sent: Result, ) -> Result> { @@ -373,7 +350,7 @@ pub(crate) fn settle( /// The answer every Chat Kind shares. Vanilla answers most chat Refusals with silence. pub(crate) fn refusal_outbound( - player: ChatActionPlayer, + player: &ProtocolSession, refusal: Option, ) -> Vec { match refusal { @@ -397,7 +374,7 @@ pub(crate) fn refusal_outbound( /// A Refusal this Gateway has no answer for drops the request and keeps the World Session. A /// Transport Loss leaves the outcome unknown, so it ends the session. pub(super) fn drop_unrecognised_refusal( - player: ChatActionPlayer, + player: &ProtocolSession, request: impl std::fmt::Display, error: anyhow::Error, ) -> Result<()> { @@ -419,7 +396,7 @@ mod tests { use crate::stdb::ReducerCallError; use lyracore_shared::channel::ChannelRefusal; use std::sync::Mutex; - use wow_world_messages::vanilla::{Language, CMSG_PING}; + use wow_world_messages::vanilla::Language; /// How a Fake's Durable Request fails. #[derive(Clone, Copy)] @@ -534,11 +511,8 @@ mod tests { } } - fn player() -> ChatActionPlayer { - ChatActionPlayer { - account_id: 7, - self_guid: Some(42), - } + fn player() -> ProtocolSession { + ProtocolSession::in_world(7, 42) } fn line(chat_type: CMSG_MESSAGECHAT_ChatType, language: Language) -> ClientOpcodeMessage { @@ -553,11 +527,8 @@ mod tests { line(CMSG_MESSAGECHAT_ChatType::Party, language) } - fn handled(outcome: ChatActionOutcome) -> Vec { - match outcome { - ChatActionOutcome::Handled { outbound } => outbound, - ChatActionOutcome::PassThrough(msg) => panic!("expected Handled, got {msg}"), - } + fn handled(outcome: ProtocolReply) -> Vec { + outcome.outbound } fn only(outbound: Vec) -> ServerOpcodeMessage { @@ -572,7 +543,7 @@ mod tests { fn a_party_line_carries_the_speaker_facts_and_the_clients_language() { let store = store(None); let outbound = - handled(dispatch_chat_action(&store, player(), party(Language::Common)).unwrap()); + handled(Chat::handle(&store, &mut player(), party(Language::Common).into()).unwrap()); assert!(outbound.is_empty(), "the line itself returns on the Relay"); assert_eq!(store.facts_reads.lock().unwrap().as_slice(), &[42]); assert_eq!( @@ -609,7 +580,7 @@ mod tests { (Language::Troll, 14), ] { let store = store(None); - handled(dispatch_chat_action(&store, player(), party(language)).unwrap()); + handled(Chat::handle(&store, &mut player(), party(language).into()).unwrap()); assert_eq!( store.requests.lock().unwrap()[0].1.language, wire, @@ -621,12 +592,9 @@ mod tests { #[test] fn a_line_without_a_world_session_reads_and_requests_nothing() { let store = store(None); - let no_session = ChatActionPlayer { - account_id: 7, - self_guid: None, - }; + let mut no_session = ProtocolSession::new(7, "TESTER".into()); let outbound = - handled(dispatch_chat_action(&store, no_session, party(Language::Common)).unwrap()); + handled(Chat::handle(&store, &mut no_session, party(Language::Common).into()).unwrap()); assert!(outbound.is_empty()); assert!(store.facts_reads.lock().unwrap().is_empty()); assert!(store.requests.lock().unwrap().is_empty()); @@ -636,7 +604,7 @@ mod tests { fn a_speaker_without_a_live_entity_sends_nothing() { let store = InMemoryChatActions::default(); let outbound = - handled(dispatch_chat_action(&store, player(), party(Language::Common)).unwrap()); + handled(Chat::handle(&store, &mut player(), party(Language::Common).into()).unwrap()); assert!(outbound.is_empty()); assert!(store.requests.lock().unwrap().is_empty()); } @@ -645,7 +613,7 @@ mod tests { fn speaking_from_no_party_answers_not_in_group() { let store = store(Some(Ok(ChatOutcome::Refused(ChatRefusal::NotInGroup)))); let outbound = - handled(dispatch_chat_action(&store, player(), party(Language::Common)).unwrap()); + handled(Chat::handle(&store, &mut player(), party(Language::Common).into()).unwrap()); match only(outbound) { ServerOpcodeMessage::SMSG_PARTY_COMMAND_RESULT(result) => { assert_eq!(result.operation, PartyOperation::Leave); @@ -660,7 +628,7 @@ mod tests { fn an_unknown_language_answers_the_vanilla_notification() { let store = store(Some(Ok(ChatOutcome::Refused(ChatRefusal::UnknownLanguage)))); let outbound = - handled(dispatch_chat_action(&store, player(), party(Language::Orcish)).unwrap()); + handled(Chat::handle(&store, &mut player(), party(Language::Orcish).into()).unwrap()); match only(outbound) { ServerOpcodeMessage::SMSG_NOTIFICATION(notice) => { assert_eq!(notice.notification, "You don't know that language"); @@ -673,8 +641,9 @@ mod tests { fn every_other_refusal_is_silent() { for refusal in [ChatRefusal::UnsupportedKind, ChatRefusal::EmptyMessage] { let store = store(Some(Ok(ChatOutcome::Refused(refusal)))); - let outbound = - handled(dispatch_chat_action(&store, player(), party(Language::Common)).unwrap()); + let outbound = handled( + Chat::handle(&store, &mut player(), party(Language::Common).into()).unwrap(), + ); assert!(outbound.is_empty(), "{refusal:?}"); } } @@ -698,7 +667,12 @@ mod tests { ] { let store = store(None); let outbound = handled( - dispatch_chat_action(&store, player(), line(chat_type, Language::Common)).unwrap(), + Chat::handle( + &store, + &mut player(), + line(chat_type, Language::Common).into(), + ) + .unwrap(), ); assert!(outbound.is_empty(), "the line itself returns on the Relay"); assert_eq!( @@ -720,7 +694,12 @@ mod tests { ] { let store = store(None); handled( - dispatch_chat_action(&store, player(), line(chat_type, Language::Addon)).unwrap(), + Chat::handle( + &store, + &mut player(), + line(chat_type, Language::Addon).into(), + ) + .unwrap(), ); assert_eq!( store.requests.lock().unwrap()[0].1.language, @@ -742,10 +721,10 @@ mod tests { ] { let store = store(Some(Ok(ChatOutcome::Refused(refusal)))); let outbound = handled( - dispatch_chat_action( + Chat::handle( &store, - player(), - line(CMSG_MESSAGECHAT_ChatType::Raid, Language::Common), + &mut player(), + line(CMSG_MESSAGECHAT_ChatType::Raid, Language::Common).into(), ) .unwrap(), ); @@ -754,10 +733,10 @@ mod tests { for refusal in [ChatRefusal::NotInGuild, ChatRefusal::NoGuildChatRight] { let store = store(Some(Ok(ChatOutcome::Refused(refusal)))); let outbound = handled( - dispatch_chat_action( + Chat::handle( &store, - player(), - line(CMSG_MESSAGECHAT_ChatType::Guild, Language::Common), + &mut player(), + line(CMSG_MESSAGECHAT_ChatType::Guild, Language::Common).into(), ) .unwrap(), ); @@ -768,7 +747,7 @@ mod tests { #[test] fn a_lost_reducer_transport_ends_the_session() { let store = store(Some(Err(Failure::TransportLost))); - let error = dispatch_chat_action(&store, player(), party(Language::Common)) + let error = Chat::handle(&store, &mut player(), party(Language::Common).into()) .err() .expect("transport loss is fatal"); assert!(matches!(classify(&error), DurableFailure::TransportLoss)); @@ -778,7 +757,7 @@ mod tests { fn an_unrecognised_refusal_drops_the_line_and_keeps_the_session() { let store = store(Some(Err(Failure::Refused))); let outbound = - handled(dispatch_chat_action(&store, player(), party(Language::Common)).unwrap()); + handled(Chat::handle(&store, &mut player(), party(Language::Common).into()).unwrap()); assert!(outbound.is_empty()); } @@ -794,8 +773,9 @@ mod tests { #[test] fn a_channel_line_carries_the_typed_channel_and_the_clients_language() { let store = store(None); - let outbound = - handled(dispatch_chat_action(&store, player(), channel_line("trade - City")).unwrap()); + let outbound = handled( + Chat::handle(&store, &mut player(), channel_line("trade - City").into()).unwrap(), + ); assert!(outbound.is_empty(), "the line itself returns on the Relay"); assert_eq!( store.requests.lock().unwrap().as_slice(), @@ -825,7 +805,7 @@ mod tests { refusal, ))))); let outbound = - handled(dispatch_chat_action(&store, player(), channel_line("Rx")).unwrap()); + handled(Chat::handle(&store, &mut player(), channel_line("Rx").into()).unwrap()); let mut outbound = outbound.into_iter(); match (outbound.next(), outbound.next()) { (Some(Outbound::Raw { opcode, body }), None) => { @@ -840,32 +820,14 @@ mod tests { #[test] fn an_unknown_language_on_a_channel_answers_the_vanilla_notification() { let store = store(Some(Ok(ChatOutcome::Refused(ChatRefusal::UnknownLanguage)))); - let outbound = handled(dispatch_chat_action(&store, player(), channel_line("Rx")).unwrap()); + let outbound = + handled(Chat::handle(&store, &mut player(), channel_line("Rx").into()).unwrap()); assert!(matches!( only(outbound), ServerOpcodeMessage::SMSG_NOTIFICATION(_) )); } - #[test] - fn say_and_yell_pass_through_untouched() { - let store = store(None); - for chat_type in [ - CMSG_MESSAGECHAT_ChatType::Say, - CMSG_MESSAGECHAT_ChatType::Yell, - ] { - match dispatch_chat_action(&store, player(), line(chat_type, Language::Common)).unwrap() - { - ChatActionOutcome::PassThrough(ClientOpcodeMessage::CMSG_MESSAGECHAT(chat)) => { - assert_eq!(chat.message, "form up"); - } - _ => panic!("say and yell stay proximity chat"), - } - } - assert!(store.facts_reads.lock().unwrap().is_empty()); - assert!(store.requests.lock().unwrap().is_empty()); - } - fn target() -> WhisperTargetFacts { WhisperTargetFacts { guid: 20, @@ -907,7 +869,8 @@ mod tests { #[test] fn a_whisper_conveys_the_speaker_and_the_target_facts() { let store = whisper_store(Ok(Some(target())), None); - let outbound = handled(dispatch_chat_action(&store, player(), whisper_to("vim")).unwrap()); + let outbound = + handled(Chat::handle(&store, &mut player(), whisper_to("vim").into()).unwrap()); assert!(outbound.is_empty(), "the lines return on the Relay"); assert_eq!( store.target_reads.lock().unwrap().as_slice(), @@ -931,7 +894,8 @@ mod tests { #[test] fn a_whisper_to_nobody_online_answers_player_not_found() { let store = whisper_store(Ok(None), None); - let outbound = handled(dispatch_chat_action(&store, player(), whisper_to("vIm")).unwrap()); + let outbound = + handled(Chat::handle(&store, &mut player(), whisper_to("vIm").into()).unwrap()); assert_eq!(not_found(outbound), "vIm"); assert!(store.whispers.lock().unwrap().is_empty()); } @@ -939,7 +903,8 @@ mod tests { #[test] fn an_unknown_target_presence_answers_player_not_found() { let store = whisper_store(Err(Failure::PresenceUnknown), None); - let outbound = handled(dispatch_chat_action(&store, player(), whisper_to("vIm")).unwrap()); + let outbound = + handled(Chat::handle(&store, &mut player(), whisper_to("vIm").into()).unwrap()); assert_eq!(not_found(outbound), "vIm"); assert!(store.whispers.lock().unwrap().is_empty()); } @@ -947,7 +912,7 @@ mod tests { #[test] fn a_lost_target_read_ends_the_session() { let store = whisper_store(Err(Failure::TransportLost), None); - let error = dispatch_chat_action(&store, player(), whisper_to("Vim")) + let error = Chat::handle(&store, &mut player(), whisper_to("Vim").into()) .err() .expect("a lost read is fatal"); assert_eq!(classify(&error), DurableFailure::TransportLoss); @@ -961,7 +926,8 @@ mod tests { Ok(Some(target())), Some(Ok(ChatOutcome::Refused(ChatRefusal::WrongFaction))), ); - let outbound = handled(dispatch_chat_action(&store, player(), whisper_to("Vim")).unwrap()); + let outbound = + handled(Chat::handle(&store, &mut player(), whisper_to("Vim").into()).unwrap()); let message = only(outbound); assert!(matches!( message, @@ -977,7 +943,7 @@ mod tests { for refusal in [ChatRefusal::EmptyMessage, ChatRefusal::UnsupportedKind] { let store = whisper_store(Ok(Some(target())), Some(Ok(ChatOutcome::Refused(refusal)))); let outbound = - handled(dispatch_chat_action(&store, player(), whisper_to("Vim")).unwrap()); + handled(Chat::handle(&store, &mut player(), whisper_to("Vim").into()).unwrap()); assert!(outbound.is_empty(), "{refusal:?}"); } } @@ -988,7 +954,8 @@ mod tests { facts: None, ..whisper_store(Ok(Some(target())), None) }; - let outbound = handled(dispatch_chat_action(&store, player(), whisper_to("Vim")).unwrap()); + let outbound = + handled(Chat::handle(&store, &mut player(), whisper_to("Vim").into()).unwrap()); assert!(outbound.is_empty()); assert!(store.target_reads.lock().unwrap().is_empty()); assert!(store.whispers.lock().unwrap().is_empty()); @@ -997,13 +964,14 @@ mod tests { #[test] fn a_lost_transport_on_a_whisper_ends_the_session() { let store = whisper_store(Ok(Some(target())), Some(Err(Failure::TransportLost))); - assert!(dispatch_chat_action(&store, player(), whisper_to("Vim")).is_err()); + assert!(Chat::handle(&store, &mut player(), whisper_to("Vim").into()).is_err()); } #[test] fn an_unrecognised_whisper_refusal_is_silent() { let store = whisper_store(Ok(Some(target())), Some(Err(Failure::Refused))); - let outbound = handled(dispatch_chat_action(&store, player(), whisper_to("Vim")).unwrap()); + let outbound = + handled(Chat::handle(&store, &mut player(), whisper_to("Vim").into()).unwrap()); assert!(outbound.is_empty()); } @@ -1020,7 +988,7 @@ mod tests { language: Language::Universal, message: message.to_string(), })); - let outbound = handled(dispatch_chat_action(&store, player(), away).unwrap()); + let outbound = handled(Chat::handle(&store, &mut player(), away.into()).unwrap()); assert!(outbound.is_empty()); } assert_eq!( @@ -1037,12 +1005,12 @@ mod tests { away_failure: Some(Failure::Refused), ..store(None) }; - assert!(handled(dispatch_chat_action(&refused, player(), afk()).unwrap()).is_empty()); + assert!(handled(Chat::handle(&refused, &mut player(), afk().into()).unwrap()).is_empty()); let lost = InMemoryChatActions { away_failure: Some(Failure::TransportLost), ..store(None) }; - assert!(dispatch_chat_action(&lost, player(), afk()).is_err()); + assert!(Chat::handle(&lost, &mut player(), afk().into()).is_err()); } /// cm:ChatHandler.cpp:801-815: the ignorer's name, to the Character whose line was dropped. @@ -1052,7 +1020,7 @@ mod tests { let ignored = ClientOpcodeMessage::CMSG_CHAT_IGNORED(CMSG_CHAT_IGNORED { guid: wow_world_base::vanilla::Guid::new(20), }); - let outbound = handled(dispatch_chat_action(&store, player(), ignored).unwrap()); + let outbound = handled(Chat::handle(&store, &mut player(), ignored.into()).unwrap()); assert!(outbound.is_empty()); assert_eq!( store.requests.lock().unwrap().as_slice(), @@ -1069,20 +1037,4 @@ mod tests { )] ); } - - #[test] - fn another_opcode_passes_through() { - let store = store(None); - let outcome = dispatch_chat_action( - &store, - player(), - ClientOpcodeMessage::CMSG_PING(CMSG_PING::default()), - ) - .unwrap(); - assert!(matches!( - outcome, - ChatActionOutcome::PassThrough(ClientOpcodeMessage::CMSG_PING(_)) - )); - assert!(store.requests.lock().unwrap().is_empty()); - } } diff --git a/gateway/src/world/handlers/combat.rs b/gateway/src/world/handlers/combat.rs index 171a1a265..8014f5091 100644 --- a/gateway/src/world/handlers/combat.rs +++ b/gateway/src/world/handlers/combat.rs @@ -38,47 +38,53 @@ pub(super) fn ignore_refusal(action: &str, account_id: u64, result: Result<()>) /// Combat family leftovers: selection, pet commands, the run-speed ack and sheathing. Each arm is /// best-effort. The session-fatal desync exits went to the melee seam with the two melee opcodes /// that owned them. -pub(crate) fn handle_combat( - store: &St, - conn: &mut WorldConn, - msg: ClientOpcodeMessage, -) -> Result> { - let actor = social::self_guid(conn).and_then(Actor::new); +pub(crate) struct Combat; - match msg { - // Targeting (N3): record the player's selection server-side (foundation for combat). - ClientOpcodeMessage::CMSG_SET_SELECTION(s) => { - let actor = actor.ok_or_else(|| anyhow!("CMSG_SET_SELECTION before world entry"))?; - store.set_target(actor, s.target.guid())? - } - // Pet command bar (CMSG_PET_ACTION): pass the raw packed `data` + target through; the module - // decodes stay/follow/attack/dismiss + passive/defensive/aggressive and validates ownership. A - // transient reject (no pet, dead/invalid target) must NOT drop the session — log + ignore, like - // the start_attack path (do NOT route through is_desync_error). - ClientOpcodeMessage::CMSG_PET_ACTION(p) => { - if let Some(actor) = actor { - let result = store.pet_command(actor, p.data, p.target.guid()); - ignore_refusal("pet_command", conn.account_id, result)?; +impl ProtocolFamily for Combat { + fn handle( + store: &St, + conn: &mut ProtocolSession, + request: ProtocolRequest, + ) -> Result { + let msg = request.message()?; + let actor = conn.self_guid().and_then(Actor::new); + + match msg { + // Targeting (N3): record the player's selection server-side (foundation for combat). + ClientOpcodeMessage::CMSG_SET_SELECTION(s) => { + let actor = + actor.ok_or_else(|| anyhow!("CMSG_SET_SELECTION before world entry"))?; + store.set_target(actor, s.target.guid())? } - } - // The client's ack to our `SMSG_FORCE_RUN_SPEED_CHANGE` (`.speed`). We don't - // gate on the reply (the movement counter/new_speed aren't cross-checked) — explicitly - // consumed here (rather than falling through to the dispatch tail's `log::debug!` "ignoring" - // line) so a `.speed` never spams the log or risks a future desync-classifier false-positive. - ClientOpcodeMessage::CMSG_FORCE_RUN_SPEED_CHANGE_ACK(_) => {} - // Draw / stow weapons. The client sends this on `Z`, on a weapon swap, and when an - // ability auto-draws. It is a pure render-state change: nothing gates on it, so a failure is - // logged and dropped rather than being session-fatal like ATTACKSWING/ATTACKSTOP. gtker - // already parsed the payload into a `SheathState`, so the byte reaching the module is one of - // 0/1/2 — the module re-checks anyway, being the trust boundary for every caller. - ClientOpcodeMessage::CMSG_SETSHEATHED(s) => { - let state = s.sheathed.as_int(); - if let Some(actor) = actor { - let result = store.set_sheathed(actor, state); - ignore_refusal(&format!("set_sheathed({state})"), conn.account_id, result)?; + // Pet command bar (CMSG_PET_ACTION): pass the raw packed `data` + target through; the module + // decodes stay/follow/attack/dismiss + passive/defensive/aggressive and validates ownership. A + // transient reject (no pet, dead/invalid target) must NOT drop the session — log + ignore, like + // the start_attack path (do NOT route through is_desync_error). + ClientOpcodeMessage::CMSG_PET_ACTION(p) => { + if let Some(actor) = actor { + let result = store.pet_command(actor, p.data, p.target.guid()); + ignore_refusal("pet_command", conn.account_id, result)?; + } + } + // The client's ack to our `SMSG_FORCE_RUN_SPEED_CHANGE` (`.speed`). We don't + // gate on the reply (the movement counter/new_speed aren't cross-checked) — explicitly + // consumed here (rather than falling through to the dispatch tail's `log::debug!` "ignoring" + // line) so a `.speed` never spams the log or risks a future desync-classifier false-positive. + ClientOpcodeMessage::CMSG_FORCE_RUN_SPEED_CHANGE_ACK(_) => {} + // Draw / stow weapons. The client sends this on `Z`, on a weapon swap, and when an + // ability auto-draws. It is a pure render-state change: nothing gates on it, so a failure is + // logged and dropped rather than being session-fatal like ATTACKSWING/ATTACKSTOP. gtker + // already parsed the payload into a `SheathState`, so the byte reaching the module is one of + // 0/1/2 — the module re-checks anyway, being the trust boundary for every caller. + ClientOpcodeMessage::CMSG_SETSHEATHED(s) => { + let state = s.sheathed.as_int(); + if let Some(actor) = actor { + let result = store.set_sheathed(actor, state); + ignore_refusal(&format!("set_sheathed({state})"), conn.account_id, result)?; + } } + _ => return Err(anyhow!("opcode routed to the wrong Protocol Family")), } - other => return Ok(Some(other)), + Ok(ProtocolReply::default()) } - Ok(None) } diff --git a/gateway/src/world/handlers/duel.rs b/gateway/src/world/handlers/duel.rs index b7917ed33..0700b9f81 100644 --- a/gateway/src/world/handlers/duel.rs +++ b/gateway/src/world/handlers/duel.rs @@ -7,59 +7,50 @@ pub(crate) trait DuelActionStore: Send + Sync { fn duel_cancel(&self, actor: Actor, flag_guid: u64) -> Result<()>; } -#[derive(Clone, Copy)] -pub(crate) struct DuelActionPlayer { - pub(crate) account_id: u64, - pub(crate) self_guid: Option, -} - -pub(crate) enum DuelActionOutcome { - Handled { outbound: Vec }, - PassThrough(ClientOpcodeMessage), -} - -pub(crate) fn dispatch_duel_action( - store: &St, - player: DuelActionPlayer, - msg: ClientOpcodeMessage, -) -> Result { - let (accept, flag_guid) = match msg { - ClientOpcodeMessage::CMSG_DUEL_ACCEPTED(request) => (true, request.guid.guid()), - ClientOpcodeMessage::CMSG_DUEL_CANCELLED(request) => (false, request.guid.guid()), - other => return Ok(DuelActionOutcome::PassThrough(other)), - }; - let Some(actor) = player.self_guid.and_then(Actor::new) else { - return Ok(DuelActionOutcome::Handled { - outbound: Vec::new(), - }); - }; - let result = if accept { - store.duel_accept(actor, flag_guid) - } else { - store.duel_cancel(actor, flag_guid) - }; - if let Err(error) = result { - if matches!( - crate::stdb::classify(&error), - crate::stdb::DurableFailure::TransportLoss - ) { - return Err(error); +pub(crate) struct Duel; + +impl ProtocolFamily for Duel { + fn handle( + store: &St, + session: &mut ProtocolSession, + request: ProtocolRequest, + ) -> Result { + let player = &*session; + let msg = request.message()?; + let (accept, flag_guid) = match msg { + ClientOpcodeMessage::CMSG_DUEL_ACCEPTED(request) => (true, request.guid.guid()), + ClientOpcodeMessage::CMSG_DUEL_CANCELLED(request) => (false, request.guid.guid()), + other => return Err(anyhow!("opcode routed to wrong Protocol Family: {other}")), + }; + let Some(actor) = player.self_guid().and_then(Actor::new) else { + return Ok(ProtocolReply::from(Vec::new())); + }; + let result = if accept { + store.duel_accept(actor, flag_guid) + } else { + store.duel_cancel(actor, flag_guid) + }; + if let Err(error) = result { + if matches!( + crate::stdb::classify(&error), + crate::stdb::DurableFailure::TransportLoss + ) { + return Err(error); + } + log::debug!( + "world: duel action ignored (account {}): {error}", + player.account_id + ); } - log::debug!( - "world: duel action ignored (account {}): {error}", - player.account_id - ); + Ok(ProtocolReply::from(Vec::new())) } - Ok(DuelActionOutcome::Handled { - outbound: Vec::new(), - }) } #[cfg(test)] mod tests { use super::*; use std::sync::Mutex; - use wow_world_messages::vanilla::{CMSG_DUEL_ACCEPTED, CMSG_DUEL_CANCELLED, CMSG_PING}; + use wow_world_messages::vanilla::{CMSG_DUEL_ACCEPTED, CMSG_DUEL_CANCELLED}; use wow_world_messages::Guid; use crate::stdb::ReducerCallError; @@ -100,36 +91,33 @@ mod tests { } } - fn player() -> DuelActionPlayer { - DuelActionPlayer { - account_id: 7, - self_guid: Some(42), - } + fn player() -> ProtocolSession { + ProtocolSession::in_world(7, 42) } #[test] fn accept_and_cancel_forward_the_wire_arbiter_as_reducer_intents() { let store = InMemoryDuelStore::default(); - let accepted = dispatch_duel_action( + let accepted = Duel::handle( &store, - player(), + &mut player(), ClientOpcodeMessage::CMSG_DUEL_ACCEPTED(CMSG_DUEL_ACCEPTED { guid: Guid::new(99), - }), + }) + .into(), ) .unwrap(); - let cancelled = dispatch_duel_action( + let cancelled = Duel::handle( &store, - player(), + &mut player(), ClientOpcodeMessage::CMSG_DUEL_CANCELLED(CMSG_DUEL_CANCELLED { guid: Guid::new(100), - }), + }) + .into(), ) .unwrap(); - assert!(matches!(accepted, DuelActionOutcome::Handled { outbound } if outbound.is_empty())); - assert!( - matches!(cancelled, DuelActionOutcome::Handled { outbound } if outbound.is_empty()) - ); + assert!(accepted.outbound.is_empty()); + assert!(cancelled.outbound.is_empty()); assert_eq!( store.calls.lock().unwrap().as_slice(), &[("accept", 42, 99), ("cancel", 42, 100)] @@ -139,18 +127,16 @@ mod tests { #[test] fn no_in_world_actor_is_consumed_without_a_forged_reducer_identity() { let store = InMemoryDuelStore::default(); - let outcome = dispatch_duel_action( + let outcome = Duel::handle( &store, - DuelActionPlayer { - account_id: 7, - self_guid: None, - }, + &mut ProtocolSession::new(7, "TESTER".into()), ClientOpcodeMessage::CMSG_DUEL_ACCEPTED(CMSG_DUEL_ACCEPTED { guid: Guid::new(99), - }), + }) + .into(), ) .unwrap(); - assert!(matches!(outcome, DuelActionOutcome::Handled { .. })); + assert!(outcome.outbound.is_empty()); assert!(store.calls.lock().unwrap().is_empty()); } @@ -163,7 +149,7 @@ mod tests { #[test] fn transport_loss_ends_the_world_session() { let store = InMemoryDuelStore::failing(ReducerCallError::transport_lost("gw_duel_accept")); - assert!(dispatch_duel_action(&store, player(), accept_message()).is_err()); + assert!(Duel::handle(&store, &mut player(), accept_message().into()).is_err()); } #[test] @@ -172,22 +158,7 @@ mod tests { "gw_duel_accept", "duel_not_pending", )); - let outcome = dispatch_duel_action(&store, player(), accept_message()).unwrap(); - assert!(matches!(outcome, DuelActionOutcome::Handled { outbound } if outbound.is_empty())); - } - - #[test] - fn unrelated_opcode_passes_through() { - let store = InMemoryDuelStore::default(); - let outcome = dispatch_duel_action( - &store, - player(), - ClientOpcodeMessage::CMSG_PING(CMSG_PING::default()), - ) - .unwrap(); - assert!(matches!( - outcome, - DuelActionOutcome::PassThrough(ClientOpcodeMessage::CMSG_PING(_)) - )); + let outcome = Duel::handle(&store, &mut player(), accept_message().into()).unwrap(); + assert!(outcome.outbound.is_empty()); } } diff --git a/gateway/src/world/handlers/gameobject.rs b/gateway/src/world/handlers/gameobject.rs new file mode 100644 index 000000000..44b72aa39 --- /dev/null +++ b/gateway/src/world/handlers/gameobject.rs @@ -0,0 +1,36 @@ +//! GameObject use resolves its interaction before entering a quest or loot operation. +use super::super::*; + +pub(crate) struct GameObject; + +impl< + St: DeathStore + + LootRollStore + + LootWindowStore + + NpcStore + + ShardRoutingStore + + QuestActionStore + + ?Sized, + > ProtocolFamily for GameObject +{ + fn handle( + store: &St, + session: &mut ProtocolSession, + request: ProtocolRequest, + ) -> Result { + let message = request.message()?; + let ClientOpcodeMessage::CMSG_GAMEOBJ_USE(request) = &message else { + return Err(anyhow!("non-GameObject request routed to GameObject")); + }; + let guid = request.guid.guid(); + match store.gameobject_type(guid)? { + Some(lyracore_shared::constants::go_type::QUESTGIVER) => { + Ok(super::quest_giver_menu(store, guid, session.self_guid().unwrap_or(0))?.into()) + } + Some(lyracore_shared::constants::go_type::CHEST) => { + super::LootWindow::handle(store, session, message.into()) + } + _ => super::Loot::handle(store, session, message.into()), + } + } +} diff --git a/gateway/src/world/handlers/guild.rs b/gateway/src/world/handlers/guild.rs index 6bdcc9204..f81f75e40 100644 --- a/gateway/src/world/handlers/guild.rs +++ b/gateway/src/world/handlers/guild.rs @@ -213,24 +213,6 @@ pub(crate) trait GuildActionStore: GuildFeeStore + Send + Sync { fn guild_name_of_member(&self, character_guid: u64) -> Result>; } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) struct GuildActionPlayer { - pub(crate) account_id: u64, - pub(crate) self_guid: Option, -} - -pub(crate) enum GuildActionOutcome { - Handled { outbound: Vec }, - PassThrough(ClientOpcodeMessage), -} - -impl GuildActionPlayer { - /// The Character this player acts as. `None` before the World Session has one. - fn actor(self) -> Option { - self.self_guid.and_then(Actor::new) - } -} - /// A Transport Loss ends the World Session. A Refusal this Gateway has no answer for, or a Realm /// Presence another World Shard cannot vouch for, comes back for the caller to drop. fn unless_transport_loss(error: anyhow::Error) -> Result { @@ -243,126 +225,130 @@ fn unless_transport_loss(error: anyhow::Error) -> Result { } } -pub(crate) fn dispatch_guild_action( - store: &St, - player: GuildActionPlayer, - msg: ClientOpcodeMessage, -) -> Result { - let outbound = match msg { - ClientOpcodeMessage::CMSG_GUILD_QUERY(query) => query_outbound(store, query.guild_id), - ClientOpcodeMessage::CMSG_GUILD_ROSTER => roster_outbound(store, player), - ClientOpcodeMessage::CMSG_GUILD_INFO => info_outbound(store, player), - ClientOpcodeMessage::CMSG_GUILD_CREATE(create) => { - create_outbound(store, player, create.guild_name) - } - ClientOpcodeMessage::MSG_TABARDVENDOR_ACTIVATE(activate) => { - tabard_window_outbound(store, player, activate.guid.guid()) - } - ClientOpcodeMessage::MSG_SAVE_GUILD_EMBLEM(save) => { - save_emblem_outbound(store, player, &save) - } - ClientOpcodeMessage::CMSG_GUILD_INVITE(invite) => { - invite_outbound(store, player, invite.invited_player) - } - ClientOpcodeMessage::CMSG_GUILD_ACCEPT => accept_outbound(store, player), - ClientOpcodeMessage::CMSG_GUILD_DECLINE => decline_outbound(store, player), - ClientOpcodeMessage::CMSG_GUILD_LEAVE => leave_outbound(store, player), - ClientOpcodeMessage::CMSG_GUILD_REMOVE(remove) => named_member_op( - store, - player, - remove.player_name, - GuildOpKind::Remove, - |target_guid| GuildRequest::Remove { target_guid }, - ), - ClientOpcodeMessage::CMSG_GUILD_PROMOTE(promote) => named_member_op( - store, - player, - promote.player_name, - GuildOpKind::Promote, - |target_guid| GuildRequest::Promote { target_guid }, - ), - ClientOpcodeMessage::CMSG_GUILD_DEMOTE(demote) => named_member_op( - store, - player, - demote.player_name, - GuildOpKind::Demote, - |target_guid| GuildRequest::Demote { target_guid }, - ), - ClientOpcodeMessage::CMSG_GUILD_LEADER(leader) => named_member_op( - store, - player, - leader.new_guild_leader_name, - GuildOpKind::Leader, - |target_guid| GuildRequest::SetLeader { target_guid }, - ), - ClientOpcodeMessage::CMSG_GUILD_DISBAND => disband_outbound(store, player), - ClientOpcodeMessage::CMSG_GUILD_MOTD(motd) => { - motd_outbound(store, player, motd.message_of_the_day) - } - ClientOpcodeMessage::CMSG_GUILD_INFO_TEXT(info) => { - info_text_outbound(store, player, info.guild_info) - } - ClientOpcodeMessage::CMSG_GUILD_SET_PUBLIC_NOTE(note) => set_note_outbound( - store, - player, - note.player_name, - note.note, - |target_guid, text| GuildRequest::SetPublicNote { target_guid, text }, - ), - ClientOpcodeMessage::CMSG_GUILD_SET_OFFICER_NOTE(note) => set_note_outbound( - store, - player, - note.player_name, - note.note, - |target_guid, text| GuildRequest::SetOfficerNote { target_guid, text }, - ), - ClientOpcodeMessage::CMSG_GUILD_RANK(rank) => { - edit_rank_outbound(store, player, rank.rank_id, rank.rights, rank.rank_name) - } - ClientOpcodeMessage::CMSG_GUILD_ADD_RANK(add) => { - add_rank_outbound(store, player, add.rank_name) - } - ClientOpcodeMessage::CMSG_GUILD_DEL_RANK => delete_rank_outbound(store, player), - ClientOpcodeMessage::CMSG_PETITION_SHOWLIST(list) => { - petition_showlist_outbound(store, player, list.guid.guid()) - } - ClientOpcodeMessage::CMSG_PETITION_BUY(buy) => { - petition_buy_outbound(store, player, buy.npc.guid(), buy.name) - } - ClientOpcodeMessage::CMSG_PETITION_SHOW_SIGNATURES(show) => { - show_signatures_outbound(store, player, show.item.guid()) - } - ClientOpcodeMessage::CMSG_PETITION_QUERY(query) => { - petition_query_outbound(store, query.petition.guid()) - } - ClientOpcodeMessage::MSG_PETITION_RENAME(rename) => { - rename_petition_outbound(store, player, rename.petition.guid(), rename.new_name) - } - ClientOpcodeMessage::CMSG_OFFER_PETITION(offer) => { - offer_petition_outbound(store, player, offer.petition.guid(), offer.target.guid()) - } - ClientOpcodeMessage::CMSG_PETITION_SIGN(sign) => { - sign_petition_outbound(store, player, sign.petition.guid()) - } - ClientOpcodeMessage::MSG_PETITION_DECLINE(decline) => { - decline_petition_outbound(store, player, decline.petition.guid()) - } - ClientOpcodeMessage::CMSG_TURN_IN_PETITION(turn_in) => { - turn_in_petition_outbound(store, player, turn_in.petition.guid()) - } - other => return Ok(GuildActionOutcome::PassThrough(other)), - }; - match outbound { - Ok(outbound) => Ok(GuildActionOutcome::Handled { outbound }), - Err(error) => { - let error = unless_transport_loss(error)?; - log::debug!( - "world: guild request dropped (account {}): {error:#}", - player.account_id - ); - Ok(GuildActionOutcome::Handled { - outbound: Vec::new(), - }) +pub(crate) struct Guild; + +impl ProtocolFamily for Guild { + fn handle( + store: &St, + session: &mut ProtocolSession, + request: ProtocolRequest, + ) -> Result { + let player = &*session; + let msg = request.message()?; + let outbound = match msg { + ClientOpcodeMessage::CMSG_GUILD_QUERY(query) => query_outbound(store, query.guild_id), + ClientOpcodeMessage::CMSG_GUILD_ROSTER => roster_outbound(store, player), + ClientOpcodeMessage::CMSG_GUILD_INFO => info_outbound(store, player), + ClientOpcodeMessage::CMSG_GUILD_CREATE(create) => { + create_outbound(store, player, create.guild_name) + } + ClientOpcodeMessage::MSG_TABARDVENDOR_ACTIVATE(activate) => { + tabard_window_outbound(store, player, activate.guid.guid()) + } + ClientOpcodeMessage::MSG_SAVE_GUILD_EMBLEM(save) => { + save_emblem_outbound(store, player, &save) + } + ClientOpcodeMessage::CMSG_GUILD_INVITE(invite) => { + invite_outbound(store, player, invite.invited_player) + } + ClientOpcodeMessage::CMSG_GUILD_ACCEPT => accept_outbound(store, player), + ClientOpcodeMessage::CMSG_GUILD_DECLINE => decline_outbound(store, player), + ClientOpcodeMessage::CMSG_GUILD_LEAVE => leave_outbound(store, player), + ClientOpcodeMessage::CMSG_GUILD_REMOVE(remove) => named_member_op( + store, + player, + remove.player_name, + GuildOpKind::Remove, + |target_guid| GuildRequest::Remove { target_guid }, + ), + ClientOpcodeMessage::CMSG_GUILD_PROMOTE(promote) => named_member_op( + store, + player, + promote.player_name, + GuildOpKind::Promote, + |target_guid| GuildRequest::Promote { target_guid }, + ), + ClientOpcodeMessage::CMSG_GUILD_DEMOTE(demote) => named_member_op( + store, + player, + demote.player_name, + GuildOpKind::Demote, + |target_guid| GuildRequest::Demote { target_guid }, + ), + ClientOpcodeMessage::CMSG_GUILD_LEADER(leader) => named_member_op( + store, + player, + leader.new_guild_leader_name, + GuildOpKind::Leader, + |target_guid| GuildRequest::SetLeader { target_guid }, + ), + ClientOpcodeMessage::CMSG_GUILD_DISBAND => disband_outbound(store, player), + ClientOpcodeMessage::CMSG_GUILD_MOTD(motd) => { + motd_outbound(store, player, motd.message_of_the_day) + } + ClientOpcodeMessage::CMSG_GUILD_INFO_TEXT(info) => { + info_text_outbound(store, player, info.guild_info) + } + ClientOpcodeMessage::CMSG_GUILD_SET_PUBLIC_NOTE(note) => set_note_outbound( + store, + player, + note.player_name, + note.note, + |target_guid, text| GuildRequest::SetPublicNote { target_guid, text }, + ), + ClientOpcodeMessage::CMSG_GUILD_SET_OFFICER_NOTE(note) => set_note_outbound( + store, + player, + note.player_name, + note.note, + |target_guid, text| GuildRequest::SetOfficerNote { target_guid, text }, + ), + ClientOpcodeMessage::CMSG_GUILD_RANK(rank) => { + edit_rank_outbound(store, player, rank.rank_id, rank.rights, rank.rank_name) + } + ClientOpcodeMessage::CMSG_GUILD_ADD_RANK(add) => { + add_rank_outbound(store, player, add.rank_name) + } + ClientOpcodeMessage::CMSG_GUILD_DEL_RANK => delete_rank_outbound(store, player), + ClientOpcodeMessage::CMSG_PETITION_SHOWLIST(list) => { + petition_showlist_outbound(store, player, list.guid.guid()) + } + ClientOpcodeMessage::CMSG_PETITION_BUY(buy) => { + petition_buy_outbound(store, player, buy.npc.guid(), buy.name) + } + ClientOpcodeMessage::CMSG_PETITION_SHOW_SIGNATURES(show) => { + show_signatures_outbound(store, player, show.item.guid()) + } + ClientOpcodeMessage::CMSG_PETITION_QUERY(query) => { + petition_query_outbound(store, query.petition.guid()) + } + ClientOpcodeMessage::MSG_PETITION_RENAME(rename) => { + rename_petition_outbound(store, player, rename.petition.guid(), rename.new_name) + } + ClientOpcodeMessage::CMSG_OFFER_PETITION(offer) => { + offer_petition_outbound(store, player, offer.petition.guid(), offer.target.guid()) + } + ClientOpcodeMessage::CMSG_PETITION_SIGN(sign) => { + sign_petition_outbound(store, player, sign.petition.guid()) + } + ClientOpcodeMessage::MSG_PETITION_DECLINE(decline) => { + decline_petition_outbound(store, player, decline.petition.guid()) + } + ClientOpcodeMessage::CMSG_TURN_IN_PETITION(turn_in) => { + turn_in_petition_outbound(store, player, turn_in.petition.guid()) + } + other => return Err(anyhow!("opcode routed to wrong Protocol Family: {other}")), + }; + match outbound { + Ok(outbound) => Ok(ProtocolReply::from(outbound)), + Err(error) => { + let error = unless_transport_loss(error)?; + log::debug!( + "world: guild request dropped (account {}): {error:#}", + player.account_id + ); + Ok(ProtocolReply::from(Vec::new())) + } } } } @@ -399,9 +385,9 @@ fn query_outbound( /// The actor's membership and its Guild, when both exist. fn actor_guild( store: &St, - player: GuildActionPlayer, + player: &ProtocolSession, ) -> Result> { - let Some(actor_guid) = player.self_guid else { + let Some(actor_guid) = player.self_guid() else { return Ok(None); }; let Some(member) = store.guild_member(actor_guid)? else { @@ -415,7 +401,7 @@ fn actor_guild( /// the cap never pays for a `guild_character_facts` read that would be discarded. fn roster_outbound( store: &St, - player: GuildActionPlayer, + player: &ProtocolSession, ) -> Result> { let Some((viewer, guild)) = actor_guild(store, player)? else { return Ok(Vec::new()); @@ -513,7 +499,7 @@ pub(crate) fn roster_packet( fn info_outbound( store: &St, - player: GuildActionPlayer, + player: &ProtocolSession, ) -> Result> { let Some((_, guild)) = actor_guild(store, player)? else { return Ok(vec![not_in_guild()]); @@ -549,7 +535,7 @@ fn account_count(members: &[codec::GuildMemberView]) -> u32 { /// replies to neither outcome (`cm:GuildHandler.cpp:46-64`). fn create_outbound( store: &St, - player: GuildActionPlayer, + player: &ProtocolSession, name: String, ) -> Result> { let Some(actor) = player.actor() else { @@ -602,7 +588,7 @@ fn live_character_named( /// so this Gateway read happens first here too. fn invite_outbound( store: &St, - player: GuildActionPlayer, + player: &ProtocolSession, typed_name: String, ) -> Result> { let Some(actor) = player.actor() else { @@ -643,7 +629,7 @@ fn invite_outbound( /// CMSG_GUILD_ACCEPT (`cm:GuildHandler.cpp:192-211`): every failure is silent, like mangos. fn accept_outbound( store: &St, - player: GuildActionPlayer, + player: &ProtocolSession, ) -> Result> { let Some(actor) = player.actor() else { return Ok(Vec::new()); @@ -668,7 +654,7 @@ fn accept_outbound( /// CMSG_GUILD_DECLINE (`cm:GuildHandler.cpp:214-238`): every failure is silent. fn decline_outbound( store: &St, - player: GuildActionPlayer, + player: &ProtocolSession, ) -> Result> { let Some(actor) = player.actor() else { return Ok(Vec::new()); @@ -696,7 +682,7 @@ fn decline_outbound( /// outcomes apart, only whether the actor already is the Guild Leader. fn leave_outbound( store: &St, - player: GuildActionPlayer, + player: &ProtocolSession, ) -> Result> { let Some(actor) = player.actor() else { return Ok(Vec::new()); @@ -728,7 +714,7 @@ fn leave_outbound( /// CMSG_GUILD_DISBAND (`cm:GuildHandler.cpp:424-435`). fn disband_outbound( store: &St, - player: GuildActionPlayer, + player: &ProtocolSession, ) -> Result> { let Some(actor) = player.actor() else { return Ok(Vec::new()); @@ -752,7 +738,7 @@ fn disband_outbound( /// (`cm:GuildHandler.cpp:146-152,290-296,343-349,467-473`). fn named_member_op( store: &St, - player: GuildActionPlayer, + player: &ProtocolSession, typed_name: String, op_kind: GuildOpKind, build_request: F, @@ -882,7 +868,7 @@ fn membership_refusal_outbound( /// CMSG_GUILD_MOTD (`cm:GuildHandler.cpp:488-513`). fn motd_outbound( store: &St, - player: GuildActionPlayer, + player: &ProtocolSession, text: String, ) -> Result> { let Some(actor) = player.actor() else { @@ -904,7 +890,7 @@ fn motd_outbound( /// CMSG_GUILD_INFO_TEXT (`cm:GuildHandler.cpp:693-713`). fn info_text_outbound( store: &St, - player: GuildActionPlayer, + player: &ProtocolSession, text: String, ) -> Result> { let Some(actor) = player.actor() else { @@ -928,7 +914,7 @@ fn info_text_outbound( /// `GetMemberSlot` (`cm:GuildHandler.cpp:526-545,564-582`), then run the edit. fn set_note_outbound( store: &St, - player: GuildActionPlayer, + player: &ProtocolSession, target_name: String, text: String, request: impl FnOnce(u64, String) -> GuildRequest, @@ -979,7 +965,7 @@ fn rank_refusal_outbound(outcome: GuildOutcome) -> Vec { /// CMSG_GUILD_RANK. fn edit_rank_outbound( store: &St, - player: GuildActionPlayer, + player: &ProtocolSession, rank_id: u32, rights: u32, name: String, @@ -1001,7 +987,7 @@ fn edit_rank_outbound( /// CMSG_GUILD_ADD_RANK. fn add_rank_outbound( store: &St, - player: GuildActionPlayer, + player: &ProtocolSession, name: String, ) -> Result> { let Some(actor) = player.actor() else { @@ -1014,7 +1000,7 @@ fn add_rank_outbound( /// CMSG_GUILD_DEL_RANK. fn delete_rank_outbound( store: &St, - player: GuildActionPlayer, + player: &ProtocolSession, ) -> Result> { let Some(actor) = player.actor() else { return Ok(Vec::new()); @@ -1036,7 +1022,7 @@ pub(crate) fn is_guild_dot_command(text: &str) -> bool { /// system line for the actor; success is silent, like every other dot-command. pub(crate) fn run_guild_dot_command( store: &St, - player: GuildActionPlayer, + player: &ProtocolSession, text: &str, ) -> Result> { let Some(actor) = player.actor() else { @@ -1124,10 +1110,10 @@ fn parse_guild_create(text: &str) -> Option<(Option<&str>, &str)> { /// run in the Fee Hold when the emblem is saved. fn tabard_window_outbound( store: &St, - player: GuildActionPlayer, + player: &ProtocolSession, npc_guid: u64, ) -> Result> { - let Some(actor_guid) = player.self_guid else { + let Some(actor_guid) = player.self_guid() else { return Ok(Vec::new()); }; if store.guild_npc_refuses(npc_guid, actor_guid)? { @@ -1153,7 +1139,7 @@ pub(crate) fn tabard_designer_window(npc_guid: u64) -> Outbound { /// through the TABARD_CHANGED Guild Event. fn save_emblem_outbound( store: &St, - player: GuildActionPlayer, + player: &ProtocolSession, save: &MSG_SAVE_GUILD_EMBLEM_Client, ) -> Result> { let Some(actor) = player.actor() else { @@ -1210,10 +1196,10 @@ fn emblem_result(outcome: guild_fee::FeeOutcome) -> GuildEmblemResult { /// bought. fn petition_showlist_outbound( store: &St, - player: GuildActionPlayer, + player: &ProtocolSession, npc_guid: u64, ) -> Result> { - let Some(actor_guid) = player.self_guid else { + let Some(actor_guid) = player.self_guid() else { return Ok(Vec::new()); }; if store.guild_npc_refuses(npc_guid, actor_guid)? { @@ -1241,7 +1227,7 @@ fn guild_create_result(name: String, result: GuildCommandResult) -> Outbound { /// Charter reaches the client through the item relay. fn petition_buy_outbound( store: &St, - player: GuildActionPlayer, + player: &ProtocolSession, npc_guid: u64, name: String, ) -> Result> { @@ -1337,10 +1323,10 @@ fn charter_purchase_reply( /// CMSG_PETITION_SHOW_SIGNATURES (`cm:PetitionsHandler.cpp:176-218`). A member hears nothing. fn show_signatures_outbound( store: &St, - player: GuildActionPlayer, + player: &ProtocolSession, charter_item_guid: u64, ) -> Result> { - let Some(actor_guid) = player.self_guid else { + let Some(actor_guid) = player.self_guid() else { return Ok(Vec::new()); }; if store.guild_member(actor_guid)?.is_some() { @@ -1383,7 +1369,7 @@ fn petition_query(petition: &codec::PetitionView) -> Outbound { /// Charter. The reply echoes the new name. fn rename_petition_outbound( store: &St, - player: GuildActionPlayer, + player: &ProtocolSession, charter_item_guid: u64, name: String, ) -> Result> { @@ -1426,7 +1412,7 @@ fn rename_petition_outbound( /// the offerer's name there. fn offer_petition_outbound( store: &St, - player: GuildActionPlayer, + player: &ProtocolSession, charter_item_guid: u64, target_guid: u64, ) -> Result> { @@ -1474,7 +1460,7 @@ fn offer_petition_outbound( /// Account that already signed, reach both the signer and the owner through Guild Events. fn sign_petition_outbound( store: &St, - player: GuildActionPlayer, + player: &ProtocolSession, charter_item_guid: u64, ) -> Result> { let Some(actor) = player.actor() else { @@ -1527,7 +1513,7 @@ fn sign_petition_outbound( /// Event; the decliner hears nothing. fn decline_petition_outbound( store: &St, - player: GuildActionPlayer, + player: &ProtocolSession, charter_item_guid: u64, ) -> Result> { let Some(actor) = player.actor() else { @@ -1547,7 +1533,7 @@ fn decline_petition_outbound( /// the next world entry destroys it (`destroy_inert_charters`). fn turn_in_petition_outbound( store: &St, - player: GuildActionPlayer, + player: &ProtocolSession, charter_item_guid: u64, ) -> Result> { let Some(actor) = player.actor() else { @@ -1973,7 +1959,7 @@ mod tests { CMSG_GUILD_INFO_TEXT, CMSG_GUILD_MOTD, CMSG_GUILD_QUERY, CMSG_GUILD_RANK, CMSG_GUILD_SET_OFFICER_NOTE, CMSG_GUILD_SET_PUBLIC_NOTE, CMSG_OFFER_PETITION, CMSG_PETITION_BUY, CMSG_PETITION_QUERY, CMSG_PETITION_SHOWLIST, - CMSG_PETITION_SHOW_SIGNATURES, CMSG_PETITION_SIGN, CMSG_PING, CMSG_TURN_IN_PETITION, + CMSG_PETITION_SHOW_SIGNATURES, CMSG_PETITION_SIGN, CMSG_TURN_IN_PETITION, MSG_PETITION_DECLINE, }; @@ -2348,22 +2334,18 @@ mod tests { ReducerCallError::refused("realm_guild_op", "mystery").into() } - fn in_world(guid: u64) -> GuildActionPlayer { - GuildActionPlayer { - account_id: 7, - self_guid: Some(guid), - } + fn in_world(guid: u64) -> ProtocolSession { + ProtocolSession::in_world(7, guid) } fn dispatch( store: &InMemoryGuildActions, - player: GuildActionPlayer, + mut player: ProtocolSession, msg: ClientOpcodeMessage, ) -> Vec { - match dispatch_guild_action(store, player, msg).unwrap() { - GuildActionOutcome::Handled { outbound } => outbound, - GuildActionOutcome::PassThrough(msg) => panic!("{msg} was not handled"), - } + Guild::handle(store, &mut player, msg.into()) + .unwrap() + .outbound } fn only_message(outbound: Vec) -> ServerOpcodeMessage { @@ -2386,7 +2368,7 @@ mod tests { fn dot_create_founds_a_guild_led_by_the_gm_when_nothing_is_selected() { let store = realm(); let line = - run_guild_dot_command(&store, in_world(GM), ".guild create \"Tracer Guild\"").unwrap(); + run_guild_dot_command(&store, &in_world(GM), ".guild create \"Tracer Guild\"").unwrap(); assert_eq!(line, None); let guild = store.guild_named("Tracer Guild").expect("guild founded"); assert_eq!(guild.leader_guid, GM); @@ -2404,9 +2386,9 @@ mod tests { #[test] fn a_repeated_dot_create_of_the_same_name_answers_guild_not_created() { let store = realm(); - run_guild_dot_command(&store, in_world(GM), ".guild create \"Tracer Guild\"").unwrap(); + run_guild_dot_command(&store, &in_world(GM), ".guild create \"Tracer Guild\"").unwrap(); let line = - run_guild_dot_command(&store, in_world(GM), ".guild create \"tracer guild\"").unwrap(); + run_guild_dot_command(&store, &in_world(GM), ".guild create \"tracer guild\"").unwrap(); assert_eq!(line.as_deref(), Some("guild not created")); assert_eq!(store.guilds.lock().unwrap().len(), 1); assert_eq!(store.members.lock().unwrap().len(), 1); @@ -2415,10 +2397,13 @@ mod tests { #[test] fn dot_create_with_a_taken_name_in_another_case_answers_guild_not_created() { let store = realm(); - run_guild_dot_command(&store, in_world(GM), ".guild create Bob \"Tracer Guild\"").unwrap(); - let line = - run_guild_dot_command(&store, in_world(GM), ".guild create Carol \"tracer guild\"") - .unwrap(); + run_guild_dot_command(&store, &in_world(GM), ".guild create Bob \"Tracer Guild\"").unwrap(); + let line = run_guild_dot_command( + &store, + &in_world(GM), + ".guild create Carol \"tracer guild\"", + ) + .unwrap(); assert_eq!(line.as_deref(), Some("guild not created")); assert_eq!(store.guilds.lock().unwrap().len(), 1); assert_eq!(store.guild_member(CAROL).unwrap(), None); @@ -2429,7 +2414,7 @@ mod tests { let mut store = realm(); store.characters[1].online = false; let line = - run_guild_dot_command(&store, in_world(GM), ".guild create bob \"Knights\"").unwrap(); + run_guild_dot_command(&store, &in_world(GM), ".guild create bob \"Knights\"").unwrap(); assert_eq!(line, None); assert_eq!(store.guild_named("Knights").unwrap().leader_guid, BOB); let (_, request) = store.ops.lock().unwrap()[0].clone(); @@ -2451,7 +2436,7 @@ mod tests { let store = realm(); store.add_member(9, BOB, 3, ""); let line = - run_guild_dot_command(&store, in_world(GM), ".guild create Bob \"Knights\"").unwrap(); + run_guild_dot_command(&store, &in_world(GM), ".guild create Bob \"Knights\"").unwrap(); assert_eq!(line.as_deref(), Some("Bob is already in a guild")); assert!(store.guilds.lock().unwrap().is_empty()); } @@ -2460,7 +2445,7 @@ mod tests { fn dot_create_for_an_unknown_leader_answers_no_player_named() { let store = realm(); let line = - run_guild_dot_command(&store, in_world(GM), ".guild create Dave \"Knights\"").unwrap(); + run_guild_dot_command(&store, &in_world(GM), ".guild create Dave \"Knights\"").unwrap(); assert_eq!(line.as_deref(), Some("no player named Dave")); assert!(store.ops.lock().unwrap().is_empty()); } @@ -2469,7 +2454,7 @@ mod tests { fn dot_create_without_a_name_uses_the_selected_player() { let mut store = realm(); store.selected = CAROL; - run_guild_dot_command(&store, in_world(GM), ".guild create \"Knights\"").unwrap(); + run_guild_dot_command(&store, &in_world(GM), ".guild create \"Knights\"").unwrap(); assert_eq!(store.guild_named("Knights").unwrap().leader_guid, CAROL); } @@ -2477,7 +2462,7 @@ mod tests { fn dot_create_with_a_selected_creature_falls_back_to_the_actor() { let mut store = realm(); store.selected = 0xF130_0000_0000_0042; - run_guild_dot_command(&store, in_world(GM), ".guild create \"Knights\"").unwrap(); + run_guild_dot_command(&store, &in_world(GM), ".guild create \"Knights\"").unwrap(); assert_eq!(store.guild_named("Knights").unwrap().leader_guid, GM); } @@ -2485,7 +2470,7 @@ mod tests { fn dot_create_from_gm_level_zero_is_denied_before_any_request() { let store = realm(); let line = - run_guild_dot_command(&store, in_world(BOB), ".guild create \"Knights\"").unwrap(); + run_guild_dot_command(&store, &in_world(BOB), ".guild create \"Knights\"").unwrap(); assert_eq!(line.as_deref(), Some("permission denied")); assert!(store.ops.lock().unwrap().is_empty()); assert!(store.guilds.lock().unwrap().is_empty()); @@ -2498,7 +2483,7 @@ mod tests { ..realm() }; let line = - run_guild_dot_command(&store, in_world(GM), ".guild create \"Knights\"").unwrap(); + run_guild_dot_command(&store, &in_world(GM), ".guild create \"Knights\"").unwrap(); assert_eq!(line.as_deref(), Some("guild not created")); assert!(store.ops.lock().unwrap().is_empty()); } @@ -2509,7 +2494,7 @@ mod tests { facts_error: Some(lost_transport), ..realm() }; - let error = run_guild_dot_command(&store, in_world(GM), ".guild create \"Knights\"") + let error = run_guild_dot_command(&store, &in_world(GM), ".guild create \"Knights\"") .expect_err("a lost read is a Transport Loss"); assert_eq!(classify(&error), DurableFailure::TransportLoss); assert!(store.ops.lock().unwrap().is_empty()); @@ -2571,7 +2556,7 @@ mod tests { ".guild invite Bob", ] { assert_eq!( - run_guild_dot_command(&store, in_world(GM), text) + run_guild_dot_command(&store, &in_world(GM), text) .unwrap() .as_deref(), Some(GUILD_CREATE_USAGE), @@ -2593,7 +2578,7 @@ mod tests { fn a_lost_reducer_transport_ends_the_session() { let mut store = realm(); store.op_error = Some(lost_transport); - let error = run_guild_dot_command(&store, in_world(GM), ".guild create \"Knights\"") + let error = run_guild_dot_command(&store, &in_world(GM), ".guild create \"Knights\"") .expect_err("fatal"); assert_eq!(classify(&error), DurableFailure::TransportLoss); } @@ -2603,7 +2588,7 @@ mod tests { let mut store = realm(); store.op_error = Some(unknown_refusal); assert_eq!( - run_guild_dot_command(&store, in_world(GM), ".guild create \"Knights\"").unwrap(), + run_guild_dot_command(&store, &in_world(GM), ".guild create \"Knights\"").unwrap(), Some("guild not created".into()) ); } @@ -2613,12 +2598,12 @@ mod tests { let disband = || ClientOpcodeMessage::CMSG_GUILD_DISBAND; let mut store = realm(); store.op_error = Some(lost_transport); - assert!(dispatch_guild_action(&store, in_world(GM), disband()).is_err()); + assert!(Guild::handle(&store, &mut in_world(GM), disband().into()).is_err()); store.op_error = Some(unknown_refusal); - match dispatch_guild_action(&store, in_world(GM), disband()).unwrap() { - GuildActionOutcome::Handled { outbound } => assert!(outbound.is_empty()), - GuildActionOutcome::PassThrough(_) => panic!("expected Handled"), - } + assert!(Guild::handle(&store, &mut in_world(GM), disband().into()) + .unwrap() + .outbound + .is_empty()); } #[test] @@ -2653,11 +2638,8 @@ mod tests { #[test] fn guild_query_answers_any_guild_at_character_select() { let store = realm(); - run_guild_dot_command(&store, in_world(GM), ".guild create \"Knights\"").unwrap(); - let character_select = GuildActionPlayer { - account_id: 7, - self_guid: None, - }; + run_guild_dot_command(&store, &in_world(GM), ".guild create \"Knights\"").unwrap(); + let character_select = ProtocolSession::new(7, "TESTER".into()); let message = only_message(dispatch( &store, character_select, @@ -2694,7 +2676,7 @@ mod tests { store.characters[2].online = false; // A day and a half ago: 1.5 * 86_400_000_000 micros. store.characters[2].last_logout_micros = now_micros() - 129_600_000_000; - run_guild_dot_command(&store, in_world(GM), ".guild create \"Knights\"").unwrap(); + run_guild_dot_command(&store, &in_world(GM), ".guild create \"Knights\"").unwrap(); store.add_member(1, BOB, 3, "bob note"); store.add_member(1, CAROL, 4, "carol note"); store @@ -2826,21 +2808,6 @@ mod tests { ); } - #[test] - fn unrelated_opcode_passes_through() { - let store = realm(); - let outcome = dispatch_guild_action( - &store, - in_world(GM), - ClientOpcodeMessage::CMSG_PING(CMSG_PING::default()), - ) - .unwrap(); - assert!(matches!( - outcome, - GuildActionOutcome::PassThrough(ClientOpcodeMessage::CMSG_PING(_)) - )); - } - fn raw(outbound: Vec) -> Vec<(u16, Vec)> { outbound .into_iter() @@ -2936,7 +2903,7 @@ mod tests { fn activate_tabard_vendor( store: &InMemoryGuildActions, - player: GuildActionPlayer, + player: ProtocolSession, ) -> Vec { dispatch( store, @@ -2966,10 +2933,7 @@ mod tests { ..realm() }; assert!(activate_tabard_vendor(&store, in_world(BOB)).is_empty()); - let character_select = GuildActionPlayer { - account_id: 7, - self_guid: None, - }; + let character_select = ProtocolSession::new(7, "TESTER".into()); assert!(activate_tabard_vendor(&realm(), character_select).is_empty()); } @@ -3010,7 +2974,7 @@ mod tests { fee_refusal, ..realm() }; - run_guild_dot_command(&store, in_world(GM), ".guild create Bob \"Knights\"").unwrap(); + run_guild_dot_command(&store, &in_world(GM), ".guild create Bob \"Knights\"").unwrap(); store.add_member(1, CAROL, 3, ""); store } @@ -3021,7 +2985,7 @@ mod tests { fee_decide_error: Some(lost_transport), ..led_by_bob(None, None) }; - let error = dispatch_guild_action(&store, in_world(BOB), save_emblem_message()) + let error = Guild::handle(&store, &mut in_world(BOB), save_emblem_message().into()) .err() .expect("a Transport Loss is fatal"); assert_eq!(classify(&error), DurableFailure::TransportLoss); @@ -3108,7 +3072,7 @@ mod tests { fn invite_reaches_a_live_target_with_both_teams_and_no_reply() { let mut store = founded_with_members(); store.characters.push(facts(DAVE, "Dave")); - let outbound = invite_outbound(&store, in_world(GM), "Dave".into()).unwrap(); + let outbound = invite_outbound(&store, &in_world(GM), "Dave".into()).unwrap(); assert!(outbound.is_empty()); assert_eq!( store.ops.lock().unwrap().last().cloned(), @@ -3127,7 +3091,7 @@ mod tests { #[test] fn invite_of_an_unknown_name_answers_player_not_found() { let store = founded_with_members(); - let outbound = invite_outbound(&store, in_world(GM), "Nobody".into()).unwrap(); + let outbound = invite_outbound(&store, &in_world(GM), "Nobody".into()).unwrap(); assert_eq!( command_result_of(outbound), ( @@ -3143,7 +3107,7 @@ mod tests { let mut offline_dave = facts(DAVE, "Dave"); offline_dave.online = false; store.characters.push(offline_dave); - let outbound = invite_outbound(&store, in_world(GM), "Dave".into()).unwrap(); + let outbound = invite_outbound(&store, &in_world(GM), "Dave".into()).unwrap(); assert_eq!( command_result_of(outbound), ( @@ -3158,7 +3122,7 @@ mod tests { let mut store = founded_with_members(); store.characters.push(facts(DAVE, "Dave")); store.ignored.push((DAVE, GM)); - let outbound = invite_outbound(&store, in_world(GM), "Dave".into()).unwrap(); + let outbound = invite_outbound(&store, &in_world(GM), "Dave".into()).unwrap(); assert!(outbound.is_empty()); assert_eq!( store.ops.lock().unwrap().last().cloned(), @@ -3179,7 +3143,7 @@ mod tests { let mut store = founded_with_members(); store.characters.push(horde_facts(DAVE, "Dave")); store.refuse_next_op(GuildRefusal::NotAllied); - let outbound = invite_outbound(&store, in_world(GM), "dave".into()).unwrap(); + let outbound = invite_outbound(&store, &in_world(GM), "dave".into()).unwrap(); assert_eq!( store.ops.lock().unwrap().last().cloned(), Some(( @@ -3204,7 +3168,7 @@ mod tests { fn invite_of_a_guilded_target_maps_already_in_guild_to_its_resolved_name() { let store = founded_with_members(); store.refuse_next_op(GuildRefusal::AlreadyInGuild); - let outbound = invite_outbound(&store, in_world(GM), "bob".into()).unwrap(); + let outbound = invite_outbound(&store, &in_world(GM), "bob".into()).unwrap(); let ServerOpcodeMessage::SMSG_GUILD_COMMAND_RESULT(result) = only_message(outbound) else { panic!("expected a command result"); }; @@ -3217,7 +3181,7 @@ mod tests { let mut store = founded_with_members(); store.characters.push(facts(DAVE, "Dave")); store.refuse_next_op(GuildRefusal::AlreadyInvited); - let outbound = invite_outbound(&store, in_world(GM), "Dave".into()).unwrap(); + let outbound = invite_outbound(&store, &in_world(GM), "Dave".into()).unwrap(); assert_eq!( command_result_of(outbound), ( @@ -3232,7 +3196,7 @@ mod tests { let mut store = founded_with_members(); store.characters.push(facts(DAVE, "Dave")); store.refuse_next_op(GuildRefusal::NoPermission); - let outbound = invite_outbound(&store, in_world(BOB), "Dave".into()).unwrap(); + let outbound = invite_outbound(&store, &in_world(BOB), "Dave".into()).unwrap(); assert_eq!( command_result_of(outbound), ( @@ -3247,7 +3211,7 @@ mod tests { let mut store = founded_with_members(); store.characters.push(facts(DAVE, "Dave")); store.refuse_next_op(GuildRefusal::NotInGuild); - let outbound = invite_outbound(&store, in_world(DAVE), "Bob".into()).unwrap(); + let outbound = invite_outbound(&store, &in_world(DAVE), "Bob".into()).unwrap(); assert_eq!( command_result_of(outbound), ( @@ -3261,7 +3225,7 @@ mod tests { fn accept_conveys_the_actors_name_and_team_and_replies_nothing() { let mut store = founded_with_members(); store.characters.push(facts(DAVE, "Dave")); - let outbound = accept_outbound(&store, in_world(DAVE)).unwrap(); + let outbound = accept_outbound(&store, &in_world(DAVE)).unwrap(); assert!(outbound.is_empty()); assert_eq!( store.ops.lock().unwrap().last().cloned(), @@ -3279,7 +3243,7 @@ mod tests { fn decline_conveys_the_actors_name_and_replies_nothing() { let mut store = founded_with_members(); store.characters.push(facts(DAVE, "Dave")); - let outbound = decline_outbound(&store, in_world(DAVE)).unwrap(); + let outbound = decline_outbound(&store, &in_world(DAVE)).unwrap(); assert!(outbound.is_empty()); assert_eq!( store.ops.lock().unwrap().last().cloned(), @@ -3295,7 +3259,7 @@ mod tests { #[test] fn leave_of_an_ordinary_member_replies_quit_success() { let store = founded_with_members(); - let outbound = leave_outbound(&store, in_world(BOB)).unwrap(); + let outbound = leave_outbound(&store, &in_world(BOB)).unwrap(); assert_eq!( command_result_of(outbound), (GuildCommand::Quit, GuildCommandResult::PlayerNoMoreInGuild) @@ -3306,7 +3270,7 @@ mod tests { fn leave_of_the_leader_with_company_answers_leader_cannot_leave() { let store = founded_with_members(); store.refuse_next_op(GuildRefusal::LeaderCannotLeave); - let outbound = leave_outbound(&store, in_world(GM)).unwrap(); + let outbound = leave_outbound(&store, &in_world(GM)).unwrap(); assert_eq!( command_result_of(outbound), ( @@ -3319,8 +3283,8 @@ mod tests { #[test] fn leave_of_a_lone_leader_disbands_silently() { let store = realm(); - run_guild_dot_command(&store, in_world(GM), ".guild create \"Solo\"").unwrap(); - let outbound = leave_outbound(&store, in_world(GM)).unwrap(); + run_guild_dot_command(&store, &in_world(GM), ".guild create \"Solo\"").unwrap(); + let outbound = leave_outbound(&store, &in_world(GM)).unwrap(); assert!(outbound.is_empty()); } @@ -3328,7 +3292,7 @@ mod tests { fn leave_outside_a_guild_answers_not_in_guild() { let mut store = founded_with_members(); store.characters.push(facts(DAVE, "Dave")); - let outbound = leave_outbound(&store, in_world(DAVE)).unwrap(); + let outbound = leave_outbound(&store, &in_world(DAVE)).unwrap(); assert_eq!( command_result_of(outbound), ( @@ -3344,7 +3308,7 @@ mod tests { store.refuse_next_op(GuildRefusal::TargetNotInGuild); let outbound = named_member_op( &store, - in_world(GM), + &in_world(GM), "Nobody".into(), GuildOpKind::Remove, |target_guid| GuildRequest::Remove { target_guid }, @@ -3368,7 +3332,7 @@ mod tests { let store = founded_with_members(); let outbound = named_member_op( &store, - in_world(GM), + &in_world(GM), snapshot_name(BOB), GuildOpKind::Remove, |target_guid| GuildRequest::Remove { target_guid }, @@ -3387,7 +3351,7 @@ mod tests { store.refuse_next_op(GuildRefusal::NoPermission); let outbound = named_member_op( &store, - in_world(BOB), + &in_world(BOB), snapshot_name(CAROL), GuildOpKind::Remove, |target_guid| GuildRequest::Remove { target_guid }, @@ -3408,7 +3372,7 @@ mod tests { store.refuse_next_op(GuildRefusal::LeaderCannotLeave); let outbound = named_member_op( &store, - in_world(GM), + &in_world(GM), snapshot_name(GM), GuildOpKind::Remove, |target_guid| GuildRequest::Remove { target_guid }, @@ -3429,7 +3393,7 @@ mod tests { store.refuse_next_op(GuildRefusal::RankTooHigh); let outbound = named_member_op( &store, - in_world(BOB), + &in_world(BOB), snapshot_name(CAROL), GuildOpKind::Remove, |target_guid| GuildRequest::Remove { target_guid }, @@ -3446,7 +3410,7 @@ mod tests { let store = founded_with_members(); let outbound = named_member_op( &store, - in_world(GM), + &in_world(GM), snapshot_name(BOB), GuildOpKind::Promote, |target_guid| GuildRequest::Promote { target_guid }, @@ -3465,7 +3429,7 @@ mod tests { store.refuse_next_op(GuildRefusal::RankTooHigh); let outbound = named_member_op( &store, - in_world(BOB), + &in_world(BOB), snapshot_name(CAROL), GuildOpKind::Promote, |target_guid| GuildRequest::Promote { target_guid }, @@ -3483,7 +3447,7 @@ mod tests { store.refuse_next_op(GuildRefusal::TargetIsSelf); let outbound = named_member_op( &store, - in_world(GM), + &in_world(GM), snapshot_name(GM), GuildOpKind::Promote, |target_guid| GuildRequest::Promote { target_guid }, @@ -3500,7 +3464,7 @@ mod tests { let store = founded_with_members(); let outbound = named_member_op( &store, - in_world(GM), + &in_world(GM), snapshot_name(BOB), GuildOpKind::Demote, |target_guid| GuildRequest::Demote { target_guid }, @@ -3519,7 +3483,7 @@ mod tests { store.refuse_next_op(GuildRefusal::RankTooLow); let outbound = named_member_op( &store, - in_world(GM), + &in_world(GM), snapshot_name(CAROL), GuildOpKind::Demote, |target_guid| GuildRequest::Demote { target_guid }, @@ -3536,7 +3500,7 @@ mod tests { let store = founded_with_members(); let outbound = named_member_op( &store, - in_world(GM), + &in_world(GM), snapshot_name(BOB), GuildOpKind::Leader, |target_guid| GuildRequest::SetLeader { target_guid }, @@ -3555,7 +3519,7 @@ mod tests { store.refuse_next_op(GuildRefusal::NotLeader); let outbound = named_member_op( &store, - in_world(BOB), + &in_world(BOB), snapshot_name(CAROL), GuildOpKind::Leader, |target_guid| GuildRequest::SetLeader { target_guid }, @@ -3575,7 +3539,7 @@ mod tests { let store = founded_with_members(); let outbound = named_member_op( &store, - in_world(GM), + &in_world(GM), snapshot_name(GM), GuildOpKind::Leader, |target_guid| GuildRequest::SetLeader { target_guid }, @@ -3590,7 +3554,7 @@ mod tests { store.refuse_next_op(GuildRefusal::TargetNotInGuild); let outbound = named_member_op( &store, - in_world(GM), + &in_world(GM), "Nobody".into(), GuildOpKind::Leader, |target_guid| GuildRequest::SetLeader { target_guid }, @@ -3612,7 +3576,7 @@ mod tests { #[test] fn disband_by_the_leader_replies_nothing() { let store = founded_with_members(); - let outbound = disband_outbound(&store, in_world(GM)).unwrap(); + let outbound = disband_outbound(&store, &in_world(GM)).unwrap(); assert!(outbound.is_empty()); } @@ -3620,7 +3584,7 @@ mod tests { fn disband_by_a_non_leader_answers_no_permission() { let store = founded_with_members(); store.refuse_next_op(GuildRefusal::NotLeader); - let outbound = disband_outbound(&store, in_world(BOB)).unwrap(); + let outbound = disband_outbound(&store, &in_world(BOB)).unwrap(); assert_eq!( command_result_of(outbound), ( @@ -3634,7 +3598,7 @@ mod tests { fn disband_outside_a_guild_answers_not_in_guild() { let store = founded_with_members(); store.refuse_next_op(GuildRefusal::NotInGuild); - let outbound = disband_outbound(&store, in_world(DAVE)).unwrap(); + let outbound = disband_outbound(&store, &in_world(DAVE)).unwrap(); assert_eq!( command_result_of(outbound), ( @@ -3701,7 +3665,7 @@ mod tests { invited_player: "Dave".into(), }, )); - let error = dispatch_guild_action(&store, in_world(GM), msg) + let error = Guild::handle(&store, &mut in_world(GM), msg.into()) .err() .expect("a lost read is a Transport Loss"); assert_eq!(classify(&error), DurableFailure::TransportLoss); @@ -4387,10 +4351,7 @@ mod tests { let query = |charter: u64| { dispatch( &store, - GuildActionPlayer { - account_id: 7, - self_guid: None, - }, + ProtocolSession::new(7, "TESTER".into()), ClientOpcodeMessage::CMSG_PETITION_QUERY(Box::new(CMSG_PETITION_QUERY { guild_id: 42, petition: Guid::new(charter), diff --git a/gateway/src/world/handlers/item.rs b/gateway/src/world/handlers/item.rs index 65b046e2a..c4f29aab2 100644 --- a/gateway/src/world/handlers/item.rs +++ b/gateway/src/world/handlers/item.rs @@ -1,7 +1,7 @@ //! Item-action dispatcher: protocol mapping and feedback for equip, use, and inventory operations. use super::super::*; -use super::quest::{item_started_quest, QuestActionPlayer, QuestActionStore}; +use super::quest::{item_started_quest, QuestActionStore}; use lyracore_shared::item::ItemRefusal; const MAIN_BAG: u8 = 255; // INVENTORY_SLOT_BAG_0 — backpack + equipped slots share this pseudo-bag @@ -36,25 +36,14 @@ pub(crate) trait ItemActionStore: Send + Sync { ) -> Result; } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) struct ItemActionPlayer { - pub(crate) account_id: u64, - pub(crate) self_guid: Option, -} - -pub(crate) enum ItemActionOutcome { - Handled { outbound: Vec }, - PassThrough(ClientOpcodeMessage), -} - /// Run one durable item request as the session's Actor and render its answer. A session without /// a Character in the world has nothing to act as, so the client gets an internal-error Refusal. fn act( - player: ItemActionPlayer, + player: &ProtocolSession, operation: &str, request: impl FnOnce(Actor) -> Result, -) -> Result { - let result = match player.self_guid.and_then(Actor::new) { +) -> Result { + let result = match player.self_guid().and_then(Actor::new) { Some(actor) => request(actor)?, None => { log::warn!( @@ -64,16 +53,20 @@ fn act( ItemRefusal::Internal.into() } }; - Ok(ItemActionOutcome::Handled { - outbound: inventory_action_outbound(player.account_id, operation, result), - }) + Ok(ProtocolReply::from(inventory_action_outbound( + player.account_id, + operation, + result, + ))) } /// A request the Gateway refuses before any Durable Request, such as a bag position it cannot map. -fn refuse(player: ItemActionPlayer, operation: &str, refusal: ItemRefusal) -> ItemActionOutcome { - ItemActionOutcome::Handled { - outbound: inventory_action_outbound(player.account_id, operation, refusal.into()), - } +fn refuse(player: &ProtocolSession, operation: &str, refusal: ItemRefusal) -> ProtocolReply { + ProtocolReply::from(inventory_action_outbound( + player.account_id, + operation, + refusal.into(), + )) } /// A Refusal answers the client with its own result code; a completed action answers nothing. @@ -98,75 +91,77 @@ fn inventory_action_outbound( } } -/// Quest-starting items are the one item action the quest family owns, so the store must answer -/// both vocabularies. Every `WorldStore` already does. -pub(crate) fn dispatch_item_action( - store: &St, - player: ItemActionPlayer, - msg: ClientOpcodeMessage, -) -> Result { - match msg { - ClientOpcodeMessage::CMSG_DESTROYITEM(c) => match codec::inventory_slot(c.bag, c.slot) { - Some(slot) => act(player, "destroy_item", |actor| { - store.destroy_item(actor, slot, u32::from(c.amount)) - }), - None => Ok(refuse(player, "destroy_item", ItemRefusal::WrongSlot)), - }, - ClientOpcodeMessage::CMSG_SPLIT_ITEM(c) => match ( - codec::inventory_slot(c.source_bag, c.source_slot), - codec::inventory_slot(c.destination_bag, c.destination_slot), - ) { - (Some(source), Some(destination)) => act(player, "split_item", |actor| { - store.split_item(actor, source, destination, u32::from(c.amount)) - }), - _ => Ok(refuse(player, "split_item", ItemRefusal::WrongSlot)), - }, - ClientOpcodeMessage::CMSG_AUTOEQUIP_ITEM(c) => { - match codec::inventory_slot(c.source_bag, c.source_slot) { - Some(slot) => act(player, "equip_item", |actor| store.equip_item(actor, slot)), - None => Ok(refuse(player, "equip_item", ItemRefusal::WrongSlot)), +/// A quest-starting item needs the quest operation as well as inventory operations. +pub(crate) struct Item; + +impl ProtocolFamily for Item { + fn handle( + store: &St, + session: &mut ProtocolSession, + request: ProtocolRequest, + ) -> Result { + let player = &*session; + let msg = request.message()?; + match msg { + ClientOpcodeMessage::CMSG_DESTROYITEM(c) => { + match codec::inventory_slot(c.bag, c.slot) { + Some(slot) => act(player, "destroy_item", |actor| { + store.destroy_item(actor, slot, u32::from(c.amount)) + }), + None => Ok(refuse(player, "destroy_item", ItemRefusal::WrongSlot)), + } } - } - ClientOpcodeMessage::CMSG_AUTOSTORE_BAG_ITEM(c) - if c.source_bag == MAIN_BAG - && c.destination_bag == MAIN_BAG - && c.source_slot <= EQUIP_SLOT_END => - { - act(player, "unequip_item", |actor| { - store.unequip_item(actor, c.source_slot) - }) - } - ClientOpcodeMessage::CMSG_AUTOSTORE_BAG_ITEM(_) => { - Ok(refuse(player, "autostore", ItemRefusal::NotRightNow)) - } - ClientOpcodeMessage::CMSG_SWAP_INV_ITEM(c) => act(player, "move_item", |actor| { - store.move_item(actor, c.source_slot.as_int(), c.destination_slot.as_int()) - }), - ClientOpcodeMessage::CMSG_SWAP_ITEM(c) => match ( - codec::inventory_slot(c.source_bag, c.source_slot), - codec::inventory_slot(c.destination_bag, c.destionation_slot), - ) { - (Some(source), Some(destination)) => act(player, "move_item", |actor| { - store.move_item(actor, source, destination) + ClientOpcodeMessage::CMSG_SPLIT_ITEM(c) => match ( + codec::inventory_slot(c.source_bag, c.source_slot), + codec::inventory_slot(c.destination_bag, c.destination_slot), + ) { + (Some(source), Some(destination)) => act(player, "split_item", |actor| { + store.split_item(actor, source, destination, u32::from(c.amount)) + }), + _ => Ok(refuse(player, "split_item", ItemRefusal::WrongSlot)), + }, + ClientOpcodeMessage::CMSG_AUTOEQUIP_ITEM(c) => { + match codec::inventory_slot(c.source_bag, c.source_slot) { + Some(slot) => act(player, "equip_item", |actor| store.equip_item(actor, slot)), + None => Ok(refuse(player, "equip_item", ItemRefusal::WrongSlot)), + } + } + ClientOpcodeMessage::CMSG_AUTOSTORE_BAG_ITEM(c) + if c.source_bag == MAIN_BAG + && c.destination_bag == MAIN_BAG + && c.source_slot <= EQUIP_SLOT_END => + { + act(player, "unequip_item", |actor| { + store.unequip_item(actor, c.source_slot) + }) + } + ClientOpcodeMessage::CMSG_AUTOSTORE_BAG_ITEM(_) => { + Ok(refuse(player, "autostore", ItemRefusal::NotRightNow)) + } + ClientOpcodeMessage::CMSG_SWAP_INV_ITEM(c) => act(player, "move_item", |actor| { + store.move_item(actor, c.source_slot.as_int(), c.destination_slot.as_int()) }), - _ => Ok(refuse(player, "move_item", ItemRefusal::WrongSlot)), - }, - // Using an item that starts a quest opens that quest instead of consuming the item, so the - // quest family gets first refusal on the slot. - ClientOpcodeMessage::CMSG_USE_ITEM(c) => { - let Some(slot) = codec::inventory_slot(c.bag_index, c.bag_slot) else { - return Ok(refuse(player, "use_item", ItemRefusal::WrongSlot)); - }; - let quest_player = QuestActionPlayer { - account_id: player.account_id, - self_guid: player.self_guid, - }; - match item_started_quest(store, quest_player, slot)? { - Some(outbound) => Ok(ItemActionOutcome::Handled { outbound }), - None => act(player, "use_item", |actor| store.use_item(actor, slot)), + ClientOpcodeMessage::CMSG_SWAP_ITEM(c) => match ( + codec::inventory_slot(c.source_bag, c.source_slot), + codec::inventory_slot(c.destination_bag, c.destionation_slot), + ) { + (Some(source), Some(destination)) => act(player, "move_item", |actor| { + store.move_item(actor, source, destination) + }), + _ => Ok(refuse(player, "move_item", ItemRefusal::WrongSlot)), + }, + // A quest-starting item opens quest details without consuming the item. + ClientOpcodeMessage::CMSG_USE_ITEM(c) => { + let Some(slot) = codec::inventory_slot(c.bag_index, c.bag_slot) else { + return Ok(refuse(player, "use_item", ItemRefusal::WrongSlot)); + }; + match item_started_quest(store, player, slot)? { + Some(outbound) => Ok(ProtocolReply::from(outbound)), + None => act(player, "use_item", |actor| store.use_item(actor, slot)), + } } + other => Err(anyhow!("opcode routed to wrong Protocol Family: {other}")), } - other => Ok(ItemActionOutcome::PassThrough(other)), } } @@ -176,8 +171,8 @@ pub(super) mod tests { use crate::stdb::{classify, DurableFailure, ReducerCallError}; use std::sync::Mutex; use wow_world_messages::vanilla::{ - ItemSlot, CMSG_AUTOEQUIP_ITEM, CMSG_AUTOSTORE_BAG_ITEM, CMSG_PING, CMSG_SWAP_INV_ITEM, - CMSG_SWAP_ITEM, CMSG_USE_ITEM, + ItemSlot, CMSG_AUTOEQUIP_ITEM, CMSG_AUTOSTORE_BAG_ITEM, CMSG_SWAP_INV_ITEM, CMSG_SWAP_ITEM, + CMSG_USE_ITEM, }; #[derive(Default)] @@ -316,11 +311,8 @@ pub(super) mod tests { } } - fn player() -> ItemActionPlayer { - ItemActionPlayer { - account_id: 7, - self_guid: Some(42), - } + fn player() -> ProtocolSession { + ProtocolSession::in_world(7, 42) } fn equip(source_bag: u8) -> ClientOpcodeMessage { @@ -375,23 +367,20 @@ pub(super) mod tests { } } - fn handled_without_outbound(outcome: ItemActionOutcome) { - assert!(matches!(outcome, ItemActionOutcome::Handled { outbound } if outbound.is_empty())); + fn handled_without_outbound(outcome: ProtocolReply) { + assert!(outcome.outbound.is_empty()); } - fn inventory_failure(outcome: ItemActionOutcome) { + fn inventory_failure(outcome: ProtocolReply) { assert!(matches!( outcome, - ItemActionOutcome::Handled { outbound } + ProtocolReply { outbound, .. } if matches!(outbound.as_slice(), [Outbound::One(ServerOpcodeMessage::SMSG_INVENTORY_CHANGE_FAILURE(_))]) )); } - fn handled_outbound(outcome: ItemActionOutcome) -> Vec { - match outcome { - ItemActionOutcome::Handled { outbound } => outbound, - ItemActionOutcome::PassThrough(_) => panic!("an item action must be handled here"), - } + fn handled_outbound(outcome: ProtocolReply) -> Vec { + outcome.outbound } fn assert_no_durable_requests(actions: &InMemoryItemActions) { @@ -407,7 +396,7 @@ pub(super) mod tests { fn equip_from_the_main_bag_requests_the_durable_equip_for_the_actor_and_slot() { let actions = InMemoryItemActions::default(); - let outcome = dispatch_item_action(&actions, player(), equip(MAIN_BAG)).unwrap(); + let outcome = Item::handle(&actions, &mut player(), equip(MAIN_BAG).into()).unwrap(); handled_without_outbound(outcome); assert_eq!( @@ -423,14 +412,14 @@ pub(super) mod tests { ..Default::default() }; - inventory_failure(dispatch_item_action(&actions, player(), equip(MAIN_BAG)).unwrap()); + inventory_failure(Item::handle(&actions, &mut player(), equip(MAIN_BAG).into()).unwrap()); } #[test] fn equip_from_an_invalid_bag_position_returns_a_refusal() { let actions = InMemoryItemActions::default(); - inventory_failure(dispatch_item_action(&actions, player(), equip(19)).unwrap()); + inventory_failure(Item::handle(&actions, &mut player(), equip(19).into()).unwrap()); assert_no_durable_requests(&actions); } @@ -441,7 +430,7 @@ pub(super) mod tests { fn unequip_of_an_equipped_slot_requests_the_durable_unequip() { let actions = InMemoryItemActions::default(); - let outcome = dispatch_item_action(&actions, player(), unequip(15, MAIN_BAG)).unwrap(); + let outcome = Item::handle(&actions, &mut player(), unequip(15, MAIN_BAG).into()).unwrap(); handled_without_outbound(outcome); assert_eq!( @@ -457,14 +446,18 @@ pub(super) mod tests { ..Default::default() }; - inventory_failure(dispatch_item_action(&actions, player(), unequip(15, MAIN_BAG)).unwrap()); + inventory_failure( + Item::handle(&actions, &mut player(), unequip(15, MAIN_BAG).into()).unwrap(), + ); } #[test] fn unequip_from_a_backpack_slot_requests_no_durable_operation() { let actions = InMemoryItemActions::default(); - inventory_failure(dispatch_item_action(&actions, player(), unequip(24, MAIN_BAG)).unwrap()); + inventory_failure( + Item::handle(&actions, &mut player(), unequip(24, MAIN_BAG).into()).unwrap(), + ); assert_no_durable_requests(&actions); } @@ -473,7 +466,7 @@ pub(super) mod tests { fn unequip_into_a_sub_bag_requests_no_durable_operation() { let actions = InMemoryItemActions::default(); - inventory_failure(dispatch_item_action(&actions, player(), unequip(15, 19)).unwrap()); + inventory_failure(Item::handle(&actions, &mut player(), unequip(15, 19).into()).unwrap()); assert_no_durable_requests(&actions); } @@ -483,14 +476,15 @@ pub(super) mod tests { let actions = InMemoryItemActions::default(); inventory_failure( - dispatch_item_action( + Item::handle( &actions, - player(), + &mut player(), ClientOpcodeMessage::CMSG_AUTOSTORE_BAG_ITEM(CMSG_AUTOSTORE_BAG_ITEM { source_bag: 19, source_slot: 0, destination_bag: MAIN_BAG, - }), + }) + .into(), ) .unwrap(), ); @@ -504,13 +498,14 @@ pub(super) mod tests { fn move_maps_the_wire_slots_to_the_durable_move() { let actions = InMemoryItemActions::default(); - let outcome = dispatch_item_action( + let outcome = Item::handle( &actions, - player(), + &mut player(), ClientOpcodeMessage::CMSG_SWAP_INV_ITEM(CMSG_SWAP_INV_ITEM { source_slot: ItemSlot::MainHand, destination_slot: ItemSlot::Inventory1, - }), + }) + .into(), ) .unwrap(); @@ -533,13 +528,14 @@ pub(super) mod tests { }; inventory_failure( - dispatch_item_action( + Item::handle( &actions, - player(), + &mut player(), ClientOpcodeMessage::CMSG_SWAP_INV_ITEM(CMSG_SWAP_INV_ITEM { source_slot: ItemSlot::Inventory0, destination_slot: ItemSlot::MainHand, - }), + }) + .into(), ) .unwrap(), ); @@ -551,7 +547,7 @@ pub(super) mod tests { fn swap_within_the_main_bag_maps_both_slots_to_the_durable_move() { let actions = InMemoryItemActions::default(); - let outcome = dispatch_item_action(&actions, player(), swap(MAIN_BAG)).unwrap(); + let outcome = Item::handle(&actions, &mut player(), swap(MAIN_BAG).into()).unwrap(); handled_without_outbound(outcome); assert_eq!( @@ -567,14 +563,14 @@ pub(super) mod tests { ..Default::default() }; - inventory_failure(dispatch_item_action(&actions, player(), swap(MAIN_BAG)).unwrap()); + inventory_failure(Item::handle(&actions, &mut player(), swap(MAIN_BAG).into()).unwrap()); } #[test] fn invalid_bag_position_returns_a_refusal_without_a_durable_request() { let actions = InMemoryItemActions::default(); - inventory_failure(dispatch_item_action(&actions, player(), swap(19)).unwrap()); + inventory_failure(Item::handle(&actions, &mut player(), swap(19).into()).unwrap()); assert_no_durable_requests(&actions); } @@ -585,7 +581,7 @@ pub(super) mod tests { fn use_from_the_main_bag_requests_the_durable_use_for_the_actor_and_slot() { let actions = InMemoryItemActions::default(); - let outcome = dispatch_item_action(&actions, player(), use_item(MAIN_BAG)).unwrap(); + let outcome = Item::handle(&actions, &mut player(), use_item(MAIN_BAG).into()).unwrap(); handled_without_outbound(outcome); assert_eq!(actions.use_requests.lock().unwrap().as_slice(), &[(42, 5)]); @@ -598,14 +594,18 @@ pub(super) mod tests { ..Default::default() }; - inventory_failure(dispatch_item_action(&actions, player(), use_item(MAIN_BAG)).unwrap()); + inventory_failure( + Item::handle(&actions, &mut player(), use_item(MAIN_BAG).into()).unwrap(), + ); } #[test] fn use_from_a_bag_resolves_its_slot_before_the_durable_request() { let actions = InMemoryItemActions::default(); - handled_without_outbound(dispatch_item_action(&actions, player(), use_item(19)).unwrap()); + handled_without_outbound( + Item::handle(&actions, &mut player(), use_item(19).into()).unwrap(), + ); assert_eq!( actions.use_requests.lock().unwrap().as_slice(), @@ -623,11 +623,11 @@ pub(super) mod tests { ..Default::default() }; - let outcome = dispatch_item_action(&actions, player(), use_item(MAIN_BAG)).unwrap(); + let outcome = Item::handle(&actions, &mut player(), use_item(MAIN_BAG).into()).unwrap(); assert!(matches!( outcome, - ItemActionOutcome::Handled { outbound } + ProtocolReply { outbound, .. } if matches!(outbound.as_slice(), [Outbound::Raw { opcode: 0x0188, body }] if body[..8] == 0x4000_0000_0000_0099u64.to_le_bytes() && body[8..12] == 1234u32.to_le_bytes()) @@ -642,7 +642,7 @@ pub(super) mod tests { ..Default::default() }; - let outcome = dispatch_item_action(&actions, player(), use_item(MAIN_BAG)).unwrap(); + let outcome = Item::handle(&actions, &mut player(), use_item(MAIN_BAG).into()).unwrap(); handled_without_outbound(outcome); assert!(actions.use_requests.lock().unwrap().is_empty()); @@ -653,10 +653,7 @@ pub(super) mod tests { #[test] fn unresolved_player_requests_nothing_and_every_inventory_action_answers_a_refusal() { let actions = InMemoryItemActions::default(); - let player = ItemActionPlayer { - account_id: 7, - self_guid: None, - }; + let mut player = ProtocolSession::new(7, "TESTER".into()); for msg in [ equip(MAIN_BAG), @@ -668,7 +665,7 @@ pub(super) mod tests { swap(MAIN_BAG), use_item(MAIN_BAG), ] { - inventory_failure(dispatch_item_action(&actions, player, msg).unwrap()); + inventory_failure(Item::handle(&actions, &mut player, msg.into()).unwrap()); } assert_no_durable_requests(&actions); @@ -681,12 +678,9 @@ pub(super) mod tests { quest_detail: Some(quest_detail(1234)), ..Default::default() }; - let player = ItemActionPlayer { - account_id: 7, - self_guid: None, - }; + let mut player = ProtocolSession::new(7, "TESTER".into()); - let outcome = dispatch_item_action(&actions, player, use_item(MAIN_BAG)).unwrap(); + let outcome = Item::handle(&actions, &mut player, use_item(MAIN_BAG).into()).unwrap(); inventory_failure(outcome); assert_no_durable_requests(&actions); @@ -777,7 +771,7 @@ pub(super) mod tests { }; let outbound = handled_outbound( - dispatch_item_action(&actions, player(), equip(MAIN_BAG)).unwrap(), + Item::handle(&actions, &mut player(), equip(MAIN_BAG).into()).unwrap(), ); assert!( @@ -797,7 +791,7 @@ pub(super) mod tests { transport_lost: true, ..Default::default() }; - let error = match dispatch_item_action(&lost, player(), equip(MAIN_BAG)) { + let error = match Item::handle(&lost, &mut player(), equip(MAIN_BAG).into()) { Err(error) => error, Ok(_) => panic!("a lost transport must end the session"), }; @@ -807,25 +801,8 @@ pub(super) mod tests { equip_result: Some(ItemRefusal::CannotEquip.into()), ..Default::default() }; - inventory_failure(dispatch_item_action(&refusing, player(), equip(MAIN_BAG)).unwrap()); + inventory_failure(Item::handle(&refusing, &mut player(), equip(MAIN_BAG).into()).unwrap()); } // ── Pass-through ───────────────────────────────────────────────────────── - - #[test] - fn unrelated_opcodes_pass_through_to_the_next_dispatcher() { - let actions = InMemoryItemActions::default(); - - let outcome = dispatch_item_action( - &actions, - player(), - ClientOpcodeMessage::CMSG_PING(CMSG_PING::default()), - ) - .unwrap(); - - assert!(matches!( - outcome, - ItemActionOutcome::PassThrough(ClientOpcodeMessage::CMSG_PING(_)) - )); - } } diff --git a/gateway/src/world/handlers/loot.rs b/gateway/src/world/handlers/loot.rs index 656e69343..ef71080c3 100644 --- a/gateway/src/world/handlers/loot.rs +++ b/gateway/src/world/handlers/loot.rs @@ -2,6 +2,7 @@ use super::super::*; use super::combat::ignore_refusal; +use crate::world::family::{ProtocolFamily, ProtocolReply, ProtocolRequest, ProtocolSession}; use lyracore_shared::loot::LootRefusal; use wow_world_messages::vanilla::LootMethodError; @@ -85,30 +86,6 @@ pub(crate) struct OpenLootState { pub(crate) target_guid: Option, } -/// Authenticated player facts needed by loot-window operations. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) struct LootWindowPlayer { - pub(crate) account_id: u64, - pub(crate) self_guid: Option, -} - -impl LootWindowPlayer { - fn actor(self) -> Option { - self.self_guid.and_then(Actor::new) - } -} - -/// Records the durable request already executed while producing an outcome; it is not a command -/// for the outcome consumer to execute again. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum LootWindowDurableRequest { - UseGameObject { target_guid: u64 }, - OpenCreature { target_guid: u64 }, - SkinCreature { target_guid: u64 }, - TakeMoney { target_guid: u64 }, - TakeItem { target_guid: u64, loot_slot: u8 }, -} - /// The client traffic a loot Refusal earns. An unanswered Refusal stays silent. fn refusal_outbound(refusal: LootWindowRefusal, target_guid: u64) -> Vec { refusal @@ -135,10 +112,10 @@ fn refusal_transition( (next_state, refusal_outbound(refusal, target_guid)) } -fn log_refusal(player: LootWindowPlayer, refusal: LootWindowRefusal) { +fn log_refusal(session: &ProtocolSession, refusal: LootWindowRefusal) { log::debug!( "world: loot request refused (account {}): {refusal:?}", - player.account_id + session.account_id ); } @@ -148,352 +125,323 @@ fn finish_loot_action(status: LootActionStatus) { } } -/// A handled loot request returns all session state and client traffic to apply in order. -pub(crate) enum LootWindowOutcome { - Handled { - next_state: OpenLootState, - durable_request: Option, - outbound: Vec, - }, - PassThrough(ClientOpcodeMessage), -} - /// Map a client request through the loot-window lifecycle without owning session transport. -pub(crate) fn dispatch_loot_window( - store: &St, - player: LootWindowPlayer, - current_state: OpenLootState, - msg: ClientOpcodeMessage, -) -> Result { - match msg { - ClientOpcodeMessage::CMSG_GAMEOBJ_USE(request) => { - let Some(actor) = player.actor() else { - return Ok(LootWindowOutcome::Handled { - next_state: current_state, - durable_request: None, - outbound: Vec::new(), - }); - }; - let target_guid = request.guid.guid(); - let durable_request = Some(LootWindowDurableRequest::UseGameObject { target_guid }); - if let LootWindowRequestStatus::Refused(refusal) = - store.use_gameobject(actor, target_guid)? - { - log_refusal(player, refusal); - let (next_state, outbound) = - refusal_transition(refusal, current_state, target_guid); - return Ok(LootWindowOutcome::Handled { - next_state, - durable_request, - outbound, - }); - } - let items = store.loot_target_items(target_guid, actor.guid())?; - if items.is_empty() { - return Ok(LootWindowOutcome::Handled { - next_state: current_state, - durable_request, - outbound: Vec::new(), - }); - } - let (opcode, body) = codec::build_loot_response_raw(target_guid, 0, &items); - Ok(LootWindowOutcome::Handled { - next_state: OpenLootState { - target_guid: Some(target_guid), - }, - durable_request, - outbound: vec![Outbound::Raw { opcode, body }], - }) - } - ClientOpcodeMessage::CMSG_LOOT(request) => { - let Some(viewer) = player.actor() else { - return Ok(LootWindowOutcome::Handled { - next_state: current_state, - durable_request: None, - outbound: Vec::new(), - }); - }; - let target_guid = request.guid.guid(); - let open_request = LootWindowDurableRequest::OpenCreature { target_guid }; - if let LootWindowRequestStatus::Refused(refusal) = - store.open_creature_loot(viewer, target_guid)? - { - log_refusal(player, refusal); - let (next_state, outbound) = - refusal_transition(refusal, current_state, target_guid); - return Ok(LootWindowOutcome::Handled { - next_state, - durable_request: Some(open_request), - outbound, - }); +pub(crate) struct LootWindow; + +impl ProtocolFamily for LootWindow { + fn handle( + store: &St, + session: &mut ProtocolSession, + request: ProtocolRequest, + ) -> Result { + let msg = request.message()?; + let current_state = match &session.state { + WorldState::InWorld(world) => world.open_loot, + WorldState::CharSelect => OpenLootState::default(), + }; + match msg { + ClientOpcodeMessage::CMSG_GAMEOBJ_USE(request) => { + let Some(actor) = session.actor() else { + return Ok(ProtocolReply::default()); + }; + let target_guid = request.guid.guid(); + if let LootWindowRequestStatus::Refused(refusal) = + store.use_gameobject(actor, target_guid)? + { + log_refusal(session, refusal); + let (next_state, outbound) = + refusal_transition(refusal, current_state, target_guid); + return Ok({ + if let WorldState::InWorld(world) = &mut session.state { + world.open_loot = next_state; + } + ProtocolReply::from(outbound) + }); + } + let items = store.loot_target_items(target_guid, actor.guid())?; + if items.is_empty() { + return Ok(ProtocolReply::default()); + } + let (opcode, body) = codec::build_loot_response_raw(target_guid, 0, &items); + Ok({ + if let WorldState::InWorld(world) = &mut session.state { + world.open_loot = OpenLootState { + target_guid: Some(target_guid), + }; + } + ProtocolReply::from(vec![Outbound::Raw { opcode, body }]) + }) } - let money = store.loot_target_money(target_guid)?; - let items = store.loot_target_items(target_guid, viewer.guid())?; - let durable_request = if items.is_empty() && money == 0 { - // Skinning an empty corpse is opportunistic: a Refusal still shows the empty window. - store.skin_corpse(viewer, target_guid)?; - Some(LootWindowDurableRequest::SkinCreature { target_guid }) - } else { - Some(open_request) - }; - let (opcode, body) = codec::build_loot_response_raw(target_guid, money, &items); - Ok(LootWindowOutcome::Handled { - next_state: OpenLootState { - target_guid: Some(target_guid), - }, - durable_request, - outbound: vec![Outbound::Raw { opcode, body }], - }) - } - ClientOpcodeMessage::CMSG_LOOT_MONEY => { - let (Some(actor), Some(target_guid)) = (player.actor(), current_state.target_guid) - else { - return Ok(LootWindowOutcome::Handled { - next_state: current_state, - durable_request: None, - outbound: Vec::new(), - }); - }; - let durable_request = LootWindowDurableRequest::TakeMoney { target_guid }; - let (next_state, outbound) = match store.loot_money(actor, target_guid)? { - LootWindowRequestStatus::Applied => ( - current_state, - vec![Outbound::One(ServerOpcodeMessage::SMSG_LOOT_CLEAR_MONEY)], - ), - LootWindowRequestStatus::Refused(refusal) => { - log_refusal(player, refusal); - refusal_transition(refusal, current_state, target_guid) + ClientOpcodeMessage::CMSG_LOOT(request) => { + let Some(viewer) = session.actor() else { + return Ok(ProtocolReply::default()); + }; + let target_guid = request.guid.guid(); + if let LootWindowRequestStatus::Refused(refusal) = + store.open_creature_loot(viewer, target_guid)? + { + log_refusal(session, refusal); + let (next_state, outbound) = + refusal_transition(refusal, current_state, target_guid); + return Ok({ + if let WorldState::InWorld(world) = &mut session.state { + world.open_loot = next_state; + } + ProtocolReply::from(outbound) + }); } - }; - Ok(LootWindowOutcome::Handled { - next_state, - durable_request: Some(durable_request), - outbound, - }) - } - ClientOpcodeMessage::CMSG_AUTOSTORE_LOOT_ITEM(request) => { - let (Some(actor), Some(target_guid)) = (player.actor(), current_state.target_guid) - else { - return Ok(LootWindowOutcome::Handled { - next_state: current_state, - durable_request: None, - outbound: Vec::new(), - }); - }; - let durable_request = LootWindowDurableRequest::TakeItem { - target_guid, - loot_slot: request.item_slot, - }; - let (next_state, outbound) = - match store.take_loot(actor, target_guid, request.item_slot)? { + let money = store.loot_target_money(target_guid)?; + let items = store.loot_target_items(target_guid, viewer.guid())?; + if items.is_empty() && money == 0 { + // Skinning an empty corpse is opportunistic: a Refusal still shows the empty window. + store.skin_corpse(viewer, target_guid)?; + } + let (opcode, body) = codec::build_loot_response_raw(target_guid, money, &items); + Ok({ + if let WorldState::InWorld(world) = &mut session.state { + world.open_loot = OpenLootState { + target_guid: Some(target_guid), + }; + } + ProtocolReply::from(vec![Outbound::Raw { opcode, body }]) + }) + } + ClientOpcodeMessage::CMSG_LOOT_MONEY => { + let (Some(actor), Some(target_guid)) = (session.actor(), current_state.target_guid) + else { + return Ok(ProtocolReply::default()); + }; + let (next_state, outbound) = match store.loot_money(actor, target_guid)? { LootWindowRequestStatus::Applied => ( current_state, - vec![Outbound::One(ServerOpcodeMessage::SMSG_LOOT_REMOVED( - codec::build_loot_removed(request.item_slot), - ))], + vec![Outbound::One(ServerOpcodeMessage::SMSG_LOOT_CLEAR_MONEY)], ), LootWindowRequestStatus::Refused(refusal) => { - log_refusal(player, refusal); + log_refusal(session, refusal); refusal_transition(refusal, current_state, target_guid) } }; - Ok(LootWindowOutcome::Handled { - next_state, - durable_request: Some(durable_request), - outbound, - }) + Ok({ + if let WorldState::InWorld(world) = &mut session.state { + world.open_loot = next_state; + } + ProtocolReply::from(outbound) + }) + } + ClientOpcodeMessage::CMSG_AUTOSTORE_LOOT_ITEM(request) => { + let (Some(actor), Some(target_guid)) = (session.actor(), current_state.target_guid) + else { + return Ok(ProtocolReply::default()); + }; + let (next_state, outbound) = + match store.take_loot(actor, target_guid, request.item_slot)? { + LootWindowRequestStatus::Applied => ( + current_state, + vec![Outbound::One(ServerOpcodeMessage::SMSG_LOOT_REMOVED( + codec::build_loot_removed(request.item_slot), + ))], + ), + LootWindowRequestStatus::Refused(refusal) => { + log_refusal(session, refusal); + refusal_transition(refusal, current_state, target_guid) + } + }; + Ok({ + if let WorldState::InWorld(world) = &mut session.state { + world.open_loot = next_state; + } + ProtocolReply::from(outbound) + }) + } + ClientOpcodeMessage::CMSG_LOOT_RELEASE(request) => Ok({ + if let WorldState::InWorld(world) = &mut session.state { + world.open_loot = OpenLootState::default(); + } + ProtocolReply::from(vec![Outbound::One( + ServerOpcodeMessage::SMSG_LOOT_RELEASE_RESPONSE(Box::new( + codec::build_loot_release_response(request.guid.guid()), + )), + )]) + }), + other => Err(anyhow!("request routed to LootWindow: {other}")), } - ClientOpcodeMessage::CMSG_LOOT_RELEASE(request) => Ok(LootWindowOutcome::Handled { - next_state: OpenLootState::default(), - durable_request: None, - outbound: vec![Outbound::One( - ServerOpcodeMessage::SMSG_LOOT_RELEASE_RESPONSE(Box::new( - codec::build_loot_release_response(request.guid.guid()), - )), - )], - }), - other => Ok(LootWindowOutcome::PassThrough(other)), } } -/// Remaining group-loot, non-window GameObject, and death-recovery operations not yet migrated to -/// a focused action interface. -pub(crate) fn handle_loot< - St: DeathStore + LootRollStore + LootWindowStore + NpcStore + ShardRoutingStore + ?Sized, ->( - tx: &SessionTx, - store: &St, - conn: &mut WorldConn, - msg: ClientOpcodeMessage, -) -> Result> { - let actor = social::self_guid(conn).and_then(Actor::new); - match msg { - // Group loot methods: a need/greed vote, and the master looter's - // explicit assign. Both are per-action — a rejection (no roll open, already voted, not the - // master) is logged + ignored rather than tearing the session; the live vote/winner/master - // packets ride the `game_group_event` roll relay (`stdb/subscriptions.rs`), not a direct - // reply here. - ClientOpcodeMessage::CMSG_LOOT_ROLL(c) => { - let corpse_guid = c.item.guid(); - let vote = c.vote.as_int(); - // Unsharded, `loot::run_vote` is exactly the call above (`store.loot_roll`); - // sharded, it routes to realm-core instead, so the Actor it votes as must be the one - // THIS socket authenticated with, never a literal from the packet. - let actor = actor.ok_or_else(|| anyhow!("CMSG_LOOT_ROLL before world entry"))?; - finish_loot_action(loot::run_vote( - store, - actor, - corpse_guid, - c.item_slot, - vote, - )?); - } - ClientOpcodeMessage::CMSG_LOOT_MASTER_GIVE(c) => { - let corpse_guid = c.loot.guid(); - let target_guid = c.player.guid(); - let actor = actor.ok_or_else(|| anyhow!("CMSG_LOOT_MASTER_GIVE before world entry"))?; - finish_loot_action(store.loot_master_give( - actor, - corpse_guid, - c.slot_id, - target_guid, - )?); - } - ClientOpcodeMessage::CMSG_GAMEOBJ_USE(request) => { - let Some(actor) = actor else { - return Ok(None); - }; - let target_guid = request.guid.guid(); - match store.use_gameobject(actor, target_guid)? { - LootWindowRequestStatus::Applied => { - let items = store.loot_target_items(target_guid, actor.guid())?; - if !items.is_empty() { - if let WorldState::InWorld(iw) = &mut conn.state { - iw.open_loot = OpenLootState { - target_guid: Some(target_guid), - }; +/// Handle loot rolls, general GameObject use and death recovery. +pub(crate) struct Loot; + +impl + ProtocolFamily for Loot +{ + fn handle( + store: &St, + session: &mut ProtocolSession, + request: ProtocolRequest, + ) -> Result { + let msg = request.message()?; + let mut outbound = Vec::new(); + let actor = session.actor(); + match msg { + // Group loot methods: a need/greed vote, and the master looter's + // explicit assign. Both are per-action — a rejection (no roll open, already voted, not the + // master) is logged + ignored rather than tearing the session; the live vote/winner/master + // packets ride the `game_group_event` roll relay (`stdb/subscriptions.rs`), not a direct + // reply here. + ClientOpcodeMessage::CMSG_LOOT_ROLL(c) => { + let corpse_guid = c.item.guid(); + let vote = c.vote.as_int(); + // Unsharded, `loot::run_vote` is exactly the call above (`store.loot_roll`); + // sharded, it routes to realm-core instead, so the Actor it votes as must be the one + // THIS socket authenticated with, never a literal from the packet. + let actor = actor.ok_or_else(|| anyhow!("CMSG_LOOT_ROLL before world entry"))?; + finish_loot_action(loot::run_vote( + store, + actor, + corpse_guid, + c.item_slot, + vote, + )?); + } + ClientOpcodeMessage::CMSG_LOOT_MASTER_GIVE(c) => { + let corpse_guid = c.loot.guid(); + let target_guid = c.player.guid(); + let actor = + actor.ok_or_else(|| anyhow!("CMSG_LOOT_MASTER_GIVE before world entry"))?; + finish_loot_action(store.loot_master_give( + actor, + corpse_guid, + c.slot_id, + target_guid, + )?); + } + ClientOpcodeMessage::CMSG_GAMEOBJ_USE(request) => { + let Some(actor) = actor else { + return Ok(outbound.into()); + }; + let target_guid = request.guid.guid(); + match store.use_gameobject(actor, target_guid)? { + LootWindowRequestStatus::Applied => { + let items = store.loot_target_items(target_guid, actor.guid())?; + if !items.is_empty() { + if let WorldState::InWorld(iw) = &mut session.state { + iw.open_loot = OpenLootState { + target_guid: Some(target_guid), + }; + } + let (opcode, body) = + codec::build_loot_response_raw(target_guid, 0, &items); + outbound.push(Outbound::Raw { opcode, body }); } - let (opcode, body) = codec::build_loot_response_raw(target_guid, 0, &items); - send(tx, Outbound::Raw { opcode, body })?; } - } - LootWindowRequestStatus::Refused(refusal) => { - if refusal.loot_error().is_some() { - if let WorldState::InWorld(iw) = &mut conn.state { - iw.open_loot = OpenLootState::default(); + LootWindowRequestStatus::Refused(refusal) => { + if refusal.loot_error().is_some() { + if let WorldState::InWorld(iw) = &mut session.state { + iw.open_loot = OpenLootState::default(); + } } - } - for outbound in refusal_outbound(refusal, target_guid) { - send(tx, outbound)?; + outbound.extend(refusal_outbound(refusal, target_guid)); } } } - } - // Enter an area trigger (CMSG_AREATRIGGER): the client fires this when the player physically - // walks into a trigger zone (e.g. a mine for an "explore" quest). The module credits any active - // explore quest tied to the trigger id. A transient/no-match result is logged + ignored. - ClientOpcodeMessage::CMSG_AREATRIGGER(a) => { - if let Some(actor) = social::self_guid(conn).and_then(Actor::new) { - super::trainer::settle_per_action( - "enter_areatrigger", - conn.account_id, - store.enter_areatrigger(actor, a.trigger_id), - )?; + // Enter an area trigger (CMSG_AREATRIGGER): the client fires this when the player physically + // walks into a trigger zone (e.g. a mine for an "explore" quest). The module credits any active + // explore quest tied to the trigger id. A transient/no-match result is logged + ignored. + ClientOpcodeMessage::CMSG_AREATRIGGER(a) => { + if let Some(actor) = session.actor() { + super::trainer::settle_per_action( + "enter_areatrigger", + session.account_id, + store.enter_areatrigger(actor, a.trigger_id), + )?; + } } - } - // Gameobject template query (CMSG_GAMEOBJECT_QUERY): the client asks for a GO's name/type/display - // before it renders/interacts. Reply with the template, or the not-found form. - ClientOpcodeMessage::CMSG_GAMEOBJECT_QUERY(q) => { - let tmpl = store.gameobject_template(q.entry_id)?; - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_GAMEOBJECT_QUERY_RESPONSE( - Box::new(codec::build_gameobject_query_response( - q.entry_id, - tmpl.as_ref(), + // Gameobject template query (CMSG_GAMEOBJECT_QUERY): the client asks for a GO's name/type/display + // before it renders/interacts. Reply with the template, or the not-found form. + ClientOpcodeMessage::CMSG_GAMEOBJECT_QUERY(q) => { + let tmpl = store.gameobject_template(q.entry_id)?; + outbound.push(Outbound::One( + ServerOpcodeMessage::SMSG_GAMEOBJECT_QUERY_RESPONSE(Box::new( + codec::build_gameobject_query_response(q.entry_id, tmpl.as_ref()), )), - )), - )?; - } - // Release Spirit after death. The client sends this (empty body) when the player - // clicks Release on the death screen. Revive in place at full health; the restored health - // replicates via the on_update VALUES relay and the client leaves the death screen. - // SMSG_CORPSE_RECLAIM_DELAY is now relay-driven (the escalated per-corpse - // delay, not a flat 30s) — see `on_corpse_insert` in `stdb/subscriptions.rs`, which fires off - // the SAME `game_corpse` insert `repop`'s reducer call just caused, so no explicit send here. - ClientOpcodeMessage::CMSG_REPOP_REQUEST => { - if let Some(actor) = actor { - ignore_refusal("repop", conn.account_id, store.repop(actor))?; + )); } - } - // Corpse location query: the client asks where the player's corpse is to draw the - // map marker + offer "Reclaim Corpse" near it. Reply with the corpse's position, or NotFound. - ClientOpcodeMessage::MSG_CORPSE_QUERY => { - if let WorldState::InWorld(iw) = &conn.state { - let loc = store.corpse_location(iw.self_guid)?; - send( - tx, - Outbound::One(ServerOpcodeMessage::MSG_CORPSE_QUERY(Box::new( - codec::build_corpse_query_response(loc)?, - ))), - )?; + // Release Spirit after death. The client sends this (empty body) when the player + // clicks Release on the death screen. Revive in place at full health; the restored health + // replicates via the on_update VALUES relay and the client leaves the death screen. + // SMSG_CORPSE_RECLAIM_DELAY is now relay-driven (the escalated per-corpse + // delay, not a flat 30s) — see `on_corpse_insert` in `stdb/subscriptions.rs`, which fires off + // the SAME `game_corpse` insert `repop`'s reducer call just caused, so no explicit send here. + ClientOpcodeMessage::CMSG_REPOP_REQUEST => { + if let Some(actor) = actor { + ignore_refusal("repop", session.account_id, store.repop(actor))?; + } } - } - // Reclaim your corpse: the ghost, near its corpse and past the 30s delay, resurrects - // at 50%. The module validates ownership/ghost/range/delay; a failure (too far, too soon, not - // a ghost) is expected and silently ignored — the client just stays a ghost. - ClientOpcodeMessage::CMSG_RECLAIM_CORPSE(r) => { - if let Some(actor) = actor { - let result = store.reclaim_corpse(actor, r.guid.guid()); - ignore_refusal("reclaim_corpse", conn.account_id, result)?; + // Corpse location query: the client asks where the player's corpse is to draw the + // map marker + offer "Reclaim Corpse" near it. Reply with the corpse's position, or NotFound. + ClientOpcodeMessage::MSG_CORPSE_QUERY => { + if let WorldState::InWorld(iw) = &session.state { + let loc = store.corpse_location(iw.self_guid)?; + outbound.push(Outbound::One(ServerOpcodeMessage::MSG_CORPSE_QUERY( + Box::new(codec::build_corpse_query_response(loc)?), + ))); + } } - } - // Resurrection accept-prompt response: the dead player answered the SMSG_RESURRECT_REQUEST - // offer. `status` is vanilla's accept(1)/decline(0) byte; the offer's guid is ignored (mirrors - // `reclaim_corpse`'s own-corpse derivation — the module resolves the pending offer from the - // CALLER via `ctx.sender()`, never the wire guid). A failure (no pending offer — already - // answered/lapsed) is expected and silently ignored. - ClientOpcodeMessage::CMSG_RESURRECT_RESPONSE(r) => { - if let Some(actor) = actor { - let result = store.resurrect_response(actor, r.status != 0); - ignore_refusal("resurrect_response", conn.account_id, result)?; + // Reclaim your corpse: the ghost, near its corpse and past the 30s delay, resurrects + // at 50%. The module validates ownership/ghost/range/delay; a failure (too far, too soon, not + // a ghost) is expected and silently ignored — the client just stays a ghost. + ClientOpcodeMessage::CMSG_RECLAIM_CORPSE(r) => { + if let Some(actor) = actor { + let result = store.reclaim_corpse(actor, r.guid.guid()); + ignore_refusal("reclaim_corpse", session.account_id, result)?; + } } - } - // The death dialog's second button: use the Self-Resurrection Option the Module wrote into - // PLAYER_SELF_RES_SPELL. The revive replicates through the entity VALUES relay. A Refusal - // (already used, already alive) is expected after a race and sends nothing. - ClientOpcodeMessage::CMSG_SELF_RES => { - if let Some(actor) = actor { - ignore_refusal( - "self_resurrect", - conn.account_id, - store.self_resurrect(actor), - )?; + // Resurrection accept-prompt response: the dead player answered the SMSG_RESURRECT_REQUEST + // offer. `status` is vanilla's accept(1)/decline(0) byte; the offer's guid is ignored (mirrors + // `reclaim_corpse`'s own-corpse derivation — the module resolves the pending offer from the + // CALLER via `ctx.sender()`, never the wire guid). A failure (no pending offer — already + // answered/lapsed) is expected and silently ignored. + ClientOpcodeMessage::CMSG_RESURRECT_RESPONSE(r) => { + if let Some(actor) = actor { + let result = store.resurrect_response(actor, r.status != 0); + ignore_refusal("resurrect_response", session.account_id, result)?; + } } - } - // Spirit-Healer resurrection: a ghost activated the graveyard Spirit Healer (npc_flags - // SPIRITHEALER). The module res's in place at 50% + applies Resurrection Sickness; on success - // reply with SMSG_SPIRIT_HEALER_CONFIRM (echoing the healer's guid) so the client closes the - // dialog. The res itself replicates via the entity VALUES relay (health > 0 + cleared ghost - // bits), exactly like reclaim_corpse. A failure (not a ghost) is per-action — log + ignore. - ClientOpcodeMessage::CMSG_SPIRIT_HEALER_ACTIVATE(s) => { - if let Some(actor) = actor { - let result = store.spirit_healer_res(actor, s.guid.guid()); - let revived = result.is_ok(); - ignore_refusal("spirit_healer_res", conn.account_id, result)?; - if revived { - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_SPIRIT_HEALER_CONFIRM( - SMSG_SPIRIT_HEALER_CONFIRM { guid: s.guid }, - )), + // The death dialog's second button: use the Self-Resurrection Option the Module wrote into + // PLAYER_SELF_RES_SPELL. The revive replicates through the entity VALUES relay. A Refusal + // (already used, already alive) is expected after a race and sends nothing. + ClientOpcodeMessage::CMSG_SELF_RES => { + if let Some(actor) = actor { + ignore_refusal( + "self_resurrect", + session.account_id, + store.self_resurrect(actor), )?; } } + // Spirit-Healer resurrection: a ghost activated the graveyard Spirit Healer (npc_flags + // SPIRITHEALER). The module res's in place at 50% + applies Resurrection Sickness; on success + // reply with SMSG_SPIRIT_HEALER_CONFIRM (echoing the healer's guid) so the client closes the + // dialog. The res itself replicates via the entity VALUES relay (health > 0 + cleared ghost + // bits), exactly like reclaim_corpse. A failure (not a ghost) is per-action — log + ignore. + ClientOpcodeMessage::CMSG_SPIRIT_HEALER_ACTIVATE(s) => { + if let Some(actor) = actor { + let result = store.spirit_healer_res(actor, s.guid.guid()); + let revived = result.is_ok(); + ignore_refusal("spirit_healer_res", session.account_id, result)?; + if revived { + outbound.push(Outbound::One( + ServerOpcodeMessage::SMSG_SPIRIT_HEALER_CONFIRM( + SMSG_SPIRIT_HEALER_CONFIRM { guid: s.guid }, + ), + )); + } + } + } + other => return Err(anyhow!("request routed to Loot: {other}")), } - other => return Ok(Some(other)), + Ok(outbound.into()) } - Ok(None) } #[cfg(test)] @@ -656,11 +604,25 @@ mod tests { ); } - fn player() -> LootWindowPlayer { - LootWindowPlayer { - account_id: 7, - self_guid: Some(42), + fn run_window( + store: &St, + mut session: ProtocolSession, + current: OpenLootState, + message: ClientOpcodeMessage, + ) -> Result<(OpenLootState, Vec)> { + if let WorldState::InWorld(world) = &mut session.state { + world.open_loot = current; } + let reply = LootWindow::handle(store, &mut session, message.into())?; + let state = match &session.state { + WorldState::InWorld(world) => world.open_loot, + WorldState::CharSelect => OpenLootState::default(), + }; + Ok((state, reply.outbound)) + } + + fn session() -> ProtocolSession { + ProtocolSession::in_world(7, 42) } fn open_creature(target_guid: u64) -> ClientOpcodeMessage { @@ -701,12 +663,9 @@ mod tests { target_guid: Some(11), }; - let outcome = dispatch_loot_window( + let outcome = run_window( &store, - LootWindowPlayer { - account_id: 7, - self_guid: None, - }, + ProtocolSession::new(7, "TESTER".into()), current_state, open_creature(60), ) @@ -714,13 +673,7 @@ mod tests { assert!(matches!( outcome, - LootWindowOutcome::Handled { - next_state, - durable_request, - outbound, - } if next_state == current_state - && durable_request.is_none() - && outbound.is_empty() + (next_state, outbound) if next_state == OpenLootState::default() && outbound.is_empty() )); assert!(store.money_reads.lock().unwrap().is_empty()); assert!(store.item_reads.lock().unwrap().is_empty()); @@ -734,12 +687,9 @@ mod tests { target_guid: Some(11), }; - let outcome = dispatch_loot_window( + let outcome = run_window( &store, - LootWindowPlayer { - account_id: 7, - self_guid: Some(0), - }, + ProtocolSession::in_world(7, 0), current_state, open_creature(60), ) @@ -747,13 +697,7 @@ mod tests { assert!(matches!( outcome, - LootWindowOutcome::Handled { - next_state, - durable_request, - outbound, - } if next_state == current_state - && durable_request.is_none() - && outbound.is_empty() + (next_state, outbound) if next_state == current_state && outbound.is_empty() )); assert!(store.money_reads.lock().unwrap().is_empty()); assert!(store.item_reads.lock().unwrap().is_empty()); @@ -768,22 +712,11 @@ mod tests { target_guid: Some(11), }; - let outcome = - dispatch_loot_window(&store, player(), current_state, open_chest(90)).unwrap(); + let outcome = run_window(&store, session(), current_state, open_chest(90)).unwrap(); - let LootWindowOutcome::Handled { - next_state, - durable_request, - outbound, - } = outcome - else { - panic!("chest use passed through") - }; + let (next_state, outbound) = outcome; assert_eq!(next_state.target_guid, Some(90)); - assert_eq!( - durable_request, - Some(LootWindowDurableRequest::UseGameObject { target_guid: 90 }) - ); + let [Outbound::Raw { opcode, body }] = outbound.as_slice() else { panic!("expected one raw loot window") }; @@ -807,18 +740,11 @@ mod tests { target_guid: Some(11), }; - let outcome = - dispatch_loot_window(&store, player(), current_state, open_chest(90)).unwrap(); + let outcome = run_window(&store, session(), current_state, open_chest(90)).unwrap(); assert!(matches!( outcome, - LootWindowOutcome::Handled { - next_state, - durable_request, - outbound, - } if next_state == current_state - && durable_request == Some(LootWindowDurableRequest::UseGameObject { target_guid: 90 }) - && outbound.is_empty() + (next_state, outbound) if next_state == current_state && outbound.is_empty() )); assert_eq!( store.operations.lock().unwrap().as_slice(), @@ -836,18 +762,11 @@ mod tests { target_guid: Some(11), }; - let outcome = - dispatch_loot_window(&store, player(), current_state, open_chest(90)).unwrap(); + let outcome = run_window(&store, session(), current_state, open_chest(90)).unwrap(); assert!(matches!( outcome, - LootWindowOutcome::Handled { - next_state, - durable_request, - outbound, - } if next_state == current_state - && durable_request == Some(LootWindowDurableRequest::UseGameObject { target_guid: 90 }) - && outbound.is_empty() + (next_state, outbound) if next_state == current_state && outbound.is_empty() )); assert_eq!( store.operations.lock().unwrap().as_slice(), @@ -866,7 +785,7 @@ mod tests { target_guid: Some(11), }; - let error = dispatch_loot_window(&store, player(), current_state, open_chest(90)) + let error = run_window(&store, session(), current_state, open_chest(90)) .err() .expect("fatal GameObject failure was handled"); @@ -886,9 +805,9 @@ mod tests { ..Default::default() }; - let error = dispatch_loot_window( + let error = run_window( &store, - player(), + session(), OpenLootState { target_guid: Some(11), }, @@ -912,9 +831,9 @@ mod tests { ..Default::default() }; - let outcome = dispatch_loot_window( + let outcome = run_window( &store, - player(), + session(), OpenLootState { target_guid: Some(11), }, @@ -922,19 +841,9 @@ mod tests { ) .unwrap(); - let LootWindowOutcome::Handled { - next_state, - durable_request, - outbound, - } = outcome - else { - panic!("creature open passed through") - }; + let (next_state, outbound) = outcome; assert_eq!(next_state, OpenLootState::default()); - assert_eq!( - durable_request, - Some(LootWindowDurableRequest::OpenCreature { target_guid: 60 }) - ); + assert_didnt_kill(&outbound, 60); assert_eq!(store.open_requests.lock().unwrap().as_slice(), &[(42, 60)]); assert!(store.money_reads.lock().unwrap().is_empty()); @@ -949,9 +858,9 @@ mod tests { ..Default::default() }; - let error = dispatch_loot_window( + let error = run_window( &store, - player(), + session(), OpenLootState::default(), open_creature(60), ) @@ -973,16 +882,11 @@ mod tests { target_guid: Some(11), }; - let outcome = - dispatch_loot_window(&store, player(), current_state, open_creature(60)).unwrap(); + let outcome = run_window(&store, session(), current_state, open_creature(60)).unwrap(); assert!(matches!( outcome, - LootWindowOutcome::Handled { - next_state, - durable_request: Some(LootWindowDurableRequest::OpenCreature { target_guid: 60 }), - outbound, - } if next_state == current_state && outbound.is_empty() + (next_state, outbound) if next_state == current_state && outbound.is_empty() )); assert!(store.money_reads.lock().unwrap().is_empty()); assert!(store.item_reads.lock().unwrap().is_empty()); @@ -995,22 +899,15 @@ mod tests { } fn dispatch_release(current_state: OpenLootState, request_target: u64) -> OpenLootState { - let outcome = dispatch_loot_window( + let outcome = run_window( &InMemoryLootWindow::default(), - player(), + session(), current_state, release(request_target), ) .unwrap(); - let LootWindowOutcome::Handled { - next_state, - durable_request, - outbound, - } = outcome - else { - panic!("release passed through") - }; - assert_eq!(durable_request, None); + let (next_state, outbound) = outcome; + assert!(matches!( outbound.as_slice(), [Outbound::One(ServerOpcodeMessage::SMSG_LOOT_RELEASE_RESPONSE(response))] @@ -1026,9 +923,9 @@ mod tests { target_guid: Some(60), }; - let outcome = dispatch_loot_window( + let outcome = run_window( &store, - player(), + session(), current_state, ClientOpcodeMessage::CMSG_LOOT_MONEY, ) @@ -1036,13 +933,7 @@ mod tests { assert!(matches!( outcome, - LootWindowOutcome::Handled { - next_state, - durable_request, - outbound, - } if next_state == current_state - && durable_request == Some(LootWindowDurableRequest::TakeMoney { target_guid: 60 }) - && matches!(outbound.as_slice(), [Outbound::One(ServerOpcodeMessage::SMSG_LOOT_CLEAR_MONEY)]) + (next_state, outbound) if next_state == current_state && matches!(outbound.as_slice(), [Outbound::One(ServerOpcodeMessage::SMSG_LOOT_CLEAR_MONEY)]) )); assert_eq!( store.money_take_requests.lock().unwrap().as_slice(), @@ -1060,9 +951,9 @@ mod tests { target_guid: Some(60), }; - let outcome = dispatch_loot_window( + let outcome = run_window( &store, - player(), + session(), current_state, ClientOpcodeMessage::CMSG_LOOT_MONEY, ) @@ -1070,13 +961,7 @@ mod tests { assert!(matches!( outcome, - LootWindowOutcome::Handled { - next_state, - durable_request, - outbound, - } if next_state == current_state - && durable_request == Some(LootWindowDurableRequest::TakeMoney { target_guid: 60 }) - && outbound.is_empty() + (next_state, outbound) if next_state == current_state && outbound.is_empty() )); assert_eq!( store.money_take_requests.lock().unwrap().as_slice(), @@ -1136,15 +1021,8 @@ mod tests { }, ), ] { - let outcome = dispatch_loot_window(&store, player(), open_window, message).unwrap(); - let LootWindowOutcome::Handled { - next_state, - outbound, - .. - } = outcome - else { - panic!("{refusal:?} passed through") - }; + let outcome = run_window(&store, session(), open_window, message).unwrap(); + let (next_state, outbound) = outcome; match loot_error { // An answered Refusal invalidates the Loot Window the client is showing. Some(loot_error) => { @@ -1167,9 +1045,9 @@ mod tests { ..Default::default() }; - let outcome = dispatch_loot_window( + let outcome = run_window( &store, - player(), + session(), OpenLootState { target_guid: Some(60), }, @@ -1177,19 +1055,9 @@ mod tests { ) .unwrap(); - let LootWindowOutcome::Handled { - next_state, - durable_request, - outbound, - } = outcome - else { - panic!("money take passed through") - }; + let (next_state, outbound) = outcome; assert_eq!(next_state, OpenLootState::default()); - assert_eq!( - durable_request, - Some(LootWindowDurableRequest::TakeMoney { target_guid: 60 }) - ); + assert_didnt_kill(&outbound, 60); assert_eq!( store.money_take_requests.lock().unwrap().as_slice(), @@ -1201,9 +1069,9 @@ mod tests { fn money_take_without_an_open_target_has_no_operation_or_outbound() { let store = InMemoryLootWindow::default(); - let outcome = dispatch_loot_window( + let outcome = run_window( &store, - player(), + session(), OpenLootState::default(), ClientOpcodeMessage::CMSG_LOOT_MONEY, ) @@ -1211,11 +1079,7 @@ mod tests { assert!(matches!( outcome, - LootWindowOutcome::Handled { - next_state: OpenLootState { target_guid: None }, - durable_request, - outbound, - } if durable_request.is_none() && outbound.is_empty() + (OpenLootState { target_guid: None }, outbound) if outbound.is_empty() )); assert!(store.money_take_requests.lock().unwrap().is_empty()); } @@ -1227,9 +1091,9 @@ mod tests { target_guid: Some(75), }; - let outcome = dispatch_loot_window( + let outcome = run_window( &store, - player(), + session(), current_state, ClientOpcodeMessage::CMSG_AUTOSTORE_LOOT_ITEM(CMSG_AUTOSTORE_LOOT_ITEM { item_slot: 3, @@ -1239,13 +1103,7 @@ mod tests { assert!(matches!( outcome, - LootWindowOutcome::Handled { - next_state, - durable_request, - outbound, - } if next_state == current_state - && durable_request == Some(LootWindowDurableRequest::TakeItem { target_guid: 75, loot_slot: 3 }) - && matches!(outbound.as_slice(), [Outbound::One(ServerOpcodeMessage::SMSG_LOOT_REMOVED(removed))] if removed.slot == 3) + (next_state, outbound) if next_state == current_state && matches!(outbound.as_slice(), [Outbound::One(ServerOpcodeMessage::SMSG_LOOT_REMOVED(removed))] if removed.slot == 3) )); assert_eq!( store.item_take_requests.lock().unwrap().as_slice(), @@ -1263,9 +1121,9 @@ mod tests { target_guid: Some(75), }; - let outcome = dispatch_loot_window( + let outcome = run_window( &store, - player(), + session(), current_state, ClientOpcodeMessage::CMSG_AUTOSTORE_LOOT_ITEM(CMSG_AUTOSTORE_LOOT_ITEM { item_slot: 3, @@ -1275,13 +1133,7 @@ mod tests { assert!(matches!( outcome, - LootWindowOutcome::Handled { - next_state, - durable_request, - outbound, - } if next_state == current_state - && durable_request == Some(LootWindowDurableRequest::TakeItem { target_guid: 75, loot_slot: 3 }) - && outbound.is_empty() + (next_state, outbound) if next_state == current_state && outbound.is_empty() )); assert_eq!( store.item_take_requests.lock().unwrap().as_slice(), @@ -1296,9 +1148,9 @@ mod tests { ..Default::default() }; - let outcome = dispatch_loot_window( + let outcome = run_window( &store, - player(), + session(), OpenLootState { target_guid: Some(75), }, @@ -1308,22 +1160,9 @@ mod tests { ) .unwrap(); - let LootWindowOutcome::Handled { - next_state, - durable_request, - outbound, - } = outcome - else { - panic!("item take passed through") - }; + let (next_state, outbound) = outcome; assert_eq!(next_state, OpenLootState::default()); - assert_eq!( - durable_request, - Some(LootWindowDurableRequest::TakeItem { - target_guid: 75, - loot_slot: 3, - }) - ); + assert_didnt_kill(&outbound, 75); assert_eq!( store.item_take_requests.lock().unwrap().as_slice(), @@ -1335,9 +1174,9 @@ mod tests { fn item_take_without_an_open_target_has_no_operation_or_outbound() { let store = InMemoryLootWindow::default(); - let outcome = dispatch_loot_window( + let outcome = run_window( &store, - player(), + session(), OpenLootState::default(), ClientOpcodeMessage::CMSG_AUTOSTORE_LOOT_ITEM(CMSG_AUTOSTORE_LOOT_ITEM { item_slot: 3, @@ -1347,11 +1186,7 @@ mod tests { assert!(matches!( outcome, - LootWindowOutcome::Handled { - next_state: OpenLootState { target_guid: None }, - durable_request, - outbound, - } if durable_request.is_none() && outbound.is_empty() + (OpenLootState { target_guid: None }, outbound) if outbound.is_empty() )); assert!(store.item_take_requests.lock().unwrap().is_empty()); } @@ -1368,7 +1203,7 @@ mod tests { }; for request in [ClientOpcodeMessage::CMSG_LOOT_MONEY, take_item(3)] { - let error = dispatch_loot_window(&store, player(), open, request) + let error = run_window(&store, session(), open, request) .err() .expect("transport loss was handled"); @@ -1384,9 +1219,9 @@ mod tests { }; store.items_by_viewer.insert(42, vec![(3, 2589, 5, 200, 0)]); - let outcome = dispatch_loot_window( + let outcome = run_window( &store, - player(), + session(), OpenLootState { target_guid: Some(11), }, @@ -1394,19 +1229,9 @@ mod tests { ) .unwrap(); - let LootWindowOutcome::Handled { - next_state, - durable_request, - outbound, - } = outcome - else { - panic!("creature open passed through") - }; + let (next_state, outbound) = outcome; assert_eq!(next_state.target_guid, Some(60)); - assert_eq!( - durable_request, - Some(LootWindowDurableRequest::OpenCreature { target_guid: 60 }) - ); + let [Outbound::Raw { opcode, body }] = outbound.as_slice() else { panic!("expected one raw loot window") }; @@ -1430,38 +1255,23 @@ mod tests { store.items_by_viewer.insert(42, vec![(0, 6948, 1, 100, 0)]); store.items_by_viewer.insert(43, vec![(2, 2589, 5, 200, 0)]); - let first = dispatch_loot_window( + let first = run_window( &store, - player(), + session(), OpenLootState::default(), open_creature(60), ) .unwrap(); - let second = dispatch_loot_window( + let second = run_window( &store, - LootWindowPlayer { - account_id: 8, - self_guid: Some(43), - }, + ProtocolSession::in_world(7, 43), OpenLootState::default(), open_creature(60), ) .unwrap(); - let LootWindowOutcome::Handled { - outbound: first_outbound, - .. - } = first - else { - panic!("first creature open passed through") - }; - let LootWindowOutcome::Handled { - outbound: second_outbound, - .. - } = second - else { - panic!("second creature open passed through") - }; + let (_, first_outbound) = first; + let (_, second_outbound) = second; let [Outbound::Raw { body: first_body, .. }] = first_outbound.as_slice() @@ -1510,27 +1320,17 @@ mod tests { fn fully_empty_creature_attempts_skinning_and_returns_an_empty_window() { let store = InMemoryLootWindow::default(); - let outcome = dispatch_loot_window( + let outcome = run_window( &store, - player(), + session(), OpenLootState::default(), open_creature(61), ) .unwrap(); - let LootWindowOutcome::Handled { - next_state, - durable_request, - outbound, - } = outcome - else { - panic!("creature open passed through") - }; + let (next_state, outbound) = outcome; assert_eq!(next_state.target_guid, Some(61)); - assert_eq!( - durable_request, - Some(LootWindowDurableRequest::SkinCreature { target_guid: 61 }) - ); + let [Outbound::Raw { opcode, body }] = outbound.as_slice() else { panic!("expected one raw loot window") }; @@ -1548,9 +1348,9 @@ mod tests { ..Default::default() }; - let outcome = dispatch_loot_window( + let outcome = run_window( &store, - player(), + session(), OpenLootState::default(), open_creature(61), ) @@ -1558,15 +1358,9 @@ mod tests { assert!(matches!( outcome, - LootWindowOutcome::Handled { - next_state: OpenLootState { + (OpenLootState { target_guid: Some(61) - }, - durable_request: Some(LootWindowDurableRequest::SkinCreature { - target_guid: 61 - }), - outbound, - } if matches!(outbound.as_slice(), [Outbound::Raw { opcode: 0x0160, body }] if body[13] == 0) + }, outbound) if matches!(outbound.as_slice(), [Outbound::Raw { opcode: 0x0160, body }] if body[13] == 0) )); } @@ -1577,9 +1371,9 @@ mod tests { ..Default::default() }; - let error = dispatch_loot_window( + let error = run_window( &store, - player(), + session(), OpenLootState::default(), open_creature(61), ) @@ -1595,14 +1389,7 @@ mod tests { state: &mut OpenLootState, msg: ClientOpcodeMessage, ) -> Vec { - let LootWindowOutcome::Handled { - next_state, - outbound, - .. - } = dispatch_loot_window(store, player(), *state, msg).unwrap() - else { - panic!("the request passed through") - }; + let (next_state, outbound) = run_window(store, session(), *state, msg).unwrap(); *state = next_state; outbound } diff --git a/gateway/src/world/handlers/mail.rs b/gateway/src/world/handlers/mail.rs index 76c7fa0a1..b9b1b5738 100644 --- a/gateway/src/world/handlers/mail.rs +++ b/gateway/src/world/handlers/mail.rs @@ -8,326 +8,309 @@ use super::super::*; use wow_world_messages::vanilla::SMSG_SEND_MAIL_RESULT_MailResultTwo; -#[allow(clippy::too_many_lines)] // One arm per mail opcode. -pub(crate) fn handle_mail< - St: CharacterStore + MailStore + SessionStore + ShardRoutingStore + SocialStore + ?Sized, ->( - tx: &SessionTx, - store: &St, - conn: &mut WorldConn, - msg: ClientOpcodeMessage, -) -> Result> { - match msg { - // The 1.12 client opens the mail frame ITSELF on a type-19 gameobject (there is no - // SMSG_SHOW_MAILBOX in vanilla) and then asks for the list. An empty mailbox still replies: - // "no mail" and "the server ignored me" must not look the same. A refused gate sends - // nothing — the same silent drop the out-of-range gameobject and unfriendly-vendor gates - // take, since vanilla has no mailbox-refusal packet. - ClientOpcodeMessage::CMSG_GET_MAIL_LIST(c) => { - match mail::open_mailbox(store, social::self_actor(conn), c.mailbox.guid()) { - Ok(mails) => send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_MAIL_LIST_RESULT(Box::new( - codec::build_mail_list(&mails, mail::now_secs()), - ))), - )?, - Err(failure) => log::debug!( - "world: mail list refused (account {}): {}", - conn.account_id, - failure.refusal()? - ), - } - } - // The periodic "do I have new mail" poll behind the minimap envelope. It names no mailbox — - // the client sends it from anywhere — so the only gate is being in world. A refusal is - // answered as "no unread mail" rather than with silence: the client repeats this poll and a - // dropped reply leaves a stale envelope lit. - ClientOpcodeMessage::MSG_QUERY_NEXT_MAIL_TIME => { - let unread = match mail::has_unread(store, social::self_actor(conn)) { - Ok(unread) => unread, - Err(failure) => { - log::debug!( - "world: mail poll answered empty (account {}): {}", - conn.account_id, - failure.refusal()? - ); - false - } - }; - send( - tx, - Outbound::One(ServerOpcodeMessage::MSG_QUERY_NEXT_MAIL_TIME( - codec::build_next_mail_time(unread), - )), - )?; - } - // The letter body. It does not ride the list packet: the list advertises the mail's own id - // as an `item_text_id` and the client fetches the text here — resolved by `item_text_id`, - // never trusted on the second field alone (a bag item guid when reading an item, a mail id - // otherwise; cm:MailHandler.cpp:630-646). That field is only a HINT the ownership check may - // use for a cheap lookup. Answered only for a caller who holds an item carrying the id or - // owns the mail it names — ids are small and sequential, so answering a bare id would let a - // crafted query read anyone's copied letter. A body the caller cannot have (someone else's - // mail or item, a deleted mail) answers with EMPTY text rather than silence — the client has - // already opened the letter and is waiting on this packet. - ClientOpcodeMessage::CMSG_ITEM_TEXT_QUERY(c) => { - let body = match mail::item_text( - store, - social::self_actor(conn), - c.item_text_id, - u64::from(c.mail_id), - ) { - Ok(body) => body, - Err(failure) => { - log::debug!( - "world: item text query refused (account {}): {}", +pub(crate) struct Mail; + +impl + ProtocolFamily for Mail +{ + #[allow(clippy::too_many_lines)] // One arm per Mail opcode. + fn handle( + store: &St, + conn: &mut ProtocolSession, + request: ProtocolRequest, + ) -> Result { + let msg = request.message()?; + let mut outbound = Vec::new(); + match msg { + // The 1.12 client opens the mail frame ITSELF on a type-19 gameobject (there is no + // SMSG_SHOW_MAILBOX in vanilla) and then asks for the list. An empty mailbox still replies: + // "no mail" and "the server ignored me" must not look the same. A refused gate sends + // nothing — the same silent drop the out-of-range gameobject and unfriendly-vendor gates + // take, since vanilla has no mailbox-refusal packet. + ClientOpcodeMessage::CMSG_GET_MAIL_LIST(c) => { + match mail::open_mailbox(store, conn.actor(), c.mailbox.guid()) { + Ok(mails) => { + outbound.push(Outbound::One(ServerOpcodeMessage::SMSG_MAIL_LIST_RESULT( + Box::new(codec::build_mail_list(&mails, mail::now_secs())), + ))) + } + Err(failure) => log::debug!( + "world: mail list refused (account {}): {}", conn.account_id, failure.refusal()? - ); - None + ), } - }; - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_ITEM_TEXT_QUERY_RESPONSE( - Box::new(codec::build_item_text_response( - c.item_text_id, - body.unwrap_or_default(), + } + // The periodic "do I have new mail" poll behind the minimap envelope. It names no mailbox — + // the client sends it from anywhere — so the only gate is being in world. A refusal is + // answered as "no unread mail" rather than with silence: the client repeats this poll and a + // dropped reply leaves a stale envelope lit. + ClientOpcodeMessage::MSG_QUERY_NEXT_MAIL_TIME => { + let unread = match mail::has_unread(store, conn.actor()) { + Ok(unread) => unread, + Err(failure) => { + log::debug!( + "world: mail poll answered empty (account {}): {}", + conn.account_id, + failure.refusal()? + ); + false + } + }; + outbound.push(Outbound::One( + ServerOpcodeMessage::MSG_QUERY_NEXT_MAIL_TIME(codec::build_next_mail_time( + unread, )), - )), - )?; - } - // Flip a mail's read state. Vanilla sends NO reply for this opcode — the client already - // flipped its own display — so success and a refusal both answer with silence; only the - // next CMSG_GET_MAIL_LIST shows the truth. A crafted id (someone else's mail, or a stale - // one) is refused the same as a genuine miss — never trust a client-supplied mail id. - ClientOpcodeMessage::CMSG_MAIL_MARK_AS_READ(c) => { - let actor = social::self_actor(conn); - if let Err(failure) = - mail::mark_read(store, actor, c.mailbox.guid(), u64::from(c.mail_id)) - { - log::debug!( - "world: mail mark-as-read refused (account {}): {}", - conn.account_id, - failure.refusal()? - ); + )); } - } - // Delete a mail — destroys any attachment it still holds, as vanilla does (the confirmation - // prompt is client-side). Unlike the read-only arms above, `CMSG_MAIL_DELETE` has a real - // ack (`SMSG_SEND_MAIL_RESULT`/Deleted), so both outcomes reply through it, matching the - // vendor/loot arms' "a failed action still answers" rule. - ClientOpcodeMessage::CMSG_MAIL_DELETE(c) => { - let actor = social::self_actor(conn); - let ok = match mail::delete(store, actor, c.mailbox_id.guid(), u64::from(c.mail_id)) { - Ok(()) => true, - Err(failure) => { - log::debug!( - "world: mail delete refused (account {}): {}", - conn.account_id, - failure.refusal()? - ); - false - } - }; - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_SEND_MAIL_RESULT(Box::new( - codec::build_mail_delete_result(c.mail_id, ok), - ))), - )?; - } - // Return a mail to whoever sent it. The row is re-addressed in place — no escrow, since it - // never leaves the plane that already holds it — so this is [`mail::delete`]'s twin down to - // the authorization: a mail id is client-supplied, and "not yours" reads the same as "no - // such mail". Acks through `SMSG_SEND_MAIL_RESULT`/ReturnedToSender either way. - ClientOpcodeMessage::CMSG_MAIL_RETURN_TO_SENDER(c) => { - let actor = social::self_actor(conn); - let ok = match mail::return_to_sender( - store, - actor, - c.mailbox_id.guid(), - u64::from(c.mail_id), - ) { - Ok(()) => true, - Err(failure) => { - log::debug!( - "world: mail return refused (account {}): {}", - conn.account_id, - failure.refusal()? - ); - false - } - }; - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_SEND_MAIL_RESULT(Box::new( - codec::build_mail_return_result(c.mail_id, ok), - ))), - )?; - } - // Take a mail's copper into the purse. The mail id is client-supplied, so the refusal for - // somebody else's mail is the authorization boundary and not a sanity check — and it reads - // the same as "there is nothing in it", so a crafted id learns nothing either way. Both - // outcomes ack through `SMSG_SEND_MAIL_RESULT`/MoneyTaken, which is what closes the - // client's spinner. - ClientOpcodeMessage::CMSG_MAIL_TAKE_MONEY(c) => { - let actor = social::self_actor(conn); - let ok = match mail::take_money(store, actor, c.mailbox.guid(), u64::from(c.mail_id)) { - Ok(()) => true, - Err(failure) => { + // The letter body. It does not ride the list packet: the list advertises the mail's own id + // as an `item_text_id` and the client fetches the text here — resolved by `item_text_id`, + // never trusted on the second field alone (a bag item guid when reading an item, a mail id + // otherwise; cm:MailHandler.cpp:630-646). That field is only a HINT the ownership check may + // use for a cheap lookup. Answered only for a caller who holds an item carrying the id or + // owns the mail it names — ids are small and sequential, so answering a bare id would let a + // crafted query read anyone's copied letter. A body the caller cannot have (someone else's + // mail or item, a deleted mail) answers with EMPTY text rather than silence — the client has + // already opened the letter and is waiting on this packet. + ClientOpcodeMessage::CMSG_ITEM_TEXT_QUERY(c) => { + let body = match mail::item_text( + store, + conn.actor(), + c.item_text_id, + u64::from(c.mail_id), + ) { + Ok(body) => body, + Err(failure) => { + log::debug!( + "world: item text query refused (account {}): {}", + conn.account_id, + failure.refusal()? + ); + None + } + }; + outbound.push(Outbound::One( + ServerOpcodeMessage::SMSG_ITEM_TEXT_QUERY_RESPONSE(Box::new( + codec::build_item_text_response(c.item_text_id, body.unwrap_or_default()), + )), + )); + } + // Flip a mail's read state. Vanilla sends NO reply for this opcode — the client already + // flipped its own display — so success and a refusal both answer with silence; only the + // next CMSG_GET_MAIL_LIST shows the truth. A crafted id (someone else's mail, or a stale + // one) is refused the same as a genuine miss — never trust a client-supplied mail id. + ClientOpcodeMessage::CMSG_MAIL_MARK_AS_READ(c) => { + let actor = conn.actor(); + if let Err(failure) = + mail::mark_read(store, actor, c.mailbox.guid(), u64::from(c.mail_id)) + { log::debug!( - "world: mail take-money refused (account {}): {}", + "world: mail mark-as-read refused (account {}): {}", conn.account_id, failure.refusal()? ); - false } - }; - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_SEND_MAIL_RESULT(Box::new( - codec::build_mail_take_money_result(c.mail_id, ok), - ))), - )?; - } - // Take a mail's attached item into the bags, paying any cash-on-delivery price for it. A - // full bag answers `ErrEquipError` and an unaffordable price `ErrNotEnoughMoney` — the two - // refusals a player can act on, and in both the item STAYS in the letter, which is also - // what leaves a refused buyer free to return it. Every other refusal reads the same, so a - // crafted mail id learns nothing. - ClientOpcodeMessage::CMSG_MAIL_TAKE_ITEM(c) => { - let actor = social::self_actor(conn); - let outcome = - match mail::take_item(store, actor, c.mailbox.guid(), u64::from(c.mail_id)) { - Ok(taken) => Ok(taken), + } + // Delete a mail — destroys any attachment it still holds, as vanilla does (the confirmation + // prompt is client-side). Unlike the read-only arms above, `CMSG_MAIL_DELETE` has a real + // ack (`SMSG_SEND_MAIL_RESULT`/Deleted), so both outcomes reply through it, matching the + // vendor/loot arms' "a failed action still answers" rule. + ClientOpcodeMessage::CMSG_MAIL_DELETE(c) => { + let actor = conn.actor(); + let ok = match mail::delete(store, actor, c.mailbox_id.guid(), u64::from(c.mail_id)) + { + Ok(()) => true, Err(failure) => { - let e = failure.refusal()?; log::debug!( - "world: mail take-item refused (account {}): {e}", - conn.account_id + "world: mail delete refused (account {}): {}", + conn.account_id, + failure.refusal()? ); - Err(match e { - mail::TakeItemRefusal::BagsFull(_) => { - codec::MailTakeItemError::BagsFull - } - mail::TakeItemRefusal::CannotAffordCod(_) => { - codec::MailTakeItemError::NotEnoughMoney - } - mail::TakeItemRefusal::Other(_) => codec::MailTakeItemError::Other, - }) + false } }; - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_SEND_MAIL_RESULT(Box::new( - codec::build_mail_take_item_result(c.mail_id, outcome), - ))), - )?; - } - // Post a letter. `cash_on_delivery_amount` is the price the RECIPIENT will owe for the - // attachment — it costs the sender nothing here. Every refusal answers with its OWN - // `MailResultTwo`, never a generic internal error: the client renders each as distinct - // on-screen text, which is all the player gets to work with. - // - // The one refusal that answers with SILENCE is the mailbox gate, matching the list arm - // above — vanilla has no mailbox-refusal packet, and the client only offers Send at a - // mailbox, so reaching it means a crafted packet or a desynced session. - ClientOpcodeMessage::CMSG_SEND_MAIL(c) => { - let result2 = match mail::send( - store, - social::self_actor(conn), - c.mailbox.guid(), - &c.receiver, - c.subject.clone(), - c.body.clone(), - c.money.as_int(), - c.cash_on_delivery_amount, - c.item.guid(), - ) { - Ok(()) => Some(SMSG_SEND_MAIL_RESULT_MailResultTwo::Ok), - Err(failure) => { - let e = failure.refusal()?; - log::debug!( - "world: mail send refused (account {}): {e}", - conn.account_id - ); - match e { - mail::SendRefusal::NoMailbox(_) => None, - mail::SendRefusal::RecipientNotFound(_) => { - Some(SMSG_SEND_MAIL_RESULT_MailResultTwo::ErrRecipientNotFound) - } - mail::SendRefusal::CannotSendToSelf => { - Some(SMSG_SEND_MAIL_RESULT_MailResultTwo::ErrCannotSendToSelf) - } - mail::SendRefusal::NotYourTeam => { - Some(SMSG_SEND_MAIL_RESULT_MailResultTwo::ErrNotYourTeam) - } - mail::SendRefusal::NotEnoughMoney(_) => { - Some(SMSG_SEND_MAIL_RESULT_MailResultTwo::ErrNotEnoughMoney) - } - // A bound item and one that is not the sender's are different mistakes, so - // the client is told which: vanilla has no "soulbound" mail line, and - // `ErrCantSendWrappedCod` is mangoszero's nearest — it renders as a refusal - // about the attachment rather than about the letter. - mail::SendRefusal::AttachmentSoulbound(_) => { - Some(SMSG_SEND_MAIL_RESULT_MailResultTwo::ErrCantSendWrappedCod) + outbound.push(Outbound::One(ServerOpcodeMessage::SMSG_SEND_MAIL_RESULT( + Box::new(codec::build_mail_delete_result(c.mail_id, ok)), + ))); + } + // Return a mail to whoever sent it. The row is re-addressed in place — no escrow, since it + // never leaves the plane that already holds it — so this is [`mail::delete`]'s twin down to + // the authorization: a mail id is client-supplied, and "not yours" reads the same as "no + // such mail". Acks through `SMSG_SEND_MAIL_RESULT`/ReturnedToSender either way. + ClientOpcodeMessage::CMSG_MAIL_RETURN_TO_SENDER(c) => { + let actor = conn.actor(); + let ok = match mail::return_to_sender( + store, + actor, + c.mailbox_id.guid(), + u64::from(c.mail_id), + ) { + Ok(()) => true, + Err(failure) => { + log::debug!( + "world: mail return refused (account {}): {}", + conn.account_id, + failure.refusal()? + ); + false + } + }; + outbound.push(Outbound::One(ServerOpcodeMessage::SMSG_SEND_MAIL_RESULT( + Box::new(codec::build_mail_return_result(c.mail_id, ok)), + ))); + } + // Take a mail's copper into the purse. The mail id is client-supplied, so the refusal for + // somebody else's mail is the authorization boundary and not a sanity check — and it reads + // the same as "there is nothing in it", so a crafted id learns nothing either way. Both + // outcomes ack through `SMSG_SEND_MAIL_RESULT`/MoneyTaken, which is what closes the + // client's spinner. + ClientOpcodeMessage::CMSG_MAIL_TAKE_MONEY(c) => { + let actor = conn.actor(); + let ok = + match mail::take_money(store, actor, c.mailbox.guid(), u64::from(c.mail_id)) { + Ok(()) => true, + Err(failure) => { + log::debug!( + "world: mail take-money refused (account {}): {}", + conn.account_id, + failure.refusal()? + ); + false } - mail::SendRefusal::AttachmentInvalid(_) => { - Some(SMSG_SEND_MAIL_RESULT_MailResultTwo::ErrMailAttachmentInvalid) + }; + outbound.push(Outbound::One(ServerOpcodeMessage::SMSG_SEND_MAIL_RESULT( + Box::new(codec::build_mail_take_money_result(c.mail_id, ok)), + ))); + } + // Take a mail's attached item into the bags, paying any cash-on-delivery price for it. A + // full bag answers `ErrEquipError` and an unaffordable price `ErrNotEnoughMoney` — the two + // refusals a player can act on, and in both the item STAYS in the letter, which is also + // what leaves a refused buyer free to return it. Every other refusal reads the same, so a + // crafted mail id learns nothing. + ClientOpcodeMessage::CMSG_MAIL_TAKE_ITEM(c) => { + let actor = conn.actor(); + let outcome = + match mail::take_item(store, actor, c.mailbox.guid(), u64::from(c.mail_id)) { + Ok(taken) => Ok(taken), + Err(failure) => { + let e = failure.refusal()?; + log::debug!( + "world: mail take-item refused (account {}): {e}", + conn.account_id + ); + Err(match e { + mail::TakeItemRefusal::BagsFull(_) => { + codec::MailTakeItemError::BagsFull + } + mail::TakeItemRefusal::CannotAffordCod(_) => { + codec::MailTakeItemError::NotEnoughMoney + } + mail::TakeItemRefusal::Other(_) => codec::MailTakeItemError::Other, + }) } - mail::SendRefusal::Internal(_) => { - Some(SMSG_SEND_MAIL_RESULT_MailResultTwo::ErrInternalError) + }; + outbound.push(Outbound::One(ServerOpcodeMessage::SMSG_SEND_MAIL_RESULT( + Box::new(codec::build_mail_take_item_result(c.mail_id, outcome)), + ))); + } + // Post a letter. `cash_on_delivery_amount` is the price the RECIPIENT will owe for the + // attachment — it costs the sender nothing here. Every refusal answers with its OWN + // `MailResultTwo`, never a generic internal error: the client renders each as distinct + // on-screen text, which is all the player gets to work with. + // + // The one refusal that answers with SILENCE is the mailbox gate, matching the list arm + // above — vanilla has no mailbox-refusal packet, and the client only offers Send at a + // mailbox, so reaching it means a crafted packet or a desynced session. + ClientOpcodeMessage::CMSG_SEND_MAIL(c) => { + let result2 = match mail::send( + store, + conn.actor(), + c.mailbox.guid(), + &c.receiver, + c.subject.clone(), + c.body.clone(), + c.money.as_int(), + c.cash_on_delivery_amount, + c.item.guid(), + ) { + Ok(()) => Some(SMSG_SEND_MAIL_RESULT_MailResultTwo::Ok), + Err(failure) => { + let e = failure.refusal()?; + log::debug!( + "world: mail send refused (account {}): {e}", + conn.account_id + ); + match e { + mail::SendRefusal::NoMailbox(_) => None, + mail::SendRefusal::RecipientNotFound(_) => { + Some(SMSG_SEND_MAIL_RESULT_MailResultTwo::ErrRecipientNotFound) + } + mail::SendRefusal::CannotSendToSelf => { + Some(SMSG_SEND_MAIL_RESULT_MailResultTwo::ErrCannotSendToSelf) + } + mail::SendRefusal::NotYourTeam => { + Some(SMSG_SEND_MAIL_RESULT_MailResultTwo::ErrNotYourTeam) + } + mail::SendRefusal::NotEnoughMoney(_) => { + Some(SMSG_SEND_MAIL_RESULT_MailResultTwo::ErrNotEnoughMoney) + } + // A bound item and one that is not the sender's are different mistakes, so + // the client is told which: vanilla has no "soulbound" mail line, and + // `ErrCantSendWrappedCod` is mangoszero's nearest — it renders as a refusal + // about the attachment rather than about the letter. + mail::SendRefusal::AttachmentSoulbound(_) => { + Some(SMSG_SEND_MAIL_RESULT_MailResultTwo::ErrCantSendWrappedCod) + } + mail::SendRefusal::AttachmentInvalid(_) => { + Some(SMSG_SEND_MAIL_RESULT_MailResultTwo::ErrMailAttachmentInvalid) + } + mail::SendRefusal::Internal(_) => { + Some(SMSG_SEND_MAIL_RESULT_MailResultTwo::ErrInternalError) + } } } + }; + if let Some(result2) = result2 { + outbound.push(Outbound::One(ServerOpcodeMessage::SMSG_SEND_MAIL_RESULT( + Box::new(codec::build_mail_send_result(result2)), + ))); } - }; - if let Some(result2) = result2 { - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_SEND_MAIL_RESULT(Box::new( - codec::build_mail_send_result(result2), - ))), - )?; } - } - // Turn a delivered letter's text into a Plain Letter in the bags (the client's letter - // button, offered on a takeable mail that is not yet COPIED). Bags-full and every other - // refusal are the two outcomes the player can act on, so both ack through - // `SMSG_SEND_MAIL_RESULT`/MadePermanent; the mailbox gate alone stays silent, matching the - // other arms. - ClientOpcodeMessage::CMSG_MAIL_CREATE_TEXT_ITEM(c) => { - let actor = social::self_actor(conn); - let made = match mail::copy_letter(store, actor, c.mailbox.guid(), u64::from(c.mail_id)) - { - Ok(()) => Some(Ok(())), - Err(failure) => { - let e = failure.refusal()?; - log::debug!( - "world: letter copy refused (account {}): {e}", - conn.account_id - ); - match e { - mail::CopyLetterRefusal::NoMailbox(_) => None, - mail::CopyLetterRefusal::BagsFull(_) => { - Some(Err(codec::MailMadePermanentError::BagsFull)) - } - mail::CopyLetterRefusal::Other(_) => { - Some(Err(codec::MailMadePermanentError::Other)) + // Turn a delivered letter's text into a Plain Letter in the bags (the client's letter + // button, offered on a takeable mail that is not yet COPIED). Bags-full and every other + // refusal are the two outcomes the player can act on, so both ack through + // `SMSG_SEND_MAIL_RESULT`/MadePermanent; the mailbox gate alone stays silent, matching the + // other arms. + ClientOpcodeMessage::CMSG_MAIL_CREATE_TEXT_ITEM(c) => { + let actor = conn.actor(); + let made = + match mail::copy_letter(store, actor, c.mailbox.guid(), u64::from(c.mail_id)) { + Ok(()) => Some(Ok(())), + Err(failure) => { + let e = failure.refusal()?; + log::debug!( + "world: letter copy refused (account {}): {e}", + conn.account_id + ); + match e { + mail::CopyLetterRefusal::NoMailbox(_) => None, + mail::CopyLetterRefusal::BagsFull(_) => { + Some(Err(codec::MailMadePermanentError::BagsFull)) + } + mail::CopyLetterRefusal::Other(_) => { + Some(Err(codec::MailMadePermanentError::Other)) + } + } } - } + }; + if let Some(made) = made { + outbound.push(Outbound::One(ServerOpcodeMessage::SMSG_SEND_MAIL_RESULT( + Box::new(codec::build_mail_made_permanent_result(c.mail_id, made)), + ))); } - }; - if let Some(made) = made { - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_SEND_MAIL_RESULT(Box::new( - codec::build_mail_made_permanent_result(c.mail_id, made), - ))), - )?; } + _ => return Err(anyhow!("opcode routed to the wrong Protocol Family")), } - other => return Ok(Some(other)), + Ok(outbound.into()) } - Ok(None) } diff --git a/gateway/src/world/handlers/meeting_stone.rs b/gateway/src/world/handlers/meeting_stone.rs index ee96e1574..0722d9584 100644 --- a/gateway/src/world/handlers/meeting_stone.rs +++ b/gateway/src/world/handlers/meeting_stone.rs @@ -47,56 +47,47 @@ pub(crate) trait MeetingStoneActionStore { fn queued_area(&self, character_guid: u64) -> Result>; } -#[derive(Clone, Copy, Debug)] -pub(crate) struct MeetingStonePlayer { - pub(crate) account_id: u64, - /// `None` outside the world. - pub(crate) self_guid: Option, -} - -pub(crate) enum MeetingStoneActionOutcome { - Handled { outbound: Vec }, - PassThrough(ClientOpcodeMessage), -} - -/// Consume the meeting stone opcodes and `MSG_LOOKING_FOR_GROUP`, silently outside the world, and -/// pass everything else on. -pub(crate) fn dispatch_meeting_stone_action( - store: &St, - player: MeetingStonePlayer, - msg: ClientOpcodeMessage, -) -> Result { - let action = match msg { - ClientOpcodeMessage::CMSG_MEETINGSTONE_JOIN(join) => Action::Join(join.guid.guid()), - ClientOpcodeMessage::CMSG_MEETINGSTONE_LEAVE => Action::Leave, - ClientOpcodeMessage::CMSG_MEETINGSTONE_INFO => Action::Info, - ClientOpcodeMessage::MSG_LOOKING_FOR_GROUP => Action::LookingForGroup, - other => return Ok(MeetingStoneActionOutcome::PassThrough(other)), - }; - let Some(actor) = player.self_guid.and_then(Actor::new) else { - return Ok(MeetingStoneActionOutcome::Handled { - outbound: Vec::new(), - }); - }; - let outbound = match action { - Action::Join(go_guid) => join(store, player.account_id, actor, go_guid)?, - Action::Leave => { - run_op( - store, - player.account_id, - actor, - realm_op::LEAVE, - 0, - Vec::new(), - )?; - Vec::new() - } - Action::Info => info(store, player.account_id, actor)?, - Action::LookingForGroup => vec![Outbound::One(ServerOpcodeMessage::MSG_LOOKING_FOR_GROUP( - codec::build_looking_for_group(), - ))], - }; - Ok(MeetingStoneActionOutcome::Handled { outbound }) +/// Handle Meeting Stone requests, silently outside the world. +pub(crate) struct MeetingStone; + +impl ProtocolFamily for MeetingStone { + fn handle( + store: &St, + session: &mut ProtocolSession, + request: ProtocolRequest, + ) -> Result { + let player = &*session; + let msg = request.message()?; + let action = match msg { + ClientOpcodeMessage::CMSG_MEETINGSTONE_JOIN(join) => Action::Join(join.guid.guid()), + ClientOpcodeMessage::CMSG_MEETINGSTONE_LEAVE => Action::Leave, + ClientOpcodeMessage::CMSG_MEETINGSTONE_INFO => Action::Info, + ClientOpcodeMessage::MSG_LOOKING_FOR_GROUP => Action::LookingForGroup, + other => return Err(anyhow!("opcode routed to wrong Protocol Family: {other}")), + }; + let Some(actor) = player.self_guid().and_then(Actor::new) else { + return Ok(ProtocolReply::from(Vec::new())); + }; + let outbound = match action { + Action::Join(go_guid) => join(store, player.account_id, actor, go_guid)?, + Action::Leave => { + run_op( + store, + player.account_id, + actor, + realm_op::LEAVE, + 0, + Vec::new(), + )?; + Vec::new() + } + Action::Info => info(store, player.account_id, actor)?, + Action::LookingForGroup => vec![Outbound::One( + ServerOpcodeMessage::MSG_LOOKING_FOR_GROUP(codec::build_looking_for_group()), + )], + }; + Ok(ProtocolReply::from(outbound)) + } } enum Action { @@ -252,7 +243,7 @@ mod tests { use crate::stdb::ReducerCallError; use std::collections::HashMap; use std::sync::Mutex; - use wow_world_messages::vanilla::{CMSG_MEETINGSTONE_JOIN, CMSG_PING}; + use wow_world_messages::vanilla::CMSG_MEETINGSTONE_JOIN; use wow_world_messages::Guid; const ACTOR: u64 = 7; @@ -359,11 +350,8 @@ mod tests { } } - fn in_world() -> MeetingStonePlayer { - MeetingStonePlayer { - account_id: 1, - self_guid: Some(ACTOR), - } + fn in_world() -> ProtocolSession { + ProtocolSession::in_world(1, ACTOR) } fn join_stone() -> ClientOpcodeMessage { @@ -373,10 +361,9 @@ mod tests { } fn handled(store: &FakeMeetingStones, msg: ClientOpcodeMessage) -> Vec { - match dispatch_meeting_stone_action(store, in_world(), msg).unwrap() { - MeetingStoneActionOutcome::Handled { outbound } => outbound, - MeetingStoneActionOutcome::PassThrough(_) => panic!("the opcode was not consumed"), - } + MeetingStone::handle(store, &mut in_world(), msg.into()) + .unwrap() + .outbound } fn joinfailed_reason(outbound: &[Outbound]) -> u8 { @@ -562,20 +549,17 @@ mod tests { #[test] fn every_opcode_is_silent_outside_the_world() { let store = FakeMeetingStones::at_stone(); - let outside = MeetingStonePlayer { - account_id: 1, - self_guid: None, - }; + let mut outside = ProtocolSession::new(1, "TESTER".into()); for msg in [ join_stone(), ClientOpcodeMessage::CMSG_MEETINGSTONE_LEAVE, ClientOpcodeMessage::CMSG_MEETINGSTONE_INFO, ClientOpcodeMessage::MSG_LOOKING_FOR_GROUP, ] { - match dispatch_meeting_stone_action(&store, outside, msg).unwrap() { - MeetingStoneActionOutcome::Handled { outbound } => assert!(outbound.is_empty()), - MeetingStoneActionOutcome::PassThrough(_) => panic!("not consumed"), - } + assert!(MeetingStone::handle(&store, &mut outside, msg.into()) + .unwrap() + .outbound + .is_empty()); } assert!(store.calls().is_empty()); } @@ -597,16 +581,6 @@ mod tests { })), ..FakeMeetingStones::at_stone() }; - assert!(dispatch_meeting_stone_action(&lost, in_world(), join_stone()).is_err()); - } - - #[test] - fn other_opcodes_pass_through() { - let store = FakeMeetingStones::default(); - let msg = ClientOpcodeMessage::CMSG_PING(CMSG_PING::default()); - assert!(matches!( - dispatch_meeting_stone_action(&store, in_world(), msg).unwrap(), - MeetingStoneActionOutcome::PassThrough(_) - )); + assert!(MeetingStone::handle(&lost, &mut in_world(), join_stone().into()).is_err()); } } diff --git a/gateway/src/world/handlers/melee.rs b/gateway/src/world/handlers/melee.rs index 8a896d8e8..91b91d6a0 100644 --- a/gateway/src/world/handlers/melee.rs +++ b/gateway/src/world/handlers/melee.rs @@ -2,6 +2,7 @@ use super::super::*; use crate::stdb::{classify, DurableFailure}; +use crate::world::family::{ProtocolFamily, ProtocolReply, ProtocolRequest, ProtocolSession}; pub(crate) trait MeleeActionStore: Send + Sync { fn start_attack(&self, actor: Actor, target_guid: u64) -> Result<()>; @@ -10,115 +11,54 @@ pub(crate) trait MeleeActionStore: Send + Sync { fn stop_attack(&self, actor: Actor) -> Result<()>; } -/// The melee-relevant session facts. `self_guid` is `None` outside the world. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) struct MeleeActionPlayer { - pub(crate) account_id: u64, - pub(crate) self_guid: Option, - pub(crate) attacking_target: Option, - pub(crate) ranged_repeat: bool, -} - -impl MeleeActionPlayer { - pub(crate) fn from_conn(conn: &WorldConn) -> Self { - let (attacking_target, ranged_repeat) = match &conn.state { - WorldState::InWorld(iw) => (iw.attacking_target, iw.ranged_repeat), - WorldState::CharSelect => (None, false), - }; - Self { - account_id: conn.account_id, - self_guid: social::self_guid(conn), - attacking_target, - ranged_repeat, - } - } -} - -/// The session-state change a melee outcome asks the world session to apply. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum MeleeTransition { - /// Leave the session's combat state as it is (every refusal). - Unchanged, - /// Melee engagement armed on `target`. Clears ranged auto-repeat: the shared durable row now - /// means melee. - Engaged(u64), - /// Melee engagement dropped. Leaves ranged auto-repeat alone: a stop that reaches here never - /// had one armed. - Disengaged, -} - -impl MeleeTransition { - /// A player who left the world meanwhile has no combat state to carry, so this is a no-op. - pub(crate) fn apply(self, state: &mut WorldState) { - let WorldState::InWorld(iw) = state else { - return; - }; - match self { - Self::Unchanged => {} - Self::Engaged(target_guid) => { - iw.attacking_target = Some(target_guid); - iw.ranged_repeat = false; - } - Self::Disengaged => iw.attacking_target = None, - } - } -} - -pub(crate) enum MeleeActionOutcome { - Handled { - transition: MeleeTransition, - outbound: Vec, - }, - PassThrough(ClientOpcodeMessage), -} - -pub(crate) fn dispatch_melee_action( - store: &St, - player: MeleeActionPlayer, - msg: ClientOpcodeMessage, -) -> Result { - match msg { - ClientOpcodeMessage::CMSG_ATTACKSWING(s) => { - let target_guid = s.guid.guid(); - log::info!( +pub(crate) struct Melee; + +impl ProtocolFamily for Melee { + fn handle( + store: &St, + session: &mut ProtocolSession, + request: ProtocolRequest, + ) -> Result { + let msg = request.message()?; + match msg { + ClientOpcodeMessage::CMSG_ATTACKSWING(s) => { + let target_guid = s.guid.guid(); + log::info!( "world[autoshot]: CMSG_ATTACKSWING target={target_guid} ranged_repeat_active={} (account {})", - player.ranged_repeat, - player.account_id + matches!(&session.state, WorldState::InWorld(world) if world.ranged_repeat), + session.account_id ); - attack_start(store, player, target_guid) - } - ClientOpcodeMessage::CMSG_ATTACKSTOP => { - log::info!( - "world[autoshot]: CMSG_ATTACKSTOP ranged_repeat_active={} (account {})", - player.ranged_repeat, - player.account_id - ); - attack_stop(store, player) + attack_start(store, session, target_guid) + } + ClientOpcodeMessage::CMSG_ATTACKSTOP => { + log::info!( + "world[autoshot]: CMSG_ATTACKSTOP ranged_repeat_active={} (account {})", + matches!(&session.state, WorldState::InWorld(world) if world.ranged_repeat), + session.account_id + ); + attack_stop(store, session) + } + other => Err(anyhow!("request routed to Melee: {other}")), } - other => Ok(MeleeActionOutcome::PassThrough(other)), } } -/// The player's own entity is gone, so no further action can be served. Close the session rather -/// than leave the player in a frozen world with no recovery. +/// The Character's entity is gone, so no further action can be served. End the World Session so the client can log in again. fn desync_exit(error: anyhow::Error, opcode: &str) -> anyhow::Error { error.context(format!( - "player desync (entity missing) on {opcode} — closing session for a clean relog" + "Character entity missing on {opcode}: desync ends the World Session" )) } /// Arm the durable engagement first; session state and client stance follow only on success. fn attack_start( store: &St, - player: MeleeActionPlayer, + session: &mut ProtocolSession, target_guid: u64, -) -> Result { +) -> Result { // Not in the world: no combat state to arm and no attacker guid to name. - let Some(actor) = player.self_guid.and_then(Actor::new) else { - return Ok(MeleeActionOutcome::Handled { - transition: MeleeTransition::Unchanged, - outbound: Vec::new(), - }); + let Some(actor) = session.actor() else { + return Ok(ProtocolReply::default()); }; if let Err(e) = store.start_attack(actor, target_guid) { let DurableFailure::Refusal { reason } = classify(&e) else { @@ -139,18 +79,22 @@ fn attack_start( // Every non-desync refusal stays visible to operators, answered ones included. log::debug!( "world: start_attack ignored (account {}): {e}", - player.account_id + session.account_id ); - return Ok(MeleeActionOutcome::Handled { - transition: MeleeTransition::Unchanged, - outbound: refusal.map(Outbound::One).into_iter().collect(), - }); + return Ok(refusal + .map(Outbound::One) + .into_iter() + .collect::>() + .into()); } - Ok(MeleeActionOutcome::Handled { - transition: MeleeTransition::Engaged(target_guid), - outbound: vec![Outbound::One(ServerOpcodeMessage::SMSG_ATTACKSTART( + Ok({ + if let WorldState::InWorld(world) = &mut session.state { + world.attacking_target = Some(target_guid); + world.ranged_repeat = false; + } + ProtocolReply::from(vec![Outbound::One(ServerOpcodeMessage::SMSG_ATTACKSTART( Box::new(codec::build_attack_start(actor.guid(), target_guid)), - ))], + ))]) }) } @@ -158,22 +102,21 @@ fn attack_start( /// combat stance after. fn attack_stop( store: &St, - player: MeleeActionPlayer, -) -> Result { + session: &mut ProtocolSession, +) -> Result { // Melee and ranged auto-repeat share one durable engagement row per attacker. The client sends // CMSG_ATTACKSTOP whenever it leaves melee stance, ranged loop armed or not, so honoring it // here would delete the auto-shot engagement: one shot, then silence. Only // CMSG_CANCEL_AUTO_REPEAT_SPELL tears that loop down. - if player.ranged_repeat { - return Ok(MeleeActionOutcome::Handled { - transition: MeleeTransition::Unchanged, - outbound: Vec::new(), - }); + if matches!(&session.state, WorldState::InWorld(world) if world.ranged_repeat) { + return Ok(ProtocolReply::default()); } - let Some(actor) = player.self_guid.and_then(Actor::new) else { - return Ok(MeleeActionOutcome::Handled { - transition: MeleeTransition::Disengaged, - outbound: Vec::new(), + let Some(actor) = session.actor() else { + return Ok({ + if let WorldState::InWorld(world) = &mut session.state { + world.attacking_target = None; + } + ProtocolReply::from(Vec::new()) }); }; if let Err(e) = store.stop_attack(actor) { @@ -184,24 +127,28 @@ fn attack_stop( return Err(desync_exit(e, "attackstop")); } // A refused stop still clears the client's stance: the recorded target may already be dead, - // and leaving the player swinging at nothing is worse than a stale disarm. + // and leaving the Character swinging at nothing is worse than a stale disarm. log::debug!( "world: stop_attack ignored (account {}): {e}", - player.account_id + session.account_id ); } - let outbound = player - .attacking_target - .map(|target_guid| { - Outbound::One(ServerOpcodeMessage::SMSG_ATTACKSTOP(Box::new( - codec::build_attack_stop(actor.guid(), target_guid), - ))) - }) - .into_iter() - .collect(); - Ok(MeleeActionOutcome::Handled { - transition: MeleeTransition::Disengaged, - outbound, + let outbound: Vec = match &session.state { + WorldState::InWorld(world) => world.attacking_target, + WorldState::CharSelect => None, + } + .map(|target_guid| { + Outbound::One(ServerOpcodeMessage::SMSG_ATTACKSTOP(Box::new( + codec::build_attack_stop(actor.guid(), target_guid), + ))) + }) + .into_iter() + .collect(); + Ok({ + if let WorldState::InWorld(world) = &mut session.state { + world.attacking_target = None; + } + ProtocolReply::from(outbound) }) } @@ -210,7 +157,7 @@ pub(super) mod tests { use super::*; use crate::stdb::ReducerCallError; use std::sync::Mutex; - use wow_world_messages::vanilla::{Guid, CMSG_ATTACKSWING, CMSG_PING}; + use wow_world_messages::vanilla::{Guid, CMSG_ATTACKSWING}; #[derive(Default)] pub(crate) struct InMemoryMeleeActions { @@ -262,360 +209,190 @@ pub(super) mod tests { } } - /// A player mid ranged auto-repeat: attack start must engage melee and clear that loop. - fn player() -> MeleeActionPlayer { - MeleeActionPlayer { - account_id: 7, - self_guid: Some(42), - attacking_target: None, - ranged_repeat: true, + fn session(target: Option, ranged: bool) -> ProtocolSession { + let mut session = ProtocolSession::in_world(7, 42); + if let WorldState::InWorld(world) = &mut session.state { + world.attacking_target = target; + world.ranged_repeat = ranged; } + session } - fn in_world(attacking_target: Option, ranged_repeat: bool) -> WorldState { - WorldState::InWorld(InWorld { - self_guid: 42, - subs: PlayerSubscriptions::empty(), - attacking_target, - open_loot: OpenLootState::default(), - ranged_repeat, - }) - } - - fn combat_state(state: &WorldState) -> (Option, bool) { - match state { - WorldState::InWorld(iw) => (iw.attacking_target, iw.ranged_repeat), - WorldState::CharSelect => panic!("not in world"), - } - } - - /// A player in melee on `target`, with no ranged loop armed. - fn engaged(target: u64) -> MeleeActionPlayer { - MeleeActionPlayer { - attacking_target: Some(target), - ranged_repeat: false, - ..player() + fn combat_state(session: &ProtocolSession) -> (Option, bool) { + match &session.state { + WorldState::InWorld(world) => (world.attacking_target, world.ranged_repeat), + WorldState::CharSelect => (None, false), } } - fn attack_swing(target: u64) -> ClientOpcodeMessage { + fn swing(target: u64) -> ProtocolRequest { ClientOpcodeMessage::CMSG_ATTACKSWING(CMSG_ATTACKSWING { guid: Guid::new(target), }) - } - - fn refused(error: &str) -> InMemoryMeleeActions { - InMemoryMeleeActions { - start_error: Some(error.into()), - ..Default::default() - } + .into() } #[test] - fn attack_start_requests_the_durable_engagement_then_arms_the_session_and_client() { - let actions = InMemoryMeleeActions::default(); - - let outcome = dispatch_melee_action(&actions, player(), attack_swing(90)).unwrap(); - - assert_eq!( - actions.start_requests.lock().unwrap().as_slice(), - &[(42, 90)] - ); - let MeleeActionOutcome::Handled { - transition, - outbound, - } = outcome - else { - panic!("attack start must be handled by the melee seam"); - }; - assert_eq!(transition, MeleeTransition::Engaged(90)); + fn starting_melee_arms_the_engagement_and_clears_ranged_repeat() { + let store = InMemoryMeleeActions::default(); + let mut session = session(None, true); + let reply = Melee::handle(&store, &mut session, swing(90)).unwrap(); + assert_eq!(*store.engaged.lock().unwrap(), vec![42]); + assert_eq!(combat_state(&session), (Some(90), false)); assert!( - matches!(outbound.as_slice(), [Outbound::One(ServerOpcodeMessage::SMSG_ATTACKSTART(a))] - if a.attacker.guid() == 42 && a.victim.guid() == 90) + matches!(reply.outbound.as_slice(), [Outbound::One(ServerOpcodeMessage::SMSG_ATTACKSTART(message))] if message.attacker.guid() == 42 && message.victim.guid() == 90) ); - let mut state = in_world(None, true); - transition.apply(&mut state); - assert_eq!(combat_state(&state), (Some(90), false)); - } - - #[test] - fn attack_start_from_an_inactive_ranged_state_gives_the_same_melee_transition() { - let actions = InMemoryMeleeActions::default(); - let player = MeleeActionPlayer { - ranged_repeat: false, - ..player() - }; - - let outcome = dispatch_melee_action(&actions, player, attack_swing(90)).unwrap(); - - assert!(matches!( - outcome, - MeleeActionOutcome::Handled { transition, .. } - if transition == MeleeTransition::Engaged(90) - )); } #[test] - fn a_dead_target_replies_deadtarget_and_leaves_the_active_target_unchanged() { - let actions = refused(lyracore_shared::ERR_ATTACK_TARGET_DEAD); - - let outcome = dispatch_melee_action(&actions, player(), attack_swing(90)).unwrap(); - - assert!(matches!( - outcome, - MeleeActionOutcome::Handled { transition, outbound } - if transition == MeleeTransition::Unchanged - && matches!(outbound.as_slice(), - [Outbound::One(ServerOpcodeMessage::SMSG_ATTACKSWING_DEADTARGET)]) - )); - } - - #[test] - fn a_friendly_target_replies_cant_attack_and_leaves_the_active_target_unchanged() { - let actions = refused(lyracore_shared::ERR_ATTACK_FRIENDLY); - - let outcome = dispatch_melee_action(&actions, player(), attack_swing(90)).unwrap(); - - assert!(matches!( - outcome, - MeleeActionOutcome::Handled { transition, outbound } - if transition == MeleeTransition::Unchanged - && matches!(outbound.as_slice(), - [Outbound::One(ServerOpcodeMessage::SMSG_ATTACKSWING_CANT_ATTACK)]) - )); - } - - #[test] - fn an_ordinary_refusal_sends_no_client_message_and_keeps_the_session_alive() { - let actions = refused("target out of range"); - - let outcome = dispatch_melee_action(&actions, player(), attack_swing(90)).unwrap(); - - assert!(matches!( - outcome, - MeleeActionOutcome::Handled { transition, outbound } - if transition == MeleeTransition::Unchanged && outbound.is_empty() - )); + fn refused_attack_preserves_the_previous_target_and_answers_the_refusal() { + for (reason, expected) in [ + ( + lyracore_shared::ERR_ATTACK_TARGET_DEAD, + Some(ServerOpcodeMessage::SMSG_ATTACKSWING_DEADTARGET), + ), + ( + lyracore_shared::ERR_ATTACK_FRIENDLY, + Some(ServerOpcodeMessage::SMSG_ATTACKSWING_CANT_ATTACK), + ), + ("target out of range", None), + ] { + let store = InMemoryMeleeActions { + start_error: Some(reason.into()), + ..Default::default() + }; + let mut session = session(Some(80), true); + let reply = Melee::handle(&store, &mut session, swing(90)).unwrap(); + assert_eq!(combat_state(&session), (Some(80), true)); + assert!(store.engaged.lock().unwrap().is_empty()); + match expected { + Some(expected) => assert!( + matches!(reply.outbound.as_slice(), [Outbound::One(actual)] if *actual == expected) + ), + None => assert!(reply.outbound.is_empty()), + } + } } #[test] - fn an_unresolved_player_requests_nothing_and_arms_nothing() { - let actions = InMemoryMeleeActions::default(); - let player = MeleeActionPlayer { - self_guid: None, - ..player() - }; - - let outcome = dispatch_melee_action(&actions, player, attack_swing(90)).unwrap(); - - assert!(actions.start_requests.lock().unwrap().is_empty()); - assert!(matches!( - outcome, - MeleeActionOutcome::Handled { transition, outbound } - if transition == MeleeTransition::Unchanged && outbound.is_empty() - )); + fn melee_requests_before_world_entry_do_nothing() { + let store = InMemoryMeleeActions::default(); + let mut session = ProtocolSession::new(7, "TESTER".into()); + assert!(Melee::handle(&store, &mut session, swing(90)) + .unwrap() + .outbound + .is_empty()); + assert!(Melee::handle( + &store, + &mut session, + ClientOpcodeMessage::CMSG_ATTACKSTOP.into() + ) + .unwrap() + .outbound + .is_empty()); + assert!(store.start_requests.lock().unwrap().is_empty()); + assert!(store.stop_requests.lock().unwrap().is_empty()); } #[test] - fn attack_stop_requests_the_durable_disengagement_then_clears_the_session_and_client() { - let actions = InMemoryMeleeActions::default(); - - let outcome = - dispatch_melee_action(&actions, engaged(90), ClientOpcodeMessage::CMSG_ATTACKSTOP) - .unwrap(); - - assert_eq!(actions.stop_requests.lock().unwrap().as_slice(), &[42]); - let MeleeActionOutcome::Handled { - transition, - outbound, - } = outcome - else { - panic!("attack stop must be handled by the melee seam"); - }; - assert_eq!(transition, MeleeTransition::Disengaged); + fn stopping_melee_disarms_the_engagement_and_clears_the_client_stance() { + let store = InMemoryMeleeActions::default(); + store.engaged.lock().unwrap().push(42); + let mut session = session(Some(90), false); + let reply = Melee::handle( + &store, + &mut session, + ClientOpcodeMessage::CMSG_ATTACKSTOP.into(), + ) + .unwrap(); + assert!(store.engaged.lock().unwrap().is_empty()); + assert_eq!(combat_state(&session), (None, false)); assert!( - matches!(outbound.as_slice(), [Outbound::One(ServerOpcodeMessage::SMSG_ATTACKSTOP(a))] - if a.player.guid() == 42 && a.enemy.guid() == 90) + matches!(reply.outbound.as_slice(), [Outbound::One(ServerOpcodeMessage::SMSG_ATTACKSTOP(message))] if message.player.guid() == 42 && message.enemy.guid() == 90) ); - let mut state = in_world(Some(90), false); - transition.apply(&mut state); - assert_eq!(combat_state(&state), (None, false)); } #[test] - fn attack_stop_with_no_recorded_target_still_disengages_and_fabricates_no_message() { - let actions = InMemoryMeleeActions::default(); - let player = MeleeActionPlayer { - ranged_repeat: false, - ..player() - }; - - let outcome = - dispatch_melee_action(&actions, player, ClientOpcodeMessage::CMSG_ATTACKSTOP).unwrap(); - - assert_eq!(actions.stop_requests.lock().unwrap().as_slice(), &[42]); - assert!(matches!( - outcome, - MeleeActionOutcome::Handled { transition, outbound } - if transition == MeleeTransition::Disengaged && outbound.is_empty() - )); + fn stopping_without_a_target_does_not_invent_a_client_message() { + let store = InMemoryMeleeActions::default(); + let mut session = session(None, false); + let reply = Melee::handle( + &store, + &mut session, + ClientOpcodeMessage::CMSG_ATTACKSTOP.into(), + ) + .unwrap(); + assert!(reply.outbound.is_empty()); + assert_eq!(combat_state(&session), (None, false)); } - /// A kill clears the durable row on another thread, so a late stop is refused. That is harmless. #[test] - fn an_ordinary_stop_refusal_still_clears_the_target_and_keeps_the_session_alive() { - let actions = InMemoryMeleeActions { + fn an_ordinary_stop_refusal_still_clears_the_target_and_client_stance() { + let store = InMemoryMeleeActions { stop_error: Some("no engagement for that attacker".into()), ..Default::default() }; - - let outcome = - dispatch_melee_action(&actions, engaged(90), ClientOpcodeMessage::CMSG_ATTACKSTOP) - .unwrap(); - - assert_eq!( - actions.stop_requests.lock().unwrap().as_slice(), - &[42], - "the durable stop is attempted before the refusal is swallowed" - ); - assert!(matches!( - outcome, - MeleeActionOutcome::Handled { transition, outbound } - if transition == MeleeTransition::Disengaged - && matches!(outbound.as_slice(), - [Outbound::One(ServerOpcodeMessage::SMSG_ATTACKSTOP(_))]) - )); - } - - #[test] - fn attack_stop_from_an_unresolved_player_requests_nothing_and_echoes_nothing() { - let actions = InMemoryMeleeActions::default(); - let player = MeleeActionPlayer { - self_guid: None, - ..engaged(90) - }; - - let outcome = - dispatch_melee_action(&actions, player, ClientOpcodeMessage::CMSG_ATTACKSTOP).unwrap(); - - assert!(actions.stop_requests.lock().unwrap().is_empty()); - assert!(matches!( - outcome, - MeleeActionOutcome::Handled { transition, outbound } - if transition == MeleeTransition::Disengaged && outbound.is_empty() - )); - } - - #[test] - fn an_armed_ranged_repeat_consumes_the_stop_and_changes_nothing() { - let actions = InMemoryMeleeActions::default(); - let player = MeleeActionPlayer { - ranged_repeat: true, - ..engaged(90) - }; - - let outcome = - dispatch_melee_action(&actions, player, ClientOpcodeMessage::CMSG_ATTACKSTOP).unwrap(); - - assert!( - actions.stop_requests.lock().unwrap().is_empty(), - "honoring the stop would delete the shared auto-shot engagement row" - ); - assert!(matches!( - outcome, - MeleeActionOutcome::Handled { transition, outbound } - if transition == MeleeTransition::Unchanged && outbound.is_empty() - )); - } - - #[test] - fn unrelated_opcodes_pass_through_to_the_next_dispatcher() { - let actions = InMemoryMeleeActions::default(); - - let outcome = dispatch_melee_action( - &actions, - player(), - ClientOpcodeMessage::CMSG_PING(CMSG_PING::default()), + let mut session = session(Some(90), false); + let reply = Melee::handle( + &store, + &mut session, + ClientOpcodeMessage::CMSG_ATTACKSTOP.into(), ) .unwrap(); - + assert_eq!(combat_state(&session), (None, false)); assert!(matches!( - outcome, - MeleeActionOutcome::PassThrough(ClientOpcodeMessage::CMSG_PING(_)) + reply.outbound.as_slice(), + [Outbound::One(ServerOpcodeMessage::SMSG_ATTACKSTOP(_))] )); - assert!(actions.start_requests.lock().unwrap().is_empty()); } #[test] - fn a_player_desync_ends_the_session_with_attack_start_context() { - let actions = refused("no live entity for guid 42"); - - let error = match dispatch_melee_action(&actions, player(), attack_swing(90)) { - Err(error) => error, - Ok(_) => panic!("a missing player entity must end the session"), - }; - - let text = format!("{error:#}"); - assert!( - text.contains("desync"), - "expected desync context, got: {text}" - ); - assert!( - text.contains("attackswing"), - "expected attack-start context, got: {text}" - ); + fn stopping_melee_preserves_an_armed_ranged_repeat() { + let store = InMemoryMeleeActions::default(); + store.engaged.lock().unwrap().push(42); + let mut session = session(Some(90), true); + let reply = Melee::handle( + &store, + &mut session, + ClientOpcodeMessage::CMSG_ATTACKSTOP.into(), + ) + .unwrap(); + assert_eq!(combat_state(&session), (Some(90), true)); + assert_eq!(*store.engaged.lock().unwrap(), vec![42]); + assert!(store.stop_requests.lock().unwrap().is_empty()); + assert!(reply.outbound.is_empty()); } #[test] - fn a_player_desync_on_stop_ends_the_session_with_attack_stop_context() { - let actions = InMemoryMeleeActions { + fn a_missing_entity_ends_the_world_session_with_request_context() { + let store = InMemoryMeleeActions { + start_error: Some("no live entity for guid 42".into()), stop_error: Some("player 42 not in world".into()), ..Default::default() }; - - let error = match dispatch_melee_action( - &actions, - engaged(90), - ClientOpcodeMessage::CMSG_ATTACKSTOP, - ) { - Err(error) => error, - Ok(_) => panic!("a missing player entity must end the session"), - }; - - let text = format!("{error:#}"); - assert!( - text.contains("desync"), - "expected desync context, got: {text}" - ); - assert!( - text.contains("attackstop"), - "expected attack-stop context, got: {text}" - ); + for (request, context) in [ + (swing(90), "attackswing"), + (ClientOpcodeMessage::CMSG_ATTACKSTOP.into(), "attackstop"), + ] { + let mut session = session(Some(90), false); + let error = Melee::handle(&store, &mut session, request) + .err() + .expect("missing entity must end the World Session"); + assert!(format!("{error:#}").contains(context)); + assert_eq!(combat_state(&session), (Some(90), false)); + } } #[test] - fn a_transport_loss_ends_the_session_where_a_refusal_keeps_it_alive() { - let lost = InMemoryMeleeActions { + fn transport_loss_keeps_protocol_state_unchanged_and_ends_the_world_session() { + let store = InMemoryMeleeActions { transport_lost: true, ..Default::default() }; - assert!(dispatch_melee_action(&lost, player(), attack_swing(90)).is_err()); - assert!( - dispatch_melee_action(&lost, engaged(90), ClientOpcodeMessage::CMSG_ATTACKSTOP) - .is_err() - ); - - let refusing = InMemoryMeleeActions { - start_error: Some("target out of range".into()), - stop_error: Some("no engagement for that attacker".into()), - ..Default::default() - }; - assert!(dispatch_melee_action(&refusing, player(), attack_swing(90)).is_ok()); - assert!(dispatch_melee_action( - &refusing, - engaged(90), - ClientOpcodeMessage::CMSG_ATTACKSTOP - ) - .is_ok()); + for request in [swing(90), ClientOpcodeMessage::CMSG_ATTACKSTOP.into()] { + let mut session = session(Some(80), false); + assert!(Melee::handle(&store, &mut session, request).is_err()); + assert_eq!(combat_state(&session), (Some(80), false)); + } } } diff --git a/gateway/src/world/handlers/member_stats.rs b/gateway/src/world/handlers/member_stats.rs index 991e3c2a5..0368bbbd8 100644 --- a/gateway/src/world/handlers/member_stats.rs +++ b/gateway/src/world/handlers/member_stats.rs @@ -7,7 +7,7 @@ use super::super::*; use crate::codec::{ build_member_stats, build_member_status, full_update_mask, member_status, stats_delta, - MemberStats, MemberStatsPacket, + MemberStats as MemberStatsView, MemberStatsPacket, }; use std::collections::HashMap; use std::sync::{Mutex, MutexGuard, PoisonError}; @@ -18,7 +18,7 @@ pub(crate) enum MemberPresence { /// A live `game_world_entity` row on some World Shard. Boxed: `MemberStats` carries a 32-slot /// and a 16-slot aura array plus its pet's own copies, so an unboxed variant would triple the /// size of every `MemberPresence`, most of which are `InTransit` or `Offline`. - Live(Box), + Live(Box), /// No live entity, but the member is between two places: a pending Transfer, or the loading /// screen of a map change. Reporting it offline would make the frame flicker. InTransit, @@ -41,7 +41,7 @@ pub(crate) trait MemberStatsStore: Send + Sync { pub(crate) enum MemberSnapshot { Offline, /// Boxed for the same reason as [`MemberPresence::Live`]. - Live(Box), + Live(Box), } /// What one World Session's client holds of each group mate's Member Stats. @@ -129,52 +129,42 @@ pub(crate) fn member_stats_tick( Ok(outbound) } -#[derive(Clone, Copy, Default)] -pub(crate) struct MemberStatsPlayer<'a> { - pub(crate) self_guid: Option, - /// `None` only for a Store with no Relay, such as the in-memory test Store. - pub(crate) record: Option<&'a MemberStatsRecord>, -} - -pub(crate) enum MemberStatsOutcome { - Handled { outbound: Vec }, - PassThrough(ClientOpcodeMessage), -} - -/// Answer `CMSG_REQUEST_PARTY_MEMBER_STATS` with one `SMSG_PARTY_MEMBER_STATS_FULL`. -/// -/// The answer is a writer job that also forgets the member in the viewer's record, so the next -/// Relay tick sends every field on top of it. A failed read logs and sends nothing, because a -/// party frame is never worth a World Session. -pub(crate) fn dispatch_member_stats( - store: &St, - player: MemberStatsPlayer<'_>, - msg: ClientOpcodeMessage, -) -> MemberStatsOutcome { - let (ClientOpcodeMessage::CMSG_REQUEST_PARTY_MEMBER_STATS(request), Some(self_guid)) = - (&msg, player.self_guid) - else { - return MemberStatsOutcome::PassThrough(msg); - }; - let guid = request.guid.guid(); - let (opcode, body) = match full_answer(store, self_guid, guid) { - Ok(answer) => answer, - Err(error) => { - log::warn!("member stats: no answer for {guid} to {self_guid}: {error:#}"); - return MemberStatsOutcome::Handled { - outbound: Vec::new(), - }; - } - }; - let record = player.record.cloned(); - let answer = move || { - if let Some(record) = record { - record.forget(guid); - } - vec![Outbound::Raw { opcode, body }] - }; - MemberStatsOutcome::Handled { - outbound: vec![Outbound::Job(Box::new(answer))], +pub(crate) struct MemberStats; + +impl ProtocolFamily for MemberStats { + /// Answer on the writer and clear the previous Relay record before the next tick. + /// An unreadable group member sends nothing and leaves the World Session open. + fn handle( + store: &St, + session: &mut ProtocolSession, + request: ProtocolRequest, + ) -> Result { + let ClientOpcodeMessage::CMSG_REQUEST_PARTY_MEMBER_STATS(request) = request.message()? + else { + return Err(anyhow!("opcode routed to wrong Protocol Family")); + }; + let Some(self_guid) = session.self_guid() else { + return Ok(ProtocolReply::default()); + }; + let guid = request.guid.guid(); + let (opcode, body) = match full_answer(store, self_guid, guid) { + Ok(answer) => answer, + Err(error) => { + log::warn!("member stats: no answer for {guid} to {self_guid}: {error:#}"); + return Ok(ProtocolReply::default()); + } + }; + let record = match &session.state { + WorldState::InWorld(world) => world.subs.member_stats_record().cloned(), + WorldState::CharSelect => None, + }; + let answer = move || { + if let Some(record) = record { + record.forget(guid); + } + vec![Outbound::Raw { opcode, body }] + }; + Ok(vec![Outbound::Job(Box::new(answer))].into()) } } diff --git a/gateway/src/world/handlers/mod.rs b/gateway/src/world/handlers/mod.rs index 239c8181f..9b58cfdd1 100644 --- a/gateway/src/world/handlers/mod.rs +++ b/gateway/src/world/handlers/mod.rs @@ -1,15 +1,7 @@ -//! Per-domain dispatch handlers and the deeper action dispatchers that own a complete protocol -//! family, plus the couple of helpers shared by two handlers. +//! Protocol Family handlers, their Stores, and shared protocol operations. use super::*; -// Two shapes live here. A `handle_*` handler is code-motion of the former dispatch match arms -// (bodies verbatim): it sends on the socket itself and returns `Ok(None)` once it consumes its -// opcode, else `Ok(Some(msg))` to pass the message on. A `dispatch_*_action` seam (auction, channel, -// chat, item, meeting stone, melee, quest and vendor) owns a whole protocol family instead: it takes a narrow store -// trait and a player context, decides refusal-versus-fatal itself, and returns the outbound batch -// for the world session to send, so the family can be tested without a socket. - mod auction; mod bank; mod cast; @@ -19,7 +11,9 @@ mod chat; mod combat; mod death; mod duel; +mod gameobject; mod guild; +pub(crate) use gameobject::GameObject; mod item; mod loot; mod mail; @@ -38,94 +32,74 @@ mod weather; #[cfg(test)] pub(crate) use auction::tests::{store_with, InMemoryAuctionActions}; pub(crate) use auction::{ - decode_auction_browse, dispatch_auction_action, dispatch_auction_browse_action, - AuctionActionOutcome, AuctionActionPlayer, AuctionActionStore, AuctionBrowseRequest, - AuctionPage, AuctionQuery, CancelAuctionOutcome, CancelAuctionRequest, CreateAuctionOutcome, + decode_auction_browse, Auction, AuctionActionStore, AuctionBrowseRequest, AuctionPage, + AuctionQuery, CancelAuctionOutcome, CancelAuctionRequest, CreateAuctionOutcome, CreateAuctionRequest, PlaceBidOutcome, PlaceBidRequest, CMSG_AUCTION_LIST_ITEMS_OPCODE, }; pub(crate) use auction::{AuctionHousePolicy, AuctionInteraction}; -pub(crate) use bank::{handle_bank, BankStore}; +pub(crate) use bank::{Bank, BankStore}; #[cfg(test)] pub(crate) use cast::tests::InMemoryCasts; -pub(crate) use cast::{dispatch_cast, CastOutcome, CastPlayer, CastStore, CastTransition}; +pub(crate) use cast::{Cast, CastStore}; +pub(crate) use channel::{resolve_online_character, ResolvedTarget}; pub(crate) use channel::{ - dispatch_channel_action, ChannelActionOutcome, ChannelActionStore, ChannelOutcome, - ChannelRequest, ChannelRoster, + Channel, ChannelActionStore, ChannelOutcome, ChannelRequest, ChannelRoster, }; -pub(crate) use channel::{resolve_online_character, ResolvedTarget}; -pub(crate) use char::{handle_char, CharacterStore}; +pub(crate) use char::{Character, CharacterStore}; pub(crate) use chat::{ - dispatch_chat_action, ChatActionOutcome, ChatActionPlayer, ChatActionStore, ChatOutcome, - RealmChatRequest, SpeakerFacts, SpeechStore, WhisperRequest, WhisperTargetFacts, + Chat, ChatActionStore, ChatOutcome, RealmChatRequest, SpeakerFacts, SpeechStore, + WhisperRequest, WhisperTargetFacts, }; -pub(crate) use combat::{handle_combat, CombatStore}; +pub(crate) use combat::{Combat, CombatStore}; pub(crate) use death::DeathStore; -pub(crate) use duel::{dispatch_duel_action, DuelActionOutcome, DuelActionPlayer, DuelActionStore}; +pub(crate) use duel::{Duel, DuelActionStore}; pub(crate) use guild::{ - character_facts, destroy_inert_charters, dispatch_guild_action, guild_projection, - guild_sign_on, guild_world_entry, guild_world_exit, is_guild_dot_command, leads_a_guild, - reconcile_deleted_guild_characters, run_guild_dot_command, CharacterFacts, - DurableCharacterFacts, GuildActionOutcome, GuildActionPlayer, GuildActionStore, GuildCleanup, - GuildEventSnapshot, GuildOutcome, GuildRequest, + character_facts, destroy_inert_charters, guild_projection, guild_sign_on, guild_world_entry, + guild_world_exit, is_guild_dot_command, leads_a_guild, reconcile_deleted_guild_characters, + run_guild_dot_command, CharacterFacts, DurableCharacterFacts, Guild, GuildActionStore, + GuildCleanup, GuildEventSnapshot, GuildOutcome, GuildRequest, }; #[cfg(test)] pub(crate) use item::tests::InMemoryItemActions; -pub(crate) use item::{ - dispatch_item_action, ItemActionOutcome, ItemActionPlayer, ItemActionResult, ItemActionStore, -}; +pub(crate) use item::{Item, ItemActionResult, ItemActionStore}; pub(crate) use loot::{ - dispatch_loot_window, handle_loot, LootActionStatus, LootWindowOutcome, LootWindowPlayer, - LootWindowRefusal, LootWindowRequestStatus, LootWindowStore, OpenLootState, + Loot, LootActionStatus, LootWindow, LootWindowRefusal, LootWindowRequestStatus, + LootWindowStore, OpenLootState, }; -pub(crate) use mail::handle_mail; +pub(crate) use mail::Mail; pub(crate) use meeting_stone::{ - dispatch_meeting_stone_action, MeetingStoneActionOutcome, MeetingStoneActionStore, - MeetingStoneOutcome, MeetingStonePlayer, SeekerFacts, + MeetingStone, MeetingStoneActionStore, MeetingStoneOutcome, SeekerFacts, }; #[cfg(test)] pub(crate) use melee::tests::InMemoryMeleeActions; -pub(crate) use melee::{ - dispatch_melee_action, MeleeActionOutcome, MeleeActionPlayer, MeleeActionStore, -}; +pub(crate) use melee::{Melee, MeleeActionStore}; #[cfg(test)] pub(crate) use member_stats::MemberSnapshot; pub(crate) use member_stats::{ - dispatch_member_stats, member_stats_tick, MemberPresence, MemberStatsOutcome, - MemberStatsPlayer, MemberStatsRecord, MemberStatsStore, -}; -pub(crate) use query::{handle_query, NpcStore}; -pub(crate) use quest::{ - dispatch_quest_action, quest_giver_menu, QuestActionOutcome, QuestActionPlayer, - QuestActionStore, + member_stats_tick, MemberPresence, MemberStats, MemberStatsRecord, MemberStatsStore, }; +pub(crate) use query::{NpcStore, Query}; +pub(crate) use quest::{quest_giver_menu, Quest, QuestActionStore}; #[cfg(test)] pub(crate) use taxi::tests::InMemoryTaxiActions; -pub(crate) use taxi::{ - dispatch_taxi_action, queue_reply_then_arm, TaxiActionOutcome, TaxiActionPlayer, - TaxiActionStore, -}; -pub(crate) use trade::{handle_trade, TradeStore}; -pub(crate) use trainer::{ - handle_at_war, handle_trainer, handle_watched_faction, TrainerBuyOutcome, TrainerStore, -}; +pub(crate) use taxi::{Taxi, TaxiActionStore}; +pub(crate) use trade::{Trade, TradeStore}; +pub(crate) use trainer::{Trainer, TrainerBuyOutcome, TrainerStore}; #[cfg(test)] pub(crate) use vendor::tests::InMemoryVendorActions; -pub(crate) use vendor::{ - dispatch_vendor_action, VendorActionOutcome, VendorActionPlayer, VendorActionStore, -}; +pub(crate) use vendor::{Vendor, VendorActionStore}; pub(crate) use weather::{zone_weather_message, WeatherStore}; /// Open the bank window for `banker_guid`. Single chokepoint for `CMSG_BANKER_ACTIVATE` and the /// BANKER gossip option, so the two entry points cannot drift apart. -fn send_show_bank(tx: &SessionTx, banker_guid: u64) -> Result<()> { - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_SHOW_BANK(codec::build_show_bank( - banker_guid, - ))), - ) +fn show_bank(banker_guid: u64) -> Outbound { + Outbound::One(ServerOpcodeMessage::SMSG_SHOW_BANK(codec::build_show_bank( + banker_guid, + ))) } pub(crate) use unavailable::{ is_control_receipt, raw_unavailable_outbound, unavailable_outbound, UnavailableNotice, }; + +pub(crate) use vendor::build_buyback_view_replay; diff --git a/gateway/src/world/handlers/query.rs b/gateway/src/world/handlers/query.rs index e4d4882af..8a03bd8a7 100644 --- a/gateway/src/world/handlers/query.rs +++ b/gateway/src/world/handlers/query.rs @@ -1,5 +1,4 @@ -//! Query / social family: name / creature / item lookups + the gossip / npc-text round-trips, -//! plus who/friend/ignore. Pure code-motion out of `world/mod.rs`. +//! NPC queries, gossip, Speech, and dot-commands. Other chat requests delegate to Chat. use super::super::*; use super::quest; @@ -81,12 +80,8 @@ pub(crate) trait NpcStore: Send + Sync { ) -> Result<()>; } -/// The NPC's imported gossip options, condition-filtered against `player_guid`'s quest -/// state — the SINGLE chokepoint both `CMSG_GOSSIP_HELLO` (render) and `CMSG_GOSSIP_SELECT_OPTION` -/// (re-derive the click) call, so the two can never disagree about which options are visible (the -/// "HELLO/SELECT_OPTION alignment" trap: a click's `gossip_list_id` indexes into whatever list HELLO -/// actually sent, so SELECT must reproduce that exact list, not just re-read the raw unfiltered rows). -/// Preserves `option_index` order (already sorted by the store read). +/// Filter imported gossip options for the menu. The World Session retains their order so a +/// later click resolves against the options the client saw. fn filtered_gossip_options< St: CharacterStore + NpcStore + QuestActionStore + TrainerStore + ?Sized, >( @@ -129,24 +124,18 @@ fn filtered_gossip_options< /// returns on the Relay; a Refusal gets the answer every chat line shares, and only a Transport /// Loss ends the World Session. A speaker with no Character yet is silent. fn speak_nearby( - tx: &SessionTx, store: &St, - conn: &WorldConn, - actor: Option, + session: &ProtocolSession, chat_type: u8, language: u8, message: String, -) -> Result<()> { - let Some(actor) = actor else { +) -> Result> { + let Some(actor) = session.actor() else { log::debug!( "world: broadcast chat type {chat_type} dropped (account {}): no Character", - conn.account_id + session.account_id ); - return Ok(()); - }; - let player = super::ChatActionPlayer { - account_id: conn.account_id, - self_guid: social::self_guid(conn), + return Ok(Vec::new()); }; let refusal = match store.send_chat(actor, chat_type, language, message) { Ok(ChatOutcome::Delivered) => None, @@ -155,415 +144,409 @@ fn speak_nearby( DurableFailure::Refusal { reason } => { log::debug!( "world: broadcast chat type {chat_type} dropped (account {}): {reason}", - player.account_id + session.account_id ); None } DurableFailure::TransportLoss => return Err(error), }, }; - for message in super::chat::refusal_outbound(player, refusal) { - send(tx, message)?; - } - Ok(()) + Ok(super::chat::refusal_outbound(session, refusal)) } -/// Query / social family: name / creature / item lookups + the gossip / npc-text round-trips, plus -/// the social tier (say / yell / `/e` chat + text emotes), grouped as the stateless -/// request→reply / broadcast opcodes. -#[allow(clippy::too_many_lines)] // One arm per query and social opcode. -pub(crate) fn handle_query< - St: CastStore - + CharacterStore - + GuildActionStore - + NpcStore - + PartyStore - + QuestActionStore - + SessionStore - + ShardRoutingStore - + SocialStore - + SpeechStore - + TaxiActionStore - + TrainerStore - + VendorActionStore - + ?Sized, ->( - tx: &SessionTx, - store: &St, - conn: &mut WorldConn, - msg: ClientOpcodeMessage, -) -> Result> { - // `None` until the session has a Character in the world. - let actor = match &conn.state { - WorldState::InWorld(iw) => Actor::new(iw.self_guid), - _ => None, - }; +pub(crate) struct Query; + +impl< + St: CastStore + + ChatActionStore + + CharacterStore + + GuildActionStore + + NpcStore + + PartyStore + + QuestActionStore + + SessionStore + + ShardRoutingStore + + SocialStore + + SpeechStore + + TaxiActionStore + + TrainerStore + + VendorActionStore + + ?Sized, + > ProtocolFamily for Query +{ + #[allow(clippy::too_many_lines)] // One match arm per query or Speech opcode. + fn handle( + store: &St, + session: &mut ProtocolSession, + request: ProtocolRequest, + ) -> Result { + let msg = request.message()?; + let mut outbound = Vec::new(); + // `None` until the session has a Character in the world. + let actor = session.actor(); - match msg { - ClientOpcodeMessage::CMSG_BINDER_ACTIVATE(request) => { - let Some(actor) = actor else { - return Ok(None); - }; - if let InteractionOutcome::Refused(reason) = - store.bind_home(actor, request.guid.guid())? - { - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_MESSAGECHAT(Box::new( - codec::build_gm_system_message(reason), - ))), - )?; + match msg { + ClientOpcodeMessage::CMSG_BINDER_ACTIVATE(request) => { + let Some(actor) = actor else { + return Ok(outbound.into()); + }; + if let InteractionOutcome::Refused(reason) = + store.bind_home(actor, request.guid.guid())? + { + outbound.push(Outbound::One(ServerOpcodeMessage::SMSG_MESSAGECHAT( + Box::new(codec::build_gm_system_message(reason)), + ))); + } + outbound.push(Outbound::One(ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE)); } - send(tx, Outbound::One(ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE))?; - } - ClientOpcodeMessage::MSG_TALENT_WIPE_CONFIRM(request) => { - let Some(actor) = actor else { - return Ok(None); - }; - if let InteractionOutcome::Refused(reason) = - store.reset_talents(actor, request.wiping_npc.guid())? - { - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_MESSAGECHAT(Box::new( - codec::build_gm_system_message(reason), - ))), - )?; + ClientOpcodeMessage::MSG_TALENT_WIPE_CONFIRM(request) => { + let Some(actor) = actor else { + return Ok(outbound.into()); + }; + if let InteractionOutcome::Refused(reason) = + store.reset_talents(actor, request.wiping_npc.guid())? + { + outbound.push(Outbound::One(ServerOpcodeMessage::SMSG_MESSAGECHAT( + Box::new(codec::build_gm_system_message(reason)), + ))); + } + outbound.push(Outbound::One(ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE)); } - send(tx, Outbound::One(ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE))?; - } - // Name resolution: the client asks for a guid's name to render its plate (else "Unknown"). - // - // Resolved across every connected shard, not just this one. A guid the - // client has met across a database boundary — the sender of a cross-shard whisper, which - // arrives as a GUID because the client resolves whisper names itself — has no row on the - // asking session's shard, and a dropped reply renders the line with nobody's name on it. On a - // single-database gateway `world_stores()` is empty, so this is exactly the one read it was. - ClientOpcodeMessage::CMSG_NAME_QUERY(q) => { - let guid = q.guid.guid(); - match presence::character_anywhere(store, guid)? { - Some(c) => send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_NAME_QUERY_RESPONSE(Box::new( - codec::build_name_query_response(&c)?, - ))), - )?, - None => log::debug!("world: name query for unknown guid {guid}"), + // Name resolution: the client asks for a guid's name to render its plate (else "Unknown"). + // + // Resolved across every connected shard, not just this one. A guid the + // client has met across a database boundary — the sender of a cross-shard whisper, which + // arrives as a GUID because the client resolves whisper names itself — has no row on the + // asking session's shard, and a dropped reply renders the line with nobody's name on it. On a + // single-database gateway `world_stores()` is empty, so this is exactly the one read it was. + ClientOpcodeMessage::CMSG_NAME_QUERY(q) => { + let guid = q.guid.guid(); + match presence::character_anywhere(store, guid)? { + Some(c) => outbound.push(Outbound::One( + ServerOpcodeMessage::SMSG_NAME_QUERY_RESPONSE(Box::new( + codec::build_name_query_response(&c)?, + )), + )), + None => log::debug!("world: name query for unknown guid {guid}"), + } } - } - ClientOpcodeMessage::CMSG_PET_NAME_QUERY(q) => { - let pet_guid = q.guid.guid(); - let pet = match actor { - Some(actor) => store.pet_name(actor, q.pet_number, pet_guid)?, - None => None, - }; - match pet { - Some(pet) => send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_PET_NAME_QUERY_RESPONSE(Box::new( - codec::build_pet_name_query_response(&pet), - ))), - )?, - None => log::debug!( - "world: pet name query ignored for unknown or foreign guid {pet_guid}" - ), + ClientOpcodeMessage::CMSG_PET_NAME_QUERY(q) => { + let pet_guid = q.guid.guid(); + let pet = match actor { + Some(actor) => store.pet_name(actor, q.pet_number, pet_guid)?, + None => None, + }; + match pet { + Some(pet) => outbound.push(Outbound::One( + ServerOpcodeMessage::SMSG_PET_NAME_QUERY_RESPONSE(Box::new( + codec::build_pet_name_query_response(&pet), + )), + )), + None => log::debug!( + "world: pet name query ignored for unknown or foreign guid {pet_guid}" + ), + } } - } - // Inspect: validate range + friendly target server-side (the `inspect` - // reducer), then ack with SMSG_INSPECT(target guid) so the client opens the paperdoll — it - // renders the target's equipment from fields the client already has (the visible-item relay - // is the follow-up for full paperdoll correctness). Out of range / hostile / - // no-such-target → the reducer errors and we silently drop the request, same as the other - // stateless gates (CMSG_GAMEOBJ_USE, CMSG_AREATRIGGER). - ClientOpcodeMessage::CMSG_INSPECT(i) => { - let target_guid = i.guid.guid(); - if let Some(actor) = actor { - match store.inspect(actor, target_guid) { - Ok(()) => send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_INSPECT( + // Inspect: validate range + friendly target server-side (the `inspect` + // reducer), then ack with SMSG_INSPECT(target guid) so the client opens the paperdoll — it + // renders the target's equipment from fields the client already has (the visible-item relay + // is the follow-up for full paperdoll correctness). Out of range / hostile / + // no-such-target → the reducer errors and we silently drop the request, same as the other + // stateless gates (CMSG_GAMEOBJ_USE, CMSG_AREATRIGGER). + ClientOpcodeMessage::CMSG_INSPECT(i) => { + let target_guid = i.guid.guid(); + if let Some(actor) = actor { + match store.inspect(actor, target_guid) { + Ok(()) => outbound.push(Outbound::One(ServerOpcodeMessage::SMSG_INSPECT( codec::build_inspect_response(target_guid), - )), - )?, - Err(error) => settle_per_action("inspect", conn.account_id, Err(error))?, + ))), + Err(error) => settle_per_action("inspect", session.account_id, Err(error))?, + } } } - } - // Creature name resolution (the NPC analogue of CMSG_NAME_QUERY). - ClientOpcodeMessage::CMSG_CREATURE_QUERY(q) => { - match store.creature_template(q.creature)? { - Some(c) => send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_CREATURE_QUERY_RESPONSE(Box::new( - codec::build_creature_query_response(&c), - ))), - )?, - None => log::debug!("world: creature query for unknown entry {}", q.creature), + // Creature name resolution (the NPC analogue of CMSG_NAME_QUERY). + ClientOpcodeMessage::CMSG_CREATURE_QUERY(q) => { + match store.creature_template(q.creature)? { + Some(c) => outbound.push(Outbound::One( + ServerOpcodeMessage::SMSG_CREATURE_QUERY_RESPONSE(Box::new( + codec::build_creature_query_response(&c), + )), + )), + None => log::debug!("world: creature query for unknown entry {}", q.creature), + } } - } - // Gossip (rank 12, extended with imported gossip menus): the player right-clicked a gossip NPC - // (npc_flags GOSSIP bit). Reply with a title (resolved via the NPC_TEXT round-trip below) + - // either the NPC's IMPORTED menu options (precedence) or the flag-derived vendor/innkeeper - // synthesis (fallback) + the QUEST section. A gossip-FLAGGED questgiver (npc_flags - // GOSSIP|QUESTGIVER, e.g. Marshal McBride) delivers its quests here, not via - // CMSG_QUESTGIVER_HELLO, so fold the same quest menu in (empty for a plain gossip NPC → - // unchanged). - ClientOpcodeMessage::CMSG_GOSSIP_HELLO(h) => { - let npc = h.guid.guid(); - let player_guid = actor.map_or(0, Actor::guid); - // A gossip NPC that dislikes you doesn't open its menu (silent drop — - // vanilla unfriendly NPCs just ignore the click). - if player_guid != 0 - && store - .npc_refuses_interaction(npc, player_guid) - .unwrap_or(false) - { - return Ok(None); + // Gossip (rank 12, extended with imported gossip menus): the player right-clicked a gossip NPC + // (npc_flags GOSSIP bit). Reply with a title (resolved via the NPC_TEXT round-trip below) + + // either the NPC's IMPORTED menu options (precedence) or the flag-derived vendor/innkeeper + // synthesis (fallback) + the QUEST section. A gossip-FLAGGED questgiver (npc_flags + // GOSSIP|QUESTGIVER, e.g. Marshal McBride) delivers its quests here, not via + // CMSG_QUESTGIVER_HELLO, so fold the same quest menu in (empty for a plain gossip NPC → + // unchanged). + ClientOpcodeMessage::CMSG_GOSSIP_HELLO(h) => { + let npc = h.guid.guid(); + let player_guid = actor.map_or(0, Actor::guid); + // A gossip NPC that dislikes you doesn't open its menu (silent drop — + // vanilla unfriendly NPCs just ignore the click). + if player_guid != 0 + && store + .npc_refuses_interaction(npc, player_guid) + .unwrap_or(false) + { + return Ok(outbound.into()); + } + let quests = match &session.state { + WorldState::InWorld(iw) => quest::gossip_quest_items(store, npc, iw.self_guid)?, + WorldState::CharSelect => Vec::new(), + }; + // A vendor that ALSO has the gossip bit gets a "browse goods" menu entry (rank-vendor); + // having stock is the is-vendor signal, so no npc_flags read is needed. An innkeeper gets a + // "Make this inn your home." entry (hearthstone bind) — that one DOES need the npc_flags + // read. Both are APPENDED to the imported options rather than replaced by them: a dump menu + // that omits the row would otherwise strand the NPC's stock or its bind. + let is_vendor = vendor_has_stock(store, npc)?; + let is_innkeeper = store.npc_is_innkeeper(npc)?; + let options = codec::gossip_menu_options( + filtered_gossip_options(store, npc, player_guid)?, + is_vendor, + is_innkeeper, + ); + // Snapshot what this client is about to look at — the select handler resolves the clicked + // POSITION against this, never against a fresh read (see `GossipMenuSnapshot`). + session.gossip_menu = Some(GossipMenuSnapshot { + npc_guid: npc, + options: options.iter().map(|o| (o.row_id, o.action)).collect(), + }); + let title_text_id = store.npc_gossip_text_id(npc); + outbound.push(Outbound::One(ServerOpcodeMessage::SMSG_GOSSIP_MESSAGE( + Box::new(codec::build_gossip_message( + npc, + title_text_id, + quests, + &options, + )), + ))); } - let quests = match &conn.state { - WorldState::InWorld(iw) => quest::gossip_quest_items(store, npc, iw.self_guid)?, - WorldState::CharSelect => Vec::new(), - }; - // A vendor that ALSO has the gossip bit gets a "browse goods" menu entry (rank-vendor); - // having stock is the is-vendor signal, so no npc_flags read is needed. An innkeeper gets a - // "Make this inn your home." entry (hearthstone bind) — that one DOES need the npc_flags - // read. Both are APPENDED to the imported options rather than replaced by them: a dump menu - // that omits the row would otherwise strand the NPC's stock or its bind. - let is_vendor = vendor_has_stock(store, npc)?; - let is_innkeeper = store.npc_is_innkeeper(npc)?; - let options = codec::gossip_menu_options( - filtered_gossip_options(store, npc, player_guid)?, - is_vendor, - is_innkeeper, - ); - // Snapshot what this client is about to look at — the select handler resolves the clicked - // POSITION against this, never against a fresh read (see `GossipMenuSnapshot`). - conn.gossip_menu = Some(GossipMenuSnapshot { - npc_guid: npc, - options: options.iter().map(|o| (o.row_id, o.action)).collect(), - }); - let title_text_id = store.npc_gossip_text_id(npc); - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_GOSSIP_MESSAGE(Box::new( - codec::build_gossip_message(npc, title_text_id, quests, &options), - ))), - )?; - } - // The client resolves a gossip/quest title text id (sent in SMSG_GOSSIP_MESSAGE) → reply with - // the NPC's imported (weighted) text, or the generic greeting when none is - // imported yet. - ClientOpcodeMessage::CMSG_NPC_TEXT_QUERY(q) => { - let view = store.npc_text_for_id(q.text_id); - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_NPC_TEXT_UPDATE(Box::new( - codec::build_npc_text_update(q.text_id, view.as_ref()), - ))), - )?; - } - // The player clicked a gossip option. The click carries a POSITION, resolved against the - // snapshot HELLO took (`GossipMenuSnapshot`), then routed by ACTION: vendor → inventory, - // innkeeper → bind_home, trainer → SMSG_TRAINER_LIST, taxi → the same cohesive - // open operation as CMSG_TAXIQUERYAVAILABLENODES, banker → SMSG_SHOW_BANK, everything - // else including the trailing Farewell → SMSG_GOSSIP_COMPLETE. Submenu navigation is - // deferred (`action_menu_id` stays inert). - ClientOpcodeMessage::CMSG_GOSSIP_SELECT_OPTION(c) => { - let npc = c.guid.guid(); - let player_guid = actor.map_or(0, Actor::guid); - // A click naming an NPC other than the one the open menu belongs to is stale (the client - // sends HELLO before it can show a menu), so it selects nothing. - let clicked = conn - .gossip_menu - .as_ref() - .filter(|snap| snap.npc_guid == npc) - .and_then(|snap| snap.options.get(c.gossip_list_id as usize)) - .copied(); - // The module is told the clicked row's `row_id`, not its position: a position is - // per-viewer (a cond-gated row renumbers it), so it identifies nothing to a package. - let option_row_id = clicked.map_or(codec::SYNTHESIZED_ROW_ID, |(row_id, _)| row_id); - // Notify the module (the on_gossip_select hook chokepoint) — best-effort, - // so a module hiccup never blocks the gossip reply below. - if let Some(actor) = actor { - settle_per_action( - "gossip_select", - conn.account_id, - store.gossip_select(actor, npc, c.gossip_list_id, option_row_id), - )?; + // The client resolves a gossip/quest title text id (sent in SMSG_GOSSIP_MESSAGE) → reply with + // the NPC's imported (weighted) text, or the generic greeting when none is + // imported yet. + ClientOpcodeMessage::CMSG_NPC_TEXT_QUERY(q) => { + let view = store.npc_text_for_id(q.text_id); + outbound.push(Outbound::One(ServerOpcodeMessage::SMSG_NPC_TEXT_UPDATE( + Box::new(codec::build_npc_text_update(q.text_id, view.as_ref())), + ))); } - use lyracore_shared::constants::gossip_option; - match clicked.map(|(_, action)| action) { - // The gossip click does not run the interaction gate — it never has: only the - // direct CMSG_LIST_INVENTORY open does. - Some(gossip_option::VENDOR) => { - for message in vendor_open_outbound(store, npc)? { - send(tx, message)?; - } + // The player clicked a gossip option. The click carries a POSITION, resolved against the + // snapshot HELLO took (`GossipMenuSnapshot`), then routed by ACTION: vendor → inventory, + // innkeeper → bind_home, trainer → SMSG_TRAINER_LIST, taxi → the same cohesive + // open operation as CMSG_TAXIQUERYAVAILABLENODES, banker → SMSG_SHOW_BANK, everything + // else including the trailing Farewell → SMSG_GOSSIP_COMPLETE. Submenu navigation is + // deferred (`action_menu_id` stays inert). + ClientOpcodeMessage::CMSG_GOSSIP_SELECT_OPTION(c) => { + let npc = c.guid.guid(); + let player_guid = actor.map_or(0, Actor::guid); + // A click naming an NPC other than the one the open menu belongs to is stale (the client + // sends HELLO before it can show a menu), so it selects nothing. + let clicked = session + .gossip_menu + .as_ref() + .filter(|snap| snap.npc_guid == npc) + .and_then(|snap| snap.options.get(c.gossip_list_id as usize)) + .copied(); + // The module is told the clicked row's `row_id`, not its position: a position is + // per-viewer (a cond-gated row renumbers it), so it identifies nothing to a package. + let option_row_id = clicked.map_or(codec::SYNTHESIZED_ROW_ID, |(row_id, _)| row_id); + // Notify the module (the on_gossip_select hook chokepoint) — best-effort, + // so a module hiccup never blocks the gossip reply below. + if let Some(actor) = actor { + settle_per_action( + "gossip_select", + session.account_id, + store.gossip_select(actor, npc, c.gossip_list_id, option_row_id), + )?; } - Some(gossip_option::INNKEEPER) => { - send(tx, Outbound::One(ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE))?; - send( - tx, - Outbound::Raw { + use lyracore_shared::constants::gossip_option; + match clicked.map(|(_, action)| action) { + // The gossip click does not run the interaction gate — it never has: only the + // direct CMSG_LIST_INVENTORY open does. + Some(gossip_option::VENDOR) => { + for message in vendor_open_outbound(store, npc)? { + outbound.push(message); + } + } + Some(gossip_option::INNKEEPER) => { + outbound.push(Outbound::One(ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE)); + outbound.push(Outbound::Raw { opcode: 0x02eb, body: npc.to_le_bytes().to_vec(), - }, - )?; - } - Some(gossip_option::TRAINER) => { - let spells = store.trainer_list(player_guid, npc)?; - let list = - codec::build_trainer_list(npc, &spells, "I can teach you a thing or two."); - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_TRAINER_LIST(Box::new(list))), - )?; - } - Some(gossip_option::UNLEARNTALENTS) => { - send(tx, Outbound::One(ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE))?; - if let Some(cost) = store.talent_reset_cost(player_guid) { - let mut body = npc.to_le_bytes().to_vec(); - body.extend_from_slice(&cost.to_le_bytes()); - send( - tx, - Outbound::Raw { + }); + } + Some(gossip_option::TRAINER) => { + let spells = store.trainer_list(player_guid, npc)?; + let list = codec::build_trainer_list( + npc, + &spells, + "I can teach you a thing or two.", + ); + outbound.push(Outbound::One(ServerOpcodeMessage::SMSG_TRAINER_LIST( + Box::new(list), + ))); + } + Some(gossip_option::UNLEARNTALENTS) => { + outbound.push(Outbound::One(ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE)); + if let Some(cost) = store.talent_reset_cost(player_guid) { + let mut body = npc.to_le_bytes().to_vec(); + body.extend_from_slice(&cost.to_le_bytes()); + outbound.push(Outbound::Raw { opcode: 0x02aa, body, - }, - )?; - } else { - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_MESSAGECHAT(Box::new( - codec::build_gm_system_message( + }); + } else { + outbound.push(Outbound::One(ServerOpcodeMessage::SMSG_MESSAGECHAT( + Box::new(codec::build_gm_system_message( "The talent reset quote is not available. Try again.".into(), - ), - ))), - )?; + )), + ))); + } } - } - Some(gossip_option::TAXI) => { - for message in open_taxi_outbound(store, actor, npc)? { - send(tx, message)?; + Some(gossip_option::TAXI) => { + for message in open_taxi_outbound(store, actor, npc)? { + outbound.push(message); + } } - } - Some(gossip_option::BANKER) => super::send_show_bank(tx, npc)?, - Some(gossip_option::TABARDDESIGNER) => { - send(tx, Outbound::One(ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE))?; - send(tx, super::guild::tabard_designer_window(npc))?; - } - Some(gossip_option::PETITIONER) => { - send(tx, Outbound::One(ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE))?; - send(tx, super::guild::petition_showlist(npc))?; - } - // Plain-GOSSIP/submenu-link, the trailing Farewell, or a click with no live snapshot - // behind it — close the window. - _ => send(tx, Outbound::One(ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE))?, - } - } - // Item template resolution: the client queries an item it has encountered - // (it holds the object) for its name/tooltip/icon. Always reply — `build_item_query_response` - // emits a NotFound (`found: None`) for an unknown entry so the client stops re-asking. - ClientOpcodeMessage::CMSG_ITEM_QUERY_SINGLE(q) => { - let resp = Box::new(codec::build_item_query_response( - q.item, - store.item_template(q.item)?.as_ref(), - )); - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_ITEM_QUERY_SINGLE_RESPONSE(resp)), - )?; - } - // Social tier: say/yell/`/e` -> send_chat (insert a broadcast game_chat_event the gateway - // fans back as SMSG_MESSAGECHAT to listeners in range). Whisper, party, raid, guild, - // officer chat and `/afk` `/dnd` never reach this arm: `dispatch_chat_action` consumes - // them. No reply on success (the speaker sees their own line via the relay). A say, yell - // or `/e` line in a language the speaker's race does not know answers "You don't know that - // language"; every other Refusal is silent, matching vanilla. - // - ClientOpcodeMessage::CMSG_MESSAGECHAT(c) => { - let CMSG_MESSAGECHAT { - chat_type, - language, - message, - } = *c; - let lang = language.as_int() as u8; - match chat_type { - CMSG_MESSAGECHAT_ChatType::Say if super::is_guild_dot_command(&message) => { - let player = super::GuildActionPlayer { - account_id: conn.account_id, - self_guid: social::self_guid(conn), - }; - if let Some(line) = super::run_guild_dot_command(store, player, &message)? { - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_MESSAGECHAT(Box::new( - codec::build_gm_system_message(line), - ))), - )?; + Some(gossip_option::BANKER) => outbound.push(super::show_bank(npc)), + Some(gossip_option::TABARDDESIGNER) => { + outbound.push(Outbound::One(ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE)); + outbound.push(super::guild::tabard_designer_window(npc)); } - } - // The GM reads the Module's Refusal text verbatim. Any other failure ends the - // World Session. - CMSG_MESSAGECHAT_ChatType::Say if message.starts_with('.') => { - let reply = match actor { - Some(actor) => match store.gm_command(&conn.account_name, actor, message) { - Ok(()) => None, - Err(error) => match classify(&error) { - DurableFailure::Refusal { reason } => Some(reason.to_string()), - DurableFailure::TransportLoss => return Err(error), - }, - }, - None => Some(NO_CHARACTER_FOR_GM_COMMAND.to_string()), - }; - if let Some(line) = reply { - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_MESSAGECHAT(Box::new( - codec::build_gm_system_message(line), - ))), - )?; + Some(gossip_option::PETITIONER) => { + outbound.push(Outbound::One(ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE)); + outbound.push(super::guild::petition_showlist(npc)); } + // Plain-GOSSIP/submenu-link, the trailing Farewell, or a click with no live snapshot + // behind it — close the window. + _ => outbound.push(Outbound::One(ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE)), } - CMSG_MESSAGECHAT_ChatType::Say => { - speak_nearby(tx, store, conn, actor, broadcast_chat::SAY, lang, message)?; + } + // Item template resolution: the client queries an item it has encountered + // (it holds the object) for its name/tooltip/icon. Always reply — `build_item_query_response` + // emits a NotFound (`found: None`) for an unknown entry so the client stops re-asking. + ClientOpcodeMessage::CMSG_ITEM_QUERY_SINGLE(q) => { + let resp = Box::new(codec::build_item_query_response( + q.item, + store.item_template(q.item)?.as_ref(), + )); + outbound.push(Outbound::One( + ServerOpcodeMessage::SMSG_ITEM_QUERY_SINGLE_RESPONSE(resp), + )); + } + // Speech and dot-commands share the opcode with Realm Chat Lines and Auto-Replies. + ClientOpcodeMessage::CMSG_MESSAGECHAT(c) => { + if !matches!( + &c.chat_type, + CMSG_MESSAGECHAT_ChatType::Say + | CMSG_MESSAGECHAT_ChatType::Yell + | CMSG_MESSAGECHAT_ChatType::Emote + ) { + return super::Chat::handle( + store, + session, + ClientOpcodeMessage::CMSG_MESSAGECHAT(c).into(), + ); } - CMSG_MESSAGECHAT_ChatType::Yell => { - speak_nearby(tx, store, conn, actor, broadcast_chat::YELL, lang, message)?; + let CMSG_MESSAGECHAT { + chat_type, + language, + message, + } = *c; + let lang = language.as_int() as u8; + match chat_type { + CMSG_MESSAGECHAT_ChatType::Say if super::is_guild_dot_command(&message) => { + if let Some(line) = super::run_guild_dot_command(store, session, &message)? + { + outbound.push(Outbound::One(ServerOpcodeMessage::SMSG_MESSAGECHAT( + Box::new(codec::build_gm_system_message(line)), + ))); + } + } + // The GM reads the Module's Refusal text verbatim. Any other failure ends the + // World Session. + CMSG_MESSAGECHAT_ChatType::Say if message.starts_with('.') => { + let reply = match actor { + Some(actor) => { + match store.gm_command(&session.account_name, actor, message) { + Ok(()) => None, + Err(error) => match classify(&error) { + DurableFailure::Refusal { reason } => { + Some(reason.to_string()) + } + DurableFailure::TransportLoss => return Err(error), + }, + } + } + None => Some(NO_CHARACTER_FOR_GM_COMMAND.to_string()), + }; + if let Some(line) = reply { + outbound.push(Outbound::One(ServerOpcodeMessage::SMSG_MESSAGECHAT( + Box::new(codec::build_gm_system_message(line)), + ))); + } + } + CMSG_MESSAGECHAT_ChatType::Say => { + outbound.extend(speak_nearby( + store, + session, + broadcast_chat::SAY, + lang, + message, + )?); + } + CMSG_MESSAGECHAT_ChatType::Yell => { + outbound.extend(speak_nearby( + store, + session, + broadcast_chat::YELL, + lang, + message, + )?); + } + // `/e` custom emote: same broadcast path as Say/Yell, EMOTE type. + CMSG_MESSAGECHAT_ChatType::Emote => { + outbound.extend(speak_nearby( + store, + session, + broadcast_chat::EMOTE, + lang, + message, + )?); + } + _ => return Err(anyhow!("non-Speech chat request bypassed Chat")), } - // `/e` custom emote: same broadcast path as Say/Yell, EMOTE type. - CMSG_MESSAGECHAT_ChatType::Emote => { - speak_nearby(tx, store, conn, actor, broadcast_chat::EMOTE, lang, message)?; + } + // Social tier: a text emote (/dance, /wave, …) → send_emote (insert a broadcast + // game_emote_event the gateway fans back as SMSG_TEXT_EMOTE + SMSG_EMOTE). The client supplies + // the social-emote id, the animation, and its selected target (0 guid = untargeted); the + // gateway resolves the target guid to a name on relay. A Refusal is dropped. + ClientOpcodeMessage::CMSG_TEXT_EMOTE(c) => { + if let Some(actor) = actor { + settle_per_action( + "send_emote", + session.account_id, + store.send_emote(actor, c.text_emote.as_int(), c.emote, c.target.guid()), + )?; } - // Whisper, party, raid, channel, guild and officer lines and `/afk` `/dnd` never - // get here: `dispatch_chat_action` consumes them. - _ => {} } - } - // Social tier: a text emote (/dance, /wave, …) → send_emote (insert a broadcast - // game_emote_event the gateway fans back as SMSG_TEXT_EMOTE + SMSG_EMOTE). The client supplies - // the social-emote id, the animation, and its selected target (0 guid = untargeted); the - // gateway resolves the target guid to a name on relay. A Refusal is dropped. - ClientOpcodeMessage::CMSG_TEXT_EMOTE(c) => { - if let Some(actor) = actor { - settle_per_action( - "send_emote", - conn.account_id, - store.send_emote(actor, c.text_emote.as_int(), c.emote, c.target.guid()), - )?; + // /roll is a Group Broadcast: the party authority draws the result and sends it to every + // group member on any shard, or to the roller alone when ungrouped. + ClientOpcodeMessage::MSG_RANDOM_ROLL(r) => { + let op = party::Op::RandomRoll { + min: r.minimum, + max: r.maximum, + }; + social::run_group_broadcast(store, session, op); } + _ => return Err(anyhow!("opcode routed to the wrong Protocol Family")), } - // /roll is a Group Broadcast: the party authority draws the result and sends it to every - // group member on any shard, or to the roller alone when ungrouped. - ClientOpcodeMessage::MSG_RANDOM_ROLL(r) => { - let op = party::Op::RandomRoll { - min: r.minimum, - max: r.maximum, - }; - social::run_group_broadcast(store, conn, op); - } - other => return Ok(Some(other)), + Ok(outbound.into()) } - Ok(None) } diff --git a/gateway/src/world/handlers/quest.rs b/gateway/src/world/handlers/quest.rs index 4bcd0b115..04af61b6a 100644 --- a/gateway/src/world/handlers/quest.rs +++ b/gateway/src/world/handlers/quest.rs @@ -1,6 +1,6 @@ //! Quest family: the overhead `!`/`?` status, the quest-giver menu, the details and definition //! screens, accept, the turn-in round trip, log abandon and party sharing all enter through -//! `dispatch_quest_action`; the gameobject giver, the item-started quest, the world-entry +//! `Quest::handle`; the gameobject giver, the item-started quest, the world-entry //! descriptor block and the gossip quest section call the shared builders here rather than //! reaching for the store. Every quest read and reducer the world session needs lives on //! `QuestActionStore`; no other Store family carries them. The stdb-tier relays in @@ -9,6 +9,9 @@ use super::super::*; use crate::stdb::{classify, DurableFailure}; +use crate::world::family::{ + ProtocolFamily, ProtocolReply, ProtocolRequest, ProtocolSession, WorldSessionAction, +}; use wow_world_messages::vanilla::QuestItem; /// The durable reads and reducer calls the quest family needs, in the seam's own vocabulary so it @@ -68,26 +71,6 @@ pub(crate) trait QuestActionStore: Send + Sync { fn quest_status(&self, player_guid: u64, quest_id: u32) -> (bool, bool); } -/// Who is asking. `self_guid` is `None` before world entry — a questgiver can only be clicked -/// in-world, so those opcodes pass through instead of being evaluated against a placeholder. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) struct QuestActionPlayer { - pub(crate) account_id: u64, - pub(crate) self_guid: Option, -} - -pub(crate) enum QuestActionOutcome { - Handled { - outbound: Vec, - }, - /// The quest was turned in. The turn-in filed any Reward Letter it sends as Escrow, so the - /// session drives the Character's held letters after it sends `outbound`. - TurnedIn { - outbound: Vec, - }, - PassThrough(ClientOpcodeMessage), -} - /// A Refusal of a best-effort quest request is logged and dropped. A Transport Loss ends the /// World Session. fn ignore_refusal(result: Result<()>, operation: &str, account_id: u64) -> Result<()> { @@ -175,10 +158,10 @@ pub(crate) fn quest_details_screen( /// never used up by opening its own quest. pub(crate) fn item_started_quest( store: &St, - player: QuestActionPlayer, + session: &ProtocolSession, slot: u8, ) -> Result>> { - let Some(self_guid) = player.self_guid else { + let Some(self_guid) = session.self_guid() else { return Ok(None); }; let Some((item_guid, quest_id)) = store.item_start_quest(self_guid, slot) else { @@ -216,11 +199,9 @@ fn quest_reward_screen( giver: u64, self_guid: u64, quest_id: u32, -) -> Result { +) -> Result { let Some(detail) = store.quest_detail_view(quest_id)? else { - return Ok(QuestActionOutcome::Handled { - outbound: Vec::new(), - }); + return Ok(ProtocolReply::from(Vec::new())); }; let complete = store .giver_quest_evals(giver, self_guid)? @@ -239,156 +220,152 @@ fn quest_reward_screen( giver, &detail, false, ))) }; - Ok(QuestActionOutcome::Handled { - outbound: vec![Outbound::One(screen)], - }) + Ok(ProtocolReply::from(vec![Outbound::One(screen)])) } -/// The quest opcodes that own their whole protocol round trip. Anything else — and anything at all -/// before world entry — passes through to the next family in the dispatch chain. -pub(crate) fn dispatch_quest_action( - store: &St, - player: QuestActionPlayer, - msg: ClientOpcodeMessage, -) -> Result { - let Some(actor) = player.self_guid.and_then(Actor::new) else { - return Ok(QuestActionOutcome::PassThrough(msg)); - }; - let self_guid = actor.guid(); - match msg { - // The client polls each nearby questgiver for its overhead icon (`!` available / `?` turn-in). - ClientOpcodeMessage::CMSG_QUESTGIVER_STATUS_QUERY(q) => { - let giver = q.guid.guid(); - let status = codec::quest_giver_status(&store.giver_quest_evals(giver, self_guid)?); - Ok(QuestActionOutcome::Handled { - outbound: vec![Outbound::One(ServerOpcodeMessage::SMSG_QUESTGIVER_STATUS( - Box::new(codec::build_questgiver_status(giver, status)), - ))], - }) - } - // Right-click a questgiver → the quest menu (every quest it offers/completes for this - // player). An Unfriendly-or-below giver refuses it and answers nothing at all. A - // QUESTGIVER GameObject reaches the same `quest_giver_menu` helper from session dispatch. - ClientOpcodeMessage::CMSG_QUESTGIVER_HELLO(h) => { - let giver = h.guid.guid(); - let refuses = store.giver_refuses_interaction(giver, self_guid)?; - Ok(QuestActionOutcome::Handled { - outbound: if refuses { +/// Map quest requests and durable outcomes to client replies. +pub(crate) struct Quest; + +impl ProtocolFamily for Quest { + fn handle( + store: &St, + session: &mut ProtocolSession, + request: ProtocolRequest, + ) -> Result { + let msg = request.message()?; + let Some(actor) = session.actor() else { + return Ok(ProtocolReply::default()); + }; + let self_guid = actor.guid(); + match msg { + // The client polls each nearby questgiver for its overhead icon (`!` available / `?` turn-in). + ClientOpcodeMessage::CMSG_QUESTGIVER_STATUS_QUERY(q) => { + let giver = q.guid.guid(); + let status = codec::quest_giver_status(&store.giver_quest_evals(giver, self_guid)?); + Ok(ProtocolReply::from(vec![Outbound::One( + ServerOpcodeMessage::SMSG_QUESTGIVER_STATUS(Box::new( + codec::build_questgiver_status(giver, status), + )), + )])) + } + // Right-click a questgiver → the quest menu (every quest it offers/completes for this + // player). An Unfriendly-or-below giver refuses it and answers nothing at all. A + // QUESTGIVER GameObject reaches the same `quest_giver_menu` helper from session dispatch. + ClientOpcodeMessage::CMSG_QUESTGIVER_HELLO(h) => { + let giver = h.guid.guid(); + let refuses = store.giver_refuses_interaction(giver, self_guid)?; + Ok(ProtocolReply::from(if refuses { Vec::new() } else { quest_giver_menu(store, giver, self_guid)? - }, - }) - } - // Clicked a quest in the menu → its details + Accept button. - ClientOpcodeMessage::CMSG_QUESTGIVER_QUERY_QUEST(q) => Ok(QuestActionOutcome::Handled { - outbound: quest_details_screen(store, q.guid.guid(), q.quest_id)?, - }), - // The client asks for a quest's full definition (it sends this for any quest id it sees in a - // PLAYER_QUEST_LOG slot but has no data for). Without this reply the client won't - // display/count the quest in its log — so this is what makes the quest-log window entry - // actually appear. RAW-encoded: gtker's typed layout writes the rep Faction fields as u16, - // shifting the title by 4 bytes; the hand-rolled body matches the 5875 layout exactly. - ClientOpcodeMessage::CMSG_QUEST_QUERY(q) => { - let outbound = store - .quest_detail_view(q.quest_id)? - .map_or_else(Vec::new, |detail| { - let (opcode, body) = codec::build_quest_query_response_raw(&detail); - vec![Outbound::Raw { opcode, body }] - }); - Ok(QuestActionOutcome::Handled { outbound }) - } - // Clicked Accept → the module opens the quest log row (gated). No SMSG on success: the - // client closes the window itself and the quest-log relay carries the new slot. - ClientOpcodeMessage::CMSG_QUESTGIVER_ACCEPT_QUEST(a) => { - ignore_refusal( - store.accept_quest(actor, a.guid.guid(), a.quest_id), - "accept_quest", - player.account_id, - )?; - Ok(QuestActionOutcome::Handled { - outbound: Vec::new(), - }) - } - ClientOpcodeMessage::CMSG_QUESTGIVER_COMPLETE_QUEST(c) => { - quest_reward_screen(store, c.guid.guid(), self_guid, c.quest_id) - } - ClientOpcodeMessage::CMSG_QUESTGIVER_REQUEST_REWARD(c) => { - quest_reward_screen(store, c.guid.guid(), self_guid, c.quest_id) - } - // Chose the reward → the module grants money/XP/items (gated on completion). The durable - // turn-in is requested BEFORE any outbound is built, so a refused turn-in can never show a - // "Quest Complete" popup for rewards the player did not get. A refusal instead re-opens - // the current offer when its details remain readable, so the player can correct the choice. - ClientOpcodeMessage::CMSG_QUESTGIVER_CHOOSE_REWARD(c) => { - match store.turn_in_quest(actor, c.guid.guid(), c.quest_id, c.reward) { - // The popup echoes the definition's XP/money/items, so what it shows matches what - // the module granted. Unreadable details drop it — the turn-in already happened. - Ok(()) => Ok(QuestActionOutcome::TurnedIn { - outbound: match store.quest_detail_view(c.quest_id)? { - Some(detail) => vec![Outbound::One( - ServerOpcodeMessage::SMSG_QUESTGIVER_QUEST_COMPLETE(Box::new( - codec::build_quest_complete(&detail), - )), - )], - None => Vec::new(), - }, - }), - Err(e) if matches!(classify(&e), DurableFailure::Refusal { .. }) => { - log::debug!( - "world: turn_in_quest refused (quest {}, reward index {}): {e}", - c.quest_id, - c.reward - ); - Ok(QuestActionOutcome::Handled { + })) + } + // Clicked a quest in the menu → its details + Accept button. + ClientOpcodeMessage::CMSG_QUESTGIVER_QUERY_QUEST(q) => Ok(ProtocolReply::from( + quest_details_screen(store, q.guid.guid(), q.quest_id)?, + )), + // The client asks for a quest's full definition (it sends this for any quest id it sees in a + // PLAYER_QUEST_LOG slot but has no data for). Without this reply the client won't + // display/count the quest in its log — so this is what makes the quest-log window entry + // actually appear. RAW-encoded: gtker's typed layout writes the rep Faction fields as u16, + // shifting the title by 4 bytes; the hand-rolled body matches the 5875 layout exactly. + ClientOpcodeMessage::CMSG_QUEST_QUERY(q) => { + let outbound = + store + .quest_detail_view(q.quest_id)? + .map_or_else(Vec::new, |detail| { + let (opcode, body) = codec::build_quest_query_response_raw(&detail); + vec![Outbound::Raw { opcode, body }] + }); + Ok(ProtocolReply::from(outbound)) + } + // Clicked Accept → the module opens the quest log row (gated). No SMSG on success: the + // client closes the window itself and the quest-log relay carries the new slot. + ClientOpcodeMessage::CMSG_QUESTGIVER_ACCEPT_QUEST(a) => { + ignore_refusal( + store.accept_quest(actor, a.guid.guid(), a.quest_id), + "accept_quest", + session.account_id, + )?; + Ok(ProtocolReply::from(Vec::new())) + } + ClientOpcodeMessage::CMSG_QUESTGIVER_COMPLETE_QUEST(c) => { + quest_reward_screen(store, c.guid.guid(), self_guid, c.quest_id) + } + ClientOpcodeMessage::CMSG_QUESTGIVER_REQUEST_REWARD(c) => { + quest_reward_screen(store, c.guid.guid(), self_guid, c.quest_id) + } + // Chose the reward → the module grants money/XP/items (gated on completion). The durable + // turn-in is requested BEFORE any outbound is built, so a refused turn-in can never show a + // "Quest Complete" popup for rewards the player did not get. A refusal instead re-opens + // the current offer when its details remain readable, so the player can correct the choice. + ClientOpcodeMessage::CMSG_QUESTGIVER_CHOOSE_REWARD(c) => { + match store.turn_in_quest(actor, c.guid.guid(), c.quest_id, c.reward) { + // The popup echoes the definition's XP/money/items, so what it shows matches what + // the module granted. Unreadable details drop it — the turn-in already happened. + Ok(()) => Ok(ProtocolReply { outbound: match store.quest_detail_view(c.quest_id)? { Some(detail) => vec![Outbound::One( - ServerOpcodeMessage::SMSG_QUESTGIVER_OFFER_REWARD(Box::new( - codec::build_offer_reward(c.guid.guid(), &detail), + ServerOpcodeMessage::SMSG_QUESTGIVER_QUEST_COMPLETE(Box::new( + codec::build_quest_complete(&detail), )), )], None => Vec::new(), }, - }) + after_queue: Some(WorldSessionAction::RedriveMail(actor)), + }), + Err(e) if matches!(classify(&e), DurableFailure::Refusal { .. }) => { + log::debug!( + "world: turn_in_quest refused (quest {}, reward index {}): {e}", + c.quest_id, + c.reward + ); + Ok(ProtocolReply::from( + match store.quest_detail_view(c.quest_id)? { + Some(detail) => vec![Outbound::One( + ServerOpcodeMessage::SMSG_QUESTGIVER_OFFER_REWARD(Box::new( + codec::build_offer_reward(c.guid.guid(), &detail), + )), + )], + None => Vec::new(), + }, + )) + } + Err(e) => Err(e), } - Err(e) => Err(e), } - } - // Abandon a quest from the log ("Abandon Quest"). The payload is a LOG SLOT (0..19), not a - // quest id — resolve it against the same `player_quest_log` ordering the world-entry block - // reads, then request the durable abandon. A slot that is not currently in the log (stale - // window, typo'd click) resolves to nothing and requests nothing — it cannot abandon an - // arbitrary quest. No SMSG on success: the quest-log relay re-sends the cleared block. - ClientOpcodeMessage::CMSG_QUESTLOG_REMOVE_QUEST(r) => { - if let Some(s) = store - .player_quest_log(self_guid)? - .into_iter() - .find(|s| s.slot == r.slot) - { + // Abandon a quest from the log ("Abandon Quest"). The payload is a LOG SLOT (0..19), not a + // quest id — resolve it against the same `player_quest_log` ordering the world-entry block + // reads, then request the durable abandon. A slot that is not currently in the log (stale + // window, typo'd click) resolves to nothing and requests nothing — it cannot abandon an + // arbitrary quest. No SMSG on success: the quest-log relay re-sends the cleared block. + ClientOpcodeMessage::CMSG_QUESTLOG_REMOVE_QUEST(r) => { + if let Some(s) = store + .player_quest_log(self_guid)? + .into_iter() + .find(|s| s.slot == r.slot) + { + ignore_refusal( + store.abandon_quest(actor, s.quest_id), + "abandon_quest", + session.account_id, + )?; + } + Ok(ProtocolReply::from(Vec::new())) + } + // Share a quest with the party (`CMSG_PUSHQUESTTOPARTY`). The module validates + // grouped + actively-on-the-quest and pushes the per-member `QUEST_SHARE`/`QUEST_PUSH_RESULT` + // events itself (relayed by `subscriptions.rs`'s `on_group_event`); no direct SMSG here. + ClientOpcodeMessage::CMSG_PUSHQUESTTOPARTY(p) => { ignore_refusal( - store.abandon_quest(actor, s.quest_id), - "abandon_quest", - player.account_id, + store.push_quest(actor, p.quest_id), + "push_quest", + session.account_id, )?; + Ok(ProtocolReply::from(Vec::new())) } - Ok(QuestActionOutcome::Handled { - outbound: Vec::new(), - }) + other => Err(anyhow!("request routed to Quest: {other}")), } - // Share a quest with the party (`CMSG_PUSHQUESTTOPARTY`). The module validates - // grouped + actively-on-the-quest and pushes the per-member `QUEST_SHARE`/`QUEST_PUSH_RESULT` - // events itself (relayed by `subscriptions.rs`'s `on_group_event`); no direct SMSG here. - ClientOpcodeMessage::CMSG_PUSHQUESTTOPARTY(p) => { - ignore_refusal( - store.push_quest(actor, p.quest_id), - "push_quest", - player.account_id, - )?; - Ok(QuestActionOutcome::Handled { - outbound: Vec::new(), - }) - } - other => Ok(QuestActionOutcome::PassThrough(other)), } } @@ -398,7 +375,7 @@ mod tests { use crate::stdb::ReducerCallError; use std::sync::Mutex; use wow_world_messages::vanilla::{ - Guid, QuestGiverStatus, CMSG_PING, CMSG_PUSHQUESTTOPARTY, CMSG_QUESTGIVER_ACCEPT_QUEST, + Guid, QuestGiverStatus, CMSG_PUSHQUESTTOPARTY, CMSG_QUESTGIVER_ACCEPT_QUEST, CMSG_QUESTGIVER_CHOOSE_REWARD, CMSG_QUESTGIVER_COMPLETE_QUEST, CMSG_QUESTGIVER_HELLO, CMSG_QUESTGIVER_QUERY_QUEST, CMSG_QUESTGIVER_STATUS_QUERY, CMSG_QUESTLOG_REMOVE_QUEST, CMSG_QUEST_QUERY, @@ -565,11 +542,16 @@ mod tests { const OP_QUEST_DETAILS: u16 = 0x0188; const OP_QUEST_QUERY_RESPONSE: u16 = 0x005D; - fn player() -> QuestActionPlayer { - QuestActionPlayer { - account_id: 7, - self_guid: Some(SELF_GUID), - } + fn run_quest( + store: &St, + mut session: ProtocolSession, + message: ClientOpcodeMessage, + ) -> Result { + Quest::handle(store, &mut session, message.into()) + } + + fn session() -> ProtocolSession { + ProtocolSession::in_world(7, SELF_GUID) } fn status_query(giver: u64) -> ClientOpcodeMessage { @@ -670,14 +652,8 @@ mod tests { } } - fn outbound(outcome: QuestActionOutcome) -> Vec { - match outcome { - QuestActionOutcome::Handled { outbound } - | QuestActionOutcome::TurnedIn { outbound } => outbound, - QuestActionOutcome::PassThrough(_) => { - panic!("expected the quest module to handle this") - } - } + fn outbound(reply: ProtocolReply) -> Vec { + reply.outbound } /// The same client-visible traffic: same order, same messages, same raw bytes. @@ -708,8 +684,7 @@ mod tests { ..Default::default() }; - let batch = - outbound(dispatch_quest_action(&actions, player(), status_query(GIVER)).unwrap()); + let batch = outbound(run_quest(&actions, session(), status_query(GIVER)).unwrap()); assert!(matches!( batch.as_slice(), @@ -731,9 +706,7 @@ mod tests { ..one_quest(codec::ROLE_START, false, false) }; - assert!( - outbound(dispatch_quest_action(&actions, player(), hello(GIVER)).unwrap()).is_empty() - ); + assert!(outbound(run_quest(&actions, session(), hello(GIVER)).unwrap()).is_empty()); assert_eq!( actions.gate_requests.lock().unwrap().as_slice(), &[(GIVER, SELF_GUID)] @@ -745,7 +718,7 @@ mod tests { fn a_single_new_quest_opens_its_details_screen_directly() { let actions = one_quest(codec::ROLE_START, false, false); - let batch = outbound(dispatch_quest_action(&actions, player(), hello(GIVER)).unwrap()); + let batch = outbound(run_quest(&actions, session(), hello(GIVER)).unwrap()); assert!(matches!( batch.as_slice(), @@ -758,7 +731,7 @@ mod tests { fn a_single_complete_turn_in_opens_the_offer_reward_screen() { let actions = one_quest(codec::ROLE_END, true, true); - let batch = outbound(dispatch_quest_action(&actions, player(), hello(GIVER)).unwrap()); + let batch = outbound(run_quest(&actions, session(), hello(GIVER)).unwrap()); assert!(matches!( batch.as_slice(), @@ -771,7 +744,7 @@ mod tests { fn a_single_incomplete_turn_in_opens_the_request_items_screen() { let actions = one_quest(codec::ROLE_END, true, false); - let batch = outbound(dispatch_quest_action(&actions, player(), hello(GIVER)).unwrap()); + let batch = outbound(run_quest(&actions, session(), hello(GIVER)).unwrap()); assert!(matches!( batch.as_slice(), @@ -791,7 +764,7 @@ mod tests { ..Default::default() }; - let batch = outbound(dispatch_quest_action(&actions, player(), hello(GIVER)).unwrap()); + let batch = outbound(run_quest(&actions, session(), hello(GIVER)).unwrap()); assert!(matches!( batch.as_slice(), @@ -806,7 +779,7 @@ mod tests { fn a_giver_with_no_menu_quests_opens_an_empty_list() { let actions = InMemoryQuestActions::default(); - let batch = outbound(dispatch_quest_action(&actions, player(), hello(GIVER)).unwrap()); + let batch = outbound(run_quest(&actions, session(), hello(GIVER)).unwrap()); assert!(matches!( batch.as_slice(), @@ -822,7 +795,7 @@ mod tests { // would let those two answers disagree inside one request. let actions = one_quest(codec::ROLE_END, true, true); - dispatch_quest_action(&actions, player(), hello(GIVER)).unwrap(); + run_quest(&actions, session(), hello(GIVER)).unwrap(); assert_eq!( actions.eval_requests.lock().unwrap().as_slice(), @@ -833,11 +806,11 @@ mod tests { #[test] fn a_gameobject_giver_opens_the_same_screen_as_a_creature_giver() { - // The client never sends HELLO for a gameobject giver, so `handle_loot` calls the menu + // The client never sends HELLO for a gameobject giver, so `GameObject::handle` calls the menu // directly. Same state, same screen — only the giver guid in the body differs. let actions = one_quest(codec::ROLE_START, false, false); - let creature = outbound(dispatch_quest_action(&actions, player(), hello(GIVER)).unwrap()); + let creature = outbound(run_quest(&actions, session(), hello(GIVER)).unwrap()); let gameobject = quest_giver_menu(&actions, GIVER, SELF_GUID).unwrap(); let other_giver = quest_giver_menu(&actions, GO_GIVER, SELF_GUID).unwrap(); @@ -854,8 +827,7 @@ mod tests { fn a_details_request_opens_the_whole_screen_for_the_asking_giver() { let actions = loaded_quest(); - let batch = - outbound(dispatch_quest_action(&actions, player(), query_quest(GIVER, QUEST)).unwrap()); + let batch = outbound(run_quest(&actions, session(), query_quest(GIVER, QUEST)).unwrap()); assert!(matches!( batch.as_slice(), @@ -868,9 +840,8 @@ mod tests { fn a_details_request_for_an_unloaded_quest_answers_nothing() { let actions = loaded_quest(); - let batch = outbound( - dispatch_quest_action(&actions, player(), query_quest(GIVER, QUEST + 1)).unwrap(), - ); + let batch = + outbound(run_quest(&actions, session(), query_quest(GIVER, QUEST + 1)).unwrap()); assert!(batch.is_empty()); } @@ -879,8 +850,7 @@ mod tests { fn a_definition_query_answers_the_raw_vanilla_definition() { let actions = loaded_quest(); - let batch = - outbound(dispatch_quest_action(&actions, player(), quest_query(QUEST)).unwrap()); + let batch = outbound(run_quest(&actions, session(), quest_query(QUEST)).unwrap()); assert!(matches!( batch.as_slice(), @@ -893,8 +863,7 @@ mod tests { fn a_definition_query_for_an_unloaded_quest_answers_nothing() { let actions = loaded_quest(); - let batch = - outbound(dispatch_quest_action(&actions, player(), quest_query(QUEST + 1)).unwrap()); + let batch = outbound(run_quest(&actions, session(), quest_query(QUEST + 1)).unwrap()); assert!(batch.is_empty()); } @@ -905,8 +874,7 @@ mod tests { fn accept_requests_the_durable_accept_for_the_player_giver_and_quest() { let actions = loaded_quest(); - let batch = - outbound(dispatch_quest_action(&actions, player(), accept(GIVER, QUEST)).unwrap()); + let batch = outbound(run_quest(&actions, session(), accept(GIVER, QUEST)).unwrap()); assert!(batch.is_empty(), "the client closes the window itself"); assert_eq!( @@ -922,8 +890,7 @@ mod tests { ..loaded_quest() }; - let batch = - outbound(dispatch_quest_action(&actions, player(), accept(GIVER, QUEST)).unwrap()); + let batch = outbound(run_quest(&actions, session(), accept(GIVER, QUEST)).unwrap()); assert!(batch.is_empty()); } @@ -935,7 +902,7 @@ mod tests { ..loaded_quest() }; - let error = match dispatch_quest_action(&actions, player(), accept(GIVER, QUEST)) { + let error = match run_quest(&actions, session(), accept(GIVER, QUEST)) { Err(error) => error, Ok(_) => panic!("a dead reducer transport must end the session"), }; @@ -952,7 +919,7 @@ mod tests { ..loaded_quest() }; - let batch = item_started_quest(&actions, player(), BAG_SLOT) + let batch = item_started_quest(&actions, &session(), BAG_SLOT) .unwrap() .expect("the quest module owns a quest-starting item"); @@ -971,7 +938,7 @@ mod tests { fn an_item_that_starts_no_quest_leaves_the_action_to_the_item_family() { let actions = loaded_quest(); - assert!(item_started_quest(&actions, player(), BAG_SLOT) + assert!(item_started_quest(&actions, &session(), BAG_SLOT) .unwrap() .is_none()); } @@ -985,7 +952,7 @@ mod tests { ..loaded_quest() }; - let batch = item_started_quest(&actions, player(), BAG_SLOT).unwrap(); + let batch = item_started_quest(&actions, &session(), BAG_SLOT).unwrap(); assert_eq!(batch.map(|batch| batch.len()), Some(0)); } @@ -996,12 +963,9 @@ mod tests { start_quest: Some((ITEM_GIVER, QUEST)), ..loaded_quest() }; - let player = QuestActionPlayer { - account_id: 7, - self_guid: None, - }; + let player = ProtocolSession::new(7, "TESTER".into()); - assert!(item_started_quest(&actions, player, BAG_SLOT) + assert!(item_started_quest(&actions, &player, BAG_SLOT) .unwrap() .is_none()); assert!(actions.start_quest_requests.lock().unwrap().is_empty()); @@ -1015,8 +979,8 @@ mod tests { }; let from_giver = - outbound(dispatch_quest_action(&actions, player(), query_quest(GIVER, QUEST)).unwrap()); - let from_item = item_started_quest(&actions, player(), BAG_SLOT) + outbound(run_quest(&actions, session(), query_quest(GIVER, QUEST)).unwrap()); + let from_item = item_started_quest(&actions, &session(), BAG_SLOT) .unwrap() .expect("the quest module owns a quest-starting item"); @@ -1032,7 +996,7 @@ mod tests { ..Default::default() }; - let batch = outbound(dispatch_quest_action(&actions, player(), abandon(3)).unwrap()); + let batch = outbound(run_quest(&actions, session(), abandon(3)).unwrap()); assert!( batch.is_empty(), @@ -1053,7 +1017,7 @@ mod tests { ..Default::default() }; - let batch = outbound(dispatch_quest_action(&actions, player(), abandon(9)).unwrap()); + let batch = outbound(run_quest(&actions, session(), abandon(9)).unwrap()); assert!(batch.is_empty()); assert!(actions.abandon_requests.lock().unwrap().is_empty()); @@ -1063,7 +1027,7 @@ mod tests { fn abandon_against_an_empty_log_requests_nothing() { let actions = InMemoryQuestActions::default(); - let batch = outbound(dispatch_quest_action(&actions, player(), abandon(0)).unwrap()); + let batch = outbound(run_quest(&actions, session(), abandon(0)).unwrap()); assert!(batch.is_empty()); assert!(actions.abandon_requests.lock().unwrap().is_empty()); @@ -1077,7 +1041,7 @@ mod tests { ..Default::default() }; - let batch = outbound(dispatch_quest_action(&actions, player(), abandon(3)).unwrap()); + let batch = outbound(run_quest(&actions, session(), abandon(3)).unwrap()); assert!(batch.is_empty()); } @@ -1090,7 +1054,7 @@ mod tests { ..Default::default() }; - let error = match dispatch_quest_action(&actions, player(), abandon(3)) { + let error = match run_quest(&actions, session(), abandon(3)) { Err(error) => error, Ok(_) => panic!("a dead reducer transport must end the session"), }; @@ -1098,25 +1062,27 @@ mod tests { assert!(matches!(classify(&error), DurableFailure::TransportLoss)); } - // ── Player context and error classification ────────────────────────────── - #[test] - fn before_world_entry_the_quest_opcodes_pass_through() { - let actions = one_quest(codec::ROLE_START, false, false); - let player = QuestActionPlayer { - account_id: 7, - self_guid: None, - }; - - for msg in [status_query(GIVER), hello(GIVER)] { - assert!(matches!( - dispatch_quest_action(&actions, player, msg).unwrap(), - QuestActionOutcome::PassThrough(_) - )); + fn quest_requests_before_world_entry_make_no_durable_request() { + let actions = InMemoryQuestActions::default(); + for message in [ + hello(GIVER), + accept(GIVER, QUEST), + abandon(0), + push_to_party(QUEST), + ] { + let mut session = ProtocolSession::new(7, "TESTER".into()); + let reply = Quest::handle(&actions, &mut session, message.into()).unwrap(); + assert!(reply.outbound.is_empty()); + assert!(reply.after_queue.is_none()); } - assert!(actions.eval_requests.lock().unwrap().is_empty()); + assert!(actions.accept_requests.lock().unwrap().is_empty()); + assert!(actions.abandon_requests.lock().unwrap().is_empty()); + assert!(actions.push_requests.lock().unwrap().is_empty()); } + // ── World Session context and error classification ────────────────────────────── + #[test] fn a_failed_interaction_gate_read_is_session_fatal() { let actions = InMemoryQuestActions { @@ -1124,7 +1090,7 @@ mod tests { ..one_quest(codec::ROLE_START, false, false) }; - let error = match dispatch_quest_action(&actions, player(), hello(GIVER)) { + let error = match run_quest(&actions, session(), hello(GIVER)) { Err(error) => error, Ok(_) => panic!("a dead reducer transport must end the session"), }; @@ -1132,25 +1098,6 @@ mod tests { assert!(matches!(classify(&error), DurableFailure::TransportLoss)); } - // ── Pass-through ───────────────────────────────────────────────────────── - - #[test] - fn unrelated_opcodes_pass_through_to_the_next_dispatcher() { - let actions = InMemoryQuestActions::default(); - - let outcome = dispatch_quest_action( - &actions, - player(), - ClientOpcodeMessage::CMSG_PING(CMSG_PING::default()), - ) - .unwrap(); - - assert!(matches!( - outcome, - QuestActionOutcome::PassThrough(ClientOpcodeMessage::CMSG_PING(_)) - )); - } - // ── The turn-in round trip ─────────────────────────────────────────────── fn complete_quest(quest_id: u32) -> ClientOpcodeMessage { @@ -1197,8 +1144,7 @@ mod tests { fn opening_a_complete_turn_in_offers_the_reward_and_grants_nothing() { let actions = one_quest(codec::ROLE_END, true, true); - let batch = - outbound(dispatch_quest_action(&actions, player(), complete_quest(QUEST)).unwrap()); + let batch = outbound(run_quest(&actions, session(), complete_quest(QUEST)).unwrap()); assert!(matches!( batch.as_slice(), @@ -1218,8 +1164,7 @@ mod tests { fn opening_an_incomplete_turn_in_asks_for_the_remaining_items() { let actions = one_quest(codec::ROLE_END, true, false); - let batch = - outbound(dispatch_quest_action(&actions, player(), complete_quest(QUEST)).unwrap()); + let batch = outbound(run_quest(&actions, session(), complete_quest(QUEST)).unwrap()); assert!(matches!( batch.as_slice(), @@ -1232,8 +1177,7 @@ mod tests { fn opening_a_turn_in_for_an_unknown_quest_answers_nothing() { let actions = one_quest(codec::ROLE_END, true, true); - let batch = - outbound(dispatch_quest_action(&actions, player(), complete_quest(QUEST + 1)).unwrap()); + let batch = outbound(run_quest(&actions, session(), complete_quest(QUEST + 1)).unwrap()); assert!(batch.is_empty()); // No screen to build, so the giver is never evaluated. @@ -1246,7 +1190,7 @@ mod tests { // recorded ahead of it is what stops a refusal from showing a false "Quest Complete". let actions = rewarded_turn_in(); - dispatch_quest_action(&actions, player(), choose_reward(2)).unwrap(); + run_quest(&actions, session(), choose_reward(2)).unwrap(); assert_eq!( actions.turn_in_calls.lock().unwrap().as_slice(), @@ -1258,7 +1202,7 @@ mod tests { fn a_granted_turn_in_answers_the_popup_built_from_the_quest_details() { let actions = rewarded_turn_in(); - let batch = outbound(dispatch_quest_action(&actions, player(), choose_reward(0)).unwrap()); + let batch = outbound(run_quest(&actions, session(), choose_reward(0)).unwrap()); assert!(matches!( batch.as_slice(), @@ -1273,14 +1217,26 @@ mod tests { /// the Character's held letters. #[test] fn only_a_granted_turn_in_asks_for_the_held_letters_to_be_driven() { - let granted = dispatch_quest_action(&rewarded_turn_in(), player(), choose_reward(0)); - assert!(matches!(granted, Ok(QuestActionOutcome::TurnedIn { .. }))); + let granted = run_quest(&rewarded_turn_in(), session(), choose_reward(0)); + assert!(matches!( + granted, + Ok(ProtocolReply { + after_queue: Some(WorldSessionAction::RedriveMail(_)), + .. + }) + )); let refused = InMemoryQuestActions { turn_in_error: Some("quest objectives are not complete".into()), ..rewarded_turn_in() }; - let refused = dispatch_quest_action(&refused, player(), choose_reward(0)); - assert!(matches!(refused, Ok(QuestActionOutcome::Handled { .. }))); + let refused = run_quest(&refused, session(), choose_reward(0)); + assert!(matches!( + refused, + Ok(ProtocolReply { + after_queue: None, + .. + }) + )); } #[test] @@ -1290,7 +1246,7 @@ mod tests { ..rewarded_turn_in() }; - let batch = outbound(dispatch_quest_action(&actions, player(), choose_reward(2)).unwrap()); + let batch = outbound(run_quest(&actions, session(), choose_reward(2)).unwrap()); assert!(matches!( batch.as_slice(), @@ -1311,7 +1267,7 @@ mod tests { ..one_quest(codec::ROLE_END, true, true) }; - let batch = outbound(dispatch_quest_action(&actions, player(), choose_reward(2)).unwrap()); + let batch = outbound(run_quest(&actions, session(), choose_reward(2)).unwrap()); assert!(batch.is_empty()); assert_eq!( @@ -1327,7 +1283,7 @@ mod tests { ..one_quest(codec::ROLE_END, true, true) }; - let batch = outbound(dispatch_quest_action(&actions, player(), choose_reward(0)).unwrap()); + let batch = outbound(run_quest(&actions, session(), choose_reward(0)).unwrap()); assert!(batch.is_empty()); assert_eq!( @@ -1343,7 +1299,7 @@ mod tests { ..rewarded_turn_in() }; - let error = match dispatch_quest_action(&actions, player(), choose_reward(2)) { + let error = match run_quest(&actions, session(), choose_reward(2)) { Err(error) => error, Ok(_) => panic!("a dead reducer transport must end the session"), }; @@ -1361,8 +1317,7 @@ mod tests { fn party_sharing_requests_the_durable_share_and_answers_nothing_on_success() { let actions = InMemoryQuestActions::default(); - let batch = - outbound(dispatch_quest_action(&actions, player(), push_to_party(QUEST)).unwrap()); + let batch = outbound(run_quest(&actions, session(), push_to_party(QUEST)).unwrap()); assert!( batch.is_empty(), @@ -1381,8 +1336,7 @@ mod tests { ..Default::default() }; - let batch = - outbound(dispatch_quest_action(&actions, player(), push_to_party(QUEST)).unwrap()); + let batch = outbound(run_quest(&actions, session(), push_to_party(QUEST)).unwrap()); assert!(batch.is_empty()); } @@ -1394,7 +1348,7 @@ mod tests { ..Default::default() }; - let error = match dispatch_quest_action(&actions, player(), push_to_party(QUEST)) { + let error = match run_quest(&actions, session(), push_to_party(QUEST)) { Err(error) => error, Ok(_) => panic!("a dead reducer transport must end the session"), }; @@ -1418,7 +1372,7 @@ mod tests { }; let gossip_items = gossip_quest_items(&actions, GIVER, SELF_GUID).unwrap(); - let menu = outbound(dispatch_quest_action(&actions, player(), hello(GIVER)).unwrap()); + let menu = outbound(run_quest(&actions, session(), hello(GIVER)).unwrap()); let menu_items = match menu.as_slice() { [Outbound::One(ServerOpcodeMessage::SMSG_QUESTGIVER_QUEST_LIST(l))] => &l.quest_items, diff --git a/gateway/src/world/handlers/taxi.rs b/gateway/src/world/handlers/taxi.rs index 4d09d1e33..c83529d17 100644 --- a/gateway/src/world/handlers/taxi.rs +++ b/gateway/src/world/handlers/taxi.rs @@ -2,6 +2,9 @@ //! catalogue, discovery, range, or topology tables and therefore cannot fork module policy. use super::super::*; +use crate::world::family::{ + ProtocolFamily, ProtocolReply, ProtocolRequest, ProtocolSession, WorldSessionAction, +}; pub(crate) trait TaxiActionStore: Send + Sync { fn taxi_node_status( @@ -23,23 +26,6 @@ pub(crate) trait TaxiActionStore: Send + Sync { fn arm_taxi_flight(&self, actor: Actor) -> Result<()>; } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) struct TaxiActionPlayer { - pub(crate) self_guid: Option, -} - -pub(crate) enum TaxiActionOutcome { - Handled { - outbound: Vec, - }, - Activated { - outbound: Vec, - character_guid: u64, - arm: bool, - }, - PassThrough(ClientOpcodeMessage), -} - fn status_outbound( store: &St, actor: Option, @@ -84,24 +70,6 @@ fn activate_taxi_outbound( )) } -/// Queue the activation reply, then arm the flight. The activation is already committed, so an -/// arming failure of any kind ends the World Session. -pub(crate) fn queue_reply_then_arm( - tx: &SessionTx, - store: &St, - outbound: Vec, - character_guid: u64, - arm: bool, -) -> Result<()> { - for message in outbound { - send(tx, message)?; - } - match Actor::new(character_guid) { - Some(actor) if arm => store.arm_taxi_flight(actor), - _ => Ok(()), - } -} - /// The single gateway entry to the module's open operation. Both the direct taxi query and TAXI /// gossip selection call this exact function. No Character yet gets no window. pub(crate) fn open_taxi_outbound( @@ -120,37 +88,38 @@ pub(crate) fn open_taxi_outbound( } } -pub(crate) fn dispatch_taxi_action( - store: &St, - player: TaxiActionPlayer, - msg: ClientOpcodeMessage, -) -> Result { - let actor = player.self_guid.and_then(Actor::new); - match msg { - ClientOpcodeMessage::CMSG_TAXINODE_STATUS_QUERY(query) => Ok(TaxiActionOutcome::Handled { - outbound: status_outbound(store, actor, query.guid.guid())?, - }), - ClientOpcodeMessage::CMSG_TAXIQUERYAVAILABLENODES(query) => { - Ok(TaxiActionOutcome::Handled { - outbound: open_taxi_outbound(store, actor, query.guid.guid())?, - }) - } - ClientOpcodeMessage::CMSG_ACTIVATETAXI(request) => { - let character_guid = player.self_guid.unwrap_or(0); - let (outbound, arm) = activate_taxi_outbound( - store, - actor, - request.guid.guid(), - request.source_node, - request.destination_node, - )?; - Ok(TaxiActionOutcome::Activated { - outbound, - character_guid, - arm, - }) +pub(crate) struct Taxi; + +impl ProtocolFamily for Taxi { + fn handle( + store: &St, + session: &mut ProtocolSession, + request: ProtocolRequest, + ) -> Result { + let msg = request.message()?; + let actor = session.actor(); + match msg { + ClientOpcodeMessage::CMSG_TAXINODE_STATUS_QUERY(query) => Ok(ProtocolReply::from( + status_outbound(store, actor, query.guid.guid())?, + )), + ClientOpcodeMessage::CMSG_TAXIQUERYAVAILABLENODES(query) => Ok(ProtocolReply::from( + open_taxi_outbound(store, actor, query.guid.guid())?, + )), + ClientOpcodeMessage::CMSG_ACTIVATETAXI(request) => { + let (outbound, arm) = activate_taxi_outbound( + store, + actor, + request.guid.guid(), + request.source_node, + request.destination_node, + )?; + Ok(ProtocolReply { + outbound, + after_queue: actor.filter(|_| arm).map(WorldSessionAction::ArmTaxi), + }) + } + other => Err(anyhow!("request routed to Taxi: {other}")), } - other => Ok(TaxiActionOutcome::PassThrough(other)), } } @@ -176,8 +145,6 @@ pub(super) mod tests { /// The one (source, destination) node pair the Module accepts; any other pair answers /// `ACTIVATE_NO_SUCH_PATH`. `None` accepts every pair. pub(crate) route: Option<(u32, u32)>, - pub(crate) arm_tx_probe: Mutex>, - pub(crate) arm_observed_depth: Mutex>, } impl TaxiActionStore for InMemoryTaxiActions { @@ -245,13 +212,55 @@ pub(super) mod tests { fn arm_taxi_flight(&self, actor: Actor) -> Result<()> { self.calls.lock().unwrap().push(("arm", actor.guid(), 0)); - if let Some(tx) = self.arm_tx_probe.lock().unwrap().as_ref() { - *self.arm_observed_depth.lock().unwrap() = Some(tx.depth()); - } Ok(()) } } + fn run_taxi( + store: &InMemoryTaxiActions, + mut session: ProtocolSession, + message: ClientOpcodeMessage, + ) -> Result { + Taxi::handle(store, &mut session, message.into()) + } + + #[test] + fn accepted_activation_requests_arming_after_the_reply_is_queued() { + let store = InMemoryTaxiActions::default(); + *store.activation.lock().unwrap() = codec::TaxiActivationResult { + result_code: lyracore_shared::constants::taxi_protocol::ACTIVATE_OK, + }; + let reply = run_taxi(&store, ProtocolSession::in_world(7, 9), activate_query()).unwrap(); + assert!(matches!( + reply.outbound.as_slice(), + [Outbound::One(ServerOpcodeMessage::SMSG_ACTIVATETAXIREPLY( + _ + ))] + )); + assert!( + matches!(reply.after_queue, Some(WorldSessionAction::ArmTaxi(actor)) if actor.guid() == 9) + ); + assert!(!store + .calls + .lock() + .unwrap() + .iter() + .any(|call| call.0 == "arm")); + } + + #[test] + fn refused_activation_does_not_request_arming() { + let store = InMemoryTaxiActions::default(); + *store.activation.lock().unwrap() = codec::TaxiActivationResult { + result_code: lyracore_shared::constants::taxi_protocol::ACTIVATE_NOT_ENOUGH_MONEY, + }; + let reply = run_taxi(&store, ProtocolSession::in_world(7, 9), activate_query()).unwrap(); + assert!(reply.after_queue.is_none()); + assert!( + matches!(reply.outbound.as_slice(), [Outbound::One(ServerOpcodeMessage::SMSG_ACTIVATETAXIREPLY(reply))] if reply.reply == wow_world_messages::vanilla::ActivateTaxiReply::NotEnoughMoney) + ); + } + #[test] fn status_query_returns_persisted_state_without_opening() { let store = InMemoryTaxiActions::default(); @@ -259,16 +268,16 @@ pub(super) mod tests { npc_guid: 77, known: false, }); - let outcome = dispatch_taxi_action( + let outcome = run_taxi( &store, - TaxiActionPlayer { self_guid: Some(9) }, + ProtocolSession::in_world(7, 9), CMSG_TAXINODE_STATUS_QUERY { guid: Guid::new(77), } .into(), ) .unwrap(); - assert!(matches!(outcome, TaxiActionOutcome::Handled { outbound } if outbound.len() == 1)); + assert!(matches!(outcome, ProtocolReply { outbound, .. } if outbound.len() == 1)); assert_eq!(*store.calls.lock().unwrap(), vec![("status", 9, 77)]); } @@ -280,16 +289,16 @@ pub(super) mod tests { source_client_node_id: 255, available_client_node_ids: vec![255, 256], }); - let outcome = dispatch_taxi_action( + let outcome = run_taxi( &store, - TaxiActionPlayer { self_guid: Some(9) }, + ProtocolSession::in_world(7, 9), CMSG_TAXIQUERYAVAILABLENODES { guid: Guid::new(77), } .into(), ) .unwrap(); - assert!(matches!(outcome, TaxiActionOutcome::Handled { outbound } if outbound.len() == 1)); + assert!(matches!(outcome, ProtocolReply { outbound, .. } if outbound.len() == 1)); assert_eq!(*store.calls.lock().unwrap(), vec![("open", 9, 77)]); } @@ -320,16 +329,16 @@ pub(super) mod tests { #[test] fn an_unanswered_query_is_nonfatal() { let store = InMemoryTaxiActions::default(); - let outcome = dispatch_taxi_action( + let outcome = run_taxi( &store, - TaxiActionPlayer { self_guid: Some(9) }, + ProtocolSession::in_world(7, 9), CMSG_TAXIQUERYAVAILABLENODES { guid: Guid::new(77), } .into(), ) .unwrap(); - assert!(matches!(outcome, TaxiActionOutcome::Handled { outbound } if outbound.is_empty())); + assert!(matches!(outcome, ProtocolReply { outbound, .. } if outbound.is_empty())); } #[test] @@ -339,7 +348,7 @@ pub(super) mod tests { ..Default::default() }; for msg in [status_query(), activate_query()] { - let result = dispatch_taxi_action(&store, TaxiActionPlayer { self_guid: Some(9) }, msg); + let result = run_taxi(&store, ProtocolSession::in_world(7, 9), msg); assert!( result.is_err(), "a lost transport must end the world session" @@ -355,7 +364,7 @@ pub(super) mod tests { ..Default::default() }; for msg in [status_query(), activate_query()] { - let result = dispatch_taxi_action(&store, TaxiActionPlayer { self_guid: Some(9) }, msg); + let result = run_taxi(&store, ProtocolSession::in_world(7, 9), msg); assert!(result.is_err(), "a taxi Refusal ends the world session"); } } @@ -363,10 +372,13 @@ pub(super) mod tests { #[test] fn no_character_yet_gets_no_taxi_answer_and_no_store_call() { let store = InMemoryTaxiActions::default(); - let outcome = - dispatch_taxi_action(&store, TaxiActionPlayer { self_guid: None }, status_query()) - .unwrap(); - assert!(matches!(outcome, TaxiActionOutcome::Handled { outbound } if outbound.is_empty())); + let outcome = run_taxi( + &store, + ProtocolSession::new(7, "TESTER".into()), + status_query(), + ) + .unwrap(); + assert!(matches!(outcome, ProtocolReply { outbound, .. } if outbound.is_empty())); assert!(store.calls.lock().unwrap().is_empty()); } @@ -376,9 +388,9 @@ pub(super) mod tests { *store.activation.lock().unwrap() = codec::TaxiActivationResult { result_code: lyracore_shared::constants::taxi_protocol::ACTIVATE_NOT_ENOUGH_MONEY, }; - let outcome = dispatch_taxi_action( + let outcome = run_taxi( &store, - TaxiActionPlayer { self_guid: Some(9) }, + ProtocolSession::in_world(7, 9), CMSG_ACTIVATETAXI { guid: Guid::new(77), source_node: 255, @@ -387,11 +399,7 @@ pub(super) mod tests { .into(), ) .unwrap(); - let outbound = match outcome { - TaxiActionOutcome::Activated { outbound, .. } => outbound, - TaxiActionOutcome::Handled { .. } => panic!("activate returned ordinary outcome"), - TaxiActionOutcome::PassThrough(_) => panic!("activate must be consumed"), - }; + let outbound = outcome.outbound; assert!(matches!( outbound.as_slice(), [Outbound::One(ServerOpcodeMessage::SMSG_ACTIVATETAXIREPLY(reply))] @@ -402,50 +410,4 @@ pub(super) mod tests { vec![(9, 77, 255, 256)] ); } - - #[test] - fn activation_reply_is_queued_before_the_arm_side_effect() { - let store = InMemoryTaxiActions::default(); - *store.activation.lock().unwrap() = codec::TaxiActivationResult { - result_code: lyracore_shared::constants::taxi_protocol::ACTIVATE_OK, - }; - let (tx, rx) = SessionTx::with_depth(0); - *store.arm_tx_probe.lock().unwrap() = Some(tx.clone()); - let (outbound, arm) = activate_taxi_outbound(&store, Actor::new(9), 77, 255, 256).unwrap(); - - queue_reply_then_arm(&tx, &store, outbound, 9, arm).unwrap(); - - assert_eq!(*store.arm_observed_depth.lock().unwrap(), Some(1)); - assert!(matches!( - rx.try_recv(), - Ok(Outbound::One(ServerOpcodeMessage::SMSG_ACTIVATETAXIREPLY( - _ - ))) - )); - } - - #[test] - fn activation_refusal_is_queued_without_calling_arm() { - let store = InMemoryTaxiActions::default(); - *store.activation.lock().unwrap() = codec::TaxiActivationResult { - result_code: lyracore_shared::constants::taxi_protocol::ACTIVATE_NOT_ENOUGH_MONEY, - }; - let (tx, rx) = SessionTx::with_depth(0); - *store.arm_tx_probe.lock().unwrap() = Some(tx.clone()); - let (outbound, arm) = activate_taxi_outbound(&store, Actor::new(9), 77, 255, 256).unwrap(); - - queue_reply_then_arm(&tx, &store, outbound, 9, arm).unwrap(); - - assert_eq!(tx.depth(), 1); - assert_eq!(*store.arm_observed_depth.lock().unwrap(), None); - assert!(!store - .calls - .lock() - .unwrap() - .iter() - .any(|call| call.0 == "arm")); - assert!( - matches!(rx.try_recv(), Ok(Outbound::One(ServerOpcodeMessage::SMSG_ACTIVATETAXIREPLY(reply))) if reply.reply == wow_world_messages::vanilla::ActivateTaxiReply::NotEnoughMoney) - ); - } } diff --git a/gateway/src/world/handlers/trade.rs b/gateway/src/world/handlers/trade.rs index 39b18ef55..3843abe74 100644 --- a/gateway/src/world/handlers/trade.rs +++ b/gateway/src/world/handlers/trade.rs @@ -5,7 +5,6 @@ //! convention. A Transport Loss ends the World Session. Outside the world there is no Actor to act //! as, so the opcodes are dropped silently, like the social family. -use super::super::social::self_guid; use super::super::*; use super::combat::ignore_refusal; @@ -46,82 +45,86 @@ pub(crate) trait TradeStore: Send + Sync { fn ignore_trade(&self, actor: Actor) -> Result<()>; } -pub(crate) fn handle_trade( - _tx: &SessionTx, - store: &St, - conn: &mut WorldConn, - msg: ClientOpcodeMessage, -) -> Result> { - let account_id = conn.account_id; - let actor = self_guid(conn).and_then(Actor::new); - let result = match (msg, actor) { - (ClientOpcodeMessage::CMSG_INITIATE_TRADE(c), Some(actor)) => { - ("initiate_trade", store.initiate_trade(actor, c.guid.guid())) - } - (ClientOpcodeMessage::CMSG_BEGIN_TRADE, Some(actor)) => { - ("begin_trade", store.begin_trade(actor)) - } - (ClientOpcodeMessage::CMSG_CANCEL_TRADE, Some(actor)) => { - ("cancel_trade", store.cancel_trade(actor)) - } - // Offer mutations. The (bag, slot) pair maps onto the module's absolute slots the - // same way the item family does: only the main pseudo-bag (255) is modelled — items inside - // equipped sub-bags are logged + ignored, matching the item-action dispatcher's posture. - (ClientOpcodeMessage::CMSG_SET_TRADE_ITEM(c), Some(actor)) => { - const MAIN_BAG: u8 = 255; // INVENTORY_SLOT_BAG_0 - if c.bag != MAIN_BAG { - log::debug!( - "world: set_trade_item from sub-bag {} ignored (account {account_id})", - c.bag - ); - return Ok(None); +pub(crate) struct Trade; + +impl ProtocolFamily for Trade { + fn handle( + store: &St, + conn: &mut ProtocolSession, + request: ProtocolRequest, + ) -> Result { + let msg = request.message()?; + let account_id = conn.account_id; + let actor = conn.self_guid().and_then(Actor::new); + let result = match (msg, actor) { + (ClientOpcodeMessage::CMSG_INITIATE_TRADE(c), Some(actor)) => { + ("initiate_trade", store.initiate_trade(actor, c.guid.guid())) + } + (ClientOpcodeMessage::CMSG_BEGIN_TRADE, Some(actor)) => { + ("begin_trade", store.begin_trade(actor)) + } + (ClientOpcodeMessage::CMSG_CANCEL_TRADE, Some(actor)) => { + ("cancel_trade", store.cancel_trade(actor)) + } + // Offer mutations. The (bag, slot) pair maps onto the module's absolute slots the + // same way the item family does: only the main pseudo-bag (255) is modelled — items inside + // equipped sub-bags are logged + ignored, matching the item-action dispatcher's posture. + (ClientOpcodeMessage::CMSG_SET_TRADE_ITEM(c), Some(actor)) => { + const MAIN_BAG: u8 = 255; // INVENTORY_SLOT_BAG_0 + if c.bag != MAIN_BAG { + log::debug!( + "world: set_trade_item from sub-bag {} ignored (account {account_id})", + c.bag + ); + return Ok(ProtocolReply::default()); + } + ( + "set_trade_item", + store.set_trade_item(actor, c.trade_slot, c.slot), + ) + } + (ClientOpcodeMessage::CMSG_CLEAR_TRADE_ITEM(c), Some(actor)) => ( + "clear_trade_item", + store.clear_trade_item(actor, c.trade_slot), + ), + (ClientOpcodeMessage::CMSG_SET_TRADE_GOLD(c), Some(actor)) => ( + "set_trade_gold", + store.set_trade_gold(actor, c.gold.as_int()), + ), + // Accept / unaccept. The accept body's `unknown1` is padding (vmangos skips it; + // bots set 1) — dropped here, the module needs only who accepted. + (ClientOpcodeMessage::CMSG_ACCEPT_TRADE(_), Some(actor)) => { + ("accept_trade", store.accept_trade(actor)) + } + (ClientOpcodeMessage::CMSG_UNACCEPT_TRADE, Some(actor)) => { + ("unaccept_trade", store.unaccept_trade(actor)) + } + // Proposal declines: the client auto-answers a BeginTrade it can't take, busy + // (already in a dialog) or the initiator is on the ignore list. + (ClientOpcodeMessage::CMSG_BUSY_TRADE, Some(actor)) => { + ("busy_trade", store.busy_trade(actor)) + } + (ClientOpcodeMessage::CMSG_IGNORE_TRADE, Some(actor)) => { + ("ignore_trade", store.ignore_trade(actor)) } + // Outside the world there is no Actor: the trade opcodes are consumed in silence. ( - "set_trade_item", - store.set_trade_item(actor, c.trade_slot, c.slot), - ) - } - (ClientOpcodeMessage::CMSG_CLEAR_TRADE_ITEM(c), Some(actor)) => ( - "clear_trade_item", - store.clear_trade_item(actor, c.trade_slot), - ), - (ClientOpcodeMessage::CMSG_SET_TRADE_GOLD(c), Some(actor)) => ( - "set_trade_gold", - store.set_trade_gold(actor, c.gold.as_int()), - ), - // Accept / unaccept. The accept body's `unknown1` is padding (vmangos skips it; - // bots set 1) — dropped here, the module needs only who accepted. - (ClientOpcodeMessage::CMSG_ACCEPT_TRADE(_), Some(actor)) => { - ("accept_trade", store.accept_trade(actor)) - } - (ClientOpcodeMessage::CMSG_UNACCEPT_TRADE, Some(actor)) => { - ("unaccept_trade", store.unaccept_trade(actor)) - } - // Proposal declines: the client auto-answers a BeginTrade it can't take, busy - // (already in a dialog) or the initiator is on the ignore list. - (ClientOpcodeMessage::CMSG_BUSY_TRADE, Some(actor)) => { - ("busy_trade", store.busy_trade(actor)) - } - (ClientOpcodeMessage::CMSG_IGNORE_TRADE, Some(actor)) => { - ("ignore_trade", store.ignore_trade(actor)) - } - // Outside the world there is no Actor: the trade opcodes are consumed in silence. - ( - ClientOpcodeMessage::CMSG_INITIATE_TRADE(_) - | ClientOpcodeMessage::CMSG_BEGIN_TRADE - | ClientOpcodeMessage::CMSG_CANCEL_TRADE - | ClientOpcodeMessage::CMSG_SET_TRADE_ITEM(_) - | ClientOpcodeMessage::CMSG_CLEAR_TRADE_ITEM(_) - | ClientOpcodeMessage::CMSG_SET_TRADE_GOLD(_) - | ClientOpcodeMessage::CMSG_ACCEPT_TRADE(_) - | ClientOpcodeMessage::CMSG_UNACCEPT_TRADE - | ClientOpcodeMessage::CMSG_BUSY_TRADE - | ClientOpcodeMessage::CMSG_IGNORE_TRADE, - None, - ) => return Ok(None), - (other, _) => return Ok(Some(other)), - }; - let (action, result) = result; - ignore_refusal(action, account_id, result)?; - Ok(None) + ClientOpcodeMessage::CMSG_INITIATE_TRADE(_) + | ClientOpcodeMessage::CMSG_BEGIN_TRADE + | ClientOpcodeMessage::CMSG_CANCEL_TRADE + | ClientOpcodeMessage::CMSG_SET_TRADE_ITEM(_) + | ClientOpcodeMessage::CMSG_CLEAR_TRADE_ITEM(_) + | ClientOpcodeMessage::CMSG_SET_TRADE_GOLD(_) + | ClientOpcodeMessage::CMSG_ACCEPT_TRADE(_) + | ClientOpcodeMessage::CMSG_UNACCEPT_TRADE + | ClientOpcodeMessage::CMSG_BUSY_TRADE + | ClientOpcodeMessage::CMSG_IGNORE_TRADE, + None, + ) => return Ok(ProtocolReply::default()), + (_, _) => return Err(anyhow!("opcode routed to the wrong Protocol Family")), + }; + let (action, result) = result; + ignore_refusal(action, account_id, result)?; + Ok(ProtocolReply::default()) + } } diff --git a/gateway/src/world/handlers/trainer.rs b/gateway/src/world/handlers/trainer.rs index f8f754408..1b972f308 100644 --- a/gateway/src/world/handlers/trainer.rs +++ b/gateway/src/world/handlers/trainer.rs @@ -141,200 +141,204 @@ impl From for TrainerBuyOutcome { /// Green/Red/Gray) and learn a spell (`CMSG_TRAINER_BUY_SPELL` → the module buy → /// `SMSG_TRAINER_BUY_*` + a live `SMSG_LEARNED_SPELL` so it hits the action bar without a relog). /// Needs the in-world player guid (a trainer is only clicked in-world); in CharSelect the opcodes -/// pass through. A buy Refusal is per-action — surfaced as `SMSG_TRAINER_BUY_FAILED`. -pub(crate) fn handle_trainer( - tx: &SessionTx, - store: &St, - conn: &mut WorldConn, - msg: ClientOpcodeMessage, -) -> Result> { - let Some(actor) = (match &conn.state { - WorldState::InWorld(iw) => Actor::new(iw.self_guid), - WorldState::CharSelect => None, - }) else { - return Ok(Some(msg)); - }; - let self_guid = actor.guid(); - match msg { - ClientOpcodeMessage::CMSG_TRAINER_LIST(c) => { - let trainer_guid = c.guid.guid(); - // A trainer that dislikes you refuses the window (silent drop). - if store - .npc_refuses_interaction(trainer_guid, self_guid) - .unwrap_or(false) - { - return Ok(None); - } - // Wrong class: silent drop, not an empty window — an empty list is indistinguishable - // from a trainer whose offerings were never imported. - if !store - .trainer_serves(self_guid, trainer_guid) - .unwrap_or(true) - { - return Ok(None); +/// answer nothing. A buy Refusal is per-action — surfaced as `SMSG_TRAINER_BUY_FAILED`. +pub(crate) struct Trainer; + +impl ProtocolFamily for Trainer { + fn handle( + store: &St, + conn: &mut ProtocolSession, + request: ProtocolRequest, + ) -> Result { + let msg = match request { + ProtocolRequest::FactionAtWar(body) => return faction_at_war(store, conn, &body), + ProtocolRequest::WatchedFaction(body) => return watched_faction(store, conn, &body), + other => other.message()?, + }; + let mut outbound = Vec::new(); + let Some(actor) = (match &conn.state { + WorldState::InWorld(iw) => Actor::new(iw.self_guid), + WorldState::CharSelect => None, + }) else { + return Ok(outbound.into()); + }; + let self_guid = actor.guid(); + match msg { + ClientOpcodeMessage::CMSG_TRAINER_LIST(c) => { + let trainer_guid = c.guid.guid(); + // A trainer that dislikes you refuses the window (silent drop). + if store + .npc_refuses_interaction(trainer_guid, self_guid) + .unwrap_or(false) + { + return Ok(outbound.into()); + } + // Wrong class: silent drop, not an empty window — an empty list is indistinguishable + // from a trainer whose offerings were never imported. + if !store + .trainer_serves(self_guid, trainer_guid) + .unwrap_or(true) + { + return Ok(outbound.into()); + } + let spells = store.trainer_list(self_guid, trainer_guid)?; + // Deliberate simplification: a generic greeting — the per-NPC trainer greeting text is + // a later npc_text slice (same as the vendor's generic gossip line). + let list = codec::build_trainer_list( + trainer_guid, + &spells, + "I can teach you a thing or two.", + ); + outbound.push(Outbound::One(ServerOpcodeMessage::SMSG_TRAINER_LIST( + Box::new(list), + ))); } - let spells = store.trainer_list(self_guid, trainer_guid)?; - // Deliberate simplification: a generic greeting — the per-NPC trainer greeting text is - // a later npc_text slice (same as the vendor's generic gossip line). - let list = - codec::build_trainer_list(trainer_guid, &spells, "I can teach you a thing or two."); - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_TRAINER_LIST(Box::new(list))), - )?; - } - ClientOpcodeMessage::CMSG_TRAINER_BUY_SPELL(c) => { - let trainer_guid = c.guid.guid(); - let spell_id = c.id; - // A Refusal arrives as an outcome. An error leaves the durable result unknown, so it - // ends the session instead of posing as a gameplay answer. - match store.buy_trainer_spell(actor, trainer_guid, spell_id)? { - TrainerBuyOutcome::Learned => { - // Confirm + push the spell live so it appears on the action bar without a relog. - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_TRAINER_BUY_SUCCEEDED(Box::new( - codec::build_trainer_buy_succeeded(trainer_guid, spell_id), - ))), - )?; - // RIDING buy: the offering teaches a SKILL, and its trainer-list id is a marker with - // no Spell.dbc row behind it — echoing that as a learned spell would push the client - // an id it cannot resolve. The skill pane already moves on its own, from the live - // `game_player_skill` relay, so a riding purchase needs no spell echo at all. - // Profession offerings keep theirs: the importer synthesizes them with real - // learn-spell ids the client does resolve. - if store.trainer_offer_skill_line(trainer_guid, spell_id) - == lyracore_shared::trainer::RIDING_SKILL_LINE - { - return Ok(None); - } - // Book the RESOLVED rank (465), not the wrapper (1875) — the module granted - // the trigger spell; echoing the wrapper put "the spell that teaches Devotion - // Aura" in the player's General tab until relog. - // A RANK UPGRADE (the chain prev is already known) sends SUPERCEDED - // instead — the client REPLACES the old rank's book entry (vanilla) rather - // than stacking "Rank 1" next to "Rank 2". WIRE ORDER: cmangos writes - // old u16 THEN new u16; gtker's field names claim new-first — per the - // field-names-lie precedent we follow cmangos, so `new_spell_id` (the FIRST - // wire slot) carries the OLD rank. If live verify shows the NEW rank - // vanishing instead, swap these two. - let resolved = store.resolve_learn_target(spell_id); - match store.superseded_old_rank(resolved, self_guid) { - Some(old_rank) => { - use wow_world_messages::vanilla::SMSG_SUPERCEDED_SPELL; - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_SUPERCEDED_SPELL( - SMSG_SUPERCEDED_SPELL { - new_spell_id: old_rank as u16, - old_spell_id: resolved as u16, - }, - )), - )?; + ClientOpcodeMessage::CMSG_TRAINER_BUY_SPELL(c) => { + let trainer_guid = c.guid.guid(); + let spell_id = c.id; + // A Refusal arrives as an outcome. An error leaves the durable result unknown, so it + // ends the session instead of posing as a gameplay answer. + match store.buy_trainer_spell(actor, trainer_guid, spell_id)? { + TrainerBuyOutcome::Learned => { + // Confirm + push the spell live so it appears on the action bar without a relog. + outbound.push(Outbound::One( + ServerOpcodeMessage::SMSG_TRAINER_BUY_SUCCEEDED(Box::new( + codec::build_trainer_buy_succeeded(trainer_guid, spell_id), + )), + )); + // RIDING buy: the offering teaches a SKILL, and its trainer-list id is a marker with + // no Spell.dbc row behind it — echoing that as a learned spell would push the client + // an id it cannot resolve. The skill pane already moves on its own, from the live + // `game_player_skill` relay, so a riding purchase needs no spell echo at all. + // Profession offerings keep theirs: the importer synthesizes them with real + // learn-spell ids the client does resolve. + if store.trainer_offer_skill_line(trainer_guid, spell_id) + == lyracore_shared::trainer::RIDING_SKILL_LINE + { + return Ok(outbound.into()); + } + // Book the RESOLVED rank (465), not the wrapper (1875) — the module granted + // the trigger spell; echoing the wrapper put "the spell that teaches Devotion + // Aura" in the player's General tab until relog. + // A RANK UPGRADE (the chain prev is already known) sends SUPERCEDED + // instead — the client REPLACES the old rank's book entry (vanilla) rather + // than stacking "Rank 1" next to "Rank 2". WIRE ORDER: cmangos writes + // old u16 THEN new u16; gtker's field names claim new-first — per the + // field-names-lie precedent we follow cmangos, so `new_spell_id` (the FIRST + // wire slot) carries the OLD rank. If live verify shows the NEW rank + // vanishing instead, swap these two. + let resolved = store.resolve_learn_target(spell_id); + match store.superseded_old_rank(resolved, self_guid) { + Some(old_rank) => { + use wow_world_messages::vanilla::SMSG_SUPERCEDED_SPELL; + outbound.push(Outbound::One( + ServerOpcodeMessage::SMSG_SUPERCEDED_SPELL( + SMSG_SUPERCEDED_SPELL { + new_spell_id: old_rank as u16, + old_spell_id: resolved as u16, + }, + ), + )); + } + None => { + outbound.push(Outbound::One( + ServerOpcodeMessage::SMSG_LEARNED_SPELL( + codec::build_learned_spell(resolved), + ), + )); + } } - None => { - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_LEARNED_SPELL( - codec::build_learned_spell(resolved), - )), - )?; + // An armor-proficiency purchase widens what this Character may wear, and the + // client only learns that from SMSG_SET_PROFICIENCY. Re-read the spellbook the + // buy just changed and resend the ARMOR mask; the weapon table never moves. + if teaches_armor_proficiency(spell_id) + || teaches_armor_proficiency(resolved) + { + append_armor_proficiency(&mut outbound, store, self_guid)?; } } - // An armor-proficiency purchase widens what this Character may wear, and the - // client only learns that from SMSG_SET_PROFICIENCY. Re-read the spellbook the - // buy just changed and resend the ARMOR mask; the weapon table never moves. - if teaches_armor_proficiency(spell_id) || teaches_armor_proficiency(resolved) { - send_armor_proficiency(tx, store, self_guid)?; + TrainerBuyOutcome::Refused(reason) => { + log::debug!( + "world: trainer buy refused (account {}): {reason:?}", + conn.account_id + ); + outbound.push(Outbound::One(ServerOpcodeMessage::SMSG_TRAINER_BUY_FAILED( + Box::new(codec::build_trainer_buy_failed( + trainer_guid, + spell_id, + reason, + )), + ))); } } - TrainerBuyOutcome::Refused(reason) => { - log::debug!( - "world: trainer buy refused (account {}): {reason:?}", - conn.account_id - ); - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_TRAINER_BUY_FAILED(Box::new( - codec::build_trainer_buy_failed(trainer_guid, spell_id, reason), - ))), - )?; - } } - } - // Spend a talent point (`CMSG_LEARN_TALENT`). The module gates points/prereqs; on success the - // passive aura relay covers stat/buff updates. If this talent also grants a learnable ability - // (`grant_spell_id != 0`), push `SMSG_LEARNED_SPELL` so the action bar is usable without a relog. - // Action-bar persistence: the client sends ONE of these per drag/clear and expects the - // full bar back at login (SMSG_ACTION_BUTTONS). Unhandled until now — every bar change - // was lost on relog (only the creation-seeded buttons survived; user find via a - // talent-learned Consecration vanishing from the bar). `action`+`misc` are the client's - // packed u24 payload (spell id, or item id spilling into misc); best-effort (a failure - // must never drop the session — the button just won't stick). - ClientOpcodeMessage::CMSG_SET_ACTION_BUTTON(c) => { - let action = c.action as u32 | ((c.misc as u32) << 16); - settle_per_action( - "set_action_button", - conn.account_id, - store.set_action_button(actor, c.button, action, c.action_type), - )?; - } - ClientOpcodeMessage::CMSG_LEARN_TALENT(c) => { - let talent_id = c.talent.as_int(); - let grant_spell_id = store.talent_grant_spell(talent_id); - match store.learn_talent(actor, talent_id) { - Ok(()) => { - if grant_spell_id != 0 { - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_LEARNED_SPELL( + // Spend a talent point (`CMSG_LEARN_TALENT`). The module gates points/prereqs; on success the + // passive aura relay covers stat/buff updates. If this talent also grants a learnable ability + // (`grant_spell_id != 0`), push `SMSG_LEARNED_SPELL` so the action bar is usable without a relog. + // Action-bar persistence: the client sends ONE of these per drag/clear and expects the + // full bar back at login (SMSG_ACTION_BUTTONS). Unhandled until now — every bar change + // was lost on relog (only the creation-seeded buttons survived; user find via a + // talent-learned Consecration vanishing from the bar). `action`+`misc` are the client's + // packed u24 payload (spell id, or item id spilling into misc); best-effort (a failure + // must never drop the session — the button just won't stick). + ClientOpcodeMessage::CMSG_SET_ACTION_BUTTON(c) => { + let action = c.action as u32 | ((c.misc as u32) << 16); + settle_per_action( + "set_action_button", + conn.account_id, + store.set_action_button(actor, c.button, action, c.action_type), + )?; + } + ClientOpcodeMessage::CMSG_LEARN_TALENT(c) => { + let talent_id = c.talent.as_int(); + let grant_spell_id = store.talent_grant_spell(talent_id); + match store.learn_talent(actor, talent_id) { + Ok(()) => { + if grant_spell_id != 0 { + outbound.push(Outbound::One(ServerOpcodeMessage::SMSG_LEARNED_SPELL( codec::build_learned_spell(grant_spell_id), - )), - )?; - } + ))); + } - let (teach, superseded, remaining) = - store.talent_pane_sync(self_guid, talent_id); - if teach != 0 && teach != grant_spell_id { - if superseded != 0 { - use wow_world_messages::vanilla::SMSG_SUPERCEDED_SPELL; - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_SUPERCEDED_SPELL( - SMSG_SUPERCEDED_SPELL { - new_spell_id: superseded as u16, // cmangos wire order: OLD rides the first slot - old_spell_id: teach as u16, - }, - )), - )?; - } else { - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_LEARNED_SPELL( - codec::build_learned_spell(teach), - )), - )?; + let (teach, superseded, remaining) = + store.talent_pane_sync(self_guid, talent_id); + if teach != 0 && teach != grant_spell_id { + if superseded != 0 { + use wow_world_messages::vanilla::SMSG_SUPERCEDED_SPELL; + outbound.push(Outbound::One( + ServerOpcodeMessage::SMSG_SUPERCEDED_SPELL( + SMSG_SUPERCEDED_SPELL { + new_spell_id: superseded as u16, // cmangos wire order: OLD rides the first slot + old_spell_id: teach as u16, + }, + ), + )); + } else { + outbound.push(Outbound::One( + ServerOpcodeMessage::SMSG_LEARNED_SPELL( + codec::build_learned_spell(teach), + ), + )); + } + } + outbound.push(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( + Box::new(codec::build_talent_points_values(self_guid, remaining)), + ))); + // Spell-modifier mirror: the pick may have applied an A_SPELLMOD + // passive — re-send the aggregated totals so the client's cast bars match + // the server's folded timings immediately (idempotent absolute values). + for m in codec::build_spell_modifier_msgs(&store.spell_modifiers(self_guid)) + { + outbound.push(Outbound::One(m)); } } - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( - codec::build_talent_points_values(self_guid, remaining), - ))), - )?; - // Spell-modifier mirror: the pick may have applied an A_SPELLMOD - // passive — re-send the aggregated totals so the client's cast bars match - // the server's folded timings immediately (idempotent absolute values). - for m in codec::build_spell_modifier_msgs(&store.spell_modifiers(self_guid)) { - send(tx, Outbound::One(m))?; - } + Err(error) => settle_per_action("learn_talent", conn.account_id, Err(error))?, } - Err(error) => settle_per_action("learn_talent", conn.account_id, Err(error))?, } + _ => return Err(anyhow!("opcode routed to the wrong Protocol Family")), } - other => return Ok(Some(other)), + Ok(outbound.into()) } - Ok(None) } /// Does this trainer offering teach an armor proficiency? Both the trainer-list wrapper and the @@ -355,8 +359,8 @@ fn teaches_armor_proficiency(spell_id: u32) -> bool { /// re-tints its bags without a relog. Read after the buy: the mask states what the Character knows /// now, not what the purchase was meant to grant, so a buy the Module only half-applied never /// tints an item the equip Gate would still refuse. -fn send_armor_proficiency( - tx: &SessionTx, +fn append_armor_proficiency( + outbound: &mut Vec, store: &St, self_guid: u64, ) -> Result<()> { @@ -366,57 +370,49 @@ fn send_armor_proficiency( let Some((_, _, player_class, _)) = store.character_presence(self_guid).ok().flatten() else { return Ok(()); }; - send( - tx, - Outbound::One(codec::build_armor_proficiency_msg(player_class, &learned)), - ) + { + outbound.push(Outbound::One(codec::build_armor_proficiency_msg( + player_class, + &learned, + ))); + Ok(()) + } } -/// The vanilla request names a reputation-list index, not a Faction.dbc id. -pub(crate) fn handle_at_war( - tx: &SessionTx, +fn faction_at_war( store: &St, - conn: &mut WorldConn, + session: &ProtocolSession, body: &[u8], -) -> Result<()> { +) -> Result { anyhow::ensure!( body.len() == 5 && body[4] <= 1, "invalid CMSG_SET_FACTION_ATWAR body" ); - let Some(actor) = social::self_guid(conn).and_then(Actor::new) else { - return Ok(()); + let Some(actor) = session.actor() else { + return Ok(ProtocolReply::default()); }; let index = u32::from_le_bytes(body[..4].try_into()?); - if let Some((opcode, info)) = conn.move_coalesce.flush_now() { - forward_movement(store, conn, opcode, &info)?; - } + let mut outbound = Vec::new(); if let InteractionOutcome::Refused(reason) = store.set_faction_at_war(actor, index, body[4] != 0)? { - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_MESSAGECHAT(Box::new( - codec::build_gm_system_message(reason), - ))), - )?; + outbound.push(Outbound::One(ServerOpcodeMessage::SMSG_MESSAGECHAT( + Box::new(codec::build_gm_system_message(reason)), + ))); } - Ok(()) + Ok(outbound.into()) } -pub(crate) fn handle_watched_faction( - tx: &SessionTx, +fn watched_faction( store: &St, - conn: &mut WorldConn, + session: &ProtocolSession, body: &[u8], -) -> Result<()> { +) -> Result { anyhow::ensure!(body.len() == 4, "invalid CMSG_SET_WATCHED_FACTION body"); - let Some(actor) = social::self_guid(conn).and_then(Actor::new) else { - return Ok(()); + let Some(actor) = session.actor() else { + return Ok(ProtocolReply::default()); }; let index = i32::from_le_bytes(body.try_into()?); - if let Some((opcode, info)) = conn.move_coalesce.flush_now() { - forward_movement(store, conn, opcode, &info)?; - } let message = match store.set_watched_faction(actor, index)? { InteractionOutcome::Done => ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( codec::build_watched_faction_values(actor.guid(), index), @@ -425,5 +421,5 @@ pub(crate) fn handle_watched_faction( ServerOpcodeMessage::SMSG_MESSAGECHAT(Box::new(codec::build_gm_system_message(reason))) } }; - send(tx, Outbound::One(message)) + Ok(vec![Outbound::One(message)].into()) } diff --git a/gateway/src/world/handlers/vendor.rs b/gateway/src/world/handlers/vendor.rs index d2e31ffa3..1dc500b70 100644 --- a/gateway/src/world/handlers/vendor.rs +++ b/gateway/src/world/handlers/vendor.rs @@ -1,5 +1,5 @@ //! Vendor family: opening a vendor's inventory window, buy/sell/buyback/repair, and the buyback-tab -//! view. Every vendor opcode enters through `dispatch_vendor_action`; the gossip Browse-goods click +//! view. Every vendor opcode enters through `Vendor::handle`; the gossip Browse-goods click //! and the world-entry ring replay call the shared builders here rather than reaching for the store. use super::super::*; @@ -42,19 +42,6 @@ pub(crate) const BUYBACK_WIRE_SLOT_BASE: u16 = 69; /// need no memory of what the tab showed before. const BUYBACK_SLOTS: u16 = 12; -/// Who is asking. `self_guid` is `None` before world entry — the character-select state has no -/// actor, so gates that need one are skipped rather than run against a placeholder. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) struct VendorActionPlayer { - pub(crate) account_id: u64, - pub(crate) self_guid: Option, -} - -pub(crate) enum VendorActionOutcome { - Handled { outbound: Vec }, - PassThrough(ClientOpcodeMessage), -} - /// Whether a Module Gate refused the call. Anything else leaves the durable outcome unknown. fn is_refusal(error: &anyhow::Error) -> bool { matches!(classify(error), DurableFailure::Refusal { .. }) @@ -64,7 +51,7 @@ fn is_refusal(error: &anyhow::Error) -> bool { /// out of a vendor — but a Transport Loss ends the session. fn refuses_interaction( store: &St, - player: VendorActionPlayer, + player: &ProtocolSession, actor: Option, vendor_guid: u64, ) -> Result { @@ -105,152 +92,137 @@ pub(crate) fn vendor_has_stock( Ok(!store.vendor_stock(vendor_guid)?.is_empty()) } -pub(crate) fn dispatch_vendor_action( - store: &St, - player: VendorActionPlayer, - msg: ClientOpcodeMessage, -) -> Result { - let actor = player.self_guid.and_then(Actor::new); - match msg { - // A refusing NPC answers nothing at all; an empty stock still answers, or the client waits - // forever on the window it asked for. - ClientOpcodeMessage::CMSG_LIST_INVENTORY(c) => { - let vendor_guid = c.guid.guid(); - if refuses_interaction(store, player, actor, vendor_guid)? { - return Ok(VendorActionOutcome::Handled { - outbound: Vec::new(), - }); +pub(crate) struct Vendor; + +impl ProtocolFamily for Vendor { + fn handle( + store: &St, + session: &mut ProtocolSession, + request: ProtocolRequest, + ) -> Result { + let player = &*session; + let msg = request.message()?; + let actor = player.self_guid().and_then(Actor::new); + match msg { + // A refusing NPC answers nothing at all; an empty stock still answers, or the client waits + // forever on the window it asked for. + ClientOpcodeMessage::CMSG_LIST_INVENTORY(c) => { + let vendor_guid = c.guid.guid(); + if refuses_interaction(store, player, actor, vendor_guid)? { + return Ok(ProtocolReply::from(Vec::new())); + } + Ok(ProtocolReply::from(vendor_open_outbound( + store, + vendor_guid, + )?)) } - Ok(VendorActionOutcome::Handled { - outbound: vendor_open_outbound(store, vendor_guid)?, - }) - } - // Successful purchases carry no reply — the item/purse subscriptions deliver the row - // changes; only a rejection needs an explicit client-visible message. - ClientOpcodeMessage::CMSG_BUY_ITEM(c) => { - let vendor_guid = c.vendor.guid(); - let item_entry = c.item; - let buy_failed = |reason: &str| { - vec![Outbound::One(ServerOpcodeMessage::SMSG_BUY_FAILED( - Box::new(codec::build_buy_failed(vendor_guid, item_entry, reason)), - ))] - }; - let outbound = match actor { - None => buy_failed(""), - Some(actor) => { - match store.vendor_buy(actor, vendor_guid, item_entry, c.amount as u32) { - Ok(()) => Vec::new(), - Err(e) if is_refusal(&e) => { - log::debug!( - "world: vendor_buy failed (account {}): {e}", - player.account_id - ); - buy_failed(&e.to_string()) + // Successful purchases carry no reply — the item/purse subscriptions deliver the row + // changes; only a rejection needs an explicit client-visible message. + ClientOpcodeMessage::CMSG_BUY_ITEM(c) => { + let vendor_guid = c.vendor.guid(); + let item_entry = c.item; + let buy_failed = |reason: &str| { + vec![Outbound::One(ServerOpcodeMessage::SMSG_BUY_FAILED( + Box::new(codec::build_buy_failed(vendor_guid, item_entry, reason)), + ))] + }; + let outbound = match actor { + None => buy_failed(""), + Some(actor) => { + match store.vendor_buy(actor, vendor_guid, item_entry, c.amount as u32) { + Ok(()) => Vec::new(), + Err(e) if is_refusal(&e) => { + log::debug!( + "world: vendor_buy failed (account {}): {e}", + player.account_id + ); + buy_failed(&e.to_string()) + } + Err(e) => return Err(e), } - Err(e) => return Err(e), } + }; + Ok(ProtocolReply::from(outbound)) + } + // CMSG_REPAIR_ITEM carries the item INSTANCE guid, but the module's repair takes the + // inventory SLOT; guid 0 means repair-all, routed to the whole-body slot instead of a + // guid lookup. An unmatched guid (already sold / not ours) is a silent no-op. + ClientOpcodeMessage::CMSG_REPAIR_ITEM(c) => { + let Some(actor) = actor else { + return Ok(ProtocolReply::from(Vec::new())); + }; + let item_guid = c.item.guid(); + let slot = if item_guid == 0 { + Some(u8::MAX) + } else { + store.vendor_item_slot(item_guid) + }; + let Some(slot) = slot else { + return Ok(ProtocolReply::from(Vec::new())); + }; + match store.vendor_repair(actor, c.npc.guid(), slot) { + Ok(()) => Ok(ProtocolReply::from(Vec::new())), + Err(e) if is_refusal(&e) => { + log::debug!( + "world: repair_item ignored (account {}): {e}", + player.account_id + ); + Ok(ProtocolReply::from(vec![Outbound::One( + ServerOpcodeMessage::SMSG_MESSAGECHAT(Box::new( + codec::build_gm_system_message(e.to_string()), + )), + )])) + } + Err(e) => Err(e), } - }; - Ok(VendorActionOutcome::Handled { outbound }) - } - // CMSG_REPAIR_ITEM carries the item INSTANCE guid, but the module's repair takes the - // inventory SLOT; guid 0 means repair-all, routed to the whole-body slot instead of a - // guid lookup. An unmatched guid (already sold / not ours) is a silent no-op. - ClientOpcodeMessage::CMSG_REPAIR_ITEM(c) => { - let Some(actor) = actor else { - return Ok(VendorActionOutcome::Handled { - outbound: Vec::new(), - }); - }; - let item_guid = c.item.guid(); - let slot = if item_guid == 0 { - Some(u8::MAX) - } else { - store.vendor_item_slot(item_guid) - }; - let Some(slot) = slot else { - return Ok(VendorActionOutcome::Handled { - outbound: Vec::new(), - }); - }; - match store.vendor_repair(actor, c.npc.guid(), slot) { - Ok(()) => Ok(VendorActionOutcome::Handled { - outbound: Vec::new(), - }), - Err(e) if is_refusal(&e) => { - log::debug!( - "world: repair_item ignored (account {}): {e}", - player.account_id - ); - Ok(VendorActionOutcome::Handled { - outbound: vec![Outbound::One(ServerOpcodeMessage::SMSG_MESSAGECHAT( - Box::new(codec::build_gm_system_message(e.to_string())), - ))], - }) - } - Err(e) => Err(e), } - } - // CMSG_SELL_ITEM carries the item INSTANCE guid; the module's sell takes the inventory - // SLOT. An unmatched guid (already sold / not ours) is a silent no-op, same as repair. - ClientOpcodeMessage::CMSG_SELL_ITEM(c) => { - let Some(actor) = actor else { - return Ok(VendorActionOutcome::Handled { - outbound: Vec::new(), - }); - }; - let Some(slot) = store.vendor_item_slot(c.item.guid()) else { - return Ok(VendorActionOutcome::Handled { - outbound: Vec::new(), - }); - }; - match store.vendor_sell(actor, c.vendor.guid(), slot) { - // Reflect the new ring in the buyback tab immediately. - Ok(()) => Ok(VendorActionOutcome::Handled { - outbound: build_buyback_view(store, actor.guid()), - }), - Err(e) if is_refusal(&e) => { - log::debug!( - "world: sell_item ignored (account {}): {e}", - player.account_id - ); - Ok(VendorActionOutcome::Handled { - outbound: Vec::new(), - }) + // CMSG_SELL_ITEM carries the item INSTANCE guid; the module's sell takes the inventory + // SLOT. An unmatched guid (already sold / not ours) is a silent no-op, same as repair. + ClientOpcodeMessage::CMSG_SELL_ITEM(c) => { + let Some(actor) = actor else { + return Ok(ProtocolReply::from(Vec::new())); + }; + let Some(slot) = store.vendor_item_slot(c.item.guid()) else { + return Ok(ProtocolReply::from(Vec::new())); + }; + match store.vendor_sell(actor, c.vendor.guid(), slot) { + // Reflect the new ring in the buyback tab immediately. + Ok(()) => Ok(ProtocolReply::from(build_buyback_view(store, actor.guid()))), + Err(e) if is_refusal(&e) => { + log::debug!( + "world: sell_item ignored (account {}): {e}", + player.account_id + ); + Ok(ProtocolReply::from(Vec::new())) + } + Err(e) => Err(e), } - Err(e) => Err(e), } - } - // CMSG_BUYBACK_ITEM carries a wire BuybackSlot enum (69–81); map to the 0-based ring slot - // the module reducer takes. A successful re-buy rebuilds the whole tab so shifted and - // cleared entries appear immediately, but only once there is an actor to render it for. - ClientOpcodeMessage::CMSG_BUYBACK_ITEM(c) => { - let Some(actor) = actor else { - return Ok(VendorActionOutcome::Handled { - outbound: Vec::new(), - }); - }; - let slot = c - .slot - .as_int() - .saturating_sub(BUYBACK_WIRE_SLOT_BASE.into()) as u8; - match store.vendor_buyback(actor, c.guid.guid(), slot) { - Ok(()) => Ok(VendorActionOutcome::Handled { - outbound: build_buyback_view(store, actor.guid()), - }), - Err(e) if is_refusal(&e) => { - log::debug!( - "world: buyback_item ignored (account {}): {e}", - player.account_id - ); - Ok(VendorActionOutcome::Handled { - outbound: Vec::new(), - }) + // CMSG_BUYBACK_ITEM carries a wire BuybackSlot enum (69–81); map to the 0-based ring slot + // the module reducer takes. A successful re-buy rebuilds the whole tab so shifted and + // cleared entries appear immediately, but only once there is an actor to render it for. + ClientOpcodeMessage::CMSG_BUYBACK_ITEM(c) => { + let Some(actor) = actor else { + return Ok(ProtocolReply::from(Vec::new())); + }; + let slot = c + .slot + .as_int() + .saturating_sub(BUYBACK_WIRE_SLOT_BASE.into()) as u8; + match store.vendor_buyback(actor, c.guid.guid(), slot) { + Ok(()) => Ok(ProtocolReply::from(build_buyback_view(store, actor.guid()))), + Err(e) if is_refusal(&e) => { + log::debug!( + "world: buyback_item ignored (account {}): {e}", + player.account_id + ); + Ok(ProtocolReply::from(Vec::new())) + } + Err(e) => Err(e), } - Err(e) => Err(e), } + other => Err(anyhow!("opcode routed to wrong Protocol Family: {other}")), } - other => Ok(VendorActionOutcome::PassThrough(other)), } } @@ -337,7 +309,7 @@ pub(super) mod tests { use std::sync::Mutex; use wow_world_messages::vanilla::{ BuyResult, BuybackSlot, Guid, CMSG_BUYBACK_ITEM, CMSG_BUY_ITEM, CMSG_LIST_INVENTORY, - CMSG_PING, CMSG_REPAIR_ITEM, CMSG_SELL_ITEM, + CMSG_REPAIR_ITEM, CMSG_SELL_ITEM, }; /// How a Fake call fails: a constructor for the typed error, so every call builds a fresh one. @@ -472,11 +444,8 @@ pub(super) mod tests { const NPC: u64 = 0xF130_0000_0000_0200; const ITEM: u64 = 0x4000_0000_0000_0099; - fn player() -> VendorActionPlayer { - VendorActionPlayer { - account_id: 7, - self_guid: Some(42), - } + fn player() -> ProtocolSession { + ProtocolSession::in_world(7, 42) } fn list_inventory() -> ClientOpcodeMessage { @@ -503,12 +472,12 @@ pub(super) mod tests { ..Default::default() }; - let outcome = dispatch_vendor_action(&actions, player(), list_inventory()).unwrap(); + let outcome = Vendor::handle(&actions, &mut player(), list_inventory().into()).unwrap(); let expected = codec::build_list_inventory_raw(VENDOR, &actions.stock); assert!(matches!( outcome, - VendorActionOutcome::Handled { outbound } + ProtocolReply { outbound, .. } if matches!(outbound.as_slice(), [Outbound::Raw { opcode, body }] if (*opcode, body.clone()) == expected) )); @@ -527,11 +496,9 @@ pub(super) mod tests { ..Default::default() }; - let outcome = dispatch_vendor_action(&actions, player(), list_inventory()).unwrap(); + let outcome = Vendor::handle(&actions, &mut player(), list_inventory().into()).unwrap(); - assert!( - matches!(outcome, VendorActionOutcome::Handled { outbound } if outbound.is_empty()) - ); + assert!(outcome.outbound.is_empty()); assert!(actions.stock_requests.lock().unwrap().is_empty()); } @@ -539,12 +506,12 @@ pub(super) mod tests { fn an_empty_stock_still_opens_the_window() { let actions = InMemoryVendorActions::default(); - let outcome = dispatch_vendor_action(&actions, player(), list_inventory()).unwrap(); + let outcome = Vendor::handle(&actions, &mut player(), list_inventory().into()).unwrap(); let expected = codec::build_list_inventory_raw(VENDOR, &[]); assert!(matches!( outcome, - VendorActionOutcome::Handled { outbound } + ProtocolReply { outbound, .. } if matches!(outbound.as_slice(), [Outbound::Raw { opcode, body }] if (*opcode, body.clone()) == expected) )); @@ -560,11 +527,11 @@ pub(super) mod tests { ..Default::default() }; - let outcome = dispatch_vendor_action(&actions, player(), list_inventory()).unwrap(); + let outcome = Vendor::handle(&actions, &mut player(), list_inventory().into()).unwrap(); assert!(matches!( outcome, - VendorActionOutcome::Handled { outbound } if outbound.len() == 1 + ProtocolReply { outbound, .. } if outbound.len() == 1 )); } @@ -617,7 +584,7 @@ pub(super) mod tests { buyback_item(BuybackSlot::Slot1), ), ] { - let error = match dispatch_vendor_action(&actions, player(), msg) { + let error = match Vendor::handle(&actions, &mut player(), msg.into()) { Err(error) => error, Ok(_) => panic!("a transport loss must end the session"), }; @@ -660,11 +627,9 @@ pub(super) mod tests { fn a_successful_purchase_requests_the_durable_buy_and_sends_no_packets() { let actions = InMemoryVendorActions::default(); - let outcome = dispatch_vendor_action(&actions, player(), buy_item(2589, 3)).unwrap(); + let outcome = Vendor::handle(&actions, &mut player(), buy_item(2589, 3).into()).unwrap(); - assert!( - matches!(outcome, VendorActionOutcome::Handled { outbound } if outbound.is_empty()) - ); + assert!(outcome.outbound.is_empty()); assert_eq!( actions.buy_requests.lock().unwrap().as_slice(), &[BuyRequest { @@ -685,11 +650,11 @@ pub(super) mod tests { ..Default::default() }; - let outcome = dispatch_vendor_action(&actions, player(), buy_item(2589, 1)).unwrap(); + let outcome = Vendor::handle(&actions, &mut player(), buy_item(2589, 1).into()).unwrap(); assert!(matches!( outcome, - VendorActionOutcome::Handled { outbound } + ProtocolReply { outbound, .. } if matches!( outbound.as_slice(), [Outbound::One(ServerOpcodeMessage::SMSG_BUY_FAILED(_))] @@ -700,16 +665,13 @@ pub(super) mod tests { #[test] fn a_buyer_without_an_actor_is_told_the_item_cannot_be_found() { let actions = InMemoryVendorActions::default(); - let player = VendorActionPlayer { - account_id: 7, - self_guid: None, - }; + let mut player = ProtocolSession::new(7, "TESTER".into()); - let outcome = dispatch_vendor_action(&actions, player, buy_item(2589, 1)).unwrap(); + let outcome = Vendor::handle(&actions, &mut player, buy_item(2589, 1).into()).unwrap(); assert!(matches!( outcome, - VendorActionOutcome::Handled { outbound } + ProtocolReply { outbound, .. } if matches!(outbound.as_slice(), [Outbound::One(ServerOpcodeMessage::SMSG_BUY_FAILED(p))] if matches!(p.result, BuyResult::CantFindItem)) @@ -725,11 +687,11 @@ pub(super) mod tests { ..Default::default() }; - let outcome = dispatch_vendor_action(&actions, player(), repair_item(ITEM)).unwrap(); + let outcome = Vendor::handle(&actions, &mut player(), repair_item(ITEM).into()).unwrap(); assert!(matches!( outcome, - VendorActionOutcome::Handled { outbound } if outbound.is_empty() + ProtocolReply { outbound, .. } if outbound.is_empty() )); assert_eq!( actions.repair_requests.lock().unwrap().as_slice(), @@ -741,11 +703,11 @@ pub(super) mod tests { fn repairing_item_guid_zero_dispatches_the_whole_body_slot() { let actions = InMemoryVendorActions::default(); - let outcome = dispatch_vendor_action(&actions, player(), repair_item(0)).unwrap(); + let outcome = Vendor::handle(&actions, &mut player(), repair_item(0).into()).unwrap(); assert!(matches!( outcome, - VendorActionOutcome::Handled { outbound } if outbound.is_empty() + ProtocolReply { outbound, .. } if outbound.is_empty() )); assert_eq!( actions.repair_requests.lock().unwrap().as_slice(), @@ -757,11 +719,11 @@ pub(super) mod tests { fn repairing_an_unknown_item_guid_is_a_harmless_no_op() { let actions = InMemoryVendorActions::default(); - let outcome = dispatch_vendor_action(&actions, player(), repair_item(0x99)).unwrap(); + let outcome = Vendor::handle(&actions, &mut player(), repair_item(0x99).into()).unwrap(); assert!(matches!( outcome, - VendorActionOutcome::Handled { outbound } if outbound.is_empty() + ProtocolReply { outbound, .. } if outbound.is_empty() )); assert!(actions.repair_requests.lock().unwrap().is_empty()); } @@ -775,14 +737,14 @@ pub(super) mod tests { ..Default::default() }; - let outcome = dispatch_vendor_action(&actions, player(), repair_item(0)).unwrap(); + let outcome = Vendor::handle(&actions, &mut player(), repair_item(0).into()).unwrap(); let expected = codec::build_gm_system_message( "gw_repair_item reducer failed: not enough money to repair".to_string(), ); assert!(matches!( outcome, - VendorActionOutcome::Handled { outbound } + ProtocolReply { outbound, .. } if matches!(outbound.as_slice(), [Outbound::One(ServerOpcodeMessage::SMSG_MESSAGECHAT(m))] if **m == expected) @@ -798,27 +760,24 @@ pub(super) mod tests { ..Default::default() }; - let outcome = dispatch_vendor_action(&actions, player(), sell_item(ITEM)).unwrap(); + let outcome = Vendor::handle(&actions, &mut player(), sell_item(ITEM).into()).unwrap(); assert_eq!( actions.sell_requests.lock().unwrap().as_slice(), &[(42, VENDOR, 30)] ); - match outcome { - VendorActionOutcome::Handled { outbound } => assert_renders_ring(&outbound, &ring), - VendorActionOutcome::PassThrough(_) => panic!("a sale must be handled"), - } + assert_renders_ring(&outcome.outbound, &ring); } #[test] fn selling_an_unknown_item_guid_is_a_harmless_no_op() { let actions = InMemoryVendorActions::default(); - let outcome = dispatch_vendor_action(&actions, player(), sell_item(0x99)).unwrap(); + let outcome = Vendor::handle(&actions, &mut player(), sell_item(0x99).into()).unwrap(); assert!(matches!( outcome, - VendorActionOutcome::Handled { outbound } if outbound.is_empty() + ProtocolReply { outbound, .. } if outbound.is_empty() )); assert!(actions.sell_requests.lock().unwrap().is_empty()); } @@ -833,11 +792,11 @@ pub(super) mod tests { ..Default::default() }; - let outcome = dispatch_vendor_action(&actions, player(), sell_item(ITEM)).unwrap(); + let outcome = Vendor::handle(&actions, &mut player(), sell_item(ITEM).into()).unwrap(); assert!(matches!( outcome, - VendorActionOutcome::Handled { outbound } if outbound.is_empty() + ProtocolReply { outbound, .. } if outbound.is_empty() )); } @@ -847,16 +806,13 @@ pub(super) mod tests { item_slots: vec![(ITEM, 30)], ..Default::default() }; - let player = VendorActionPlayer { - account_id: 7, - self_guid: None, - }; + let mut player = ProtocolSession::new(7, "TESTER".into()); - let outcome = dispatch_vendor_action(&actions, player, sell_item(ITEM)).unwrap(); + let outcome = Vendor::handle(&actions, &mut player, sell_item(ITEM).into()).unwrap(); assert!(matches!( outcome, - VendorActionOutcome::Handled { outbound } if outbound.is_empty() + ProtocolReply { outbound, .. } if outbound.is_empty() )); assert!(actions.sell_requests.lock().unwrap().is_empty()); } @@ -867,16 +823,13 @@ pub(super) mod tests { refuses: true, ..Default::default() }; - let player = VendorActionPlayer { - account_id: 7, - self_guid: None, - }; + let mut player = ProtocolSession::new(7, "TESTER".into()); - let outcome = dispatch_vendor_action(&actions, player, list_inventory()).unwrap(); + let outcome = Vendor::handle(&actions, &mut player, list_inventory().into()).unwrap(); assert!(matches!( outcome, - VendorActionOutcome::Handled { outbound } if outbound.len() == 1 + ProtocolReply { outbound, .. } if outbound.len() == 1 )); assert!(actions.gate_requests.lock().unwrap().is_empty()); } @@ -1029,28 +982,21 @@ pub(super) mod tests { } #[test] - fn unrelated_opcodes_pass_through_to_the_next_dispatcher() { + fn buyback_wire_slots_map_to_zero_based_ring_slots_at_the_durable_call() { let actions = InMemoryVendorActions::default(); - let outcome = dispatch_vendor_action( + Vendor::handle( &actions, - player(), - ClientOpcodeMessage::CMSG_PING(CMSG_PING::default()), + &mut player(), + buyback_item(BuybackSlot::Slot1).into(), + ) + .unwrap(); + Vendor::handle( + &actions, + &mut player(), + buyback_item(BuybackSlot::Slot13).into(), ) .unwrap(); - - assert!(matches!( - outcome, - VendorActionOutcome::PassThrough(ClientOpcodeMessage::CMSG_PING(_)) - )); - } - - #[test] - fn buyback_wire_slots_map_to_zero_based_ring_slots_at_the_durable_call() { - let actions = InMemoryVendorActions::default(); - - dispatch_vendor_action(&actions, player(), buyback_item(BuybackSlot::Slot1)).unwrap(); - dispatch_vendor_action(&actions, player(), buyback_item(BuybackSlot::Slot13)).unwrap(); assert_eq!( actions.buyback_requests.lock().unwrap().as_slice(), @@ -1066,13 +1012,14 @@ pub(super) mod tests { ..Default::default() }; - let outcome = - dispatch_vendor_action(&actions, player(), buyback_item(BuybackSlot::Slot1)).unwrap(); + let outcome = Vendor::handle( + &actions, + &mut player(), + buyback_item(BuybackSlot::Slot1).into(), + ) + .unwrap(); - let outbound = match outcome { - VendorActionOutcome::Handled { outbound } => outbound, - VendorActionOutcome::PassThrough(_) => panic!("buyback must be handled"), - }; + let outbound = outcome.outbound; assert_renders_ring(&outbound, &ring[1..]); } @@ -1082,17 +1029,16 @@ pub(super) mod tests { ring: Mutex::new(vec![(2589, 5, 120, 0)]), ..Default::default() }; - let player = VendorActionPlayer { - account_id: 7, - self_guid: None, - }; + let mut player = ProtocolSession::new(7, "TESTER".into()); - let outcome = - dispatch_vendor_action(&actions, player, buyback_item(BuybackSlot::Slot1)).unwrap(); + let outcome = Vendor::handle( + &actions, + &mut player, + buyback_item(BuybackSlot::Slot1).into(), + ) + .unwrap(); - assert!( - matches!(outcome, VendorActionOutcome::Handled { outbound } if outbound.is_empty()) - ); + assert!(outcome.outbound.is_empty()); assert!(actions.buyback_requests.lock().unwrap().is_empty()); } @@ -1105,11 +1051,13 @@ pub(super) mod tests { ..Default::default() }; - let outcome = - dispatch_vendor_action(&actions, player(), buyback_item(BuybackSlot::Slot1)).unwrap(); + let outcome = Vendor::handle( + &actions, + &mut player(), + buyback_item(BuybackSlot::Slot1).into(), + ) + .unwrap(); - assert!( - matches!(outcome, VendorActionOutcome::Handled { outbound } if outbound.is_empty()) - ); + assert!(outcome.outbound.is_empty()); } } diff --git a/gateway/src/world/item_tests.rs b/gateway/src/world/item_tests.rs index 7acc4e61c..4684a44ae 100644 --- a/gateway/src/world/item_tests.rs +++ b/gateway/src/world/item_tests.rs @@ -1,23 +1,19 @@ //! Item actions through their dispatcher. -use super::handlers::InMemoryItemActions; +use super::handlers::{InMemoryItemActions, Item}; use super::*; -const PLAYER: ItemActionPlayer = ItemActionPlayer { - account_id: 7, - self_guid: Some(1), -}; +fn player() -> ProtocolSession { + ProtocolSession::in_world(7, 1) +} /// Dispatch one item message and return the packets the session would send for it. fn try_run( actions: &InMemoryItemActions, - player: ItemActionPlayer, + mut player: ProtocolSession, msg: impl Into, ) -> Result> { - match dispatch_item_action(actions, player, msg.into())? { - ItemActionOutcome::Handled { outbound } => Ok(outbound), - ItemActionOutcome::PassThrough(_) => panic!("the item dispatcher passed the message on"), - } + Ok(Item::handle(actions, &mut player, ProtocolRequest::Message(msg.into()))?.outbound) } /// Slot 24 is a backpack slot (>= 23); bag 255 = INVENTORY_SLOT_BAG_0 (main bag). @@ -37,7 +33,7 @@ fn equip_item_err_sends_smsg_inventory_change_failure() { ..Default::default() }; for slot in [24, 25] { - let sent = try_run(&actions, PLAYER, autoequip(slot)).unwrap(); + let sent = try_run(&actions, player(), autoequip(slot)).unwrap(); assert!( matches!( sent.as_slice(), @@ -55,10 +51,7 @@ fn item_action_before_player_login_answers_a_refusal_without_a_durable_request() // An item frame arriving after the handshake but before CMSG_PLAYER_LOGIN (no selected // player) must not panic or error the session. It makes no request and answers a Refusal. let actions = InMemoryItemActions::default(); - let player = ItemActionPlayer { - self_guid: None, - ..PLAYER - }; + let player = ProtocolSession::new(7, "TESTER".into()); let sent = try_run(&actions, player, autoequip(24)) .expect("a session without a Character stays alive"); assert!(matches!( @@ -78,7 +71,7 @@ fn item_reducer_transport_loss_ends_the_world_session() { transport_lost: true, ..Default::default() }; - try_run(&actions, PLAYER, autoequip(24)) + try_run(&actions, player(), autoequip(24)) .err() .expect("a lost item reducer transport must be session-fatal"); } diff --git a/gateway/src/world/mail_tests.rs b/gateway/src/world/mail_tests.rs index 67a0ee8f3..b3807662c 100644 --- a/gateway/src/world/mail_tests.rs +++ b/gateway/src/world/mail_tests.rs @@ -1,5 +1,6 @@ //! Mail routing tests through the shared WorldFake topology. +use super::handlers::Mail; use super::party_tests::{character, DORMANT, GINGER, TRIN, VIM}; use super::*; const MAILBOX: u64 = 0xF110_0000_0000_0042; @@ -4207,12 +4208,11 @@ fn a_send_refusal_is_answered_and_a_transport_loss_ends_the_session() { })) }; let (realm, world, _instances, _calls) = sharded_send(); - let (tx, rx) = SessionTx::with_depth(0); - let mut conn = in_world_conn(7, GINGER); + let mut session = ProtocolSession::in_world(7, GINGER); - handle_mail(&tx, world.as_ref(), &mut conn, send("Nobody")) - .expect("a Refusal keeps the session"); - match drain_outbound(&rx).as_slice() { + let reply = Mail::handle(world.as_ref(), &mut session, send("Nobody").into()) + .expect("a Refusal keeps the World Session"); + match outbound_messages(reply.outbound).as_slice() { [ServerOpcodeMessage::SMSG_SEND_MAIL_RESULT(m)] => assert_eq!( m.action, wow_world_messages::vanilla::SMSG_SEND_MAIL_RESULT_MailAction::Send { @@ -4223,9 +4223,5 @@ fn a_send_refusal_is_answered_and_a_transport_loss_ends_the_session() { } *realm.mail.mail_kill_at.lock().unwrap() = Some("mail_commit".into()); - assert!(handle_mail(&tx, world.as_ref(), &mut conn, send("Trin")).is_err()); - assert!( - drain_outbound(&rx).is_empty(), - "a Transport Loss answers nothing" - ); + assert!(Mail::handle(world.as_ref(), &mut session, send("Trin").into()).is_err()); } diff --git a/gateway/src/world/melee_tests.rs b/gateway/src/world/melee_tests.rs index 7b9d8d0f1..e647bdad8 100644 --- a/gateway/src/world/melee_tests.rs +++ b/gateway/src/world/melee_tests.rs @@ -1,68 +1,20 @@ //! Melee attack opcodes through their dispatcher. +use super::family::{ProtocolFamily, ProtocolSession}; use super::handlers::InMemoryMeleeActions; use super::*; -/// What the session keeps between two melee messages: its combat state. -struct Session { - state: WorldState, -} - -impl Session { - fn in_world() -> Self { - Self { - state: WorldState::InWorld(InWorld { - self_guid: 1, - subs: PlayerSubscriptions::empty(), - attacking_target: None, - open_loot: OpenLootState::default(), - ranged_repeat: false, - }), - } - } - - fn at_character_select() -> Self { - Self { - state: WorldState::CharSelect, - } - } - - fn player(&self) -> MeleeActionPlayer { - let (self_guid, attacking_target, ranged_repeat) = match &self.state { - WorldState::InWorld(iw) => (Some(iw.self_guid), iw.attacking_target, iw.ranged_repeat), - WorldState::CharSelect => (None, None, false), - }; - MeleeActionPlayer { - account_id: 7, - self_guid, - attacking_target, - ranged_repeat, - } - } -} - -/// Dispatch one melee message, apply the session transition it asks for, and return the packets -/// the session would send for it. fn try_run( actions: &InMemoryMeleeActions, - session: &mut Session, + session: &mut ProtocolSession, msg: impl Into, ) -> Result> { - match dispatch_melee_action(actions, session.player(), msg.into())? { - MeleeActionOutcome::Handled { - transition, - outbound, - } => { - transition.apply(&mut session.state); - Ok(outbound) - } - MeleeActionOutcome::PassThrough(_) => panic!("the melee dispatcher passed the message on"), - } + Ok(handlers::Melee::handle(actions, session, msg.into().into())?.outbound) } fn run( actions: &InMemoryMeleeActions, - session: &mut Session, + session: &mut ProtocolSession, msg: impl Into, ) -> Vec { try_run(actions, session, msg).unwrap() @@ -77,7 +29,7 @@ fn swing(target: u64) -> CMSG_ATTACKSWING { #[test] fn attackswing_ok_replies_attackstart_and_stop_echoes_then_clears() { let actions = InMemoryMeleeActions::default(); - let mut session = Session::in_world(); + let mut session = ProtocolSession::in_world(7, 1); match run(&actions, &mut session, swing(90)).as_slice() { [Outbound::One(ServerOpcodeMessage::SMSG_ATTACKSTART(a))] => { @@ -103,7 +55,7 @@ fn melee_opcodes_at_character_select_answer_nothing_and_do_not_fail() { // No WorldEntity yet, so the seam has no attacker guid to name and no combat state to change. // It makes no durable request. let actions = InMemoryMeleeActions::default(); - let mut session = Session::at_character_select(); + let mut session = ProtocolSession::new(7, "TESTER".into()); assert!(run(&actions, &mut session, swing(90)).is_empty()); assert!(run(&actions, &mut session, CMSG_ATTACKSTOP {}).is_empty()); @@ -120,7 +72,7 @@ fn attackswing_desync_error_is_session_fatal() { start_error: Some("no live entity for guid 1".into()), ..Default::default() }; - let mut session = Session::in_world(); + let mut session = ProtocolSession::in_world(7, 1); let err = try_run(&actions, &mut session, swing(90)) .err() diff --git a/gateway/src/world/member_stats_tests.rs b/gateway/src/world/member_stats_tests.rs index e564c0c91..de0799b97 100644 --- a/gateway/src/world/member_stats_tests.rs +++ b/gateway/src/world/member_stats_tests.rs @@ -9,10 +9,7 @@ use super::party_tests::{ character, form_split_party, party_members, party_topology, BOT, GINGER, TRIN, VIM, }; use super::*; -use crate::world::handlers::{ - dispatch_member_stats, member_stats_tick, MemberSnapshot, MemberStatsOutcome, - MemberStatsPlayer, MemberStatsRecord, -}; +use crate::world::handlers::{member_stats_tick, MemberSnapshot, MemberStats}; use std::collections::HashMap; use std::sync::atomic::Ordering; @@ -414,32 +411,31 @@ fn a_mate_who_leaves_the_group_is_forgotten() { assert!(!snapshots.contains_key(&TRIN)); } -fn request_with( - store: &WorldFake, - self_guid: Option, - record: Option<&MemberStatsRecord>, - guid: u64, -) -> MemberStatsOutcome { - dispatch_member_stats( +fn request_with(store: &WorldFake, session: &mut ProtocolSession, guid: u64) -> ProtocolReply { + MemberStats::handle( store, - MemberStatsPlayer { self_guid, record }, + session, ClientOpcodeMessage::CMSG_REQUEST_PARTY_MEMBER_STATS( wow_world_messages::vanilla::CMSG_REQUEST_PARTY_MEMBER_STATS { guid: Guid::new(guid), }, - ), + ) + .into(), ) + .unwrap() } -fn request(store: &WorldFake, self_guid: Option, guid: u64) -> MemberStatsOutcome { - request_with(store, self_guid, None, guid) +fn request(store: &WorldFake, self_guid: Option, guid: u64) -> ProtocolReply { + let mut session = match self_guid { + Some(guid) => ProtocolSession::in_world(7, guid), + None => ProtocolSession::new(7, "TESTER".into()), + }; + request_with(store, &mut session, guid) } /// Run the answer the way the session writer does: every job, in order. -fn answer(outcome: MemberStatsOutcome) -> Vec<(u16, Vec)> { - let MemberStatsOutcome::Handled { outbound } = outcome else { - panic!("an in-world stats request is handled"); - }; +fn answer(outcome: ProtocolReply) -> Vec<(u16, Vec)> { + let outbound = outcome.outbound; outbound .into_iter() .flat_map(|packet| match packet { @@ -516,12 +512,20 @@ fn after_a_full_answer_the_next_tick_sends_every_field() { let (realm, world, instances, _) = party_topology(); form_split_party(&world, &instances); place(&instances, VIM, caster()); - let record = MemberStatsRecord::default(); + let view = Arc::new(crate::stdb::world_view::WorldView::new(true)); + let (tx, _queued, _depth) = session_channel(); + let mut session = ProtocolSession::in_world(7, GINGER); + let WorldState::InWorld(in_world) = &mut session.state else { + unreachable!() + }; + in_world.subs = + PlayerSubscriptions::registered_for_test(view, GINGER, &codec::EntityView::default(), tx); + let record = in_world.subs.member_stats_record().unwrap().clone(); tick(&world, &[GINGER], &mut record.lock()); despawn(&instances, VIM); realm.party.members_in_transit.lock().unwrap().push(VIM); - answer(request_with(&world, Some(GINGER), Some(&record), VIM)); + answer(request_with(&world, &mut session, VIM)); realm.party.members_in_transit.lock().unwrap().clear(); place(&world, VIM, caster()); @@ -583,12 +587,9 @@ fn a_bot_crossing_between_shards_is_never_reported_offline() { } #[test] -fn a_stats_request_outside_the_world_passes_through() { +fn a_stats_request_outside_the_world_sends_nothing() { let (_realm, world, _instances) = ginger_and_trin(); - assert!(matches!( - request(&world, None, TRIN), - MemberStatsOutcome::PassThrough(ClientOpcodeMessage::CMSG_REQUEST_PARTY_MEMBER_STATS(_)) - )); + assert!(request(&world, None, TRIN).outbound.is_empty()); } /// The raw FULL body survives the session writer and the header cipher, on a single database diff --git a/gateway/src/world/mod.rs b/gateway/src/world/mod.rs index fa3cb7f2c..44bed5882 100644 --- a/gateway/src/world/mod.rs +++ b/gateway/src/world/mod.rs @@ -38,6 +38,11 @@ use wow_world_messages::vanilla::{ mod actor; mod chat_flood; mod coalesce; +mod entry; +mod family; +mod routing; +pub(crate) use family::{ProtocolFamily, ProtocolSession}; +use family::{ProtocolReply, ProtocolRequest, WorldSessionAction}; pub(crate) mod guild_fee; mod handlers; pub mod login_queue; @@ -54,24 +59,12 @@ pub mod whisper; mod who; pub(crate) use actor::Actor; use coalesce::CoalesceState; +#[cfg(test)] +pub(crate) use handlers::MeetingStone; pub(crate) use handlers::{ - character_facts, dispatch_meeting_stone_action, reconcile_deleted_guild_characters, - DurableCharacterFacts, GuildCleanup, MeetingStoneActionOutcome, MeetingStonePlayer, -}; -use handlers::{ - decode_auction_browse, dispatch_auction_action, dispatch_auction_browse_action, dispatch_cast, - dispatch_channel_action, dispatch_chat_action, dispatch_duel_action, dispatch_guild_action, - dispatch_item_action, dispatch_loot_window, dispatch_melee_action, dispatch_member_stats, - dispatch_quest_action, dispatch_taxi_action, dispatch_vendor_action, handle_bank, handle_char, - handle_combat, handle_loot, handle_mail, handle_query, handle_trade, handle_trainer, - quest_giver_menu, queue_reply_then_arm, AuctionActionOutcome, AuctionActionPlayer, CastOutcome, - CastPlayer, CastTransition, ChannelActionOutcome, ChatActionOutcome, ChatActionPlayer, - DuelActionOutcome, DuelActionPlayer, GuildActionOutcome, GuildActionPlayer, ItemActionOutcome, - ItemActionPlayer, LootWindowOutcome, LootWindowPlayer, MeleeActionOutcome, MeleeActionPlayer, - MemberStatsOutcome, MemberStatsPlayer, OpenLootState, QuestActionOutcome, QuestActionPlayer, - TaxiActionOutcome, TaxiActionPlayer, VendorActionOutcome, VendorActionPlayer, - CMSG_AUCTION_LIST_ITEMS_OPCODE, + character_facts, reconcile_deleted_guild_characters, DurableCharacterFacts, GuildCleanup, }; +use handlers::{decode_auction_browse, OpenLootState, CMSG_AUCTION_LIST_ITEMS_OPCODE}; pub(crate) use handlers::{ member_stats_tick, resolve_online_character, zone_weather_message, AuctionBrowseRequest, AuctionHousePolicy, AuctionInteraction, AuctionPage, AuctionQuery, CancelAuctionOutcome, @@ -92,7 +85,7 @@ use login_queue::{Admission, LoginQueue}; pub(crate) use loot::LootRollStore; pub(crate) use mail::MailStore; pub(crate) use party::PartyStore; -use social::handle_social; + pub(crate) use social::ContactOutcome; pub(crate) use social::SocialStore; pub use store::{InteractionOutcome, WorldSessionToken, WorldStore}; @@ -361,25 +354,17 @@ pub struct InWorld { /// sole writer of the socket (the header cipher is a stateful stream, so exactly one writer may /// advance it). NOT game state: the cipher is re-derivable from K on reconnect. pub struct WorldConn { + pub(crate) protocol: ProtocolSession, /// Retained before world entry so failed routing or admission is also cleaned up. session_claim: Option, - pub account_id: u64, - /// Proof-validated, realm-wide Account name used for cross-database authority reads. - pub account_name: String, /// Decrypts inbound CMSG headers (reader-thread only). pub decrypt: DecrypterHalf, - /// Character-select vs in-world; the relay subs + combat/loot/session state live in `InWorld`. - pub state: WorldState, /// Per-connection movement coalescing. Lives directly on `WorldConn` (not /// `InWorld`) so it survives a world-port/relogin boundary uneventfully — it only ever sees /// traffic while `InWorld` movement is being dispatched, but there's no correctness reason to /// tear it down on a state transition, and keeping it here avoids re-plumbing it through /// `enter_world`. move_coalesce: CoalesceState, - /// The gossip menu last sent to this client. A select carries only a position into a - /// condition-filtered list, so re-deriving that list at click time renumbers it under a quest - /// accepted while the window was open. - pub(crate) gossip_menu: Option, /// Automatic client requests announce each missing feature once per World Session. unavailable_notices: std::collections::HashSet, /// The Store every message of this session runs against. @@ -400,18 +385,24 @@ pub struct WorldConn { /// Reset by the first movement whose entity is present. See /// [`MOVE_DESYNC_TOLERANCE`] for why the tolerance is bounded rather than unconditional. move_desync_drops: u32, - /// When the last `CMSG_WHO` this session's writer started answering was admitted. A second - /// request inside [`WHO_THROTTLE`] is dropped, the same shape vm:MiscHandler.cpp:230 drops a - /// concurrent request in — `/who`'s realm-wide scan is the one social read costly enough to - /// throttle per session. - who_throttled_until: Option, - /// When each throttled Group Broadcast kind may run again for this session. - group_broadcast_cooldowns: party::GroupBroadcastCooldowns, /// The Chat Flood Limiter. It lives on the connection, as cmangos keeps the mute on its /// session, so a relog does not clear it. chat_flood: chat_flood::ChatFloodLimiter, } +impl std::ops::Deref for WorldConn { + type Target = ProtocolSession; + fn deref(&self) -> &Self::Target { + &self.protocol + } +} + +impl std::ops::DerefMut for WorldConn { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.protocol + } +} + /// How many CONSECUTIVE desynced movement packets a session may drop before the desync is treated /// as real and the session ends. /// @@ -521,23 +512,6 @@ impl WorldConn { self.store.unpin(); outcome } - - /// Admit one `CMSG_WHO`, or refuse it because the last one this session sent was inside - /// [`WHO_THROTTLE`]. Advances the cooldown on every admitted request, including a malformed - /// one that answers nothing — the request itself is what cost the scan. - pub(super) fn admit_who(&mut self) -> bool { - let now = Instant::now(); - if self.who_throttled_until.is_some_and(|until| now < until) { - return false; - } - self.who_throttled_until = Some(now + WHO_THROTTLE); - true - } - - /// [`party::GroupBroadcastCooldowns::admit_at`] now. - pub(super) fn admit_group_broadcast(&mut self, op: party::Op) -> bool { - self.group_broadcast_cooldowns.admit_at(op, Instant::now()) - } } /// How often a queued connection re-checks whether it has been admitted. Cheap — one mutex @@ -693,19 +667,14 @@ fn world_handshake_with_queue_and_deadline( let request = decode_auction_browse(&body).map_err(|e| anyhow!("world read error: {e}"))?; let store = conn.store.current(); - dispatch_raw_auction_browse(&tx, &*store, &mut conn, request)?; + dispatch( + &tx, + &*store, + &mut conn, + hdr.opcode, + ProtocolRequest::AuctionBrowse(request), + )?; continue; } if hdr.opcode == CMSG_AUTH_SESSION_OPCODE { @@ -1143,12 +1118,24 @@ fn run_world_session_with_queue_and_deadline( } if hdr.opcode == 0x0125 { let store = conn.store.current(); - handlers::handle_at_war(&tx, &*store, &mut conn, &body)?; + dispatch( + &tx, + &*store, + &mut conn, + hdr.opcode, + ProtocolRequest::FactionAtWar(body), + )?; continue; } if hdr.opcode == 0x0318 { let store = conn.store.current(); - handlers::handle_watched_faction(&tx, &*store, &mut conn, &body)?; + dispatch( + &tx, + &*store, + &mut conn, + hdr.opcode, + ProtocolRequest::WatchedFaction(body), + )?; continue; } if let Some(replies) = handlers::raw_unavailable_outbound(hdr.opcode, &body)? { @@ -1174,7 +1161,7 @@ fn run_world_session_with_queue_and_deadline( // CMSG_PLAYER_LOGIN resolves one (and always, with a single-entry shard map) this is // the handle the listener passed in. let store = conn.store.current(); - dispatch(&tx, &*store, &mut conn, msg)?; + dispatch(&tx, &*store, &mut conn, hdr.opcode, msg.into())?; } })(); @@ -1240,7 +1227,7 @@ fn handle_addon_message( log::debug!("addon bridge: non-STC or malformed frame dropped: {text:?}"); return Ok(()); }; - let Some(actor) = social::self_actor(conn) else { + let Some(actor) = conn.actor() else { log::info!( "addon bridge: command {cmd:?} from account {} dropped outside the world", conn.account_id @@ -1281,358 +1268,39 @@ fn is_desync_error(e: &anyhow::Error) -> bool { s.contains("not in world") || s.contains("no live entity") } -/// Write one cast-lifecycle transition into the session. A transition only exists while the player -/// is in the world, so a character-select session ignores it. -fn apply_cast_transition(conn: &mut WorldConn, transition: CastTransition) { - if let (Some(armed), WorldState::InWorld(iw)) = (transition.ranged_repeat, &mut conn.state) { - iw.ranged_repeat = armed; - } -} - -/// Route one decrypted client message through the per-family handlers. Each stage either consumes -/// its opcode or passes it onward, so the disjoint-family chain ends in the movement-relay catch-all. -#[allow(clippy::too_many_lines)] // One stage per opcode family. +/// Flush movement and apply the Chat Flood Limiter before selecting one opcode owner. fn dispatch( tx: &SessionTx, store: &dyn WorldStore, conn: &mut WorldConn, - msg: ClientOpcodeMessage, + opcode: u32, + request: ProtocolRequest, ) -> Result<()> { - // Coalescing rule 2 (the robust flush): a pending coalesced heartbeat may leave the - // MODULE's stored position a window behind the wire. Any non-movement opcode (loot/vendor/ - // gossip/etc. range checks, in particular) must see the CURRENT position, so flush it FIRST — - // one check here removes the whole "stale position mid-coalesce" class instead of auditing - // every position-sensitive reducer individually. A movement opcode flushes (if needed) as part - // of its own classification below, not here. - if codec::relayed_move_opcode(&msg).is_none() { + let movement = match &request { + ProtocolRequest::Message(message) => codec::relayed_move_opcode(message).is_some(), + _ => false, + }; + if !movement { if let Some((opcode, info)) = conn.move_coalesce.flush_now() { forward_movement(store, conn, opcode, &info)?; } } - - // The Chat Flood Limiter runs ahead of every chat, channel and emote handler, so a muted - // line costs no Durable Request. - let speaker = social::self_guid(conn); - let flood_answer = conn.chat_flood.judge( - &msg, - Instant::now(), - || speaker.is_some_and(|guid| is_game_master(store, guid)), - || speaker.and_then(|guid| speaker_race(store, guid)), - ); - if let Some(answer) = flood_answer { - return send(tx, Outbound::One(answer)); - } - - let Some(msg) = handle_char(tx, store, conn, msg)? else { - return Ok(()); - }; - let msg = match dispatch_melee_action(store, MeleeActionPlayer::from_conn(conn), msg)? { - MeleeActionOutcome::Handled { - transition, - outbound, - } => { - transition.apply(&mut conn.state); - for message in outbound { - send(tx, message)?; - } - return Ok(()); - } - MeleeActionOutcome::PassThrough(msg) => msg, - }; - // The cast seam owns every cast opcode. Melee runs first only because its two opcodes are - // disjoint from the cast set; neither seam sees the other's traffic. - let msg = match dispatch_cast( - store, - CastPlayer { - account_id: conn.account_id, - self_guid: social::self_guid(conn), - ranged_repeat: matches!(&conn.state, WorldState::InWorld(iw) if iw.ranged_repeat), - }, - msg, - )? { - CastOutcome::Handled { - transition, - outbound, - } => { - // The transition lands first: the batch is what the client sees, and session state - // must already agree with it when the next request arrives. - apply_cast_transition(conn, transition); - for message in outbound { - send(tx, message)?; - } - return Ok(()); - } - CastOutcome::PassThrough(msg) => msg, - }; - let Some(msg) = handle_combat(store, conn, msg)? else { - return Ok(()); - }; - let dispatches_to_loot_window = if let ClientOpcodeMessage::CMSG_GAMEOBJ_USE(request) = &msg { - let target_guid = request.guid.guid(); - match store.gameobject_type(target_guid)? { - Some(lyracore_shared::constants::go_type::QUESTGIVER) => { - for message in - quest_giver_menu(store, target_guid, social::self_guid(conn).unwrap_or(0))? - { - send(tx, message)?; - } - return Ok(()); - } - Some(lyracore_shared::constants::go_type::CHEST) => true, - _ => false, - } - } else { - true - }; - let current_loot_state = match &conn.state { - WorldState::InWorld(iw) => iw.open_loot, - WorldState::CharSelect => OpenLootState::default(), - }; - let msg = if dispatches_to_loot_window { - match dispatch_loot_window( - store, - LootWindowPlayer { - account_id: conn.account_id, - self_guid: social::self_guid(conn), - }, - current_loot_state, - msg, - )? { - LootWindowOutcome::Handled { - next_state, - durable_request: _observed_durable_request, - outbound, - } => { - if let WorldState::InWorld(iw) = &mut conn.state { - iw.open_loot = next_state; - } - for message in outbound { - send(tx, message)?; - } - return Ok(()); - } - LootWindowOutcome::PassThrough(msg) => msg, - } - } else { - msg - }; - let Some(msg) = handle_loot(tx, store, conn, msg)? else { - return Ok(()); - }; - let msg = match dispatch_auction_action( - store, - AuctionActionPlayer { - self_guid: social::self_guid(conn), - }, - msg, - )? { - AuctionActionOutcome::Handled { outbound } => { - for message in outbound { - send(tx, message)?; - } - return Ok(()); + if let ProtocolRequest::Message(message) = &request { + let speaker = conn.self_guid(); + if let Some(answer) = conn.chat_flood.judge( + message, + Instant::now(), + || speaker.is_some_and(|guid| is_game_master(store, guid)), + || speaker.and_then(|guid| speaker_race(store, guid)), + ) { + return send(tx, Outbound::One(answer)); } - AuctionActionOutcome::PassThrough(msg) => msg, - }; - let msg = match dispatch_vendor_action( - store, - VendorActionPlayer { - account_id: conn.account_id, - self_guid: social::self_guid(conn), - }, - msg, - )? { - VendorActionOutcome::Handled { outbound } => { - for message in outbound { - send(tx, message)?; - } - return Ok(()); - } - VendorActionOutcome::PassThrough(msg) => msg, - }; - let Some(msg) = handle_bank(tx, store, conn, msg)? else { - return Ok(()); - }; - let Some(msg) = handle_trainer(tx, store, conn, msg)? else { - return Ok(()); - }; - let msg = match dispatch_item_action( - store, - ItemActionPlayer { - account_id: conn.account_id, - self_guid: social::self_guid(conn), - }, - msg, - )? { - ItemActionOutcome::Handled { outbound } => { - for message in outbound { - send(tx, message)?; - } - return Ok(()); - } - ItemActionOutcome::PassThrough(msg) => msg, - }; - let msg = match dispatch_quest_action( - store, - QuestActionPlayer { - account_id: conn.account_id, - self_guid: social::self_guid(conn), - }, - msg, - )? { - QuestActionOutcome::Handled { outbound } => { - for message in outbound { - send(tx, message)?; - } - return Ok(()); - } - QuestActionOutcome::TurnedIn { outbound } => { - for message in outbound { - send(tx, message)?; - } - // The turn-in used the visibility pipe, so a Reward Letter it filed is in the cache - // and the escrow index now. A failed drive leaves the letter held for world entry or - // the next mailbox visit. - if let Some(self_guid) = social::self_guid(conn) { - mail::redrive(store, self_guid); - } - return Ok(()); - } - QuestActionOutcome::PassThrough(msg) => msg, - }; - let msg = match dispatch_taxi_action( - store, - TaxiActionPlayer { - self_guid: social::self_guid(conn), - }, - msg, - )? { - TaxiActionOutcome::Handled { outbound } => { - for message in outbound { - send(tx, message)?; - } - return Ok(()); - } - TaxiActionOutcome::Activated { - outbound, - character_guid, - arm, - } => { - // Queue the vanilla result first. Arming mutates the entity and spline tables, whose - // callbacks enqueue behind this item on the same writer even if they fire immediately. - queue_reply_then_arm(tx, store, outbound, character_guid, arm)?; - return Ok(()); - } - TaxiActionOutcome::PassThrough(msg) => msg, - }; - let member_stats_player = match &conn.state { - WorldState::InWorld(iw) => MemberStatsPlayer { - self_guid: Some(iw.self_guid), - record: iw.subs.member_stats_record(), - }, - WorldState::CharSelect => MemberStatsPlayer::default(), - }; - let msg = match dispatch_member_stats(store, member_stats_player, msg) { - MemberStatsOutcome::Handled { outbound } => { - for message in outbound { - send(tx, message)?; - } - return Ok(()); - } - MemberStatsOutcome::PassThrough(msg) => msg, - }; - let Some(msg) = handle_social(tx, store, conn, msg)? else { - return Ok(()); - }; - let Some(msg) = handle_trade(tx, store, conn, msg)? else { - return Ok(()); - }; - let msg = match dispatch_duel_action( - store, - DuelActionPlayer { - account_id: conn.account_id, - self_guid: social::self_guid(conn), - }, - msg, - )? { - DuelActionOutcome::Handled { outbound } => { - for message in outbound { - send(tx, message)?; - } - return Ok(()); - } - DuelActionOutcome::PassThrough(msg) => msg, - }; - let msg = match dispatch_chat_action( - store, - ChatActionPlayer { - account_id: conn.account_id, - self_guid: social::self_guid(conn), - }, - msg, - )? { - ChatActionOutcome::Handled { outbound } => { - for message in outbound { - send(tx, message)?; - } - return Ok(()); - } - ChatActionOutcome::PassThrough(msg) => msg, - }; - let msg = match dispatch_channel_action( - store, - ChatActionPlayer { - account_id: conn.account_id, - self_guid: social::self_guid(conn), - }, - msg, - )? { - ChannelActionOutcome::Handled { outbound } => { - for message in outbound { - send(tx, message)?; - } - return Ok(()); - } - ChannelActionOutcome::PassThrough(msg) => msg, - }; - let msg = match dispatch_meeting_stone_action( - store, - MeetingStonePlayer { - account_id: conn.account_id, - self_guid: social::self_guid(conn), - }, - msg, - )? { - MeetingStoneActionOutcome::Handled { outbound } => { - for message in outbound { - send(tx, message)?; - } - return Ok(()); - } - MeetingStoneActionOutcome::PassThrough(msg) => msg, - }; - let msg = match dispatch_guild_action( - store, - GuildActionPlayer { - account_id: conn.account_id, - self_guid: social::self_guid(conn), - }, - msg, - )? { - GuildActionOutcome::Handled { outbound } => { - for message in outbound { - send(tx, message)?; - } - return Ok(()); - } - GuildActionOutcome::PassThrough(msg) => msg, - }; - let Some(msg) = handle_query(tx, store, conn, msg)? else { - return Ok(()); - }; - let Some(msg) = handle_mail(tx, store, conn, msg)? else { - return Ok(()); - }; + } + if let Some(owner) = routing::owner(opcode) { + let reply = owner.handle(store, &mut conn.protocol, request)?; + return complete_reply(tx, store, conn, reply); + } + let msg = request.message()?; // MSG_MOVE_* -> movement_update (persist + relay). The relayed peer events // come back on this player's game_movement_event subscription and are re-emitted (same // opcode + verbatim MovementInfo) to other players by their own subscription callbacks. @@ -1689,7 +1357,7 @@ fn dispatch( return Ok(()); } if let ClientOpcodeMessage::CMSG_SET_ACTIVE_MOVER(request) = &msg { - if social::self_guid(conn) == Some(request.guid.guid()) { + if conn.self_guid() == Some(request.guid.guid()) { return Ok(()); } return send( @@ -1713,32 +1381,22 @@ fn dispatch( Ok(()) } -fn dispatch_raw_auction_browse( +fn complete_reply( tx: &SessionTx, - store: &St, + store: &dyn WorldStore, conn: &mut WorldConn, - request: AuctionBrowseRequest, + reply: ProtocolReply, ) -> Result<()> { - if let Some((opcode, info)) = conn.move_coalesce.flush_now() { - forward_movement(store, conn, opcode, &info)?; - } - match dispatch_auction_browse_action( - store, - AuctionActionPlayer { - self_guid: social::self_guid(conn), - }, - request, - )? { - AuctionActionOutcome::Handled { outbound } => { - for message in outbound { - send(tx, message)?; - } + reply.complete(tx, |action| match action { + WorldSessionAction::Login(actor) => entry::login(tx, store, conn, actor), + WorldSessionAction::WorldPortAck => entry::world_port_ack(tx, store, conn), + WorldSessionAction::Logout => conn.leave_world(), + WorldSessionAction::ArmTaxi(actor) => store.arm_taxi_flight(actor), + WorldSessionAction::RedriveMail(actor) => { + mail::redrive(store, actor.guid()); Ok(()) } - AuctionActionOutcome::PassThrough(_) => Err(anyhow!( - "raw auction browse was not handled by auction dispatcher" - )), - } + }) } fn forward_movement( diff --git a/gateway/src/world/quest_tests.rs b/gateway/src/world/quest_tests.rs index 3c063d242..fb7ee0210 100644 --- a/gateway/src/world/quest_tests.rs +++ b/gateway/src/world/quest_tests.rs @@ -1,5 +1,5 @@ //! Quest traffic over the world session. Which screen a quest opens, and which durable request it -//! makes, is decided and proved at the `dispatch_quest_action` seam (`handlers/quest.rs`). What is +//! makes, is decided and proved at the `Quest::handle` seam (`handlers/quest.rs`). What is //! left here is only what the seam cannot see: that a quest opcode reaches the seam through the //! full handshake + login + cipher, and that the bodies it answers with survive the encrypted //! frame. @@ -335,7 +335,7 @@ fn assert_create_quest_progress(frames: &[ServerOpcodeMessage]) { fn quest_hello_reaches_the_quest_module_and_its_raw_details_body_survives_the_cipher() { // The socket-level contract: dispatch routes HELLO to the quest module, and the raw-encoded // DETAILS screen it returns crosses the encrypted frame intact. Which screen a giver opens is - // decided at the `dispatch_quest_action` seam and proved there. + // decided at the `Quest::handle` seam and proved there. const OP_QUEST_DETAILS: u16 = 0x0188; let mut s = quest_store(); s.quest.quest_evals = vec![eval(1234, codec::ROLE_START, false, false)]; diff --git a/gateway/src/world/routing.rs b/gateway/src/world/routing.rs new file mode 100644 index 000000000..6f20ce07d --- /dev/null +++ b/gateway/src/world/routing.rs @@ -0,0 +1,320 @@ +//! One opcode has one owning Protocol Family, independent of declaration order. +use super::handlers::*; +use super::social::Social; +use super::*; + +macro_rules! protocol_families { + ($($family:ident => [$($opcode:ident),* $(,)?]),* $(,)?) => { + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub(super) enum Owner { $($family),* } + const OWNERS: &[(u32, Owner)] = &[ + $($( (::OPCODE, Owner::$family), )*)* + ]; + impl Owner { + pub(super) fn handle(self, store: &dyn WorldStore, session: &mut ProtocolSession, request: ProtocolRequest) -> Result { + match self { $(Self::$family => $family::handle(store, session, request)),* } + } + } + }; +} + +protocol_families! { + Character => [ + CMSG_PING, + CMSG_QUERY_TIME, + CMSG_CHAR_ENUM, + CMSG_CHAR_CREATE, + CMSG_CHAR_DELETE, + CMSG_PLAYER_LOGIN, + MSG_MOVE_WORLDPORT_ACK, + CMSG_LOGOUT_CANCEL, + CMSG_LOGOUT_REQUEST, + CMSG_PLAYER_LOGOUT, + CMSG_PLAYED_TIME, + ], + Melee => [ + CMSG_ATTACKSWING, + CMSG_ATTACKSTOP, + ], + Cast => [ + CMSG_CAST_SPELL, + CMSG_CANCEL_AUTO_REPEAT_SPELL, + CMSG_CANCEL_CAST, + CMSG_CANCEL_CHANNELLING, + CMSG_CANCEL_AURA, + ], + Combat => [ + CMSG_SET_SELECTION, + CMSG_PET_ACTION, + CMSG_FORCE_RUN_SPEED_CHANGE_ACK, + CMSG_SETSHEATHED, + ], + Auction => [ + MSG_AUCTION_HELLO_Client, + CMSG_AUCTION_LIST_ITEMS, + CMSG_AUCTION_LIST_OWNER_ITEMS, + CMSG_AUCTION_LIST_BIDDER_ITEMS, + CMSG_AUCTION_PLACE_BID, + CMSG_AUCTION_SELL_ITEM, + CMSG_AUCTION_REMOVE_ITEM, + ], + Vendor => [ + CMSG_LIST_INVENTORY, + CMSG_BUY_ITEM, + CMSG_REPAIR_ITEM, + CMSG_SELL_ITEM, + CMSG_BUYBACK_ITEM, + ], + Bank => [ + CMSG_BANKER_ACTIVATE, + CMSG_AUTOBANK_ITEM, + CMSG_BUY_BANK_SLOT, + CMSG_AUTOSTORE_BANK_ITEM, + ], + Trainer => [ + CMSG_TRAINER_LIST, + CMSG_TRAINER_BUY_SPELL, + CMSG_SET_ACTION_BUTTON, + CMSG_LEARN_TALENT, + CMSG_SET_FACTION_ATWAR, + CMSG_SET_WATCHED_FACTION, + ], + Item => [ + CMSG_DESTROYITEM, + CMSG_SPLIT_ITEM, + CMSG_AUTOEQUIP_ITEM, + CMSG_AUTOSTORE_BAG_ITEM, + CMSG_SWAP_INV_ITEM, + CMSG_SWAP_ITEM, + CMSG_USE_ITEM, + ], + Quest => [ + CMSG_QUESTGIVER_STATUS_QUERY, + CMSG_QUESTGIVER_HELLO, + CMSG_QUESTGIVER_QUERY_QUEST, + CMSG_QUEST_QUERY, + CMSG_QUESTGIVER_ACCEPT_QUEST, + CMSG_QUESTGIVER_COMPLETE_QUEST, + CMSG_QUESTGIVER_REQUEST_REWARD, + CMSG_QUESTGIVER_CHOOSE_REWARD, + CMSG_QUESTLOG_REMOVE_QUEST, + CMSG_PUSHQUESTTOPARTY, + ], + Taxi => [ + CMSG_TAXINODE_STATUS_QUERY, + CMSG_TAXIQUERYAVAILABLENODES, + CMSG_ACTIVATETAXI, + ], + MemberStats => [ + CMSG_REQUEST_PARTY_MEMBER_STATS, + ], + Trade => [ + CMSG_INITIATE_TRADE, + CMSG_BEGIN_TRADE, + CMSG_CANCEL_TRADE, + CMSG_SET_TRADE_ITEM, + CMSG_CLEAR_TRADE_ITEM, + CMSG_SET_TRADE_GOLD, + CMSG_ACCEPT_TRADE, + CMSG_UNACCEPT_TRADE, + CMSG_BUSY_TRADE, + CMSG_IGNORE_TRADE, + ], + Duel => [ + CMSG_DUEL_ACCEPTED, + CMSG_DUEL_CANCELLED, + ], + Chat => [ + CMSG_CHAT_IGNORED, + ], + Channel => [ + CMSG_JOIN_CHANNEL, + CMSG_LEAVE_CHANNEL, + CMSG_CHANNEL_PASSWORD, + CMSG_CHANNEL_SET_OWNER, + CMSG_CHANNEL_MODERATOR, + CMSG_CHANNEL_UNMODERATOR, + CMSG_CHANNEL_MUTE, + CMSG_CHANNEL_UNMUTE, + CMSG_CHANNEL_KICK, + CMSG_CHANNEL_BAN, + CMSG_CHANNEL_UNBAN, + CMSG_CHANNEL_INVITE, + CMSG_CHANNEL_ANNOUNCEMENTS, + CMSG_CHANNEL_MODERATE, + CMSG_CHANNEL_LIST, + CMSG_CHANNEL_OWNER, + ], + MeetingStone => [ + CMSG_MEETINGSTONE_JOIN, + CMSG_MEETINGSTONE_LEAVE, + CMSG_MEETINGSTONE_INFO, + MSG_LOOKING_FOR_GROUP_Client, + ], + Guild => [ + CMSG_GUILD_QUERY, + CMSG_GUILD_ROSTER, + CMSG_GUILD_INFO, + CMSG_GUILD_CREATE, + MSG_TABARDVENDOR_ACTIVATE, + MSG_SAVE_GUILD_EMBLEM_Client, + CMSG_GUILD_INVITE, + CMSG_GUILD_ACCEPT, + CMSG_GUILD_DECLINE, + CMSG_GUILD_LEAVE, + CMSG_GUILD_REMOVE, + CMSG_GUILD_PROMOTE, + CMSG_GUILD_DEMOTE, + CMSG_GUILD_LEADER, + CMSG_GUILD_DISBAND, + CMSG_GUILD_MOTD, + CMSG_GUILD_INFO_TEXT, + CMSG_GUILD_SET_PUBLIC_NOTE, + CMSG_GUILD_SET_OFFICER_NOTE, + CMSG_GUILD_RANK, + CMSG_GUILD_ADD_RANK, + CMSG_GUILD_DEL_RANK, + CMSG_PETITION_SHOWLIST, + CMSG_PETITION_BUY, + CMSG_PETITION_SHOW_SIGNATURES, + CMSG_PETITION_QUERY, + MSG_PETITION_RENAME, + CMSG_OFFER_PETITION, + CMSG_PETITION_SIGN, + MSG_PETITION_DECLINE, + CMSG_TURN_IN_PETITION, + ], + Query => [ + CMSG_BINDER_ACTIVATE, + MSG_TALENT_WIPE_CONFIRM_Client, + CMSG_NAME_QUERY, + CMSG_PET_NAME_QUERY, + CMSG_INSPECT, + CMSG_CREATURE_QUERY, + CMSG_GOSSIP_HELLO, + CMSG_NPC_TEXT_QUERY, + CMSG_GOSSIP_SELECT_OPTION, + CMSG_ITEM_QUERY_SINGLE, + CMSG_MESSAGECHAT, + CMSG_TEXT_EMOTE, + MSG_RANDOM_ROLL_Client, + ], + Mail => [ + CMSG_GET_MAIL_LIST, + MSG_QUERY_NEXT_MAIL_TIME_Client, + CMSG_ITEM_TEXT_QUERY, + CMSG_MAIL_MARK_AS_READ, + CMSG_MAIL_DELETE, + CMSG_MAIL_RETURN_TO_SENDER, + CMSG_MAIL_TAKE_MONEY, + CMSG_MAIL_TAKE_ITEM, + CMSG_SEND_MAIL, + CMSG_MAIL_CREATE_TEXT_ITEM, + ], + Social => [ + CMSG_WHO, + CMSG_FRIEND_LIST, + CMSG_ADD_FRIEND, + CMSG_ADD_IGNORE, + CMSG_DEL_FRIEND, + CMSG_DEL_IGNORE, + CMSG_GROUP_INVITE, + CMSG_GROUP_ACCEPT, + CMSG_GROUP_DECLINE, + CMSG_GROUP_DISBAND, + CMSG_GROUP_UNINVITE, + CMSG_LOOT_METHOD, + CMSG_GROUP_RAID_CONVERT, + CMSG_GROUP_SET_LEADER, + CMSG_GROUP_ASSISTANT_LEADER, + CMSG_GROUP_UNINVITE_GUID, + CMSG_GROUP_CHANGE_SUB_GROUP, + CMSG_GROUP_SWAP_SUB_GROUP, + MSG_RAID_READY_CHECK_Client, + MSG_RAID_TARGET_UPDATE_Client, + MSG_MINIMAP_PING_Client, + ], + GameObject => [ + CMSG_GAMEOBJ_USE, + ], + LootWindow => [ + CMSG_LOOT, + CMSG_LOOT_MONEY, + CMSG_AUTOSTORE_LOOT_ITEM, + CMSG_LOOT_RELEASE, + ], + Loot => [ + CMSG_LOOT_ROLL, + CMSG_LOOT_MASTER_GIVE, + CMSG_AREATRIGGER, + CMSG_GAMEOBJECT_QUERY, + CMSG_REPOP_REQUEST, + MSG_CORPSE_QUERY_Client, + CMSG_RECLAIM_CORPSE, + CMSG_RESURRECT_RESPONSE, + CMSG_SELF_RES, + CMSG_SPIRIT_HEALER_ACTIVATE, + ], +} + +pub(super) fn owner(opcode: u32) -> Option { + OWNERS + .iter() + .find_map(|&(owned, owner)| (owned == opcode).then_some(owner)) +} + +#[cfg(test)] +mod tests { + use super::*; + fn duplicate(owners: &[(u32, Owner)]) -> Option<(u32, Owner, Owner)> { + let mut seen = std::collections::HashMap::new(); + for &(opcode, owner) in owners { + if let Some(previous) = seen.insert(opcode, owner) { + return Some((opcode, previous, owner)); + } + } + None + } + #[test] + fn each_opcode_has_one_owner() { + assert_eq!(duplicate(OWNERS), None); + } + #[test] + fn competing_ownership_is_detected() { + assert_eq!( + duplicate(&[(0x125, Owner::Trainer), (0x125, Owner::Query)]), + Some((0x125, Owner::Trainer, Owner::Query)) + ); + } + #[test] + fn raw_requests_have_owners() { + assert_eq!(owner(0x258), Some(Owner::Auction)); + assert_eq!(owner(0x125), Some(Owner::Trainer)); + assert_eq!(owner(0x318), Some(Owner::Trainer)); + } + + #[test] + fn unsupported_chat_kinds_are_silent_through_the_opcode_owner() { + use wow_world_messages::vanilla::Language; + let store = super::super::test_support::WorldFake::default(); + let mut session = ProtocolSession::in_world(7, 42); + for chat_type in [ + CMSG_MESSAGECHAT_ChatType::Battleground, + CMSG_MESSAGECHAT_ChatType::BattlegroundLeader, + CMSG_MESSAGECHAT_ChatType::System, + CMSG_MESSAGECHAT_ChatType::TextEmote, + ] { + let request = ClientOpcodeMessage::CMSG_MESSAGECHAT(Box::new(CMSG_MESSAGECHAT { + chat_type, + language: Language::Universal, + message: "ignored".into(), + })); + let reply = owner(::OPCODE) + .unwrap() + .handle(&store, &mut session, request.into()) + .unwrap(); + assert!(reply.outbound.is_empty()); + assert!(reply.after_queue.is_none()); + } + } +} diff --git a/gateway/src/world/social.rs b/gateway/src/world/social.rs index b8082c0a9..e6b216200 100644 --- a/gateway/src/world/social.rs +++ b/gateway/src/world/social.rs @@ -1,12 +1,13 @@ -//! Social-family dispatch: the /who panel, the friends/ignore lists, and party/group management, -//! carved out of `handle_query` in `world/mod.rs` — pure code-motion, same per-family `handle_*` -//! shape as the rest of the dispatch chain. +//! Contact lists, realm-wide presence, and party requests. use super::party::PartyOutcome; -use super::{party, presence, send, who, Actor, Outbound, SessionTx, WorldConn, WorldState}; +use super::{ + party, presence, who, Actor, Outbound, ProtocolFamily, ProtocolReply, ProtocolRequest, + ProtocolSession, WorldState, +}; use crate::codec; use crate::world::{CharacterStore, GuildActionStore, PartyStore, SessionStore, ShardRoutingStore}; -use anyhow::Result; +use anyhow::{anyhow, Result}; use lyracore_shared::group::{GroupRefusal, TARGET_ICON_LIST_REQUEST}; use lyracore_shared::social::ContactRefusal; use wow_world_base::shared::friend_result_vanilla_tbc::FriendResult; @@ -108,208 +109,218 @@ impl From for ContactOutcome { } } -/// Social family: /who, the friends/ignore lists, and party/group management — the social-pane -/// opcodes. Each arm consumes its opcode (`Ok(None)`) or passes the message on (`Ok(Some(msg))`), -/// like the other per-family handlers. -#[allow(clippy::too_many_lines)] // One arm per social and party opcode. -pub(super) fn handle_social< - St: CharacterStore - + GuildActionStore - + PartyStore - + SessionStore - + ShardRoutingStore - + SocialStore - + ?Sized, ->( - tx: &SessionTx, - store: &St, - conn: &mut WorldConn, - msg: ClientOpcodeMessage, -) -> Result> { - match msg { - // /who panel: filtered realm-wide (`who::respond`), same team only, every filter the - // client sent applied. Silently dropped outside the world (no requester Character to read - // a team from), for an unresolvable requester (never guess a team), and while this - // session's `WHO_THROTTLE` cooldown is running (vm:MiscHandler.cpp:230). - ClientOpcodeMessage::CMSG_WHO(request) => { - if let Some(me) = self_guid(conn) { - if conn.admit_who() { - if let Some(character) = store.character_by_guid(me)? { - if let Some((opcode, body)) = who::respond(store, character.race, &request)? - { - send(tx, Outbound::Raw { opcode, body })?; +pub(crate) struct Social; + +impl< + St: CharacterStore + + GuildActionStore + + PartyStore + + SessionStore + + ShardRoutingStore + + SocialStore + + ?Sized, + > ProtocolFamily for Social +{ + #[allow(clippy::too_many_lines)] // One match arm per social or party opcode. + fn handle( + store: &St, + session: &mut ProtocolSession, + request: ProtocolRequest, + ) -> Result { + let msg = request.message()?; + let mut outbound = Vec::new(); + match msg { + // /who panel: filtered realm-wide (`who::respond`), same team only, every filter the + // client sent applied. Silently dropped outside the world (no requester Character to read + // a team from), for an unresolvable requester (never guess a team), and while this + // session's `WHO_THROTTLE` cooldown is running (vm:MiscHandler.cpp:230). + ClientOpcodeMessage::CMSG_WHO(request) => { + if let Some(me) = session.self_guid() { + if session.admit_who() { + if let Some(character) = store.character_by_guid(me)? { + if let Some((opcode, body)) = + who::respond(store, character.race, &request)? + { + outbound.push(Outbound::Raw { opcode, body }); + } } } } } - } - // Friends / ignore list: opening the social pane's friends tab requests BOTH - // lists off the one opcode — vanilla answers with SMSG_FRIEND_LIST + SMSG_IGNORE_LIST. - // Silently dropped outside the world (no character to scope the lists to). - ClientOpcodeMessage::CMSG_FRIEND_LIST => { - if let WorldState::InWorld(iw) = &conn.state { - let (friend_guids, ignored) = store.contact_lists(iw.self_guid)?; - let own_team = store - .character_by_guid(iw.self_guid)? - .map_or(lyracore_shared::faction::TEAM_ALLIANCE, |character| { - lyracore_shared::faction::team_for_race(character.race) - }); - let friends = friend_views(store, own_team, &friend_guids); - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_FRIEND_LIST(Box::new( - codec::build_friend_list_response(&friends), - ))), - )?; - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_IGNORE_LIST(Box::new( - codec::build_ignore_list_response(&ignored), - ))), - )?; - } - } - // Add a friend/ignore by typed name: resolved realm-wide the same way `/whisper`'s target - // is, then the module re-validates self/duplicate/cap/team server-side. Either way the - // client gets an SMSG_FRIEND_STATUS its system message reads the result code off. The - // contact ops are silently dropped outside the world: there is no Character to act as. - ClientOpcodeMessage::CMSG_ADD_FRIEND(c) => { - if let Some(actor) = self_actor(conn) { - let (result, guid, online) = resolve_add_contact(store, actor, &c.name, false)?; - let (opcode, body) = codec::build_friend_status_raw(result, guid, online); - send(tx, Outbound::Raw { opcode, body })?; + // Friends / ignore list: opening the social pane's friends tab requests BOTH + // lists off the one opcode — vanilla answers with SMSG_FRIEND_LIST + SMSG_IGNORE_LIST. + // Silently dropped outside the world (no character to scope the lists to). + ClientOpcodeMessage::CMSG_FRIEND_LIST => { + if let WorldState::InWorld(iw) = &session.state { + let (friend_guids, ignored) = store.contact_lists(iw.self_guid)?; + let own_team = store + .character_by_guid(iw.self_guid)? + .map_or(lyracore_shared::faction::TEAM_ALLIANCE, |character| { + lyracore_shared::faction::team_for_race(character.race) + }); + let friends = friend_views(store, own_team, &friend_guids); + outbound.push(Outbound::One(ServerOpcodeMessage::SMSG_FRIEND_LIST( + Box::new(codec::build_friend_list_response(&friends)), + ))); + outbound.push(Outbound::One(ServerOpcodeMessage::SMSG_IGNORE_LIST( + Box::new(codec::build_ignore_list_response(&ignored)), + ))); + } } - } - ClientOpcodeMessage::CMSG_ADD_IGNORE(c) => { - if let Some(actor) = self_actor(conn) { - let (result, guid, online) = resolve_add_contact(store, actor, &c.name, true)?; - let (opcode, body) = codec::build_friend_status_raw(result, guid, online); - send(tx, Outbound::Raw { opcode, body })?; + // Add a friend/ignore by typed name: resolved realm-wide the same way `/whisper`'s target + // is, then the module re-validates self/duplicate/cap/team server-side. Either way the + // client gets an SMSG_FRIEND_STATUS its system message reads the result code off. The + // contact ops are silently dropped outside the world: there is no Character to act as. + ClientOpcodeMessage::CMSG_ADD_FRIEND(c) => { + if let Some(actor) = session.actor() { + let (result, guid, online) = resolve_add_contact(store, actor, &c.name, false)?; + let (opcode, body) = codec::build_friend_status_raw(result, guid, online); + outbound.push(Outbound::Raw { opcode, body }); + } } - } - // Remove a friend/ignore by guid (the client already has it from the list row). - ClientOpcodeMessage::CMSG_DEL_FRIEND(c) => { - if let Some(actor) = self_actor(conn) { - let (result, guid) = resolve_del_contact(store, actor, c.guid.guid(), false)?; - let (opcode, body) = codec::build_friend_status_raw(result, guid, None); - send(tx, Outbound::Raw { opcode, body })?; + ClientOpcodeMessage::CMSG_ADD_IGNORE(c) => { + if let Some(actor) = session.actor() { + let (result, guid, online) = resolve_add_contact(store, actor, &c.name, true)?; + let (opcode, body) = codec::build_friend_status_raw(result, guid, online); + outbound.push(Outbound::Raw { opcode, body }); + } } - } - ClientOpcodeMessage::CMSG_DEL_IGNORE(c) => { - if let Some(actor) = self_actor(conn) { - let (result, guid) = resolve_del_contact(store, actor, c.guid.guid(), true)?; - let (opcode, body) = codec::build_friend_status_raw(result, guid, None); - send(tx, Outbound::Raw { opcode, body })?; + // Remove a friend/ignore by guid (the client already has it from the list row). + ClientOpcodeMessage::CMSG_DEL_FRIEND(c) => { + if let Some(actor) = session.actor() { + let (result, guid) = resolve_del_contact(store, actor, c.guid.guid(), false)?; + let (opcode, body) = codec::build_friend_status_raw(result, guid, None); + outbound.push(Outbound::Raw { opcode, body }); + } } - } - // Party/group. The invite/uninvite names resolve gateway-side (the add_friend - // convention); outcomes echo as SMSG_PARTY_COMMAND_RESULT. The cross-player packets - // (SMSG_GROUP_INVITE/LIST/DECLINE/DESTROYED) ride the game_group_event relay. - // - // The op itself goes through `world::party`, which decides WHICH DATABASE - // runs it — realm-core when the gateway is multi-database, the player's own shard otherwise. - // Every arm below is otherwise unchanged, including which failures the client is told about. - // Silently dropped outside the world: with no in-world character there is no `self_guid` to - // act as, and none of these opcodes is reachable at character select. - ClientOpcodeMessage::CMSG_GROUP_INVITE(c) => { - let result = match (self_actor(conn), presence::resolve_by_name(store, &c.name)?) { - (Some(me), Some(guid)) => { - party_result(party::run(store, me, party::Op::Invite(guid))?) + ClientOpcodeMessage::CMSG_DEL_IGNORE(c) => { + if let Some(actor) = session.actor() { + let (result, guid) = resolve_del_contact(store, actor, c.guid.guid(), true)?; + let (opcode, body) = codec::build_friend_status_raw(result, guid, None); + outbound.push(Outbound::Raw { opcode, body }); } - _ => PartyResult::BadPlayerName, - }; - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_PARTY_COMMAND_RESULT(Box::new( - codec::build_party_command_result(PartyOperation::Invite, c.name, result), - ))), - )?; - } - ClientOpcodeMessage::CMSG_GROUP_ACCEPT => run_unanswered(store, conn, party::Op::Accept)?, - ClientOpcodeMessage::CMSG_GROUP_DECLINE => run_unanswered(store, conn, party::Op::Decline)?, - ClientOpcodeMessage::CMSG_GROUP_DISBAND => { - run_answering_refusal(tx, store, conn, party::Op::Leave)? - } - ClientOpcodeMessage::CMSG_GROUP_UNINVITE(c) => { - let result = match (self_actor(conn), presence::resolve_by_name(store, &c.name)?) { - (Some(me), Some(guid)) => { - party_result(party::run(store, me, party::Op::Uninvite(guid))?) + } + // Party/group. The invite/uninvite names resolve gateway-side (the add_friend + // convention); outcomes echo as SMSG_PARTY_COMMAND_RESULT. The cross-player packets + // (SMSG_GROUP_INVITE/LIST/DECLINE/DESTROYED) ride the game_group_event relay. + // + // The op itself goes through `world::party`, which decides WHICH DATABASE + // runs it — realm-core when the gateway is multi-database, the player's own shard otherwise. + // Every arm below is otherwise unchanged, including which failures the client is told about. + // Silently dropped outside the world: with no in-world character there is no `self_guid` to + // act as, and none of these opcodes is reachable at character select. + ClientOpcodeMessage::CMSG_GROUP_INVITE(c) => { + let result = match (session.actor(), presence::resolve_by_name(store, &c.name)?) { + (Some(me), Some(guid)) => { + party_result(party::run(store, me, party::Op::Invite(guid))?) + } + _ => PartyResult::BadPlayerName, + }; + outbound.push(Outbound::One( + ServerOpcodeMessage::SMSG_PARTY_COMMAND_RESULT(Box::new( + codec::build_party_command_result(PartyOperation::Invite, c.name, result), + )), + )); + } + ClientOpcodeMessage::CMSG_GROUP_ACCEPT => { + run_unanswered(store, session, party::Op::Accept)? + } + ClientOpcodeMessage::CMSG_GROUP_DECLINE => { + run_unanswered(store, session, party::Op::Decline)? + } + ClientOpcodeMessage::CMSG_GROUP_DISBAND => { + outbound.extend(run_answering_refusal(store, session, party::Op::Leave)?) + } + ClientOpcodeMessage::CMSG_GROUP_UNINVITE(c) => { + let result = match (session.actor(), presence::resolve_by_name(store, &c.name)?) { + (Some(me), Some(guid)) => { + party_result(party::run(store, me, party::Op::Uninvite(guid))?) + } + _ => PartyResult::BadPlayerName, + }; + if result != PartyResult::Success { + outbound.push(Outbound::One( + ServerOpcodeMessage::SMSG_PARTY_COMMAND_RESULT(Box::new( + codec::build_party_command_result( + PartyOperation::Leave, + c.name, + result, + ), + )), + )); } - _ => PartyResult::BadPlayerName, - }; - if result != PartyResult::Success { - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_PARTY_COMMAND_RESULT(Box::new( - codec::build_party_command_result(PartyOperation::Leave, c.name, result), - ))), - )?; } + // `CMSG_LOOT_METHOD`: the leader sets the party's loot method/ + // threshold/master. No ack packet — vanilla itself sends none for this opcode (cmangos's + // `HandleLootMethodOpcode` only calls `group->SendUpdate()`); the module's own reducer + // echoes via the EXISTING `SMSG_GROUP_LIST` roster relay. A rejection (not the leader, bad + // method/threshold/master) is per-action — log + ignore, matching group_accept/decline. + ClientOpcodeMessage::CMSG_LOOT_METHOD(c) => { + let op = party::Op::LootMethod { + setting: c.loot_setting.as_int(), + master: c.loot_master.guid(), + threshold: c.loot_threshold.as_int(), + }; + run_unanswered(store, session, op)?; + } + ClientOpcodeMessage::CMSG_GROUP_RAID_CONVERT => { + outbound.extend(raid_convert(store, session)?) + } + ClientOpcodeMessage::CMSG_GROUP_SET_LEADER(c) => { + run_unanswered(store, session, party::Op::SetLeader(c.guid.guid()))? + } + ClientOpcodeMessage::CMSG_GROUP_ASSISTANT_LEADER(c) => run_unanswered( + store, + session, + party::Op::SetAssistant { + target: c.guid.guid(), + promote: c.set_assistant, + }, + )?, + // The raid frame's "Remove from group" names the member by guid, so no name lookup runs + // (cm:GroupHandler.cpp:250-296). Naming yourself gets no answer (lines 255-260). + ClientOpcodeMessage::CMSG_GROUP_UNINVITE_GUID(c) + if session.self_guid() != Some(c.guid.guid()) => + { + outbound.extend(run_answering_refusal( + store, + session, + party::Op::Uninvite(c.guid.guid()), + )?) + } + ClientOpcodeMessage::CMSG_GROUP_UNINVITE_GUID(_) => {} + // `CMSG_GROUP_CHANGE_SUB_GROUP` / `CMSG_GROUP_SWAP_SUB_GROUP`: the leader or an Assistant + // drags a raid member to another Subgroup, or swaps two. No ack packet: the + // `SMSG_GROUP_LIST` roster relay is the client-visible result, and cmangos is silent for + // every refusal here (cm:GroupHandler.cpp:492-525, cm:GroupHandler.cpp:901-944). + ClientOpcodeMessage::CMSG_GROUP_CHANGE_SUB_GROUP(c) => { + change_subgroup(store, session, &c.name, c.group_number)? + } + ClientOpcodeMessage::CMSG_GROUP_SWAP_SUB_GROUP(c) => { + swap_subgroup(store, session, &c.name, &c.swap_with_name)? + } + // Group Broadcasts. Every member's packet rides the group event relay, the actor's too. + ClientOpcodeMessage::MSG_RAID_READY_CHECK(c) => { + let op = match c.answer { + None => party::Op::ReadyCheckStart, + Some(answer) => party::Op::ReadyCheckAnswer(answer.state), + }; + run_group_broadcast(store, session, op); + } + ClientOpcodeMessage::MSG_RAID_TARGET_UPDATE(c) => { + run_group_broadcast(store, session, target_icon_op(&c)); + } + ClientOpcodeMessage::MSG_MINIMAP_PING(c) => { + let op = party::Op::MinimapPing { + x: c.position_x, + y: c.position_y, + }; + run_group_broadcast(store, session, op); + } + _ => return Err(anyhow!("opcode routed to the wrong Protocol Family")), } - // `CMSG_LOOT_METHOD`: the leader sets the party's loot method/ - // threshold/master. No ack packet — vanilla itself sends none for this opcode (cmangos's - // `HandleLootMethodOpcode` only calls `group->SendUpdate()`); the module's own reducer - // echoes via the EXISTING `SMSG_GROUP_LIST` roster relay. A rejection (not the leader, bad - // method/threshold/master) is per-action — log + ignore, matching group_accept/decline. - ClientOpcodeMessage::CMSG_LOOT_METHOD(c) => { - let op = party::Op::LootMethod { - setting: c.loot_setting.as_int(), - master: c.loot_master.guid(), - threshold: c.loot_threshold.as_int(), - }; - run_unanswered(store, conn, op)?; - } - ClientOpcodeMessage::CMSG_GROUP_RAID_CONVERT => raid_convert(tx, store, conn)?, - ClientOpcodeMessage::CMSG_GROUP_SET_LEADER(c) => { - run_unanswered(store, conn, party::Op::SetLeader(c.guid.guid()))? - } - ClientOpcodeMessage::CMSG_GROUP_ASSISTANT_LEADER(c) => run_unanswered( - store, - conn, - party::Op::SetAssistant { - target: c.guid.guid(), - promote: c.set_assistant, - }, - )?, - // The raid frame's "Remove from group" names the member by guid, so no name lookup runs - // (cm:GroupHandler.cpp:250-296). Naming yourself gets no answer (lines 255-260). - ClientOpcodeMessage::CMSG_GROUP_UNINVITE_GUID(c) - if self_guid(conn) != Some(c.guid.guid()) => - { - run_answering_refusal(tx, store, conn, party::Op::Uninvite(c.guid.guid()))? - } - ClientOpcodeMessage::CMSG_GROUP_UNINVITE_GUID(_) => {} - // `CMSG_GROUP_CHANGE_SUB_GROUP` / `CMSG_GROUP_SWAP_SUB_GROUP`: the leader or an Assistant - // drags a raid member to another Subgroup, or swaps two. No ack packet: the - // `SMSG_GROUP_LIST` roster relay is the client-visible result, and cmangos is silent for - // every refusal here (cm:GroupHandler.cpp:492-525, cm:GroupHandler.cpp:901-944). - ClientOpcodeMessage::CMSG_GROUP_CHANGE_SUB_GROUP(c) => { - change_subgroup(store, conn, &c.name, c.group_number)? - } - ClientOpcodeMessage::CMSG_GROUP_SWAP_SUB_GROUP(c) => { - swap_subgroup(store, conn, &c.name, &c.swap_with_name)? - } - // Group Broadcasts. Every member's packet rides the group event relay, the actor's too. - ClientOpcodeMessage::MSG_RAID_READY_CHECK(c) => { - let op = match c.answer { - None => party::Op::ReadyCheckStart, - Some(answer) => party::Op::ReadyCheckAnswer(answer.state), - }; - run_group_broadcast(store, conn, op); - } - ClientOpcodeMessage::MSG_RAID_TARGET_UPDATE(c) => { - run_group_broadcast(store, conn, target_icon_op(&c)); - } - ClientOpcodeMessage::MSG_MINIMAP_PING(c) => { - let op = party::Op::MinimapPing { - x: c.position_x, - y: c.position_y, - }; - run_group_broadcast(store, conn, op); - } - other => return Ok(Some(other)), + Ok(outbound.into()) } - Ok(None) } /// Run a leave or a kick by guid. Only a Refusal answers, as `SMSG_PARTY_COMMAND_RESULT(Leave, "", @@ -317,59 +328,53 @@ pub(super) fn handle_social< fn run_answering_refusal< St: CharacterStore + PartyStore + SessionStore + ShardRoutingStore + SocialStore + ?Sized, >( - tx: &SessionTx, store: &St, - conn: &WorldConn, + session: &ProtocolSession, op: party::Op, -) -> Result<()> { - let Some(me) = self_actor(conn) else { - return Ok(()); +) -> Result> { + let Some(actor) = session.actor() else { + return Ok(Vec::new()); }; - match party::run(store, me, op)? { - PartyOutcome::Ran => Ok(()), - PartyOutcome::Refused(refusal) => send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_PARTY_COMMAND_RESULT(Box::new( + match party::run(store, actor, op)? { + PartyOutcome::Ran => Ok(Vec::new()), + PartyOutcome::Refused(refusal) => Ok(vec![Outbound::One( + ServerOpcodeMessage::SMSG_PARTY_COMMAND_RESULT(Box::new( codec::build_party_command_result( PartyOperation::Leave, String::new(), party_result_for(refusal), ), - ))), - ), + )), + )]), } } -/// The leader's "Convert to Raid". cmangos answers success with -/// `SMSG_PARTY_COMMAND_RESULT(Invite, "", Ok)` and every refusal with silence -/// (cm:GroupHandler.cpp:473-490); the raid list reaches every member through the LIST relay. +/// Raid conversion answers success with a party command result. A Refusal is silent. fn raid_convert< St: CharacterStore + PartyStore + SessionStore + ShardRoutingStore + SocialStore + ?Sized, >( - tx: &SessionTx, store: &St, - conn: &WorldConn, -) -> Result<()> { - let Some(me) = self_actor(conn) else { - return Ok(()); + session: &ProtocolSession, +) -> Result> { + let Some(actor) = session.actor() else { + return Ok(Vec::new()); }; - match party::run(store, me, party::Op::RaidConvert)? { - PartyOutcome::Ran => send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_PARTY_COMMAND_RESULT(Box::new( + match party::run(store, actor, party::Op::RaidConvert)? { + PartyOutcome::Ran => Ok(vec![Outbound::One( + ServerOpcodeMessage::SMSG_PARTY_COMMAND_RESULT(Box::new( codec::build_party_command_result( PartyOperation::Invite, String::new(), PartyResult::Success, ), - ))), - ), + )), + )]), PartyOutcome::Refused(refusal) => { log::debug!( "world: group_raid_convert refused (account {}): {refusal:?}", - conn.account_id + session.account_id ); - Ok(()) + Ok(Vec::new()) } } } @@ -381,16 +386,16 @@ fn run_unanswered< St: CharacterStore + PartyStore + SessionStore + ShardRoutingStore + SocialStore + ?Sized, >( store: &St, - conn: &WorldConn, + session: &ProtocolSession, op: party::Op, ) -> Result<()> { - let Some(me) = self_actor(conn) else { + let Some(me) = session.actor() else { return Ok(()); }; if let PartyOutcome::Refused(refusal) = party::run(store, me, op)? { log::debug!( "world: {op:?} refused (account {}): {refusal:?}", - conn.account_id + session.account_id ); } Ok(()) @@ -404,20 +409,24 @@ fn change_subgroup< St: CharacterStore + PartyStore + SessionStore + ShardRoutingStore + SocialStore + ?Sized, >( store: &St, - conn: &WorldConn, + session: &ProtocolSession, name: &str, subgroup: u8, ) -> Result<()> { - let Some(me) = self_actor(conn) else { + let Some(me) = session.actor() else { return Ok(()); }; match party::resolve_roster_member_by_name(store, me.guid(), name)? { - Some(target) => run_unanswered(store, conn, party::Op::ChangeSubgroup { target, subgroup }), + Some(target) => run_unanswered( + store, + session, + party::Op::ChangeSubgroup { target, subgroup }, + ), None => { log::debug!( "world: group_change_sub_group named a Character outside the caller's own \ roster (account {})", - conn.account_id + session.account_id ); Ok(()) } @@ -432,24 +441,24 @@ fn swap_subgroup< St: CharacterStore + PartyStore + SessionStore + ShardRoutingStore + SocialStore + ?Sized, >( store: &St, - conn: &WorldConn, + session: &ProtocolSession, name: &str, swap_with_name: &str, ) -> Result<()> { - let Some(me) = self_actor(conn) else { + let Some(me) = session.actor() else { return Ok(()); }; let (first, second) = party::resolve_roster_members_by_name(store, me.guid(), name, swap_with_name)?; match (first, second) { (Some(first), Some(second)) => { - run_unanswered(store, conn, party::Op::SwapSubgroup { first, second }) + run_unanswered(store, session, party::Op::SwapSubgroup { first, second }) } _ => { log::debug!( "world: group_swap_sub_group named a Character outside the caller's own roster \ (account {})", - conn.account_id + session.account_id ); Ok(()) } @@ -464,16 +473,16 @@ pub(super) fn run_group_broadcast< St: CharacterStore + PartyStore + SessionStore + ShardRoutingStore + SocialStore + ?Sized, >( store: &St, - conn: &mut WorldConn, + session: &mut ProtocolSession, op: party::Op, ) { - let Some(me) = self_actor(conn) else { + let Some(me) = session.actor() else { return; }; - if !conn.admit_group_broadcast(op) { + if !session.admit_group_broadcast(op) { log::debug!( "world: group broadcast {op:?} dropped inside its cooldown (account {})", - conn.account_id + session.account_id ); return; } @@ -481,11 +490,11 @@ pub(super) fn run_group_broadcast< Ok(PartyOutcome::Ran) => {} Ok(PartyOutcome::Refused(refusal)) => log::debug!( "world: group broadcast {op:?} refused (account {}): {refusal:?}", - conn.account_id + session.account_id ), Err(error) => log::warn!( "world: group broadcast {op:?} lost (account {}): {error:#}", - conn.account_id + session.account_id ), } } @@ -512,27 +521,6 @@ fn target_icon_op(update: &MSG_RAID_TARGET_UPDATE_Client) -> party::Op { } } -/// The session's in-world character guid, or `None` at character select. Party ops need it for two -/// reasons that only coincide on a single-database gateway: it is the CHARACTER realm-core acts as -/// (realm-core has no live entity to derive one from), and it is the character the module's -/// own `entity_by_owner` would have resolved on the shard plane. Reading it here, from the state the -/// gateway already authenticated for this socket, is what keeps the realm-core call trustworthy. -/// -/// `pub(super)` because every Realm Chat Line needs the same guid for the same reason: -/// `realm_chat` and `realm_whisper` take the speaker as an argument, so this accessor is the -/// authorization of every line (see `handlers::chat`). -pub(super) fn self_guid(conn: &WorldConn) -> Option { - match &conn.state { - WorldState::InWorld(iw) => Some(iw.self_guid), - _ => None, - } -} - -/// [`self_guid`] as the Actor a Durable Request acts as. -pub(super) fn self_actor(conn: &WorldConn) -> Option { - self_guid(conn).and_then(Actor::new) -} - /// The `PartyResult` code one party outcome renders as. fn party_result(outcome: PartyOutcome) -> PartyResult { match outcome { diff --git a/gateway/src/world/taxi_tests.rs b/gateway/src/world/taxi_tests.rs index f85f2da4d..5287d8b5b 100644 --- a/gateway/src/world/taxi_tests.rs +++ b/gateway/src/world/taxi_tests.rs @@ -1,32 +1,20 @@ //! Taxi opcodes through their dispatcher, and taxi gossip over an encrypted World Session. +use super::family::{ProtocolFamily, ProtocolSession}; use super::handlers::InMemoryTaxiActions; use super::*; use wow_world_messages::vanilla::ActivateTaxiReply; -const PLAYER: TaxiActionPlayer = TaxiActionPlayer { self_guid: Some(1) }; - -/// Dispatch one taxi message and return the packets the session would send for it. An activation -/// goes through `queue_reply_then_arm`, as the session does, so the reply is queued before the -/// flight arms. fn try_run( actions: &InMemoryTaxiActions, msg: impl Into, ) -> Result> { - match dispatch_taxi_action(actions, PLAYER, msg.into())? { - TaxiActionOutcome::Handled { outbound } => Ok(outbound), - TaxiActionOutcome::Activated { - outbound, - character_guid, - arm, - } => { - let (tx, rx) = SessionTx::with_depth(0); - queue_reply_then_arm(&tx, actions, outbound, character_guid, arm)?; - drop(tx); - Ok(rx.try_iter().collect()) - } - TaxiActionOutcome::PassThrough(_) => panic!("the taxi dispatcher passed the message on"), - } + Ok(handlers::Taxi::handle( + actions, + &mut ProtocolSession::in_world(7, 1), + msg.into().into(), + )? + .outbound) } fn run(actions: &InMemoryTaxiActions, msg: impl Into) -> Vec { @@ -63,6 +51,75 @@ fn activate_90() -> CMSG_ACTIVATETAXI { } } +fn accepted_flight(tx: &SessionTx) -> WorldFake { + let mut store = WorldFake::default(); + store.taxi.activation.result_code = lyracore_shared::constants::taxi_protocol::ACTIVATE_OK; + *store.taxi.flight_tx.lock().unwrap() = Some(tx.clone()); + store +} + +fn dispatch_activation(store: &WorldFake, tx: &SessionTx) -> Result<()> { + dispatch( + tx, + store, + &mut in_world_conn(7, 1), + ::OPCODE, + ClientOpcodeMessage::from(activate_90()).into(), + ) +} + +#[test] +fn taxi_dispatch_queues_acceptance_before_the_flight_spline() { + let (tx, rx) = SessionTx::with_depth(0); + let store = accepted_flight(&tx); + + dispatch_activation(&store, &tx).unwrap(); + + assert_eq!(*store.taxi.armed_passenger.lock().unwrap(), Some(1)); + assert!(matches!( + rx.try_recv().unwrap(), + Outbound::One(ServerOpcodeMessage::SMSG_ACTIVATETAXIREPLY(reply)) + if reply.reply == ActivateTaxiReply::Ok + )); + assert!(matches!( + rx.try_recv().unwrap(), + Outbound::Raw { opcode: 0x00dd, .. } + )); + assert!(rx.try_recv().is_err()); +} + +#[test] +fn taxi_dispatch_does_not_arm_when_the_reply_queue_is_closed() { + let (tx, rx) = SessionTx::with_depth(0); + let store = accepted_flight(&tx); + drop(rx); + + assert!(dispatch_activation(&store, &tx).is_err()); + assert_eq!(*store.taxi.armed_passenger.lock().unwrap(), None); +} + +#[test] +fn taxi_dispatch_propagates_arming_transport_loss_after_acceptance() { + let (tx, rx) = SessionTx::with_depth(0); + let mut store = accepted_flight(&tx); + store.taxi.arm_error = + Some(|| crate::stdb::ReducerCallError::transport_lost("gw_arm_taxi_flight").into()); + + let error = dispatch_activation(&store, &tx).unwrap_err(); + + assert!(matches!( + crate::stdb::classify(&error), + crate::stdb::DurableFailure::TransportLoss + )); + assert_eq!(*store.taxi.armed_passenger.lock().unwrap(), None); + assert!(matches!( + rx.try_recv().unwrap(), + Outbound::One(ServerOpcodeMessage::SMSG_ACTIVATETAXIREPLY(reply)) + if reply.reply == ActivateTaxiReply::Ok + )); + assert!(rx.try_recv().is_err()); +} + #[test] fn taxi_status_query_returns_the_persisted_bit_without_opening() { // A status answer, not a node map, shows the query did not open the taxi. diff --git a/gateway/src/world/test_support/family.rs b/gateway/src/world/test_support/family.rs index 95b33e4dd..1926880be 100644 --- a/gateway/src/world/test_support/family.rs +++ b/gateway/src/world/test_support/family.rs @@ -1,8 +1,7 @@ //! Pieces the handler-level family tests share: a session connection to run a handler against, and -//! a drain for what the handler sent. A family test owns its Fake and its `run` helper. +//! typed reply flattening. A family test owns its Fake and its `run` helper. use super::*; -use std::sync::mpsc::Receiver; /// A connection for `account_id` in the world as `self_guid`. The session's routed Store is /// never read here: every handler under test takes its family Fake as an argument. @@ -19,33 +18,22 @@ pub(crate) fn in_world_conn(account_id: u64, self_guid: u64) -> WorldConn { .unwrap_or_else(|_| panic!("the proof was made for the same key")); WorldConn { session_claim: None, - account_id, - account_name: "TESTER".into(), + protocol: ProtocolSession::in_world(account_id, self_guid), decrypt, - state: WorldState::InWorld(InWorld { - self_guid, - subs: PlayerSubscriptions::empty(), - attacking_target: None, - open_loot: Default::default(), - ranged_repeat: false, - }), move_coalesce: Default::default(), - gossip_menu: None, unavailable_notices: Default::default(), store: RoutedStore::new(Arc::new(WorldFake::default())), session_key: None, guild_signed_on: None, move_desync_drops: 0, - who_throttled_until: None, - group_broadcast_cooldowns: Default::default(), chat_flood: Default::default(), } } -/// Every message a handler queued on its `SessionTx`, in send order. -pub(crate) fn drain_outbound(rx: &Receiver) -> Vec { +/// Flatten typed replies while preserving their protocol order. +pub(crate) fn outbound_messages(outbound: Vec) -> Vec { let mut sent = Vec::new(); - while let Ok(out) = rx.try_recv() { + for out in outbound { match out { Outbound::One(message) => sent.push(message), Outbound::Batch(messages) => sent.extend(messages), diff --git a/gateway/src/world/test_support/world_fake/taxi.rs b/gateway/src/world/test_support/world_fake/taxi.rs index 163526996..9dc758929 100644 --- a/gateway/src/world/test_support/world_fake/taxi.rs +++ b/gateway/src/world/test_support/world_fake/taxi.rs @@ -1,14 +1,19 @@ use super::super::*; +use std::sync::Mutex; +use wow_world_messages::vanilla::Vector3d; #[derive(Default)] pub(crate) struct TaxiState { /// The taxi map the open operation returns, for the TAXI gossip socket tests. pub(crate) taxi_map: Option, pub(crate) taxi_error: Option anyhow::Error>, + pub(crate) activation: codec::TaxiActivationResult, + pub(crate) armed_passenger: Mutex>, + pub(crate) arm_error: Option anyhow::Error>, + pub(crate) flight_tx: Mutex>, } -/// Taxi behavior is tested through `InMemoryTaxiActions`. This adapter serves only the TAXI gossip -/// socket tests: an open that returns the configured map, or the configured failure. +/// Taxi state for World Session tests. Arming publishes a flight spline to its passenger. impl TaxiActionStore for WorldFake { fn taxi_node_status( &self, @@ -32,10 +37,33 @@ impl TaxiActionStore for WorldFake { _source_client_node_id: u32, _destination_client_node_id: u32, ) -> Result { - Ok(codec::TaxiActivationResult::default()) + Ok(self.taxi.activation) } - fn arm_taxi_flight(&self, _actor: Actor) -> Result<()> { + fn arm_taxi_flight(&self, actor: Actor) -> Result<()> { + if let Some(error) = self.taxi.arm_error { + return Err(error()); + } + *self.taxi.armed_passenger.lock().unwrap() = Some(actor.guid()); + if let Some(tx) = self.taxi.flight_tx.lock().unwrap().take() { + let (opcode, body) = codec::build_taxi_move_raw( + actor.guid(), + Vector3d { + x: 0.0, + y: 0.0, + z: 0.0, + }, + vec![Vector3d { + x: 10.0, + y: 0.0, + z: 5.0, + }], + 1000, + 1, + ) + .unwrap(); + send(&tx, Outbound::Raw { opcode, body })?; + } Ok(()) } } diff --git a/gateway/src/world/trade_tests.rs b/gateway/src/world/trade_tests.rs index 7a39ed92e..b2f491350 100644 --- a/gateway/src/world/trade_tests.rs +++ b/gateway/src/world/trade_tests.rs @@ -1,8 +1,7 @@ -//! Trade opcodes, run through `handle_trade` against a Fake that holds only the Trade Store the -//! handler is bounded on. Every trade status reaches the client through the `game_trade_event` -//! relay, so the handler sends nothing and a test reads the Fake's Trade Session instead. +//! Trade requests and durable outcomes through the Trade Store. The Relay sends trade status; +//! these tests check the Fake's Trade Session and the absence of direct replies. -use super::handlers::{handle_trade, TradeStore}; +use super::handlers::{Trade, TradeStore}; use super::*; use crate::stdb::ReducerCallError; use std::collections::{BTreeMap, BTreeSet}; @@ -194,12 +193,11 @@ impl TradeStore for TradeFake { } } -/// Send `msg` as `seat`, a Character of account 7. Phase 5 retargets only this helper. +/// Handle `msg` as `seat`, a Character of Account 7. fn run(store: &TradeFake, seat: u64, msg: impl Into) { - let mut conn = in_world_conn(7, seat); - let passed_on = - handle_trade(&SessionTx::with_depth(0).0, store, &mut conn, msg.into()).unwrap(); - assert!(passed_on.is_none(), "the trade family owns this opcode"); + let mut session = ProtocolSession::in_world(7, seat); + let reply = Trade::handle(store, &mut session, ProtocolRequest::Message(msg.into())).unwrap(); + assert!(reply.outbound.is_empty()); } /// Initiating a trade with a targeted player names the wire's target. @@ -399,19 +397,18 @@ fn cancel_trade_cancels_for_the_initiating_side_too() { #[test] fn transport_loss_ends_the_world_session() { let store = TradeFake::failing(ReducerCallError::transport_lost("gw_accept_trade")); - let mut conn = in_world_conn(7, 1); + let mut session = ProtocolSession::in_world(7, 1); let msg = CMSG_ACCEPT_TRADE { unknown1: 1 }; - let result = handle_trade(&SessionTx::with_depth(0).0, &store, &mut conn, msg.into()); + let result = Trade::handle(&store, &mut session, ProtocolRequest::Message(msg.into())); assert!(result.is_err()); } #[test] fn refusal_is_ignored_and_the_world_session_continues() { let store = TradeFake::failing(ReducerCallError::refused("gw_accept_trade", "no trade")); - let mut conn = in_world_conn(7, 1); + let mut session = ProtocolSession::in_world(7, 1); let msg = CMSG_ACCEPT_TRADE { unknown1: 1 }; - let passed_on = - handle_trade(&SessionTx::with_depth(0).0, &store, &mut conn, msg.into()).unwrap(); - assert!(passed_on.is_none(), "the trade family owns this opcode"); + let reply = Trade::handle(&store, &mut session, ProtocolRequest::Message(msg.into())).unwrap(); + assert!(reply.outbound.is_empty()); assert_eq!(store.accepted(), Vec::::new()); } diff --git a/gateway/src/world/trainer_tests.rs b/gateway/src/world/trainer_tests.rs index a73c66a09..042c95ce3 100644 --- a/gateway/src/world/trainer_tests.rs +++ b/gateway/src/world/trainer_tests.rs @@ -1,7 +1,6 @@ -//! Trainer and talent opcodes, run through `handle_trainer` against a Fake that holds only the -//! Character, Npc and Trainer Stores the handler is bounded on. +//! Trainer and talent replies through the Character, Npc, and Trainer Stores. -use super::handlers::{handle_trainer, CharacterStore, TrainerBuyOutcome, TrainerStore}; +use super::handlers::{CharacterStore, Trainer, TrainerBuyOutcome, TrainerStore}; use super::*; use crate::stdb::ReducerCallError; use lyracore_shared::constants::armor_proficiency::{ @@ -207,23 +206,18 @@ impl TrainerStore for TrainerFake { } } -/// Send `msg` as Character 1 of account 7. Returns the handler's verdict and what it sent, in order. -/// Phase 5 retargets only this helper. +/// Handle `msg` as Character 1 of Account 7 and return the replies in order. fn drive( store: &TrainerFake, msg: impl Into, -) -> (Result<()>, Vec) { - let (tx, rx) = SessionTx::with_depth(0); - let mut conn = in_world_conn(7, 1); - let verdict = handle_trainer(&tx, store, &mut conn, msg.into()) - .map(|passed_on| assert!(passed_on.is_none(), "the trainer family owns this opcode")); - (verdict, drain_outbound(&rx)) +) -> Result> { + let mut session = ProtocolSession::in_world(7, 1); + let reply = Trainer::handle(store, &mut session, ProtocolRequest::Message(msg.into()))?; + Ok(outbound_messages(reply.outbound)) } fn run(store: &TrainerFake, msg: impl Into) -> Vec { - let (verdict, sent) = drive(store, msg); - verdict.unwrap(); - sent + drive(store, msg).unwrap() } fn kinds(sent: &[ServerOpcodeMessage]) -> String { @@ -403,13 +397,8 @@ fn a_trainer_transport_loss_is_not_answered_as_a_refusal() { buy_error: Some(|| ReducerCallError::transport_lost("gw_trainer_buy").into()), ..Default::default() }; - let (verdict, sent) = drive(&store, buy(1234)); - verdict.expect_err("a lost trainer reducer transport must be session-fatal"); - assert!( - sent.is_empty(), - "nothing claims the purchase failed: [{}]", - kinds(&sent) - ); + drive(&store, buy(1234)) + .expect_err("a lost trainer Durable Request must end the World Session"); } /// A refused talent pick is per-action: the client hears nothing and the session goes on. A lost @@ -422,16 +411,14 @@ fn a_refused_talent_pick_keeps_the_session_and_a_transport_loss_ends_it() { }), ..Default::default() }; - let (verdict, sent) = drive(&refused, pick_talent()); - verdict.unwrap(); + let sent = drive(&refused, pick_talent()).unwrap(); assert!(sent.is_empty(), "got [{}]", kinds(&sent)); let lost = TrainerFake { talent_error: Some(|| ReducerCallError::transport_lost("gw_learn_talent").into()), ..Default::default() }; - let (verdict, _) = drive(&lost, pick_talent()); - verdict.expect_err("a lost talent pick must be session-fatal"); + drive(&lost, pick_talent()).expect_err("a lost talent pick must end the World Session"); } /// An ability talent (`grant_spell_id != 0`) pushes the granted spell so the new button works diff --git a/gateway/src/world/vendor_tests.rs b/gateway/src/world/vendor_tests.rs index e56eb9abd..69e1c5bab 100644 --- a/gateway/src/world/vendor_tests.rs +++ b/gateway/src/world/vendor_tests.rs @@ -1,21 +1,16 @@ -//! Vendor opcodes through their dispatcher, and the buyback ring at login. +//! Vendor replies through the Protocol Family and encrypted World Session. -use super::handlers::InMemoryVendorActions; +use super::handlers::{InMemoryVendorActions, Vendor}; use super::*; -const PLAYER: VendorActionPlayer = VendorActionPlayer { - account_id: 7, - self_guid: Some(1), -}; - -/// Dispatch one vendor message and return the packets the session would send for it. fn run(actions: &InMemoryVendorActions, msg: impl Into) -> Vec { - match dispatch_vendor_action(actions, PLAYER, msg.into()).unwrap() { - VendorActionOutcome::Handled { outbound } => outbound, - VendorActionOutcome::PassThrough(_) => { - panic!("the vendor dispatcher passed the message on") - } - } + Vendor::handle( + actions, + &mut ProtocolSession::in_world(7, 1), + ProtocolRequest::Message(msg.into()), + ) + .unwrap() + .outbound } #[test]