From cb7756f25756f94828616382694b72deb0073d83 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 17:22:26 +0200 Subject: [PATCH 01/33] refactor(gateway): classify durable failures and type the Actor Add `DurableFailure` and `classify`: only a reducer the Module rejected is a Refusal, and every other error is a Transport Loss. Fakes build both through `ReducerCallError::refused` and `transport_lost`. Add `Actor(NonZeroU64)`. `session_actor` takes an `Actor`, the deliberate guid-0 fallbacks call `owner_actor`, and the remaining raw guid call sites go through `actor_or_owner` until their families take an `Actor`. `request_gm_command` now uses `call_reducer!`. The GM handler renders the Refusal reason verbatim and ends the World Session on a Transport Loss. Add Transport Loss to the glossary. Co-Authored-By: Claude Opus 5.5 (1M context) --- CONTEXT.md | 10 +- CORE_TERMS.md | 8 +- gateway/src/realm_core.rs | 12 +- gateway/src/stdb/account_sessions.rs | 30 +- gateway/src/stdb/connection.rs | 98 ++- gateway/src/stdb/mod.rs | 3 + gateway/src/stdb/reducers.rs | 759 ++++++++---------- gateway/src/stdb/store/session.rs | 9 +- gateway/src/world/actor.rs | 29 + gateway/src/world/chat_tests.rs | 36 +- gateway/src/world/handlers/chat.rs | 5 +- gateway/src/world/handlers/query.rs | 11 +- gateway/src/world/mod.rs | 2 + .../world/test_support/world_fake/speech.rs | 15 +- 14 files changed, 546 insertions(+), 481 deletions(-) create mode 100644 gateway/src/world/actor.rs diff --git a/CONTEXT.md b/CONTEXT.md index 0a51f06e2..de87e2643 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -39,7 +39,7 @@ A World of Warcraft 1.12.1 server. Game state lives in SpacetimeDB; the gateway **S** [Script Answer](#runtime-scripts) · [Script Artifact](#packages) · [Script Diagnostic](#runtime-scripts) · [Script Directive](#packages) · [Seam](#working-method) · [Seeker](#meeting-stones) · [Self-Resurrection Option](#death-and-resurrection) · [Service Reconciliation](#realm-topology) · [Session](#accounts-characters-and-sessions) · [Session Expiry](#accounts-characters-and-sessions) · [SessionActor](#accounts-characters-and-sessions) · [Sessionless Action Consent](#sharding-and-transfer) · [Settlement](#auctions) · [Shard](#realm-topology) · [Shard Boundary](#sharding-and-transfer) · [Shard Map](#sharding-and-transfer) · [Signature](#guilds) · [Solo Target Claim](#sharding-and-transfer) · [Soulstone](#death-and-resurrection) · [Spatial Claim](#packages) · [Speaker Facts](#chat) · [Spec](#working-method) · [Speech](#chat) · [Spell Cast Event Kind](#procs) · [Staged Effect](#runtime-scripts) · [Standalone Supervisor](#realm-topology) · [Stat Kind](#random-properties) · [Stone Add](#meeting-stones) · [Store](#working-method) · [Strategy](#sharding-and-transfer) · [Subgroup](#sharding-and-transfer) · [Suffix](#random-properties) -**T** [Tabard Designer](#guilds) · [Target Icon](#sharding-and-transfer) · [Ticket](#working-method) · [Tracer](#working-method) · [Trade Commit](#trading) · [Trade Session](#trading) · [Transfer](#sharding-and-transfer) · [Transfer Intent](#sharding-and-transfer) · [Triggered Cast](#procs) · [Trust Review](#packages) +**T** [Tabard Designer](#guilds) · [Target Icon](#sharding-and-transfer) · [Ticket](#working-method) · [Tracer](#working-method) · [Trade Commit](#trading) · [Trade Session](#trading) · [Transfer](#sharding-and-transfer) · [Transfer Intent](#sharding-and-transfer) · [Transport Loss](#gateway-and-module) · [Triggered Cast](#procs) · [Trust Review](#packages) **U** [UI Transform](#client-content) @@ -178,9 +178,15 @@ _Avoid_: mutation, write, reducer call (in gateway prose) A read of Module state through the Coordinator. **Refusal**: -A Gate saying no to a Durable Request. An expected gameplay outcome, not a transport failure. +A Gate saying no to a Durable Request. An expected gameplay outcome, not a Transport Loss. _Avoid_: reject, deny, error (for gameplay refusals) +**Transport Loss**: +A failed Durable Read or Durable Request that is not a Refusal: the transport dropped, the call +timed out, or the send failed. Its durable outcome is unknown, so it ends the World Session and the +client logs in again from durable state. +_Avoid_: transport failure, fatal error, disconnect + ### Accounts, characters and sessions **Account**: diff --git a/CORE_TERMS.md b/CORE_TERMS.md index e83e2bb31..8d079b68e 100644 --- a/CORE_TERMS.md +++ b/CORE_TERMS.md @@ -49,9 +49,15 @@ A rule that refuses a request. Gates live in the Module, except the realm-wide r _Avoid_: validation, guard **Refusal**: -A Gate saying no to a Durable Request. An expected gameplay outcome, not a transport failure. +A Gate saying no to a Durable Request. An expected gameplay outcome, not a Transport Loss. _Avoid_: reject, deny, error (for gameplay refusals) +**Transport Loss**: +A failed Durable Read or Durable Request that is not a Refusal: the transport dropped, the call +timed out, or the send failed. Its durable outcome is unknown, so it ends the World Session and the +client logs in again from durable state. +_Avoid_: transport failure, fatal error, disconnect + **Durable Request**: A reducer call the Gateway makes that changes Module state. _Avoid_: mutation, write, reducer call (in gateway prose) diff --git a/gateway/src/realm_core.rs b/gateway/src/realm_core.rs index 0731484a3..8bc6a04c7 100644 --- a/gateway/src/realm_core.rs +++ b/gateway/src/realm_core.rs @@ -1060,7 +1060,10 @@ pub(crate) mod fake { if alpha_test_tools && text.starts_with(".speed") { Ok(()) } else { - Err(anyhow!("permission denied")) + Err( + crate::stdb::ReducerCallError::refused("gw_gm_command", "permission denied") + .into(), + ) } } fn character_location(&self, guid: u64) -> Option<(u32, u64)> { @@ -1370,7 +1373,12 @@ mod tests { let refusal = run_gm_command(&h, USER, 42, ".speed 4".into()) .expect_err("the same World Session must see the revocation"); - assert_eq!(refusal.to_string(), "permission denied"); + assert_eq!( + crate::stdb::classify(&refusal), + crate::stdb::DurableFailure::Refusal { + reason: "permission denied" + } + ); assert_eq!( h.db_at(WORLD).gm_commands.lock().unwrap().as_slice(), &[ diff --git a/gateway/src/stdb/account_sessions.rs b/gateway/src/stdb/account_sessions.rs index 4b87af8cf..eea251d6b 100644 --- a/gateway/src/stdb/account_sessions.rs +++ b/gateway/src/stdb/account_sessions.rs @@ -7,7 +7,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use super::bindings::*; use super::connection::{call_reducer, Coordinator}; -use crate::world::{SessionTx, WorldSessionToken as Token}; +use crate::world::{Actor, SessionTx, WorldSessionToken as Token}; pub(crate) struct SessionOwnership { token: Token, @@ -116,13 +116,25 @@ impl Coordinator { realm_account_of(owner.as_ref(), local_account.as_ref()) } - pub(crate) fn session_actor(&self, guid: u64) -> SessionActor { + /// `actor` acting under this handle's Account Claim. + pub(crate) fn session_actor(&self, actor: Actor) -> SessionActor { + self.signed_actor(actor.guid()) + } + + /// The bound owner's Character, for Durable Requests the Gateway makes on the World Session's + /// behalf rather than a client's. Guid 0 when no session is bound. + pub(crate) fn owner_actor(&self) -> SessionActor { + self.signed_actor(self.2.as_ref().map_or(0, |owner| owner.character_guid)) + } + + /// `guid` as the Actor, or the bound owner's Character when `guid` is 0. + pub(crate) fn actor_or_owner(&self, guid: u64) -> SessionActor { + Actor::new(guid).map_or_else(|| self.owner_actor(), |actor| self.session_actor(actor)) + } + + fn signed_actor(&self, guid: u64) -> SessionActor { SessionActor { - guid: if guid == 0 { - self.2.as_ref().map_or(0, |owner| owner.character_guid) - } else { - guid - }, + guid, ownership: self.2.as_ref().map(|owner| wire(owner.token)), } } @@ -690,7 +702,7 @@ mod tests { 1 ); let queued_before_takeover = GwMove { - actor: old.session_actor(1), + actor: old.session_actor(Actor::new(1).unwrap()), opcode: lyracore_shared::opcodes::movement::MSG_MOVE_HEARTBEAT as u16, movement_info: vec![], x: 500.0, @@ -727,7 +739,7 @@ mod tests { old.release_session(first).unwrap(); let batch = super::super::movement_batch::MovementBatch::new(); batch.push(GwMove { - actor: winner.session_actor(1), + actor: winner.session_actor(Actor::new(1).unwrap()), x: 100.0, move_time_ms: 100, ..queued_before_takeover.clone() diff --git a/gateway/src/stdb/connection.rs b/gateway/src/stdb/connection.rs index d7a297ec5..9273a7df2 100644 --- a/gateway/src/stdb/connection.rs +++ b/gateway/src/stdb/connection.rs @@ -522,6 +522,20 @@ impl ReducerCallError { pub(crate) fn fatal(message: String) -> Self { Self::Fatal(message) } + + /// A Module Gate refusing `operation` with `reason`, as a Fake reports it. + #[cfg(test)] + pub(crate) fn refused(operation: &str, reason: &str) -> Self { + Self::rejected(operation, reason.to_string()) + } + + /// The transport dropping `operation` mid-call, as a Fake reports it. + #[cfg(test)] + pub(crate) fn transport_lost(operation: &str) -> Self { + Self::fatal(format!( + "{operation} reducer failed: transport disconnected" + )) + } } impl std::fmt::Display for ReducerCallError { @@ -549,9 +563,23 @@ impl std::error::Error for ReducerCallError { } } -/// True only for an error returned deliberately by module gameplay rules. -pub(crate) fn is_reducer_refusal(error: &anyhow::Error) -> bool { - reducer_refusal_reason(error).is_some() +/// How a failed Durable Read or Durable Request ended, as the World Session must treat it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DurableFailure<'e> { + /// A Module Gate refused. `reason` is the Module's stable tag. Durable state is unchanged. + Refusal { reason: &'e str }, + /// The outcome is unknown: transport lost, timeout, SDK send or internal failure. + /// Ends the World Session. + TransportLoss, +} + +/// Fails closed: only a reducer the Module rejected is a Refusal. Any other error, typed or not, +/// leaves the durable outcome unknown. +pub(crate) fn classify(error: &anyhow::Error) -> DurableFailure<'_> { + match reducer_refusal_reason(error) { + Some(reason) => DurableFailure::Refusal { reason }, + None => DurableFailure::TransportLoss, + } } /// The Module's unwrapped refusal reason, preserving its stable gameplay prefix. @@ -2211,10 +2239,67 @@ mod pump_tests { } } +#[cfg(test)] +mod classify_tests { + use super::{classify, recv_reducer, DurableFailure, ReducerCallError}; + + #[test] + fn a_rejected_reducer_is_a_refusal_with_the_module_reason() { + let error = + anyhow::Error::from(ReducerCallError::refused("gw_buy_item", "vendor:no_money")) + .context("buy phase"); + assert_eq!( + classify(&error), + DurableFailure::Refusal { + reason: "vendor:no_money" + } + ); + } + + #[test] + fn an_sdk_internal_error_is_transport_loss() { + let error = anyhow::Error::from(ReducerCallError::internal( + "gw_buy_item", + spacetimedb_sdk::error::InternalError::failed_parse("ReducerResult", "Message"), + )); + assert_eq!(classify(&error), DurableFailure::TransportLoss); + } + + #[test] + fn a_failed_sdk_send_is_transport_loss() { + let error = anyhow::Error::from(ReducerCallError::sdk_send( + "gw_buy_item", + spacetimedb_sdk::Error::Disconnected, + )); + assert_eq!(classify(&error), DurableFailure::TransportLoss); + } + + #[test] + fn a_dropped_transport_is_transport_loss() { + let error = anyhow::Error::from(ReducerCallError::transport_lost("gw_buy_item")); + assert_eq!(classify(&error), DurableFailure::TransportLoss); + } + + #[test] + fn a_completion_that_never_arrives_is_transport_loss() { + let (tx, rx) = std::sync::mpsc::channel(); + drop(tx); + let error = recv_reducer(rx, "gw_buy_item").expect_err("no completion is an error"); + assert_eq!(error.to_string(), "gw_buy_item reducer timed out after 10s"); + assert_eq!(classify(&error), DurableFailure::TransportLoss); + } + + #[test] + fn an_untyped_error_is_transport_loss_even_when_it_reads_like_a_refusal() { + let error = anyhow::anyhow!("gw_buy_item reducer failed: vendor:no_money"); + assert_eq!(classify(&error), DurableFailure::TransportLoss); + } +} + #[cfg(test)] mod recv_reducer_tests { use super::{ - is_reducer_refusal, recv_reducer, reducer_refusal_reason, ReducerCompletion, + classify, recv_reducer, reducer_refusal_reason, DurableFailure, ReducerCompletion, ReducerCompletionFailure, }; use std::sync::mpsc; @@ -2243,7 +2328,6 @@ mod recv_reducer_tests { err.to_string(), "buy_item reducer failed: not enough copper" ); - assert!(is_reducer_refusal(&err)); assert_eq!(reducer_refusal_reason(&err), Some("not enough copper")); } @@ -2258,7 +2342,7 @@ mod recv_reducer_tests { let err = recv_reducer(rx, "gw_loot_money") .expect_err("an SDK InternalError must remain session-fatal"); - assert!(!is_reducer_refusal(&err)); + assert_eq!(classify(&err), DurableFailure::TransportLoss); assert!(err.to_string().starts_with("gw_loot_money reducer failed:")); } @@ -2275,7 +2359,7 @@ mod recv_reducer_tests { let error = recv_reducer(dead_rx, "gw_loot_money") .expect_err("a disconnected reducer completion must be fatal"); - assert!(!is_reducer_refusal(&error)); + assert_eq!(classify(&error), DurableFailure::TransportLoss); assert_eq!( error.to_string(), "gw_loot_money reducer failed: transport disconnected" diff --git a/gateway/src/stdb/mod.rs b/gateway/src/stdb/mod.rs index d40eace80..fe0319e08 100644 --- a/gateway/src/stdb/mod.rs +++ b/gateway/src/stdb/mod.rs @@ -36,6 +36,9 @@ mod world_store; // impl RealmDb for Coordinator pub(crate) mod world_view; // shared per-shard spatial, broadcast, private, and owner dispatch pub use connection::Coordinator; +#[cfg(test)] +pub(crate) use connection::ReducerCallError; +pub(crate) use connection::{classify, DurableFailure}; pub use subscriptions::PlayerSubscriptions; // Re-exported so `crate::stdb::{RealmRow, AccountRow}` resolves (they are the return types of // `Coordinator::realm` / `account_by_username`). `allow(unused_imports)` because in this *binary* diff --git a/gateway/src/stdb/reducers.rs b/gateway/src/stdb/reducers.rs index ff943b02e..2619da1ab 100644 --- a/gateway/src/stdb/reducers.rs +++ b/gateway/src/stdb/reducers.rs @@ -9,12 +9,12 @@ use std::sync::OnceLock; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use super::bindings::*; -use super::connection::{call_reducer, recv_reducer_on, reducer_refusal_reason, Coordinator}; +use super::connection::{call_reducer, reducer_refusal_reason, Coordinator}; use super::views::entity_view; use crate::world::guild_fee; use crate::world::party::{AdmittedCompanionCommand, CompanionCommandOutcome, PartyOutcome}; use crate::world::{ - ChannelOutcome, ChatOutcome, ContactOutcome, ItemActionResult, LootActionStatus, + Actor, ChannelOutcome, ChatOutcome, ContactOutcome, ItemActionResult, LootActionStatus, LootWindowRefusal, LootWindowRequestStatus, MeetingStoneOutcome, }; use lyracore_shared::auction::AuctionRefusal; @@ -75,7 +75,7 @@ impl Coordinator { source.map_id, source.instance_id, source.revision, - self.session_actor(0) + self.owner_actor() ) ) } @@ -330,7 +330,7 @@ impl Coordinator { call_reducer!( self.0.call_pipe().conn.reducers, "gw_ack_taxi_reply", - gw_ack_taxi_reply_then(self.session_actor(character_guid), request_id) + gw_ack_taxi_reply_then(self.actor_or_owner(character_guid), request_id) )?; if !reply.accepted { log::debug!( @@ -353,14 +353,14 @@ impl Coordinator { character_guid: u64, npc_guid: u64, ) -> Result> { - if character_guid == 0 { + let Some(actor) = Actor::new(character_guid) else { return Ok(None); - } + }; let request_id = next_taxi_request_id(); call_reducer!( self.0.call_pipe().conn.reducers, "gw_taxi_node_status", - gw_taxi_node_status_then(self.session_actor(character_guid), npc_guid, request_id) + gw_taxi_node_status_then(self.session_actor(actor), npc_guid, request_id) )?; Ok(self .await_taxi_reply( @@ -381,14 +381,14 @@ impl Coordinator { character_guid: u64, npc_guid: u64, ) -> Result> { - if character_guid == 0 { + let Some(actor) = Actor::new(character_guid) else { return Ok(None); - } + }; let request_id = next_taxi_request_id(); call_reducer!( self.0.call_pipe().conn.reducers, "gw_open_taxi", - gw_open_taxi_then(self.session_actor(character_guid), npc_guid, request_id) + gw_open_taxi_then(self.session_actor(actor), npc_guid, request_id) )?; Ok(self .await_taxi_reply( @@ -414,18 +414,18 @@ impl Coordinator { source_client_node_id: u32, destination_client_node_id: u32, ) -> Result { - if character_guid == 0 { + let Some(actor) = Actor::new(character_guid) else { return Ok(crate::codec::TaxiActivationResult { result_code: lyracore_shared::constants::taxi_protocol::ACTIVATE_UNSPECIFIED_SERVER_ERROR, }); - } + }; let request_id = next_taxi_request_id(); call_reducer!( self.0.call_pipe().conn.reducers, "gw_activate_taxi", gw_activate_taxi_then( - self.session_actor(character_guid), + self.session_actor(actor), npc_guid, source_client_node_id, destination_client_node_id, @@ -449,7 +449,7 @@ impl Coordinator { call_reducer!( self.0.call_pipe().conn.reducers, "gw_arm_taxi_flight", - gw_arm_taxi_flight_then(self.session_actor(character_guid)) + gw_arm_taxi_flight_then(self.actor_or_owner(character_guid)) ) } @@ -567,7 +567,7 @@ impl Coordinator { "gw_auction_list_local", gw_auction_list_local_then( operation_id, - self.session_actor(request.actor_guid), + self.actor_or_owner(request.actor_guid), request.item_guid, request.auctioneer_guid, request.house_id, @@ -588,7 +588,7 @@ impl Coordinator { "gw_auction_hold_listing", gw_auction_hold_listing_then( operation_id, - self.session_actor(request.actor_guid), + self.actor_or_owner(request.actor_guid), request.item_guid, request.auctioneer_guid, request.house_id, @@ -605,7 +605,7 @@ impl Coordinator { "realm_auction_commit_listing", realm_auction_commit_listing_then( hold.operation_id, - self.session_actor(hold.seller_guid), + self.actor_or_owner(hold.seller_guid), hold.item_guid, hold.item_entry, hold.item_stack_count, @@ -631,7 +631,7 @@ impl Coordinator { call_reducer!( self.0.call_pipe().conn.reducers, "realm_auction_confirm_listing", - realm_auction_confirm_listing_then(operation_id, auction_id, self.session_actor(0)) + realm_auction_confirm_listing_then(operation_id, auction_id, self.owner_actor()) ) } @@ -639,7 +639,7 @@ impl Coordinator { call_reducer!( self.0.call_pipe().conn.reducers, "realm_auction_settle_listing", - realm_auction_settle_listing_then(operation_id, self.session_actor(0)) + realm_auction_settle_listing_then(operation_id, self.owner_actor()) ) } @@ -649,7 +649,7 @@ impl Coordinator { "realm_auction_refund_listing", realm_auction_refund_listing_then( hold.operation_id, - self.session_actor(hold.seller_guid), + self.actor_or_owner(hold.seller_guid), hold.item_guid, hold.item_entry, hold.item_stack_count, @@ -677,7 +677,7 @@ impl Coordinator { "gw_auction_release_listing_hold", gw_auction_release_listing_hold_then( hold.operation_id, - self.session_actor(hold.seller_guid) + self.actor_or_owner(hold.seller_guid) ) ) } @@ -890,7 +890,7 @@ impl Coordinator { "gw_auction_bid_local", gw_auction_bid_local_then( operation_id, - self.session_actor(request.actor_guid), + self.actor_or_owner(request.actor_guid), request.auctioneer_guid, request.auction_id, request.house_id, @@ -909,7 +909,7 @@ impl Coordinator { "gw_auction_hold_bid", gw_auction_hold_bid_then( operation_id, - self.session_actor(request.actor_guid), + self.actor_or_owner(request.actor_guid), request.auctioneer_guid, request.auction_id, request.house_id, @@ -929,7 +929,7 @@ impl Coordinator { "gw_auction_hold_cancel", gw_auction_hold_cancel_then( operation_id, - self.session_actor(request.actor_guid), + self.actor_or_owner(request.actor_guid), request.auctioneer_guid, request.auction_id, request.house_id, @@ -949,7 +949,7 @@ impl Coordinator { "gw_auction_cancel_local", gw_auction_cancel_local_then( operation_id, - self.session_actor(request.actor_guid), + self.actor_or_owner(request.actor_guid), request.auctioneer_guid, request.auction_id, request.house_id, @@ -967,7 +967,7 @@ impl Coordinator { "realm_auction_decide_bid", realm_auction_decide_bid_then( hold.operation_id, - self.session_actor(hold.bidder_guid), + self.actor_or_owner(hold.bidder_guid), hold.auction_id, hold.house, hold.offer @@ -978,7 +978,7 @@ impl Coordinator { "realm_auction_decide_cancel", realm_auction_decide_cancel_then( hold.operation_id, - self.session_actor(hold.bidder_guid), + self.actor_or_owner(hold.bidder_guid), hold.auction_id, hold.house, hold.offer @@ -1098,7 +1098,7 @@ impl Coordinator { "gw_auction_finish_bid", gw_auction_finish_bid_then( hold.operation_id, - self.session_actor(hold.bidder_guid), + self.actor_or_owner(hold.bidder_guid), hold.auction_id, hold.house, hold.offer, @@ -1118,7 +1118,7 @@ impl Coordinator { "realm_auction_refund_bid", realm_auction_refund_bid_then( hold.operation_id, - self.session_actor(hold.bidder_guid), + self.actor_or_owner(hold.bidder_guid), hold.auction_id, hold.house, hold.offer, @@ -1133,7 +1133,7 @@ impl Coordinator { "gw_auction_confirm_bid_refund", gw_auction_confirm_bid_refund_then( hold.operation_id, - self.session_actor(hold.bidder_guid), + self.actor_or_owner(hold.bidder_guid), hold.auction_id, hold.house, hold.offer, @@ -1235,7 +1235,7 @@ impl Coordinator { // fail-closed on either missing) — no per-player connection exists anywhere. A world-port // rides its twin `gw_player_world_port`, which keeps the Away Status. let coord = self.0.call_pipe(); - let actor = self.session_actor(character_guid); + let actor = self.actor_or_owner(character_guid); match entry { crate::codec::WorldEntry::FreshLogin => call_reducer!( coord.conn.reducers, @@ -1409,7 +1409,7 @@ impl Coordinator { let result = call_reducer!( self.0.call_pipe().conn.reducers, "delete_character", - delete_character_then(account_id, self.session_actor(character_guid)) + delete_character_then(account_id, self.actor_or_owner(character_guid)) ); Ok(match result { Ok(()) => CharDeleteOutcome::Success, @@ -1471,7 +1471,7 @@ impl Coordinator { character_guid, map_id, instance_id, - self.session_actor(character_guid) + self.actor_or_owner(character_guid) ) ) } @@ -1503,7 +1503,7 @@ impl Coordinator { source_module_identity, intent_id, controller_generation, - self.session_actor(character_guid) + self.actor_or_owner(character_guid) ) ) } @@ -1525,7 +1525,7 @@ impl Coordinator { intent.source_module_identity, intent.id, intent.controller_generation, - self.session_actor(intent.bot_guid) + self.actor_or_owner(intent.bot_guid) ) ) } @@ -1552,7 +1552,7 @@ impl Coordinator { spacetimedb_sdk::Identity::ZERO, 0, 0, - self.session_actor(character_guid) + self.actor_or_owner(character_guid) ) ) } @@ -1584,7 +1584,7 @@ impl Coordinator { source_module_identity, transfer_intent_id, controller_generation, - self.session_actor(character_guid) + self.actor_or_owner(character_guid) ) ) } @@ -1613,14 +1613,13 @@ impl Coordinator { /// Set the player's current target (`CMSG_SET_SELECTION`, Tier 2 / N3) over the coordinator /// connection so the module attributes it to the caller. `target_guid` 0 clears it. pub fn set_target(&self, _account_id: u64, actor_guid: u64, target_guid: u64) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("set_target: actor_guid unresolved")); - } + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("set_target: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_set_target", - gw_set_target_then(self.session_actor(actor_guid), target_guid) + gw_set_target_then(self.session_actor(actor), target_guid) ) } @@ -1628,14 +1627,13 @@ impl Coordinator { /// range, friendly) over the coordinator connection so the module resolves the caller from /// `ctx.sender`. `Err` (out of range / hostile / no such target) → the caller ignores it. pub fn inspect(&self, _account_id: u64, actor_guid: u64, target_guid: u64) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("inspect: actor_guid unresolved")); - } + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("inspect: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_inspect", - gw_inspect_then(self.session_actor(actor_guid), target_guid) + gw_inspect_then(self.session_actor(actor), target_guid) ) } @@ -1648,14 +1646,13 @@ impl Coordinator { actor_guid: u64, go_guid: u64, ) -> Result { - if actor_guid == 0 { - return Err(anyhow!("use_gameobject: actor_guid unresolved")); - } + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("use_gameobject: actor_guid unresolved"))?; let coord = self.0.call_pipe(); legacy_loot_request_status(call_reducer!( coord.conn.reducers, "gw_use_gameobject", - gw_use_gameobject_then(self.session_actor(actor_guid), go_guid) + gw_use_gameobject_then(self.session_actor(actor), go_guid) )) } @@ -1666,14 +1663,13 @@ impl Coordinator { actor_guid: u64, trigger_id: u32, ) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("enter_areatrigger: actor_guid unresolved")); - } + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("enter_areatrigger: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_enter_areatrigger", - gw_enter_areatrigger_then(self.session_actor(actor_guid), trigger_id) + gw_enter_areatrigger_then(self.session_actor(actor), trigger_id) ) } @@ -1685,28 +1681,26 @@ impl Coordinator { cmd: String, payload: String, ) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("client_command: actor_guid unresolved")); - } + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("client_command: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_client_command", - gw_client_command_then(self.session_actor(actor_guid), cmd, payload) + gw_client_command_then(self.session_actor(actor), cmd, payload) ) } /// Start the player's melee auto-attack on `target_guid` (`CMSG_ATTACKSWING`, combat C1) over /// the coordinator connection so the module attributes the swing to the caller. pub fn start_attack(&self, _account_id: u64, actor_guid: u64, target_guid: u64) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("start_attack: actor_guid unresolved")); - } + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("start_attack: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_attack", - gw_attack_then(self.session_actor(actor_guid), target_guid) + gw_attack_then(self.session_actor(actor), target_guid) ) } @@ -1720,14 +1714,13 @@ impl Coordinator { data: u32, target_guid: u64, ) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("pet_command: actor_guid unresolved")); - } + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("pet_command: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_pet_command", - gw_pet_command_then(self.session_actor(actor_guid), data, target_guid) + gw_pet_command_then(self.session_actor(actor), data, target_guid) ) } @@ -1741,40 +1734,37 @@ impl Coordinator { target_guid: u64, spell_id: u32, ) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("start_ranged_attack: actor_guid unresolved")); - } + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("start_ranged_attack: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_ranged_attack", - gw_ranged_attack_then(self.session_actor(actor_guid), target_guid, spell_id) + gw_ranged_attack_then(self.session_actor(actor), target_guid, spell_id) ) } /// Stop the player's melee auto-attack (`CMSG_ATTACKSTOP`, combat C1). pub fn stop_attack(&self, _account_id: u64, actor_guid: u64) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("stop_attack: actor_guid unresolved")); - } + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("stop_attack: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_stop_attack", - gw_stop_attack_then(self.session_actor(actor_guid)) + gw_stop_attack_then(self.session_actor(actor)) ) } /// Draw or stow the player's weapons (`CMSG_SETSHEATHED`). pub fn set_sheathed(&self, _account_id: u64, actor_guid: u64, state: u8) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("set_sheathed: actor_guid unresolved")); - } + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("set_sheathed: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_set_sheathed", - gw_set_sheathed_then(self.session_actor(actor_guid), state) + gw_set_sheathed_then(self.session_actor(actor), state) ) } @@ -1788,14 +1778,13 @@ impl Coordinator { spell_id: u32, target_guid: u64, ) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("cast_spell: actor_guid unresolved")); - } + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("cast_spell: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_cast_spell", - gw_cast_spell_then(self.session_actor(actor_guid), spell_id, target_guid) + gw_cast_spell_then(self.session_actor(actor), spell_id, target_guid) ) } @@ -1807,14 +1796,13 @@ impl Coordinator { spell_id: u32, slot: u8, ) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("cast_item_target: actor_guid unresolved")); - } + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("cast_item_target: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_cast_item_target", - gw_cast_item_target_then(self.session_actor(actor_guid), spell_id, slot) + gw_cast_item_target_then(self.session_actor(actor), spell_id, slot) ) } @@ -1833,49 +1821,39 @@ impl Coordinator { y: f32, z: f32, ) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("cast_spell_at: actor_guid unresolved")); - } + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("cast_spell_at: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_cast_spell_at", - gw_cast_spell_at_then( - self.session_actor(actor_guid), - spell_id, - target_guid, - x, - y, - z - ) + gw_cast_spell_at_then(self.session_actor(actor), spell_id, target_guid, x, y, z) ) } /// Cancel one of the caller's own auras by spell id (`CMSG_CANCEL_AURA`) over the coordinator /// connection so the module attributes the removal to the caller. pub fn cancel_aura(&self, _account_id: u64, actor_guid: u64, spell_id: u32) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("cancel_aura: actor_guid unresolved")); - } + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("cancel_aura: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_cancel_aura", - gw_cancel_aura_then(self.session_actor(actor_guid), spell_id) + gw_cancel_aura_then(self.session_actor(actor), spell_id) ) } /// Cancel the caller's in-progress cast (`CMSG_CANCEL_CAST`) over the coordinator connection so the /// module clears the caller's pending cast — no phantom completion GO. pub fn cancel_cast(&self, _account_id: u64, actor_guid: u64) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("cancel_cast: actor_guid unresolved")); - } + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("cancel_cast: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_cancel_cast", - gw_cancel_cast_then(self.session_actor(actor_guid)) + gw_cancel_cast_then(self.session_actor(actor)) ) } @@ -1889,14 +1867,13 @@ impl Coordinator { language: u8, message: String, ) -> Result { - if actor_guid == 0 { - return Err(anyhow!("send_chat: actor_guid unresolved")); - } + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("send_chat: actor_guid unresolved"))?; let coord = self.0.call_pipe(); chat_outcome(call_reducer!( coord.conn.reducers, "gw_send_chat", - gw_send_chat_then(self.session_actor(actor_guid), chat_type, language, message) + gw_send_chat_then(self.session_actor(actor), chat_type, language, message) )) } @@ -1908,15 +1885,14 @@ impl Coordinator { emote_anim: u32, target_guid: u64, ) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("send_emote: actor_guid unresolved")); - } + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("send_emote: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_send_emote", gw_send_emote_then( - self.session_actor(actor_guid), + self.session_actor(actor), text_emote, emote_anim, target_guid @@ -1930,7 +1906,7 @@ impl Coordinator { call_reducer!( self.0.call_pipe().conn.reducers, "gw_set_away", - gw_set_away_then(self.session_actor(actor_guid), kind, message) + gw_set_away_then(self.actor_or_owner(actor_guid), kind, message) ) } @@ -1956,7 +1932,7 @@ impl Coordinator { chat_outcome(call_reducer!( realm.0.call_pipe().conn.reducers, "realm_chat", - realm_chat_then(realm.session_actor(speaker_guid), request) + realm_chat_then(realm.actor_or_owner(speaker_guid), request) )) } @@ -1988,7 +1964,7 @@ impl Coordinator { chat_outcome(call_reducer!( realm.0.call_pipe().conn.reducers, "realm_whisper", - realm_whisper_then(realm.session_actor(speaker_guid), request) + realm_whisper_then(realm.actor_or_owner(speaker_guid), request) )) } @@ -2016,7 +1992,7 @@ impl Coordinator { channel_outcome(call_reducer!( realm.0.call_pipe().conn.reducers, "realm_channel_op", - realm_channel_op_then(realm.session_actor(actor_guid), op, request) + realm_channel_op_then(realm.actor_or_owner(actor_guid), op, request) )) } @@ -2030,7 +2006,7 @@ impl Coordinator { meeting_stone_outcome(call_reducer!( coord.conn.reducers, "gw_admit_meeting_stone", - gw_admit_meeting_stone_then(self.session_actor(actor_guid), go_guid) + gw_admit_meeting_stone_then(self.actor_or_owner(actor_guid), go_guid) )) } @@ -2055,7 +2031,7 @@ impl Coordinator { meeting_stone_outcome(call_reducer!( realm.0.call_pipe().conn.reducers, "realm_meeting_stone_op", - realm_meeting_stone_op_then(op, realm.session_actor(actor_guid), area_id, seekers) + realm_meeting_stone_op_then(op, realm.actor_or_owner(actor_guid), area_id, seekers) )) } @@ -2066,79 +2042,33 @@ impl Coordinator { } /// Send one classified command request to this Home Shard. The Module combines the conveyed - /// Account authority with its own Character GM level and remains the final Gate. - /// Deliberately does NOT use the `call_reducer!` macro: that macro wraps a module `Err` as - /// `"{what} reducer failed: {e}"` (fine when a caller only reads its Refusal tag), but the Say - /// handler relays this `Err`'s text VERBATIM to the sender as a system chat line — a raw - /// `"permission denied"` / `"unknown command:.foo"` must reach the client with no wrapper - /// prefix. + /// Account authority with its own Character GM level and remains the final Gate. Its Refusal + /// reason is the text the GM sees. pub(crate) fn request_gm_command( &self, actor_guid: u64, alpha_test_tools: bool, text: String, ) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("gm_command: actor_guid unresolved")); - } - let (tx, rx) = std::sync::mpsc::channel::< - std::result::Result<(), super::connection::ReducerCompletionFailure>, - >(); - // Raw-module-message plumbing: the GM console renders the module's own rejection text - // ("permission denied", parse errors) verbatim, no "reducer failed" wrapper. - let coord = self.0.call_pipe(); - let completion = coord.reducer_completion.clone(); - let call_id = completion - .register(tx) - .map_err(|e| anyhow!("gm_command reducer transport disconnected: {e}"))?; - let callback_completion = completion.clone(); - coord - .conn - .reducers - .gw_gm_command_then( - self.session_actor(actor_guid), - alpha_test_tools, - text, - move |_ctx, status| { - callback_completion.finish( - call_id, - match status { - Ok(Ok(())) => Ok(()), - Ok(Err(reason)) => Err( - super::connection::ReducerCompletionFailure::Rejected(reason), - ), - Err(error) => { - Err(super::connection::ReducerCompletionFailure::Internal(error)) - } - }, - ); - }, - ) - .map_err(|e| { - completion.cancel(call_id); - super::connection::ReducerCallError::sdk_send("gw_gm_command", e) - })?; - match recv_reducer_on(rx, "gm_command", &completion, call_id) { - Ok(()) => Ok(()), - Err(e) if super::connection::is_reducer_refusal(&e) => Err(anyhow!(e - .to_string() - .trim_start_matches("gm_command reducer failed: ") - .to_string())), - Err(e) => Err(e), - } + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("gm_command: actor_guid unresolved"))?; + call_reducer!( + self.0.call_pipe().conn.reducers, + "gw_gm_command", + gw_gm_command_then(self.session_actor(actor), alpha_test_tools, text) + ) } /// `CMSG_PUSHQUESTTOPARTY` — over the coordinator connection so the module /// attributes the sender + its grouped/on-quest gates to the caller. pub fn push_quest(&self, _account_id: u64, actor_guid: u64, quest_id: u32) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("push_quest: actor_guid unresolved")); - } + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("push_quest: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_push_quest_to_party", - gw_push_quest_to_party_then(self.session_actor(actor_guid), quest_id) + gw_push_quest_to_party_then(self.session_actor(actor), quest_id) ) } @@ -2149,14 +2079,13 @@ impl Coordinator { actor_guid: u64, target_guid: u64, ) -> Result { - if actor_guid == 0 { - return Err(anyhow!("group_invite: actor_guid unresolved")); - } + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("group_invite: actor_guid unresolved"))?; let coord = self.0.call_pipe(); party_outcome(call_reducer!( coord.conn.reducers, "gw_group_invite", - gw_group_invite_then(self.session_actor(actor_guid), target_guid) + gw_group_invite_then(self.session_actor(actor), target_guid) )) } @@ -2167,64 +2096,59 @@ impl Coordinator { actor_guid: u64, target_guid: u64, ) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("initiate_trade: actor_guid unresolved")); - } + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("initiate_trade: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_initiate_trade", - gw_initiate_trade_then(self.session_actor(actor_guid), target_guid) + gw_initiate_trade_then(self.session_actor(actor), target_guid) ) } /// `CMSG_BEGIN_TRADE`. pub fn begin_trade(&self, _account_id: u64, actor_guid: u64) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("begin_trade: actor_guid unresolved")); - } + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("begin_trade: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_begin_trade", - gw_begin_trade_then(self.session_actor(actor_guid)) + gw_begin_trade_then(self.session_actor(actor)) ) } /// `CMSG_CANCEL_TRADE`. pub fn cancel_trade(&self, _account_id: u64, actor_guid: u64) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("cancel_trade: actor_guid unresolved")); - } + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("cancel_trade: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_cancel_trade", - gw_cancel_trade_then(self.session_actor(actor_guid)) + gw_cancel_trade_then(self.session_actor(actor)) ) } pub fn duel_accept(&self, _account_id: u64, actor_guid: u64, flag_guid: u64) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("duel_accept: actor_guid unresolved")); - } + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("duel_accept: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_duel_accept", - gw_duel_accept_then(self.session_actor(actor_guid), flag_guid) + gw_duel_accept_then(self.session_actor(actor), flag_guid) ) } pub fn duel_cancel(&self, _account_id: u64, actor_guid: u64, flag_guid: u64) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("duel_cancel: actor_guid unresolved")); - } + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("duel_cancel: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_duel_cancel", - gw_duel_cancel_then(self.session_actor(actor_guid), flag_guid) + gw_duel_cancel_then(self.session_actor(actor), flag_guid) ) } @@ -2236,14 +2160,13 @@ impl Coordinator { trade_slot: u8, inv_slot: u8, ) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("set_trade_item: actor_guid unresolved")); - } + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("set_trade_item: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_set_trade_item", - gw_set_trade_item_then(self.session_actor(actor_guid), trade_slot, inv_slot) + gw_set_trade_item_then(self.session_actor(actor), trade_slot, inv_slot) ) } @@ -2254,119 +2177,110 @@ impl Coordinator { actor_guid: u64, trade_slot: u8, ) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("clear_trade_item: actor_guid unresolved")); - } + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("clear_trade_item: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_clear_trade_item", - gw_clear_trade_item_then(self.session_actor(actor_guid), trade_slot) + gw_clear_trade_item_then(self.session_actor(actor), trade_slot) ) } /// `CMSG_SET_TRADE_GOLD`. pub fn set_trade_gold(&self, _account_id: u64, actor_guid: u64, copper: u32) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("set_trade_gold: actor_guid unresolved")); - } + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("set_trade_gold: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_set_trade_gold", - gw_set_trade_gold_then(self.session_actor(actor_guid), copper) + gw_set_trade_gold_then(self.session_actor(actor), copper) ) } /// `CMSG_ACCEPT_TRADE`. pub fn accept_trade(&self, _account_id: u64, actor_guid: u64) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("accept_trade: actor_guid unresolved")); - } + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("accept_trade: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_accept_trade", - gw_accept_trade_then(self.session_actor(actor_guid)) + gw_accept_trade_then(self.session_actor(actor)) ) } /// `CMSG_UNACCEPT_TRADE`. pub fn unaccept_trade(&self, _account_id: u64, actor_guid: u64) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("unaccept_trade: actor_guid unresolved")); - } + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("unaccept_trade: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_unaccept_trade", - gw_unaccept_trade_then(self.session_actor(actor_guid)) + gw_unaccept_trade_then(self.session_actor(actor)) ) } /// `CMSG_BUSY_TRADE`. pub fn busy_trade(&self, _account_id: u64, actor_guid: u64) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("busy_trade: actor_guid unresolved")); - } + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("busy_trade: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_busy_trade", - gw_busy_trade_then(self.session_actor(actor_guid)) + gw_busy_trade_then(self.session_actor(actor)) ) } /// `CMSG_IGNORE_TRADE`. pub fn ignore_trade(&self, _account_id: u64, actor_guid: u64) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("ignore_trade: actor_guid unresolved")); - } + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("ignore_trade: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_ignore_trade", - gw_ignore_trade_then(self.session_actor(actor_guid)) + gw_ignore_trade_then(self.session_actor(actor)) ) } /// `CMSG_GROUP_ACCEPT`. Rides the coordinator connection as /// `gw_accept_group_invite`. pub fn group_accept(&self, _account_id: u64, actor_guid: u64) -> Result { - if actor_guid == 0 { - return Err(anyhow!("group_accept: actor_guid unresolved")); - } + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("group_accept: actor_guid unresolved"))?; let coord = self.0.call_pipe(); party_outcome(call_reducer!( coord.conn.reducers, "gw_accept_group_invite", - gw_accept_group_invite_then(self.session_actor(actor_guid)) + gw_accept_group_invite_then(self.session_actor(actor)) )) } /// `CMSG_GROUP_DECLINE`. pub fn group_decline(&self, _account_id: u64, actor_guid: u64) -> Result { - if actor_guid == 0 { - return Err(anyhow!("group_decline: actor_guid unresolved")); - } + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("group_decline: actor_guid unresolved"))?; let coord = self.0.call_pipe(); party_outcome(call_reducer!( coord.conn.reducers, "gw_group_decline", - gw_group_decline_then(self.session_actor(actor_guid)) + gw_group_decline_then(self.session_actor(actor)) )) } /// `CMSG_GROUP_DISBAND` — leave the caller's group. pub fn group_leave(&self, _account_id: u64, actor_guid: u64) -> Result { - if actor_guid == 0 { - return Err(anyhow!("group_leave: actor_guid unresolved")); - } + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("group_leave: actor_guid unresolved"))?; let coord = self.0.call_pipe(); party_outcome(call_reducer!( coord.conn.reducers, "gw_group_leave", - gw_group_leave_then(self.session_actor(actor_guid)) + gw_group_leave_then(self.session_actor(actor)) )) } @@ -2378,14 +2292,13 @@ impl Coordinator { actor_guid: u64, target_guid: u64, ) -> Result { - if actor_guid == 0 { - return Err(anyhow!("group_uninvite: actor_guid unresolved")); - } + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("group_uninvite: actor_guid unresolved"))?; let coord = self.0.call_pipe(); party_outcome(call_reducer!( coord.conn.reducers, "gw_group_uninvite", - gw_group_uninvite_then(self.session_actor(actor_guid), target_guid) + gw_group_uninvite_then(self.session_actor(actor), target_guid) )) } @@ -2401,15 +2314,14 @@ impl Coordinator { master_guid: u64, loot_threshold: u8, ) -> Result { - if actor_guid == 0 { - return Err(anyhow!("group_loot_method: actor_guid unresolved")); - } + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("group_loot_method: actor_guid unresolved"))?; let coord = self.0.call_pipe(); party_outcome(call_reducer!( coord.conn.reducers, "gw_group_loot_method", gw_group_loot_method_then( - self.session_actor(actor_guid), + self.session_actor(actor), loot_setting, master_guid, loot_threshold @@ -2427,15 +2339,14 @@ impl Coordinator { option_id: u32, option_row_id: u32, ) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("gossip_select: actor_guid unresolved")); - } + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("gossip_select: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_gossip_select", gw_gossip_select_then( - self.session_actor(actor_guid), + self.session_actor(actor), npc_guid, option_id, option_row_id @@ -2452,14 +2363,13 @@ impl Coordinator { target_guid: u64, target_race: u8, ) -> Result { - if actor_guid == 0 { - return Err(anyhow!("add_friend: actor_guid unresolved")); - } + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("add_friend: actor_guid unresolved"))?; let coord = self.0.call_pipe(); contact_outcome(call_reducer!( coord.conn.reducers, "gw_add_friend", - gw_add_friend_then(self.session_actor(actor_guid), target_guid, target_race) + gw_add_friend_then(self.session_actor(actor), target_guid, target_race) )) } @@ -2470,14 +2380,13 @@ impl Coordinator { actor_guid: u64, target_guid: u64, ) -> Result { - if actor_guid == 0 { - return Err(anyhow!("del_friend: actor_guid unresolved")); - } + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("del_friend: actor_guid unresolved"))?; let coord = self.0.call_pipe(); contact_outcome(call_reducer!( coord.conn.reducers, "gw_del_friend", - gw_del_friend_then(self.session_actor(actor_guid), target_guid) + gw_del_friend_then(self.session_actor(actor), target_guid) )) } @@ -2488,14 +2397,13 @@ impl Coordinator { actor_guid: u64, target_guid: u64, ) -> Result { - if actor_guid == 0 { - return Err(anyhow!("add_ignore: actor_guid unresolved")); - } + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("add_ignore: actor_guid unresolved"))?; let coord = self.0.call_pipe(); contact_outcome(call_reducer!( coord.conn.reducers, "gw_add_ignore", - gw_add_ignore_then(self.session_actor(actor_guid), target_guid) + gw_add_ignore_then(self.session_actor(actor), target_guid) )) } @@ -2506,14 +2414,13 @@ impl Coordinator { actor_guid: u64, target_guid: u64, ) -> Result { - if actor_guid == 0 { - return Err(anyhow!("del_ignore: actor_guid unresolved")); - } + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("del_ignore: actor_guid unresolved"))?; let coord = self.0.call_pipe(); contact_outcome(call_reducer!( coord.conn.reducers, "gw_del_ignore", - gw_del_ignore_then(self.session_actor(actor_guid), target_guid) + gw_del_ignore_then(self.session_actor(actor), target_guid) )) } @@ -2525,14 +2432,13 @@ impl Coordinator { actor_guid: u64, target_guid: u64, ) -> Result { - if actor_guid == 0 { - return Err(anyhow!("loot_money: actor_guid unresolved")); - } + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("loot_money: actor_guid unresolved"))?; let coord = self.0.call_pipe(); strict_loot_request_status(call_reducer!( coord.conn.reducers, "gw_loot_money", - gw_loot_money_then(self.session_actor(actor_guid), target_guid) + gw_loot_money_then(self.session_actor(actor), target_guid) )) } @@ -2543,14 +2449,13 @@ impl Coordinator { actor_guid: u64, corpse_guid: u64, ) -> Result { - if actor_guid == 0 { - return Err(anyhow!("open_creature_loot: actor_guid unresolved")); - } + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("open_creature_loot: actor_guid unresolved"))?; let coord = self.0.call_pipe(); strict_loot_request_status(call_reducer!( coord.conn.reducers, "gw_open_creature_loot", - gw_open_creature_loot_then(self.session_actor(actor_guid), corpse_guid) + gw_open_creature_loot_then(self.session_actor(actor), corpse_guid) )) } @@ -2565,14 +2470,13 @@ impl Coordinator { corpse_guid: u64, loot_slot: u8, ) -> Result { - if actor_guid == 0 { - return Err(anyhow!("take_loot: actor_guid unresolved")); - } + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("take_loot: actor_guid unresolved"))?; let coord = self.0.call_pipe(); strict_loot_request_status(call_reducer!( coord.conn.reducers, "gw_take_loot", - gw_take_loot_then(self.session_actor(actor_guid), corpse_guid, loot_slot) + gw_take_loot_then(self.session_actor(actor), corpse_guid, loot_slot) )) } @@ -2582,14 +2486,13 @@ impl Coordinator { actor_guid: u64, corpse_guid: u64, ) -> Result { - if actor_guid == 0 { - return Err(anyhow!("skin_corpse: actor_guid unresolved")); - } + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("skin_corpse: actor_guid unresolved"))?; let coord = self.0.call_pipe(); legacy_loot_request_status(call_reducer!( coord.conn.reducers, "gw_skin", - gw_skin_then(self.session_actor(actor_guid), corpse_guid) + gw_skin_then(self.session_actor(actor), corpse_guid) )) } @@ -2604,14 +2507,13 @@ impl Coordinator { loot_slot: u32, vote: u8, ) -> Result { - if actor_guid == 0 { - return Err(anyhow!("loot_roll: actor_guid unresolved")); - } + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("loot_roll: actor_guid unresolved"))?; let coord = self.0.call_pipe(); loot_action_status(call_reducer!( coord.conn.reducers, "gw_loot_roll", - gw_loot_roll_then(self.session_actor(actor_guid), corpse_guid, loot_slot, vote) + gw_loot_roll_then(self.session_actor(actor), corpse_guid, loot_slot, vote) )) } @@ -2625,15 +2527,14 @@ impl Coordinator { loot_slot: u8, target_guid: u64, ) -> Result { - if actor_guid == 0 { - return Err(anyhow!("loot_master_give: actor_guid unresolved")); - } + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("loot_master_give: actor_guid unresolved"))?; let coord = self.0.call_pipe(); loot_action_status(call_reducer!( coord.conn.reducers, "gw_loot_master_give", gw_loot_master_give_then( - self.session_actor(actor_guid), + self.session_actor(actor), corpse_guid, loot_slot, target_guid @@ -2642,14 +2543,13 @@ impl Coordinator { } pub fn disenchant_item(&self, _account_id: u64, actor_guid: u64, slot: u8) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("disenchant_item: actor_guid unresolved")); - } + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("disenchant_item: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_disenchant", - gw_disenchant_then(self.session_actor(actor_guid), slot) + gw_disenchant_then(self.session_actor(actor), slot) ) } @@ -2660,14 +2560,13 @@ impl Coordinator { slot: u8, enchant_id: u32, ) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("enchant_item_on_slot: actor_guid unresolved")); - } + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("enchant_item_on_slot: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_enchant_item", - gw_enchant_item_then(self.session_actor(actor_guid), slot, enchant_id) + gw_enchant_item_then(self.session_actor(actor), slot, enchant_id) ) } @@ -2682,19 +2581,13 @@ impl Coordinator { item_entry: u32, count: u32, ) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("buy_item: actor_guid unresolved")); - } + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("buy_item: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_buy_item", - gw_buy_item_then( - self.session_actor(actor_guid), - vendor_guid, - item_entry, - count - ) + gw_buy_item_then(self.session_actor(actor), vendor_guid, item_entry, count) ) } @@ -2709,14 +2602,13 @@ impl Coordinator { trainer_guid: u64, spell_id: u32, ) -> Result { - if actor_guid == 0 { - return Err(anyhow!("buy_trainer_spell: actor_guid unresolved")); - } + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("buy_trainer_spell: actor_guid unresolved"))?; let coord = self.0.call_pipe(); let result: Result<()> = call_reducer!( coord.conn.reducers, "gw_trainer_buy", - gw_trainer_buy_then(self.session_actor(actor_guid), trainer_guid, spell_id) + gw_trainer_buy_then(self.session_actor(actor), trainer_guid, spell_id) ); match result { Ok(()) => Ok(crate::world::TrainerBuyOutcome::Learned), @@ -2730,26 +2622,24 @@ impl Coordinator { /// Buy the next bank bag slot from `banker_guid` (`CMSG_BUY_BANK_SLOT`) over the coordinator /// connection. A refusal carries the module's `[N]` `SMSG_BUY_BANK_SLOT_RESULT` code tag. pub fn buy_bank_slot(&self, _account_id: u64, actor_guid: u64, banker_guid: u64) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("buy_bank_slot: actor_guid unresolved")); - } + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("buy_bank_slot: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_buy_bank_slot", - gw_buy_bank_slot_then(self.session_actor(actor_guid), banker_guid) + gw_buy_bank_slot_then(self.session_actor(actor), banker_guid) ) } pub fn learn_talent(&self, _account_id: u64, actor_guid: u64, talent_id: u32) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("learn_talent: actor_guid unresolved")); - } + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("learn_talent: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_learn_talent", - gw_learn_talent_then(self.session_actor(actor_guid), talent_id) + gw_learn_talent_then(self.session_actor(actor), talent_id) ) } @@ -2762,28 +2652,25 @@ impl Coordinator { actor_guid: u64, trainer_guid: u64, ) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("reset_talents: actor_guid unresolved")); - } + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("reset_talents: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_reset_talents", - gw_reset_talents_then(self.session_actor(actor_guid), trainer_guid) + gw_reset_talents_then(self.session_actor(actor), trainer_guid) ) } /// Fishing cast: instant-resolve catch — the module's lenient alpha gate auto-learns the /// skill and grants the fish straight to the bag. Caller resolved via ctx.sender. pub fn fish(&self, _account_id: u64, actor_guid: u64) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("fish: actor_guid unresolved")); - } + let actor = Actor::new(actor_guid).ok_or_else(|| anyhow!("fish: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_fish", - gw_fish_then(self.session_actor(actor_guid)) + gw_fish_then(self.session_actor(actor)) ) } @@ -2791,14 +2678,13 @@ impl Coordinator { /// module attributes the pick to the caller via ctx.sender). The module gates range / lock /// requirement / Lockpicking skill; on success it records the GO unlocked + climbs the skill. pub fn pick_lock(&self, _account_id: u64, actor_guid: u64, go_guid: u64) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("pick_lock: actor_guid unresolved")); - } + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("pick_lock: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_pick_lock", - gw_pick_lock_then(self.session_actor(actor_guid), go_guid) + gw_pick_lock_then(self.session_actor(actor), go_guid) ) } @@ -2812,14 +2698,13 @@ impl Coordinator { action: u32, action_type: u8, ) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("set_action_button: actor_guid unresolved")); - } + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("set_action_button: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_set_action_button", - gw_set_action_button_then(self.session_actor(actor_guid), button, action, action_type) + gw_set_action_button_then(self.session_actor(actor), button, action, action_type) ) } @@ -2833,14 +2718,13 @@ impl Coordinator { reputation_index: u32, at_war: bool, ) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("set_faction_at_war: actor_guid unresolved")); - } + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("set_faction_at_war: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_set_faction_at_war", - gw_set_faction_at_war_then(self.session_actor(actor_guid), reputation_index, at_war) + gw_set_faction_at_war_then(self.session_actor(actor), reputation_index, at_war) ) } @@ -2855,14 +2739,13 @@ impl Coordinator { vendor_guid: u64, slot: u8, ) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("sell_item: actor_guid unresolved")); - } + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("sell_item: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_sell_item", - gw_sell_item_then(self.session_actor(actor_guid), vendor_guid, slot) + gw_sell_item_then(self.session_actor(actor), vendor_guid, slot) ) } @@ -2873,14 +2756,13 @@ impl Coordinator { vendor_guid: u64, slot: u8, ) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("buyback_item: actor_guid unresolved")); - } + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("buyback_item: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_buyback_item", - gw_buyback_item_then(self.session_actor(actor_guid), vendor_guid, slot) + gw_buyback_item_then(self.session_actor(actor), vendor_guid, slot) ) } @@ -2894,14 +2776,13 @@ impl Coordinator { npc_guid: u64, slot: u8, ) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("repair_item: actor_guid unresolved")); - } + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("repair_item: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_repair_item", - gw_repair_item_then(self.session_actor(actor_guid), npc_guid, slot) + gw_repair_item_then(self.session_actor(actor), npc_guid, slot) ) } @@ -2914,14 +2795,14 @@ impl Coordinator { actor_guid: u64, from_slot: u8, ) -> Result { - let Some(actor_guid) = resolved_item_actor("equip_item", actor_guid) else { + let Some(actor) = resolved_item_actor("equip_item", actor_guid) else { return Ok(ItemRefusal::Internal.into()); }; let coord = self.0.call_pipe(); item_action(call_reducer!( coord.conn.reducers, "gw_equip_item", - gw_equip_item_then(self.session_actor(actor_guid), from_slot) + gw_equip_item_then(self.session_actor(actor), from_slot) )) } @@ -2933,14 +2814,14 @@ impl Coordinator { actor_guid: u64, from_slot: u8, ) -> Result { - let Some(actor_guid) = resolved_item_actor("unequip_item", actor_guid) else { + let Some(actor) = resolved_item_actor("unequip_item", actor_guid) else { return Ok(ItemRefusal::Internal.into()); }; let coord = self.0.call_pipe(); item_action(call_reducer!( coord.conn.reducers, "gw_unequip_item", - gw_unequip_item_then(self.session_actor(actor_guid), from_slot) + gw_unequip_item_then(self.session_actor(actor), from_slot) )) } @@ -2953,14 +2834,14 @@ impl Coordinator { actor_guid: u64, slot: u8, ) -> Result { - let Some(actor_guid) = resolved_item_actor("use_item", actor_guid) else { + let Some(actor) = resolved_item_actor("use_item", actor_guid) else { return Ok(ItemRefusal::Internal.into()); }; let coord = self.0.call_pipe(); item_action(call_reducer!( coord.conn.reducers, "gw_use_item", - gw_use_item_then(self.session_actor(actor_guid), slot) + gw_use_item_then(self.session_actor(actor), slot) )) } @@ -2968,14 +2849,13 @@ impl Coordinator { /// innkeeper's "Make this inn your home.") over the coordinator connection so the module attributes /// it to the caller's entity. No args — `bind_home` resolves the caller via `ctx.sender`. pub fn bind_home(&self, _account_id: u64, actor_guid: u64) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("bind_home: actor_guid unresolved")); - } + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("bind_home: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_bind_home", - gw_bind_home_then(self.session_actor(actor_guid)) + gw_bind_home_then(self.session_actor(actor)) ) } @@ -2988,14 +2868,14 @@ impl Coordinator { from_slot: u8, to_slot: u8, ) -> Result { - let Some(actor_guid) = resolved_item_actor("move_item", actor_guid) else { + let Some(actor) = resolved_item_actor("move_item", actor_guid) else { return Ok(ItemRefusal::Internal.into()); }; let coord = self.0.call_pipe(); item_action(call_reducer!( coord.conn.reducers, "gw_move_item", - gw_move_item_then(self.session_actor(actor_guid), from_slot, to_slot) + gw_move_item_then(self.session_actor(actor), from_slot, to_slot) )) } @@ -3003,14 +2883,13 @@ impl Coordinator { /// over the coordinator connection. The module infers deposit vs. withdraw from `slot` and /// resolves the receiving free slot itself. pub fn auto_bank_item(&self, _account_id: u64, actor_guid: u64, slot: u8) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("auto_bank_item: actor_guid unresolved")); - } + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("auto_bank_item: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_auto_bank_item", - gw_auto_bank_item_then(self.session_actor(actor_guid), slot) + gw_auto_bank_item_then(self.session_actor(actor), slot) ) } @@ -3025,14 +2904,13 @@ impl Coordinator { giver_guid: u64, quest_id: u32, ) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("accept_quest: actor_guid unresolved")); - } + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("accept_quest: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_accept_quest", - gw_accept_quest_then(self.session_actor(actor_guid), giver_guid, quest_id) + gw_accept_quest_then(self.session_actor(actor), giver_guid, quest_id) ) } @@ -3048,15 +2926,14 @@ impl Coordinator { quest_id: u32, reward_index: u32, ) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("turn_in_quest: actor_guid unresolved")); - } + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("turn_in_quest: actor_guid unresolved"))?; let coord = self.0.visibility_pipe(); call_reducer!( coord.conn.reducers, "gw_turn_in_quest", gw_turn_in_quest_then( - self.session_actor(actor_guid), + self.session_actor(actor), giver_guid, quest_id, reward_index @@ -3067,28 +2944,26 @@ impl Coordinator { /// Abandon quest `quest_id` (`CMSG_QUESTLOG_REMOVE_QUEST`) over the coordinator connection. The /// module deletes the player's quest-log row; the quest-log relay then clears the slot. pub fn abandon_quest(&self, _account_id: u64, actor_guid: u64, quest_id: u32) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("abandon_quest: actor_guid unresolved")); - } + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("abandon_quest: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_abandon_quest", - gw_abandon_quest_then(self.session_actor(actor_guid), quest_id) + gw_abandon_quest_then(self.session_actor(actor), quest_id) ) } /// Revive the caller after death (`CMSG_REPOP_REQUEST`) over the coordinator connection. /// Rides the coordinator connection as `gw_repop`. pub fn repop(&self, _account_id: u64, actor_guid: u64) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("repop: actor_guid unresolved")); - } + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("repop: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_repop", - gw_repop_then(self.session_actor(actor_guid)) + gw_repop_then(self.session_actor(actor)) ) } @@ -3099,14 +2974,13 @@ impl Coordinator { actor_guid: u64, corpse_guid: u64, ) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("reclaim_corpse: actor_guid unresolved")); - } + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("reclaim_corpse: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_reclaim_corpse", - gw_reclaim_corpse_then(self.session_actor(actor_guid), corpse_guid) + gw_reclaim_corpse_then(self.session_actor(actor), corpse_guid) ) } @@ -3118,28 +2992,26 @@ impl Coordinator { actor_guid: u64, accept: bool, ) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("resurrect_response: actor_guid unresolved")); - } + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("resurrect_response: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_respond_resurrect", - gw_respond_resurrect_then(self.session_actor(actor_guid), accept) + gw_respond_resurrect_then(self.session_actor(actor), accept) ) } /// Use the caller's Self-Resurrection Option (`CMSG_SELF_RES`) over the coordinator connection. /// Rides the coordinator connection as `gw_self_resurrect`. pub fn self_resurrect(&self, _account_id: u64, actor_guid: u64) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("self_resurrect: actor_guid unresolved")); - } + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("self_resurrect: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_self_resurrect", - gw_self_resurrect_then(self.session_actor(actor_guid)) + gw_self_resurrect_then(self.session_actor(actor)) ) } @@ -3152,14 +3024,13 @@ impl Coordinator { actor_guid: u64, _healer_guid: u64, ) -> Result<()> { - if actor_guid == 0 { - return Err(anyhow!("spirit_healer_res: actor_guid unresolved")); - } + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("spirit_healer_res: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_spirit_res", - gw_spirit_res_then(self.session_actor(actor_guid)) + gw_spirit_res_then(self.session_actor(actor)) ) } @@ -3178,7 +3049,7 @@ impl Coordinator { "begin_transfer", begin_transfer_then( plan.transfer_id, - self.session_actor(plan.character_guid), + self.actor_or_owner(plan.character_guid), plan.dest_map_id, plan.dest_instance_id, plan.dest_x, @@ -3207,7 +3078,7 @@ impl Coordinator { source.map_id, source.instance_id, source.revision, - self.session_actor(0) + self.owner_actor() ) ) } @@ -3233,7 +3104,7 @@ impl Coordinator { intent.source_map, intent.source_instance, intent.source_locator_revision, - self.session_actor(0) + self.owner_actor() ) ) } @@ -3244,7 +3115,7 @@ impl Coordinator { call_reducer!( self.0.call_pipe().conn.reducers, "confirm_import", - confirm_import_then(transfer_id, self.session_actor(0)) + confirm_import_then(transfer_id, self.owner_actor()) ) } @@ -3253,7 +3124,7 @@ impl Coordinator { call_reducer!( self.0.call_pipe().conn.reducers, "finish_transfer", - finish_transfer_then(transfer_id, self.session_actor(0)) + finish_transfer_then(transfer_id, self.owner_actor()) ) } @@ -3262,7 +3133,7 @@ impl Coordinator { call_reducer!( self.0.call_pipe().conn.reducers, "release_transfer", - release_transfer_then(transfer_id, self.session_actor(0)) + release_transfer_then(transfer_id, self.owner_actor()) ) } @@ -3272,7 +3143,7 @@ impl Coordinator { call_reducer!( self.0.call_pipe().conn.reducers, "ensure_instance", - ensure_instance_then(instance_id, map_id, party_id, self.session_actor(0)) + ensure_instance_then(instance_id, map_id, party_id, self.owner_actor()) ) } @@ -3281,7 +3152,7 @@ impl Coordinator { call_reducer!( self.0.call_pipe().conn.reducers, "evict_instance_population", - evict_instance_population_then(instance_id, self.session_actor(0)) + evict_instance_population_then(instance_id, self.owner_actor()) ) } @@ -3332,7 +3203,7 @@ impl Coordinator { "realm_group_op", realm_group_op_then( op, - self.session_actor(actor_guid), + self.actor_or_owner(actor_guid), target_guid, arg_a, arg_b, @@ -3359,7 +3230,7 @@ impl Coordinator { "realm_group_op", realm_group_op_then( op, - self.session_actor(actor_guid), + self.actor_or_owner(actor_guid), target_guid, arg_a, arg_b, @@ -3377,7 +3248,7 @@ impl Coordinator { "realm_group_op", realm_group_op_then( lyracore_shared::group::realm_op::LEAVE, - self.session_actor(character_guid), + self.actor_or_owner(character_guid), 0, lyracore_shared::group::leave_cause::CHARACTER_DELETED, 0, @@ -3394,7 +3265,7 @@ impl Coordinator { call_reducer!( self.0.call_pipe().conn.reducers, "realm_mail_mark_read", - realm_mail_mark_read_then(self.session_actor(recipient_guid), mail_id) + realm_mail_mark_read_then(self.actor_or_owner(recipient_guid), mail_id) ) } @@ -3403,7 +3274,7 @@ impl Coordinator { call_reducer!( self.0.call_pipe().conn.reducers, "realm_mail_delete", - realm_mail_delete_then(self.session_actor(recipient_guid), mail_id) + realm_mail_delete_then(self.actor_or_owner(recipient_guid), mail_id) ) } @@ -3414,7 +3285,7 @@ impl Coordinator { call_reducer!( self.0.call_pipe().conn.reducers, "realm_mail_return", - realm_mail_return_then(self.session_actor(recipient_guid), mail_id, same_account) + realm_mail_return_then(self.actor_or_owner(recipient_guid), mail_id, same_account) ) } @@ -3441,7 +3312,7 @@ impl Coordinator { self.0.call_pipe().conn.reducers, "realm_mail_send", realm_mail_send_then( - self.session_actor(sender_guid), + self.actor_or_owner(sender_guid), recipient_guid, subject, body, @@ -3459,7 +3330,7 @@ impl Coordinator { call_reducer!( self.0.call_pipe().conn.reducers, "realm_mail_take_money", - realm_mail_take_money_then(self.session_actor(recipient_guid), mail_id) + realm_mail_take_money_then(self.actor_or_owner(recipient_guid), mail_id) ) } @@ -3469,7 +3340,7 @@ impl Coordinator { call_reducer!( self.0.call_pipe().conn.reducers, "realm_mail_take_item", - realm_mail_take_item_then(self.session_actor(recipient_guid), mail_id) + realm_mail_take_item_then(self.actor_or_owner(recipient_guid), mail_id) ) } @@ -3480,7 +3351,7 @@ impl Coordinator { call_reducer!( self.0.call_pipe().conn.reducers, "realm_mail_item_room", - realm_mail_item_room_then(self.session_actor(payee_guid)) + realm_mail_item_room_then(self.actor_or_owner(payee_guid)) ) } @@ -3490,7 +3361,7 @@ impl Coordinator { call_reducer!( self.0.call_pipe().conn.reducers, "realm_mail_copy_text", - realm_mail_copy_text_then(self.session_actor(recipient_guid), mail_id) + realm_mail_copy_text_then(self.actor_or_owner(recipient_guid), mail_id) ) } @@ -3500,7 +3371,7 @@ impl Coordinator { call_reducer!( self.0.call_pipe().conn.reducers, "gw_mail_grant_letter", - gw_mail_grant_letter_then(self.session_actor(payee_guid), item_text_id) + gw_mail_grant_letter_then(self.actor_or_owner(payee_guid), item_text_id) ) } @@ -3510,7 +3381,7 @@ impl Coordinator { call_reducer!( self.0.call_pipe().conn.reducers, "realm_mail_mark_letter_granted", - realm_mail_mark_letter_granted_then(self.session_actor(recipient_guid), mail_id) + realm_mail_mark_letter_granted_then(self.actor_or_owner(recipient_guid), mail_id) ) } @@ -3536,7 +3407,7 @@ impl Coordinator { "realm_mail_fence", realm_mail_fence_then( escrow_id, - self.session_actor(sender_guid), + self.actor_or_owner(sender_guid), recipient_guid, subject, body, @@ -3575,7 +3446,7 @@ impl Coordinator { "realm_mail_commit", realm_mail_commit_then( escrow_id, - self.session_actor(sender_guid), + self.actor_or_owner(sender_guid), recipient_guid, subject, body, @@ -3611,7 +3482,7 @@ impl Coordinator { "realm_mail_take_money_fence", realm_mail_take_money_fence_then( escrow_id, - self.session_actor(payee_guid), + self.actor_or_owner(payee_guid), mail_id, expect_money ) @@ -3630,7 +3501,7 @@ impl Coordinator { call_reducer!( self.0.call_pipe().conn.reducers, "realm_mail_payout", - realm_mail_payout_then(escrow_id, self.session_actor(payee_guid), mail_id, amount) + realm_mail_payout_then(escrow_id, self.actor_or_owner(payee_guid), mail_id, amount) ) } @@ -3648,7 +3519,7 @@ impl Coordinator { "realm_mail_take_item_fence", realm_mail_take_item_fence_then( escrow_id, - self.session_actor(payee_guid), + self.actor_or_owner(payee_guid), mail_id, expect_entry ) @@ -3669,7 +3540,7 @@ impl Coordinator { "realm_mail_item_payout", realm_mail_item_payout_then( escrow_id, - self.session_actor(payee_guid), + self.actor_or_owner(payee_guid), mail_id, item.entry, item.stack_count, @@ -3688,7 +3559,7 @@ impl Coordinator { call_reducer!( self.0.call_pipe().conn.reducers, "realm_mail_confirm_delivery", - realm_mail_confirm_delivery_then(escrow_id, self.session_actor(0)) + realm_mail_confirm_delivery_then(escrow_id, self.owner_actor()) ) } @@ -3697,7 +3568,7 @@ impl Coordinator { call_reducer!( self.0.call_pipe().conn.reducers, "realm_mail_settle", - realm_mail_settle_then(escrow_id, self.session_actor(0)) + realm_mail_settle_then(escrow_id, self.owner_actor()) ) } @@ -3737,7 +3608,7 @@ impl Coordinator { roster.loot_threshold, roster.master_looter_guid, roster.member_guids(), - self.session_actor(0), + self.owner_actor(), partitions, roster.roster_revision, roster.kind.wire(), @@ -3782,7 +3653,7 @@ impl Coordinator { corpse_guid, slot, item_entry, - self.session_actor(actor_guid), + self.actor_or_owner(actor_guid), vote, deadline_micros, recipients, @@ -3809,7 +3680,7 @@ impl Coordinator { corpse_guid, slot, 0, - self.session_actor(actor_guid), + self.actor_or_owner(actor_guid), vote, 0, Vec::new(), @@ -3828,7 +3699,7 @@ impl Coordinator { call_reducer!( self.0.call_pipe().conn.reducers, "settle_loot_roll", - settle_loot_roll_then(corpse_guid, slot, winner_guid, self.session_actor(0)) + settle_loot_roll_then(corpse_guid, slot, winner_guid, self.owner_actor()) ) } @@ -3838,7 +3709,7 @@ impl Coordinator { call_reducer!( self.0.call_pipe().conn.reducers, "clear_promoted_loot_roll", - clear_promoted_loot_roll_then(roll_id, self.session_actor(0)) + clear_promoted_loot_roll_then(roll_id, self.owner_actor()) ) } } @@ -4088,12 +3959,12 @@ fn item_action(result: Result<()>) -> Result { /// An item action needs the caller's own entity. Without one there is nothing to request, so the /// client gets a Refusal rather than a dead session. -fn resolved_item_actor(operation: &str, actor_guid: u64) -> Option { - if actor_guid == 0 { +fn resolved_item_actor(operation: &str, actor_guid: u64) -> Option { + let actor = Actor::new(actor_guid); + if actor.is_none() { log::warn!("stdb: {operation} has no resolved actor"); - return None; } - Some(actor_guid) + actor } /// The Module's typed chat Refusal. Only a reducer the Module rejected carries a tag; a timeout, @@ -4393,7 +4264,7 @@ impl Coordinator { let result = call_reducer!( self.0.call_pipe().conn.reducers, "realm_guild_op", - realm_guild_op_then(self.session_actor(actor_guid), op) + realm_guild_op_then(self.actor_or_owner(actor_guid), op) ); match result { Ok(()) => Ok(crate::world::GuildOutcome::Ran), @@ -4429,7 +4300,7 @@ impl Coordinator { let result = call_reducer!( self.0.call_pipe().conn.reducers, "gw_guild_fee_hold", - gw_guild_fee_hold_then(operation_id, self.session_actor(actor_guid), request) + gw_guild_fee_hold_then(operation_id, self.actor_or_owner(actor_guid), request) ); match result { Ok(()) => wait_for_cache_row(operation_id, "guild Fee Hold", || { @@ -4477,7 +4348,7 @@ impl Coordinator { call_reducer!( self.0.call_pipe().conn.reducers, "realm_guild_fee_decide", - realm_guild_fee_decide_then(hold.operation_id, self.session_actor(actor_guid), terms) + realm_guild_fee_decide_then(hold.operation_id, self.actor_or_owner(actor_guid), terms) )?; let decision = wait_for_cache_row(hold.operation_id, "guild fee decision", || { self.guild_fee_decision_row(hold.operation_id) @@ -4495,7 +4366,7 @@ impl Coordinator { call_reducer!( self.0.call_pipe().conn.reducers, "gw_destroy_guild_charter", - gw_destroy_guild_charter_then(self.session_actor(actor_guid), charter_item_guid) + gw_destroy_guild_charter_then(self.actor_or_owner(actor_guid), charter_item_guid) ) } @@ -4509,7 +4380,7 @@ impl Coordinator { call_reducer!( self.0.call_pipe().conn.reducers, "gw_guild_fee_finish", - gw_guild_fee_finish_then(operation_id, self.session_actor(actor_guid), accepted) + gw_guild_fee_finish_then(operation_id, self.actor_or_owner(actor_guid), accepted) ) } } diff --git a/gateway/src/stdb/store/session.rs b/gateway/src/stdb/store/session.rs index 9fccf9bc7..751e91312 100644 --- a/gateway/src/stdb/store/session.rs +++ b/gateway/src/stdb/store/session.rs @@ -4,7 +4,7 @@ use anyhow::{anyhow, Result}; use wow_world_messages::vanilla::MovementInfo; use crate::codec; -use crate::world::{SessionStore, SessionTx, WorldSession, WorldStore, MOVE_SUBMITTED}; +use crate::world::{Actor, SessionStore, SessionTx, WorldSession, WorldStore, MOVE_SUBMITTED}; use crate::stdb::bindings::GwMove; use crate::stdb::Coordinator; @@ -31,15 +31,14 @@ impl SessionStore for Coordinator { opcode: u32, info: &MovementInfo, ) -> Result<()> { - if self_guid == 0 { - return Err(anyhow!("movement_update: actor_guid unresolved")); - } + let actor = Actor::new(self_guid) + .ok_or_else(|| anyhow!("movement_update: actor_guid unresolved"))?; // Carry the MovementInfo verbatim so the module can relay it to in-range observers under // the same opcode. Queueing is the only completion observable on the shared batch; // individual reducer outcomes are intentionally unavailable. let body = codec::movement_info_to_bytes(info)?; self.0.motion_batch.push(GwMove { - actor: self.session_actor(self_guid), + actor: self.session_actor(actor), opcode: opcode as u16, movement_info: body, x: info.position.x, diff --git a/gateway/src/world/actor.rs b/gateway/src/world/actor.rs new file mode 100644 index 000000000..e5e22fa3a --- /dev/null +++ b/gateway/src/world/actor.rs @@ -0,0 +1,29 @@ +//! The Character a Durable Request acts as. + +use std::num::NonZeroU64; + +/// A resolved Actor guid. Guid 0 means the World Session has no Character yet, so it is never an +/// Actor. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) struct Actor(NonZeroU64); + +impl Actor { + pub(crate) fn new(guid: u64) -> Option { + NonZeroU64::new(guid).map(Self) + } + + pub(crate) fn guid(self) -> u64 { + self.0.get() + } +} + +#[cfg(test)] +mod tests { + use super::Actor; + + #[test] + fn guid_zero_is_no_actor() { + assert_eq!(Actor::new(0), None); + assert_eq!(Actor::new(7).map(Actor::guid), Some(7)); + } +} diff --git a/gateway/src/world/chat_tests.rs b/gateway/src/world/chat_tests.rs index 41d8d298c..f8bc5d36a 100644 --- a/gateway/src/world/chat_tests.rs +++ b/gateway/src/world/chat_tests.rs @@ -115,7 +115,9 @@ fn messagechat_dot_say_error_relays_a_system_chat_line_to_the_sender_only() { // to the SENDER as a System SMSG_MESSAGECHAT carrying the module's raw message VERBATIM — no // "reducer failed" wrapper prefix, no broadcast, no game_chat_event row. let mut s = quest_store(); - s.speech.gm_command_error = Some("permission denied".to_string()); + s.speech.gm_command_error = Some(|| { + crate::stdb::ReducerCallError::refused("gw_gm_command", "permission denied").into() + }); let store = std::sync::Arc::new(s); let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); CMSG_MESSAGECHAT { @@ -144,6 +146,38 @@ fn messagechat_dot_say_error_relays_a_system_chat_line_to_the_sender_only() { assert!(store.speech.chats.lock().unwrap().is_empty()); } +#[test] +fn messagechat_dot_say_transport_loss_ends_the_world_session() { + let mut s = quest_store(); + s.speech.gm_command_error = + Some(|| crate::stdb::ReducerCallError::transport_lost("gw_gm_command").into()); + let store = std::sync::Arc::new(s); + let (mut client, server_end) = world_session_socket_pair(); + let server = std::thread::spawn(move || run_world_session(server_end, store)); + let (mut c_enc, mut c_dec) = client_handshake(&mut client, "TESTER", K); + CMSG_PLAYER_LOGIN { guid: Guid::new(1) } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + drain_world_entry(&mut client, &mut c_dec); + CMSG_MESSAGECHAT { + chat_type: CMSG_MESSAGECHAT_ChatType::Say, + language: Language::Universal, + message: ".god".into(), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + while let Ok(message) = ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec) { + assert!( + !matches!(message, ServerOpcodeMessage::SMSG_MESSAGECHAT(_)), + "a lost command is not a Refusal the GM reads" + ); + } + assert!( + server.join().unwrap().is_err(), + "a Transport Loss ends the World Session" + ); +} + /// Character 1 in the world, with a Character row so `sync`'s sentinel is answered. fn chat_store() -> WorldFake { let base = quest_store(); diff --git a/gateway/src/world/handlers/chat.rs b/gateway/src/world/handlers/chat.rs index a088f5052..2401c2689 100644 --- a/gateway/src/world/handlers/chat.rs +++ b/gateway/src/world/handlers/chat.rs @@ -113,8 +113,9 @@ pub(crate) trait SpeechStore: Send + Sync { /// GM playtest dot-command for the proof-validated, realm-wide `account_name`: `text` is the /// raw Say line, STILL carrying its /// leading `.` — the Say handler intercepts it BEFORE any chat relay/insert and forwards it here - /// verbatim (module-side parsing keeps the command set data-free). `Err`'s message is relayed back - /// to the SENDER ONLY as a system chat line (never broadcast, never a `game_chat_event` row). + /// verbatim (module-side parsing keeps the command set data-free). A Refusal's reason is relayed + /// back to the SENDER ONLY as a system chat line (never broadcast, never a `game_chat_event` + /// row). Any other `Err` ends the World Session. fn gm_command(&self, account_name: &str, self_guid: u64, text: String) -> Result<()>; } diff --git a/gateway/src/world/handlers/query.rs b/gateway/src/world/handlers/query.rs index 7de7b3c0b..99ca66047 100644 --- a/gateway/src/world/handlers/query.rs +++ b/gateway/src/world/handlers/query.rs @@ -440,12 +440,19 @@ pub(crate) fn handle_query< )?; } } + // The GM reads the Module's Refusal text verbatim. Any other failure ends the + // World Session. CMSG_MESSAGECHAT_ChatType::Say if message.starts_with('.') => { - if let Err(e) = store.gm_command(&conn.account_name, self_guid, message) { + if let Err(error) = store.gm_command(&conn.account_name, self_guid, message) { + let crate::stdb::DurableFailure::Refusal { reason } = + crate::stdb::classify(&error) + else { + return Err(error); + }; send( tx, Outbound::One(ServerOpcodeMessage::SMSG_MESSAGECHAT(Box::new( - codec::build_gm_system_message(e.to_string()), + codec::build_gm_system_message(reason.to_string()), ))), )?; } diff --git a/gateway/src/world/mod.rs b/gateway/src/world/mod.rs index f2a323d26..7588de92a 100644 --- a/gateway/src/world/mod.rs +++ b/gateway/src/world/mod.rs @@ -35,6 +35,7 @@ use wow_world_messages::vanilla::{ SMSG_SPIRIT_HEALER_CONFIRM, }; +mod actor; mod chat_flood; mod coalesce; pub(crate) mod guild_fee; @@ -51,6 +52,7 @@ mod store; pub mod transfer; pub mod whisper; mod who; +pub(crate) use actor::Actor; use coalesce::CoalesceState; pub(crate) use handlers::{ character_facts, dispatch_meeting_stone_action, reconcile_deleted_guild_characters, diff --git a/gateway/src/world/test_support/world_fake/speech.rs b/gateway/src/world/test_support/world_fake/speech.rs index 7d0444a67..2a0911256 100644 --- a/gateway/src/world/test_support/world_fake/speech.rs +++ b/gateway/src/world/test_support/world_fake/speech.rs @@ -4,9 +4,8 @@ use super::super::*; pub(crate) struct SpeechState { /// What `send_chat` answers; `None` delivers. pub(crate) send_chat_outcome: Option, - /// When set, `gm_command` returns this error — e.g. `"permission denied"` to - /// drive the Say-handler's `Err` → self-only `SMSG_MESSAGECHAT` System relay. - pub(crate) gm_command_error: Option, + /// When set, `gm_command` fails with this error: a Refusal the GM reads, or a Transport Loss. + pub(crate) gm_command_error: Option anyhow::Error>, /// Recorded `gm_command` dispatches — the dot-command divert test asserts the /// RIGHT raw text (still carrying its leading `.`) reached the reducer call, and that a NON-dot /// Say never reaches this vec at all. @@ -75,10 +74,14 @@ impl SpeechStore for WorldFake { self.speech.gm_gameplay_changes.lock().unwrap().push(text); return Ok(()); } - return Err(anyhow!("permission denied")); + return Err(crate::stdb::ReducerCallError::refused( + "gw_gm_command", + "permission denied", + ) + .into()); } - match &self.speech.gm_command_error { - Some(e) => Err(anyhow!("{e}")), + match self.speech.gm_command_error { + Some(error) => Err(error()), None => { self.speech .gm_commands From 6e135901fa98de6f336eca6aec963c4075ed4eb7 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 17:41:05 +0200 Subject: [PATCH 02/33] refactor(gateway): move each Store family's Coordinator code into its store file Code motion only. Each `impl for Coordinator` that still sat in a world handler file moves to `stdb/store/.rs`, together with the inherent reducer calls and Durable Reads only that family calls and the family's Refusal decoder. Inherent methods with other callers (relays, logon, realm-core routing, Transfer intents) stay in `reducers.rs` and `reads/`. Bodies are unchanged. Paths, imports and the visibility of the few helpers two families share are adjusted so the moved code resolves. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/architecture.md | 2 +- docs/recipes/add-an-opcode.md | 13 +- gateway/src/stdb/reads/account.rs | 311 +- gateway/src/stdb/reads/auction.rs | 424 -- gateway/src/stdb/reads/channel.rs | 59 +- gateway/src/stdb/reads/chat.rs | 59 - gateway/src/stdb/reads/guild.rs | 244 +- gateway/src/stdb/reads/items.rs | 421 +- gateway/src/stdb/reads/mail.rs | 167 +- gateway/src/stdb/reads/meeting_stone.rs | 40 - gateway/src/stdb/reads/mod.rs | 16 +- gateway/src/stdb/reads/npc_vendor.rs | 373 -- gateway/src/stdb/reads/party.rs | 344 -- gateway/src/stdb/reads/pet.rs | 103 - gateway/src/stdb/reads/quest.rs | 347 +- gateway/src/stdb/reads/spell.rs | 247 +- gateway/src/stdb/reads/talent_reputation.rs | 118 - gateway/src/stdb/reads/templates.rs | 57 - gateway/src/stdb/reducers.rs | 4977 +----------------- gateway/src/stdb/store/auction.rs | 1709 ++++++ gateway/src/stdb/store/bank.rs | 36 +- gateway/src/stdb/store/cast.rs | 438 ++ gateway/src/stdb/store/channel.rs | 126 + gateway/src/stdb/store/character.rs | 283 +- gateway/src/stdb/store/chat.rs | 154 + gateway/src/stdb/store/combat.rs | 54 +- gateway/src/stdb/store/death.rs | 104 +- gateway/src/stdb/store/duel.rs | 42 + gateway/src/stdb/store/guild.rs | 410 ++ gateway/src/stdb/store/guild_fee.rs | 246 + gateway/src/stdb/store/item.rs | 191 + gateway/src/stdb/store/loot_roll.rs | 276 +- gateway/src/stdb/store/loot_window.rs | 656 +++ gateway/src/stdb/store/mail.rs | 513 +- gateway/src/stdb/store/meeting_stone.rs | 137 + gateway/src/stdb/store/melee.rs | 45 + gateway/src/stdb/store/member_stats.rs | 159 + gateway/src/stdb/store/mod.rs | 27 +- gateway/src/stdb/store/npc.rs | 396 +- gateway/src/stdb/store/party.rs | 569 +- gateway/src/stdb/store/quest.rs | 534 ++ gateway/src/stdb/store/session.rs | 164 +- gateway/src/stdb/store/shard_routing.rs | 3 +- gateway/src/stdb/store/social.rs | 280 +- gateway/src/stdb/store/speech.rs | 59 +- gateway/src/stdb/store/taxi.rs | 288 + gateway/src/stdb/store/trade.rs | 145 +- gateway/src/stdb/store/trainer.rs | 439 +- gateway/src/stdb/store/transfer.rs | 215 +- gateway/src/stdb/store/vendor.rs | 227 + gateway/src/stdb/{reads => store}/weather.rs | 17 +- gateway/src/world/guild_fee.rs | 36 +- gateway/src/world/handlers/auction.rs | 74 - gateway/src/world/handlers/cast/mod.rs | 132 - gateway/src/world/handlers/channel.rs | 23 - gateway/src/world/handlers/chat.rs | 30 - gateway/src/world/handlers/duel.rs | 10 - gateway/src/world/handlers/guild.rs | 79 - gateway/src/world/handlers/item.rs | 34 - gateway/src/world/handlers/loot.rs | 60 - gateway/src/world/handlers/meeting_stone.rs | 42 - gateway/src/world/handlers/melee.rs | 10 - gateway/src/world/handlers/member_stats.rs | 10 - gateway/src/world/handlers/mod.rs | 2 - gateway/src/world/handlers/quest.rs | 66 - gateway/src/world/handlers/taxi.rs | 34 - gateway/src/world/handlers/vendor.rs | 70 - gateway/src/world/handlers/weather.rs | 6 - gateway/src/world/mod.rs | 15 +- 69 files changed, 9113 insertions(+), 8884 deletions(-) delete mode 100644 gateway/src/stdb/reads/auction.rs delete mode 100644 gateway/src/stdb/reads/chat.rs delete mode 100644 gateway/src/stdb/reads/meeting_stone.rs delete mode 100644 gateway/src/stdb/reads/npc_vendor.rs delete mode 100644 gateway/src/stdb/reads/pet.rs create mode 100644 gateway/src/stdb/store/auction.rs create mode 100644 gateway/src/stdb/store/cast.rs create mode 100644 gateway/src/stdb/store/channel.rs create mode 100644 gateway/src/stdb/store/chat.rs create mode 100644 gateway/src/stdb/store/duel.rs create mode 100644 gateway/src/stdb/store/guild.rs create mode 100644 gateway/src/stdb/store/guild_fee.rs create mode 100644 gateway/src/stdb/store/item.rs create mode 100644 gateway/src/stdb/store/loot_window.rs create mode 100644 gateway/src/stdb/store/meeting_stone.rs create mode 100644 gateway/src/stdb/store/melee.rs create mode 100644 gateway/src/stdb/store/member_stats.rs create mode 100644 gateway/src/stdb/store/quest.rs create mode 100644 gateway/src/stdb/store/taxi.rs create mode 100644 gateway/src/stdb/store/vendor.rs rename gateway/src/stdb/{reads => store}/weather.rs (70%) diff --git a/docs/architecture.md b/docs/architecture.md index 7279a28ab..789b41b5a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -107,7 +107,7 @@ are answered by the gateway, which is the only component that can see the whole | whisper target resolution by name, realm-wide, plus the ignore verdict and the Auto-Reply | same | `presence.rs` (`resolve_all_by_name`, `of`, `auto_reply`), `gateway/src/world/whisper.rs` (`target_facts`, `ignored_anywhere`) | | `CMSG_NAME_QUERY` resolution | same | `presence.rs` (`character_anywhere`) | | friend/ignore add target resolution by name, realm-wide, plus the friend's race for the Enemy Gate. This is the existence check the Module no longer performs, since Realm-core holds no Character rows for it to check | same | `presence.rs` (`resolve_by_name`, `of`), `gateway/src/world/social.rs` (`resolve_add_contact`) | -| Speaker Facts: the speaker's race and chat tag for every Realm Chat Line and Chat Channel op | Realm-core holds no Characters and no live entities | `gateway/src/world/handlers/chat.rs` (`speaker_facts`), `stdb/reads/chat.rs` | +| Speaker Facts: the speaker's race and chat tag for every Realm Chat Line and Chat Channel op | Realm-core holds no Characters and no live entities | `gateway/src/world/handlers/chat.rs` (`speaker_facts`), `stdb/store/chat.rs` | | Seeker facts: the race and class of the Character that uses a Meeting Stone, or of every member of the Party it leads, and the class of a Character that accepts an invite into a queued Party. Also whether the client can name the stone's dungeon area, checked before any state changes | Realm-core holds no Characters, and only the Gateway knows which areas the client protocol encodes | `gateway/src/world/handlers/meeting_stone.rs` (`facts_for_join`, the `Area` check in `join`), `gateway/src/world/party.rs` (`AcceptorFacts`) | | channel moderation target names, realm-wide, plus whether the target ignores the actor for an invite | same | `gateway/src/world/handlers/channel.rs` (`resolve_online_character`, `ignores`) | | the per-listener ignore filter on an ignorable Realm Chat Line | the ignore list is on each listener's Home Shard; the line is on Realm-core | `gateway/src/stdb/world_view.rs` (`realm_chat_appeared`, `Viewer::ignores`) | diff --git a/docs/recipes/add-an-opcode.md b/docs/recipes/add-an-opcode.md index 5c2cafde2..d7c7bc0e7 100644 --- a/docs/recipes/add-an-opcode.md +++ b/docs/recipes/add-an-opcode.md @@ -59,15 +59,15 @@ Required for every new table and every new reducer. ## 5. Call the reducer from the Coordinator -File: `gateway/src/stdb/reducers.rs`. Add an `impl Coordinator` method that makes the Durable -Request: +File: `gateway/src/stdb/store/.rs`, declared in `gateway/src/stdb/store/mod.rs`. Add an +`impl Coordinator` method that makes the Durable Request as the `Actor` the handler resolved: ```rust let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, "gw_admit_meeting_stone", - gw_admit_meeting_stone_then(self.session_actor(actor_guid), go_guid) + gw_admit_meeting_stone_then(self.session_actor(actor), go_guid) ) ``` @@ -76,8 +76,8 @@ outcome with `reducer_refusal_reason`, and return every other error unchanged. ## 6. Add the Durable Reads -File: `gateway/src/stdb/reads/.rs`, declared in `gateway/src/stdb/reads/mod.rs`. Each read -is an `impl Coordinator` method that finds rows in the Coordinator cache by a unique index. The SDK +File: `gateway/src/stdb/store/.rs`. A read that a Relay or another family also calls goes +in `gateway/src/stdb/reads/`. Each read is an `impl Coordinator` method that finds rows 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 @@ -88,7 +88,8 @@ 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. -- `impl ActionStore for crate::stdb::Coordinator`, each method one call to step 5 or 6. +- `impl ActionStore for Coordinator` in `gateway/src/stdb/store/.rs`, each method + one call to step 5 or 6. - 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 diff --git a/gateway/src/stdb/reads/account.rs b/gateway/src/stdb/reads/account.rs index f21f40d7f..d1d51f0eb 100644 --- a/gateway/src/stdb/reads/account.rs +++ b/gateway/src/stdb/reads/account.rs @@ -9,7 +9,7 @@ use spacetimedb_sdk::{Table, TableWithPrimaryKey}; use super::super::bindings::*; use super::super::connection::Coordinator; -use super::super::views::{character_view, AccountRow, RealmRow}; +use super::super::views::{AccountRow, RealmRow}; use crate::realm_core::SessionKey; impl Coordinator { @@ -68,58 +68,6 @@ impl Coordinator { Ok(n.min(u8::MAX as usize) as u8) } - /// Read an account's characters for the character-select screen. In production - /// this reads the per-player `game_character` subscription cache (RLS-restricted to owner). - /// Equipment slots (0..=18) are populated from `game_item_instance` + `game_item_template` - /// so the client renders the character's gear on the select screen instead of all-naked. - pub fn characters(&self, account_id: u64) -> Result> { - use wow_world_messages::vanilla::{CharacterGear, InventoryType}; - let guard = self.0.coord(); - let db = &guard.conn.db; - let mut views: Vec = db - .game_character() - .iter() - .filter(|c| c.account_id == account_id) - .map(character_view) - .collect(); - // Fill equipment slots 0..=18 from item instances. Slots ≥ 19 are backpack/bag slots; - // skip them. An unknown inventory_type degrades to InventoryType::default (Non) which - // the client treats the same as display_id=0 — no model shown, no crash. - for view in &mut views { - for item in db - .game_item_instance() - .iter() - .filter(|i| i.owner_guid == view.guid && i.slot <= 18) - { - let Some(tmpl) = db.game_item_template().entry().find(&item.entry) else { - continue; - }; - let inv_type = - InventoryType::try_from(u32::from(tmpl.inventory_type)).unwrap_or_default(); - view.equipment[item.slot as usize] = CharacterGear { - equipment_display_id: tmpl.display_id, - inventory_type: inv_type, - }; - } - } - Ok(views) - } - - /// Read a single character by guid (any owner) for a `CMSG_NAME_QUERY` reply. The queried guid - /// is usually a *peer*, so this reads across owners via the privileged cache (no RLS on the - /// owner connection), unlike `characters` which filters to one account. - pub fn character_by_guid(&self, guid: u64) -> Result> { - Ok(self - .0 - .coord() - .conn - .db - .game_character() - .guid() - .find(&guid) - .map(character_view)) - } - /// Where a character IS **on this shard**, as `(map_id, instance_id)` — the input to shard /// routing. Prefers the LIVE entity and falls back to the durable `game_character` row, /// which is what a fresh login (and a mid-teleport character, whose entity was despawned) @@ -207,60 +155,6 @@ impl Coordinator { })) } - /// The EFFECTIVE armor for `guid` for the character-sheet CREATE (`UNIT_FIELD_RESISTANCES[0]`), - /// Presence check for the WORLDPORT_ACK gate: is the guid's live entity in the world? - pub fn entity_in_world(&self, guid: u64) -> bool { - let guard = self.0.coord(); - guard - .conn - .db - .game_world_entity() - .guid() - .find(&guid) - .is_some() - } - - /// folded from the coordinator's privileged cache: base (`game_world_entity.armor`) + worn gear armor. - /// The coordinator isn't subscribed to `game_aura`, so the aura term is 0 here — login-present armor - /// auras are pushed by the on_aura relay the instant they insert. Delegates to the shared - /// `stdb::armor::effective_armor` so CREATE and the relays compute the IDENTICAL fold. - pub fn effective_armor(&self, guid: u64) -> u32 { - let guard = self.0.coord(); - super::super::armor::effective_armor(&guard.conn.db, guid) - } - - pub fn effective_magic_resistances(&self, guid: u64) -> [u32; 6] { - let guard = self.0.coord(); - super::super::armor::effective_magic_resistances(&guard.conn.db, guid) - } - - /// The live entity's `max_health` from the privileged cache — 0 if not in world. Feeds the - /// fall-damage flavor line; the module applies the authoritative damage itself. - pub fn entity_max_health(&self, guid: u64) -> u32 { - let guard = self.0.coord(); - guard - .conn - .db - .game_world_entity() - .guid() - .find(&guid) - .map(|e| e.max_health) - .unwrap_or(0) - } - - /// Return the `combat_until_ms` value for `player_guid`'s entity row (0 if the entity is not - /// found or was never in combat). Read from the privileged coordinator cache. - pub fn player_combat_until_ms(&self, player_guid: u64) -> u64 { - let guard = self.0.coord(); - guard - .conn - .db - .game_world_entity() - .guid() - .find(&player_guid) - .map_or(0, |e| e.combat_until_ms) - } - /// The account's bound 32-byte SpacetimeDB identity — DERIVED, not minted by a connection /// (the per-player-connection build this replaced was the ~850/process wall; see /// `synthetic_owner_identity`'s contract). `establish_session` writes it into @@ -286,209 +180,6 @@ impl Coordinator { }) })) } - - /// Find `owner_guid`'s corpse location `(map_id, x, y, z)` from the privileged cache, for the - /// `MSG_CORPSE_QUERY` reply. `None` if they have no corpse. - pub fn corpse_location(&self, owner_guid: u64) -> Result> { - Ok(self - .0 - .coord() - .conn - .db - .game_corpse() - .iter() - .find(|c| c.owner_guid == owner_guid) - .map(|c| (c.map_id, c.x, c.y, c.z))) - } - - /// This Shard's durable Character row for `guid`: identity plus the session flag. `None` if - /// this Shard holds no `game_character` row for it. - pub fn character_identity( - &self, - guid: u64, - ) -> Result> { - Ok(self - .0 - .coord() - .conn - .db - .game_character() - .guid() - .find(&guid) - .map(|ch| crate::world::presence::CharacterIdentity { - guid, - name: ch.name, - race: ch.race, - class: ch.class, - level: ch.level, - zone_id: ch.zone_id, - session_online: ch.online, - })) - } - - /// This Shard's live `game_world_entity` row for `guid`, if any — the Member Stats columns - /// [`crate::codec::MemberEntity`] carries, joined with nothing else: level and zone come - /// straight off the entity, current unlike the durable row. - pub fn live_entity(&self, guid: u64) -> Option { - let guard = self.0.coord(); - let entity = guard.conn.db.game_world_entity().guid().find(&guid)?; - Some(crate::codec::MemberEntity { - health: entity.health, - max_health: entity.max_health, - power: entity.power, - max_power: entity.max_power, - unit_bytes_0: entity.unit_bytes_0, - level: entity.level, - zone_id: entity.zone_id, - x: entity.x, - y: entity.y, - dead: entity.dead, - player_flags: entity.player_flags, - // Member Stats' own aura/pet overlay (`Coordinator::with_member_shard_stats`) fills - // these afterward, keyed by the `ShardId` this generic, guid-only read cannot carry. - ..Default::default() - }) - } - - /// Does this Shard show `guid` between two places: its own Character row reading online with - /// no live entity here, or a Transfer Intent naming a session-less bot mid-crossing? The - /// Transfer Intent table is bounded by the Module's writer Gate, so the scan is short. - pub fn character_in_transit(&self, guid: u64) -> bool { - let guard = self.0.coord(); - let db = &guard.conn.db; - let session_online = db - .game_character() - .guid() - .find(&guid) - .is_some_and(|character| character.online); - session_online - || db - .game_bot_transfer_intent() - .iter() - .any(|intent| intent.bot_guid == guid) - } - - /// Every in-world player Character on this Shard, for `CMSG_WHO → SMSG_WHO` - /// (`presence::in_world_characters`'s per-Shard input, replacing the former `online_players`). - /// Iterates `game_world_entity` for entries with `entry == 0` (player entities; creatures have a - /// non-zero entry), then joins each against `game_character`. The coordinator bypasses RLS so it - /// sees every player's entity regardless of the caller's scope. - pub fn in_world_players(&self) -> Result> { - let guard = self.0.coord(); - let db = &guard.conn.db; - let shard_name = self.shard_name().to_string(); - let rows = db - .game_world_entity() - .iter() - .filter(|e| e.entry == 0) // players have entry == 0; creatures have a template entry - .filter_map(|e| { - let ch = db.game_character().guid().find(&e.guid)?; - let away = crate::world::presence::away_from_player_flags(e.player_flags); - let entity = crate::codec::MemberEntity { - health: e.health, - max_health: e.max_health, - power: e.power, - max_power: e.max_power, - unit_bytes_0: e.unit_bytes_0, - level: e.level, - zone_id: e.zone_id, - x: e.x, - y: e.y, - dead: e.dead, - player_flags: e.player_flags, - // `/who` does not read auras or a pet, so this bulk scan never fills them. - ..Default::default() - }; - Some(crate::world::presence::RealmPresence { - guid: e.guid, - name: ch.name, - race: ch.race, - class: ch.class, - level: u8::try_from(e.level).unwrap_or(u8::MAX), - zone_id: e.zone_id, - session_online: ch.online, - whereabouts: crate::world::presence::Whereabouts::InWorld { - away, - entity, - shard_name: shard_name.clone(), - }, - }) - }) - .collect(); - Ok(rows) - } - - /// `game_area.name` for `zone_id`, `/who`'s search-string match against a zone name — a static - /// catalogue, subscribed unconditionally. Empty when the catalogue holds no row for it. - pub fn zone_name(&self, zone_id: u32) -> String { - self.0 - .coord() - .conn - .db - .game_area() - .id() - .find(&zone_id) - .map(|a| a.name) - .unwrap_or_default() - } - - /// Resolve a typed name to a Character guid on this Shard, ASCII case-insensitive like the - /// Module's own `helpers::character_by_name`, from the Shard's [`CharacterNameIndex`]. `None` - /// if no Character here has that name. Every realm-wide name lookup (whisper, party and guild - /// invites, contacts, channel moderation) reaches this through `presence::resolve_by_name` or - /// `presence::resolve_all_by_name`. - pub fn character_guid_by_name(&self, name: &str) -> Result> { - let guard = self.0.coord(); - let names = guard - .character_names - .read() - .unwrap_or_else(|p| p.into_inner()); - Ok(names.guid_named(name)) - } - - /// A character's live presence `(online, level, class, zone_id)` for `SMSG_FRIEND_STATUS`/ - /// `SMSG_FRIEND_LIST`. `None` if the guid doesn't resolve to any character (a stale reference). - pub fn character_presence(&self, guid: u64) -> Result> { - Ok(self - .0 - .coord() - .conn - .db - .game_character() - .guid() - .find(&guid) - .map(|c| (c.online, c.level, c.class, c.zone_id))) - } - - /// `owner_guid`'s friend guids and ignore guids for `CMSG_FRIEND_LIST`, per - /// [`crate::world::SocialStore::contact_lists`]. `owner_guid` is always the CALLING - /// World Session's own guid, which is always live and registered right now, so this reads the - /// guids off its Gateway-side `Viewer` — the friend and ignore sets `world_view`'s contact - /// Relay already keeps current — instead of scanning `game_character_contact`. `None` Viewer - /// (should not happen for this caller) degrades to two empty lists rather than erroring. - /// Presence composition (online/team/Away Status) lives over the Store seam in - /// `world::social::friend_views`, not here, so a Store Fake exercises the same code a - /// Coordinator does. - pub fn contact_lists(&self, owner_guid: u64) -> Result<(Vec, Vec)> { - let Some(viewer) = self - .world_view() - .viewer_of_owner(super::super::world_view::OwnerGuid(owner_guid)) - else { - return Ok((Vec::new(), Vec::new())); - }; - Ok((viewer.friend_guids(), viewer.ignored_guids())) - } - - /// `owner_guid`'s ignore guids from this Shard's [`ContactIndex`], realm-wide safe for ANY - /// owner, per [`crate::world::SocialStore::ignored_guids`]. Unlike `contact_lists`, - /// `owner_guid` here is a Character this Gateway process may never have a `Viewer` for at all (a - /// whisper sender or a guild-invite target is usually a PEER, not the connected session), so - /// this cannot route through one. - pub fn ignored_guids(&self, owner_guid: u64) -> Result> { - let guard = self.0.coord(); - let contacts = guard.contacts.read().unwrap_or_else(|p| p.into_inner()); - Ok(contacts.ignored_by(owner_guid)) - } } /// The Gateway-side index over `game_character_contact`, keyed by owner and kept current by the diff --git a/gateway/src/stdb/reads/auction.rs b/gateway/src/stdb/reads/auction.rs deleted file mode 100644 index 273cd75e6..000000000 --- a/gateway/src/stdb/reads/auction.rs +++ /dev/null @@ -1,424 +0,0 @@ -//! Read-only auction market queries and their pure filter/pagination seam. - -use std::time::{SystemTime, UNIX_EPOCH}; - -use anyhow::{anyhow, Result}; -use lyracore_shared::item::Proficiency; -use spacetimedb_sdk::Table; - -use super::super::bindings::*; -use super::super::connection::Coordinator; -use crate::world::{AuctionBrowseRequest, AuctionPage, AuctionQuery}; - -#[derive(Clone, Copy)] -struct BrowseFacts<'a> { - name: &'a str, - required_level: u8, - inventory_type: u8, - item_class: u8, - item_subclass: u8, - quality: u8, -} - -fn browse_matches( - request: &AuctionBrowseRequest, - player_level: u8, - proficiency: Proficiency, - item: BrowseFacts<'_>, -) -> bool { - (request.name.is_empty() - || item - .name - .to_lowercase() - .contains(&request.name.to_lowercase())) - && request - .minimum_level - .is_none_or(|minimum| item.required_level >= minimum) - && request - .maximum_level - .is_none_or(|maximum| item.required_level <= maximum) - && request - .inventory_type - .is_none_or(|wanted| u32::from(item.inventory_type) == wanted) - && request - .item_class - .is_none_or(|wanted| u32::from(item.item_class) == wanted) - && request - .item_subclass - .is_none_or(|wanted| u32::from(item.item_subclass) == wanted) - && request.quality.is_none_or(|wanted| item.quality == wanted) - && (!request.usable_only - || (item.required_level <= player_level - && proficiency.can_equip(item.item_class, item.item_subclass))) -} - -fn paginate(mut rows: Vec, offset: u32) -> (Vec, u32) { - rows.sort_by_key(|row| row.id); - let total = u32::try_from(rows.len()).unwrap_or(u32::MAX); - let page = rows.into_iter().skip(offset as usize).take(50).collect(); - (page, total) -} - -fn select_active_page( - rows: impl IntoIterator, - market: lyracore_shared::auction::AuctionMarket, - now_micros: i64, - offset: u32, - mut matches: impl FnMut(&Auction) -> bool, -) -> (Vec, u32) { - paginate( - rows.into_iter() - .filter(|row| { - lyracore_shared::auction::market_of(row.house) == market - && row.expires_at.to_micros_since_unix_epoch() > now_micros - && matches(row) - }) - .collect(), - offset, - ) -} - -fn bidder_matches(row: &Auction, player_guid: u64, _outbid_auction_ids: &[u32]) -> bool { - // Vanilla sends recently outbid ids as cache-invalidation hints. They never authorize a - // displaced row back into the bidder tab; the authoritative winner is the realm Auction. - row.highest_bidder_guid == player_guid -} - -fn now_micros() -> i64 { - let micros = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_micros(); - i64::try_from(micros).unwrap_or(i64::MAX) -} - -fn auction_view(row: Auction) -> crate::codec::AuctionView { - crate::codec::AuctionView { - id: row.id, - item_entry: row.item_entry, - item_stack_count: row.item_stack_count, - item_enchant_id: row.item_enchant_id, - owner_guid: row.owner_guid, - start_bid: row.start_bid, - buyout: row.buyout, - highest_bidder_guid: row.highest_bidder_guid, - highest_bid: row.highest_bid, - expires_at_micros: row.expires_at.to_micros_since_unix_epoch(), - random_property_id: row.random_property_id, - } -} - -impl Coordinator { - pub(crate) fn auction_query( - &self, - player_guid: u64, - house_id: u32, - query: AuctionQuery, - ) -> Result { - let (player_level, proficiency) = { - let guard = self.0.coord(); - let db = &guard.conn.db; - let player = db - .game_world_entity() - .guid() - .find(&player_guid) - .ok_or_else(|| anyhow!("auction query actor {player_guid} is not in world"))?; - // The "usable" checkbox asks what THIS Character can wear, so the armor half reads its - // trained passives, not the class ceiling — a level-1 Warrior must not see plate as - // usable. One spellbook scan per query, not per row. - let learned: Vec = db - .game_player_spell() - .iter() - .filter(|s| s.character_guid == player_guid) - .map(|s| s.spell_id) - .collect(); - ( - u8::try_from(player.level).unwrap_or(u8::MAX), - crate::codec::armor_proficiency( - ((player.unit_bytes_0 >> 8) & 0xff) as u8, - &learned, - ), - ) - }; - let now_micros = now_micros(); - let market = self.realm_core()?; - let guard = market.0.coord(); - let db = &guard.conn.db; - let offset = match &query { - AuctionQuery::Browse(request) => request.offset, - AuctionQuery::Owner { offset } => *offset, - AuctionQuery::Bidder { offset, .. } => *offset, - }; - let (rows, total) = select_active_page( - db.game_auction().iter(), - lyracore_shared::auction::market_of(house_id), - now_micros, - offset, - |row| match &query { - AuctionQuery::Browse(request) => db - .game_item_template() - .entry() - .find(&row.item_entry) - .is_some_and(|item| { - browse_matches( - request, - player_level, - proficiency, - BrowseFacts { - name: &item.name, - required_level: item.required_level, - inventory_type: item.inventory_type, - item_class: item.class, - item_subclass: item.subclass, - quality: item.quality, - }, - ) - }), - AuctionQuery::Owner { .. } => row.owner_guid == player_guid, - AuctionQuery::Bidder { - outbid_auction_ids, .. - } => bidder_matches(row, player_guid, outbid_auction_ids), - }, - ); - - Ok(AuctionPage { - rows: rows.into_iter().map(auction_view).collect(), - total, - now_micros, - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn request() -> AuctionBrowseRequest { - AuctionBrowseRequest { - auctioneer_guid: 1, - offset: 0, - name: "SWORD".to_owned(), - minimum_level: Some(10), - maximum_level: Some(20), - inventory_type: Some(13), - item_class: Some(2), - item_subclass: Some(7), - quality: Some(2), - usable_only: true, - } - } - - fn facts() -> BrowseFacts<'static> { - BrowseFacts { - name: "Solid Short Sword", - required_level: 15, - inventory_type: 13, - item_class: 2, - item_subclass: 7, - quality: 2, - } - } - - /// A Character of `player_class` that has trained neither level-40 armor upgrade. - fn untrained(player_class: u8) -> Proficiency { - Proficiency::derive(player_class, false, false) - } - - #[test] - fn browse_supports_every_exact_filter_and_the_existing_usable_model() { - assert!(browse_matches(&request(), 15, untrained(1), facts())); - - let mismatches = [ - AuctionBrowseRequest { - name: "axe".to_owned(), - ..request() - }, - AuctionBrowseRequest { - minimum_level: Some(16), - ..request() - }, - AuctionBrowseRequest { - maximum_level: Some(14), - ..request() - }, - AuctionBrowseRequest { - inventory_type: Some(17), - ..request() - }, - AuctionBrowseRequest { - item_class: Some(4), - ..request() - }, - AuctionBrowseRequest { - item_subclass: Some(8), - ..request() - }, - AuctionBrowseRequest { - quality: Some(3), - ..request() - }, - ]; - for mismatch in mismatches { - assert!(!browse_matches(&mismatch, 15, untrained(1), facts())); - } - assert!(!browse_matches(&request(), 14, untrained(1), facts())); - assert!(!browse_matches(&request(), 15, untrained(5), facts())); - assert!(browse_matches( - &AuctionBrowseRequest { - usable_only: false, - ..request() - }, - 15, - untrained(5), - facts(), - )); - assert!(browse_matches( - &AuctionBrowseRequest { - auctioneer_guid: 1, - offset: 0, - name: String::new(), - minimum_level: None, - maximum_level: None, - inventory_type: None, - item_class: None, - item_subclass: None, - quality: None, - usable_only: false, - }, - 1, - untrained(5), - facts(), - )); - } - - #[test] - fn the_usable_filter_follows_trained_armor_not_the_class_ceiling() { - let plate = BrowseFacts { - item_class: 4, - item_subclass: 4, - ..facts() - }; - let usable = AuctionBrowseRequest { - item_class: Some(4), - item_subclass: Some(4), - ..request() - }; - assert!(!browse_matches(&usable, 60, untrained(1), plate)); - assert!(browse_matches( - &usable, - 60, - Proficiency::derive(1, true, false), - plate - )); - // A Mage trains neither upgrade, so plate stays unusable however the flags fall. - assert!(!browse_matches( - &usable, - 60, - Proficiency::derive(8, true, true), - plate - )); - } - - fn auction(id: u32) -> Auction { - Auction { - id, - listing_operation_id: u64::from(id), - house: 4, - owner_guid: 1, - item_guid: u64::from(id), - item_entry: 25, - item_stack_count: 1, - item_durability: 10, - item_enchant_id: 0, - item_soulbound: false, - random_property_id: 0, - start_bid: 1, - buyout: 0, - highest_bidder_guid: 0, - highest_bid: 0, - deposit: 1, - created_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, - expires_at: spacetimedb_sdk::Timestamp::from_micros_since_unix_epoch(i64::MAX), - revision: 0, - deposit_rate: 5, - consignment_rate: 5, - item_text_id: 0, - } - } - - #[test] - fn pagination_sorts_before_taking_fifty_and_reports_the_full_total() { - let rows = (1..=55).rev().map(auction).collect(); - let (first, total) = paginate(rows, 0); - assert_eq!(total, 55); - assert_eq!(first.len(), 50); - assert_eq!(first.first().unwrap().id, 1); - assert_eq!(first.last().unwrap().id, 50); - - let rows = (1..=55).rev().map(auction).collect(); - let (second, total) = paginate(rows, 50); - assert_eq!(total, 55); - assert_eq!( - second.iter().map(|row| row.id).collect::>(), - (51..=55).collect::>() - ); - } - - #[test] - fn active_selection_excludes_expired_other_market_and_non_owner_rows_before_totals() { - use lyracore_shared::auction::AuctionMarket; - - let mut expired = auction(1); - expired.expires_at = spacetimedb_sdk::Timestamp::from_micros_since_unix_epoch(10); - let mut at_deadline = auction(2); - at_deadline.expires_at = spacetimedb_sdk::Timestamp::from_micros_since_unix_epoch(20); - let mut other_market = auction(3); - other_market.house = 7; // Blackwater is neutral, outside the queried Horde market. - let mut same_market_other_house = auction(4); - same_market_other_house.house = 5; // Thunder Bluff pools into the same Horde market. - let mut other_owner = auction(5); - other_owner.house = 4; - other_owner.owner_guid = 2; - let mut owned = auction(6); - owned.house = 4; - - let rows = vec![ - expired, - at_deadline, - other_market, - same_market_other_house, - other_owner, - owned, - ]; - let (page, total) = - select_active_page(rows, AuctionMarket::Horde, 20, 0, |row| row.owner_guid == 1); - assert_eq!(total, 2); - assert_eq!( - page.iter().map(|row| row.id).collect::>(), - vec![4, 6] - ); - } - - #[test] - fn bidder_selection_never_reintroduces_requested_outbid_auctions() { - use lyracore_shared::auction::AuctionMarket; - - let mut highest = auction(5); - highest.highest_bidder_guid = 8; - highest.highest_bid = 107; - let mut displaced = auction(19); - displaced.highest_bidder_guid = 9; - displaced.highest_bid = 113; - - let requested_outbid_ids = [19, 88]; - let (page, total) = select_active_page( - vec![displaced, highest], - AuctionMarket::Horde, - 20, - 0, - |row| bidder_matches(row, 8, &requested_outbid_ids), - ); - assert_eq!(total, 1); - assert_eq!(page.iter().map(|row| row.id).collect::>(), vec![5]); - } -} diff --git a/gateway/src/stdb/reads/channel.rs b/gateway/src/stdb/reads/channel.rs index c7dfe7290..2e2454a84 100644 --- a/gateway/src/stdb/reads/channel.rs +++ b/gateway/src/stdb/reads/channel.rs @@ -3,11 +3,7 @@ use std::collections::{BTreeMap, HashMap}; -use lyracore_shared::channel::ChannelName; - -use super::super::connection::Coordinator; use super::*; -use crate::world::ChannelRoster; /// The Gateway-side index over `game_chat_channel` and `game_chat_channel_member`, kept current by /// the cache's insert, update and delete callbacks. The SDK cache has only unique-index finds, and a @@ -48,11 +44,11 @@ impl ChannelIndex { } } - fn channel_id(&self, team: u32, name_key: &str) -> Option { + pub(in crate::stdb) fn channel_id(&self, team: u32, name_key: &str) -> Option { self.by_name.get(&(team, name_key.to_string())).copied() } - fn members(&self, channel_id: u64) -> Vec<(u64, u8)> { + pub(in crate::stdb) fn members(&self, channel_id: u64) -> Vec<(u64, u8)> { self.members .get(&channel_id) .map(|members| members.values().copied().collect()) @@ -60,57 +56,6 @@ impl ChannelIndex { } } -impl Coordinator { - /// The channel `channel_name` names for `team` on Realm-core, with its members in join order - /// and its owner's name from whichever World Shard holds the Character. - pub(crate) fn channel_roster( - &self, - team: u32, - channel_name: &str, - ) -> anyhow::Result> { - let realm = self.realm_core()?; - let key = ChannelName::normalize(channel_name).key; - let found = { - let guard = realm.0.coord(); - // The index lock is released before the cache read: the pump takes the cache and - // then the index in its callbacks. - let indexed = { - let index = guard.chat_channels.read().unwrap(); - index - .channel_id(team, &key) - .map(|channel_id| (channel_id, index.members(channel_id))) - }; - indexed.and_then(|(channel_id, members)| { - let channel = guard - .conn - .db - .game_chat_channel() - .channel_id() - .find(&channel_id)?; - Some((channel, members)) - }) - }; - // The guard is gone: the owner's name reads other Shards' caches. - let Some((channel, members)) = found else { - return Ok(None); - }; - let owner_name = if channel.owner_guid == 0 { - String::new() - } else { - crate::world::presence::character_anywhere(self, channel.owner_guid)? - .map(|character| character.name) - .unwrap_or_default() - }; - Ok(Some(ChannelRoster { - name: channel.name, - flags: channel.flags, - owner_guid: channel.owner_guid, - owner_name, - members, - })) - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/gateway/src/stdb/reads/chat.rs b/gateway/src/stdb/reads/chat.rs deleted file mode 100644 index e1bf014df..000000000 --- a/gateway/src/stdb/reads/chat.rs +++ /dev/null @@ -1,59 +0,0 @@ -use super::*; - -impl super::super::connection::Coordinator { - /// Undelivered private System Messages addressed to `self_guid`, oldest-first, from the - /// shard's `game_system_message_event` cache. Rows live until the module's event GC reaps - /// them, so a message emitted inside `player_login` is still here at world entry. - pub fn system_messages_for(&self, self_guid: u64) -> Vec { - let mut rows: Vec<(u64, String)> = self - .0 - .coord() - .conn - .db - .game_system_message_event() - .iter() - .filter(|row| row.recipient_guid == self_guid) - .map(|row| (row.id, row.message)) - .collect(); - rows.sort_unstable_by_key(|(id, _)| *id); - rows.into_iter().map(|(_, message)| message).collect() - } - - /// The stored Auto-Reply for `guid` on this Shard: one primary-key read of the private - /// `game_character_away` cache. - pub(crate) fn auto_reply_text(&self, guid: u64) -> Option { - self.0 - .coord() - .conn - .db - .game_character_away() - .character_guid() - .find(&guid) - .map(|row| row.message) - } - - /// The Speaker Facts for `speaker_guid` on this Home Shard: race from `UNIT_FIELD_BYTES_0` - /// byte 0 and the chat tag from `PLAYER_FLAGS`, both off the live entity, plus the Character's - /// name. `None` when the speaker has no live entity here. - pub(crate) fn speaker_facts( - &self, - speaker_guid: u64, - ) -> anyhow::Result> { - let guard = self.0.coord(); - let db = &guard.conn.db; - let Some(entity) = db.game_world_entity().guid().find(&speaker_guid) else { - return Ok(None); - }; - let name = db - .game_character() - .guid() - .find(&speaker_guid) - .map(|character| character.name) - .unwrap_or_default(); - Ok(Some(crate::world::SpeakerFacts { - race: (entity.unit_bytes_0 & 0xFF) as u8, - chat_tag: lyracore_shared::chat::chat_tag_for(entity.player_flags), - name, - })) - } -} diff --git a/gateway/src/stdb/reads/guild.rs b/gateway/src/stdb/reads/guild.rs index 4f844a260..9934250f4 100644 --- a/gateway/src/stdb/reads/guild.rs +++ b/gateway/src/stdb/reads/guild.rs @@ -10,7 +10,7 @@ use spacetimedb_sdk::{Table, TableWithPrimaryKey}; use super::super::bindings::*; use super::super::connection::Coordinator; -use crate::world::{guild_fee, CharacterFacts}; +use crate::world::CharacterFacts; /// The Guild Ranks and members of each Guild, and the Characters that Petitions and Signatures /// name, kept from the row callbacks. The SDK cache has no index on these columns, so a guild read @@ -20,7 +20,7 @@ pub(crate) struct GuildIndex { rank_ids: HashMap>, member_guids: HashMap>, /// Petition owner or signer guid to the number of Petition and Signature rows naming it. - petition_characters: HashMap, + pub(in crate::stdb) petition_characters: HashMap, } impl GuildIndex { @@ -46,7 +46,7 @@ impl GuildIndex { remove_key(&mut self.member_guids, row.guild_id, row.character_guid); } - fn rank_ids(&self, guild_id: u32) -> Vec { + pub(in crate::stdb) fn rank_ids(&self, guild_id: u32) -> Vec { self.rank_ids .get(&guild_id) .map(|ids| ids.iter().copied().collect()) @@ -54,7 +54,7 @@ impl GuildIndex { } /// The members of `guild_id`, lowest guid first. - fn member_guids(&self, guild_id: u32) -> Vec { + pub(in crate::stdb) fn member_guids(&self, guild_id: u32) -> Vec { self.member_guids .get(&guild_id) .map(|guids| guids.iter().copied().collect()) @@ -75,7 +75,7 @@ impl GuildIndex { } /// Every member of every Guild, every Petition owner and every signer. - fn named_characters(&self) -> BTreeSet { + pub(in crate::stdb) fn named_characters(&self) -> BTreeSet { self.member_guids .values() .flatten() @@ -180,7 +180,10 @@ pub(crate) fn watch_guilds(conn: &DbConnection) -> Arc> { } /// A Petition with its signers, found by key: the SDK cache has no index on the Petition column. -fn petition_view(db: &RemoteTables, row: GuildPetition) -> crate::codec::PetitionView { +pub(in crate::stdb) fn petition_view( + db: &RemoteTables, + row: GuildPetition, +) -> crate::codec::PetitionView { use lyracore_shared::guild::{petition_signature_key, MAX_PETITION_SIGNATURES}; let signers = (0..MAX_PETITION_SIGNATURES) .filter_map(|slot| { @@ -199,138 +202,7 @@ fn petition_view(db: &RemoteTables, row: GuildPetition) -> crate::codec::Petitio } } -fn member_view(row: GuildMember) -> crate::codec::GuildMemberView { - crate::codec::GuildMemberView { - character_guid: row.character_guid, - guild_id: row.guild_id, - rank_id: row.rank_id, - name: row.name, - public_note: row.public_note, - officer_note: row.officer_note, - realm_account_id: row.realm_account_id, - } -} - impl Coordinator { - /// The membership row of `character_guid` in THIS handle's cache. Call it on the Realm-core - /// handle. - pub(crate) fn guild_member_row( - &self, - character_guid: u64, - ) -> Option { - self.0 - .coord() - .conn - .db - .game_guild_member() - .character_guid() - .find(&character_guid) - .map(member_view) - } - - /// One Guild and its Guild Ranks in THIS handle's cache, ranks ordered by `rank_id`. - pub(crate) fn guild_row(&self, guild_id: u32) -> Option { - let guard = self.0.coord(); - let db = &guard.conn.db; - let guild = db.game_guild().guild_id().find(&guild_id)?; - let rank_ids = guard.guilds.read().unwrap().rank_ids(guild_id); - let ranks_table = db.game_guild_rank(); - let mut ranks: Vec = rank_ids - .into_iter() - .filter_map(|id| ranks_table.id().find(&id)) - .map(|rank| crate::codec::GuildRankView { - rank_id: rank.rank_id, - name: rank.name, - rights: rank.rights, - }) - .collect(); - ranks.sort_by_key(|rank| rank.rank_id); - Some(crate::codec::GuildView { - guild_id: guild.guild_id, - name: guild.name, - leader_guid: guild.leader_guid, - team: guild.team, - motd: guild.motd, - info: guild.info, - emblem_style: guild.emblem_style, - emblem_color: guild.emblem_color, - border_style: guild.border_style, - border_color: guild.border_color, - background_color: guild.background_color, - created_micros: guild.created_micros, - ranks, - }) - } - - /// Every member row of one Guild in THIS handle's cache, ordered by guid. - pub(crate) fn guild_member_rows(&self, guild_id: u32) -> Vec { - let guard = self.0.coord(); - let guids = guard.guilds.read().unwrap().member_guids(guild_id); - let members = guard.conn.db.game_guild_member(); - guids - .into_iter() - .filter_map(|guid| members.character_guid().find(&guid)) - .filter(|member| member.guild_id == guild_id) - .map(member_view) - .collect() - } - - /// Every Character that a Guild, a Petition or a Signature in THIS handle's cache names: the - /// Characters the deleted-Character reconciliation checks. Call it on the Realm-core handle. - /// It fails while the subscription is unhealthy, so a stale cache never hides a Character. - pub(crate) fn guild_character_guids(&self) -> Result> { - let guard = self.0.coord(); - if !guard.is_healthy() { - anyhow::bail!( - "{} has no healthy Coordinator subscription for guild cleanup", - self.shard_name() - ); - } - let named = guard.guilds.read().unwrap().named_characters(); - Ok(named.into_iter().collect()) - } - - /// Does a Guild, a Petition or a Signature in THIS handle's cache name `character_guid`? Keyed - /// reads only. Call it on the Realm-core handle. Fails while the subscription is unhealthy. - pub(crate) fn guild_names_character(&self, character_guid: u64) -> Result { - let guard = self.0.coord(); - if !guard.is_healthy() { - anyhow::bail!( - "{} has no healthy Coordinator subscription for guild cleanup", - self.shard_name() - ); - } - let member = guard - .conn - .db - .game_guild_member() - .character_guid() - .find(&character_guid) - .is_some(); - Ok(member - || guard - .guilds - .read() - .unwrap() - .petition_characters - .contains_key(&character_guid)) - } - - /// The name of the Guild `character_guid` belongs to in THIS handle's cache, if any. Two keyed - /// reads, cheap enough for every `/who` row. - pub(crate) fn guild_name_of_member(&self, character_guid: u64) -> Option { - let guard = self.0.coord(); - let db = &guard.conn.db; - let member = db - .game_guild_member() - .character_guid() - .find(&character_guid)?; - db.game_guild() - .guild_id() - .find(&member.guild_id) - .map(|guild| guild.name) - } - /// The Guild Projection `(guild_id, rank_id)` of `character_guid` in THIS handle's cache. /// `(0, 0)` outside a Guild. Cheap enough for a relay job. pub(crate) fn guild_projection(&self, character_guid: u64) -> (u32, u32) { @@ -382,51 +254,6 @@ impl Coordinator { .map_or(0, |character| character.gm_level) } - /// The unit `actor_guid` has selected, from THIS handle's live entity. 0 for none. - pub(crate) fn selected_target(&self, actor_guid: u64) -> u64 { - self.0 - .coord() - .conn - .db - .game_world_entity() - .guid() - .find(&actor_guid) - .map_or(0, |entity| entity.target_guid) - } - - /// The Fee Hold of `payer_guid` in THIS handle's cache. Call it on the payer's Home Shard. A - /// kind this Gateway does not know reads as none. - pub(crate) fn guild_fee_hold_row(&self, payer_guid: u64) -> Option { - let hold = self - .0 - .coord() - .conn - .db - .game_guild_fee_hold() - .payer_guid() - .find(&payer_guid)?; - let terms = match hold.kind { - lyracore_shared::guild::fee_kind::EMBLEM => { - guild_fee::FeeTerms::Emblem(guild_fee::Emblem { - emblem_style: hold.emblem_style, - emblem_color: hold.emblem_color, - border_style: hold.border_style, - border_color: hold.border_color, - background_color: hold.background_color, - }) - } - lyracore_shared::guild::fee_kind::CHARTER => guild_fee::FeeTerms::Charter { - charter_item_guid: hold.charter_item_guid, - name: hold.charter_name, - }, - _ => return None, - }; - Some(guild_fee::FeeHold { - operation_id: hold.operation_id, - terms, - }) - } - /// The open Petition of the Guild Charter `charter_item_guid` in THIS handle's cache. Call it /// on the Realm-core handle. pub(crate) fn guild_petition_of_charter( @@ -473,57 +300,4 @@ impl Coordinator { item.enchantment = realm.charter_petition_id(item.guid).unwrap_or(0); } } - - /// The open Petition `owner_guid` owns in THIS handle's cache. Call it on the Realm-core - /// handle. - pub(crate) fn guild_petition_of_owner( - &self, - owner_guid: u64, - ) -> Option { - let guard = self.0.coord(); - let db = &guard.conn.db; - let row = db.game_guild_petition().owner_guid().find(&owner_guid)?; - Some(petition_view(db, row)) - } - - /// Does a Guild in THIS handle's cache hold `name`, without regard to case? Call it on the - /// Realm-core handle. - pub(crate) fn guild_name_taken(&self, name: &str) -> bool { - self.0 - .coord() - .conn - .db - .game_guild() - .name_key() - .find(&lyracore_shared::guild::name_key(name)) - .is_some() - } - - /// Does `actor_guid` hold the Guild Charter `charter_item_guid` in THIS handle's cache? Call it - /// on the actor's Home Shard. - pub(crate) fn holds_guild_charter(&self, actor_guid: u64, charter_item_guid: u64) -> bool { - self.0 - .coord() - .conn - .db - .game_item_instance() - .guid() - .find(&charter_item_guid) - .is_some_and(|item| { - item.owner_guid == actor_guid - && item.entry == lyracore_shared::guild::GUILD_CHARTER_ENTRY - }) - } - - /// Realm-core's fee decision for `operation_id` in THIS handle's cache. Call it on the - /// Realm-core handle. - pub(crate) fn guild_fee_decision_row(&self, operation_id: u64) -> Option { - self.0 - .coord() - .conn - .db - .game_guild_fee_decision() - .operation_id() - .find(&operation_id) - } } diff --git a/gateway/src/stdb/reads/items.rs b/gateway/src/stdb/reads/items.rs index f5faf30da..fe4f54960 100644 --- a/gateway/src/stdb/reads/items.rs +++ b/gateway/src/stdb/reads/items.rs @@ -5,9 +5,7 @@ use anyhow::Result; use spacetimedb_sdk::Table; use super::super::bindings::*; -use super::super::connection::{Coordinator, LiveConn}; -use super::player_item_count; -use lyracore_shared::group::GroupKind; +use super::super::connection::Coordinator; use lyracore_shared::item_property::client_enchantment_id; impl Coordinator { @@ -56,41 +54,6 @@ impl Coordinator { .collect() } - /// The three enchant ids of `random_property_id`, zero where it names none. - pub fn random_property_enchant_ids(&self, random_property_id: u32) -> [u32; 3] { - let guard = self.0.coord(); - property_enchant_ids(&guard.conn.db, random_property_id) - } - - /// Does `owner_guid` hold an item carrying `item_text_id`? `CMSG_ITEM_TEXT_QUERY`'s ownership - /// Gate: a client cannot use this to probe what anyone else holds, since the answer is a bare - /// bool. Read from the privileged cache. - /// - /// `hint_item_guid` is the wire's own second field — a bag item guid when the client is - /// reading that item's tooltip (`cm:MailHandler.cpp:630-646`), and vmangos always sends it for - /// this shape of the query. A PK lookup answers in O(1); a miss (item deleted, wrong id, or a - /// crafted query) answers `false` rather than falling back to a table-wide scan — this runs on - /// every such query, including ones for an id that names nothing, and the SDK exposes no - /// owner-keyed index to narrow it by. - pub fn owns_item_with_text( - &self, - owner_guid: u64, - item_text_id: u32, - hint_item_guid: u64, - ) -> Result { - if item_text_id == 0 || hint_item_guid == 0 { - return Ok(false); - } - let guard = self.0.coord(); - let db = &guard.conn.db; - let owns = db - .game_item_instance() - .guid() - .find(&hint_item_guid) - .is_some_and(|item| item.owner_guid == owner_guid && item.item_text_id == item_text_id); - Ok(owns) - } - /// Bag slot of the item instance with `item_guid`. Reads from the coordinator's privileged cache /// (same source as `player_items`). Item GUIDs are globally unique so account_id isn't needed /// for the lookup — ownership is enforced by the module reducer on the call. @@ -108,113 +71,6 @@ impl Coordinator { .collect(); slots.into_iter().next() } - - /// Item-starts-quest: does the item in `owner_guid`'s inventory `slot` carry a - /// non-zero `item_template.start_quest`? Returns `(item_guid, quest_id)` if so — the gateway's - /// `CMSG_USE_ITEM` handler uses this to open `SMSG_QUESTGIVER_QUEST_DETAILS` (item guid as giver) - /// INSTEAD of calling `use_item` (never both — the item isn't consumed). `None` for every ordinary - /// item (the ubiquitous case), which falls through to the normal use-item path unchanged. - pub fn item_start_quest(&self, owner_guid: u64, slot: u8) -> Option<(u64, u32)> { - let guard = self.0.coord(); - let db = &guard.conn.db; - let hit: Option<(u64, u32)> = db - .game_item_instance() - .iter() - .filter(|i| i.owner_guid == owner_guid && i.slot == slot) - .find_map(|i| { - db.game_item_template() - .entry() - .find(&i.entry) - .filter(|t| t.start_quest != 0) - .map(|t| (i.guid, t.start_quest)) - }); - hit - } - - /// Read a corpse's item loot for the loot window, joined with each item's - /// template for the display id, then filtered PER VIEWER for `quest_only` rows (quest items are - /// per-looter, not gated on whoever got kill credit) AND group-loot rows (a live NEED/GREED - /// roll is withheld from EVERYONE; a round-robin/master-designated row is visible only to its - /// designee). Read from the privileged cache (the coordinator bypasses RLS), filtered by - /// `corpse_guid` (iterate+filter, like the other row queries). Returns `(slot, item_id, count, - /// display_id)` triples for `build_loot_response_raw`. An item whose template isn't loaded falls - /// back to display 0 (the client still resolves the name via query). - pub fn corpse_loot( - &self, - corpse_guid: u64, - viewer_guid: u64, - ) -> Result> { - let guard = self.0.coord(); - let db = &guard.conn.db; - let mut items: Vec = db - .game_corpse_loot() - .iter() - .filter(|l| l.corpse_guid == corpse_guid) - .filter(|l| { - if l.quest_only { - let needs = viewer_needs_quest_item(&guard, viewer_guid, l.item_entry); - quest_row_visible_to_viewer(l.quest_only, l.reserved_for, viewer_guid, needs) - } else { - group_loot_row_visible_to_viewer( - l.reserved_for, - l.withheld, - l.master_only, - l.designated_looter_guid, - viewer_guid, - ) - } - }) - .map(|l| { - let display_id = db - .game_item_template() - .entry() - .find(&l.item_entry) - .map(|t| t.display_id) - .unwrap_or(0); - ( - l.slot, - l.item_entry, - l.count, - display_id, - l.random_property_id, - ) - }) - .collect(); - items.sort_by_key(|(slot, ..)| *slot); // stable loot-slot order (SQL has no ORDER BY in 2.5) - Ok(items) - } - - /// Read a corpse's lootable copper for `SMSG_LOOT_RESPONSE` from the privileged cache. - /// Returns 0 if the target is missing or not a corpse — the client only sends `CMSG_LOOT` on a - /// lootable corpse, but we stay defensive (an empty loot window is harmless). - pub fn loot_target_money(&self, target_guid: u64) -> Result { - Ok(self - .0 - .coord() - .conn - .db - .game_world_entity() - .guid() - .find(&target_guid) - .filter(|e| e.dead) - .map(|e| e.money) - .unwrap_or(0)) - } - - /// The player's buyback ring, newest-first: `(item_entry, stack_count, price)` ≤12. - pub fn buyback_ring(&self, player_guid: u64) -> Vec<(u32, u32, u32, u32)> { - let guard = self.0.coord(); - let db = &guard.conn.db; - let mut rows: Vec<_> = db - .game_character_buyback() - .iter() - .filter(|b| b.player_guid == player_guid) - .collect(); - rows.sort_by(|a, b| b.id.cmp(&a.id)); - rows.into_iter() - .map(|b| (b.item_entry, b.stack_count, b.price, b.random_property_id)) - .collect() - } } /// The view fields an item row holds itself, without template or catalogue reads. The enchantment @@ -248,278 +104,3 @@ pub(crate) fn property_enchant_ids(db: &RemoteTables, random_property_id: u32) - ] }) } - -/// Whether `viewer_guid` is in a Raid, read from this handle's cache: the `PartyMembershipIndex` -/// (an indexed lookup, never a table scan) names the group, and the cached `game_group.group_type` -/// names its kind. The Gateway twin of the Module's `group::in_raid`. -fn viewer_in_raid(guard: &LiveConn, viewer_guid: u64) -> bool { - let Some(group_id) = guard - .party_memberships - .read() - .unwrap() - .group_of(viewer_guid) - else { - return false; - }; - guard - .conn - .db - .game_group() - .group_id() - .find(&group_id) - .is_some_and(|group| { - GroupKind::from_wire(group.group_type).unwrap_or_default() == GroupKind::Raid - }) -} - -/// Does `viewer_guid` currently need quest item `item_entry`? The gateway twin -/// of the module's `loot::killer_needs_item`/`needs_item_pure` (an ACTIVE — un-rewarded — quest with a -/// `COLLECT_ITEM` objective naming `item_entry`, held < required), applied to the VIEWER opening the -/// loot window rather than the credited killer, and gated by the same raid quest-credit rule -/// (`lyracore_shared::quest::quest_progresses_for_raid`) so the loot window never promises an item -/// a Raid member's `take_loot` would then refuse (that Gate is `loot::killer_needs_item`, gated -/// identically). RLS-bypassed read (coordinator), same shape as -/// `quest_objectives_complete`/`player_item_count` above. -fn viewer_needs_quest_item(guard: &LiveConn, viewer_guid: u64, item_entry: u32) -> bool { - const COLLECT_ITEM: u8 = 1; // == module objective_kind::COLLECT_ITEM - let db = &guard.conn.db; - let active: Vec = db - .game_character_quest() - .iter() - .filter(|q| q.character_guid == viewer_guid && !q.rewarded) - .map(|q| q.quest_entry) - .collect(); - if active.is_empty() { - return false; - } - let in_raid = viewer_in_raid(guard, viewer_guid); - let objectives: Vec<(u32, u32, u32)> = db - .game_quest_objective() - .iter() - .filter(|o| o.kind == COLLECT_ITEM && active.contains(&o.quest_entry)) - .map(|o| { - let quest_type = db - .game_quest_template() - .entry() - .find(&o.quest_entry) - .map_or(0, |t| t.quest_type); - (quest_type, o.target_entry, o.required_count) - }) - .collect(); - needs_quest_item_pure( - in_raid, - &objectives, - item_entry, - player_item_count(db, viewer_guid, item_entry), - ) -} - -/// Pure decision behind `viewer_needs_quest_item`: does any of `objectives` (flattened -/// `(quest_type, target_entry, required_count)` for the viewer's active COLLECT_ITEM objectives) -/// want `item`, given the viewer's Raid membership? Split out so the raid quest-credit rule's -/// wiring is unit-testable without a live cache, mirroring the module's `needs_item_pure`. -fn needs_quest_item_pure( - in_raid: bool, - objectives: &[(u32, u32, u32)], - item: u32, - held: u32, -) -> bool { - objectives - .iter() - .any(|&(quest_type, target_entry, required_count)| { - target_entry == item - && held < required_count.max(1) - && lyracore_shared::quest::quest_progresses_for_raid(in_raid, quest_type) - }) -} - -/// The per-viewer loot-window visibility gate: is a `game_corpse_loot` row -/// visible to `viewer_guid`? A non-quest row is always visible (FFA, unconditional). A `quest_only` -/// row is visible when EITHER it's still the UNRESERVED shared row -/// (`reserved_for == 0` — nobody has split it yet) and the viewer currently needs the item -/// (`viewer_needs_item`, resolved by the caller — mirrors the module's ctx/pure split so this decision -/// needs no live cache to unit-test), OR it's the viewer's OWN already-split reserved clone -/// (`reserved_for == viewer_guid`) — a clone reserved for someone ELSE is invisible regardless of need. -/// Mirrored module-side by `loot::quest_take_allowed` (same predicate shape) so a row a viewer's -/// window shows is always a row their take can actually succeed on. Pure. -pub(crate) fn quest_row_visible_to_viewer( - quest_only: bool, - reserved_for: u64, - viewer_guid: u64, - viewer_needs_item: bool, -) -> bool { - if !quest_only { - return true; - } - if reserved_for == viewer_guid { - return true; - } - reserved_for == 0 && viewer_needs_item -} - -/// The per-viewer loot-window visibility gate for a NON-quest row (the -/// caller only calls this when `!quest_only`; `quest_row_visible_to_viewer` handles quest rows, -/// unchanged, above). `reserved_for` is GENERALIZED here beyond the quest clone: nonzero also covers -/// a NEED/GREED winner's inventory-full fallback row (`resolve_roll`'s module doc) — either way, -/// nonzero means "visible ONLY to that guid", full stop. A `withheld` row (a live roll in progress) -/// is invisible to EVERYONE (not just non-eligible viewers — matches the design's "withheld rows -/// invisible in EVERYONE's window until resolved", proving the AutoLoot-safety trap: a grey + -/// green drop on one corpse has the grey's row visible/FFA while the green's is withheld). A -/// `master_only` row is never shown via the plain window (the master acts through -/// `SMSG_LOOT_MASTER_LIST`/`CMSG_LOOT_MASTER_GIVE` instead). A `designated_looter_guid` row (round- -/// robin/below-threshold) is visible only to its designee. The all-zero/false baseline is plain FFA -/// (byte-identical to the original baseline). Mirrored module-side by `loot::group_loot_take_allowed` -/// (same predicate shape) so a row a viewer's window shows is always a row their take can succeed -/// on. Pure. -pub(crate) fn group_loot_row_visible_to_viewer( - reserved_for: u64, - withheld: bool, - master_only: bool, - designated_looter_guid: u64, - viewer_guid: u64, -) -> bool { - if reserved_for != 0 { - return reserved_for == viewer_guid; - } - if withheld || master_only { - return false; - } - designated_looter_guid == 0 || designated_looter_guid == viewer_guid -} - -#[cfg(test)] -mod tests { - use super::{ - group_loot_row_visible_to_viewer, needs_quest_item_pure, quest_row_visible_to_viewer, - }; - - // The per-viewer loot-window visibility gate. Every other read in this - // file goes through the coordinator's live cache (`RemoteTables`) and has no fake-cache harness to - // unit-test against (the module crate's "never mock the ctx, extract + test pure fns" rule applies - // here too). `quest_row_visible_to_viewer` and `needs_quest_item_pure` are the decisions worth - // pulling out pure so they're directly testable without a live SpacetimeDB connection. - - /// `needs_quest_item_pure` mirrors the Module's raid quest-credit rule: a Raid member's - /// non-Raid quest stops needing its item, a Raid Quest's need is unaffected, and a Party - /// member (in_raid = false) is unaffected either way. Hand-written values, not computed by - /// the code under test. - #[test] - fn needs_quest_item_pure_gates_a_raid_members_non_raid_quest() { - let normal_quest_wants_the_item = [(0, 55, 1)]; // (quest_type, target_entry, required_count) - let raid_quest_wants_the_item = [(62, 55, 1)]; - assert!( - needs_quest_item_pure(false, &normal_quest_wants_the_item, 55, 0), - "outside a Raid, a normal quest's need applies" - ); - assert!( - !needs_quest_item_pure(true, &normal_quest_wants_the_item, 55, 0), - "inside a Raid, a normal quest's item need is gated off" - ); - assert!( - needs_quest_item_pure(true, &raid_quest_wants_the_item, 55, 0), - "inside a Raid, a Raid Quest's need still applies" - ); - assert!( - !needs_quest_item_pure(false, &normal_quest_wants_the_item, 55, 1), - "held at or above the requirement never needs it" - ); - assert!( - !needs_quest_item_pure(true, &raid_quest_wants_the_item, 999, 0), - "a different item entry is never needed" - ); - } - - #[test] - fn non_quest_rows_are_always_visible_regardless_of_viewer_state() { - // FFA, unconditional — the original baseline behavior. reserved_for/need are irrelevant. - assert!(quest_row_visible_to_viewer(false, 0, 7, false)); - assert!(quest_row_visible_to_viewer(false, 99, 7, false)); - assert!(quest_row_visible_to_viewer(false, 7, 7, false)); - } - - #[test] - fn an_unreserved_quest_row_is_visible_only_to_a_needing_viewer() { - assert!( - quest_row_visible_to_viewer(true, 0, 7, true), - "needs it -> sees the shared row" - ); - assert!( - !quest_row_visible_to_viewer(true, 0, 7, false), - "doesn't need it -> invisible" - ); - } - - #[test] - fn a_reserved_clone_is_visible_only_to_its_owner() { - // The viewer's OWN clone is visible even if `viewer_needs_item` somehow reads false (a stale - // held-count race) — the reservation itself is the authority once split. - assert!( - quest_row_visible_to_viewer(true, 7, 7, false), - "the viewer's own reserved clone" - ); - assert!(quest_row_visible_to_viewer(true, 7, 7, true)); - // Reserved for a DIFFERENT character — invisible to this viewer even if they also need it. - assert!( - !quest_row_visible_to_viewer(true, 7, 8, true), - "reserved for someone else" - ); - assert!(!quest_row_visible_to_viewer(true, 7, 8, false)); - } - - // ---- Group loot methods ---- - - #[test] - fn group_loot_baseline_ffa_is_visible_to_everyone() { - assert!(group_loot_row_visible_to_viewer(0, false, false, 0, 7)); - assert!(group_loot_row_visible_to_viewer(0, false, false, 0, 8)); - } - - #[test] - fn group_loot_withheld_row_is_invisible_to_everyone_including_the_eventual_winner() { - // The AutoLoot-safety trap: a live roll's row must not appear in ANYONE's window, not even - // the guid who will eventually win it. - assert!(!group_loot_row_visible_to_viewer(0, true, false, 0, 7)); - assert!(!group_loot_row_visible_to_viewer(0, true, false, 7, 7)); - } - - #[test] - fn group_loot_master_only_row_never_shows_in_the_plain_window() { - // Not even the stamped master sees it here — they act via SMSG_LOOT_MASTER_LIST instead. - assert!(!group_loot_row_visible_to_viewer(0, false, true, 42, 42)); - assert!(!group_loot_row_visible_to_viewer(0, false, true, 42, 8)); - } - - #[test] - fn group_loot_designated_row_is_visible_only_to_its_designee() { - assert!(group_loot_row_visible_to_viewer(0, false, false, 42, 42)); - assert!(!group_loot_row_visible_to_viewer(0, false, false, 42, 8)); - } - - #[test] - fn group_loot_reserved_winner_locked_row_wins_over_every_other_flag() { - // A nonzero reserved_for (the inventory-full winner fallback) is exclusive and unconditional - // — it overrides withheld/master_only/designated entirely (those are all stale by then). - assert!(group_loot_row_visible_to_viewer(7, true, true, 99, 7)); - assert!(!group_loot_row_visible_to_viewer(7, true, true, 99, 8)); - } - - /// The AutoLoot-addon trap, spelled out as ONE corpse's two rows: a grey - /// (below-threshold, FFA-in-group) item autoloots normally while a green (at/above threshold, - /// mid-roll) item on the SAME corpse is simultaneously withheld from every window — proving the - /// two rows are decided independently and a live roll can never be swept up by autostore. - #[test] - fn a_grey_autoloots_while_a_green_on_the_same_corpse_is_withheld() { - let grey = (0u64, false, false, 0u64); // FFA baseline: (reserved_for, withheld, master_only, designated) - let green = (0u64, true, false, 0u64); // a live NEED/GREED roll in progress - for viewer in [7u64, 8u64] { - assert!( - group_loot_row_visible_to_viewer(grey.0, grey.1, grey.2, grey.3, viewer), - "the grey row autoloots for any viewer" - ); - assert!( - !group_loot_row_visible_to_viewer(green.0, green.1, green.2, green.3, viewer), - "the green row is withheld from every viewer while its roll is live" - ); - } - } -} diff --git a/gateway/src/stdb/reads/mail.rs b/gateway/src/stdb/reads/mail.rs index 6285070e4..c5964e437 100644 --- a/gateway/src/stdb/reads/mail.rs +++ b/gateway/src/stdb/reads/mail.rs @@ -6,11 +6,9 @@ use std::collections::{BTreeSet, HashMap}; -use anyhow::Result; use spacetimedb_sdk::{Table, TableWithPrimaryKey}; use super::super::bindings::*; -use super::super::connection::Coordinator; /// The mail escrow ids each Character holds on one Shard, kept from the `game_mail_escrow` row /// callbacks. A drive runs after every quest turn-in, at world entry and at every mailbox visit, so @@ -38,7 +36,7 @@ impl MailEscrowIndex { } /// The escrow ids `sender_guid` holds, lowest id first. - fn of(&self, sender_guid: u64) -> Vec { + pub(in crate::stdb) fn of(&self, sender_guid: u64) -> Vec { self.by_sender .get(&sender_guid) .map(|ids| ids.iter().copied().collect()) @@ -69,169 +67,6 @@ pub(crate) fn watch_mail_escrows( index } -fn mail_view(db: &RemoteTables, m: Mail) -> crate::codec::MailView { - crate::codec::MailView { - id: m.id, - sender_guid: m.sender_guid, - subject: m.subject, - body: m.body, - item_entry: m.item_entry, - item_stack_count: m.item_stack_count, - item_durability: m.item_durability, - // The row only ever snapshots CURRENT durability (mail.rs's `ItemSnapshot`); the - // true max lives on the attachment's own template, the same read `player_items` - // joins for. 0 for no attachment — `entry().find(0)` finds nothing. - max_durability: db - .game_item_template() - .entry() - .find(&m.item_entry) - .map(|t| t.max_durability) - .unwrap_or(0), - item_enchant_id: m.item_enchant_id, - item_soulbound: m.item_soulbound, - random_property_id: m.random_property_id, - money: m.money, - cod: m.cod, - was_read: m.was_read, - created_at_secs: m.created_at.to_micros_since_unix_epoch() / 1_000_000, - sender_kind: m.sender_kind, - sender_entry: m.sender_entry, - check_flags: m.check_flags, - mail_template_id: m.mail_template_id, - // Rounded up to the second. The Gateway compares it with its own clock, which can run - // ahead of the Module's, so the Module Gates mark-read, delete, every take, the return - // and a COD payment against its own clock again. - deliver_secs: m - .deliver_micros - .saturating_add(999_999) - .div_euclid(1_000_000), - } -} - -impl Coordinator { - /// Every mail addressed to `recipient_guid`, delivered or not, in no set order. - /// `codec::build_mail_list` orders the inbox. The SDK exposes only the PK index, so this - /// iterates and filters like every other per-owner read here (`player_items`, `player_skills`). - pub fn mail_list(&self, recipient_guid: u64) -> Result> { - let guard = self.0.coord(); - let db = &guard.conn.db; - Ok(db - .game_mail() - .iter() - .filter(|m| m.recipient_guid == recipient_guid) - .map(|m| mail_view(db, m)) - .collect()) - } - - /// The mail `mail_id`, delivered or not, by its primary key. - pub fn mail_by_id(&self, mail_id: u64) -> Option { - let guard = self.0.coord(); - let db = &guard.conn.db; - db.game_mail().id().find(&mail_id).map(|m| mail_view(db, m)) - } - - /// Every mail escrow this database is holding for `sender_guid` — the fences a drive filed and - /// never finished. - /// - /// The one module→gateway data flow the escrow adds, and it exists for the same reason - /// `escrowed_transfer` does: the gateway is the only component that can see both databases, so - /// re-driving a stalled fence means re-deriving the whole letter from its row. A Character's fresh - /// send reads nothing more than its own fence; a Reward Letter, which the Module files at - /// turn-in, is always driven from its row. Private table, read through the owner token. - /// - /// Keyed by `sender_guid`, which on a payout escrow is the PAYEE — the character owed the - /// copper either way, and the one whose session is about to re-drive it. - pub fn mail_escrows_of(&self, sender_guid: u64) -> Result> { - let guard = self.0.coord(); - let ids = guard.mail_escrows.read().unwrap().of(sender_guid); - let escrows = guard.conn.db.game_mail_escrow(); - Ok(ids - .into_iter() - .filter_map(|id| escrows.escrow_id().find(&id)) - .filter_map(|e| { - // A row no letter could have written stays held rather than commit as the wrong - // letter. - let reward = lyracore_shared::mail::RewardHeader::from_columns( - e.sender_kind, - e.sender_entry, - e.mail_template_id, - ) - .map_err(|refusal| { - log::error!("mail escrow {}: not driven: {refusal}", e.escrow_id) - }) - .ok()?; - Some((e, reward)) - }) - .map(|(e, reward)| crate::world::mail::HeldEscrow { - escrow_id: e.escrow_id, - recipient_guid: e.recipient_guid, - subject: e.subject, - body: e.body, - money: e.money, - postage: e.postage, - payout: e.payout, - mail_id: e.mail_id, - item: crate::world::mail::AttachedItem { - entry: e.item_entry, - stack_count: e.item_stack_count, - durability: e.item_durability, - enchant_id: e.item_enchant_id, - soulbound: e.item_soulbound, - random_property_id: e.random_property_id, - item_text_id: e.item_text_id, - }, - cod: e.cod, - delivery_delay_secs: e.delivery_delay_secs, - reward, - }) - .collect()) - } - - /// A copied letter's text, read from `game_item_text` on THIS handle's database. Private table, - /// read through the owner token, by its PK — the same shape `mailbox_in_range` uses to resolve - /// a gameobject. - pub fn item_text(&self, item_text_id: u32) -> Result> { - let guard = self.0.coord(); - Ok(guard - .conn - .db - .game_item_text() - .id() - .find(&item_text_id) - .map(|t| t.text)) - } - - /// Is `player_guid` standing at the mailbox `mailbox_guid` names? - /// - /// A PK lookup on `game_gameobject`, then the same map/instance/range check - /// `module/src/gameobject.rs::usable_go` applies to a chest. It must STAY a PK lookup: - /// `game_gameobject` is spatial, and a scan over a sharded table returns a silent subset, so a - /// mailbox availability would depend on which database the session reads. - /// - /// `false` for an unknown guid, a non-mailbox gameobject, another map or instance, and anything - /// out of range — the gate answers the question, and the caller decides what a refusal costs. - pub fn mailbox_in_range(&self, mailbox_guid: u64, player_guid: u64) -> Result { - let guard = self.0.coord(); - let db = &guard.conn.db; - let (Some(go), Some(player)) = ( - db.game_gameobject().guid().find(&mailbox_guid), - db.game_world_entity().guid().find(&player_guid), - ) else { - return Ok(false); - }; - let is_mailbox = db - .game_gameobject_template() - .entry() - .find(&go.template_entry) - .is_some_and(|t| t.type_id == lyracore_shared::mail::MAILBOX_GO_TYPE); - if !is_mailbox || go.map_id != player.map_id || go.instance_id != player.instance_id { - return Ok(false); - } - let (dx, dy, dz) = (go.x - player.x, go.y - player.y, go.z - player.z); - Ok(dx * dx + dy * dy + dz * dz <= lyracore_shared::mail::MAILBOX_RANGE_SQ) - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/gateway/src/stdb/reads/meeting_stone.rs b/gateway/src/stdb/reads/meeting_stone.rs deleted file mode 100644 index be03afaac..000000000 --- a/gateway/src/stdb/reads/meeting_stone.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Meeting Stone reads: stone rows on the Home Shard, Seeker rows on the party authority. - -use anyhow::Result; -use lyracore_shared::constants::go_type; - -use super::super::connection::Coordinator; -use super::*; - -impl Coordinator { - /// The dungeon area of the Meeting Stone spawned as `go_guid` on this database, if any. - pub(crate) fn meeting_stone_area(&self, go_guid: u64) -> Result> { - let guard = self.0.coord(); - let db = &guard.conn.db; - Ok(db - .game_gameobject() - .guid() - .find(&go_guid) - .and_then(|go| { - db.game_gameobject_template() - .entry() - .find(&go.template_entry) - }) - .filter(|template| template.type_id == go_type::MEETINGSTONE) - .and_then(|template| db.game_meeting_stone().entry().find(&template.entry)) - .map(|stone| stone.area_id)) - } - - /// The area `character_guid` is queued for, read from the party authority. - pub(crate) fn queued_area(&self, character_guid: u64) -> Result> { - let realm = self.realm_core()?; - let guard = realm.0.coord(); - Ok(guard - .conn - .db - .game_meeting_stone_seeker() - .character_guid() - .find(&character_guid) - .map(|seeker| seeker.area_id)) - } -} diff --git a/gateway/src/stdb/reads/mod.rs b/gateway/src/stdb/reads/mod.rs index 5fdd8b1f9..f5a138b9a 100644 --- a/gateway/src/stdb/reads/mod.rs +++ b/gateway/src/stdb/reads/mod.rs @@ -1,26 +1,21 @@ //! Pure cache-accessor methods on `Coordinator`: read the privileged subscription cache (RLS -//! bypass) and project rows into codec views. No reducer calls — those live in `reducers.rs`. +//! bypass) and project rows into codec views. A read only one Store family calls lives in +//! `store/.rs`. //! //! Split by domain (pure code-motion): each submodule below is its own `impl Coordinator` //! block. A free-fn helper used by callers in more than one submodule lives here instead of in //! either domain file. mod account; -mod auction; mod channel; -mod chat; mod guild; mod items; mod mail; -mod meeting_stone; -mod npc_vendor; mod party; -mod pet; mod quest; mod spell; mod talent_reputation; mod templates; -mod weather; use spacetimedb_sdk::Table; @@ -36,11 +31,16 @@ pub(crate) use guild::{watch_guilds, GuildIndex}; pub(crate) use items::{property_enchant_ids, view_of_item_row}; pub(crate) use mail::{watch_mail_escrows, MailEscrowIndex}; +// Shared with the Store family adapters in `stdb::store`. +pub(in crate::stdb) use guild::petition_view; +pub(in crate::stdb) use quest::quest_objectives_complete; +pub(in crate::stdb) use spell::spell_ranks_stack_in_book; + /// Sum a player's held quantity of item `entry` over `game_item_instance` (the coordinator reads any /// player's items — RLS-bypassed, like the quest log). The gateway twin of the module's /// `items::item_count`. Shared by `quest::quest_objectives_complete` and /// `items::viewer_needs_quest_item`. -fn player_item_count(db: &RemoteTables, owner_guid: u64, entry: u32) -> u32 { +pub(in crate::stdb) fn player_item_count(db: &RemoteTables, owner_guid: u64, entry: u32) -> u32 { db.game_item_instance() .iter() .filter(|i| i.owner_guid == owner_guid && i.entry == entry) diff --git a/gateway/src/stdb/reads/npc_vendor.rs b/gateway/src/stdb/reads/npc_vendor.rs deleted file mode 100644 index 5f2636194..000000000 --- a/gateway/src/stdb/reads/npc_vendor.rs +++ /dev/null @@ -1,373 +0,0 @@ -//! NPC interaction cache-accessor methods (gossip/vendor/trainer) — pure code-motion split -//! of the former `reads.rs`. - -use anyhow::Result; -use spacetimedb_sdk::Table; - -use super::super::bindings::*; -use super::super::connection::Coordinator; - -fn active_gossip_menu_id(db: &RemoteTables, entity: &WorldEntity) -> Option { - let menu = db - .game_creature_gossip_menu_override() - .creature_guid() - .find(&entity.guid); - matching_gossip_menu_id( - entity.guid, - entity.map_id, - entity.instance_id, - menu.as_ref(), - ) -} - -fn matching_gossip_menu_id( - creature_guid: u64, - map_id: u32, - instance_id: u64, - menu: Option<&CreatureGossipMenuOverride>, -) -> Option { - menu.filter(|menu| { - menu.creature_guid == creature_guid - && menu.map_id == map_id - && menu.instance_id == instance_id - }) - .map(|menu| menu.menu_id) -} - -impl Coordinator { - /// Resolve the active title text for one NPC, with a per-creature menu override before the - /// entry-owned default menu. - pub fn npc_gossip_text_id(&self, npc_guid: u64) -> u32 { - let guard = self.0.coord(); - let db = &guard.conn.db; - let entity = match db.game_world_entity().guid().find(&npc_guid) { - Some(entity) => entity, - None => return crate::codec::GOSSIP_GREETING_TEXT_ID, - }; - if let Some(menu_id) = active_gossip_menu_id(db, &entity) { - if let Some(menu) = db.game_gossip_menu_profile().menu_id().find(&menu_id) { - return menu.text_id; - } - } - match db.game_gossip_menu().entry().find(&entity.entry) { - Some(row) => row.text_id, - None => crate::codec::GOSSIP_GREETING_TEXT_ID, - } - } - - /// Look up the full weighted greeting for a `text_id`: `game_npc_text` (slot 0 - /// male, back-compat) + any `game_npc_text_slot` rows for the same id. Returns `None` when the - /// text_id has no `game_npc_text` row at all (the gateway falls back to the generic greeting). - /// - /// Back-compat normalization: a `text_id` with NO `game_npc_text_slot` rows (never imported by the - /// weighted-greeting importer — either a pre-existing row, or the legacy `npc_text[entry]` - /// fallback path, which only ever populates `game_npc_text`) is read as slot 0 = `(text, text, - /// 1.0)` and every other slot silent, matching the old single-slot behavior byte-for-byte. When - /// slot rows DO exist, they - /// are used verbatim (real per-slot probabilities from the dump) and slot 0's base-row `text` is - /// NOT separately re-applied (the slot-0 row, always emitted by the importer alongside the others, - /// is the source of truth once any slot row exists). - pub fn npc_text_for_id(&self, text_id: u32) -> Option { - let guard = self.0.coord(); - let db = &guard.conn.db; - let base = db.game_npc_text().text_id().find(&text_id)?; - let mut slot_rows: Vec<_> = db - .game_npc_text_slot() - .iter() - .filter(|s| s.text_id == text_id) - .collect(); - if slot_rows.is_empty() { - let mut view = crate::codec::NpcTextView::default(); - view.slots[0] = (base.text.clone(), base.text, 1.0); - return Some(view); - } - slot_rows.sort_by_key(|s| s.slot_index); // stable slot order (SQL has no ORDER BY in 2.5) - let mut view = crate::codec::NpcTextView::default(); - for row in slot_rows { - if (row.slot_index as usize) < 8 { - view.slots[row.slot_index as usize] = - (row.text_male, row.text_female, row.probability); - } - } - Some(view) - } - - /// Return the active imported options for one NPC. A live per-creature override replaces the - /// entry-owned default options. The dispatcher applies conditions at both HELLO and SELECT. - pub fn gossip_options(&self, npc_guid: u64) -> Result> { - let guard = self.0.coord(); - let db = &guard.conn.db; - let Some(entity) = db.game_world_entity().guid().find(&npc_guid) else { - return Ok(Vec::new()); - }; - if let Some(menu_id) = active_gossip_menu_id(db, &entity) { - if db - .game_gossip_menu_profile() - .menu_id() - .find(&menu_id) - .is_some() - { - let mut rows: Vec<_> = db - .game_gossip_menu_profile_option() - .iter() - .filter(|option| option.menu_id == menu_id) - .collect(); - rows.sort_by_key(|option| option.option_index); - return Ok(rows - .into_iter() - .map(|option| crate::codec::GossipOptionView { - row_id: option.row_id, - icon: option.icon, - text: option.text, - action: option.action, - action_menu_id: option.action_menu_id, - cond_type: option.cond_type, - cond_value1: option.cond_value1, - cond_value2: option.cond_value2, - }) - .collect()); - } - } - let mut rows: Vec<_> = db - .game_gossip_option() - .iter() - .filter(|option| option.entry == entity.entry) - .collect(); - rows.sort_by_key(|option| option.option_index); - Ok(rows - .into_iter() - .map(|option| crate::codec::GossipOptionView { - row_id: option.row_id, - icon: option.icon, - text: option.text, - action: option.action, - action_menu_id: option.action_menu_id, - cond_type: option.cond_type, - cond_value1: option.cond_value1, - cond_value2: option.cond_value2, - }) - .collect()) - } - - /// `(taken, rewarded)` for `quest_id` in `guid`'s quest log (privileged read — RLS bypassed, like - /// `quest_giver_evals`) — feeds `option_condition_holds` for the QUEST_TAKEN/QUEST_REWARDED gossip - /// option conditions. `taken` is true whenever a log row exists at all (accepted, - /// whether or not yet turned in); a quest never seen by this player is `(false, false)`. - pub fn quest_status(&self, guid: u64, quest_id: u32) -> (bool, bool) { - let guard = self.0.coord(); - let db = &guard.conn.db; - let found = db - .game_character_quest() - .iter() - .find(|q| q.character_guid == guid && q.quest_entry == quest_id) - .map(|row| row.rewarded); - match found { - Some(rewarded) => (true, rewarded), - None => (false, false), - } - } - - /// Does the NPC at `guid` carry the innkeeper flag? Gates the "Make this inn your home." gossip - /// option + the `bind_home` select. Reads `npc_flags` off the entity (privileged cache); absent → false. - pub fn npc_is_innkeeper(&self, guid: u64) -> Result { - let guard = self.0.coord(); - let db = &guard.conn.db; - Ok(db - .game_world_entity() - .guid() - .find(&guid) - .is_some_and(|e| e.npc_flags & lyracore_shared::constants::npc_flags::INNKEEPER != 0)) - } - - /// Read a vendor's stock for `SMSG_LIST_INVENTORY` (Tier 2 / vendors). Resolve the vendor's - /// creature entry from its `game_world_entity` row, filter `game_npc_vendor` to that entry, and - /// join each `game_item_template` for the display id / buy price / max durability (carrying the - /// npc_vendor row's `max_count`). Read from the privileged cache (the coordinator bypasses RLS); - /// the SDK exposes only the PK index, so iterate+filter like the other row queries. Items whose - /// template isn't loaded are skipped (we can't price/display them). Ordered by the vendor `slot`. - pub fn vendor_items(&self, vendor_guid: u64) -> Result> { - let guard = self.0.coord(); - let db = &guard.conn.db; - // The vendor's creature entry drives the stock lookup; a missing entity → no stock. - let Some(creature_entry) = db - .game_world_entity() - .guid() - .find(&vendor_guid) - .map(|e| e.entry) - else { - return Ok(Vec::new()); - }; - let mut rows: Vec<_> = db - .game_npc_vendor() - .iter() - .filter(|v| v.creature_entry == creature_entry) - .collect(); - rows.sort_by_key(|v| v.slot); // stable vendor-slot order (SQL has no ORDER BY in 2.5) - let items = rows - .into_iter() - .filter_map(|v| { - // Skip a stock line whose template isn't loaded — we can't price or display it. - db.game_item_template() - .entry() - .find(&v.item_entry) - .map(|t| crate::codec::VendorItemView { - item_entry: v.item_entry, - display_id: t.display_id, - buy_price: t.buy_price, - max_durability: t.max_durability, - max_count: v.max_count, - buy_count: t.buy_count, - }) - }) - .collect(); - Ok(items) - } - - /// Does this trainer serve `player_guid`'s class? Fail-open on any missing read (trainer, - /// character, template), matching `npc_refuses_interaction`. - pub fn trainer_serves(&self, player_guid: u64, trainer_guid: u64) -> Result { - let guard = self.0.coord(); - let db = &guard.conn.db; - let (Some(trainer), Some(player)) = ( - db.game_world_entity().guid().find(&trainer_guid), - db.game_character().guid().find(&player_guid), - ) else { - return Ok(true); - }; - Ok(db - .game_creature_template() - .entry() - .find(&trainer.entry) - .is_none_or(|t| { - lyracore_shared::trainer::serves(player.class, t.trainer_type, t.trainer_class) - })) - } - - /// The skill line offering `spell_id` teaches at trainer `trainer_guid`, or 0 for an ordinary spell - /// offering (and for any missing trainer/offering row). The offering list is keyed by the trainer's - /// creature-template `entry`, like the vendor stock. Read from the privileged cache. - pub fn trainer_offer_skill_line(&self, trainer_guid: u64, spell_id: u32) -> u32 { - let guard = self.0.coord(); - let db = &guard.conn.db; - let Some(entry) = db - .game_world_entity() - .guid() - .find(&trainer_guid) - .map(|e| e.entry) - else { - return 0; - }; - let line = db - .game_trainer_spell() - .iter() - .find(|t| t.trainer_entry == entry && t.spell_id == spell_id) - .map(|t| t.learn_skill_line) - .unwrap_or(0); - line - } - - /// The spells trainer `trainer_guid` teaches, folded with `player_guid`'s level + known-state so the - /// codec can render each Green/Red/Gray. The trainer's creature-template `entry` keys the list (like - /// the vendor stock); `known` = a `game_player_spell` row (the one castability source). A missing - /// trainer/player → empty list. Read from the privileged cache (coordinator bypasses RLS). - pub fn trainer_list( - &self, - player_guid: u64, - trainer_guid: u64, - ) -> Result> { - let guard = self.0.coord(); - let db = &guard.conn.db; - let Some(trainer_entry) = db - .game_world_entity() - .guid() - .find(&trainer_guid) - .map(|e| e.entry) - else { - return Ok(Vec::new()); - }; - let Some(player) = db.game_world_entity().guid().find(&player_guid) else { - return Ok(Vec::new()); - }; - let learned: std::collections::HashSet = db - .game_player_spell() - .iter() - .filter(|s| s.character_guid == player_guid) - .map(|s| s.spell_id) - .collect(); - // The player's trained skill caps, by line — for the per-row "known" of a PROFESSION offering - // (game_player_spell never holds a profession id, so we gray a tier on the skill cap, not the - // spellbook). RLS-bypassed via the coordinator cache, same as the reads above. - let skill_caps: std::collections::HashMap = db - .game_player_skill() - .iter() - .filter(|s| s.character_guid == player_guid) - .map(|s| (s.skill_line, s.max_rank as u32)) - .collect(); - let mut rows: Vec<_> = db - .game_trainer_spell() - .iter() - .filter(|t| t.trainer_entry == trainer_entry) - .collect(); - rows.sort_by_key(|t| t.spell_id); // stable order (SQL has no ORDER BY in 2.5) - // A class-spell offering id is a LearnSpell WRAPPER (1873 teaches 639); game_player_spell holds - // the RESOLVED rank, so `known` must compare the resolved id or every known spell reads Green. - // Same first-qualifying-trigger rule as resolve_learn_target (can't call it: coord() re-lock); - // one pass over the effect table for the whole list instead of a scan per row. - const EXCLUDED: [u8; 4] = [0x93, 0xBE, 0xAB, 0x05]; - let wrapper_ids: std::collections::HashSet = rows - .iter() - .filter(|t| t.learn_skill_line == 0) - .map(|t| t.spell_id) - .collect(); - let mut resolved: std::collections::HashMap = std::collections::HashMap::new(); - for e in db.game_spell_effect().iter() { - if wrapper_ids.contains(&e.spell_id) - && e.trigger_spell != 0 - && !EXCLUDED.contains(&e.kind) - { - resolved.entry(e.spell_id).or_insert(e.trigger_spell); - } - } - Ok(rows - .into_iter() - .map(|t| crate::codec::TrainerSpellView { - spell_id: t.spell_id, - cost: t.cost, - required_level: t.required_level, - player_level: player.level, - // PROFESSION row: "known" iff the player's skill cap for this line already meets the - // offering's cap (mirrors module trainer_buy_check's gate → the client grays the tiers - // already trained past). CLASS-SPELL row (line 0): known iff a game_player_spell row - // for the RESOLVED rank behind the wrapper (mirrors the module buy gate's knows_spell). - known: if t.learn_skill_line != 0 { - skill_caps - .get(&t.learn_skill_line) - .is_some_and(|&c| c >= t.learn_skill_cap) - } else { - learned.contains(resolved.get(&t.spell_id).unwrap_or(&t.spell_id)) - }, - profession: t.learn_skill_line != 0, - }) - .collect()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn gossip_override_is_scoped_to_the_exact_creature_instance() { - let menu = CreatureGossipMenuOverride { - creature_guid: 41, - menu_id: 6687, - map_id: 43, - instance_id: 9, - }; - - assert_eq!(matching_gossip_menu_id(41, 43, 9, Some(&menu)), Some(6687)); - assert_eq!(matching_gossip_menu_id(42, 43, 9, Some(&menu)), None); - assert_eq!(matching_gossip_menu_id(41, 43, 10, Some(&menu)), None); - assert_eq!(matching_gossip_menu_id(41, 44, 9, Some(&menu)), None); - } -} diff --git a/gateway/src/stdb/reads/party.rs b/gateway/src/stdb/reads/party.rs index 43abc48fe..53aec1963 100644 --- a/gateway/src/stdb/reads/party.rs +++ b/gateway/src/stdb/reads/party.rs @@ -1,71 +1,11 @@ //! Party/group + loot-roll cache-accessor methods — pure code-motion split of the former //! `reads.rs`. -use anyhow::Result; -use spacetimedb_sdk::Table; - use super::super::bindings::*; use super::super::connection::Coordinator; use lyracore_shared::group::{GroupKind, RaidSlot}; impl Coordinator { - pub(crate) fn stable_party_holder_observation( - &self, - character_guid: u64, - serves_locator: bool, - ) -> Result { - let guard = self.0.coord(); - if !guard.is_healthy() { - anyhow::bail!( - "{} has no healthy Coordinator subscription for party partition certification", - self.shard_name() - ); - } - let has_escrow = guard - .conn - .db - .game_transfer_out() - .transfer_id() - .find(&crate::world::transfer::transfer_id_for(character_guid)) - .is_some_and(|row| row.character_guid == character_guid); - let character_partition = guard - .conn - .db - .game_character() - .guid() - .find(&character_guid) - .map(|character| (character.map_id, character.pending_instance_id)); - Ok(crate::world::party::PartyHolderObservation { - serves_locator, - has_escrow, - character_partition, - }) - } - - /// Every party in this Realm-core or World Shard cache. - pub fn party_group_ids(&self) -> Vec { - self.0 - .coord() - .conn - .db - .game_group() - .iter() - .map(|group| group.group_id) - .collect() - } - - /// Every Character with party membership in this Realm-core cache. - pub fn party_member_guids(&self) -> Vec { - self.0 - .coord() - .conn - .db - .game_group_member() - .iter() - .map(|member| member.character_guid) - .collect() - } - /// The party `character_guid` belongs to, read from THIS handle's database. /// /// Which database the handle points at is the whole meaning of the answer: on the **realm-core** @@ -87,55 +27,6 @@ impl Coordinator { self.group_roster_by_id(group_id) } - /// Read only the bounded roster projection accepted by companion-command authority. The bound - /// is the Raid cap: a longer list is a damaged cache. - pub fn party_command_group_roster( - &self, - character_guid: u64, - ) -> anyhow::Result> { - let guard = self.0.coord(); - let db = &guard.conn.db; - let Some((group_id, rows)) = guard - .party_memberships - .read() - .unwrap() - .bounded_member_rows(character_guid, lyracore_shared::group::RAID_MAX_MEMBERS)? - else { - return Ok(None); - }; - let Some(group) = db.game_group().group_id().find(&group_id) else { - return Ok(None); - }; - let members = rows - .into_iter() - .map(|(row_id, guid)| crate::world::party::GroupRosterMember { - guid, - slot: db - .game_group_member() - .id() - .find(&row_id) - .map_or_else(Default::default, |row| { - RaidSlot::from_wire_or_default(row.raid_slot) - }), - }) - .collect(); - Ok(Some(crate::world::party::GroupRoster { - group_id, - roster_revision: db - .game_group_roster_revision() - .group_id() - .find(&group_id) - .map_or(1, |row| row.revision), - leader_guid: group.leader_guid, - loot_method: group.loot_method, - loot_threshold: group.loot_threshold, - master_looter_guid: group.master_looter_guid, - kind: GroupKind::from_wire_or_default(group.group_type), - members, - partitions: Vec::new(), - })) - } - /// [`group_roster`](Self::group_roster) keyed by the group itself — the read the mirror push /// needs for a party the acting character has just LEFT (their own membership row is gone, but /// the remaining members' rows still have to reach every shard). @@ -202,239 +93,4 @@ impl Coordinator { partitions, }) } - - /// Realm-core's roster order survives disband in `game_group_roster_revision`. - pub fn group_roster_revision(&self, group_id: u64) -> u64 { - self.0 - .coord() - .conn - .db - .game_group_roster_revision() - .group_id() - .find(&group_id) - .map_or(1, |row| row.revision) - } - - /// The Roster Revision this database holds for one party, or `None` when it holds no row. On a - /// World Shard this is the mirror's revision, which outlives a disband. - pub fn held_roster_revision(&self, group_id: u64) -> Option { - self.0 - .coord() - .conn - .db - .game_group_roster_revision() - .group_id() - .find(&group_id) - .map(|row| row.revision) - } - - /// Every UNRESOLVED `game_loot_roll` row on THIS handle's database, joined with its votes. - /// - /// A cache read, like [`group_roster`](Self::group_roster) — this table is now part of the - /// coordinator subscription list, so no reducer call is - /// needed. Meaningful only on a WORLD SHARD in a sharded deployment: realm-core never has a row - /// here that a world shard wrote (only its own `realm_loot_op` START arm inserts there), and on - /// an unsharded gateway the relay that calls this never runs at all (`realm_store()` is `None`). - pub fn pending_local_rolls(&self) -> Result> { - let guard = self.0.coord(); - let db = &guard.conn.db; - Ok(db - .game_loot_roll() - .iter() - .filter(|r| !r.resolved) - .map(|r| { - let recipients = db - .game_loot_roll_vote() - .iter() - .filter(|v| v.roll_id == r.id) - .map(|v| v.voter_guid) - .collect(); - crate::world::loot::PendingLootRoll { - roll_id: r.id, - corpse_guid: r.corpse_guid, - slot: r.slot, - item_entry: r.item_entry, - deadline_micros: r.deadline_micros, - recipients, - random_property_id: r.random_property_id, - promotion_source: r.promotion_source, - } - }) - .collect()) - } - - // The return tuple is the poll result: the new watermark plus the `(corpse, slot, winner)` triples read since the old one. - #[allow(clippy::type_complexity)] - /// Every `ROLL_WON` `game_group_event` row on THIS handle's database with `id > after_id`, - /// decoded to `(corpse_guid, slot, winner_guid)`, plus the new high-water mark (the max id seen, - /// or `after_id` unchanged if none). Meaningful on the **realm-core** handle: that is the only - /// database `resolve_roll`/`force_resolve_rolls_for_disband` push a `ROLL_WON` event on in a - /// sharded deployment (voting is routed there exclusively — `world::loot::run_vote`). - /// - /// An unparseable payload is skipped + logged rather than failing the whole scan — the module - /// writes this grammar, so a decode failure here means the two crates' `lyracore_shared::loot_roll` - /// copies have drifted, not that this event is meaningless. - pub fn loot_won_since(&self, after_id: u64) -> Result<(u64, Vec<(u64, u8, u64)>)> { - use lyracore_shared::loot_roll::event_kind as roll_kind; - let guard = self.0.coord(); - let db = &guard.conn.db; - let mut watermark = after_id; - let mut wins = Vec::new(); - for row in db.game_group_event().iter() { - if row.id <= after_id || row.kind != roll_kind::ROLL_WON { - continue; - } - watermark = watermark.max(row.id); - match lyracore_shared::loot_roll::decode_won(&row.payload) { - Some((corpse_guid, slot, ..)) => wins.push((corpse_guid, slot, row.other_guid)), - None => log::warn!( - "loot-roll relay: unparseable ROLL_WON payload {:?} (event {})", - row.payload, - row.id - ), - } - } - Ok((watermark, wins)) - } -} - -impl Coordinator { - /// Every other member of `self_guid`'s group, from the party authority's membership index: - /// Realm-core on a sharded Realm, this database otherwise. A list longer than a Raid is a - /// damaged cache, not a group. - pub(crate) fn group_mates(&self, self_guid: u64) -> Result> { - let authority = if self.is_sharded() { - self.realm_core()? - } else { - self.clone() - }; - let roster = authority - .0 - .coord() - .party_memberships - .read() - .unwrap() - .bounded_member_rows(self_guid, lyracore_shared::group::RAID_MAX_MEMBERS)?; - Ok(roster - .map(|(_, rows)| { - rows.into_iter() - .map(|(_, guid)| guid) - .filter(|guid| *guid != self_guid) - .collect() - }) - .unwrap_or_default()) - } - - /// Find `guid` for Member Stats from its Realm Presence — the same live-entity / in-transit / - /// absence-gated-offline decision every other realm-wide read now shares, so there is no - /// second discovery left to disagree with it. - pub(crate) fn member_presence(&self, guid: u64) -> Result { - use crate::world::presence::Whereabouts; - Ok( - match crate::world::presence::of(self, guid)?.map(|presence| presence.whereabouts) { - Some(Whereabouts::InWorld { - entity, shard_name, .. - }) => { - let entity = self.with_member_shard_stats(&shard_name, guid, entity); - crate::world::MemberPresence::Live(Box::new( - crate::codec::MemberStats::from_entity(&entity), - )) - } - Some(Whereabouts::InTransit) => crate::world::MemberPresence::InTransit, - Some(Whereabouts::Offline) | None => crate::world::MemberPresence::Offline, - }, - ) - } - - /// Overlay `guid`'s aura slots and live pet onto `entity` — the one piece of Member Stats a - /// generic Realm Presence read cannot supply, because it does not know about `ShardId` or - /// `WorldView`'s `AuraIndex`. Reads from the exact Shard named `shard_name`, the same one - /// `presence::of` already searched to find the live entity `entity` itself came from, keyed by - /// its position in `all_shards()` — the same `enumerate()` order `arm_shared_world_view` - /// assigned when it built the `AuraIndex`. A name this handle's own `all_shards()` no longer - /// lists (a Shard dropped between the two reads) leaves `entity` as `presence::of` built it, - /// pet-less and aura-less rather than wrong. - fn with_member_shard_stats( - &self, - shard_name: &str, - guid: u64, - entity: crate::codec::MemberEntity, - ) -> crate::codec::MemberEntity { - let Some(shard) = self - .all_shards() - .iter() - .position(|coord| coord.shard_name() == shard_name) - else { - return entity; - }; - crate::codec::MemberEntity { - auras: self.member_aura_slots(shard, guid), - pet: self.member_pet(shard, guid), - ..entity - } - } - - /// `owner_guid`'s occupied `UNIT_FIELD_AURA` slots on `shard`, from `WorldView`'s `AuraIndex`. - fn member_aura_slots( - &self, - shard: crate::stdb::world_index::ShardId, - owner_guid: u64, - ) -> Vec { - self.world_view() - .auras - .on_target(shard, owner_guid) - .into_iter() - .map(|aura| crate::codec::MemberAuraSlot { - slot: aura.slot, - spell_id: aura.spell_id, - }) - .collect() - } - - /// `owner_guid`'s live pet, resolved the way the pet bar resolves it: a Hunter's name comes - /// from `game_hunter_pet_protocol`, a summoned pet's from its creature template, and every - /// other field from the pet's own `game_world_entity` row (`pet_name`, `stdb/reads/pet.rs`). - /// - /// The pet guid is deterministic (`lyracore_shared::pet::pet_guid_for`, the same derivation - /// the Module's `pet_of` uses), so this is one keyed lookup plus an owner check, never a scan. - /// `game_world_entity().iter()` clones every cached row while holding the client cache's own - /// lock, and this read runs for every live group mate on every Relay tick; a scan here would - /// contend with the shard's coordinator pump, the busiest callback in the process. - fn member_pet( - &self, - shard: crate::stdb::world_index::ShardId, - owner_guid: u64, - ) -> Option { - let live = self.0.coord(); - let db = &live.conn.db; - let pet_guid = lyracore_shared::pet::pet_guid_for(owner_guid); - let pet = db - .game_world_entity() - .guid() - .find(&pet_guid) - .filter(|pet| pet.owner_guid == owner_guid)?; - let name = db - .game_hunter_pet_protocol() - .iter() - .find(|hunter| hunter.live_pet_guid == pet.guid) - .map(|hunter| hunter.name) - .or_else(|| { - db.game_creature_template() - .entry() - .find(&pet.entry) - .map(|template| template.name) - }) - .unwrap_or_default(); - Some(crate::codec::MemberPetEntity { - guid: pet.guid, - name, - display_id: pet.display_id, - health: pet.health, - max_health: pet.max_health, - power: pet.power, - max_power: pet.max_power, - unit_bytes_0: pet.unit_bytes_0, - auras: self.member_aura_slots(shard, pet.guid), - }) - } } diff --git a/gateway/src/stdb/reads/pet.rs b/gateway/src/stdb/reads/pet.rs deleted file mode 100644 index 91002aab6..000000000 --- a/gateway/src/stdb/reads/pet.rs +++ /dev/null @@ -1,103 +0,0 @@ -use super::*; -use crate::codec::PetNameView; - -fn resolve_pet_name( - pet_number: u32, - pet_guid: u64, - hunter: Option, - summoned_name: Option, -) -> Option { - if let Some(pet) = hunter { - return (pet.live_pet_guid == pet_guid && pet.pet_id as u32 == pet_number).then_some( - PetNameView { - pet_number, - name: pet.name, - name_timestamp: pet.name_timestamp, - }, - ); - } - Some(PetNameView { - pet_number, - name: summoned_name?, - name_timestamp: 0, - }) -} - -impl super::super::connection::Coordinator { - /// Resolve a live pet visible to the world. Pet names are public unit presentation: Hunter - /// names come from the bounded durable projection, while summoned-pet names remain authored - /// creature-template data. The requester must be in world, but need not own the observed pet. - pub fn pet_name( - &self, - requester_guid: u64, - pet_number: u32, - pet_guid: u64, - ) -> Result, anyhow::Error> { - if requester_guid == 0 || pet_guid == 0 { - return Ok(None); - } - let guard = self.0.coord(); - let db = &guard.conn.db; - let Some(entity) = db.game_world_entity().guid().find(&pet_guid) else { - return Ok(None); - }; - if entity.owner_guid == 0 { - return Ok(None); - } - if let Some(pet) = db - .game_hunter_pet_protocol() - .iter() - .find(|pet| pet.live_pet_guid == pet_guid) - { - return Ok(resolve_pet_name( - pet_number, - pet_guid, - Some(super::super::views::hunter_pet_protocol_view(pet)), - None, - )); - } - let summoned = db - .game_creature_template() - .entry() - .find(&entity.entry) - .map(|template| template.name); - Ok(resolve_pet_name(pet_number, pet_guid, None, summoned)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn hunter(owner_guid: u64) -> crate::codec::HunterPetProtocolView { - crate::codec::HunterPetProtocolView { - pet_id: 77, - owner_guid, - live_pet_guid: 99, - creature_entry: 3098, - name: "Mottled Boar".into(), - name_timestamp: 123, - level: 8, - pet_xp: 0, - next_level_xp: 4_500, - happiness: 166_500, - loyalty_level: 1, - } - } - - #[test] - fn hunter_name_requires_pet_guid_and_number_but_not_the_observers_guid() { - let got = resolve_pet_name(77, 99, Some(hunter(7)), None).unwrap(); - assert_eq!(got.name, "Mottled Boar"); - assert_eq!(got.name_timestamp, 123); - assert!(resolve_pet_name(78, 99, Some(hunter(7)), None).is_none()); - assert!(resolve_pet_name(77, 100, Some(hunter(7)), None).is_none()); - } - - #[test] - fn summoned_pet_uses_authored_name_without_hunter_state() { - let got = resolve_pet_name(0, 99, None, Some("Imp".into())).unwrap(); - assert_eq!(got.name, "Imp"); - assert_eq!(got.name_timestamp, 0); - } -} diff --git a/gateway/src/stdb/reads/quest.rs b/gateway/src/stdb/reads/quest.rs index fd4e541c7..9210f675e 100644 --- a/gateway/src/stdb/reads/quest.rs +++ b/gateway/src/stdb/reads/quest.rs @@ -1,130 +1,12 @@ //! Quest-log / quest-giver cache-accessor methods — pure code-motion split of the former //! `reads.rs`. -use anyhow::Result; use spacetimedb_sdk::Table; use super::super::bindings::*; -use super::super::connection::Coordinator; use super::player_item_count; -impl Coordinator { - /// Evaluate every quest a creature OR gameobject `giver_guid` starts/ends against `player_guid`, - /// for the overhead status icon (`SMSG_QUESTGIVER_STATUS`) + the quest menu - /// (`SMSG_QUESTGIVER_QUEST_LIST`). The giver is resolved EXACTLY like the module's - /// `quest::validate_giver` fallback: a live `game_world_entity` first (a - /// creature), else a spawned `game_gameobject` (GO 68 "Wanted Poster" starts q176 with NO creature - /// giver at all; GO 55/56 "Lost Guards" corpses drive the q37/q45/q71 END chain) — never both, and - /// never a live player (this reader doesn't special-case party-share givers; a - /// player guid here matches the entity lookup (players share the table) but resolves to Giver::Creature(0) — a player entry is always 0, and no game_creature_quest row keys entry 0, so the result is empty, same as before). For each relation - /// on the giver's entry (`game_creature_quest` or `game_gameobject_quest`): join its - /// `game_quest_template`, look the quest up in the player's `game_character_quest` log (the - /// coordinator bypasses RLS so it reads any player's log), and compute `startable` (a fresh, - /// level-qualifying START), `active` (held + un-rewarded END), and `complete` (active + every - /// objective's count met). One guard, all inline — no nested cache locks. The codec - /// ([`crate::codec::quest_giver_status`]) folds these. - pub fn quest_giver_evals( - &self, - giver_guid: u64, - player_guid: u64, - ) -> Result> { - let guard = self.0.coord(); - let db = &guard.conn.db; - enum Giver { - Creature(u32), - GameObject(u32), - } - let giver = if let Some(e) = db.game_world_entity().guid().find(&giver_guid) { - Giver::Creature(e.entry) - } else if let Some(go) = db.game_gameobject().guid().find(&giver_guid) { - Giver::GameObject(go.template_entry) - } else { - return Ok(Vec::new()); - }; - // No live player → no status (don't fabricate a level-0 phantom). A DEAD player (ghost) gets no - // status either: the module rejects both accept AND turn-in while dead, so showing a `!`/`?` the - // reducer would then reject is exactly the icon-vs-reducer mismatch we forbid. - let Some(player) = db.game_world_entity().guid().find(&player_guid) else { - return Ok(Vec::new()); - }; - if player.dead { - return Ok(Vec::new()); - } - let player_level = player.level; - // Race/class packed in unit_bytes_0 (race | class<<8 | …), for the gates. - let race = (player.unit_bytes_0 & 0xFF) as u8; - let class = ((player.unit_bytes_0 >> 8) & 0xFF) as u8; - // The player's quest log, guid-keyed (privileged read — RLS bypassed). - let log: Vec<_> = db - .game_character_quest() - .iter() - .filter(|q| q.character_guid == player_guid) - .collect(); - let evals = match giver { - Giver::Creature(entry) => db - .game_creature_quest() - .iter() - .filter(|r| r.creature_entry == entry) - .filter_map(|rel| { - eval_relation( - db, - rel.quest_entry, - rel.role, - &log, - player_guid, - player_level, - race, - class, - ) - }) - .collect(), - Giver::GameObject(entry) => db - .game_gameobject_quest() - .iter() - .filter(|r| r.go_entry == entry) - .filter_map(|rel| { - eval_relation( - db, - rel.quest_entry, - rel.role, - &log, - player_guid, - player_level, - race, - class, - ) - }) - .collect(), - }; - Ok(evals) - } - - /// Build the detail view for one quest (the accept screen, the offer-reward screen, and the - /// completion popup all read this): title, money, the RESOLVED XP reward (explicit, else the shared - /// `lyracore_shared::quest::xp_reward` so it matches the module's grant), reward items joined with their - /// display ids, and a synthesized objectives line per kill objective ("Creature slain: 0/N" from the - /// creature-template name — quest body text isn't imported yet). `None` if the quest isn't loaded. - pub fn quest_detail(&self, quest_id: u32) -> Result> { - let guard = self.0.coord(); - Ok(quest_detail_view(&guard.conn.db, quest_id)) - } - - /// The player's active (un-rewarded) quests as quest-log descriptor slots: the quest-log window. - /// Deterministic slot assignment (sorted by quest_entry → slot 0..), capped at the 20 vanilla slots; - /// each slot carries the quest id, per-objective counts, and a state byte (1 = all objectives met, - /// else 0). The gateway encodes these into the `PLAYER_QUEST_LOG_*` fields. RLS-bypassed read. - pub fn player_quest_log( - &self, - player_guid: u64, - ) -> Result> { - let guard = self.0.coord(); - let db = &guard.conn.db; - let quests: Vec<_> = db.game_character_quest().iter().collect(); - Ok(build_quest_log_slots(db, &quests, player_guid)) - } -} - -fn quest_objectives_complete( +pub(in crate::stdb) fn quest_objectives_complete( db: &RemoteTables, quest_entry: u32, logrow: Option<&CharacterQuest>, @@ -146,140 +28,6 @@ fn quest_objectives_complete( }) } -/// Build one quest's `QuestDetailView` off `db` (any connection's cache carrying the public quest -/// tables — the privileged coordinator cache via `Coordinator::quest_detail`, OR a per-player -/// subscription's own cache when it's subscribed to them too). `None` if the quest isn't loaded. -/// Extracted from `Coordinator::quest_detail` so the QUEST_SHARE group-event relay -/// (`subscriptions.rs`) can build the SAME view over a cloned `Coordinator` handle without -/// duplicating this join logic — one chokepoint, not two copies to keep in sync. -pub(crate) fn quest_detail_view( - db: &RemoteTables, - quest_id: u32, -) -> Option { - use crate::codec::QuestDetailView; - let tmpl = db.game_quest_template().entry().find(&quest_id)?; - // MUST mirror module/src/quest.rs's award resolution exactly (explicit override → authentic - // RewMoneyMaxLevel/0.6 → xp_reward placeholder) so the completion popup equals the grant. No - // over-level penalty on either side yet (deferred — add to both together with the player's level). - let reward_xp = if tmpl.reward_xp > 0 { - tmpl.reward_xp - } else if tmpl.reward_money_max_level > 0 { - lyracore_shared::quest::quest_xp(tmpl.reward_money_max_level) - } else { - lyracore_shared::quest::xp_reward(tmpl.quest_level) - }; - // Body text from the side table (cmangos Details/Objectives/OfferReward/RequestItems). - let text = db.game_quest_text().quest_entry().find(&quest_id); - let details = text.as_ref().map(|t| t.details.clone()).unwrap_or_default(); - let offer_reward_text = text - .as_ref() - .map(|t| t.offer_reward_text.clone()) - .unwrap_or_default(); - let request_items_text = text - .as_ref() - .map(|t| t.request_items_text.clone()) - .unwrap_or_default(); - let imported_objectives = text.map(|t| t.objectives).unwrap_or_default(); - // All objectives in obj_index order → wire (creature_or_go, count, req_item, req_item_count): - // KILL_CREATURE(0): (+creature_entry, count, 0, 0) - // COLLECT_ITEM(1): (0, 0, item_entry, count) - // USE_GAMEOBJECT(2): (negative go_entry as u32, count, 0, 0) — client checks sign to tell apart - // EXPLORE_AREATRIGGER(3): no wire slot (area triggers have no entry in the objectives array) - let mut raw_objs: Vec<(u8, u32, u32, u32, u32)> = db - .game_quest_objective() - .iter() - .filter(|o| o.quest_entry == quest_id) - .filter_map(|o| { - let slot = match o.kind { - 0 => (o.target_entry, o.required_count, 0u32, 0u32), - 1 => (0, 0, o.target_entry, o.required_count), - 2 => ((-(o.target_entry as i32)) as u32, o.required_count, 0, 0), - _ => return None, // EXPLORE_AREATRIGGER and unknown: no wire slot - }; - Some((o.obj_index, slot.0, slot.1, slot.2, slot.3)) - }) - .collect(); - raw_objs.sort_by_key(|(i, _, _, _, _)| *i); - Some(QuestDetailView { - quest_id, - quest_level: tmpl.quest_level, - zone_or_sort: tmpl.zone_or_sort, - title: tmpl.title.clone(), - details, - objectives_text: synthesized_objectives(db, quest_id, imported_objectives), - offer_reward_text, - request_items_text, - money_reward: tmpl.reward_money, - reward_xp, - // Threaded from the template — chains (successor auto-offer) + the level-cap - // payout preview (always computed; the client only surfaces it when the VIEWER is capped). - next_quest_id: tmpl.next_quest_id, - max_level_money_reward: lyracore_shared::quest::max_level_money_reward(reward_xp), - rewards: quest_rewards(db, quest_id), - choice_rewards: quest_choice_rewards(db, quest_id), - objectives: raw_objs - .into_iter() - .map(|(_, a, b, c, d)| (a, b, c, d)) - .collect(), - }) -} - -// One argument per column of the condition row being evaluated; the SDK row type is not in scope on this side of the read. -#[allow(clippy::too_many_arguments)] -/// Evaluate ONE giver relation (a `game_creature_quest` OR `game_gameobject_quest` row, already -/// filtered to the giver's entry by the caller) against `player_guid`'s quest log. Takes the bare -/// `quest_entry`/`role` rather than a table row type — `game_creature_quest` and `game_gameobject_quest` -/// have the identical `(quest_entry, role)` shape, so `quest_giver_evals` calls this -/// same function for both, keyed only on which relation table it iterated. -/// -/// PARITY: `startable` answers the same question as the Module's `quest::accept_gates` -/// (`module/src/quest.rs`) — level, race, class, prerequisite chain, not already held — and the two -/// must agree, else the `!` icon offers a quest the accept reducer refuses. It stays a second copy -/// because it reads the Gateway's own subscription-cache row types and cannot call Module code; the -/// mask functions in `lyracore_shared::quest` are the part both tiers do share. A Gate changed in -/// `accept_gates` has to be changed here as well. -fn eval_relation( - db: &RemoteTables, - quest_entry: u32, - role: u8, - log: &[CharacterQuest], - player_guid: u64, - player_level: u32, - race: u8, - class: u8, -) -> Option { - use crate::codec::{GiverQuestEval, ROLE_START}; - let tmpl = db.game_quest_template().entry().find(&quest_entry)?; - let logrow = log.iter().find(|q| q.quest_entry == quest_entry); - let active = logrow.map(|q| !q.rewarded).unwrap_or(false); - // Complete = active AND every objective met — INVENTORY-AWARE for COLLECT_ITEM (see the helper). - let complete = active && quest_objectives_complete(db, quest_entry, logrow, player_guid); - // A repeatable quest already rewarded keeps its CharacterQuest row (module/src/quest.rs no longer - // deletes it — deleting would erase the only record it was ever completed, breaking any OTHER quest's - // `prev_quest_id` check below), so "no row" is not the right re-offer test for it: a rewarded row on a - // repeatable quest is startable again, mirroring apply_accept_quest's duplicate-guard exception. - let no_active_or_done = - logrow.is_none() || (tmpl.repeatable && logrow.is_some_and(|q| q.rewarded)); - let startable = role == ROLE_START - && no_active_or_done - && player_level >= tmpl.min_level - && lyracore_shared::quest::race_allowed(tmpl.required_races, race) - && lyracore_shared::quest::class_allowed(tmpl.required_classes, class) - && (tmpl.prev_quest_id == 0 - || log - .iter() - .any(|q| q.quest_entry == tmpl.prev_quest_id && q.rewarded)); - Some(GiverQuestEval { - quest_id: tmpl.entry, - title: tmpl.title.clone(), - level: tmpl.quest_level, - role, - startable, - active, - complete, - }) -} - /// Build the player's quest-log descriptor slots from raw quest + objective rows. Shared by the login /// read ([`Coordinator::player_quest_log`], over the privileged coordinator cache) and the live relay /// (`subscriptions.rs`, over the player connection's own RLS-scoped cache) so both produce identical @@ -313,96 +61,3 @@ pub(crate) fn build_quest_log_slots( }) .collect() } - -/// A quest's guaranteed reward items joined with each item's `display_id` (0 if the template isn't loaded). -fn quest_rewards(db: &RemoteTables, quest_id: u32) -> Vec { - db.game_quest_reward_item() - .iter() - .filter(|r| r.quest_entry == quest_id) - .map(|r| { - let display_id = db - .game_item_template() - .entry() - .find(&r.item_entry) - .map(|t| t.display_id) - .unwrap_or(0); - crate::codec::QuestRewardView { - item_entry: r.item_entry, - count: r.count, - display_id, - } - }) - .collect() -} - -/// A quest's CHOICE reward items (pick-1-of-N), joined to each item's `display_id` (0 if the template -/// isn't loaded) and ORDERED BY `choice_index` so the wire position matches the index the client sends -/// back as `CMSG_QUESTGIVER_CHOOSE_REWARD.reward`. -fn quest_choice_rewards(db: &RemoteTables, quest_id: u32) -> Vec { - let mut rows: Vec<_> = db - .game_quest_reward_choice() - .iter() - .filter(|r| r.quest_entry == quest_id) - .collect(); - rows.sort_by_key(|r| r.choice_index); - rows.into_iter() - .map(|r| { - let display_id = db - .game_item_template() - .entry() - .find(&r.item_entry) - .map(|t| t.display_id) - .unwrap_or(0); - crate::codec::QuestRewardView { - item_entry: r.item_entry, - count: r.count, - display_id, - } - }) - .collect() -} - -/// The objectives text for the detail window: the imported cmangos `Objectives` if present, else a -/// synthesized line per objective (kind-aware: kill = "Name slain: 0/N", collect = "Name: 0/N"). -fn synthesized_objectives(db: &RemoteTables, quest_id: u32, imported: String) -> String { - if !imported.trim().is_empty() { - return imported; - } - let mut objs: Vec<(u8, String)> = db - .game_quest_objective() - .iter() - .filter(|o| o.quest_entry == quest_id) - .map(|o| { - let text = match o.kind { - 0 => { - // KILL_CREATURE - let name = db - .game_creature_template() - .entry() - .find(&o.target_entry) - .map(|c| c.name.clone()) - .unwrap_or_default(); - format!("{name} slain: 0/{}", o.required_count) - } - 1 => { - // COLLECT_ITEM - let name = db - .game_item_template() - .entry() - .find(&o.target_entry) - .map(|t| t.name.clone()) - .unwrap_or_default(); - format!("{name}: 0/{}", o.required_count) - } - _ => String::new(), // USE_GAMEOBJECT / EXPLORE: cmangos Objectives text covers these - }; - (o.obj_index, text) - }) - .filter(|(_, t)| !t.is_empty()) - .collect(); - objs.sort_by_key(|(i, _)| *i); - objs.into_iter() - .map(|(_, s)| s) - .collect::>() - .join("\n") -} diff --git a/gateway/src/stdb/reads/spell.rs b/gateway/src/stdb/reads/spell.rs index 34ec39e94..718ae2892 100644 --- a/gateway/src/stdb/reads/spell.rs +++ b/gateway/src/stdb/reads/spell.rs @@ -1,252 +1,7 @@ //! Spell/spellbook cache-accessor methods — pure code-motion split of the former //! `reads.rs`. -use anyhow::Result; -use spacetimedb_sdk::Table; - use super::super::bindings::*; -use super::super::connection::Coordinator; - -impl Coordinator { - /// The spell's cast time (ms) from the static `game_spell` header — 0 = instant. Used by the - /// CMSG_CAST_SPELL handler to clear an instant cast SYNCHRONOUSLY (the async relay delivers START/GO - /// after the aura effects, which wedges the 5875 client's cast slot). None = unknown spell. - pub fn spell_cast_time(&self, spell_id: u32) -> Option { - self.0 - .coord() - .conn - .db - .game_spell() - .spell_id() - .find(&spell_id) - .map(|s| s.cast_time_ms) - } - - /// Enchant/disenchant routing for `spell_id`, read off the spell's effect rows — `None` if the spell - /// has no item-target enchanting effect (a normal cast). The gateway uses this to route ITEM-target - /// casts by effect KIND, with the enchant id carried in the effect's `p_0`, instead of a hardcoded - /// spell-id list — a new enchant spell is a data row, no gateway change. - pub fn enchant_route(&self, spell_id: u32) -> Option { - use crate::world::EnchantRoute; - const E_ENCHANT_ITEM: u8 = 0x17; // taxonomy E_ENCHANT_ITEM (p_0 = enchant_id) - const E_DISENCHANT: u8 = 0x18; // taxonomy E_DISENCHANT - self.0 - .coord() - .conn - .db - .game_spell_effect() - .iter() - .filter(|e| e.spell_id == spell_id) - .find_map(|e| match e.kind { - E_ENCHANT_ITEM => Some(EnchantRoute::Enchant(e.p_0 as u32)), - E_DISENCHANT => Some(EnchantRoute::Disenchant), - _ => None, - }) - } - - /// True iff `spell_id` has a ground-area effect (taxonomy `E_PERSISTENT_AREA` 0x1B — - /// Consecration etc.). The instant-cast GO for such a spell must carry an EMPTY hit list: the - /// default self-cast fallback put the CASTER in `hits[]`, and the 5875 client rendered the - /// spell's impact animation ON the paladin ("hit animation on the caster"). Kind-routed - /// like `enchant_route` — a new ground spell is a data row. - pub fn spell_is_ground_area(&self, spell_id: u32) -> bool { - const E_PERSISTENT_AREA: u8 = 0x1B; // lockstep with module taxonomy - self.0 - .coord() - .conn - .db - .game_spell_effect() - .iter() - .any(|e| e.spell_id == spell_id && e.kind == E_PERSISTENT_AREA) - } - - /// The character's active SPELL-MODIFIER auras as raw `(family_mask, op, amount, is_pct)` - /// rows — the client-mirror source for SMSG_SET_FLAT/PCT_SPELL_MODIFIER (aggregation by - /// (op, mask-bit) happens in the codec helper; mangos sends the TOTAL per bit). - pub fn spell_modifiers(&self, character_guid: u64) -> Vec<(u32, u8, i32, bool)> { - const A_SPELLMOD_FLAT: u8 = 0xAC; // lockstep with module taxonomy - const A_SPELLMOD_PCT: u8 = 0xAD; - self.0 - .coord() - .conn - .db - .game_aura() - .iter() - .filter(|a| { - a.target_guid == character_guid - && (a.eff_kind == A_SPELLMOD_FLAT || a.eff_kind == A_SPELLMOD_PCT) - }) - .map(|a| { - ( - a.eff_p1 as u32, - a.eff_p0 as u8, - a.amount, - a.eff_kind == A_SPELLMOD_PCT, - ) - }) - .collect() - } - - /// True iff `spell_id` is a FISHING cast (taxonomy `E_FISH` 0x1C — the three tier ids carry a - /// synthesized marker effect row). Kind-routed like `enchant_route`/`spell_is_ground_area`. - pub fn spell_is_fishing(&self, spell_id: u32) -> bool { - const E_FISH: u8 = 0x1C; // lockstep with module taxonomy - self.0 - .coord() - .conn - .db - .game_spell_effect() - .iter() - .any(|e| e.spell_id == spell_id && e.kind == E_FISH) - } - - /// True iff `spell_id` is an OPEN-LOCK cast (taxonomy `E_OPEN_LOCK` 0x1D — Pick Lock 1804). Routed to - /// the `pick_lock` reducer, gateway-intercepted exactly like `spell_is_fishing`/`enchant_route`. The - /// GO guid the pick targets rides the cast's SpellCastTargets (GAMEOBJECT flag). - pub fn spell_is_open_lock(&self, spell_id: u32) -> bool { - const E_OPEN_LOCK: u8 = 0x1D; // lockstep with module taxonomy - self.0 - .coord() - .conn - .db - .game_spell_effect() - .iter() - .any(|e| e.spell_id == spell_id && e.kind == E_OPEN_LOCK) - } - - /// The player's LEARNED spells — `game_player_spell` rows for this character (the coordinator - /// bypasses RLS so it reads any player's). Chained into the login spellbook so a taught ability - /// (Auto Shot) reaches the client and `CastSpellByName` can fire it. - pub fn player_learned_spells(&self, player_guid: u64) -> Result> { - let guard = self.0.coord(); - let db = &guard.conn.db; - let known: Vec = db - .game_player_spell() - .iter() - .filter(|s| s.character_guid == player_guid) - .map(|s| s.spell_id) - .collect(); - // 258 rank collapse: drop a known rank that another KNOWN spell supersedes (a game_spell_chain - // row whose prev_spell is this id) — GATED on the same cmangos stacking rule as - // superseded_old_rank (operator-corrected): MANA spells keep every rank in the book - // (downranking Holy Light is a real thing); only non-mana/passive chains collapse - // (Heroic Strike). One pass suffices: each superseded rank is prev of its own successor. - let known_set: std::collections::HashSet = known.iter().copied().collect(); - let superseded: std::collections::HashSet = db - .game_spell_chain() - .iter() - .filter(|c| { - c.prev_spell != 0 - && known_set.contains(&c.prev_spell) - && known_set.contains(&c.spell_id) - && !spell_ranks_stack_in_book(db, c.spell_id) - }) - .map(|c| c.prev_spell) - .collect(); - Ok(known - .into_iter() - .filter(|id| !superseded.contains(id)) - .collect()) - } - - /// The player's IMPORTED action-bar rows as `(button, action, action_type)` triples — - /// `game_player_action` rows copied at character creation from `game_createinfo_action` (empty when - /// no dump has been imported, the common case today). Chained into the login codec - /// (`login_sequence_messages`), which builds the bar from these when non-empty and falls back to - /// the spellbook synth otherwise. RLS-bypassed read, like `player_learned_spells`. - pub fn player_actions(&self, player_guid: u64) -> Result> { - let guard = self.0.coord(); - let db = &guard.conn.db; - Ok(db - .game_player_action() - .iter() - .filter(|a| a.character_guid == player_guid) - .map(|a| (a.button, a.action, a.action_type)) - .collect()) - } - - /// Resolve a trainer offering's LEARN TARGET (the rank the buy actually granted) so - /// SMSG_LEARNED_SPELL books the REAL spell, not the LearnSpell wrapper (live find 2026-07-11: - /// "Devotion Aura appeared in my General tab as the spell that teaches Devotion Aura"). - /// LOCKSTEP with module trainer.rs::resolve_learn_target — the same first-qualifying-trigger - /// rule; the excluded kinds are the module's taxonomy values (A_PERIODIC_TRIGGER 0x93, - /// A_FLAG 0xBE, A_PROC_TRIGGER 0xAB, E_TRIGGER 0x05). - /// True iff `spell_id` is an on-next-swing QUEUE spell (Heroic Strike/Cleave — any effect of kind - /// E_NEXT_SWING). The CMSG_CAST_SPELL handler then sends NO synchronous START/CAST_RESULT/GO: the - /// 5875 client lights the button locally on the press and holds it as a pending cast until the - /// swing-fire GO arrives. Kind value LOCKSTEP with module taxonomy.rs::E_NEXT_SWING (0x13). - pub fn spell_queues_next_swing(&self, spell_id: u32) -> bool { - const E_NEXT_SWING: u8 = 0x13; - let guard = self.0.coord(); - let queues = guard - .conn - .db - .game_spell_effect() - .iter() - .any(|e| e.spell_id == spell_id && e.kind == E_NEXT_SWING); - queues - } - - /// True iff `spell_id` is an AUTO-REPEAT ranged attack (Auto Shot / wand Shoot) — the - /// `SPELL_ATTR_RANGED_AUTO_REPEAT` cast_flags bit set by the importer (from the DBC AttributesEx2 - /// AUTOREPEAT bit / by name). The CMSG_CAST_SPELL handler routes on this instead of a hardcoded - /// `spell == 75 || 5019` id list, so a new ranged auto-repeat ability onboards as data. - pub fn spell_is_ranged_auto_repeat(&self, spell_id: u32) -> bool { - const SPELL_ATTR_RANGED_AUTO_REPEAT: u32 = 0x0200; // lockstep with importer::spell.rs - let guard = self.0.coord(); - guard - .conn - .db - .game_spell() - .spell_id() - .find(&spell_id) - .is_some_and(|s| s.cast_flags & SPELL_ATTR_RANGED_AUTO_REPEAT != 0) - } - - /// The KNOWN rank that learning `new_spell` supersedes: the game_spell_chain prev of - /// `new_spell`, if the player knows it AND the spell's ranks don't stack in the book. Drives - /// SMSG_SUPERCEDED_SPELL instead of LEARNED_SPELL on a trainer buy. - /// - /// The stacking rule is cmangos' canStackSpellRanksInSpellBook (operator-corrected live: - /// downranking Holy Light is a real thing): MANA spells keep EVERY rank visible (casters - /// downrank for mana efficiency); rage/energy/health-cost spells and PASSIVES supersede - /// (Heroic Strike replaces its old rank — there is no "downranked HS"). - pub fn superseded_old_rank(&self, new_spell: u32, player_guid: u64) -> Option { - let guard = self.0.coord(); - let db = &guard.conn.db; - if spell_ranks_stack_in_book(db, new_spell) { - return None; - } - let prev = db - .game_spell_chain() - .spell_id() - .find(&new_spell) - .map(|c| c.prev_spell)?; - if prev == 0 || prev == new_spell { - return None; - } - let knows_prev = db - .game_player_spell() - .iter() - .any(|s| s.character_guid == player_guid && s.spell_id == prev); - knows_prev.then_some(prev) - } - - pub fn resolve_learn_target(&self, spell_id: u32) -> u32 { - const EXCLUDED: [u8; 4] = [0x93, 0xBE, 0xAB, 0x05]; - let guard = self.0.coord(); - let resolved = guard - .conn - .db - .game_spell_effect() - .iter() - .filter(|e| e.spell_id == spell_id) - .find_map(|e| { - (e.trigger_spell != 0 && !EXCLUDED.contains(&e.kind)).then_some(e.trigger_spell) - }); - resolved.unwrap_or(spell_id) - } -} // --- quest read helpers (free fns over the cache, shared by the methods above) --------------------- // NOTE: game_creature_quest / game_quest_objective / game_quest_reward_item / game_character_quest only @@ -258,7 +13,7 @@ impl Coordinator { /// visible in the client book — downranking is a real caster mechanic; PASSIVES and /// non-mana-cost spells (rage/energy/health) SUPERSEDE their old rank. Unknown spell → stack /// (never hide a rank on missing data). LOCKSTEP: SPELL_ATTR_PASSIVE = 0x40 (module taxonomy). -fn spell_ranks_stack_in_book(db: &RemoteTables, spell_id: u32) -> bool { +pub(in crate::stdb) fn spell_ranks_stack_in_book(db: &RemoteTables, spell_id: u32) -> bool { const SPELL_ATTR_PASSIVE: u32 = 0x40; const POWER_MANA: u8 = 0; match db.game_spell().spell_id().find(&spell_id) { diff --git a/gateway/src/stdb/reads/talent_reputation.rs b/gateway/src/stdb/reads/talent_reputation.rs index b6bebef68..e42ca2644 100644 --- a/gateway/src/stdb/reads/talent_reputation.rs +++ b/gateway/src/stdb/reads/talent_reputation.rs @@ -8,22 +8,6 @@ use super::super::bindings::*; use super::super::connection::Coordinator; impl Coordinator { - /// The player's persisted reputation standings as `(reputation_index, standing, - /// at_war)` triples — chained into the login `SMSG_INITIALIZE_FACTIONS` so a relog carries the - /// real standing + the At-War checkbox instead of the all-neutral stub. Rows with - /// `reputation_index < 0` (stale pre-migration filler) are skipped — there is no slot to - /// address. RLS-bypassed read, like `player_learned_spells`. - pub fn player_reputations(&self, player_guid: u64) -> Result> { - let guard = self.0.coord(); - let db = &guard.conn.db; - Ok(db - .game_player_reputation() - .iter() - .filter(|r| r.character_guid == player_guid && r.reputation_index >= 0) - .map(|r| (r.reputation_index, r.standing, r.at_war)) - .collect()) - } - /// Does `npc_guid` REFUSE to interact with `player_guid` (vanilla /// `Unit::GetReactionTo` for the gossip/vendor/trainer/questgiver windows)? The NPC's /// faction_template resolves to its parent faction: @@ -67,108 +51,6 @@ impl Coordinator { }; Ok(faction_template_hostile(&npc_ft, &player_ft)) } - - /// Look up the static `Talent` metadata for `talent_id` (from the coordinator's `game_talent` cache). - /// Returns `None` if the talent isn't seeded (the dispatch treats an unknown talent as a noop). - pub fn talent_by_id(&self, talent_id: u32) -> Option { - self.0 - .coord() - .conn - .db - .game_talent() - .iter() - .find(|t| t.talent_id == talent_id) - } - - /// Sum of the character's spent talent ranks (`game_character_talent`, coordinator RLS-bypassed). - /// Non-zero gates the post-CREATE login correction of `PLAYER_CHARACTER_POINTS1` (the CREATE's - /// formula counts points EARNED only — see `codec/entity.rs`). - pub fn talent_points_spent(&self, character_guid: u64) -> u32 { - let guard = self.0.coord(); - guard - .conn - .db - .game_character_talent() - .iter() - .filter(|t| t.character_guid == character_guid) - .map(|t| t.rank as u32) - .sum() - } - - /// Talent-pane sync data read AFTER a successful `learn_talent` (the blocking reducer call - /// returns with the cache already consistent): `(teach_spell, superseded_prev, points_remaining)`. - /// `teach_spell` = the rank-spell the module just put in the spellbook — the 1.12 TalentFrame - /// derives a talent's shown rank from which rank-spell is KNOWN, so this must relay live as - /// SMSG_LEARNED_SPELL or the pane freezes until relog; 0 when the module taught nothing (mirror - /// of `talent::apply_talent_rank`'s game_spell-existence gate). `superseded_prev` = the previous - /// rank's now-replaced spell (drives SMSG_SUPERCEDED_SPELL; 0 for rank 1 / same-spell demo - /// trees). `points_remaining` = earned (level−9, floor 0) minus spent — the live - /// `PLAYER_CHARACTER_POINTS1` value. Callers may pass `talent_id = 0` to get just the points. - pub fn talent_pane_sync(&self, character_guid: u64, talent_id: u32) -> (u32, u32, u32) { - let guard = self.0.coord(); - let db = &guard.conn.db; - let level = db - .game_world_entity() - .guid() - .find(&character_guid) - .map(|e| e.level) - .unwrap_or(0); - let spent: u32 = db - .game_character_talent() - .iter() - .filter(|t| t.character_guid == character_guid) - .map(|t| t.rank as u32) - .sum(); - let remaining = (level as i32 - 9).max(0) as u32; - let remaining = remaining.saturating_sub(spent); - let Some(t) = db.game_talent().iter().find(|t| t.talent_id == talent_id) else { - return (0, 0, remaining); - }; - let rank = db - .game_character_talent() - .iter() - .find(|r| r.character_guid == character_guid && r.talent_id == talent_id) - .map(|r| r.rank) - .unwrap_or(0); - let new_spell = pick_rank_spell(rank, &t); - let prev_spell = if rank >= 2 { - pick_rank_spell(rank - 1, &t) - } else { - 0 - }; - // Mirror the module's teach gate: apply_talent_rank only puts the rank-spell in the book - // when it exists in game_spell (an unimported rank-spell is skipped there — sending - // LEARNED for it would desync the client book from the server). - let teach = if new_spell != 0 && db.game_spell().spell_id().find(&new_spell).is_some() { - new_spell - } else { - 0 - }; - let superseded = if teach != 0 && prev_spell != 0 && prev_spell != teach { - prev_spell - } else { - 0 - }; - (teach, superseded, remaining) - } -} - -/// Mirror of the module's `talent::pick_rank_spell`: rank N's spell from the per-rank columns -/// (`rank_spell_2..5`), a 0 column falling back to `spell_id` (the demo tree scales ONE spell by -/// rank; the imported tree has a distinct spell per rank). -fn pick_rank_spell(rank: u8, t: &Talent) -> u32 { - let s = match rank { - 2 => t.rank_spell_2, - 3 => t.rank_spell_3, - 4 => t.rank_spell_4, - 5 => t.rank_spell_5, - _ => t.spell_id, - }; - if s != 0 { - s - } else { - t.spell_id - } } /// Reputation RANK for a raw standing (0=Hated .. 7=Exalted, Neutral=3). KEEP IN LOCKSTEP with diff --git a/gateway/src/stdb/reads/templates.rs b/gateway/src/stdb/reads/templates.rs index d3d940e0a..e7b7a49af 100644 --- a/gateway/src/stdb/reads/templates.rs +++ b/gateway/src/stdb/reads/templates.rs @@ -5,31 +5,8 @@ use anyhow::Result; use super::super::bindings::*; use super::super::connection::Coordinator; -use super::super::views::item_template_view; impl Coordinator { - /// Read a creature template by entry for a `CMSG_CREATURE_QUERY` reply (Tier 2 / NPCs). - pub fn creature_template(&self, entry: u32) -> Result> { - Ok(self - .0 - .coord() - .conn - .db - .game_creature_template() - .entry() - .find(&entry) - .map(|t| crate::codec::CreatureView { - entry: t.entry, - name: t.name, - subname: t.subname, - display_id: t.display_id, - creature_type: t.creature_type as u32, - creature_family: t.creature_family, - type_flags: t.type_flags, - rank: t.rank as u32, - })) - } - /// Read a gameobject template by entry for a `CMSG_GAMEOBJECT_QUERY` reply. A Meeting Stone's /// `data0..data2` come from its stone row, the only copy of its level range and dungeon area, /// so the client tooltip shows the range. @@ -57,38 +34,4 @@ impl Coordinator { } })) } - - /// The `type_id` of a SPAWNED gameobject, by its live guid (join `game_gameobject` → - /// `game_gameobject_template`). Feeds the `CMSG_GAMEOBJ_USE` dispatch: a - /// `lyracore_shared::constants::go_type::QUESTGIVER` GO (the Wanted Poster, the Lost Guards corpses) - /// opens the quest window instead of rolling loot / toggling state — that is what a questgiver - /// gameobject does in vanilla. `None` for an unspawned/unknown guid (the caller falls back to the - /// ordinary use-reducer path, which itself no-ops on an unknown guid). - pub fn gameobject_type(&self, go_guid: u64) -> Result> { - let guard = self.0.coord(); - let db = &guard.conn.db; - Ok(db - .game_gameobject() - .guid() - .find(&go_guid) - .and_then(|go| { - db.game_gameobject_template() - .entry() - .find(&go.template_entry) - }) - .map(|t| t.type_id)) - } - - /// Read an item template by entry for a `CMSG_ITEM_QUERY_SINGLE` reply. - pub fn item_template(&self, entry: u32) -> Result> { - Ok(self - .0 - .coord() - .conn - .db - .game_item_template() - .entry() - .find(&entry) - .map(item_template_view)) - } } diff --git a/gateway/src/stdb/reducers.rs b/gateway/src/stdb/reducers.rs index 2619da1ab..dc6f8ebbe 100644 --- a/gateway/src/stdb/reducers.rs +++ b/gateway/src/stdb/reducers.rs @@ -1,85 +1,16 @@ -//! Reducer-call wrapper methods on `Coordinator`: each fires a `gw_*` module reducer over the -//! shared coordinator call pipe (the module attributes the caller by the `actor_guid` argument) -//! and blocks on its completion via the `call_reducer!` macro. Cache reads live in `reads.rs`. +//! Durable Requests on `Coordinator` that no single Store family owns: realm-core routing, +//! Transfer intents, provisioning and the helpers several families share. A family's own +//! reducer calls live in `store/.rs`. use anyhow::{anyhow, Result}; use spacetimedb_sdk::Identity; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::OnceLock; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::Duration; use super::bindings::*; -use super::connection::{call_reducer, reducer_refusal_reason, Coordinator}; -use super::views::entity_view; -use crate::world::guild_fee; -use crate::world::party::{AdmittedCompanionCommand, CompanionCommandOutcome, PartyOutcome}; -use crate::world::{ - Actor, ChannelOutcome, ChatOutcome, ContactOutcome, ItemActionResult, LootActionStatus, - LootWindowRefusal, LootWindowRequestStatus, MeetingStoneOutcome, -}; -use lyracore_shared::auction::AuctionRefusal; -use lyracore_shared::channel::ChannelRefusal; -use lyracore_shared::chat::ChatRefusal; -use lyracore_shared::group::GroupRefusal; -use lyracore_shared::guild::GuildRefusal; -use lyracore_shared::item::ItemRefusal; -use lyracore_shared::loot::{LootBoundaryFailure, LootRefusal}; -use lyracore_shared::meeting_stone::MeetingStoneRefusal; -use lyracore_shared::social::ContactRefusal; -use lyracore_shared::trainer::TrainerRefusal; - -static NEXT_TAXI_REQUEST_ID: OnceLock = OnceLock::new(); - -fn next_taxi_request_id() -> u64 { - let next = NEXT_TAXI_REQUEST_ID.get_or_init(|| { - let seed = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() as u64; - AtomicU64::new(seed.max(1)) - }); - let id = next.fetch_add(1, Ordering::Relaxed); - if id == 0 { - next.fetch_add(1, Ordering::Relaxed) - } else { - id - } -} - -fn taxi_reply_matches( - reply: &TaxiServiceReply, - character_guid: u64, - npc_guid: u64, - operation: u8, -) -> bool { - reply.character_guid == character_guid - && reply.operation == operation - && reply.npc_guid == npc_guid -} +use super::connection::{call_reducer, Coordinator}; +use crate::world::Actor; impl Coordinator { - /// `release_player_transfer_arrival`, with the exact Realm locator predecessor carried by the - /// destination fence. - pub fn release_player_transfer_arrival( - &self, - transfer_id: u64, - character_guid: u64, - source: crate::world::transfer::RealmLocatorPredecessor, - ) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "release_player_transfer_arrival", - release_player_transfer_arrival_then( - transfer_id, - character_guid, - source.map_id, - source.instance_id, - source.revision, - self.owner_actor() - ) - ) - } - pub fn claim_bot_transfer_intent( &self, intent_id: u64, @@ -113,2924 +44,223 @@ impl Coordinator { ) } - pub fn mark_bot_transfer_arrival_ready( - &self, - intent_id: u64, - bot_guid: u64, - controller_generation: u64, - claim_token: u64, - ) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "mark_bot_transfer_arrival_ready", - mark_bot_transfer_arrival_ready_then( - intent_id, - bot_guid, - controller_generation, - claim_token - ) - ) - } - - // The arguments mirror the World reducer's exact Transfer Gate. - #[allow(clippy::too_many_arguments)] - pub fn release_bot_transfer_arrival( - &self, - transfer_id: u64, - bot_guid: u64, - source_module_identity: spacetimedb_sdk::Identity, - intent_id: u64, - controller_generation: u64, - intent_created_micros: i64, - source_map: u32, - source_instance: u64, - source_locator_revision: u64, - ) -> Result<()> { + pub fn defer_party_command_intent(&self, intent_id: u64, claim_token: u64) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, - "release_bot_transfer_arrival", - release_bot_transfer_arrival_then( - transfer_id, - bot_guid, - source_module_identity, - intent_id, - controller_generation, - intent_created_micros, - source_map, - source_instance, - source_locator_revision - ) + "defer_party_command_intent", + defer_party_command_intent_then(intent_id, claim_token) ) } - pub fn claim_party_command_intent(&self, intent_id: u64, claim_token: u64) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "claim_party_command_intent", - claim_party_command_intent_then(intent_id, claim_token) - ) + /// Heartbeat this gateway's `game_gateway_lease` row every 15 s on EVERY connected + /// database's coordinator connection, forever. Every shard, not just the default: + /// `gw_player_login` fail-closes on the lease of the database it runs ON, and a + /// cross-database login (an instance entry resuming a transfer) runs on that destination + /// shard — a default-only lease made every instance login die with "no lease for this + /// gateway". Fire-and-forget per beat — a missed beat is harmless (the TTL tolerates + /// several) and the loop must never stall on a slow call. Spawned from `main`. + pub fn spawn_gateway_heartbeat(&self) { + let coord = self.clone(); + tokio::spawn(async move { + let mut tick = tokio::time::interval(Duration::from_secs(15)); + loop { + tick.tick().await; + // `world_shards()` is default-first; realm-core is appended when it is a + // distinct database (unconfigured, it aliases the default handle). + let mut shards = coord.world_shards(); + if let Ok(rc) = coord.realm_core() { + if !shards.iter().any(|(n, _)| n == rc.shard_name()) { + shards.push((rc.shard_name().to_string(), rc)); + } + } + for (shard_name, shard) in shards { + let guard = shard.0.coord(); + if let Err(e) = guard.conn.reducers.gw_heartbeat() { + log::warn!( + "gateway heartbeat send failed on {shard_name} (will retry next beat): {e}" + ); + } + } + } + }); } - pub fn defer_party_command_intent(&self, intent_id: u64, claim_token: u64) -> Result<()> { + /// Provision SRP6 credentials computed by the Gateway. + pub fn provision_account(&self, username: &str, salt: &[u8], verifier: &[u8]) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, - "defer_party_command_intent", - defer_party_command_intent_then(intent_id, claim_token) + "provision_account", + provision_account_then(username.to_string(), salt.to_vec(), verifier.to_vec()) ) } - pub fn admit_party_command_authority( + /// Logon writes K + the bound per-account identity. + pub fn establish_session( &self, - group_id: u64, - leader_guid: u64, - bot_guid: u64, - authority_member_guid: u64, - expected_members: Vec, - ) -> Result { - match call_reducer!( + account_id: u64, + session_key: &[u8; 40], + bound_identity: [u8; 32], + ) -> Result<()> { + call_reducer!( self.0.call_pipe().conn.reducers, - "admit_party_command_authority", - admit_party_command_authority_then( - group_id, - leader_guid, - bot_guid, - authority_member_guid, - expected_members + "establish_session", + establish_session_then( + account_id, + session_key.to_vec(), + Identity::from_byte_array(bound_identity) ) - ) { - Ok(()) => Ok(CompanionCommandOutcome::Applied), - Err(error) => command_refusal(&error).ok_or(error), - } + ) } - pub fn apply_admitted_party_command( + /// Publish `character_guid`'s location into this handle's character→shard index. Call it + /// on the REALM-CORE handle: on a world shard the index is already maintained transactionally by + /// `finish_transfer`. Operator-gated module-side (the index is a routing input). + pub fn set_character_shard( &self, - command: &AdmittedCompanionCommand, - ) -> Result { - let applied = call_reducer!( + character_guid: u64, + map_id: u32, + instance_id: u64, + ) -> Result<()> { + call_reducer!( self.0.call_pipe().conn.reducers, - "apply_admitted_party_command", - apply_admitted_party_command_then( - command.source_identity, - command.intent_id, - command.issuer_guid, - command.issuer_sequence, - command.group_id, - command.leader_guid, - command.members.clone(), - command.kind, - command.bot_guid, - command.authority_member_guid, - command.exact_target_guid, - command.expires_micros, - command.receipt_retain_until_micros + "set_character_shard", + set_character_shard_then( + character_guid, + map_id, + instance_id, + self.actor_or_owner(character_guid) ) - ); - if let Err(error) = applied { - return command_refusal(&error).ok_or(error); - } - self.confirm_party_command_receipt(command.source_identity, command.intent_id)? - .ok_or_else(|| anyhow!("party command apply committed without a receipt")) + ) } - pub fn finish_party_command_intent( + // The arguments mirror the Realm reducer's exact Transfer Gate. + #[allow(clippy::too_many_arguments)] + pub fn begin_character_shard_transfer( &self, + source_map: u32, + source_instance: u64, + source_revision: u64, + destination_map: u32, + destination_instance: u64, + source_module_identity: spacetimedb_sdk::Identity, intent_id: u64, - claim_token: u64, - outcome: CompanionCommandOutcome, + controller_generation: u64, + character_guid: u64, ) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, - "finish_party_command_intent", - finish_party_command_intent_then( + "begin_character_shard_transfer", + begin_character_shard_transfer_then( + character_guid, + source_map, + source_instance, + source_revision, + destination_map, + destination_instance, + source_module_identity, intent_id, - claim_token, - command_outcome_binding(outcome) + controller_generation, + self.actor_or_owner(character_guid) ) ) } - pub fn confirm_party_command_receipt( - &self, - source_identity: Identity, - intent_id: u64, - ) -> Result> { - match call_reducer!( - self.0.call_pipe().conn.reducers, - "confirm_party_command_receipt", - confirm_party_command_receipt_then(source_identity, intent_id) - ) { - Ok(()) => Ok(None), - Err(error) => command_refusal(&error).map(Some).ok_or(error), - } - } - - pub fn confirm_party_command_holder( - &self, - bot_guid: u64, - ) -> Result { - match call_reducer!( - self.0.call_pipe().conn.reducers, - "confirm_party_command_holder", - confirm_party_command_holder_then(bot_guid) - ) { - Ok(()) => Ok(crate::world::party::PartyCommandHolder::Present), - Err(error) => match reducer_refusal_reason(&error) { - Some("MissingBot") => Ok(crate::world::party::PartyCommandHolder::Missing), - Some("TransferInProgress") => { - Ok(crate::world::party::PartyCommandHolder::InTransit) - } - _ => Err(error), - }, - } - } - - /// Claim and admit a Group Intent in one World Shard transaction. - pub fn claim_bot_invite_intent(&self, intent_id: u64) -> Result { - party_outcome(call_reducer!( - self.0.call_pipe().conn.reducers, - "claim_bot_invite_intent", - claim_bot_invite_intent_then(intent_id) - )) - } - - /// Wait for the owning World Shard's current consent Gate, without subscription readback. - pub fn admit_sessionless_group_action(&self, character_guid: u64) -> Result { - party_outcome(call_reducer!( - self.0.call_pipe().conn.reducers, - "admit_sessionless_group_action", - admit_sessionless_group_action_then(character_guid) - )) - } - - fn await_taxi_reply( - &self, - character_guid: u64, - npc_guid: u64, - request_id: u64, - operation: u8, - ) -> Result> { - // Reducer completion and subscription propagation travel on the same SDK connection but - // are separate events. Poll by the caller-chosen id so an older cached reply can never be - // mistaken for this operation's result. - for _ in 0..100 { - let reply = self - .0 - .coord() - .conn - .db - .game_taxi_service_reply() - .request_id() - .find(&request_id); - if let Some(reply) = - reply.filter(|reply| taxi_reply_matches(reply, character_guid, npc_guid, operation)) - { - // Copy the cache row before acknowledging it. The ack deletes only this request's - // module row; a failed ack is infrastructure loss and remains session-fatal. - call_reducer!( - self.0.call_pipe().conn.reducers, - "gw_ack_taxi_reply", - gw_ack_taxi_reply_then(self.actor_or_owner(character_guid), request_id) - )?; - if !reply.accepted { - log::debug!( - "taxi operation {operation} refused for character {character_guid}: {}", - reply.refusal - ); - } - return Ok(Some(reply)); - } - std::thread::sleep(Duration::from_millis(10)); - } - Err(anyhow!( - "taxi operation {operation} committed but reply {request_id} was not visible within 1s" - )) - } - - /// Cohesive status request: module resolution + policy runs before this returns one known bit. - pub fn taxi_node_status( + pub fn finish_character_shard_transfer( &self, - character_guid: u64, - npc_guid: u64, - ) -> Result> { - let Some(actor) = Actor::new(character_guid) else { - return Ok(None); - }; - let request_id = next_taxi_request_id(); + intent: &crate::world::transfer::BotTransferIntent, + ) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, - "gw_taxi_node_status", - gw_taxi_node_status_then(self.session_actor(actor), npc_guid, request_id) - )?; - Ok(self - .await_taxi_reply( - character_guid, - npc_guid, - request_id, - lyracore_shared::constants::taxi_protocol::REPLY_STATUS, - )? - .filter(|reply| reply.accepted) - .map(|reply| crate::codec::TaxiNodeStatusView { - npc_guid: reply.npc_guid, - known: reply.known, - })) + "finish_character_shard_transfer", + finish_character_shard_transfer_then( + intent.bot_guid, + intent.source_map, + intent.source_instance, + intent.source_locator_revision, + intent.destination_map, + intent.destination_instance, + intent.source_module_identity, + intent.id, + intent.controller_generation, + self.actor_or_owner(intent.bot_guid) + ) + ) } - pub fn open_taxi( + pub fn finish_player_character_shard_transfer( &self, character_guid: u64, - npc_guid: u64, - ) -> Result> { - let Some(actor) = Actor::new(character_guid) else { - return Ok(None); - }; - let request_id = next_taxi_request_id(); + source_map: u32, + source_instance: u64, + source_revision: u64, + destination_map: u32, + destination_instance: u64, + ) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, - "gw_open_taxi", - gw_open_taxi_then(self.session_actor(actor), npc_guid, request_id) - )?; - Ok(self - .await_taxi_reply( + "finish_character_shard_transfer", + finish_character_shard_transfer_then( character_guid, - npc_guid, - request_id, - lyracore_shared::constants::taxi_protocol::REPLY_OPEN, - )? - .filter(|reply| reply.accepted) - .map(|reply| crate::codec::TaxiMapView { - npc_guid: reply.npc_guid, - source_client_node_id: reply.source_client_node_id, - available_client_node_ids: reply.available_client_node_ids, - })) + source_map, + source_instance, + source_revision, + destination_map, + destination_instance, + spacetimedb_sdk::Identity::ZERO, + 0, + 0, + self.actor_or_owner(character_guid) + ) + ) } - /// Cohesive direct-flight activation. The module commits every gameplay outcome as a stable - /// result code; only reducer transport/timeout failures escape as `Err` and end the session. - pub fn activate_taxi( + // The arguments mirror the Realm reducer's exact Transfer Gate. + #[allow(clippy::too_many_arguments)] + pub fn finish_pending_character_shard_transfer( &self, character_guid: u64, - npc_guid: u64, - source_client_node_id: u32, - destination_client_node_id: u32, - ) -> Result { - let Some(actor) = Actor::new(character_guid) else { - return Ok(crate::codec::TaxiActivationResult { - result_code: - lyracore_shared::constants::taxi_protocol::ACTIVATE_UNSPECIFIED_SERVER_ERROR, - }); - }; - let request_id = next_taxi_request_id(); + source_map: u32, + source_instance: u64, + source_revision: u64, + destination_map: u32, + destination_instance: u64, + source_module_identity: spacetimedb_sdk::Identity, + transfer_intent_id: u64, + controller_generation: u64, + ) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, - "gw_activate_taxi", - gw_activate_taxi_then( - self.session_actor(actor), - npc_guid, - source_client_node_id, - destination_client_node_id, - request_id - ) - )?; - let reply = self - .await_taxi_reply( + "finish_pending_character_shard_transfer", + finish_pending_character_shard_transfer_then( character_guid, - npc_guid, - request_id, - lyracore_shared::constants::taxi_protocol::REPLY_ACTIVATE, - )? - .ok_or_else(|| anyhow!("taxi activation reply {request_id} disappeared"))?; - Ok(crate::codec::TaxiActivationResult { - result_code: reply.result_code, - }) + source_map, + source_instance, + source_revision, + destination_map, + destination_instance, + source_module_identity, + transfer_intent_id, + controller_generation, + self.actor_or_owner(character_guid) + ) + ) } - pub fn arm_taxi_flight(&self, character_guid: u64) -> Result<()> { + /// Send one classified command request to this Home Shard. The Module combines the conveyed + /// Account authority with its own Character GM level and remains the final Gate. Its Refusal + /// reason is the text the GM sees. + pub(crate) fn request_gm_command( + &self, + actor_guid: u64, + alpha_test_tools: bool, + text: String, + ) -> Result<()> { + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("gm_command: actor_guid unresolved"))?; call_reducer!( self.0.call_pipe().conn.reducers, - "gw_arm_taxi_flight", - gw_arm_taxi_flight_then(self.actor_or_owner(character_guid)) - ) - } - - /// Create one auction listing. Single-database deployments use one atomic reducer; sharded - /// deployments drive the durable Hold -> Auction -> receipt -> settle protocol. - pub(crate) fn create_auction( - &self, - request: crate::world::CreateAuctionRequest, - ) -> Result { - use crate::world::CreateAuctionOutcome; - - let item_is_present = self - .0 - .coord() - .conn - .db - .game_item_instance() - .guid() - .find(&request.item_guid) - .is_some(); - // A successful listing removes the source item in the same transaction that records its - // Hold or receipt. Presence therefore proves this is a new request even when a returned - // item later reuses the same guid and terms. - if !item_is_present { - if let Some(receipt) = self.matching_active_auction_receipt(request)? { - if self - .0 - .coord() - .conn - .db - .game_auction_hold() - .operation_id() - .find(&receipt.operation_id) - .is_some() - { - self.auction_settle_listing(receipt.operation_id)?; - } - return Ok(CreateAuctionOutcome::Created { - auction_id: receipt.auction_id, - }); - } - } - - let operation_id = if item_is_present { - next_operation_id()? - } else { - match self.matching_auction_hold(request) { - Some(hold) => hold.operation_id, - None => next_operation_id()?, - } - }; - - let result = if self.is_sharded() { - self.drive_sharded_auction_listing(operation_id, request) - } else { - self.auction_list_local(operation_id, request) - .and_then(|()| self.wait_for_auction_receipt(operation_id)) - .map(|receipt| receipt.auction_id) - }; - - match result { - Ok(auction_id) => Ok(CreateAuctionOutcome::Created { auction_id }), - Err(error) => match auction_refusal(&error) { - Some(refusal) => Ok(refusal.into()), - None => Err(error), - }, - } - } - - fn drive_sharded_auction_listing( - &self, - operation_id: u64, - request: crate::world::CreateAuctionRequest, - ) -> Result { - self.auction_hold_listing(operation_id, request)?; - let hold = self.wait_for_auction_hold(operation_id)?; - self.complete_listing_hold(&hold) - } - - /// Realm-core's Auction, this Home Shard's receipt and the settle of one listing Hold, or its - /// refund when Realm-core refuses the listing, from the phase a stopped Gateway left it in. - /// Every phase is idempotent, so a replay changes nothing. - fn complete_listing_hold(&self, hold: &AuctionHold) -> Result { - let operation_id = hold.operation_id; - let realm = self.realm_core()?; - if let Err(error) = realm.auction_commit_listing(hold) { - // Only a Refusal proves realm-core took nothing. A timeout or transport failure - // leaves the Hold for the next replay, which commits idempotently. - if reducer_refusal_reason(&error).is_some() - && !realm.auction_receipt_is_visible(operation_id) - { - // Mail is authoritative on Realm-core. Commit its idempotent refund receipt and - // the exact returned value there before deleting the source Hold. If either call - // is interrupted, the Hold remains recovery evidence and the next replay resumes - // from the same operation id. - realm.auction_refund_listing(hold)?; - self.auction_release_listing_hold(hold)?; - } - return Err(error); - } - let receipt = realm.wait_for_auction_receipt(operation_id)?; - self.auction_confirm_listing(operation_id, receipt.auction_id)?; - self.wait_for_auction_receipt(operation_id)?; - self.auction_settle_listing(operation_id)?; - Ok(receipt.auction_id) - } - - fn auction_list_local( - &self, - operation_id: u64, - request: crate::world::CreateAuctionRequest, - ) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "gw_auction_list_local", - gw_auction_list_local_then( - operation_id, - self.actor_or_owner(request.actor_guid), - request.item_guid, - request.auctioneer_guid, - request.house_id, - request.start_bid, - request.buyout, - request.duration_minutes - ) - ) - } - - fn auction_hold_listing( - &self, - operation_id: u64, - request: crate::world::CreateAuctionRequest, - ) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "gw_auction_hold_listing", - gw_auction_hold_listing_then( - operation_id, - self.actor_or_owner(request.actor_guid), - request.item_guid, - request.auctioneer_guid, - request.house_id, - request.start_bid, - request.buyout, - request.duration_minutes - ) - ) - } - - fn auction_commit_listing(&self, hold: &AuctionHold) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "realm_auction_commit_listing", - realm_auction_commit_listing_then( - hold.operation_id, - self.actor_or_owner(hold.seller_guid), - hold.item_guid, - hold.item_entry, - hold.item_stack_count, - hold.item_durability, - hold.item_enchant_id, - hold.item_soulbound, - hold.random_property_id, - hold.house, - hold.deposit_rate, - hold.consignment_rate, - hold.start_bid, - hold.buyout, - hold.duration_minutes, - hold.deposit, - hold.created_micros, - hold.expires_micros, - hold.item_text_id - ) - ) - } - - fn auction_confirm_listing(&self, operation_id: u64, auction_id: u32) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "realm_auction_confirm_listing", - realm_auction_confirm_listing_then(operation_id, auction_id, self.owner_actor()) - ) - } - - fn auction_settle_listing(&self, operation_id: u64) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "realm_auction_settle_listing", - realm_auction_settle_listing_then(operation_id, self.owner_actor()) - ) - } - - fn auction_refund_listing(&self, hold: &AuctionHold) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "realm_auction_refund_listing", - realm_auction_refund_listing_then( - hold.operation_id, - self.actor_or_owner(hold.seller_guid), - hold.item_guid, - hold.item_entry, - hold.item_stack_count, - hold.item_durability, - hold.item_enchant_id, - hold.item_soulbound, - hold.random_property_id, - hold.house, - hold.deposit_rate, - hold.consignment_rate, - hold.start_bid, - hold.buyout, - hold.duration_minutes, - hold.deposit, - hold.created_micros, - hold.expires_micros, - hold.item_text_id - ) - ) - } - - fn auction_release_listing_hold(&self, hold: &AuctionHold) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "gw_auction_release_listing_hold", - gw_auction_release_listing_hold_then( - hold.operation_id, - self.actor_or_owner(hold.seller_guid) - ) - ) - } - - fn auction_receipt_is_visible(&self, operation_id: u64) -> bool { - self.0 - .coord() - .conn - .db - .game_auction_operation_receipt() - .operation_id() - .find(&operation_id) - .is_some_and(|receipt| receipt.auction_id != 0) - } - - fn matching_auction_hold( - &self, - request: crate::world::CreateAuctionRequest, - ) -> Option { - self.listing_holds(request.actor_guid) - .into_iter() - .find(|hold| same_auction_request(hold, request)) - } - - fn matching_active_auction_receipt( - &self, - request: crate::world::CreateAuctionRequest, - ) -> Result> { - let candidates: Vec<_> = self - .listing_receipts(request.actor_guid) - .into_iter() - .filter(|receipt| same_auction_request(receipt, request)) - .collect(); - if candidates.is_empty() { - return Ok(None); - } - - // Receipts outlive Auctions so an operation replay stays auditable. Only reuse a receipt - // while its Auction is still active: returned items can later be granted the same item guid - // and legitimately listed again with the same terms. - let realm = self.realm_core()?; - let guard = realm.0.coord(); - let receipt = candidates.into_iter().find(|receipt| { - guard - .conn - .db - .game_auction() - .id() - .find(&receipt.auction_id) - .is_some_and(|auction| auction.listing_operation_id == receipt.operation_id) - }); - Ok(receipt) - } - - fn wait_for_auction_hold(&self, operation_id: u64) -> Result { - wait_for_cache_row(operation_id, "auction Hold", || { - self.0 - .coord() - .conn - .db - .game_auction_hold() - .operation_id() - .find(&operation_id) - }) - } - - fn wait_for_auction_receipt(&self, operation_id: u64) -> Result { - wait_for_cache_row(operation_id, "auction receipt", || { - self.0 - .coord() - .conn - .db - .game_auction_operation_receipt() - .operation_id() - .find(&operation_id) - .filter(|receipt| receipt.auction_id != 0) - }) - } - - /// Place one full-offer bid. The sharded path fences the bidder purse before realm-core makes - /// its serialized Auction decision; both paths finish with the same normalized terminal Hold. - pub(crate) fn place_bid( - &self, - request: crate::world::PlaceBidRequest, - ) -> Result { - let operation_id = self - .matching_unfinished_bid_hold(request) - .map_or_else(next_operation_id, |hold| Ok(hold.operation_id))?; - let result = if self.is_sharded() { - self.drive_sharded_auction_bid(operation_id, request) - } else { - self.auction_bid_local(operation_id, request) - .and_then(|()| self.wait_for_terminal_bid_hold(operation_id)) - }; - match result { - Ok(hold) => bid_outcome(&hold), - Err(error) => match auction_refusal(&error) { - Some(refusal) => Ok(refusal.into()), - None => Err(error), - }, - } - } - - fn drive_sharded_auction_bid( - &self, - operation_id: u64, - request: crate::world::PlaceBidRequest, - ) -> Result { - self.auction_hold_bid(operation_id, request)?; - let hold = self.wait_for_auction_bid_hold(operation_id)?; - self.complete_auction_hold(hold) - } - - /// Cancel one listing. The Gateway reads the listing from the Realm-core cache and fences the - /// Auction Cut it computes; Realm-core refuses the Cancellation if a later bid changed that cut. - /// A pending Cancellation of the same listing is driven again instead of fencing a second cut. - pub(crate) fn cancel_auction( - &self, - request: crate::world::CancelAuctionRequest, - ) -> Result { - use crate::world::CancelAuctionOutcome; - let pending = self - .unfinished_auction_holds(request.actor_guid) - .find(|hold| { - hold.operation == lyracore_shared::auction::hold_operation::CANCEL - && hold.outcome == lyracore_shared::auction::bid_outcome::PENDING - && hold.auction_id == request.auction_id - && hold.house == request.house_id - }); - let (operation_id, cut) = match pending { - Some(hold) => (hold.operation_id, hold.offer), - None => match self.realm_core()?.cancellation_cut(request) { - Some(cut) => (next_operation_id()?, cut), - None => return Ok(CancelAuctionOutcome::NotFound), - }, - }; - let result = if self.is_sharded() { - self.auction_hold_cancel(operation_id, request, cut) - .and_then(|()| self.wait_for_auction_bid_hold(operation_id)) - .and_then(|hold| self.complete_auction_hold(hold)) - } else { - self.auction_cancel_local(operation_id, request, cut) - .and_then(|()| self.wait_for_terminal_bid_hold(operation_id)) - }; - match result { - Ok(hold) => cancel_outcome(&hold), - Err(error) => match auction_refusal(&error) { - Some(refusal) => Ok(refusal.into()), - None => Err(error), - }, - } - } - - /// Finish every unfinished Hold of `actor_guid` on this Home Shard, listing Holds first. A - /// Refusal leaves that Hold for a later attempt; a transport failure ends the request. - pub(crate) fn resume_auction_holds(&self, actor_guid: u64) -> Result<()> { - for hold in self.listing_holds(actor_guid) { - let operation_id = hold.operation_id; - if let Err(error) = self.complete_listing_hold(&hold) { - match auction_refusal(&error) { - Some(refusal) => log::warn!( - "listing Hold {operation_id} of {actor_guid} did not resume: {refusal:?}" - ), - None => return Err(error), - } - } - } - let holds: Vec<_> = self.unfinished_auction_holds(actor_guid).collect(); - for hold in holds { - let operation_id = hold.operation_id; - if let Err(error) = self.complete_auction_hold(hold) { - match auction_refusal(&error) { - Some(refusal) => log::warn!( - "auction Hold {operation_id} of {actor_guid} did not resume: {refusal:?}" - ), - None => return Err(error), - } - } - } - Ok(()) - } - - /// Realm-core's decision, the Home Shard's finish, and any refund phases of one Hold, from the - /// phase a stopped Gateway left it in. Every phase is idempotent, so a replay changes nothing. - fn complete_auction_hold(&self, mut hold: AuctionBidHold) -> Result { - let operation_id = hold.operation_id; - let realm = self.realm_core()?; - if hold.outcome == lyracore_shared::auction::bid_outcome::PENDING { - realm.auction_decide(&hold)?; - let decision = realm.wait_for_auction_bid_decision(operation_id)?; - self.auction_finish_bid(&hold, &decision)?; - hold = self.wait_for_terminal_bid_hold(operation_id)?; - } - if hold.deferred_refund != 0 { - realm.auction_refund_bid(&hold)?; - realm.wait_for_bid_refund(&hold)?; - self.auction_confirm_bid_refund(&hold)?; - hold = self.wait_for_settled_bid_refund(hold.operation_id)?; - } - Ok(hold) - } - - fn auction_bid_local( - &self, - operation_id: u64, - request: crate::world::PlaceBidRequest, - ) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "gw_auction_bid_local", - gw_auction_bid_local_then( - operation_id, - self.actor_or_owner(request.actor_guid), - request.auctioneer_guid, - request.auction_id, - request.house_id, - request.offer - ) - ) - } - - fn auction_hold_bid( - &self, - operation_id: u64, - request: crate::world::PlaceBidRequest, - ) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "gw_auction_hold_bid", - gw_auction_hold_bid_then( - operation_id, - self.actor_or_owner(request.actor_guid), - request.auctioneer_guid, - request.auction_id, - request.house_id, - request.offer - ) - ) - } - - fn auction_hold_cancel( - &self, - operation_id: u64, - request: crate::world::CancelAuctionRequest, - cut: u32, - ) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "gw_auction_hold_cancel", - gw_auction_hold_cancel_then( - operation_id, - self.actor_or_owner(request.actor_guid), - request.auctioneer_guid, - request.auction_id, - request.house_id, - cut - ) - ) - } - - fn auction_cancel_local( - &self, - operation_id: u64, - request: crate::world::CancelAuctionRequest, - cut: u32, - ) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "gw_auction_cancel_local", - gw_auction_cancel_local_then( - operation_id, - self.actor_or_owner(request.actor_guid), - request.auctioneer_guid, - request.auction_id, - request.house_id, - cut - ) - ) - } - - /// Realm-core's decision for a Hold, by the operation the Hold pays for. - fn auction_decide(&self, hold: &AuctionBidHold) -> Result<()> { - use lyracore_shared::auction::hold_operation; - match hold.operation { - hold_operation::BID => call_reducer!( - self.0.call_pipe().conn.reducers, - "realm_auction_decide_bid", - realm_auction_decide_bid_then( - hold.operation_id, - self.actor_or_owner(hold.bidder_guid), - hold.auction_id, - hold.house, - hold.offer - ) - ), - hold_operation::CANCEL => call_reducer!( - self.0.call_pipe().conn.reducers, - "realm_auction_decide_cancel", - realm_auction_decide_cancel_then( - hold.operation_id, - self.actor_or_owner(hold.bidder_guid), - hold.auction_id, - hold.house, - hold.offer - ) - ), - operation => Err(anyhow!( - "auction Hold {} pays for unknown operation {operation}", - hold.operation_id - )), - } - } - - /// The Auction Cut the seller pays to cancel the listing, read from THIS handle's cache. - /// `None` when the listing is gone, is not the seller's, or lists in another market. - fn cancellation_cut(&self, request: crate::world::CancelAuctionRequest) -> Option { - let guard = self.0.coord(); - let auction = guard - .conn - .db - .game_auction() - .id() - .find(&request.auction_id)?; - listing_cut(&auction, request) - } - - /// The keys `pick` names for `character_guid` in THIS handle's auction index. - fn auction_keys( - &self, - pick: impl Fn(&super::auction_holds::AuctionIndex) -> &super::auction_holds::KeysByCharacter, - character_guid: u64, - ) -> Vec { - let guard = self.0.coord(); - let index = guard - .auctions - .read() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - pick(&index).keys_of(character_guid) - } - - /// The rows `pick` names for `character_guid` in THIS handle's auction index, read back by - /// primary key through `find`. A row whose `owner` is no longer `character_guid` is dropped, - /// because the index can trail the cache. - fn indexed_rows( - &self, - pick: impl Fn(&super::auction_holds::AuctionIndex) -> &super::auction_holds::KeysByCharacter, - character_guid: u64, - find: impl Fn(&RemoteTables, u64) -> Option, - owner: impl Fn(&T) -> u64, - ) -> Vec { - let keys = self.auction_keys(pick, character_guid); - let guard = self.0.coord(); - keys.into_iter() - .filter_map(|key| find(&guard.conn.db, key)) - .filter(|row| owner(row) == character_guid) - .collect() - } - - /// `actor_guid`'s unfinished Holds on THIS handle. - fn unfinished_auction_holds(&self, actor_guid: u64) -> impl Iterator { - self.indexed_rows( - |index| &index.unfinished_bid_holds, - actor_guid, - |db, id| db.game_auction_bid_hold().operation_id().find(&id), - |hold| hold.bidder_guid, - ) - .into_iter() - .filter(super::auction_holds::hold_is_unfinished) - } - - /// `seller_guid`'s listing Holds on THIS handle. - fn listing_holds(&self, seller_guid: u64) -> Vec { - self.indexed_rows( - |index| &index.listing_holds, - seller_guid, - |db, id| db.game_auction_hold().operation_id().find(&id), - |hold| hold.seller_guid, - ) - } - - /// `seller_guid`'s listing receipts on THIS handle. - fn listing_receipts(&self, seller_guid: u64) -> Vec { - self.indexed_rows( - |index| &index.listing_receipts, - seller_guid, - |db, id| db.game_auction_operation_receipt().operation_id().find(&id), - |receipt| receipt.actor_guid, - ) - } - - /// Does `character_guid` sell, or lead the bidding on, an Auction on THIS handle? - fn has_auction(&self, character_guid: u64) -> bool { - let auctions = self.auction_keys(|index| &index.auctions, character_guid); - let guard = self.0.coord(); - auctions.into_iter().any(|id| { - u32::try_from(id) - .ok() - .and_then(|id| guard.conn.db.game_auction().id().find(&id)) - .is_some_and(|auction| { - auction.owner_guid == character_guid - || auction.highest_bidder_guid == character_guid - }) - }) - } - - fn auction_finish_bid( - &self, - hold: &AuctionBidHold, - decision: &AuctionBidDecision, - ) -> Result<()> { - if !bid_payload_matches(hold, decision) { - return Err(anyhow!( - "auction bid decision payload does not match its Hold" - )); - } - call_reducer!( - self.0.call_pipe().conn.reducers, - "gw_auction_finish_bid", - gw_auction_finish_bid_then( - hold.operation_id, - self.actor_or_owner(hold.bidder_guid), - hold.auction_id, - hold.house, - hold.offer, - decision.outcome, - decision.revision, - decision.result_bidder_guid, - decision.result_bid, - decision.minimum_increment, - decision.accepted_price - ) - ) - } - - fn auction_refund_bid(&self, hold: &AuctionBidHold) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "realm_auction_refund_bid", - realm_auction_refund_bid_then( - hold.operation_id, - self.actor_or_owner(hold.bidder_guid), - hold.auction_id, - hold.house, - hold.offer, - hold.deferred_refund - ) - ) - } - - fn auction_confirm_bid_refund(&self, hold: &AuctionBidHold) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "gw_auction_confirm_bid_refund", - gw_auction_confirm_bid_refund_then( - hold.operation_id, - self.actor_or_owner(hold.bidder_guid), - hold.auction_id, - hold.house, - hold.offer, - hold.deferred_refund - ) - ) - } - - fn matching_unfinished_bid_hold( - &self, - request: crate::world::PlaceBidRequest, - ) -> Option { - self.unfinished_auction_holds(request.actor_guid) - .find(|hold| { - hold.operation == lyracore_shared::auction::hold_operation::BID - && hold.auction_id == request.auction_id - && hold.house == request.house_id - && hold.offer == request.offer - }) - } - - fn wait_for_auction_bid_hold(&self, operation_id: u64) -> Result { - wait_for_cache_row(operation_id, "auction bid Hold", || { - self.0 - .coord() - .conn - .db - .game_auction_bid_hold() - .operation_id() - .find(&operation_id) - }) - } - - fn wait_for_terminal_bid_hold(&self, operation_id: u64) -> Result { - wait_for_cache_row(operation_id, "auction terminal bid Hold", || { - self.0 - .coord() - .conn - .db - .game_auction_bid_hold() - .operation_id() - .find(&operation_id) - .filter(|hold| hold.outcome != lyracore_shared::auction::bid_outcome::PENDING) - }) - } - - fn wait_for_auction_bid_decision(&self, operation_id: u64) -> Result { - wait_for_cache_row(operation_id, "auction bid decision", || { - self.0 - .coord() - .conn - .db - .game_auction_bid_decision() - .operation_id() - .find(&operation_id) - }) - } - - fn wait_for_bid_refund(&self, hold: &AuctionBidHold) -> Result { - wait_for_cache_row(hold.operation_id, "auction bid refund", || { - self.0 - .coord() - .conn - .db - .game_auction_bid_decision() - .operation_id() - .find(&hold.operation_id) - .filter(|decision| bid_refund_is_recorded(hold, decision)) - }) - } - - fn wait_for_settled_bid_refund(&self, operation_id: u64) -> Result { - wait_for_cache_row(operation_id, "auction settled bid refund", || { - self.0 - .coord() - .conn - .db - .game_auction_bid_hold() - .operation_id() - .find(&operation_id) - .filter(|hold| { - hold.outcome != lyracore_shared::auction::bid_outcome::PENDING - && hold.deferred_refund == 0 - }) - }) - } - - /// Enter the world: call the `player_login` reducer on the coordinator connection - /// (so `ctx.sender` is the player's bound identity), then read the resulting - /// `game_world_entity` row back through the privileged cache as an `EntityView`. - pub fn player_login( - &self, - account_id: u64, - character_guid: u64, - entry: crate::codec::WorldEntry, - ) -> Result { - // Login rides `gw_player_login` on the COORDINATOR connection (module half: delegates to - // apply_player_login with the account's bound identity as row owner, binds entity→lease, - // fail-closed on either missing) — no per-player connection exists anywhere. A world-port - // rides its twin `gw_player_world_port`, which keeps the Away Status. - let coord = self.0.call_pipe(); - let actor = self.actor_or_owner(character_guid); - match entry { - crate::codec::WorldEntry::FreshLogin => call_reducer!( - coord.conn.reducers, - "gw_player_login", - gw_player_login_then(account_id, actor) - )?, - crate::codec::WorldEntry::WorldPort => call_reducer!( - coord.conn.reducers, - "gw_player_world_port", - gw_player_world_port_then(account_id, actor) - )?, - } - - // The reducer committed; the row propagates to the owner cache asynchronously. Poll - // briefly until it appears (home_* ride along from the game_character row, and its - // zone_id is the fallback for a live row the Module could not resolve a zone for). - let char_row = self - .0 - .coord() - .conn - .db - .game_character() - .guid() - .find(&character_guid); - let zone_id = char_row.as_ref().map(|c| c.zone_id).unwrap_or(0); - let home_map = char_row.as_ref().map(|c| c.home_map).unwrap_or(0); - let home_zone = char_row.as_ref().map(|c| c.home_zone).unwrap_or(0); - let home_x = char_row.as_ref().map(|c| c.home_x).unwrap_or(0.0); - let home_y = char_row.as_ref().map(|c| c.home_y).unwrap_or(0.0); - let home_z = char_row.as_ref().map(|c| c.home_z).unwrap_or(0.0); - // 15 s cap, 15 ms steps. Was 3 s — the cold-1000 measurement showed the reducer - // COMMITTING while the coordinator stream lagged the login-burst tail past 3 s (writer at - // 34.5%, so pure propagation, not CPU): 67/1000 logins died here with the entity already - // live. The poll exits on first sight, so the longer cap costs nothing outside a burst. - for _ in 0..1000 { - if let Some(e) = self - .0 - .coord() - .conn - .db - .game_world_entity() - .guid() - .find(&character_guid) - { - let mut view = entity_view(e, zone_id); - view.home_map = home_map; - view.home_zone = home_zone; - view.home_x = home_x; - view.home_y = home_y; - view.home_z = home_z; - return Ok(view); - } - std::thread::sleep(Duration::from_millis(15)); - } - Err(anyhow!( - "player_login committed but game_world_entity {character_guid} not visible in the \ - coordinator cache within 15s" - )) - } - - /// Heartbeat this gateway's `game_gateway_lease` row every 15 s on EVERY connected - /// database's coordinator connection, forever. Every shard, not just the default: - /// `gw_player_login` fail-closes on the lease of the database it runs ON, and a - /// cross-database login (an instance entry resuming a transfer) runs on that destination - /// shard — a default-only lease made every instance login die with "no lease for this - /// gateway". Fire-and-forget per beat — a missed beat is harmless (the TTL tolerates - /// several) and the loop must never stall on a slow call. Spawned from `main`. - pub fn spawn_gateway_heartbeat(&self) { - let coord = self.clone(); - tokio::spawn(async move { - let mut tick = tokio::time::interval(Duration::from_secs(15)); - loop { - tick.tick().await; - // `world_shards()` is default-first; realm-core is appended when it is a - // distinct database (unconfigured, it aliases the default handle). - let mut shards = coord.world_shards(); - if let Ok(rc) = coord.realm_core() { - if !shards.iter().any(|(n, _)| n == rc.shard_name()) { - shards.push((rc.shard_name().to_string(), rc)); - } - } - for (shard_name, shard) in shards { - let guard = shard.0.coord(); - if let Err(e) = guard.conn.reducers.gw_heartbeat() { - log::warn!( - "gateway heartbeat send failed on {shard_name} (will retry next beat): {e}" - ); - } - } - } - }); - } - - /// Provision SRP6 credentials computed by the Gateway. - pub fn provision_account(&self, username: &str, salt: &[u8], verifier: &[u8]) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "provision_account", - provision_account_then(username.to_string(), salt.to_vec(), verifier.to_vec()) - ) - } - - /// Create a character via the `create_character` reducer (owner connection), mapping the - /// reducer result to a game outcome. A distinguished `NAME_IN_USE` error → `NameInUse`; any - /// other reducer/transport error → `Failed` (never propagated as a hard error, so a bad - /// creation can't drop the world session). - pub fn create_character( - &self, - account_id: u64, - name: &str, - race: u8, - class: u8, - gender: u8, - appearance: crate::codec::Appearance, - ) -> Result { - use crate::codec::CharCreateOutcome; - // The SpacetimeDB-generated reducer binding takes the five appearance bytes positionally; - // unbundle `Appearance` here, at the single generated-boundary call. - let result = call_reducer!( - self.0.call_pipe().conn.reducers, - "create_character", - create_character_then( - account_id, - name.to_string(), - race, - class, - gender, - appearance.skin, - appearance.face, - appearance.hair_style, - appearance.hair_color, - appearance.facial_hair - ) - ); - Ok(match result { - Ok(()) => CharCreateOutcome::Success, - Err(e) if e.to_string().contains("NAME_IN_USE") => CharCreateOutcome::NameInUse, - Err(e) if e.to_string().contains("SERVER_LIMIT") => CharCreateOutcome::ServerLimit, - // The 5875 client has no code for "this database may not mint guids", so the outcome is - // the generic failure — but the REASON must not be swallowed: the whole point of - // guid-range licensing is that an unlicensed shard fails loudly instead of minting - // into someone else's range. - Err(e) => { - log::warn!("create_character on {} failed: {e:#}", self.shard_name()); - CharCreateOutcome::Failed - } - }) - } - - /// Delete a character via the `delete_character` reducer (owner connection — the reducer is - /// operator-gated, mirroring `create_character`). Ownership is enforced module-side (`NOT_OWNER` - /// if `character_guid` isn't `account_id`'s), so a malicious/buggy client can't delete another - /// account's character. Maps to a game outcome the same way `create_character` does: never - /// propagated as a hard error, so a bad delete can't drop the world session. - pub fn delete_character( - &self, - account_id: u64, - character_guid: u64, - ) -> Result { - use crate::codec::CharDeleteOutcome; - match self.character_has_auction_value(character_guid) { - Ok(true) => return Ok(CharDeleteOutcome::Failed), - Ok(false) => {} - Err(error) => { - log::warn!( - "delete_character: could not verify auction value for {character_guid}: {error:#}" - ); - return Ok(CharDeleteOutcome::Failed); - } - } - let result = call_reducer!( - self.0.call_pipe().conn.reducers, - "delete_character", - delete_character_then(account_id, self.actor_or_owner(character_guid)) - ); - Ok(match result { - Ok(()) => CharDeleteOutcome::Success, - // The 1.12 client has one reason for every refusal, so the reason goes to the log: - // CHAR_HAS_GUILD_FEE_HOLD clears when the Character next enters the world and the - // Gateway finishes its Fee Hold. - Err(error) => { - log::info!("delete_character: {character_guid} not deleted: {error:#}"); - CharDeleteOutcome::Failed - } - }) - } - - fn character_has_auction_value(&self, character_guid: u64) -> Result { - for (_, shard) in self.world_shards() { - if !shard.listing_holds(character_guid).is_empty() - || shard - .unfinished_auction_holds(character_guid) - .next() - .is_some() - { - return Ok(true); - } - } - Ok(self.realm_core()?.has_auction(character_guid)) - } - - /// Logon writes K + the bound per-account identity. - pub fn establish_session( - &self, - account_id: u64, - session_key: &[u8; 40], - bound_identity: [u8; 32], - ) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "establish_session", - establish_session_then( - account_id, - session_key.to_vec(), - Identity::from_byte_array(bound_identity) - ) - ) - } - - /// Publish `character_guid`'s location into this handle's character→shard index. Call it - /// on the REALM-CORE handle: on a world shard the index is already maintained transactionally by - /// `finish_transfer`. Operator-gated module-side (the index is a routing input). - pub fn set_character_shard( - &self, - character_guid: u64, - map_id: u32, - instance_id: u64, - ) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "set_character_shard", - set_character_shard_then( - character_guid, - map_id, - instance_id, - self.actor_or_owner(character_guid) - ) - ) - } - - // The arguments mirror the Realm reducer's exact Transfer Gate. - #[allow(clippy::too_many_arguments)] - pub fn begin_character_shard_transfer( - &self, - source_map: u32, - source_instance: u64, - source_revision: u64, - destination_map: u32, - destination_instance: u64, - source_module_identity: spacetimedb_sdk::Identity, - intent_id: u64, - controller_generation: u64, - character_guid: u64, - ) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "begin_character_shard_transfer", - begin_character_shard_transfer_then( - character_guid, - source_map, - source_instance, - source_revision, - destination_map, - destination_instance, - source_module_identity, - intent_id, - controller_generation, - self.actor_or_owner(character_guid) - ) - ) - } - - pub fn finish_character_shard_transfer( - &self, - intent: &crate::world::transfer::BotTransferIntent, - ) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "finish_character_shard_transfer", - finish_character_shard_transfer_then( - intent.bot_guid, - intent.source_map, - intent.source_instance, - intent.source_locator_revision, - intent.destination_map, - intent.destination_instance, - intent.source_module_identity, - intent.id, - intent.controller_generation, - self.actor_or_owner(intent.bot_guid) - ) - ) - } - - pub fn finish_player_character_shard_transfer( - &self, - character_guid: u64, - source_map: u32, - source_instance: u64, - source_revision: u64, - destination_map: u32, - destination_instance: u64, - ) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "finish_character_shard_transfer", - finish_character_shard_transfer_then( - character_guid, - source_map, - source_instance, - source_revision, - destination_map, - destination_instance, - spacetimedb_sdk::Identity::ZERO, - 0, - 0, - self.actor_or_owner(character_guid) - ) - ) - } - - // The arguments mirror the Realm reducer's exact Transfer Gate. - #[allow(clippy::too_many_arguments)] - pub fn finish_pending_character_shard_transfer( - &self, - character_guid: u64, - source_map: u32, - source_instance: u64, - source_revision: u64, - destination_map: u32, - destination_instance: u64, - source_module_identity: spacetimedb_sdk::Identity, - transfer_intent_id: u64, - controller_generation: u64, - ) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "finish_pending_character_shard_transfer", - finish_pending_character_shard_transfer_then( - character_guid, - source_map, - source_instance, - source_revision, - destination_map, - destination_instance, - source_module_identity, - transfer_intent_id, - controller_generation, - self.actor_or_owner(character_guid) - ) - ) - } - - pub fn bind_bot_transfer_locator( - &self, - intent: &crate::world::transfer::BotTransferIntent, - source_revision: u64, - claim_token: u64, - ) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "bind_bot_transfer_locator", - bind_bot_transfer_locator_then( - intent.id, - intent.bot_guid, - intent.controller_generation, - claim_token, - intent.source_map, - intent.source_instance, - source_revision - ) - ) - } - - /// Set the player's current target (`CMSG_SET_SELECTION`, Tier 2 / N3) over the coordinator - /// connection so the module attributes it to the caller. `target_guid` 0 clears it. - pub fn set_target(&self, _account_id: u64, actor_guid: u64, target_guid: u64) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("set_target: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_set_target", - gw_set_target_then(self.session_actor(actor), target_guid) - ) - } - - /// Validate a `CMSG_INSPECT` request (target is a real in-world player, on the caller's map, in - /// range, friendly) over the coordinator connection so the module resolves the caller from - /// `ctx.sender`. `Err` (out of range / hostile / no such target) → the caller ignores it. - pub fn inspect(&self, _account_id: u64, actor_guid: u64, target_guid: u64) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("inspect: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_inspect", - gw_inspect_then(self.session_actor(actor), target_guid) - ) - } - - /// Use a gameobject (`CMSG_GAMEOBJ_USE`) — a chest rolls its loot into the corpse-loot table keyed - /// on the GO guid, a quest-use object grants quest credit. The module gates range + type. - /// Rides the coordinator connection as `gw_use_gameobject`. - pub fn use_gameobject( - &self, - _account_id: u64, - actor_guid: u64, - go_guid: u64, - ) -> Result { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("use_gameobject: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - legacy_loot_request_status(call_reducer!( - coord.conn.reducers, - "gw_use_gameobject", - gw_use_gameobject_then(self.session_actor(actor), go_guid) - )) - } - - /// Enter an area trigger (`CMSG_AREATRIGGER`) — credit any active explore quest tied to `trigger_id`. - pub fn enter_areatrigger( - &self, - _account_id: u64, - actor_guid: u64, - trigger_id: u32, - ) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("enter_areatrigger: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_enter_areatrigger", - gw_enter_areatrigger_then(self.session_actor(actor), trigger_id) - ) - } - - /// Forward an addon-bridge command to the module's `client_command` dispatch. - pub fn client_command( - &self, - _account_id: u64, - actor_guid: u64, - cmd: String, - payload: String, - ) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("client_command: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_client_command", - gw_client_command_then(self.session_actor(actor), cmd, payload) - ) - } - - /// Start the player's melee auto-attack on `target_guid` (`CMSG_ATTACKSWING`, combat C1) over - /// the coordinator connection so the module attributes the swing to the caller. - pub fn start_attack(&self, _account_id: u64, actor_guid: u64, target_guid: u64) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("start_attack: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_attack", - gw_attack_then(self.session_actor(actor), target_guid) - ) - } - - /// Relay a pet command-bar action (`CMSG_PET_ACTION`) over the coordinator connection so the module - /// attributes it to the pet's owner. `data` is the raw packed action (flag<<24 | id); the module - /// decodes stay/follow/attack/dismiss + passive/defensive/aggressive. - pub fn pet_command( - &self, - _account_id: u64, - actor_guid: u64, - data: u32, - target_guid: u64, - ) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("pet_command: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_pet_command", - gw_pet_command_then(self.session_actor(actor), data, target_guid) - ) - } - - /// Start the player's RANGED auto-attack on `target_guid` with `spell_id` (75 Auto Shot / 5019 Shoot) - /// over the coordinator connection so the module attributes the shot to the caller. - /// Rides the coordinator connection as `gw_ranged_attack`. - pub fn start_ranged_attack( - &self, - _account_id: u64, - actor_guid: u64, - target_guid: u64, - spell_id: u32, - ) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("start_ranged_attack: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_ranged_attack", - gw_ranged_attack_then(self.session_actor(actor), target_guid, spell_id) - ) - } - - /// Stop the player's melee auto-attack (`CMSG_ATTACKSTOP`, combat C1). - pub fn stop_attack(&self, _account_id: u64, actor_guid: u64) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("stop_attack: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_stop_attack", - gw_stop_attack_then(self.session_actor(actor)) - ) - } - - /// Draw or stow the player's weapons (`CMSG_SETSHEATHED`). - pub fn set_sheathed(&self, _account_id: u64, actor_guid: u64, state: u8) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("set_sheathed: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_set_sheathed", - gw_set_sheathed_then(self.session_actor(actor), state) - ) - } - - /// Cast a spell (`CMSG_CAST_SPELL`, aura tracer) over the coordinator connection so the module - /// attributes the cast to the caller. `target_guid` is the client's selected unit (0 = none/self → - /// the module substitutes the caster), threaded so target-keyed effects see the real target. - pub fn cast_spell( - &self, - _account_id: u64, - actor_guid: u64, - spell_id: u32, - target_guid: u64, - ) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("cast_spell: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_cast_spell", - gw_cast_spell_then(self.session_actor(actor), spell_id, target_guid) - ) - } - - /// Resolve an item-target cast through the module's generic item-effect seam. - pub fn cast_item_target( - &self, - _account_id: u64, - actor_guid: u64, - spell_id: u32, - slot: u8, - ) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("cast_item_target: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_cast_item_target", - gw_cast_item_target_then(self.session_actor(actor), spell_id, slot) - ) - } - - /// Cast a GROUND-TARGETED spell at a clicked world point (`CMSG_CAST_SPELL` with a DEST_LOCATION — - /// Flamestrike/Blizzard/Rain of Fire). Same per-account attribution as `cast_spell`; the `(x,y,z)` is - /// the ground click so the module anchors the AoE/patch there. - // The ground point rides flat because it mirrors the `gw_cast_spell_at` reducer signature. - #[allow(clippy::too_many_arguments)] - pub fn cast_spell_at( - &self, - _account_id: u64, - actor_guid: u64, - spell_id: u32, - target_guid: u64, - x: f32, - y: f32, - z: f32, - ) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("cast_spell_at: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_cast_spell_at", - gw_cast_spell_at_then(self.session_actor(actor), spell_id, target_guid, x, y, z) - ) - } - - /// Cancel one of the caller's own auras by spell id (`CMSG_CANCEL_AURA`) over the coordinator - /// connection so the module attributes the removal to the caller. - pub fn cancel_aura(&self, _account_id: u64, actor_guid: u64, spell_id: u32) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("cancel_aura: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_cancel_aura", - gw_cancel_aura_then(self.session_actor(actor), spell_id) - ) - } - - /// Cancel the caller's in-progress cast (`CMSG_CANCEL_CAST`) over the coordinator connection so the - /// module clears the caller's pending cast — no phantom completion GO. - pub fn cancel_cast(&self, _account_id: u64, actor_guid: u64) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("cancel_cast: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_cancel_cast", - gw_cancel_cast_then(self.session_actor(actor)) - ) - } - - /// Say, yell or `/e` on the speaker's Home Shard. The Module's language Gate answers a - /// `chat:*` Refusal. - pub fn send_chat( - &self, - _account_id: u64, - actor_guid: u64, - chat_type: u8, - language: u8, - message: String, - ) -> Result { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("send_chat: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - chat_outcome(call_reducer!( - coord.conn.reducers, - "gw_send_chat", - gw_send_chat_then(self.session_actor(actor), chat_type, language, message) - )) - } - - pub fn send_emote( - &self, - _account_id: u64, - actor_guid: u64, - text_emote: u32, - emote_anim: u32, - target_guid: u64, - ) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("send_emote: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_send_emote", - gw_send_emote_then( - self.session_actor(actor), - text_emote, - emote_anim, - target_guid - ) - ) - } - - /// `gw_set_away`: one `/afk` or `/dnd` on the Character's Home Shard, where its live entity - /// and Auto-Reply live. - pub fn set_away(&self, actor_guid: u64, kind: u8, message: String) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "gw_set_away", - gw_set_away_then(self.actor_or_owner(actor_guid), kind, message) - ) - } - - /// `realm_chat`: commit one Realm Chat Line on Realm-core, or on the one database of an - /// unsharded Realm. The Module applies every chat Gate. The speaker's name stays here. - pub fn realm_chat( - &self, - speaker_guid: u64, - request: crate::world::RealmChatRequest, - ) -> Result { - let realm = self.realm_core()?; - let request = RealmChatRequest { - kind: request.kind, - language: request.language, - channel_name: request.channel_name, - target_guid: request.target_guid, - message: request.message, - speaker: SpeakerFacts { - race: request.speaker.race, - chat_tag: request.speaker.chat_tag, - }, - }; - chat_outcome(call_reducer!( - realm.0.call_pipe().conn.reducers, - "realm_chat", - realm_chat_then(realm.actor_or_owner(speaker_guid), request) - )) - } - - /// `realm_whisper`: commit one whisper's lines on Realm-core, or on the one database of an - /// unsharded Realm. The Module applies every whisper Gate. The speaker's name stays here. - pub fn realm_whisper( - &self, - speaker_guid: u64, - request: crate::world::WhisperRequest, - ) -> Result { - let realm = self.realm_core()?; - let target = request.target; - let request = WhisperRequest { - language: request.language, - message: request.message, - speaker: SpeakerFacts { - race: request.speaker.race, - chat_tag: request.speaker.chat_tag, - }, - target: WhisperTargetFacts { - guid: target.guid, - race: target.race, - name: target.name, - ignores_speaker: target.ignores_speaker, - away_kind: target.away_kind, - away_message: target.away_message, - }, - }; - chat_outcome(call_reducer!( - realm.0.call_pipe().conn.reducers, - "realm_whisper", - realm_whisper_then(realm.actor_or_owner(speaker_guid), request) - )) - } - - /// `realm_channel_op`: run one Chat Channel op on Realm-core, or on the one database of an - /// unsharded Realm. The Module applies every channel rule. The actor's name stays here. - pub fn channel_op( - &self, - actor_guid: u64, - op: u8, - request: crate::world::ChannelRequest, - ) -> Result { - let realm = self.realm_core()?; - let request = ChannelRequest { - channel_name: request.channel_name, - password: request.password, - target_guid: request.target_guid, - target_name: request.target_name, - target_race: request.target_race, - target_ignores_actor: request.target_ignores_actor, - speaker: SpeakerFacts { - race: request.speaker.race, - chat_tag: request.speaker.chat_tag, - }, - }; - channel_outcome(call_reducer!( - realm.0.call_pipe().conn.reducers, - "realm_channel_op", - realm_channel_op_then(realm.actor_or_owner(actor_guid), op, request) - )) - } - - /// `gw_admit_meeting_stone` on this Home Shard: may the actor use the stone? Writes nothing. - pub fn admit_meeting_stone( - &self, - actor_guid: u64, - go_guid: u64, - ) -> Result { - let coord = self.0.call_pipe(); - meeting_stone_outcome(call_reducer!( - coord.conn.reducers, - "gw_admit_meeting_stone", - gw_admit_meeting_stone_then(self.actor_or_owner(actor_guid), go_guid) - )) - } - - /// `realm_meeting_stone_op`: run one Meeting Stone Queue op on Realm-core, or on the one - /// database of an unsharded Realm. The Module applies every queue rule. - pub fn meeting_stone_op( - &self, - actor_guid: u64, - op: u8, - area_id: u32, - seekers: Vec, - ) -> Result { - let realm = self.realm_core()?; - let seekers = seekers - .into_iter() - .map(|facts| SeekerFacts { - character_guid: facts.character_guid, - race: facts.race, - class: facts.class, - }) - .collect(); - meeting_stone_outcome(call_reducer!( - realm.0.call_pipe().conn.reducers, - "realm_meeting_stone_op", - realm_meeting_stone_op_then(op, realm.actor_or_owner(actor_guid), area_id, seekers) - )) - } - - /// GM playtest dot-command: resolve current Account authority on Realm-core, then convey it to - /// the Home Shard through one Store operation. - pub fn gm_command(&self, account_name: &str, actor_guid: u64, text: String) -> Result<()> { - crate::realm_core::run_gm_command(self, account_name, actor_guid, text) - } - - /// Send one classified command request to this Home Shard. The Module combines the conveyed - /// Account authority with its own Character GM level and remains the final Gate. Its Refusal - /// reason is the text the GM sees. - pub(crate) fn request_gm_command( - &self, - actor_guid: u64, - alpha_test_tools: bool, - text: String, - ) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("gm_command: actor_guid unresolved"))?; - call_reducer!( - self.0.call_pipe().conn.reducers, - "gw_gm_command", - gw_gm_command_then(self.session_actor(actor), alpha_test_tools, text) - ) - } - - /// `CMSG_PUSHQUESTTOPARTY` — over the coordinator connection so the module - /// attributes the sender + its grouped/on-quest gates to the caller. - pub fn push_quest(&self, _account_id: u64, actor_guid: u64, quest_id: u32) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("push_quest: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_push_quest_to_party", - gw_push_quest_to_party_then(self.session_actor(actor), quest_id) - ) - } - - /// `CMSG_GROUP_INVITE` — `target_guid` is already resolved by the gateway. - pub fn group_invite( - &self, - _account_id: u64, - actor_guid: u64, - target_guid: u64, - ) -> Result { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("group_invite: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - party_outcome(call_reducer!( - coord.conn.reducers, - "gw_group_invite", - gw_group_invite_then(self.session_actor(actor), target_guid) - )) - } - - /// `CMSG_INITIATE_TRADE`, `target_guid` is the client's targeted player. - pub fn initiate_trade( - &self, - _account_id: u64, - actor_guid: u64, - target_guid: u64, - ) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("initiate_trade: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_initiate_trade", - gw_initiate_trade_then(self.session_actor(actor), target_guid) - ) - } - - /// `CMSG_BEGIN_TRADE`. - pub fn begin_trade(&self, _account_id: u64, actor_guid: u64) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("begin_trade: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_begin_trade", - gw_begin_trade_then(self.session_actor(actor)) - ) - } - - /// `CMSG_CANCEL_TRADE`. - pub fn cancel_trade(&self, _account_id: u64, actor_guid: u64) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("cancel_trade: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_cancel_trade", - gw_cancel_trade_then(self.session_actor(actor)) - ) - } - - pub fn duel_accept(&self, _account_id: u64, actor_guid: u64, flag_guid: u64) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("duel_accept: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_duel_accept", - gw_duel_accept_then(self.session_actor(actor), flag_guid) - ) - } - - pub fn duel_cancel(&self, _account_id: u64, actor_guid: u64, flag_guid: u64) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("duel_cancel: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_duel_cancel", - gw_duel_cancel_then(self.session_actor(actor), flag_guid) - ) - } - - /// `CMSG_SET_TRADE_ITEM`. - pub fn set_trade_item( - &self, - _account_id: u64, - actor_guid: u64, - trade_slot: u8, - inv_slot: u8, - ) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("set_trade_item: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_set_trade_item", - gw_set_trade_item_then(self.session_actor(actor), trade_slot, inv_slot) - ) - } - - /// `CMSG_CLEAR_TRADE_ITEM`. - pub fn clear_trade_item( - &self, - _account_id: u64, - actor_guid: u64, - trade_slot: u8, - ) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("clear_trade_item: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_clear_trade_item", - gw_clear_trade_item_then(self.session_actor(actor), trade_slot) - ) - } - - /// `CMSG_SET_TRADE_GOLD`. - pub fn set_trade_gold(&self, _account_id: u64, actor_guid: u64, copper: u32) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("set_trade_gold: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_set_trade_gold", - gw_set_trade_gold_then(self.session_actor(actor), copper) - ) - } - - /// `CMSG_ACCEPT_TRADE`. - pub fn accept_trade(&self, _account_id: u64, actor_guid: u64) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("accept_trade: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_accept_trade", - gw_accept_trade_then(self.session_actor(actor)) - ) - } - - /// `CMSG_UNACCEPT_TRADE`. - pub fn unaccept_trade(&self, _account_id: u64, actor_guid: u64) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("unaccept_trade: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_unaccept_trade", - gw_unaccept_trade_then(self.session_actor(actor)) - ) - } - - /// `CMSG_BUSY_TRADE`. - pub fn busy_trade(&self, _account_id: u64, actor_guid: u64) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("busy_trade: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_busy_trade", - gw_busy_trade_then(self.session_actor(actor)) - ) - } - - /// `CMSG_IGNORE_TRADE`. - pub fn ignore_trade(&self, _account_id: u64, actor_guid: u64) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("ignore_trade: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_ignore_trade", - gw_ignore_trade_then(self.session_actor(actor)) - ) - } - - /// `CMSG_GROUP_ACCEPT`. Rides the coordinator connection as - /// `gw_accept_group_invite`. - pub fn group_accept(&self, _account_id: u64, actor_guid: u64) -> Result { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("group_accept: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - party_outcome(call_reducer!( - coord.conn.reducers, - "gw_accept_group_invite", - gw_accept_group_invite_then(self.session_actor(actor)) - )) - } - - /// `CMSG_GROUP_DECLINE`. - pub fn group_decline(&self, _account_id: u64, actor_guid: u64) -> Result { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("group_decline: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - party_outcome(call_reducer!( - coord.conn.reducers, - "gw_group_decline", - gw_group_decline_then(self.session_actor(actor)) - )) - } - - /// `CMSG_GROUP_DISBAND` — leave the caller's group. - pub fn group_leave(&self, _account_id: u64, actor_guid: u64) -> Result { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("group_leave: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - party_outcome(call_reducer!( - coord.conn.reducers, - "gw_group_leave", - gw_group_leave_then(self.session_actor(actor)) - )) - } - - /// `CMSG_GROUP_UNINVITE` or `CMSG_GROUP_UNINVITE_GUID` — the leader or an Assistant kicks - /// `target_guid`. - pub fn group_uninvite( - &self, - _account_id: u64, - actor_guid: u64, - target_guid: u64, - ) -> Result { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("group_uninvite: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - party_outcome(call_reducer!( - coord.conn.reducers, - "gw_group_uninvite", - gw_group_uninvite_then(self.session_actor(actor), target_guid) - )) - } - - /// `CMSG_LOOT_METHOD` — the leader sets the party's loot method/ - /// threshold/master. Echoed to every member via the existing `SMSG_GROUP_LIST` relay (the - /// module's `group_loot_method` reducer re-renders the roster payload); no separate ack packet - /// (vanilla sends none for this opcode either). - pub fn group_loot_method( - &self, - _account_id: u64, - actor_guid: u64, - loot_setting: u8, - master_guid: u64, - loot_threshold: u8, - ) -> Result { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("group_loot_method: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - party_outcome(call_reducer!( - coord.conn.reducers, - "gw_group_loot_method", - gw_group_loot_method_then( - self.session_actor(actor), - loot_setting, - master_guid, - loot_threshold - ) - )) - } - - /// `CMSG_GOSSIP_SELECT_OPTION` — the NOTIFY-ONLY module chokepoint. Fired - /// best-effort BEFORE the gateway's own gossip behavior; a failure never blocks the reply. - pub fn gossip_select( - &self, - _account_id: u64, - actor_guid: u64, - npc_guid: u64, - option_id: u32, - option_row_id: u32, - ) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("gossip_select: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_gossip_select", - gw_gossip_select_then( - self.session_actor(actor), - npc_guid, - option_id, - option_row_id - ) - ) - } - - /// `CMSG_ADD_FRIEND` — `target_guid` is already resolved by the gateway. `target_race` is the - /// target's Speaker Fact, read realm-wide (`presence::of`): the Module's Enemy Gate needs it. - pub fn add_friend( - &self, - _account_id: u64, - actor_guid: u64, - target_guid: u64, - target_race: u8, - ) -> Result { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("add_friend: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - contact_outcome(call_reducer!( - coord.conn.reducers, - "gw_add_friend", - gw_add_friend_then(self.session_actor(actor), target_guid, target_race) - )) - } - - /// `CMSG_DEL_FRIEND`. - pub fn del_friend( - &self, - _account_id: u64, - actor_guid: u64, - target_guid: u64, - ) -> Result { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("del_friend: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - contact_outcome(call_reducer!( - coord.conn.reducers, - "gw_del_friend", - gw_del_friend_then(self.session_actor(actor), target_guid) - )) - } - - /// `CMSG_ADD_IGNORE` — `target_guid` is already resolved by the gateway. - pub fn add_ignore( - &self, - _account_id: u64, - actor_guid: u64, - target_guid: u64, - ) -> Result { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("add_ignore: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - contact_outcome(call_reducer!( - coord.conn.reducers, - "gw_add_ignore", - gw_add_ignore_then(self.session_actor(actor), target_guid) - )) - } - - /// `CMSG_DEL_IGNORE`. - pub fn del_ignore( - &self, - _account_id: u64, - actor_guid: u64, - target_guid: u64, - ) -> Result { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("del_ignore: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - contact_outcome(call_reducer!( - coord.conn.reducers, - "gw_del_ignore", - gw_del_ignore_then(self.session_actor(actor), target_guid) - )) - } - - /// Take the money from a corpse (`CMSG_LOOT_MONEY`) over the coordinator connection so - /// the module attributes the loot to the caller (as `gw_loot_money`). - pub fn loot_money( - &self, - _account_id: u64, - actor_guid: u64, - target_guid: u64, - ) -> Result { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("loot_money: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - strict_loot_request_status(call_reducer!( - coord.conn.reducers, - "gw_loot_money", - gw_loot_money_then(self.session_actor(actor), target_guid) - )) - } - - /// Authorize opening a creature corpse before the Gateway reads its loot rows. - pub fn open_creature_loot( - &self, - _account_id: u64, - actor_guid: u64, - corpse_guid: u64, - ) -> Result { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("open_creature_loot: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - strict_loot_request_status(call_reducer!( - coord.conn.reducers, - "gw_open_creature_loot", - gw_open_creature_loot_then(self.session_actor(actor), corpse_guid) - )) - } - - /// Take one item from the open corpse into the backpack (`CMSG_AUTOSTORE_LOOT_ITEM`) over - /// the coordinator connection so the module attributes the loot to the caller. The module moves the - /// item into a free slot + deletes the corpse-loot row (the inventory relay then shows it in the bag). - /// Rides the coordinator connection as `gw_take_loot`. - pub fn take_loot( - &self, - _account_id: u64, - actor_guid: u64, - corpse_guid: u64, - loot_slot: u8, - ) -> Result { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("take_loot: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - strict_loot_request_status(call_reducer!( - coord.conn.reducers, - "gw_take_loot", - gw_take_loot_then(self.session_actor(actor), corpse_guid, loot_slot) - )) - } - - pub fn skin_corpse( - &self, - _account_id: u64, - actor_guid: u64, - corpse_guid: u64, - ) -> Result { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("skin_corpse: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - legacy_loot_request_status(call_reducer!( - coord.conn.reducers, - "gw_skin", - gw_skin_then(self.session_actor(actor), corpse_guid) - )) - } - - /// `CMSG_LOOT_ROLL` — record the caller's need/greed/pass vote on a - /// live roll. Live votes/roll numbers relay to every eligible member via the `game_group_event` - /// roll-kind rows (`stdb/subscriptions.rs`). - pub fn loot_roll( - &self, - _account_id: u64, - actor_guid: u64, - corpse_guid: u64, - loot_slot: u32, - vote: u8, - ) -> Result { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("loot_roll: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - loot_action_status(call_reducer!( - coord.conn.reducers, - "gw_loot_roll", - gw_loot_roll_then(self.session_actor(actor), corpse_guid, loot_slot, vote) - )) - } - - /// `CMSG_LOOT_MASTER_GIVE` — the master looter assigns an above- - /// threshold row to `target_guid`. - pub fn loot_master_give( - &self, - _account_id: u64, - actor_guid: u64, - corpse_guid: u64, - loot_slot: u8, - target_guid: u64, - ) -> Result { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("loot_master_give: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - loot_action_status(call_reducer!( - coord.conn.reducers, - "gw_loot_master_give", - gw_loot_master_give_then( - self.session_actor(actor), - corpse_guid, - loot_slot, - target_guid - ) - )) - } - - pub fn disenchant_item(&self, _account_id: u64, actor_guid: u64, slot: u8) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("disenchant_item: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_disenchant", - gw_disenchant_then(self.session_actor(actor), slot) - ) - } - - pub fn enchant_item_on_slot( - &self, - _account_id: u64, - actor_guid: u64, - slot: u8, - enchant_id: u32, - ) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("enchant_item_on_slot: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_enchant_item", - gw_enchant_item_then(self.session_actor(actor), slot, enchant_id) - ) - } - - /// Buy `count` of `item_entry` from the vendor `vendor_guid` (`CMSG_BUY_ITEM`, Tier 2) over the - /// coordinator connection so the module attributes the purchase to the caller. The module gates - /// it on the vendor (stock + NPC flags + range) and debits the buyer's copper. - pub fn buy_item( - &self, - _account_id: u64, - actor_guid: u64, - vendor_guid: u64, - item_entry: u32, - count: u32, - ) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("buy_item: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_buy_item", - gw_buy_item_then(self.session_actor(actor), vendor_guid, item_entry, count) - ) - } - - /// Learn `spell_id` from trainer `trainer_guid` (`CMSG_TRAINER_BUY_SPELL`) over the coordinator - /// connection. The module gates it (range / level / cost / not-already-known) and charges copper. - /// A Refusal the Module tagged comes back as an outcome; anything else stays an error. - /// Rides the coordinator connection as `gw_trainer_buy`. - pub fn buy_trainer_spell( - &self, - _account_id: u64, - actor_guid: u64, - trainer_guid: u64, - spell_id: u32, - ) -> Result { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("buy_trainer_spell: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - let result: Result<()> = call_reducer!( - coord.conn.reducers, - "gw_trainer_buy", - gw_trainer_buy_then(self.session_actor(actor), trainer_guid, spell_id) - ); - match result { - Ok(()) => Ok(crate::world::TrainerBuyOutcome::Learned), - Err(error) => match trainer_refusal(&error) { - Some(refusal) => Ok(refusal.into()), - None => Err(error), - }, - } - } - - /// Buy the next bank bag slot from `banker_guid` (`CMSG_BUY_BANK_SLOT`) over the coordinator - /// connection. A refusal carries the module's `[N]` `SMSG_BUY_BANK_SLOT_RESULT` code tag. - pub fn buy_bank_slot(&self, _account_id: u64, actor_guid: u64, banker_guid: u64) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("buy_bank_slot: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_buy_bank_slot", - gw_buy_bank_slot_then(self.session_actor(actor), banker_guid) - ) - } - - pub fn learn_talent(&self, _account_id: u64, actor_guid: u64, talent_id: u32) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("learn_talent: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_learn_talent", - gw_learn_talent_then(self.session_actor(actor), talent_id) - ) - } - - /// Respec at a trainer (the "I wish to unlearn my talents." gossip option), clears every - /// learned talent for the calling player's escalating gold cost. Rides the coordinator - /// connection as `gw_reset_talents` (deleted the per-player sender path). - pub fn reset_talents( - &self, - _account_id: u64, - actor_guid: u64, - trainer_guid: u64, - ) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("reset_talents: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_reset_talents", - gw_reset_talents_then(self.session_actor(actor), trainer_guid) - ) - } - - /// Fishing cast: instant-resolve catch — the module's lenient alpha gate auto-learns the - /// skill and grants the fish straight to the bag. Caller resolved via ctx.sender. - pub fn fish(&self, _account_id: u64, actor_guid: u64) -> Result<()> { - let actor = Actor::new(actor_guid).ok_or_else(|| anyhow!("fish: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_fish", - gw_fish_then(self.session_actor(actor)) - ) - } - - /// Pick Lock: unlock the locked GameObject `go_guid` over the coordinator connection (so the - /// module attributes the pick to the caller via ctx.sender). The module gates range / lock - /// requirement / Lockpicking skill; on success it records the GO unlocked + climbs the skill. - pub fn pick_lock(&self, _account_id: u64, actor_guid: u64, go_guid: u64) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("pick_lock: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_pick_lock", - gw_pick_lock_then(self.session_actor(actor), go_guid) - ) - } - - /// Persist one action-bar button (`CMSG_SET_ACTION_BUTTON`): upsert by (character, button); - /// action 0 clears. Without this every bar drag was lost on relog (only creation seeds survived). - pub fn set_action_button( - &self, - _account_id: u64, - actor_guid: u64, - button: u8, - action: u32, - action_type: u8, - ) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("set_action_button: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_set_action_button", - gw_set_action_button_then(self.session_actor(actor), button, action, action_type) - ) - } - - /// Persist the rep pane's At-War checkbox (`CMSG_SET_FACTION_ATWAR`): the wire's - /// u16 is the client's 0..63 rep-array slot (ReputationListID — the gtker `Faction` field name - /// lies, same as SET_FACTION_STANDING); the module reverse-resolves the faction and upserts. - pub fn set_faction_at_war( - &self, - _account_id: u64, - actor_guid: u64, - reputation_index: u32, - at_war: bool, - ) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("set_faction_at_war: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_set_faction_at_war", - gw_set_faction_at_war_then(self.session_actor(actor), reputation_index, at_war) - ) - } - - /// Sell the item in inventory `slot` back to a vendor (`CMSG_SELL_ITEM`, Tier 2) over the - /// coordinator connection. The gateway resolves the client's item-INSTANCE guid to the owning - /// slot before calling (the reducer takes the slot); the module credits the seller's copper. - /// Rides the coordinator connection as `gw_sell_item`. - pub fn sell_item( - &self, - _account_id: u64, - actor_guid: u64, - vendor_guid: u64, - slot: u8, - ) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("sell_item: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_sell_item", - gw_sell_item_then(self.session_actor(actor), vendor_guid, slot) - ) - } - - pub fn buyback_item( - &self, - _account_id: u64, - actor_guid: u64, - vendor_guid: u64, - slot: u8, - ) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("buyback_item: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_buyback_item", - gw_buyback_item_then(self.session_actor(actor), vendor_guid, slot) - ) - } - - /// Repair the item in inventory `slot` at REPAIR-NPC `npc_guid` (`CMSG_REPAIR_ITEM`) over the - /// coordinator connection. The module gates the NPC + charges copper; the player's item + - /// purse replicate back via subscription. - pub fn repair_item( - &self, - _account_id: u64, - actor_guid: u64, - npc_guid: u64, - slot: u8, - ) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("repair_item: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_repair_item", - gw_repair_item_then(self.session_actor(actor), npc_guid, slot) - ) - } - - /// Equip the item in main-inventory `from_slot` (`CMSG_AUTOEQUIP_ITEM`) over the coordinator - /// connection. The module resolves the matching equipment slot and gates the required level. - /// Rides the coordinator connection as `gw_equip_item`. - pub fn equip_item( - &self, - _account_id: u64, - actor_guid: u64, - from_slot: u8, - ) -> Result { - let Some(actor) = resolved_item_actor("equip_item", actor_guid) else { - return Ok(ItemRefusal::Internal.into()); - }; - let coord = self.0.call_pipe(); - item_action(call_reducer!( - coord.conn.reducers, - "gw_equip_item", - gw_equip_item_then(self.session_actor(actor), from_slot) - )) - } - - /// Unequip the item in equipment `from_slot` to a free backpack slot (`CMSG_AUTOSTORE_BAG_ITEM`) - /// over the coordinator connection. The module gates "is equipped" + "backpack has room". - pub fn unequip_item( - &self, - _account_id: u64, - actor_guid: u64, - from_slot: u8, - ) -> Result { - let Some(actor) = resolved_item_actor("unequip_item", actor_guid) else { - return Ok(ItemRefusal::Internal.into()); - }; - let coord = self.0.call_pipe(); - item_action(call_reducer!( - coord.conn.reducers, - "gw_unequip_item", - gw_unequip_item_then(self.session_actor(actor), from_slot) - )) - } - - /// Use the consumable in main-inventory `slot` (`CMSG_USE_ITEM`) over the coordinator connection — - /// eat/drink/potion/bandage. The module applies the on-use effect (flat heal for slice food) and - /// decrements the stack; a gameplay `Err` (no item / not usable) is per-action. - pub fn use_item( - &self, - _account_id: u64, - actor_guid: u64, - slot: u8, - ) -> Result { - let Some(actor) = resolved_item_actor("use_item", actor_guid) else { - return Ok(ItemRefusal::Internal.into()); - }; - let coord = self.0.call_pipe(); - item_action(call_reducer!( - coord.conn.reducers, - "gw_use_item", - gw_use_item_then(self.session_actor(actor), slot) - )) - } - - /// Bind the caller's hearthstone home to their current position (`CMSG_GOSSIP_SELECT_OPTION` on an - /// innkeeper's "Make this inn your home.") over the coordinator connection so the module attributes - /// it to the caller's entity. No args — `bind_home` resolves the caller via `ctx.sender`. - pub fn bind_home(&self, _account_id: u64, actor_guid: u64) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("bind_home: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_bind_home", - gw_bind_home_then(self.session_actor(actor)) - ) - } - - /// Move (or swap) main-inventory `from_slot` → `to_slot` (`CMSG_SWAP_INV_ITEM`/`CMSG_SWAP_ITEM`) - /// over the coordinator connection. The module's move primitive validates equip-slot transitions. - pub fn move_item( - &self, - _account_id: u64, - actor_guid: u64, - from_slot: u8, - to_slot: u8, - ) -> Result { - let Some(actor) = resolved_item_actor("move_item", actor_guid) else { - return Ok(ItemRefusal::Internal.into()); - }; - let coord = self.0.call_pipe(); - item_action(call_reducer!( - coord.conn.reducers, - "gw_move_item", - gw_move_item_then(self.session_actor(actor), from_slot, to_slot) - )) - } - - /// Auto-bank/auto-store-bank the item in `slot` (`CMSG_AUTOBANK_ITEM`/`CMSG_AUTOSTORE_BANK_ITEM`) - /// over the coordinator connection. The module infers deposit vs. withdraw from `slot` and - /// resolves the receiving free slot itself. - pub fn auto_bank_item(&self, _account_id: u64, actor_guid: u64, slot: u8) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("auto_bank_item: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_auto_bank_item", - gw_auto_bank_item_then(self.session_actor(actor), slot) - ) - } - - /// Accept quest `quest_id` from giver `giver_guid` (`CMSG_QUESTGIVER_ACCEPT_QUEST`) over the - /// coordinator connection so the module attributes it to the caller. The module gates the accept - /// (giver relation + range + level + not-already-held); a gameplay `Err` is per-action, not fatal. - /// Rides the coordinator connection as `gw_accept_quest`. - pub fn accept_quest( - &self, - _account_id: u64, - actor_guid: u64, - giver_guid: u64, - quest_id: u32, - ) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("accept_quest: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_accept_quest", - gw_accept_quest_then(self.session_actor(actor), giver_guid, quest_id) - ) - } - - /// Turn quest `quest_id` in to giver `giver_guid` (`CMSG_QUESTGIVER_CHOOSE_REWARD`). The module - /// validates completion + grants the rewards (money/XP/items). `reward_index` is the player's - /// pick-1-of-N choice slot; ignored when the quest has no choices. This call uses the subscribed - /// visibility pipe so committed item relays are queued before success presentation is allowed. - pub fn turn_in_quest( - &self, - _account_id: u64, - actor_guid: u64, - giver_guid: u64, - quest_id: u32, - reward_index: u32, - ) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("turn_in_quest: actor_guid unresolved"))?; - let coord = self.0.visibility_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_turn_in_quest", - gw_turn_in_quest_then( - self.session_actor(actor), - giver_guid, - quest_id, - reward_index - ) - ) - } - - /// Abandon quest `quest_id` (`CMSG_QUESTLOG_REMOVE_QUEST`) over the coordinator connection. The - /// module deletes the player's quest-log row; the quest-log relay then clears the slot. - pub fn abandon_quest(&self, _account_id: u64, actor_guid: u64, quest_id: u32) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("abandon_quest: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_abandon_quest", - gw_abandon_quest_then(self.session_actor(actor), quest_id) - ) - } - - /// Revive the caller after death (`CMSG_REPOP_REQUEST`) over the coordinator connection. - /// Rides the coordinator connection as `gw_repop`. - pub fn repop(&self, _account_id: u64, actor_guid: u64) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("repop: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_repop", - gw_repop_then(self.session_actor(actor)) - ) - } - - /// Reclaim the caller's corpse (`CMSG_RECLAIM_CORPSE`) over the coordinator connection. - pub fn reclaim_corpse( - &self, - _account_id: u64, - actor_guid: u64, - corpse_guid: u64, - ) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("reclaim_corpse: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_reclaim_corpse", - gw_reclaim_corpse_then(self.session_actor(actor), corpse_guid) - ) - } - - /// Answer a pending resurrect offer (`CMSG_RESURRECT_RESPONSE`) over the coordinator connection. - /// Rides the coordinator connection as `gw_respond_resurrect`. - pub fn resurrect_response( - &self, - _account_id: u64, - actor_guid: u64, - accept: bool, - ) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("resurrect_response: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_respond_resurrect", - gw_respond_resurrect_then(self.session_actor(actor), accept) - ) - } - - /// Use the caller's Self-Resurrection Option (`CMSG_SELF_RES`) over the coordinator connection. - /// Rides the coordinator connection as `gw_self_resurrect`. - pub fn self_resurrect(&self, _account_id: u64, actor_guid: u64) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("self_resurrect: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_self_resurrect", - gw_self_resurrect_then(self.session_actor(actor)) - ) - } - - /// Spirit-Healer resurrect (`CMSG_SPIRIT_HEALER_ACTIVATE`) over the coordinator connection: the - /// module res's the caller in place at 50% + applies Resurrection Sickness if it's a ghost. - /// `gw_spirit_res` takes no `healer_guid`. - pub fn spirit_healer_res( - &self, - _account_id: u64, - actor_guid: u64, - _healer_guid: u64, - ) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("spirit_healer_res: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_spirit_res", - gw_spirit_res_then(self.session_actor(actor)) + "gw_gm_command", + gw_gm_command_then(self.session_actor(actor), alpha_test_tools, text) ) } @@ -3041,121 +271,6 @@ impl Coordinator { // is precisely what they exist to make happen. // ------------------------------------------------------------------------------------- - /// `begin_transfer` — freeze the character, serialize it (row + every manifest table's rows), - /// and delete its live entity, in ONE transaction. Idempotent on `transfer_id`. - pub fn begin_transfer(&self, plan: &crate::world::transfer::TransferPlan) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "begin_transfer", - begin_transfer_then( - plan.transfer_id, - self.actor_or_owner(plan.character_guid), - plan.dest_map_id, - plan.dest_instance_id, - plan.dest_x, - plan.dest_y, - plan.dest_z, - plan.dest_o, - true - ) - ) - } - - /// `import_player_character_blob`, with the Realm locator predecessor written as part of the - /// destination import transaction. - pub fn import_player_character_blob( - &self, - transfer_id: u64, - blob: &[u8], - source: crate::world::transfer::RealmLocatorPredecessor, - ) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "import_player_character_blob", - import_player_character_blob_then( - transfer_id, - blob.to_vec(), - source.map_id, - source.instance_id, - source.revision, - self.owner_actor() - ) - ) - } - - /// `import_bot_character_blob`, with the exact source intent identity written as part of the - /// destination import transaction. - pub fn import_bot_character_blob( - &self, - transfer_id: u64, - blob: &[u8], - intent: &crate::world::transfer::BotTransferIntent, - ) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "import_bot_character_blob", - import_bot_character_blob_then( - transfer_id, - blob.to_vec(), - intent.source_module_identity, - intent.id, - intent.controller_generation, - intent.created_micros, - intent.source_map, - intent.source_instance, - intent.source_locator_revision, - self.owner_actor() - ) - ) - } - - /// `confirm_import` — attest ON THE SOURCE that the destination copy committed. Called only - /// after `import_character_blob` returned Ok; see `world::transfer::run_transfer`. - pub fn confirm_import(&self, transfer_id: u64) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "confirm_import", - confirm_import_then(transfer_id, self.owner_actor()) - ) - } - - /// `finish_transfer` — delete-last: destroy the source copy and clear the escrow. - pub fn finish_transfer(&self, transfer_id: u64) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "finish_transfer", - finish_transfer_then(transfer_id, self.owner_actor()) - ) - } - - /// `release_transfer` — drop the arrival copy's fence at the destination. - pub fn release_transfer(&self, transfer_id: u64) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "release_transfer", - release_transfer_then(transfer_id, self.owner_actor()) - ) - } - - /// `ensure_instance` — mirror an instance id onto this shard (idempotent), spawning its - /// population the first time. - pub fn ensure_instance(&self, instance_id: u64, map_id: u32, party_id: u64) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "ensure_instance", - ensure_instance_then(instance_id, map_id, party_id, self.owner_actor()) - ) - } - - /// `evict_instance_population` — stop this shard ticking an instance whose run moved elsewhere. - pub fn evict_instance_population(&self, instance_id: u64) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "evict_instance_population", - evict_instance_population_then(instance_id, self.owner_actor()) - ) - } - /// `record_shard_load` — fired against THIS handle's connection. Callers hold the /// **realm-core** handle: `game_shard_load` is only ever read from there. pub fn record_shard_load( @@ -3163,620 +278,24 @@ impl Coordinator { shard: &str, writer_occupancy_pct: f32, sessions: u32, - gateway_key: u64, - ) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "record_shard_load", - record_shard_load_then( - shard.to_string(), - writer_occupancy_pct, - sessions, - gateway_key - ) - ) - } - - /// `realm_group_op` — one party op against the database THIS handle points at. The gateway - /// calls it on the **realm-core** handle, where membership is authoritative. - /// - /// Through an operator connection, not the player's: the reducer is operator-gated because it - /// takes the acting character's guid as an argument (realm-core has no live entity to derive - /// one from), so only the token that holds the operator identity may call it. The guid passed is - /// the one this socket authenticated into the world with — see `world::party`. - /// - /// This form rides a call pipe, which subscribes no group table, so the Coordinator cache may - /// still hold the old roster when it returns. Only an op that pushes no Group mirror after it - /// uses it: a Group Broadcast or a Target Icon request. Every roster op uses - /// [`Self::realm_group_op_visible`]. - pub fn realm_group_op( - &self, - op: u8, - actor_guid: u64, - target_guid: u64, - arg_a: u8, - arg_b: u8, - arg_c: u64, - ) -> Result { - party_outcome(call_reducer!( - self.0.call_pipe().conn.reducers, - "realm_group_op", - realm_group_op_then( - op, - self.actor_or_owner(actor_guid), - target_guid, - arg_a, - arg_b, - arg_c - ) - )) - } - - /// [`Self::realm_group_op`] on the visibility pipe: it returns only after the Coordinator cache - /// holds the committed roster, so the mirror push that follows reads the op's own result. The - /// caller must not run on the Coordinator pump: World Sessions and the bot intent threads. - pub fn realm_group_op_visible( - &self, - op: u8, - actor_guid: u64, - target_guid: u64, - arg_a: u8, - arg_b: u8, - arg_c: u64, - ) -> Result { - let coordinator = self.0.visibility_pipe(); - party_outcome(call_reducer!( - coordinator.conn.reducers, - "realm_group_op", - realm_group_op_then( - op, - self.actor_or_owner(actor_guid), - target_guid, - arg_a, - arg_b, - arg_c - ) - )) - } - - /// Remove a deleted Character from a party and return only after the Coordinator cache holds - /// the committed Realm-core roster. The caller always runs off the Coordinator pump. - pub fn deleted_character_party_leave(&self, character_guid: u64) -> Result { - let coordinator = self.0.visibility_pipe(); - party_outcome(call_reducer!( - coordinator.conn.reducers, - "realm_group_op", - realm_group_op_then( - lyracore_shared::group::realm_op::LEAVE, - self.actor_or_owner(character_guid), - 0, - lyracore_shared::group::leave_cause::CHARACTER_DELETED, - 0, - 0 - ) - )) - } - - /// `realm_mail_mark_read` — flip a mail's read state against the database THIS handle points - /// at. Operator-gated, `recipient_guid` passed - /// explicitly (the plane may hold no live entity), called on whichever handle `world::mail` - /// picked — realm-core when sharded, this shard's own database when not. - pub fn mail_mark_read(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "realm_mail_mark_read", - realm_mail_mark_read_then(self.actor_or_owner(recipient_guid), mail_id) - ) - } - - /// `realm_mail_delete` — [`mail_mark_read`](Self::mail_mark_read)'s twin for delete. - pub fn mail_delete(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "realm_mail_delete", - realm_mail_delete_then(self.actor_or_owner(recipient_guid), mail_id) - ) - } - - /// `realm_mail_return` — [`mail_delete`](Self::mail_delete)'s twin for return-to-sender: the row - /// is re-addressed in place, on the database THIS handle points at. No sharded variant — the row - /// never leaves the plane that already holds it. - pub fn mail_return(&self, recipient_guid: u64, mail_id: u64, same_account: bool) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "realm_mail_return", - realm_mail_return_then(self.actor_or_owner(recipient_guid), mail_id, same_account) - ) - } - - /// `realm_mail_send` — write one sent letter against the database THIS handle points at, and - /// charge the sender for it in the same transaction. Same trust shape as the two above, and the - /// guid it carries is the one the socket authenticated: every gate deciding who may write to - /// whom ran in `world::mail`, because realm-core can answer none of them. - /// - /// **The single-database plane only.** A sharded realm cannot have that one transaction and - /// drives the escrow below instead. - #[allow(clippy::too_many_arguments)] - pub fn mail_send( - &self, - sender_guid: u64, - recipient_guid: u64, - subject: String, - body: String, - money: u32, - cod: u32, - item_guid: u64, - same_account: bool, - ) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "realm_mail_send", - realm_mail_send_then( - self.actor_or_owner(sender_guid), - recipient_guid, - subject, - body, - money, - cod, - item_guid, - same_account - ) - ) - } - - /// `realm_mail_take_money` — credit a mail's copper to the recipient and empty the row, in one - /// transaction. The single-database plane's whole take, for the same reason. - pub fn mail_take_money(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "realm_mail_take_money", - realm_mail_take_money_then(self.actor_or_owner(recipient_guid), mail_id) - ) - } - - /// `realm_mail_take_item` — re-create a mail's attachment in the recipient's bags and empty the - /// row, in one transaction. The single-database plane's whole item take. - pub fn mail_take_item(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "realm_mail_take_item", - realm_mail_take_item_then(self.actor_or_owner(recipient_guid), mail_id) - ) - } - - /// `realm_mail_item_room` — the bag-space probe a sharded item take runs BEFORE it fences, on - /// the taker's own handle. A read dressed as a reducer, because only the module can answer it - /// without a second copy of the bag search. - pub fn mail_item_room(&self, payee_guid: u64) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "realm_mail_item_room", - realm_mail_item_room_then(self.actor_or_owner(payee_guid)) - ) - } - - /// `realm_mail_copy_text` — Letter Copy step 1, against the database THIS handle points at. - /// Same trust shape as `mail_mark_read`: operator-gated, `recipient_guid` passed explicitly. - pub fn mail_copy_text(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "realm_mail_copy_text", - realm_mail_copy_text_then(self.actor_or_owner(recipient_guid), mail_id) - ) - } - - /// `gw_mail_grant_letter` — Letter Copy step 2, on the PAYEE's own handle: grants one Plain - /// Letter carrying `item_text_id`. - pub fn mail_grant_letter(&self, payee_guid: u64, item_text_id: u32) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "gw_mail_grant_letter", - gw_mail_grant_letter_then(self.actor_or_owner(payee_guid), item_text_id) - ) - } - - /// `realm_mail_mark_letter_granted` — Letter Copy step 3, against the database THIS handle - /// points at: the durable record that the Home Shard grant landed. - pub fn mail_mark_letter_granted(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "realm_mail_mark_letter_granted", - realm_mail_mark_letter_granted_then(self.actor_or_owner(recipient_guid), mail_id) - ) - } - - /// `realm_mail_fence` — step 1 of a sharded SEND, on the SENDER's own handle: the postage plus - /// the attached coin leave the purse into an escrow row keyed by the caller-chosen `escrow_id`. - #[allow(clippy::too_many_arguments)] - pub fn mail_fence( - &self, - escrow_id: u64, - sender_guid: u64, - recipient_guid: u64, - subject: String, - body: String, - money: u32, - postage: u32, - item_guid: u64, - cod: u32, - cod_source_mail_id: u64, - same_account: bool, - ) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "realm_mail_fence", - realm_mail_fence_then( - escrow_id, - self.actor_or_owner(sender_guid), - recipient_guid, - subject, - body, - money, - postage, - item_guid, - cod, - cod_source_mail_id, - same_account - ) - ) - } - - /// `realm_mail_commit` — step 2 of a sharded send, on the REALM handle: the mail row plus a - /// receipt under the same `escrow_id`, so a replay writes one letter and not two. A Reward - /// Letter commits here on every plane, with its `reward` header. - #[allow(clippy::too_many_arguments)] - pub fn mail_commit( - &self, - escrow_id: u64, - sender_guid: u64, - recipient_guid: u64, - subject: String, - body: String, - money: u32, - item: crate::world::mail::AttachedItem, - cod: u32, - cod_source_mail_id: u64, - delivery_delay_secs: u32, - reward: Option, - ) -> Result<()> { - let (sender_kind, sender_entry, mail_template_id) = - lyracore_shared::mail::RewardHeader::columns(reward); - call_reducer!( - self.0.call_pipe().conn.reducers, - "realm_mail_commit", - realm_mail_commit_then( - escrow_id, - self.actor_or_owner(sender_guid), - recipient_guid, - subject, - body, - money, - item.entry, - item.stack_count, - item.durability, - item.enchant_id, - item.soulbound, - item.random_property_id, - cod, - cod_source_mail_id, - delivery_delay_secs, - sender_kind, - sender_entry, - mail_template_id, - item.item_text_id - ) - ) - } - - /// `realm_mail_take_money_fence` — step 1 of a sharded TAKE, on the handle that OWNS THE MAIL - /// ROW: the copper leaves the row into an escrow there. The mirror of `mail_fence`. - pub fn mail_take_money_fence( - &self, - escrow_id: u64, - payee_guid: u64, - mail_id: u64, - expect_money: u32, - ) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "realm_mail_take_money_fence", - realm_mail_take_money_fence_then( - escrow_id, - self.actor_or_owner(payee_guid), - mail_id, - expect_money - ) - ) - } - - /// `realm_mail_payout` — step 2 of a sharded take, on the TAKER's own handle: the purse plus a - /// receipt under the same `escrow_id`. `mail_commit`'s twin. - pub fn mail_payout( - &self, - escrow_id: u64, - payee_guid: u64, - mail_id: u64, - amount: u32, - ) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "realm_mail_payout", - realm_mail_payout_then(escrow_id, self.actor_or_owner(payee_guid), mail_id, amount) - ) - } - - /// `realm_mail_take_item_fence` — step 1 of a sharded ITEM take, on the handle that OWNS THE - /// MAIL ROW: the attachment leaves the row into an escrow there. - pub fn mail_take_item_fence( - &self, - escrow_id: u64, - payee_guid: u64, - mail_id: u64, - expect_entry: u32, - ) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "realm_mail_take_item_fence", - realm_mail_take_item_fence_then( - escrow_id, - self.actor_or_owner(payee_guid), - mail_id, - expect_entry - ) - ) - } - - /// `realm_mail_item_payout` — step 2 of a sharded item take, on the TAKER's own handle: the - /// item plus a receipt under the same `escrow_id`. `mail_payout`'s twin. - pub fn mail_item_payout( - &self, - escrow_id: u64, - payee_guid: u64, - mail_id: u64, - item: crate::world::mail::AttachedItem, - ) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "realm_mail_item_payout", - realm_mail_item_payout_then( - escrow_id, - self.actor_or_owner(payee_guid), - mail_id, - item.entry, - item.stack_count, - item.durability, - item.enchant_id, - item.soulbound, - item.random_property_id, - item.item_text_id - ) - ) - } - - /// `realm_mail_confirm_delivery` — step 3, on the handle that HOLDS THE FENCE. The attestation - /// that the other database committed, and the only thing that licenses the settle. - pub fn mail_confirm_delivery(&self, escrow_id: u64) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "realm_mail_confirm_delivery", - realm_mail_confirm_delivery_then(escrow_id, self.owner_actor()) - ) - } - - /// `realm_mail_settle` — step 4, on the handle that holds the fence: destroy it. Delete-last. - pub fn mail_settle(&self, escrow_id: u64) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "realm_mail_settle", - realm_mail_settle_then(escrow_id, self.owner_actor()) - ) - } - - /// `sync_group_mirror` — replace THIS shard's mirror of one party with realm-core's roster. - /// Operator-gated, coordinator connection, same reasoning as above; called - /// on each WORLD shard after a party op and at world entry. - pub fn sync_group_mirror(&self, roster: &crate::world::party::GroupRoster) -> Result<()> { - let partitions = roster - .partitions - .iter() - .map(|partition| GroupMemberPartition { - character_guid: partition.character_guid, - group_id: partition.group_id, - membership_revision: partition.membership_revision, - member_active: partition.member_active, - map_id: partition.map_id, - instance_id: partition.instance_id, - locator_revision: partition.locator_revision, - state: match partition.state { - crate::world::party::PartyPartitionState::Unknown => { - PartyPartitionState::Unknown - } - crate::world::party::PartyPartitionState::Known => PartyPartitionState::Known, - crate::world::party::PartyPartitionState::PendingTransfer => { - PartyPartitionState::PendingTransfer - } - }, - }) - .collect(); - call_reducer!( - self.0.call_pipe().conn.reducers, - "sync_group_mirror", - sync_group_mirror_then( - roster.group_id, - roster.leader_guid, - roster.loot_method, - roster.loot_threshold, - roster.master_looter_guid, - roster.member_guids(), - self.owner_actor(), - partitions, - roster.roster_revision, - roster.kind.wire(), - roster - .members - .iter() - .map(|member| member.slot.wire()) - .collect() - ) - ) - } - - /// `realm_loot_op` — one loot-roll op against the database THIS handle points at. The - /// gateway calls it on the **realm-core** handle: START promotes a world shard's staging roll, - /// VOTE casts `CMSG_LOOT_ROLL`'s vote. - /// - /// Through the COORDINATOR connection, not the player's: the reducer is operator-gated because - /// it acts on realm-core, which has no live entity to derive an actor from. VOTE's `actor_guid` - /// is the guid this socket authenticated into the world with (`InWorld::self_guid`), never a - /// literal a client supplies; START's `recipients` are the spatial snapshot a world shard already - /// computed at kill time. - #[allow(clippy::too_many_arguments)] - pub fn realm_loot_op( - &self, - op: u8, - corpse_guid: u64, - slot: u8, - item_entry: u32, - actor_guid: u64, - vote: u8, - deadline_micros: i64, - recipients: Vec, - random_property_id: u32, - promotion_source: spacetimedb_sdk::Identity, - source_roll_id: u64, + gateway_key: u64, ) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, - "realm_loot_op", - realm_loot_op_then( - op, - corpse_guid, - slot, - item_entry, - self.actor_or_owner(actor_guid), - vote, - deadline_micros, - recipients, - random_property_id, - promotion_source, - source_roll_id - ) - ) - } - - /// Cast a realm-core Loot Roll vote and classify only a known typed Refusal as an answer. - pub fn realm_loot_vote( - &self, - corpse_guid: u64, - slot: u8, - actor_guid: u64, - vote: u8, - ) -> Result { - loot_action_status(call_reducer!( - self.0.call_pipe().conn.reducers, - "realm_loot_op", - realm_loot_op_then( - lyracore_shared::loot_roll::loot_op::VOTE, - corpse_guid, - slot, - 0, - self.actor_or_owner(actor_guid), - vote, - 0, - Vec::new(), - 0, - spacetimedb_sdk::Identity::ZERO, - 0 + "record_shard_load", + record_shard_load_then( + shard.to_string(), + writer_occupancy_pct, + sessions, + gateway_key ) - )) - } - - /// `settle_loot_roll` — grant a resolved roll's item on THIS world shard, if it holds the - /// matching corpse row. Operator-gated, coordinator connection; the loot-roll relay calls - /// it on every connected world shard after observing realm-core's `ROLL_WON` event — the - /// module's own `withheld` guard makes a wrong-shard call a harmless no-op. - pub fn settle_loot_roll(&self, corpse_guid: u64, slot: u8, winner_guid: u64) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "settle_loot_roll", - settle_loot_roll_then(corpse_guid, slot, winner_guid, self.owner_actor()) - ) - } - - /// `clear_promoted_loot_roll` — delete a staging roll's rows on THIS world shard, once the - /// loot-roll relay has promoted it onto realm-core. Operator-gated, coordinator connection. - pub fn clear_promoted_loot_roll(&self, roll_id: u64) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "clear_promoted_loot_roll", - clear_promoted_loot_roll_then(roll_id, self.owner_actor()) ) } } -#[cfg(test)] -mod taxi_reply_tests { - use super::*; - - fn reply( - request_id: u64, - character_guid: u64, - npc_guid: u64, - operation: u8, - ) -> TaxiServiceReply { - TaxiServiceReply { - request_id, - character_guid, - operation, - npc_guid, - accepted: true, - known: false, - source_client_node_id: 0, - available_client_node_ids: Vec::new(), - refusal: String::new(), - created_micros: 0, - result_code: lyracore_shared::constants::taxi_protocol::ACTIVATE_OK, - } - } - - #[test] - fn reply_selection_rejects_stale_character_operation_and_npc_rows() { - let current = reply( - 22, - 7, - 90, - lyracore_shared::constants::taxi_protocol::REPLY_OPEN, - ); - assert!(taxi_reply_matches( - ¤t, - 7, - 90, - lyracore_shared::constants::taxi_protocol::REPLY_OPEN, - )); - assert!(!taxi_reply_matches( - ¤t, - 8, - 90, - lyracore_shared::constants::taxi_protocol::REPLY_OPEN, - )); - assert!(!taxi_reply_matches( - ¤t, - 7, - 91, - lyracore_shared::constants::taxi_protocol::REPLY_OPEN, - )); - assert!(!taxi_reply_matches( - ¤t, - 7, - 90, - lyracore_shared::constants::taxi_protocol::REPLY_STATUS, - )); - } -} - /// Wait up to two seconds for a row a value-flow reducer just committed to reach this handle's /// cache. -fn wait_for_cache_row( +pub(super) fn wait_for_cache_row( operation_id: u64, row_name: &str, mut read: impl FnMut() -> Option, @@ -3792,71 +311,8 @@ fn wait_for_cache_row( )) } -trait AuctionRequestFields { - fn actor_guid(&self) -> u64; - fn item_guid(&self) -> u64; - fn start_bid(&self) -> u32; - fn buyout(&self) -> u32; - fn duration_minutes(&self) -> u32; - fn house_id(&self) -> u32; -} - -impl AuctionRequestFields for AuctionHold { - fn actor_guid(&self) -> u64 { - self.seller_guid - } - fn item_guid(&self) -> u64 { - self.item_guid - } - fn start_bid(&self) -> u32 { - self.start_bid - } - fn buyout(&self) -> u32 { - self.buyout - } - fn duration_minutes(&self) -> u32 { - self.duration_minutes - } - fn house_id(&self) -> u32 { - self.house - } -} - -impl AuctionRequestFields for AuctionOperationReceipt { - fn actor_guid(&self) -> u64 { - self.actor_guid - } - fn item_guid(&self) -> u64 { - self.item_guid - } - fn start_bid(&self) -> u32 { - self.start_bid - } - fn buyout(&self) -> u32 { - self.buyout - } - fn duration_minutes(&self) -> u32 { - self.duration_minutes - } - fn house_id(&self) -> u32 { - self.house - } -} - -fn same_auction_request( - row: &impl AuctionRequestFields, - request: crate::world::CreateAuctionRequest, -) -> bool { - row.actor_guid() == request.actor_guid - && row.item_guid() == request.item_guid - && row.start_bid() == request.start_bid - && row.buyout() == request.buyout - && row.duration_minutes() == request.duration_minutes - && row.house_id() == request.house_id -} - /// A random nonzero operation id for one auction or guild fee value flow. -fn next_operation_id() -> Result { +pub(super) fn next_operation_id() -> Result { loop { let mut bytes = [0; 8]; getrandom::fill(&mut bytes) @@ -3867,1078 +323,3 @@ fn next_operation_id() -> Result { } } } - -#[derive(Clone, Copy)] -enum UntaggedLootRejection { - Fatal, - LegacyUnanswered, -} - -/// Classify a Durable Request from a core whose gameplay refusals all have loot tags. -fn strict_loot_request_status(result: Result<()>) -> Result { - loot_request_status(result, UntaggedLootRejection::Fatal) -} - -/// Preserve silent gameplay refusals from the legacy GameObject and skinning cores. Boundary -/// failures and every tagged result remain explicit, so this compatibility cannot hide them. -fn legacy_loot_request_status(result: Result<()>) -> Result { - loot_request_status(result, UntaggedLootRejection::LegacyUnanswered) -} - -fn loot_request_status( - result: Result<()>, - untagged: UntaggedLootRejection, -) -> Result { - match result { - Ok(()) => Ok(LootWindowRequestStatus::Applied), - Err(error) => match reducer_refusal_reason(&error) { - Some(reason) => { - if LootBoundaryFailure::parse_tag(reason).is_some() { - return Err(error); - } - if let Some(refusal) = LootRefusal::parse_tag(reason) { - log::debug!("stdb: loot Durable Request refused: {error:#}"); - return Ok(LootWindowRequestStatus::Refused(refusal.into())); - } - if reason.starts_with("loot:") { - return Err(error); - } - match untagged { - UntaggedLootRejection::LegacyUnanswered => { - log::debug!("stdb: legacy loot Durable Request refused: {error:#}"); - Ok(LootWindowRequestStatus::Refused( - LootWindowRefusal::Unanswered, - )) - } - UntaggedLootRejection::Fatal => Err(error), - } - } - None => Err(error), - }, - } -} - -/// The typed answer for Loot Roll and master-loot requests. An unknown rejection or transport -/// failure stays an error with an unknown durable result. -fn loot_action_status(result: Result<()>) -> Result { - match result { - Ok(()) => Ok(LootActionStatus::Applied), - Err(error) => match reducer_refusal_reason(&error).and_then(LootRefusal::parse_tag) { - Some(refusal) => { - log::debug!("stdb: loot action refused: {error:#}"); - Ok(LootActionStatus::Refused(refusal)) - } - None => Err(error), - }, - } -} - -/// The Module's typed auction Refusal. Only a reducer the Module rejected carries a tag; a timeout, -/// transport, or SDK failure stays an error with an unknown outcome. -fn auction_refusal(error: &anyhow::Error) -> Option { - reducer_refusal_reason(error).and_then(AuctionRefusal::parse_tag) -} - -/// The Module's typed trainer Refusal. Only a reducer the Module rejected carries a tag; a timeout, -/// transport, or SDK failure stays an error with an unknown outcome. -fn trainer_refusal(error: &anyhow::Error) -> Option { - reducer_refusal_reason(error).and_then(TrainerRefusal::parse_tag) -} - -/// The Module's typed item Refusal, on the same rule as the auction family: only a reducer the -/// Module rejected carries a tag, so a timeout or transport failure keeps its unknown outcome. -fn item_action(result: Result<()>) -> Result { - match result { - Ok(()) => Ok(ItemActionResult::Done), - Err(error) => match reducer_refusal_reason(&error).and_then(ItemRefusal::parse_tag) { - Some(refusal) => Ok(refusal.into()), - None => Err(error), - }, - } -} - -/// An item action needs the caller's own entity. Without one there is nothing to request, so the -/// client gets a Refusal rather than a dead session. -fn resolved_item_actor(operation: &str, actor_guid: u64) -> Option { - let actor = Actor::new(actor_guid); - if actor.is_none() { - log::warn!("stdb: {operation} has no resolved actor"); - } - actor -} - -/// The Module's typed chat Refusal. Only a reducer the Module rejected carries a tag; a timeout, -/// transport, or SDK failure stays an error with an unknown outcome. -fn chat_outcome(result: Result<()>) -> Result { - match result { - Ok(()) => Ok(ChatOutcome::Delivered), - Err(error) => match reducer_refusal_reason(&error).and_then(ChatRefusal::parse_tag) { - Some(refusal) => Ok(ChatOutcome::Refused(refusal)), - None => Err(error), - }, - } -} - -/// The Module's typed channel Refusal. Only a reducer the Module rejected carries a tag; a timeout, -/// transport, or SDK failure stays an error with an unknown outcome. -fn channel_outcome(result: Result<()>) -> Result { - match result { - Ok(()) => Ok(ChannelOutcome::Done), - Err(error) => match reducer_refusal_reason(&error).and_then(ChannelRefusal::parse_tag) { - Some(refusal) => Ok(ChannelOutcome::Refused(refusal)), - None => Err(error), - }, - } -} - -fn meeting_stone_outcome(result: Result<()>) -> Result { - match result { - Ok(()) => Ok(MeetingStoneOutcome::Ran), - Err(error) => match reducer_refusal_reason(&error).and_then(MeetingStoneRefusal::parse_tag) - { - Some(refusal) => Ok(MeetingStoneOutcome::Refused(refusal)), - None => Err(error), - }, - } -} - -/// The Module's typed party Refusal. Only a reducer the Module rejected carries a tag; a timeout, -/// transport, or SDK failure stays an error with an unknown outcome. -fn group_refusal(error: &anyhow::Error) -> Option { - reducer_refusal_reason(error).and_then(GroupRefusal::parse_tag) -} - -fn command_refusal(error: &anyhow::Error) -> Option { - let tag = reducer_refusal_reason(error)?; - Some(match tag { - "Applied" => CompanionCommandOutcome::Applied, - "Unchanged" => CompanionCommandOutcome::Unchanged, - "Malformed" => CompanionCommandOutcome::Malformed, - "NotLeader" => CompanionCommandOutcome::NotLeader, - "NotMember" => CompanionCommandOutcome::NotMember, - "StalePartyMirror" => CompanionCommandOutcome::StalePartyMirror, - "WrongAccount" => CompanionCommandOutcome::WrongAccount, - "MissingBot" => CompanionCommandOutcome::MissingBot, - "WrongPartition" => CompanionCommandOutcome::WrongPartition, - "Suppressed" => CompanionCommandOutcome::Suppressed, - "TargetDead" => CompanionCommandOutcome::TargetDead, - "TargetUnavailable" => CompanionCommandOutcome::TargetUnavailable, - "TargetControlled" => CompanionCommandOutcome::TargetControlled, - "Expired" => CompanionCommandOutcome::Expired, - "WaitingForCapacity" => CompanionCommandOutcome::WaitingForCapacity, - "OutcomeUnknown" => CompanionCommandOutcome::OutcomeUnknown, - "Superseded" => CompanionCommandOutcome::Superseded, - _ => return None, - }) -} - -fn command_outcome_binding(outcome: CompanionCommandOutcome) -> super::bindings::CommandOutcome { - use super::bindings::CommandOutcome as Row; - match outcome { - CompanionCommandOutcome::Applied => Row::Applied, - CompanionCommandOutcome::Unchanged => Row::Unchanged, - CompanionCommandOutcome::Malformed => Row::Malformed, - CompanionCommandOutcome::NotLeader => Row::NotLeader, - CompanionCommandOutcome::NotMember => Row::NotMember, - CompanionCommandOutcome::StalePartyMirror => Row::StalePartyMirror, - CompanionCommandOutcome::WrongAccount => Row::WrongAccount, - CompanionCommandOutcome::MissingBot => Row::MissingBot, - CompanionCommandOutcome::WrongPartition => Row::WrongPartition, - CompanionCommandOutcome::Suppressed => Row::Suppressed, - CompanionCommandOutcome::TargetDead => Row::TargetDead, - CompanionCommandOutcome::TargetUnavailable => Row::TargetUnavailable, - CompanionCommandOutcome::TargetControlled => Row::TargetControlled, - CompanionCommandOutcome::Expired => Row::Expired, - CompanionCommandOutcome::WaitingForCapacity => Row::WaitingForCapacity, - CompanionCommandOutcome::OutcomeUnknown => Row::OutcomeUnknown, - CompanionCommandOutcome::Superseded => Row::Superseded, - } -} - -/// A refused party reducer is an outcome the client renders; anything else ends the session. -fn party_outcome(result: Result<()>) -> Result { - match result { - Ok(()) => Ok(PartyOutcome::Ran), - Err(error) => match group_refusal(&error) { - Some(refusal) => Ok(refusal.into()), - None => Err(error), - }, - } -} - -/// [`party_outcome`] for the friends and ignore lists. -fn contact_outcome(result: Result<()>) -> Result { - match result { - Ok(()) => Ok(ContactOutcome::Done), - Err(error) => match reducer_refusal_reason(&error).and_then(ContactRefusal::parse_tag) { - Some(refusal) => Ok(refusal.into()), - None => Err(error), - }, - } -} - -fn bid_payload_matches(hold: &AuctionBidHold, decision: &AuctionBidDecision) -> bool { - hold.operation_id == decision.operation_id - && hold.operation == decision.operation - && hold.bidder_guid == decision.bidder_guid - && hold.auction_id == decision.auction_id - && hold.house == decision.house - && hold.offer == decision.offer -} - -fn bid_refund_is_recorded(hold: &AuctionBidHold, decision: &AuctionBidDecision) -> bool { - hold.deferred_refund != 0 - && bid_payload_matches(hold, decision) - && hold.deferred_refund == decision.deferred_refund -} - -/// The seller's Auction Cut for `request`'s listing, when the listing is the seller's and lists in -/// the auctioneer's market. -fn listing_cut(auction: &Auction, request: crate::world::CancelAuctionRequest) -> Option { - use lyracore_shared::auction::{auction_cut, market_of}; - if auction.owner_guid != request.actor_guid - || market_of(auction.house) != market_of(request.house_id) - { - return None; - } - auction_cut(auction.highest_bid, auction.consignment_rate) -} - -fn cancel_outcome(hold: &AuctionBidHold) -> Result { - use crate::world::CancelAuctionOutcome; - use lyracore_shared::auction::{bid_outcome, hold_operation}; - if hold.operation != hold_operation::CANCEL { - return Err(anyhow!( - "auction Hold {} is not a Cancellation", - hold.operation_id - )); - } - Ok(match hold.outcome { - bid_outcome::CANCELLED => CancelAuctionOutcome::Cancelled, - bid_outcome::ITEM_NOT_FOUND => CancelAuctionOutcome::NotFound, - bid_outcome::PENDING => { - return Err(anyhow!( - "auction Cancellation Hold {} is still pending", - hold.operation_id - )); - } - _ => CancelAuctionOutcome::Stale, - }) -} - -fn bid_outcome(hold: &AuctionBidHold) -> Result { - use crate::world::PlaceBidOutcome; - use lyracore_shared::auction::bid_outcome; - Ok(match hold.outcome { - bid_outcome::ACCEPTED => PlaceBidOutcome::Accepted { - minimum_increment: lyracore_shared::auction::bid_increment( - if hold.accepted_price == 0 { - hold.offer - } else { - hold.accepted_price - }, - ), - }, - bid_outcome::ITEM_NOT_FOUND => PlaceBidOutcome::ItemNotFound, - bid_outcome::HIGHER_BID => PlaceBidOutcome::HigherBid { - bidder_guid: hold.result_bidder_guid, - current_bid: hold.result_bid, - minimum_increment: hold.minimum_increment, - }, - bid_outcome::BID_INCREMENT => PlaceBidOutcome::BidIncrement, - bid_outcome::BID_OWN => PlaceBidOutcome::BidOwn, - bid_outcome::DATABASE => PlaceBidOutcome::Database, - outcome => { - return Err(anyhow!( - "auction bid Hold has non-terminal outcome {outcome}" - )); - } - }) -} - -impl Coordinator { - /// `realm_guild_op`: one guild op against the database THIS handle points at. Callers hold the - /// Realm-core handle. The actor is the guid this World Session entered the world with. - pub fn realm_guild_op( - &self, - actor_guid: u64, - request: crate::world::GuildRequest, - ) -> Result { - use crate::world::GuildRequest; - let op = match request { - GuildRequest::GmCreate { - leader_guid, - leader_name, - leader_team, - leader_realm_account, - gm_level, - name, - } => GuildOp::GmCreate(GuildGmCreate { - leader_guid, - leader_name, - leader_team, - leader_realm_account, - gm_level, - name, - }), - GuildRequest::SignOn { actor_name } => GuildOp::SignOn(actor_name), - GuildRequest::SignOff => GuildOp::SignOff, - GuildRequest::Invite { - target_guid, - actor_team, - target_team, - target_ignores_actor, - } => GuildOp::Invite(GuildInviteRequest { - target_guid, - actor_team, - target_team, - target_ignores_actor, - }), - GuildRequest::Accept { - actor_name, - actor_team, - } => GuildOp::Accept(GuildAcceptRequest { - actor_name, - actor_team, - }), - GuildRequest::Decline { actor_name } => GuildOp::Decline(actor_name), - GuildRequest::Leave => GuildOp::Leave, - GuildRequest::Remove { target_guid } => GuildOp::Remove(target_guid), - GuildRequest::Promote { target_guid } => GuildOp::Promote(target_guid), - GuildRequest::Demote { target_guid } => GuildOp::Demote(target_guid), - GuildRequest::SetLeader { target_guid } => GuildOp::SetLeader(target_guid), - GuildRequest::Disband => GuildOp::Disband, - GuildRequest::SetMotd { text } => GuildOp::SetMotd(text), - GuildRequest::SetInfo { text } => GuildOp::SetInfo(text), - GuildRequest::SetPublicNote { target_guid, text } => { - GuildOp::SetPublicNote(GuildNoteEdit { target_guid, text }) - } - GuildRequest::SetOfficerNote { target_guid, text } => { - GuildOp::SetOfficerNote(GuildNoteEdit { target_guid, text }) - } - GuildRequest::EditRank { - rank_id, - rights, - name, - } => GuildOp::EditRank(GuildRankEdit { - rank_id, - rights, - name, - }), - GuildRequest::AddRank { name } => GuildOp::AddRank(name), - GuildRequest::DeleteRank => GuildOp::DeleteRank, - GuildRequest::SignPetition { - charter_item_guid, - actor_name, - actor_team, - } => GuildOp::SignPetition(GuildPetitionSign { - charter_item_guid, - actor_name, - actor_team, - }), - GuildRequest::OfferPetition { - charter_item_guid, - target_guid, - target_team, - } => GuildOp::OfferPetition(GuildPetitionOffer { - charter_item_guid, - target_guid, - target_team, - }), - GuildRequest::DeclinePetition { charter_item_guid } => { - GuildOp::DeclinePetition(charter_item_guid) - } - GuildRequest::RenamePetition { - charter_item_guid, - name, - } => GuildOp::RenamePetition(GuildPetitionRename { - charter_item_guid, - name, - }), - GuildRequest::TurnInPetition { charter_item_guid } => { - GuildOp::TurnInPetition(charter_item_guid) - } - GuildRequest::ClosePetition { petition_id } => GuildOp::ClosePetition(petition_id), - GuildRequest::ForgetDeletedCharacter => GuildOp::ForgetDeletedCharacter, - }; - let result = call_reducer!( - self.0.call_pipe().conn.reducers, - "realm_guild_op", - realm_guild_op_then(self.actor_or_owner(actor_guid), op) - ); - match result { - Ok(()) => Ok(crate::world::GuildOutcome::Ran), - Err(error) => match reducer_refusal_reason(&error).and_then(GuildRefusal::parse_tag) { - Some(refusal) => Ok(crate::world::GuildOutcome::Refused(refusal)), - None => Err(error), - }, - } - } -} - -impl Coordinator { - /// `gw_guild_fee_hold` on THIS handle, the actor's Home Shard, under a fresh operation id, then - /// the Hold once this handle's cache shows it: the hold mints a Guild Charter's guid. A tagged - /// Refusal answers `Ok(Err(_))`. The call is made once per operation id and never retried. - pub(crate) fn hold_guild_fee( - &self, - actor_guid: u64, - request: guild_fee::FeeRequest, - ) -> Result> { - let operation_id = next_operation_id()?; - let request = match request { - guild_fee::FeeRequest::Emblem { npc_guid, emblem } => { - GuildFeeRequest::Emblem(GuildEmblemPurchase { - npc_guid, - emblem: guild_emblem(emblem), - }) - } - guild_fee::FeeRequest::Charter { npc_guid, name } => { - GuildFeeRequest::Charter(GuildCharterPurchase { npc_guid, name }) - } - }; - let result = call_reducer!( - self.0.call_pipe().conn.reducers, - "gw_guild_fee_hold", - gw_guild_fee_hold_then(operation_id, self.actor_or_owner(actor_guid), request) - ); - match result { - Ok(()) => wait_for_cache_row(operation_id, "guild Fee Hold", || { - self.guild_fee_hold_row(actor_guid) - .filter(|hold| hold.operation_id == operation_id) - }) - .map(Ok), - Err(error) => match reducer_refusal_reason(&error).and_then(GuildRefusal::parse_tag) { - Some(refusal) => Ok(Err(refusal)), - None => Err(error), - }, - } - } - - /// `realm_guild_fee_decide` on THIS handle, Realm-core, then the decision once this handle's - /// cache shows it. The reducer is idempotent, so a re-drive calls it again. A Charter needs - /// `owner`, the facts Realm-core cannot read. - pub(crate) fn decide_guild_fee( - &self, - actor_guid: u64, - hold: guild_fee::FeeHold, - owner: Option, - ) -> Result { - let terms = match (hold.terms, owner) { - (guild_fee::FeeTerms::Emblem(emblem), _) => GuildFeeTerms::Emblem(guild_emblem(emblem)), - ( - guild_fee::FeeTerms::Charter { - charter_item_guid, - name, - }, - Some(owner), - ) => GuildFeeTerms::Charter(GuildCharterTerms { - charter_item_guid, - name, - payer_name: owner.name, - payer_team: owner.team, - }), - (guild_fee::FeeTerms::Charter { .. }, None) => { - return Err(anyhow!( - "Guild Charter decision {} has no owner facts", - hold.operation_id - )); - } - }; - call_reducer!( - self.0.call_pipe().conn.reducers, - "realm_guild_fee_decide", - realm_guild_fee_decide_then(hold.operation_id, self.actor_or_owner(actor_guid), terms) - )?; - let decision = wait_for_cache_row(hold.operation_id, "guild fee decision", || { - self.guild_fee_decision_row(hold.operation_id) - })?; - fee_outcome(&decision) - } - - /// `gw_destroy_guild_charter` on THIS handle, the actor's Home Shard. A Charter already gone - /// is Ok. - pub(crate) fn destroy_guild_charter( - &self, - actor_guid: u64, - charter_item_guid: u64, - ) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "gw_destroy_guild_charter", - gw_destroy_guild_charter_then(self.actor_or_owner(actor_guid), charter_item_guid) - ) - } - - /// `gw_guild_fee_finish` on THIS handle, the actor's Home Shard. - pub(crate) fn finish_guild_fee( - &self, - actor_guid: u64, - operation_id: u64, - accepted: bool, - ) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "gw_guild_fee_finish", - gw_guild_fee_finish_then(operation_id, self.actor_or_owner(actor_guid), accepted) - ) - } -} - -fn guild_emblem(emblem: guild_fee::Emblem) -> GuildEmblem { - GuildEmblem { - emblem_style: emblem.emblem_style, - emblem_color: emblem.emblem_color, - border_style: emblem.border_style, - border_color: emblem.border_color, - background_color: emblem.background_color, - } -} - -/// Realm-core's decision row as a fee outcome. A refusal tag this Gateway does not know is an -/// error, so the Hold stays for a Gateway that does. -fn fee_outcome(decision: &GuildFeeDecision) -> Result { - if decision.accepted { - return Ok(guild_fee::FeeOutcome::Accepted); - } - GuildRefusal::parse_tag(&decision.refusal) - .map(guild_fee::FeeOutcome::Refused) - .ok_or_else(|| { - anyhow!( - "guild fee decision {} carries an unknown refusal {:?}", - decision.operation_id, - decision.refusal - ) - }) -} - -#[cfg(test)] -mod guild_fee_reducer_tests { - use super::*; - - fn decision(accepted: bool, refusal: &str) -> GuildFeeDecision { - GuildFeeDecision { - operation_id: 9, - payer_guid: 5_090_401, - kind: lyracore_shared::guild::fee_kind::EMBLEM, - accepted, - refusal: refusal.into(), - petition_id: 0, - decided_micros: 0, - } - } - - #[test] - fn a_decision_row_maps_to_its_fee_outcome() { - assert_eq!( - fee_outcome(&decision(true, "")).unwrap(), - guild_fee::FeeOutcome::Accepted - ); - assert_eq!( - fee_outcome(&decision(false, "guild:not_leader")).unwrap(), - guild_fee::FeeOutcome::Refused(GuildRefusal::NotLeader) - ); - assert!( - fee_outcome(&decision(false, "guild:from_a_newer_module")).is_err(), - "an unknown refusal must not read as a spend or a refund" - ); - } -} - -#[cfg(test)] -mod item_reducer_tests { - use super::*; - use crate::stdb::connection::ReducerCallError; - - #[test] - fn only_a_rejected_reducer_carries_a_typed_refusal() { - for refusal in ItemRefusal::ALL { - let rejected = Err(anyhow::Error::from(ReducerCallError::Rejected { - operation: "gw_move_item".to_string(), - reason: refusal.as_tag().to_string(), - }) - .context("move phase")); - assert_eq!( - item_action(rejected).unwrap(), - ItemActionResult::Refused(refusal) - ); - } - - assert_eq!(item_action(Ok(())).unwrap(), ItemActionResult::Done); - - let not_refusals = [ - anyhow::Error::from(ReducerCallError::fatal( - "gw_move_item reducer timed out after 10s".to_string(), - )), - anyhow::Error::from(ReducerCallError::fatal( - "gw_use_item reducer failed: transport disconnected".to_string(), - )), - anyhow::Error::from(ReducerCallError::Rejected { - operation: "gw_equip_item".to_string(), - reason: "operator only".to_string(), - }), - anyhow!( - "wrapped text that mentions {}", - ItemRefusal::Internal.as_tag() - ), - ]; - for error in not_refusals { - let text = format!("{error:#}"); - assert!(item_action(Err(error)).is_err(), "{text}"); - } - } -} - -#[cfg(test)] -mod loot_reducer_tests { - use super::*; - use crate::stdb::connection::ReducerCallError; - - fn refusal_of( - result: Result<()>, - classify: fn(Result<()>) -> Result, - ) -> LootWindowRefusal { - match classify(result) { - Ok(LootWindowRequestStatus::Refused(refusal)) => refusal, - Ok(LootWindowRequestStatus::Applied) => panic!("a Refusal was applied"), - Err(error) => panic!("a Refusal ended the session: {error:#}"), - } - } - - fn rejected(reason: &str) -> Result<()> { - Err(anyhow::Error::from(ReducerCallError::Rejected { - operation: "gw_take_loot".to_string(), - reason: reason.to_string(), - }) - .context("loot window")) - } - - #[test] - fn every_module_refusal_tag_becomes_one_client_answer() { - for refusal in LootRefusal::ALL { - for classify in [ - strict_loot_request_status as fn(Result<()>) -> Result, - legacy_loot_request_status, - ] { - assert_eq!( - refusal_of(rejected(refusal.as_tag()), classify), - LootWindowRefusal::from(refusal), - "{refusal:?}" - ); - } - } - } - - #[test] - fn only_legacy_cores_keep_untagged_gameplay_refusals_unanswered() { - for reason in ["it is locked", "not a beast", "inventory is full"] { - assert_eq!( - refusal_of(rejected(reason), legacy_loot_request_status), - LootWindowRefusal::Unanswered, - "{reason}" - ); - assert!( - strict_loot_request_status(rejected(reason)).is_err(), - "{reason}" - ); - } - } - - #[test] - fn boundary_failures_and_unknown_loot_tags_are_fatal() { - let reasons = LootBoundaryFailure::ALL - .into_iter() - .map(LootBoundaryFailure::as_tag) - .chain(["loot:newer_module_refusal"]); - - for reason in reasons { - assert!( - strict_loot_request_status(rejected(reason)).is_err(), - "{reason}" - ); - assert!( - legacy_loot_request_status(rejected(reason)).is_err(), - "{reason}" - ); - } - } - - #[test] - fn a_timeout_is_not_answered_as_a_refusal() { - let not_refusals = [ - anyhow::Error::from(ReducerCallError::fatal( - "gw_take_loot reducer timed out after 10s".to_string(), - )), - anyhow::Error::from(ReducerCallError::fatal( - "gw_loot_money reducer failed: transport disconnected".to_string(), - )), - anyhow!( - "wrapped text that mentions {}", - LootRefusal::LootTagIneligible.as_tag() - ), - ]; - for error in not_refusals { - let text = format!("{error:#}"); - assert!(strict_loot_request_status(Err(error)).is_err(), "{text}"); - } - } - - #[test] - fn loot_action_status_keeps_unknown_results_as_errors() { - for refusal in [ - LootRefusal::RollUnavailable, - LootRefusal::NotMasterLooter, - LootRefusal::RecipientUnavailable, - LootRefusal::RecipientInventoryFull, - ] { - assert_eq!( - loot_action_status(rejected(refusal.as_tag())).unwrap(), - LootActionStatus::Refused(refusal) - ); - } - - for error in [ - anyhow::Error::from(ReducerCallError::Rejected { - operation: "gw_loot_roll".to_string(), - reason: "loot:newer_module_refusal".to_string(), - }), - anyhow::Error::from(ReducerCallError::fatal( - "gw_loot_roll reducer timed out after 10s".to_string(), - )), - ] { - assert!(loot_action_status(Err(error)).is_err()); - } - } -} - -#[cfg(test)] -mod auction_reducer_tests { - use super::*; - use crate::stdb::connection::ReducerCallError; - - #[test] - #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] - fn a_local_bid_interaction_refusal_stays_a_client_outcome() { - use crate::accept::BlockingTaskCapacity; - use crate::config::GatewayConfig; - use crate::durable_test_support::Standalone; - use crate::world::{PlaceBidOutcome, PlaceBidRequest}; - - for variable in [ - "LYRACORE_SHARD_MAP", - "LYRACORE_SHARD_MAP_FILE", - "LYRACORE_REALM_CORE", - ] { - assert!( - std::env::var_os(variable).is_none(), - "unset {variable} for this private test" - ); - } - let mut standalone = Standalone::start("auction-refusal"); - standalone.publish_module(); - standalone.assert_call("claim_operator", &[]); - standalone.assert_call("install_guid_range", &["0"]); - let cfg = GatewayConfig { - logon_bind: "127.0.0.1:0".into(), - world_bind: "127.0.0.1:0".into(), - stdb_uri: standalone.server().into(), - module_name: standalone.shard_name().into(), - coordinator_token: Some(standalone.owner_token()), - gateway_id: "auction-refusal-test".into(), - blocking_task_capacity: BlockingTaskCapacity::new(1), - }; - let runtime = tokio::runtime::Runtime::new().unwrap(); - let coordinator = runtime.block_on(Coordinator::connect(&cfg)).unwrap(); - assert!(!coordinator.is_sharded()); - let outcome = coordinator - .place_bid(PlaceBidRequest { - actor_guid: 5_090_099, - auctioneer_guid: 5_090_098, - auction_id: 5_090_097, - offer: 100, - house_id: 1, - }) - .expect("a Module Refusal must not end the World Session"); - assert_eq!(outcome, PlaceBidOutcome::Database); - assert!(standalone - .query_rows("SELECT * FROM game_auction_bid_hold") - .is_empty()); - } - - #[test] - fn only_a_rejected_reducer_carries_a_typed_refusal() { - for refusal in AuctionRefusal::ALL { - let error = anyhow::Error::from(ReducerCallError::Rejected { - operation: "gw_auction_hold_listing".to_string(), - reason: refusal.as_tag().to_string(), - }) - .context("preparing auction listing"); - assert_eq!(auction_refusal(&error), Some(refusal)); - } - - let not_refusals = [ - anyhow::Error::from(ReducerCallError::fatal( - "gw_auction_hold_listing reducer timed out after 10s".to_string(), - )), - anyhow::Error::from(ReducerCallError::fatal( - "gw_auction_hold_bid reducer failed: transport disconnected".to_string(), - )), - anyhow::Error::from(ReducerCallError::Rejected { - operation: "gw_auction_hold_bid".to_string(), - reason: "operator only".to_string(), - }), - anyhow!( - "wrapped text that mentions {}", - AuctionRefusal::Database.as_tag() - ), - ]; - for error in not_refusals { - assert_eq!(auction_refusal(&error), None, "{error:#}"); - } - } - - #[test] - fn deferred_refund_receipt_requires_the_full_hold_payload() { - let hold = AuctionBidHold { - operation_id: 7, - bidder_guid: 8, - auction_id: 9, - house: 4, - offer: 10, - outcome: lyracore_shared::auction::bid_outcome::BID_INCREMENT, - revision: 0, - result_bidder_guid: 0, - result_bid: 0, - minimum_increment: 0, - accepted_price: 0, - deferred_refund: 3, - operation: lyracore_shared::auction::hold_operation::BID, - }; - let decision = AuctionBidDecision { - operation_id: hold.operation_id, - bidder_guid: hold.bidder_guid, - auction_id: hold.auction_id, - house: hold.house, - offer: hold.offer, - outcome: hold.outcome, - revision: hold.revision, - result_bidder_guid: hold.result_bidder_guid, - result_bid: hold.result_bid, - minimum_increment: hold.minimum_increment, - accepted_price: hold.accepted_price, - deferred_refund: hold.deferred_refund, - item_entry: 0, - random_property_id: 0, - operation: hold.operation, - }; - - assert!(bid_refund_is_recorded(&hold, &decision)); - assert!(!bid_refund_is_recorded( - &hold, - &AuctionBidDecision { - offer: 11, - ..decision.clone() - } - )); - assert!(!bid_refund_is_recorded( - &hold, - &AuctionBidDecision { - deferred_refund: 2, - ..decision - } - )); - - assert!(super::super::auction_holds::hold_is_unfinished(&hold)); - assert!(!super::super::auction_holds::hold_is_unfinished( - &AuctionBidHold { - deferred_refund: 0, - ..hold - } - )); - } - - #[test] - fn refused_listing_binding_matches_the_generated_commit_listing_shape() { - let expected = include_str!("bindings/realm_auction_commit_listing_reducer.rs") - .replace("RealmAuctionCommitListing", "RealmAuctionRefundListing") - .replace( - "realm_auction_commit_listing", - "realm_auction_refund_listing", - ); - assert_eq!( - include_str!("bindings/realm_auction_refund_listing_reducer.rs"), - expected, - "the hand-added reducer binding must remain generator-identical" - ); - } - - fn cancel_request() -> crate::world::CancelAuctionRequest { - crate::world::CancelAuctionRequest { - actor_guid: 7, - auctioneer_guid: 42, - auction_id: 41, - house_id: 2, - } - } - - fn listing(owner_guid: u64, house: u32, highest_bid: u32) -> Auction { - Auction { - id: 41, - listing_operation_id: 1, - house, - owner_guid, - item_guid: 70, - item_entry: 25, - item_stack_count: 1, - item_durability: 0, - item_enchant_id: 0, - item_soulbound: false, - start_bid: 100, - buyout: 0, - highest_bidder_guid: u64::from(highest_bid != 0) * 9, - highest_bid, - deposit: 10, - created_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, - expires_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, - revision: 1, - deposit_rate: 5, - consignment_rate: 5, - random_property_id: 0, - item_text_id: 0, - } - } - - /// 5% of a bid of 201 is 10.05, truncated to 10 (`cm:AuctionHouseMgr.cpp:733-736`). - #[test] - fn the_gateway_fences_the_cut_of_the_sellers_own_listing_in_its_market() { - assert_eq!(listing_cut(&listing(7, 1, 201), cancel_request()), Some(10)); - assert_eq!(listing_cut(&listing(7, 3, 0), cancel_request()), Some(0)); - assert_eq!( - listing_cut(&listing(8, 1, 201), cancel_request()), - None, - "another player's listing" - ); - assert_eq!( - listing_cut(&listing(7, 4, 201), cancel_request()), - None, - "the Horde market" - ); - } - - #[test] - fn a_terminal_cancellation_hold_maps_to_one_client_outcome() { - use crate::world::CancelAuctionOutcome; - use lyracore_shared::auction::{bid_outcome, hold_operation}; - let hold = |operation, outcome| AuctionBidHold { - operation_id: 7, - bidder_guid: 8, - auction_id: 41, - house: 1, - offer: 10, - outcome, - revision: 0, - result_bidder_guid: 0, - result_bid: 0, - minimum_increment: 0, - accepted_price: 10, - deferred_refund: 0, - operation, - }; - for (outcome, expected) in [ - (bid_outcome::CANCELLED, CancelAuctionOutcome::Cancelled), - (bid_outcome::ITEM_NOT_FOUND, CancelAuctionOutcome::NotFound), - (bid_outcome::DATABASE, CancelAuctionOutcome::Stale), - ] { - assert_eq!( - cancel_outcome(&hold(hold_operation::CANCEL, outcome)).unwrap(), - expected - ); - } - assert!(cancel_outcome(&hold(hold_operation::CANCEL, bid_outcome::PENDING)).is_err()); - assert!( - cancel_outcome(&hold(hold_operation::BID, bid_outcome::CANCELLED)).is_err(), - "a bid Hold never answers a Cancellation" - ); - assert!(!bid_payload_matches( - &hold(hold_operation::CANCEL, bid_outcome::PENDING), - &AuctionBidDecision { - operation_id: 7, - bidder_guid: 8, - auction_id: 41, - offer: 10, - outcome: bid_outcome::CANCELLED, - revision: 0, - result_bidder_guid: 0, - result_bid: 0, - minimum_increment: 0, - deferred_refund: 0, - accepted_price: 10, - house: 1, - item_entry: 25, - random_property_id: 0, - operation: hold_operation::BID, - } - )); - } - - #[test] - fn normalized_buyout_price_drives_the_exact_success_command_value() { - let hold = AuctionBidHold { - operation_id: 7, - bidder_guid: 8, - auction_id: 9, - house: 4, - offer: 900, - outcome: lyracore_shared::auction::bid_outcome::ACCEPTED, - revision: 0, - result_bidder_guid: 0, - result_bid: 0, - minimum_increment: 0, - accepted_price: 500, - deferred_refund: 0, - operation: lyracore_shared::auction::hold_operation::BID, - }; - - assert_eq!( - bid_outcome(&hold).unwrap(), - crate::world::PlaceBidOutcome::Accepted { - minimum_increment: 25, - } - ); - } -} - -#[cfg(test)] -mod trainer_reducer_tests { - use super::*; - use crate::stdb::connection::ReducerCallError; - - #[test] - fn only_a_rejected_reducer_carries_a_typed_refusal() { - for refusal in TrainerRefusal::ALL { - let error = anyhow::Error::from(ReducerCallError::Rejected { - operation: "gw_trainer_buy".to_string(), - reason: refusal.as_tag().to_string(), - }) - .context("trainer buy"); - assert_eq!(trainer_refusal(&error), Some(refusal)); - } - - let not_refusals = [ - anyhow::Error::from(ReducerCallError::fatal( - "gw_trainer_buy reducer timed out after 10s".to_string(), - )), - anyhow::Error::from(ReducerCallError::Rejected { - operation: "gw_trainer_buy".to_string(), - reason: "operator only".to_string(), - }), - anyhow!( - "wrapped text that mentions {}", - TrainerRefusal::AlreadyKnown.as_tag() - ), - ]; - for error in not_refusals { - assert_eq!(trainer_refusal(&error), None, "{error:#}"); - } - } -} diff --git a/gateway/src/stdb/store/auction.rs b/gateway/src/stdb/store/auction.rs new file mode 100644 index 000000000..952a2ed39 --- /dev/null +++ b/gateway/src/stdb/store/auction.rs @@ -0,0 +1,1709 @@ +//! `Coordinator`'s [`AuctionActionStore`] adapter. + +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{anyhow, Result}; +use lyracore_shared::auction::AuctionRefusal; +use lyracore_shared::item::Proficiency; +use spacetimedb_sdk::Table; + +use crate::stdb::bindings::*; +use crate::stdb::connection::{call_reducer, reducer_refusal_reason}; +use crate::stdb::reducers::{next_operation_id, wait_for_cache_row}; +use crate::stdb::Coordinator; +use crate::world::{ + AuctionActionStore, AuctionBrowseRequest, AuctionHousePolicy, AuctionInteraction, AuctionPage, + AuctionQuery, CancelAuctionOutcome, CancelAuctionRequest, CreateAuctionOutcome, + CreateAuctionRequest, PlaceBidOutcome, PlaceBidRequest, +}; + +impl AuctionActionStore for crate::stdb::Coordinator { + fn auction_interaction( + &self, + player_guid: u64, + auctioneer_guid: u64, + ) -> Result> { + use crate::stdb::bindings::{ + GameAuctionHouseTableAccess, GameFactionTemplateTableAccess, GameWorldEntityTableAccess, + }; + let house = { + let guard = self.0.coord(); + let db = &guard.conn.db; + let Some(auctioneer) = db.game_world_entity().guid().find(&auctioneer_guid) else { + return Ok(None); + }; + let Some(faction_group) = db + .game_faction_template() + .id() + .find(&auctioneer.faction_template) + .map(|template| template.faction_group) + else { + return Ok(None); + }; + let house_id = lyracore_shared::auction::house_for_faction_template( + auctioneer.faction_template, + faction_group, + ); + let Some(house) = + db.game_auction_house() + .id() + .find(&house_id) + .map(|house| AuctionHousePolicy { + id: house.id, + deposit_rate: house.deposit_rate, + consignment_rate: house.consignment_rate, + }) + else { + return Ok(None); + }; + house + }; + let refuses_interaction = self.npc_refuses_interaction(auctioneer_guid, player_guid)?; + Ok(Some(AuctionInteraction { + house, + refuses_interaction, + })) + } + + fn create_auction(&self, request: CreateAuctionRequest) -> Result { + crate::stdb::Coordinator::create_auction(self, request) + } + + fn place_bid(&self, request: PlaceBidRequest) -> Result { + crate::stdb::Coordinator::place_bid(self, request) + } + + fn cancel_auction(&self, request: CancelAuctionRequest) -> Result { + crate::stdb::Coordinator::cancel_auction(self, request) + } + + fn resume_auction_holds(&self, actor_guid: u64) -> Result<()> { + crate::stdb::Coordinator::resume_auction_holds(self, actor_guid) + } + + fn auction_query( + &self, + player_guid: u64, + house_id: u32, + query: AuctionQuery, + ) -> Result { + crate::stdb::Coordinator::auction_query(self, player_guid, house_id, query) + } +} + +impl Coordinator { + /// The keys `pick` names for `character_guid` in THIS handle's auction index. + pub(super) fn auction_keys( + &self, + pick: impl Fn( + &crate::stdb::auction_holds::AuctionIndex, + ) -> &crate::stdb::auction_holds::KeysByCharacter, + character_guid: u64, + ) -> Vec { + let guard = self.0.coord(); + let index = guard + .auctions + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + pick(&index).keys_of(character_guid) + } + + /// The rows `pick` names for `character_guid` in THIS handle's auction index, read back by + /// primary key through `find`. A row whose `owner` is no longer `character_guid` is dropped, + /// because the index can trail the cache. + pub(super) fn indexed_rows( + &self, + pick: impl Fn( + &crate::stdb::auction_holds::AuctionIndex, + ) -> &crate::stdb::auction_holds::KeysByCharacter, + character_guid: u64, + find: impl Fn(&RemoteTables, u64) -> Option, + owner: impl Fn(&T) -> u64, + ) -> Vec { + let keys = self.auction_keys(pick, character_guid); + let guard = self.0.coord(); + keys.into_iter() + .filter_map(|key| find(&guard.conn.db, key)) + .filter(|row| owner(row) == character_guid) + .collect() + } + + /// `actor_guid`'s unfinished Holds on THIS handle. + pub(super) fn unfinished_auction_holds( + &self, + actor_guid: u64, + ) -> impl Iterator { + self.indexed_rows( + |index| &index.unfinished_bid_holds, + actor_guid, + |db, id| db.game_auction_bid_hold().operation_id().find(&id), + |hold| hold.bidder_guid, + ) + .into_iter() + .filter(crate::stdb::auction_holds::hold_is_unfinished) + } + + /// `seller_guid`'s listing Holds on THIS handle. + pub(super) fn listing_holds(&self, seller_guid: u64) -> Vec { + self.indexed_rows( + |index| &index.listing_holds, + seller_guid, + |db, id| db.game_auction_hold().operation_id().find(&id), + |hold| hold.seller_guid, + ) + } + pub(crate) fn auction_query( + &self, + player_guid: u64, + house_id: u32, + query: AuctionQuery, + ) -> Result { + let (player_level, proficiency) = { + let guard = self.0.coord(); + let db = &guard.conn.db; + let player = db + .game_world_entity() + .guid() + .find(&player_guid) + .ok_or_else(|| anyhow!("auction query actor {player_guid} is not in world"))?; + // The "usable" checkbox asks what THIS Character can wear, so the armor half reads its + // trained passives, not the class ceiling — a level-1 Warrior must not see plate as + // usable. One spellbook scan per query, not per row. + let learned: Vec = db + .game_player_spell() + .iter() + .filter(|s| s.character_guid == player_guid) + .map(|s| s.spell_id) + .collect(); + ( + u8::try_from(player.level).unwrap_or(u8::MAX), + crate::codec::armor_proficiency( + ((player.unit_bytes_0 >> 8) & 0xff) as u8, + &learned, + ), + ) + }; + let now_micros = now_micros(); + let market = self.realm_core()?; + let guard = market.0.coord(); + let db = &guard.conn.db; + let offset = match &query { + AuctionQuery::Browse(request) => request.offset, + AuctionQuery::Owner { offset } => *offset, + AuctionQuery::Bidder { offset, .. } => *offset, + }; + let (rows, total) = select_active_page( + db.game_auction().iter(), + lyracore_shared::auction::market_of(house_id), + now_micros, + offset, + |row| match &query { + AuctionQuery::Browse(request) => db + .game_item_template() + .entry() + .find(&row.item_entry) + .is_some_and(|item| { + browse_matches( + request, + player_level, + proficiency, + BrowseFacts { + name: &item.name, + required_level: item.required_level, + inventory_type: item.inventory_type, + item_class: item.class, + item_subclass: item.subclass, + quality: item.quality, + }, + ) + }), + AuctionQuery::Owner { .. } => row.owner_guid == player_guid, + AuctionQuery::Bidder { + outbid_auction_ids, .. + } => bidder_matches(row, player_guid, outbid_auction_ids), + }, + ); + + Ok(AuctionPage { + rows: rows.into_iter().map(auction_view).collect(), + total, + now_micros, + }) + } + + /// Create one auction listing. Single-database deployments use one atomic reducer; sharded + /// deployments drive the durable Hold -> Auction -> receipt -> settle protocol. + pub(crate) fn create_auction( + &self, + request: crate::world::CreateAuctionRequest, + ) -> Result { + use crate::world::CreateAuctionOutcome; + + let item_is_present = self + .0 + .coord() + .conn + .db + .game_item_instance() + .guid() + .find(&request.item_guid) + .is_some(); + // A successful listing removes the source item in the same transaction that records its + // Hold or receipt. Presence therefore proves this is a new request even when a returned + // item later reuses the same guid and terms. + if !item_is_present { + if let Some(receipt) = self.matching_active_auction_receipt(request)? { + if self + .0 + .coord() + .conn + .db + .game_auction_hold() + .operation_id() + .find(&receipt.operation_id) + .is_some() + { + self.auction_settle_listing(receipt.operation_id)?; + } + return Ok(CreateAuctionOutcome::Created { + auction_id: receipt.auction_id, + }); + } + } + + let operation_id = if item_is_present { + next_operation_id()? + } else { + match self.matching_auction_hold(request) { + Some(hold) => hold.operation_id, + None => next_operation_id()?, + } + }; + + let result = if self.is_sharded() { + self.drive_sharded_auction_listing(operation_id, request) + } else { + self.auction_list_local(operation_id, request) + .and_then(|()| self.wait_for_auction_receipt(operation_id)) + .map(|receipt| receipt.auction_id) + }; + + match result { + Ok(auction_id) => Ok(CreateAuctionOutcome::Created { auction_id }), + Err(error) => match auction_refusal(&error) { + Some(refusal) => Ok(refusal.into()), + None => Err(error), + }, + } + } + + fn drive_sharded_auction_listing( + &self, + operation_id: u64, + request: crate::world::CreateAuctionRequest, + ) -> Result { + self.auction_hold_listing(operation_id, request)?; + let hold = self.wait_for_auction_hold(operation_id)?; + self.complete_listing_hold(&hold) + } + + /// Realm-core's Auction, this Home Shard's receipt and the settle of one listing Hold, or its + /// refund when Realm-core refuses the listing, from the phase a stopped Gateway left it in. + /// Every phase is idempotent, so a replay changes nothing. + fn complete_listing_hold(&self, hold: &AuctionHold) -> Result { + let operation_id = hold.operation_id; + let realm = self.realm_core()?; + if let Err(error) = realm.auction_commit_listing(hold) { + // Only a Refusal proves realm-core took nothing. A timeout or transport failure + // leaves the Hold for the next replay, which commits idempotently. + if reducer_refusal_reason(&error).is_some() + && !realm.auction_receipt_is_visible(operation_id) + { + // Mail is authoritative on Realm-core. Commit its idempotent refund receipt and + // the exact returned value there before deleting the source Hold. If either call + // is interrupted, the Hold remains recovery evidence and the next replay resumes + // from the same operation id. + realm.auction_refund_listing(hold)?; + self.auction_release_listing_hold(hold)?; + } + return Err(error); + } + let receipt = realm.wait_for_auction_receipt(operation_id)?; + self.auction_confirm_listing(operation_id, receipt.auction_id)?; + self.wait_for_auction_receipt(operation_id)?; + self.auction_settle_listing(operation_id)?; + Ok(receipt.auction_id) + } + + fn auction_list_local( + &self, + operation_id: u64, + request: crate::world::CreateAuctionRequest, + ) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "gw_auction_list_local", + gw_auction_list_local_then( + operation_id, + self.actor_or_owner(request.actor_guid), + request.item_guid, + request.auctioneer_guid, + request.house_id, + request.start_bid, + request.buyout, + request.duration_minutes + ) + ) + } + + fn auction_hold_listing( + &self, + operation_id: u64, + request: crate::world::CreateAuctionRequest, + ) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "gw_auction_hold_listing", + gw_auction_hold_listing_then( + operation_id, + self.actor_or_owner(request.actor_guid), + request.item_guid, + request.auctioneer_guid, + request.house_id, + request.start_bid, + request.buyout, + request.duration_minutes + ) + ) + } + + fn auction_commit_listing(&self, hold: &AuctionHold) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "realm_auction_commit_listing", + realm_auction_commit_listing_then( + hold.operation_id, + self.actor_or_owner(hold.seller_guid), + hold.item_guid, + hold.item_entry, + hold.item_stack_count, + hold.item_durability, + hold.item_enchant_id, + hold.item_soulbound, + hold.random_property_id, + hold.house, + hold.deposit_rate, + hold.consignment_rate, + hold.start_bid, + hold.buyout, + hold.duration_minutes, + hold.deposit, + hold.created_micros, + hold.expires_micros, + hold.item_text_id + ) + ) + } + + fn auction_confirm_listing(&self, operation_id: u64, auction_id: u32) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "realm_auction_confirm_listing", + realm_auction_confirm_listing_then(operation_id, auction_id, self.owner_actor()) + ) + } + + fn auction_settle_listing(&self, operation_id: u64) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "realm_auction_settle_listing", + realm_auction_settle_listing_then(operation_id, self.owner_actor()) + ) + } + + fn auction_refund_listing(&self, hold: &AuctionHold) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "realm_auction_refund_listing", + realm_auction_refund_listing_then( + hold.operation_id, + self.actor_or_owner(hold.seller_guid), + hold.item_guid, + hold.item_entry, + hold.item_stack_count, + hold.item_durability, + hold.item_enchant_id, + hold.item_soulbound, + hold.random_property_id, + hold.house, + hold.deposit_rate, + hold.consignment_rate, + hold.start_bid, + hold.buyout, + hold.duration_minutes, + hold.deposit, + hold.created_micros, + hold.expires_micros, + hold.item_text_id + ) + ) + } + + fn auction_release_listing_hold(&self, hold: &AuctionHold) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "gw_auction_release_listing_hold", + gw_auction_release_listing_hold_then( + hold.operation_id, + self.actor_or_owner(hold.seller_guid) + ) + ) + } + + fn auction_receipt_is_visible(&self, operation_id: u64) -> bool { + self.0 + .coord() + .conn + .db + .game_auction_operation_receipt() + .operation_id() + .find(&operation_id) + .is_some_and(|receipt| receipt.auction_id != 0) + } + + fn matching_auction_hold( + &self, + request: crate::world::CreateAuctionRequest, + ) -> Option { + self.listing_holds(request.actor_guid) + .into_iter() + .find(|hold| same_auction_request(hold, request)) + } + + fn matching_active_auction_receipt( + &self, + request: crate::world::CreateAuctionRequest, + ) -> Result> { + let candidates: Vec<_> = self + .listing_receipts(request.actor_guid) + .into_iter() + .filter(|receipt| same_auction_request(receipt, request)) + .collect(); + if candidates.is_empty() { + return Ok(None); + } + + // Receipts outlive Auctions so an operation replay stays auditable. Only reuse a receipt + // while its Auction is still active: returned items can later be granted the same item guid + // and legitimately listed again with the same terms. + let realm = self.realm_core()?; + let guard = realm.0.coord(); + let receipt = candidates.into_iter().find(|receipt| { + guard + .conn + .db + .game_auction() + .id() + .find(&receipt.auction_id) + .is_some_and(|auction| auction.listing_operation_id == receipt.operation_id) + }); + Ok(receipt) + } + + fn wait_for_auction_hold(&self, operation_id: u64) -> Result { + wait_for_cache_row(operation_id, "auction Hold", || { + self.0 + .coord() + .conn + .db + .game_auction_hold() + .operation_id() + .find(&operation_id) + }) + } + + fn wait_for_auction_receipt(&self, operation_id: u64) -> Result { + wait_for_cache_row(operation_id, "auction receipt", || { + self.0 + .coord() + .conn + .db + .game_auction_operation_receipt() + .operation_id() + .find(&operation_id) + .filter(|receipt| receipt.auction_id != 0) + }) + } + + /// Place one full-offer bid. The sharded path fences the bidder purse before realm-core makes + /// its serialized Auction decision; both paths finish with the same normalized terminal Hold. + pub(crate) fn place_bid( + &self, + request: crate::world::PlaceBidRequest, + ) -> Result { + let operation_id = self + .matching_unfinished_bid_hold(request) + .map_or_else(next_operation_id, |hold| Ok(hold.operation_id))?; + let result = if self.is_sharded() { + self.drive_sharded_auction_bid(operation_id, request) + } else { + self.auction_bid_local(operation_id, request) + .and_then(|()| self.wait_for_terminal_bid_hold(operation_id)) + }; + match result { + Ok(hold) => bid_outcome(&hold), + Err(error) => match auction_refusal(&error) { + Some(refusal) => Ok(refusal.into()), + None => Err(error), + }, + } + } + + fn drive_sharded_auction_bid( + &self, + operation_id: u64, + request: crate::world::PlaceBidRequest, + ) -> Result { + self.auction_hold_bid(operation_id, request)?; + let hold = self.wait_for_auction_bid_hold(operation_id)?; + self.complete_auction_hold(hold) + } + + /// Cancel one listing. The Gateway reads the listing from the Realm-core cache and fences the + /// Auction Cut it computes; Realm-core refuses the Cancellation if a later bid changed that cut. + /// A pending Cancellation of the same listing is driven again instead of fencing a second cut. + pub(crate) fn cancel_auction( + &self, + request: crate::world::CancelAuctionRequest, + ) -> Result { + use crate::world::CancelAuctionOutcome; + let pending = self + .unfinished_auction_holds(request.actor_guid) + .find(|hold| { + hold.operation == lyracore_shared::auction::hold_operation::CANCEL + && hold.outcome == lyracore_shared::auction::bid_outcome::PENDING + && hold.auction_id == request.auction_id + && hold.house == request.house_id + }); + let (operation_id, cut) = match pending { + Some(hold) => (hold.operation_id, hold.offer), + None => match self.realm_core()?.cancellation_cut(request) { + Some(cut) => (next_operation_id()?, cut), + None => return Ok(CancelAuctionOutcome::NotFound), + }, + }; + let result = if self.is_sharded() { + self.auction_hold_cancel(operation_id, request, cut) + .and_then(|()| self.wait_for_auction_bid_hold(operation_id)) + .and_then(|hold| self.complete_auction_hold(hold)) + } else { + self.auction_cancel_local(operation_id, request, cut) + .and_then(|()| self.wait_for_terminal_bid_hold(operation_id)) + }; + match result { + Ok(hold) => cancel_outcome(&hold), + Err(error) => match auction_refusal(&error) { + Some(refusal) => Ok(refusal.into()), + None => Err(error), + }, + } + } + + /// Finish every unfinished Hold of `actor_guid` on this Home Shard, listing Holds first. A + /// Refusal leaves that Hold for a later attempt; a transport failure ends the request. + pub(crate) fn resume_auction_holds(&self, actor_guid: u64) -> Result<()> { + for hold in self.listing_holds(actor_guid) { + let operation_id = hold.operation_id; + if let Err(error) = self.complete_listing_hold(&hold) { + match auction_refusal(&error) { + Some(refusal) => log::warn!( + "listing Hold {operation_id} of {actor_guid} did not resume: {refusal:?}" + ), + None => return Err(error), + } + } + } + let holds: Vec<_> = self.unfinished_auction_holds(actor_guid).collect(); + for hold in holds { + let operation_id = hold.operation_id; + if let Err(error) = self.complete_auction_hold(hold) { + match auction_refusal(&error) { + Some(refusal) => log::warn!( + "auction Hold {operation_id} of {actor_guid} did not resume: {refusal:?}" + ), + None => return Err(error), + } + } + } + Ok(()) + } + + /// Realm-core's decision, the Home Shard's finish, and any refund phases of one Hold, from the + /// phase a stopped Gateway left it in. Every phase is idempotent, so a replay changes nothing. + fn complete_auction_hold(&self, mut hold: AuctionBidHold) -> Result { + let operation_id = hold.operation_id; + let realm = self.realm_core()?; + if hold.outcome == lyracore_shared::auction::bid_outcome::PENDING { + realm.auction_decide(&hold)?; + let decision = realm.wait_for_auction_bid_decision(operation_id)?; + self.auction_finish_bid(&hold, &decision)?; + hold = self.wait_for_terminal_bid_hold(operation_id)?; + } + if hold.deferred_refund != 0 { + realm.auction_refund_bid(&hold)?; + realm.wait_for_bid_refund(&hold)?; + self.auction_confirm_bid_refund(&hold)?; + hold = self.wait_for_settled_bid_refund(hold.operation_id)?; + } + Ok(hold) + } + + fn auction_bid_local( + &self, + operation_id: u64, + request: crate::world::PlaceBidRequest, + ) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "gw_auction_bid_local", + gw_auction_bid_local_then( + operation_id, + self.actor_or_owner(request.actor_guid), + request.auctioneer_guid, + request.auction_id, + request.house_id, + request.offer + ) + ) + } + + fn auction_hold_bid( + &self, + operation_id: u64, + request: crate::world::PlaceBidRequest, + ) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "gw_auction_hold_bid", + gw_auction_hold_bid_then( + operation_id, + self.actor_or_owner(request.actor_guid), + request.auctioneer_guid, + request.auction_id, + request.house_id, + request.offer + ) + ) + } + + fn auction_hold_cancel( + &self, + operation_id: u64, + request: crate::world::CancelAuctionRequest, + cut: u32, + ) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "gw_auction_hold_cancel", + gw_auction_hold_cancel_then( + operation_id, + self.actor_or_owner(request.actor_guid), + request.auctioneer_guid, + request.auction_id, + request.house_id, + cut + ) + ) + } + + fn auction_cancel_local( + &self, + operation_id: u64, + request: crate::world::CancelAuctionRequest, + cut: u32, + ) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "gw_auction_cancel_local", + gw_auction_cancel_local_then( + operation_id, + self.actor_or_owner(request.actor_guid), + request.auctioneer_guid, + request.auction_id, + request.house_id, + cut + ) + ) + } + + /// Realm-core's decision for a Hold, by the operation the Hold pays for. + fn auction_decide(&self, hold: &AuctionBidHold) -> Result<()> { + use lyracore_shared::auction::hold_operation; + match hold.operation { + hold_operation::BID => call_reducer!( + self.0.call_pipe().conn.reducers, + "realm_auction_decide_bid", + realm_auction_decide_bid_then( + hold.operation_id, + self.actor_or_owner(hold.bidder_guid), + hold.auction_id, + hold.house, + hold.offer + ) + ), + hold_operation::CANCEL => call_reducer!( + self.0.call_pipe().conn.reducers, + "realm_auction_decide_cancel", + realm_auction_decide_cancel_then( + hold.operation_id, + self.actor_or_owner(hold.bidder_guid), + hold.auction_id, + hold.house, + hold.offer + ) + ), + operation => Err(anyhow!( + "auction Hold {} pays for unknown operation {operation}", + hold.operation_id + )), + } + } + + /// The Auction Cut the seller pays to cancel the listing, read from THIS handle's cache. + /// `None` when the listing is gone, is not the seller's, or lists in another market. + fn cancellation_cut(&self, request: crate::world::CancelAuctionRequest) -> Option { + let guard = self.0.coord(); + let auction = guard + .conn + .db + .game_auction() + .id() + .find(&request.auction_id)?; + listing_cut(&auction, request) + } + + /// `seller_guid`'s listing receipts on THIS handle. + fn listing_receipts(&self, seller_guid: u64) -> Vec { + self.indexed_rows( + |index| &index.listing_receipts, + seller_guid, + |db, id| db.game_auction_operation_receipt().operation_id().find(&id), + |receipt| receipt.actor_guid, + ) + } + + fn auction_finish_bid( + &self, + hold: &AuctionBidHold, + decision: &AuctionBidDecision, + ) -> Result<()> { + if !bid_payload_matches(hold, decision) { + return Err(anyhow!( + "auction bid decision payload does not match its Hold" + )); + } + call_reducer!( + self.0.call_pipe().conn.reducers, + "gw_auction_finish_bid", + gw_auction_finish_bid_then( + hold.operation_id, + self.actor_or_owner(hold.bidder_guid), + hold.auction_id, + hold.house, + hold.offer, + decision.outcome, + decision.revision, + decision.result_bidder_guid, + decision.result_bid, + decision.minimum_increment, + decision.accepted_price + ) + ) + } + + fn auction_refund_bid(&self, hold: &AuctionBidHold) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "realm_auction_refund_bid", + realm_auction_refund_bid_then( + hold.operation_id, + self.actor_or_owner(hold.bidder_guid), + hold.auction_id, + hold.house, + hold.offer, + hold.deferred_refund + ) + ) + } + + fn auction_confirm_bid_refund(&self, hold: &AuctionBidHold) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "gw_auction_confirm_bid_refund", + gw_auction_confirm_bid_refund_then( + hold.operation_id, + self.actor_or_owner(hold.bidder_guid), + hold.auction_id, + hold.house, + hold.offer, + hold.deferred_refund + ) + ) + } + + fn matching_unfinished_bid_hold( + &self, + request: crate::world::PlaceBidRequest, + ) -> Option { + self.unfinished_auction_holds(request.actor_guid) + .find(|hold| { + hold.operation == lyracore_shared::auction::hold_operation::BID + && hold.auction_id == request.auction_id + && hold.house == request.house_id + && hold.offer == request.offer + }) + } + + fn wait_for_auction_bid_hold(&self, operation_id: u64) -> Result { + wait_for_cache_row(operation_id, "auction bid Hold", || { + self.0 + .coord() + .conn + .db + .game_auction_bid_hold() + .operation_id() + .find(&operation_id) + }) + } + + fn wait_for_terminal_bid_hold(&self, operation_id: u64) -> Result { + wait_for_cache_row(operation_id, "auction terminal bid Hold", || { + self.0 + .coord() + .conn + .db + .game_auction_bid_hold() + .operation_id() + .find(&operation_id) + .filter(|hold| hold.outcome != lyracore_shared::auction::bid_outcome::PENDING) + }) + } + + fn wait_for_auction_bid_decision(&self, operation_id: u64) -> Result { + wait_for_cache_row(operation_id, "auction bid decision", || { + self.0 + .coord() + .conn + .db + .game_auction_bid_decision() + .operation_id() + .find(&operation_id) + }) + } + + fn wait_for_bid_refund(&self, hold: &AuctionBidHold) -> Result { + wait_for_cache_row(hold.operation_id, "auction bid refund", || { + self.0 + .coord() + .conn + .db + .game_auction_bid_decision() + .operation_id() + .find(&hold.operation_id) + .filter(|decision| bid_refund_is_recorded(hold, decision)) + }) + } + + fn wait_for_settled_bid_refund(&self, operation_id: u64) -> Result { + wait_for_cache_row(operation_id, "auction settled bid refund", || { + self.0 + .coord() + .conn + .db + .game_auction_bid_hold() + .operation_id() + .find(&operation_id) + .filter(|hold| { + hold.outcome != lyracore_shared::auction::bid_outcome::PENDING + && hold.deferred_refund == 0 + }) + }) + } +} + +#[derive(Clone, Copy)] +struct BrowseFacts<'a> { + name: &'a str, + required_level: u8, + inventory_type: u8, + item_class: u8, + item_subclass: u8, + quality: u8, +} + +fn browse_matches( + request: &AuctionBrowseRequest, + player_level: u8, + proficiency: Proficiency, + item: BrowseFacts<'_>, +) -> bool { + (request.name.is_empty() + || item + .name + .to_lowercase() + .contains(&request.name.to_lowercase())) + && request + .minimum_level + .is_none_or(|minimum| item.required_level >= minimum) + && request + .maximum_level + .is_none_or(|maximum| item.required_level <= maximum) + && request + .inventory_type + .is_none_or(|wanted| u32::from(item.inventory_type) == wanted) + && request + .item_class + .is_none_or(|wanted| u32::from(item.item_class) == wanted) + && request + .item_subclass + .is_none_or(|wanted| u32::from(item.item_subclass) == wanted) + && request.quality.is_none_or(|wanted| item.quality == wanted) + && (!request.usable_only + || (item.required_level <= player_level + && proficiency.can_equip(item.item_class, item.item_subclass))) +} + +fn paginate(mut rows: Vec, offset: u32) -> (Vec, u32) { + rows.sort_by_key(|row| row.id); + let total = u32::try_from(rows.len()).unwrap_or(u32::MAX); + let page = rows.into_iter().skip(offset as usize).take(50).collect(); + (page, total) +} + +fn select_active_page( + rows: impl IntoIterator, + market: lyracore_shared::auction::AuctionMarket, + now_micros: i64, + offset: u32, + mut matches: impl FnMut(&Auction) -> bool, +) -> (Vec, u32) { + paginate( + rows.into_iter() + .filter(|row| { + lyracore_shared::auction::market_of(row.house) == market + && row.expires_at.to_micros_since_unix_epoch() > now_micros + && matches(row) + }) + .collect(), + offset, + ) +} + +fn bidder_matches(row: &Auction, player_guid: u64, _outbid_auction_ids: &[u32]) -> bool { + // Vanilla sends recently outbid ids as cache-invalidation hints. They never authorize a + // displaced row back into the bidder tab; the authoritative winner is the realm Auction. + row.highest_bidder_guid == player_guid +} + +fn now_micros() -> i64 { + let micros = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_micros(); + i64::try_from(micros).unwrap_or(i64::MAX) +} + +fn auction_view(row: Auction) -> crate::codec::AuctionView { + crate::codec::AuctionView { + id: row.id, + item_entry: row.item_entry, + item_stack_count: row.item_stack_count, + item_enchant_id: row.item_enchant_id, + owner_guid: row.owner_guid, + start_bid: row.start_bid, + buyout: row.buyout, + highest_bidder_guid: row.highest_bidder_guid, + highest_bid: row.highest_bid, + expires_at_micros: row.expires_at.to_micros_since_unix_epoch(), + random_property_id: row.random_property_id, + } +} + +trait AuctionRequestFields { + fn actor_guid(&self) -> u64; + fn item_guid(&self) -> u64; + fn start_bid(&self) -> u32; + fn buyout(&self) -> u32; + fn duration_minutes(&self) -> u32; + fn house_id(&self) -> u32; +} + +impl AuctionRequestFields for AuctionHold { + fn actor_guid(&self) -> u64 { + self.seller_guid + } + fn item_guid(&self) -> u64 { + self.item_guid + } + fn start_bid(&self) -> u32 { + self.start_bid + } + fn buyout(&self) -> u32 { + self.buyout + } + fn duration_minutes(&self) -> u32 { + self.duration_minutes + } + fn house_id(&self) -> u32 { + self.house + } +} + +impl AuctionRequestFields for AuctionOperationReceipt { + fn actor_guid(&self) -> u64 { + self.actor_guid + } + fn item_guid(&self) -> u64 { + self.item_guid + } + fn start_bid(&self) -> u32 { + self.start_bid + } + fn buyout(&self) -> u32 { + self.buyout + } + fn duration_minutes(&self) -> u32 { + self.duration_minutes + } + fn house_id(&self) -> u32 { + self.house + } +} + +fn same_auction_request( + row: &impl AuctionRequestFields, + request: crate::world::CreateAuctionRequest, +) -> bool { + row.actor_guid() == request.actor_guid + && row.item_guid() == request.item_guid + && row.start_bid() == request.start_bid + && row.buyout() == request.buyout + && row.duration_minutes() == request.duration_minutes + && row.house_id() == request.house_id +} + +/// The Module's typed auction Refusal. Only a reducer the Module rejected carries a tag; a timeout, +/// transport, or SDK failure stays an error with an unknown outcome. +fn auction_refusal(error: &anyhow::Error) -> Option { + reducer_refusal_reason(error).and_then(AuctionRefusal::parse_tag) +} + +fn bid_payload_matches(hold: &AuctionBidHold, decision: &AuctionBidDecision) -> bool { + hold.operation_id == decision.operation_id + && hold.operation == decision.operation + && hold.bidder_guid == decision.bidder_guid + && hold.auction_id == decision.auction_id + && hold.house == decision.house + && hold.offer == decision.offer +} + +fn bid_refund_is_recorded(hold: &AuctionBidHold, decision: &AuctionBidDecision) -> bool { + hold.deferred_refund != 0 + && bid_payload_matches(hold, decision) + && hold.deferred_refund == decision.deferred_refund +} + +/// The seller's Auction Cut for `request`'s listing, when the listing is the seller's and lists in +/// the auctioneer's market. +fn listing_cut(auction: &Auction, request: crate::world::CancelAuctionRequest) -> Option { + use lyracore_shared::auction::{auction_cut, market_of}; + if auction.owner_guid != request.actor_guid + || market_of(auction.house) != market_of(request.house_id) + { + return None; + } + auction_cut(auction.highest_bid, auction.consignment_rate) +} + +fn cancel_outcome(hold: &AuctionBidHold) -> Result { + use crate::world::CancelAuctionOutcome; + use lyracore_shared::auction::{bid_outcome, hold_operation}; + if hold.operation != hold_operation::CANCEL { + return Err(anyhow!( + "auction Hold {} is not a Cancellation", + hold.operation_id + )); + } + Ok(match hold.outcome { + bid_outcome::CANCELLED => CancelAuctionOutcome::Cancelled, + bid_outcome::ITEM_NOT_FOUND => CancelAuctionOutcome::NotFound, + bid_outcome::PENDING => { + return Err(anyhow!( + "auction Cancellation Hold {} is still pending", + hold.operation_id + )); + } + _ => CancelAuctionOutcome::Stale, + }) +} + +fn bid_outcome(hold: &AuctionBidHold) -> Result { + use crate::world::PlaceBidOutcome; + use lyracore_shared::auction::bid_outcome; + Ok(match hold.outcome { + bid_outcome::ACCEPTED => PlaceBidOutcome::Accepted { + minimum_increment: lyracore_shared::auction::bid_increment( + if hold.accepted_price == 0 { + hold.offer + } else { + hold.accepted_price + }, + ), + }, + bid_outcome::ITEM_NOT_FOUND => PlaceBidOutcome::ItemNotFound, + bid_outcome::HIGHER_BID => PlaceBidOutcome::HigherBid { + bidder_guid: hold.result_bidder_guid, + current_bid: hold.result_bid, + minimum_increment: hold.minimum_increment, + }, + bid_outcome::BID_INCREMENT => PlaceBidOutcome::BidIncrement, + bid_outcome::BID_OWN => PlaceBidOutcome::BidOwn, + bid_outcome::DATABASE => PlaceBidOutcome::Database, + outcome => { + return Err(anyhow!( + "auction bid Hold has non-terminal outcome {outcome}" + )); + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request() -> AuctionBrowseRequest { + AuctionBrowseRequest { + auctioneer_guid: 1, + offset: 0, + name: "SWORD".to_owned(), + minimum_level: Some(10), + maximum_level: Some(20), + inventory_type: Some(13), + item_class: Some(2), + item_subclass: Some(7), + quality: Some(2), + usable_only: true, + } + } + + fn facts() -> BrowseFacts<'static> { + BrowseFacts { + name: "Solid Short Sword", + required_level: 15, + inventory_type: 13, + item_class: 2, + item_subclass: 7, + quality: 2, + } + } + + /// A Character of `player_class` that has trained neither level-40 armor upgrade. + fn untrained(player_class: u8) -> Proficiency { + Proficiency::derive(player_class, false, false) + } + + #[test] + fn browse_supports_every_exact_filter_and_the_existing_usable_model() { + assert!(browse_matches(&request(), 15, untrained(1), facts())); + + let mismatches = [ + AuctionBrowseRequest { + name: "axe".to_owned(), + ..request() + }, + AuctionBrowseRequest { + minimum_level: Some(16), + ..request() + }, + AuctionBrowseRequest { + maximum_level: Some(14), + ..request() + }, + AuctionBrowseRequest { + inventory_type: Some(17), + ..request() + }, + AuctionBrowseRequest { + item_class: Some(4), + ..request() + }, + AuctionBrowseRequest { + item_subclass: Some(8), + ..request() + }, + AuctionBrowseRequest { + quality: Some(3), + ..request() + }, + ]; + for mismatch in mismatches { + assert!(!browse_matches(&mismatch, 15, untrained(1), facts())); + } + assert!(!browse_matches(&request(), 14, untrained(1), facts())); + assert!(!browse_matches(&request(), 15, untrained(5), facts())); + assert!(browse_matches( + &AuctionBrowseRequest { + usable_only: false, + ..request() + }, + 15, + untrained(5), + facts(), + )); + assert!(browse_matches( + &AuctionBrowseRequest { + auctioneer_guid: 1, + offset: 0, + name: String::new(), + minimum_level: None, + maximum_level: None, + inventory_type: None, + item_class: None, + item_subclass: None, + quality: None, + usable_only: false, + }, + 1, + untrained(5), + facts(), + )); + } + + #[test] + fn the_usable_filter_follows_trained_armor_not_the_class_ceiling() { + let plate = BrowseFacts { + item_class: 4, + item_subclass: 4, + ..facts() + }; + let usable = AuctionBrowseRequest { + item_class: Some(4), + item_subclass: Some(4), + ..request() + }; + assert!(!browse_matches(&usable, 60, untrained(1), plate)); + assert!(browse_matches( + &usable, + 60, + Proficiency::derive(1, true, false), + plate + )); + // A Mage trains neither upgrade, so plate stays unusable however the flags fall. + assert!(!browse_matches( + &usable, + 60, + Proficiency::derive(8, true, true), + plate + )); + } + + fn auction(id: u32) -> Auction { + Auction { + id, + listing_operation_id: u64::from(id), + house: 4, + owner_guid: 1, + item_guid: u64::from(id), + item_entry: 25, + item_stack_count: 1, + item_durability: 10, + item_enchant_id: 0, + item_soulbound: false, + random_property_id: 0, + start_bid: 1, + buyout: 0, + highest_bidder_guid: 0, + highest_bid: 0, + deposit: 1, + created_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, + expires_at: spacetimedb_sdk::Timestamp::from_micros_since_unix_epoch(i64::MAX), + revision: 0, + deposit_rate: 5, + consignment_rate: 5, + item_text_id: 0, + } + } + + #[test] + fn pagination_sorts_before_taking_fifty_and_reports_the_full_total() { + let rows = (1..=55).rev().map(auction).collect(); + let (first, total) = paginate(rows, 0); + assert_eq!(total, 55); + assert_eq!(first.len(), 50); + assert_eq!(first.first().unwrap().id, 1); + assert_eq!(first.last().unwrap().id, 50); + + let rows = (1..=55).rev().map(auction).collect(); + let (second, total) = paginate(rows, 50); + assert_eq!(total, 55); + assert_eq!( + second.iter().map(|row| row.id).collect::>(), + (51..=55).collect::>() + ); + } + + #[test] + fn active_selection_excludes_expired_other_market_and_non_owner_rows_before_totals() { + use lyracore_shared::auction::AuctionMarket; + + let mut expired = auction(1); + expired.expires_at = spacetimedb_sdk::Timestamp::from_micros_since_unix_epoch(10); + let mut at_deadline = auction(2); + at_deadline.expires_at = spacetimedb_sdk::Timestamp::from_micros_since_unix_epoch(20); + let mut other_market = auction(3); + other_market.house = 7; // Blackwater is neutral, outside the queried Horde market. + let mut same_market_other_house = auction(4); + same_market_other_house.house = 5; // Thunder Bluff pools into the same Horde market. + let mut other_owner = auction(5); + other_owner.house = 4; + other_owner.owner_guid = 2; + let mut owned = auction(6); + owned.house = 4; + + let rows = vec![ + expired, + at_deadline, + other_market, + same_market_other_house, + other_owner, + owned, + ]; + let (page, total) = + select_active_page(rows, AuctionMarket::Horde, 20, 0, |row| row.owner_guid == 1); + assert_eq!(total, 2); + assert_eq!( + page.iter().map(|row| row.id).collect::>(), + vec![4, 6] + ); + } + + #[test] + fn bidder_selection_never_reintroduces_requested_outbid_auctions() { + use lyracore_shared::auction::AuctionMarket; + + let mut highest = auction(5); + highest.highest_bidder_guid = 8; + highest.highest_bid = 107; + let mut displaced = auction(19); + displaced.highest_bidder_guid = 9; + displaced.highest_bid = 113; + + let requested_outbid_ids = [19, 88]; + let (page, total) = select_active_page( + vec![displaced, highest], + AuctionMarket::Horde, + 20, + 0, + |row| bidder_matches(row, 8, &requested_outbid_ids), + ); + assert_eq!(total, 1); + assert_eq!(page.iter().map(|row| row.id).collect::>(), vec![5]); + } +} + +#[cfg(test)] +mod auction_reducer_tests { + use super::*; + use crate::stdb::connection::ReducerCallError; + + #[test] + #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] + fn a_local_bid_interaction_refusal_stays_a_client_outcome() { + use crate::accept::BlockingTaskCapacity; + use crate::config::GatewayConfig; + use crate::durable_test_support::Standalone; + use crate::world::{PlaceBidOutcome, PlaceBidRequest}; + + for variable in [ + "LYRACORE_SHARD_MAP", + "LYRACORE_SHARD_MAP_FILE", + "LYRACORE_REALM_CORE", + ] { + assert!( + std::env::var_os(variable).is_none(), + "unset {variable} for this private test" + ); + } + let mut standalone = Standalone::start("auction-refusal"); + standalone.publish_module(); + standalone.assert_call("claim_operator", &[]); + standalone.assert_call("install_guid_range", &["0"]); + let cfg = GatewayConfig { + logon_bind: "127.0.0.1:0".into(), + world_bind: "127.0.0.1:0".into(), + stdb_uri: standalone.server().into(), + module_name: standalone.shard_name().into(), + coordinator_token: Some(standalone.owner_token()), + gateway_id: "auction-refusal-test".into(), + blocking_task_capacity: BlockingTaskCapacity::new(1), + }; + let runtime = tokio::runtime::Runtime::new().unwrap(); + let coordinator = runtime.block_on(Coordinator::connect(&cfg)).unwrap(); + assert!(!coordinator.is_sharded()); + let outcome = coordinator + .place_bid(PlaceBidRequest { + actor_guid: 5_090_099, + auctioneer_guid: 5_090_098, + auction_id: 5_090_097, + offer: 100, + house_id: 1, + }) + .expect("a Module Refusal must not end the World Session"); + assert_eq!(outcome, PlaceBidOutcome::Database); + assert!(standalone + .query_rows("SELECT * FROM game_auction_bid_hold") + .is_empty()); + } + + #[test] + fn only_a_rejected_reducer_carries_a_typed_refusal() { + for refusal in AuctionRefusal::ALL { + let error = anyhow::Error::from(ReducerCallError::Rejected { + operation: "gw_auction_hold_listing".to_string(), + reason: refusal.as_tag().to_string(), + }) + .context("preparing auction listing"); + assert_eq!(auction_refusal(&error), Some(refusal)); + } + + let not_refusals = [ + anyhow::Error::from(ReducerCallError::fatal( + "gw_auction_hold_listing reducer timed out after 10s".to_string(), + )), + anyhow::Error::from(ReducerCallError::fatal( + "gw_auction_hold_bid reducer failed: transport disconnected".to_string(), + )), + anyhow::Error::from(ReducerCallError::Rejected { + operation: "gw_auction_hold_bid".to_string(), + reason: "operator only".to_string(), + }), + anyhow!( + "wrapped text that mentions {}", + AuctionRefusal::Database.as_tag() + ), + ]; + for error in not_refusals { + assert_eq!(auction_refusal(&error), None, "{error:#}"); + } + } + + #[test] + fn deferred_refund_receipt_requires_the_full_hold_payload() { + let hold = AuctionBidHold { + operation_id: 7, + bidder_guid: 8, + auction_id: 9, + house: 4, + offer: 10, + outcome: lyracore_shared::auction::bid_outcome::BID_INCREMENT, + revision: 0, + result_bidder_guid: 0, + result_bid: 0, + minimum_increment: 0, + accepted_price: 0, + deferred_refund: 3, + operation: lyracore_shared::auction::hold_operation::BID, + }; + let decision = AuctionBidDecision { + operation_id: hold.operation_id, + bidder_guid: hold.bidder_guid, + auction_id: hold.auction_id, + house: hold.house, + offer: hold.offer, + outcome: hold.outcome, + revision: hold.revision, + result_bidder_guid: hold.result_bidder_guid, + result_bid: hold.result_bid, + minimum_increment: hold.minimum_increment, + accepted_price: hold.accepted_price, + deferred_refund: hold.deferred_refund, + item_entry: 0, + random_property_id: 0, + operation: hold.operation, + }; + + assert!(bid_refund_is_recorded(&hold, &decision)); + assert!(!bid_refund_is_recorded( + &hold, + &AuctionBidDecision { + offer: 11, + ..decision.clone() + } + )); + assert!(!bid_refund_is_recorded( + &hold, + &AuctionBidDecision { + deferred_refund: 2, + ..decision + } + )); + + assert!(crate::stdb::auction_holds::hold_is_unfinished(&hold)); + assert!(!crate::stdb::auction_holds::hold_is_unfinished( + &AuctionBidHold { + deferred_refund: 0, + ..hold + } + )); + } + + #[test] + fn refused_listing_binding_matches_the_generated_commit_listing_shape() { + let expected = include_str!("../bindings/realm_auction_commit_listing_reducer.rs") + .replace("RealmAuctionCommitListing", "RealmAuctionRefundListing") + .replace( + "realm_auction_commit_listing", + "realm_auction_refund_listing", + ); + assert_eq!( + include_str!("../bindings/realm_auction_refund_listing_reducer.rs"), + expected, + "the hand-added reducer binding must remain generator-identical" + ); + } + + fn cancel_request() -> crate::world::CancelAuctionRequest { + crate::world::CancelAuctionRequest { + actor_guid: 7, + auctioneer_guid: 42, + auction_id: 41, + house_id: 2, + } + } + + fn listing(owner_guid: u64, house: u32, highest_bid: u32) -> Auction { + Auction { + id: 41, + listing_operation_id: 1, + house, + owner_guid, + item_guid: 70, + item_entry: 25, + item_stack_count: 1, + item_durability: 0, + item_enchant_id: 0, + item_soulbound: false, + start_bid: 100, + buyout: 0, + highest_bidder_guid: u64::from(highest_bid != 0) * 9, + highest_bid, + deposit: 10, + created_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, + expires_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, + revision: 1, + deposit_rate: 5, + consignment_rate: 5, + random_property_id: 0, + item_text_id: 0, + } + } + + /// 5% of a bid of 201 is 10.05, truncated to 10 (`cm:AuctionHouseMgr.cpp:733-736`). + #[test] + fn the_gateway_fences_the_cut_of_the_sellers_own_listing_in_its_market() { + assert_eq!(listing_cut(&listing(7, 1, 201), cancel_request()), Some(10)); + assert_eq!(listing_cut(&listing(7, 3, 0), cancel_request()), Some(0)); + assert_eq!( + listing_cut(&listing(8, 1, 201), cancel_request()), + None, + "another player's listing" + ); + assert_eq!( + listing_cut(&listing(7, 4, 201), cancel_request()), + None, + "the Horde market" + ); + } + + #[test] + fn a_terminal_cancellation_hold_maps_to_one_client_outcome() { + use crate::world::CancelAuctionOutcome; + use lyracore_shared::auction::{bid_outcome, hold_operation}; + let hold = |operation, outcome| AuctionBidHold { + operation_id: 7, + bidder_guid: 8, + auction_id: 41, + house: 1, + offer: 10, + outcome, + revision: 0, + result_bidder_guid: 0, + result_bid: 0, + minimum_increment: 0, + accepted_price: 10, + deferred_refund: 0, + operation, + }; + for (outcome, expected) in [ + (bid_outcome::CANCELLED, CancelAuctionOutcome::Cancelled), + (bid_outcome::ITEM_NOT_FOUND, CancelAuctionOutcome::NotFound), + (bid_outcome::DATABASE, CancelAuctionOutcome::Stale), + ] { + assert_eq!( + cancel_outcome(&hold(hold_operation::CANCEL, outcome)).unwrap(), + expected + ); + } + assert!(cancel_outcome(&hold(hold_operation::CANCEL, bid_outcome::PENDING)).is_err()); + assert!( + cancel_outcome(&hold(hold_operation::BID, bid_outcome::CANCELLED)).is_err(), + "a bid Hold never answers a Cancellation" + ); + assert!(!bid_payload_matches( + &hold(hold_operation::CANCEL, bid_outcome::PENDING), + &AuctionBidDecision { + operation_id: 7, + bidder_guid: 8, + auction_id: 41, + offer: 10, + outcome: bid_outcome::CANCELLED, + revision: 0, + result_bidder_guid: 0, + result_bid: 0, + minimum_increment: 0, + deferred_refund: 0, + accepted_price: 10, + house: 1, + item_entry: 25, + random_property_id: 0, + operation: hold_operation::BID, + } + )); + } + + #[test] + fn normalized_buyout_price_drives_the_exact_success_command_value() { + let hold = AuctionBidHold { + operation_id: 7, + bidder_guid: 8, + auction_id: 9, + house: 4, + offer: 900, + outcome: lyracore_shared::auction::bid_outcome::ACCEPTED, + revision: 0, + result_bidder_guid: 0, + result_bid: 0, + minimum_increment: 0, + accepted_price: 500, + deferred_refund: 0, + operation: lyracore_shared::auction::hold_operation::BID, + }; + + assert_eq!( + bid_outcome(&hold).unwrap(), + crate::world::PlaceBidOutcome::Accepted { + minimum_increment: 25, + } + ); + } +} diff --git a/gateway/src/stdb/store/bank.rs b/gateway/src/stdb/store/bank.rs index 33444d3f9..bc7b6ed36 100644 --- a/gateway/src/stdb/store/bank.rs +++ b/gateway/src/stdb/store/bank.rs @@ -1,10 +1,11 @@ //! `Coordinator`'s [`BankStore`] adapter. -use anyhow::Result; - -use crate::world::BankStore; +use anyhow::{anyhow, Result}; +use crate::stdb::bindings::*; +use crate::stdb::connection::call_reducer; use crate::stdb::Coordinator; +use crate::world::{Actor, BankStore}; impl BankStore for Coordinator { fn auto_bank_item(&self, account_id: u64, self_guid: u64, slot: u8) -> Result<()> { @@ -15,3 +16,32 @@ impl BankStore for Coordinator { self.buy_bank_slot(account_id, self_guid, banker_guid) } } + +impl Coordinator { + /// Buy the next bank bag slot from `banker_guid` (`CMSG_BUY_BANK_SLOT`) over the coordinator + /// connection. A refusal carries the module's `[N]` `SMSG_BUY_BANK_SLOT_RESULT` code tag. + pub fn buy_bank_slot(&self, _account_id: u64, actor_guid: u64, banker_guid: u64) -> Result<()> { + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("buy_bank_slot: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_buy_bank_slot", + gw_buy_bank_slot_then(self.session_actor(actor), banker_guid) + ) + } + + /// Auto-bank/auto-store-bank the item in `slot` (`CMSG_AUTOBANK_ITEM`/`CMSG_AUTOSTORE_BANK_ITEM`) + /// over the coordinator connection. The module infers deposit vs. withdraw from `slot` and + /// resolves the receiving free slot itself. + pub fn auto_bank_item(&self, _account_id: u64, actor_guid: u64, slot: u8) -> Result<()> { + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("auto_bank_item: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_auto_bank_item", + gw_auto_bank_item_then(self.session_actor(actor), slot) + ) + } +} diff --git a/gateway/src/stdb/store/cast.rs b/gateway/src/stdb/store/cast.rs new file mode 100644 index 000000000..64acf4be5 --- /dev/null +++ b/gateway/src/stdb/store/cast.rs @@ -0,0 +1,438 @@ +//! `Coordinator`'s [`CastStore`] adapter. + +use anyhow::{anyhow, Result}; +use spacetimedb_sdk::Table; + +use crate::codec; +use crate::stdb::bindings::*; +use crate::stdb::connection::call_reducer; +use crate::stdb::views::item_template_view; +use crate::stdb::Coordinator; +use crate::world::{Actor, CastStore, EnchantRoute}; + +impl CastStore for crate::stdb::Coordinator { + fn spell_is_ranged_auto_repeat(&self, spell_id: u32) -> bool { + crate::stdb::Coordinator::spell_is_ranged_auto_repeat(self, spell_id) + } + + fn enchant_route(&self, spell_id: u32) -> Option { + crate::stdb::Coordinator::enchant_route(self, spell_id) + } + + fn spell_is_fishing(&self, spell_id: u32) -> bool { + crate::stdb::Coordinator::spell_is_fishing(self, spell_id) + } + + fn spell_is_open_lock(&self, spell_id: u32) -> bool { + crate::stdb::Coordinator::spell_is_open_lock(self, spell_id) + } + + fn spell_is_ground_area(&self, spell_id: u32) -> bool { + crate::stdb::Coordinator::spell_is_ground_area(self, spell_id) + } + + fn spell_cast_time(&self, spell_id: u32) -> Option { + crate::stdb::Coordinator::spell_cast_time(self, spell_id) + } + + fn spell_queues_next_swing(&self, spell_id: u32) -> bool { + crate::stdb::Coordinator::spell_queues_next_swing(self, spell_id) + } + + fn cast_spell( + &self, + account_id: u64, + self_guid: u64, + spell_id: u32, + target_guid: u64, + ) -> Result<()> { + crate::stdb::Coordinator::cast_spell(self, account_id, self_guid, spell_id, target_guid) + } + + fn cast_spell_at( + &self, + account_id: u64, + self_guid: u64, + spell_id: u32, + target_guid: u64, + x: f32, + y: f32, + z: f32, + ) -> Result<()> { + crate::stdb::Coordinator::cast_spell_at( + self, + account_id, + self_guid, + spell_id, + target_guid, + x, + y, + z, + ) + } + + fn cast_item_target( + &self, + account_id: u64, + self_guid: u64, + spell_id: u32, + slot: u8, + ) -> Result<()> { + crate::stdb::Coordinator::cast_item_target(self, account_id, self_guid, spell_id, slot) + } + + fn start_ranged_attack( + &self, + account_id: u64, + self_guid: u64, + target_guid: u64, + spell_id: u32, + ) -> Result<()> { + crate::stdb::Coordinator::start_ranged_attack( + self, + account_id, + self_guid, + target_guid, + spell_id, + ) + } + + fn item_slot_by_guid(&self, account_id: u64, item_guid: u64) -> Option { + crate::stdb::Coordinator::item_slot_by_guid(self, account_id, item_guid) + } + + fn disenchant_item(&self, account_id: u64, self_guid: u64, slot: u8) -> Result<()> { + crate::stdb::Coordinator::disenchant_item(self, account_id, self_guid, slot) + } + + fn enchant_item_on_slot( + &self, + account_id: u64, + self_guid: u64, + slot: u8, + enchant_id: u32, + ) -> Result<()> { + crate::stdb::Coordinator::enchant_item_on_slot( + self, account_id, self_guid, slot, enchant_id, + ) + } + + fn fish(&self, account_id: u64, self_guid: u64) -> Result<()> { + crate::stdb::Coordinator::fish(self, account_id, self_guid) + } + + fn pick_lock(&self, account_id: u64, self_guid: u64, go_guid: u64) -> Result<()> { + crate::stdb::Coordinator::pick_lock(self, account_id, self_guid, go_guid) + } + + fn cancel_cast(&self, account_id: u64, self_guid: u64) -> Result<()> { + crate::stdb::Coordinator::cancel_cast(self, account_id, self_guid) + } + + fn cancel_aura(&self, account_id: u64, self_guid: u64, spell_id: u32) -> Result<()> { + crate::stdb::Coordinator::cancel_aura(self, account_id, self_guid, spell_id) + } + + fn player_items(&self, owner_guid: u64) -> Result> { + crate::stdb::Coordinator::player_items(self, owner_guid) + } + + fn item_template(&self, entry: u32) -> Result> { + crate::stdb::Coordinator::item_template(self, entry) + } +} + +impl Coordinator { + /// The spell's cast time (ms) from the static `game_spell` header — 0 = instant. Used by the + /// CMSG_CAST_SPELL handler to clear an instant cast SYNCHRONOUSLY (the async relay delivers START/GO + /// after the aura effects, which wedges the 5875 client's cast slot). None = unknown spell. + pub fn spell_cast_time(&self, spell_id: u32) -> Option { + self.0 + .coord() + .conn + .db + .game_spell() + .spell_id() + .find(&spell_id) + .map(|s| s.cast_time_ms) + } + + /// Enchant/disenchant routing for `spell_id`, read off the spell's effect rows — `None` if the spell + /// has no item-target enchanting effect (a normal cast). The gateway uses this to route ITEM-target + /// casts by effect KIND, with the enchant id carried in the effect's `p_0`, instead of a hardcoded + /// spell-id list — a new enchant spell is a data row, no gateway change. + pub fn enchant_route(&self, spell_id: u32) -> Option { + use crate::world::EnchantRoute; + const E_ENCHANT_ITEM: u8 = 0x17; // taxonomy E_ENCHANT_ITEM (p_0 = enchant_id) + const E_DISENCHANT: u8 = 0x18; // taxonomy E_DISENCHANT + self.0 + .coord() + .conn + .db + .game_spell_effect() + .iter() + .filter(|e| e.spell_id == spell_id) + .find_map(|e| match e.kind { + E_ENCHANT_ITEM => Some(EnchantRoute::Enchant(e.p_0 as u32)), + E_DISENCHANT => Some(EnchantRoute::Disenchant), + _ => None, + }) + } + + /// True iff `spell_id` has a ground-area effect (taxonomy `E_PERSISTENT_AREA` 0x1B — + /// Consecration etc.). The instant-cast GO for such a spell must carry an EMPTY hit list: the + /// default self-cast fallback put the CASTER in `hits[]`, and the 5875 client rendered the + /// spell's impact animation ON the paladin ("hit animation on the caster"). Kind-routed + /// like `enchant_route` — a new ground spell is a data row. + pub fn spell_is_ground_area(&self, spell_id: u32) -> bool { + const E_PERSISTENT_AREA: u8 = 0x1B; // lockstep with module taxonomy + self.0 + .coord() + .conn + .db + .game_spell_effect() + .iter() + .any(|e| e.spell_id == spell_id && e.kind == E_PERSISTENT_AREA) + } + + /// True iff `spell_id` is a FISHING cast (taxonomy `E_FISH` 0x1C — the three tier ids carry a + /// synthesized marker effect row). Kind-routed like `enchant_route`/`spell_is_ground_area`. + pub fn spell_is_fishing(&self, spell_id: u32) -> bool { + const E_FISH: u8 = 0x1C; // lockstep with module taxonomy + self.0 + .coord() + .conn + .db + .game_spell_effect() + .iter() + .any(|e| e.spell_id == spell_id && e.kind == E_FISH) + } + + /// True iff `spell_id` is an OPEN-LOCK cast (taxonomy `E_OPEN_LOCK` 0x1D — Pick Lock 1804). Routed to + /// the `pick_lock` reducer, gateway-intercepted exactly like `spell_is_fishing`/`enchant_route`. The + /// GO guid the pick targets rides the cast's SpellCastTargets (GAMEOBJECT flag). + pub fn spell_is_open_lock(&self, spell_id: u32) -> bool { + const E_OPEN_LOCK: u8 = 0x1D; // lockstep with module taxonomy + self.0 + .coord() + .conn + .db + .game_spell_effect() + .iter() + .any(|e| e.spell_id == spell_id && e.kind == E_OPEN_LOCK) + } + + /// Resolve a trainer offering's LEARN TARGET (the rank the buy actually granted) so + /// SMSG_LEARNED_SPELL books the REAL spell, not the LearnSpell wrapper (live find 2026-07-11: + /// "Devotion Aura appeared in my General tab as the spell that teaches Devotion Aura"). + /// LOCKSTEP with module trainer.rs::resolve_learn_target — the same first-qualifying-trigger + /// rule; the excluded kinds are the module's taxonomy values (A_PERIODIC_TRIGGER 0x93, + /// A_FLAG 0xBE, A_PROC_TRIGGER 0xAB, E_TRIGGER 0x05). + /// True iff `spell_id` is an on-next-swing QUEUE spell (Heroic Strike/Cleave — any effect of kind + /// E_NEXT_SWING). The CMSG_CAST_SPELL handler then sends NO synchronous START/CAST_RESULT/GO: the + /// 5875 client lights the button locally on the press and holds it as a pending cast until the + /// swing-fire GO arrives. Kind value LOCKSTEP with module taxonomy.rs::E_NEXT_SWING (0x13). + pub fn spell_queues_next_swing(&self, spell_id: u32) -> bool { + const E_NEXT_SWING: u8 = 0x13; + let guard = self.0.coord(); + let queues = guard + .conn + .db + .game_spell_effect() + .iter() + .any(|e| e.spell_id == spell_id && e.kind == E_NEXT_SWING); + queues + } + + /// True iff `spell_id` is an AUTO-REPEAT ranged attack (Auto Shot / wand Shoot) — the + /// `SPELL_ATTR_RANGED_AUTO_REPEAT` cast_flags bit set by the importer (from the DBC AttributesEx2 + /// AUTOREPEAT bit / by name). The CMSG_CAST_SPELL handler routes on this instead of a hardcoded + /// `spell == 75 || 5019` id list, so a new ranged auto-repeat ability onboards as data. + pub fn spell_is_ranged_auto_repeat(&self, spell_id: u32) -> bool { + const SPELL_ATTR_RANGED_AUTO_REPEAT: u32 = 0x0200; // lockstep with importer::spell.rs + let guard = self.0.coord(); + guard + .conn + .db + .game_spell() + .spell_id() + .find(&spell_id) + .is_some_and(|s| s.cast_flags & SPELL_ATTR_RANGED_AUTO_REPEAT != 0) + } + + /// Read an item template by entry for a `CMSG_ITEM_QUERY_SINGLE` reply. + pub fn item_template(&self, entry: u32) -> Result> { + Ok(self + .0 + .coord() + .conn + .db + .game_item_template() + .entry() + .find(&entry) + .map(item_template_view)) + } + + /// Start the player's RANGED auto-attack on `target_guid` with `spell_id` (75 Auto Shot / 5019 Shoot) + /// over the coordinator connection so the module attributes the shot to the caller. + /// Rides the coordinator connection as `gw_ranged_attack`. + pub fn start_ranged_attack( + &self, + _account_id: u64, + actor_guid: u64, + target_guid: u64, + spell_id: u32, + ) -> Result<()> { + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("start_ranged_attack: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_ranged_attack", + gw_ranged_attack_then(self.session_actor(actor), target_guid, spell_id) + ) + } + + /// Cast a spell (`CMSG_CAST_SPELL`, aura tracer) over the coordinator connection so the module + /// attributes the cast to the caller. `target_guid` is the client's selected unit (0 = none/self → + /// the module substitutes the caster), threaded so target-keyed effects see the real target. + pub fn cast_spell( + &self, + _account_id: u64, + actor_guid: u64, + spell_id: u32, + target_guid: u64, + ) -> Result<()> { + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("cast_spell: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_cast_spell", + gw_cast_spell_then(self.session_actor(actor), spell_id, target_guid) + ) + } + + /// Resolve an item-target cast through the module's generic item-effect seam. + pub fn cast_item_target( + &self, + _account_id: u64, + actor_guid: u64, + spell_id: u32, + slot: u8, + ) -> Result<()> { + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("cast_item_target: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_cast_item_target", + gw_cast_item_target_then(self.session_actor(actor), spell_id, slot) + ) + } + + /// Cast a GROUND-TARGETED spell at a clicked world point (`CMSG_CAST_SPELL` with a DEST_LOCATION — + /// Flamestrike/Blizzard/Rain of Fire). Same per-account attribution as `cast_spell`; the `(x,y,z)` is + /// the ground click so the module anchors the AoE/patch there. + // The ground point rides flat because it mirrors the `gw_cast_spell_at` reducer signature. + #[allow(clippy::too_many_arguments)] + pub fn cast_spell_at( + &self, + _account_id: u64, + actor_guid: u64, + spell_id: u32, + target_guid: u64, + x: f32, + y: f32, + z: f32, + ) -> Result<()> { + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("cast_spell_at: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_cast_spell_at", + gw_cast_spell_at_then(self.session_actor(actor), spell_id, target_guid, x, y, z) + ) + } + + /// Cancel one of the caller's own auras by spell id (`CMSG_CANCEL_AURA`) over the coordinator + /// connection so the module attributes the removal to the caller. + pub fn cancel_aura(&self, _account_id: u64, actor_guid: u64, spell_id: u32) -> Result<()> { + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("cancel_aura: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_cancel_aura", + gw_cancel_aura_then(self.session_actor(actor), spell_id) + ) + } + + /// Cancel the caller's in-progress cast (`CMSG_CANCEL_CAST`) over the coordinator connection so the + /// module clears the caller's pending cast — no phantom completion GO. + pub fn cancel_cast(&self, _account_id: u64, actor_guid: u64) -> Result<()> { + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("cancel_cast: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_cancel_cast", + gw_cancel_cast_then(self.session_actor(actor)) + ) + } + + pub fn disenchant_item(&self, _account_id: u64, actor_guid: u64, slot: u8) -> Result<()> { + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("disenchant_item: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_disenchant", + gw_disenchant_then(self.session_actor(actor), slot) + ) + } + + pub fn enchant_item_on_slot( + &self, + _account_id: u64, + actor_guid: u64, + slot: u8, + enchant_id: u32, + ) -> Result<()> { + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("enchant_item_on_slot: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_enchant_item", + gw_enchant_item_then(self.session_actor(actor), slot, enchant_id) + ) + } + + /// Fishing cast: instant-resolve catch — the module's lenient alpha gate auto-learns the + /// skill and grants the fish straight to the bag. Caller resolved via ctx.sender. + pub fn fish(&self, _account_id: u64, actor_guid: u64) -> Result<()> { + let actor = Actor::new(actor_guid).ok_or_else(|| anyhow!("fish: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_fish", + gw_fish_then(self.session_actor(actor)) + ) + } + + /// Pick Lock: unlock the locked GameObject `go_guid` over the coordinator connection (so the + /// module attributes the pick to the caller via ctx.sender). The module gates range / lock + /// requirement / Lockpicking skill; on success it records the GO unlocked + climbs the skill. + pub fn pick_lock(&self, _account_id: u64, actor_guid: u64, go_guid: u64) -> Result<()> { + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("pick_lock: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_pick_lock", + gw_pick_lock_then(self.session_actor(actor), go_guid) + ) + } +} diff --git a/gateway/src/stdb/store/channel.rs b/gateway/src/stdb/store/channel.rs new file mode 100644 index 000000000..523469605 --- /dev/null +++ b/gateway/src/stdb/store/channel.rs @@ -0,0 +1,126 @@ +//! `Coordinator`'s [`ChannelActionStore`] adapter. + +use anyhow::Result; +use lyracore_shared::channel::{ChannelName, ChannelRefusal}; + +use crate::stdb::bindings::*; +use crate::stdb::connection::{call_reducer, reducer_refusal_reason}; +use crate::stdb::Coordinator; +use crate::world::{ + resolve_online_character, whisper, ChannelActionStore, ChannelOutcome, ChannelRoster, + ResolvedTarget, +}; + +impl ChannelActionStore for crate::stdb::Coordinator { + fn channel_op( + &self, + actor_guid: u64, + op: u8, + request: crate::world::ChannelRequest, + ) -> Result { + crate::stdb::Coordinator::channel_op(self, actor_guid, op, request) + } + + fn channel_roster(&self, team: u32, channel_name: &str) -> Result> { + crate::stdb::Coordinator::channel_roster(self, team, channel_name) + } + + fn online_character_by_name(&self, name: &str) -> Result> { + resolve_online_character(self, name) + } + + fn ignores(&self, owner_guid: u64, other_guid: u64) -> Result { + whisper::ignored_anywhere(self, owner_guid, other_guid) + } +} + +impl Coordinator { + /// The channel `channel_name` names for `team` on Realm-core, with its members in join order + /// and its owner's name from whichever World Shard holds the Character. + pub(crate) fn channel_roster( + &self, + team: u32, + channel_name: &str, + ) -> anyhow::Result> { + let realm = self.realm_core()?; + let key = ChannelName::normalize(channel_name).key; + let found = { + let guard = realm.0.coord(); + // The index lock is released before the cache read: the pump takes the cache and + // then the index in its callbacks. + let indexed = { + let index = guard.chat_channels.read().unwrap(); + index + .channel_id(team, &key) + .map(|channel_id| (channel_id, index.members(channel_id))) + }; + indexed.and_then(|(channel_id, members)| { + let channel = guard + .conn + .db + .game_chat_channel() + .channel_id() + .find(&channel_id)?; + Some((channel, members)) + }) + }; + // The guard is gone: the owner's name reads other Shards' caches. + let Some((channel, members)) = found else { + return Ok(None); + }; + let owner_name = if channel.owner_guid == 0 { + String::new() + } else { + crate::world::presence::character_anywhere(self, channel.owner_guid)? + .map(|character| character.name) + .unwrap_or_default() + }; + Ok(Some(ChannelRoster { + name: channel.name, + flags: channel.flags, + owner_guid: channel.owner_guid, + owner_name, + members, + })) + } + + /// `realm_channel_op`: run one Chat Channel op on Realm-core, or on the one database of an + /// unsharded Realm. The Module applies every channel rule. The actor's name stays here. + pub fn channel_op( + &self, + actor_guid: u64, + op: u8, + request: crate::world::ChannelRequest, + ) -> Result { + let realm = self.realm_core()?; + let request = ChannelRequest { + channel_name: request.channel_name, + password: request.password, + target_guid: request.target_guid, + target_name: request.target_name, + target_race: request.target_race, + target_ignores_actor: request.target_ignores_actor, + speaker: SpeakerFacts { + race: request.speaker.race, + chat_tag: request.speaker.chat_tag, + }, + }; + channel_outcome(call_reducer!( + realm.0.call_pipe().conn.reducers, + "realm_channel_op", + realm_channel_op_then(realm.actor_or_owner(actor_guid), op, request) + )) + } +} + +/// The Module's typed channel Refusal. Only a reducer the Module rejected carries a tag; a timeout, +/// transport, or SDK failure stays an error with an unknown outcome. +fn channel_outcome(result: Result<()>) -> Result { + match result { + Ok(()) => Ok(ChannelOutcome::Done), + Err(error) => match reducer_refusal_reason(&error).and_then(ChannelRefusal::parse_tag) { + Some(refusal) => Ok(ChannelOutcome::Refused(refusal)), + None => Err(error), + }, + } +} diff --git a/gateway/src/stdb/store/character.rs b/gateway/src/stdb/store/character.rs index 2621d4d90..b118e0496 100644 --- a/gateway/src/stdb/store/character.rs +++ b/gateway/src/stdb/store/character.rs @@ -1,12 +1,15 @@ //! `Coordinator`'s [`CharacterStore`] adapter. use anyhow::Result; +use spacetimedb_sdk::Table; use crate::codec; +use crate::stdb::bindings::*; +use crate::stdb::connection::{call_reducer, CharacterPresenceSnapshot, Coordinator}; +use crate::stdb::reads::spell_ranks_stack_in_book; +use crate::stdb::views::character_view; use crate::world::CharacterStore; -use crate::stdb::connection::{CharacterPresenceSnapshot, Coordinator}; - fn stable_character_absence( first: &[CharacterPresenceSnapshot], second: &[CharacterPresenceSnapshot], @@ -189,6 +192,282 @@ impl CharacterStore for Coordinator { } } +impl Coordinator { + /// Read an account's characters for the character-select screen. In production + /// this reads the per-player `game_character` subscription cache (RLS-restricted to owner). + /// Equipment slots (0..=18) are populated from `game_item_instance` + `game_item_template` + /// so the client renders the character's gear on the select screen instead of all-naked. + pub fn characters(&self, account_id: u64) -> Result> { + use wow_world_messages::vanilla::{CharacterGear, InventoryType}; + let guard = self.0.coord(); + let db = &guard.conn.db; + let mut views: Vec = db + .game_character() + .iter() + .filter(|c| c.account_id == account_id) + .map(character_view) + .collect(); + // Fill equipment slots 0..=18 from item instances. Slots ≥ 19 are backpack/bag slots; + // skip them. An unknown inventory_type degrades to InventoryType::default (Non) which + // the client treats the same as display_id=0 — no model shown, no crash. + for view in &mut views { + for item in db + .game_item_instance() + .iter() + .filter(|i| i.owner_guid == view.guid && i.slot <= 18) + { + let Some(tmpl) = db.game_item_template().entry().find(&item.entry) else { + continue; + }; + let inv_type = + InventoryType::try_from(u32::from(tmpl.inventory_type)).unwrap_or_default(); + view.equipment[item.slot as usize] = CharacterGear { + equipment_display_id: tmpl.display_id, + inventory_type: inv_type, + }; + } + } + Ok(views) + } + + /// Read a single character by guid (any owner) for a `CMSG_NAME_QUERY` reply. The queried guid + /// is usually a *peer*, so this reads across owners via the privileged cache (no RLS on the + /// owner connection), unlike `characters` which filters to one account. + pub fn character_by_guid(&self, guid: u64) -> Result> { + Ok(self + .0 + .coord() + .conn + .db + .game_character() + .guid() + .find(&guid) + .map(character_view)) + } + + /// folded from the coordinator's privileged cache: base (`game_world_entity.armor`) + worn gear armor. + /// The coordinator isn't subscribed to `game_aura`, so the aura term is 0 here — login-present armor + /// auras are pushed by the on_aura relay the instant they insert. Delegates to the shared + /// `stdb::armor::effective_armor` so CREATE and the relays compute the IDENTICAL fold. + pub fn effective_armor(&self, guid: u64) -> u32 { + let guard = self.0.coord(); + super::super::armor::effective_armor(&guard.conn.db, guid) + } + + pub fn effective_magic_resistances(&self, guid: u64) -> [u32; 6] { + let guard = self.0.coord(); + super::super::armor::effective_magic_resistances(&guard.conn.db, guid) + } + + /// The character's active SPELL-MODIFIER auras as raw `(family_mask, op, amount, is_pct)` + /// rows — the client-mirror source for SMSG_SET_FLAT/PCT_SPELL_MODIFIER (aggregation by + /// (op, mask-bit) happens in the codec helper; mangos sends the TOTAL per bit). + pub fn spell_modifiers(&self, character_guid: u64) -> Vec<(u32, u8, i32, bool)> { + const A_SPELLMOD_FLAT: u8 = 0xAC; // lockstep with module taxonomy + const A_SPELLMOD_PCT: u8 = 0xAD; + self.0 + .coord() + .conn + .db + .game_aura() + .iter() + .filter(|a| { + a.target_guid == character_guid + && (a.eff_kind == A_SPELLMOD_FLAT || a.eff_kind == A_SPELLMOD_PCT) + }) + .map(|a| { + ( + a.eff_p1 as u32, + a.eff_p0 as u8, + a.amount, + a.eff_kind == A_SPELLMOD_PCT, + ) + }) + .collect() + } + + /// The player's LEARNED spells — `game_player_spell` rows for this character (the coordinator + /// bypasses RLS so it reads any player's). Chained into the login spellbook so a taught ability + /// (Auto Shot) reaches the client and `CastSpellByName` can fire it. + pub fn player_learned_spells(&self, player_guid: u64) -> Result> { + let guard = self.0.coord(); + let db = &guard.conn.db; + let known: Vec = db + .game_player_spell() + .iter() + .filter(|s| s.character_guid == player_guid) + .map(|s| s.spell_id) + .collect(); + // 258 rank collapse: drop a known rank that another KNOWN spell supersedes (a game_spell_chain + // row whose prev_spell is this id) — GATED on the same cmangos stacking rule as + // superseded_old_rank (operator-corrected): MANA spells keep every rank in the book + // (downranking Holy Light is a real thing); only non-mana/passive chains collapse + // (Heroic Strike). One pass suffices: each superseded rank is prev of its own successor. + let known_set: std::collections::HashSet = known.iter().copied().collect(); + let superseded: std::collections::HashSet = db + .game_spell_chain() + .iter() + .filter(|c| { + c.prev_spell != 0 + && known_set.contains(&c.prev_spell) + && known_set.contains(&c.spell_id) + && !spell_ranks_stack_in_book(db, c.spell_id) + }) + .map(|c| c.prev_spell) + .collect(); + Ok(known + .into_iter() + .filter(|id| !superseded.contains(id)) + .collect()) + } + + /// The player's IMPORTED action-bar rows as `(button, action, action_type)` triples — + /// `game_player_action` rows copied at character creation from `game_createinfo_action` (empty when + /// no dump has been imported, the common case today). Chained into the login codec + /// (`login_sequence_messages`), which builds the bar from these when non-empty and falls back to + /// the spellbook synth otherwise. RLS-bypassed read, like `player_learned_spells`. + pub fn player_actions(&self, player_guid: u64) -> Result> { + let guard = self.0.coord(); + let db = &guard.conn.db; + Ok(db + .game_player_action() + .iter() + .filter(|a| a.character_guid == player_guid) + .map(|a| (a.button, a.action, a.action_type)) + .collect()) + } + + /// The player's persisted reputation standings as `(reputation_index, standing, + /// at_war)` triples — chained into the login `SMSG_INITIALIZE_FACTIONS` so a relog carries the + /// real standing + the At-War checkbox instead of the all-neutral stub. Rows with + /// `reputation_index < 0` (stale pre-migration filler) are skipped — there is no slot to + /// address. RLS-bypassed read, like `player_learned_spells`. + pub fn player_reputations(&self, player_guid: u64) -> Result> { + let guard = self.0.coord(); + let db = &guard.conn.db; + Ok(db + .game_player_reputation() + .iter() + .filter(|r| r.character_guid == player_guid && r.reputation_index >= 0) + .map(|r| (r.reputation_index, r.standing, r.at_war)) + .collect()) + } + + /// Does `character_guid` sell, or lead the bidding on, an Auction on THIS handle? + fn has_auction(&self, character_guid: u64) -> bool { + let auctions = self.auction_keys(|index| &index.auctions, character_guid); + let guard = self.0.coord(); + auctions.into_iter().any(|id| { + u32::try_from(id) + .ok() + .and_then(|id| guard.conn.db.game_auction().id().find(&id)) + .is_some_and(|auction| { + auction.owner_guid == character_guid + || auction.highest_bidder_guid == character_guid + }) + }) + } + + /// Create a character via the `create_character` reducer (owner connection), mapping the + /// reducer result to a game outcome. A distinguished `NAME_IN_USE` error → `NameInUse`; any + /// other reducer/transport error → `Failed` (never propagated as a hard error, so a bad + /// creation can't drop the world session). + pub fn create_character( + &self, + account_id: u64, + name: &str, + race: u8, + class: u8, + gender: u8, + appearance: crate::codec::Appearance, + ) -> Result { + use crate::codec::CharCreateOutcome; + // The SpacetimeDB-generated reducer binding takes the five appearance bytes positionally; + // unbundle `Appearance` here, at the single generated-boundary call. + let result = call_reducer!( + self.0.call_pipe().conn.reducers, + "create_character", + create_character_then( + account_id, + name.to_string(), + race, + class, + gender, + appearance.skin, + appearance.face, + appearance.hair_style, + appearance.hair_color, + appearance.facial_hair + ) + ); + Ok(match result { + Ok(()) => CharCreateOutcome::Success, + Err(e) if e.to_string().contains("NAME_IN_USE") => CharCreateOutcome::NameInUse, + Err(e) if e.to_string().contains("SERVER_LIMIT") => CharCreateOutcome::ServerLimit, + // The 5875 client has no code for "this database may not mint guids", so the outcome is + // the generic failure — but the REASON must not be swallowed: the whole point of + // guid-range licensing is that an unlicensed shard fails loudly instead of minting + // into someone else's range. + Err(e) => { + log::warn!("create_character on {} failed: {e:#}", self.shard_name()); + CharCreateOutcome::Failed + } + }) + } + + /// Delete a character via the `delete_character` reducer (owner connection — the reducer is + /// operator-gated, mirroring `create_character`). Ownership is enforced module-side (`NOT_OWNER` + /// if `character_guid` isn't `account_id`'s), so a malicious/buggy client can't delete another + /// account's character. Maps to a game outcome the same way `create_character` does: never + /// propagated as a hard error, so a bad delete can't drop the world session. + pub fn delete_character( + &self, + account_id: u64, + character_guid: u64, + ) -> Result { + use crate::codec::CharDeleteOutcome; + match self.character_has_auction_value(character_guid) { + Ok(true) => return Ok(CharDeleteOutcome::Failed), + Ok(false) => {} + Err(error) => { + log::warn!( + "delete_character: could not verify auction value for {character_guid}: {error:#}" + ); + return Ok(CharDeleteOutcome::Failed); + } + } + let result = call_reducer!( + self.0.call_pipe().conn.reducers, + "delete_character", + delete_character_then(account_id, self.actor_or_owner(character_guid)) + ); + Ok(match result { + Ok(()) => CharDeleteOutcome::Success, + // The 1.12 client has one reason for every refusal, so the reason goes to the log: + // CHAR_HAS_GUILD_FEE_HOLD clears when the Character next enters the world and the + // Gateway finishes its Fee Hold. + Err(error) => { + log::info!("delete_character: {character_guid} not deleted: {error:#}"); + CharDeleteOutcome::Failed + } + }) + } + + fn character_has_auction_value(&self, character_guid: u64) -> Result { + for (_, shard) in self.world_shards() { + if !shard.listing_holds(character_guid).is_empty() + || shard + .unfinished_auction_holds(character_guid) + .next() + .is_some() + { + return Ok(true); + } + } + Ok(self.realm_core()?.has_auction(character_guid)) + } +} + #[cfg(test)] mod stable_absence_tests { #[test] diff --git a/gateway/src/stdb/store/chat.rs b/gateway/src/stdb/store/chat.rs new file mode 100644 index 000000000..0f79c2cfe --- /dev/null +++ b/gateway/src/stdb/store/chat.rs @@ -0,0 +1,154 @@ +//! `Coordinator`'s [`ChatActionStore`] adapter. + +use anyhow::Result; +use lyracore_shared::chat::ChatRefusal; + +use crate::stdb::bindings::*; +use crate::stdb::connection::{call_reducer, reducer_refusal_reason}; +use crate::stdb::Coordinator; +use crate::world::{whisper, ChatActionStore, ChatOutcome}; + +impl ChatActionStore for crate::stdb::Coordinator { + fn speaker_facts(&self, speaker_guid: u64) -> Result> { + crate::stdb::Coordinator::speaker_facts(self, speaker_guid) + } + + fn realm_chat( + &self, + speaker_guid: u64, + request: crate::world::RealmChatRequest, + ) -> Result { + crate::stdb::Coordinator::realm_chat(self, speaker_guid, request) + } + + fn set_away(&self, speaker_guid: u64, kind: u8, message: String) -> Result<()> { + crate::stdb::Coordinator::set_away(self, speaker_guid, kind, message) + } + + fn whisper_target( + &self, + speaker_guid: u64, + typed_name: &str, + ) -> Result> { + whisper::target_facts(self, speaker_guid, typed_name) + } + + fn realm_whisper( + &self, + speaker_guid: u64, + request: crate::world::WhisperRequest, + ) -> Result { + crate::stdb::Coordinator::realm_whisper(self, speaker_guid, request) + } + + fn speaker_gm_level(&self, speaker_guid: u64) -> Result { + Ok(crate::stdb::Coordinator::home_gm_level(self, speaker_guid)) + } +} + +impl Coordinator { + /// The Speaker Facts for `speaker_guid` on this Home Shard: race from `UNIT_FIELD_BYTES_0` + /// byte 0 and the chat tag from `PLAYER_FLAGS`, both off the live entity, plus the Character's + /// name. `None` when the speaker has no live entity here. + pub(crate) fn speaker_facts( + &self, + speaker_guid: u64, + ) -> anyhow::Result> { + let guard = self.0.coord(); + let db = &guard.conn.db; + let Some(entity) = db.game_world_entity().guid().find(&speaker_guid) else { + return Ok(None); + }; + let name = db + .game_character() + .guid() + .find(&speaker_guid) + .map(|character| character.name) + .unwrap_or_default(); + Ok(Some(crate::world::SpeakerFacts { + race: (entity.unit_bytes_0 & 0xFF) as u8, + chat_tag: lyracore_shared::chat::chat_tag_for(entity.player_flags), + name, + })) + } + + /// `gw_set_away`: one `/afk` or `/dnd` on the Character's Home Shard, where its live entity + /// and Auto-Reply live. + pub fn set_away(&self, actor_guid: u64, kind: u8, message: String) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "gw_set_away", + gw_set_away_then(self.actor_or_owner(actor_guid), kind, message) + ) + } + + /// `realm_chat`: commit one Realm Chat Line on Realm-core, or on the one database of an + /// unsharded Realm. The Module applies every chat Gate. The speaker's name stays here. + pub fn realm_chat( + &self, + speaker_guid: u64, + request: crate::world::RealmChatRequest, + ) -> Result { + let realm = self.realm_core()?; + let request = RealmChatRequest { + kind: request.kind, + language: request.language, + channel_name: request.channel_name, + target_guid: request.target_guid, + message: request.message, + speaker: SpeakerFacts { + race: request.speaker.race, + chat_tag: request.speaker.chat_tag, + }, + }; + chat_outcome(call_reducer!( + realm.0.call_pipe().conn.reducers, + "realm_chat", + realm_chat_then(realm.actor_or_owner(speaker_guid), request) + )) + } + + /// `realm_whisper`: commit one whisper's lines on Realm-core, or on the one database of an + /// unsharded Realm. The Module applies every whisper Gate. The speaker's name stays here. + pub fn realm_whisper( + &self, + speaker_guid: u64, + request: crate::world::WhisperRequest, + ) -> Result { + let realm = self.realm_core()?; + let target = request.target; + let request = WhisperRequest { + language: request.language, + message: request.message, + speaker: SpeakerFacts { + race: request.speaker.race, + chat_tag: request.speaker.chat_tag, + }, + target: WhisperTargetFacts { + guid: target.guid, + race: target.race, + name: target.name, + ignores_speaker: target.ignores_speaker, + away_kind: target.away_kind, + away_message: target.away_message, + }, + }; + chat_outcome(call_reducer!( + realm.0.call_pipe().conn.reducers, + "realm_whisper", + realm_whisper_then(realm.actor_or_owner(speaker_guid), request) + )) + } +} + +/// The Module's typed chat Refusal. Only a reducer the Module rejected carries a tag; a timeout, +/// transport, or SDK failure stays an error with an unknown outcome. +pub(super) fn chat_outcome(result: Result<()>) -> Result { + match result { + Ok(()) => Ok(ChatOutcome::Delivered), + Err(error) => match reducer_refusal_reason(&error).and_then(ChatRefusal::parse_tag) { + Some(refusal) => Ok(ChatOutcome::Refused(refusal)), + None => Err(error), + }, + } +} diff --git a/gateway/src/stdb/store/combat.rs b/gateway/src/stdb/store/combat.rs index f078699df..3ed92371b 100644 --- a/gateway/src/stdb/store/combat.rs +++ b/gateway/src/stdb/store/combat.rs @@ -1,10 +1,11 @@ //! `Coordinator`'s [`CombatStore`] adapter. -use anyhow::Result; - -use crate::world::CombatStore; +use anyhow::{anyhow, Result}; +use crate::stdb::bindings::*; +use crate::stdb::connection::call_reducer; use crate::stdb::Coordinator; +use crate::world::{Actor, CombatStore}; impl CombatStore for Coordinator { fn set_target(&self, account_id: u64, self_guid: u64, target_guid: u64) -> Result<()> { @@ -25,3 +26,50 @@ impl CombatStore for Coordinator { self.set_sheathed(account_id, self_guid, state) } } + +impl Coordinator { + /// Set the player's current target (`CMSG_SET_SELECTION`, Tier 2 / N3) over the coordinator + /// connection so the module attributes it to the caller. `target_guid` 0 clears it. + pub fn set_target(&self, _account_id: u64, actor_guid: u64, target_guid: u64) -> Result<()> { + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("set_target: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_set_target", + gw_set_target_then(self.session_actor(actor), target_guid) + ) + } + + /// Relay a pet command-bar action (`CMSG_PET_ACTION`) over the coordinator connection so the module + /// attributes it to the pet's owner. `data` is the raw packed action (flag<<24 | id); the module + /// decodes stay/follow/attack/dismiss + passive/defensive/aggressive. + pub fn pet_command( + &self, + _account_id: u64, + actor_guid: u64, + data: u32, + target_guid: u64, + ) -> Result<()> { + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("pet_command: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_pet_command", + gw_pet_command_then(self.session_actor(actor), data, target_guid) + ) + } + + /// Draw or stow the player's weapons (`CMSG_SETSHEATHED`). + pub fn set_sheathed(&self, _account_id: u64, actor_guid: u64, state: u8) -> Result<()> { + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("set_sheathed: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_set_sheathed", + gw_set_sheathed_then(self.session_actor(actor), state) + ) + } +} diff --git a/gateway/src/stdb/store/death.rs b/gateway/src/stdb/store/death.rs index 3247dbedc..d74639625 100644 --- a/gateway/src/stdb/store/death.rs +++ b/gateway/src/stdb/store/death.rs @@ -1,10 +1,12 @@ //! `Coordinator`'s [`DeathStore`] adapter. -use anyhow::Result; - -use crate::world::DeathStore; +use anyhow::{anyhow, Result}; +use spacetimedb_sdk::Table; +use crate::stdb::bindings::*; +use crate::stdb::connection::call_reducer; use crate::stdb::Coordinator; +use crate::world::{Actor, DeathStore}; impl DeathStore for Coordinator { fn repop(&self, account_id: u64, self_guid: u64) -> Result<()> { @@ -31,3 +33,99 @@ impl DeathStore for Coordinator { self.corpse_location(owner_guid) } } + +impl Coordinator { + /// Find `owner_guid`'s corpse location `(map_id, x, y, z)` from the privileged cache, for the + /// `MSG_CORPSE_QUERY` reply. `None` if they have no corpse. + pub fn corpse_location(&self, owner_guid: u64) -> Result> { + Ok(self + .0 + .coord() + .conn + .db + .game_corpse() + .iter() + .find(|c| c.owner_guid == owner_guid) + .map(|c| (c.map_id, c.x, c.y, c.z))) + } + + /// Revive the caller after death (`CMSG_REPOP_REQUEST`) over the coordinator connection. + /// Rides the coordinator connection as `gw_repop`. + pub fn repop(&self, _account_id: u64, actor_guid: u64) -> Result<()> { + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("repop: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_repop", + gw_repop_then(self.session_actor(actor)) + ) + } + + /// Reclaim the caller's corpse (`CMSG_RECLAIM_CORPSE`) over the coordinator connection. + pub fn reclaim_corpse( + &self, + _account_id: u64, + actor_guid: u64, + corpse_guid: u64, + ) -> Result<()> { + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("reclaim_corpse: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_reclaim_corpse", + gw_reclaim_corpse_then(self.session_actor(actor), corpse_guid) + ) + } + + /// Answer a pending resurrect offer (`CMSG_RESURRECT_RESPONSE`) over the coordinator connection. + /// Rides the coordinator connection as `gw_respond_resurrect`. + pub fn resurrect_response( + &self, + _account_id: u64, + actor_guid: u64, + accept: bool, + ) -> Result<()> { + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("resurrect_response: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_respond_resurrect", + gw_respond_resurrect_then(self.session_actor(actor), accept) + ) + } + + /// Use the caller's Self-Resurrection Option (`CMSG_SELF_RES`) over the coordinator connection. + /// Rides the coordinator connection as `gw_self_resurrect`. + pub fn self_resurrect(&self, _account_id: u64, actor_guid: u64) -> Result<()> { + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("self_resurrect: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_self_resurrect", + gw_self_resurrect_then(self.session_actor(actor)) + ) + } + + /// Spirit-Healer resurrect (`CMSG_SPIRIT_HEALER_ACTIVATE`) over the coordinator connection: the + /// module res's the caller in place at 50% + applies Resurrection Sickness if it's a ghost. + /// `gw_spirit_res` takes no `healer_guid`. + pub fn spirit_healer_res( + &self, + _account_id: u64, + actor_guid: u64, + _healer_guid: u64, + ) -> Result<()> { + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("spirit_healer_res: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_spirit_res", + gw_spirit_res_then(self.session_actor(actor)) + ) + } +} diff --git a/gateway/src/stdb/store/duel.rs b/gateway/src/stdb/store/duel.rs new file mode 100644 index 000000000..60a3f71cb --- /dev/null +++ b/gateway/src/stdb/store/duel.rs @@ -0,0 +1,42 @@ +//! `Coordinator`'s [`DuelActionStore`] adapter. + +use anyhow::{anyhow, Result}; + +use crate::stdb::bindings::*; +use crate::stdb::connection::call_reducer; +use crate::stdb::Coordinator; +use crate::world::{Actor, DuelActionStore}; + +impl DuelActionStore for crate::stdb::Coordinator { + fn duel_accept(&self, account_id: u64, actor_guid: u64, flag_guid: u64) -> Result<()> { + crate::stdb::Coordinator::duel_accept(self, account_id, actor_guid, flag_guid) + } + + fn duel_cancel(&self, account_id: u64, actor_guid: u64, flag_guid: u64) -> Result<()> { + crate::stdb::Coordinator::duel_cancel(self, account_id, actor_guid, flag_guid) + } +} + +impl Coordinator { + pub fn duel_accept(&self, _account_id: u64, actor_guid: u64, flag_guid: u64) -> Result<()> { + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("duel_accept: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_duel_accept", + gw_duel_accept_then(self.session_actor(actor), flag_guid) + ) + } + + pub fn duel_cancel(&self, _account_id: u64, actor_guid: u64, flag_guid: u64) -> Result<()> { + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("duel_cancel: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_duel_cancel", + gw_duel_cancel_then(self.session_actor(actor), flag_guid) + ) + } +} diff --git a/gateway/src/stdb/store/guild.rs b/gateway/src/stdb/store/guild.rs new file mode 100644 index 000000000..906a25721 --- /dev/null +++ b/gateway/src/stdb/store/guild.rs @@ -0,0 +1,410 @@ +//! `Coordinator`'s [`GuildActionStore`] adapter. + +use anyhow::Result; +use lyracore_shared::guild::GuildRefusal; + +use crate::codec; +use crate::stdb::bindings::*; +use crate::stdb::connection::{call_reducer, reducer_refusal_reason}; +use crate::stdb::reads::petition_view; +use crate::stdb::Coordinator; +use crate::world::{ + presence, whisper, CharacterFacts, GuildActionStore, GuildOutcome, GuildRequest, +}; + +impl GuildActionStore for crate::stdb::Coordinator { + fn guild_member(&self, character_guid: u64) -> Result> { + Ok(self.realm_core()?.guild_member_row(character_guid)) + } + + fn guild(&self, guild_id: u32) -> Result> { + Ok(self.realm_core()?.guild_row(guild_id)) + } + + fn guild_members(&self, guild_id: u32) -> Result> { + Ok(self.realm_core()?.guild_member_rows(guild_id)) + } + + fn guild_character_facts(&self, character_guid: u64) -> Result> { + crate::stdb::Coordinator::guild_character_facts(self, character_guid) + } + + fn guild_characters_named(&self, name: &str) -> Result> { + presence::resolve_all_by_name(self, name) + } + + fn guild_gm_level(&self, actor_guid: u64) -> Result { + Ok(crate::stdb::Coordinator::home_gm_level(self, actor_guid)) + } + + fn guild_selected_target(&self, actor_guid: u64) -> u64 { + crate::stdb::Coordinator::selected_target(self, actor_guid) + } + + fn guild_ignored_by(&self, owner_guid: u64, other_guid: u64) -> Result { + whisper::ignored_anywhere(self, owner_guid, other_guid) + } + + fn guild_op(&self, actor_guid: u64, request: GuildRequest) -> Result { + self.realm_core()?.realm_guild_op(actor_guid, request) + } + + fn guild_npc_refuses(&self, npc_guid: u64, actor_guid: u64) -> Result { + crate::stdb::Coordinator::npc_refuses_interaction(self, npc_guid, actor_guid) + } + + fn guild_petition_of_charter( + &self, + charter_item_guid: u64, + ) -> Result> { + Ok(self + .realm_core()? + .guild_petition_of_charter(charter_item_guid)) + } + + fn guild_petition_of_owner(&self, owner_guid: u64) -> Result> { + Ok(self.realm_core()?.guild_petition_of_owner(owner_guid)) + } + + fn guild_name_taken(&self, name: &str) -> Result { + Ok(self.realm_core()?.guild_name_taken(name)) + } + + fn guild_holds_charter(&self, actor_guid: u64, charter_item_guid: u64) -> Result { + Ok(self.holds_guild_charter(actor_guid, charter_item_guid)) + } + + fn guild_destroy_charter(&self, actor_guid: u64, charter_item_guid: u64) -> Result<()> { + self.destroy_guild_charter(actor_guid, charter_item_guid) + } + + fn guild_character_guids(&self) -> Result> { + self.realm_core()?.guild_character_guids() + } + + fn guild_names_character(&self, character_guid: u64) -> Result { + self.realm_core()?.guild_names_character(character_guid) + } + + fn guild_name_of_member(&self, character_guid: u64) -> Result> { + Ok(self.realm_core()?.guild_name_of_member(character_guid)) + } +} + +impl Coordinator { + /// The membership row of `character_guid` in THIS handle's cache. Call it on the Realm-core + /// handle. + pub(crate) fn guild_member_row( + &self, + character_guid: u64, + ) -> Option { + self.0 + .coord() + .conn + .db + .game_guild_member() + .character_guid() + .find(&character_guid) + .map(member_view) + } + + /// One Guild and its Guild Ranks in THIS handle's cache, ranks ordered by `rank_id`. + pub(crate) fn guild_row(&self, guild_id: u32) -> Option { + let guard = self.0.coord(); + let db = &guard.conn.db; + let guild = db.game_guild().guild_id().find(&guild_id)?; + let rank_ids = guard.guilds.read().unwrap().rank_ids(guild_id); + let ranks_table = db.game_guild_rank(); + let mut ranks: Vec = rank_ids + .into_iter() + .filter_map(|id| ranks_table.id().find(&id)) + .map(|rank| crate::codec::GuildRankView { + rank_id: rank.rank_id, + name: rank.name, + rights: rank.rights, + }) + .collect(); + ranks.sort_by_key(|rank| rank.rank_id); + Some(crate::codec::GuildView { + guild_id: guild.guild_id, + name: guild.name, + leader_guid: guild.leader_guid, + team: guild.team, + motd: guild.motd, + info: guild.info, + emblem_style: guild.emblem_style, + emblem_color: guild.emblem_color, + border_style: guild.border_style, + border_color: guild.border_color, + background_color: guild.background_color, + created_micros: guild.created_micros, + ranks, + }) + } + + /// Every member row of one Guild in THIS handle's cache, ordered by guid. + pub(crate) fn guild_member_rows(&self, guild_id: u32) -> Vec { + let guard = self.0.coord(); + let guids = guard.guilds.read().unwrap().member_guids(guild_id); + let members = guard.conn.db.game_guild_member(); + guids + .into_iter() + .filter_map(|guid| members.character_guid().find(&guid)) + .filter(|member| member.guild_id == guild_id) + .map(member_view) + .collect() + } + + /// Every Character that a Guild, a Petition or a Signature in THIS handle's cache names: the + /// Characters the deleted-Character reconciliation checks. Call it on the Realm-core handle. + /// It fails while the subscription is unhealthy, so a stale cache never hides a Character. + pub(crate) fn guild_character_guids(&self) -> Result> { + let guard = self.0.coord(); + if !guard.is_healthy() { + anyhow::bail!( + "{} has no healthy Coordinator subscription for guild cleanup", + self.shard_name() + ); + } + let named = guard.guilds.read().unwrap().named_characters(); + Ok(named.into_iter().collect()) + } + + /// Does a Guild, a Petition or a Signature in THIS handle's cache name `character_guid`? Keyed + /// reads only. Call it on the Realm-core handle. Fails while the subscription is unhealthy. + pub(crate) fn guild_names_character(&self, character_guid: u64) -> Result { + let guard = self.0.coord(); + if !guard.is_healthy() { + anyhow::bail!( + "{} has no healthy Coordinator subscription for guild cleanup", + self.shard_name() + ); + } + let member = guard + .conn + .db + .game_guild_member() + .character_guid() + .find(&character_guid) + .is_some(); + Ok(member + || guard + .guilds + .read() + .unwrap() + .petition_characters + .contains_key(&character_guid)) + } + + /// The name of the Guild `character_guid` belongs to in THIS handle's cache, if any. Two keyed + /// reads, cheap enough for every `/who` row. + pub(crate) fn guild_name_of_member(&self, character_guid: u64) -> Option { + let guard = self.0.coord(); + let db = &guard.conn.db; + let member = db + .game_guild_member() + .character_guid() + .find(&character_guid)?; + db.game_guild() + .guild_id() + .find(&member.guild_id) + .map(|guild| guild.name) + } + + /// The unit `actor_guid` has selected, from THIS handle's live entity. 0 for none. + pub(crate) fn selected_target(&self, actor_guid: u64) -> u64 { + self.0 + .coord() + .conn + .db + .game_world_entity() + .guid() + .find(&actor_guid) + .map_or(0, |entity| entity.target_guid) + } + + /// The open Petition `owner_guid` owns in THIS handle's cache. Call it on the Realm-core + /// handle. + pub(crate) fn guild_petition_of_owner( + &self, + owner_guid: u64, + ) -> Option { + let guard = self.0.coord(); + let db = &guard.conn.db; + let row = db.game_guild_petition().owner_guid().find(&owner_guid)?; + Some(petition_view(db, row)) + } + + /// Does a Guild in THIS handle's cache hold `name`, without regard to case? Call it on the + /// Realm-core handle. + pub(crate) fn guild_name_taken(&self, name: &str) -> bool { + self.0 + .coord() + .conn + .db + .game_guild() + .name_key() + .find(&lyracore_shared::guild::name_key(name)) + .is_some() + } + + /// Does `actor_guid` hold the Guild Charter `charter_item_guid` in THIS handle's cache? Call it + /// on the actor's Home Shard. + pub(crate) fn holds_guild_charter(&self, actor_guid: u64, charter_item_guid: u64) -> bool { + self.0 + .coord() + .conn + .db + .game_item_instance() + .guid() + .find(&charter_item_guid) + .is_some_and(|item| { + item.owner_guid == actor_guid + && item.entry == lyracore_shared::guild::GUILD_CHARTER_ENTRY + }) + } + + /// `realm_guild_op`: one guild op against the database THIS handle points at. Callers hold the + /// Realm-core handle. The actor is the guid this World Session entered the world with. + pub fn realm_guild_op( + &self, + actor_guid: u64, + request: crate::world::GuildRequest, + ) -> Result { + use crate::world::GuildRequest; + let op = match request { + GuildRequest::GmCreate { + leader_guid, + leader_name, + leader_team, + leader_realm_account, + gm_level, + name, + } => GuildOp::GmCreate(GuildGmCreate { + leader_guid, + leader_name, + leader_team, + leader_realm_account, + gm_level, + name, + }), + GuildRequest::SignOn { actor_name } => GuildOp::SignOn(actor_name), + GuildRequest::SignOff => GuildOp::SignOff, + GuildRequest::Invite { + target_guid, + actor_team, + target_team, + target_ignores_actor, + } => GuildOp::Invite(GuildInviteRequest { + target_guid, + actor_team, + target_team, + target_ignores_actor, + }), + GuildRequest::Accept { + actor_name, + actor_team, + } => GuildOp::Accept(GuildAcceptRequest { + actor_name, + actor_team, + }), + GuildRequest::Decline { actor_name } => GuildOp::Decline(actor_name), + GuildRequest::Leave => GuildOp::Leave, + GuildRequest::Remove { target_guid } => GuildOp::Remove(target_guid), + GuildRequest::Promote { target_guid } => GuildOp::Promote(target_guid), + GuildRequest::Demote { target_guid } => GuildOp::Demote(target_guid), + GuildRequest::SetLeader { target_guid } => GuildOp::SetLeader(target_guid), + GuildRequest::Disband => GuildOp::Disband, + GuildRequest::SetMotd { text } => GuildOp::SetMotd(text), + GuildRequest::SetInfo { text } => GuildOp::SetInfo(text), + GuildRequest::SetPublicNote { target_guid, text } => { + GuildOp::SetPublicNote(GuildNoteEdit { target_guid, text }) + } + GuildRequest::SetOfficerNote { target_guid, text } => { + GuildOp::SetOfficerNote(GuildNoteEdit { target_guid, text }) + } + GuildRequest::EditRank { + rank_id, + rights, + name, + } => GuildOp::EditRank(GuildRankEdit { + rank_id, + rights, + name, + }), + GuildRequest::AddRank { name } => GuildOp::AddRank(name), + GuildRequest::DeleteRank => GuildOp::DeleteRank, + GuildRequest::SignPetition { + charter_item_guid, + actor_name, + actor_team, + } => GuildOp::SignPetition(GuildPetitionSign { + charter_item_guid, + actor_name, + actor_team, + }), + GuildRequest::OfferPetition { + charter_item_guid, + target_guid, + target_team, + } => GuildOp::OfferPetition(GuildPetitionOffer { + charter_item_guid, + target_guid, + target_team, + }), + GuildRequest::DeclinePetition { charter_item_guid } => { + GuildOp::DeclinePetition(charter_item_guid) + } + GuildRequest::RenamePetition { + charter_item_guid, + name, + } => GuildOp::RenamePetition(GuildPetitionRename { + charter_item_guid, + name, + }), + GuildRequest::TurnInPetition { charter_item_guid } => { + GuildOp::TurnInPetition(charter_item_guid) + } + GuildRequest::ClosePetition { petition_id } => GuildOp::ClosePetition(petition_id), + GuildRequest::ForgetDeletedCharacter => GuildOp::ForgetDeletedCharacter, + }; + let result = call_reducer!( + self.0.call_pipe().conn.reducers, + "realm_guild_op", + realm_guild_op_then(self.actor_or_owner(actor_guid), op) + ); + match result { + Ok(()) => Ok(crate::world::GuildOutcome::Ran), + Err(error) => match reducer_refusal_reason(&error).and_then(GuildRefusal::parse_tag) { + Some(refusal) => Ok(crate::world::GuildOutcome::Refused(refusal)), + None => Err(error), + }, + } + } + + /// `gw_destroy_guild_charter` on THIS handle, the actor's Home Shard. A Charter already gone + /// is Ok. + pub(crate) fn destroy_guild_charter( + &self, + actor_guid: u64, + charter_item_guid: u64, + ) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "gw_destroy_guild_charter", + gw_destroy_guild_charter_then(self.actor_or_owner(actor_guid), charter_item_guid) + ) + } +} + +fn member_view(row: GuildMember) -> crate::codec::GuildMemberView { + crate::codec::GuildMemberView { + character_guid: row.character_guid, + guild_id: row.guild_id, + rank_id: row.rank_id, + name: row.name, + public_note: row.public_note, + officer_note: row.officer_note, + realm_account_id: row.realm_account_id, + } +} diff --git a/gateway/src/stdb/store/guild_fee.rs b/gateway/src/stdb/store/guild_fee.rs new file mode 100644 index 000000000..335e3f1b9 --- /dev/null +++ b/gateway/src/stdb/store/guild_fee.rs @@ -0,0 +1,246 @@ +//! `Coordinator`'s [`GuildFeeStore`] adapter. + +use anyhow::{anyhow, Result}; +use lyracore_shared::guild::GuildRefusal; + +use crate::stdb::bindings::*; +use crate::stdb::connection::{call_reducer, reducer_refusal_reason}; +use crate::stdb::reducers::{next_operation_id, wait_for_cache_row}; +use crate::stdb::Coordinator; +use crate::world::guild_fee; +use crate::world::guild_fee::{ + CharterOwner, FeeHold, FeeOutcome, FeeRequest, FeeTerms, GuildFeeStore, +}; + +impl GuildFeeStore for crate::stdb::Coordinator { + fn guild_fee_held(&self, actor_guid: u64) -> Result> { + Ok(self.guild_fee_hold_row(actor_guid)) + } + + fn guild_fee_hold( + &self, + actor_guid: u64, + request: FeeRequest, + ) -> Result> { + self.hold_guild_fee(actor_guid, request) + } + + fn guild_fee_decide(&self, actor_guid: u64, hold: FeeHold) -> Result { + let owner = match hold.terms { + FeeTerms::Emblem(_) => None, + FeeTerms::Charter { .. } => { + let facts = crate::stdb::Coordinator::guild_character_facts(self, actor_guid)? + .ok_or_else(|| anyhow!("Guild Charter owner {actor_guid} is unreadable"))?; + Some(CharterOwner { + name: facts.name, + team: lyracore_shared::faction::team_for_race(facts.race), + }) + } + }; + self.realm_core()?.decide_guild_fee(actor_guid, hold, owner) + } + + fn guild_fee_finish(&self, actor_guid: u64, operation_id: u64, accepted: bool) -> Result<()> { + self.finish_guild_fee(actor_guid, operation_id, accepted) + } +} + +impl Coordinator { + /// The Fee Hold of `payer_guid` in THIS handle's cache. Call it on the payer's Home Shard. A + /// kind this Gateway does not know reads as none. + pub(crate) fn guild_fee_hold_row(&self, payer_guid: u64) -> Option { + let hold = self + .0 + .coord() + .conn + .db + .game_guild_fee_hold() + .payer_guid() + .find(&payer_guid)?; + let terms = match hold.kind { + lyracore_shared::guild::fee_kind::EMBLEM => { + guild_fee::FeeTerms::Emblem(guild_fee::Emblem { + emblem_style: hold.emblem_style, + emblem_color: hold.emblem_color, + border_style: hold.border_style, + border_color: hold.border_color, + background_color: hold.background_color, + }) + } + lyracore_shared::guild::fee_kind::CHARTER => guild_fee::FeeTerms::Charter { + charter_item_guid: hold.charter_item_guid, + name: hold.charter_name, + }, + _ => return None, + }; + Some(guild_fee::FeeHold { + operation_id: hold.operation_id, + terms, + }) + } + + /// Realm-core's fee decision for `operation_id` in THIS handle's cache. Call it on the + /// Realm-core handle. + pub(crate) fn guild_fee_decision_row(&self, operation_id: u64) -> Option { + self.0 + .coord() + .conn + .db + .game_guild_fee_decision() + .operation_id() + .find(&operation_id) + } + + /// `gw_guild_fee_hold` on THIS handle, the actor's Home Shard, under a fresh operation id, then + /// the Hold once this handle's cache shows it: the hold mints a Guild Charter's guid. A tagged + /// Refusal answers `Ok(Err(_))`. The call is made once per operation id and never retried. + pub(crate) fn hold_guild_fee( + &self, + actor_guid: u64, + request: guild_fee::FeeRequest, + ) -> Result> { + let operation_id = next_operation_id()?; + let request = match request { + guild_fee::FeeRequest::Emblem { npc_guid, emblem } => { + GuildFeeRequest::Emblem(GuildEmblemPurchase { + npc_guid, + emblem: guild_emblem(emblem), + }) + } + guild_fee::FeeRequest::Charter { npc_guid, name } => { + GuildFeeRequest::Charter(GuildCharterPurchase { npc_guid, name }) + } + }; + let result = call_reducer!( + self.0.call_pipe().conn.reducers, + "gw_guild_fee_hold", + gw_guild_fee_hold_then(operation_id, self.actor_or_owner(actor_guid), request) + ); + match result { + Ok(()) => wait_for_cache_row(operation_id, "guild Fee Hold", || { + self.guild_fee_hold_row(actor_guid) + .filter(|hold| hold.operation_id == operation_id) + }) + .map(Ok), + Err(error) => match reducer_refusal_reason(&error).and_then(GuildRefusal::parse_tag) { + Some(refusal) => Ok(Err(refusal)), + None => Err(error), + }, + } + } + + /// `realm_guild_fee_decide` on THIS handle, Realm-core, then the decision once this handle's + /// cache shows it. The reducer is idempotent, so a re-drive calls it again. A Charter needs + /// `owner`, the facts Realm-core cannot read. + pub(crate) fn decide_guild_fee( + &self, + actor_guid: u64, + hold: guild_fee::FeeHold, + owner: Option, + ) -> Result { + let terms = match (hold.terms, owner) { + (guild_fee::FeeTerms::Emblem(emblem), _) => GuildFeeTerms::Emblem(guild_emblem(emblem)), + ( + guild_fee::FeeTerms::Charter { + charter_item_guid, + name, + }, + Some(owner), + ) => GuildFeeTerms::Charter(GuildCharterTerms { + charter_item_guid, + name, + payer_name: owner.name, + payer_team: owner.team, + }), + (guild_fee::FeeTerms::Charter { .. }, None) => { + return Err(anyhow!( + "Guild Charter decision {} has no owner facts", + hold.operation_id + )); + } + }; + call_reducer!( + self.0.call_pipe().conn.reducers, + "realm_guild_fee_decide", + realm_guild_fee_decide_then(hold.operation_id, self.actor_or_owner(actor_guid), terms) + )?; + let decision = wait_for_cache_row(hold.operation_id, "guild fee decision", || { + self.guild_fee_decision_row(hold.operation_id) + })?; + fee_outcome(&decision) + } + + /// `gw_guild_fee_finish` on THIS handle, the actor's Home Shard. + pub(crate) fn finish_guild_fee( + &self, + actor_guid: u64, + operation_id: u64, + accepted: bool, + ) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "gw_guild_fee_finish", + gw_guild_fee_finish_then(operation_id, self.actor_or_owner(actor_guid), accepted) + ) + } +} + +fn guild_emblem(emblem: guild_fee::Emblem) -> GuildEmblem { + GuildEmblem { + emblem_style: emblem.emblem_style, + emblem_color: emblem.emblem_color, + border_style: emblem.border_style, + border_color: emblem.border_color, + background_color: emblem.background_color, + } +} + +/// Realm-core's decision row as a fee outcome. A refusal tag this Gateway does not know is an +/// error, so the Hold stays for a Gateway that does. +fn fee_outcome(decision: &GuildFeeDecision) -> Result { + if decision.accepted { + return Ok(guild_fee::FeeOutcome::Accepted); + } + GuildRefusal::parse_tag(&decision.refusal) + .map(guild_fee::FeeOutcome::Refused) + .ok_or_else(|| { + anyhow!( + "guild fee decision {} carries an unknown refusal {:?}", + decision.operation_id, + decision.refusal + ) + }) +} + +#[cfg(test)] +mod guild_fee_reducer_tests { + use super::*; + + fn decision(accepted: bool, refusal: &str) -> GuildFeeDecision { + GuildFeeDecision { + operation_id: 9, + payer_guid: 5_090_401, + kind: lyracore_shared::guild::fee_kind::EMBLEM, + accepted, + refusal: refusal.into(), + petition_id: 0, + decided_micros: 0, + } + } + + #[test] + fn a_decision_row_maps_to_its_fee_outcome() { + assert_eq!( + fee_outcome(&decision(true, "")).unwrap(), + guild_fee::FeeOutcome::Accepted + ); + assert_eq!( + fee_outcome(&decision(false, "guild:not_leader")).unwrap(), + guild_fee::FeeOutcome::Refused(GuildRefusal::NotLeader) + ); + assert!( + fee_outcome(&decision(false, "guild:from_a_newer_module")).is_err(), + "an unknown refusal must not read as a spend or a refund" + ); + } +} diff --git a/gateway/src/stdb/store/item.rs b/gateway/src/stdb/store/item.rs new file mode 100644 index 000000000..419ce3868 --- /dev/null +++ b/gateway/src/stdb/store/item.rs @@ -0,0 +1,191 @@ +//! `Coordinator`'s [`ItemActionStore`] adapter. + +use anyhow::Result; +use lyracore_shared::item::ItemRefusal; + +use crate::stdb::bindings::*; +use crate::stdb::connection::{call_reducer, reducer_refusal_reason}; +use crate::stdb::Coordinator; +use crate::world::{Actor, ItemActionResult, ItemActionStore}; + +impl ItemActionStore for crate::stdb::Coordinator { + fn equip_item( + &self, + account_id: u64, + actor_guid: u64, + from_slot: u8, + ) -> Result { + crate::stdb::Coordinator::equip_item(self, account_id, actor_guid, from_slot) + } + + fn unequip_item( + &self, + account_id: u64, + actor_guid: u64, + from_slot: u8, + ) -> Result { + crate::stdb::Coordinator::unequip_item(self, account_id, actor_guid, from_slot) + } + + fn move_item( + &self, + account_id: u64, + actor_guid: u64, + from_slot: u8, + to_slot: u8, + ) -> Result { + crate::stdb::Coordinator::move_item(self, account_id, actor_guid, from_slot, to_slot) + } + + fn use_item(&self, account_id: u64, actor_guid: u64, slot: u8) -> Result { + crate::stdb::Coordinator::use_item(self, account_id, actor_guid, slot) + } +} + +impl Coordinator { + /// Equip the item in main-inventory `from_slot` (`CMSG_AUTOEQUIP_ITEM`) over the coordinator + /// connection. The module resolves the matching equipment slot and gates the required level. + /// Rides the coordinator connection as `gw_equip_item`. + pub fn equip_item( + &self, + _account_id: u64, + actor_guid: u64, + from_slot: u8, + ) -> Result { + let Some(actor) = resolved_item_actor("equip_item", actor_guid) else { + return Ok(ItemRefusal::Internal.into()); + }; + let coord = self.0.call_pipe(); + item_action(call_reducer!( + coord.conn.reducers, + "gw_equip_item", + gw_equip_item_then(self.session_actor(actor), from_slot) + )) + } + + /// Unequip the item in equipment `from_slot` to a free backpack slot (`CMSG_AUTOSTORE_BAG_ITEM`) + /// over the coordinator connection. The module gates "is equipped" + "backpack has room". + pub fn unequip_item( + &self, + _account_id: u64, + actor_guid: u64, + from_slot: u8, + ) -> Result { + let Some(actor) = resolved_item_actor("unequip_item", actor_guid) else { + return Ok(ItemRefusal::Internal.into()); + }; + let coord = self.0.call_pipe(); + item_action(call_reducer!( + coord.conn.reducers, + "gw_unequip_item", + gw_unequip_item_then(self.session_actor(actor), from_slot) + )) + } + + /// Use the consumable in main-inventory `slot` (`CMSG_USE_ITEM`) over the coordinator connection — + /// eat/drink/potion/bandage. The module applies the on-use effect (flat heal for slice food) and + /// decrements the stack; a gameplay `Err` (no item / not usable) is per-action. + pub fn use_item( + &self, + _account_id: u64, + actor_guid: u64, + slot: u8, + ) -> Result { + let Some(actor) = resolved_item_actor("use_item", actor_guid) else { + return Ok(ItemRefusal::Internal.into()); + }; + let coord = self.0.call_pipe(); + item_action(call_reducer!( + coord.conn.reducers, + "gw_use_item", + gw_use_item_then(self.session_actor(actor), slot) + )) + } + + /// Move (or swap) main-inventory `from_slot` → `to_slot` (`CMSG_SWAP_INV_ITEM`/`CMSG_SWAP_ITEM`) + /// over the coordinator connection. The module's move primitive validates equip-slot transitions. + pub fn move_item( + &self, + _account_id: u64, + actor_guid: u64, + from_slot: u8, + to_slot: u8, + ) -> Result { + let Some(actor) = resolved_item_actor("move_item", actor_guid) else { + return Ok(ItemRefusal::Internal.into()); + }; + let coord = self.0.call_pipe(); + item_action(call_reducer!( + coord.conn.reducers, + "gw_move_item", + gw_move_item_then(self.session_actor(actor), from_slot, to_slot) + )) + } +} + +/// The Module's typed item Refusal, on the same rule as the auction family: only a reducer the +/// Module rejected carries a tag, so a timeout or transport failure keeps its unknown outcome. +fn item_action(result: Result<()>) -> Result { + match result { + Ok(()) => Ok(ItemActionResult::Done), + Err(error) => match reducer_refusal_reason(&error).and_then(ItemRefusal::parse_tag) { + Some(refusal) => Ok(refusal.into()), + None => Err(error), + }, + } +} + +/// An item action needs the caller's own entity. Without one there is nothing to request, so the +/// client gets a Refusal rather than a dead session. +fn resolved_item_actor(operation: &str, actor_guid: u64) -> Option { + let actor = Actor::new(actor_guid); + if actor.is_none() { + log::warn!("stdb: {operation} has no resolved actor"); + } + actor +} + +#[cfg(test)] +mod item_reducer_tests { + use super::*; + use crate::stdb::connection::ReducerCallError; + use anyhow::anyhow; + + #[test] + fn only_a_rejected_reducer_carries_a_typed_refusal() { + for refusal in ItemRefusal::ALL { + let rejected = Err(anyhow::Error::from(ReducerCallError::Rejected { + operation: "gw_move_item".to_string(), + reason: refusal.as_tag().to_string(), + }) + .context("move phase")); + assert_eq!( + item_action(rejected).unwrap(), + ItemActionResult::Refused(refusal) + ); + } + + assert_eq!(item_action(Ok(())).unwrap(), ItemActionResult::Done); + + let not_refusals = [ + anyhow::Error::from(ReducerCallError::fatal( + "gw_move_item reducer timed out after 10s".to_string(), + )), + anyhow::Error::from(ReducerCallError::fatal( + "gw_use_item reducer failed: transport disconnected".to_string(), + )), + anyhow::Error::from(ReducerCallError::Rejected { + operation: "gw_equip_item".to_string(), + reason: "operator only".to_string(), + }), + anyhow!( + "wrapped text that mentions {}", + ItemRefusal::Internal.as_tag() + ), + ]; + for error in not_refusals { + let text = format!("{error:#}"); + assert!(item_action(Err(error)).is_err(), "{text}"); + } + } +} diff --git a/gateway/src/stdb/store/loot_roll.rs b/gateway/src/stdb/store/loot_roll.rs index a898c8341..7be66d864 100644 --- a/gateway/src/stdb/store/loot_roll.rs +++ b/gateway/src/stdb/store/loot_roll.rs @@ -1,11 +1,14 @@ //! `Coordinator`'s [`LootRollStore`] adapter. -use anyhow::Result; - -use crate::world::loot::{LootRollStore, PendingLootRoll}; -use crate::world::LootActionStatus; +use anyhow::{anyhow, Result}; +use lyracore_shared::loot::LootRefusal; +use spacetimedb_sdk::Table; +use crate::stdb::bindings::*; +use crate::stdb::connection::{call_reducer, reducer_refusal_reason}; use crate::stdb::Coordinator; +use crate::world::loot::{LootRollStore, PendingLootRoll}; +use crate::world::{Actor, LootActionStatus}; impl LootRollStore for Coordinator { fn loot_roll( @@ -85,3 +88,268 @@ impl LootRollStore for Coordinator { self.loot_master_give(account_id, self_guid, corpse_guid, loot_slot, target_guid) } } + +impl Coordinator { + /// Every UNRESOLVED `game_loot_roll` row on THIS handle's database, joined with its votes. + /// + /// A cache read, like [`group_roster`](Self::group_roster) — this table is now part of the + /// coordinator subscription list, so no reducer call is + /// needed. Meaningful only on a WORLD SHARD in a sharded deployment: realm-core never has a row + /// here that a world shard wrote (only its own `realm_loot_op` START arm inserts there), and on + /// an unsharded gateway the relay that calls this never runs at all (`realm_store()` is `None`). + pub fn pending_local_rolls(&self) -> Result> { + let guard = self.0.coord(); + let db = &guard.conn.db; + Ok(db + .game_loot_roll() + .iter() + .filter(|r| !r.resolved) + .map(|r| { + let recipients = db + .game_loot_roll_vote() + .iter() + .filter(|v| v.roll_id == r.id) + .map(|v| v.voter_guid) + .collect(); + crate::world::loot::PendingLootRoll { + roll_id: r.id, + corpse_guid: r.corpse_guid, + slot: r.slot, + item_entry: r.item_entry, + deadline_micros: r.deadline_micros, + recipients, + random_property_id: r.random_property_id, + promotion_source: r.promotion_source, + } + }) + .collect()) + } + + // The return tuple is the poll result: the new watermark plus the `(corpse, slot, winner)` triples read since the old one. + #[allow(clippy::type_complexity)] + /// Every `ROLL_WON` `game_group_event` row on THIS handle's database with `id > after_id`, + /// decoded to `(corpse_guid, slot, winner_guid)`, plus the new high-water mark (the max id seen, + /// or `after_id` unchanged if none). Meaningful on the **realm-core** handle: that is the only + /// database `resolve_roll`/`force_resolve_rolls_for_disband` push a `ROLL_WON` event on in a + /// sharded deployment (voting is routed there exclusively — `world::loot::run_vote`). + /// + /// An unparseable payload is skipped + logged rather than failing the whole scan — the module + /// writes this grammar, so a decode failure here means the two crates' `lyracore_shared::loot_roll` + /// copies have drifted, not that this event is meaningless. + pub fn loot_won_since(&self, after_id: u64) -> Result<(u64, Vec<(u64, u8, u64)>)> { + use lyracore_shared::loot_roll::event_kind as roll_kind; + let guard = self.0.coord(); + let db = &guard.conn.db; + let mut watermark = after_id; + let mut wins = Vec::new(); + for row in db.game_group_event().iter() { + if row.id <= after_id || row.kind != roll_kind::ROLL_WON { + continue; + } + watermark = watermark.max(row.id); + match lyracore_shared::loot_roll::decode_won(&row.payload) { + Some((corpse_guid, slot, ..)) => wins.push((corpse_guid, slot, row.other_guid)), + None => log::warn!( + "loot-roll relay: unparseable ROLL_WON payload {:?} (event {})", + row.payload, + row.id + ), + } + } + Ok((watermark, wins)) + } + + /// `CMSG_LOOT_ROLL` — record the caller's need/greed/pass vote on a + /// live roll. Live votes/roll numbers relay to every eligible member via the `game_group_event` + /// roll-kind rows (`stdb/subscriptions.rs`). + pub fn loot_roll( + &self, + _account_id: u64, + actor_guid: u64, + corpse_guid: u64, + loot_slot: u32, + vote: u8, + ) -> Result { + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("loot_roll: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + loot_action_status(call_reducer!( + coord.conn.reducers, + "gw_loot_roll", + gw_loot_roll_then(self.session_actor(actor), corpse_guid, loot_slot, vote) + )) + } + + /// `CMSG_LOOT_MASTER_GIVE` — the master looter assigns an above- + /// threshold row to `target_guid`. + pub fn loot_master_give( + &self, + _account_id: u64, + actor_guid: u64, + corpse_guid: u64, + loot_slot: u8, + target_guid: u64, + ) -> Result { + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("loot_master_give: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + loot_action_status(call_reducer!( + coord.conn.reducers, + "gw_loot_master_give", + gw_loot_master_give_then( + self.session_actor(actor), + corpse_guid, + loot_slot, + target_guid + ) + )) + } + + /// `realm_loot_op` — one loot-roll op against the database THIS handle points at. The + /// gateway calls it on the **realm-core** handle: START promotes a world shard's staging roll, + /// VOTE casts `CMSG_LOOT_ROLL`'s vote. + /// + /// Through the COORDINATOR connection, not the player's: the reducer is operator-gated because + /// it acts on realm-core, which has no live entity to derive an actor from. VOTE's `actor_guid` + /// is the guid this socket authenticated into the world with (`InWorld::self_guid`), never a + /// literal a client supplies; START's `recipients` are the spatial snapshot a world shard already + /// computed at kill time. + #[allow(clippy::too_many_arguments)] + pub fn realm_loot_op( + &self, + op: u8, + corpse_guid: u64, + slot: u8, + item_entry: u32, + actor_guid: u64, + vote: u8, + deadline_micros: i64, + recipients: Vec, + random_property_id: u32, + promotion_source: spacetimedb_sdk::Identity, + source_roll_id: u64, + ) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "realm_loot_op", + realm_loot_op_then( + op, + corpse_guid, + slot, + item_entry, + self.actor_or_owner(actor_guid), + vote, + deadline_micros, + recipients, + random_property_id, + promotion_source, + source_roll_id + ) + ) + } + + /// Cast a realm-core Loot Roll vote and classify only a known typed Refusal as an answer. + pub fn realm_loot_vote( + &self, + corpse_guid: u64, + slot: u8, + actor_guid: u64, + vote: u8, + ) -> Result { + loot_action_status(call_reducer!( + self.0.call_pipe().conn.reducers, + "realm_loot_op", + realm_loot_op_then( + lyracore_shared::loot_roll::loot_op::VOTE, + corpse_guid, + slot, + 0, + self.actor_or_owner(actor_guid), + vote, + 0, + Vec::new(), + 0, + spacetimedb_sdk::Identity::ZERO, + 0 + ) + )) + } + + /// `settle_loot_roll` — grant a resolved roll's item on THIS world shard, if it holds the + /// matching corpse row. Operator-gated, coordinator connection; the loot-roll relay calls + /// it on every connected world shard after observing realm-core's `ROLL_WON` event — the + /// module's own `withheld` guard makes a wrong-shard call a harmless no-op. + pub fn settle_loot_roll(&self, corpse_guid: u64, slot: u8, winner_guid: u64) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "settle_loot_roll", + settle_loot_roll_then(corpse_guid, slot, winner_guid, self.owner_actor()) + ) + } + + /// `clear_promoted_loot_roll` — delete a staging roll's rows on THIS world shard, once the + /// loot-roll relay has promoted it onto realm-core. Operator-gated, coordinator connection. + pub fn clear_promoted_loot_roll(&self, roll_id: u64) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "clear_promoted_loot_roll", + clear_promoted_loot_roll_then(roll_id, self.owner_actor()) + ) + } +} + +/// The typed answer for Loot Roll and master-loot requests. An unknown rejection or transport +/// failure stays an error with an unknown durable result. +fn loot_action_status(result: Result<()>) -> Result { + match result { + Ok(()) => Ok(LootActionStatus::Applied), + Err(error) => match reducer_refusal_reason(&error).and_then(LootRefusal::parse_tag) { + Some(refusal) => { + log::debug!("stdb: loot action refused: {error:#}"); + Ok(LootActionStatus::Refused(refusal)) + } + None => Err(error), + }, + } +} + +#[cfg(test)] +mod loot_reducer_tests { + use super::*; + use crate::stdb::connection::ReducerCallError; + + fn rejected(reason: &str) -> Result<()> { + Err(anyhow::Error::from(ReducerCallError::Rejected { + operation: "gw_take_loot".to_string(), + reason: reason.to_string(), + }) + .context("loot window")) + } + + #[test] + fn loot_action_status_keeps_unknown_results_as_errors() { + for refusal in [ + LootRefusal::RollUnavailable, + LootRefusal::NotMasterLooter, + LootRefusal::RecipientUnavailable, + LootRefusal::RecipientInventoryFull, + ] { + assert_eq!( + loot_action_status(rejected(refusal.as_tag())).unwrap(), + LootActionStatus::Refused(refusal) + ); + } + + for error in [ + anyhow::Error::from(ReducerCallError::Rejected { + operation: "gw_loot_roll".to_string(), + reason: "loot:newer_module_refusal".to_string(), + }), + anyhow::Error::from(ReducerCallError::fatal( + "gw_loot_roll reducer timed out after 10s".to_string(), + )), + ] { + assert!(loot_action_status(Err(error)).is_err()); + } + } +} diff --git a/gateway/src/stdb/store/loot_window.rs b/gateway/src/stdb/store/loot_window.rs new file mode 100644 index 000000000..e39f8600e --- /dev/null +++ b/gateway/src/stdb/store/loot_window.rs @@ -0,0 +1,656 @@ +//! `Coordinator`'s [`LootWindowStore`] adapter. + +use anyhow::{anyhow, Result}; +use lyracore_shared::group::GroupKind; +use lyracore_shared::loot::{LootBoundaryFailure, LootRefusal}; +use spacetimedb_sdk::Table; + +use crate::codec; +use crate::stdb::bindings::*; +use crate::stdb::connection::{call_reducer, reducer_refusal_reason, LiveConn}; +use crate::stdb::reads::player_item_count; +use crate::stdb::Coordinator; +use crate::world::{Actor, LootWindowRefusal, LootWindowRequestStatus, LootWindowStore}; + +impl LootWindowStore for crate::stdb::Coordinator { + fn loot_target_money(&self, target_guid: u64) -> Result { + crate::stdb::Coordinator::loot_target_money(self, target_guid) + } + + fn loot_target_items( + &self, + target_guid: u64, + viewer_guid: u64, + ) -> Result> { + crate::stdb::Coordinator::corpse_loot(self, target_guid, viewer_guid) + } + + fn use_gameobject( + &self, + account_id: u64, + actor_guid: u64, + target_guid: u64, + ) -> Result { + crate::stdb::Coordinator::use_gameobject(self, account_id, actor_guid, target_guid) + } + + fn open_creature_loot( + &self, + account_id: u64, + actor_guid: u64, + corpse_guid: u64, + ) -> Result { + crate::stdb::Coordinator::open_creature_loot(self, account_id, actor_guid, corpse_guid) + } + + fn skin_corpse( + &self, + account_id: u64, + actor_guid: u64, + target_guid: u64, + ) -> Result { + crate::stdb::Coordinator::skin_corpse(self, account_id, actor_guid, target_guid) + } + + fn loot_money( + &self, + account_id: u64, + actor_guid: u64, + target_guid: u64, + ) -> Result { + crate::stdb::Coordinator::loot_money(self, account_id, actor_guid, target_guid) + } + + fn take_loot( + &self, + account_id: u64, + actor_guid: u64, + target_guid: u64, + loot_slot: u8, + ) -> Result { + crate::stdb::Coordinator::take_loot(self, account_id, actor_guid, target_guid, loot_slot) + } +} + +impl Coordinator { + /// Read a corpse's item loot for the loot window, joined with each item's + /// template for the display id, then filtered PER VIEWER for `quest_only` rows (quest items are + /// per-looter, not gated on whoever got kill credit) AND group-loot rows (a live NEED/GREED + /// roll is withheld from EVERYONE; a round-robin/master-designated row is visible only to its + /// designee). Read from the privileged cache (the coordinator bypasses RLS), filtered by + /// `corpse_guid` (iterate+filter, like the other row queries). Returns `(slot, item_id, count, + /// display_id)` triples for `build_loot_response_raw`. An item whose template isn't loaded falls + /// back to display 0 (the client still resolves the name via query). + pub fn corpse_loot( + &self, + corpse_guid: u64, + viewer_guid: u64, + ) -> Result> { + let guard = self.0.coord(); + let db = &guard.conn.db; + let mut items: Vec = db + .game_corpse_loot() + .iter() + .filter(|l| l.corpse_guid == corpse_guid) + .filter(|l| { + if l.quest_only { + let needs = viewer_needs_quest_item(&guard, viewer_guid, l.item_entry); + quest_row_visible_to_viewer(l.quest_only, l.reserved_for, viewer_guid, needs) + } else { + group_loot_row_visible_to_viewer( + l.reserved_for, + l.withheld, + l.master_only, + l.designated_looter_guid, + viewer_guid, + ) + } + }) + .map(|l| { + let display_id = db + .game_item_template() + .entry() + .find(&l.item_entry) + .map(|t| t.display_id) + .unwrap_or(0); + ( + l.slot, + l.item_entry, + l.count, + display_id, + l.random_property_id, + ) + }) + .collect(); + items.sort_by_key(|(slot, ..)| *slot); // stable loot-slot order (SQL has no ORDER BY in 2.5) + Ok(items) + } + + /// Read a corpse's lootable copper for `SMSG_LOOT_RESPONSE` from the privileged cache. + /// Returns 0 if the target is missing or not a corpse — the client only sends `CMSG_LOOT` on a + /// lootable corpse, but we stay defensive (an empty loot window is harmless). + pub fn loot_target_money(&self, target_guid: u64) -> Result { + Ok(self + .0 + .coord() + .conn + .db + .game_world_entity() + .guid() + .find(&target_guid) + .filter(|e| e.dead) + .map(|e| e.money) + .unwrap_or(0)) + } + + /// Use a gameobject (`CMSG_GAMEOBJ_USE`) — a chest rolls its loot into the corpse-loot table keyed + /// on the GO guid, a quest-use object grants quest credit. The module gates range + type. + /// Rides the coordinator connection as `gw_use_gameobject`. + pub fn use_gameobject( + &self, + _account_id: u64, + actor_guid: u64, + go_guid: u64, + ) -> Result { + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("use_gameobject: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + legacy_loot_request_status(call_reducer!( + coord.conn.reducers, + "gw_use_gameobject", + gw_use_gameobject_then(self.session_actor(actor), go_guid) + )) + } + + /// Take the money from a corpse (`CMSG_LOOT_MONEY`) over the coordinator connection so + /// the module attributes the loot to the caller (as `gw_loot_money`). + pub fn loot_money( + &self, + _account_id: u64, + actor_guid: u64, + target_guid: u64, + ) -> Result { + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("loot_money: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + strict_loot_request_status(call_reducer!( + coord.conn.reducers, + "gw_loot_money", + gw_loot_money_then(self.session_actor(actor), target_guid) + )) + } + + /// Authorize opening a creature corpse before the Gateway reads its loot rows. + pub fn open_creature_loot( + &self, + _account_id: u64, + actor_guid: u64, + corpse_guid: u64, + ) -> Result { + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("open_creature_loot: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + strict_loot_request_status(call_reducer!( + coord.conn.reducers, + "gw_open_creature_loot", + gw_open_creature_loot_then(self.session_actor(actor), corpse_guid) + )) + } + + /// Take one item from the open corpse into the backpack (`CMSG_AUTOSTORE_LOOT_ITEM`) over + /// the coordinator connection so the module attributes the loot to the caller. The module moves the + /// item into a free slot + deletes the corpse-loot row (the inventory relay then shows it in the bag). + /// Rides the coordinator connection as `gw_take_loot`. + pub fn take_loot( + &self, + _account_id: u64, + actor_guid: u64, + corpse_guid: u64, + loot_slot: u8, + ) -> Result { + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("take_loot: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + strict_loot_request_status(call_reducer!( + coord.conn.reducers, + "gw_take_loot", + gw_take_loot_then(self.session_actor(actor), corpse_guid, loot_slot) + )) + } + + pub fn skin_corpse( + &self, + _account_id: u64, + actor_guid: u64, + corpse_guid: u64, + ) -> Result { + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("skin_corpse: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + legacy_loot_request_status(call_reducer!( + coord.conn.reducers, + "gw_skin", + gw_skin_then(self.session_actor(actor), corpse_guid) + )) + } +} + +/// Whether `viewer_guid` is in a Raid, read from this handle's cache: the `PartyMembershipIndex` +/// (an indexed lookup, never a table scan) names the group, and the cached `game_group.group_type` +/// names its kind. The Gateway twin of the Module's `group::in_raid`. +fn viewer_in_raid(guard: &LiveConn, viewer_guid: u64) -> bool { + let Some(group_id) = guard + .party_memberships + .read() + .unwrap() + .group_of(viewer_guid) + else { + return false; + }; + guard + .conn + .db + .game_group() + .group_id() + .find(&group_id) + .is_some_and(|group| { + GroupKind::from_wire(group.group_type).unwrap_or_default() == GroupKind::Raid + }) +} + +/// Does `viewer_guid` currently need quest item `item_entry`? The gateway twin +/// of the module's `loot::killer_needs_item`/`needs_item_pure` (an ACTIVE — un-rewarded — quest with a +/// `COLLECT_ITEM` objective naming `item_entry`, held < required), applied to the VIEWER opening the +/// loot window rather than the credited killer, and gated by the same raid quest-credit rule +/// (`lyracore_shared::quest::quest_progresses_for_raid`) so the loot window never promises an item +/// a Raid member's `take_loot` would then refuse (that Gate is `loot::killer_needs_item`, gated +/// identically). RLS-bypassed read (coordinator), same shape as +/// `quest_objectives_complete`/`player_item_count` above. +fn viewer_needs_quest_item(guard: &LiveConn, viewer_guid: u64, item_entry: u32) -> bool { + const COLLECT_ITEM: u8 = 1; // == module objective_kind::COLLECT_ITEM + let db = &guard.conn.db; + let active: Vec = db + .game_character_quest() + .iter() + .filter(|q| q.character_guid == viewer_guid && !q.rewarded) + .map(|q| q.quest_entry) + .collect(); + if active.is_empty() { + return false; + } + let in_raid = viewer_in_raid(guard, viewer_guid); + let objectives: Vec<(u32, u32, u32)> = db + .game_quest_objective() + .iter() + .filter(|o| o.kind == COLLECT_ITEM && active.contains(&o.quest_entry)) + .map(|o| { + let quest_type = db + .game_quest_template() + .entry() + .find(&o.quest_entry) + .map_or(0, |t| t.quest_type); + (quest_type, o.target_entry, o.required_count) + }) + .collect(); + needs_quest_item_pure( + in_raid, + &objectives, + item_entry, + player_item_count(db, viewer_guid, item_entry), + ) +} + +/// Pure decision behind `viewer_needs_quest_item`: does any of `objectives` (flattened +/// `(quest_type, target_entry, required_count)` for the viewer's active COLLECT_ITEM objectives) +/// want `item`, given the viewer's Raid membership? Split out so the raid quest-credit rule's +/// wiring is unit-testable without a live cache, mirroring the module's `needs_item_pure`. +fn needs_quest_item_pure( + in_raid: bool, + objectives: &[(u32, u32, u32)], + item: u32, + held: u32, +) -> bool { + objectives + .iter() + .any(|&(quest_type, target_entry, required_count)| { + target_entry == item + && held < required_count.max(1) + && lyracore_shared::quest::quest_progresses_for_raid(in_raid, quest_type) + }) +} + +/// The per-viewer loot-window visibility gate: is a `game_corpse_loot` row +/// visible to `viewer_guid`? A non-quest row is always visible (FFA, unconditional). A `quest_only` +/// row is visible when EITHER it's still the UNRESERVED shared row +/// (`reserved_for == 0` — nobody has split it yet) and the viewer currently needs the item +/// (`viewer_needs_item`, resolved by the caller — mirrors the module's ctx/pure split so this decision +/// needs no live cache to unit-test), OR it's the viewer's OWN already-split reserved clone +/// (`reserved_for == viewer_guid`) — a clone reserved for someone ELSE is invisible regardless of need. +/// Mirrored module-side by `loot::quest_take_allowed` (same predicate shape) so a row a viewer's +/// window shows is always a row their take can actually succeed on. Pure. +pub(crate) fn quest_row_visible_to_viewer( + quest_only: bool, + reserved_for: u64, + viewer_guid: u64, + viewer_needs_item: bool, +) -> bool { + if !quest_only { + return true; + } + if reserved_for == viewer_guid { + return true; + } + reserved_for == 0 && viewer_needs_item +} + +/// The per-viewer loot-window visibility gate for a NON-quest row (the +/// caller only calls this when `!quest_only`; `quest_row_visible_to_viewer` handles quest rows, +/// unchanged, above). `reserved_for` is GENERALIZED here beyond the quest clone: nonzero also covers +/// a NEED/GREED winner's inventory-full fallback row (`resolve_roll`'s module doc) — either way, +/// nonzero means "visible ONLY to that guid", full stop. A `withheld` row (a live roll in progress) +/// is invisible to EVERYONE (not just non-eligible viewers — matches the design's "withheld rows +/// invisible in EVERYONE's window until resolved", proving the AutoLoot-safety trap: a grey + +/// green drop on one corpse has the grey's row visible/FFA while the green's is withheld). A +/// `master_only` row is never shown via the plain window (the master acts through +/// `SMSG_LOOT_MASTER_LIST`/`CMSG_LOOT_MASTER_GIVE` instead). A `designated_looter_guid` row (round- +/// robin/below-threshold) is visible only to its designee. The all-zero/false baseline is plain FFA +/// (byte-identical to the original baseline). Mirrored module-side by `loot::group_loot_take_allowed` +/// (same predicate shape) so a row a viewer's window shows is always a row their take can succeed +/// on. Pure. +pub(crate) fn group_loot_row_visible_to_viewer( + reserved_for: u64, + withheld: bool, + master_only: bool, + designated_looter_guid: u64, + viewer_guid: u64, +) -> bool { + if reserved_for != 0 { + return reserved_for == viewer_guid; + } + if withheld || master_only { + return false; + } + designated_looter_guid == 0 || designated_looter_guid == viewer_guid +} + +#[derive(Clone, Copy)] +enum UntaggedLootRejection { + Fatal, + LegacyUnanswered, +} + +/// Classify a Durable Request from a core whose gameplay refusals all have loot tags. +fn strict_loot_request_status(result: Result<()>) -> Result { + loot_request_status(result, UntaggedLootRejection::Fatal) +} + +/// Preserve silent gameplay refusals from the legacy GameObject and skinning cores. Boundary +/// failures and every tagged result remain explicit, so this compatibility cannot hide them. +fn legacy_loot_request_status(result: Result<()>) -> Result { + loot_request_status(result, UntaggedLootRejection::LegacyUnanswered) +} + +fn loot_request_status( + result: Result<()>, + untagged: UntaggedLootRejection, +) -> Result { + match result { + Ok(()) => Ok(LootWindowRequestStatus::Applied), + Err(error) => match reducer_refusal_reason(&error) { + Some(reason) => { + if LootBoundaryFailure::parse_tag(reason).is_some() { + return Err(error); + } + if let Some(refusal) = LootRefusal::parse_tag(reason) { + log::debug!("stdb: loot Durable Request refused: {error:#}"); + return Ok(LootWindowRequestStatus::Refused(refusal.into())); + } + if reason.starts_with("loot:") { + return Err(error); + } + match untagged { + UntaggedLootRejection::LegacyUnanswered => { + log::debug!("stdb: legacy loot Durable Request refused: {error:#}"); + Ok(LootWindowRequestStatus::Refused( + LootWindowRefusal::Unanswered, + )) + } + UntaggedLootRejection::Fatal => Err(error), + } + } + None => Err(error), + }, + } +} + +#[cfg(test)] +mod tests { + use super::{ + group_loot_row_visible_to_viewer, needs_quest_item_pure, quest_row_visible_to_viewer, + }; + + // The per-viewer loot-window visibility gate. Every other read in this + // file goes through the coordinator's live cache (`RemoteTables`) and has no fake-cache harness to + // unit-test against (the module crate's "never mock the ctx, extract + test pure fns" rule applies + // here too). `quest_row_visible_to_viewer` and `needs_quest_item_pure` are the decisions worth + // pulling out pure so they're directly testable without a live SpacetimeDB connection. + + /// `needs_quest_item_pure` mirrors the Module's raid quest-credit rule: a Raid member's + /// non-Raid quest stops needing its item, a Raid Quest's need is unaffected, and a Party + /// member (in_raid = false) is unaffected either way. Hand-written values, not computed by + /// the code under test. + #[test] + fn needs_quest_item_pure_gates_a_raid_members_non_raid_quest() { + let normal_quest_wants_the_item = [(0, 55, 1)]; // (quest_type, target_entry, required_count) + let raid_quest_wants_the_item = [(62, 55, 1)]; + assert!( + needs_quest_item_pure(false, &normal_quest_wants_the_item, 55, 0), + "outside a Raid, a normal quest's need applies" + ); + assert!( + !needs_quest_item_pure(true, &normal_quest_wants_the_item, 55, 0), + "inside a Raid, a normal quest's item need is gated off" + ); + assert!( + needs_quest_item_pure(true, &raid_quest_wants_the_item, 55, 0), + "inside a Raid, a Raid Quest's need still applies" + ); + assert!( + !needs_quest_item_pure(false, &normal_quest_wants_the_item, 55, 1), + "held at or above the requirement never needs it" + ); + assert!( + !needs_quest_item_pure(true, &raid_quest_wants_the_item, 999, 0), + "a different item entry is never needed" + ); + } + + #[test] + fn non_quest_rows_are_always_visible_regardless_of_viewer_state() { + // FFA, unconditional — the original baseline behavior. reserved_for/need are irrelevant. + assert!(quest_row_visible_to_viewer(false, 0, 7, false)); + assert!(quest_row_visible_to_viewer(false, 99, 7, false)); + assert!(quest_row_visible_to_viewer(false, 7, 7, false)); + } + + #[test] + fn an_unreserved_quest_row_is_visible_only_to_a_needing_viewer() { + assert!( + quest_row_visible_to_viewer(true, 0, 7, true), + "needs it -> sees the shared row" + ); + assert!( + !quest_row_visible_to_viewer(true, 0, 7, false), + "doesn't need it -> invisible" + ); + } + + #[test] + fn a_reserved_clone_is_visible_only_to_its_owner() { + // The viewer's OWN clone is visible even if `viewer_needs_item` somehow reads false (a stale + // held-count race) — the reservation itself is the authority once split. + assert!( + quest_row_visible_to_viewer(true, 7, 7, false), + "the viewer's own reserved clone" + ); + assert!(quest_row_visible_to_viewer(true, 7, 7, true)); + // Reserved for a DIFFERENT character — invisible to this viewer even if they also need it. + assert!( + !quest_row_visible_to_viewer(true, 7, 8, true), + "reserved for someone else" + ); + assert!(!quest_row_visible_to_viewer(true, 7, 8, false)); + } + + // ---- Group loot methods ---- + + #[test] + fn group_loot_baseline_ffa_is_visible_to_everyone() { + assert!(group_loot_row_visible_to_viewer(0, false, false, 0, 7)); + assert!(group_loot_row_visible_to_viewer(0, false, false, 0, 8)); + } + + #[test] + fn group_loot_withheld_row_is_invisible_to_everyone_including_the_eventual_winner() { + // The AutoLoot-safety trap: a live roll's row must not appear in ANYONE's window, not even + // the guid who will eventually win it. + assert!(!group_loot_row_visible_to_viewer(0, true, false, 0, 7)); + assert!(!group_loot_row_visible_to_viewer(0, true, false, 7, 7)); + } + + #[test] + fn group_loot_master_only_row_never_shows_in_the_plain_window() { + // Not even the stamped master sees it here — they act via SMSG_LOOT_MASTER_LIST instead. + assert!(!group_loot_row_visible_to_viewer(0, false, true, 42, 42)); + assert!(!group_loot_row_visible_to_viewer(0, false, true, 42, 8)); + } + + #[test] + fn group_loot_designated_row_is_visible_only_to_its_designee() { + assert!(group_loot_row_visible_to_viewer(0, false, false, 42, 42)); + assert!(!group_loot_row_visible_to_viewer(0, false, false, 42, 8)); + } + + #[test] + fn group_loot_reserved_winner_locked_row_wins_over_every_other_flag() { + // A nonzero reserved_for (the inventory-full winner fallback) is exclusive and unconditional + // — it overrides withheld/master_only/designated entirely (those are all stale by then). + assert!(group_loot_row_visible_to_viewer(7, true, true, 99, 7)); + assert!(!group_loot_row_visible_to_viewer(7, true, true, 99, 8)); + } + + /// The AutoLoot-addon trap, spelled out as ONE corpse's two rows: a grey + /// (below-threshold, FFA-in-group) item autoloots normally while a green (at/above threshold, + /// mid-roll) item on the SAME corpse is simultaneously withheld from every window — proving the + /// two rows are decided independently and a live roll can never be swept up by autostore. + #[test] + fn a_grey_autoloots_while_a_green_on_the_same_corpse_is_withheld() { + let grey = (0u64, false, false, 0u64); // FFA baseline: (reserved_for, withheld, master_only, designated) + let green = (0u64, true, false, 0u64); // a live NEED/GREED roll in progress + for viewer in [7u64, 8u64] { + assert!( + group_loot_row_visible_to_viewer(grey.0, grey.1, grey.2, grey.3, viewer), + "the grey row autoloots for any viewer" + ); + assert!( + !group_loot_row_visible_to_viewer(green.0, green.1, green.2, green.3, viewer), + "the green row is withheld from every viewer while its roll is live" + ); + } + } +} + +#[cfg(test)] +mod loot_reducer_tests { + use super::*; + use crate::stdb::connection::ReducerCallError; + + fn refusal_of( + result: Result<()>, + classify: fn(Result<()>) -> Result, + ) -> LootWindowRefusal { + match classify(result) { + Ok(LootWindowRequestStatus::Refused(refusal)) => refusal, + Ok(LootWindowRequestStatus::Applied) => panic!("a Refusal was applied"), + Err(error) => panic!("a Refusal ended the session: {error:#}"), + } + } + + fn rejected(reason: &str) -> Result<()> { + Err(anyhow::Error::from(ReducerCallError::Rejected { + operation: "gw_take_loot".to_string(), + reason: reason.to_string(), + }) + .context("loot window")) + } + + #[test] + fn every_module_refusal_tag_becomes_one_client_answer() { + for refusal in LootRefusal::ALL { + for classify in [ + strict_loot_request_status as fn(Result<()>) -> Result, + legacy_loot_request_status, + ] { + assert_eq!( + refusal_of(rejected(refusal.as_tag()), classify), + LootWindowRefusal::from(refusal), + "{refusal:?}" + ); + } + } + } + + #[test] + fn only_legacy_cores_keep_untagged_gameplay_refusals_unanswered() { + for reason in ["it is locked", "not a beast", "inventory is full"] { + assert_eq!( + refusal_of(rejected(reason), legacy_loot_request_status), + LootWindowRefusal::Unanswered, + "{reason}" + ); + assert!( + strict_loot_request_status(rejected(reason)).is_err(), + "{reason}" + ); + } + } + + #[test] + fn boundary_failures_and_unknown_loot_tags_are_fatal() { + let reasons = LootBoundaryFailure::ALL + .into_iter() + .map(LootBoundaryFailure::as_tag) + .chain(["loot:newer_module_refusal"]); + + for reason in reasons { + assert!( + strict_loot_request_status(rejected(reason)).is_err(), + "{reason}" + ); + assert!( + legacy_loot_request_status(rejected(reason)).is_err(), + "{reason}" + ); + } + } + + #[test] + fn a_timeout_is_not_answered_as_a_refusal() { + let not_refusals = [ + anyhow::Error::from(ReducerCallError::fatal( + "gw_take_loot reducer timed out after 10s".to_string(), + )), + anyhow::Error::from(ReducerCallError::fatal( + "gw_loot_money reducer failed: transport disconnected".to_string(), + )), + anyhow!( + "wrapped text that mentions {}", + LootRefusal::LootTagIneligible.as_tag() + ), + ]; + for error in not_refusals { + let text = format!("{error:#}"); + assert!(strict_loot_request_status(Err(error)).is_err(), "{text}"); + } + } +} diff --git a/gateway/src/stdb/store/mail.rs b/gateway/src/stdb/store/mail.rs index 6889bce42..1f0650daa 100644 --- a/gateway/src/stdb/store/mail.rs +++ b/gateway/src/stdb/store/mail.rs @@ -1,11 +1,13 @@ //! `Coordinator`'s [`MailStore`] adapter. use anyhow::Result; +use spacetimedb_sdk::Table; use crate::codec; -use crate::world::MailStore; - +use crate::stdb::bindings::*; +use crate::stdb::connection::call_reducer; use crate::stdb::Coordinator; +use crate::world::MailStore; impl MailStore for Coordinator { fn mail_list(&self, recipient_guid: u64) -> Result> { @@ -206,3 +208,510 @@ impl MailStore for Coordinator { self.mail_escrows_of(sender_guid) } } + +impl Coordinator { + /// Does `owner_guid` hold an item carrying `item_text_id`? `CMSG_ITEM_TEXT_QUERY`'s ownership + /// Gate: a client cannot use this to probe what anyone else holds, since the answer is a bare + /// bool. Read from the privileged cache. + /// + /// `hint_item_guid` is the wire's own second field — a bag item guid when the client is + /// reading that item's tooltip (`cm:MailHandler.cpp:630-646`), and vmangos always sends it for + /// this shape of the query. A PK lookup answers in O(1); a miss (item deleted, wrong id, or a + /// crafted query) answers `false` rather than falling back to a table-wide scan — this runs on + /// every such query, including ones for an id that names nothing, and the SDK exposes no + /// owner-keyed index to narrow it by. + pub fn owns_item_with_text( + &self, + owner_guid: u64, + item_text_id: u32, + hint_item_guid: u64, + ) -> Result { + if item_text_id == 0 || hint_item_guid == 0 { + return Ok(false); + } + let guard = self.0.coord(); + let db = &guard.conn.db; + let owns = db + .game_item_instance() + .guid() + .find(&hint_item_guid) + .is_some_and(|item| item.owner_guid == owner_guid && item.item_text_id == item_text_id); + Ok(owns) + } + + /// Every mail addressed to `recipient_guid`, delivered or not, in no set order. + /// `codec::build_mail_list` orders the inbox. The SDK exposes only the PK index, so this + /// iterates and filters like every other per-owner read here (`player_items`, `player_skills`). + pub fn mail_list(&self, recipient_guid: u64) -> Result> { + let guard = self.0.coord(); + let db = &guard.conn.db; + Ok(db + .game_mail() + .iter() + .filter(|m| m.recipient_guid == recipient_guid) + .map(|m| mail_view(db, m)) + .collect()) + } + + /// The mail `mail_id`, delivered or not, by its primary key. + pub fn mail_by_id(&self, mail_id: u64) -> Option { + let guard = self.0.coord(); + let db = &guard.conn.db; + db.game_mail().id().find(&mail_id).map(|m| mail_view(db, m)) + } + + /// Every mail escrow this database is holding for `sender_guid` — the fences a drive filed and + /// never finished. + /// + /// The one module→gateway data flow the escrow adds, and it exists for the same reason + /// `escrowed_transfer` does: the gateway is the only component that can see both databases, so + /// re-driving a stalled fence means re-deriving the whole letter from its row. A Character's fresh + /// send reads nothing more than its own fence; a Reward Letter, which the Module files at + /// turn-in, is always driven from its row. Private table, read through the owner token. + /// + /// Keyed by `sender_guid`, which on a payout escrow is the PAYEE — the character owed the + /// copper either way, and the one whose session is about to re-drive it. + pub fn mail_escrows_of(&self, sender_guid: u64) -> Result> { + let guard = self.0.coord(); + let ids = guard.mail_escrows.read().unwrap().of(sender_guid); + let escrows = guard.conn.db.game_mail_escrow(); + Ok(ids + .into_iter() + .filter_map(|id| escrows.escrow_id().find(&id)) + .filter_map(|e| { + // A row no letter could have written stays held rather than commit as the wrong + // letter. + let reward = lyracore_shared::mail::RewardHeader::from_columns( + e.sender_kind, + e.sender_entry, + e.mail_template_id, + ) + .map_err(|refusal| { + log::error!("mail escrow {}: not driven: {refusal}", e.escrow_id) + }) + .ok()?; + Some((e, reward)) + }) + .map(|(e, reward)| crate::world::mail::HeldEscrow { + escrow_id: e.escrow_id, + recipient_guid: e.recipient_guid, + subject: e.subject, + body: e.body, + money: e.money, + postage: e.postage, + payout: e.payout, + mail_id: e.mail_id, + item: crate::world::mail::AttachedItem { + entry: e.item_entry, + stack_count: e.item_stack_count, + durability: e.item_durability, + enchant_id: e.item_enchant_id, + soulbound: e.item_soulbound, + random_property_id: e.random_property_id, + item_text_id: e.item_text_id, + }, + cod: e.cod, + delivery_delay_secs: e.delivery_delay_secs, + reward, + }) + .collect()) + } + + /// A copied letter's text, read from `game_item_text` on THIS handle's database. Private table, + /// read through the owner token, by its PK — the same shape `mailbox_in_range` uses to resolve + /// a gameobject. + pub fn item_text(&self, item_text_id: u32) -> Result> { + let guard = self.0.coord(); + Ok(guard + .conn + .db + .game_item_text() + .id() + .find(&item_text_id) + .map(|t| t.text)) + } + + /// Is `player_guid` standing at the mailbox `mailbox_guid` names? + /// + /// A PK lookup on `game_gameobject`, then the same map/instance/range check + /// `module/src/gameobject.rs::usable_go` applies to a chest. It must STAY a PK lookup: + /// `game_gameobject` is spatial, and a scan over a sharded table returns a silent subset, so a + /// mailbox availability would depend on which database the session reads. + /// + /// `false` for an unknown guid, a non-mailbox gameobject, another map or instance, and anything + /// out of range — the gate answers the question, and the caller decides what a refusal costs. + pub fn mailbox_in_range(&self, mailbox_guid: u64, player_guid: u64) -> Result { + let guard = self.0.coord(); + let db = &guard.conn.db; + let (Some(go), Some(player)) = ( + db.game_gameobject().guid().find(&mailbox_guid), + db.game_world_entity().guid().find(&player_guid), + ) else { + return Ok(false); + }; + let is_mailbox = db + .game_gameobject_template() + .entry() + .find(&go.template_entry) + .is_some_and(|t| t.type_id == lyracore_shared::mail::MAILBOX_GO_TYPE); + if !is_mailbox || go.map_id != player.map_id || go.instance_id != player.instance_id { + return Ok(false); + } + let (dx, dy, dz) = (go.x - player.x, go.y - player.y, go.z - player.z); + Ok(dx * dx + dy * dy + dz * dz <= lyracore_shared::mail::MAILBOX_RANGE_SQ) + } + + /// `realm_mail_mark_read` — flip a mail's read state against the database THIS handle points + /// at. Operator-gated, `recipient_guid` passed + /// explicitly (the plane may hold no live entity), called on whichever handle `world::mail` + /// picked — realm-core when sharded, this shard's own database when not. + pub fn mail_mark_read(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "realm_mail_mark_read", + realm_mail_mark_read_then(self.actor_or_owner(recipient_guid), mail_id) + ) + } + + /// `realm_mail_delete` — [`mail_mark_read`](Self::mail_mark_read)'s twin for delete. + pub fn mail_delete(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "realm_mail_delete", + realm_mail_delete_then(self.actor_or_owner(recipient_guid), mail_id) + ) + } + + /// `realm_mail_return` — [`mail_delete`](Self::mail_delete)'s twin for return-to-sender: the row + /// is re-addressed in place, on the database THIS handle points at. No sharded variant — the row + /// never leaves the plane that already holds it. + pub fn mail_return(&self, recipient_guid: u64, mail_id: u64, same_account: bool) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "realm_mail_return", + realm_mail_return_then(self.actor_or_owner(recipient_guid), mail_id, same_account) + ) + } + + /// `realm_mail_send` — write one sent letter against the database THIS handle points at, and + /// charge the sender for it in the same transaction. Same trust shape as the two above, and the + /// guid it carries is the one the socket authenticated: every gate deciding who may write to + /// whom ran in `world::mail`, because realm-core can answer none of them. + /// + /// **The single-database plane only.** A sharded realm cannot have that one transaction and + /// drives the escrow below instead. + #[allow(clippy::too_many_arguments)] + pub fn mail_send( + &self, + sender_guid: u64, + recipient_guid: u64, + subject: String, + body: String, + money: u32, + cod: u32, + item_guid: u64, + same_account: bool, + ) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "realm_mail_send", + realm_mail_send_then( + self.actor_or_owner(sender_guid), + recipient_guid, + subject, + body, + money, + cod, + item_guid, + same_account + ) + ) + } + + /// `realm_mail_take_money` — credit a mail's copper to the recipient and empty the row, in one + /// transaction. The single-database plane's whole take, for the same reason. + pub fn mail_take_money(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "realm_mail_take_money", + realm_mail_take_money_then(self.actor_or_owner(recipient_guid), mail_id) + ) + } + + /// `realm_mail_take_item` — re-create a mail's attachment in the recipient's bags and empty the + /// row, in one transaction. The single-database plane's whole item take. + pub fn mail_take_item(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "realm_mail_take_item", + realm_mail_take_item_then(self.actor_or_owner(recipient_guid), mail_id) + ) + } + + /// `realm_mail_item_room` — the bag-space probe a sharded item take runs BEFORE it fences, on + /// the taker's own handle. A read dressed as a reducer, because only the module can answer it + /// without a second copy of the bag search. + pub fn mail_item_room(&self, payee_guid: u64) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "realm_mail_item_room", + realm_mail_item_room_then(self.actor_or_owner(payee_guid)) + ) + } + + /// `realm_mail_copy_text` — Letter Copy step 1, against the database THIS handle points at. + /// Same trust shape as `mail_mark_read`: operator-gated, `recipient_guid` passed explicitly. + pub fn mail_copy_text(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "realm_mail_copy_text", + realm_mail_copy_text_then(self.actor_or_owner(recipient_guid), mail_id) + ) + } + + /// `gw_mail_grant_letter` — Letter Copy step 2, on the PAYEE's own handle: grants one Plain + /// Letter carrying `item_text_id`. + pub fn mail_grant_letter(&self, payee_guid: u64, item_text_id: u32) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "gw_mail_grant_letter", + gw_mail_grant_letter_then(self.actor_or_owner(payee_guid), item_text_id) + ) + } + + /// `realm_mail_mark_letter_granted` — Letter Copy step 3, against the database THIS handle + /// points at: the durable record that the Home Shard grant landed. + pub fn mail_mark_letter_granted(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "realm_mail_mark_letter_granted", + realm_mail_mark_letter_granted_then(self.actor_or_owner(recipient_guid), mail_id) + ) + } + + /// `realm_mail_fence` — step 1 of a sharded SEND, on the SENDER's own handle: the postage plus + /// the attached coin leave the purse into an escrow row keyed by the caller-chosen `escrow_id`. + #[allow(clippy::too_many_arguments)] + pub fn mail_fence( + &self, + escrow_id: u64, + sender_guid: u64, + recipient_guid: u64, + subject: String, + body: String, + money: u32, + postage: u32, + item_guid: u64, + cod: u32, + cod_source_mail_id: u64, + same_account: bool, + ) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "realm_mail_fence", + realm_mail_fence_then( + escrow_id, + self.actor_or_owner(sender_guid), + recipient_guid, + subject, + body, + money, + postage, + item_guid, + cod, + cod_source_mail_id, + same_account + ) + ) + } + + /// `realm_mail_commit` — step 2 of a sharded send, on the REALM handle: the mail row plus a + /// receipt under the same `escrow_id`, so a replay writes one letter and not two. A Reward + /// Letter commits here on every plane, with its `reward` header. + #[allow(clippy::too_many_arguments)] + pub fn mail_commit( + &self, + escrow_id: u64, + sender_guid: u64, + recipient_guid: u64, + subject: String, + body: String, + money: u32, + item: crate::world::mail::AttachedItem, + cod: u32, + cod_source_mail_id: u64, + delivery_delay_secs: u32, + reward: Option, + ) -> Result<()> { + let (sender_kind, sender_entry, mail_template_id) = + lyracore_shared::mail::RewardHeader::columns(reward); + call_reducer!( + self.0.call_pipe().conn.reducers, + "realm_mail_commit", + realm_mail_commit_then( + escrow_id, + self.actor_or_owner(sender_guid), + recipient_guid, + subject, + body, + money, + item.entry, + item.stack_count, + item.durability, + item.enchant_id, + item.soulbound, + item.random_property_id, + cod, + cod_source_mail_id, + delivery_delay_secs, + sender_kind, + sender_entry, + mail_template_id, + item.item_text_id + ) + ) + } + + /// `realm_mail_take_money_fence` — step 1 of a sharded TAKE, on the handle that OWNS THE MAIL + /// ROW: the copper leaves the row into an escrow there. The mirror of `mail_fence`. + pub fn mail_take_money_fence( + &self, + escrow_id: u64, + payee_guid: u64, + mail_id: u64, + expect_money: u32, + ) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "realm_mail_take_money_fence", + realm_mail_take_money_fence_then( + escrow_id, + self.actor_or_owner(payee_guid), + mail_id, + expect_money + ) + ) + } + + /// `realm_mail_payout` — step 2 of a sharded take, on the TAKER's own handle: the purse plus a + /// receipt under the same `escrow_id`. `mail_commit`'s twin. + pub fn mail_payout( + &self, + escrow_id: u64, + payee_guid: u64, + mail_id: u64, + amount: u32, + ) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "realm_mail_payout", + realm_mail_payout_then(escrow_id, self.actor_or_owner(payee_guid), mail_id, amount) + ) + } + + /// `realm_mail_take_item_fence` — step 1 of a sharded ITEM take, on the handle that OWNS THE + /// MAIL ROW: the attachment leaves the row into an escrow there. + pub fn mail_take_item_fence( + &self, + escrow_id: u64, + payee_guid: u64, + mail_id: u64, + expect_entry: u32, + ) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "realm_mail_take_item_fence", + realm_mail_take_item_fence_then( + escrow_id, + self.actor_or_owner(payee_guid), + mail_id, + expect_entry + ) + ) + } + + /// `realm_mail_item_payout` — step 2 of a sharded item take, on the TAKER's own handle: the + /// item plus a receipt under the same `escrow_id`. `mail_payout`'s twin. + pub fn mail_item_payout( + &self, + escrow_id: u64, + payee_guid: u64, + mail_id: u64, + item: crate::world::mail::AttachedItem, + ) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "realm_mail_item_payout", + realm_mail_item_payout_then( + escrow_id, + self.actor_or_owner(payee_guid), + mail_id, + item.entry, + item.stack_count, + item.durability, + item.enchant_id, + item.soulbound, + item.random_property_id, + item.item_text_id + ) + ) + } + + /// `realm_mail_confirm_delivery` — step 3, on the handle that HOLDS THE FENCE. The attestation + /// that the other database committed, and the only thing that licenses the settle. + pub fn mail_confirm_delivery(&self, escrow_id: u64) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "realm_mail_confirm_delivery", + realm_mail_confirm_delivery_then(escrow_id, self.owner_actor()) + ) + } + + /// `realm_mail_settle` — step 4, on the handle that holds the fence: destroy it. Delete-last. + pub fn mail_settle(&self, escrow_id: u64) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "realm_mail_settle", + realm_mail_settle_then(escrow_id, self.owner_actor()) + ) + } +} + +fn mail_view(db: &RemoteTables, m: Mail) -> crate::codec::MailView { + crate::codec::MailView { + id: m.id, + sender_guid: m.sender_guid, + subject: m.subject, + body: m.body, + item_entry: m.item_entry, + item_stack_count: m.item_stack_count, + item_durability: m.item_durability, + // The row only ever snapshots CURRENT durability (mail.rs's `ItemSnapshot`); the + // true max lives on the attachment's own template, the same read `player_items` + // joins for. 0 for no attachment — `entry().find(0)` finds nothing. + max_durability: db + .game_item_template() + .entry() + .find(&m.item_entry) + .map(|t| t.max_durability) + .unwrap_or(0), + item_enchant_id: m.item_enchant_id, + item_soulbound: m.item_soulbound, + random_property_id: m.random_property_id, + money: m.money, + cod: m.cod, + was_read: m.was_read, + created_at_secs: m.created_at.to_micros_since_unix_epoch() / 1_000_000, + sender_kind: m.sender_kind, + sender_entry: m.sender_entry, + check_flags: m.check_flags, + mail_template_id: m.mail_template_id, + // Rounded up to the second. The Gateway compares it with its own clock, which can run + // ahead of the Module's, so the Module Gates mark-read, delete, every take, the return + // and a COD payment against its own clock again. + deliver_secs: m + .deliver_micros + .saturating_add(999_999) + .div_euclid(1_000_000), + } +} diff --git a/gateway/src/stdb/store/meeting_stone.rs b/gateway/src/stdb/store/meeting_stone.rs new file mode 100644 index 000000000..1464a0d22 --- /dev/null +++ b/gateway/src/stdb/store/meeting_stone.rs @@ -0,0 +1,137 @@ +//! `Coordinator`'s [`MeetingStoneActionStore`] adapter. + +use anyhow::Result; +use lyracore_shared::constants::go_type; +use lyracore_shared::meeting_stone::MeetingStoneRefusal; + +use crate::stdb::bindings::*; +use crate::stdb::connection::{call_reducer, reducer_refusal_reason}; +use crate::stdb::Coordinator; +use crate::world::{presence, MeetingStoneActionStore, MeetingStoneOutcome}; + +impl MeetingStoneActionStore for crate::stdb::Coordinator { + fn admit_meeting_stone(&self, actor_guid: u64, go_guid: u64) -> Result { + crate::stdb::Coordinator::admit_meeting_stone(self, actor_guid, go_guid) + } + + fn meeting_stone_area(&self, go_guid: u64) -> Result> { + crate::stdb::Coordinator::meeting_stone_area(self, go_guid) + } + + fn party_members(&self, actor_guid: u64) -> Result>> { + let authority = self.realm_core()?; + Ok( + crate::stdb::Coordinator::group_roster(&authority, actor_guid) + .map(|roster| roster.members.iter().map(|member| member.guid).collect()), + ) + } + + fn seeker_facts(&self, character_guid: u64) -> Result> { + Ok( + presence::character_anywhere(self, character_guid)?.map(|character| { + crate::world::SeekerFacts { + character_guid, + race: character.race, + class: character.class, + } + }), + ) + } + + fn meeting_stone_op( + &self, + actor_guid: u64, + op: u8, + area_id: u32, + seekers: Vec, + ) -> Result { + crate::stdb::Coordinator::meeting_stone_op(self, actor_guid, op, area_id, seekers) + } + + fn queued_area(&self, character_guid: u64) -> Result> { + crate::stdb::Coordinator::queued_area(self, character_guid) + } +} + +impl Coordinator { + /// The dungeon area of the Meeting Stone spawned as `go_guid` on this database, if any. + pub(crate) fn meeting_stone_area(&self, go_guid: u64) -> Result> { + let guard = self.0.coord(); + let db = &guard.conn.db; + Ok(db + .game_gameobject() + .guid() + .find(&go_guid) + .and_then(|go| { + db.game_gameobject_template() + .entry() + .find(&go.template_entry) + }) + .filter(|template| template.type_id == go_type::MEETINGSTONE) + .and_then(|template| db.game_meeting_stone().entry().find(&template.entry)) + .map(|stone| stone.area_id)) + } + + /// The area `character_guid` is queued for, read from the party authority. + pub(crate) fn queued_area(&self, character_guid: u64) -> Result> { + let realm = self.realm_core()?; + let guard = realm.0.coord(); + Ok(guard + .conn + .db + .game_meeting_stone_seeker() + .character_guid() + .find(&character_guid) + .map(|seeker| seeker.area_id)) + } + + /// `gw_admit_meeting_stone` on this Home Shard: may the actor use the stone? Writes nothing. + pub fn admit_meeting_stone( + &self, + actor_guid: u64, + go_guid: u64, + ) -> Result { + let coord = self.0.call_pipe(); + meeting_stone_outcome(call_reducer!( + coord.conn.reducers, + "gw_admit_meeting_stone", + gw_admit_meeting_stone_then(self.actor_or_owner(actor_guid), go_guid) + )) + } + + /// `realm_meeting_stone_op`: run one Meeting Stone Queue op on Realm-core, or on the one + /// database of an unsharded Realm. The Module applies every queue rule. + pub fn meeting_stone_op( + &self, + actor_guid: u64, + op: u8, + area_id: u32, + seekers: Vec, + ) -> Result { + let realm = self.realm_core()?; + let seekers = seekers + .into_iter() + .map(|facts| SeekerFacts { + character_guid: facts.character_guid, + race: facts.race, + class: facts.class, + }) + .collect(); + meeting_stone_outcome(call_reducer!( + realm.0.call_pipe().conn.reducers, + "realm_meeting_stone_op", + realm_meeting_stone_op_then(op, realm.actor_or_owner(actor_guid), area_id, seekers) + )) + } +} + +fn meeting_stone_outcome(result: Result<()>) -> Result { + match result { + Ok(()) => Ok(MeetingStoneOutcome::Ran), + Err(error) => match reducer_refusal_reason(&error).and_then(MeetingStoneRefusal::parse_tag) + { + Some(refusal) => Ok(MeetingStoneOutcome::Refused(refusal)), + None => Err(error), + }, + } +} diff --git a/gateway/src/stdb/store/melee.rs b/gateway/src/stdb/store/melee.rs new file mode 100644 index 000000000..43d35592b --- /dev/null +++ b/gateway/src/stdb/store/melee.rs @@ -0,0 +1,45 @@ +//! `Coordinator`'s [`MeleeActionStore`] adapter. + +use anyhow::{anyhow, Result}; + +use crate::stdb::bindings::*; +use crate::stdb::connection::call_reducer; +use crate::stdb::Coordinator; +use crate::world::{Actor, MeleeActionStore}; + +impl MeleeActionStore for crate::stdb::Coordinator { + fn start_attack(&self, account_id: u64, actor_guid: u64, target_guid: u64) -> Result<()> { + crate::stdb::Coordinator::start_attack(self, account_id, actor_guid, target_guid) + } + + fn stop_attack(&self, account_id: u64, actor_guid: u64) -> Result<()> { + crate::stdb::Coordinator::stop_attack(self, account_id, actor_guid) + } +} + +impl Coordinator { + /// Start the player's melee auto-attack on `target_guid` (`CMSG_ATTACKSWING`, combat C1) over + /// the coordinator connection so the module attributes the swing to the caller. + pub fn start_attack(&self, _account_id: u64, actor_guid: u64, target_guid: u64) -> Result<()> { + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("start_attack: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_attack", + gw_attack_then(self.session_actor(actor), target_guid) + ) + } + + /// Stop the player's melee auto-attack (`CMSG_ATTACKSTOP`, combat C1). + pub fn stop_attack(&self, _account_id: u64, actor_guid: u64) -> Result<()> { + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("stop_attack: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_stop_attack", + gw_stop_attack_then(self.session_actor(actor)) + ) + } +} diff --git a/gateway/src/stdb/store/member_stats.rs b/gateway/src/stdb/store/member_stats.rs new file mode 100644 index 000000000..d24723681 --- /dev/null +++ b/gateway/src/stdb/store/member_stats.rs @@ -0,0 +1,159 @@ +//! `Coordinator`'s [`MemberStatsStore`] adapter. + +use anyhow::Result; +use spacetimedb_sdk::Table; + +use crate::stdb::bindings::*; +use crate::stdb::Coordinator; +use crate::world::{MemberPresence, MemberStatsStore}; + +impl MemberStatsStore for crate::stdb::Coordinator { + fn group_mates(&self, self_guid: u64) -> Result> { + crate::stdb::Coordinator::group_mates(self, self_guid) + } + + fn member_presence(&self, guid: u64) -> Result { + crate::stdb::Coordinator::member_presence(self, guid) + } +} + +impl Coordinator { + /// Every other member of `self_guid`'s group, from the party authority's membership index: + /// Realm-core on a sharded Realm, this database otherwise. A list longer than a Raid is a + /// damaged cache, not a group. + pub(crate) fn group_mates(&self, self_guid: u64) -> Result> { + let authority = if self.is_sharded() { + self.realm_core()? + } else { + self.clone() + }; + let roster = authority + .0 + .coord() + .party_memberships + .read() + .unwrap() + .bounded_member_rows(self_guid, lyracore_shared::group::RAID_MAX_MEMBERS)?; + Ok(roster + .map(|(_, rows)| { + rows.into_iter() + .map(|(_, guid)| guid) + .filter(|guid| *guid != self_guid) + .collect() + }) + .unwrap_or_default()) + } + + /// Find `guid` for Member Stats from its Realm Presence — the same live-entity / in-transit / + /// absence-gated-offline decision every other realm-wide read now shares, so there is no + /// second discovery left to disagree with it. + pub(crate) fn member_presence(&self, guid: u64) -> Result { + use crate::world::presence::Whereabouts; + Ok( + match crate::world::presence::of(self, guid)?.map(|presence| presence.whereabouts) { + Some(Whereabouts::InWorld { + entity, shard_name, .. + }) => { + let entity = self.with_member_shard_stats(&shard_name, guid, entity); + crate::world::MemberPresence::Live(Box::new( + crate::codec::MemberStats::from_entity(&entity), + )) + } + Some(Whereabouts::InTransit) => crate::world::MemberPresence::InTransit, + Some(Whereabouts::Offline) | None => crate::world::MemberPresence::Offline, + }, + ) + } + + /// Overlay `guid`'s aura slots and live pet onto `entity` — the one piece of Member Stats a + /// generic Realm Presence read cannot supply, because it does not know about `ShardId` or + /// `WorldView`'s `AuraIndex`. Reads from the exact Shard named `shard_name`, the same one + /// `presence::of` already searched to find the live entity `entity` itself came from, keyed by + /// its position in `all_shards()` — the same `enumerate()` order `arm_shared_world_view` + /// assigned when it built the `AuraIndex`. A name this handle's own `all_shards()` no longer + /// lists (a Shard dropped between the two reads) leaves `entity` as `presence::of` built it, + /// pet-less and aura-less rather than wrong. + fn with_member_shard_stats( + &self, + shard_name: &str, + guid: u64, + entity: crate::codec::MemberEntity, + ) -> crate::codec::MemberEntity { + let Some(shard) = self + .all_shards() + .iter() + .position(|coord| coord.shard_name() == shard_name) + else { + return entity; + }; + crate::codec::MemberEntity { + auras: self.member_aura_slots(shard, guid), + pet: self.member_pet(shard, guid), + ..entity + } + } + + /// `owner_guid`'s occupied `UNIT_FIELD_AURA` slots on `shard`, from `WorldView`'s `AuraIndex`. + fn member_aura_slots( + &self, + shard: crate::stdb::world_index::ShardId, + owner_guid: u64, + ) -> Vec { + self.world_view() + .auras + .on_target(shard, owner_guid) + .into_iter() + .map(|aura| crate::codec::MemberAuraSlot { + slot: aura.slot, + spell_id: aura.spell_id, + }) + .collect() + } + + /// `owner_guid`'s live pet, resolved the way the pet bar resolves it: a Hunter's name comes + /// from `game_hunter_pet_protocol`, a summoned pet's from its creature template, and every + /// other field from the pet's own `game_world_entity` row (`pet_name`, `stdb/reads/pet.rs`). + /// + /// The pet guid is deterministic (`lyracore_shared::pet::pet_guid_for`, the same derivation + /// the Module's `pet_of` uses), so this is one keyed lookup plus an owner check, never a scan. + /// `game_world_entity().iter()` clones every cached row while holding the client cache's own + /// lock, and this read runs for every live group mate on every Relay tick; a scan here would + /// contend with the shard's coordinator pump, the busiest callback in the process. + fn member_pet( + &self, + shard: crate::stdb::world_index::ShardId, + owner_guid: u64, + ) -> Option { + let live = self.0.coord(); + let db = &live.conn.db; + let pet_guid = lyracore_shared::pet::pet_guid_for(owner_guid); + let pet = db + .game_world_entity() + .guid() + .find(&pet_guid) + .filter(|pet| pet.owner_guid == owner_guid)?; + let name = db + .game_hunter_pet_protocol() + .iter() + .find(|hunter| hunter.live_pet_guid == pet.guid) + .map(|hunter| hunter.name) + .or_else(|| { + db.game_creature_template() + .entry() + .find(&pet.entry) + .map(|template| template.name) + }) + .unwrap_or_default(); + Some(crate::codec::MemberPetEntity { + guid: pet.guid, + name, + display_id: pet.display_id, + health: pet.health, + max_health: pet.max_health, + power: pet.power, + max_power: pet.max_power, + unit_bytes_0: pet.unit_bytes_0, + auras: self.member_aura_slots(shard, pet.guid), + }) + } +} diff --git a/gateway/src/stdb/store/mod.rs b/gateway/src/stdb/store/mod.rs index c72d6a6d1..9f934fe15 100644 --- a/gateway/src/stdb/store/mod.rs +++ b/gateway/src/stdb/store/mod.rs @@ -1,22 +1,39 @@ -//! `Coordinator`'s Store family adapters, one file per family. +//! `Coordinator`'s Store family adapters, one file per Protocol Family. //! -//! Most methods forward to `Coordinator`'s like-named INHERENT method (defined by concern in -//! `reads`/`reducers`/`subscriptions`/`connection`). Rust method resolution prefers inherent methods -//! over trait methods, so `self.characters(..)` here calls the inherent `Coordinator::characters`, -//! not the trait method: these are thin views, not recursion. +//! Each file holds the family's trait impl and the inherent methods only that family calls. +//! Inherent methods with other callers stay in `reads`, `reducers`, `subscriptions` and +//! `connection`. Rust method resolution prefers inherent methods over trait methods, so +//! `self.characters(..)` here calls the inherent `Coordinator::characters`, not the trait method: +//! these are thin views, not recursion. +mod auction; mod bank; +mod cast; +mod channel; mod character; +mod chat; mod combat; mod death; +mod duel; +mod guild; +mod guild_fee; +mod item; mod loot_roll; +mod loot_window; mod mail; +mod meeting_stone; +mod melee; +mod member_stats; mod npc; mod party; +mod quest; mod session; mod shard_routing; mod social; mod speech; +mod taxi; mod trade; mod trainer; mod transfer; +mod vendor; +mod weather; diff --git a/gateway/src/stdb/store/npc.rs b/gateway/src/stdb/store/npc.rs index 07365314d..a3987bf75 100644 --- a/gateway/src/stdb/store/npc.rs +++ b/gateway/src/stdb/store/npc.rs @@ -1,11 +1,14 @@ //! `Coordinator`'s [`NpcStore`] adapter. -use anyhow::Result; +use anyhow::{anyhow, Result}; +use spacetimedb_sdk::Table; use crate::codec; -use crate::world::NpcStore; - +use crate::codec::PetNameView; +use crate::stdb::bindings::*; +use crate::stdb::connection::call_reducer; use crate::stdb::Coordinator; +use crate::world::{Actor, NpcStore}; impl NpcStore for Coordinator { fn creature_template(&self, entry: u32) -> Result> { @@ -72,3 +75,390 @@ impl NpcStore for Coordinator { self.gossip_select(account_id, self_guid, npc_guid, option_id, option_row_id) } } + +impl Coordinator { + /// Resolve the active title text for one NPC, with a per-creature menu override before the + /// entry-owned default menu. + pub fn npc_gossip_text_id(&self, npc_guid: u64) -> u32 { + let guard = self.0.coord(); + let db = &guard.conn.db; + let entity = match db.game_world_entity().guid().find(&npc_guid) { + Some(entity) => entity, + None => return crate::codec::GOSSIP_GREETING_TEXT_ID, + }; + if let Some(menu_id) = active_gossip_menu_id(db, &entity) { + if let Some(menu) = db.game_gossip_menu_profile().menu_id().find(&menu_id) { + return menu.text_id; + } + } + match db.game_gossip_menu().entry().find(&entity.entry) { + Some(row) => row.text_id, + None => crate::codec::GOSSIP_GREETING_TEXT_ID, + } + } + + /// Look up the full weighted greeting for a `text_id`: `game_npc_text` (slot 0 + /// male, back-compat) + any `game_npc_text_slot` rows for the same id. Returns `None` when the + /// text_id has no `game_npc_text` row at all (the gateway falls back to the generic greeting). + /// + /// Back-compat normalization: a `text_id` with NO `game_npc_text_slot` rows (never imported by the + /// weighted-greeting importer — either a pre-existing row, or the legacy `npc_text[entry]` + /// fallback path, which only ever populates `game_npc_text`) is read as slot 0 = `(text, text, + /// 1.0)` and every other slot silent, matching the old single-slot behavior byte-for-byte. When + /// slot rows DO exist, they + /// are used verbatim (real per-slot probabilities from the dump) and slot 0's base-row `text` is + /// NOT separately re-applied (the slot-0 row, always emitted by the importer alongside the others, + /// is the source of truth once any slot row exists). + pub fn npc_text_for_id(&self, text_id: u32) -> Option { + let guard = self.0.coord(); + let db = &guard.conn.db; + let base = db.game_npc_text().text_id().find(&text_id)?; + let mut slot_rows: Vec<_> = db + .game_npc_text_slot() + .iter() + .filter(|s| s.text_id == text_id) + .collect(); + if slot_rows.is_empty() { + let mut view = crate::codec::NpcTextView::default(); + view.slots[0] = (base.text.clone(), base.text, 1.0); + return Some(view); + } + slot_rows.sort_by_key(|s| s.slot_index); // stable slot order (SQL has no ORDER BY in 2.5) + let mut view = crate::codec::NpcTextView::default(); + for row in slot_rows { + if (row.slot_index as usize) < 8 { + view.slots[row.slot_index as usize] = + (row.text_male, row.text_female, row.probability); + } + } + Some(view) + } + + /// Return the active imported options for one NPC. A live per-creature override replaces the + /// entry-owned default options. The dispatcher applies conditions at both HELLO and SELECT. + pub fn gossip_options(&self, npc_guid: u64) -> Result> { + let guard = self.0.coord(); + let db = &guard.conn.db; + let Some(entity) = db.game_world_entity().guid().find(&npc_guid) else { + return Ok(Vec::new()); + }; + if let Some(menu_id) = active_gossip_menu_id(db, &entity) { + if db + .game_gossip_menu_profile() + .menu_id() + .find(&menu_id) + .is_some() + { + let mut rows: Vec<_> = db + .game_gossip_menu_profile_option() + .iter() + .filter(|option| option.menu_id == menu_id) + .collect(); + rows.sort_by_key(|option| option.option_index); + return Ok(rows + .into_iter() + .map(|option| crate::codec::GossipOptionView { + row_id: option.row_id, + icon: option.icon, + text: option.text, + action: option.action, + action_menu_id: option.action_menu_id, + cond_type: option.cond_type, + cond_value1: option.cond_value1, + cond_value2: option.cond_value2, + }) + .collect()); + } + } + let mut rows: Vec<_> = db + .game_gossip_option() + .iter() + .filter(|option| option.entry == entity.entry) + .collect(); + rows.sort_by_key(|option| option.option_index); + Ok(rows + .into_iter() + .map(|option| crate::codec::GossipOptionView { + row_id: option.row_id, + icon: option.icon, + text: option.text, + action: option.action, + action_menu_id: option.action_menu_id, + cond_type: option.cond_type, + cond_value1: option.cond_value1, + cond_value2: option.cond_value2, + }) + .collect()) + } + + /// Does the NPC at `guid` carry the innkeeper flag? Gates the "Make this inn your home." gossip + /// option + the `bind_home` select. Reads `npc_flags` off the entity (privileged cache); absent → false. + pub fn npc_is_innkeeper(&self, guid: u64) -> Result { + let guard = self.0.coord(); + let db = &guard.conn.db; + Ok(db + .game_world_entity() + .guid() + .find(&guid) + .is_some_and(|e| e.npc_flags & lyracore_shared::constants::npc_flags::INNKEEPER != 0)) + } + + /// Resolve a live pet visible to the world. Pet names are public unit presentation: Hunter + /// names come from the bounded durable projection, while summoned-pet names remain authored + /// creature-template data. The requester must be in world, but need not own the observed pet. + pub fn pet_name( + &self, + requester_guid: u64, + pet_number: u32, + pet_guid: u64, + ) -> Result, anyhow::Error> { + if requester_guid == 0 || pet_guid == 0 { + return Ok(None); + } + let guard = self.0.coord(); + let db = &guard.conn.db; + let Some(entity) = db.game_world_entity().guid().find(&pet_guid) else { + return Ok(None); + }; + if entity.owner_guid == 0 { + return Ok(None); + } + if let Some(pet) = db + .game_hunter_pet_protocol() + .iter() + .find(|pet| pet.live_pet_guid == pet_guid) + { + return Ok(resolve_pet_name( + pet_number, + pet_guid, + Some(super::super::views::hunter_pet_protocol_view(pet)), + None, + )); + } + let summoned = db + .game_creature_template() + .entry() + .find(&entity.entry) + .map(|template| template.name); + Ok(resolve_pet_name(pet_number, pet_guid, None, summoned)) + } + + /// Read a creature template by entry for a `CMSG_CREATURE_QUERY` reply (Tier 2 / NPCs). + pub fn creature_template(&self, entry: u32) -> Result> { + Ok(self + .0 + .coord() + .conn + .db + .game_creature_template() + .entry() + .find(&entry) + .map(|t| crate::codec::CreatureView { + entry: t.entry, + name: t.name, + subname: t.subname, + display_id: t.display_id, + creature_type: t.creature_type as u32, + creature_family: t.creature_family, + type_flags: t.type_flags, + rank: t.rank as u32, + })) + } + + /// The `type_id` of a SPAWNED gameobject, by its live guid (join `game_gameobject` → + /// `game_gameobject_template`). Feeds the `CMSG_GAMEOBJ_USE` dispatch: a + /// `lyracore_shared::constants::go_type::QUESTGIVER` GO (the Wanted Poster, the Lost Guards corpses) + /// opens the quest window instead of rolling loot / toggling state — that is what a questgiver + /// gameobject does in vanilla. `None` for an unspawned/unknown guid (the caller falls back to the + /// ordinary use-reducer path, which itself no-ops on an unknown guid). + pub fn gameobject_type(&self, go_guid: u64) -> Result> { + let guard = self.0.coord(); + let db = &guard.conn.db; + Ok(db + .game_gameobject() + .guid() + .find(&go_guid) + .and_then(|go| { + db.game_gameobject_template() + .entry() + .find(&go.template_entry) + }) + .map(|t| t.type_id)) + } + + /// Validate a `CMSG_INSPECT` request (target is a real in-world player, on the caller's map, in + /// range, friendly) over the coordinator connection so the module resolves the caller from + /// `ctx.sender`. `Err` (out of range / hostile / no such target) → the caller ignores it. + pub fn inspect(&self, _account_id: u64, actor_guid: u64, target_guid: u64) -> Result<()> { + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("inspect: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_inspect", + gw_inspect_then(self.session_actor(actor), target_guid) + ) + } + + /// Enter an area trigger (`CMSG_AREATRIGGER`) — credit any active explore quest tied to `trigger_id`. + pub fn enter_areatrigger( + &self, + _account_id: u64, + actor_guid: u64, + trigger_id: u32, + ) -> Result<()> { + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("enter_areatrigger: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_enter_areatrigger", + gw_enter_areatrigger_then(self.session_actor(actor), trigger_id) + ) + } + + /// `CMSG_GOSSIP_SELECT_OPTION` — the NOTIFY-ONLY module chokepoint. Fired + /// best-effort BEFORE the gateway's own gossip behavior; a failure never blocks the reply. + pub fn gossip_select( + &self, + _account_id: u64, + actor_guid: u64, + npc_guid: u64, + option_id: u32, + option_row_id: u32, + ) -> Result<()> { + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("gossip_select: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_gossip_select", + gw_gossip_select_then( + self.session_actor(actor), + npc_guid, + option_id, + option_row_id + ) + ) + } + + /// Bind the caller's hearthstone home to their current position (`CMSG_GOSSIP_SELECT_OPTION` on an + /// innkeeper's "Make this inn your home.") over the coordinator connection so the module attributes + /// it to the caller's entity. No args — `bind_home` resolves the caller via `ctx.sender`. + pub fn bind_home(&self, _account_id: u64, actor_guid: u64) -> Result<()> { + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("bind_home: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_bind_home", + gw_bind_home_then(self.session_actor(actor)) + ) + } +} + +fn active_gossip_menu_id(db: &RemoteTables, entity: &WorldEntity) -> Option { + let menu = db + .game_creature_gossip_menu_override() + .creature_guid() + .find(&entity.guid); + matching_gossip_menu_id( + entity.guid, + entity.map_id, + entity.instance_id, + menu.as_ref(), + ) +} + +fn matching_gossip_menu_id( + creature_guid: u64, + map_id: u32, + instance_id: u64, + menu: Option<&CreatureGossipMenuOverride>, +) -> Option { + menu.filter(|menu| { + menu.creature_guid == creature_guid + && menu.map_id == map_id + && menu.instance_id == instance_id + }) + .map(|menu| menu.menu_id) +} + +fn resolve_pet_name( + pet_number: u32, + pet_guid: u64, + hunter: Option, + summoned_name: Option, +) -> Option { + if let Some(pet) = hunter { + return (pet.live_pet_guid == pet_guid && pet.pet_id as u32 == pet_number).then_some( + PetNameView { + pet_number, + name: pet.name, + name_timestamp: pet.name_timestamp, + }, + ); + } + Some(PetNameView { + pet_number, + name: summoned_name?, + name_timestamp: 0, + }) +} + +#[cfg(test)] +mod gossip_tests { + use super::*; + + #[test] + fn gossip_override_is_scoped_to_the_exact_creature_instance() { + let menu = CreatureGossipMenuOverride { + creature_guid: 41, + menu_id: 6687, + map_id: 43, + instance_id: 9, + }; + + assert_eq!(matching_gossip_menu_id(41, 43, 9, Some(&menu)), Some(6687)); + assert_eq!(matching_gossip_menu_id(42, 43, 9, Some(&menu)), None); + assert_eq!(matching_gossip_menu_id(41, 43, 10, Some(&menu)), None); + assert_eq!(matching_gossip_menu_id(41, 44, 9, Some(&menu)), None); + } +} + +#[cfg(test)] +mod pet_name_tests { + use super::*; + + fn hunter(owner_guid: u64) -> crate::codec::HunterPetProtocolView { + crate::codec::HunterPetProtocolView { + pet_id: 77, + owner_guid, + live_pet_guid: 99, + creature_entry: 3098, + name: "Mottled Boar".into(), + name_timestamp: 123, + level: 8, + pet_xp: 0, + next_level_xp: 4_500, + happiness: 166_500, + loyalty_level: 1, + } + } + + #[test] + fn hunter_name_requires_pet_guid_and_number_but_not_the_observers_guid() { + let got = resolve_pet_name(77, 99, Some(hunter(7)), None).unwrap(); + assert_eq!(got.name, "Mottled Boar"); + assert_eq!(got.name_timestamp, 123); + assert!(resolve_pet_name(78, 99, Some(hunter(7)), None).is_none()); + assert!(resolve_pet_name(77, 100, Some(hunter(7)), None).is_none()); + } + + #[test] + fn summoned_pet_uses_authored_name_without_hunter_state() { + let got = resolve_pet_name(0, 99, None, Some("Imp".into())).unwrap(); + assert_eq!(got.name, "Imp"); + assert_eq!(got.name_timestamp, 0); + } +} diff --git a/gateway/src/stdb/store/party.rs b/gateway/src/stdb/store/party.rs index 90dd2b781..7a119ef58 100644 --- a/gateway/src/stdb/store/party.rs +++ b/gateway/src/stdb/store/party.rs @@ -1,11 +1,15 @@ //! `Coordinator`'s [`PartyStore`] adapter. -use anyhow::Result; - -use crate::world::{PartyStore, ShardRoutingStore}; +use anyhow::{anyhow, Result}; +use lyracore_shared::group::{GroupKind, GroupRefusal, RaidSlot}; +use spacetimedb_sdk::{Identity, Table}; use crate::stdb::bindings::game_world_entity_table::GameWorldEntityTableAccess; +use crate::stdb::bindings::*; +use crate::stdb::connection::{call_reducer, reducer_refusal_reason}; use crate::stdb::Coordinator; +use crate::world::party::{AdmittedCompanionCommand, CompanionCommandOutcome, PartyOutcome}; +use crate::world::{Actor, PartyStore, ShardRoutingStore}; impl PartyStore for Coordinator { // The SINGLE-DATABASE party path: unchanged reducer calls on the player's own connection. @@ -264,3 +268,562 @@ impl PartyStore for Coordinator { self.sync_group_mirror(roster) } } + +impl Coordinator { + pub(crate) fn stable_party_holder_observation( + &self, + character_guid: u64, + serves_locator: bool, + ) -> Result { + let guard = self.0.coord(); + if !guard.is_healthy() { + anyhow::bail!( + "{} has no healthy Coordinator subscription for party partition certification", + self.shard_name() + ); + } + let has_escrow = guard + .conn + .db + .game_transfer_out() + .transfer_id() + .find(&crate::world::transfer::transfer_id_for(character_guid)) + .is_some_and(|row| row.character_guid == character_guid); + let character_partition = guard + .conn + .db + .game_character() + .guid() + .find(&character_guid) + .map(|character| (character.map_id, character.pending_instance_id)); + Ok(crate::world::party::PartyHolderObservation { + serves_locator, + has_escrow, + character_partition, + }) + } + + /// Every party in this Realm-core or World Shard cache. + pub fn party_group_ids(&self) -> Vec { + self.0 + .coord() + .conn + .db + .game_group() + .iter() + .map(|group| group.group_id) + .collect() + } + + /// Every Character with party membership in this Realm-core cache. + pub fn party_member_guids(&self) -> Vec { + self.0 + .coord() + .conn + .db + .game_group_member() + .iter() + .map(|member| member.character_guid) + .collect() + } + + /// Read only the bounded roster projection accepted by companion-command authority. The bound + /// is the Raid cap: a longer list is a damaged cache. + pub fn party_command_group_roster( + &self, + character_guid: u64, + ) -> anyhow::Result> { + let guard = self.0.coord(); + let db = &guard.conn.db; + let Some((group_id, rows)) = guard + .party_memberships + .read() + .unwrap() + .bounded_member_rows(character_guid, lyracore_shared::group::RAID_MAX_MEMBERS)? + else { + return Ok(None); + }; + let Some(group) = db.game_group().group_id().find(&group_id) else { + return Ok(None); + }; + let members = rows + .into_iter() + .map(|(row_id, guid)| crate::world::party::GroupRosterMember { + guid, + slot: db + .game_group_member() + .id() + .find(&row_id) + .map_or_else(Default::default, |row| { + RaidSlot::from_wire_or_default(row.raid_slot) + }), + }) + .collect(); + Ok(Some(crate::world::party::GroupRoster { + group_id, + roster_revision: db + .game_group_roster_revision() + .group_id() + .find(&group_id) + .map_or(1, |row| row.revision), + leader_guid: group.leader_guid, + loot_method: group.loot_method, + loot_threshold: group.loot_threshold, + master_looter_guid: group.master_looter_guid, + kind: GroupKind::from_wire_or_default(group.group_type), + members, + partitions: Vec::new(), + })) + } + + /// Realm-core's roster order survives disband in `game_group_roster_revision`. + pub fn group_roster_revision(&self, group_id: u64) -> u64 { + self.0 + .coord() + .conn + .db + .game_group_roster_revision() + .group_id() + .find(&group_id) + .map_or(1, |row| row.revision) + } + + /// The Roster Revision this database holds for one party, or `None` when it holds no row. On a + /// World Shard this is the mirror's revision, which outlives a disband. + pub fn held_roster_revision(&self, group_id: u64) -> Option { + self.0 + .coord() + .conn + .db + .game_group_roster_revision() + .group_id() + .find(&group_id) + .map(|row| row.revision) + } + + pub fn claim_party_command_intent(&self, intent_id: u64, claim_token: u64) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "claim_party_command_intent", + claim_party_command_intent_then(intent_id, claim_token) + ) + } + + pub fn admit_party_command_authority( + &self, + group_id: u64, + leader_guid: u64, + bot_guid: u64, + authority_member_guid: u64, + expected_members: Vec, + ) -> Result { + match call_reducer!( + self.0.call_pipe().conn.reducers, + "admit_party_command_authority", + admit_party_command_authority_then( + group_id, + leader_guid, + bot_guid, + authority_member_guid, + expected_members + ) + ) { + Ok(()) => Ok(CompanionCommandOutcome::Applied), + Err(error) => command_refusal(&error).ok_or(error), + } + } + + pub fn apply_admitted_party_command( + &self, + command: &AdmittedCompanionCommand, + ) -> Result { + let applied = call_reducer!( + self.0.call_pipe().conn.reducers, + "apply_admitted_party_command", + apply_admitted_party_command_then( + command.source_identity, + command.intent_id, + command.issuer_guid, + command.issuer_sequence, + command.group_id, + command.leader_guid, + command.members.clone(), + command.kind, + command.bot_guid, + command.authority_member_guid, + command.exact_target_guid, + command.expires_micros, + command.receipt_retain_until_micros + ) + ); + if let Err(error) = applied { + return command_refusal(&error).ok_or(error); + } + self.confirm_party_command_receipt(command.source_identity, command.intent_id)? + .ok_or_else(|| anyhow!("party command apply committed without a receipt")) + } + + pub fn finish_party_command_intent( + &self, + intent_id: u64, + claim_token: u64, + outcome: CompanionCommandOutcome, + ) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "finish_party_command_intent", + finish_party_command_intent_then( + intent_id, + claim_token, + command_outcome_binding(outcome) + ) + ) + } + + pub fn confirm_party_command_receipt( + &self, + source_identity: Identity, + intent_id: u64, + ) -> Result> { + match call_reducer!( + self.0.call_pipe().conn.reducers, + "confirm_party_command_receipt", + confirm_party_command_receipt_then(source_identity, intent_id) + ) { + Ok(()) => Ok(None), + Err(error) => command_refusal(&error).map(Some).ok_or(error), + } + } + + pub fn confirm_party_command_holder( + &self, + bot_guid: u64, + ) -> Result { + match call_reducer!( + self.0.call_pipe().conn.reducers, + "confirm_party_command_holder", + confirm_party_command_holder_then(bot_guid) + ) { + Ok(()) => Ok(crate::world::party::PartyCommandHolder::Present), + Err(error) => match reducer_refusal_reason(&error) { + Some("MissingBot") => Ok(crate::world::party::PartyCommandHolder::Missing), + Some("TransferInProgress") => { + Ok(crate::world::party::PartyCommandHolder::InTransit) + } + _ => Err(error), + }, + } + } + + /// Claim and admit a Group Intent in one World Shard transaction. + pub fn claim_bot_invite_intent(&self, intent_id: u64) -> Result { + party_outcome(call_reducer!( + self.0.call_pipe().conn.reducers, + "claim_bot_invite_intent", + claim_bot_invite_intent_then(intent_id) + )) + } + + /// Wait for the owning World Shard's current consent Gate, without subscription readback. + pub fn admit_sessionless_group_action(&self, character_guid: u64) -> Result { + party_outcome(call_reducer!( + self.0.call_pipe().conn.reducers, + "admit_sessionless_group_action", + admit_sessionless_group_action_then(character_guid) + )) + } + + /// `CMSG_GROUP_INVITE` — `target_guid` is already resolved by the gateway. + pub fn group_invite( + &self, + _account_id: u64, + actor_guid: u64, + target_guid: u64, + ) -> Result { + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("group_invite: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + party_outcome(call_reducer!( + coord.conn.reducers, + "gw_group_invite", + gw_group_invite_then(self.session_actor(actor), target_guid) + )) + } + + /// `CMSG_GROUP_ACCEPT`. Rides the coordinator connection as + /// `gw_accept_group_invite`. + pub fn group_accept(&self, _account_id: u64, actor_guid: u64) -> Result { + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("group_accept: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + party_outcome(call_reducer!( + coord.conn.reducers, + "gw_accept_group_invite", + gw_accept_group_invite_then(self.session_actor(actor)) + )) + } + + /// `CMSG_GROUP_DECLINE`. + pub fn group_decline(&self, _account_id: u64, actor_guid: u64) -> Result { + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("group_decline: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + party_outcome(call_reducer!( + coord.conn.reducers, + "gw_group_decline", + gw_group_decline_then(self.session_actor(actor)) + )) + } + + /// `CMSG_GROUP_DISBAND` — leave the caller's group. + pub fn group_leave(&self, _account_id: u64, actor_guid: u64) -> Result { + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("group_leave: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + party_outcome(call_reducer!( + coord.conn.reducers, + "gw_group_leave", + gw_group_leave_then(self.session_actor(actor)) + )) + } + + /// `CMSG_GROUP_UNINVITE` or `CMSG_GROUP_UNINVITE_GUID` — the leader or an Assistant kicks + /// `target_guid`. + pub fn group_uninvite( + &self, + _account_id: u64, + actor_guid: u64, + target_guid: u64, + ) -> Result { + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("group_uninvite: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + party_outcome(call_reducer!( + coord.conn.reducers, + "gw_group_uninvite", + gw_group_uninvite_then(self.session_actor(actor), target_guid) + )) + } + + /// `CMSG_LOOT_METHOD` — the leader sets the party's loot method/ + /// threshold/master. Echoed to every member via the existing `SMSG_GROUP_LIST` relay (the + /// module's `group_loot_method` reducer re-renders the roster payload); no separate ack packet + /// (vanilla sends none for this opcode either). + pub fn group_loot_method( + &self, + _account_id: u64, + actor_guid: u64, + loot_setting: u8, + master_guid: u64, + loot_threshold: u8, + ) -> Result { + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("group_loot_method: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + party_outcome(call_reducer!( + coord.conn.reducers, + "gw_group_loot_method", + gw_group_loot_method_then( + self.session_actor(actor), + loot_setting, + master_guid, + loot_threshold + ) + )) + } + + /// `realm_group_op` — one party op against the database THIS handle points at. The gateway + /// calls it on the **realm-core** handle, where membership is authoritative. + /// + /// Through an operator connection, not the player's: the reducer is operator-gated because it + /// takes the acting character's guid as an argument (realm-core has no live entity to derive + /// one from), so only the token that holds the operator identity may call it. The guid passed is + /// the one this socket authenticated into the world with — see `world::party`. + /// + /// This form rides a call pipe, which subscribes no group table, so the Coordinator cache may + /// still hold the old roster when it returns. Only an op that pushes no Group mirror after it + /// uses it: a Group Broadcast or a Target Icon request. Every roster op uses + /// [`Self::realm_group_op_visible`]. + pub fn realm_group_op( + &self, + op: u8, + actor_guid: u64, + target_guid: u64, + arg_a: u8, + arg_b: u8, + arg_c: u64, + ) -> Result { + party_outcome(call_reducer!( + self.0.call_pipe().conn.reducers, + "realm_group_op", + realm_group_op_then( + op, + self.actor_or_owner(actor_guid), + target_guid, + arg_a, + arg_b, + arg_c + ) + )) + } + + /// [`Self::realm_group_op`] on the visibility pipe: it returns only after the Coordinator cache + /// holds the committed roster, so the mirror push that follows reads the op's own result. The + /// caller must not run on the Coordinator pump: World Sessions and the bot intent threads. + pub fn realm_group_op_visible( + &self, + op: u8, + actor_guid: u64, + target_guid: u64, + arg_a: u8, + arg_b: u8, + arg_c: u64, + ) -> Result { + let coordinator = self.0.visibility_pipe(); + party_outcome(call_reducer!( + coordinator.conn.reducers, + "realm_group_op", + realm_group_op_then( + op, + self.actor_or_owner(actor_guid), + target_guid, + arg_a, + arg_b, + arg_c + ) + )) + } + + /// Remove a deleted Character from a party and return only after the Coordinator cache holds + /// the committed Realm-core roster. The caller always runs off the Coordinator pump. + pub fn deleted_character_party_leave(&self, character_guid: u64) -> Result { + let coordinator = self.0.visibility_pipe(); + party_outcome(call_reducer!( + coordinator.conn.reducers, + "realm_group_op", + realm_group_op_then( + lyracore_shared::group::realm_op::LEAVE, + self.actor_or_owner(character_guid), + 0, + lyracore_shared::group::leave_cause::CHARACTER_DELETED, + 0, + 0 + ) + )) + } + + /// `sync_group_mirror` — replace THIS shard's mirror of one party with realm-core's roster. + /// Operator-gated, coordinator connection, same reasoning as above; called + /// on each WORLD shard after a party op and at world entry. + pub fn sync_group_mirror(&self, roster: &crate::world::party::GroupRoster) -> Result<()> { + let partitions = roster + .partitions + .iter() + .map(|partition| GroupMemberPartition { + character_guid: partition.character_guid, + group_id: partition.group_id, + membership_revision: partition.membership_revision, + member_active: partition.member_active, + map_id: partition.map_id, + instance_id: partition.instance_id, + locator_revision: partition.locator_revision, + state: match partition.state { + crate::world::party::PartyPartitionState::Unknown => { + PartyPartitionState::Unknown + } + crate::world::party::PartyPartitionState::Known => PartyPartitionState::Known, + crate::world::party::PartyPartitionState::PendingTransfer => { + PartyPartitionState::PendingTransfer + } + }, + }) + .collect(); + call_reducer!( + self.0.call_pipe().conn.reducers, + "sync_group_mirror", + sync_group_mirror_then( + roster.group_id, + roster.leader_guid, + roster.loot_method, + roster.loot_threshold, + roster.master_looter_guid, + roster.member_guids(), + self.owner_actor(), + partitions, + roster.roster_revision, + roster.kind.wire(), + roster + .members + .iter() + .map(|member| member.slot.wire()) + .collect() + ) + ) + } +} + +/// The Module's typed party Refusal. Only a reducer the Module rejected carries a tag; a timeout, +/// transport, or SDK failure stays an error with an unknown outcome. +fn group_refusal(error: &anyhow::Error) -> Option { + reducer_refusal_reason(error).and_then(GroupRefusal::parse_tag) +} + +fn command_refusal(error: &anyhow::Error) -> Option { + let tag = reducer_refusal_reason(error)?; + Some(match tag { + "Applied" => CompanionCommandOutcome::Applied, + "Unchanged" => CompanionCommandOutcome::Unchanged, + "Malformed" => CompanionCommandOutcome::Malformed, + "NotLeader" => CompanionCommandOutcome::NotLeader, + "NotMember" => CompanionCommandOutcome::NotMember, + "StalePartyMirror" => CompanionCommandOutcome::StalePartyMirror, + "WrongAccount" => CompanionCommandOutcome::WrongAccount, + "MissingBot" => CompanionCommandOutcome::MissingBot, + "WrongPartition" => CompanionCommandOutcome::WrongPartition, + "Suppressed" => CompanionCommandOutcome::Suppressed, + "TargetDead" => CompanionCommandOutcome::TargetDead, + "TargetUnavailable" => CompanionCommandOutcome::TargetUnavailable, + "TargetControlled" => CompanionCommandOutcome::TargetControlled, + "Expired" => CompanionCommandOutcome::Expired, + "WaitingForCapacity" => CompanionCommandOutcome::WaitingForCapacity, + "OutcomeUnknown" => CompanionCommandOutcome::OutcomeUnknown, + "Superseded" => CompanionCommandOutcome::Superseded, + _ => return None, + }) +} + +fn command_outcome_binding( + outcome: CompanionCommandOutcome, +) -> crate::stdb::bindings::CommandOutcome { + use crate::stdb::bindings::CommandOutcome as Row; + match outcome { + CompanionCommandOutcome::Applied => Row::Applied, + CompanionCommandOutcome::Unchanged => Row::Unchanged, + CompanionCommandOutcome::Malformed => Row::Malformed, + CompanionCommandOutcome::NotLeader => Row::NotLeader, + CompanionCommandOutcome::NotMember => Row::NotMember, + CompanionCommandOutcome::StalePartyMirror => Row::StalePartyMirror, + CompanionCommandOutcome::WrongAccount => Row::WrongAccount, + CompanionCommandOutcome::MissingBot => Row::MissingBot, + CompanionCommandOutcome::WrongPartition => Row::WrongPartition, + CompanionCommandOutcome::Suppressed => Row::Suppressed, + CompanionCommandOutcome::TargetDead => Row::TargetDead, + CompanionCommandOutcome::TargetUnavailable => Row::TargetUnavailable, + CompanionCommandOutcome::TargetControlled => Row::TargetControlled, + CompanionCommandOutcome::Expired => Row::Expired, + CompanionCommandOutcome::WaitingForCapacity => Row::WaitingForCapacity, + CompanionCommandOutcome::OutcomeUnknown => Row::OutcomeUnknown, + CompanionCommandOutcome::Superseded => Row::Superseded, + } +} + +/// A refused party reducer is an outcome the client renders; anything else ends the session. +fn party_outcome(result: Result<()>) -> Result { + match result { + Ok(()) => Ok(PartyOutcome::Ran), + Err(error) => match group_refusal(&error) { + Some(refusal) => Ok(refusal.into()), + None => Err(error), + }, + } +} diff --git a/gateway/src/stdb/store/quest.rs b/gateway/src/stdb/store/quest.rs new file mode 100644 index 000000000..e5b7a9ebe --- /dev/null +++ b/gateway/src/stdb/store/quest.rs @@ -0,0 +1,534 @@ +//! `Coordinator`'s [`QuestActionStore`] adapter. + +use anyhow::{anyhow, Result}; +use spacetimedb_sdk::Table; + +use crate::codec; +use crate::stdb::bindings::*; +use crate::stdb::connection::call_reducer; +use crate::stdb::reads::{build_quest_log_slots, quest_objectives_complete}; +use crate::stdb::Coordinator; +use crate::world::{Actor, QuestActionStore}; + +impl QuestActionStore for crate::stdb::Coordinator { + fn giver_quest_evals( + &self, + giver_guid: u64, + player_guid: u64, + ) -> Result> { + crate::stdb::Coordinator::quest_giver_evals(self, giver_guid, player_guid) + } + + fn quest_detail_view(&self, quest_id: u32) -> Result> { + crate::stdb::Coordinator::quest_detail(self, quest_id) + } + + fn giver_refuses_interaction(&self, giver_guid: u64, player_guid: u64) -> Result { + crate::stdb::Coordinator::npc_refuses_interaction(self, giver_guid, player_guid) + } + + fn accept_quest( + &self, + account_id: u64, + self_guid: u64, + giver_guid: u64, + quest_id: u32, + ) -> Result<()> { + crate::stdb::Coordinator::accept_quest(self, account_id, self_guid, giver_guid, quest_id) + } + + fn item_start_quest(&self, owner_guid: u64, slot: u8) -> Option<(u64, u32)> { + crate::stdb::Coordinator::item_start_quest(self, owner_guid, slot) + } + + fn turn_in_quest( + &self, + account_id: u64, + self_guid: u64, + giver_guid: u64, + quest_id: u32, + reward_index: u32, + ) -> Result<()> { + crate::stdb::Coordinator::turn_in_quest( + self, + account_id, + self_guid, + giver_guid, + quest_id, + reward_index, + ) + } + + fn player_quest_log(&self, player_guid: u64) -> Result> { + crate::stdb::Coordinator::player_quest_log(self, player_guid) + } + + fn abandon_quest(&self, account_id: u64, self_guid: u64, quest_id: u32) -> Result<()> { + crate::stdb::Coordinator::abandon_quest(self, account_id, self_guid, quest_id) + } + + fn push_quest(&self, account_id: u64, self_guid: u64, quest_id: u32) -> Result<()> { + crate::stdb::Coordinator::push_quest(self, account_id, self_guid, quest_id) + } + + fn quest_status(&self, player_guid: u64, quest_id: u32) -> (bool, bool) { + crate::stdb::Coordinator::quest_status(self, player_guid, quest_id) + } +} + +impl Coordinator { + /// Item-starts-quest: does the item in `owner_guid`'s inventory `slot` carry a + /// non-zero `item_template.start_quest`? Returns `(item_guid, quest_id)` if so — the gateway's + /// `CMSG_USE_ITEM` handler uses this to open `SMSG_QUESTGIVER_QUEST_DETAILS` (item guid as giver) + /// INSTEAD of calling `use_item` (never both — the item isn't consumed). `None` for every ordinary + /// item (the ubiquitous case), which falls through to the normal use-item path unchanged. + pub fn item_start_quest(&self, owner_guid: u64, slot: u8) -> Option<(u64, u32)> { + let guard = self.0.coord(); + let db = &guard.conn.db; + let hit: Option<(u64, u32)> = db + .game_item_instance() + .iter() + .filter(|i| i.owner_guid == owner_guid && i.slot == slot) + .find_map(|i| { + db.game_item_template() + .entry() + .find(&i.entry) + .filter(|t| t.start_quest != 0) + .map(|t| (i.guid, t.start_quest)) + }); + hit + } + + /// `(taken, rewarded)` for `quest_id` in `guid`'s quest log (privileged read — RLS bypassed, like + /// `quest_giver_evals`) — feeds `option_condition_holds` for the QUEST_TAKEN/QUEST_REWARDED gossip + /// option conditions. `taken` is true whenever a log row exists at all (accepted, + /// whether or not yet turned in); a quest never seen by this player is `(false, false)`. + pub fn quest_status(&self, guid: u64, quest_id: u32) -> (bool, bool) { + let guard = self.0.coord(); + let db = &guard.conn.db; + let found = db + .game_character_quest() + .iter() + .find(|q| q.character_guid == guid && q.quest_entry == quest_id) + .map(|row| row.rewarded); + match found { + Some(rewarded) => (true, rewarded), + None => (false, false), + } + } + + /// Evaluate every quest a creature OR gameobject `giver_guid` starts/ends against `player_guid`, + /// for the overhead status icon (`SMSG_QUESTGIVER_STATUS`) + the quest menu + /// (`SMSG_QUESTGIVER_QUEST_LIST`). The giver is resolved EXACTLY like the module's + /// `quest::validate_giver` fallback: a live `game_world_entity` first (a + /// creature), else a spawned `game_gameobject` (GO 68 "Wanted Poster" starts q176 with NO creature + /// giver at all; GO 55/56 "Lost Guards" corpses drive the q37/q45/q71 END chain) — never both, and + /// never a live player (this reader doesn't special-case party-share givers; a + /// player guid here matches the entity lookup (players share the table) but resolves to Giver::Creature(0) — a player entry is always 0, and no game_creature_quest row keys entry 0, so the result is empty, same as before). For each relation + /// on the giver's entry (`game_creature_quest` or `game_gameobject_quest`): join its + /// `game_quest_template`, look the quest up in the player's `game_character_quest` log (the + /// coordinator bypasses RLS so it reads any player's log), and compute `startable` (a fresh, + /// level-qualifying START), `active` (held + un-rewarded END), and `complete` (active + every + /// objective's count met). One guard, all inline — no nested cache locks. The codec + /// ([`crate::codec::quest_giver_status`]) folds these. + pub fn quest_giver_evals( + &self, + giver_guid: u64, + player_guid: u64, + ) -> Result> { + let guard = self.0.coord(); + let db = &guard.conn.db; + enum Giver { + Creature(u32), + GameObject(u32), + } + let giver = if let Some(e) = db.game_world_entity().guid().find(&giver_guid) { + Giver::Creature(e.entry) + } else if let Some(go) = db.game_gameobject().guid().find(&giver_guid) { + Giver::GameObject(go.template_entry) + } else { + return Ok(Vec::new()); + }; + // No live player → no status (don't fabricate a level-0 phantom). A DEAD player (ghost) gets no + // status either: the module rejects both accept AND turn-in while dead, so showing a `!`/`?` the + // reducer would then reject is exactly the icon-vs-reducer mismatch we forbid. + let Some(player) = db.game_world_entity().guid().find(&player_guid) else { + return Ok(Vec::new()); + }; + if player.dead { + return Ok(Vec::new()); + } + let player_level = player.level; + // Race/class packed in unit_bytes_0 (race | class<<8 | …), for the gates. + let race = (player.unit_bytes_0 & 0xFF) as u8; + let class = ((player.unit_bytes_0 >> 8) & 0xFF) as u8; + // The player's quest log, guid-keyed (privileged read — RLS bypassed). + let log: Vec<_> = db + .game_character_quest() + .iter() + .filter(|q| q.character_guid == player_guid) + .collect(); + let evals = match giver { + Giver::Creature(entry) => db + .game_creature_quest() + .iter() + .filter(|r| r.creature_entry == entry) + .filter_map(|rel| { + eval_relation( + db, + rel.quest_entry, + rel.role, + &log, + player_guid, + player_level, + race, + class, + ) + }) + .collect(), + Giver::GameObject(entry) => db + .game_gameobject_quest() + .iter() + .filter(|r| r.go_entry == entry) + .filter_map(|rel| { + eval_relation( + db, + rel.quest_entry, + rel.role, + &log, + player_guid, + player_level, + race, + class, + ) + }) + .collect(), + }; + Ok(evals) + } + + /// Build the detail view for one quest (the accept screen, the offer-reward screen, and the + /// completion popup all read this): title, money, the RESOLVED XP reward (explicit, else the shared + /// `lyracore_shared::quest::xp_reward` so it matches the module's grant), reward items joined with their + /// display ids, and a synthesized objectives line per kill objective ("Creature slain: 0/N" from the + /// creature-template name — quest body text isn't imported yet). `None` if the quest isn't loaded. + pub fn quest_detail(&self, quest_id: u32) -> Result> { + let guard = self.0.coord(); + Ok(quest_detail_view(&guard.conn.db, quest_id)) + } + + /// The player's active (un-rewarded) quests as quest-log descriptor slots: the quest-log window. + /// Deterministic slot assignment (sorted by quest_entry → slot 0..), capped at the 20 vanilla slots; + /// each slot carries the quest id, per-objective counts, and a state byte (1 = all objectives met, + /// else 0). The gateway encodes these into the `PLAYER_QUEST_LOG_*` fields. RLS-bypassed read. + pub fn player_quest_log( + &self, + player_guid: u64, + ) -> Result> { + let guard = self.0.coord(); + let db = &guard.conn.db; + let quests: Vec<_> = db.game_character_quest().iter().collect(); + Ok(build_quest_log_slots(db, &quests, player_guid)) + } + + /// `CMSG_PUSHQUESTTOPARTY` — over the coordinator connection so the module + /// attributes the sender + its grouped/on-quest gates to the caller. + pub fn push_quest(&self, _account_id: u64, actor_guid: u64, quest_id: u32) -> Result<()> { + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("push_quest: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_push_quest_to_party", + gw_push_quest_to_party_then(self.session_actor(actor), quest_id) + ) + } + + /// Accept quest `quest_id` from giver `giver_guid` (`CMSG_QUESTGIVER_ACCEPT_QUEST`) over the + /// coordinator connection so the module attributes it to the caller. The module gates the accept + /// (giver relation + range + level + not-already-held); a gameplay `Err` is per-action, not fatal. + /// Rides the coordinator connection as `gw_accept_quest`. + pub fn accept_quest( + &self, + _account_id: u64, + actor_guid: u64, + giver_guid: u64, + quest_id: u32, + ) -> Result<()> { + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("accept_quest: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_accept_quest", + gw_accept_quest_then(self.session_actor(actor), giver_guid, quest_id) + ) + } + + /// Turn quest `quest_id` in to giver `giver_guid` (`CMSG_QUESTGIVER_CHOOSE_REWARD`). The module + /// validates completion + grants the rewards (money/XP/items). `reward_index` is the player's + /// pick-1-of-N choice slot; ignored when the quest has no choices. This call uses the subscribed + /// visibility pipe so committed item relays are queued before success presentation is allowed. + pub fn turn_in_quest( + &self, + _account_id: u64, + actor_guid: u64, + giver_guid: u64, + quest_id: u32, + reward_index: u32, + ) -> Result<()> { + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("turn_in_quest: actor_guid unresolved"))?; + let coord = self.0.visibility_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_turn_in_quest", + gw_turn_in_quest_then( + self.session_actor(actor), + giver_guid, + quest_id, + reward_index + ) + ) + } + + /// Abandon quest `quest_id` (`CMSG_QUESTLOG_REMOVE_QUEST`) over the coordinator connection. The + /// module deletes the player's quest-log row; the quest-log relay then clears the slot. + pub fn abandon_quest(&self, _account_id: u64, actor_guid: u64, quest_id: u32) -> Result<()> { + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("abandon_quest: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_abandon_quest", + gw_abandon_quest_then(self.session_actor(actor), quest_id) + ) + } +} + +/// Build one quest's `QuestDetailView` off `db` (any connection's cache carrying the public quest +/// tables — the privileged coordinator cache via `Coordinator::quest_detail`, OR a per-player +/// subscription's own cache when it's subscribed to them too). `None` if the quest isn't loaded. +/// Extracted from `Coordinator::quest_detail` so the QUEST_SHARE group-event relay +/// (`subscriptions.rs`) can build the SAME view over a cloned `Coordinator` handle without +/// duplicating this join logic — one chokepoint, not two copies to keep in sync. +pub(crate) fn quest_detail_view( + db: &RemoteTables, + quest_id: u32, +) -> Option { + use crate::codec::QuestDetailView; + let tmpl = db.game_quest_template().entry().find(&quest_id)?; + // MUST mirror module/src/quest.rs's award resolution exactly (explicit override → authentic + // RewMoneyMaxLevel/0.6 → xp_reward placeholder) so the completion popup equals the grant. No + // over-level penalty on either side yet (deferred — add to both together with the player's level). + let reward_xp = if tmpl.reward_xp > 0 { + tmpl.reward_xp + } else if tmpl.reward_money_max_level > 0 { + lyracore_shared::quest::quest_xp(tmpl.reward_money_max_level) + } else { + lyracore_shared::quest::xp_reward(tmpl.quest_level) + }; + // Body text from the side table (cmangos Details/Objectives/OfferReward/RequestItems). + let text = db.game_quest_text().quest_entry().find(&quest_id); + let details = text.as_ref().map(|t| t.details.clone()).unwrap_or_default(); + let offer_reward_text = text + .as_ref() + .map(|t| t.offer_reward_text.clone()) + .unwrap_or_default(); + let request_items_text = text + .as_ref() + .map(|t| t.request_items_text.clone()) + .unwrap_or_default(); + let imported_objectives = text.map(|t| t.objectives).unwrap_or_default(); + // All objectives in obj_index order → wire (creature_or_go, count, req_item, req_item_count): + // KILL_CREATURE(0): (+creature_entry, count, 0, 0) + // COLLECT_ITEM(1): (0, 0, item_entry, count) + // USE_GAMEOBJECT(2): (negative go_entry as u32, count, 0, 0) — client checks sign to tell apart + // EXPLORE_AREATRIGGER(3): no wire slot (area triggers have no entry in the objectives array) + let mut raw_objs: Vec<(u8, u32, u32, u32, u32)> = db + .game_quest_objective() + .iter() + .filter(|o| o.quest_entry == quest_id) + .filter_map(|o| { + let slot = match o.kind { + 0 => (o.target_entry, o.required_count, 0u32, 0u32), + 1 => (0, 0, o.target_entry, o.required_count), + 2 => ((-(o.target_entry as i32)) as u32, o.required_count, 0, 0), + _ => return None, // EXPLORE_AREATRIGGER and unknown: no wire slot + }; + Some((o.obj_index, slot.0, slot.1, slot.2, slot.3)) + }) + .collect(); + raw_objs.sort_by_key(|(i, _, _, _, _)| *i); + Some(QuestDetailView { + quest_id, + quest_level: tmpl.quest_level, + zone_or_sort: tmpl.zone_or_sort, + title: tmpl.title.clone(), + details, + objectives_text: synthesized_objectives(db, quest_id, imported_objectives), + offer_reward_text, + request_items_text, + money_reward: tmpl.reward_money, + reward_xp, + // Threaded from the template — chains (successor auto-offer) + the level-cap + // payout preview (always computed; the client only surfaces it when the VIEWER is capped). + next_quest_id: tmpl.next_quest_id, + max_level_money_reward: lyracore_shared::quest::max_level_money_reward(reward_xp), + rewards: quest_rewards(db, quest_id), + choice_rewards: quest_choice_rewards(db, quest_id), + objectives: raw_objs + .into_iter() + .map(|(_, a, b, c, d)| (a, b, c, d)) + .collect(), + }) +} + +// One argument per column of the condition row being evaluated; the SDK row type is not in scope on this side of the read. +#[allow(clippy::too_many_arguments)] +/// Evaluate ONE giver relation (a `game_creature_quest` OR `game_gameobject_quest` row, already +/// filtered to the giver's entry by the caller) against `player_guid`'s quest log. Takes the bare +/// `quest_entry`/`role` rather than a table row type — `game_creature_quest` and `game_gameobject_quest` +/// have the identical `(quest_entry, role)` shape, so `quest_giver_evals` calls this +/// same function for both, keyed only on which relation table it iterated. +/// +/// PARITY: `startable` answers the same question as the Module's `quest::accept_gates` +/// (`module/src/quest.rs`) — level, race, class, prerequisite chain, not already held — and the two +/// must agree, else the `!` icon offers a quest the accept reducer refuses. It stays a second copy +/// because it reads the Gateway's own subscription-cache row types and cannot call Module code; the +/// mask functions in `lyracore_shared::quest` are the part both tiers do share. A Gate changed in +/// `accept_gates` has to be changed here as well. +fn eval_relation( + db: &RemoteTables, + quest_entry: u32, + role: u8, + log: &[CharacterQuest], + player_guid: u64, + player_level: u32, + race: u8, + class: u8, +) -> Option { + use crate::codec::{GiverQuestEval, ROLE_START}; + let tmpl = db.game_quest_template().entry().find(&quest_entry)?; + let logrow = log.iter().find(|q| q.quest_entry == quest_entry); + let active = logrow.map(|q| !q.rewarded).unwrap_or(false); + // Complete = active AND every objective met — INVENTORY-AWARE for COLLECT_ITEM (see the helper). + let complete = active && quest_objectives_complete(db, quest_entry, logrow, player_guid); + // A repeatable quest already rewarded keeps its CharacterQuest row (module/src/quest.rs no longer + // deletes it — deleting would erase the only record it was ever completed, breaking any OTHER quest's + // `prev_quest_id` check below), so "no row" is not the right re-offer test for it: a rewarded row on a + // repeatable quest is startable again, mirroring apply_accept_quest's duplicate-guard exception. + let no_active_or_done = + logrow.is_none() || (tmpl.repeatable && logrow.is_some_and(|q| q.rewarded)); + let startable = role == ROLE_START + && no_active_or_done + && player_level >= tmpl.min_level + && lyracore_shared::quest::race_allowed(tmpl.required_races, race) + && lyracore_shared::quest::class_allowed(tmpl.required_classes, class) + && (tmpl.prev_quest_id == 0 + || log + .iter() + .any(|q| q.quest_entry == tmpl.prev_quest_id && q.rewarded)); + Some(GiverQuestEval { + quest_id: tmpl.entry, + title: tmpl.title.clone(), + level: tmpl.quest_level, + role, + startable, + active, + complete, + }) +} + +/// A quest's guaranteed reward items joined with each item's `display_id` (0 if the template isn't loaded). +fn quest_rewards(db: &RemoteTables, quest_id: u32) -> Vec { + db.game_quest_reward_item() + .iter() + .filter(|r| r.quest_entry == quest_id) + .map(|r| { + let display_id = db + .game_item_template() + .entry() + .find(&r.item_entry) + .map(|t| t.display_id) + .unwrap_or(0); + crate::codec::QuestRewardView { + item_entry: r.item_entry, + count: r.count, + display_id, + } + }) + .collect() +} + +/// A quest's CHOICE reward items (pick-1-of-N), joined to each item's `display_id` (0 if the template +/// isn't loaded) and ORDERED BY `choice_index` so the wire position matches the index the client sends +/// back as `CMSG_QUESTGIVER_CHOOSE_REWARD.reward`. +fn quest_choice_rewards(db: &RemoteTables, quest_id: u32) -> Vec { + let mut rows: Vec<_> = db + .game_quest_reward_choice() + .iter() + .filter(|r| r.quest_entry == quest_id) + .collect(); + rows.sort_by_key(|r| r.choice_index); + rows.into_iter() + .map(|r| { + let display_id = db + .game_item_template() + .entry() + .find(&r.item_entry) + .map(|t| t.display_id) + .unwrap_or(0); + crate::codec::QuestRewardView { + item_entry: r.item_entry, + count: r.count, + display_id, + } + }) + .collect() +} + +/// The objectives text for the detail window: the imported cmangos `Objectives` if present, else a +/// synthesized line per objective (kind-aware: kill = "Name slain: 0/N", collect = "Name: 0/N"). +fn synthesized_objectives(db: &RemoteTables, quest_id: u32, imported: String) -> String { + if !imported.trim().is_empty() { + return imported; + } + let mut objs: Vec<(u8, String)> = db + .game_quest_objective() + .iter() + .filter(|o| o.quest_entry == quest_id) + .map(|o| { + let text = match o.kind { + 0 => { + // KILL_CREATURE + let name = db + .game_creature_template() + .entry() + .find(&o.target_entry) + .map(|c| c.name.clone()) + .unwrap_or_default(); + format!("{name} slain: 0/{}", o.required_count) + } + 1 => { + // COLLECT_ITEM + let name = db + .game_item_template() + .entry() + .find(&o.target_entry) + .map(|t| t.name.clone()) + .unwrap_or_default(); + format!("{name}: 0/{}", o.required_count) + } + _ => String::new(), // USE_GAMEOBJECT / EXPLORE: cmangos Objectives text covers these + }; + (o.obj_index, text) + }) + .filter(|(_, t)| !t.is_empty()) + .collect(); + objs.sort_by_key(|(i, _)| *i); + objs.into_iter() + .map(|(_, s)| s) + .collect::>() + .join("\n") +} diff --git a/gateway/src/stdb/store/session.rs b/gateway/src/stdb/store/session.rs index 751e91312..f29b4afb4 100644 --- a/gateway/src/stdb/store/session.rs +++ b/gateway/src/stdb/store/session.rs @@ -1,15 +1,18 @@ //! `Coordinator`'s [`SessionStore`] adapter. +use std::time::Duration; + use anyhow::{anyhow, Result}; +use spacetimedb_sdk::Table; use wow_world_messages::vanilla::MovementInfo; use crate::codec; +use crate::stdb::bindings::{GwMove, *}; +use crate::stdb::connection::call_reducer; +use crate::stdb::views::entity_view; +use crate::stdb::{Coordinator, PlayerSubscriptions}; use crate::world::{Actor, SessionStore, SessionTx, WorldSession, WorldStore, MOVE_SUBMITTED}; -use crate::stdb::bindings::GwMove; -use crate::stdb::Coordinator; -use crate::stdb::PlayerSubscriptions; - impl SessionStore for Coordinator { fn lookup_session(&self, account_name: &str) -> Result> { crate::realm_core::lookup_session(self, account_name) @@ -106,3 +109,156 @@ impl SessionStore for Coordinator { self.player_combat_until_ms(player_guid) } } + +impl Coordinator { + /// The EFFECTIVE armor for `guid` for the character-sheet CREATE (`UNIT_FIELD_RESISTANCES[0]`), + /// Presence check for the WORLDPORT_ACK gate: is the guid's live entity in the world? + pub fn entity_in_world(&self, guid: u64) -> bool { + let guard = self.0.coord(); + guard + .conn + .db + .game_world_entity() + .guid() + .find(&guid) + .is_some() + } + + /// The live entity's `max_health` from the privileged cache — 0 if not in world. Feeds the + /// fall-damage flavor line; the module applies the authoritative damage itself. + pub fn entity_max_health(&self, guid: u64) -> u32 { + let guard = self.0.coord(); + guard + .conn + .db + .game_world_entity() + .guid() + .find(&guid) + .map(|e| e.max_health) + .unwrap_or(0) + } + + /// Return the `combat_until_ms` value for `player_guid`'s entity row (0 if the entity is not + /// found or was never in combat). Read from the privileged coordinator cache. + pub fn player_combat_until_ms(&self, player_guid: u64) -> u64 { + let guard = self.0.coord(); + guard + .conn + .db + .game_world_entity() + .guid() + .find(&player_guid) + .map_or(0, |e| e.combat_until_ms) + } + + /// Undelivered private System Messages addressed to `self_guid`, oldest-first, from the + /// shard's `game_system_message_event` cache. Rows live until the module's event GC reaps + /// them, so a message emitted inside `player_login` is still here at world entry. + pub fn system_messages_for(&self, self_guid: u64) -> Vec { + let mut rows: Vec<(u64, String)> = self + .0 + .coord() + .conn + .db + .game_system_message_event() + .iter() + .filter(|row| row.recipient_guid == self_guid) + .map(|row| (row.id, row.message)) + .collect(); + rows.sort_unstable_by_key(|(id, _)| *id); + rows.into_iter().map(|(_, message)| message).collect() + } + + /// Enter the world: call the `player_login` reducer on the coordinator connection + /// (so `ctx.sender` is the player's bound identity), then read the resulting + /// `game_world_entity` row back through the privileged cache as an `EntityView`. + pub fn player_login( + &self, + account_id: u64, + character_guid: u64, + entry: crate::codec::WorldEntry, + ) -> Result { + // Login rides `gw_player_login` on the COORDINATOR connection (module half: delegates to + // apply_player_login with the account's bound identity as row owner, binds entity→lease, + // fail-closed on either missing) — no per-player connection exists anywhere. A world-port + // rides its twin `gw_player_world_port`, which keeps the Away Status. + let coord = self.0.call_pipe(); + let actor = self.actor_or_owner(character_guid); + match entry { + crate::codec::WorldEntry::FreshLogin => call_reducer!( + coord.conn.reducers, + "gw_player_login", + gw_player_login_then(account_id, actor) + )?, + crate::codec::WorldEntry::WorldPort => call_reducer!( + coord.conn.reducers, + "gw_player_world_port", + gw_player_world_port_then(account_id, actor) + )?, + } + + // The reducer committed; the row propagates to the owner cache asynchronously. Poll + // briefly until it appears (home_* ride along from the game_character row, and its + // zone_id is the fallback for a live row the Module could not resolve a zone for). + let char_row = self + .0 + .coord() + .conn + .db + .game_character() + .guid() + .find(&character_guid); + let zone_id = char_row.as_ref().map(|c| c.zone_id).unwrap_or(0); + let home_map = char_row.as_ref().map(|c| c.home_map).unwrap_or(0); + let home_zone = char_row.as_ref().map(|c| c.home_zone).unwrap_or(0); + let home_x = char_row.as_ref().map(|c| c.home_x).unwrap_or(0.0); + let home_y = char_row.as_ref().map(|c| c.home_y).unwrap_or(0.0); + let home_z = char_row.as_ref().map(|c| c.home_z).unwrap_or(0.0); + // 15 s cap, 15 ms steps. Was 3 s — the cold-1000 measurement showed the reducer + // COMMITTING while the coordinator stream lagged the login-burst tail past 3 s (writer at + // 34.5%, so pure propagation, not CPU): 67/1000 logins died here with the entity already + // live. The poll exits on first sight, so the longer cap costs nothing outside a burst. + for _ in 0..1000 { + if let Some(e) = self + .0 + .coord() + .conn + .db + .game_world_entity() + .guid() + .find(&character_guid) + { + let mut view = entity_view(e, zone_id); + view.home_map = home_map; + view.home_zone = home_zone; + view.home_x = home_x; + view.home_y = home_y; + view.home_z = home_z; + return Ok(view); + } + std::thread::sleep(Duration::from_millis(15)); + } + Err(anyhow!( + "player_login committed but game_world_entity {character_guid} not visible in the \ + coordinator cache within 15s" + )) + } + + /// Forward an addon-bridge command to the module's `client_command` dispatch. + pub fn client_command( + &self, + _account_id: u64, + actor_guid: u64, + cmd: String, + payload: String, + ) -> Result<()> { + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("client_command: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_client_command", + gw_client_command_then(self.session_actor(actor), cmd, payload) + ) + } +} diff --git a/gateway/src/stdb/store/shard_routing.rs b/gateway/src/stdb/store/shard_routing.rs index f92a0a901..e95dc30c3 100644 --- a/gateway/src/stdb/store/shard_routing.rs +++ b/gateway/src/stdb/store/shard_routing.rs @@ -2,9 +2,8 @@ use anyhow::Result; -use crate::world::{ShardRoutingStore, WorldStore}; - use crate::stdb::Coordinator; +use crate::world::{ShardRoutingStore, WorldStore}; impl ShardRoutingStore for Coordinator { fn shard_name(&self) -> &str { diff --git a/gateway/src/stdb/store/social.rs b/gateway/src/stdb/store/social.rs index 4ac80ae68..e4a3d5416 100644 --- a/gateway/src/stdb/store/social.rs +++ b/gateway/src/stdb/store/social.rs @@ -1,11 +1,14 @@ //! `Coordinator`'s [`SocialStore`] adapter. -use anyhow::Result; +use anyhow::{anyhow, Result}; +use lyracore_shared::social::ContactRefusal; +use spacetimedb_sdk::Table; use crate::codec; -use crate::world::SocialStore; - +use crate::stdb::bindings::*; +use crate::stdb::connection::{call_reducer, reducer_refusal_reason}; use crate::stdb::Coordinator; +use crate::world::{Actor, ContactOutcome, SocialStore}; impl SocialStore for Coordinator { fn character_identity( @@ -89,3 +92,274 @@ impl SocialStore for Coordinator { self.del_ignore(account_id, self_guid, target_guid) } } + +impl Coordinator { + /// This Shard's durable Character row for `guid`: identity plus the session flag. `None` if + /// this Shard holds no `game_character` row for it. + pub fn character_identity( + &self, + guid: u64, + ) -> Result> { + Ok(self + .0 + .coord() + .conn + .db + .game_character() + .guid() + .find(&guid) + .map(|ch| crate::world::presence::CharacterIdentity { + guid, + name: ch.name, + race: ch.race, + class: ch.class, + level: ch.level, + zone_id: ch.zone_id, + session_online: ch.online, + })) + } + + /// This Shard's live `game_world_entity` row for `guid`, if any — the Member Stats columns + /// [`crate::codec::MemberEntity`] carries, joined with nothing else: level and zone come + /// straight off the entity, current unlike the durable row. + pub fn live_entity(&self, guid: u64) -> Option { + let guard = self.0.coord(); + let entity = guard.conn.db.game_world_entity().guid().find(&guid)?; + Some(crate::codec::MemberEntity { + health: entity.health, + max_health: entity.max_health, + power: entity.power, + max_power: entity.max_power, + unit_bytes_0: entity.unit_bytes_0, + level: entity.level, + zone_id: entity.zone_id, + x: entity.x, + y: entity.y, + dead: entity.dead, + player_flags: entity.player_flags, + // Member Stats' own aura/pet overlay (`Coordinator::with_member_shard_stats`) fills + // these afterward, keyed by the `ShardId` this generic, guid-only read cannot carry. + ..Default::default() + }) + } + + /// Does this Shard show `guid` between two places: its own Character row reading online with + /// no live entity here, or a Transfer Intent naming a session-less bot mid-crossing? The + /// Transfer Intent table is bounded by the Module's writer Gate, so the scan is short. + pub fn character_in_transit(&self, guid: u64) -> bool { + let guard = self.0.coord(); + let db = &guard.conn.db; + let session_online = db + .game_character() + .guid() + .find(&guid) + .is_some_and(|character| character.online); + session_online + || db + .game_bot_transfer_intent() + .iter() + .any(|intent| intent.bot_guid == guid) + } + + /// Every in-world player Character on this Shard, for `CMSG_WHO → SMSG_WHO` + /// (`presence::in_world_characters`'s per-Shard input, replacing the former `online_players`). + /// Iterates `game_world_entity` for entries with `entry == 0` (player entities; creatures have a + /// non-zero entry), then joins each against `game_character`. The coordinator bypasses RLS so it + /// sees every player's entity regardless of the caller's scope. + pub fn in_world_players(&self) -> Result> { + let guard = self.0.coord(); + let db = &guard.conn.db; + let shard_name = self.shard_name().to_string(); + let rows = db + .game_world_entity() + .iter() + .filter(|e| e.entry == 0) // players have entry == 0; creatures have a template entry + .filter_map(|e| { + let ch = db.game_character().guid().find(&e.guid)?; + let away = crate::world::presence::away_from_player_flags(e.player_flags); + let entity = crate::codec::MemberEntity { + health: e.health, + max_health: e.max_health, + power: e.power, + max_power: e.max_power, + unit_bytes_0: e.unit_bytes_0, + level: e.level, + zone_id: e.zone_id, + x: e.x, + y: e.y, + dead: e.dead, + player_flags: e.player_flags, + // `/who` does not read auras or a pet, so this bulk scan never fills them. + ..Default::default() + }; + Some(crate::world::presence::RealmPresence { + guid: e.guid, + name: ch.name, + race: ch.race, + class: ch.class, + level: u8::try_from(e.level).unwrap_or(u8::MAX), + zone_id: e.zone_id, + session_online: ch.online, + whereabouts: crate::world::presence::Whereabouts::InWorld { + away, + entity, + shard_name: shard_name.clone(), + }, + }) + }) + .collect(); + Ok(rows) + } + + /// `game_area.name` for `zone_id`, `/who`'s search-string match against a zone name — a static + /// catalogue, subscribed unconditionally. Empty when the catalogue holds no row for it. + pub fn zone_name(&self, zone_id: u32) -> String { + self.0 + .coord() + .conn + .db + .game_area() + .id() + .find(&zone_id) + .map(|a| a.name) + .unwrap_or_default() + } + + /// Resolve a typed name to a Character guid on this Shard, ASCII case-insensitive like the + /// Module's own `helpers::character_by_name`, from the Shard's [`CharacterNameIndex`]. `None` + /// if no Character here has that name. Every realm-wide name lookup (whisper, party and guild + /// invites, contacts, channel moderation) reaches this through `presence::resolve_by_name` or + /// `presence::resolve_all_by_name`. + pub fn character_guid_by_name(&self, name: &str) -> Result> { + let guard = self.0.coord(); + let names = guard + .character_names + .read() + .unwrap_or_else(|p| p.into_inner()); + Ok(names.guid_named(name)) + } + + /// `owner_guid`'s friend guids and ignore guids for `CMSG_FRIEND_LIST`, per + /// [`crate::world::SocialStore::contact_lists`]. `owner_guid` is always the CALLING + /// World Session's own guid, which is always live and registered right now, so this reads the + /// guids off its Gateway-side `Viewer` — the friend and ignore sets `world_view`'s contact + /// Relay already keeps current — instead of scanning `game_character_contact`. `None` Viewer + /// (should not happen for this caller) degrades to two empty lists rather than erroring. + /// Presence composition (online/team/Away Status) lives over the Store seam in + /// `world::social::friend_views`, not here, so a Store Fake exercises the same code a + /// Coordinator does. + pub fn contact_lists(&self, owner_guid: u64) -> Result<(Vec, Vec)> { + let Some(viewer) = self + .world_view() + .viewer_of_owner(super::super::world_view::OwnerGuid(owner_guid)) + else { + return Ok((Vec::new(), Vec::new())); + }; + Ok((viewer.friend_guids(), viewer.ignored_guids())) + } + + /// `owner_guid`'s ignore guids from this Shard's [`ContactIndex`], realm-wide safe for ANY + /// owner, per [`crate::world::SocialStore::ignored_guids`]. Unlike `contact_lists`, + /// `owner_guid` here is a Character this Gateway process may never have a `Viewer` for at all (a + /// whisper sender or a guild-invite target is usually a PEER, not the connected session), so + /// this cannot route through one. + pub fn ignored_guids(&self, owner_guid: u64) -> Result> { + let guard = self.0.coord(); + let contacts = guard.contacts.read().unwrap_or_else(|p| p.into_inner()); + Ok(contacts.ignored_by(owner_guid)) + } + + /// The stored Auto-Reply for `guid` on this Shard: one primary-key read of the private + /// `game_character_away` cache. + pub(crate) fn auto_reply_text(&self, guid: u64) -> Option { + self.0 + .coord() + .conn + .db + .game_character_away() + .character_guid() + .find(&guid) + .map(|row| row.message) + } + + /// `CMSG_ADD_FRIEND` — `target_guid` is already resolved by the gateway. `target_race` is the + /// target's Speaker Fact, read realm-wide (`presence::of`): the Module's Enemy Gate needs it. + pub fn add_friend( + &self, + _account_id: u64, + actor_guid: u64, + target_guid: u64, + target_race: u8, + ) -> Result { + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("add_friend: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + contact_outcome(call_reducer!( + coord.conn.reducers, + "gw_add_friend", + gw_add_friend_then(self.session_actor(actor), target_guid, target_race) + )) + } + + /// `CMSG_DEL_FRIEND`. + pub fn del_friend( + &self, + _account_id: u64, + actor_guid: u64, + target_guid: u64, + ) -> Result { + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("del_friend: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + contact_outcome(call_reducer!( + coord.conn.reducers, + "gw_del_friend", + gw_del_friend_then(self.session_actor(actor), target_guid) + )) + } + + /// `CMSG_ADD_IGNORE` — `target_guid` is already resolved by the gateway. + pub fn add_ignore( + &self, + _account_id: u64, + actor_guid: u64, + target_guid: u64, + ) -> Result { + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("add_ignore: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + contact_outcome(call_reducer!( + coord.conn.reducers, + "gw_add_ignore", + gw_add_ignore_then(self.session_actor(actor), target_guid) + )) + } + + /// `CMSG_DEL_IGNORE`. + pub fn del_ignore( + &self, + _account_id: u64, + actor_guid: u64, + target_guid: u64, + ) -> Result { + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("del_ignore: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + contact_outcome(call_reducer!( + coord.conn.reducers, + "gw_del_ignore", + gw_del_ignore_then(self.session_actor(actor), target_guid) + )) + } +} + +/// [`party_outcome`] for the friends and ignore lists. +fn contact_outcome(result: Result<()>) -> Result { + match result { + Ok(()) => Ok(ContactOutcome::Done), + Err(error) => match reducer_refusal_reason(&error).and_then(ContactRefusal::parse_tag) { + Some(refusal) => Ok(refusal.into()), + None => Err(error), + }, + } +} diff --git a/gateway/src/stdb/store/speech.rs b/gateway/src/stdb/store/speech.rs index 0274de298..861c3791e 100644 --- a/gateway/src/stdb/store/speech.rs +++ b/gateway/src/stdb/store/speech.rs @@ -1,10 +1,12 @@ //! `Coordinator`'s [`SpeechStore`] adapter. -use anyhow::Result; - -use crate::world::{ChatOutcome, SpeechStore}; +use anyhow::{anyhow, Result}; +use super::chat::chat_outcome; +use crate::stdb::bindings::*; +use crate::stdb::connection::call_reducer; use crate::stdb::Coordinator; +use crate::world::{Actor, ChatOutcome, SpeechStore}; impl SpeechStore for Coordinator { fn send_chat( @@ -33,3 +35,54 @@ impl SpeechStore for Coordinator { self.gm_command(account_name, self_guid, text) } } + +impl Coordinator { + /// Say, yell or `/e` on the speaker's Home Shard. The Module's language Gate answers a + /// `chat:*` Refusal. + pub fn send_chat( + &self, + _account_id: u64, + actor_guid: u64, + chat_type: u8, + language: u8, + message: String, + ) -> Result { + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("send_chat: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + chat_outcome(call_reducer!( + coord.conn.reducers, + "gw_send_chat", + gw_send_chat_then(self.session_actor(actor), chat_type, language, message) + )) + } + + pub fn send_emote( + &self, + _account_id: u64, + actor_guid: u64, + text_emote: u32, + emote_anim: u32, + target_guid: u64, + ) -> Result<()> { + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("send_emote: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_send_emote", + gw_send_emote_then( + self.session_actor(actor), + text_emote, + emote_anim, + target_guid + ) + ) + } + + /// GM playtest dot-command: resolve current Account authority on Realm-core, then convey it to + /// the Home Shard through one Store operation. + pub fn gm_command(&self, account_name: &str, actor_guid: u64, text: String) -> Result<()> { + crate::realm_core::run_gm_command(self, account_name, actor_guid, text) + } +} diff --git a/gateway/src/stdb/store/taxi.rs b/gateway/src/stdb/store/taxi.rs new file mode 100644 index 000000000..44bfbd399 --- /dev/null +++ b/gateway/src/stdb/store/taxi.rs @@ -0,0 +1,288 @@ +//! `Coordinator`'s [`TaxiActionStore`] adapter. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::OnceLock; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use anyhow::{anyhow, Result}; + +use crate::codec; +use crate::stdb::bindings::*; +use crate::stdb::connection::call_reducer; +use crate::stdb::Coordinator; +use crate::world::{Actor, TaxiActionStore}; + +impl TaxiActionStore for crate::stdb::Coordinator { + fn taxi_node_status( + &self, + character_guid: u64, + npc_guid: u64, + ) -> Result> { + crate::stdb::Coordinator::taxi_node_status(self, character_guid, npc_guid) + } + + fn open_taxi(&self, character_guid: u64, npc_guid: u64) -> Result> { + crate::stdb::Coordinator::open_taxi(self, character_guid, npc_guid) + } + + fn activate_taxi( + &self, + character_guid: u64, + npc_guid: u64, + source_client_node_id: u32, + destination_client_node_id: u32, + ) -> Result { + crate::stdb::Coordinator::activate_taxi( + self, + character_guid, + npc_guid, + source_client_node_id, + destination_client_node_id, + ) + } + + fn arm_taxi_flight(&self, character_guid: u64) -> Result<()> { + crate::stdb::Coordinator::arm_taxi_flight(self, character_guid) + } +} + +impl Coordinator { + fn await_taxi_reply( + &self, + character_guid: u64, + npc_guid: u64, + request_id: u64, + operation: u8, + ) -> Result> { + // Reducer completion and subscription propagation travel on the same SDK connection but + // are separate events. Poll by the caller-chosen id so an older cached reply can never be + // mistaken for this operation's result. + for _ in 0..100 { + let reply = self + .0 + .coord() + .conn + .db + .game_taxi_service_reply() + .request_id() + .find(&request_id); + if let Some(reply) = + reply.filter(|reply| taxi_reply_matches(reply, character_guid, npc_guid, operation)) + { + // Copy the cache row before acknowledging it. The ack deletes only this request's + // module row; a failed ack is infrastructure loss and remains session-fatal. + call_reducer!( + self.0.call_pipe().conn.reducers, + "gw_ack_taxi_reply", + gw_ack_taxi_reply_then(self.actor_or_owner(character_guid), request_id) + )?; + if !reply.accepted { + log::debug!( + "taxi operation {operation} refused for character {character_guid}: {}", + reply.refusal + ); + } + return Ok(Some(reply)); + } + std::thread::sleep(Duration::from_millis(10)); + } + Err(anyhow!( + "taxi operation {operation} committed but reply {request_id} was not visible within 1s" + )) + } + + /// Cohesive status request: module resolution + policy runs before this returns one known bit. + pub fn taxi_node_status( + &self, + character_guid: u64, + npc_guid: u64, + ) -> Result> { + let Some(actor) = Actor::new(character_guid) else { + return Ok(None); + }; + let request_id = next_taxi_request_id(); + call_reducer!( + self.0.call_pipe().conn.reducers, + "gw_taxi_node_status", + gw_taxi_node_status_then(self.session_actor(actor), npc_guid, request_id) + )?; + Ok(self + .await_taxi_reply( + character_guid, + npc_guid, + request_id, + lyracore_shared::constants::taxi_protocol::REPLY_STATUS, + )? + .filter(|reply| reply.accepted) + .map(|reply| crate::codec::TaxiNodeStatusView { + npc_guid: reply.npc_guid, + known: reply.known, + })) + } + + pub fn open_taxi( + &self, + character_guid: u64, + npc_guid: u64, + ) -> Result> { + let Some(actor) = Actor::new(character_guid) else { + return Ok(None); + }; + let request_id = next_taxi_request_id(); + call_reducer!( + self.0.call_pipe().conn.reducers, + "gw_open_taxi", + gw_open_taxi_then(self.session_actor(actor), npc_guid, request_id) + )?; + Ok(self + .await_taxi_reply( + character_guid, + npc_guid, + request_id, + lyracore_shared::constants::taxi_protocol::REPLY_OPEN, + )? + .filter(|reply| reply.accepted) + .map(|reply| crate::codec::TaxiMapView { + npc_guid: reply.npc_guid, + source_client_node_id: reply.source_client_node_id, + available_client_node_ids: reply.available_client_node_ids, + })) + } + + /// Cohesive direct-flight activation. The module commits every gameplay outcome as a stable + /// result code; only reducer transport/timeout failures escape as `Err` and end the session. + pub fn activate_taxi( + &self, + character_guid: u64, + npc_guid: u64, + source_client_node_id: u32, + destination_client_node_id: u32, + ) -> Result { + let Some(actor) = Actor::new(character_guid) else { + return Ok(crate::codec::TaxiActivationResult { + result_code: + lyracore_shared::constants::taxi_protocol::ACTIVATE_UNSPECIFIED_SERVER_ERROR, + }); + }; + let request_id = next_taxi_request_id(); + call_reducer!( + self.0.call_pipe().conn.reducers, + "gw_activate_taxi", + gw_activate_taxi_then( + self.session_actor(actor), + npc_guid, + source_client_node_id, + destination_client_node_id, + request_id + ) + )?; + let reply = self + .await_taxi_reply( + character_guid, + npc_guid, + request_id, + lyracore_shared::constants::taxi_protocol::REPLY_ACTIVATE, + )? + .ok_or_else(|| anyhow!("taxi activation reply {request_id} disappeared"))?; + Ok(crate::codec::TaxiActivationResult { + result_code: reply.result_code, + }) + } + + pub fn arm_taxi_flight(&self, character_guid: u64) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "gw_arm_taxi_flight", + gw_arm_taxi_flight_then(self.actor_or_owner(character_guid)) + ) + } +} + +static NEXT_TAXI_REQUEST_ID: OnceLock = OnceLock::new(); + +fn next_taxi_request_id() -> u64 { + let next = NEXT_TAXI_REQUEST_ID.get_or_init(|| { + let seed = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() as u64; + AtomicU64::new(seed.max(1)) + }); + let id = next.fetch_add(1, Ordering::Relaxed); + if id == 0 { + next.fetch_add(1, Ordering::Relaxed) + } else { + id + } +} + +fn taxi_reply_matches( + reply: &TaxiServiceReply, + character_guid: u64, + npc_guid: u64, + operation: u8, +) -> bool { + reply.character_guid == character_guid + && reply.operation == operation + && reply.npc_guid == npc_guid +} + +#[cfg(test)] +mod taxi_reply_tests { + use super::*; + + fn reply( + request_id: u64, + character_guid: u64, + npc_guid: u64, + operation: u8, + ) -> TaxiServiceReply { + TaxiServiceReply { + request_id, + character_guid, + operation, + npc_guid, + accepted: true, + known: false, + source_client_node_id: 0, + available_client_node_ids: Vec::new(), + refusal: String::new(), + created_micros: 0, + result_code: lyracore_shared::constants::taxi_protocol::ACTIVATE_OK, + } + } + + #[test] + fn reply_selection_rejects_stale_character_operation_and_npc_rows() { + let current = reply( + 22, + 7, + 90, + lyracore_shared::constants::taxi_protocol::REPLY_OPEN, + ); + assert!(taxi_reply_matches( + ¤t, + 7, + 90, + lyracore_shared::constants::taxi_protocol::REPLY_OPEN, + )); + assert!(!taxi_reply_matches( + ¤t, + 8, + 90, + lyracore_shared::constants::taxi_protocol::REPLY_OPEN, + )); + assert!(!taxi_reply_matches( + ¤t, + 7, + 91, + lyracore_shared::constants::taxi_protocol::REPLY_OPEN, + )); + assert!(!taxi_reply_matches( + ¤t, + 7, + 90, + lyracore_shared::constants::taxi_protocol::REPLY_STATUS, + )); + } +} diff --git a/gateway/src/stdb/store/trade.rs b/gateway/src/stdb/store/trade.rs index 8f9b8fe49..697aa8c6c 100644 --- a/gateway/src/stdb/store/trade.rs +++ b/gateway/src/stdb/store/trade.rs @@ -1,10 +1,11 @@ //! `Coordinator`'s [`TradeStore`] adapter. -use anyhow::Result; - -use crate::world::TradeStore; +use anyhow::{anyhow, Result}; +use crate::stdb::bindings::*; +use crate::stdb::connection::call_reducer; use crate::stdb::Coordinator; +use crate::world::{Actor, TradeStore}; impl TradeStore for Coordinator { fn initiate_trade(&self, account_id: u64, self_guid: u64, target_guid: u64) -> Result<()> { @@ -53,3 +54,141 @@ impl TradeStore for Coordinator { self.ignore_trade(account_id, self_guid) } } + +impl Coordinator { + /// `CMSG_INITIATE_TRADE`, `target_guid` is the client's targeted player. + pub fn initiate_trade( + &self, + _account_id: u64, + actor_guid: u64, + target_guid: u64, + ) -> Result<()> { + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("initiate_trade: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_initiate_trade", + gw_initiate_trade_then(self.session_actor(actor), target_guid) + ) + } + + /// `CMSG_BEGIN_TRADE`. + pub fn begin_trade(&self, _account_id: u64, actor_guid: u64) -> Result<()> { + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("begin_trade: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_begin_trade", + gw_begin_trade_then(self.session_actor(actor)) + ) + } + + /// `CMSG_CANCEL_TRADE`. + pub fn cancel_trade(&self, _account_id: u64, actor_guid: u64) -> Result<()> { + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("cancel_trade: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_cancel_trade", + gw_cancel_trade_then(self.session_actor(actor)) + ) + } + + /// `CMSG_SET_TRADE_ITEM`. + pub fn set_trade_item( + &self, + _account_id: u64, + actor_guid: u64, + trade_slot: u8, + inv_slot: u8, + ) -> Result<()> { + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("set_trade_item: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_set_trade_item", + gw_set_trade_item_then(self.session_actor(actor), trade_slot, inv_slot) + ) + } + + /// `CMSG_CLEAR_TRADE_ITEM`. + pub fn clear_trade_item( + &self, + _account_id: u64, + actor_guid: u64, + trade_slot: u8, + ) -> Result<()> { + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("clear_trade_item: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_clear_trade_item", + gw_clear_trade_item_then(self.session_actor(actor), trade_slot) + ) + } + + /// `CMSG_SET_TRADE_GOLD`. + pub fn set_trade_gold(&self, _account_id: u64, actor_guid: u64, copper: u32) -> Result<()> { + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("set_trade_gold: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_set_trade_gold", + gw_set_trade_gold_then(self.session_actor(actor), copper) + ) + } + + /// `CMSG_ACCEPT_TRADE`. + pub fn accept_trade(&self, _account_id: u64, actor_guid: u64) -> Result<()> { + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("accept_trade: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_accept_trade", + gw_accept_trade_then(self.session_actor(actor)) + ) + } + + /// `CMSG_UNACCEPT_TRADE`. + pub fn unaccept_trade(&self, _account_id: u64, actor_guid: u64) -> Result<()> { + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("unaccept_trade: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_unaccept_trade", + gw_unaccept_trade_then(self.session_actor(actor)) + ) + } + + /// `CMSG_BUSY_TRADE`. + pub fn busy_trade(&self, _account_id: u64, actor_guid: u64) -> Result<()> { + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("busy_trade: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_busy_trade", + gw_busy_trade_then(self.session_actor(actor)) + ) + } + + /// `CMSG_IGNORE_TRADE`. + pub fn ignore_trade(&self, _account_id: u64, actor_guid: u64) -> Result<()> { + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("ignore_trade: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_ignore_trade", + gw_ignore_trade_then(self.session_actor(actor)) + ) + } +} diff --git a/gateway/src/stdb/store/trainer.rs b/gateway/src/stdb/store/trainer.rs index 9bd61ae0f..b31459fec 100644 --- a/gateway/src/stdb/store/trainer.rs +++ b/gateway/src/stdb/store/trainer.rs @@ -1,11 +1,15 @@ //! `Coordinator`'s [`TrainerStore`] adapter. -use anyhow::Result; +use anyhow::{anyhow, Result}; +use lyracore_shared::trainer::TrainerRefusal; +use spacetimedb_sdk::Table; use crate::codec; -use crate::world::TrainerStore; - +use crate::stdb::bindings::*; +use crate::stdb::connection::{call_reducer, reducer_refusal_reason}; +use crate::stdb::reads::spell_ranks_stack_in_book; use crate::stdb::Coordinator; +use crate::world::{Actor, TrainerStore}; impl TrainerStore for Coordinator { fn trainer_serves(&self, player_guid: u64, trainer_guid: u64) -> Result { @@ -89,3 +93,432 @@ impl TrainerStore for Coordinator { self.character_presence(guid) } } + +impl Coordinator { + /// A character's live presence `(online, level, class, zone_id)` for `SMSG_FRIEND_STATUS`/ + /// `SMSG_FRIEND_LIST`. `None` if the guid doesn't resolve to any character (a stale reference). + pub fn character_presence(&self, guid: u64) -> Result> { + Ok(self + .0 + .coord() + .conn + .db + .game_character() + .guid() + .find(&guid) + .map(|c| (c.online, c.level, c.class, c.zone_id))) + } + + /// Does this trainer serve `player_guid`'s class? Fail-open on any missing read (trainer, + /// character, template), matching `npc_refuses_interaction`. + pub fn trainer_serves(&self, player_guid: u64, trainer_guid: u64) -> Result { + let guard = self.0.coord(); + let db = &guard.conn.db; + let (Some(trainer), Some(player)) = ( + db.game_world_entity().guid().find(&trainer_guid), + db.game_character().guid().find(&player_guid), + ) else { + return Ok(true); + }; + Ok(db + .game_creature_template() + .entry() + .find(&trainer.entry) + .is_none_or(|t| { + lyracore_shared::trainer::serves(player.class, t.trainer_type, t.trainer_class) + })) + } + + /// The skill line offering `spell_id` teaches at trainer `trainer_guid`, or 0 for an ordinary spell + /// offering (and for any missing trainer/offering row). The offering list is keyed by the trainer's + /// creature-template `entry`, like the vendor stock. Read from the privileged cache. + pub fn trainer_offer_skill_line(&self, trainer_guid: u64, spell_id: u32) -> u32 { + let guard = self.0.coord(); + let db = &guard.conn.db; + let Some(entry) = db + .game_world_entity() + .guid() + .find(&trainer_guid) + .map(|e| e.entry) + else { + return 0; + }; + let line = db + .game_trainer_spell() + .iter() + .find(|t| t.trainer_entry == entry && t.spell_id == spell_id) + .map(|t| t.learn_skill_line) + .unwrap_or(0); + line + } + + /// The spells trainer `trainer_guid` teaches, folded with `player_guid`'s level + known-state so the + /// codec can render each Green/Red/Gray. The trainer's creature-template `entry` keys the list (like + /// the vendor stock); `known` = a `game_player_spell` row (the one castability source). A missing + /// trainer/player → empty list. Read from the privileged cache (coordinator bypasses RLS). + pub fn trainer_list( + &self, + player_guid: u64, + trainer_guid: u64, + ) -> Result> { + let guard = self.0.coord(); + let db = &guard.conn.db; + let Some(trainer_entry) = db + .game_world_entity() + .guid() + .find(&trainer_guid) + .map(|e| e.entry) + else { + return Ok(Vec::new()); + }; + let Some(player) = db.game_world_entity().guid().find(&player_guid) else { + return Ok(Vec::new()); + }; + let learned: std::collections::HashSet = db + .game_player_spell() + .iter() + .filter(|s| s.character_guid == player_guid) + .map(|s| s.spell_id) + .collect(); + // The player's trained skill caps, by line — for the per-row "known" of a PROFESSION offering + // (game_player_spell never holds a profession id, so we gray a tier on the skill cap, not the + // spellbook). RLS-bypassed via the coordinator cache, same as the reads above. + let skill_caps: std::collections::HashMap = db + .game_player_skill() + .iter() + .filter(|s| s.character_guid == player_guid) + .map(|s| (s.skill_line, s.max_rank as u32)) + .collect(); + let mut rows: Vec<_> = db + .game_trainer_spell() + .iter() + .filter(|t| t.trainer_entry == trainer_entry) + .collect(); + rows.sort_by_key(|t| t.spell_id); // stable order (SQL has no ORDER BY in 2.5) + // A class-spell offering id is a LearnSpell WRAPPER (1873 teaches 639); game_player_spell holds + // the RESOLVED rank, so `known` must compare the resolved id or every known spell reads Green. + // Same first-qualifying-trigger rule as resolve_learn_target (can't call it: coord() re-lock); + // one pass over the effect table for the whole list instead of a scan per row. + const EXCLUDED: [u8; 4] = [0x93, 0xBE, 0xAB, 0x05]; + let wrapper_ids: std::collections::HashSet = rows + .iter() + .filter(|t| t.learn_skill_line == 0) + .map(|t| t.spell_id) + .collect(); + let mut resolved: std::collections::HashMap = std::collections::HashMap::new(); + for e in db.game_spell_effect().iter() { + if wrapper_ids.contains(&e.spell_id) + && e.trigger_spell != 0 + && !EXCLUDED.contains(&e.kind) + { + resolved.entry(e.spell_id).or_insert(e.trigger_spell); + } + } + Ok(rows + .into_iter() + .map(|t| crate::codec::TrainerSpellView { + spell_id: t.spell_id, + cost: t.cost, + required_level: t.required_level, + player_level: player.level, + // PROFESSION row: "known" iff the player's skill cap for this line already meets the + // offering's cap (mirrors module trainer_buy_check's gate → the client grays the tiers + // already trained past). CLASS-SPELL row (line 0): known iff a game_player_spell row + // for the RESOLVED rank behind the wrapper (mirrors the module buy gate's knows_spell). + known: if t.learn_skill_line != 0 { + skill_caps + .get(&t.learn_skill_line) + .is_some_and(|&c| c >= t.learn_skill_cap) + } else { + learned.contains(resolved.get(&t.spell_id).unwrap_or(&t.spell_id)) + }, + profession: t.learn_skill_line != 0, + }) + .collect()) + } + + /// The KNOWN rank that learning `new_spell` supersedes: the game_spell_chain prev of + /// `new_spell`, if the player knows it AND the spell's ranks don't stack in the book. Drives + /// SMSG_SUPERCEDED_SPELL instead of LEARNED_SPELL on a trainer buy. + /// + /// The stacking rule is cmangos' canStackSpellRanksInSpellBook (operator-corrected live: + /// downranking Holy Light is a real thing): MANA spells keep EVERY rank visible (casters + /// downrank for mana efficiency); rage/energy/health-cost spells and PASSIVES supersede + /// (Heroic Strike replaces its old rank — there is no "downranked HS"). + pub fn superseded_old_rank(&self, new_spell: u32, player_guid: u64) -> Option { + let guard = self.0.coord(); + let db = &guard.conn.db; + if spell_ranks_stack_in_book(db, new_spell) { + return None; + } + let prev = db + .game_spell_chain() + .spell_id() + .find(&new_spell) + .map(|c| c.prev_spell)?; + if prev == 0 || prev == new_spell { + return None; + } + let knows_prev = db + .game_player_spell() + .iter() + .any(|s| s.character_guid == player_guid && s.spell_id == prev); + knows_prev.then_some(prev) + } + + pub fn resolve_learn_target(&self, spell_id: u32) -> u32 { + const EXCLUDED: [u8; 4] = [0x93, 0xBE, 0xAB, 0x05]; + let guard = self.0.coord(); + let resolved = guard + .conn + .db + .game_spell_effect() + .iter() + .filter(|e| e.spell_id == spell_id) + .find_map(|e| { + (e.trigger_spell != 0 && !EXCLUDED.contains(&e.kind)).then_some(e.trigger_spell) + }); + resolved.unwrap_or(spell_id) + } + + /// Look up the static `Talent` metadata for `talent_id` (from the coordinator's `game_talent` cache). + /// Returns `None` if the talent isn't seeded (the dispatch treats an unknown talent as a noop). + pub fn talent_by_id(&self, talent_id: u32) -> Option { + self.0 + .coord() + .conn + .db + .game_talent() + .iter() + .find(|t| t.talent_id == talent_id) + } + + /// Sum of the character's spent talent ranks (`game_character_talent`, coordinator RLS-bypassed). + /// Non-zero gates the post-CREATE login correction of `PLAYER_CHARACTER_POINTS1` (the CREATE's + /// formula counts points EARNED only — see `codec/entity.rs`). + pub fn talent_points_spent(&self, character_guid: u64) -> u32 { + let guard = self.0.coord(); + guard + .conn + .db + .game_character_talent() + .iter() + .filter(|t| t.character_guid == character_guid) + .map(|t| t.rank as u32) + .sum() + } + + /// Talent-pane sync data read AFTER a successful `learn_talent` (the blocking reducer call + /// returns with the cache already consistent): `(teach_spell, superseded_prev, points_remaining)`. + /// `teach_spell` = the rank-spell the module just put in the spellbook — the 1.12 TalentFrame + /// derives a talent's shown rank from which rank-spell is KNOWN, so this must relay live as + /// SMSG_LEARNED_SPELL or the pane freezes until relog; 0 when the module taught nothing (mirror + /// of `talent::apply_talent_rank`'s game_spell-existence gate). `superseded_prev` = the previous + /// rank's now-replaced spell (drives SMSG_SUPERCEDED_SPELL; 0 for rank 1 / same-spell demo + /// trees). `points_remaining` = earned (level−9, floor 0) minus spent — the live + /// `PLAYER_CHARACTER_POINTS1` value. Callers may pass `talent_id = 0` to get just the points. + pub fn talent_pane_sync(&self, character_guid: u64, talent_id: u32) -> (u32, u32, u32) { + let guard = self.0.coord(); + let db = &guard.conn.db; + let level = db + .game_world_entity() + .guid() + .find(&character_guid) + .map(|e| e.level) + .unwrap_or(0); + let spent: u32 = db + .game_character_talent() + .iter() + .filter(|t| t.character_guid == character_guid) + .map(|t| t.rank as u32) + .sum(); + let remaining = (level as i32 - 9).max(0) as u32; + let remaining = remaining.saturating_sub(spent); + let Some(t) = db.game_talent().iter().find(|t| t.talent_id == talent_id) else { + return (0, 0, remaining); + }; + let rank = db + .game_character_talent() + .iter() + .find(|r| r.character_guid == character_guid && r.talent_id == talent_id) + .map(|r| r.rank) + .unwrap_or(0); + let new_spell = pick_rank_spell(rank, &t); + let prev_spell = if rank >= 2 { + pick_rank_spell(rank - 1, &t) + } else { + 0 + }; + // Mirror the module's teach gate: apply_talent_rank only puts the rank-spell in the book + // when it exists in game_spell (an unimported rank-spell is skipped there — sending + // LEARNED for it would desync the client book from the server). + let teach = if new_spell != 0 && db.game_spell().spell_id().find(&new_spell).is_some() { + new_spell + } else { + 0 + }; + let superseded = if teach != 0 && prev_spell != 0 && prev_spell != teach { + prev_spell + } else { + 0 + }; + (teach, superseded, remaining) + } + + /// Learn `spell_id` from trainer `trainer_guid` (`CMSG_TRAINER_BUY_SPELL`) over the coordinator + /// connection. The module gates it (range / level / cost / not-already-known) and charges copper. + /// A Refusal the Module tagged comes back as an outcome; anything else stays an error. + /// Rides the coordinator connection as `gw_trainer_buy`. + pub fn buy_trainer_spell( + &self, + _account_id: u64, + actor_guid: u64, + trainer_guid: u64, + spell_id: u32, + ) -> Result { + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("buy_trainer_spell: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + let result: Result<()> = call_reducer!( + coord.conn.reducers, + "gw_trainer_buy", + gw_trainer_buy_then(self.session_actor(actor), trainer_guid, spell_id) + ); + match result { + Ok(()) => Ok(crate::world::TrainerBuyOutcome::Learned), + Err(error) => match trainer_refusal(&error) { + Some(refusal) => Ok(refusal.into()), + None => Err(error), + }, + } + } + + pub fn learn_talent(&self, _account_id: u64, actor_guid: u64, talent_id: u32) -> Result<()> { + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("learn_talent: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_learn_talent", + gw_learn_talent_then(self.session_actor(actor), talent_id) + ) + } + + /// Respec at a trainer (the "I wish to unlearn my talents." gossip option), clears every + /// learned talent for the calling player's escalating gold cost. Rides the coordinator + /// connection as `gw_reset_talents` (deleted the per-player sender path). + pub fn reset_talents( + &self, + _account_id: u64, + actor_guid: u64, + trainer_guid: u64, + ) -> Result<()> { + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("reset_talents: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_reset_talents", + gw_reset_talents_then(self.session_actor(actor), trainer_guid) + ) + } + + /// Persist one action-bar button (`CMSG_SET_ACTION_BUTTON`): upsert by (character, button); + /// action 0 clears. Without this every bar drag was lost on relog (only creation seeds survived). + pub fn set_action_button( + &self, + _account_id: u64, + actor_guid: u64, + button: u8, + action: u32, + action_type: u8, + ) -> Result<()> { + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("set_action_button: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_set_action_button", + gw_set_action_button_then(self.session_actor(actor), button, action, action_type) + ) + } + + /// Persist the rep pane's At-War checkbox (`CMSG_SET_FACTION_ATWAR`): the wire's + /// u16 is the client's 0..63 rep-array slot (ReputationListID — the gtker `Faction` field name + /// lies, same as SET_FACTION_STANDING); the module reverse-resolves the faction and upserts. + pub fn set_faction_at_war( + &self, + _account_id: u64, + actor_guid: u64, + reputation_index: u32, + at_war: bool, + ) -> Result<()> { + let actor = Actor::new(actor_guid) + .ok_or_else(|| anyhow!("set_faction_at_war: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_set_faction_at_war", + gw_set_faction_at_war_then(self.session_actor(actor), reputation_index, at_war) + ) + } +} + +/// Mirror of the module's `talent::pick_rank_spell`: rank N's spell from the per-rank columns +/// (`rank_spell_2..5`), a 0 column falling back to `spell_id` (the demo tree scales ONE spell by +/// rank; the imported tree has a distinct spell per rank). +fn pick_rank_spell(rank: u8, t: &Talent) -> u32 { + let s = match rank { + 2 => t.rank_spell_2, + 3 => t.rank_spell_3, + 4 => t.rank_spell_4, + 5 => t.rank_spell_5, + _ => t.spell_id, + }; + if s != 0 { + s + } else { + t.spell_id + } +} + +/// The Module's typed trainer Refusal. Only a reducer the Module rejected carries a tag; a timeout, +/// transport, or SDK failure stays an error with an unknown outcome. +fn trainer_refusal(error: &anyhow::Error) -> Option { + reducer_refusal_reason(error).and_then(TrainerRefusal::parse_tag) +} + +#[cfg(test)] +mod trainer_reducer_tests { + use super::*; + use crate::stdb::connection::ReducerCallError; + + #[test] + fn only_a_rejected_reducer_carries_a_typed_refusal() { + for refusal in TrainerRefusal::ALL { + let error = anyhow::Error::from(ReducerCallError::Rejected { + operation: "gw_trainer_buy".to_string(), + reason: refusal.as_tag().to_string(), + }) + .context("trainer buy"); + assert_eq!(trainer_refusal(&error), Some(refusal)); + } + + let not_refusals = [ + anyhow::Error::from(ReducerCallError::fatal( + "gw_trainer_buy reducer timed out after 10s".to_string(), + )), + anyhow::Error::from(ReducerCallError::Rejected { + operation: "gw_trainer_buy".to_string(), + reason: "operator only".to_string(), + }), + anyhow!( + "wrapped text that mentions {}", + TrainerRefusal::AlreadyKnown.as_tag() + ), + ]; + for error in not_refusals { + assert_eq!(trainer_refusal(&error), None, "{error:#}"); + } + } +} diff --git a/gateway/src/stdb/store/transfer.rs b/gateway/src/stdb/store/transfer.rs index 60211db07..e337d5b5a 100644 --- a/gateway/src/stdb/store/transfer.rs +++ b/gateway/src/stdb/store/transfer.rs @@ -2,11 +2,12 @@ use anyhow::Result; -use crate::world::TransferStore; - use crate::stdb::bindings::game_instance_table::GameInstanceTableAccess; use crate::stdb::bindings::game_transfer_in_table::GameTransferInTableAccess; +use crate::stdb::bindings::*; +use crate::stdb::connection::call_reducer; use crate::stdb::Coordinator; +use crate::world::TransferStore; impl TransferStore for Coordinator { fn escrowed_transfer( @@ -253,3 +254,213 @@ impl TransferStore for Coordinator { ) } } + +impl Coordinator { + /// `release_player_transfer_arrival`, with the exact Realm locator predecessor carried by the + /// destination fence. + pub fn release_player_transfer_arrival( + &self, + transfer_id: u64, + character_guid: u64, + source: crate::world::transfer::RealmLocatorPredecessor, + ) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "release_player_transfer_arrival", + release_player_transfer_arrival_then( + transfer_id, + character_guid, + source.map_id, + source.instance_id, + source.revision, + self.owner_actor() + ) + ) + } + + pub fn mark_bot_transfer_arrival_ready( + &self, + intent_id: u64, + bot_guid: u64, + controller_generation: u64, + claim_token: u64, + ) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "mark_bot_transfer_arrival_ready", + mark_bot_transfer_arrival_ready_then( + intent_id, + bot_guid, + controller_generation, + claim_token + ) + ) + } + + // The arguments mirror the World reducer's exact Transfer Gate. + #[allow(clippy::too_many_arguments)] + pub fn release_bot_transfer_arrival( + &self, + transfer_id: u64, + bot_guid: u64, + source_module_identity: spacetimedb_sdk::Identity, + intent_id: u64, + controller_generation: u64, + intent_created_micros: i64, + source_map: u32, + source_instance: u64, + source_locator_revision: u64, + ) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "release_bot_transfer_arrival", + release_bot_transfer_arrival_then( + transfer_id, + bot_guid, + source_module_identity, + intent_id, + controller_generation, + intent_created_micros, + source_map, + source_instance, + source_locator_revision + ) + ) + } + + pub fn bind_bot_transfer_locator( + &self, + intent: &crate::world::transfer::BotTransferIntent, + source_revision: u64, + claim_token: u64, + ) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "bind_bot_transfer_locator", + bind_bot_transfer_locator_then( + intent.id, + intent.bot_guid, + intent.controller_generation, + claim_token, + intent.source_map, + intent.source_instance, + source_revision + ) + ) + } + + /// `begin_transfer` — freeze the character, serialize it (row + every manifest table's rows), + /// and delete its live entity, in ONE transaction. Idempotent on `transfer_id`. + pub fn begin_transfer(&self, plan: &crate::world::transfer::TransferPlan) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "begin_transfer", + begin_transfer_then( + plan.transfer_id, + self.actor_or_owner(plan.character_guid), + plan.dest_map_id, + plan.dest_instance_id, + plan.dest_x, + plan.dest_y, + plan.dest_z, + plan.dest_o, + true + ) + ) + } + + /// `import_player_character_blob`, with the Realm locator predecessor written as part of the + /// destination import transaction. + pub fn import_player_character_blob( + &self, + transfer_id: u64, + blob: &[u8], + source: crate::world::transfer::RealmLocatorPredecessor, + ) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "import_player_character_blob", + import_player_character_blob_then( + transfer_id, + blob.to_vec(), + source.map_id, + source.instance_id, + source.revision, + self.owner_actor() + ) + ) + } + + /// `import_bot_character_blob`, with the exact source intent identity written as part of the + /// destination import transaction. + pub fn import_bot_character_blob( + &self, + transfer_id: u64, + blob: &[u8], + intent: &crate::world::transfer::BotTransferIntent, + ) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "import_bot_character_blob", + import_bot_character_blob_then( + transfer_id, + blob.to_vec(), + intent.source_module_identity, + intent.id, + intent.controller_generation, + intent.created_micros, + intent.source_map, + intent.source_instance, + intent.source_locator_revision, + self.owner_actor() + ) + ) + } + + /// `confirm_import` — attest ON THE SOURCE that the destination copy committed. Called only + /// after `import_character_blob` returned Ok; see `world::transfer::run_transfer`. + pub fn confirm_import(&self, transfer_id: u64) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "confirm_import", + confirm_import_then(transfer_id, self.owner_actor()) + ) + } + + /// `finish_transfer` — delete-last: destroy the source copy and clear the escrow. + pub fn finish_transfer(&self, transfer_id: u64) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "finish_transfer", + finish_transfer_then(transfer_id, self.owner_actor()) + ) + } + + /// `release_transfer` — drop the arrival copy's fence at the destination. + pub fn release_transfer(&self, transfer_id: u64) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "release_transfer", + release_transfer_then(transfer_id, self.owner_actor()) + ) + } + + /// `ensure_instance` — mirror an instance id onto this shard (idempotent), spawning its + /// population the first time. + pub fn ensure_instance(&self, instance_id: u64, map_id: u32, party_id: u64) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "ensure_instance", + ensure_instance_then(instance_id, map_id, party_id, self.owner_actor()) + ) + } + + /// `evict_instance_population` — stop this shard ticking an instance whose run moved elsewhere. + pub fn evict_instance_population(&self, instance_id: u64) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "evict_instance_population", + evict_instance_population_then(instance_id, self.owner_actor()) + ) + } +} diff --git a/gateway/src/stdb/store/vendor.rs b/gateway/src/stdb/store/vendor.rs new file mode 100644 index 000000000..9c11c6a8b --- /dev/null +++ b/gateway/src/stdb/store/vendor.rs @@ -0,0 +1,227 @@ +//! `Coordinator`'s [`VendorActionStore`] adapter. + +use anyhow::{anyhow, Result}; +use spacetimedb_sdk::Table; + +use crate::codec; +use crate::stdb::bindings::*; +use crate::stdb::connection::call_reducer; +use crate::stdb::reads::property_enchant_ids; +use crate::stdb::Coordinator; +use crate::world::{Actor, VendorActionStore}; + +impl VendorActionStore for crate::stdb::Coordinator { + fn vendor_stock(&self, vendor_guid: u64) -> Result> { + crate::stdb::Coordinator::vendor_items(self, vendor_guid) + } + + fn vendor_refuses_interaction(&self, vendor_guid: u64, player_guid: u64) -> Result { + crate::stdb::Coordinator::npc_refuses_interaction(self, vendor_guid, player_guid) + } + + fn vendor_buy( + &self, + account_id: u64, + self_guid: u64, + vendor_guid: u64, + item_entry: u32, + count: u32, + ) -> Result<()> { + crate::stdb::Coordinator::buy_item( + self, + account_id, + self_guid, + vendor_guid, + item_entry, + count, + ) + } + + fn buyback_slots(&self, player_guid: u64) -> Vec<(u32, u32, u32, u32)> { + crate::stdb::Coordinator::buyback_ring(self, player_guid) + } + + fn random_property_enchant_ids(&self, random_property_id: u32) -> [u32; 3] { + crate::stdb::Coordinator::random_property_enchant_ids(self, random_property_id) + } + + fn vendor_item_slot(&self, item_guid: u64) -> Option { + crate::stdb::Coordinator::item_slot_by_guid(self, 0, item_guid) + } + + fn vendor_repair( + &self, + account_id: u64, + self_guid: u64, + npc_guid: u64, + slot: u8, + ) -> Result<()> { + crate::stdb::Coordinator::repair_item(self, account_id, self_guid, npc_guid, slot) + } + + fn vendor_sell( + &self, + account_id: u64, + self_guid: u64, + vendor_guid: u64, + slot: u8, + ) -> Result<()> { + crate::stdb::Coordinator::sell_item(self, account_id, self_guid, vendor_guid, slot) + } + + fn vendor_buyback( + &self, + account_id: u64, + self_guid: u64, + vendor_guid: u64, + slot: u8, + ) -> Result<()> { + crate::stdb::Coordinator::buyback_item(self, account_id, self_guid, vendor_guid, slot) + } +} + +impl Coordinator { + /// The three enchant ids of `random_property_id`, zero where it names none. + pub fn random_property_enchant_ids(&self, random_property_id: u32) -> [u32; 3] { + let guard = self.0.coord(); + property_enchant_ids(&guard.conn.db, random_property_id) + } + + /// The player's buyback ring, newest-first: `(item_entry, stack_count, price)` ≤12. + pub fn buyback_ring(&self, player_guid: u64) -> Vec<(u32, u32, u32, u32)> { + let guard = self.0.coord(); + let db = &guard.conn.db; + let mut rows: Vec<_> = db + .game_character_buyback() + .iter() + .filter(|b| b.player_guid == player_guid) + .collect(); + rows.sort_by(|a, b| b.id.cmp(&a.id)); + rows.into_iter() + .map(|b| (b.item_entry, b.stack_count, b.price, b.random_property_id)) + .collect() + } + + /// Read a vendor's stock for `SMSG_LIST_INVENTORY` (Tier 2 / vendors). Resolve the vendor's + /// creature entry from its `game_world_entity` row, filter `game_npc_vendor` to that entry, and + /// join each `game_item_template` for the display id / buy price / max durability (carrying the + /// npc_vendor row's `max_count`). Read from the privileged cache (the coordinator bypasses RLS); + /// the SDK exposes only the PK index, so iterate+filter like the other row queries. Items whose + /// template isn't loaded are skipped (we can't price/display them). Ordered by the vendor `slot`. + pub fn vendor_items(&self, vendor_guid: u64) -> Result> { + let guard = self.0.coord(); + let db = &guard.conn.db; + // The vendor's creature entry drives the stock lookup; a missing entity → no stock. + let Some(creature_entry) = db + .game_world_entity() + .guid() + .find(&vendor_guid) + .map(|e| e.entry) + else { + return Ok(Vec::new()); + }; + let mut rows: Vec<_> = db + .game_npc_vendor() + .iter() + .filter(|v| v.creature_entry == creature_entry) + .collect(); + rows.sort_by_key(|v| v.slot); // stable vendor-slot order (SQL has no ORDER BY in 2.5) + let items = rows + .into_iter() + .filter_map(|v| { + // Skip a stock line whose template isn't loaded — we can't price or display it. + db.game_item_template() + .entry() + .find(&v.item_entry) + .map(|t| crate::codec::VendorItemView { + item_entry: v.item_entry, + display_id: t.display_id, + buy_price: t.buy_price, + max_durability: t.max_durability, + max_count: v.max_count, + buy_count: t.buy_count, + }) + }) + .collect(); + Ok(items) + } + + /// Buy `count` of `item_entry` from the vendor `vendor_guid` (`CMSG_BUY_ITEM`, Tier 2) over the + /// coordinator connection so the module attributes the purchase to the caller. The module gates + /// it on the vendor (stock + NPC flags + range) and debits the buyer's copper. + pub fn buy_item( + &self, + _account_id: u64, + actor_guid: u64, + vendor_guid: u64, + item_entry: u32, + count: u32, + ) -> Result<()> { + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("buy_item: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_buy_item", + gw_buy_item_then(self.session_actor(actor), vendor_guid, item_entry, count) + ) + } + + /// Sell the item in inventory `slot` back to a vendor (`CMSG_SELL_ITEM`, Tier 2) over the + /// coordinator connection. The gateway resolves the client's item-INSTANCE guid to the owning + /// slot before calling (the reducer takes the slot); the module credits the seller's copper. + /// Rides the coordinator connection as `gw_sell_item`. + pub fn sell_item( + &self, + _account_id: u64, + actor_guid: u64, + vendor_guid: u64, + slot: u8, + ) -> Result<()> { + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("sell_item: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_sell_item", + gw_sell_item_then(self.session_actor(actor), vendor_guid, slot) + ) + } + + pub fn buyback_item( + &self, + _account_id: u64, + actor_guid: u64, + vendor_guid: u64, + slot: u8, + ) -> Result<()> { + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("buyback_item: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_buyback_item", + gw_buyback_item_then(self.session_actor(actor), vendor_guid, slot) + ) + } + + /// Repair the item in inventory `slot` at REPAIR-NPC `npc_guid` (`CMSG_REPAIR_ITEM`) over the + /// coordinator connection. The module gates the NPC + charges copper; the player's item + + /// purse replicate back via subscription. + pub fn repair_item( + &self, + _account_id: u64, + actor_guid: u64, + npc_guid: u64, + slot: u8, + ) -> Result<()> { + let actor = + Actor::new(actor_guid).ok_or_else(|| anyhow!("repair_item: actor_guid unresolved"))?; + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_repair_item", + gw_repair_item_then(self.session_actor(actor), npc_guid, slot) + ) + } +} diff --git a/gateway/src/stdb/reads/weather.rs b/gateway/src/stdb/store/weather.rs similarity index 70% rename from gateway/src/stdb/reads/weather.rs rename to gateway/src/stdb/store/weather.rs index c8cc8ed0a..ab6e4e153 100644 --- a/gateway/src/stdb/reads/weather.rs +++ b/gateway/src/stdb/store/weather.rs @@ -1,7 +1,20 @@ -use super::*; +//! `Coordinator`'s [`WeatherStore`] adapter. + +use anyhow::Result; + +use crate::codec; use crate::codec::ZoneWeatherView; +use crate::stdb::bindings::*; +use crate::stdb::Coordinator; +use crate::world::WeatherStore; + +impl WeatherStore for crate::stdb::Coordinator { + fn zone_weather(&self, zone_id: u32) -> Result> { + crate::stdb::Coordinator::zone_weather(self, zone_id) + } +} -impl super::super::connection::Coordinator { +impl Coordinator { /// One zone's current sky, read from the shard's `game_zone_weather` cache. /// /// `None` means the zone has no row, which the Module defines as fine weather; zone 0 is the diff --git a/gateway/src/world/guild_fee.rs b/gateway/src/world/guild_fee.rs index 5cef98211..378366307 100644 --- a/gateway/src/world/guild_fee.rs +++ b/gateway/src/world/guild_fee.rs @@ -9,7 +9,7 @@ //! A hold call mints its own operation id and is never retried, so no Hold is taken twice for one //! id. After a lost answer the Hold is found by its payer and re-driven, not held again. -use anyhow::{anyhow, Result}; +use anyhow::Result; use lyracore_shared::guild::GuildRefusal; /// The five tabard design values of a Guild Emblem, in wire order. @@ -127,42 +127,10 @@ fn settle( Ok(outcome) } -impl GuildFeeStore for crate::stdb::Coordinator { - fn guild_fee_held(&self, actor_guid: u64) -> Result> { - Ok(self.guild_fee_hold_row(actor_guid)) - } - - fn guild_fee_hold( - &self, - actor_guid: u64, - request: FeeRequest, - ) -> Result> { - self.hold_guild_fee(actor_guid, request) - } - - fn guild_fee_decide(&self, actor_guid: u64, hold: FeeHold) -> Result { - let owner = match hold.terms { - FeeTerms::Emblem(_) => None, - FeeTerms::Charter { .. } => { - let facts = crate::stdb::Coordinator::guild_character_facts(self, actor_guid)? - .ok_or_else(|| anyhow!("Guild Charter owner {actor_guid} is unreadable"))?; - Some(CharterOwner { - name: facts.name, - team: lyracore_shared::faction::team_for_race(facts.race), - }) - } - }; - self.realm_core()?.decide_guild_fee(actor_guid, hold, owner) - } - - fn guild_fee_finish(&self, actor_guid: u64, operation_id: u64, accepted: bool) -> Result<()> { - self.finish_guild_fee(actor_guid, operation_id, accepted) - } -} - #[cfg(test)] mod tests { use super::*; + use anyhow::anyhow; use lyracore_shared::guild::EMBLEM_COST_COPPER; use std::sync::{Arc, Mutex}; diff --git a/gateway/src/world/handlers/auction.rs b/gateway/src/world/handlers/auction.rs index 422864c8f..2d3bef041 100644 --- a/gateway/src/world/handlers/auction.rs +++ b/gateway/src/world/handlers/auction.rs @@ -172,80 +172,6 @@ pub(crate) trait AuctionActionStore: Send + Sync { ) -> Result; } -impl AuctionActionStore for crate::stdb::Coordinator { - fn auction_interaction( - &self, - player_guid: u64, - auctioneer_guid: u64, - ) -> Result> { - use crate::stdb::bindings::{ - GameAuctionHouseTableAccess, GameFactionTemplateTableAccess, GameWorldEntityTableAccess, - }; - let house = { - let guard = self.0.coord(); - let db = &guard.conn.db; - let Some(auctioneer) = db.game_world_entity().guid().find(&auctioneer_guid) else { - return Ok(None); - }; - let Some(faction_group) = db - .game_faction_template() - .id() - .find(&auctioneer.faction_template) - .map(|template| template.faction_group) - else { - return Ok(None); - }; - let house_id = lyracore_shared::auction::house_for_faction_template( - auctioneer.faction_template, - faction_group, - ); - let Some(house) = - db.game_auction_house() - .id() - .find(&house_id) - .map(|house| AuctionHousePolicy { - id: house.id, - deposit_rate: house.deposit_rate, - consignment_rate: house.consignment_rate, - }) - else { - return Ok(None); - }; - house - }; - let refuses_interaction = self.npc_refuses_interaction(auctioneer_guid, player_guid)?; - Ok(Some(AuctionInteraction { - house, - refuses_interaction, - })) - } - - fn create_auction(&self, request: CreateAuctionRequest) -> Result { - crate::stdb::Coordinator::create_auction(self, request) - } - - fn place_bid(&self, request: PlaceBidRequest) -> Result { - crate::stdb::Coordinator::place_bid(self, request) - } - - fn cancel_auction(&self, request: CancelAuctionRequest) -> Result { - crate::stdb::Coordinator::cancel_auction(self, request) - } - - fn resume_auction_holds(&self, actor_guid: u64) -> Result<()> { - crate::stdb::Coordinator::resume_auction_holds(self, actor_guid) - } - - fn auction_query( - &self, - player_guid: u64, - house_id: u32, - query: AuctionQuery, - ) -> Result { - crate::stdb::Coordinator::auction_query(self, player_guid, house_id, query) - } -} - #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct AuctionActionPlayer { pub(crate) self_guid: Option, diff --git a/gateway/src/world/handlers/cast/mod.rs b/gateway/src/world/handlers/cast/mod.rs index 64ffd9ff4..18f8c0bd0 100644 --- a/gateway/src/world/handlers/cast/mod.rs +++ b/gateway/src/world/handlers/cast/mod.rs @@ -137,138 +137,6 @@ pub(crate) trait CastStore: MeleeActionStore + Send + Sync { fn item_template(&self, entry: u32) -> Result>; } -impl CastStore for crate::stdb::Coordinator { - fn spell_is_ranged_auto_repeat(&self, spell_id: u32) -> bool { - crate::stdb::Coordinator::spell_is_ranged_auto_repeat(self, spell_id) - } - - fn enchant_route(&self, spell_id: u32) -> Option { - crate::stdb::Coordinator::enchant_route(self, spell_id) - } - - fn spell_is_fishing(&self, spell_id: u32) -> bool { - crate::stdb::Coordinator::spell_is_fishing(self, spell_id) - } - - fn spell_is_open_lock(&self, spell_id: u32) -> bool { - crate::stdb::Coordinator::spell_is_open_lock(self, spell_id) - } - - fn spell_is_ground_area(&self, spell_id: u32) -> bool { - crate::stdb::Coordinator::spell_is_ground_area(self, spell_id) - } - - fn spell_cast_time(&self, spell_id: u32) -> Option { - crate::stdb::Coordinator::spell_cast_time(self, spell_id) - } - - fn spell_queues_next_swing(&self, spell_id: u32) -> bool { - crate::stdb::Coordinator::spell_queues_next_swing(self, spell_id) - } - - fn cast_spell( - &self, - account_id: u64, - self_guid: u64, - spell_id: u32, - target_guid: u64, - ) -> Result<()> { - crate::stdb::Coordinator::cast_spell(self, account_id, self_guid, spell_id, target_guid) - } - - fn cast_spell_at( - &self, - account_id: u64, - self_guid: u64, - spell_id: u32, - target_guid: u64, - x: f32, - y: f32, - z: f32, - ) -> Result<()> { - crate::stdb::Coordinator::cast_spell_at( - self, - account_id, - self_guid, - spell_id, - target_guid, - x, - y, - z, - ) - } - - fn cast_item_target( - &self, - account_id: u64, - self_guid: u64, - spell_id: u32, - slot: u8, - ) -> Result<()> { - crate::stdb::Coordinator::cast_item_target(self, account_id, self_guid, spell_id, slot) - } - - fn start_ranged_attack( - &self, - account_id: u64, - self_guid: u64, - target_guid: u64, - spell_id: u32, - ) -> Result<()> { - crate::stdb::Coordinator::start_ranged_attack( - self, - account_id, - self_guid, - target_guid, - spell_id, - ) - } - - fn item_slot_by_guid(&self, account_id: u64, item_guid: u64) -> Option { - crate::stdb::Coordinator::item_slot_by_guid(self, account_id, item_guid) - } - - fn disenchant_item(&self, account_id: u64, self_guid: u64, slot: u8) -> Result<()> { - crate::stdb::Coordinator::disenchant_item(self, account_id, self_guid, slot) - } - - fn enchant_item_on_slot( - &self, - account_id: u64, - self_guid: u64, - slot: u8, - enchant_id: u32, - ) -> Result<()> { - crate::stdb::Coordinator::enchant_item_on_slot( - self, account_id, self_guid, slot, enchant_id, - ) - } - - fn fish(&self, account_id: u64, self_guid: u64) -> Result<()> { - crate::stdb::Coordinator::fish(self, account_id, self_guid) - } - - fn pick_lock(&self, account_id: u64, self_guid: u64, go_guid: u64) -> Result<()> { - crate::stdb::Coordinator::pick_lock(self, account_id, self_guid, go_guid) - } - - fn cancel_cast(&self, account_id: u64, self_guid: u64) -> Result<()> { - crate::stdb::Coordinator::cancel_cast(self, account_id, self_guid) - } - - fn cancel_aura(&self, account_id: u64, self_guid: u64, spell_id: u32) -> Result<()> { - crate::stdb::Coordinator::cancel_aura(self, account_id, self_guid, spell_id) - } - - fn player_items(&self, owner_guid: u64) -> Result> { - crate::stdb::Coordinator::player_items(self, owner_guid) - } - - fn item_template(&self, entry: u32) -> Result> { - crate::stdb::Coordinator::item_template(self, entry) - } -} - /// Everything the cast module knows about the caller. `self_guid` is `None` when the session has no /// character in the world: the durable call then carries guid 0 and no synchronous message is sent, /// which is what the pre-seam handler did. diff --git a/gateway/src/world/handlers/channel.rs b/gateway/src/world/handlers/channel.rs index c72d48fa1..834dcaf14 100644 --- a/gateway/src/world/handlers/channel.rs +++ b/gateway/src/world/handlers/channel.rs @@ -73,29 +73,6 @@ pub(crate) trait ChannelActionStore: ChatActionStore { fn ignores(&self, owner_guid: u64, other_guid: u64) -> Result; } -impl ChannelActionStore for crate::stdb::Coordinator { - fn channel_op( - &self, - actor_guid: u64, - op: u8, - request: ChannelRequest, - ) -> Result { - crate::stdb::Coordinator::channel_op(self, actor_guid, op, request) - } - - fn channel_roster(&self, team: u32, channel_name: &str) -> Result> { - crate::stdb::Coordinator::channel_roster(self, team, channel_name) - } - - fn online_character_by_name(&self, name: &str) -> Result> { - resolve_online_character(self, name) - } - - fn ignores(&self, owner_guid: u64, other_guid: u64) -> Result { - whisper::ignored_anywhere(self, owner_guid, other_guid) - } -} - /// The read [`ChannelActionStore::online_character_by_name`] and its Fakes share: every op that /// names a Character resolves it realm-wide and requires it online, the same shape whisper's /// ONLINE gate uses (`whisper::run`). A channel can only name a Character presently reachable to diff --git a/gateway/src/world/handlers/chat.rs b/gateway/src/world/handlers/chat.rs index 2401c2689..18813ee1f 100644 --- a/gateway/src/world/handlers/chat.rs +++ b/gateway/src/world/handlers/chat.rs @@ -119,36 +119,6 @@ pub(crate) trait SpeechStore: Send + Sync { fn gm_command(&self, account_name: &str, self_guid: u64, text: String) -> Result<()>; } -impl ChatActionStore for crate::stdb::Coordinator { - fn speaker_facts(&self, speaker_guid: u64) -> Result> { - crate::stdb::Coordinator::speaker_facts(self, speaker_guid) - } - - fn realm_chat(&self, speaker_guid: u64, request: RealmChatRequest) -> Result { - crate::stdb::Coordinator::realm_chat(self, speaker_guid, request) - } - - fn set_away(&self, speaker_guid: u64, kind: u8, message: String) -> Result<()> { - crate::stdb::Coordinator::set_away(self, speaker_guid, kind, message) - } - - fn whisper_target( - &self, - speaker_guid: u64, - typed_name: &str, - ) -> Result> { - whisper::target_facts(self, speaker_guid, typed_name) - } - - fn realm_whisper(&self, speaker_guid: u64, request: WhisperRequest) -> Result { - crate::stdb::Coordinator::realm_whisper(self, speaker_guid, request) - } - - fn speaker_gm_level(&self, speaker_guid: u64) -> Result { - Ok(crate::stdb::Coordinator::home_gm_level(self, speaker_guid)) - } -} - #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct ChatActionPlayer { pub(crate) account_id: u64, diff --git a/gateway/src/world/handlers/duel.rs b/gateway/src/world/handlers/duel.rs index b22647034..97060799f 100644 --- a/gateway/src/world/handlers/duel.rs +++ b/gateway/src/world/handlers/duel.rs @@ -7,16 +7,6 @@ pub(crate) trait DuelActionStore: Send + Sync { fn duel_cancel(&self, account_id: u64, actor_guid: u64, flag_guid: u64) -> Result<()>; } -impl DuelActionStore for crate::stdb::Coordinator { - fn duel_accept(&self, account_id: u64, actor_guid: u64, flag_guid: u64) -> Result<()> { - crate::stdb::Coordinator::duel_accept(self, account_id, actor_guid, flag_guid) - } - - fn duel_cancel(&self, account_id: u64, actor_guid: u64, flag_guid: u64) -> Result<()> { - crate::stdb::Coordinator::duel_cancel(self, account_id, actor_guid, flag_guid) - } -} - #[derive(Clone, Copy)] pub(crate) struct DuelActionPlayer { pub(crate) account_id: u64, diff --git a/gateway/src/world/handlers/guild.rs b/gateway/src/world/handlers/guild.rs index 4b50c38e7..6601df2ef 100644 --- a/gateway/src/world/handlers/guild.rs +++ b/gateway/src/world/handlers/guild.rs @@ -212,85 +212,6 @@ pub(crate) trait GuildActionStore: GuildFeeStore + Send + Sync { fn guild_name_of_member(&self, character_guid: u64) -> Result>; } -impl GuildActionStore for crate::stdb::Coordinator { - fn guild_member(&self, character_guid: u64) -> Result> { - Ok(self.realm_core()?.guild_member_row(character_guid)) - } - - fn guild(&self, guild_id: u32) -> Result> { - Ok(self.realm_core()?.guild_row(guild_id)) - } - - fn guild_members(&self, guild_id: u32) -> Result> { - Ok(self.realm_core()?.guild_member_rows(guild_id)) - } - - fn guild_character_facts(&self, character_guid: u64) -> Result> { - crate::stdb::Coordinator::guild_character_facts(self, character_guid) - } - - fn guild_characters_named(&self, name: &str) -> Result> { - presence::resolve_all_by_name(self, name) - } - - fn guild_gm_level(&self, actor_guid: u64) -> Result { - Ok(crate::stdb::Coordinator::home_gm_level(self, actor_guid)) - } - - fn guild_selected_target(&self, actor_guid: u64) -> u64 { - crate::stdb::Coordinator::selected_target(self, actor_guid) - } - - fn guild_ignored_by(&self, owner_guid: u64, other_guid: u64) -> Result { - whisper::ignored_anywhere(self, owner_guid, other_guid) - } - - fn guild_op(&self, actor_guid: u64, request: GuildRequest) -> Result { - self.realm_core()?.realm_guild_op(actor_guid, request) - } - - fn guild_npc_refuses(&self, npc_guid: u64, actor_guid: u64) -> Result { - crate::stdb::Coordinator::npc_refuses_interaction(self, npc_guid, actor_guid) - } - - fn guild_petition_of_charter( - &self, - charter_item_guid: u64, - ) -> Result> { - Ok(self - .realm_core()? - .guild_petition_of_charter(charter_item_guid)) - } - - fn guild_petition_of_owner(&self, owner_guid: u64) -> Result> { - Ok(self.realm_core()?.guild_petition_of_owner(owner_guid)) - } - - fn guild_name_taken(&self, name: &str) -> Result { - Ok(self.realm_core()?.guild_name_taken(name)) - } - - fn guild_holds_charter(&self, actor_guid: u64, charter_item_guid: u64) -> Result { - Ok(self.holds_guild_charter(actor_guid, charter_item_guid)) - } - - fn guild_destroy_charter(&self, actor_guid: u64, charter_item_guid: u64) -> Result<()> { - self.destroy_guild_charter(actor_guid, charter_item_guid) - } - - fn guild_character_guids(&self) -> Result> { - self.realm_core()?.guild_character_guids() - } - - fn guild_names_character(&self, character_guid: u64) -> Result { - self.realm_core()?.guild_names_character(character_guid) - } - - fn guild_name_of_member(&self, character_guid: u64) -> Result> { - Ok(self.realm_core()?.guild_name_of_member(character_guid)) - } -} - #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct GuildActionPlayer { pub(crate) account_id: u64, diff --git a/gateway/src/world/handlers/item.rs b/gateway/src/world/handlers/item.rs index 2959c30c3..9bfbcb413 100644 --- a/gateway/src/world/handlers/item.rs +++ b/gateway/src/world/handlers/item.rs @@ -44,40 +44,6 @@ pub(crate) trait ItemActionStore: Send + Sync { fn use_item(&self, account_id: u64, actor_guid: u64, slot: u8) -> Result; } -impl ItemActionStore for crate::stdb::Coordinator { - fn equip_item( - &self, - account_id: u64, - actor_guid: u64, - from_slot: u8, - ) -> Result { - crate::stdb::Coordinator::equip_item(self, account_id, actor_guid, from_slot) - } - - fn unequip_item( - &self, - account_id: u64, - actor_guid: u64, - from_slot: u8, - ) -> Result { - crate::stdb::Coordinator::unequip_item(self, account_id, actor_guid, from_slot) - } - - fn move_item( - &self, - account_id: u64, - actor_guid: u64, - from_slot: u8, - to_slot: u8, - ) -> Result { - crate::stdb::Coordinator::move_item(self, account_id, actor_guid, from_slot, to_slot) - } - - fn use_item(&self, account_id: u64, actor_guid: u64, slot: u8) -> Result { - crate::stdb::Coordinator::use_item(self, account_id, actor_guid, slot) - } -} - #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct ItemActionPlayer { pub(crate) account_id: u64, diff --git a/gateway/src/world/handlers/loot.rs b/gateway/src/world/handlers/loot.rs index 230da9ef0..2be94c4f2 100644 --- a/gateway/src/world/handlers/loot.rs +++ b/gateway/src/world/handlers/loot.rs @@ -98,66 +98,6 @@ impl LootWindowRefusal { } } -impl LootWindowStore for crate::stdb::Coordinator { - fn loot_target_money(&self, target_guid: u64) -> Result { - crate::stdb::Coordinator::loot_target_money(self, target_guid) - } - - fn loot_target_items( - &self, - target_guid: u64, - viewer_guid: u64, - ) -> Result> { - crate::stdb::Coordinator::corpse_loot(self, target_guid, viewer_guid) - } - - fn use_gameobject( - &self, - account_id: u64, - actor_guid: u64, - target_guid: u64, - ) -> Result { - crate::stdb::Coordinator::use_gameobject(self, account_id, actor_guid, target_guid) - } - - fn open_creature_loot( - &self, - account_id: u64, - actor_guid: u64, - corpse_guid: u64, - ) -> Result { - crate::stdb::Coordinator::open_creature_loot(self, account_id, actor_guid, corpse_guid) - } - - fn skin_corpse( - &self, - account_id: u64, - actor_guid: u64, - target_guid: u64, - ) -> Result { - crate::stdb::Coordinator::skin_corpse(self, account_id, actor_guid, target_guid) - } - - fn loot_money( - &self, - account_id: u64, - actor_guid: u64, - target_guid: u64, - ) -> Result { - crate::stdb::Coordinator::loot_money(self, account_id, actor_guid, target_guid) - } - - fn take_loot( - &self, - account_id: u64, - actor_guid: u64, - target_guid: u64, - loot_slot: u8, - ) -> Result { - crate::stdb::Coordinator::take_loot(self, account_id, actor_guid, target_guid, loot_slot) - } -} - /// The target whose loot window is currently open for this world session. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) struct OpenLootState { diff --git a/gateway/src/world/handlers/meeting_stone.rs b/gateway/src/world/handlers/meeting_stone.rs index f656efc87..c99fcc081 100644 --- a/gateway/src/world/handlers/meeting_stone.rs +++ b/gateway/src/world/handlers/meeting_stone.rs @@ -47,48 +47,6 @@ pub(crate) trait MeetingStoneActionStore { fn queued_area(&self, character_guid: u64) -> Result>; } -impl MeetingStoneActionStore for crate::stdb::Coordinator { - fn admit_meeting_stone(&self, actor_guid: u64, go_guid: u64) -> Result { - crate::stdb::Coordinator::admit_meeting_stone(self, actor_guid, go_guid) - } - - fn meeting_stone_area(&self, go_guid: u64) -> Result> { - crate::stdb::Coordinator::meeting_stone_area(self, go_guid) - } - - fn party_members(&self, actor_guid: u64) -> Result>> { - let authority = self.realm_core()?; - Ok( - crate::stdb::Coordinator::group_roster(&authority, actor_guid) - .map(|roster| roster.members.iter().map(|member| member.guid).collect()), - ) - } - - fn seeker_facts(&self, character_guid: u64) -> Result> { - Ok( - presence::character_anywhere(self, character_guid)?.map(|character| SeekerFacts { - character_guid, - race: character.race, - class: character.class, - }), - ) - } - - fn meeting_stone_op( - &self, - actor_guid: u64, - op: u8, - area_id: u32, - seekers: Vec, - ) -> Result { - crate::stdb::Coordinator::meeting_stone_op(self, actor_guid, op, area_id, seekers) - } - - fn queued_area(&self, character_guid: u64) -> Result> { - crate::stdb::Coordinator::queued_area(self, character_guid) - } -} - #[derive(Clone, Copy, Debug)] pub(crate) struct MeetingStonePlayer { pub(crate) account_id: u64, diff --git a/gateway/src/world/handlers/melee.rs b/gateway/src/world/handlers/melee.rs index 77e89c3af..b9cdb10d6 100644 --- a/gateway/src/world/handlers/melee.rs +++ b/gateway/src/world/handlers/melee.rs @@ -9,16 +9,6 @@ pub(crate) trait MeleeActionStore: Send + Sync { fn stop_attack(&self, account_id: u64, actor_guid: u64) -> Result<()>; } -impl MeleeActionStore for crate::stdb::Coordinator { - fn start_attack(&self, account_id: u64, actor_guid: u64, target_guid: u64) -> Result<()> { - crate::stdb::Coordinator::start_attack(self, account_id, actor_guid, target_guid) - } - - fn stop_attack(&self, account_id: u64, actor_guid: u64) -> Result<()> { - crate::stdb::Coordinator::stop_attack(self, account_id, actor_guid) - } -} - /// The melee-relevant session facts. `self_guid` is `None` outside the world. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct MeleeActionPlayer { diff --git a/gateway/src/world/handlers/member_stats.rs b/gateway/src/world/handlers/member_stats.rs index 376cda1e1..991e3c2a5 100644 --- a/gateway/src/world/handlers/member_stats.rs +++ b/gateway/src/world/handlers/member_stats.rs @@ -36,16 +36,6 @@ pub(crate) trait MemberStatsStore: Send + Sync { fn member_presence(&self, guid: u64) -> Result; } -impl MemberStatsStore for crate::stdb::Coordinator { - fn group_mates(&self, self_guid: u64) -> Result> { - crate::stdb::Coordinator::group_mates(self, self_guid) - } - - fn member_presence(&self, guid: u64) -> Result { - crate::stdb::Coordinator::member_presence(self, guid) - } -} - /// What the Relay last sent one viewer about one group mate. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum MemberSnapshot { diff --git a/gateway/src/world/handlers/mod.rs b/gateway/src/world/handlers/mod.rs index 2c18cfb6a..40b71c2b6 100644 --- a/gateway/src/world/handlers/mod.rs +++ b/gateway/src/world/handlers/mod.rs @@ -42,7 +42,6 @@ pub(crate) use auction::{ AuctionPage, AuctionQuery, CancelAuctionOutcome, CancelAuctionRequest, CreateAuctionOutcome, CreateAuctionRequest, PlaceBidOutcome, PlaceBidRequest, CMSG_AUCTION_LIST_ITEMS_OPCODE, }; -#[cfg(test)] pub(crate) use auction::{AuctionHousePolicy, AuctionInteraction}; pub(crate) use bank::{handle_bank, BankStore}; #[cfg(test)] @@ -52,7 +51,6 @@ pub(crate) use channel::{ dispatch_channel_action, ChannelActionOutcome, ChannelActionStore, ChannelOutcome, ChannelRequest, ChannelRoster, }; -#[cfg(test)] pub(crate) use channel::{resolve_online_character, ResolvedTarget}; pub(crate) use char::{handle_char, CharacterStore}; pub(crate) use chat::{ diff --git a/gateway/src/world/handlers/quest.rs b/gateway/src/world/handlers/quest.rs index 8b262ff83..7c2a79000 100644 --- a/gateway/src/world/handlers/quest.rs +++ b/gateway/src/world/handlers/quest.rs @@ -74,72 +74,6 @@ pub(crate) trait QuestActionStore: Send + Sync { fn quest_status(&self, player_guid: u64, quest_id: u32) -> (bool, bool); } -impl QuestActionStore for crate::stdb::Coordinator { - fn giver_quest_evals( - &self, - giver_guid: u64, - player_guid: u64, - ) -> Result> { - crate::stdb::Coordinator::quest_giver_evals(self, giver_guid, player_guid) - } - - fn quest_detail_view(&self, quest_id: u32) -> Result> { - crate::stdb::Coordinator::quest_detail(self, quest_id) - } - - fn giver_refuses_interaction(&self, giver_guid: u64, player_guid: u64) -> Result { - crate::stdb::Coordinator::npc_refuses_interaction(self, giver_guid, player_guid) - } - - fn accept_quest( - &self, - account_id: u64, - self_guid: u64, - giver_guid: u64, - quest_id: u32, - ) -> Result<()> { - crate::stdb::Coordinator::accept_quest(self, account_id, self_guid, giver_guid, quest_id) - } - - fn item_start_quest(&self, owner_guid: u64, slot: u8) -> Option<(u64, u32)> { - crate::stdb::Coordinator::item_start_quest(self, owner_guid, slot) - } - - fn turn_in_quest( - &self, - account_id: u64, - self_guid: u64, - giver_guid: u64, - quest_id: u32, - reward_index: u32, - ) -> Result<()> { - crate::stdb::Coordinator::turn_in_quest( - self, - account_id, - self_guid, - giver_guid, - quest_id, - reward_index, - ) - } - - fn player_quest_log(&self, player_guid: u64) -> Result> { - crate::stdb::Coordinator::player_quest_log(self, player_guid) - } - - fn abandon_quest(&self, account_id: u64, self_guid: u64, quest_id: u32) -> Result<()> { - crate::stdb::Coordinator::abandon_quest(self, account_id, self_guid, quest_id) - } - - fn push_quest(&self, account_id: u64, self_guid: u64, quest_id: u32) -> Result<()> { - crate::stdb::Coordinator::push_quest(self, account_id, self_guid, quest_id) - } - - fn quest_status(&self, player_guid: u64, quest_id: u32) -> (bool, bool) { - crate::stdb::Coordinator::quest_status(self, player_guid, quest_id) - } -} - /// 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)] diff --git a/gateway/src/world/handlers/taxi.rs b/gateway/src/world/handlers/taxi.rs index 5c78c9240..be82e9652 100644 --- a/gateway/src/world/handlers/taxi.rs +++ b/gateway/src/world/handlers/taxi.rs @@ -23,40 +23,6 @@ pub(crate) trait TaxiActionStore: Send + Sync { fn arm_taxi_flight(&self, character_guid: u64) -> Result<()>; } -impl TaxiActionStore for crate::stdb::Coordinator { - fn taxi_node_status( - &self, - character_guid: u64, - npc_guid: u64, - ) -> Result> { - crate::stdb::Coordinator::taxi_node_status(self, character_guid, npc_guid) - } - - fn open_taxi(&self, character_guid: u64, npc_guid: u64) -> Result> { - crate::stdb::Coordinator::open_taxi(self, character_guid, npc_guid) - } - - fn activate_taxi( - &self, - character_guid: u64, - npc_guid: u64, - source_client_node_id: u32, - destination_client_node_id: u32, - ) -> Result { - crate::stdb::Coordinator::activate_taxi( - self, - character_guid, - npc_guid, - source_client_node_id, - destination_client_node_id, - ) - } - - fn arm_taxi_flight(&self, character_guid: u64) -> Result<()> { - crate::stdb::Coordinator::arm_taxi_flight(self, character_guid) - } -} - #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct TaxiActionPlayer { pub(crate) self_guid: Option, diff --git a/gateway/src/world/handlers/vendor.rs b/gateway/src/world/handlers/vendor.rs index 5555ef0bc..a2483a02b 100644 --- a/gateway/src/world/handlers/vendor.rs +++ b/gateway/src/world/handlers/vendor.rs @@ -52,76 +52,6 @@ pub(crate) trait VendorActionStore: Send + Sync { ) -> Result<()>; } -impl VendorActionStore for crate::stdb::Coordinator { - fn vendor_stock(&self, vendor_guid: u64) -> Result> { - crate::stdb::Coordinator::vendor_items(self, vendor_guid) - } - - fn vendor_refuses_interaction(&self, vendor_guid: u64, player_guid: u64) -> Result { - crate::stdb::Coordinator::npc_refuses_interaction(self, vendor_guid, player_guid) - } - - fn vendor_buy( - &self, - account_id: u64, - self_guid: u64, - vendor_guid: u64, - item_entry: u32, - count: u32, - ) -> Result<()> { - crate::stdb::Coordinator::buy_item( - self, - account_id, - self_guid, - vendor_guid, - item_entry, - count, - ) - } - - fn buyback_slots(&self, player_guid: u64) -> Vec<(u32, u32, u32, u32)> { - crate::stdb::Coordinator::buyback_ring(self, player_guid) - } - - fn random_property_enchant_ids(&self, random_property_id: u32) -> [u32; 3] { - crate::stdb::Coordinator::random_property_enchant_ids(self, random_property_id) - } - - fn vendor_item_slot(&self, item_guid: u64) -> Option { - crate::stdb::Coordinator::item_slot_by_guid(self, 0, item_guid) - } - - fn vendor_repair( - &self, - account_id: u64, - self_guid: u64, - npc_guid: u64, - slot: u8, - ) -> Result<()> { - crate::stdb::Coordinator::repair_item(self, account_id, self_guid, npc_guid, slot) - } - - fn vendor_sell( - &self, - account_id: u64, - self_guid: u64, - vendor_guid: u64, - slot: u8, - ) -> Result<()> { - crate::stdb::Coordinator::sell_item(self, account_id, self_guid, vendor_guid, slot) - } - - fn vendor_buyback( - &self, - account_id: u64, - self_guid: u64, - vendor_guid: u64, - slot: u8, - ) -> Result<()> { - crate::stdb::Coordinator::buyback_item(self, account_id, self_guid, vendor_guid, slot) - } -} - /// Wire slot of the first buyback tab entry (`BuybackSlot::Slot1`); the 12 ring entries render at /// base + i and a client's slot enum parses back with − base. Both directions share this. pub(crate) const BUYBACK_WIRE_SLOT_BASE: u16 = 69; diff --git a/gateway/src/world/handlers/weather.rs b/gateway/src/world/handlers/weather.rs index 6a147e11c..854687c8a 100644 --- a/gateway/src/world/handlers/weather.rs +++ b/gateway/src/world/handlers/weather.rs @@ -15,12 +15,6 @@ pub(crate) trait WeatherStore: Send + Sync { fn zone_weather(&self, zone_id: u32) -> Result>; } -impl WeatherStore for crate::stdb::Coordinator { - fn zone_weather(&self, zone_id: u32) -> Result> { - crate::stdb::Coordinator::zone_weather(self, zone_id) - } -} - /// The `SMSG_WEATHER` a client in `zone_id` should be holding right now. /// /// Total by design: an absent row, an unresolved zone, and a Store that could not answer all mean diff --git a/gateway/src/world/mod.rs b/gateway/src/world/mod.rs index 7588de92a..1400c2998 100644 --- a/gateway/src/world/mod.rs +++ b/gateway/src/world/mod.rs @@ -73,13 +73,14 @@ use handlers::{ CMSG_AUCTION_LIST_ITEMS_OPCODE, }; pub(crate) use handlers::{ - member_stats_tick, zone_weather_message, AuctionBrowseRequest, AuctionPage, AuctionQuery, - CancelAuctionOutcome, CancelAuctionRequest, ChannelOutcome, ChannelRequest, ChannelRoster, - CharacterFacts, ChatOutcome, CreateAuctionOutcome, CreateAuctionRequest, GuildEventSnapshot, - GuildOutcome, GuildRequest, ItemActionResult, LootActionStatus, LootWindowRefusal, - LootWindowRequestStatus, MeetingStoneOutcome, MemberPresence, MemberStatsRecord, - MemberStatsStore, PlaceBidOutcome, PlaceBidRequest, RealmChatRequest, SeekerFacts, - SpeakerFacts, TrainerBuyOutcome, WeatherStore, WhisperRequest, WhisperTargetFacts, + member_stats_tick, resolve_online_character, zone_weather_message, AuctionBrowseRequest, + AuctionHousePolicy, AuctionInteraction, AuctionPage, AuctionQuery, CancelAuctionOutcome, + CancelAuctionRequest, ChannelOutcome, ChannelRequest, ChannelRoster, CharacterFacts, + ChatOutcome, CreateAuctionOutcome, CreateAuctionRequest, GuildEventSnapshot, GuildOutcome, + GuildRequest, ItemActionResult, LootActionStatus, LootWindowRefusal, LootWindowRequestStatus, + MeetingStoneOutcome, MemberPresence, MemberStatsRecord, MemberStatsStore, PlaceBidOutcome, + PlaceBidRequest, RealmChatRequest, ResolvedTarget, SeekerFacts, SpeakerFacts, + TrainerBuyOutcome, WeatherStore, WhisperRequest, WhisperTargetFacts, }; pub(crate) use handlers::{ AuctionActionStore, BankStore, CastStore, ChannelActionStore, CharacterStore, ChatActionStore, From a9f116b231404f085016e8701c943aaf8891910e Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 18:16:10 +0200 Subject: [PATCH 03/33] refactor(gateway): collapse the combat Store to one hop Co-Authored-By: Claude Sonnet 5.5 --- gateway/src/stdb/store/combat.rs | 48 ++------------ gateway/src/world/combat_tests.rs | 65 +++++++++++++++---- gateway/src/world/handlers/combat.rs | 53 +++++++-------- gateway/src/world/session_tests.rs | 2 +- .../world/test_support/world_fake/combat.rs | 25 +++---- 5 files changed, 97 insertions(+), 96 deletions(-) diff --git a/gateway/src/stdb/store/combat.rs b/gateway/src/stdb/store/combat.rs index 3ed92371b..9c509a5a6 100644 --- a/gateway/src/stdb/store/combat.rs +++ b/gateway/src/stdb/store/combat.rs @@ -1,6 +1,6 @@ //! `Coordinator`'s [`CombatStore`] adapter. -use anyhow::{anyhow, Result}; +use anyhow::Result; use crate::stdb::bindings::*; use crate::stdb::connection::call_reducer; @@ -8,31 +8,9 @@ use crate::stdb::Coordinator; use crate::world::{Actor, CombatStore}; impl CombatStore for Coordinator { - fn set_target(&self, account_id: u64, self_guid: u64, target_guid: u64) -> Result<()> { - self.set_target(account_id, self_guid, target_guid) - } - - fn pet_command( - &self, - account_id: u64, - self_guid: u64, - data: u32, - target_guid: u64, - ) -> Result<()> { - self.pet_command(account_id, self_guid, data, target_guid) - } - - fn set_sheathed(&self, account_id: u64, self_guid: u64, state: u8) -> Result<()> { - self.set_sheathed(account_id, self_guid, state) - } -} - -impl Coordinator { - /// Set the player's current target (`CMSG_SET_SELECTION`, Tier 2 / N3) over the coordinator - /// connection so the module attributes it to the caller. `target_guid` 0 clears it. - pub fn set_target(&self, _account_id: u64, actor_guid: u64, target_guid: u64) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("set_target: actor_guid unresolved"))?; + /// Targets are attributed to the caller through the coordinator connection. `target_guid` 0 + /// clears the selection. + fn set_target(&self, actor: Actor, target_guid: u64) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, @@ -41,18 +19,7 @@ impl Coordinator { ) } - /// Relay a pet command-bar action (`CMSG_PET_ACTION`) over the coordinator connection so the module - /// attributes it to the pet's owner. `data` is the raw packed action (flag<<24 | id); the module - /// decodes stay/follow/attack/dismiss + passive/defensive/aggressive. - pub fn pet_command( - &self, - _account_id: u64, - actor_guid: u64, - data: u32, - target_guid: u64, - ) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("pet_command: actor_guid unresolved"))?; + fn pet_command(&self, actor: Actor, data: u32, target_guid: u64) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, @@ -61,10 +28,7 @@ impl Coordinator { ) } - /// Draw or stow the player's weapons (`CMSG_SETSHEATHED`). - pub fn set_sheathed(&self, _account_id: u64, actor_guid: u64, state: u8) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("set_sheathed: actor_guid unresolved"))?; + fn set_sheathed(&self, actor: Actor, state: u8) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, diff --git a/gateway/src/world/combat_tests.rs b/gateway/src/world/combat_tests.rs index 90b930314..e9dd8d2cc 100644 --- a/gateway/src/world/combat_tests.rs +++ b/gateway/src/world/combat_tests.rs @@ -3,6 +3,7 @@ use super::handlers::{handle_combat, CombatStore}; use super::*; +use crate::stdb::ReducerCallError; use std::collections::BTreeMap; use std::sync::Mutex; @@ -11,6 +12,8 @@ use std::sync::Mutex; struct CombatFake { selected: Mutex>, sheath: Mutex>, + /// Answered once by the next request, in place of success. + failure: Mutex>, } impl CombatFake { @@ -21,26 +24,39 @@ impl CombatFake { fn sheath_of(&self, character: u64) -> Option { self.sheath.lock().unwrap().get(&character).copied() } + + fn failing(failure: ReducerCallError) -> Self { + Self { + failure: Mutex::new(Some(failure)), + ..Self::default() + } + } + + fn answer(&self) -> Result<()> { + match self.failure.lock().unwrap().take() { + Some(failure) => Err(failure.into()), + None => Ok(()), + } + } } impl CombatStore for CombatFake { - fn set_target(&self, _account_id: u64, self_guid: u64, target_guid: u64) -> Result<()> { - self.selected.lock().unwrap().insert(self_guid, target_guid); + fn set_target(&self, actor: Actor, target_guid: u64) -> Result<()> { + self.answer()?; + self.selected + .lock() + .unwrap() + .insert(actor.guid(), target_guid); Ok(()) } - fn pet_command( - &self, - _account_id: u64, - _self_guid: u64, - _data: u32, - _target_guid: u64, - ) -> Result<()> { - unimplemented!("pet_command") + fn pet_command(&self, _actor: Actor, _data: u32, _target_guid: u64) -> Result<()> { + self.answer() } - fn set_sheathed(&self, _account_id: u64, self_guid: u64, state: u8) -> Result<()> { - self.sheath.lock().unwrap().insert(self_guid, state); + fn set_sheathed(&self, actor: Actor, state: u8) -> Result<()> { + self.answer()?; + self.sheath.lock().unwrap().insert(actor.guid(), state); Ok(()) } } @@ -80,3 +96,28 @@ fn set_selection_records_the_wire_guid_as_the_target() { ); assert_eq!(store.target_of(1), Some(321)); } + +fn sheathe() -> CMSG_SETSHEATHED { + CMSG_SETSHEATHED { + sheathed: SheathState::Melee, + } +} + +#[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()); +} + +#[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" + ); + assert_eq!(store.sheath_of(1), None); +} diff --git a/gateway/src/world/handlers/combat.rs b/gateway/src/world/handlers/combat.rs index a4295ae42..171a1a265 100644 --- a/gateway/src/world/handlers/combat.rs +++ b/gateway/src/world/handlers/combat.rs @@ -7,23 +7,32 @@ use super::super::*; /// Selection, pet commands and sheathing. pub(crate) trait CombatStore: Send + Sync { /// Record the player's current target (`CMSG_SET_SELECTION`, Tier 2 / N3). 0 clears it. - fn set_target(&self, account_id: u64, self_guid: u64, target_guid: u64) -> Result<()>; + fn set_target(&self, actor: Actor, target_guid: u64) -> Result<()>; /// Relay a pet command-bar action (`CMSG_PET_ACTION`). `data` is the raw packed action /// (flag<<24 | id): flag 0x07 = command (Stay/Follow/Attack/Dismiss), flag 0x06 = react state /// (Passive/Defensive/Aggressive). The module decodes + validates (all pet policy lives there). - fn pet_command( - &self, - account_id: u64, - self_guid: u64, - data: u32, - target_guid: u64, - ) -> Result<()>; + fn pet_command(&self, actor: Actor, data: u32, target_guid: u64) -> Result<()>; /// Draw or stow the player's weapons (`CMSG_SETSHEATHED`, the `Z` key). `state` is 0 stowed / /// 1 melee / 2 ranged; the module range-checks it. Writes `UNIT_FIELD_BYTES_2` byte 0, which is /// what makes a drawn or stowed weapon visible to OTHER players. - fn set_sheathed(&self, account_id: u64, self_guid: u64, state: u8) -> Result<()>; + fn set_sheathed(&self, actor: Actor, state: u8) -> Result<()>; +} + +/// A Refusal of a best-effort action is logged and dropped. A Transport Loss ends the World Session. +pub(super) fn ignore_refusal(action: &str, account_id: u64, result: Result<()>) -> Result<()> { + let Err(error) = result else { + return Ok(()); + }; + if matches!( + crate::stdb::classify(&error), + crate::stdb::DurableFailure::TransportLoss + ) { + return Err(error); + } + log::debug!("world: {action} ignored (account {account_id}): {error}"); + Ok(()) } /// Combat family leftovers: selection, pet commands, the run-speed ack and sheathing. Each arm is @@ -34,28 +43,22 @@ pub(crate) fn handle_combat( conn: &mut WorldConn, msg: ClientOpcodeMessage, ) -> Result> { - // The shared-call path names the actor by guid; 0 (not in world) forces the - // per-player path in the store. - let self_guid = match &conn.state { - WorldState::InWorld(iw) => iw.self_guid, - _ => 0, - }; + let actor = social::self_guid(conn).and_then(Actor::new); match msg { // Targeting (N3): record the player's selection server-side (foundation for combat). ClientOpcodeMessage::CMSG_SET_SELECTION(s) => { - store.set_target(conn.account_id, self_guid, s.target.guid())? + 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 Err(e) = store.pet_command(conn.account_id, self_guid, p.data, p.target.guid()) { - log::debug!( - "world: pet_command ignored (account {}): {e}", - conn.account_id - ); + 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 @@ -70,11 +73,9 @@ pub(crate) fn handle_combat( // 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 Err(e) = store.set_sheathed(conn.account_id, self_guid, state) { - log::debug!( - "world: set_sheathed({state}) ignored (account {}): {e}", - conn.account_id - ); + if let Some(actor) = actor { + let result = store.set_sheathed(actor, state); + ignore_refusal(&format!("set_sheathed({state})"), conn.account_id, result)?; } } other => return Ok(Some(other)), diff --git a/gateway/src/world/session_tests.rs b/gateway/src/world/session_tests.rs index f84cfaec3..4225ad3e8 100644 --- a/gateway/src/world/session_tests.rs +++ b/gateway/src/world/session_tests.rs @@ -2004,7 +2004,7 @@ fn reducer_transport_loss_ends_an_admitted_session_and_frees_one_queue_seat() { ..base.session }, combat: CombatState { - set_target_error: Some("transport disconnected".into()), + set_target_transport_lost: true, }, ..base } diff --git a/gateway/src/world/test_support/world_fake/combat.rs b/gateway/src/world/test_support/world_fake/combat.rs index fd7798758..85fde277d 100644 --- a/gateway/src/world/test_support/world_fake/combat.rs +++ b/gateway/src/world/test_support/world_fake/combat.rs @@ -1,31 +1,26 @@ use super::super::*; +use crate::stdb::ReducerCallError; #[derive(Default)] pub(crate) struct CombatState { - /// When set, `set_target` fails before the reducer can complete. This models the call pipe - /// whose transport dies while an admitted world session is in flight. - pub(crate) set_target_error: Option, + /// When set, `set_target` fails as a Transport Loss before the reducer can complete. This + /// models the call pipe whose transport dies while an admitted World Session is in flight. + pub(crate) set_target_transport_lost: bool, } impl CombatStore for WorldFake { - fn set_target(&self, _account_id: u64, _self_guid: u64, _target_guid: u64) -> Result<()> { - match &self.combat.set_target_error { - Some(e) => Err(anyhow!("{e}")), - None => Ok(()), + fn set_target(&self, _actor: Actor, _target_guid: u64) -> Result<()> { + if self.combat.set_target_transport_lost { + return Err(ReducerCallError::transport_lost("gw_set_target").into()); } + Ok(()) } - fn pet_command( - &self, - _account_id: u64, - _self_guid: u64, - _data: u32, - _target_guid: u64, - ) -> Result<()> { + fn pet_command(&self, _actor: Actor, _data: u32, _target_guid: u64) -> Result<()> { Ok(()) } - fn set_sheathed(&self, _account_id: u64, _self_guid: u64, _state: u8) -> Result<()> { + fn set_sheathed(&self, _actor: Actor, _state: u8) -> Result<()> { Ok(()) } } From 378ab0aab7cc21104bc58c6eaac776c99f84483b Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 18:16:10 +0200 Subject: [PATCH 04/33] refactor(gateway): collapse the duel Store to one hop Co-Authored-By: Claude Sonnet 5.5 --- gateway/src/stdb/store/duel.rs | 21 +----- gateway/src/world/handlers/duel.rs | 73 +++++++++++++++---- .../src/world/test_support/world_fake/duel.rs | 4 +- 3 files changed, 63 insertions(+), 35 deletions(-) diff --git a/gateway/src/stdb/store/duel.rs b/gateway/src/stdb/store/duel.rs index 60a3f71cb..4d0ecd974 100644 --- a/gateway/src/stdb/store/duel.rs +++ b/gateway/src/stdb/store/duel.rs @@ -1,26 +1,13 @@ //! `Coordinator`'s [`DuelActionStore`] adapter. -use anyhow::{anyhow, Result}; +use anyhow::Result; use crate::stdb::bindings::*; use crate::stdb::connection::call_reducer; -use crate::stdb::Coordinator; use crate::world::{Actor, DuelActionStore}; impl DuelActionStore for crate::stdb::Coordinator { - fn duel_accept(&self, account_id: u64, actor_guid: u64, flag_guid: u64) -> Result<()> { - crate::stdb::Coordinator::duel_accept(self, account_id, actor_guid, flag_guid) - } - - fn duel_cancel(&self, account_id: u64, actor_guid: u64, flag_guid: u64) -> Result<()> { - crate::stdb::Coordinator::duel_cancel(self, account_id, actor_guid, flag_guid) - } -} - -impl Coordinator { - pub fn duel_accept(&self, _account_id: u64, actor_guid: u64, flag_guid: u64) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("duel_accept: actor_guid unresolved"))?; + fn duel_accept(&self, actor: Actor, flag_guid: u64) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, @@ -29,9 +16,7 @@ impl Coordinator { ) } - pub fn duel_cancel(&self, _account_id: u64, actor_guid: u64, flag_guid: u64) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("duel_cancel: actor_guid unresolved"))?; + fn duel_cancel(&self, actor: Actor, flag_guid: u64) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, diff --git a/gateway/src/world/handlers/duel.rs b/gateway/src/world/handlers/duel.rs index 97060799f..b7917ed33 100644 --- a/gateway/src/world/handlers/duel.rs +++ b/gateway/src/world/handlers/duel.rs @@ -3,8 +3,8 @@ use super::super::*; pub(crate) trait DuelActionStore: Send + Sync { - fn duel_accept(&self, account_id: u64, actor_guid: u64, flag_guid: u64) -> Result<()>; - fn duel_cancel(&self, account_id: u64, actor_guid: u64, flag_guid: u64) -> Result<()>; + fn duel_accept(&self, actor: Actor, flag_guid: u64) -> Result<()>; + fn duel_cancel(&self, actor: Actor, flag_guid: u64) -> Result<()>; } #[derive(Clone, Copy)] @@ -28,17 +28,23 @@ pub(crate) fn dispatch_duel_action( ClientOpcodeMessage::CMSG_DUEL_CANCELLED(request) => (false, request.guid.guid()), other => return Ok(DuelActionOutcome::PassThrough(other)), }; - let Some(actor_guid) = player.self_guid else { + 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(player.account_id, actor_guid, flag_guid) + store.duel_accept(actor, flag_guid) } else { - store.duel_cancel(player.account_id, actor_guid, flag_guid) + 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 @@ -56,26 +62,41 @@ mod tests { use wow_world_messages::vanilla::{CMSG_DUEL_ACCEPTED, CMSG_DUEL_CANCELLED, CMSG_PING}; use wow_world_messages::Guid; + use crate::stdb::ReducerCallError; + #[derive(Default)] struct InMemoryDuelStore { calls: Mutex>, + failure: Mutex>, } - impl DuelActionStore for InMemoryDuelStore { - fn duel_accept(&self, _account_id: u64, actor_guid: u64, flag_guid: u64) -> Result<()> { - self.calls - .lock() - .unwrap() - .push(("accept", actor_guid, flag_guid)); - Ok(()) + impl InMemoryDuelStore { + fn failing(failure: ReducerCallError) -> Self { + Self { + failure: Mutex::new(Some(failure)), + ..Self::default() + } } - fn duel_cancel(&self, _account_id: u64, actor_guid: u64, flag_guid: u64) -> Result<()> { + fn record(&self, call: &'static str, actor: Actor, flag_guid: u64) -> Result<()> { self.calls .lock() .unwrap() - .push(("cancel", actor_guid, flag_guid)); - Ok(()) + .push((call, actor.guid(), flag_guid)); + match self.failure.lock().unwrap().take() { + Some(failure) => Err(failure.into()), + None => Ok(()), + } + } + } + + impl DuelActionStore for InMemoryDuelStore { + fn duel_accept(&self, actor: Actor, flag_guid: u64) -> Result<()> { + self.record("accept", actor, flag_guid) + } + + fn duel_cancel(&self, actor: Actor, flag_guid: u64) -> Result<()> { + self.record("cancel", actor, flag_guid) } } @@ -133,6 +154,28 @@ mod tests { assert!(store.calls.lock().unwrap().is_empty()); } + fn accept_message() -> ClientOpcodeMessage { + ClientOpcodeMessage::CMSG_DUEL_ACCEPTED(CMSG_DUEL_ACCEPTED { + guid: Guid::new(99), + }) + } + + #[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()); + } + + #[test] + fn refusal_is_ignored_and_the_session_continues() { + let store = InMemoryDuelStore::failing(ReducerCallError::refused( + "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(); diff --git a/gateway/src/world/test_support/world_fake/duel.rs b/gateway/src/world/test_support/world_fake/duel.rs index 563d032ac..2e10717fc 100644 --- a/gateway/src/world/test_support/world_fake/duel.rs +++ b/gateway/src/world/test_support/world_fake/duel.rs @@ -1,11 +1,11 @@ use super::super::*; impl DuelActionStore for WorldFake { - fn duel_accept(&self, _account_id: u64, _actor_guid: u64, _flag_guid: u64) -> Result<()> { + fn duel_accept(&self, _actor: Actor, _flag_guid: u64) -> Result<()> { Ok(()) } - fn duel_cancel(&self, _account_id: u64, _actor_guid: u64, _flag_guid: u64) -> Result<()> { + fn duel_cancel(&self, _actor: Actor, _flag_guid: u64) -> Result<()> { Ok(()) } } From 3a929b8119055c9d3ae88e887d93aa786f512df3 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 18:16:10 +0200 Subject: [PATCH 05/33] refactor(gateway): collapse the trade Store to one hop Co-Authored-By: Claude Sonnet 5.5 --- gateway/src/stdb/store/trade.rs | 119 ++--------- gateway/src/world/handlers/trade.rs | 202 ++++++------------ .../world/test_support/world_fake/trade.rs | 26 +-- gateway/src/world/trade_tests.rs | 83 +++++-- 4 files changed, 159 insertions(+), 271 deletions(-) diff --git a/gateway/src/stdb/store/trade.rs b/gateway/src/stdb/store/trade.rs index 697aa8c6c..631987bd5 100644 --- a/gateway/src/stdb/store/trade.rs +++ b/gateway/src/stdb/store/trade.rs @@ -1,70 +1,13 @@ //! `Coordinator`'s [`TradeStore`] adapter. -use anyhow::{anyhow, Result}; +use anyhow::Result; use crate::stdb::bindings::*; use crate::stdb::connection::call_reducer; -use crate::stdb::Coordinator; use crate::world::{Actor, TradeStore}; -impl TradeStore for Coordinator { - fn initiate_trade(&self, account_id: u64, self_guid: u64, target_guid: u64) -> Result<()> { - self.initiate_trade(account_id, self_guid, target_guid) - } - - fn begin_trade(&self, account_id: u64, self_guid: u64) -> Result<()> { - self.begin_trade(account_id, self_guid) - } - - fn cancel_trade(&self, account_id: u64, self_guid: u64) -> Result<()> { - self.cancel_trade(account_id, self_guid) - } - - fn set_trade_item( - &self, - account_id: u64, - self_guid: u64, - trade_slot: u8, - inv_slot: u8, - ) -> Result<()> { - self.set_trade_item(account_id, self_guid, trade_slot, inv_slot) - } - - fn clear_trade_item(&self, account_id: u64, self_guid: u64, trade_slot: u8) -> Result<()> { - self.clear_trade_item(account_id, self_guid, trade_slot) - } - - fn set_trade_gold(&self, account_id: u64, self_guid: u64, copper: u32) -> Result<()> { - self.set_trade_gold(account_id, self_guid, copper) - } - - fn accept_trade(&self, account_id: u64, self_guid: u64) -> Result<()> { - self.accept_trade(account_id, self_guid) - } - - fn unaccept_trade(&self, account_id: u64, self_guid: u64) -> Result<()> { - self.unaccept_trade(account_id, self_guid) - } - - fn busy_trade(&self, account_id: u64, self_guid: u64) -> Result<()> { - self.busy_trade(account_id, self_guid) - } - - fn ignore_trade(&self, account_id: u64, self_guid: u64) -> Result<()> { - self.ignore_trade(account_id, self_guid) - } -} - -impl Coordinator { - /// `CMSG_INITIATE_TRADE`, `target_guid` is the client's targeted player. - pub fn initiate_trade( - &self, - _account_id: u64, - actor_guid: u64, - target_guid: u64, - ) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("initiate_trade: actor_guid unresolved"))?; +impl TradeStore for crate::stdb::Coordinator { + fn initiate_trade(&self, actor: Actor, target_guid: u64) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, @@ -73,10 +16,7 @@ impl Coordinator { ) } - /// `CMSG_BEGIN_TRADE`. - pub fn begin_trade(&self, _account_id: u64, actor_guid: u64) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("begin_trade: actor_guid unresolved"))?; + fn begin_trade(&self, actor: Actor) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, @@ -85,10 +25,7 @@ impl Coordinator { ) } - /// `CMSG_CANCEL_TRADE`. - pub fn cancel_trade(&self, _account_id: u64, actor_guid: u64) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("cancel_trade: actor_guid unresolved"))?; + fn cancel_trade(&self, actor: Actor) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, @@ -97,16 +34,7 @@ impl Coordinator { ) } - /// `CMSG_SET_TRADE_ITEM`. - pub fn set_trade_item( - &self, - _account_id: u64, - actor_guid: u64, - trade_slot: u8, - inv_slot: u8, - ) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("set_trade_item: actor_guid unresolved"))?; + fn set_trade_item(&self, actor: Actor, trade_slot: u8, inv_slot: u8) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, @@ -115,15 +43,7 @@ impl Coordinator { ) } - /// `CMSG_CLEAR_TRADE_ITEM`. - pub fn clear_trade_item( - &self, - _account_id: u64, - actor_guid: u64, - trade_slot: u8, - ) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("clear_trade_item: actor_guid unresolved"))?; + fn clear_trade_item(&self, actor: Actor, trade_slot: u8) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, @@ -132,10 +52,7 @@ impl Coordinator { ) } - /// `CMSG_SET_TRADE_GOLD`. - pub fn set_trade_gold(&self, _account_id: u64, actor_guid: u64, copper: u32) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("set_trade_gold: actor_guid unresolved"))?; + fn set_trade_gold(&self, actor: Actor, copper: u32) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, @@ -144,10 +61,7 @@ impl Coordinator { ) } - /// `CMSG_ACCEPT_TRADE`. - pub fn accept_trade(&self, _account_id: u64, actor_guid: u64) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("accept_trade: actor_guid unresolved"))?; + fn accept_trade(&self, actor: Actor) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, @@ -156,10 +70,7 @@ impl Coordinator { ) } - /// `CMSG_UNACCEPT_TRADE`. - pub fn unaccept_trade(&self, _account_id: u64, actor_guid: u64) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("unaccept_trade: actor_guid unresolved"))?; + fn unaccept_trade(&self, actor: Actor) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, @@ -168,10 +79,7 @@ impl Coordinator { ) } - /// `CMSG_BUSY_TRADE`. - pub fn busy_trade(&self, _account_id: u64, actor_guid: u64) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("busy_trade: actor_guid unresolved"))?; + fn busy_trade(&self, actor: Actor) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, @@ -180,10 +88,7 @@ impl Coordinator { ) } - /// `CMSG_IGNORE_TRADE`. - pub fn ignore_trade(&self, _account_id: u64, actor_guid: u64) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("ignore_trade: actor_guid unresolved"))?; + fn ignore_trade(&self, actor: Actor) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, diff --git a/gateway/src/world/handlers/trade.rs b/gateway/src/world/handlers/trade.rs index 14eca1de4..39b18ef55 100644 --- a/gateway/src/world/handlers/trade.rs +++ b/gateway/src/world/handlers/trade.rs @@ -1,54 +1,49 @@ //! The Trade Session handshake, initiate / begin / cancel. The module owns //! every gate; ALL statuses, to both parties, ride the `game_trade_event` relay //! (`stdb::subscriptions::trade_event_outbound`), so no arm here answers synchronously. Reducer -//! rejections are transient per-action failures (logged, never session-fatal), the vendor-arm -//! convention. Outside the world there is no `self_guid` to act as — dropped silently, like the -//! social family. +//! Refusals are transient per-action failures (logged, never session-fatal), the vendor-arm +//! 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; /// The Trade Session handshake. Every status, BeginTrade/OpenWindow to the parties, or a refusal /// back to the caller, rides the `game_trade_event` relay; these calls answer nothing -/// synchronously, and an `Err` is only an unresolved actor (per-action, log + ignore). +/// synchronously, and a Refusal is a per-action failure (log + ignore). pub(crate) trait TradeStore: Send + Sync { /// `CMSG_INITIATE_TRADE` — propose a Trade Session against the targeted player. - fn initiate_trade(&self, account_id: u64, self_guid: u64, target_guid: u64) -> Result<()>; + fn initiate_trade(&self, actor: Actor, target_guid: u64) -> Result<()>; /// `CMSG_BEGIN_TRADE` — the proposed target's client answered; the module opens both windows. - fn begin_trade(&self, account_id: u64, self_guid: u64) -> Result<()>; + fn begin_trade(&self, actor: Actor) -> Result<()>; /// `CMSG_CANCEL_TRADE` — tear the caller's Trade Session down (`TradeCanceled` to both). - fn cancel_trade(&self, account_id: u64, self_guid: u64) -> Result<()>; + fn cancel_trade(&self, actor: Actor) -> Result<()>; /// `CMSG_SET_TRADE_ITEM` — `inv_slot` is the ABSOLUTE inventory slot (the gateway maps the /// client's (bag, slot) pair, the item-family convention). - fn set_trade_item( - &self, - account_id: u64, - self_guid: u64, - trade_slot: u8, - inv_slot: u8, - ) -> Result<()>; + fn set_trade_item(&self, actor: Actor, trade_slot: u8, inv_slot: u8) -> Result<()>; /// `CMSG_CLEAR_TRADE_ITEM`. - fn clear_trade_item(&self, account_id: u64, self_guid: u64, trade_slot: u8) -> Result<()>; + fn clear_trade_item(&self, actor: Actor, trade_slot: u8) -> Result<()>; /// `CMSG_SET_TRADE_GOLD`, `copper` is the offered amount. - fn set_trade_gold(&self, account_id: u64, self_guid: u64, copper: u32) -> Result<()>; + fn set_trade_gold(&self, actor: Actor, copper: u32) -> Result<()>; /// `CMSG_ACCEPT_TRADE` — accept the current offer; dual-accept runs the atomic Trade Commit /// module-side. - fn accept_trade(&self, account_id: u64, self_guid: u64) -> Result<()>; + fn accept_trade(&self, actor: Actor) -> Result<()>; /// `CMSG_UNACCEPT_TRADE`, withdraw an accept; partner hears `BackToTrade`. - fn unaccept_trade(&self, account_id: u64, self_guid: u64) -> Result<()>; + fn unaccept_trade(&self, actor: Actor) -> Result<()>; /// `CMSG_BUSY_TRADE`, decline a pending proposal as busy; initiator hears `Busy`. - fn busy_trade(&self, account_id: u64, self_guid: u64) -> Result<()>; + fn busy_trade(&self, actor: Actor) -> Result<()>; /// `CMSG_IGNORE_TRADE`, decline via ignore; initiator hears `IgnoreYou`. - fn ignore_trade(&self, account_id: u64, self_guid: u64) -> Result<()>; + fn ignore_trade(&self, actor: Actor) -> Result<()>; } pub(crate) fn handle_trade( @@ -57,133 +52,76 @@ pub(crate) fn handle_trade( conn: &mut WorldConn, msg: ClientOpcodeMessage, ) -> Result> { - match msg { - ClientOpcodeMessage::CMSG_INITIATE_TRADE(c) => { - if let Some(me) = self_guid(conn) { - if let Err(e) = store.initiate_trade(conn.account_id, me, c.guid.guid()) { - log::debug!( - "world: initiate_trade ignored (account {}): {e}", - conn.account_id - ); - } - } - Ok(None) + 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 => { - if let Some(me) = self_guid(conn) { - if let Err(e) = store.begin_trade(conn.account_id, me) { - log::debug!( - "world: begin_trade ignored (account {}): {e}", - conn.account_id - ); - } - } - Ok(None) + (ClientOpcodeMessage::CMSG_BEGIN_TRADE, Some(actor)) => { + ("begin_trade", store.begin_trade(actor)) } - ClientOpcodeMessage::CMSG_CANCEL_TRADE => { - if let Some(me) = self_guid(conn) { - if let Err(e) = store.cancel_trade(conn.account_id, me) { - log::debug!( - "world: cancel_trade ignored (account {}): {e}", - conn.account_id - ); - } - } - Ok(None) + (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) => { + (ClientOpcodeMessage::CMSG_SET_TRADE_ITEM(c), Some(actor)) => { const MAIN_BAG: u8 = 255; // INVENTORY_SLOT_BAG_0 - if let Some(me) = self_guid(conn) { - if c.bag != MAIN_BAG { - log::debug!( - "world: set_trade_item from sub-bag {} ignored (account {})", - c.bag, - conn.account_id - ); - } else if let Err(e) = - store.set_trade_item(conn.account_id, me, c.trade_slot, c.slot) - { - log::debug!( - "world: set_trade_item ignored (account {}): {e}", - conn.account_id - ); - } + if c.bag != MAIN_BAG { + log::debug!( + "world: set_trade_item from sub-bag {} ignored (account {account_id})", + c.bag + ); + return Ok(None); } - Ok(None) - } - ClientOpcodeMessage::CMSG_CLEAR_TRADE_ITEM(c) => { - if let Some(me) = self_guid(conn) { - if let Err(e) = store.clear_trade_item(conn.account_id, me, c.trade_slot) { - log::debug!( - "world: clear_trade_item ignored (account {}): {e}", - conn.account_id - ); - } - } - Ok(None) - } - ClientOpcodeMessage::CMSG_SET_TRADE_GOLD(c) => { - if let Some(me) = self_guid(conn) { - if let Err(e) = store.set_trade_gold(conn.account_id, me, c.gold.as_int()) { - log::debug!( - "world: set_trade_gold ignored (account {}): {e}", - conn.account_id - ); - } - } - Ok(None) + ( + "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(_) => { - if let Some(me) = self_guid(conn) { - if let Err(e) = store.accept_trade(conn.account_id, me) { - log::debug!( - "world: accept_trade ignored (account {}): {e}", - conn.account_id - ); - } - } - Ok(None) + (ClientOpcodeMessage::CMSG_ACCEPT_TRADE(_), Some(actor)) => { + ("accept_trade", store.accept_trade(actor)) } - ClientOpcodeMessage::CMSG_UNACCEPT_TRADE => { - if let Some(me) = self_guid(conn) { - if let Err(e) = store.unaccept_trade(conn.account_id, me) { - log::debug!( - "world: unaccept_trade ignored (account {}): {e}", - conn.account_id - ); - } - } - Ok(None) + (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 => { - if let Some(me) = self_guid(conn) { - if let Err(e) = store.busy_trade(conn.account_id, me) { - log::debug!( - "world: busy_trade ignored (account {}): {e}", - conn.account_id - ); - } - } - Ok(None) + (ClientOpcodeMessage::CMSG_BUSY_TRADE, Some(actor)) => { + ("busy_trade", store.busy_trade(actor)) } - ClientOpcodeMessage::CMSG_IGNORE_TRADE => { - if let Some(me) = self_guid(conn) { - if let Err(e) = store.ignore_trade(conn.account_id, me) { - log::debug!( - "world: ignore_trade ignored (account {}): {e}", - conn.account_id - ); - } - } - Ok(None) + (ClientOpcodeMessage::CMSG_IGNORE_TRADE, Some(actor)) => { + ("ignore_trade", store.ignore_trade(actor)) } - other => Ok(Some(other)), - } + // 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) } diff --git a/gateway/src/world/test_support/world_fake/trade.rs b/gateway/src/world/test_support/world_fake/trade.rs index 5424bc18c..a5718fb21 100644 --- a/gateway/src/world/test_support/world_fake/trade.rs +++ b/gateway/src/world/test_support/world_fake/trade.rs @@ -1,49 +1,43 @@ use super::super::*; impl TradeStore for WorldFake { - fn initiate_trade(&self, _account_id: u64, _self_guid: u64, _target_guid: u64) -> Result<()> { + fn initiate_trade(&self, _actor: Actor, _target_guid: u64) -> Result<()> { Ok(()) } - fn begin_trade(&self, _account_id: u64, _self_guid: u64) -> Result<()> { + fn begin_trade(&self, _actor: Actor) -> Result<()> { Ok(()) } - fn cancel_trade(&self, _account_id: u64, _self_guid: u64) -> Result<()> { + fn cancel_trade(&self, _actor: Actor) -> Result<()> { Ok(()) } - fn set_trade_item( - &self, - _account_id: u64, - _self_guid: u64, - _trade_slot: u8, - _inv_slot: u8, - ) -> Result<()> { + fn set_trade_item(&self, _actor: Actor, _trade_slot: u8, _inv_slot: u8) -> Result<()> { Ok(()) } - fn clear_trade_item(&self, _account_id: u64, _self_guid: u64, _trade_slot: u8) -> Result<()> { + fn clear_trade_item(&self, _actor: Actor, _trade_slot: u8) -> Result<()> { Ok(()) } - fn set_trade_gold(&self, _account_id: u64, _self_guid: u64, _copper: u32) -> Result<()> { + fn set_trade_gold(&self, _actor: Actor, _copper: u32) -> Result<()> { Ok(()) } - fn accept_trade(&self, _account_id: u64, _self_guid: u64) -> Result<()> { + fn accept_trade(&self, _actor: Actor) -> Result<()> { Ok(()) } - fn unaccept_trade(&self, _account_id: u64, _self_guid: u64) -> Result<()> { + fn unaccept_trade(&self, _actor: Actor) -> Result<()> { Ok(()) } - fn busy_trade(&self, _account_id: u64, _self_guid: u64) -> Result<()> { + fn busy_trade(&self, _actor: Actor) -> Result<()> { Ok(()) } - fn ignore_trade(&self, _account_id: u64, _self_guid: u64) -> Result<()> { + fn ignore_trade(&self, _actor: Actor) -> Result<()> { Ok(()) } } diff --git a/gateway/src/world/trade_tests.rs b/gateway/src/world/trade_tests.rs index d923639a6..7a39ed92e 100644 --- a/gateway/src/world/trade_tests.rs +++ b/gateway/src/world/trade_tests.rs @@ -4,6 +4,7 @@ use super::handlers::{handle_trade, TradeStore}; use super::*; +use crate::stdb::ReducerCallError; use std::collections::{BTreeMap, BTreeSet}; use std::sync::Mutex; use wow_world_messages::vanilla::{ @@ -54,9 +55,25 @@ struct Session { #[derive(Default)] struct TradeFake { session: Mutex, + /// Answered once by the next request, in place of success. + failure: Mutex>, } impl TradeFake { + fn failing(failure: ReducerCallError) -> Self { + Self { + failure: Mutex::new(Some(failure)), + ..Self::default() + } + } + + fn answer(&self) -> Result<()> { + match self.failure.lock().unwrap().take() { + Some(failure) => Err(failure.into()), + None => Ok(()), + } + } + fn phase(&self) -> Phase { self.session.lock().unwrap().phase } @@ -90,7 +107,9 @@ impl TradeFake { } impl TradeStore for TradeFake { - fn initiate_trade(&self, _account_id: u64, self_guid: u64, target_guid: u64) -> Result<()> { + fn initiate_trade(&self, actor: Actor, target_guid: u64) -> Result<()> { + self.answer()?; + let self_guid = actor.guid(); *self.session.lock().unwrap() = Session { phase: Phase::Proposed { initiator: self_guid, @@ -101,7 +120,9 @@ impl TradeStore for TradeFake { Ok(()) } - fn begin_trade(&self, _account_id: u64, self_guid: u64) -> Result<()> { + fn begin_trade(&self, actor: Actor) -> Result<()> { + self.answer()?; + let self_guid = actor.guid(); let mut session = self.session.lock().unwrap(); if let Phase::Proposed { initiator, target } = session.phase { if target == self_guid { @@ -111,24 +132,24 @@ impl TradeStore for TradeFake { Ok(()) } - fn cancel_trade(&self, _account_id: u64, self_guid: u64) -> Result<()> { + fn cancel_trade(&self, actor: Actor) -> Result<()> { + self.answer()?; + let self_guid = actor.guid(); self.session.lock().unwrap().phase = Phase::Cancelled { by: self_guid }; Ok(()) } - fn set_trade_item( - &self, - _account_id: u64, - self_guid: u64, - trade_slot: u8, - inv_slot: u8, - ) -> Result<()> { + fn set_trade_item(&self, actor: Actor, trade_slot: u8, inv_slot: u8) -> Result<()> { + self.answer()?; + let self_guid = actor.guid(); let mut session = self.session.lock().unwrap(); session.items.insert((self_guid, trade_slot), inv_slot); Ok(()) } - fn clear_trade_item(&self, _account_id: u64, self_guid: u64, trade_slot: u8) -> Result<()> { + fn clear_trade_item(&self, actor: Actor, trade_slot: u8) -> Result<()> { + self.answer()?; + let self_guid = actor.guid(); self.session .lock() .unwrap() @@ -137,27 +158,37 @@ impl TradeStore for TradeFake { Ok(()) } - fn set_trade_gold(&self, _account_id: u64, self_guid: u64, copper: u32) -> Result<()> { + fn set_trade_gold(&self, actor: Actor, copper: u32) -> Result<()> { + self.answer()?; + let self_guid = actor.guid(); self.session.lock().unwrap().gold.insert(self_guid, copper); Ok(()) } - fn accept_trade(&self, _account_id: u64, self_guid: u64) -> Result<()> { + fn accept_trade(&self, actor: Actor) -> Result<()> { + self.answer()?; + let self_guid = actor.guid(); self.session.lock().unwrap().accepted.insert(self_guid); Ok(()) } - fn unaccept_trade(&self, _account_id: u64, self_guid: u64) -> Result<()> { + fn unaccept_trade(&self, actor: Actor) -> Result<()> { + self.answer()?; + let self_guid = actor.guid(); self.session.lock().unwrap().accepted.remove(&self_guid); Ok(()) } - fn busy_trade(&self, _account_id: u64, self_guid: u64) -> Result<()> { + fn busy_trade(&self, actor: Actor) -> Result<()> { + self.answer()?; + let self_guid = actor.guid(); self.decline_if_proposed(self_guid, Decline::Busy); Ok(()) } - fn ignore_trade(&self, _account_id: u64, self_guid: u64) -> Result<()> { + fn ignore_trade(&self, actor: Actor) -> Result<()> { + self.answer()?; + let self_guid = actor.guid(); self.decline_if_proposed(self_guid, Decline::IgnoresInitiator); Ok(()) } @@ -364,3 +395,23 @@ fn cancel_trade_cancels_for_the_initiating_side_too() { run(&store, 1, CMSG_CANCEL_TRADE {}); assert_eq!(store.phase(), Phase::Cancelled { by: 1 }); } + +#[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 msg = CMSG_ACCEPT_TRADE { unknown1: 1 }; + let result = handle_trade(&SessionTx::with_depth(0).0, &store, &mut conn, 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 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"); + assert_eq!(store.accepted(), Vec::::new()); +} From e466acf00906693f60c7870694f4006c6d47692a Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 18:16:10 +0200 Subject: [PATCH 06/33] refactor(gateway): collapse the death Store to one hop Co-Authored-By: Claude Sonnet 5.5 --- gateway/src/stdb/store/death.rs | 101 ++++-------------- gateway/src/world/death_tests.rs | 17 +++ gateway/src/world/handlers/death.rs | 10 +- .../world/test_support/world_fake/death.rs | 15 +-- 4 files changed, 47 insertions(+), 96 deletions(-) diff --git a/gateway/src/stdb/store/death.rs b/gateway/src/stdb/store/death.rs index d74639625..8dc58ad9c 100644 --- a/gateway/src/stdb/store/death.rs +++ b/gateway/src/stdb/store/death.rs @@ -1,6 +1,6 @@ //! `Coordinator`'s [`DeathStore`] adapter. -use anyhow::{anyhow, Result}; +use anyhow::Result; use spacetimedb_sdk::Table; use crate::stdb::bindings::*; @@ -9,51 +9,7 @@ use crate::stdb::Coordinator; use crate::world::{Actor, DeathStore}; impl DeathStore for Coordinator { - fn repop(&self, account_id: u64, self_guid: u64) -> Result<()> { - self.repop(account_id, self_guid) - } - - fn reclaim_corpse(&self, account_id: u64, self_guid: u64, corpse_guid: u64) -> Result<()> { - self.reclaim_corpse(account_id, self_guid, corpse_guid) - } - - fn resurrect_response(&self, account_id: u64, self_guid: u64, accept: bool) -> Result<()> { - self.resurrect_response(account_id, self_guid, accept) - } - - fn self_resurrect(&self, account_id: u64, self_guid: u64) -> Result<()> { - self.self_resurrect(account_id, self_guid) - } - - fn spirit_healer_res(&self, account_id: u64, self_guid: u64, healer_guid: u64) -> Result<()> { - self.spirit_healer_res(account_id, self_guid, healer_guid) - } - - fn corpse_location(&self, owner_guid: u64) -> Result> { - self.corpse_location(owner_guid) - } -} - -impl Coordinator { - /// Find `owner_guid`'s corpse location `(map_id, x, y, z)` from the privileged cache, for the - /// `MSG_CORPSE_QUERY` reply. `None` if they have no corpse. - pub fn corpse_location(&self, owner_guid: u64) -> Result> { - Ok(self - .0 - .coord() - .conn - .db - .game_corpse() - .iter() - .find(|c| c.owner_guid == owner_guid) - .map(|c| (c.map_id, c.x, c.y, c.z))) - } - - /// Revive the caller after death (`CMSG_REPOP_REQUEST`) over the coordinator connection. - /// Rides the coordinator connection as `gw_repop`. - pub fn repop(&self, _account_id: u64, actor_guid: u64) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("repop: actor_guid unresolved"))?; + fn repop(&self, actor: Actor) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, @@ -62,15 +18,7 @@ impl Coordinator { ) } - /// Reclaim the caller's corpse (`CMSG_RECLAIM_CORPSE`) over the coordinator connection. - pub fn reclaim_corpse( - &self, - _account_id: u64, - actor_guid: u64, - corpse_guid: u64, - ) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("reclaim_corpse: actor_guid unresolved"))?; + fn reclaim_corpse(&self, actor: Actor, corpse_guid: u64) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, @@ -79,16 +27,7 @@ impl Coordinator { ) } - /// Answer a pending resurrect offer (`CMSG_RESURRECT_RESPONSE`) over the coordinator connection. - /// Rides the coordinator connection as `gw_respond_resurrect`. - pub fn resurrect_response( - &self, - _account_id: u64, - actor_guid: u64, - accept: bool, - ) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("resurrect_response: actor_guid unresolved"))?; + fn resurrect_response(&self, actor: Actor, accept: bool) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, @@ -97,11 +36,7 @@ impl Coordinator { ) } - /// Use the caller's Self-Resurrection Option (`CMSG_SELF_RES`) over the coordinator connection. - /// Rides the coordinator connection as `gw_self_resurrect`. - pub fn self_resurrect(&self, _account_id: u64, actor_guid: u64) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("self_resurrect: actor_guid unresolved"))?; + fn self_resurrect(&self, actor: Actor) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, @@ -110,17 +45,8 @@ impl Coordinator { ) } - /// Spirit-Healer resurrect (`CMSG_SPIRIT_HEALER_ACTIVATE`) over the coordinator connection: the - /// module res's the caller in place at 50% + applies Resurrection Sickness if it's a ghost. - /// `gw_spirit_res` takes no `healer_guid`. - pub fn spirit_healer_res( - &self, - _account_id: u64, - actor_guid: u64, - _healer_guid: u64, - ) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("spirit_healer_res: actor_guid unresolved"))?; + /// `gw_spirit_res` takes no healer guid: the Module revives the ghost in place. + fn spirit_healer_res(&self, actor: Actor, _healer_guid: u64) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, @@ -128,4 +54,17 @@ impl Coordinator { gw_spirit_res_then(self.session_actor(actor)) ) } + + /// Reads the corpse from the privileged cache. `None` when the owner has no corpse. + fn corpse_location(&self, owner_guid: u64) -> Result> { + Ok(self + .0 + .coord() + .conn + .db + .game_corpse() + .iter() + .find(|c| c.owner_guid == owner_guid) + .map(|c| (c.map_id, c.x, c.y, c.z))) + } } diff --git a/gateway/src/world/death_tests.rs b/gateway/src/world/death_tests.rs index 39e4700df..e8c7e679b 100644 --- a/gateway/src/world/death_tests.rs +++ b/gateway/src/world/death_tests.rs @@ -172,3 +172,20 @@ fn resurrect_response(status: u8) -> ClientOpcodeMessage { status, })) } + +#[test] +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, + &store, + &mut in_world_conn(), + ClientOpcodeMessage::CMSG_REPOP_REQUEST, + ); + + assert!(result.is_err()); +} diff --git a/gateway/src/world/handlers/death.rs b/gateway/src/world/handlers/death.rs index ac5f56db0..ac3dbe179 100644 --- a/gateway/src/world/handlers/death.rs +++ b/gateway/src/world/handlers/death.rs @@ -7,26 +7,26 @@ pub(crate) trait DeathStore: Send + Sync { /// Revive the caller after death (`CMSG_REPOP_REQUEST` / Release Spirit): the module /// restores full health in place and clears the dead state (the client leaves the death screen /// once the restored health replicates). - fn repop(&self, account_id: u64, self_guid: u64) -> Result<()>; + fn repop(&self, actor: Actor) -> Result<()>; /// Reclaim the caller's corpse (`CMSG_RECLAIM_CORPSE`): the module validates the caller /// is a ghost owning the corpse, in range, past the reclaim delay, then resurrects at 50%. - fn reclaim_corpse(&self, account_id: u64, self_guid: u64, corpse_guid: u64) -> Result<()>; + fn reclaim_corpse(&self, actor: Actor, corpse_guid: u64) -> Result<()>; /// Answer a pending resurrect offer (`CMSG_RESURRECT_RESPONSE`): `accept=true` revives the /// caller at the offer's frozen `%`; either way the offer is consumed. A failure (no pending offer /// for the caller) is expected when the offer already lapsed/was answered — per-action, log + ignore. - fn resurrect_response(&self, account_id: u64, self_guid: u64, accept: bool) -> Result<()>; + fn resurrect_response(&self, actor: Actor, accept: bool) -> Result<()>; /// Use the caller's Self-Resurrection Option (`CMSG_SELF_RES`): the module revives the dead /// caller in place and spends the option. A Refusal (alive, or no option) is expected after a /// race. Per-action: log and ignore. - fn self_resurrect(&self, account_id: u64, self_guid: u64) -> Result<()>; + fn self_resurrect(&self, actor: Actor) -> Result<()>; /// Spirit-Healer resurrect (`CMSG_SPIRIT_HEALER_ACTIVATE`): a ghost activates the graveyard Spirit /// Healer to res IN PLACE at 50% health/mana + a Resurrection Sickness debuff. `healer_guid` is the /// activated healer's guid (passed through to the confirm echo). The module gates on ghost state. - fn spirit_healer_res(&self, account_id: u64, self_guid: u64, healer_guid: u64) -> Result<()>; + fn spirit_healer_res(&self, actor: Actor, healer_guid: u64) -> Result<()>; /// Find `owner_guid`'s corpse location `(map_id, x, y, z)` for `MSG_CORPSE_QUERY`. fn corpse_location(&self, owner_guid: u64) -> Result>; diff --git a/gateway/src/world/test_support/world_fake/death.rs b/gateway/src/world/test_support/world_fake/death.rs index 8b1561cb8..c80e7de67 100644 --- a/gateway/src/world/test_support/world_fake/death.rs +++ b/gateway/src/world/test_support/world_fake/death.rs @@ -3,28 +3,23 @@ use super::super::*; /// Death requests are tested against `HandleLootFake`. A World Session on this Fake answers /// every one with success and no effect. impl DeathStore for WorldFake { - fn repop(&self, _account_id: u64, _self_guid: u64) -> Result<()> { + fn repop(&self, _actor: Actor) -> Result<()> { Ok(()) } - fn reclaim_corpse(&self, _account_id: u64, _self_guid: u64, _corpse_guid: u64) -> Result<()> { + fn reclaim_corpse(&self, _actor: Actor, _corpse_guid: u64) -> Result<()> { Ok(()) } - fn resurrect_response(&self, _account_id: u64, _self_guid: u64, _accept: bool) -> Result<()> { + fn resurrect_response(&self, _actor: Actor, _accept: bool) -> Result<()> { Ok(()) } - fn self_resurrect(&self, _account_id: u64, _self_guid: u64) -> Result<()> { + fn self_resurrect(&self, _actor: Actor) -> Result<()> { Ok(()) } - fn spirit_healer_res( - &self, - _account_id: u64, - _self_guid: u64, - _healer_guid: u64, - ) -> Result<()> { + fn spirit_healer_res(&self, _actor: Actor, _healer_guid: u64) -> Result<()> { Ok(()) } From c46586cbb15c9e647da0fbae2f9008ee23c6f8f1 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 18:16:10 +0200 Subject: [PATCH 07/33] refactor(gateway): collapse the loot window Store to one hop Co-Authored-By: Claude Sonnet 5.5 --- gateway/src/stdb/store/loot_window.rs | 214 +++++------------- .../test_support/world_fake/loot_window.rs | 27 +-- 2 files changed, 63 insertions(+), 178 deletions(-) diff --git a/gateway/src/stdb/store/loot_window.rs b/gateway/src/stdb/store/loot_window.rs index e39f8600e..bc5960b04 100644 --- a/gateway/src/stdb/store/loot_window.rs +++ b/gateway/src/stdb/store/loot_window.rs @@ -1,79 +1,36 @@ //! `Coordinator`'s [`LootWindowStore`] adapter. -use anyhow::{anyhow, Result}; +use anyhow::Result; use lyracore_shared::group::GroupKind; use lyracore_shared::loot::{LootBoundaryFailure, LootRefusal}; use spacetimedb_sdk::Table; use crate::codec; use crate::stdb::bindings::*; -use crate::stdb::connection::{call_reducer, reducer_refusal_reason, LiveConn}; +use crate::stdb::connection::{call_reducer, LiveConn}; use crate::stdb::reads::player_item_count; -use crate::stdb::Coordinator; +use crate::stdb::{classify, Coordinator, DurableFailure}; use crate::world::{Actor, LootWindowRefusal, LootWindowRequestStatus, LootWindowStore}; -impl LootWindowStore for crate::stdb::Coordinator { +impl LootWindowStore for Coordinator { + /// Reads a corpse's lootable copper for `SMSG_LOOT_RESPONSE` from the privileged cache. + /// Returns 0 if the target is missing or not a corpse — the client only sends `CMSG_LOOT` on a + /// lootable corpse, but we stay defensive (an empty loot window is harmless). fn loot_target_money(&self, target_guid: u64) -> Result { - crate::stdb::Coordinator::loot_target_money(self, target_guid) - } - - fn loot_target_items( - &self, - target_guid: u64, - viewer_guid: u64, - ) -> Result> { - crate::stdb::Coordinator::corpse_loot(self, target_guid, viewer_guid) - } - - fn use_gameobject( - &self, - account_id: u64, - actor_guid: u64, - target_guid: u64, - ) -> Result { - crate::stdb::Coordinator::use_gameobject(self, account_id, actor_guid, target_guid) - } - - fn open_creature_loot( - &self, - account_id: u64, - actor_guid: u64, - corpse_guid: u64, - ) -> Result { - crate::stdb::Coordinator::open_creature_loot(self, account_id, actor_guid, corpse_guid) - } - - fn skin_corpse( - &self, - account_id: u64, - actor_guid: u64, - target_guid: u64, - ) -> Result { - crate::stdb::Coordinator::skin_corpse(self, account_id, actor_guid, target_guid) - } - - fn loot_money( - &self, - account_id: u64, - actor_guid: u64, - target_guid: u64, - ) -> Result { - crate::stdb::Coordinator::loot_money(self, account_id, actor_guid, target_guid) - } - - fn take_loot( - &self, - account_id: u64, - actor_guid: u64, - target_guid: u64, - loot_slot: u8, - ) -> Result { - crate::stdb::Coordinator::take_loot(self, account_id, actor_guid, target_guid, loot_slot) + Ok(self + .0 + .coord() + .conn + .db + .game_world_entity() + .guid() + .find(&target_guid) + .filter(|e| e.dead) + .map(|e| e.money) + .unwrap_or(0)) } -} -impl Coordinator { - /// Read a corpse's item loot for the loot window, joined with each item's + /// Reads a corpse's item loot for the loot window, joined with each item's /// template for the display id, then filtered PER VIEWER for `quest_only` rows (quest items are /// per-looter, not gated on whoever got kill credit) AND group-loot rows (a live NEED/GREED /// roll is withheld from EVERYONE; a round-robin/master-designated row is visible only to its @@ -81,17 +38,17 @@ impl Coordinator { /// `corpse_guid` (iterate+filter, like the other row queries). Returns `(slot, item_id, count, /// display_id)` triples for `build_loot_response_raw`. An item whose template isn't loaded falls /// back to display 0 (the client still resolves the name via query). - pub fn corpse_loot( + fn loot_target_items( &self, - corpse_guid: u64, + target_guid: u64, viewer_guid: u64, - ) -> Result> { + ) -> Result> { let guard = self.0.coord(); let db = &guard.conn.db; - let mut items: Vec = db + let mut items: Vec = db .game_corpse_loot() .iter() - .filter(|l| l.corpse_guid == corpse_guid) + .filter(|l| l.corpse_guid == target_guid) .filter(|l| { if l.quest_only { let needs = viewer_needs_quest_item(&guard, viewer_guid, l.item_entry); @@ -126,34 +83,9 @@ impl Coordinator { Ok(items) } - /// Read a corpse's lootable copper for `SMSG_LOOT_RESPONSE` from the privileged cache. - /// Returns 0 if the target is missing or not a corpse — the client only sends `CMSG_LOOT` on a - /// lootable corpse, but we stay defensive (an empty loot window is harmless). - pub fn loot_target_money(&self, target_guid: u64) -> Result { - Ok(self - .0 - .coord() - .conn - .db - .game_world_entity() - .guid() - .find(&target_guid) - .filter(|e| e.dead) - .map(|e| e.money) - .unwrap_or(0)) - } - - /// Use a gameobject (`CMSG_GAMEOBJ_USE`) — a chest rolls its loot into the corpse-loot table keyed - /// on the GO guid, a quest-use object grants quest credit. The module gates range + type. - /// Rides the coordinator connection as `gw_use_gameobject`. - pub fn use_gameobject( - &self, - _account_id: u64, - actor_guid: u64, - go_guid: u64, - ) -> Result { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("use_gameobject: actor_guid unresolved"))?; + /// A chest rolls its loot into the corpse-loot table keyed on the GO guid, a quest-use object + /// grants quest credit. The module gates range + type. + fn use_gameobject(&self, actor: Actor, go_guid: u64) -> Result { let coord = self.0.call_pipe(); legacy_loot_request_status(call_reducer!( coord.conn.reducers, @@ -162,75 +94,51 @@ impl Coordinator { )) } - /// Take the money from a corpse (`CMSG_LOOT_MONEY`) over the coordinator connection so - /// the module attributes the loot to the caller (as `gw_loot_money`). - pub fn loot_money( + /// Authorizes opening a creature corpse before the Gateway reads its loot rows. + fn open_creature_loot( &self, - _account_id: u64, - actor_guid: u64, - target_guid: u64, + actor: Actor, + corpse_guid: u64, ) -> Result { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("loot_money: actor_guid unresolved"))?; let coord = self.0.call_pipe(); strict_loot_request_status(call_reducer!( coord.conn.reducers, - "gw_loot_money", - gw_loot_money_then(self.session_actor(actor), target_guid) + "gw_open_creature_loot", + gw_open_creature_loot_then(self.session_actor(actor), corpse_guid) )) } - /// Authorize opening a creature corpse before the Gateway reads its loot rows. - pub fn open_creature_loot( - &self, - _account_id: u64, - actor_guid: u64, - corpse_guid: u64, - ) -> Result { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("open_creature_loot: actor_guid unresolved"))?; + fn skin_corpse(&self, actor: Actor, corpse_guid: u64) -> Result { let coord = self.0.call_pipe(); - strict_loot_request_status(call_reducer!( + legacy_loot_request_status(call_reducer!( coord.conn.reducers, - "gw_open_creature_loot", - gw_open_creature_loot_then(self.session_actor(actor), corpse_guid) + "gw_skin", + gw_skin_then(self.session_actor(actor), corpse_guid) )) } - /// Take one item from the open corpse into the backpack (`CMSG_AUTOSTORE_LOOT_ITEM`) over - /// the coordinator connection so the module attributes the loot to the caller. The module moves the - /// item into a free slot + deletes the corpse-loot row (the inventory relay then shows it in the bag). - /// Rides the coordinator connection as `gw_take_loot`. - pub fn take_loot( - &self, - _account_id: u64, - actor_guid: u64, - corpse_guid: u64, - loot_slot: u8, - ) -> Result { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("take_loot: actor_guid unresolved"))?; + fn loot_money(&self, actor: Actor, target_guid: u64) -> Result { let coord = self.0.call_pipe(); strict_loot_request_status(call_reducer!( coord.conn.reducers, - "gw_take_loot", - gw_take_loot_then(self.session_actor(actor), corpse_guid, loot_slot) + "gw_loot_money", + gw_loot_money_then(self.session_actor(actor), target_guid) )) } - pub fn skin_corpse( + /// The module moves the item into a free slot and deletes the corpse-loot row; the inventory + /// relay then shows it in the bag. + fn take_loot( &self, - _account_id: u64, - actor_guid: u64, + actor: Actor, corpse_guid: u64, + loot_slot: u8, ) -> Result { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("skin_corpse: actor_guid unresolved"))?; let coord = self.0.call_pipe(); - legacy_loot_request_status(call_reducer!( + strict_loot_request_status(call_reducer!( coord.conn.reducers, - "gw_skin", - gw_skin_then(self.session_actor(actor), corpse_guid) + "gw_take_loot", + gw_take_loot_then(self.session_actor(actor), corpse_guid, loot_slot) )) } } @@ -396,8 +304,8 @@ fn loot_request_status( ) -> Result { match result { Ok(()) => Ok(LootWindowRequestStatus::Applied), - Err(error) => match reducer_refusal_reason(&error) { - Some(reason) => { + Err(error) => match classify(&error) { + DurableFailure::Refusal { reason } => { if LootBoundaryFailure::parse_tag(reason).is_some() { return Err(error); } @@ -418,7 +326,7 @@ fn loot_request_status( UntaggedLootRejection::Fatal => Err(error), } } - None => Err(error), + DurableFailure::TransportLoss => Err(error), }, } } @@ -577,11 +485,10 @@ mod loot_reducer_tests { } fn rejected(reason: &str) -> Result<()> { - Err(anyhow::Error::from(ReducerCallError::Rejected { - operation: "gw_take_loot".to_string(), - reason: reason.to_string(), - }) - .context("loot window")) + Err( + anyhow::Error::from(ReducerCallError::refused("gw_take_loot", reason)) + .context("loot window"), + ) } #[test] @@ -635,15 +542,10 @@ mod loot_reducer_tests { } #[test] - fn a_timeout_is_not_answered_as_a_refusal() { + fn a_transport_loss_is_not_answered_as_a_refusal() { let not_refusals = [ - anyhow::Error::from(ReducerCallError::fatal( - "gw_take_loot reducer timed out after 10s".to_string(), - )), - anyhow::Error::from(ReducerCallError::fatal( - "gw_loot_money reducer failed: transport disconnected".to_string(), - )), - anyhow!( + anyhow::Error::from(ReducerCallError::transport_lost("gw_loot_money")), + anyhow::anyhow!( "wrapped text that mentions {}", LootRefusal::LootTagIneligible.as_tag() ), diff --git a/gateway/src/world/test_support/world_fake/loot_window.rs b/gateway/src/world/test_support/world_fake/loot_window.rs index 0d307c69d..9494d64c7 100644 --- a/gateway/src/world/test_support/world_fake/loot_window.rs +++ b/gateway/src/world/test_support/world_fake/loot_window.rs @@ -33,12 +33,7 @@ impl LootWindowStore for WorldFake { .unwrap_or_default()) } - fn use_gameobject( - &self, - _account_id: u64, - _actor_guid: u64, - target_guid: u64, - ) -> Result { + fn use_gameobject(&self, _actor: Actor, target_guid: u64) -> Result { self.loot_window .gameobjects_used .lock() @@ -49,28 +44,17 @@ impl LootWindowStore for WorldFake { fn open_creature_loot( &self, - _account_id: u64, - _actor_guid: u64, + _actor: Actor, _corpse_guid: u64, ) -> Result { Ok(LootWindowRequestStatus::Applied) } - fn skin_corpse( - &self, - _account_id: u64, - _actor_guid: u64, - _target_guid: u64, - ) -> Result { + fn skin_corpse(&self, _actor: Actor, _target_guid: u64) -> Result { Ok(LootWindowRequestStatus::Applied) } - fn loot_money( - &self, - _account_id: u64, - _actor_guid: u64, - target_guid: u64, - ) -> Result { + fn loot_money(&self, _actor: Actor, target_guid: u64) -> Result { self.loot_window .money_looted .lock() @@ -81,8 +65,7 @@ impl LootWindowStore for WorldFake { fn take_loot( &self, - _account_id: u64, - _actor_guid: u64, + _actor: Actor, target_guid: u64, loot_slot: u8, ) -> Result { From 21aba9861ea40d0ea3b6842f6951be720728aa75 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 18:16:10 +0200 Subject: [PATCH 08/33] refactor(gateway): collapse the loot roll Store to one hop Co-Authored-By: Claude Sonnet 5.5 --- gateway/src/stdb/store/loot_roll.rs | 304 ++++++------------ gateway/src/world/loot.rs | 39 +-- gateway/src/world/loot_tests.rs | 20 +- .../test_support/world_fake/loot_roll.rs | 63 ++-- 4 files changed, 153 insertions(+), 273 deletions(-) diff --git a/gateway/src/stdb/store/loot_roll.rs b/gateway/src/stdb/store/loot_roll.rs index 7be66d864..7bb173481 100644 --- a/gateway/src/stdb/store/loot_roll.rs +++ b/gateway/src/stdb/store/loot_roll.rs @@ -1,53 +1,48 @@ //! `Coordinator`'s [`LootRollStore`] adapter. -use anyhow::{anyhow, Result}; +use anyhow::Result; use lyracore_shared::loot::LootRefusal; use spacetimedb_sdk::Table; use crate::stdb::bindings::*; -use crate::stdb::connection::{call_reducer, reducer_refusal_reason}; -use crate::stdb::Coordinator; +use crate::stdb::connection::call_reducer; +use crate::stdb::{classify, Coordinator, DurableFailure}; use crate::world::loot::{LootRollStore, PendingLootRoll}; use crate::world::{Actor, LootActionStatus}; impl LootRollStore for Coordinator { - fn loot_roll( - &self, - account_id: u64, - self_guid: u64, - corpse_guid: u64, - loot_slot: u32, - vote: u8, - ) -> Result { - self.loot_roll(account_id, self_guid, corpse_guid, loot_slot, vote) - } - - fn realm_loot_op( + /// Promotes a world shard's staging roll onto the database THIS handle points at (Realm-core). + /// Through the coordinator connection, not a player's: the reducer is operator-gated because + /// Realm-core has no live entity to derive an Actor from. `recipients` are the spatial snapshot + /// a world shard computed at kill time. + #[allow(clippy::too_many_arguments)] + fn realm_loot_start( &self, - op: u8, corpse_guid: u64, slot: u8, item_entry: u32, - actor_guid: u64, - vote: u8, deadline_micros: i64, recipients: Vec, random_property_id: u32, promotion_source: spacetimedb_sdk::Identity, source_roll_id: u64, ) -> Result<()> { - self.realm_loot_op( - op, - corpse_guid, - slot, - item_entry, - actor_guid, - vote, - deadline_micros, - recipients, - random_property_id, - promotion_source, - source_roll_id, + call_reducer!( + self.0.call_pipe().conn.reducers, + "realm_loot_op", + realm_loot_op_then( + lyracore_shared::loot_roll::loot_op::START, + corpse_guid, + slot, + item_entry, + self.owner_actor(), + 0, + deadline_micros, + recipients, + random_property_id, + promotion_source, + source_roll_id + ) ) } @@ -55,41 +50,28 @@ impl LootRollStore for Coordinator { &self, corpse_guid: u64, slot: u8, - actor_guid: u64, + actor: Actor, vote: u8, ) -> Result { - self.realm_loot_vote(corpse_guid, slot, actor_guid, vote) - } - - fn pending_local_rolls(&self) -> Result> { - self.pending_local_rolls() - } - - fn settle_loot_roll(&self, corpse_guid: u64, slot: u8, winner_guid: u64) -> Result<()> { - self.settle_loot_roll(corpse_guid, slot, winner_guid) - } - - fn clear_promoted_loot_roll(&self, roll_id: u64) -> Result<()> { - self.clear_promoted_loot_roll(roll_id) - } - - fn loot_won_since(&self, after_id: u64) -> Result<(u64, Vec<(u64, u8, u64)>)> { - self.loot_won_since(after_id) - } - - fn loot_master_give( - &self, - account_id: u64, - self_guid: u64, - corpse_guid: u64, - loot_slot: u8, - target_guid: u64, - ) -> Result { - self.loot_master_give(account_id, self_guid, corpse_guid, loot_slot, target_guid) + loot_action_status(call_reducer!( + self.0.call_pipe().conn.reducers, + "realm_loot_op", + realm_loot_op_then( + lyracore_shared::loot_roll::loot_op::VOTE, + corpse_guid, + slot, + 0, + self.session_actor(actor), + vote, + 0, + Vec::new(), + 0, + spacetimedb_sdk::Identity::ZERO, + 0 + ) + )) } -} -impl Coordinator { /// Every UNRESOLVED `game_loot_roll` row on THIS handle's database, joined with its votes. /// /// A cache read, like [`group_roster`](Self::group_roster) — this table is now part of the @@ -97,7 +79,7 @@ impl Coordinator { /// needed. Meaningful only on a WORLD SHARD in a sharded deployment: realm-core never has a row /// here that a world shard wrote (only its own `realm_loot_op` START arm inserts there), and on /// an unsharded gateway the relay that calls this never runs at all (`realm_store()` is `None`). - pub fn pending_local_rolls(&self) -> Result> { + fn pending_local_rolls(&self) -> Result> { let guard = self.0.coord(); let db = &guard.conn.db; Ok(db @@ -111,7 +93,7 @@ impl Coordinator { .filter(|v| v.roll_id == r.id) .map(|v| v.voter_guid) .collect(); - crate::world::loot::PendingLootRoll { + PendingLootRoll { roll_id: r.id, corpse_guid: r.corpse_guid, slot: r.slot, @@ -125,18 +107,35 @@ impl Coordinator { .collect()) } - // The return tuple is the poll result: the new watermark plus the `(corpse, slot, winner)` triples read since the old one. - #[allow(clippy::type_complexity)] - /// Every `ROLL_WON` `game_group_event` row on THIS handle's database with `id > after_id`, - /// decoded to `(corpse_guid, slot, winner_guid)`, plus the new high-water mark (the max id seen, - /// or `after_id` unchanged if none). Meaningful on the **realm-core** handle: that is the only - /// database `resolve_roll`/`force_resolve_rolls_for_disband` push a `ROLL_WON` event on in a - /// sharded deployment (voting is routed there exclusively — `world::loot::run_vote`). + /// Operator-gated, coordinator connection; the loot-roll relay calls it on every connected + /// world shard after observing realm-core's `ROLL_WON` event — the module's own `withheld` + /// guard makes a wrong-shard call a harmless no-op. + fn settle_loot_roll(&self, corpse_guid: u64, slot: u8, winner_guid: u64) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "settle_loot_roll", + settle_loot_roll_then(corpse_guid, slot, winner_guid, self.owner_actor()) + ) + } + + fn clear_promoted_loot_roll(&self, roll_id: u64) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "clear_promoted_loot_roll", + clear_promoted_loot_roll_then(roll_id, self.owner_actor()) + ) + } + + /// Reads every `ROLL_WON` `game_group_event` row on THIS handle's database with `id > + /// after_id`, decoded to `(corpse_guid, slot, winner_guid)`, plus the new high-water mark (the + /// max id seen, or `after_id` unchanged if none). Meaningful on the **realm-core** handle: that + /// is the only database `resolve_roll`/`force_resolve_rolls_for_disband` push a `ROLL_WON` + /// event on in a sharded deployment (voting is routed there exclusively — `world::loot::run_vote`). /// /// An unparseable payload is skipped + logged rather than failing the whole scan — the module /// writes this grammar, so a decode failure here means the two crates' `lyracore_shared::loot_roll` /// copies have drifted, not that this event is meaningless. - pub fn loot_won_since(&self, after_id: u64) -> Result<(u64, Vec<(u64, u8, u64)>)> { + fn loot_won_since(&self, after_id: u64) -> Result<(u64, Vec<(u64, u8, u64)>)> { use lyracore_shared::loot_roll::event_kind as roll_kind; let guard = self.0.coord(); let db = &guard.conn.db; @@ -159,19 +158,15 @@ impl Coordinator { Ok((watermark, wins)) } - /// `CMSG_LOOT_ROLL` — record the caller's need/greed/pass vote on a - /// live roll. Live votes/roll numbers relay to every eligible member via the `game_group_event` - /// roll-kind rows (`stdb/subscriptions.rs`). - pub fn loot_roll( + /// Records the caller's need/greed/pass vote on a live roll. Live votes and roll numbers relay + /// to every eligible member via the `game_group_event` roll-kind rows (`stdb/subscriptions.rs`). + fn loot_roll( &self, - _account_id: u64, - actor_guid: u64, + actor: Actor, corpse_guid: u64, loot_slot: u32, vote: u8, ) -> Result { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("loot_roll: actor_guid unresolved"))?; let coord = self.0.call_pipe(); loot_action_status(call_reducer!( coord.conn.reducers, @@ -180,18 +175,13 @@ impl Coordinator { )) } - /// `CMSG_LOOT_MASTER_GIVE` — the master looter assigns an above- - /// threshold row to `target_guid`. - pub fn loot_master_give( + fn loot_master_give( &self, - _account_id: u64, - actor_guid: u64, + actor: Actor, corpse_guid: u64, loot_slot: u8, target_guid: u64, ) -> Result { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("loot_master_give: actor_guid unresolved"))?; let coord = self.0.call_pipe(); loot_action_status(call_reducer!( coord.conn.reducers, @@ -204,111 +194,22 @@ impl Coordinator { ) )) } - - /// `realm_loot_op` — one loot-roll op against the database THIS handle points at. The - /// gateway calls it on the **realm-core** handle: START promotes a world shard's staging roll, - /// VOTE casts `CMSG_LOOT_ROLL`'s vote. - /// - /// Through the COORDINATOR connection, not the player's: the reducer is operator-gated because - /// it acts on realm-core, which has no live entity to derive an actor from. VOTE's `actor_guid` - /// is the guid this socket authenticated into the world with (`InWorld::self_guid`), never a - /// literal a client supplies; START's `recipients` are the spatial snapshot a world shard already - /// computed at kill time. - #[allow(clippy::too_many_arguments)] - pub fn realm_loot_op( - &self, - op: u8, - corpse_guid: u64, - slot: u8, - item_entry: u32, - actor_guid: u64, - vote: u8, - deadline_micros: i64, - recipients: Vec, - random_property_id: u32, - promotion_source: spacetimedb_sdk::Identity, - source_roll_id: u64, - ) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "realm_loot_op", - realm_loot_op_then( - op, - corpse_guid, - slot, - item_entry, - self.actor_or_owner(actor_guid), - vote, - deadline_micros, - recipients, - random_property_id, - promotion_source, - source_roll_id - ) - ) - } - - /// Cast a realm-core Loot Roll vote and classify only a known typed Refusal as an answer. - pub fn realm_loot_vote( - &self, - corpse_guid: u64, - slot: u8, - actor_guid: u64, - vote: u8, - ) -> Result { - loot_action_status(call_reducer!( - self.0.call_pipe().conn.reducers, - "realm_loot_op", - realm_loot_op_then( - lyracore_shared::loot_roll::loot_op::VOTE, - corpse_guid, - slot, - 0, - self.actor_or_owner(actor_guid), - vote, - 0, - Vec::new(), - 0, - spacetimedb_sdk::Identity::ZERO, - 0 - ) - )) - } - - /// `settle_loot_roll` — grant a resolved roll's item on THIS world shard, if it holds the - /// matching corpse row. Operator-gated, coordinator connection; the loot-roll relay calls - /// it on every connected world shard after observing realm-core's `ROLL_WON` event — the - /// module's own `withheld` guard makes a wrong-shard call a harmless no-op. - pub fn settle_loot_roll(&self, corpse_guid: u64, slot: u8, winner_guid: u64) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "settle_loot_roll", - settle_loot_roll_then(corpse_guid, slot, winner_guid, self.owner_actor()) - ) - } - - /// `clear_promoted_loot_roll` — delete a staging roll's rows on THIS world shard, once the - /// loot-roll relay has promoted it onto realm-core. Operator-gated, coordinator connection. - pub fn clear_promoted_loot_roll(&self, roll_id: u64) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "clear_promoted_loot_roll", - clear_promoted_loot_roll_then(roll_id, self.owner_actor()) - ) - } } -/// The typed answer for Loot Roll and master-loot requests. An unknown rejection or transport -/// failure stays an error with an unknown durable result. +/// The typed answer for Loot Roll and master-loot requests. A Refusal with an unknown tag or a +/// Transport Loss stays an error with an unknown durable result. fn loot_action_status(result: Result<()>) -> Result { match result { Ok(()) => Ok(LootActionStatus::Applied), - Err(error) => match reducer_refusal_reason(&error).and_then(LootRefusal::parse_tag) { - Some(refusal) => { - log::debug!("stdb: loot action refused: {error:#}"); - Ok(LootActionStatus::Refused(refusal)) - } - None => Err(error), + Err(error) => match classify(&error) { + DurableFailure::Refusal { reason } => match LootRefusal::parse_tag(reason) { + Some(refusal) => { + log::debug!("stdb: loot action refused: {error:#}"); + Ok(LootActionStatus::Refused(refusal)) + } + None => Err(error), + }, + DurableFailure::TransportLoss => Err(error), }, } } @@ -318,16 +219,15 @@ mod loot_reducer_tests { use super::*; use crate::stdb::connection::ReducerCallError; - fn rejected(reason: &str) -> Result<()> { - Err(anyhow::Error::from(ReducerCallError::Rejected { - operation: "gw_take_loot".to_string(), - reason: reason.to_string(), - }) - .context("loot window")) + fn refused(reason: &str) -> Result<()> { + Err( + anyhow::Error::from(ReducerCallError::refused("gw_loot_roll", reason)) + .context("loot roll"), + ) } #[test] - fn loot_action_status_keeps_unknown_results_as_errors() { + fn a_known_refusal_tag_is_an_answer() { for refusal in [ LootRefusal::RollUnavailable, LootRefusal::NotMasterLooter, @@ -335,21 +235,19 @@ mod loot_reducer_tests { LootRefusal::RecipientInventoryFull, ] { assert_eq!( - loot_action_status(rejected(refusal.as_tag())).unwrap(), + loot_action_status(refused(refusal.as_tag())).unwrap(), LootActionStatus::Refused(refusal) ); } + } - for error in [ - anyhow::Error::from(ReducerCallError::Rejected { - operation: "gw_loot_roll".to_string(), - reason: "loot:newer_module_refusal".to_string(), - }), - anyhow::Error::from(ReducerCallError::fatal( - "gw_loot_roll reducer timed out after 10s".to_string(), - )), + #[test] + fn an_unknown_refusal_tag_and_a_transport_loss_stay_errors() { + for result in [ + refused("loot:newer_module_refusal"), + Err(ReducerCallError::transport_lost("gw_loot_roll").into()), ] { - assert!(loot_action_status(Err(error)).is_err()); + assert!(loot_action_status(result).is_err()); } } } diff --git a/gateway/src/world/loot.rs b/gateway/src/world/loot.rs index c7391de53..2aecbf077 100644 --- a/gateway/src/world/loot.rs +++ b/gateway/src/world/loot.rs @@ -3,8 +3,7 @@ use crate::world::ShardRoutingStore; use anyhow::Result; -use super::{LootActionStatus, WorldStore}; -use lyracore_shared::loot_roll::loot_op; +use super::{Actor, LootActionStatus, WorldStore}; /// One unresolved loot roll a world shard has created but not yet had promoted onto realm-core — /// [`LootRollStore::pending_local_rolls`]'s answer, and [`relay_tick`]'s promotion input. @@ -26,18 +25,14 @@ pub struct PendingLootRoll { /// Durable Reads and Durable Requests for Loot Rolls: the vote, the master looter's assignment, and /// the relay that promotes each roll onto Realm-core and settles its winner. pub(crate) trait LootRollStore: Send + Sync { - // Mirrors the `realm_loot_op` REDUCER's parameter list 1:1 — this trait is the seam between them, so the shapes have to match. + /// Promote a world shard's staging roll onto the database THIS handle names. Called on the + /// **realm-core** handle; the Gateway acts as the session owner, not as a Character. #[allow(clippy::too_many_arguments)] - /// `realm_loot_op` — run one loot-roll op against the database THIS handle names. Called on - /// the **realm-core** handle: START promotes a world shard's staging roll, VOTE casts a vote. - fn realm_loot_op( + fn realm_loot_start( &self, - op: u8, corpse_guid: u64, slot: u8, item_entry: u32, - actor_guid: u64, - vote: u8, deadline_micros: i64, recipients: Vec, random_property_id: u32, @@ -51,13 +46,13 @@ pub(crate) trait LootRollStore: Send + Sync { &self, corpse_guid: u64, slot: u8, - actor_guid: u64, + actor: Actor, vote: u8, ) -> Result; /// Every UNRESOLVED loot roll this WORLD SHARD has created but not yet had promoted onto /// realm-core — the relay's promotion queue. Empty on realm-core's own handle: nothing is ever - /// created there directly — only `realm_loot_op`'s START arm writes it, and that is not this + /// created there directly — only `realm_loot_start` writes it, and that is not this /// method. fn pending_local_rolls(&self) -> Result>; @@ -80,8 +75,7 @@ pub(crate) trait LootRollStore: Send + Sync { /// `CMSG_LOOT_ROLL` — record the caller's need/greed/pass vote. fn loot_roll( &self, - account_id: u64, - self_guid: u64, + actor: Actor, corpse_guid: u64, loot_slot: u32, vote: u8, @@ -91,18 +85,17 @@ pub(crate) trait LootRollStore: Send + Sync { /// threshold row to `target_guid`. fn loot_master_give( &self, - account_id: u64, - self_guid: u64, + actor: Actor, corpse_guid: u64, loot_slot: u8, target_guid: u64, ) -> Result; } -/// Route `CMSG_LOOT_ROLL` for the session that owns `self_guid`. +/// Route `CMSG_LOOT_ROLL` for the session that owns `actor`. /// /// Unsharded → the pre-realm-core path, verbatim: `loot_roll` on the player's own connection. Sharded → -/// realm-core, where the roll is authoritative once promoted; `actor_guid` is the guid the gateway +/// realm-core, where the roll is authoritative once promoted; `actor` is the Character the gateway /// authenticated for this socket, never the client's own claim (there isn't one — `CMSG_LOOT_ROLL` /// carries no actor field at all, only the roll's own `(corpse_guid, slot)` and the vote). /// @@ -110,17 +103,16 @@ pub(crate) trait LootRollStore: Send + Sync { /// Realm-core refuses a vote for a roll it does not hold, so pending promotions are flushed first. pub(crate) fn run_vote( store: &St, - account_id: u64, - self_guid: u64, + actor: Actor, corpse_guid: u64, slot: u32, vote: u8, ) -> Result { let Some(realm) = store.realm_store() else { - return store.loot_roll(account_id, self_guid, corpse_guid, slot, vote); + return store.loot_roll(actor, corpse_guid, slot, vote); }; flush_pending_promotions(store, realm.as_ref()); - realm.realm_loot_vote(corpse_guid, slot as u8, self_guid, vote) + realm.realm_loot_vote(corpse_guid, slot as u8, actor, vote) } /// Promote ONE world shard's staging roll onto realm-core, then clear the staging copy — the shared @@ -129,13 +121,10 @@ pub(crate) fn run_vote( /// propagated as an error (the caller's own op — a vote route, a party op — must not fail because a /// DIFFERENT roll's promotion did). fn promote_one(shard: &dyn WorldStore, realm: &dyn WorldStore, roll: &PendingLootRoll) { - match realm.realm_loot_op( - loot_op::START, + match realm.realm_loot_start( roll.corpse_guid, roll.slot, roll.item_entry, - 0, - 0, roll.deadline_micros, roll.recipients.clone(), roll.random_property_id, diff --git a/gateway/src/world/loot_tests.rs b/gateway/src/world/loot_tests.rs index 0f1015c97..7727b1f1e 100644 --- a/gateway/src/world/loot_tests.rs +++ b/gateway/src/world/loot_tests.rs @@ -24,8 +24,7 @@ fn an_unsharded_store_routes_the_vote_through_the_players_own_reducer() { let store = WorldFake::default(); loot::run_vote( &store, - 7, - 42, + Actor::new(42).unwrap(), 500, 2, lyracore_shared::loot_roll::vote_kind::NEED, @@ -50,8 +49,7 @@ fn a_sharded_store_routes_the_vote_to_realm_core_with_the_authenticated_guid() { let (realm, world, _instances, _calls) = party_topology(); loot::run_vote( world.as_ref(), - 7, - GINGER, + Actor::new(GINGER).unwrap(), 500, 2, lyracore_shared::loot_roll::vote_kind::GREED, @@ -99,8 +97,7 @@ fn a_vote_on_a_staged_roll_reaches_realm_core_after_its_promotion() { loot::run_vote( world.as_ref(), - 7, - GINGER, + Actor::new(GINGER).unwrap(), 500, 2, lyracore_shared::loot_roll::vote_kind::GREED, @@ -211,7 +208,7 @@ fn relay_tick_promotes_a_staging_roll_and_clears_it() { fn a_refused_promotion_keeps_its_source_identity_and_staging_row_for_retry() { let realm = std::sync::Arc::new(WorldFake { loot_roll: LootRollState { - realm_loot_op_error: Some("another Loot Roll is active in this slot".into()), + realm_loot_op_refusal: Some("another Loot Roll is active in this slot".into()), ..Default::default() }, ..Default::default() @@ -293,7 +290,7 @@ fn a_failed_settlement_retries_after_the_shard_recovers() { ..Default::default() }, loot_roll: LootRollState { - settle_loot_roll_error: Some("Shard connection unavailable".into()), + settle_loot_roll_transport_lost: true, ..Default::default() }, ..Default::default() @@ -530,9 +527,9 @@ fn loot_master_give_refusals_keep_the_world_session_alive() { } #[test] -fn loot_roll_timeout_ends_the_world_session() { +fn loot_roll_transport_loss_ends_the_world_session() { let mut s = quest_store(); - s.loot_roll.loot_action_failure = Some("gw_loot_roll reducer timed out after 10s".to_string()); + s.loot_roll.loot_action_transport_lost = true; let store = std::sync::Arc::new(s); let (mut client, mut c_enc, _c_dec, server) = enter_world(store, 1); CMSG_LOOT_ROLL { @@ -553,8 +550,7 @@ fn loot_roll_timeout_ends_the_world_session() { #[test] fn loot_master_give_transport_failure_ends_the_world_session() { let mut s = quest_store(); - s.loot_roll.loot_action_failure = - Some("gw_loot_master_give reducer transport disconnected".to_string()); + s.loot_roll.loot_action_transport_lost = true; let store = std::sync::Arc::new(s); let (mut client, mut c_enc, _c_dec, server) = enter_world(store, 1); CMSG_LOOT_MASTER_GIVE { diff --git a/gateway/src/world/test_support/world_fake/loot_roll.rs b/gateway/src/world/test_support/world_fake/loot_roll.rs index bd22c508c..b0c3bc7a4 100644 --- a/gateway/src/world/test_support/world_fake/loot_roll.rs +++ b/gateway/src/world/test_support/world_fake/loot_roll.rs @@ -1,4 +1,5 @@ use super::super::*; +use crate::stdb::ReducerCallError; #[derive(Default)] pub(crate) struct LootRollState { @@ -8,9 +9,9 @@ pub(crate) struct LootRollState { pub(crate) loot_master_gives: std::sync::Mutex>, /// Typed gameplay Refusal returned by loot-roll and master-loot action tests. pub(crate) loot_action_refusal: Option, - /// Infrastructure failure returned by loot-roll and master-loot action tests. - pub(crate) loot_action_failure: Option, - /// Records every `realm_loot_op` argument in wire order. The Realm-core handle owns the + /// When set, loot-roll and master-loot requests fail as a Transport Loss. + pub(crate) loot_action_transport_lost: bool, + /// Records every `realm_loot_op` reducer argument in wire order. The Realm-core handle owns the /// recorder so a test can distinguish authority routing from a Shard-local request. #[allow(clippy::type_complexity)] pub(crate) realm_loot_ops: std::sync::Mutex< @@ -28,8 +29,8 @@ pub(crate) struct LootRollState { u64, )>, >, - /// When set, `realm_loot_op` fails with this message. - pub(crate) realm_loot_op_error: Option, + /// When set, `realm_loot_start` is refused with this reason. + pub(crate) realm_loot_op_refusal: Option, /// This WORLD SHARD's staging rolls `pending_local_rolls` answers — the relay's promotion /// INPUT. `Mutex`-wrapped (like `mirror`) so a test can set it AFTER the fixture /// is wrapped in an `Arc` — every existing party/whisper topology builder hands back `Arc`s. @@ -37,8 +38,8 @@ pub(crate) struct LootRollState { pub(crate) pending_rolls: std::sync::Mutex>, /// Recorded `settle_loot_roll` calls on THIS shard — `(corpse_guid, slot, winner_guid)`. pub(crate) settled_rolls: std::sync::Mutex>, - /// When set, `settle_loot_roll` fails with this message. - pub(crate) settle_loot_roll_error: Option, + /// When set, `settle_loot_roll` fails as a Transport Loss. + pub(crate) settle_loot_roll_transport_lost: bool, /// Recorded `clear_promoted_loot_roll` calls on THIS shard — the roll ids the relay told /// this shard's staging copy to forget after a successful promotion. pub(crate) cleared_rolls: std::sync::Mutex>, @@ -53,14 +54,13 @@ pub(crate) struct LootRollState { impl LootRollStore for WorldFake { fn loot_roll( &self, - _account_id: u64, - _self_guid: u64, + _actor: Actor, corpse_guid: u64, loot_slot: u32, vote: u8, ) -> Result { - if let Some(failure) = &self.loot_roll.loot_action_failure { - return Err(anyhow!(failure.clone())); + if self.loot_roll.loot_action_transport_lost { + return Err(ReducerCallError::transport_lost("gw_loot_roll").into()); } if let Some(refusal) = self.loot_roll.loot_action_refusal { return Ok(LootActionStatus::Refused(refusal)); @@ -75,14 +75,13 @@ impl LootRollStore for WorldFake { fn loot_master_give( &self, - _account_id: u64, - _self_guid: u64, + _actor: Actor, corpse_guid: u64, loot_slot: u8, target_guid: u64, ) -> Result { - if let Some(failure) = &self.loot_roll.loot_action_failure { - return Err(anyhow!(failure.clone())); + if self.loot_roll.loot_action_transport_lost { + return Err(ReducerCallError::transport_lost("gw_loot_master_give").into()); } if let Some(refusal) = self.loot_roll.loot_action_refusal { return Ok(LootActionStatus::Refused(refusal)); @@ -96,14 +95,11 @@ impl LootRollStore for WorldFake { } #[allow(clippy::too_many_arguments)] - fn realm_loot_op( + fn realm_loot_start( &self, - op: u8, corpse_guid: u64, slot: u8, item_entry: u32, - actor_guid: u64, - vote: u8, deadline_micros: i64, recipients: Vec, random_property_id: u32, @@ -112,46 +108,47 @@ impl LootRollStore for WorldFake { ) -> Result<()> { self.rec("realm_loot_op"); self.loot_roll.realm_loot_ops.lock().unwrap().push(( - op, + lyracore_shared::loot_roll::loot_op::START, corpse_guid, slot, item_entry, - actor_guid, - vote, + 0, + 0, deadline_micros, recipients, random_property_id, promotion_source, source_roll_id, )); - if let Some(e) = &self.loot_roll.realm_loot_op_error { - return Err(anyhow!("{e}")); + match &self.loot_roll.realm_loot_op_refusal { + Some(reason) => Err(ReducerCallError::refused("realm_loot_op", reason).into()), + None => Ok(()), } - Ok(()) } fn realm_loot_vote( &self, corpse_guid: u64, slot: u8, - actor_guid: u64, + actor: Actor, vote: u8, ) -> Result { - self.realm_loot_op( + self.rec("realm_loot_op"); + self.loot_roll.realm_loot_ops.lock().unwrap().push(( lyracore_shared::loot_roll::loot_op::VOTE, corpse_guid, slot, 0, - actor_guid, + actor.guid(), vote, 0, Vec::new(), 0, spacetimedb_sdk::Identity::ZERO, 0, - )?; - if let Some(failure) = &self.loot_roll.loot_action_failure { - return Err(anyhow!(failure.clone())); + )); + if self.loot_roll.loot_action_transport_lost { + return Err(ReducerCallError::transport_lost("realm_loot_op").into()); } Ok(self .loot_roll @@ -165,8 +162,8 @@ impl LootRollStore for WorldFake { fn settle_loot_roll(&self, corpse_guid: u64, slot: u8, winner_guid: u64) -> Result<()> { self.rec("settle_loot_roll"); - if let Some(e) = &self.loot_roll.settle_loot_roll_error { - return Err(anyhow!("{e}")); + if self.loot_roll.settle_loot_roll_transport_lost { + return Err(ReducerCallError::transport_lost("settle_loot_roll").into()); } self.loot_roll .settled_rolls From cfc8a2fd11a8bbe5166b33de10093f7455742400 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 18:16:10 +0200 Subject: [PATCH 09/33] refactor(gateway): resolve the loot and death Actor once in handle_loot Covers the loot window dispatcher, the loot roll and death arms of handle_loot, and HandleLootFake, which serves all three families. Co-Authored-By: Claude Sonnet 5.5 --- gateway/src/world/handle_loot_fake.rs | 119 ++++--- gateway/src/world/handlers/loot.rs | 489 +++++++++++--------------- 2 files changed, 269 insertions(+), 339 deletions(-) diff --git a/gateway/src/world/handle_loot_fake.rs b/gateway/src/world/handle_loot_fake.rs index 359061f8a..992862c80 100644 --- a/gateway/src/world/handle_loot_fake.rs +++ b/gateway/src/world/handle_loot_fake.rs @@ -3,12 +3,13 @@ //! tests that use it. use crate::codec; +use crate::stdb::ReducerCallError; use crate::world::handlers::{ DeathStore, LootActionStatus, LootWindowRequestStatus, LootWindowStore, NpcStore, }; use crate::world::loot::{LootRollStore, PendingLootRoll}; -use crate::world::{ShardRoutingStore, WorldStore}; -use anyhow::{anyhow, Result}; +use crate::world::{Actor, ShardRoutingStore, WorldStore}; +use anyhow::Result; use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; @@ -30,6 +31,8 @@ pub(crate) struct HandleLootFake { self_res_options: Mutex>, spirit_healers: HashSet, loot_by_viewer: HashMap>, + /// When set, every Durable Request fails as a Transport Loss. + transport_lost: bool, } impl HandleLootFake { @@ -58,6 +61,11 @@ impl HandleLootFake { self } + pub(crate) fn with_transport_loss(mut self) -> Self { + self.transport_lost = true; + self + } + pub(crate) fn with_loot(mut self, viewer: u64, items: Vec) -> Self { self.loot_by_viewer.insert(viewer, items); self @@ -81,39 +89,62 @@ impl HandleLootFake { self.self_res_options.lock().unwrap().contains(&guid) } - fn require(&self, guid: u64, needed: Life) -> Result<()> { + /// The Module refuses a request the character's state does not allow. + fn require(&self, operation: &str, guid: u64, needed: Life) -> Result<()> { + self.reachable(operation)?; match self.life(guid) { life if life == needed => Ok(()), - life => Err(anyhow!("character {guid} is {life:?}, not {needed:?}")), + life => Err(ReducerCallError::refused( + operation, + &format!("character {guid} is {life:?}, not {needed:?}"), + ) + .into()), } } + fn reachable(&self, operation: &str) -> Result<()> { + if self.transport_lost { + return Err(ReducerCallError::transport_lost(operation).into()); + } + Ok(()) + } + fn revive(&self, guid: u64) { self.life.lock().unwrap().insert(guid, Life::Alive); } } impl DeathStore for HandleLootFake { - fn repop(&self, _account_id: u64, self_guid: u64) -> Result<()> { - self.require(self_guid, Life::Dead)?; + fn repop(&self, actor: Actor) -> Result<()> { + let self_guid = actor.guid(); + self.require("gw_repop", self_guid, Life::Dead)?; self.revive(self_guid); Ok(()) } - fn reclaim_corpse(&self, _account_id: u64, self_guid: u64, corpse_guid: u64) -> Result<()> { - self.require(self_guid, Life::Ghost)?; + fn reclaim_corpse(&self, actor: Actor, corpse_guid: u64) -> Result<()> { + let self_guid = actor.guid(); + self.require("gw_reclaim_corpse", self_guid, Life::Ghost)?; if self.corpses.get(&self_guid) != Some(&corpse_guid) { - return Err(anyhow!( - "corpse {corpse_guid} is not character {self_guid}'s" - )); + return Err(ReducerCallError::refused( + "gw_reclaim_corpse", + &format!("corpse {corpse_guid} is not character {self_guid}'s"), + ) + .into()); } self.revive(self_guid); Ok(()) } - fn resurrect_response(&self, _account_id: u64, self_guid: u64, accept: bool) -> Result<()> { + fn resurrect_response(&self, actor: Actor, accept: bool) -> Result<()> { + let self_guid = actor.guid(); + self.reachable("gw_respond_resurrect")?; if !self.res_offers.lock().unwrap().remove(&self_guid) { - return Err(anyhow!("character {self_guid} has no pending offer")); + return Err(ReducerCallError::refused( + "gw_respond_resurrect", + &format!("character {self_guid} has no pending offer"), + ) + .into()); } if accept { self.revive(self_guid); @@ -121,19 +152,29 @@ impl DeathStore for HandleLootFake { Ok(()) } - fn self_resurrect(&self, _account_id: u64, self_guid: u64) -> Result<()> { - self.require(self_guid, Life::Dead)?; + fn self_resurrect(&self, actor: Actor) -> Result<()> { + let self_guid = actor.guid(); + self.require("gw_self_resurrect", self_guid, Life::Dead)?; if !self.self_res_options.lock().unwrap().remove(&self_guid) { - return Err(anyhow!("no Self-Resurrection Option")); + return Err(ReducerCallError::refused( + "gw_self_resurrect", + "no Self-Resurrection Option", + ) + .into()); } self.revive(self_guid); Ok(()) } - fn spirit_healer_res(&self, _account_id: u64, self_guid: u64, healer_guid: u64) -> Result<()> { - self.require(self_guid, Life::Ghost)?; + fn spirit_healer_res(&self, actor: Actor, healer_guid: u64) -> Result<()> { + let self_guid = actor.guid(); + self.require("gw_spirit_res", self_guid, Life::Ghost)?; if !self.spirit_healers.contains(&healer_guid) { - return Err(anyhow!("{healer_guid} is not a Spirit Healer")); + return Err(ReducerCallError::refused( + "gw_spirit_res", + &format!("{healer_guid} is not a Spirit Healer"), + ) + .into()); } self.revive(self_guid); Ok(()) @@ -161,46 +202,29 @@ impl LootWindowStore for HandleLootFake { .unwrap_or_default()) } - fn use_gameobject( - &self, - _account_id: u64, - _actor_guid: u64, - _target_guid: u64, - ) -> Result { + fn use_gameobject(&self, _actor: Actor, _target_guid: u64) -> Result { Ok(LootWindowRequestStatus::Applied) } fn open_creature_loot( &self, - _account_id: u64, - _actor_guid: u64, + _actor: Actor, _corpse_guid: u64, ) -> Result { unreachable!("handle_loot opens no creature loot") } - fn skin_corpse( - &self, - _account_id: u64, - _actor_guid: u64, - _target_guid: u64, - ) -> Result { + fn skin_corpse(&self, _actor: Actor, _target_guid: u64) -> Result { unreachable!("handle_loot skins no corpse") } - fn loot_money( - &self, - _account_id: u64, - _actor_guid: u64, - _target_guid: u64, - ) -> Result { + fn loot_money(&self, _actor: Actor, _target_guid: u64) -> Result { unreachable!("handle_loot takes no money") } fn take_loot( &self, - _account_id: u64, - _actor_guid: u64, + _actor: Actor, _target_guid: u64, _loot_slot: u8, ) -> Result { @@ -275,14 +299,11 @@ impl NpcStore for HandleLootFake { } impl LootRollStore for HandleLootFake { - fn realm_loot_op( + fn realm_loot_start( &self, - _op: u8, _corpse_guid: u64, _slot: u8, _item_entry: u32, - _actor_guid: u64, - _vote: u8, _deadline_micros: i64, _recipients: Vec, _random_property_id: u32, @@ -296,7 +317,7 @@ impl LootRollStore for HandleLootFake { &self, _corpse_guid: u64, _slot: u8, - _actor_guid: u64, + _actor: Actor, _vote: u8, ) -> Result { unreachable!("no test rolls for loot") @@ -320,8 +341,7 @@ impl LootRollStore for HandleLootFake { fn loot_roll( &self, - _account_id: u64, - _self_guid: u64, + _actor: Actor, _corpse_guid: u64, _loot_slot: u32, _vote: u8, @@ -331,8 +351,7 @@ impl LootRollStore for HandleLootFake { fn loot_master_give( &self, - _account_id: u64, - _self_guid: u64, + _actor: Actor, _corpse_guid: u64, _loot_slot: u8, _target_guid: u64, diff --git a/gateway/src/world/handlers/loot.rs b/gateway/src/world/handlers/loot.rs index 2be94c4f2..c0503e723 100644 --- a/gateway/src/world/handlers/loot.rs +++ b/gateway/src/world/handlers/loot.rs @@ -1,6 +1,7 @@ //! Loot-window action dispatch plus the remaining corpse, GameObject, and group-loot handler. use super::super::*; +use super::combat::ignore_refusal; use lyracore_shared::loot::LootRefusal; use wow_world_messages::vanilla::LootMethodError; @@ -12,41 +13,21 @@ pub(crate) trait LootWindowStore: Send + Sync { target_guid: u64, viewer_guid: u64, ) -> Result>; - fn use_gameobject( - &self, - account_id: u64, - actor_guid: u64, - target_guid: u64, - ) -> Result; - fn open_creature_loot( - &self, - account_id: u64, - actor_guid: u64, - corpse_guid: u64, - ) -> Result; - fn skin_corpse( - &self, - account_id: u64, - actor_guid: u64, - target_guid: u64, - ) -> Result; - fn loot_money( - &self, - account_id: u64, - actor_guid: u64, - target_guid: u64, - ) -> Result; + fn use_gameobject(&self, actor: Actor, target_guid: u64) -> Result; + fn open_creature_loot(&self, actor: Actor, corpse_guid: u64) + -> Result; + fn skin_corpse(&self, actor: Actor, target_guid: u64) -> Result; + fn loot_money(&self, actor: Actor, target_guid: u64) -> Result; fn take_loot( &self, - account_id: u64, - actor_guid: u64, + actor: Actor, target_guid: u64, loot_slot: u8, ) -> Result; } -/// How the Module answered a loot Durable Request. A Refusal is an outcome; a timeout or transport -/// failure stays an error and ends the session. +/// How the Module answered a loot Durable Request. A Refusal is an outcome; a Transport Loss stays +/// an error and ends the World Session. pub(crate) enum LootWindowRequestStatus { Applied, Refused(LootWindowRefusal), @@ -112,8 +93,8 @@ pub(crate) struct LootWindowPlayer { } impl LootWindowPlayer { - fn actor_guid(self) -> Option { - self.self_guid.filter(|guid| *guid != 0) + fn actor(self) -> Option { + self.self_guid.and_then(Actor::new) } } @@ -154,6 +135,13 @@ fn refusal_transition( (next_state, refusal_outbound(refusal, target_guid)) } +fn log_refusal(player: LootWindowPlayer, refusal: LootWindowRefusal) { + log::debug!( + "world: loot request refused (account {}): {refusal:?}", + player.account_id + ); +} + fn finish_loot_action(status: LootActionStatus) { if let LootActionStatus::Refused(refusal) = status { log::debug!("world: loot action refused: {}", refusal.as_tag()); @@ -179,7 +167,7 @@ pub(crate) fn dispatch_loot_window( ) -> Result { match msg { ClientOpcodeMessage::CMSG_GAMEOBJ_USE(request) => { - let Some(actor_guid) = player.actor_guid() else { + let Some(actor) = player.actor() else { return Ok(LootWindowOutcome::Handled { next_state: current_state, durable_request: None, @@ -189,8 +177,9 @@ pub(crate) fn dispatch_loot_window( let target_guid = request.guid.guid(); let durable_request = Some(LootWindowDurableRequest::UseGameObject { target_guid }); if let LootWindowRequestStatus::Refused(refusal) = - store.use_gameobject(player.account_id, actor_guid, target_guid)? + 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 { @@ -199,7 +188,7 @@ pub(crate) fn dispatch_loot_window( outbound, }); } - let items = store.loot_target_items(target_guid, actor_guid)?; + let items = store.loot_target_items(target_guid, actor.guid())?; if items.is_empty() { return Ok(LootWindowOutcome::Handled { next_state: current_state, @@ -217,7 +206,7 @@ pub(crate) fn dispatch_loot_window( }) } ClientOpcodeMessage::CMSG_LOOT(request) => { - let Some(viewer_guid) = player.actor_guid() else { + let Some(viewer) = player.actor() else { return Ok(LootWindowOutcome::Handled { next_state: current_state, durable_request: None, @@ -227,8 +216,9 @@ pub(crate) fn dispatch_loot_window( let target_guid = request.guid.guid(); let open_request = LootWindowDurableRequest::OpenCreature { target_guid }; if let LootWindowRequestStatus::Refused(refusal) = - store.open_creature_loot(player.account_id, viewer_guid, target_guid)? + 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 { @@ -238,10 +228,10 @@ pub(crate) fn dispatch_loot_window( }); } let money = store.loot_target_money(target_guid)?; - let items = store.loot_target_items(target_guid, viewer_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(player.account_id, viewer_guid, target_guid)?; + store.skin_corpse(viewer, target_guid)?; Some(LootWindowDurableRequest::SkinCreature { target_guid }) } else { Some(open_request) @@ -256,8 +246,7 @@ pub(crate) fn dispatch_loot_window( }) } ClientOpcodeMessage::CMSG_LOOT_MONEY => { - let (Some(actor_guid), Some(target_guid)) = - (player.actor_guid(), current_state.target_guid) + let (Some(actor), Some(target_guid)) = (player.actor(), current_state.target_guid) else { return Ok(LootWindowOutcome::Handled { next_state: current_state, @@ -266,16 +255,16 @@ pub(crate) fn dispatch_loot_window( }); }; let durable_request = LootWindowDurableRequest::TakeMoney { target_guid }; - let (next_state, outbound) = - match store.loot_money(player.account_id, actor_guid, target_guid)? { - LootWindowRequestStatus::Applied => ( - current_state, - vec![Outbound::One(ServerOpcodeMessage::SMSG_LOOT_CLEAR_MONEY)], - ), - LootWindowRequestStatus::Refused(refusal) => { - refusal_transition(refusal, current_state, 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) + } + }; Ok(LootWindowOutcome::Handled { next_state, durable_request: Some(durable_request), @@ -283,8 +272,7 @@ pub(crate) fn dispatch_loot_window( }) } ClientOpcodeMessage::CMSG_AUTOSTORE_LOOT_ITEM(request) => { - let (Some(actor_guid), Some(target_guid)) = - (player.actor_guid(), current_state.target_guid) + let (Some(actor), Some(target_guid)) = (player.actor(), current_state.target_guid) else { return Ok(LootWindowOutcome::Handled { next_state: current_state, @@ -296,22 +284,19 @@ pub(crate) fn dispatch_loot_window( target_guid, loot_slot: request.item_slot, }; - let (next_state, outbound) = match store.take_loot( - player.account_id, - actor_guid, - 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) => { - refusal_transition(refusal, current_state, target_guid) - } - }; + 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(player, refusal); + refusal_transition(refusal, current_state, target_guid) + } + }; Ok(LootWindowOutcome::Handled { next_state, durable_request: Some(durable_request), @@ -341,6 +326,7 @@ pub(crate) fn handle_loot< 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 @@ -351,16 +337,12 @@ pub(crate) fn handle_loot< 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 guid it authorizes as the voter must - // be the one THIS socket authenticated with, never a literal from the packet. - let self_guid = match &conn.state { - WorldState::InWorld(iw) => iw.self_guid, - WorldState::CharSelect => 0, - }; + // 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, - conn.account_id, - self_guid, + actor, corpse_guid, c.item_slot, vote, @@ -369,22 +351,22 @@ pub(crate) fn handle_loot< 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( - conn.account_id, - social::self_guid(conn).unwrap_or(0), + actor, corpse_guid, c.slot_id, target_guid, )?); } ClientOpcodeMessage::CMSG_GAMEOBJ_USE(request) => { - let Some(actor_guid) = social::self_guid(conn).filter(|guid| *guid != 0) else { + let Some(actor) = actor else { return Ok(None); }; let target_guid = request.guid.guid(); - match store.use_gameobject(conn.account_id, actor_guid, target_guid)? { + match store.use_gameobject(actor, target_guid)? { LootWindowRequestStatus::Applied => { - let items = store.loot_target_items(target_guid, actor_guid)?; + 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 { @@ -443,12 +425,8 @@ pub(crate) fn handle_loot< // 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 => { - let self_guid = match &conn.state { - WorldState::InWorld(iw) => iw.self_guid, - WorldState::CharSelect => 0, - }; - if let Err(e) = store.repop(conn.account_id, self_guid) { - log::debug!("world: repop ignored (account {}): {e}", conn.account_id); + 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 @@ -468,15 +446,9 @@ pub(crate) fn handle_loot< // 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 Err(e) = store.reclaim_corpse( - conn.account_id, - social::self_guid(conn).unwrap_or(0), - r.guid.guid(), - ) { - log::debug!( - "world: reclaim_corpse ignored (account {}): {e}", - conn.account_id - ); + if let Some(actor) = actor { + let result = store.reclaim_corpse(actor, r.guid.guid()); + ignore_refusal("reclaim_corpse", conn.account_id, result)?; } } // Resurrection accept-prompt response: the dead player answered the SMSG_RESURRECT_REQUEST @@ -485,30 +457,21 @@ pub(crate) fn handle_loot< // 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) => { - let self_guid = match &conn.state { - WorldState::InWorld(iw) => iw.self_guid, - WorldState::CharSelect => 0, - }; - if let Err(e) = store.resurrect_response(conn.account_id, self_guid, r.status != 0) { - log::debug!( - "world: resurrect_response ignored (account {}): {e}", - conn.account_id - ); + if let Some(actor) = actor { + let result = store.resurrect_response(actor, r.status != 0); + ignore_refusal("resurrect_response", conn.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 => { - let self_guid = match &conn.state { - WorldState::InWorld(iw) => iw.self_guid, - WorldState::CharSelect => 0, - }; - if let Err(e) = store.self_resurrect(conn.account_id, self_guid) { - log::debug!( - "world: self_resurrect ignored (account {}): {e}", - conn.account_id - ); + if let Some(actor) = actor { + ignore_refusal( + "self_resurrect", + conn.account_id, + store.self_resurrect(actor), + )?; } } // Spirit-Healer resurrection: a ghost activated the graveyard Spirit Healer (npc_flags @@ -517,22 +480,17 @@ pub(crate) fn handle_loot< // 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) => { - let self_guid = match &conn.state { - WorldState::InWorld(iw) => iw.self_guid, - WorldState::CharSelect => 0, - }; - match store.spirit_healer_res(conn.account_id, self_guid, s.guid.guid()) { - Ok(()) => send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_SPIRIT_HEALER_CONFIRM( - SMSG_SPIRIT_HEALER_CONFIRM { guid: s.guid }, - )), - )?, - Err(e) => { - log::debug!( - "world: spirit_healer_res ignored (account {}): {e}", - conn.account_id - ) + 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 }, + )), + )?; } } } @@ -552,36 +510,52 @@ mod tests { }; use wow_world_messages::Guid; + use crate::stdb::ReducerCallError; + #[derive(Default)] struct InMemoryLootWindow { money: u32, items_by_viewer: HashMap>, money_reads: Mutex>, item_reads: Mutex>, - use_requests: Mutex>, - open_requests: Mutex>, + use_requests: Mutex>, + open_requests: Mutex>, operations: Mutex>, - skin_requests: Mutex>, - money_take_requests: Mutex>, - item_take_requests: Mutex>, + skin_requests: Mutex>, + money_take_requests: Mutex>, + item_take_requests: Mutex>, skin_refusal: Option, - skin_fatal_error: Option, + skin_failure: Option, use_refusal: Option, - use_fatal_error: Option, + use_failure: Option, open_refusal: Option, - open_fatal_error: Option, + open_failure: Option, money_take_refusal: Option, - money_take_fatal_error: Option, + money_take_failure: Option, item_take_refusal: Option, - item_take_fatal_error: Option, + item_take_failure: Option, + } + + /// How a Store call fails before the Store has an answer to hand the handler. + #[derive(Clone, Copy)] + enum Failure { + TransportLost, + /// The Store returns a Module refusal it does not decode, such as a boundary failure. + Refused(&'static str), } fn request_status( + operation: &str, refusal: Option, - fatal_error: &Option, + failure: Option, ) -> Result { - match (fatal_error, refusal) { - (Some(error), _) => Err(anyhow::anyhow!(error.clone())), + match (failure, refusal) { + (Some(Failure::TransportLost), _) => { + Err(ReducerCallError::transport_lost(operation).into()) + } + (Some(Failure::Refused(reason)), _) => { + Err(ReducerCallError::refused(operation, reason).into()) + } (None, Some(refusal)) => Ok(LootWindowRequestStatus::Refused(refusal)), (None, None) => Ok(LootWindowRequestStatus::Applied), } @@ -612,75 +586,79 @@ mod tests { fn use_gameobject( &self, - account_id: u64, - actor_guid: u64, + actor: Actor, target_guid: u64, ) -> Result { self.operations.lock().unwrap().push("use gameobject"); self.use_requests .lock() .unwrap() - .push((account_id, actor_guid, target_guid)); - request_status(self.use_refusal, &self.use_fatal_error) + .push((actor.guid(), target_guid)); + request_status("gw_use_gameobject", self.use_refusal, self.use_failure) } fn open_creature_loot( &self, - account_id: u64, - actor_guid: u64, + actor: Actor, corpse_guid: u64, ) -> Result { self.operations.lock().unwrap().push("open creature loot"); self.open_requests .lock() .unwrap() - .push((account_id, actor_guid, corpse_guid)); - request_status(self.open_refusal, &self.open_fatal_error) + .push((actor.guid(), corpse_guid)); + request_status( + "gw_open_creature_loot", + self.open_refusal, + self.open_failure, + ) } - fn skin_corpse( - &self, - account_id: u64, - actor_guid: u64, - target_guid: u64, - ) -> Result { + fn skin_corpse(&self, actor: Actor, target_guid: u64) -> Result { self.skin_requests .lock() .unwrap() - .push((account_id, actor_guid, target_guid)); - request_status(self.skin_refusal, &self.skin_fatal_error) + .push((actor.guid(), target_guid)); + request_status("gw_skin", self.skin_refusal, self.skin_failure) } - fn loot_money( - &self, - account_id: u64, - actor_guid: u64, - target_guid: u64, - ) -> Result { + fn loot_money(&self, actor: Actor, target_guid: u64) -> Result { self.money_take_requests .lock() .unwrap() - .push((account_id, actor_guid, target_guid)); - request_status(self.money_take_refusal, &self.money_take_fatal_error) + .push((actor.guid(), target_guid)); + request_status( + "gw_loot_money", + self.money_take_refusal, + self.money_take_failure, + ) } fn take_loot( &self, - account_id: u64, - actor_guid: u64, + actor: Actor, target_guid: u64, loot_slot: u8, ) -> Result { - self.item_take_requests.lock().unwrap().push(( - account_id, - actor_guid, - target_guid, - loot_slot, - )); - request_status(self.item_take_refusal, &self.item_take_fatal_error) + self.item_take_requests + .lock() + .unwrap() + .push((actor.guid(), target_guid, loot_slot)); + request_status( + "gw_take_loot", + self.item_take_refusal, + self.item_take_failure, + ) } } + fn assert_transport_loss(error: &anyhow::Error) { + assert_eq!( + crate::stdb::classify(error), + crate::stdb::DurableFailure::TransportLoss + ); + } + fn player() -> LootWindowPlayer { LootWindowPlayer { account_id: 7, @@ -820,10 +798,7 @@ mod tests { store.operations.lock().unwrap().as_slice(), &["use gameobject", "read generated loot"] ); - assert_eq!( - store.use_requests.lock().unwrap().as_slice(), - &[(7, 42, 90)] - ); + assert_eq!(store.use_requests.lock().unwrap().as_slice(), &[(42, 90)]); assert_eq!(store.item_reads.lock().unwrap().as_slice(), &[(90, 42)]); assert!(store.open_requests.lock().unwrap().is_empty()); } @@ -887,7 +862,7 @@ mod tests { #[test] fn fatal_chest_use_failure_propagates_without_reading_loot() { let store = InMemoryLootWindow { - use_fatal_error: Some("transport disconnected".into()), + use_failure: Some(Failure::TransportLost), ..Default::default() }; let current_state = OpenLootState { @@ -898,7 +873,7 @@ mod tests { .err() .expect("fatal GameObject failure was handled"); - assert_eq!(error.to_string(), "transport disconnected"); + assert_transport_loss(&error); assert_eq!( store.operations.lock().unwrap().as_slice(), &["use gameobject"] @@ -910,7 +885,7 @@ mod tests { fn missing_actor_chest_failure_propagates_without_reading_loot() { let failure = lyracore_shared::loot::LootBoundaryFailure::MissingActor.as_tag(); let store = InMemoryLootWindow { - use_fatal_error: Some(failure.into()), + use_failure: Some(Failure::Refused(failure)), ..Default::default() }; @@ -925,7 +900,7 @@ mod tests { .err() .expect("missing Actor failure was handled"); - assert_eq!(error.to_string(), failure); + assert!(error.to_string().contains(failure)); assert_eq!( store.operations.lock().unwrap().as_slice(), &["use gameobject"] @@ -964,10 +939,7 @@ mod tests { Some(LootWindowDurableRequest::OpenCreature { target_guid: 60 }) ); assert_didnt_kill(&outbound, 60); - assert_eq!( - store.open_requests.lock().unwrap().as_slice(), - &[(7, 42, 60)] - ); + assert_eq!(store.open_requests.lock().unwrap().as_slice(), &[(42, 60)]); assert!(store.money_reads.lock().unwrap().is_empty()); assert!(store.item_reads.lock().unwrap().is_empty()); assert!(store.skin_requests.lock().unwrap().is_empty()); @@ -976,7 +948,7 @@ mod tests { #[test] fn creature_open_transport_failure_propagates_without_loot_reads() { let store = InMemoryLootWindow { - open_fatal_error: Some("transport disconnected".into()), + open_failure: Some(Failure::TransportLost), ..Default::default() }; @@ -989,7 +961,7 @@ mod tests { .err() .expect("fatal creature-open failure was handled"); - assert_eq!(error.to_string(), "transport disconnected"); + assert_transport_loss(&error); assert!(store.money_reads.lock().unwrap().is_empty()); assert!(store.item_reads.lock().unwrap().is_empty()); } @@ -1077,7 +1049,7 @@ mod tests { )); assert_eq!( store.money_take_requests.lock().unwrap().as_slice(), - &[(7, 42, 60)] + &[(42, 60)] ); } @@ -1111,7 +1083,7 @@ mod tests { )); assert_eq!( store.money_take_requests.lock().unwrap().as_slice(), - &[(7, 42, 60)] + &[(42, 60)] ); } @@ -1224,7 +1196,7 @@ mod tests { assert_didnt_kill(&outbound, 60); assert_eq!( store.money_take_requests.lock().unwrap().as_slice(), - &[(7, 42, 60)] + &[(42, 60)] ); } @@ -1280,7 +1252,7 @@ mod tests { )); assert_eq!( store.item_take_requests.lock().unwrap().as_slice(), - &[(7, 42, 75, 3)] + &[(42, 75, 3)] ); } @@ -1316,7 +1288,7 @@ mod tests { )); assert_eq!( store.item_take_requests.lock().unwrap().as_slice(), - &[(7, 42, 75, 3)] + &[(42, 75, 3)] ); } @@ -1358,7 +1330,7 @@ mod tests { assert_didnt_kill(&outbound, 75); assert_eq!( store.item_take_requests.lock().unwrap().as_slice(), - &[(7, 42, 75, 3)] + &[(42, 75, 3)] ); } @@ -1388,70 +1360,22 @@ mod tests { } #[test] - fn fatal_money_and_item_take_failures_propagate() { - let operations = [ - ( - "gw_loot_money reducer transport disconnected: channel closed", - ClientOpcodeMessage::CMSG_LOOT_MONEY, - ), - ( - "gw_loot_money reducer failed: transport disconnected", - ClientOpcodeMessage::CMSG_LOOT_MONEY, - ), - ( - "send gw_loot_money: connection closed", - ClientOpcodeMessage::CMSG_LOOT_MONEY, - ), - ( - "gw_loot_money reducer timed out after 10s", - ClientOpcodeMessage::CMSG_LOOT_MONEY, - ), - ]; - for (message, request) in operations { - let store = InMemoryLootWindow { - money_take_fatal_error: Some(message.into()), - ..Default::default() - }; - - let error = dispatch_loot_window( - &store, - player(), - OpenLootState { - target_guid: Some(60), - }, - request, - ) - .err() - .expect("fatal money-take failure was handled"); - - assert_eq!(error.to_string(), message); - } - - for message in [ - "gw_take_loot reducer transport disconnected: channel closed", - "gw_take_loot reducer failed: transport disconnected", - "send gw_take_loot: connection closed", - "gw_take_loot reducer timed out after 10s", - ] { - let store = InMemoryLootWindow { - item_take_fatal_error: Some(message.into()), - ..Default::default() - }; + fn transport_loss_on_a_money_or_item_take_propagates() { + let store = InMemoryLootWindow { + money_take_failure: Some(Failure::TransportLost), + item_take_failure: Some(Failure::TransportLost), + ..Default::default() + }; + let open = OpenLootState { + target_guid: Some(60), + }; - let error = dispatch_loot_window( - &store, - player(), - OpenLootState { - target_guid: Some(75), - }, - ClientOpcodeMessage::CMSG_AUTOSTORE_LOOT_ITEM(CMSG_AUTOSTORE_LOOT_ITEM { - item_slot: 3, - }), - ) - .err() - .expect("fatal item-take failure was handled"); + for request in [ClientOpcodeMessage::CMSG_LOOT_MONEY, take_item(3)] { + let error = dispatch_loot_window(&store, player(), open, request) + .err() + .expect("transport loss was handled"); - assert_eq!(error.to_string(), message); + assert_transport_loss(&error); } } @@ -1499,10 +1423,7 @@ mod tests { assert_eq!(&body[23..27], &200u32.to_le_bytes()); assert_eq!(store.money_reads.lock().unwrap().as_slice(), &[60]); assert_eq!(store.item_reads.lock().unwrap().as_slice(), &[(60, 42)]); - assert_eq!( - store.open_requests.lock().unwrap().as_slice(), - &[(7, 42, 60)] - ); + assert_eq!(store.open_requests.lock().unwrap().as_slice(), &[(42, 60)]); assert!(store.skin_requests.lock().unwrap().is_empty()); } @@ -1620,10 +1541,7 @@ mod tests { assert_eq!(&body[0..8], &61u64.to_le_bytes()); assert_eq!(&body[9..13], &0u32.to_le_bytes()); assert_eq!(body[13], 0); - assert_eq!( - store.skin_requests.lock().unwrap().as_slice(), - &[(7, 42, 61)] - ); + assert_eq!(store.skin_requests.lock().unwrap().as_slice(), &[(42, 61)]); } #[test] @@ -1656,29 +1574,22 @@ mod tests { } #[test] - fn fatal_skinning_failures_propagate() { - for message in [ - "gw_skin reducer transport disconnected: channel closed", - "gw_skin reducer failed: transport disconnected", - "send gw_skin: connection closed", - "gw_skin reducer timed out after 10s", - ] { - let store = InMemoryLootWindow { - skin_fatal_error: Some(message.into()), - ..Default::default() - }; + fn transport_loss_on_skinning_propagates() { + let store = InMemoryLootWindow { + skin_failure: Some(Failure::TransportLost), + ..Default::default() + }; - let error = dispatch_loot_window( - &store, - player(), - OpenLootState::default(), - open_creature(61), - ) - .err() - .expect("fatal skinning failure was handled"); + let error = dispatch_loot_window( + &store, + player(), + OpenLootState::default(), + open_creature(61), + ) + .err() + .expect("transport loss was handled"); - assert_eq!(error.to_string(), message); - } + assert_transport_loss(&error); } /// Run one request as the session does: apply the state transition, return the client traffic. @@ -1746,7 +1657,7 @@ mod tests { )); assert_eq!( store.item_take_requests.lock().unwrap().as_slice(), - &[(7, 42, 90, 4)] + &[(42, 90, 4)] ); } @@ -1770,7 +1681,7 @@ mod tests { assert_clear_money(&run(&store, &mut state, take_money())); assert_eq!( store.money_take_requests.lock().unwrap().as_slice(), - &[(7, 42, 60)] + &[(42, 60)] ); } @@ -1783,7 +1694,7 @@ mod tests { assert_clear_money(&run(&store, &mut state, take_money())); assert_eq!( store.money_take_requests.lock().unwrap().as_slice(), - &[(7, 42, 60)] + &[(42, 60)] ); } From 316a8837e375dd094e996fd661c3e175c93d4150 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 18:17:33 +0200 Subject: [PATCH 10/33] refactor(gateway): melee Store takes an Actor and classifies durable failures Inline the melee Coordinator wrappers into the trait methods, drop the unused account id and the zero-guid guards, and classify start and stop failures with classify. A Transport Loss now ends the World Session. Co-Authored-By: Claude Sonnet 5.5 --- gateway/src/stdb/account_sessions.rs | 3 +- gateway/src/stdb/store/melee.rs | 22 +-- gateway/src/world/handlers/cast/mod.rs | 18 +-- gateway/src/world/handlers/cast/ranged.rs | 12 +- gateway/src/world/handlers/melee.rs | 150 +++++++++++------- gateway/src/world/melee_tests.rs | 7 +- .../world/test_support/world_fake/melee.rs | 4 +- 7 files changed, 117 insertions(+), 99 deletions(-) diff --git a/gateway/src/stdb/account_sessions.rs b/gateway/src/stdb/account_sessions.rs index eea251d6b..89ce038ec 100644 --- a/gateway/src/stdb/account_sessions.rs +++ b/gateway/src/stdb/account_sessions.rs @@ -757,7 +757,8 @@ mod tests { fixture.query_rows("SELECT x,last_move_ms FROM game_world_entity WHERE guid = 1"); assert_eq!(entity[0]["x"].parse::().unwrap(), 100.0); assert_eq!(entity[0]["last_move_ms"], "100"); - let stale = old.stop_attack(account_id, 1).unwrap_err(); + let stale = + crate::world::MeleeActionStore::stop_attack(&old, Actor::new(1).unwrap()).unwrap_err(); assert!( stale.to_string().contains("STALE_WORLD_SESSION"), "{stale:#}" diff --git a/gateway/src/stdb/store/melee.rs b/gateway/src/stdb/store/melee.rs index 43d35592b..51ff4ac98 100644 --- a/gateway/src/stdb/store/melee.rs +++ b/gateway/src/stdb/store/melee.rs @@ -1,28 +1,16 @@ //! `Coordinator`'s [`MeleeActionStore`] adapter. -use anyhow::{anyhow, Result}; +use anyhow::Result; use crate::stdb::bindings::*; use crate::stdb::connection::call_reducer; use crate::stdb::Coordinator; use crate::world::{Actor, MeleeActionStore}; -impl MeleeActionStore for crate::stdb::Coordinator { - fn start_attack(&self, account_id: u64, actor_guid: u64, target_guid: u64) -> Result<()> { - crate::stdb::Coordinator::start_attack(self, account_id, actor_guid, target_guid) - } - - fn stop_attack(&self, account_id: u64, actor_guid: u64) -> Result<()> { - crate::stdb::Coordinator::stop_attack(self, account_id, actor_guid) - } -} - -impl Coordinator { +impl MeleeActionStore for Coordinator { /// Start the player's melee auto-attack on `target_guid` (`CMSG_ATTACKSWING`, combat C1) over /// the coordinator connection so the module attributes the swing to the caller. - pub fn start_attack(&self, _account_id: u64, actor_guid: u64, target_guid: u64) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("start_attack: actor_guid unresolved"))?; + fn start_attack(&self, actor: Actor, target_guid: u64) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, @@ -32,9 +20,7 @@ impl Coordinator { } /// Stop the player's melee auto-attack (`CMSG_ATTACKSTOP`, combat C1). - pub fn stop_attack(&self, _account_id: u64, actor_guid: u64) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("stop_attack: actor_guid unresolved"))?; + fn stop_attack(&self, actor: Actor) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, diff --git a/gateway/src/world/handlers/cast/mod.rs b/gateway/src/world/handlers/cast/mod.rs index 18f8c0bd0..19c1f7832 100644 --- a/gateway/src/world/handlers/cast/mod.rs +++ b/gateway/src/world/handlers/cast/mod.rs @@ -398,7 +398,7 @@ pub(super) mod tests { pub(crate) ground_casts: Mutex>, pub(crate) item_target_casts: Mutex>, pub(crate) ranged_attacks: Mutex>, - pub(crate) stop_attacks: Mutex>, + pub(crate) stop_attacks: Mutex>, pub(crate) disenchant_calls: Mutex>, pub(crate) enchant_calls: Mutex>, pub(crate) fish_calls: Mutex>, @@ -624,24 +624,16 @@ pub(super) mod tests { /// The ranged teardown the cast module shares with the melee seam. `start_attack` is never /// reached from a cast route; it exists because the two share one durable engagement row. impl MeleeActionStore for InMemoryCasts { - fn start_attack( - &self, - _account_id: u64, - _actor_guid: u64, - _target_guid: u64, - ) -> Result<()> { + fn start_attack(&self, _actor: Actor, _target_guid: u64) -> Result<()> { unreachable!("no cast route arms a melee engagement") } - fn stop_attack(&self, account_id: u64, actor_guid: u64) -> Result<()> { - self.stop_attacks - .lock() - .unwrap() - .push((account_id, actor_guid)); + fn stop_attack(&self, actor: Actor) -> Result<()> { + self.stop_attacks.lock().unwrap().push(actor.guid()); self.engaged .lock() .unwrap() - .retain(|&guid| guid != actor_guid); + .retain(|&guid| guid != actor.guid()); Ok(()) } } diff --git a/gateway/src/world/handlers/cast/ranged.rs b/gateway/src/world/handlers/cast/ranged.rs index be5b25e24..18f1ce12a 100644 --- a/gateway/src/world/handlers/cast/ranged.rs +++ b/gateway/src/world/handlers/cast/ranged.rs @@ -123,7 +123,10 @@ pub(super) fn cancel(store: &St, player: CastPlayer) -> /// Ask the module to tear the engagement down. Best effort: a refused stop (nothing armed, a race /// with the swing tick) must not end the session or reach the client. fn stop_loop(store: &St, player: CastPlayer, context: &str) { - if let Err(e) = store.stop_attack(player.account_id, player.self_guid.unwrap_or(0)) { + let Some(actor) = player.self_guid.and_then(Actor::new) else { + return; + }; + if let Err(e) = store.stop_attack(actor) { log::debug!( "world: {context} stop_attack ignored (account {}): {e}", player.account_id @@ -321,7 +324,7 @@ mod tests { ); assert_eq!( store.stop_attacks.lock().unwrap().as_slice(), - &[(ACCOUNT, CASTER)], + &[CASTER], "the client dropped its toggle, so the still-firing old loop must go too" ); } @@ -357,10 +360,7 @@ mod tests { ranged_repeat: Some(false) } ); - assert_eq!( - store.stop_attacks.lock().unwrap().as_slice(), - &[(ACCOUNT, CASTER)] - ); + assert_eq!(store.stop_attacks.lock().unwrap().as_slice(), &[CASTER]); assert!( outbound.is_empty(), "the engagement's on_delete relay is the one sender of the cancel signal" diff --git a/gateway/src/world/handlers/melee.rs b/gateway/src/world/handlers/melee.rs index b9cdb10d6..8a896d8e8 100644 --- a/gateway/src/world/handlers/melee.rs +++ b/gateway/src/world/handlers/melee.rs @@ -1,12 +1,13 @@ //! Melee-attack dispatcher: durable engagement, session transition, and client stance messages. use super::super::*; +use crate::stdb::{classify, DurableFailure}; pub(crate) trait MeleeActionStore: Send + Sync { - fn start_attack(&self, account_id: u64, actor_guid: u64, target_guid: u64) -> Result<()>; + fn start_attack(&self, actor: Actor, target_guid: u64) -> Result<()>; /// Disarm the actor's outgoing engagement row. Also the ranged auto-repeat teardown call; /// `attack_stop` states the rule the two share. - fn stop_attack(&self, account_id: u64, actor_guid: u64) -> Result<()>; + fn stop_attack(&self, actor: Actor) -> Result<()>; } /// The melee-relevant session facts. `self_guid` is `None` outside the world. @@ -112,17 +113,22 @@ fn attack_start( player: MeleeActionPlayer, target_guid: u64, ) -> Result { - if let Err(e) = store.start_attack( - player.account_id, - player.self_guid.unwrap_or(0), - target_guid, - ) { - let text = e.to_string(); + // 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(), + }); + }; + if let Err(e) = store.start_attack(actor, target_guid) { + let DurableFailure::Refusal { reason } = classify(&e) else { + return Err(e); + }; // A corpse or a friendly target must be answered: with no refusal the client hangs in // combat stance and never swings. Both are transient per-swing failures, not fatal. - let refusal = if text.contains(lyracore_shared::ERR_ATTACK_TARGET_DEAD) { + let refusal = if reason.contains(lyracore_shared::ERR_ATTACK_TARGET_DEAD) { Some(ServerOpcodeMessage::SMSG_ATTACKSWING_DEADTARGET) - } else if text.contains(lyracore_shared::ERR_ATTACK_FRIENDLY) { + } else if reason.contains(lyracore_shared::ERR_ATTACK_FRIENDLY) { Some(ServerOpcodeMessage::SMSG_ATTACKSWING_CANT_ATTACK) } else if is_desync_error(&e) { return Err(desync_exit(e, "attackswing")); @@ -140,18 +146,10 @@ fn attack_start( outbound: refusal.map(Outbound::One).into_iter().collect(), }); } - // Not in the world: no combat state to arm and no attacker guid to name. The durable call - // already ran, and the module rejects an unresolved actor. - let Some(self_guid) = player.self_guid else { - return Ok(MeleeActionOutcome::Handled { - transition: MeleeTransition::Unchanged, - outbound: Vec::new(), - }); - }; Ok(MeleeActionOutcome::Handled { transition: MeleeTransition::Engaged(target_guid), outbound: vec![Outbound::One(ServerOpcodeMessage::SMSG_ATTACKSTART( - Box::new(codec::build_attack_start(self_guid, target_guid)), + Box::new(codec::build_attack_start(actor.guid(), target_guid)), ))], }) } @@ -172,7 +170,16 @@ fn attack_stop( outbound: Vec::new(), }); } - if let Err(e) = store.stop_attack(player.account_id, player.self_guid.unwrap_or(0)) { + let Some(actor) = player.self_guid.and_then(Actor::new) else { + return Ok(MeleeActionOutcome::Handled { + transition: MeleeTransition::Disengaged, + outbound: Vec::new(), + }); + }; + if let Err(e) = store.stop_attack(actor) { + if matches!(classify(&e), DurableFailure::TransportLoss) { + return Err(e); + } if is_desync_error(&e) { return Err(desync_exit(e, "attackstop")); } @@ -183,14 +190,15 @@ fn attack_stop( player.account_id ); } - let outbound = match (player.self_guid, player.attacking_target) { - (Some(self_guid), Some(target_guid)) => { - vec![Outbound::One(ServerOpcodeMessage::SMSG_ATTACKSTOP( - Box::new(codec::build_attack_stop(self_guid, target_guid)), - ))] - } - _ => Vec::new(), - }; + 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, @@ -200,47 +208,56 @@ fn attack_stop( #[cfg(test)] pub(super) mod tests { use super::*; + use crate::stdb::ReducerCallError; use std::sync::Mutex; use wow_world_messages::vanilla::{Guid, CMSG_ATTACKSWING, CMSG_PING}; #[derive(Default)] pub(crate) struct InMemoryMeleeActions { - pub(crate) start_requests: Mutex>, + /// Recorded `start_attack` calls: actor and target. + pub(crate) start_requests: Mutex>, + /// The Module's reason when `start_attack` is refused. pub(crate) start_error: Option, - pub(crate) stop_requests: Mutex>, + pub(crate) stop_requests: Mutex>, + /// The Module's reason when `stop_attack` is refused. pub(crate) stop_error: Option, + /// Every durable call fails with a Transport Loss. + pub(crate) transport_lost: bool, /// The attackers with a live engagement: a started attack adds its actor, a stop removes it. pub(crate) engaged: Mutex>, } impl MeleeActionStore for InMemoryMeleeActions { - fn start_attack(&self, account_id: u64, actor_guid: u64, target_guid: u64) -> Result<()> { + fn start_attack(&self, actor: Actor, target_guid: u64) -> Result<()> { self.start_requests .lock() .unwrap() - .push((account_id, actor_guid, target_guid)); - if let Some(error) = &self.start_error { - return Err(anyhow::anyhow!("{error}")); + .push((actor.guid(), target_guid)); + if self.transport_lost { + return Err(ReducerCallError::transport_lost("gw_attack").into()); + } + if let Some(reason) = &self.start_error { + return Err(ReducerCallError::refused("gw_attack", reason).into()); } let mut engaged = self.engaged.lock().unwrap(); - if !engaged.contains(&actor_guid) { - engaged.push(actor_guid); + if !engaged.contains(&actor.guid()) { + engaged.push(actor.guid()); } Ok(()) } - fn stop_attack(&self, account_id: u64, actor_guid: u64) -> Result<()> { - self.stop_requests - .lock() - .unwrap() - .push((account_id, actor_guid)); - if let Some(error) = &self.stop_error { - return Err(anyhow::anyhow!("{error}")); + fn stop_attack(&self, actor: Actor) -> Result<()> { + self.stop_requests.lock().unwrap().push(actor.guid()); + if self.transport_lost { + return Err(ReducerCallError::transport_lost("gw_stop_attack").into()); + } + if let Some(reason) = &self.stop_error { + return Err(ReducerCallError::refused("gw_stop_attack", reason).into()); } self.engaged .lock() .unwrap() - .retain(|&guid| guid != actor_guid); + .retain(|&guid| guid != actor.guid()); Ok(()) } } @@ -302,7 +319,7 @@ pub(super) mod tests { assert_eq!( actions.start_requests.lock().unwrap().as_slice(), - &[(7, 42, 90)] + &[(42, 90)] ); let MeleeActionOutcome::Handled { transition, @@ -382,7 +399,7 @@ pub(super) mod tests { } #[test] - fn an_unresolved_player_requests_the_legacy_zero_actor_and_arms_nothing() { + fn an_unresolved_player_requests_nothing_and_arms_nothing() { let actions = InMemoryMeleeActions::default(); let player = MeleeActionPlayer { self_guid: None, @@ -391,10 +408,7 @@ pub(super) mod tests { let outcome = dispatch_melee_action(&actions, player, attack_swing(90)).unwrap(); - assert_eq!( - actions.start_requests.lock().unwrap().as_slice(), - &[(7, 0, 90)] - ); + assert!(actions.start_requests.lock().unwrap().is_empty()); assert!(matches!( outcome, MeleeActionOutcome::Handled { transition, outbound } @@ -410,7 +424,7 @@ pub(super) mod tests { dispatch_melee_action(&actions, engaged(90), ClientOpcodeMessage::CMSG_ATTACKSTOP) .unwrap(); - assert_eq!(actions.stop_requests.lock().unwrap().as_slice(), &[(7, 42)]); + assert_eq!(actions.stop_requests.lock().unwrap().as_slice(), &[42]); let MeleeActionOutcome::Handled { transition, outbound, @@ -439,7 +453,7 @@ pub(super) mod tests { let outcome = dispatch_melee_action(&actions, player, ClientOpcodeMessage::CMSG_ATTACKSTOP).unwrap(); - assert_eq!(actions.stop_requests.lock().unwrap().as_slice(), &[(7, 42)]); + assert_eq!(actions.stop_requests.lock().unwrap().as_slice(), &[42]); assert!(matches!( outcome, MeleeActionOutcome::Handled { transition, outbound } @@ -461,7 +475,7 @@ pub(super) mod tests { assert_eq!( actions.stop_requests.lock().unwrap().as_slice(), - &[(7, 42)], + &[42], "the durable stop is attempted before the refusal is swallowed" ); assert!(matches!( @@ -474,7 +488,7 @@ pub(super) mod tests { } #[test] - fn attack_stop_from_an_unresolved_player_requests_the_legacy_zero_actor_and_echoes_nothing() { + fn attack_stop_from_an_unresolved_player_requests_nothing_and_echoes_nothing() { let actions = InMemoryMeleeActions::default(); let player = MeleeActionPlayer { self_guid: None, @@ -484,7 +498,7 @@ pub(super) mod tests { let outcome = dispatch_melee_action(&actions, player, ClientOpcodeMessage::CMSG_ATTACKSTOP).unwrap(); - assert_eq!(actions.stop_requests.lock().unwrap().as_slice(), &[(7, 0)]); + assert!(actions.stop_requests.lock().unwrap().is_empty()); assert!(matches!( outcome, MeleeActionOutcome::Handled { transition, outbound } @@ -578,4 +592,30 @@ pub(super) mod tests { "expected attack-stop context, got: {text}" ); } + + #[test] + fn a_transport_loss_ends_the_session_where_a_refusal_keeps_it_alive() { + let lost = 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()); + } } diff --git a/gateway/src/world/melee_tests.rs b/gateway/src/world/melee_tests.rs index 32f6faf87..7b9d8d0f1 100644 --- a/gateway/src/world/melee_tests.rs +++ b/gateway/src/world/melee_tests.rs @@ -101,15 +101,14 @@ fn attackswing_ok_replies_attackstart_and_stop_echoes_then_clears() { #[test] 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. - // The durable calls still go out under the legacy zero actor, which the stop shows: it clears - // the engagement the swing left on actor 0. + // It makes no durable request. let actions = InMemoryMeleeActions::default(); let mut session = Session::at_character_select(); assert!(run(&actions, &mut session, swing(90)).is_empty()); - assert_eq!(actions.engaged.lock().unwrap().as_slice(), &[0]); assert!(run(&actions, &mut session, CMSG_ATTACKSTOP {}).is_empty()); - assert!(actions.engaged.lock().unwrap().is_empty()); + assert!(actions.start_requests.lock().unwrap().is_empty()); + assert!(actions.stop_requests.lock().unwrap().is_empty()); } #[test] diff --git a/gateway/src/world/test_support/world_fake/melee.rs b/gateway/src/world/test_support/world_fake/melee.rs index 72d8dc1db..5403efbd2 100644 --- a/gateway/src/world/test_support/world_fake/melee.rs +++ b/gateway/src/world/test_support/world_fake/melee.rs @@ -3,11 +3,11 @@ use super::super::*; /// Melee behaviour is tested through `InMemoryMeleeActions`. The shard routing tests read the /// `start_attack` entry in the topology call log, so that one call stays recorded. impl MeleeActionStore for WorldFake { - fn start_attack(&self, _account_id: u64, _self_guid: u64, _target_guid: u64) -> Result<()> { + fn start_attack(&self, _actor: Actor, _target_guid: u64) -> Result<()> { self.rec("start_attack"); Ok(()) } - fn stop_attack(&self, _account_id: u64, _self_guid: u64) -> Result<()> { + fn stop_attack(&self, _actor: Actor) -> Result<()> { Ok(()) } } From 0152aed6de867c19a5dbfe4928c0010a7f965482 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 18:17:33 +0200 Subject: [PATCH 11/33] refactor(gateway): quest Store takes an Actor and classifies durable failures Inline the quest Coordinator wrappers into the trait methods, drop the unused account id and the zero-guid guards, and replace the transport-text classifier with classify. A failed interaction-gate read is a Transport Loss. Co-Authored-By: Claude Sonnet 5.5 --- gateway/src/stdb/store/quest.rs | 114 ++------ gateway/src/world/handlers/item.rs | 15 +- gateway/src/world/handlers/quest.rs | 257 +++++++----------- gateway/src/world/quest_tests.rs | 2 +- .../world/test_support/world_fake/quest.rs | 30 +- 5 files changed, 126 insertions(+), 292 deletions(-) diff --git a/gateway/src/stdb/store/quest.rs b/gateway/src/stdb/store/quest.rs index e5b7a9ebe..dc2a9ffd2 100644 --- a/gateway/src/stdb/store/quest.rs +++ b/gateway/src/stdb/store/quest.rs @@ -1,88 +1,21 @@ //! `Coordinator`'s [`QuestActionStore`] adapter. -use anyhow::{anyhow, Result}; +use anyhow::Result; use spacetimedb_sdk::Table; -use crate::codec; use crate::stdb::bindings::*; use crate::stdb::connection::call_reducer; use crate::stdb::reads::{build_quest_log_slots, quest_objectives_complete}; use crate::stdb::Coordinator; use crate::world::{Actor, QuestActionStore}; -impl QuestActionStore for crate::stdb::Coordinator { - fn giver_quest_evals( - &self, - giver_guid: u64, - player_guid: u64, - ) -> Result> { - crate::stdb::Coordinator::quest_giver_evals(self, giver_guid, player_guid) - } - - fn quest_detail_view(&self, quest_id: u32) -> Result> { - crate::stdb::Coordinator::quest_detail(self, quest_id) - } - - fn giver_refuses_interaction(&self, giver_guid: u64, player_guid: u64) -> Result { - crate::stdb::Coordinator::npc_refuses_interaction(self, giver_guid, player_guid) - } - - fn accept_quest( - &self, - account_id: u64, - self_guid: u64, - giver_guid: u64, - quest_id: u32, - ) -> Result<()> { - crate::stdb::Coordinator::accept_quest(self, account_id, self_guid, giver_guid, quest_id) - } - - fn item_start_quest(&self, owner_guid: u64, slot: u8) -> Option<(u64, u32)> { - crate::stdb::Coordinator::item_start_quest(self, owner_guid, slot) - } - - fn turn_in_quest( - &self, - account_id: u64, - self_guid: u64, - giver_guid: u64, - quest_id: u32, - reward_index: u32, - ) -> Result<()> { - crate::stdb::Coordinator::turn_in_quest( - self, - account_id, - self_guid, - giver_guid, - quest_id, - reward_index, - ) - } - - fn player_quest_log(&self, player_guid: u64) -> Result> { - crate::stdb::Coordinator::player_quest_log(self, player_guid) - } - - fn abandon_quest(&self, account_id: u64, self_guid: u64, quest_id: u32) -> Result<()> { - crate::stdb::Coordinator::abandon_quest(self, account_id, self_guid, quest_id) - } - - fn push_quest(&self, account_id: u64, self_guid: u64, quest_id: u32) -> Result<()> { - crate::stdb::Coordinator::push_quest(self, account_id, self_guid, quest_id) - } - - fn quest_status(&self, player_guid: u64, quest_id: u32) -> (bool, bool) { - crate::stdb::Coordinator::quest_status(self, player_guid, quest_id) - } -} - -impl Coordinator { +impl QuestActionStore for Coordinator { /// Item-starts-quest: does the item in `owner_guid`'s inventory `slot` carry a /// non-zero `item_template.start_quest`? Returns `(item_guid, quest_id)` if so — the gateway's /// `CMSG_USE_ITEM` handler uses this to open `SMSG_QUESTGIVER_QUEST_DETAILS` (item guid as giver) /// INSTEAD of calling `use_item` (never both — the item isn't consumed). `None` for every ordinary /// item (the ubiquitous case), which falls through to the normal use-item path unchanged. - pub fn item_start_quest(&self, owner_guid: u64, slot: u8) -> Option<(u64, u32)> { + fn item_start_quest(&self, owner_guid: u64, slot: u8) -> Option<(u64, u32)> { let guard = self.0.coord(); let db = &guard.conn.db; let hit: Option<(u64, u32)> = db @@ -100,10 +33,10 @@ impl Coordinator { } /// `(taken, rewarded)` for `quest_id` in `guid`'s quest log (privileged read — RLS bypassed, like - /// `quest_giver_evals`) — feeds `option_condition_holds` for the QUEST_TAKEN/QUEST_REWARDED gossip + /// `giver_quest_evals`) — feeds `option_condition_holds` for the QUEST_TAKEN/QUEST_REWARDED gossip /// option conditions. `taken` is true whenever a log row exists at all (accepted, /// whether or not yet turned in); a quest never seen by this player is `(false, false)`. - pub fn quest_status(&self, guid: u64, quest_id: u32) -> (bool, bool) { + fn quest_status(&self, guid: u64, quest_id: u32) -> (bool, bool) { let guard = self.0.coord(); let db = &guard.conn.db; let found = db @@ -131,7 +64,7 @@ impl Coordinator { /// level-qualifying START), `active` (held + un-rewarded END), and `complete` (active + every /// objective's count met). One guard, all inline — no nested cache locks. The codec /// ([`crate::codec::quest_giver_status`]) folds these. - pub fn quest_giver_evals( + fn giver_quest_evals( &self, giver_guid: u64, player_guid: u64, @@ -212,16 +145,20 @@ impl Coordinator { /// `lyracore_shared::quest::xp_reward` so it matches the module's grant), reward items joined with their /// display ids, and a synthesized objectives line per kill objective ("Creature slain: 0/N" from the /// creature-template name — quest body text isn't imported yet). `None` if the quest isn't loaded. - pub fn quest_detail(&self, quest_id: u32) -> Result> { + fn quest_detail_view(&self, quest_id: u32) -> Result> { let guard = self.0.coord(); Ok(quest_detail_view(&guard.conn.db, quest_id)) } + fn giver_refuses_interaction(&self, giver_guid: u64, player_guid: u64) -> Result { + Coordinator::npc_refuses_interaction(self, giver_guid, player_guid) + } + /// The player's active (un-rewarded) quests as quest-log descriptor slots: the quest-log window. /// Deterministic slot assignment (sorted by quest_entry → slot 0..), capped at the 20 vanilla slots; /// each slot carries the quest id, per-objective counts, and a state byte (1 = all objectives met, /// else 0). The gateway encodes these into the `PLAYER_QUEST_LOG_*` fields. RLS-bypassed read. - pub fn player_quest_log( + fn player_quest_log( &self, player_guid: u64, ) -> Result> { @@ -233,9 +170,7 @@ impl Coordinator { /// `CMSG_PUSHQUESTTOPARTY` — over the coordinator connection so the module /// attributes the sender + its grouped/on-quest gates to the caller. - pub fn push_quest(&self, _account_id: u64, actor_guid: u64, quest_id: u32) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("push_quest: actor_guid unresolved"))?; + fn push_quest(&self, actor: Actor, quest_id: u32) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, @@ -248,15 +183,7 @@ impl Coordinator { /// coordinator connection so the module attributes it to the caller. The module gates the accept /// (giver relation + range + level + not-already-held); a gameplay `Err` is per-action, not fatal. /// Rides the coordinator connection as `gw_accept_quest`. - pub fn accept_quest( - &self, - _account_id: u64, - actor_guid: u64, - giver_guid: u64, - quest_id: u32, - ) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("accept_quest: actor_guid unresolved"))?; + fn accept_quest(&self, actor: Actor, giver_guid: u64, quest_id: u32) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, @@ -269,16 +196,13 @@ impl Coordinator { /// validates completion + grants the rewards (money/XP/items). `reward_index` is the player's /// pick-1-of-N choice slot; ignored when the quest has no choices. This call uses the subscribed /// visibility pipe so committed item relays are queued before success presentation is allowed. - pub fn turn_in_quest( + fn turn_in_quest( &self, - _account_id: u64, - actor_guid: u64, + actor: Actor, giver_guid: u64, quest_id: u32, reward_index: u32, ) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("turn_in_quest: actor_guid unresolved"))?; let coord = self.0.visibility_pipe(); call_reducer!( coord.conn.reducers, @@ -294,9 +218,7 @@ impl Coordinator { /// Abandon quest `quest_id` (`CMSG_QUESTLOG_REMOVE_QUEST`) over the coordinator connection. The /// module deletes the player's quest-log row; the quest-log relay then clears the slot. - pub fn abandon_quest(&self, _account_id: u64, actor_guid: u64, quest_id: u32) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("abandon_quest: actor_guid unresolved"))?; + fn abandon_quest(&self, actor: Actor, quest_id: u32) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, @@ -389,7 +311,7 @@ pub(crate) fn quest_detail_view( /// Evaluate ONE giver relation (a `game_creature_quest` OR `game_gameobject_quest` row, already /// filtered to the giver's entry by the caller) against `player_guid`'s quest log. Takes the bare /// `quest_entry`/`role` rather than a table row type — `game_creature_quest` and `game_gameobject_quest` -/// have the identical `(quest_entry, role)` shape, so `quest_giver_evals` calls this +/// have the identical `(quest_entry, role)` shape, so `giver_quest_evals` calls this /// same function for both, keyed only on which relation table it iterated. /// /// PARITY: `startable` answers the same question as the Module's `quest::accept_gates` diff --git a/gateway/src/world/handlers/item.rs b/gateway/src/world/handlers/item.rs index 9bfbcb413..baa62a3a9 100644 --- a/gateway/src/world/handlers/item.rs +++ b/gateway/src/world/handlers/item.rs @@ -311,8 +311,7 @@ pub(super) mod tests { fn turn_in_quest( &self, - _account_id: u64, - _self_guid: u64, + _actor: Actor, _giver_guid: u64, _quest_id: u32, _reward_index: u32, @@ -320,13 +319,7 @@ pub(super) mod tests { unreachable!("no item action turns a quest in") } - fn accept_quest( - &self, - _account_id: u64, - _self_guid: u64, - _giver_guid: u64, - _quest_id: u32, - ) -> Result<()> { + fn accept_quest(&self, _actor: Actor, _giver_guid: u64, _quest_id: u32) -> Result<()> { unreachable!("no item action accepts a quest") } @@ -341,11 +334,11 @@ pub(super) mod tests { unreachable!("no item action reads the quest log") } - fn abandon_quest(&self, _account_id: u64, _self_guid: u64, _quest_id: u32) -> Result<()> { + fn abandon_quest(&self, _actor: Actor, _quest_id: u32) -> Result<()> { unreachable!("no item action abandons a quest") } - fn push_quest(&self, _account_id: u64, _self_guid: u64, _quest_id: u32) -> Result<()> { + fn push_quest(&self, _actor: Actor, _quest_id: u32) -> Result<()> { unreachable!("no item action shares a quest with the party") } diff --git a/gateway/src/world/handlers/quest.rs b/gateway/src/world/handlers/quest.rs index 7c2a79000..4a7378556 100644 --- a/gateway/src/world/handlers/quest.rs +++ b/gateway/src/world/handlers/quest.rs @@ -8,6 +8,7 @@ //! render their own copies from the same `codec` builders these functions use. use super::super::*; +use crate::stdb::{classify, DurableFailure}; use wow_world_messages::vanilla::QuestItem; /// The durable reads and reducer calls the quest family needs, in the seam's own vocabulary so it @@ -30,13 +31,7 @@ pub(crate) trait QuestActionStore: Send + Sync { /// Open the quest log row for `quest_id` offered by `giver_guid`. The module gates it, so a /// refusal here is a gameplay answer, not a broken session. - fn accept_quest( - &self, - account_id: u64, - self_guid: u64, - giver_guid: u64, - quest_id: u32, - ) -> Result<()>; + fn accept_quest(&self, actor: Actor, giver_guid: u64, quest_id: u32) -> Result<()>; /// The quest the item in `slot` starts, as `(item instance guid, quest id)`. `None` when the /// item starts no quest — an item is its own quest giver, hence the instance guid. @@ -48,8 +43,7 @@ pub(crate) trait QuestActionStore: Send + Sync { /// Escrow in the same transaction. fn turn_in_quest( &self, - account_id: u64, - self_guid: u64, + actor: Actor, giver_guid: u64, quest_id: u32, reward_index: u32, @@ -62,12 +56,12 @@ pub(crate) trait QuestActionStore: Send + Sync { /// Abandon an active quest (`CMSG_QUESTLOG_REMOVE_QUEST`). The module deletes the quest-log row; /// the relay clears the slot. - fn abandon_quest(&self, account_id: u64, self_guid: u64, quest_id: u32) -> Result<()>; + fn abandon_quest(&self, actor: Actor, quest_id: u32) -> Result<()>; /// Share `quest_id` with the caller's party (`CMSG_PUSHQUESTTOPARTY`). The module validates /// grouped + actively-on-the-quest and pushes the per-member `QUEST_SHARE`/`QUEST_PUSH_RESULT` /// events itself; a gameplay `Err` is per-action, not session-fatal. - fn push_quest(&self, account_id: u64, self_guid: u64, quest_id: u32) -> Result<()>; + fn push_quest(&self, actor: Actor, quest_id: u32) -> Result<()>; /// `(taken, rewarded)` for `quest_id` in `player_guid`'s quest log — feeds the /// QUEST_TAKEN/QUEST_REWARDED gossip option conditions. @@ -94,20 +88,16 @@ pub(crate) enum QuestActionOutcome { PassThrough(ClientOpcodeMessage), } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum QuestActionErrorClass { - GameplayRefusal, - Fatal, -} - -fn classify_quest_action_error(error: &anyhow::Error) -> QuestActionErrorClass { - if error - .chain() - .any(|cause| cause.to_string().contains("reducer transport disconnected")) - { - QuestActionErrorClass::Fatal - } else { - QuestActionErrorClass::GameplayRefusal +/// 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<()> { + match result { + Err(e) if matches!(classify(&e), DurableFailure::TransportLoss) => Err(e), + Err(e) => { + log::debug!("world: {operation} ignored (account {account_id}): {e}"); + Ok(()) + } + Ok(()) => Ok(()), } } @@ -247,9 +237,10 @@ pub(crate) fn dispatch_quest_action( player: QuestActionPlayer, msg: ClientOpcodeMessage, ) -> Result { - let Some(self_guid) = player.self_guid else { + 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) => { @@ -266,22 +257,7 @@ pub(crate) fn dispatch_quest_action( // QUESTGIVER GameObject reaches the same `quest_giver_menu` helper from session dispatch. ClientOpcodeMessage::CMSG_QUESTGIVER_HELLO(h) => { let giver = h.guid.guid(); - let refuses = match store.giver_refuses_interaction(giver, self_guid) { - Ok(refuses) => refuses, - // The gate fails open — missing standing data must not lock a player out of a - // giver — but a dead reducer transport is not missing data and ends the session. - Err(e) - if classify_quest_action_error(&e) - == QuestActionErrorClass::GameplayRefusal => - { - log::debug!( - "world: questgiver {giver} interaction gate unavailable (account {}): {e}", - player.account_id - ); - false - } - Err(e) => return Err(e), - }; + let refuses = store.giver_refuses_interaction(giver, self_guid)?; Ok(QuestActionOutcome::Handled { outbound: if refuses { Vec::new() @@ -311,19 +287,11 @@ pub(crate) fn dispatch_quest_action( // 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) => { - match store.accept_quest(player.account_id, self_guid, a.guid.guid(), a.quest_id) { - Ok(()) => {} - Err(e) - if classify_quest_action_error(&e) - == QuestActionErrorClass::GameplayRefusal => - { - log::debug!( - "world: accept_quest ignored (account {}): {e}", - player.account_id - ); - } - Err(e) => return Err(e), - } + ignore_refusal( + store.accept_quest(actor, a.guid.guid(), a.quest_id), + "accept_quest", + player.account_id, + )?; Ok(QuestActionOutcome::Handled { outbound: Vec::new(), }) @@ -360,13 +328,7 @@ pub(crate) fn dispatch_quest_action( // "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( - player.account_id, - self_guid, - c.guid.guid(), - c.quest_id, - c.reward, - ) { + 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 { @@ -379,10 +341,7 @@ pub(crate) fn dispatch_quest_action( None => Vec::new(), }, }), - Err(e) - if classify_quest_action_error(&e) - == QuestActionErrorClass::GameplayRefusal => - { + Err(e) if matches!(classify(&e), DurableFailure::Refusal { .. }) => { log::debug!( "world: turn_in_quest refused (quest {}, reward index {}): {e}", c.quest_id, @@ -413,19 +372,11 @@ pub(crate) fn dispatch_quest_action( .into_iter() .find(|s| s.slot == r.slot) { - match store.abandon_quest(player.account_id, self_guid, s.quest_id) { - Ok(()) => {} - Err(e) - if classify_quest_action_error(&e) - == QuestActionErrorClass::GameplayRefusal => - { - log::debug!( - "world: abandon_quest ignored (account {}): {e}", - player.account_id - ); - } - Err(e) => return Err(e), - } + ignore_refusal( + store.abandon_quest(actor, s.quest_id), + "abandon_quest", + player.account_id, + )?; } Ok(QuestActionOutcome::Handled { outbound: Vec::new(), @@ -435,19 +386,11 @@ pub(crate) fn dispatch_quest_action( // 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) => { - match store.push_quest(player.account_id, self_guid, p.quest_id) { - Ok(()) => {} - Err(e) - if classify_quest_action_error(&e) - == QuestActionErrorClass::GameplayRefusal => - { - log::debug!( - "world: push_quest ignored (account {}): {e}", - player.account_id - ); - } - Err(e) => return Err(e), - } + ignore_refusal( + store.push_quest(actor, p.quest_id), + "push_quest", + player.account_id, + )?; Ok(QuestActionOutcome::Handled { outbound: Vec::new(), }) @@ -459,6 +402,7 @@ pub(crate) fn dispatch_quest_action( #[cfg(test)] mod tests { use super::*; + use crate::stdb::ReducerCallError; use std::sync::Mutex; use wow_world_messages::vanilla::{ Guid, QuestGiverStatus, CMSG_PING, CMSG_PUSHQUESTTOPARTY, CMSG_QUESTGIVER_ACCEPT_QUEST, @@ -473,7 +417,6 @@ mod tests { #[derive(Clone, Debug, PartialEq, Eq)] enum TurnInCall { TurnIn { - account_id: u64, self_guid: u64, giver: u64, quest_id: u32, @@ -487,17 +430,16 @@ mod tests { eval_requests: Mutex>, detail_requests: Mutex>, gate_requests: Mutex>, - accept_requests: Mutex>, + accept_requests: Mutex>, start_quest_requests: Mutex>, turn_in_calls: Mutex>, quest_log_requests: Mutex>, - abandon_requests: Mutex>, - push_requests: Mutex>, + abandon_requests: Mutex>, + push_requests: Mutex>, status_requests: Mutex>, evals: Vec, details: Vec, refuses: bool, - gate_error: Option, accept_error: Option, start_quest: Option<(u64, u32)>, turn_in_error: Option, @@ -505,6 +447,20 @@ mod tests { abandon_error: Option, push_error: Option, quest_status: (bool, bool), + /// Every durable call and read fails with a Transport Loss. + transport_lost: bool, + } + + impl InMemoryQuestActions { + /// The Module's answer to a durable request: a Refusal with `reason`, or success. + fn answer(&self, operation: &str, reason: &Option) -> Result<()> { + if self.transport_lost { + return Err(ReducerCallError::transport_lost(operation).into()); + } + reason.as_ref().map_or(Ok(()), |reason| { + Err(ReducerCallError::refused(operation, reason).into()) + }) + } } impl QuestActionStore for InMemoryQuestActions { @@ -538,26 +494,18 @@ mod tests { .lock() .unwrap() .push((giver_guid, player_guid)); - match &self.gate_error { - Some(error) => Err(anyhow::anyhow!("{error}")), - None => Ok(self.refuses), + if self.transport_lost { + return Err(ReducerCallError::transport_lost("npc_refuses_interaction").into()); } + Ok(self.refuses) } - fn accept_quest( - &self, - account_id: u64, - self_guid: u64, - giver_guid: u64, - quest_id: u32, - ) -> Result<()> { + fn accept_quest(&self, actor: Actor, giver_guid: u64, quest_id: u32) -> Result<()> { self.accept_requests .lock() .unwrap() - .push((account_id, self_guid, giver_guid, quest_id)); - self.accept_error - .as_ref() - .map_or_else(|| Ok(()), |error| Err(anyhow::anyhow!("{error}"))) + .push((actor.guid(), giver_guid, quest_id)); + self.answer("gw_accept_quest", &self.accept_error) } fn item_start_quest(&self, owner_guid: u64, slot: u8) -> Option<(u64, u32)> { @@ -570,22 +518,18 @@ mod tests { fn turn_in_quest( &self, - account_id: u64, - self_guid: u64, + actor: Actor, giver_guid: u64, quest_id: u32, reward_index: u32, ) -> Result<()> { self.turn_in_calls.lock().unwrap().push(TurnInCall::TurnIn { - account_id, - self_guid, + self_guid: actor.guid(), giver: giver_guid, quest_id, reward_index, }); - self.turn_in_error - .as_ref() - .map_or_else(|| Ok(()), |error| Err(anyhow::anyhow!("{error}"))) + self.answer("gw_turn_in_quest", &self.turn_in_error) } fn player_quest_log( @@ -596,24 +540,20 @@ mod tests { Ok(self.quest_log.clone()) } - fn abandon_quest(&self, account_id: u64, self_guid: u64, quest_id: u32) -> Result<()> { + fn abandon_quest(&self, actor: Actor, quest_id: u32) -> Result<()> { self.abandon_requests .lock() .unwrap() - .push((account_id, self_guid, quest_id)); - self.abandon_error - .as_ref() - .map_or_else(|| Ok(()), |error| Err(anyhow::anyhow!("{error}"))) + .push((actor.guid(), quest_id)); + self.answer("gw_abandon_quest", &self.abandon_error) } - fn push_quest(&self, account_id: u64, self_guid: u64, quest_id: u32) -> Result<()> { + fn push_quest(&self, actor: Actor, quest_id: u32) -> Result<()> { self.push_requests .lock() .unwrap() - .push((account_id, self_guid, quest_id)); - self.push_error - .as_ref() - .map_or_else(|| Ok(()), |error| Err(anyhow::anyhow!("{error}"))) + .push((actor.guid(), quest_id)); + self.answer("gw_push_quest_to_party", &self.push_error) } fn quest_status(&self, player_guid: u64, quest_id: u32) -> (bool, bool) { @@ -810,18 +750,6 @@ mod tests { assert!(actions.eval_requests.lock().unwrap().is_empty()); } - #[test] - fn an_unavailable_interaction_gate_still_opens_the_menu() { - let actions = InMemoryQuestActions { - gate_error: Some("no reputation row for that faction".into()), - ..one_quest(codec::ROLE_START, false, false) - }; - - let batch = outbound(dispatch_quest_action(&actions, player(), hello(GIVER)).unwrap()); - - assert!(matches!(batch.as_slice(), [Outbound::Raw { .. }])); - } - #[test] fn a_single_new_quest_opens_its_details_screen_directly() { let actions = one_quest(codec::ROLE_START, false, false); @@ -983,7 +911,7 @@ mod tests { // ── Accept ─────────────────────────────────────────────────────────────── #[test] - fn accept_requests_the_durable_accept_for_the_account_player_giver_and_quest() { + fn accept_requests_the_durable_accept_for_the_player_giver_and_quest() { let actions = loaded_quest(); let batch = @@ -992,7 +920,7 @@ mod tests { assert!(batch.is_empty(), "the client closes the window itself"); assert_eq!( actions.accept_requests.lock().unwrap().as_slice(), - &[(7, SELF_GUID, GIVER, QUEST)] + &[(SELF_GUID, GIVER, QUEST)] ); } @@ -1012,9 +940,7 @@ mod tests { #[test] fn a_dead_transport_on_accept_ends_the_session() { let actions = InMemoryQuestActions { - accept_error: Some( - "accept_quest reducer transport disconnected: channel closed".into(), - ), + transport_lost: true, ..loaded_quest() }; @@ -1023,7 +949,7 @@ mod tests { Ok(_) => panic!("a dead reducer transport must end the session"), }; - assert!(format!("{error:#}").contains("reducer transport disconnected")); + assert!(matches!(classify(&error), DurableFailure::TransportLoss)); } // ── The item-started quest ─────────────────────────────────────────────── @@ -1123,7 +1049,7 @@ mod tests { ); assert_eq!( actions.abandon_requests.lock().unwrap().as_slice(), - &[(7, SELF_GUID, 777)] + &[(SELF_GUID, 777)] ); } @@ -1169,9 +1095,7 @@ mod tests { fn a_dead_transport_on_abandon_ends_the_session() { let actions = InMemoryQuestActions { quest_log: vec![log_slot(3, 777)], - abandon_error: Some( - "abandon_quest reducer transport disconnected: channel closed".into(), - ), + transport_lost: true, ..Default::default() }; @@ -1180,7 +1104,7 @@ mod tests { Ok(_) => panic!("a dead reducer transport must end the session"), }; - assert!(format!("{error:#}").contains("reducer transport disconnected")); + assert!(matches!(classify(&error), DurableFailure::TransportLoss)); } #[test] @@ -1246,11 +1170,9 @@ mod tests { } #[test] - fn reducer_transport_failure_is_session_fatal() { + fn a_failed_interaction_gate_read_is_session_fatal() { let actions = InMemoryQuestActions { - gate_error: Some( - "npc_refuses_interaction reducer transport disconnected: channel closed".into(), - ), + transport_lost: true, ..one_quest(codec::ROLE_START, false, false) }; @@ -1259,7 +1181,7 @@ mod tests { Ok(_) => panic!("a dead reducer transport must end the session"), }; - assert!(format!("{error:#}").contains("reducer transport disconnected")); + assert!(matches!(classify(&error), DurableFailure::TransportLoss)); } // ── Pass-through ───────────────────────────────────────────────────────── @@ -1316,7 +1238,6 @@ mod tests { fn turn_in_of(reward_index: u32) -> TurnInCall { TurnInCall::TurnIn { - account_id: 7, self_guid: SELF_GUID, giver: GIVER, quest_id: QUEST, @@ -1470,9 +1391,7 @@ mod tests { #[test] fn a_reducer_transport_failure_on_turn_in_is_session_fatal() { let actions = InMemoryQuestActions { - turn_in_error: Some( - "turn_in_quest reducer transport disconnected: channel closed".into(), - ), + transport_lost: true, ..rewarded_turn_in() }; @@ -1481,7 +1400,7 @@ mod tests { Ok(_) => panic!("a dead reducer transport must end the session"), }; - assert!(format!("{error:#}").contains("reducer transport disconnected")); + assert!(matches!(classify(&error), DurableFailure::TransportLoss)); } // ── Party sharing ──────────────────────────────────────────────────────── @@ -1503,7 +1422,7 @@ mod tests { ); assert_eq!( actions.push_requests.lock().unwrap().as_slice(), - &[(7, SELF_GUID, QUEST)] + &[(SELF_GUID, QUEST)] ); } @@ -1523,7 +1442,7 @@ mod tests { #[test] fn a_dead_transport_on_share_ends_the_session() { let actions = InMemoryQuestActions { - push_error: Some("push_quest reducer transport disconnected: channel closed".into()), + transport_lost: true, ..Default::default() }; @@ -1532,7 +1451,7 @@ mod tests { Ok(_) => panic!("a dead reducer transport must end the session"), }; - assert!(format!("{error:#}").contains("reducer transport disconnected")); + assert!(matches!(classify(&error), DurableFailure::TransportLoss)); } // ── The gossip quest section ──────────────────────────────────────────── @@ -1648,8 +1567,14 @@ mod reward_letter_durable_tests { let coordinator = runtime.block_on(Coordinator::connect(&cfg)).unwrap(); assert!(!coordinator.is_sharded()); - QuestActionStore::turn_in_quest(&coordinator, 0, TESTER, GIVER, CARD_QUEST, 0) - .expect("the fixture quest is complete"); + QuestActionStore::turn_in_quest( + &coordinator, + crate::world::Actor::new(TESTER).unwrap(), + GIVER, + CARD_QUEST, + 0, + ) + .expect("the fixture quest is complete"); crate::world::mail::redrive(&coordinator, TESTER); assert!( diff --git a/gateway/src/world/quest_tests.rs b/gateway/src/world/quest_tests.rs index 61450b44b..bf572afdb 100644 --- a/gateway/src/world/quest_tests.rs +++ b/gateway/src/world/quest_tests.rs @@ -82,7 +82,7 @@ fn quest_choose_reward_relays_inventory_before_completion_over_the_cipher() { server.join().unwrap(); assert_eq!( store.quest.turned_in.lock().unwrap().as_slice(), - &[(7, 50, 1234, 2)] + &[(1, 50, 1234, 2)] ); } diff --git a/gateway/src/world/test_support/world_fake/quest.rs b/gateway/src/world/test_support/world_fake/quest.rs index 5704b252e..41efa5a8c 100644 --- a/gateway/src/world/test_support/world_fake/quest.rs +++ b/gateway/src/world/test_support/world_fake/quest.rs @@ -8,7 +8,7 @@ pub(crate) struct QuestState { pub(crate) quest_details: Vec, /// The player's quest-log slots `player_quest_log` returns (drives the login descriptor block). pub(crate) quest_log_slots: Vec, - /// Recorded `turn_in_quest` dispatches: (account, giver, quest, reward_index) — so the + /// Recorded `turn_in_quest` dispatches: (actor, giver, quest, reward_index) — so the /// choose-reward socket test asserts the player's pick reached the store unchanged. pub(crate) turned_in: std::sync::Mutex>, /// A Reward Letter a successful `turn_in_quest` files as Escrow for the Character, as @@ -46,13 +46,7 @@ impl QuestActionStore for WorldFake { /// Accept, abandon and share are driven only at the quest seam, where /// `InMemoryQuestActions` records the request — a socket test would prove nothing more, so /// this store just answers success. - fn accept_quest( - &self, - _account_id: u64, - _self_guid: u64, - _giver_guid: u64, - _quest_id: u32, - ) -> Result<()> { + fn accept_quest(&self, _actor: Actor, _giver_guid: u64, _quest_id: u32) -> Result<()> { Ok(()) } @@ -65,11 +59,11 @@ impl QuestActionStore for WorldFake { Ok(self.quest.quest_log_slots.clone()) } - fn abandon_quest(&self, _account_id: u64, _self_guid: u64, _quest_id: u32) -> Result<()> { + fn abandon_quest(&self, _actor: Actor, _quest_id: u32) -> Result<()> { Ok(()) } - fn push_quest(&self, _account_id: u64, _self_guid: u64, _quest_id: u32) -> Result<()> { + fn push_quest(&self, _actor: Actor, _quest_id: u32) -> Result<()> { Ok(()) } @@ -89,20 +83,20 @@ impl QuestActionStore for WorldFake { fn turn_in_quest( &self, - account_id: u64, - _self_guid: u64, + actor: Actor, giver_guid: u64, quest_id: u32, reward_index: u32, ) -> Result<()> { if let Some(e) = &self.trade_error { - return Err(anyhow!("{e}")); + return Err(crate::stdb::ReducerCallError::refused("gw_turn_in_quest", e).into()); } - self.quest - .turned_in - .lock() - .unwrap() - .push((account_id, giver_guid, quest_id, reward_index)); + self.quest.turned_in.lock().unwrap().push(( + actor.guid(), + giver_guid, + quest_id, + reward_index, + )); if let Some(letter) = self.quest.turn_in_reward_letter.clone() { self.mail .attested From 2ccbbd246b34edb4d3e362ec78daeea6b2091245 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 18:17:40 +0200 Subject: [PATCH 12/33] refactor(gateway): collapse the vendor family to one hop Inline the single-caller Coordinator wrappers into the VendorActionStore methods, drop the unused account id, take Actor, and classify failures with the typed Refusal and Transport Loss split. Co-Authored-By: Claude Sonnet 5.5 --- gateway/src/stdb/store/vendor.rs | 215 ++++-------- gateway/src/world/handlers/vendor.rs | 315 +++++++----------- .../world/test_support/world_fake/vendor.rs | 30 +- gateway/src/world/vendor_tests.rs | 7 +- 4 files changed, 199 insertions(+), 368 deletions(-) diff --git a/gateway/src/stdb/store/vendor.rs b/gateway/src/stdb/store/vendor.rs index 9c11c6a8b..f03f5099f 100644 --- a/gateway/src/stdb/store/vendor.rs +++ b/gateway/src/stdb/store/vendor.rs @@ -1,114 +1,22 @@ //! `Coordinator`'s [`VendorActionStore`] adapter. -use anyhow::{anyhow, Result}; +use anyhow::Result; use spacetimedb_sdk::Table; use crate::codec; use crate::stdb::bindings::*; use crate::stdb::connection::call_reducer; use crate::stdb::reads::property_enchant_ids; -use crate::stdb::Coordinator; use crate::world::{Actor, VendorActionStore}; impl VendorActionStore for crate::stdb::Coordinator { + /// Read a vendor's stock for `SMSG_LIST_INVENTORY`. Resolve the vendor's creature entry from + /// its `game_world_entity` row, filter `game_npc_vendor` to that entry, and join each + /// `game_item_template` for the display id / buy price / max durability (carrying the + /// npc_vendor row's `max_count`). Read from the privileged cache; the SDK exposes only the PK + /// index, so iterate and filter. A stock line whose template isn't loaded is skipped: it can't + /// be priced or displayed. Ordered by the vendor `slot`. fn vendor_stock(&self, vendor_guid: u64) -> Result> { - crate::stdb::Coordinator::vendor_items(self, vendor_guid) - } - - fn vendor_refuses_interaction(&self, vendor_guid: u64, player_guid: u64) -> Result { - crate::stdb::Coordinator::npc_refuses_interaction(self, vendor_guid, player_guid) - } - - fn vendor_buy( - &self, - account_id: u64, - self_guid: u64, - vendor_guid: u64, - item_entry: u32, - count: u32, - ) -> Result<()> { - crate::stdb::Coordinator::buy_item( - self, - account_id, - self_guid, - vendor_guid, - item_entry, - count, - ) - } - - fn buyback_slots(&self, player_guid: u64) -> Vec<(u32, u32, u32, u32)> { - crate::stdb::Coordinator::buyback_ring(self, player_guid) - } - - fn random_property_enchant_ids(&self, random_property_id: u32) -> [u32; 3] { - crate::stdb::Coordinator::random_property_enchant_ids(self, random_property_id) - } - - fn vendor_item_slot(&self, item_guid: u64) -> Option { - crate::stdb::Coordinator::item_slot_by_guid(self, 0, item_guid) - } - - fn vendor_repair( - &self, - account_id: u64, - self_guid: u64, - npc_guid: u64, - slot: u8, - ) -> Result<()> { - crate::stdb::Coordinator::repair_item(self, account_id, self_guid, npc_guid, slot) - } - - fn vendor_sell( - &self, - account_id: u64, - self_guid: u64, - vendor_guid: u64, - slot: u8, - ) -> Result<()> { - crate::stdb::Coordinator::sell_item(self, account_id, self_guid, vendor_guid, slot) - } - - fn vendor_buyback( - &self, - account_id: u64, - self_guid: u64, - vendor_guid: u64, - slot: u8, - ) -> Result<()> { - crate::stdb::Coordinator::buyback_item(self, account_id, self_guid, vendor_guid, slot) - } -} - -impl Coordinator { - /// The three enchant ids of `random_property_id`, zero where it names none. - pub fn random_property_enchant_ids(&self, random_property_id: u32) -> [u32; 3] { - let guard = self.0.coord(); - property_enchant_ids(&guard.conn.db, random_property_id) - } - - /// The player's buyback ring, newest-first: `(item_entry, stack_count, price)` ≤12. - pub fn buyback_ring(&self, player_guid: u64) -> Vec<(u32, u32, u32, u32)> { - let guard = self.0.coord(); - let db = &guard.conn.db; - let mut rows: Vec<_> = db - .game_character_buyback() - .iter() - .filter(|b| b.player_guid == player_guid) - .collect(); - rows.sort_by(|a, b| b.id.cmp(&a.id)); - rows.into_iter() - .map(|b| (b.item_entry, b.stack_count, b.price, b.random_property_id)) - .collect() - } - - /// Read a vendor's stock for `SMSG_LIST_INVENTORY` (Tier 2 / vendors). Resolve the vendor's - /// creature entry from its `game_world_entity` row, filter `game_npc_vendor` to that entry, and - /// join each `game_item_template` for the display id / buy price / max durability (carrying the - /// npc_vendor row's `max_count`). Read from the privileged cache (the coordinator bypasses RLS); - /// the SDK exposes only the PK index, so iterate+filter like the other row queries. Items whose - /// template isn't loaded are skipped (we can't price/display them). Ordered by the vendor `slot`. - pub fn vendor_items(&self, vendor_guid: u64) -> Result> { let guard = self.0.coord(); let db = &guard.conn.db; // The vendor's creature entry drives the stock lookup; a missing entity → no stock. @@ -129,11 +37,10 @@ impl Coordinator { let items = rows .into_iter() .filter_map(|v| { - // Skip a stock line whose template isn't loaded — we can't price or display it. db.game_item_template() .entry() .find(&v.item_entry) - .map(|t| crate::codec::VendorItemView { + .map(|t| codec::VendorItemView { item_entry: v.item_entry, display_id: t.display_id, buy_price: t.buy_price, @@ -146,82 +53,76 @@ impl Coordinator { Ok(items) } - /// Buy `count` of `item_entry` from the vendor `vendor_guid` (`CMSG_BUY_ITEM`, Tier 2) over the - /// coordinator connection so the module attributes the purchase to the caller. The module gates - /// it on the vendor (stock + NPC flags + range) and debits the buyer's copper. - pub fn buy_item( + fn vendor_refuses_interaction(&self, vendor_guid: u64, actor: Actor) -> Result { + self.npc_refuses_interaction(vendor_guid, actor.guid()) + } + + /// The module gates the purchase on the vendor (stock + NPC flags + range) and debits the + /// buyer's copper. + fn vendor_buy( &self, - _account_id: u64, - actor_guid: u64, + actor: Actor, vendor_guid: u64, item_entry: u32, count: u32, ) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("buy_item: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); call_reducer!( - coord.conn.reducers, + self.0.call_pipe().conn.reducers, "gw_buy_item", gw_buy_item_then(self.session_actor(actor), vendor_guid, item_entry, count) ) } - /// Sell the item in inventory `slot` back to a vendor (`CMSG_SELL_ITEM`, Tier 2) over the - /// coordinator connection. The gateway resolves the client's item-INSTANCE guid to the owning - /// slot before calling (the reducer takes the slot); the module credits the seller's copper. - /// Rides the coordinator connection as `gw_sell_item`. - pub fn sell_item( - &self, - _account_id: u64, - actor_guid: u64, - vendor_guid: u64, - slot: u8, - ) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("sell_item: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); + /// The player's buyback ring, newest-first: `(item_entry, stack_count, price, + /// random_property_id)`, at most 12. + fn buyback_slots(&self, player_guid: u64) -> Vec<(u32, u32, u32, u32)> { + let guard = self.0.coord(); + let db = &guard.conn.db; + let mut rows: Vec<_> = db + .game_character_buyback() + .iter() + .filter(|b| b.player_guid == player_guid) + .collect(); + rows.sort_by(|a, b| b.id.cmp(&a.id)); + rows.into_iter() + .map(|b| (b.item_entry, b.stack_count, b.price, b.random_property_id)) + .collect() + } + + fn random_property_enchant_ids(&self, random_property_id: u32) -> [u32; 3] { + let guard = self.0.coord(); + property_enchant_ids(&guard.conn.db, random_property_id) + } + + fn vendor_item_slot(&self, item_guid: u64) -> Option { + crate::stdb::Coordinator::item_slot_by_guid(self, 0, item_guid) + } + + /// The module gates the REPAIR NPC and charges copper; the player's item and purse replicate + /// back via subscription. + fn vendor_repair(&self, actor: Actor, npc_guid: u64, slot: u8) -> Result<()> { call_reducer!( - coord.conn.reducers, - "gw_sell_item", - gw_sell_item_then(self.session_actor(actor), vendor_guid, slot) + self.0.call_pipe().conn.reducers, + "gw_repair_item", + gw_repair_item_then(self.session_actor(actor), npc_guid, slot) ) } - pub fn buyback_item( - &self, - _account_id: u64, - actor_guid: u64, - vendor_guid: u64, - slot: u8, - ) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("buyback_item: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); + /// The gateway resolves the client's item-INSTANCE guid to the owning slot before calling + /// (the reducer takes the slot); the module credits the seller's copper. + fn vendor_sell(&self, actor: Actor, vendor_guid: u64, slot: u8) -> Result<()> { call_reducer!( - coord.conn.reducers, - "gw_buyback_item", - gw_buyback_item_then(self.session_actor(actor), vendor_guid, slot) + self.0.call_pipe().conn.reducers, + "gw_sell_item", + gw_sell_item_then(self.session_actor(actor), vendor_guid, slot) ) } - /// Repair the item in inventory `slot` at REPAIR-NPC `npc_guid` (`CMSG_REPAIR_ITEM`) over the - /// coordinator connection. The module gates the NPC + charges copper; the player's item + - /// purse replicate back via subscription. - pub fn repair_item( - &self, - _account_id: u64, - actor_guid: u64, - npc_guid: u64, - slot: u8, - ) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("repair_item: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); + fn vendor_buyback(&self, actor: Actor, vendor_guid: u64, slot: u8) -> Result<()> { call_reducer!( - coord.conn.reducers, - "gw_repair_item", - gw_repair_item_then(self.session_actor(actor), npc_guid, slot) + self.0.call_pipe().conn.reducers, + "gw_buyback_item", + gw_buyback_item_then(self.session_actor(actor), vendor_guid, slot) ) } } diff --git a/gateway/src/world/handlers/vendor.rs b/gateway/src/world/handlers/vendor.rs index a2483a02b..d2e31ffa3 100644 --- a/gateway/src/world/handlers/vendor.rs +++ b/gateway/src/world/handlers/vendor.rs @@ -3,22 +3,17 @@ //! and the world-entry ring replay call the shared builders here rather than reaching for the store. use super::super::*; +use crate::stdb::{classify, DurableFailure}; /// Durable reads and requests the vendor family needs, in the seam's own vocabulary so it can be /// exercised without the broad `WorldStore`. pub(crate) trait VendorActionStore: Send + Sync { fn vendor_stock(&self, vendor_guid: u64) -> Result>; - fn vendor_refuses_interaction(&self, vendor_guid: u64, player_guid: u64) -> Result; + fn vendor_refuses_interaction(&self, vendor_guid: u64, actor: Actor) -> Result; - fn vendor_buy( - &self, - account_id: u64, - self_guid: u64, - vendor_guid: u64, - item_entry: u32, - count: u32, - ) -> Result<()>; + fn vendor_buy(&self, actor: Actor, vendor_guid: u64, item_entry: u32, count: u32) + -> Result<()>; /// The player's buyback ring, newest-first: `(item_entry, stack_count, price, random_property_id)` per entry (≤12). fn buyback_slots(&self, player_guid: u64) -> Vec<(u32, u32, u32, u32)>; @@ -30,26 +25,13 @@ pub(crate) trait VendorActionStore: Send + Sync { /// owner check is needed here — the module reducer enforces ownership on the repair call. fn vendor_item_slot(&self, item_guid: u64) -> Option; - fn vendor_repair(&self, account_id: u64, self_guid: u64, npc_guid: u64, slot: u8) - -> Result<()>; + fn vendor_repair(&self, actor: Actor, npc_guid: u64, slot: u8) -> Result<()>; - fn vendor_sell( - &self, - account_id: u64, - self_guid: u64, - vendor_guid: u64, - slot: u8, - ) -> Result<()>; + fn vendor_sell(&self, actor: Actor, vendor_guid: u64, slot: u8) -> Result<()>; /// Re-purchase the ring entry at 0-based `slot` from `vendor_guid`. The gateway maps the wire /// `BuybackSlot` enum via [`BUYBACK_WIRE_SLOT_BASE`] before calling. - fn vendor_buyback( - &self, - account_id: u64, - self_guid: u64, - vendor_guid: u64, - slot: u8, - ) -> Result<()>; + fn vendor_buyback(&self, actor: Actor, vendor_guid: u64, slot: u8) -> Result<()>; } /// Wire slot of the first buyback tab entry (`BuybackSlot::Slot1`); the 12 ring entries render at @@ -73,36 +55,25 @@ pub(crate) enum VendorActionOutcome { PassThrough(ClientOpcodeMessage), } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum VendorActionErrorClass { - GameplayRefusal, - Fatal, -} - -fn classify_vendor_action_error(error: &anyhow::Error) -> VendorActionErrorClass { - if error - .chain() - .any(|cause| cause.to_string().contains("reducer transport disconnected")) - { - VendorActionErrorClass::Fatal - } else { - VendorActionErrorClass::GameplayRefusal - } +/// 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 { .. }) } -/// The interaction gate fails open — missing standing data must not lock a player out of a vendor — -/// but a dead reducer transport is not missing data and ends the session. +/// The interaction gate fails open on a Refusal — missing standing data must not lock a player +/// out of a vendor — but a Transport Loss ends the session. fn refuses_interaction( store: &St, player: VendorActionPlayer, + actor: Option, vendor_guid: u64, ) -> Result { - let Some(self_guid) = player.self_guid else { + let Some(actor) = actor else { return Ok(false); }; - match store.vendor_refuses_interaction(vendor_guid, self_guid) { + match store.vendor_refuses_interaction(vendor_guid, actor) { Ok(refuses) => Ok(refuses), - Err(e) if classify_vendor_action_error(&e) == VendorActionErrorClass::GameplayRefusal => { + Err(e) if is_refusal(&e) => { log::debug!( "world: vendor {vendor_guid} interaction gate unavailable (account {}): {e}", player.account_id @@ -139,12 +110,13 @@ pub(crate) fn dispatch_vendor_action( 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, vendor_guid)? { + if refuses_interaction(store, player, actor, vendor_guid)? { return Ok(VendorActionOutcome::Handled { outbound: Vec::new(), }); @@ -158,31 +130,26 @@ pub(crate) fn dispatch_vendor_action( ClientOpcodeMessage::CMSG_BUY_ITEM(c) => { let vendor_guid = c.vendor.guid(); let item_entry = c.item; - let outbound = match store.vendor_buy( - player.account_id, - player.self_guid.unwrap_or(0), - vendor_guid, - item_entry, - c.amount as u32, - ) { - Ok(()) => Vec::new(), - Err(e) - if classify_vendor_action_error(&e) - == VendorActionErrorClass::GameplayRefusal => - { - log::debug!( - "world: vendor_buy failed (account {}): {e}", - player.account_id - ); - vec![Outbound::One(ServerOpcodeMessage::SMSG_BUY_FAILED( - Box::new(codec::build_buy_failed( - vendor_guid, - item_entry, - &e.to_string(), - )), - ))] + 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(VendorActionOutcome::Handled { outbound }) } @@ -190,7 +157,7 @@ pub(crate) fn dispatch_vendor_action( // 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(self_guid) = player.self_guid else { + let Some(actor) = actor else { return Ok(VendorActionOutcome::Handled { outbound: Vec::new(), }); @@ -206,14 +173,11 @@ pub(crate) fn dispatch_vendor_action( outbound: Vec::new(), }); }; - match store.vendor_repair(player.account_id, self_guid, c.npc.guid(), slot) { + match store.vendor_repair(actor, c.npc.guid(), slot) { Ok(()) => Ok(VendorActionOutcome::Handled { outbound: Vec::new(), }), - Err(e) - if classify_vendor_action_error(&e) - == VendorActionErrorClass::GameplayRefusal => - { + Err(e) if is_refusal(&e) => { log::debug!( "world: repair_item ignored (account {}): {e}", player.account_id @@ -230,7 +194,7 @@ pub(crate) fn dispatch_vendor_action( // 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(self_guid) = player.self_guid else { + let Some(actor) = actor else { return Ok(VendorActionOutcome::Handled { outbound: Vec::new(), }); @@ -240,15 +204,12 @@ pub(crate) fn dispatch_vendor_action( outbound: Vec::new(), }); }; - match store.vendor_sell(player.account_id, self_guid, c.vendor.guid(), slot) { + 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, self_guid), + outbound: build_buyback_view(store, actor.guid()), }), - Err(e) - if classify_vendor_action_error(&e) - == VendorActionErrorClass::GameplayRefusal => - { + Err(e) if is_refusal(&e) => { log::debug!( "world: sell_item ignored (account {}): {e}", player.account_id @@ -264,27 +225,20 @@ pub(crate) fn dispatch_vendor_action( // 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 vendor_guid = c.guid.guid(); + 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( - player.account_id, - player.self_guid.unwrap_or(0), - vendor_guid, - slot, - ) { + match store.vendor_buyback(actor, c.guid.guid(), slot) { Ok(()) => Ok(VendorActionOutcome::Handled { - outbound: match player.self_guid { - Some(self_guid) => build_buyback_view(store, self_guid), - None => Vec::new(), - }, + outbound: build_buyback_view(store, actor.guid()), }), - Err(e) - if classify_vendor_action_error(&e) - == VendorActionErrorClass::GameplayRefusal => - { + Err(e) if is_refusal(&e) => { log::debug!( "world: buyback_item ignored (account {}): {e}", player.account_id @@ -379,15 +333,18 @@ fn render_buyback_view( #[cfg(test)] pub(super) mod tests { use super::*; + use crate::stdb::ReducerCallError; use std::sync::Mutex; use wow_world_messages::vanilla::{ - BuybackSlot, Guid, CMSG_BUYBACK_ITEM, CMSG_BUY_ITEM, CMSG_LIST_INVENTORY, CMSG_PING, - CMSG_REPAIR_ITEM, CMSG_SELL_ITEM, + BuyResult, BuybackSlot, Guid, CMSG_BUYBACK_ITEM, CMSG_BUY_ITEM, CMSG_LIST_INVENTORY, + CMSG_PING, CMSG_REPAIR_ITEM, CMSG_SELL_ITEM, }; + /// How a Fake call fails: a constructor for the typed error, so every call builds a fresh one. + pub(crate) type Failure = fn() -> ReducerCallError; + #[derive(Debug, Eq, PartialEq)] pub(crate) struct BuyRequest { - account_id: u64, actor_guid: u64, vendor_guid: u64, item_entry: u32, @@ -399,60 +356,58 @@ pub(super) mod tests { pub(crate) stock_requests: Mutex>, pub(crate) gate_requests: Mutex>, pub(crate) buy_requests: Mutex>, - pub(crate) repair_requests: Mutex>, - pub(crate) sell_requests: Mutex>, + pub(crate) repair_requests: Mutex>, + pub(crate) sell_requests: Mutex>, pub(crate) stock: Vec, pub(crate) refuses: bool, - pub(crate) stock_error: Option, - pub(crate) gate_error: Option, - pub(crate) buy_error: Option, + pub(crate) stock_error: Option, + pub(crate) gate_error: Option, + pub(crate) buy_error: Option, /// The player's buyback ring; a successful buyback removes the entry it took. pub(crate) ring: Mutex>, pub(crate) random_properties: Vec<(u32, [u32; 3])>, pub(crate) item_slots: Vec<(u64, u8)>, - pub(crate) repair_error: Option, - pub(crate) sell_error: Option, - pub(crate) buyback_requests: Mutex>, - pub(crate) buyback_error: Option, + pub(crate) repair_error: Option, + pub(crate) sell_error: Option, + pub(crate) buyback_requests: Mutex>, + pub(crate) buyback_error: Option, } impl VendorActionStore for InMemoryVendorActions { fn vendor_stock(&self, vendor_guid: u64) -> Result> { self.stock_requests.lock().unwrap().push(vendor_guid); match &self.stock_error { - Some(error) => Err(anyhow::anyhow!("{error}")), + Some(fail) => Err(fail().into()), None => Ok(self.stock.clone()), } } - fn vendor_refuses_interaction(&self, vendor_guid: u64, player_guid: u64) -> Result { + fn vendor_refuses_interaction(&self, vendor_guid: u64, actor: Actor) -> Result { self.gate_requests .lock() .unwrap() - .push((vendor_guid, player_guid)); + .push((vendor_guid, actor.guid())); match &self.gate_error { - Some(error) => Err(anyhow::anyhow!("{error}")), + Some(fail) => Err(fail().into()), None => Ok(self.refuses), } } fn vendor_buy( &self, - account_id: u64, - self_guid: u64, + actor: Actor, vendor_guid: u64, item_entry: u32, count: u32, ) -> Result<()> { self.buy_requests.lock().unwrap().push(BuyRequest { - account_id, - actor_guid: self_guid, + actor_guid: actor.guid(), vendor_guid, item_entry, count, }); match &self.buy_error { - Some(error) => Err(anyhow::anyhow!("{error}")), + Some(fail) => Err(fail().into()), None => Ok(()), } } @@ -475,53 +430,35 @@ pub(super) mod tests { .map(|&(_, s)| s) } - fn vendor_repair( - &self, - account_id: u64, - self_guid: u64, - npc_guid: u64, - slot: u8, - ) -> Result<()> { + fn vendor_repair(&self, actor: Actor, npc_guid: u64, slot: u8) -> Result<()> { self.repair_requests .lock() .unwrap() - .push((account_id, self_guid, npc_guid, slot)); + .push((actor.guid(), npc_guid, slot)); match &self.repair_error { - Some(error) => Err(anyhow::anyhow!("{error}")), + Some(fail) => Err(fail().into()), None => Ok(()), } } - fn vendor_sell( - &self, - account_id: u64, - self_guid: u64, - vendor_guid: u64, - slot: u8, - ) -> Result<()> { + fn vendor_sell(&self, actor: Actor, vendor_guid: u64, slot: u8) -> Result<()> { self.sell_requests .lock() .unwrap() - .push((account_id, self_guid, vendor_guid, slot)); + .push((actor.guid(), vendor_guid, slot)); match &self.sell_error { - Some(error) => Err(anyhow::anyhow!("{error}")), + Some(fail) => Err(fail().into()), None => Ok(()), } } - fn vendor_buyback( - &self, - account_id: u64, - self_guid: u64, - vendor_guid: u64, - slot: u8, - ) -> Result<()> { + fn vendor_buyback(&self, actor: Actor, vendor_guid: u64, slot: u8) -> Result<()> { self.buyback_requests .lock() .unwrap() - .push((account_id, self_guid, vendor_guid, slot)); - if let Some(error) = &self.buyback_error { - return Err(anyhow::anyhow!("{error}")); + .push((actor.guid(), vendor_guid, slot)); + if let Some(fail) = self.buyback_error { + return Err(fail().into()); } let mut ring = self.ring.lock().unwrap(); if usize::from(slot) < ring.len() { @@ -616,7 +553,9 @@ pub(super) mod tests { #[test] fn an_unavailable_interaction_gate_still_opens_the_window() { let actions = InMemoryVendorActions { - gate_error: Some("no standing row for that faction".into()), + gate_error: Some(|| { + ReducerCallError::refused("npc_refuses_interaction", "no standing row") + }), stock: vec![stock_item(2589)], ..Default::default() }; @@ -630,34 +569,34 @@ pub(super) mod tests { } #[test] - fn reducer_transport_failure_is_session_fatal() { + fn a_transport_loss_ends_the_session_in_every_vendor_call() { for (actions, msg) in [ ( InMemoryVendorActions { - gate_error: Some( - "npc_refuses_interaction reducer transport disconnected".into(), - ), + gate_error: Some(|| { + ReducerCallError::transport_lost("npc_refuses_interaction") + }), ..Default::default() }, list_inventory(), ), ( InMemoryVendorActions { - stock_error: Some("vendor_items reducer transport disconnected".into()), + stock_error: Some(|| ReducerCallError::transport_lost("vendor_items")), ..Default::default() }, list_inventory(), ), ( InMemoryVendorActions { - buy_error: Some("buy_item reducer transport disconnected".into()), + buy_error: Some(|| ReducerCallError::transport_lost("gw_buy_item")), ..Default::default() }, buy_item(2589, 1), ), ( InMemoryVendorActions { - repair_error: Some("repair_item reducer transport disconnected".into()), + repair_error: Some(|| ReducerCallError::transport_lost("gw_repair_item")), ..Default::default() }, repair_item(0), @@ -665,14 +604,14 @@ pub(super) mod tests { ( InMemoryVendorActions { item_slots: vec![(ITEM, 30)], - sell_error: Some("sell_item reducer transport disconnected".into()), + sell_error: Some(|| ReducerCallError::transport_lost("gw_sell_item")), ..Default::default() }, sell_item(ITEM), ), ( InMemoryVendorActions { - buyback_error: Some("buyback_item reducer transport disconnected".into()), + buyback_error: Some(|| ReducerCallError::transport_lost("gw_buyback_item")), ..Default::default() }, buyback_item(BuybackSlot::Slot1), @@ -680,9 +619,9 @@ pub(super) mod tests { ] { let error = match dispatch_vendor_action(&actions, player(), msg) { Err(error) => error, - Ok(_) => panic!("a dead reducer transport must end the session"), + Ok(_) => panic!("a transport loss must end the session"), }; - assert!(format!("{error:#}").contains("reducer transport disconnected")); + assert_eq!(classify(&error), DurableFailure::TransportLoss); } } @@ -729,7 +668,6 @@ pub(super) mod tests { assert_eq!( actions.buy_requests.lock().unwrap().as_slice(), &[BuyRequest { - account_id: 7, actor_guid: 42, vendor_guid: VENDOR, item_entry: 2589, @@ -741,7 +679,9 @@ pub(super) mod tests { #[test] fn a_rejected_purchase_sends_smsg_buy_failed() { let actions = InMemoryVendorActions { - buy_error: Some("not enough money to buy that item".into()), + buy_error: Some(|| { + ReducerCallError::refused("gw_buy_item", "not enough money to buy that item") + }), ..Default::default() }; @@ -758,25 +698,23 @@ pub(super) mod tests { } #[test] - fn a_buyer_without_an_actor_falls_back_to_the_legacy_zero_actor() { + 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, }; - dispatch_vendor_action(&actions, player, buy_item(2589, 1)).unwrap(); + let outcome = dispatch_vendor_action(&actions, player, buy_item(2589, 1)).unwrap(); - assert_eq!( - actions.buy_requests.lock().unwrap().as_slice(), - &[BuyRequest { - account_id: 7, - actor_guid: 0, - vendor_guid: VENDOR, - item_entry: 2589, - count: 1, - }] - ); + assert!(matches!( + outcome, + VendorActionOutcome::Handled { outbound } + if matches!(outbound.as_slice(), + [Outbound::One(ServerOpcodeMessage::SMSG_BUY_FAILED(p))] + if matches!(p.result, BuyResult::CantFindItem)) + )); + assert!(actions.buy_requests.lock().unwrap().is_empty()); } #[test] @@ -795,7 +733,7 @@ pub(super) mod tests { )); assert_eq!( actions.repair_requests.lock().unwrap().as_slice(), - &[(7, 42, NPC, 7)] + &[(42, NPC, 7)] ); } @@ -811,7 +749,7 @@ pub(super) mod tests { )); assert_eq!( actions.repair_requests.lock().unwrap().as_slice(), - &[(7, 42, NPC, u8::MAX)] + &[(42, NPC, u8::MAX)] ); } @@ -831,13 +769,17 @@ pub(super) mod tests { #[test] fn a_rejected_repair_relays_the_same_private_system_message() { let actions = InMemoryVendorActions { - repair_error: Some("not enough money to repair".into()), + repair_error: Some(|| { + ReducerCallError::refused("gw_repair_item", "not enough money to repair") + }), ..Default::default() }; let outcome = dispatch_vendor_action(&actions, player(), repair_item(0)).unwrap(); - let expected = codec::build_gm_system_message("not enough money to repair".to_string()); + 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 } @@ -860,7 +802,7 @@ pub(super) mod tests { assert_eq!( actions.sell_requests.lock().unwrap().as_slice(), - &[(7, 42, VENDOR, 30)] + &[(42, VENDOR, 30)] ); match outcome { VendorActionOutcome::Handled { outbound } => assert_renders_ring(&outbound, &ring), @@ -885,7 +827,9 @@ pub(super) mod tests { fn a_rejected_sale_is_silent_and_non_fatal() { let actions = InMemoryVendorActions { item_slots: vec![(ITEM, 30)], - sell_error: Some("that item cannot be sold".into()), + sell_error: Some(|| { + ReducerCallError::refused("gw_sell_item", "that item cannot be sold") + }), ..Default::default() }; @@ -1110,7 +1054,7 @@ pub(super) mod tests { assert_eq!( actions.buyback_requests.lock().unwrap().as_slice(), - &[(7, 42, VENDOR, 0), (7, 42, VENDOR, 12)] + &[(42, VENDOR, 0), (42, VENDOR, 12)] ); } @@ -1133,7 +1077,7 @@ pub(super) mod tests { } #[test] - fn a_buyback_without_an_actor_falls_back_to_the_legacy_zero_actor_and_renders_no_view() { + fn a_buyback_without_an_actor_is_silent() { let actions = InMemoryVendorActions { ring: Mutex::new(vec![(2589, 5, 120, 0)]), ..Default::default() @@ -1149,16 +1093,15 @@ pub(super) mod tests { assert!( matches!(outcome, VendorActionOutcome::Handled { outbound } if outbound.is_empty()) ); - assert_eq!( - actions.buyback_requests.lock().unwrap().as_slice(), - &[(7, 0, VENDOR, 0)] - ); + assert!(actions.buyback_requests.lock().unwrap().is_empty()); } #[test] fn a_rejected_buyback_is_silent_and_non_fatal() { let actions = InMemoryVendorActions { - buyback_error: Some("no such buyback slot".into()), + buyback_error: Some(|| { + ReducerCallError::refused("gw_buyback_item", "no such buyback slot") + }), ..Default::default() }; diff --git a/gateway/src/world/test_support/world_fake/vendor.rs b/gateway/src/world/test_support/world_fake/vendor.rs index 11135f580..e9a41f179 100644 --- a/gateway/src/world/test_support/world_fake/vendor.rs +++ b/gateway/src/world/test_support/world_fake/vendor.rs @@ -1,4 +1,5 @@ use super::super::*; +use crate::world::Actor; #[derive(Default)] pub(crate) struct VendorState { @@ -16,14 +17,13 @@ impl VendorActionStore for WorldFake { Ok(self.vendor.vendor_stock.clone()) } - fn vendor_refuses_interaction(&self, _vendor_guid: u64, _player_guid: u64) -> Result { + fn vendor_refuses_interaction(&self, _vendor_guid: u64, _actor: Actor) -> Result { Ok(self.npc_refuses) } fn vendor_buy( &self, - _account_id: u64, - _self_guid: u64, + _actor: Actor, _vendor_guid: u64, _item_entry: u32, _count: u32, @@ -43,33 +43,15 @@ impl VendorActionStore for WorldFake { None } - fn vendor_repair( - &self, - _account_id: u64, - _self_guid: u64, - _npc_guid: u64, - _slot: u8, - ) -> Result<()> { + fn vendor_repair(&self, _actor: Actor, _npc_guid: u64, _slot: u8) -> Result<()> { Ok(()) } - fn vendor_sell( - &self, - _account_id: u64, - _self_guid: u64, - _vendor_guid: u64, - _slot: u8, - ) -> Result<()> { + fn vendor_sell(&self, _actor: Actor, _vendor_guid: u64, _slot: u8) -> Result<()> { Ok(()) } - fn vendor_buyback( - &self, - _account_id: u64, - _self_guid: u64, - _vendor_guid: u64, - _slot: u8, - ) -> Result<()> { + fn vendor_buyback(&self, _actor: Actor, _vendor_guid: u64, _slot: u8) -> Result<()> { Ok(()) } } diff --git a/gateway/src/world/vendor_tests.rs b/gateway/src/world/vendor_tests.rs index a83f741e9..e56eb9abd 100644 --- a/gateway/src/world/vendor_tests.rs +++ b/gateway/src/world/vendor_tests.rs @@ -23,7 +23,12 @@ fn buy_item_err_sends_smsg_buy_failed() { // When `buy_item` returns Err (e.g. "not enough money"), the gateway must send SMSG_BUY_FAILED // with the matching BuyResult code so the player gets an on-screen error. let actions = InMemoryVendorActions { - buy_error: Some("not enough money to buy that item".into()), + buy_error: Some(|| { + crate::stdb::ReducerCallError::refused( + "gw_buy_item", + "not enough money to buy that item", + ) + }), ..Default::default() }; let sent = run( From 36ff22a51e5071fdea57095fe310f1a30ec249cb Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 18:17:40 +0200 Subject: [PATCH 13/33] refactor(gateway): collapse the auction family to one hop Inline the single-caller Coordinator methods into the AuctionActionStore methods, take Actor in the requests, resolve Hold actors once, and decode Refusals with classify. Co-Authored-By: Claude Sonnet 5.5 --- gateway/src/stdb/store/auction.rs | 514 +++++++++--------- gateway/src/world/handlers/auction.rs | 167 +++--- .../world/test_support/world_fake/auction.rs | 7 +- 3 files changed, 318 insertions(+), 370 deletions(-) diff --git a/gateway/src/stdb/store/auction.rs b/gateway/src/stdb/store/auction.rs index 952a2ed39..98d7ba5de 100644 --- a/gateway/src/stdb/store/auction.rs +++ b/gateway/src/stdb/store/auction.rs @@ -8,19 +8,19 @@ use lyracore_shared::item::Proficiency; use spacetimedb_sdk::Table; use crate::stdb::bindings::*; -use crate::stdb::connection::{call_reducer, reducer_refusal_reason}; +use crate::stdb::connection::{call_reducer, classify, DurableFailure}; use crate::stdb::reducers::{next_operation_id, wait_for_cache_row}; use crate::stdb::Coordinator; use crate::world::{ - AuctionActionStore, AuctionBrowseRequest, AuctionHousePolicy, AuctionInteraction, AuctionPage, - AuctionQuery, CancelAuctionOutcome, CancelAuctionRequest, CreateAuctionOutcome, + Actor, AuctionActionStore, AuctionBrowseRequest, AuctionHousePolicy, AuctionInteraction, + AuctionPage, AuctionQuery, CancelAuctionOutcome, CancelAuctionRequest, CreateAuctionOutcome, CreateAuctionRequest, PlaceBidOutcome, PlaceBidRequest, }; impl AuctionActionStore for crate::stdb::Coordinator { fn auction_interaction( &self, - player_guid: u64, + actor: Actor, auctioneer_guid: u64, ) -> Result> { use crate::stdb::bindings::{ @@ -58,106 +58,168 @@ impl AuctionActionStore for crate::stdb::Coordinator { }; house }; - let refuses_interaction = self.npc_refuses_interaction(auctioneer_guid, player_guid)?; + let refuses_interaction = self.npc_refuses_interaction(auctioneer_guid, actor.guid())?; Ok(Some(AuctionInteraction { house, refuses_interaction, })) } + /// Create one auction listing. Single-database deployments use one atomic reducer; sharded + /// deployments drive the durable Hold -> Auction -> receipt -> settle protocol. fn create_auction(&self, request: CreateAuctionRequest) -> Result { - crate::stdb::Coordinator::create_auction(self, request) - } - - fn place_bid(&self, request: PlaceBidRequest) -> Result { - crate::stdb::Coordinator::place_bid(self, request) - } + let item_is_present = self + .0 + .coord() + .conn + .db + .game_item_instance() + .guid() + .find(&request.item_guid) + .is_some(); + // A successful listing removes the source item in the same transaction that records its + // Hold or receipt. Presence therefore proves this is a new request even when a returned + // item later reuses the same guid and terms. + if !item_is_present { + if let Some(receipt) = self.matching_active_auction_receipt(request)? { + if self + .0 + .coord() + .conn + .db + .game_auction_hold() + .operation_id() + .find(&receipt.operation_id) + .is_some() + { + self.auction_settle_listing(receipt.operation_id)?; + } + return Ok(CreateAuctionOutcome::Created { + auction_id: receipt.auction_id, + }); + } + } - fn cancel_auction(&self, request: CancelAuctionRequest) -> Result { - crate::stdb::Coordinator::cancel_auction(self, request) - } + let operation_id = if item_is_present { + next_operation_id()? + } else { + match self.matching_auction_hold(request) { + Some(hold) => hold.operation_id, + None => next_operation_id()?, + } + }; - fn resume_auction_holds(&self, actor_guid: u64) -> Result<()> { - crate::stdb::Coordinator::resume_auction_holds(self, actor_guid) - } + let result = if self.is_sharded() { + self.drive_sharded_auction_listing(operation_id, request) + } else { + self.auction_list_local(operation_id, request) + .and_then(|()| self.wait_for_auction_receipt(operation_id)) + .map(|receipt| receipt.auction_id) + }; - fn auction_query( - &self, - player_guid: u64, - house_id: u32, - query: AuctionQuery, - ) -> Result { - crate::stdb::Coordinator::auction_query(self, player_guid, house_id, query) + match result { + Ok(auction_id) => Ok(CreateAuctionOutcome::Created { auction_id }), + Err(error) => match auction_refusal(&error) { + Some(refusal) => Ok(refusal.into()), + None => Err(error), + }, + } } -} -impl Coordinator { - /// The keys `pick` names for `character_guid` in THIS handle's auction index. - pub(super) fn auction_keys( - &self, - pick: impl Fn( - &crate::stdb::auction_holds::AuctionIndex, - ) -> &crate::stdb::auction_holds::KeysByCharacter, - character_guid: u64, - ) -> Vec { - let guard = self.0.coord(); - let index = guard - .auctions - .read() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - pick(&index).keys_of(character_guid) + /// Place one full-offer bid. The sharded path fences the bidder purse before realm-core makes + /// its serialized Auction decision; both paths finish with the same normalized terminal Hold. + fn place_bid(&self, request: PlaceBidRequest) -> Result { + let operation_id = self + .matching_unfinished_bid_hold(request) + .map_or_else(next_operation_id, |hold| Ok(hold.operation_id))?; + let result = if self.is_sharded() { + self.drive_sharded_auction_bid(operation_id, request) + } else { + self.auction_bid_local(operation_id, request) + .and_then(|()| self.wait_for_terminal_bid_hold(operation_id)) + }; + match result { + Ok(hold) => bid_outcome(&hold), + Err(error) => match auction_refusal(&error) { + Some(refusal) => Ok(refusal.into()), + None => Err(error), + }, + } } - /// The rows `pick` names for `character_guid` in THIS handle's auction index, read back by - /// primary key through `find`. A row whose `owner` is no longer `character_guid` is dropped, - /// because the index can trail the cache. - pub(super) fn indexed_rows( - &self, - pick: impl Fn( - &crate::stdb::auction_holds::AuctionIndex, - ) -> &crate::stdb::auction_holds::KeysByCharacter, - character_guid: u64, - find: impl Fn(&RemoteTables, u64) -> Option, - owner: impl Fn(&T) -> u64, - ) -> Vec { - let keys = self.auction_keys(pick, character_guid); - let guard = self.0.coord(); - keys.into_iter() - .filter_map(|key| find(&guard.conn.db, key)) - .filter(|row| owner(row) == character_guid) - .collect() + /// Cancel one listing. The Gateway reads the listing from the Realm-core cache and fences the + /// Auction Cut it computes; Realm-core refuses the Cancellation if a later bid changed that cut. + /// A pending Cancellation of the same listing is driven again instead of fencing a second cut. + fn cancel_auction(&self, request: CancelAuctionRequest) -> Result { + let pending = self + .unfinished_auction_holds(request.actor.guid()) + .find(|hold| { + hold.operation == lyracore_shared::auction::hold_operation::CANCEL + && hold.outcome == lyracore_shared::auction::bid_outcome::PENDING + && hold.auction_id == request.auction_id + && hold.house == request.house_id + }); + let (operation_id, cut) = match pending { + Some(hold) => (hold.operation_id, hold.offer), + None => match self.realm_core()?.cancellation_cut(request) { + Some(cut) => (next_operation_id()?, cut), + None => return Ok(CancelAuctionOutcome::NotFound), + }, + }; + let result = if self.is_sharded() { + self.auction_hold_cancel(operation_id, request, cut) + .and_then(|()| self.wait_for_auction_bid_hold(operation_id)) + .and_then(|hold| self.complete_auction_hold(hold)) + } else { + self.auction_cancel_local(operation_id, request, cut) + .and_then(|()| self.wait_for_terminal_bid_hold(operation_id)) + }; + match result { + Ok(hold) => cancel_outcome(&hold), + Err(error) => match auction_refusal(&error) { + Some(refusal) => Ok(refusal.into()), + None => Err(error), + }, + } } - /// `actor_guid`'s unfinished Holds on THIS handle. - pub(super) fn unfinished_auction_holds( - &self, - actor_guid: u64, - ) -> impl Iterator { - self.indexed_rows( - |index| &index.unfinished_bid_holds, - actor_guid, - |db, id| db.game_auction_bid_hold().operation_id().find(&id), - |hold| hold.bidder_guid, - ) - .into_iter() - .filter(crate::stdb::auction_holds::hold_is_unfinished) + /// Finish every unfinished Hold of `actor` on this Home Shard, listing Holds first. A Refusal + /// leaves that Hold for a later attempt; a Transport Loss ends the request. + fn resume_auction_holds(&self, actor: Actor) -> Result<()> { + let actor_guid = actor.guid(); + for hold in self.listing_holds(actor_guid) { + let operation_id = hold.operation_id; + if let Err(error) = self.complete_listing_hold(&hold) { + match auction_refusal(&error) { + Some(refusal) => log::warn!( + "listing Hold {operation_id} of {actor_guid} did not resume: {refusal:?}" + ), + None => return Err(error), + } + } + } + let holds: Vec<_> = self.unfinished_auction_holds(actor_guid).collect(); + for hold in holds { + let operation_id = hold.operation_id; + if let Err(error) = self.complete_auction_hold(hold) { + match auction_refusal(&error) { + Some(refusal) => log::warn!( + "auction Hold {operation_id} of {actor_guid} did not resume: {refusal:?}" + ), + None => return Err(error), + } + } + } + Ok(()) } - /// `seller_guid`'s listing Holds on THIS handle. - pub(super) fn listing_holds(&self, seller_guid: u64) -> Vec { - self.indexed_rows( - |index| &index.listing_holds, - seller_guid, - |db, id| db.game_auction_hold().operation_id().find(&id), - |hold| hold.seller_guid, - ) - } - pub(crate) fn auction_query( + fn auction_query( &self, - player_guid: u64, + actor: Actor, house_id: u32, query: AuctionQuery, ) -> Result { + let player_guid = actor.guid(); let (player_level, proficiency) = { let guard = self.0.coord(); let db = &guard.conn.db; @@ -230,73 +292,69 @@ impl Coordinator { now_micros, }) } +} - /// Create one auction listing. Single-database deployments use one atomic reducer; sharded - /// deployments drive the durable Hold -> Auction -> receipt -> settle protocol. - pub(crate) fn create_auction( +impl Coordinator { + /// The keys `pick` names for `character_guid` in THIS handle's auction index. + pub(super) fn auction_keys( &self, - request: crate::world::CreateAuctionRequest, - ) -> Result { - use crate::world::CreateAuctionOutcome; - - let item_is_present = self - .0 - .coord() - .conn - .db - .game_item_instance() - .guid() - .find(&request.item_guid) - .is_some(); - // A successful listing removes the source item in the same transaction that records its - // Hold or receipt. Presence therefore proves this is a new request even when a returned - // item later reuses the same guid and terms. - if !item_is_present { - if let Some(receipt) = self.matching_active_auction_receipt(request)? { - if self - .0 - .coord() - .conn - .db - .game_auction_hold() - .operation_id() - .find(&receipt.operation_id) - .is_some() - { - self.auction_settle_listing(receipt.operation_id)?; - } - return Ok(CreateAuctionOutcome::Created { - auction_id: receipt.auction_id, - }); - } - } - - let operation_id = if item_is_present { - next_operation_id()? - } else { - match self.matching_auction_hold(request) { - Some(hold) => hold.operation_id, - None => next_operation_id()?, - } - }; + pick: impl Fn( + &crate::stdb::auction_holds::AuctionIndex, + ) -> &crate::stdb::auction_holds::KeysByCharacter, + character_guid: u64, + ) -> Vec { + let guard = self.0.coord(); + let index = guard + .auctions + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + pick(&index).keys_of(character_guid) + } - let result = if self.is_sharded() { - self.drive_sharded_auction_listing(operation_id, request) - } else { - self.auction_list_local(operation_id, request) - .and_then(|()| self.wait_for_auction_receipt(operation_id)) - .map(|receipt| receipt.auction_id) - }; + /// The rows `pick` names for `character_guid` in THIS handle's auction index, read back by + /// primary key through `find`. A row whose `owner` is no longer `character_guid` is dropped, + /// because the index can trail the cache. + pub(super) fn indexed_rows( + &self, + pick: impl Fn( + &crate::stdb::auction_holds::AuctionIndex, + ) -> &crate::stdb::auction_holds::KeysByCharacter, + character_guid: u64, + find: impl Fn(&RemoteTables, u64) -> Option, + owner: impl Fn(&T) -> u64, + ) -> Vec { + let keys = self.auction_keys(pick, character_guid); + let guard = self.0.coord(); + keys.into_iter() + .filter_map(|key| find(&guard.conn.db, key)) + .filter(|row| owner(row) == character_guid) + .collect() + } - match result { - Ok(auction_id) => Ok(CreateAuctionOutcome::Created { auction_id }), - Err(error) => match auction_refusal(&error) { - Some(refusal) => Ok(refusal.into()), - None => Err(error), - }, - } + /// `actor_guid`'s unfinished Holds on THIS handle. + pub(super) fn unfinished_auction_holds( + &self, + actor_guid: u64, + ) -> impl Iterator { + self.indexed_rows( + |index| &index.unfinished_bid_holds, + actor_guid, + |db, id| db.game_auction_bid_hold().operation_id().find(&id), + |hold| hold.bidder_guid, + ) + .into_iter() + .filter(crate::stdb::auction_holds::hold_is_unfinished) } + /// `seller_guid`'s listing Holds on THIS handle. + pub(super) fn listing_holds(&self, seller_guid: u64) -> Vec { + self.indexed_rows( + |index| &index.listing_holds, + seller_guid, + |db, id| db.game_auction_hold().operation_id().find(&id), + |hold| hold.seller_guid, + ) + } fn drive_sharded_auction_listing( &self, operation_id: u64, @@ -316,7 +374,7 @@ impl Coordinator { if let Err(error) = realm.auction_commit_listing(hold) { // Only a Refusal proves realm-core took nothing. A timeout or transport failure // leaves the Hold for the next replay, which commits idempotently. - if reducer_refusal_reason(&error).is_some() + if matches!(classify(&error), DurableFailure::Refusal { .. }) && !realm.auction_receipt_is_visible(operation_id) { // Mail is authoritative on Realm-core. Commit its idempotent refund receipt and @@ -345,7 +403,7 @@ impl Coordinator { "gw_auction_list_local", gw_auction_list_local_then( operation_id, - self.actor_or_owner(request.actor_guid), + self.session_actor(request.actor), request.item_guid, request.auctioneer_guid, request.house_id, @@ -366,7 +424,7 @@ impl Coordinator { "gw_auction_hold_listing", gw_auction_hold_listing_then( operation_id, - self.actor_or_owner(request.actor_guid), + self.session_actor(request.actor), request.item_guid, request.auctioneer_guid, request.house_id, @@ -378,12 +436,13 @@ impl Coordinator { } fn auction_commit_listing(&self, hold: &AuctionHold) -> Result<()> { + let actor = self.hold_actor(hold.seller_guid)?; call_reducer!( self.0.call_pipe().conn.reducers, "realm_auction_commit_listing", realm_auction_commit_listing_then( hold.operation_id, - self.actor_or_owner(hold.seller_guid), + actor, hold.item_guid, hold.item_entry, hold.item_stack_count, @@ -422,12 +481,13 @@ impl Coordinator { } fn auction_refund_listing(&self, hold: &AuctionHold) -> Result<()> { + let actor = self.hold_actor(hold.seller_guid)?; call_reducer!( self.0.call_pipe().conn.reducers, "realm_auction_refund_listing", realm_auction_refund_listing_then( hold.operation_id, - self.actor_or_owner(hold.seller_guid), + actor, hold.item_guid, hold.item_entry, hold.item_stack_count, @@ -450,16 +510,21 @@ impl Coordinator { } fn auction_release_listing_hold(&self, hold: &AuctionHold) -> Result<()> { + let actor = self.hold_actor(hold.seller_guid)?; call_reducer!( self.0.call_pipe().conn.reducers, "gw_auction_release_listing_hold", - gw_auction_release_listing_hold_then( - hold.operation_id, - self.actor_or_owner(hold.seller_guid) - ) + gw_auction_release_listing_hold_then(hold.operation_id, actor) ) } + /// The Character a Hold acts for. A Hold without one is corrupt. + fn hold_actor(&self, character_guid: u64) -> Result { + let actor = + Actor::new(character_guid).ok_or_else(|| anyhow!("auction Hold names no Character"))?; + Ok(self.session_actor(actor)) + } + fn auction_receipt_is_visible(&self, operation_id: u64) -> bool { self.0 .coord() @@ -475,7 +540,7 @@ impl Coordinator { &self, request: crate::world::CreateAuctionRequest, ) -> Option { - self.listing_holds(request.actor_guid) + self.listing_holds(request.actor.guid()) .into_iter() .find(|hold| same_auction_request(hold, request)) } @@ -485,7 +550,7 @@ impl Coordinator { request: crate::world::CreateAuctionRequest, ) -> Result> { let candidates: Vec<_> = self - .listing_receipts(request.actor_guid) + .listing_receipts(request.actor.guid()) .into_iter() .filter(|receipt| same_auction_request(receipt, request)) .collect(); @@ -535,30 +600,6 @@ impl Coordinator { }) } - /// Place one full-offer bid. The sharded path fences the bidder purse before realm-core makes - /// its serialized Auction decision; both paths finish with the same normalized terminal Hold. - pub(crate) fn place_bid( - &self, - request: crate::world::PlaceBidRequest, - ) -> Result { - let operation_id = self - .matching_unfinished_bid_hold(request) - .map_or_else(next_operation_id, |hold| Ok(hold.operation_id))?; - let result = if self.is_sharded() { - self.drive_sharded_auction_bid(operation_id, request) - } else { - self.auction_bid_local(operation_id, request) - .and_then(|()| self.wait_for_terminal_bid_hold(operation_id)) - }; - match result { - Ok(hold) => bid_outcome(&hold), - Err(error) => match auction_refusal(&error) { - Some(refusal) => Ok(refusal.into()), - None => Err(error), - }, - } - } - fn drive_sharded_auction_bid( &self, operation_id: u64, @@ -569,75 +610,6 @@ impl Coordinator { self.complete_auction_hold(hold) } - /// Cancel one listing. The Gateway reads the listing from the Realm-core cache and fences the - /// Auction Cut it computes; Realm-core refuses the Cancellation if a later bid changed that cut. - /// A pending Cancellation of the same listing is driven again instead of fencing a second cut. - pub(crate) fn cancel_auction( - &self, - request: crate::world::CancelAuctionRequest, - ) -> Result { - use crate::world::CancelAuctionOutcome; - let pending = self - .unfinished_auction_holds(request.actor_guid) - .find(|hold| { - hold.operation == lyracore_shared::auction::hold_operation::CANCEL - && hold.outcome == lyracore_shared::auction::bid_outcome::PENDING - && hold.auction_id == request.auction_id - && hold.house == request.house_id - }); - let (operation_id, cut) = match pending { - Some(hold) => (hold.operation_id, hold.offer), - None => match self.realm_core()?.cancellation_cut(request) { - Some(cut) => (next_operation_id()?, cut), - None => return Ok(CancelAuctionOutcome::NotFound), - }, - }; - let result = if self.is_sharded() { - self.auction_hold_cancel(operation_id, request, cut) - .and_then(|()| self.wait_for_auction_bid_hold(operation_id)) - .and_then(|hold| self.complete_auction_hold(hold)) - } else { - self.auction_cancel_local(operation_id, request, cut) - .and_then(|()| self.wait_for_terminal_bid_hold(operation_id)) - }; - match result { - Ok(hold) => cancel_outcome(&hold), - Err(error) => match auction_refusal(&error) { - Some(refusal) => Ok(refusal.into()), - None => Err(error), - }, - } - } - - /// Finish every unfinished Hold of `actor_guid` on this Home Shard, listing Holds first. A - /// Refusal leaves that Hold for a later attempt; a transport failure ends the request. - pub(crate) fn resume_auction_holds(&self, actor_guid: u64) -> Result<()> { - for hold in self.listing_holds(actor_guid) { - let operation_id = hold.operation_id; - if let Err(error) = self.complete_listing_hold(&hold) { - match auction_refusal(&error) { - Some(refusal) => log::warn!( - "listing Hold {operation_id} of {actor_guid} did not resume: {refusal:?}" - ), - None => return Err(error), - } - } - } - let holds: Vec<_> = self.unfinished_auction_holds(actor_guid).collect(); - for hold in holds { - let operation_id = hold.operation_id; - if let Err(error) = self.complete_auction_hold(hold) { - match auction_refusal(&error) { - Some(refusal) => log::warn!( - "auction Hold {operation_id} of {actor_guid} did not resume: {refusal:?}" - ), - None => return Err(error), - } - } - } - Ok(()) - } - /// Realm-core's decision, the Home Shard's finish, and any refund phases of one Hold, from the /// phase a stopped Gateway left it in. Every phase is idempotent, so a replay changes nothing. fn complete_auction_hold(&self, mut hold: AuctionBidHold) -> Result { @@ -668,7 +640,7 @@ impl Coordinator { "gw_auction_bid_local", gw_auction_bid_local_then( operation_id, - self.actor_or_owner(request.actor_guid), + self.session_actor(request.actor), request.auctioneer_guid, request.auction_id, request.house_id, @@ -687,7 +659,7 @@ impl Coordinator { "gw_auction_hold_bid", gw_auction_hold_bid_then( operation_id, - self.actor_or_owner(request.actor_guid), + self.session_actor(request.actor), request.auctioneer_guid, request.auction_id, request.house_id, @@ -707,7 +679,7 @@ impl Coordinator { "gw_auction_hold_cancel", gw_auction_hold_cancel_then( operation_id, - self.actor_or_owner(request.actor_guid), + self.session_actor(request.actor), request.auctioneer_guid, request.auction_id, request.house_id, @@ -727,7 +699,7 @@ impl Coordinator { "gw_auction_cancel_local", gw_auction_cancel_local_then( operation_id, - self.actor_or_owner(request.actor_guid), + self.session_actor(request.actor), request.auctioneer_guid, request.auction_id, request.house_id, @@ -738,6 +710,7 @@ impl Coordinator { /// Realm-core's decision for a Hold, by the operation the Hold pays for. fn auction_decide(&self, hold: &AuctionBidHold) -> Result<()> { + let actor = self.hold_actor(hold.bidder_guid)?; use lyracore_shared::auction::hold_operation; match hold.operation { hold_operation::BID => call_reducer!( @@ -745,7 +718,7 @@ impl Coordinator { "realm_auction_decide_bid", realm_auction_decide_bid_then( hold.operation_id, - self.actor_or_owner(hold.bidder_guid), + actor, hold.auction_id, hold.house, hold.offer @@ -756,7 +729,7 @@ impl Coordinator { "realm_auction_decide_cancel", realm_auction_decide_cancel_then( hold.operation_id, - self.actor_or_owner(hold.bidder_guid), + actor, hold.auction_id, hold.house, hold.offer @@ -797,6 +770,7 @@ impl Coordinator { hold: &AuctionBidHold, decision: &AuctionBidDecision, ) -> Result<()> { + let actor = self.hold_actor(hold.bidder_guid)?; if !bid_payload_matches(hold, decision) { return Err(anyhow!( "auction bid decision payload does not match its Hold" @@ -807,7 +781,7 @@ impl Coordinator { "gw_auction_finish_bid", gw_auction_finish_bid_then( hold.operation_id, - self.actor_or_owner(hold.bidder_guid), + actor, hold.auction_id, hold.house, hold.offer, @@ -822,12 +796,13 @@ impl Coordinator { } fn auction_refund_bid(&self, hold: &AuctionBidHold) -> Result<()> { + let actor = self.hold_actor(hold.bidder_guid)?; call_reducer!( self.0.call_pipe().conn.reducers, "realm_auction_refund_bid", realm_auction_refund_bid_then( hold.operation_id, - self.actor_or_owner(hold.bidder_guid), + actor, hold.auction_id, hold.house, hold.offer, @@ -837,12 +812,13 @@ impl Coordinator { } fn auction_confirm_bid_refund(&self, hold: &AuctionBidHold) -> Result<()> { + let actor = self.hold_actor(hold.bidder_guid)?; call_reducer!( self.0.call_pipe().conn.reducers, "gw_auction_confirm_bid_refund", gw_auction_confirm_bid_refund_then( hold.operation_id, - self.actor_or_owner(hold.bidder_guid), + actor, hold.auction_id, hold.house, hold.offer, @@ -855,7 +831,7 @@ impl Coordinator { &self, request: crate::world::PlaceBidRequest, ) -> Option { - self.unfinished_auction_holds(request.actor_guid) + self.unfinished_auction_holds(request.actor.guid()) .find(|hold| { hold.operation == lyracore_shared::auction::hold_operation::BID && hold.auction_id == request.auction_id @@ -1084,7 +1060,7 @@ fn same_auction_request( row: &impl AuctionRequestFields, request: crate::world::CreateAuctionRequest, ) -> bool { - row.actor_guid() == request.actor_guid + row.actor_guid() == request.actor.guid() && row.item_guid() == request.item_guid && row.start_bid() == request.start_bid && row.buyout() == request.buyout @@ -1092,10 +1068,13 @@ fn same_auction_request( && row.house_id() == request.house_id } -/// The Module's typed auction Refusal. Only a reducer the Module rejected carries a tag; a timeout, -/// transport, or SDK failure stays an error with an unknown outcome. +/// The Module's typed auction Refusal. Only a reducer the Module rejected carries a tag; a Transport +/// Loss stays an error with an unknown outcome. fn auction_refusal(error: &anyhow::Error) -> Option { - reducer_refusal_reason(error).and_then(AuctionRefusal::parse_tag) + match classify(error) { + DurableFailure::Refusal { reason } => AuctionRefusal::parse_tag(reason), + DurableFailure::TransportLoss => None, + } } fn bid_payload_matches(hold: &AuctionBidHold, decision: &AuctionBidDecision) -> bool { @@ -1117,7 +1096,7 @@ fn bid_refund_is_recorded(hold: &AuctionBidHold, decision: &AuctionBidDecision) /// the auctioneer's market. fn listing_cut(auction: &Auction, request: crate::world::CancelAuctionRequest) -> Option { use lyracore_shared::auction::{auction_cut, market_of}; - if auction.owner_guid != request.actor_guid + if auction.owner_guid != request.actor.guid() || market_of(auction.house) != market_of(request.house_id) { return None; @@ -1452,7 +1431,7 @@ mod auction_reducer_tests { assert!(!coordinator.is_sharded()); let outcome = coordinator .place_bid(PlaceBidRequest { - actor_guid: 5_090_099, + actor: Actor::new(5_090_099).unwrap(), auctioneer_guid: 5_090_098, auction_id: 5_090_097, offer: 100, @@ -1477,12 +1456,7 @@ mod auction_reducer_tests { } let not_refusals = [ - anyhow::Error::from(ReducerCallError::fatal( - "gw_auction_hold_listing reducer timed out after 10s".to_string(), - )), - anyhow::Error::from(ReducerCallError::fatal( - "gw_auction_hold_bid reducer failed: transport disconnected".to_string(), - )), + anyhow::Error::from(ReducerCallError::transport_lost("gw_auction_hold_listing")), anyhow::Error::from(ReducerCallError::Rejected { operation: "gw_auction_hold_bid".to_string(), reason: "operator only".to_string(), @@ -1574,7 +1548,7 @@ mod auction_reducer_tests { fn cancel_request() -> crate::world::CancelAuctionRequest { crate::world::CancelAuctionRequest { - actor_guid: 7, + actor: Actor::new(7).unwrap(), auctioneer_guid: 42, auction_id: 41, house_id: 2, diff --git a/gateway/src/world/handlers/auction.rs b/gateway/src/world/handlers/auction.rs index 2d3bef041..f0193e54e 100644 --- a/gateway/src/world/handlers/auction.rs +++ b/gateway/src/world/handlers/auction.rs @@ -20,7 +20,7 @@ pub(crate) struct AuctionInteraction { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct CreateAuctionRequest { - pub(crate) actor_guid: u64, + pub(crate) actor: Actor, pub(crate) auctioneer_guid: u64, pub(crate) item_guid: u64, pub(crate) start_bid: u32, @@ -50,7 +50,7 @@ impl From for CreateAuctionOutcome { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct PlaceBidRequest { - pub(crate) actor_guid: u64, + pub(crate) actor: Actor, pub(crate) auctioneer_guid: u64, pub(crate) auction_id: u32, pub(crate) offer: u32, @@ -86,7 +86,7 @@ impl From for PlaceBidOutcome { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct CancelAuctionRequest { - pub(crate) actor_guid: u64, + pub(crate) actor: Actor, pub(crate) auctioneer_guid: u64, pub(crate) auction_id: u32, pub(crate) house_id: u32, @@ -149,7 +149,7 @@ pub(crate) struct AuctionPage { pub(crate) trait AuctionActionStore: Send + Sync { fn auction_interaction( &self, - player_guid: u64, + actor: Actor, auctioneer_guid: u64, ) -> Result>; @@ -159,14 +159,14 @@ pub(crate) trait AuctionActionStore: Send + Sync { fn cancel_auction(&self, request: CancelAuctionRequest) -> Result; - /// Drive every unfinished bid or Cancellation Hold of `actor_guid` to its end. A Gateway that + /// Drive every unfinished bid or Cancellation Hold of `actor` to its end. A Gateway that /// stopped between phases leaves them fenced, and a Cancellation has no natural retry because /// the listing leaves the owner list. - fn resume_auction_holds(&self, actor_guid: u64) -> Result<()>; + fn resume_auction_holds(&self, actor: Actor) -> Result<()>; fn auction_query( &self, - player_guid: u64, + actor: Actor, house_id: u32, query: AuctionQuery, ) -> Result; @@ -249,7 +249,7 @@ pub(crate) fn dispatch_auction_browse_action( player: AuctionActionPlayer, request: AuctionBrowseRequest, ) -> Result { - let Some((player_guid, interaction)) = + let Some((actor, interaction)) = auction_actor_interaction(store, player, request.auctioneer_guid)? .filter(|(_, interaction)| !interaction.refuses_interaction) else { @@ -261,11 +261,7 @@ pub(crate) fn dispatch_auction_browse_action( )], }); }; - let page = store.auction_query( - player_guid, - interaction.house.id, - AuctionQuery::Browse(request), - )?; + let page = store.auction_query(actor, interaction.house.id, AuctionQuery::Browse(request))?; Ok(AuctionActionOutcome::Handled { outbound: vec![Outbound::One( ServerOpcodeMessage::SMSG_AUCTION_LIST_RESULT(Box::new( @@ -281,13 +277,13 @@ fn auction_actor_interaction( store: &St, player: AuctionActionPlayer, auctioneer_guid: u64, -) -> Result> { - let Some(player_guid) = player.self_guid else { +) -> Result> { + 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. - let interaction = store.auction_interaction(player_guid, auctioneer_guid)?; - Ok(interaction.map(|interaction| (player_guid, interaction))) + let interaction = store.auction_interaction(actor, auctioneer_guid)?; + Ok(interaction.map(|interaction| (actor, interaction))) } fn create_result(outcome: CreateAuctionOutcome) -> AuctionActionOutcome { @@ -428,7 +424,7 @@ pub(crate) fn dispatch_auction_action( ClientOpcodeMessage::CMSG_AUCTION_PLACE_BID(message) => { let auction_id = message.auction_id; let auctioneer_guid = message.auctioneer.guid(); - let Some((player_guid, interaction)) = + let Some((actor, interaction)) = auction_actor_interaction(store, player, auctioneer_guid)? else { return Ok(bid_result(auction_id, PlaceBidOutcome::Database)); @@ -436,7 +432,7 @@ pub(crate) fn dispatch_auction_action( // 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_guid: player_guid, + actor, auctioneer_guid, auction_id, offer: message.price.as_int(), @@ -446,13 +442,13 @@ pub(crate) fn dispatch_auction_action( } ClientOpcodeMessage::CMSG_AUCTION_SELL_ITEM(message) => { let auctioneer_guid = message.auctioneer.guid(); - let Some((player_guid, interaction)) = + 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_guid: player_guid, + actor, auctioneer_guid, item_guid: message.item.guid(), start_bid: message.starting_bid, @@ -467,7 +463,7 @@ pub(crate) fn dispatch_auction_action( let auctioneer_guid = message.auctioneer.guid(); // Vanilla ignores a Cancellation away from an auctioneer // (`cm:AuctionHouseHandler.cpp:413-415`). - let Some((player_guid, interaction)) = + let Some((actor, interaction)) = auction_actor_interaction(store, player, auctioneer_guid)? else { return Ok(AuctionActionOutcome::Handled { @@ -475,7 +471,7 @@ pub(crate) fn dispatch_auction_action( }); }; let outcome = store.cancel_auction(CancelAuctionRequest { - actor_guid: player_guid, + actor, auctioneer_guid, auction_id, house_id: interaction.house.id, @@ -484,13 +480,14 @@ pub(crate) fn dispatch_auction_action( } other => return Ok(AuctionActionOutcome::PassThrough(other)), }; - if let (AuctionRequest::Hello(_), Some(player_guid)) = (&request, player.self_guid) { - store.resume_auction_holds(player_guid)?; + if let (AuctionRequest::Hello(_), Some(actor)) = + (&request, player.self_guid.and_then(Actor::new)) + { + store.resume_auction_holds(actor)?; } let auctioneer_guid = auctioneer.guid(); - let Some((player_guid, interaction)) = - auction_actor_interaction(store, player, auctioneer_guid)? - .filter(|(_, interaction)| !interaction.refuses_interaction) + let Some((actor, interaction)) = auction_actor_interaction(store, player, auctioneer_guid)? + .filter(|(_, interaction)| !interaction.refuses_interaction) else { return Ok(AuctionActionOutcome::Handled { outbound: Vec::new(), @@ -507,8 +504,7 @@ pub(crate) fn dispatch_auction_action( })) } AuctionRequest::Owner(offset) => { - let page = - store.auction_query(player_guid, house.id, AuctionQuery::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), )) @@ -518,7 +514,7 @@ pub(crate) fn dispatch_auction_action( outbid_auction_ids, } => { let page = store.auction_query( - player_guid, + actor, house.id, AuctionQuery::Bidder { offset, @@ -538,6 +534,7 @@ pub(crate) fn dispatch_auction_action( #[cfg(test)] pub(super) mod tests { use super::*; + use crate::stdb::{classify, DurableFailure, ReducerCallError}; use std::sync::Mutex; use wow_world_messages::shared::Gold; use wow_world_messages::vanilla::{ @@ -548,37 +545,40 @@ pub(super) mod tests { CMSG_AUCTION_SELL_ITEM, }; + /// How a Fake call fails: a constructor for the typed error, so every call builds a fresh one. + type Failure = fn() -> ReducerCallError; + pub(crate) struct InMemoryAuctionActions { - result: Mutex, String>>, + result: Mutex, Failure>>, lookups: Mutex>, creates: Mutex>, - create_result: Mutex>, + create_result: Mutex>, bids: Mutex>, - bid_result: Mutex>, + bid_result: Mutex>, cancels: Mutex>, - cancel_result: Mutex>, + cancel_result: Mutex>, resumes: Mutex>, - resume_result: Mutex>, - query_result: Mutex>, + resume_result: Mutex>, + query_result: Mutex>, queries: Mutex>, } impl AuctionActionStore for InMemoryAuctionActions { fn auction_interaction( &self, - player_guid: u64, + actor: Actor, auctioneer_guid: u64, ) -> Result> { self.lookups .lock() .unwrap() - .push((player_guid, auctioneer_guid)); + .push((actor.guid(), auctioneer_guid)); self.result .lock() .unwrap() .as_ref() .map(Clone::clone) - .map_err(|error| anyhow::anyhow!(error.clone())) + .map_err(|fail| fail().into()) } fn create_auction(&self, request: CreateAuctionRequest) -> Result { @@ -588,7 +588,7 @@ pub(super) mod tests { .unwrap() .as_ref() .copied() - .map_err(|error| anyhow::anyhow!(error.clone())) + .map_err(|fail| fail().into()) } fn place_bid(&self, request: PlaceBidRequest) -> Result { @@ -598,7 +598,7 @@ pub(super) mod tests { .unwrap() .as_ref() .copied() - .map_err(|error| anyhow::anyhow!(error.clone())) + .map_err(|fail| fail().into()) } fn cancel_auction(&self, request: CancelAuctionRequest) -> Result { @@ -608,34 +608,33 @@ pub(super) mod tests { .unwrap() .as_ref() .copied() - .map_err(|error| anyhow::anyhow!(error.clone())) + .map_err(|fail| fail().into()) } - fn resume_auction_holds(&self, actor_guid: u64) -> Result<()> { - self.resumes.lock().unwrap().push(actor_guid); + fn resume_auction_holds(&self, actor: Actor) -> Result<()> { + self.resumes.lock().unwrap().push(actor.guid()); self.resume_result .lock() .unwrap() - .clone() - .map_err(|error| anyhow::anyhow!(error)) + .map_err(|fail| fail().into()) } fn auction_query( &self, - player_guid: u64, + actor: Actor, house_id: u32, query: AuctionQuery, ) -> Result { self.queries .lock() .unwrap() - .push((player_guid, house_id, query)); + .push((actor.guid(), house_id, query)); self.query_result .lock() .unwrap() .as_ref() .map(Clone::clone) - .map_err(|error| anyhow::anyhow!(error.clone())) + .map_err(|fail| fail().into()) } } @@ -673,9 +672,9 @@ pub(super) mod tests { } } - fn store_error(error: &str) -> InMemoryAuctionActions { + fn store_error(error: Failure) -> InMemoryAuctionActions { InMemoryAuctionActions { - result: Mutex::new(Err(error.to_string())), + result: Mutex::new(Err(error)), lookups: Mutex::default(), creates: Mutex::default(), create_result: Mutex::new(Ok(CreateAuctionOutcome::Database)), @@ -1082,7 +1081,7 @@ pub(super) mod tests { assert_eq!( store.creates.lock().unwrap().as_slice(), &[CreateAuctionRequest { - actor_guid: 7, + actor: Actor::new(7).unwrap(), auctioneer_guid: 42, item_guid: 70, start_bid: 100, @@ -1100,7 +1099,7 @@ pub(super) mod tests { assert_eq!( store.bids.lock().unwrap().as_slice(), &[PlaceBidRequest { - actor_guid: 8, + actor: Actor::new(8).unwrap(), auctioneer_guid: 42, auction_id: 41, offer: 107, @@ -1296,45 +1295,23 @@ pub(super) mod tests { } } + fn assert_transport_loss(error: &anyhow::Error) { + assert_eq!(classify(error), DurableFailure::TransportLoss); + } + #[test] - fn a_reducer_timeout_is_not_answered_as_a_refusal() { + fn a_transport_loss_ends_the_session_instead_of_answering_as_a_refusal() { let store = store_with(Some(valid_interaction())); *store.create_result.lock().unwrap() = - Err("gw_auction_hold_listing reducer timed out after 10s".to_string()); + Err(|| ReducerCallError::transport_lost("gw_auction_hold_listing")); let error = session_error(sell_outbound(&store), "an unknown listing outcome"); - assert!(error.to_string().contains("timed out")); + assert_transport_loss(&error); let store = store_with(Some(valid_interaction())); *store.bid_result.lock().unwrap() = - Err("gw_auction_hold_bid reducer timed out after 10s".to_string()); + Err(|| ReducerCallError::transport_lost("gw_auction_hold_bid")); let error = session_error(bid_outbound(&store), "an unknown bid outcome"); - assert!(error.to_string().contains("timed out")); - } - - #[test] - fn sell_transport_failure_is_fatal() { - let store = store_with(Some(valid_interaction())); - *store.create_result.lock().unwrap() = - Err("auction reducer transport disconnected: channel closed".to_string()); - - let error = match sell_outbound(&store) { - Ok(_) => panic!("a dead reducer transport must end the session"), - Err(error) => error, - }; - assert!(error.to_string().contains("transport disconnected")); - } - - #[test] - fn sell_realm_core_outage_is_fatal() { - let store = store_with(Some(valid_interaction())); - *store.create_result.lock().unwrap() = - Err("realm-core database lyracore-realm is not connected".to_string()); - - let error = match sell_outbound(&store) { - Ok(_) => panic!("an unavailable realm plane must end the session"), - Err(error) => error, - }; - assert!(error.to_string().contains("not connected")); + assert_transport_loss(&error); } #[test] @@ -1426,7 +1403,7 @@ pub(super) mod tests { assert_eq!( store.cancels.lock().unwrap().as_slice(), &[CancelAuctionRequest { - actor_guid: 7, + actor: Actor::new(7).unwrap(), auctioneer_guid: 42, auction_id: 41, house_id: 4, @@ -1466,9 +1443,9 @@ pub(super) mod tests { fn a_cancellation_with_an_unknown_durable_result_is_fatal() { let store = store_with(Some(valid_interaction())); *store.cancel_result.lock().unwrap() = - Err("gw_auction_hold_cancel reducer timed out after 10s".to_string()); + Err(|| ReducerCallError::transport_lost("gw_auction_hold_cancel")); let error = session_error(cancel_outbound(&store), "an unknown Cancellation outcome"); - assert!(error.to_string().contains("timed out")); + assert_transport_loss(&error); } #[test] @@ -1494,19 +1471,15 @@ pub(super) mod tests { let store = store_with(Some(valid_interaction())); *store.resume_result.lock().unwrap() = - Err("realm_auction_decide_cancel reducer timed out after 10s".to_string()); + Err(|| ReducerCallError::transport_lost("realm_auction_decide_cancel")); let error = session_error(hello_outbound(&store), "an unknown resume outcome"); - assert!(error.to_string().contains("timed out")); + assert_transport_loss(&error); } #[test] fn a_failed_interaction_read_is_fatal() { - for message in [ - "auction state unavailable", - "auction interaction reducer transport disconnected: channel closed", - ] { - let error = session_error(hello_outbound(&store_error(message)), "a failed read"); - assert_eq!(error.to_string(), message); - } + let store = store_error(|| ReducerCallError::transport_lost("auction_interaction")); + let error = session_error(hello_outbound(&store), "a failed read"); + assert_transport_loss(&error); } } diff --git a/gateway/src/world/test_support/world_fake/auction.rs b/gateway/src/world/test_support/world_fake/auction.rs index 7f10736f5..f42a7f445 100644 --- a/gateway/src/world/test_support/world_fake/auction.rs +++ b/gateway/src/world/test_support/world_fake/auction.rs @@ -1,4 +1,5 @@ use super::super::*; +use crate::world::Actor; #[derive(Default)] pub(crate) struct AuctionState { @@ -9,7 +10,7 @@ pub(crate) struct AuctionState { impl AuctionActionStore for WorldFake { fn auction_interaction( &self, - _player_guid: u64, + _actor: Actor, _auctioneer_guid: u64, ) -> Result> { self.rec("auction_interaction"); @@ -37,13 +38,13 @@ impl AuctionActionStore for WorldFake { Ok(crate::world::handlers::CancelAuctionOutcome::Stale) } - fn resume_auction_holds(&self, _actor_guid: u64) -> Result<()> { + fn resume_auction_holds(&self, _actor: Actor) -> Result<()> { Ok(()) } fn auction_query( &self, - _player_guid: u64, + _actor: Actor, _house_id: u32, _query: crate::world::handlers::AuctionQuery, ) -> Result { From 3ff58cb85c5e52f90203b32203fe5fb7d5f615bc Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 18:35:43 +0200 Subject: [PATCH 14/33] refactor(gateway): chat takes an Actor and classifies by Transport Loss Inline the chat inherent wrappers into the Coordinator impl, take Actor on realm_chat, realm_whisper and set_away, and replace the message-text classifier with classify. Add decoder tests for chat_outcome. Co-Authored-By: Claude Sonnet 5.5 --- gateway/src/stdb/store/chat.rs | 127 +++++------ gateway/src/world/handlers/chat.rs | 197 ++++++++++-------- .../src/world/test_support/world_fake/chat.rs | 10 +- 3 files changed, 181 insertions(+), 153 deletions(-) diff --git a/gateway/src/stdb/store/chat.rs b/gateway/src/stdb/store/chat.rs index 0f79c2cfe..a75d22a65 100644 --- a/gateway/src/stdb/store/chat.rs +++ b/gateway/src/stdb/store/chat.rs @@ -6,54 +6,13 @@ use lyracore_shared::chat::ChatRefusal; use crate::stdb::bindings::*; use crate::stdb::connection::{call_reducer, reducer_refusal_reason}; use crate::stdb::Coordinator; -use crate::world::{whisper, ChatActionStore, ChatOutcome}; +use crate::world::{whisper, Actor, ChatActionStore, ChatOutcome}; -impl ChatActionStore for crate::stdb::Coordinator { - fn speaker_facts(&self, speaker_guid: u64) -> Result> { - crate::stdb::Coordinator::speaker_facts(self, speaker_guid) - } - - fn realm_chat( - &self, - speaker_guid: u64, - request: crate::world::RealmChatRequest, - ) -> Result { - crate::stdb::Coordinator::realm_chat(self, speaker_guid, request) - } - - fn set_away(&self, speaker_guid: u64, kind: u8, message: String) -> Result<()> { - crate::stdb::Coordinator::set_away(self, speaker_guid, kind, message) - } - - fn whisper_target( - &self, - speaker_guid: u64, - typed_name: &str, - ) -> Result> { - whisper::target_facts(self, speaker_guid, typed_name) - } - - fn realm_whisper( - &self, - speaker_guid: u64, - request: crate::world::WhisperRequest, - ) -> Result { - crate::stdb::Coordinator::realm_whisper(self, speaker_guid, request) - } - - fn speaker_gm_level(&self, speaker_guid: u64) -> Result { - Ok(crate::stdb::Coordinator::home_gm_level(self, speaker_guid)) - } -} - -impl Coordinator { +impl ChatActionStore for Coordinator { /// The Speaker Facts for `speaker_guid` on this Home Shard: race from `UNIT_FIELD_BYTES_0` /// byte 0 and the chat tag from `PLAYER_FLAGS`, both off the live entity, plus the Character's /// name. `None` when the speaker has no live entity here. - pub(crate) fn speaker_facts( - &self, - speaker_guid: u64, - ) -> anyhow::Result> { + fn speaker_facts(&self, speaker_guid: u64) -> Result> { let guard = self.0.coord(); let db = &guard.conn.db; let Some(entity) = db.game_world_entity().guid().find(&speaker_guid) else { @@ -72,21 +31,11 @@ impl Coordinator { })) } - /// `gw_set_away`: one `/afk` or `/dnd` on the Character's Home Shard, where its live entity - /// and Auto-Reply live. - pub fn set_away(&self, actor_guid: u64, kind: u8, message: String) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "gw_set_away", - gw_set_away_then(self.actor_or_owner(actor_guid), kind, message) - ) - } - /// `realm_chat`: commit one Realm Chat Line on Realm-core, or on the one database of an /// unsharded Realm. The Module applies every chat Gate. The speaker's name stays here. - pub fn realm_chat( + fn realm_chat( &self, - speaker_guid: u64, + actor: Actor, request: crate::world::RealmChatRequest, ) -> Result { let realm = self.realm_core()?; @@ -104,15 +53,33 @@ impl Coordinator { chat_outcome(call_reducer!( realm.0.call_pipe().conn.reducers, "realm_chat", - realm_chat_then(realm.actor_or_owner(speaker_guid), request) + realm_chat_then(realm.session_actor(actor), request) )) } + /// `gw_set_away`: one `/afk` or `/dnd` on the Character's Home Shard, where its live entity + /// and Auto-Reply live. + fn set_away(&self, actor: Actor, kind: u8, message: String) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "gw_set_away", + gw_set_away_then(self.session_actor(actor), kind, message) + ) + } + + fn whisper_target( + &self, + speaker_guid: u64, + typed_name: &str, + ) -> Result> { + whisper::target_facts(self, speaker_guid, typed_name) + } + /// `realm_whisper`: commit one whisper's lines on Realm-core, or on the one database of an /// unsharded Realm. The Module applies every whisper Gate. The speaker's name stays here. - pub fn realm_whisper( + fn realm_whisper( &self, - speaker_guid: u64, + actor: Actor, request: crate::world::WhisperRequest, ) -> Result { let realm = self.realm_core()?; @@ -136,9 +103,13 @@ impl Coordinator { chat_outcome(call_reducer!( realm.0.call_pipe().conn.reducers, "realm_whisper", - realm_whisper_then(realm.actor_or_owner(speaker_guid), request) + realm_whisper_then(realm.session_actor(actor), request) )) } + + fn speaker_gm_level(&self, speaker_guid: u64) -> Result { + Ok(self.home_gm_level(speaker_guid)) + } } /// The Module's typed chat Refusal. Only a reducer the Module rejected carries a tag; a timeout, @@ -152,3 +123,39 @@ pub(super) fn chat_outcome(result: Result<()>) -> Result { }, } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::stdb::{classify, DurableFailure, ReducerCallError}; + + #[test] + fn a_rejected_chat_tag_decodes_to_its_refusal() { + let error = ReducerCallError::refused("realm_chat", ChatRefusal::NotInGroup.as_tag()); + assert_eq!( + chat_outcome(Err(error.into())).unwrap(), + ChatOutcome::Refused(ChatRefusal::NotInGroup) + ); + } + + #[test] + fn a_delivered_line_decodes_to_delivered() { + assert_eq!(chat_outcome(Ok(())).unwrap(), ChatOutcome::Delivered); + } + + #[test] + fn an_unknown_rejection_stays_a_refusal_for_the_handler() { + let error = chat_outcome(Err( + ReducerCallError::refused("realm_chat", "mystery").into() + )) + .unwrap_err(); + assert!(matches!(classify(&error), DurableFailure::Refusal { .. })); + } + + #[test] + fn a_lost_transport_stays_a_transport_loss() { + let error = + chat_outcome(Err(ReducerCallError::transport_lost("realm_chat").into())).unwrap_err(); + assert_eq!(classify(&error), DurableFailure::TransportLoss); + } +} diff --git a/gateway/src/world/handlers/chat.rs b/gateway/src/world/handlers/chat.rs index 18813ee1f..a35846bd0 100644 --- a/gateway/src/world/handlers/chat.rs +++ b/gateway/src/world/handlers/chat.rs @@ -6,6 +6,7 @@ //! every kind this file does not own pass through. use super::super::*; +use crate::stdb::{classify, DurableFailure}; use lyracore_shared::chat::{chat_kind, language, ChatRefusal}; use wow_world_messages::vanilla::{CMSG_CHAT_IGNORED, SMSG_NOTIFICATION}; @@ -67,9 +68,9 @@ pub(crate) trait ChatActionStore: Send + Sync { /// Durable Read on the speaker's Home Shard. `None` when the speaker has no live entity. fn speaker_facts(&self, speaker_guid: u64) -> Result>; /// Durable Request on Realm-core. The Coordinator picks the database; handlers never do. - fn realm_chat(&self, speaker_guid: u64, request: RealmChatRequest) -> Result; + fn realm_chat(&self, actor: Actor, request: RealmChatRequest) -> Result; /// Durable Request on the speaker's Home Shard: one `/afk` or `/dnd`. - fn set_away(&self, speaker_guid: u64, kind: u8, message: String) -> Result<()>; + fn set_away(&self, actor: Actor, kind: u8, message: String) -> Result<()>; /// Durable Read across every World Shard: the online Character the typed name reaches, with /// the facts the Module's whisper Gates need. `None` when no online Character has that name. fn whisper_target( @@ -78,7 +79,7 @@ pub(crate) trait ChatActionStore: Send + Sync { typed_name: &str, ) -> Result>; /// Durable Request on Realm-core. - fn realm_whisper(&self, speaker_guid: u64, request: WhisperRequest) -> Result; + fn realm_whisper(&self, actor: Actor, request: WhisperRequest) -> Result; /// The speaker's GM level, read on its Home Shard. The Chat Flood Limiter never mutes a /// Character above 0. fn speaker_gm_level(&self, speaker_guid: u64) -> Result; @@ -125,6 +126,13 @@ pub(crate) struct ChatActionPlayer { 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), @@ -243,8 +251,7 @@ pub(crate) fn dispatch_chat_action( /// One whisper: the target is resolved realm-wide, the Module applies the Gates on Realm-core, and /// the lines return on the Relay. A name no online Character holds answers -/// `SMSG_CHAT_PLAYER_NOT_FOUND` with the typed name (cm:ChatHandler.cpp:243-266). A target read -/// that fails without a transport loss answers the same way: this Gateway cannot reach the target. +/// `SMSG_CHAT_PLAYER_NOT_FOUND` with the typed name (cm:ChatHandler.cpp:243-266). fn whisper( store: &St, player: ChatActionPlayer, @@ -252,23 +259,15 @@ fn whisper( message: String, typed_name: String, ) -> Result> { - let Some(speaker_guid) = player.self_guid else { + let Some(actor) = player.actor() else { return Ok(Vec::new()); }; - let Some(speaker) = store.speaker_facts(speaker_guid)? else { + let Some(speaker) = store.speaker_facts(actor.guid())? else { return Ok(Vec::new()); }; - let target = match store.whisper_target(speaker_guid, &typed_name) { - Ok(Some(target)) => target, - Ok(None) => return Ok(vec![player_not_found(typed_name)]), - Err(error) if is_transport_failure(&error) => return Err(error), - Err(error) => { - log::debug!( - "world: whisper target read failed (account {}): {error:#}", - player.account_id - ); - return Ok(vec![player_not_found(typed_name)]); - } + let target = match store.whisper_target(actor.guid(), &typed_name)? { + Some(target) => target, + None => return Ok(vec![player_not_found(typed_name)]), }; let request = WhisperRequest { language, @@ -276,19 +275,15 @@ fn whisper( speaker, target, }; - match store.realm_whisper(speaker_guid, request) { + match store.realm_whisper(actor, request) { Ok(ChatOutcome::Delivered) => Ok(Vec::new()), // cm:ChatHandler.cpp:268-275, cm:ChatHandler.cpp:824-828: an empty packet. Ok(ChatOutcome::Refused(ChatRefusal::WrongFaction)) => Ok(vec![Outbound::One( ServerOpcodeMessage::SMSG_CHAT_WRONG_FACTION, )]), Ok(ChatOutcome::Refused(refusal)) => Ok(refusal_outbound(player, Some(refusal))), - Err(error) if is_transport_failure(&error) => Err(error), Err(error) => { - log::debug!( - "world: whisper dropped (account {}): {error:#}", - player.account_id - ); + drop_unrecognised_refusal(player, "whisper", error)?; Ok(Vec::new()) } } @@ -308,16 +303,11 @@ fn set_away( kind: u8, message: String, ) -> Result> { - let Some(speaker_guid) = player.self_guid else { + let Some(actor) = player.actor() else { return Ok(Vec::new()); }; - match store.set_away(speaker_guid, kind, message) { - Ok(()) => {} - Err(error) if is_transport_failure(&error) => return Err(error), - Err(error) => log::debug!( - "world: away kind {kind} dropped (account {}): {error:#}", - player.account_id - ), + if let Err(error) = store.set_away(actor, kind, message) { + drop_unrecognised_refusal(player, format_args!("away kind {kind}"), error)?; } Ok(Vec::new()) } @@ -342,16 +332,16 @@ fn chat_ignored( /// Send one line through the Realm Chat path. `request` builds the Durable Request from the /// Speaker Facts. `Ok(None)` means the line went out or was dropped; a Refusal comes back for the -/// calling arm to answer. Only a lost reducer transport is fatal. +/// calling arm to answer. A Transport Loss is fatal. fn send_line( store: &St, player: ChatActionPlayer, request: impl FnOnce(SpeakerFacts) -> RealmChatRequest, ) -> Result> { - let Some(speaker_guid) = player.self_guid else { + let Some(actor) = player.actor() else { return Ok(None); }; - let Some(speaker) = store.speaker_facts(speaker_guid)? else { + let Some(speaker) = store.speaker_facts(actor.guid())? else { return Ok(None); }; let request = request(speaker); @@ -359,13 +349,13 @@ fn send_line( settle( player, format_args!("chat kind {kind}"), - store.realm_chat(speaker_guid, request), + store.realm_chat(actor, request), ) } /// Sort one chat Durable Request's result. `Ok(None)` means the line went out, or was dropped for -/// a failure the World Session survives; a Refusal comes back for the caller to answer. Only a -/// lost reducer transport is fatal. +/// 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, line: impl std::fmt::Display, @@ -374,12 +364,8 @@ pub(crate) fn settle( match sent { Ok(ChatOutcome::Delivered) => Ok(None), Ok(ChatOutcome::Refused(refusal)) => Ok(Some(refusal)), - Err(error) if is_transport_failure(&error) => Err(error), Err(error) => { - log::debug!( - "world: {line} dropped (account {}): {error:#}", - player.account_id - ); + drop_unrecognised_refusal(player, line, error)?; Ok(None) } } @@ -408,7 +394,27 @@ pub(crate) fn refusal_outbound( } } -/// A dead reducer transport cannot serve any further request, so it ends the World Session. +/// 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, + request: impl std::fmt::Display, + error: anyhow::Error, +) -> Result<()> { + match classify(&error) { + DurableFailure::TransportLoss => Err(error), + DurableFailure::Refusal { reason } => { + log::debug!( + "world: {request} dropped (account {}): {reason}", + player.account_id + ); + Ok(()) + } + } +} + +/// The meeting stone handler still classifies by message text through this. Delete it with that +/// handler's conversion to `classify`. pub(super) fn is_transport_failure(error: &anyhow::Error) -> bool { error .chain() @@ -418,29 +424,51 @@ pub(super) fn is_transport_failure(error: &anyhow::Error) -> bool { #[cfg(test)] mod tests { use super::*; + use crate::stdb::ReducerCallError; use lyracore_shared::channel::ChannelRefusal; use std::sync::Mutex; use wow_world_messages::vanilla::{Language, CMSG_PING}; + /// How a Fake's Durable Request fails. + #[derive(Clone, Copy)] + enum Failure { + /// The Module rejects with a tag this Gateway does not know. + Refused, + TransportLost, + } + + impl Failure { + fn error(self, op: &str) -> anyhow::Error { + match self { + Self::Refused => ReducerCallError::refused(op, "mystery").into(), + Self::TransportLost => ReducerCallError::transport_lost(op).into(), + } + } + } + #[derive(Default)] struct InMemoryChatActions { facts: Option, - outcome: Option>, + outcome: Option>, facts_reads: Mutex>, requests: Mutex>, - away_failure: Option, + away_failure: Option, away_requests: Mutex>, - target: Option, String>>, + target: Option, Failure>>, target_reads: Mutex>, - whisper_outcome: Option>, + whisper_outcome: Option>, whispers: Mutex>, } - fn answer(configured: &Option>, default: T) -> Result { + fn answer( + configured: &Option>, + op: &str, + default: T, + ) -> Result { match configured { None => Ok(default), Some(Ok(value)) => Ok(value.clone()), - Some(Err(failure)) => Err(anyhow::anyhow!("{failure}")), + Some(Err(failure)) => Err(failure.error(op)), } } @@ -450,19 +478,19 @@ mod tests { Ok(self.facts.clone()) } - fn realm_chat(&self, speaker_guid: u64, request: RealmChatRequest) -> Result { - self.requests.lock().unwrap().push((speaker_guid, request)); - answer(&self.outcome, ChatOutcome::Delivered) + fn realm_chat(&self, actor: Actor, request: RealmChatRequest) -> Result { + self.requests.lock().unwrap().push((actor.guid(), request)); + answer(&self.outcome, "realm_chat", ChatOutcome::Delivered) } - fn set_away(&self, speaker_guid: u64, kind: u8, message: String) -> Result<()> { + fn set_away(&self, actor: Actor, kind: u8, message: String) -> Result<()> { self.away_requests .lock() .unwrap() - .push((speaker_guid, kind, message)); - match &self.away_failure { + .push((actor.guid(), kind, message)); + match self.away_failure { None => Ok(()), - Some(failure) => Err(anyhow::anyhow!("{failure}")), + Some(failure) => Err(failure.error("gw_set_away")), } } @@ -475,12 +503,16 @@ mod tests { .lock() .unwrap() .push((speaker_guid, typed_name.to_string())); - answer(&self.target, None) + answer(&self.target, "whisper_target", None) } - fn realm_whisper(&self, speaker_guid: u64, request: WhisperRequest) -> Result { - self.whispers.lock().unwrap().push((speaker_guid, request)); - answer(&self.whisper_outcome, ChatOutcome::Delivered) + fn realm_whisper(&self, actor: Actor, request: WhisperRequest) -> Result { + self.whispers.lock().unwrap().push((actor.guid(), request)); + answer( + &self.whisper_outcome, + "realm_whisper", + ChatOutcome::Delivered, + ) } fn speaker_gm_level(&self, _speaker_guid: u64) -> Result { @@ -496,7 +528,7 @@ mod tests { } } - fn store(outcome: Option>) -> InMemoryChatActions { + fn store(outcome: Option>) -> InMemoryChatActions { InMemoryChatActions { facts: Some(speaker()), outcome, @@ -737,20 +769,16 @@ mod tests { #[test] fn a_lost_reducer_transport_ends_the_session() { - let store = store(Some(Err( - "realm_chat reducer transport disconnected: channel closed".to_string(), - ))); + let store = store(Some(Err(Failure::TransportLost))); let error = dispatch_chat_action(&store, player(), party(Language::Common)) .err() .expect("transport loss is fatal"); - assert!(error.to_string().contains("transport disconnected")); + assert!(matches!(classify(&error), DurableFailure::TransportLoss)); } #[test] - fn any_other_failure_drops_the_line_and_keeps_the_session() { - let store = store(Some(Err( - "realm_chat reducer timed out after 10s".to_string() - ))); + 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()); assert!(outbound.is_empty()); @@ -852,8 +880,8 @@ mod tests { } fn whisper_store( - target: Result, String>, - outcome: Option>, + target: Result, Failure>, + outcome: Option>, ) -> InMemoryChatActions { InMemoryChatActions { target: Some(target), @@ -911,10 +939,9 @@ mod tests { } #[test] - fn a_failed_target_read_answers_player_not_found_and_keeps_the_session() { - let store = whisper_store(Err("peer Shard cannot vouch".to_string()), None); - let outbound = handled(dispatch_chat_action(&store, player(), whisper_to("Vim")).unwrap()); - assert_eq!(not_found(outbound), "Vim"); + fn a_failed_target_read_ends_the_session() { + let store = whisper_store(Err(Failure::TransportLost), None); + assert!(dispatch_chat_action(&store, player(), whisper_to("Vim")).is_err()); assert!(store.whispers.lock().unwrap().is_empty()); } @@ -960,19 +987,13 @@ mod tests { #[test] fn a_lost_transport_on_a_whisper_ends_the_session() { - let lost = "realm_whisper reducer transport disconnected: channel closed".to_string(); - let store = whisper_store(Ok(Some(target())), Some(Err(lost.clone()))); - assert!(dispatch_chat_action(&store, player(), whisper_to("Vim")).is_err()); - let store = whisper_store(Err(lost), None); + let store = whisper_store(Ok(Some(target())), Some(Err(Failure::TransportLost))); assert!(dispatch_chat_action(&store, player(), whisper_to("Vim")).is_err()); } #[test] - fn any_other_whisper_failure_is_silent() { - let store = whisper_store( - Ok(Some(target())), - Some(Err("realm_whisper reducer timed out after 10s".to_string())), - ); + 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()); assert!(outbound.is_empty()); } @@ -1001,15 +1022,15 @@ mod tests { } #[test] - fn a_failed_away_request_is_fatal_only_for_a_lost_transport() { + fn a_failed_away_request_is_fatal_only_for_a_transport_loss() { let afk = || line(CMSG_MESSAGECHAT_ChatType::Afk, Language::Universal); let refused = InMemoryChatActions { - away_failure: Some("mover not in world".to_string()), + away_failure: Some(Failure::Refused), ..store(None) }; assert!(handled(dispatch_chat_action(&refused, player(), afk()).unwrap()).is_empty()); let lost = InMemoryChatActions { - away_failure: Some("gw_set_away reducer transport disconnected".to_string()), + away_failure: Some(Failure::TransportLost), ..store(None) }; assert!(dispatch_chat_action(&lost, player(), afk()).is_err()); diff --git a/gateway/src/world/test_support/world_fake/chat.rs b/gateway/src/world/test_support/world_fake/chat.rs index c606cc30d..4ecb85975 100644 --- a/gateway/src/world/test_support/world_fake/chat.rs +++ b/gateway/src/world/test_support/world_fake/chat.rs @@ -17,16 +17,16 @@ impl ChatActionStore for WorldFake { Ok(self.chat.speaker_facts.clone()) } - fn realm_chat(&self, speaker_guid: u64, request: RealmChatRequest) -> Result { + fn realm_chat(&self, actor: Actor, request: RealmChatRequest) -> Result { self.chat .realm_chats .lock() .unwrap() - .push((speaker_guid, request)); + .push((actor.guid(), request)); Ok(ChatOutcome::Delivered) } - fn set_away(&self, _speaker_guid: u64, _kind: u8, _message: String) -> Result<()> { + fn set_away(&self, _actor: Actor, _kind: u8, _message: String) -> Result<()> { Ok(()) } @@ -40,13 +40,13 @@ impl ChatActionStore for WorldFake { /// The Module's `realm_whisper`, modelled: it records what the Gateway conveyed before it /// answers, because the speaker guid is the whole authorization of the call. - fn realm_whisper(&self, speaker_guid: u64, request: WhisperRequest) -> Result { + fn realm_whisper(&self, actor: Actor, request: WhisperRequest) -> Result { self.rec("realm_whisper"); self.chat .realm_whispers .lock() .unwrap() - .push((speaker_guid, request)); + .push((actor.guid(), request)); Ok(ChatOutcome::Delivered) } From 4b63b83215e72086a196d0270937b59d7e18bab3 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 18:35:43 +0200 Subject: [PATCH 15/33] refactor(gateway): channel takes an Actor and classifies by Transport Loss Inline the channel inherent wrappers, take Actor on channel_op, and drop the recoverable read path: a failed target or roster read ends the World Session. Add decoder tests for channel_outcome. Co-Authored-By: Claude Sonnet 5.5 --- gateway/src/stdb/store/channel.rs | 119 ++++---- gateway/src/world/handlers/channel.rs | 266 ++++++------------ .../world/test_support/world_fake/channel.rs | 2 +- 3 files changed, 161 insertions(+), 226 deletions(-) diff --git a/gateway/src/stdb/store/channel.rs b/gateway/src/stdb/store/channel.rs index 523469605..3feb041e0 100644 --- a/gateway/src/stdb/store/channel.rs +++ b/gateway/src/stdb/store/channel.rs @@ -7,41 +7,42 @@ use crate::stdb::bindings::*; use crate::stdb::connection::{call_reducer, reducer_refusal_reason}; use crate::stdb::Coordinator; use crate::world::{ - resolve_online_character, whisper, ChannelActionStore, ChannelOutcome, ChannelRoster, + resolve_online_character, whisper, Actor, ChannelActionStore, ChannelOutcome, ChannelRoster, ResolvedTarget, }; -impl ChannelActionStore for crate::stdb::Coordinator { +impl ChannelActionStore for Coordinator { + /// `realm_channel_op`: run one Chat Channel op on Realm-core, or on the one database of an + /// unsharded Realm. The Module applies every channel rule. The actor's name stays here. fn channel_op( &self, - actor_guid: u64, + actor: Actor, op: u8, request: crate::world::ChannelRequest, ) -> Result { - crate::stdb::Coordinator::channel_op(self, actor_guid, op, request) - } - - fn channel_roster(&self, team: u32, channel_name: &str) -> Result> { - crate::stdb::Coordinator::channel_roster(self, team, channel_name) - } - - fn online_character_by_name(&self, name: &str) -> Result> { - resolve_online_character(self, name) - } - - fn ignores(&self, owner_guid: u64, other_guid: u64) -> Result { - whisper::ignored_anywhere(self, owner_guid, other_guid) + let realm = self.realm_core()?; + let request = ChannelRequest { + channel_name: request.channel_name, + password: request.password, + target_guid: request.target_guid, + target_name: request.target_name, + target_race: request.target_race, + target_ignores_actor: request.target_ignores_actor, + speaker: SpeakerFacts { + race: request.speaker.race, + chat_tag: request.speaker.chat_tag, + }, + }; + channel_outcome(call_reducer!( + realm.0.call_pipe().conn.reducers, + "realm_channel_op", + realm_channel_op_then(realm.session_actor(actor), op, request) + )) } -} -impl Coordinator { /// The channel `channel_name` names for `team` on Realm-core, with its members in join order /// and its owner's name from whichever World Shard holds the Character. - pub(crate) fn channel_roster( - &self, - team: u32, - channel_name: &str, - ) -> anyhow::Result> { + fn channel_roster(&self, team: u32, channel_name: &str) -> Result> { let realm = self.realm_core()?; let key = ChannelName::normalize(channel_name).key; let found = { @@ -84,32 +85,12 @@ impl Coordinator { })) } - /// `realm_channel_op`: run one Chat Channel op on Realm-core, or on the one database of an - /// unsharded Realm. The Module applies every channel rule. The actor's name stays here. - pub fn channel_op( - &self, - actor_guid: u64, - op: u8, - request: crate::world::ChannelRequest, - ) -> Result { - let realm = self.realm_core()?; - let request = ChannelRequest { - channel_name: request.channel_name, - password: request.password, - target_guid: request.target_guid, - target_name: request.target_name, - target_race: request.target_race, - target_ignores_actor: request.target_ignores_actor, - speaker: SpeakerFacts { - race: request.speaker.race, - chat_tag: request.speaker.chat_tag, - }, - }; - channel_outcome(call_reducer!( - realm.0.call_pipe().conn.reducers, - "realm_channel_op", - realm_channel_op_then(realm.actor_or_owner(actor_guid), op, request) - )) + fn online_character_by_name(&self, name: &str) -> Result> { + resolve_online_character(self, name) + } + + fn ignores(&self, owner_guid: u64, other_guid: u64) -> Result { + whisper::ignored_anywhere(self, owner_guid, other_guid) } } @@ -124,3 +105,43 @@ fn channel_outcome(result: Result<()>) -> Result { }, } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::stdb::{classify, DurableFailure, ReducerCallError}; + + #[test] + fn a_rejected_channel_tag_decodes_to_its_refusal() { + let error = ReducerCallError::refused("realm_channel_op", ChannelRefusal::Muted.as_tag()); + assert_eq!( + channel_outcome(Err(error.into())).unwrap(), + ChannelOutcome::Refused(ChannelRefusal::Muted) + ); + } + + #[test] + fn a_done_op_decodes_to_done() { + assert_eq!(channel_outcome(Ok(())).unwrap(), ChannelOutcome::Done); + } + + #[test] + fn an_unknown_rejection_stays_a_refusal_for_the_handler() { + let error = channel_outcome(Err(ReducerCallError::refused( + "realm_channel_op", + "mystery", + ) + .into())) + .unwrap_err(); + assert!(matches!(classify(&error), DurableFailure::Refusal { .. })); + } + + #[test] + fn a_lost_transport_stays_a_transport_loss() { + let error = channel_outcome(Err( + ReducerCallError::transport_lost("realm_channel_op").into() + )) + .unwrap_err(); + assert_eq!(classify(&error), DurableFailure::TransportLoss); + } +} diff --git a/gateway/src/world/handlers/channel.rs b/gateway/src/world/handlers/channel.rs index 834dcaf14..642b7912a 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::{is_transport_failure, ChatActionPlayer, ChatActionStore, SpeakerFacts}; +use super::chat::{drop_unrecognised_refusal, ChatActionPlayer, ChatActionStore, SpeakerFacts}; use lyracore_shared::channel::{channel_op, notice, ChannelRefusal}; use lyracore_shared::faction::team_for_race; @@ -57,12 +57,7 @@ pub(crate) struct ResolvedTarget { pub(crate) trait ChannelActionStore: ChatActionStore { /// Durable Request on Realm-core. The Coordinator picks the database; handlers never do. - fn channel_op( - &self, - actor_guid: u64, - op: u8, - request: ChannelRequest, - ) -> Result; + fn channel_op(&self, actor: Actor, op: u8, request: ChannelRequest) -> Result; /// Durable Read of the Realm-core cache. `None` when `team` has no channel by that name. fn channel_roster(&self, team: u32, channel_name: &str) -> Result>; /// The first `session_online` Character named `name`, realm-wide. `None` when no online @@ -249,14 +244,14 @@ pub(crate) fn dispatch_channel_action( fn actor_and_speaker( store: &St, player: ChatActionPlayer, -) -> Result> { - let Some(actor_guid) = player.self_guid else { +) -> Result> { + let Some(actor) = player.actor() else { return Ok(None); }; - let Some(speaker) = store.speaker_facts(actor_guid)? else { + let Some(speaker) = store.speaker_facts(actor.guid())? else { return Ok(None); }; - Ok(Some((actor_guid, speaker))) + Ok(Some((actor, speaker))) } /// What a Refusal from [`submit_channel_op`] names in its notice: the channel as the client typed @@ -269,17 +264,17 @@ struct RefusalContext { } /// Submit one built `request` as `op` and translate the outcome. Success notices return on the -/// Relay; a Refusal is answered here, from `refusal`. Only a lost reducer transport is fatal; -/// every other failure is logged at debug and dropped, keeping the World Session up. +/// Relay; a Refusal is answered here, from `refusal`. A Refusal this Gateway does not know is +/// logged at debug and dropped. A Transport Loss ends the World Session. fn submit_channel_op( store: &St, - account_id: u64, - actor_guid: u64, + player: ChatActionPlayer, + actor: Actor, op: u8, request: ChannelRequest, refusal: RefusalContext, ) -> Result> { - match store.channel_op(actor_guid, op, request) { + match store.channel_op(actor, op, request) { Ok(ChannelOutcome::Done) => Ok(Vec::new()), Ok(ChannelOutcome::Refused(tag)) => Ok(vec![refusal_notice( tag, @@ -287,9 +282,8 @@ fn submit_channel_op( refusal.subject_guid, refusal.target_name, )]), - Err(error) if is_transport_failure(&error) => Err(error), Err(error) => { - log::debug!("world: channel op {op} dropped (account {account_id}): {error:#}"); + drop_unrecognised_refusal(player, format_args!("channel op {op}"), error)?; Ok(Vec::new()) } } @@ -304,7 +298,7 @@ fn run_op( channel_name: String, password: String, ) -> Result> { - let Some((actor_guid, speaker)) = actor_and_speaker(store, player)? else { + let Some((actor, speaker)) = actor_and_speaker(store, player)? else { return Ok(Vec::new()); }; let request = ChannelRequest { @@ -318,49 +312,26 @@ fn run_op( }; submit_channel_op( store, - player.account_id, - actor_guid, + player, + actor, op, request, RefusalContext { channel_name, - subject_guid: actor_guid, + subject_guid: actor.guid(), target_name: String::new(), }, ) } -/// A Store read behind [`run_targeted_op`] that must not end the World Session unless it fails -/// with a genuine transport loss. Anything else is logged at debug and answered as absent, the -/// same way an unresolved name is: this Gateway process cannot finish the op either way, and a -/// name lookup or an ignore-list read that a peer Shard cannot currently answer is no different -/// from a target this Gateway cannot see. -fn recoverable(op: u8, account_id: u64, result: Result) -> Result> { - match result { - Ok(value) => Ok(Some(value)), - Err(error) if is_transport_failure(&error) => Err(error), - Err(error) => { - log::debug!( - "world: channel op {op} target read dropped (account {account_id}): {error:#}" - ); - Ok(None) - } - } -} - /// Run one op that names another Character: SET_OWNER, MODERATOR, UNMODERATOR, MUTE, UNMUTE, KICK, /// BAN, UNBAN and INVITE. The target is resolved realm-wide and must be online /// (cm:Channel.cpp:192-199, cm:Channel.cpp:262-269, cm:Channel.cpp:348-355, cm:Channel.cpp:416-423, /// cm:Channel.cpp:678-685). cmangos checks membership, then rights, then the target name, so an /// unresolved name still reaches the Module: it goes out as `target_guid` 0, the sentinel no real /// Character ever holds, and the op core runs its own NotMember, NotModerator or NotOwner Gate -/// first and only then answers PLAYER_NOT_FOUND. A target lookup that fails without losing -/// transport degrades the same way, as [`recoverable`] documents. -/// -/// INVITE alone also reads whether a RESOLVED target ignores the actor; that read has no name to -/// fall back on, so a failure there (again, anything short of a transport loss) answers -/// PLAYER_NOT_FOUND directly and skips the Durable Request, since there is nothing further to -/// learn from one. +/// first and only then answers PLAYER_NOT_FOUND. A failed target read is a Transport Loss and +/// ends the World Session. fn run_targeted_op( store: &St, player: ChatActionPlayer, @@ -368,38 +339,18 @@ fn run_targeted_op( channel_name: String, typed_name: String, ) -> Result> { - let Some((actor_guid, speaker)) = actor_and_speaker(store, player)? else { + let Some((actor, speaker)) = actor_and_speaker(store, player)? else { return Ok(Vec::new()); }; - let resolved = recoverable( - op, - player.account_id, - store.online_character_by_name(&typed_name), - )? - .flatten(); - let target = resolved.unwrap_or(ResolvedTarget { - guid: 0, - race: 0, - name: typed_name, - }); - let target_ignores_actor = if target.guid != 0 && op == channel_op::INVITE { - let Some(ignores) = recoverable( - op, - player.account_id, - store.ignores(target.guid, actor_guid), - )? - else { - return Ok(vec![refusal_notice( - ChannelRefusal::PlayerNotFound, - channel_name, - target.guid, - target.name, - )]); - }; - ignores - } else { - false - }; + let target = store + .online_character_by_name(&typed_name)? + .unwrap_or(ResolvedTarget { + guid: 0, + race: 0, + name: typed_name, + }); + let target_ignores_actor = + target.guid != 0 && op == channel_op::INVITE && store.ignores(target.guid, actor.guid())?; let request = ChannelRequest { channel_name: channel_name.clone(), password: String::new(), @@ -411,8 +362,8 @@ fn run_targeted_op( }; submit_channel_op( store, - player.account_id, - actor_guid, + player, + actor, op, request, RefusalContext { @@ -431,29 +382,21 @@ fn read_roster( channel_name: String, answer: impl FnOnce(ChannelRoster) -> Outbound, ) -> Result> { - let Some(actor_guid) = player.self_guid else { + let Some(actor) = player.actor() else { return Ok(Vec::new()); }; - let Some(speaker) = store.speaker_facts(actor_guid)? else { + let Some(speaker) = store.speaker_facts(actor.guid())? else { return Ok(Vec::new()); }; - let roster = match store.channel_roster(team_for_race(speaker.race), &channel_name) { - Ok(roster) => roster, - Err(error) => { - log::debug!( - "world: channel read dropped (account {}): {error:#}", - player.account_id - ); - return Ok(Vec::new()); - } - }; - let member = roster.filter(|roster| roster.members.iter().any(|&(guid, _)| guid == actor_guid)); + let roster = store.channel_roster(team_for_race(speaker.race), &channel_name)?; + let member = + roster.filter(|roster| roster.members.iter().any(|&(guid, _)| guid == actor.guid())); Ok(vec![match member { Some(roster) => answer(roster), None => refusal_notice( ChannelRefusal::NotMember, channel_name, - actor_guid, + actor.guid(), String::new(), ), }]) @@ -487,6 +430,7 @@ fn notice_outbound(view: codec::channel::ChannelNoticeView) -> Outbound { mod tests { use super::super::chat::{ChatOutcome, RealmChatRequest, WhisperRequest, WhisperTargetFacts}; use super::*; + use crate::stdb::{classify, DurableFailure, ReducerCallError}; use std::sync::Mutex; use wow_world_messages::vanilla::{ CMSG_CHANNEL_ANNOUNCEMENTS, CMSG_CHANNEL_BAN, CMSG_CHANNEL_INVITE, CMSG_CHANNEL_KICK, @@ -496,21 +440,38 @@ mod tests { CMSG_PING, }; + /// How a Fake's Durable Request or read fails. + #[derive(Clone, Copy)] + enum Failure { + /// The Module rejects with a tag this Gateway does not know. + Refused, + TransportLost, + } + + impl Failure { + fn error(self, op: &str) -> anyhow::Error { + match self { + Self::Refused => ReducerCallError::refused(op, "mystery").into(), + Self::TransportLost => ReducerCallError::transport_lost(op).into(), + } + } + } + #[derive(Default)] struct InMemoryChannelActions { facts: Option, - outcome: Option>, + outcome: Option>, roster: Option, roster_failure: bool, /// What `online_character_by_name` resolves a typed name to. `None` answers /// PLAYER_NOT_FOUND, as an unknown or offline Character does. online: Option, - /// `online_character_by_name` fails with this non-transport message instead of resolving. - lookup_failure: Option, + /// `online_character_by_name` fails like this instead of resolving. + lookup_failure: Option, /// What `ignores` answers for every pair. ignored: bool, - /// `ignores` fails with this non-transport message instead of answering. - ignore_failure: Option, + /// `ignores` fails like this instead of answering. + ignore_failure: Option, ignore_reads: Mutex, ops: Mutex>, roster_reads: Mutex>, @@ -521,15 +482,11 @@ mod tests { Ok(self.facts.clone()) } - fn realm_chat( - &self, - _speaker_guid: u64, - _request: RealmChatRequest, - ) -> Result { + fn realm_chat(&self, _actor: Actor, _request: RealmChatRequest) -> Result { Ok(ChatOutcome::Delivered) } - fn set_away(&self, _speaker_guid: u64, _kind: u8, _message: String) -> Result<()> { + fn set_away(&self, _actor: Actor, _kind: u8, _message: String) -> Result<()> { Ok(()) } @@ -541,11 +498,7 @@ mod tests { Ok(None) } - fn realm_whisper( - &self, - _speaker_guid: u64, - _request: WhisperRequest, - ) -> Result { + fn realm_whisper(&self, _actor: Actor, _request: WhisperRequest) -> Result { Ok(ChatOutcome::Delivered) } @@ -557,15 +510,15 @@ mod tests { impl ChannelActionStore for InMemoryChannelActions { fn channel_op( &self, - actor_guid: u64, + actor: Actor, op: u8, request: ChannelRequest, ) -> Result { - self.ops.lock().unwrap().push((actor_guid, op, request)); - match &self.outcome { + self.ops.lock().unwrap().push((actor.guid(), op, request)); + match self.outcome { None => Ok(ChannelOutcome::Done), - Some(Ok(outcome)) => Ok(*outcome), - Some(Err(failure)) => Err(anyhow::anyhow!("{failure}")), + Some(Ok(outcome)) => Ok(outcome), + Some(Err(failure)) => Err(failure.error("realm_channel_op")), } } @@ -581,16 +534,16 @@ mod tests { } fn online_character_by_name(&self, _name: &str) -> Result> { - if let Some(failure) = &self.lookup_failure { - anyhow::bail!("{failure}"); + if let Some(failure) = self.lookup_failure { + return Err(failure.error("online_character_by_name")); } Ok(self.online.clone()) } fn ignores(&self, _owner_guid: u64, _other_guid: u64) -> Result { *self.ignore_reads.lock().unwrap() += 1; - if let Some(failure) = &self.ignore_failure { - anyhow::bail!("{failure}"); + if let Some(failure) = self.ignore_failure { + return Err(failure.error("ignores")); } Ok(self.ignored) } @@ -606,7 +559,7 @@ mod tests { } } - fn store(outcome: Option>) -> InMemoryChannelActions { + fn store(outcome: Option>) -> InMemoryChannelActions { InMemoryChannelActions { facts: Some(orc()), outcome, @@ -863,20 +816,16 @@ mod tests { #[test] fn a_lost_reducer_transport_ends_the_session() { - let store = store(Some(Err( - "realm_channel_op reducer transport disconnected: channel closed".to_string(), - ))); + let store = store(Some(Err(Failure::TransportLost))); let error = dispatch_channel_action(&store, player(), join("Rx", "")) .err() .expect("transport loss is fatal"); - assert!(error.to_string().contains("transport disconnected")); + assert_eq!(classify(&error), DurableFailure::TransportLoss); } #[test] - fn any_other_failure_drops_the_op_and_keeps_the_session() { - let store = store(Some(Err( - "realm_channel_op reducer timed out after 10s".to_string() - ))); + 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()); assert!(outbound.is_empty()); } @@ -970,13 +919,12 @@ mod tests { } #[test] - fn a_failed_read_answers_nothing() { + fn a_failed_roster_read_ends_the_session() { let store = InMemoryChannelActions { roster_failure: true, ..store(None) }; - let outbound = handled(dispatch_channel_action(&store, player(), list("Raiders")).unwrap()); - assert!(outbound.is_empty()); + assert!(dispatch_channel_action(&store, player(), list("Raiders")).is_err()); } #[test] @@ -1084,29 +1032,18 @@ mod tests { assert_eq!(ops[0].2.target_name, "Ghost"); } - /// A target lookup that fails without losing transport (a peer Shard that cannot currently - /// vouch for absence) degrades the same way as an unresolved name: `target_guid` 0 reaches - /// the Module, and the World Session stays up. Only a transport loss may end the session. + /// A failed target lookup is a Transport Loss: no Durable Request goes out. #[test] - fn a_failed_target_lookup_reaches_the_module_as_guid_zero_and_keeps_the_session() { + fn a_failed_target_lookup_ends_the_session() { let store = InMemoryChannelActions { - lookup_failure: Some("realm-core database lyracore-realm is not connected".to_string()), - outcome: Some(Ok(ChannelOutcome::Refused(ChannelRefusal::PlayerNotFound))), + lookup_failure: Some(Failure::TransportLost), ..store(None) }; - let outbound = - handled(dispatch_channel_action(&store, player(), kick("Raiders", "Ghost")).unwrap()); - assert_eq!( - only_raw(outbound), - (0x0099, [&[0x09][..], b"Raiders\0Ghost\0"].concat()) - ); - let ops = store.ops.lock().unwrap(); - assert_eq!( - ops.len(), - 1, - "a recoverable lookup failure still reaches the Module" - ); - assert_eq!(ops[0].2.target_guid, 0); + let error = dispatch_channel_action(&store, player(), kick("Raiders", "Ghost")) + .err() + .expect("a failed read is fatal"); + assert_eq!(classify(&error), DurableFailure::TransportLoss); + assert!(store.ops.lock().unwrap().is_empty()); } /// An unresolved INVITE target carries `target_ignores_actor` false and never reads the @@ -1130,40 +1067,17 @@ mod tests { ); } - /// A failed ignore-list read on INVITE degrades the same way: PLAYER_NOT_FOUND, no Durable - /// Request, session kept alive. + /// A failed ignore-list read on INVITE is a Transport Loss: no Durable Request goes out. #[test] - fn a_failed_ignore_read_answers_player_not_found_and_keeps_the_session() { + fn a_failed_ignore_read_ends_the_session() { let store = InMemoryChannelActions { - ignore_failure: Some("realm-core database lyracore-realm is not connected".to_string()), + ignore_failure: Some(Failure::TransportLost), ..resolved(false) }; - let outbound = handled( - dispatch_channel_action(&store, player(), invite("Raiders", "Thrall")).unwrap(), - ); - assert_eq!( - only_raw(outbound), - (0x0099, [&[0x09][..], b"Raiders\0Thrall\0"].concat()) - ); + assert!(dispatch_channel_action(&store, player(), invite("Raiders", "Thrall")).is_err()); assert!(store.ops.lock().unwrap().is_empty()); } - /// A transport-lost target lookup still ends the World Session; only the answer-and-continue - /// behavior above is new. - #[test] - fn a_lost_transport_on_target_lookup_ends_the_session() { - let store = InMemoryChannelActions { - lookup_failure: Some( - "realm_channel_op reducer transport disconnected: channel closed".to_string(), - ), - ..store(None) - }; - let error = dispatch_channel_action(&store, player(), kick("Raiders", "Ghost")) - .err() - .expect("transport loss is fatal"); - assert!(error.to_string().contains("transport disconnected")); - } - /// PLAYER_ALREADY_MEMBER 0x17 names the resolved target, not the actor. #[test] fn player_already_member_names_the_resolved_target() { diff --git a/gateway/src/world/test_support/world_fake/channel.rs b/gateway/src/world/test_support/world_fake/channel.rs index 75b0a6b9f..5862e49ad 100644 --- a/gateway/src/world/test_support/world_fake/channel.rs +++ b/gateway/src/world/test_support/world_fake/channel.rs @@ -3,7 +3,7 @@ use super::super::*; impl ChannelActionStore for WorldFake { fn channel_op( &self, - _actor_guid: u64, + _actor: Actor, _op: u8, _request: ChannelRequest, ) -> Result { From bc3dc7d6da918e05fa9c96d6837774fa61b5791a Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 18:35:43 +0200 Subject: [PATCH 16/33] refactor(gateway): guild fee takes an Actor Inline the hold, decide and finish wrappers, drop CharterOwner, and type the test realm's failures. Co-Authored-By: Claude Sonnet 5.5 --- gateway/src/stdb/store/guild_fee.rs | 201 +++++++++++----------------- gateway/src/world/guild_fee.rs | 121 ++++++++++------- 2 files changed, 148 insertions(+), 174 deletions(-) diff --git a/gateway/src/stdb/store/guild_fee.rs b/gateway/src/stdb/store/guild_fee.rs index 335e3f1b9..c9ece3187 100644 --- a/gateway/src/stdb/store/guild_fee.rs +++ b/gateway/src/stdb/store/guild_fee.rs @@ -8,117 +8,42 @@ use crate::stdb::connection::{call_reducer, reducer_refusal_reason}; use crate::stdb::reducers::{next_operation_id, wait_for_cache_row}; use crate::stdb::Coordinator; use crate::world::guild_fee; -use crate::world::guild_fee::{ - CharterOwner, FeeHold, FeeOutcome, FeeRequest, FeeTerms, GuildFeeStore, -}; +use crate::world::guild_fee::{FeeHold, FeeOutcome, FeeRequest, FeeTerms, GuildFeeStore}; +use crate::world::Actor; -impl GuildFeeStore for crate::stdb::Coordinator { - fn guild_fee_held(&self, actor_guid: u64) -> Result> { - Ok(self.guild_fee_hold_row(actor_guid)) +impl GuildFeeStore for Coordinator { + fn guild_fee_held(&self, actor: Actor) -> Result> { + Ok(self.guild_fee_hold_row(actor)) } + /// `gw_guild_fee_hold` on this Home Shard under a fresh operation id, then the Hold once this + /// handle's cache shows it: the hold mints a Guild Charter's guid. A tagged Refusal answers + /// `Ok(Err(_))`. The call is made once per operation id and never retried. fn guild_fee_hold( &self, - actor_guid: u64, + actor: Actor, request: FeeRequest, ) -> Result> { - self.hold_guild_fee(actor_guid, request) - } - - fn guild_fee_decide(&self, actor_guid: u64, hold: FeeHold) -> Result { - let owner = match hold.terms { - FeeTerms::Emblem(_) => None, - FeeTerms::Charter { .. } => { - let facts = crate::stdb::Coordinator::guild_character_facts(self, actor_guid)? - .ok_or_else(|| anyhow!("Guild Charter owner {actor_guid} is unreadable"))?; - Some(CharterOwner { - name: facts.name, - team: lyracore_shared::faction::team_for_race(facts.race), - }) - } - }; - self.realm_core()?.decide_guild_fee(actor_guid, hold, owner) - } - - fn guild_fee_finish(&self, actor_guid: u64, operation_id: u64, accepted: bool) -> Result<()> { - self.finish_guild_fee(actor_guid, operation_id, accepted) - } -} - -impl Coordinator { - /// The Fee Hold of `payer_guid` in THIS handle's cache. Call it on the payer's Home Shard. A - /// kind this Gateway does not know reads as none. - pub(crate) fn guild_fee_hold_row(&self, payer_guid: u64) -> Option { - let hold = self - .0 - .coord() - .conn - .db - .game_guild_fee_hold() - .payer_guid() - .find(&payer_guid)?; - let terms = match hold.kind { - lyracore_shared::guild::fee_kind::EMBLEM => { - guild_fee::FeeTerms::Emblem(guild_fee::Emblem { - emblem_style: hold.emblem_style, - emblem_color: hold.emblem_color, - border_style: hold.border_style, - border_color: hold.border_color, - background_color: hold.background_color, - }) - } - lyracore_shared::guild::fee_kind::CHARTER => guild_fee::FeeTerms::Charter { - charter_item_guid: hold.charter_item_guid, - name: hold.charter_name, - }, - _ => return None, - }; - Some(guild_fee::FeeHold { - operation_id: hold.operation_id, - terms, - }) - } - - /// Realm-core's fee decision for `operation_id` in THIS handle's cache. Call it on the - /// Realm-core handle. - pub(crate) fn guild_fee_decision_row(&self, operation_id: u64) -> Option { - self.0 - .coord() - .conn - .db - .game_guild_fee_decision() - .operation_id() - .find(&operation_id) - } - - /// `gw_guild_fee_hold` on THIS handle, the actor's Home Shard, under a fresh operation id, then - /// the Hold once this handle's cache shows it: the hold mints a Guild Charter's guid. A tagged - /// Refusal answers `Ok(Err(_))`. The call is made once per operation id and never retried. - pub(crate) fn hold_guild_fee( - &self, - actor_guid: u64, - request: guild_fee::FeeRequest, - ) -> Result> { let operation_id = next_operation_id()?; let request = match request { - guild_fee::FeeRequest::Emblem { npc_guid, emblem } => { + FeeRequest::Emblem { npc_guid, emblem } => { GuildFeeRequest::Emblem(GuildEmblemPurchase { npc_guid, emblem: guild_emblem(emblem), }) } - guild_fee::FeeRequest::Charter { npc_guid, name } => { + FeeRequest::Charter { npc_guid, name } => { GuildFeeRequest::Charter(GuildCharterPurchase { npc_guid, name }) } }; let result = call_reducer!( self.0.call_pipe().conn.reducers, "gw_guild_fee_hold", - gw_guild_fee_hold_then(operation_id, self.actor_or_owner(actor_guid), request) + gw_guild_fee_hold_then(operation_id, self.session_actor(actor), request) ); match result { Ok(()) => wait_for_cache_row(operation_id, "guild Fee Hold", || { - self.guild_fee_hold_row(actor_guid) + self.guild_fee_hold_row(actor) .filter(|hold| hold.operation_id == operation_id) }) .map(Ok), @@ -129,62 +54,88 @@ impl Coordinator { } } - /// `realm_guild_fee_decide` on THIS handle, Realm-core, then the decision once this handle's - /// cache shows it. The reducer is idempotent, so a re-drive calls it again. A Charter needs - /// `owner`, the facts Realm-core cannot read. - pub(crate) fn decide_guild_fee( - &self, - actor_guid: u64, - hold: guild_fee::FeeHold, - owner: Option, - ) -> Result { - let terms = match (hold.terms, owner) { - (guild_fee::FeeTerms::Emblem(emblem), _) => GuildFeeTerms::Emblem(guild_emblem(emblem)), - ( - guild_fee::FeeTerms::Charter { - charter_item_guid, - name, - }, - Some(owner), - ) => GuildFeeTerms::Charter(GuildCharterTerms { + /// `realm_guild_fee_decide` on Realm-core, then the decision once Realm-core's cache shows + /// it. The reducer is idempotent, so a re-drive calls it again. A Charter needs the owner + /// facts Realm-core cannot read. + fn guild_fee_decide(&self, actor: Actor, hold: FeeHold) -> Result { + let terms = match hold.terms { + FeeTerms::Emblem(emblem) => GuildFeeTerms::Emblem(guild_emblem(emblem)), + FeeTerms::Charter { charter_item_guid, name, - payer_name: owner.name, - payer_team: owner.team, - }), - (guild_fee::FeeTerms::Charter { .. }, None) => { - return Err(anyhow!( - "Guild Charter decision {} has no owner facts", - hold.operation_id - )); + } => { + let facts = crate::stdb::Coordinator::guild_character_facts(self, actor.guid())? + .ok_or_else(|| anyhow!("Guild Charter owner {} is unreadable", actor.guid()))?; + GuildFeeTerms::Charter(GuildCharterTerms { + charter_item_guid, + name, + payer_name: facts.name, + payer_team: lyracore_shared::faction::team_for_race(facts.race), + }) } }; + let realm = self.realm_core()?; call_reducer!( - self.0.call_pipe().conn.reducers, + realm.0.call_pipe().conn.reducers, "realm_guild_fee_decide", - realm_guild_fee_decide_then(hold.operation_id, self.actor_or_owner(actor_guid), terms) + realm_guild_fee_decide_then(hold.operation_id, realm.session_actor(actor), terms) )?; let decision = wait_for_cache_row(hold.operation_id, "guild fee decision", || { - self.guild_fee_decision_row(hold.operation_id) + realm + .0 + .coord() + .conn + .db + .game_guild_fee_decision() + .operation_id() + .find(&hold.operation_id) })?; fee_outcome(&decision) } - /// `gw_guild_fee_finish` on THIS handle, the actor's Home Shard. - pub(crate) fn finish_guild_fee( - &self, - actor_guid: u64, - operation_id: u64, - accepted: bool, - ) -> Result<()> { + /// `gw_guild_fee_finish` on this Home Shard. + fn guild_fee_finish(&self, actor: Actor, operation_id: u64, accepted: bool) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, "gw_guild_fee_finish", - gw_guild_fee_finish_then(operation_id, self.actor_or_owner(actor_guid), accepted) + gw_guild_fee_finish_then(operation_id, self.session_actor(actor), accepted) ) } } +impl Coordinator { + /// The Fee Hold of `payer` in THIS handle's cache. Call it on the payer's Home Shard. A kind + /// this Gateway does not know reads as none. + fn guild_fee_hold_row(&self, payer: Actor) -> Option { + let hold = self + .0 + .coord() + .conn + .db + .game_guild_fee_hold() + .payer_guid() + .find(&payer.guid())?; + let terms = match hold.kind { + lyracore_shared::guild::fee_kind::EMBLEM => FeeTerms::Emblem(guild_fee::Emblem { + emblem_style: hold.emblem_style, + emblem_color: hold.emblem_color, + border_style: hold.border_style, + border_color: hold.border_color, + background_color: hold.background_color, + }), + lyracore_shared::guild::fee_kind::CHARTER => FeeTerms::Charter { + charter_item_guid: hold.charter_item_guid, + name: hold.charter_name, + }, + _ => return None, + }; + Some(FeeHold { + operation_id: hold.operation_id, + terms, + }) + } +} + fn guild_emblem(emblem: guild_fee::Emblem) -> GuildEmblem { GuildEmblem { emblem_style: emblem.emblem_style, diff --git a/gateway/src/world/guild_fee.rs b/gateway/src/world/guild_fee.rs index 378366307..87c925b30 100644 --- a/gateway/src/world/guild_fee.rs +++ b/gateway/src/world/guild_fee.rs @@ -12,6 +12,8 @@ use anyhow::Result; use lyracore_shared::guild::GuildRefusal; +use super::Actor; + /// The five tabard design values of a Guild Emblem, in wire order. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) struct Emblem { @@ -42,14 +44,6 @@ pub(crate) enum FeeTerms { }, } -/// The owner facts a Charter decision carries. Realm-core holds no Character rows. -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct CharterOwner { - pub(crate) name: String, - /// `lyracore_shared::faction::TEAM_*`. - pub(crate) team: u32, -} - /// One Fee Hold on the actor's Home Shard. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct FeeHold { @@ -68,19 +62,19 @@ pub(crate) enum FeeOutcome { /// to Realm-core. pub(crate) trait GuildFeeStore: Send + Sync { /// The actor's Fee Hold on its Home Shard, if one is left. - fn guild_fee_held(&self, actor_guid: u64) -> Result>; + fn guild_fee_held(&self, actor: Actor) -> Result>; /// Move the fee for `request` from the actor's purse into a new Fee Hold under a fresh /// operation id. `Ok(Err(_))` is a hold Refusal, and nothing changed. fn guild_fee_hold( &self, - actor_guid: u64, + actor: Actor, request: FeeRequest, ) -> Result>; /// Realm-core's one decision for `hold`, committed now unless it already exists. - fn guild_fee_decide(&self, actor_guid: u64, hold: FeeHold) -> Result; + fn guild_fee_decide(&self, actor: Actor, hold: FeeHold) -> Result; /// Spend the Hold when `accepted`, refund it otherwise, then delete it. Without a Hold for /// `operation_id` this changes nothing. - fn guild_fee_finish(&self, actor_guid: u64, operation_id: u64, accepted: bool) -> Result<()>; + fn guild_fee_finish(&self, actor: Actor, operation_id: u64, accepted: bool) -> Result<()>; } /// Pay a guild fee. A leftover Hold is finished first. A hold Refusal (the NPC, the purse) @@ -88,12 +82,12 @@ pub(crate) trait GuildFeeStore: Send + Sync { /// been spent or refunded to match it. `Err` leaves any Hold for the next re-drive. pub(crate) fn pay( store: &St, - actor_guid: u64, + actor: Actor, request: FeeRequest, ) -> Result { - finish_leftover(store, actor_guid)?; - match store.guild_fee_hold(actor_guid, request)? { - Ok(hold) => settle(store, actor_guid, hold), + finish_leftover(store, actor)?; + match store.guild_fee_hold(actor, request)? { + Ok(hold) => settle(store, actor, hold), Err(refusal) => Ok(FeeOutcome::Refused(refusal)), } } @@ -101,36 +95,42 @@ pub(crate) fn pay( /// Finish the actor's leftover Fee Hold at world entry. A failure logs and leaves the Hold for /// the next attempt. pub(crate) fn redrive(store: &St, actor_guid: u64) { - if let Err(error) = finish_leftover(store, actor_guid) { + let Some(actor) = Actor::new(actor_guid) else { + return; + }; + if let Err(error) = finish_leftover(store, actor) { log::warn!("world: Fee Hold of {actor_guid} left for the next re-drive: {error:#}"); } } -fn finish_leftover(store: &St, actor_guid: u64) -> Result<()> { - let Some(hold) = store.guild_fee_held(actor_guid)? else { +fn finish_leftover(store: &St, actor: Actor) -> Result<()> { + let Some(hold) = store.guild_fee_held(actor)? else { return Ok(()); }; let operation_id = hold.operation_id; - let outcome = settle(store, actor_guid, hold)?; - log::info!("world: finished leftover Fee Hold {operation_id} of {actor_guid}: {outcome:?}"); + let outcome = settle(store, actor, hold)?; + log::info!( + "world: finished leftover Fee Hold {operation_id} of {}: {outcome:?}", + actor.guid() + ); Ok(()) } fn settle( store: &St, - actor_guid: u64, + actor: Actor, hold: FeeHold, ) -> Result { let operation_id = hold.operation_id; - let outcome = store.guild_fee_decide(actor_guid, hold)?; - store.guild_fee_finish(actor_guid, operation_id, outcome == FeeOutcome::Accepted)?; + let outcome = store.guild_fee_decide(actor, hold)?; + store.guild_fee_finish(actor, operation_id, outcome == FeeOutcome::Accepted)?; Ok(outcome) } #[cfg(test)] mod tests { use super::*; - use anyhow::anyhow; + use crate::stdb::ReducerCallError; use lyracore_shared::guild::EMBLEM_COST_COPPER; use std::sync::{Arc, Mutex}; @@ -146,6 +146,10 @@ mod tests { background_color: 15, }; + fn actor(guid: u64) -> Actor { + Actor::new(guid).unwrap() + } + fn purchase() -> FeeRequest { FeeRequest::Emblem { npc_guid: DESIGNER, @@ -219,7 +223,7 @@ mod tests { let mut crash = self.crash_after.lock().unwrap(); if *crash == Some(step) { *crash = None; - return Err(anyhow!("connection lost after {step:?} committed")); + return Err(ReducerCallError::transport_lost(&format!("{step:?}")).into()); } Ok(()) } @@ -235,7 +239,8 @@ mod tests { } impl GuildFeeStore for Shard { - fn guild_fee_held(&self, actor_guid: u64) -> Result> { + fn guild_fee_held(&self, actor: Actor) -> Result> { + let actor_guid = actor.guid(); Ok(self .holds .lock() @@ -247,11 +252,16 @@ mod tests { fn guild_fee_hold( &self, - actor_guid: u64, + actor: Actor, request: FeeRequest, ) -> Result> { - if self.guild_fee_held(actor_guid)?.is_some() { - return Err(anyhow!("guild fee: another Fee Hold is pending")); + let actor_guid = actor.guid(); + if self.guild_fee_held(actor)?.is_some() { + return Err(ReducerCallError::refused( + "gw_guild_fee_hold", + "another Fee Hold is pending", + ) + .into()); } if self.purse(actor_guid) < EMBLEM_COST_COPPER { return Ok(Err(GuildRefusal::NotEnoughMoney)); @@ -274,7 +284,8 @@ mod tests { Ok(Ok(hold)) } - fn guild_fee_decide(&self, actor_guid: u64, hold: FeeHold) -> Result { + fn guild_fee_decide(&self, actor: Actor, hold: FeeHold) -> Result { + let actor_guid = actor.guid(); let mut realm = self.realm.lock().unwrap(); let known = realm .decisions @@ -303,12 +314,8 @@ mod tests { Ok(outcome) } - fn guild_fee_finish( - &self, - actor_guid: u64, - operation_id: u64, - accepted: bool, - ) -> Result<()> { + fn guild_fee_finish(&self, actor: Actor, operation_id: u64, accepted: bool) -> Result<()> { + let actor_guid = actor.guid(); let mut holds = self.holds.lock().unwrap(); if let Some(at) = holds.iter().position(|(payer, hold, _)| { *payer == actor_guid && hold.operation_id == operation_id @@ -335,11 +342,11 @@ mod tests { let realm = realm_led_by(LEADER); let home = Shard::new(&realm, 150_000); assert_eq!( - pay(&home, LEADER, purchase()).unwrap(), + pay(&home, actor(LEADER), purchase()).unwrap(), FeeOutcome::Accepted ); assert_eq!(home.purse(LEADER), 50_000); - assert_eq!(home.guild_fee_held(LEADER).unwrap(), None); + assert_eq!(home.guild_fee_held(actor(LEADER)).unwrap(), None); assert_eq!(realm.lock().unwrap().emblem, Some(EMBLEM)); } @@ -348,11 +355,11 @@ mod tests { let realm = realm_led_by(LEADER); let home = Shard::new(&realm, 150_000); assert_eq!( - pay(&home, MEMBER, purchase()).unwrap(), + pay(&home, actor(MEMBER), purchase()).unwrap(), FeeOutcome::Refused(GuildRefusal::NotLeader) ); assert_eq!(home.purse(MEMBER), 150_000); - assert_eq!(home.guild_fee_held(MEMBER).unwrap(), None); + assert_eq!(home.guild_fee_held(actor(MEMBER)).unwrap(), None); assert_eq!(realm.lock().unwrap().emblem, None); } @@ -361,7 +368,7 @@ mod tests { let realm = realm_led_by(LEADER); let home = Shard::new(&realm, 99_999); assert_eq!( - pay(&home, LEADER, purchase()).unwrap(), + pay(&home, actor(LEADER), purchase()).unwrap(), FeeOutcome::Refused(GuildRefusal::NotEnoughMoney) ); assert_eq!(home.purse(LEADER), 99_999); @@ -377,14 +384,14 @@ mod tests { let realm = realm_led_by(LEADER); let home = Shard::new(&realm, 150_000); home.crash_after(step); - assert!(pay(&home, payer, purchase()).is_err(), "{step:?}"); + assert!(pay(&home, actor(payer), purchase()).is_err(), "{step:?}"); redrive(&home, payer); redrive(&home, payer); let purse = if spent { 50_000 } else { 150_000 }; assert_eq!(home.purse(payer), purse, "{step:?} {payer}"); - assert_eq!(home.guild_fee_held(payer).unwrap(), None, "{step:?}"); + assert_eq!(home.guild_fee_held(actor(payer)).unwrap(), None, "{step:?}"); let realm = realm.lock().unwrap(); assert_eq!(realm.decisions.len(), 1, "{step:?}"); assert_eq!(realm.emblems_saved, usize::from(spent), "{step:?}"); @@ -392,14 +399,30 @@ mod tests { } } + #[test] + fn a_lost_answer_is_a_transport_loss_and_a_rejected_hold_is_a_refusal() { + use crate::stdb::{classify, DurableFailure}; + let realm = realm_led_by(LEADER); + let home = Shard::new(&realm, 250_000); + home.crash_after(Step::Hold); + let lost = pay(&home, actor(LEADER), purchase()).unwrap_err(); + assert_eq!(classify(&lost), DurableFailure::TransportLoss); + + let rejected = home.guild_fee_hold(actor(LEADER), purchase()).unwrap_err(); + assert!(matches!( + classify(&rejected), + DurableFailure::Refusal { .. } + )); + } + #[test] fn the_next_fee_finishes_a_leftover_hold_first() { let realm = realm_led_by(LEADER); let home = Shard::new(&realm, 250_000); home.crash_after(Step::Decide); - assert!(pay(&home, LEADER, purchase()).is_err()); + assert!(pay(&home, actor(LEADER), purchase()).is_err()); assert_eq!( - pay(&home, LEADER, purchase()).unwrap(), + pay(&home, actor(LEADER), purchase()).unwrap(), FeeOutcome::Accepted ); assert_eq!(home.purse(LEADER), 50_000); @@ -412,7 +435,7 @@ mod tests { let old_home = Shard::new(&realm, 150_000); let new_home = Shard::new(&realm, 0); old_home.crash_after(Step::Hold); - assert!(pay(&old_home, MEMBER, purchase()).is_err()); + assert!(pay(&old_home, actor(MEMBER), purchase()).is_err()); assert_eq!(old_home.purse(MEMBER), 50_000); old_home.transfer_holds_to(&new_home); @@ -420,7 +443,7 @@ mod tests { redrive(&new_home, MEMBER); assert_eq!(new_home.purse(MEMBER), 150_000); - assert_eq!(new_home.guild_fee_held(MEMBER).unwrap(), None); - assert_eq!(old_home.guild_fee_held(MEMBER).unwrap(), None); + assert_eq!(new_home.guild_fee_held(actor(MEMBER)).unwrap(), None); + assert_eq!(old_home.guild_fee_held(actor(MEMBER)).unwrap(), None); } } From 4e97cead0a2ddc0c711dbb0005f8e45b8055cb5b Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 18:35:43 +0200 Subject: [PATCH 17/33] refactor(gateway): guild takes an Actor and classifies by Transport Loss Inline the guild inherent wrappers, take Actor on guild_op and guild_destroy_charter, extract guild_outcome, and replace is_fatal with classify. Co-Authored-By: Claude Sonnet 5.5 --- gateway/src/stdb/store/guild.rs | 430 +++++++++--------- gateway/src/world/handlers/guild.rs | 335 ++++++++------ .../world/test_support/world_fake/guild.rs | 24 +- 3 files changed, 424 insertions(+), 365 deletions(-) diff --git a/gateway/src/stdb/store/guild.rs b/gateway/src/stdb/store/guild.rs index 906a25721..118da271e 100644 --- a/gateway/src/stdb/store/guild.rs +++ b/gateway/src/stdb/store/guild.rs @@ -9,123 +9,46 @@ use crate::stdb::connection::{call_reducer, reducer_refusal_reason}; use crate::stdb::reads::petition_view; use crate::stdb::Coordinator; use crate::world::{ - presence, whisper, CharacterFacts, GuildActionStore, GuildOutcome, GuildRequest, + presence, whisper, Actor, CharacterFacts, GuildActionStore, GuildOutcome, GuildRequest, }; -impl GuildActionStore for crate::stdb::Coordinator { +impl GuildActionStore for Coordinator { + /// The membership row of `character_guid` on Realm-core. fn guild_member(&self, character_guid: u64) -> Result> { - Ok(self.realm_core()?.guild_member_row(character_guid)) - } - - fn guild(&self, guild_id: u32) -> Result> { - Ok(self.realm_core()?.guild_row(guild_id)) - } - - fn guild_members(&self, guild_id: u32) -> Result> { - Ok(self.realm_core()?.guild_member_rows(guild_id)) - } - - fn guild_character_facts(&self, character_guid: u64) -> Result> { - crate::stdb::Coordinator::guild_character_facts(self, character_guid) - } - - fn guild_characters_named(&self, name: &str) -> Result> { - presence::resolve_all_by_name(self, name) - } - - fn guild_gm_level(&self, actor_guid: u64) -> Result { - Ok(crate::stdb::Coordinator::home_gm_level(self, actor_guid)) - } - - fn guild_selected_target(&self, actor_guid: u64) -> u64 { - crate::stdb::Coordinator::selected_target(self, actor_guid) - } - - fn guild_ignored_by(&self, owner_guid: u64, other_guid: u64) -> Result { - whisper::ignored_anywhere(self, owner_guid, other_guid) - } - - fn guild_op(&self, actor_guid: u64, request: GuildRequest) -> Result { - self.realm_core()?.realm_guild_op(actor_guid, request) - } - - fn guild_npc_refuses(&self, npc_guid: u64, actor_guid: u64) -> Result { - crate::stdb::Coordinator::npc_refuses_interaction(self, npc_guid, actor_guid) - } - - fn guild_petition_of_charter( - &self, - charter_item_guid: u64, - ) -> Result> { - Ok(self - .realm_core()? - .guild_petition_of_charter(charter_item_guid)) - } - - fn guild_petition_of_owner(&self, owner_guid: u64) -> Result> { - Ok(self.realm_core()?.guild_petition_of_owner(owner_guid)) - } - - fn guild_name_taken(&self, name: &str) -> Result { - Ok(self.realm_core()?.guild_name_taken(name)) - } - - fn guild_holds_charter(&self, actor_guid: u64, charter_item_guid: u64) -> Result { - Ok(self.holds_guild_charter(actor_guid, charter_item_guid)) - } - - fn guild_destroy_charter(&self, actor_guid: u64, charter_item_guid: u64) -> Result<()> { - self.destroy_guild_charter(actor_guid, charter_item_guid) - } - - fn guild_character_guids(&self) -> Result> { - self.realm_core()?.guild_character_guids() - } - - fn guild_names_character(&self, character_guid: u64) -> Result { - self.realm_core()?.guild_names_character(character_guid) - } - - fn guild_name_of_member(&self, character_guid: u64) -> Result> { - Ok(self.realm_core()?.guild_name_of_member(character_guid)) - } -} - -impl Coordinator { - /// The membership row of `character_guid` in THIS handle's cache. Call it on the Realm-core - /// handle. - pub(crate) fn guild_member_row( - &self, - character_guid: u64, - ) -> Option { - self.0 + let realm = self.realm_core()?; + let member = realm + .0 .coord() .conn .db .game_guild_member() .character_guid() .find(&character_guid) - .map(member_view) + .map(member_view); + Ok(member) } - /// One Guild and its Guild Ranks in THIS handle's cache, ranks ordered by `rank_id`. - pub(crate) fn guild_row(&self, guild_id: u32) -> Option { - let guard = self.0.coord(); + /// One Guild and its Guild Ranks on Realm-core, ranks ordered by `rank_id`. + fn guild(&self, guild_id: u32) -> Result> { + let realm = self.realm_core()?; + let guard = realm.0.coord(); let db = &guard.conn.db; - let guild = db.game_guild().guild_id().find(&guild_id)?; + let Some(guild) = db.game_guild().guild_id().find(&guild_id) else { + return Ok(None); + }; let rank_ids = guard.guilds.read().unwrap().rank_ids(guild_id); let ranks_table = db.game_guild_rank(); - let mut ranks: Vec = rank_ids + let mut ranks: Vec = rank_ids .into_iter() .filter_map(|id| ranks_table.id().find(&id)) - .map(|rank| crate::codec::GuildRankView { + .map(|rank| codec::GuildRankView { rank_id: rank.rank_id, name: rank.name, rights: rank.rights, }) .collect(); ranks.sort_by_key(|rank| rank.rank_id); - Some(crate::codec::GuildView { + Ok(Some(codec::GuildView { guild_id: guild.guild_id, name: guild.name, leader_guid: guild.leader_guid, @@ -139,80 +62,37 @@ impl Coordinator { background_color: guild.background_color, created_micros: guild.created_micros, ranks, - }) + })) } - /// Every member row of one Guild in THIS handle's cache, ordered by guid. - pub(crate) fn guild_member_rows(&self, guild_id: u32) -> Vec { - let guard = self.0.coord(); + /// Every member row of one Guild on Realm-core, ordered by guid. + fn guild_members(&self, guild_id: u32) -> Result> { + let realm = self.realm_core()?; + let guard = realm.0.coord(); let guids = guard.guilds.read().unwrap().member_guids(guild_id); let members = guard.conn.db.game_guild_member(); - guids + Ok(guids .into_iter() .filter_map(|guid| members.character_guid().find(&guid)) .filter(|member| member.guild_id == guild_id) .map(member_view) - .collect() + .collect()) } - /// Every Character that a Guild, a Petition or a Signature in THIS handle's cache names: the - /// Characters the deleted-Character reconciliation checks. Call it on the Realm-core handle. - /// It fails while the subscription is unhealthy, so a stale cache never hides a Character. - pub(crate) fn guild_character_guids(&self) -> Result> { - let guard = self.0.coord(); - if !guard.is_healthy() { - anyhow::bail!( - "{} has no healthy Coordinator subscription for guild cleanup", - self.shard_name() - ); - } - let named = guard.guilds.read().unwrap().named_characters(); - Ok(named.into_iter().collect()) + fn guild_character_facts(&self, character_guid: u64) -> Result> { + crate::stdb::Coordinator::guild_character_facts(self, character_guid) } - /// Does a Guild, a Petition or a Signature in THIS handle's cache name `character_guid`? Keyed - /// reads only. Call it on the Realm-core handle. Fails while the subscription is unhealthy. - pub(crate) fn guild_names_character(&self, character_guid: u64) -> Result { - let guard = self.0.coord(); - if !guard.is_healthy() { - anyhow::bail!( - "{} has no healthy Coordinator subscription for guild cleanup", - self.shard_name() - ); - } - let member = guard - .conn - .db - .game_guild_member() - .character_guid() - .find(&character_guid) - .is_some(); - Ok(member - || guard - .guilds - .read() - .unwrap() - .petition_characters - .contains_key(&character_guid)) + fn guild_characters_named(&self, name: &str) -> Result> { + presence::resolve_all_by_name(self, name) } - /// The name of the Guild `character_guid` belongs to in THIS handle's cache, if any. Two keyed - /// reads, cheap enough for every `/who` row. - pub(crate) fn guild_name_of_member(&self, character_guid: u64) -> Option { - let guard = self.0.coord(); - let db = &guard.conn.db; - let member = db - .game_guild_member() - .character_guid() - .find(&character_guid)?; - db.game_guild() - .guild_id() - .find(&member.guild_id) - .map(|guild| guild.name) + fn guild_gm_level(&self, actor_guid: u64) -> Result { + Ok(self.home_gm_level(actor_guid)) } - /// The unit `actor_guid` has selected, from THIS handle's live entity. 0 for none. - pub(crate) fn selected_target(&self, actor_guid: u64) -> u64 { + /// The unit `actor_guid` has selected, from this Home Shard's live entity. 0 for none. + fn guild_selected_target(&self, actor_guid: u64) -> u64 { self.0 .coord() .conn @@ -223,55 +103,14 @@ impl Coordinator { .map_or(0, |entity| entity.target_guid) } - /// The open Petition `owner_guid` owns in THIS handle's cache. Call it on the Realm-core - /// handle. - pub(crate) fn guild_petition_of_owner( - &self, - owner_guid: u64, - ) -> Option { - let guard = self.0.coord(); - let db = &guard.conn.db; - let row = db.game_guild_petition().owner_guid().find(&owner_guid)?; - Some(petition_view(db, row)) - } - - /// Does a Guild in THIS handle's cache hold `name`, without regard to case? Call it on the - /// Realm-core handle. - pub(crate) fn guild_name_taken(&self, name: &str) -> bool { - self.0 - .coord() - .conn - .db - .game_guild() - .name_key() - .find(&lyracore_shared::guild::name_key(name)) - .is_some() - } - - /// Does `actor_guid` hold the Guild Charter `charter_item_guid` in THIS handle's cache? Call it - /// on the actor's Home Shard. - pub(crate) fn holds_guild_charter(&self, actor_guid: u64, charter_item_guid: u64) -> bool { - self.0 - .coord() - .conn - .db - .game_item_instance() - .guid() - .find(&charter_item_guid) - .is_some_and(|item| { - item.owner_guid == actor_guid - && item.entry == lyracore_shared::guild::GUILD_CHARTER_ENTRY - }) + fn guild_ignored_by(&self, owner_guid: u64, other_guid: u64) -> Result { + whisper::ignored_anywhere(self, owner_guid, other_guid) } - /// `realm_guild_op`: one guild op against the database THIS handle points at. Callers hold the - /// Realm-core handle. The actor is the guid this World Session entered the world with. - pub fn realm_guild_op( - &self, - actor_guid: u64, - request: crate::world::GuildRequest, - ) -> Result { - use crate::world::GuildRequest; + /// `realm_guild_op`: one guild op on Realm-core, or on the one database of an unsharded + /// Realm. + fn guild_op(&self, actor: Actor, request: GuildRequest) -> Result { + let realm = self.realm_core()?; let op = match request { GuildRequest::GmCreate { leader_guid, @@ -368,33 +207,152 @@ impl Coordinator { GuildRequest::ClosePetition { petition_id } => GuildOp::ClosePetition(petition_id), GuildRequest::ForgetDeletedCharacter => GuildOp::ForgetDeletedCharacter, }; - let result = call_reducer!( - self.0.call_pipe().conn.reducers, + guild_outcome(call_reducer!( + realm.0.call_pipe().conn.reducers, "realm_guild_op", - realm_guild_op_then(self.actor_or_owner(actor_guid), op) - ); - match result { - Ok(()) => Ok(crate::world::GuildOutcome::Ran), - Err(error) => match reducer_refusal_reason(&error).and_then(GuildRefusal::parse_tag) { - Some(refusal) => Ok(crate::world::GuildOutcome::Refused(refusal)), - None => Err(error), - }, - } + realm_guild_op_then(realm.session_actor(actor), op) + )) + } + + fn guild_npc_refuses(&self, npc_guid: u64, actor_guid: u64) -> Result { + crate::stdb::Coordinator::npc_refuses_interaction(self, npc_guid, actor_guid) } - /// `gw_destroy_guild_charter` on THIS handle, the actor's Home Shard. A Charter already gone - /// is Ok. - pub(crate) fn destroy_guild_charter( + fn guild_petition_of_charter( &self, - actor_guid: u64, charter_item_guid: u64, - ) -> Result<()> { + ) -> Result> { + Ok(self + .realm_core()? + .guild_petition_of_charter(charter_item_guid)) + } + + /// The open Petition `owner_guid` owns on Realm-core. + fn guild_petition_of_owner(&self, owner_guid: u64) -> Result> { + let realm = self.realm_core()?; + let guard = realm.0.coord(); + let db = &guard.conn.db; + Ok(db + .game_guild_petition() + .owner_guid() + .find(&owner_guid) + .map(|row| petition_view(db, row))) + } + + /// Does a Guild on Realm-core hold `name`, without regard to case? + fn guild_name_taken(&self, name: &str) -> Result { + let realm = self.realm_core()?; + let taken = realm + .0 + .coord() + .conn + .db + .game_guild() + .name_key() + .find(&lyracore_shared::guild::name_key(name)) + .is_some(); + Ok(taken) + } + + /// Does `actor_guid` hold the Guild Charter `charter_item_guid` on this Home Shard? + fn guild_holds_charter(&self, actor_guid: u64, charter_item_guid: u64) -> Result { + Ok(self + .0 + .coord() + .conn + .db + .game_item_instance() + .guid() + .find(&charter_item_guid) + .is_some_and(|item| { + item.owner_guid == actor_guid + && item.entry == lyracore_shared::guild::GUILD_CHARTER_ENTRY + })) + } + + /// `gw_destroy_guild_charter` on this Home Shard. A Charter already gone is Ok. + fn guild_destroy_charter(&self, actor: Actor, charter_item_guid: u64) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, "gw_destroy_guild_charter", - gw_destroy_guild_charter_then(self.actor_or_owner(actor_guid), charter_item_guid) + gw_destroy_guild_charter_then(self.session_actor(actor), charter_item_guid) ) } + + /// Every Character that a Guild, a Petition or a Signature on Realm-core names: the + /// Characters the deleted-Character reconciliation checks. It fails while the subscription + /// is unhealthy, so a stale cache never hides a Character. + fn guild_character_guids(&self) -> Result> { + let realm = self.realm_core()?; + let guard = realm.0.coord(); + if !guard.is_healthy() { + anyhow::bail!( + "{} has no healthy Coordinator subscription for guild cleanup", + realm.shard_name() + ); + } + let named = guard.guilds.read().unwrap().named_characters(); + Ok(named.into_iter().collect()) + } + + /// Does a Guild, a Petition or a Signature on Realm-core name `character_guid`? Keyed reads + /// only. Fails while the subscription is unhealthy. + fn guild_names_character(&self, character_guid: u64) -> Result { + let realm = self.realm_core()?; + let guard = realm.0.coord(); + if !guard.is_healthy() { + anyhow::bail!( + "{} has no healthy Coordinator subscription for guild cleanup", + realm.shard_name() + ); + } + let member = guard + .conn + .db + .game_guild_member() + .character_guid() + .find(&character_guid) + .is_some(); + Ok(member + || guard + .guilds + .read() + .unwrap() + .petition_characters + .contains_key(&character_guid)) + } + + /// The name of the Guild `character_guid` belongs to on Realm-core, if any. Two keyed reads, + /// cheap enough for every `/who` row. + fn guild_name_of_member(&self, character_guid: u64) -> Result> { + let realm = self.realm_core()?; + let guard = realm.0.coord(); + let db = &guard.conn.db; + let Some(member) = db + .game_guild_member() + .character_guid() + .find(&character_guid) + else { + return Ok(None); + }; + Ok(db + .game_guild() + .guild_id() + .find(&member.guild_id) + .map(|guild| guild.name)) + } +} + +/// The Module's typed guild Refusal. Only a reducer the Module rejected carries a tag; a timeout, +/// transport, or SDK failure stays an error with an unknown outcome. +fn guild_outcome(result: Result<()>) -> Result { + match result { + Ok(()) => Ok(GuildOutcome::Ran), + Err(error) => match reducer_refusal_reason(&error).and_then(GuildRefusal::parse_tag) { + Some(refusal) => Ok(GuildOutcome::Refused(refusal)), + None => Err(error), + }, + } } fn member_view(row: GuildMember) -> crate::codec::GuildMemberView { @@ -408,3 +366,41 @@ fn member_view(row: GuildMember) -> crate::codec::GuildMemberView { realm_account_id: row.realm_account_id, } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::stdb::{classify, DurableFailure, ReducerCallError}; + + #[test] + fn a_rejected_guild_tag_decodes_to_its_refusal() { + let error = ReducerCallError::refused("realm_guild_op", GuildRefusal::NotLeader.as_tag()); + assert_eq!( + guild_outcome(Err(error.into())).unwrap(), + GuildOutcome::Refused(GuildRefusal::NotLeader) + ); + } + + #[test] + fn a_committed_op_decodes_to_ran() { + assert_eq!(guild_outcome(Ok(())).unwrap(), GuildOutcome::Ran); + } + + #[test] + fn an_unknown_rejection_stays_a_refusal_for_the_handler() { + let error = guild_outcome(Err( + ReducerCallError::refused("realm_guild_op", "mystery").into() + )) + .unwrap_err(); + assert!(matches!(classify(&error), DurableFailure::Refusal { .. })); + } + + #[test] + fn a_lost_transport_stays_a_transport_loss() { + let error = guild_outcome(Err( + ReducerCallError::transport_lost("realm_guild_op").into() + )) + .unwrap_err(); + assert_eq!(classify(&error), DurableFailure::TransportLoss); + } +} diff --git a/gateway/src/world/handlers/guild.rs b/gateway/src/world/handlers/guild.rs index 6601df2ef..c804f75f4 100644 --- a/gateway/src/world/handlers/guild.rs +++ b/gateway/src/world/handlers/guild.rs @@ -7,6 +7,7 @@ use super::super::guild_fee::{self, GuildFeeStore}; use super::super::*; +use crate::stdb::{classify, DurableFailure}; use lyracore_shared::guild::{event_kind, has_right, rights, GuildRefusal, LEADER_RANK}; use wow_world_messages::vanilla::{ BuyResult, GuildCommand, GuildCommandResult, GuildEmblemResult, MSG_SAVE_GUILD_EMBLEM_Client, @@ -183,8 +184,8 @@ pub(crate) trait GuildActionStore: GuildFeeStore + Send + Sync { fn guild_selected_target(&self, actor_guid: u64) -> u64; /// Does `owner_guid` have `other_guid` on their ignore list, checked realm-wide? fn guild_ignored_by(&self, owner_guid: u64, other_guid: u64) -> Result; - /// Run one guild op on Realm-core as `actor_guid`. - fn guild_op(&self, actor_guid: u64, request: GuildRequest) -> Result; + /// Run one guild op on Realm-core as `actor`. + fn guild_op(&self, actor: Actor, request: GuildRequest) -> Result; /// Does the NPC refuse the actor by faction? The one Gate a read path answers here. fn guild_npc_refuses(&self, npc_guid: u64, actor_guid: u64) -> Result; /// The open Petition of the Guild Charter `charter_item_guid`. @@ -201,7 +202,7 @@ pub(crate) trait GuildActionStore: GuildFeeStore + Send + Sync { fn guild_holds_charter(&self, actor_guid: u64, charter_item_guid: u64) -> Result; /// Destroy a turned-in Guild Charter on THIS handle, the actor's Home Shard. A Charter /// already gone is Ok. - fn guild_destroy_charter(&self, actor_guid: u64, charter_item_guid: u64) -> Result<()>; + fn guild_destroy_charter(&self, actor: Actor, charter_item_guid: u64) -> Result<()>; /// Every Character a Guild, a Petition or a Signature names. Fails while Realm-core cannot /// answer, so the deleted-Character reconciliation retries instead of skipping anyone. fn guild_character_guids(&self) -> Result>; @@ -223,12 +224,20 @@ pub(crate) enum GuildActionOutcome { PassThrough(ClientOpcodeMessage), } -/// A lost reducer transport ends the session. Every other failure is a guild outcome the client -/// hears as silence. -fn is_fatal(error: &anyhow::Error) -> bool { - error - .chain() - .any(|cause| cause.to_string().contains("reducer transport disconnected")) +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 comes back +/// for the caller to drop, and the client hears silence. +fn unless_transport_loss(error: anyhow::Error) -> Result { + match classify(&error) { + DurableFailure::TransportLoss => Err(error), + DurableFailure::Refusal { .. } => Ok(error), + } } pub(crate) fn dispatch_guild_action( @@ -342,8 +351,8 @@ pub(crate) fn dispatch_guild_action( }; match outbound { Ok(outbound) => Ok(GuildActionOutcome::Handled { outbound }), - Err(error) if is_fatal(&error) => Err(error), Err(error) => { + let error = unless_transport_loss(error)?; log::debug!( "world: guild request dropped (account {}): {error:#}", player.account_id @@ -540,17 +549,18 @@ fn create_outbound( player: GuildActionPlayer, name: String, ) -> Result> { - let Some(actor_guid) = player.self_guid else { + let Some(actor) = player.actor() else { return Ok(Vec::new()); }; + let actor_guid = actor.guid(); let gm_level = store.guild_gm_level(actor_guid)?; if gm_level == 0 { return Ok(Vec::new()); } - let Some(actor) = store.guild_character_facts(actor_guid)? else { + let Some(actor_facts) = store.guild_character_facts(actor_guid)? else { return Ok(Vec::new()); }; - let outcome = store.guild_op(actor_guid, gm_create(&actor, gm_level, name))?; + let outcome = store.guild_op(actor, gm_create(&actor_facts, gm_level, name))?; if let GuildOutcome::Refused(refusal) = outcome { log::debug!("world: CMSG_GUILD_CREATE from {actor_guid} refused: {refusal:?}"); } @@ -592,9 +602,10 @@ fn invite_outbound( player: GuildActionPlayer, typed_name: String, ) -> Result> { - let Some(actor_guid) = player.self_guid else { + let Some(actor) = player.actor() else { return Ok(Vec::new()); }; + let actor_guid = actor.guid(); let Some(target) = live_character_named(store, &typed_name)? else { return Ok(vec![command_result( GuildCommand::Invite, @@ -602,15 +613,15 @@ fn invite_outbound( GuildCommandResult::GuildPlayerNotFoundS, )]); }; - let Some(actor) = store.guild_character_facts(actor_guid)? else { + let Some(actor_facts) = store.guild_character_facts(actor_guid)? else { return Ok(Vec::new()); }; let target_ignores_actor = store.guild_ignored_by(target.guid, actor_guid)?; let outcome = store.guild_op( - actor_guid, + actor, GuildRequest::Invite { target_guid: target.guid, - actor_team: lyracore_shared::faction::team_for_race(actor.race), + actor_team: lyracore_shared::faction::team_for_race(actor_facts.race), target_team: lyracore_shared::faction::team_for_race(target.race), target_ignores_actor, }, @@ -631,17 +642,18 @@ fn accept_outbound( store: &St, player: GuildActionPlayer, ) -> Result> { - let Some(actor_guid) = player.self_guid else { + let Some(actor) = player.actor() else { return Ok(Vec::new()); }; - let Some(actor) = store.guild_character_facts(actor_guid)? else { + let actor_guid = actor.guid(); + let Some(actor_facts) = store.guild_character_facts(actor_guid)? else { return Ok(Vec::new()); }; let outcome = store.guild_op( - actor_guid, + actor, GuildRequest::Accept { - actor_name: actor.name, - actor_team: lyracore_shared::faction::team_for_race(actor.race), + actor_name: actor_facts.name, + actor_team: lyracore_shared::faction::team_for_race(actor_facts.race), }, )?; if let GuildOutcome::Refused(refusal) = outcome { @@ -655,16 +667,17 @@ fn decline_outbound( store: &St, player: GuildActionPlayer, ) -> Result> { - let Some(actor_guid) = player.self_guid else { + let Some(actor) = player.actor() else { return Ok(Vec::new()); }; - let Some(actor) = store.guild_character_facts(actor_guid)? else { + let actor_guid = actor.guid(); + let Some(actor_facts) = store.guild_character_facts(actor_guid)? else { return Ok(Vec::new()); }; let outcome = store.guild_op( - actor_guid, + actor, GuildRequest::Decline { - actor_name: actor.name, + actor_name: actor_facts.name, }, )?; if let GuildOutcome::Refused(refusal) = outcome { @@ -682,14 +695,15 @@ fn leave_outbound( store: &St, player: GuildActionPlayer, ) -> Result> { - let Some(actor_guid) = player.self_guid else { + let Some(actor) = player.actor() else { return Ok(Vec::new()); }; + let actor_guid = actor.guid(); let Some((_, guild)) = actor_guild(store, player)? else { return Ok(vec![not_in_guild()]); }; let is_leader = guild.leader_guid == actor_guid; - let outcome = store.guild_op(actor_guid, GuildRequest::Leave)?; + let outcome = store.guild_op(actor, GuildRequest::Leave)?; Ok(match outcome { GuildOutcome::Ran if is_leader => Vec::new(), GuildOutcome::Ran => vec![command_result( @@ -713,10 +727,10 @@ fn disband_outbound( store: &St, player: GuildActionPlayer, ) -> Result> { - let Some(actor_guid) = player.self_guid else { + let Some(actor) = player.actor() else { return Ok(Vec::new()); }; - let outcome = store.guild_op(actor_guid, GuildRequest::Disband)?; + let outcome = store.guild_op(actor, GuildRequest::Disband)?; Ok(match outcome { GuildOutcome::Ran => Vec::new(), GuildOutcome::Refused(refusal) => { @@ -744,7 +758,7 @@ where St: GuildActionStore + ?Sized, F: FnOnce(u64) -> GuildRequest, { - let Some(actor_guid) = player.self_guid else { + let Some(actor) = player.actor() else { return Ok(Vec::new()); }; let Some((_, guild)) = actor_guild(store, player)? else { @@ -755,7 +769,7 @@ where MemberMatch::Found(guid) => guid, MemberMatch::NotInGuild => 0, }; - let outcome = store.guild_op(actor_guid, build_request(target_guid))?; + let outcome = store.guild_op(actor, build_request(target_guid))?; Ok(match outcome { GuildOutcome::Ran => Vec::new(), GuildOutcome::Refused(refusal) => { @@ -868,10 +882,10 @@ fn motd_outbound( player: GuildActionPlayer, text: String, ) -> Result> { - let Some(actor_guid) = player.self_guid else { + let Some(actor) = player.actor() else { return Ok(Vec::new()); }; - let outcome = store.guild_op(actor_guid, GuildRequest::SetMotd { text })?; + let outcome = store.guild_op(actor, GuildRequest::SetMotd { text })?; Ok(match outcome { GuildOutcome::Ran => Vec::new(), GuildOutcome::Refused(GuildRefusal::NotInGuild) => vec![not_in_guild()], @@ -890,10 +904,10 @@ fn info_text_outbound( player: GuildActionPlayer, text: String, ) -> Result> { - let Some(actor_guid) = player.self_guid else { + let Some(actor) = player.actor() else { return Ok(Vec::new()); }; - let outcome = store.guild_op(actor_guid, GuildRequest::SetInfo { text })?; + let outcome = store.guild_op(actor, GuildRequest::SetInfo { text })?; Ok(match outcome { GuildOutcome::Ran => Vec::new(), GuildOutcome::Refused(GuildRefusal::NotInGuild) => vec![not_in_guild()], @@ -916,7 +930,7 @@ fn set_note_outbound( text: String, request: impl FnOnce(u64, String) -> GuildRequest, ) -> Result> { - let Some(actor_guid) = player.self_guid else { + let Some(actor) = player.actor() else { return Ok(Vec::new()); }; let Some((_, guild)) = actor_guild(store, player)? else { @@ -927,7 +941,7 @@ fn set_note_outbound( MemberMatch::Found(guid) => guid, MemberMatch::NotInGuild => 0, }; - let outcome = store.guild_op(actor_guid, request(target_guid, text))?; + let outcome = store.guild_op(actor, request(target_guid, text))?; Ok(match outcome { GuildOutcome::Refused(GuildRefusal::NoPermission) => vec![command_result( GuildCommand::Invite, @@ -967,11 +981,11 @@ fn edit_rank_outbound( rights: u32, name: String, ) -> Result> { - let Some(actor_guid) = player.self_guid else { + let Some(actor) = player.actor() else { return Ok(Vec::new()); }; let outcome = store.guild_op( - actor_guid, + actor, GuildRequest::EditRank { rank_id, rights, @@ -987,10 +1001,10 @@ fn add_rank_outbound( player: GuildActionPlayer, name: String, ) -> Result> { - let Some(actor_guid) = player.self_guid else { + let Some(actor) = player.actor() else { return Ok(Vec::new()); }; - let outcome = store.guild_op(actor_guid, GuildRequest::AddRank { name })?; + let outcome = store.guild_op(actor, GuildRequest::AddRank { name })?; Ok(rank_refusal_outbound(outcome)) } @@ -999,10 +1013,10 @@ fn delete_rank_outbound( store: &St, player: GuildActionPlayer, ) -> Result> { - let Some(actor_guid) = player.self_guid else { + let Some(actor) = player.actor() else { return Ok(Vec::new()); }; - let outcome = store.guild_op(actor_guid, GuildRequest::DeleteRank)?; + let outcome = store.guild_op(actor, GuildRequest::DeleteRank)?; Ok(rank_refusal_outbound(outcome)) } @@ -1022,9 +1036,10 @@ pub(crate) fn run_guild_dot_command( player: GuildActionPlayer, text: &str, ) -> Result> { - let Some(actor_guid) = player.self_guid else { + let Some(actor) = player.actor() else { return Ok(None); }; + let actor_guid = actor.guid(); let gm_level = store.guild_gm_level(actor_guid)?; if gm_level == 0 { return Ok(Some("permission denied".into())); @@ -1035,16 +1050,13 @@ pub(crate) fn run_guild_dot_command( let leader = match guild_create_leader(store, actor_guid, leader_name) { Ok(Ok(leader)) => leader, Ok(Err(line)) => return Ok(Some(line)), - Err(error) if is_fatal(&error) => return Err(error), Err(error) => { + let error = unless_transport_loss(error)?; log::warn!("world: .guild create by {actor_guid} found no leader: {error:#}"); return Ok(Some("guild not created".into())); } }; - let outcome = store.guild_op( - actor_guid, - gm_create(&leader, gm_level, guild_name.to_string()), - ); + let outcome = store.guild_op(actor, gm_create(&leader, gm_level, guild_name.to_string())); Ok(match outcome { Ok(GuildOutcome::Ran) => None, Ok(GuildOutcome::Refused(GuildRefusal::NotGameMaster)) => Some("permission denied".into()), @@ -1052,8 +1064,8 @@ pub(crate) fn run_guild_dot_command( Some(format!("{} is already in a guild", leader.name)) } Ok(GuildOutcome::Refused(_)) => Some("guild not created".into()), - Err(error) if is_fatal(&error) => return Err(error), Err(error) => { + let error = unless_transport_loss(error)?; log::warn!("world: .guild create by {actor_guid} failed: {error:#}"); Some("guild not created".into()) } @@ -1141,9 +1153,10 @@ fn save_emblem_outbound( player: GuildActionPlayer, save: &MSG_SAVE_GUILD_EMBLEM_Client, ) -> Result> { - let Some(actor_guid) = player.self_guid else { + let Some(actor) = player.actor() else { return Ok(Vec::new()); }; + let actor_guid = actor.guid(); let result = match store.guild_member(actor_guid)? { None => GuildEmblemResult::NoGuild, Some(member) if member.rank_id != LEADER_RANK => GuildEmblemResult::NotGuildMaster, @@ -1158,13 +1171,13 @@ fn save_emblem_outbound( background_color: save.background_color, }, }; - match guild_fee::pay(store, actor_guid, request) { + match guild_fee::pay(store, actor, request) { Ok(outcome) => emblem_result(outcome), - Err(error) if is_fatal(&error) => return Err(error), // The decision may have committed. The Hold is finished at the next fee or world // entry, and the new emblem, if saved, reaches every member as TABARD_CHANGED. // The client still needs an answer to close its wait. Err(error) => { + let error = unless_transport_loss(error)?; log::warn!("world: emblem fee of {actor_guid} left unfinished: {error:#}"); GuildEmblemResult::NoMessage } @@ -1229,9 +1242,10 @@ fn petition_buy_outbound( npc_guid: u64, name: String, ) -> Result> { - let Some(actor_guid) = player.self_guid else { + let Some(actor) = player.actor() else { return Ok(Vec::new()); }; + let actor_guid = actor.guid(); if store.guild_member(actor_guid)?.is_some() { return Ok(Vec::new()); } @@ -1240,7 +1254,7 @@ fn petition_buy_outbound( return Ok(Vec::new()); } let closed = store.guild_op( - actor_guid, + actor, GuildRequest::ClosePetition { petition_id: petition.petition_id, }, @@ -1266,7 +1280,7 @@ fn petition_buy_outbound( name: name.clone(), }; Ok(charter_purchase_reply( - guild_fee::pay(store, actor_guid, request)?, + guild_fee::pay(store, actor, request)?, npc_guid, name, )) @@ -1370,14 +1384,15 @@ fn rename_petition_outbound( charter_item_guid: u64, name: String, ) -> Result> { - let Some(actor_guid) = player.self_guid else { + let Some(actor) = player.actor() else { return Ok(Vec::new()); }; + let actor_guid = actor.guid(); if !store.guild_holds_charter(actor_guid, charter_item_guid)? { return Ok(Vec::new()); } let outcome = store.guild_op( - actor_guid, + actor, GuildRequest::RenamePetition { charter_item_guid, name: name.clone(), @@ -1412,9 +1427,10 @@ fn offer_petition_outbound( charter_item_guid: u64, target_guid: u64, ) -> Result> { - let Some(actor_guid) = player.self_guid else { + let Some(actor) = player.actor() else { return Ok(Vec::new()); }; + let actor_guid = actor.guid(); let Some(target) = store .guild_character_facts(target_guid)? .filter(|facts| facts.online) @@ -1425,7 +1441,7 @@ fn offer_petition_outbound( return Ok(Vec::new()); } let outcome = store.guild_op( - actor_guid, + actor, GuildRequest::OfferPetition { charter_item_guid, target_guid, @@ -1458,18 +1474,19 @@ fn sign_petition_outbound( player: GuildActionPlayer, charter_item_guid: u64, ) -> Result> { - let Some(actor_guid) = player.self_guid else { + let Some(actor) = player.actor() else { return Ok(Vec::new()); }; - let Some(actor) = store.guild_character_facts(actor_guid)? else { + let actor_guid = actor.guid(); + let Some(actor_facts) = store.guild_character_facts(actor_guid)? else { return Ok(Vec::new()); }; let outcome = store.guild_op( - actor_guid, + actor, GuildRequest::SignPetition { charter_item_guid, - actor_name: actor.name.clone(), - actor_team: lyracore_shared::faction::team_for_race(actor.race), + actor_name: actor_facts.name.clone(), + actor_team: lyracore_shared::faction::team_for_race(actor_facts.race), }, )?; Ok(match outcome { @@ -1490,12 +1507,12 @@ fn sign_petition_outbound( )], GuildOutcome::Refused(GuildRefusal::AlreadyInGuild) => vec![command_result( GuildCommand::Invite, - actor.name, + actor_facts.name, GuildCommandResult::AlreadyInGuildS, )], GuildOutcome::Refused(GuildRefusal::AlreadyInvited) => vec![command_result( GuildCommand::Invite, - actor.name, + actor_facts.name, GuildCommandResult::AlreadyInvitedToGuildS, )], // Signed or already signed: the Guild Events answer. A full or unknown Petition is silent. @@ -1510,13 +1527,11 @@ fn decline_petition_outbound( player: GuildActionPlayer, charter_item_guid: u64, ) -> Result> { - let Some(actor_guid) = player.self_guid else { + let Some(actor) = player.actor() else { return Ok(Vec::new()); }; - let outcome = store.guild_op( - actor_guid, - GuildRequest::DeclinePetition { charter_item_guid }, - )?; + let actor_guid = actor.guid(); + let outcome = store.guild_op(actor, GuildRequest::DeclinePetition { charter_item_guid })?; if let GuildOutcome::Refused(refusal) = outcome { log::debug!("world: MSG_PETITION_DECLINE from {actor_guid} refused: {refusal:?}"); } @@ -1532,9 +1547,10 @@ fn turn_in_petition_outbound( player: GuildActionPlayer, charter_item_guid: u64, ) -> Result> { - let Some(actor_guid) = player.self_guid else { + let Some(actor) = player.actor() else { return Ok(Vec::new()); }; + let actor_guid = actor.guid(); if !store.guild_holds_charter(actor_guid, charter_item_guid)? { return Ok(Vec::new()); } @@ -1547,16 +1563,11 @@ fn turn_in_petition_outbound( SMSG_TURN_IN_PETITION_RESULTS { result }, )) }; - let outcome = store.guild_op( - actor_guid, - GuildRequest::TurnInPetition { charter_item_guid }, - )?; + let outcome = store.guild_op(actor, GuildRequest::TurnInPetition { charter_item_guid })?; Ok(match outcome { GuildOutcome::Ran => { - if let Err(error) = store.guild_destroy_charter(actor_guid, charter_item_guid) { - if is_fatal(&error) { - return Err(error); - } + if let Err(error) = store.guild_destroy_charter(actor, charter_item_guid) { + let error = unless_transport_loss(error)?; log::warn!("world: turned-in Charter {charter_item_guid} left inert: {error:#}"); } // mangos sends both: the founding result from the turn-in handler, since @@ -1645,6 +1656,9 @@ pub(crate) fn guild_sign_on( store: &St, character_guid: u64, ) -> bool { + let Some(actor) = Actor::new(character_guid) else { + return false; + }; if !matches!(store.guild_member(character_guid), Ok(Some(_))) { return false; } @@ -1654,30 +1668,29 @@ pub(crate) fn guild_sign_on( .flatten() .map(|facts| facts.name) .unwrap_or_default(); - run_best_effort( - store, - character_guid, - GuildRequest::SignOn { actor_name }, - "sign-on", - ) + run_best_effort(store, actor, GuildRequest::SignOn { actor_name }, "sign-on") } /// The guild part of leaving the world: a member signs off. Runs before the Account Claim is /// released, while Realm-core still accepts the actor (`cm:WorldSession.cpp:714-724`). pub(crate) fn guild_world_exit(store: &St, character_guid: u64) { + let Some(actor) = Actor::new(character_guid) else { + return; + }; if matches!(store.guild_member(character_guid), Ok(Some(_))) { - let _ = run_best_effort(store, character_guid, GuildRequest::SignOff, "sign-off"); + let _ = run_best_effort(store, actor, GuildRequest::SignOff, "sign-off"); } } /// Run one guild op whose failure must not fail the caller. Answers whether it ran. fn run_best_effort( store: &St, - character_guid: u64, + actor: Actor, request: GuildRequest, step: &str, ) -> bool { - match store.guild_op(character_guid, request) { + let character_guid = actor.guid(); + match store.guild_op(actor, request) { Ok(GuildOutcome::Ran) => true, Ok(GuildOutcome::Refused(refusal)) => { log::debug!("world: guild {step} for {character_guid} refused: {refusal:?}"); @@ -1701,10 +1714,13 @@ pub(crate) fn destroy_inert_charters( character_guid: u64, charter_item_guids: &[u64], ) { + let Some(actor) = Actor::new(character_guid) else { + return; + }; if charter_item_guids.is_empty() { return; } - if !matches!(store.guild_fee_held(character_guid), Ok(None)) { + if !matches!(store.guild_fee_held(actor), Ok(None)) { return; } for &charter_item_guid in charter_item_guids { @@ -1716,7 +1732,7 @@ pub(crate) fn destroy_inert_charters( return; } } - match store.guild_destroy_charter(character_guid, charter_item_guid) { + match store.guild_destroy_charter(actor, charter_item_guid) { Ok(()) => log::info!( "world: destroyed Guild Charter {charter_item_guid} of {character_guid}: no \ Petition stands behind it" @@ -1769,7 +1785,10 @@ pub(crate) fn forget_deleted_character< { return Ok(DeletedCharacterGuildCleanup::Preserved); } - match store.guild_op(character_guid, GuildRequest::ForgetDeletedCharacter)? { + let Some(actor) = Actor::new(character_guid) else { + return Ok(DeletedCharacterGuildCleanup::Preserved); + }; + match store.guild_op(actor, GuildRequest::ForgetDeletedCharacter)? { GuildOutcome::Ran => Ok(DeletedCharacterGuildCleanup::Forgotten), GuildOutcome::Refused(refusal) => { anyhow::bail!( @@ -1943,6 +1962,7 @@ fn now_micros() -> u64 { #[cfg(test)] mod tests { use super::*; + use crate::stdb::ReducerCallError; use lyracore_shared::guild::{founding_gate, name_key, DEFAULT_MOTD, DEFAULT_RANKS}; use std::sync::Mutex; use wow_world_messages::vanilla::{ @@ -1971,7 +1991,8 @@ mod tests { ignored: Vec<(u64, u64)>, selected: u64, ops: Mutex>, - op_error: Option, + /// `guild_op` fails like this. + op_error: Option anyhow::Error>, /// NPCs that refuse every actor by faction. refusing_npcs: Vec, /// Every fee request that reached the Home Shard. @@ -1981,9 +2002,9 @@ mod tests { /// What Realm-core decides; `None` accepts. fee_refusal: Option, /// The decision's answer is lost, as when it commits but is late to the cache. - fee_decide_error: Option, + fee_decide_error: Option anyhow::Error>, /// Realm Presence cannot answer, as when a World Shard's subscription is unhealthy. - facts_error: Option, + facts_error: Option anyhow::Error>, /// What the next non-`GmCreate` op answers; `None` runs it. The Gate arithmetic for every /// op lives on the Module, proved by its own unit and durable tests; this seam only proves /// the wire mapping from a given outcome, the same shape `GuildFeeStore` below uses for the @@ -2095,8 +2116,8 @@ mod tests { } fn guild_character_facts(&self, character_guid: u64) -> Result> { - if let Some(error) = &self.facts_error { - return Err(anyhow!("{error}")); + if let Some(error) = self.facts_error { + return Err(error()); } Ok(self .characters @@ -2133,10 +2154,13 @@ mod tests { .any(|(owner, ignored)| *owner == owner_guid && *ignored == other_guid)) } - fn guild_op(&self, actor_guid: u64, request: GuildRequest) -> Result { - self.ops.lock().unwrap().push((actor_guid, request.clone())); - if let Some(error) = &self.op_error { - return Err(anyhow!("{error}")); + fn guild_op(&self, actor: Actor, request: GuildRequest) -> Result { + self.ops + .lock() + .unwrap() + .push((actor.guid(), request.clone())); + if let Some(error) = self.op_error { + return Err(error()); } // Every op but GmCreate is a Module Gate this Fake does not reproduce (proved by the // Module's own unit and durable tests instead): it answers the outcome a test scripts @@ -2195,7 +2219,7 @@ mod tests { Ok(self.held_charters.contains(&charter_item_guid)) } - fn guild_destroy_charter(&self, _actor_guid: u64, charter_item_guid: u64) -> Result<()> { + fn guild_destroy_charter(&self, _actor: Actor, charter_item_guid: u64) -> Result<()> { self.destroyed_charters .lock() .unwrap() @@ -2227,13 +2251,13 @@ mod tests { /// The fee answers the seam maps to result codes. `guild_fee`'s own tests drive the protocol. impl GuildFeeStore for InMemoryGuildActions { - fn guild_fee_held(&self, _actor_guid: u64) -> Result> { + fn guild_fee_held(&self, _actor: Actor) -> Result> { Ok(None) } fn guild_fee_hold( &self, - _actor_guid: u64, + _actor: Actor, request: guild_fee::FeeRequest, ) -> Result> { self.fee_requests.lock().unwrap().push(request.clone()); @@ -2255,11 +2279,11 @@ mod tests { fn guild_fee_decide( &self, - _actor_guid: u64, + _actor: Actor, _hold: guild_fee::FeeHold, ) -> Result { - if let Some(error) = &self.fee_decide_error { - return Err(anyhow!("{error}")); + if let Some(error) = self.fee_decide_error { + return Err(error()); } Ok(self.fee_refusal.map_or( guild_fee::FeeOutcome::Accepted, @@ -2269,7 +2293,7 @@ mod tests { fn guild_fee_finish( &self, - _actor_guid: u64, + _actor: Actor, _operation_id: u64, _accepted: bool, ) -> Result<()> { @@ -2304,6 +2328,15 @@ mod tests { } } + fn lost_transport() -> anyhow::Error { + ReducerCallError::transport_lost("realm_guild_op").into() + } + + /// A Module rejection whose tag this Gateway does not know. + fn unknown_refusal() -> anyhow::Error { + ReducerCallError::refused("realm_guild_op", "mystery").into() + } + fn in_world(guid: u64) -> GuildActionPlayer { GuildActionPlayer { account_id: 7, @@ -2448,17 +2481,14 @@ mod tests { } #[test] - fn dot_create_answers_guild_not_created_when_presence_cannot_answer() { + fn dot_create_ends_the_session_when_presence_cannot_answer() { let store = InMemoryGuildActions { - facts_error: Some( - "World Shard instances has no healthy Coordinator subscription".into(), - ), + facts_error: Some(lost_transport), ..realm() }; - assert_eq!( - run_guild_dot_command(&store, in_world(GM), ".guild create \"Knights\"").unwrap(), - Some("guild not created".into()) - ); + let error = run_guild_dot_command(&store, in_world(GM), ".guild create \"Knights\"") + .expect_err("a failed read is a Transport Loss"); + assert_eq!(classify(&error), DurableFailure::TransportLoss); assert!(store.ops.lock().unwrap().is_empty()); } @@ -2539,10 +2569,33 @@ mod tests { #[test] fn a_lost_reducer_transport_ends_the_session() { let mut store = realm(); - store.op_error = Some("realm_guild_op reducer transport disconnected: gone".into()); + store.op_error = Some(lost_transport); let error = run_guild_dot_command(&store, in_world(GM), ".guild create \"Knights\"") .expect_err("fatal"); - assert!(error.to_string().contains("transport disconnected")); + assert_eq!(classify(&error), DurableFailure::TransportLoss); + } + + #[test] + fn an_unrecognised_refusal_answers_guild_not_created_and_keeps_the_session() { + let mut store = realm(); + store.op_error = Some(unknown_refusal); + assert_eq!( + run_guild_dot_command(&store, in_world(GM), ".guild create \"Knights\"").unwrap(), + Some("guild not created".into()) + ); + } + + #[test] + fn a_lost_transport_on_a_guild_opcode_ends_the_session_and_a_refusal_does_not() { + 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()); + 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"), + } } #[test] @@ -2829,7 +2882,7 @@ mod tests { #[test] fn a_failed_sign_on_owes_no_sign_off() { let mut store = founded_with_members(); - store.op_error = Some("realm_guild_op reducer transport disconnected: gone".into()); + store.op_error = Some(lost_transport); assert!(!guild_sign_on(&store, BOB)); } @@ -2905,19 +2958,19 @@ mod tests { background_color: 15, }; + fn save_emblem_message() -> ClientOpcodeMessage { + ClientOpcodeMessage::MSG_SAVE_GUILD_EMBLEM(Box::new(MSG_SAVE_GUILD_EMBLEM_Client { + vendor: Guid::new(DESIGNER), + emblem_style: EMBLEM.emblem_style, + emblem_color: EMBLEM.emblem_color, + border_style: EMBLEM.border_style, + border_color: EMBLEM.border_color, + background_color: EMBLEM.background_color, + })) + } + fn save_emblem(store: &InMemoryGuildActions, actor: u64) -> GuildEmblemResult { - let message = only_message(dispatch( - store, - in_world(actor), - ClientOpcodeMessage::MSG_SAVE_GUILD_EMBLEM(Box::new(MSG_SAVE_GUILD_EMBLEM_Client { - vendor: Guid::new(DESIGNER), - emblem_style: EMBLEM.emblem_style, - emblem_color: EMBLEM.emblem_color, - border_style: EMBLEM.border_style, - border_color: EMBLEM.border_color, - background_color: EMBLEM.background_color, - })), - )); + let message = only_message(dispatch(store, in_world(actor), save_emblem_message())); match message { ServerOpcodeMessage::MSG_SAVE_GUILD_EMBLEM(saved) => saved.result, other => panic!("expected an emblem result, got {other}"), @@ -2940,9 +2993,21 @@ mod tests { } #[test] - fn a_lost_fee_decision_still_answers_the_emblem_window() { + fn a_lost_fee_decision_ends_the_session() { + let store = InMemoryGuildActions { + fee_decide_error: Some(lost_transport), + ..led_by_bob(None, None) + }; + let error = dispatch_guild_action(&store, in_world(BOB), save_emblem_message()) + .err() + .expect("a Transport Loss is fatal"); + assert_eq!(classify(&error), DurableFailure::TransportLoss); + } + + #[test] + fn an_unrecognised_fee_refusal_still_answers_the_emblem_window() { let store = InMemoryGuildActions { - fee_decide_error: Some("guild fee decision 1 not in the cache after 5s".into()), + fee_decide_error: Some(unknown_refusal), ..led_by_bob(None, None) }; assert_eq!(save_emblem(&store, BOB), GuildEmblemResult::NoMessage); diff --git a/gateway/src/world/test_support/world_fake/guild.rs b/gateway/src/world/test_support/world_fake/guild.rs index fa8bdefb3..48826de5d 100644 --- a/gateway/src/world/test_support/world_fake/guild.rs +++ b/gateway/src/world/test_support/world_fake/guild.rs @@ -77,9 +77,9 @@ impl GuildActionStore for WorldFake { Ok(false) } - fn guild_op(&self, actor_guid: u64, request: GuildRequest) -> Result { + fn guild_op(&self, actor: Actor, request: GuildRequest) -> Result { if request == GuildRequest::ForgetDeletedCharacter { - self.rec(&format!("guild_op:ForgetDeletedCharacter:{actor_guid}")); + self.rec(&format!("guild_op:ForgetDeletedCharacter:{}", actor.guid())); return Ok(GuildOutcome::Ran); } self.rec(match request { @@ -150,7 +150,7 @@ impl GuildActionStore for WorldFake { Ok(false) } - fn guild_destroy_charter(&self, _actor_guid: u64, _charter_item_guid: u64) -> Result<()> { + fn guild_destroy_charter(&self, _actor: Actor, _charter_item_guid: u64) -> Result<()> { Ok(()) } @@ -171,7 +171,10 @@ impl GuildActionStore for WorldFake { fn guild_names_character(&self, character_guid: u64) -> Result { if self.guild.guild_lookup_error_for == Some(character_guid) { - return Err(anyhow!("guild lookup for {character_guid} failed")); + return Err(crate::stdb::ReducerCallError::transport_lost(&format!( + "guild lookup for {character_guid}" + )) + .into()); } Ok(self.guild_character_guids()?.contains(&character_guid)) } @@ -186,13 +189,13 @@ impl GuildActionStore for WorldFake { /// Realm-core refuses every fee: the socket tests only watch a leftover hold finish. impl crate::world::guild_fee::GuildFeeStore for WorldFake { - fn guild_fee_held(&self, _actor_guid: u64) -> Result> { + fn guild_fee_held(&self, _actor: Actor) -> Result> { Ok(self.guild.guild_fee_hold.lock().unwrap().clone()) } fn guild_fee_hold( &self, - _actor_guid: u64, + _actor: Actor, _request: crate::world::guild_fee::FeeRequest, ) -> Result> { @@ -201,7 +204,7 @@ impl crate::world::guild_fee::GuildFeeStore for WorldFake { fn guild_fee_decide( &self, - _actor_guid: u64, + _actor: Actor, _hold: crate::world::guild_fee::FeeHold, ) -> Result { self.rec("guild_fee_decide"); @@ -210,12 +213,7 @@ impl crate::world::guild_fee::GuildFeeStore for WorldFake { )) } - fn guild_fee_finish( - &self, - _actor_guid: u64, - _operation_id: u64, - _accepted: bool, - ) -> Result<()> { + fn guild_fee_finish(&self, _actor: Actor, _operation_id: u64, _accepted: bool) -> Result<()> { self.rec("guild_fee_finish"); *self.guild.guild_fee_hold.lock().unwrap() = None; Ok(()) From c6d68e60bbda50c6a2779cddee6772eb3a864ed8 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 18:38:33 +0200 Subject: [PATCH 18/33] refactor(gateway): item Store takes an Actor Inline the item Coordinator wrappers into the trait methods and drop the unused account id. The handler answers an internal-error Refusal when the session has no Actor, as the Store did before. The Fake models a Transport Loss with a typed error. Co-Authored-By: Claude Sonnet 5.5 --- gateway/src/stdb/store/item.rs | 134 ++------ gateway/src/world/handlers/item.rs | 291 +++++++----------- gateway/src/world/item_tests.rs | 28 +- .../src/world/test_support/world_fake/item.rs | 24 +- 4 files changed, 150 insertions(+), 327 deletions(-) diff --git a/gateway/src/stdb/store/item.rs b/gateway/src/stdb/store/item.rs index 419ce3868..31c2703a9 100644 --- a/gateway/src/stdb/store/item.rs +++ b/gateway/src/stdb/store/item.rs @@ -4,57 +4,14 @@ use anyhow::Result; use lyracore_shared::item::ItemRefusal; use crate::stdb::bindings::*; -use crate::stdb::connection::{call_reducer, reducer_refusal_reason}; +use crate::stdb::connection::{call_reducer, classify, DurableFailure}; use crate::stdb::Coordinator; use crate::world::{Actor, ItemActionResult, ItemActionStore}; -impl ItemActionStore for crate::stdb::Coordinator { - fn equip_item( - &self, - account_id: u64, - actor_guid: u64, - from_slot: u8, - ) -> Result { - crate::stdb::Coordinator::equip_item(self, account_id, actor_guid, from_slot) - } - - fn unequip_item( - &self, - account_id: u64, - actor_guid: u64, - from_slot: u8, - ) -> Result { - crate::stdb::Coordinator::unequip_item(self, account_id, actor_guid, from_slot) - } - - fn move_item( - &self, - account_id: u64, - actor_guid: u64, - from_slot: u8, - to_slot: u8, - ) -> Result { - crate::stdb::Coordinator::move_item(self, account_id, actor_guid, from_slot, to_slot) - } - - fn use_item(&self, account_id: u64, actor_guid: u64, slot: u8) -> Result { - crate::stdb::Coordinator::use_item(self, account_id, actor_guid, slot) - } -} - -impl Coordinator { +impl ItemActionStore for Coordinator { /// Equip the item in main-inventory `from_slot` (`CMSG_AUTOEQUIP_ITEM`) over the coordinator /// connection. The module resolves the matching equipment slot and gates the required level. - /// Rides the coordinator connection as `gw_equip_item`. - pub fn equip_item( - &self, - _account_id: u64, - actor_guid: u64, - from_slot: u8, - ) -> Result { - let Some(actor) = resolved_item_actor("equip_item", actor_guid) else { - return Ok(ItemRefusal::Internal.into()); - }; + fn equip_item(&self, actor: Actor, from_slot: u8) -> Result { let coord = self.0.call_pipe(); item_action(call_reducer!( coord.conn.reducers, @@ -65,15 +22,7 @@ impl Coordinator { /// Unequip the item in equipment `from_slot` to a free backpack slot (`CMSG_AUTOSTORE_BAG_ITEM`) /// over the coordinator connection. The module gates "is equipped" + "backpack has room". - pub fn unequip_item( - &self, - _account_id: u64, - actor_guid: u64, - from_slot: u8, - ) -> Result { - let Some(actor) = resolved_item_actor("unequip_item", actor_guid) else { - return Ok(ItemRefusal::Internal.into()); - }; + fn unequip_item(&self, actor: Actor, from_slot: u8) -> Result { let coord = self.0.call_pipe(); item_action(call_reducer!( coord.conn.reducers, @@ -82,43 +31,26 @@ impl Coordinator { )) } - /// Use the consumable in main-inventory `slot` (`CMSG_USE_ITEM`) over the coordinator connection — - /// eat/drink/potion/bandage. The module applies the on-use effect (flat heal for slice food) and - /// decrements the stack; a gameplay `Err` (no item / not usable) is per-action. - pub fn use_item( - &self, - _account_id: u64, - actor_guid: u64, - slot: u8, - ) -> Result { - let Some(actor) = resolved_item_actor("use_item", actor_guid) else { - return Ok(ItemRefusal::Internal.into()); - }; + /// Move (or swap) main-inventory `from_slot` → `to_slot` (`CMSG_SWAP_INV_ITEM`/`CMSG_SWAP_ITEM`) + /// over the coordinator connection. The module's move primitive validates equip-slot transitions. + fn move_item(&self, actor: Actor, from_slot: u8, to_slot: u8) -> Result { let coord = self.0.call_pipe(); item_action(call_reducer!( coord.conn.reducers, - "gw_use_item", - gw_use_item_then(self.session_actor(actor), slot) + "gw_move_item", + gw_move_item_then(self.session_actor(actor), from_slot, to_slot) )) } - /// Move (or swap) main-inventory `from_slot` → `to_slot` (`CMSG_SWAP_INV_ITEM`/`CMSG_SWAP_ITEM`) - /// over the coordinator connection. The module's move primitive validates equip-slot transitions. - pub fn move_item( - &self, - _account_id: u64, - actor_guid: u64, - from_slot: u8, - to_slot: u8, - ) -> Result { - let Some(actor) = resolved_item_actor("move_item", actor_guid) else { - return Ok(ItemRefusal::Internal.into()); - }; + /// Use the consumable in main-inventory `slot` (`CMSG_USE_ITEM`) over the coordinator connection — + /// eat/drink/potion/bandage. The module applies the on-use effect (flat heal for slice food) and + /// decrements the stack; a gameplay `Err` (no item / not usable) is per-action. + fn use_item(&self, actor: Actor, slot: u8) -> Result { let coord = self.0.call_pipe(); item_action(call_reducer!( coord.conn.reducers, - "gw_move_item", - gw_move_item_then(self.session_actor(actor), from_slot, to_slot) + "gw_use_item", + gw_use_item_then(self.session_actor(actor), slot) )) } } @@ -128,23 +60,16 @@ impl Coordinator { fn item_action(result: Result<()>) -> Result { match result { Ok(()) => Ok(ItemActionResult::Done), - Err(error) => match reducer_refusal_reason(&error).and_then(ItemRefusal::parse_tag) { - Some(refusal) => Ok(refusal.into()), - None => Err(error), + Err(error) => match classify(&error) { + DurableFailure::Refusal { reason } => match ItemRefusal::parse_tag(reason) { + Some(refusal) => Ok(refusal.into()), + None => Err(error), + }, + DurableFailure::TransportLoss => Err(error), }, } } -/// An item action needs the caller's own entity. Without one there is nothing to request, so the -/// client gets a Refusal rather than a dead session. -fn resolved_item_actor(operation: &str, actor_guid: u64) -> Option { - let actor = Actor::new(actor_guid); - if actor.is_none() { - log::warn!("stdb: {operation} has no resolved actor"); - } - actor -} - #[cfg(test)] mod item_reducer_tests { use super::*; @@ -154,10 +79,10 @@ mod item_reducer_tests { #[test] fn only_a_rejected_reducer_carries_a_typed_refusal() { for refusal in ItemRefusal::ALL { - let rejected = Err(anyhow::Error::from(ReducerCallError::Rejected { - operation: "gw_move_item".to_string(), - reason: refusal.as_tag().to_string(), - }) + let rejected = Err(anyhow::Error::from(ReducerCallError::refused( + "gw_move_item", + refusal.as_tag(), + )) .context("move phase")); assert_eq!( item_action(rejected).unwrap(), @@ -171,13 +96,8 @@ mod item_reducer_tests { anyhow::Error::from(ReducerCallError::fatal( "gw_move_item reducer timed out after 10s".to_string(), )), - anyhow::Error::from(ReducerCallError::fatal( - "gw_use_item reducer failed: transport disconnected".to_string(), - )), - anyhow::Error::from(ReducerCallError::Rejected { - operation: "gw_equip_item".to_string(), - reason: "operator only".to_string(), - }), + anyhow::Error::from(ReducerCallError::transport_lost("gw_use_item")), + anyhow::Error::from(ReducerCallError::refused("gw_equip_item", "operator only")), anyhow!( "wrapped text that mentions {}", ItemRefusal::Internal.as_tag() diff --git a/gateway/src/world/handlers/item.rs b/gateway/src/world/handlers/item.rs index baa62a3a9..e819f3485 100644 --- a/gateway/src/world/handlers/item.rs +++ b/gateway/src/world/handlers/item.rs @@ -22,26 +22,10 @@ impl From for ItemActionResult { } pub(crate) trait ItemActionStore: Send + Sync { - fn equip_item( - &self, - account_id: u64, - actor_guid: u64, - from_slot: u8, - ) -> Result; - fn unequip_item( - &self, - account_id: u64, - actor_guid: u64, - from_slot: u8, - ) -> Result; - fn move_item( - &self, - account_id: u64, - actor_guid: u64, - from_slot: u8, - to_slot: u8, - ) -> Result; - fn use_item(&self, account_id: u64, actor_guid: u64, slot: u8) -> Result; + fn equip_item(&self, actor: Actor, from_slot: u8) -> Result; + fn unequip_item(&self, actor: Actor, from_slot: u8) -> Result; + fn move_item(&self, actor: Actor, from_slot: u8, to_slot: u8) -> Result; + fn use_item(&self, actor: Actor, slot: u8) -> Result; } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -55,6 +39,28 @@ pub(crate) enum ItemActionOutcome { 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, + operation: &str, + request: impl FnOnce(Actor) -> Result, +) -> Result { + let result = match player.self_guid.and_then(Actor::new) { + Some(actor) => request(actor)?, + None => { + log::warn!( + "world: {operation} has no resolved actor (account {})", + player.account_id + ); + ItemRefusal::Internal.into() + } + }; + Ok(ItemActionOutcome::Handled { + outbound: inventory_action_outbound(player.account_id, operation, result), + }) +} + /// A Refusal answers the client with its own result code; a completed action answers nothing. fn inventory_action_outbound( account_id: u64, @@ -86,16 +92,9 @@ pub(crate) fn dispatch_item_action Result { match msg { ClientOpcodeMessage::CMSG_AUTOEQUIP_ITEM(c) if c.source_bag == MAIN_BAG => { - let outbound = inventory_action_outbound( - player.account_id, - "equip_item", - store.equip_item( - player.account_id, - player.self_guid.unwrap_or(0), - c.source_slot, - )?, - ); - Ok(ItemActionOutcome::Handled { outbound }) + act(player, "equip_item", |actor| { + store.equip_item(actor, c.source_slot) + }) } ClientOpcodeMessage::CMSG_AUTOEQUIP_ITEM(c) => { log::debug!( @@ -112,16 +111,9 @@ pub(crate) fn dispatch_item_action { - let outbound = inventory_action_outbound( - player.account_id, - "unequip_item", - store.unequip_item( - player.account_id, - player.self_guid.unwrap_or(0), - c.source_slot, - )?, - ); - Ok(ItemActionOutcome::Handled { outbound }) + act(player, "unequip_item", |actor| { + store.unequip_item(actor, c.source_slot) + }) } ClientOpcodeMessage::CMSG_AUTOSTORE_BAG_ITEM(c) => { log::debug!( @@ -134,33 +126,15 @@ pub(crate) fn dispatch_item_action { - let outbound = inventory_action_outbound( - player.account_id, - "move_item", - store.move_item( - player.account_id, - player.self_guid.unwrap_or(0), - c.source_slot.as_int(), - c.destination_slot.as_int(), - )?, - ); - Ok(ItemActionOutcome::Handled { outbound }) - } + 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) if c.source_bag == MAIN_BAG && c.destination_bag == MAIN_BAG => { - let outbound = inventory_action_outbound( - player.account_id, - "move_item (swap)", - store.move_item( - player.account_id, - player.self_guid.unwrap_or(0), - c.source_slot, - c.destionation_slot, - )?, - ); - Ok(ItemActionOutcome::Handled { outbound }) + act(player, "move_item (swap)", |actor| { + store.move_item(actor, c.source_slot, c.destionation_slot) + }) } ClientOpcodeMessage::CMSG_SWAP_ITEM(_) => { log::debug!( @@ -178,15 +152,12 @@ pub(crate) fn dispatch_item_action details, - None => inventory_action_outbound( - player.account_id, - "use_item", - store.use_item(player.account_id, player.self_guid.unwrap_or(0), c.bag_slot)?, - ), - }; - Ok(ItemActionOutcome::Handled { outbound }) + match item_started_quest(store, quest_player, c.bag_slot)? { + Some(outbound) => Ok(ItemActionOutcome::Handled { outbound }), + None => act(player, "use_item", |actor| { + store.use_item(actor, c.bag_slot) + }), + } } ClientOpcodeMessage::CMSG_USE_ITEM(c) => { log::debug!( @@ -205,6 +176,7 @@ pub(crate) fn dispatch_item_action>, - pub(crate) unequip_requests: Mutex>, - pub(crate) move_requests: Mutex>, - pub(crate) use_requests: Mutex>, - pub(crate) equip_result: Option>, - pub(crate) unequip_result: Option>, - pub(crate) move_result: Option>, - pub(crate) use_result: Option>, + pub(crate) equip_requests: Mutex>, + pub(crate) unequip_requests: Mutex>, + pub(crate) move_requests: Mutex>, + pub(crate) use_requests: Mutex>, + pub(crate) equip_result: Option, + pub(crate) unequip_result: Option, + pub(crate) move_result: Option, + pub(crate) use_result: Option, + /// Every durable call fails with a Transport Loss. + pub(crate) transport_lost: bool, pub(crate) start_quest: Option<(u64, u32)>, pub(crate) quest_detail: Option, } - /// The Coordinator answers either a typed Refusal or a failure with an unknown durable outcome, - /// so the Fake answers in exactly those two shapes. - fn answer(canned: &Option>) -> Result { - match canned { - None => Ok(ItemActionResult::Done), - Some(Ok(result)) => Ok(*result), - Some(Err(failure)) => Err(anyhow::anyhow!("{failure}")), + impl InMemoryItemActions { + /// The Coordinator answers a decoded Refusal or, for a Transport Loss, an error. The Fake + /// answers in those two shapes. + fn answer( + &self, + operation: &str, + canned: Option, + ) -> Result { + if self.transport_lost { + return Err(ReducerCallError::transport_lost(operation).into()); + } + Ok(canned.unwrap_or(ItemActionResult::Done)) } } impl ItemActionStore for InMemoryItemActions { - fn equip_item( - &self, - account_id: u64, - actor_guid: u64, - from_slot: u8, - ) -> Result { + fn equip_item(&self, actor: Actor, from_slot: u8) -> Result { self.equip_requests .lock() .unwrap() - .push((account_id, actor_guid, from_slot)); - answer(&self.equip_result) + .push((actor.guid(), from_slot)); + self.answer("gw_equip_item", self.equip_result) } - fn unequip_item( - &self, - account_id: u64, - actor_guid: u64, - from_slot: u8, - ) -> Result { + fn unequip_item(&self, actor: Actor, from_slot: u8) -> Result { self.unequip_requests .lock() .unwrap() - .push((account_id, actor_guid, from_slot)); - answer(&self.unequip_result) + .push((actor.guid(), from_slot)); + self.answer("gw_unequip_item", self.unequip_result) } - fn move_item( - &self, - account_id: u64, - actor_guid: u64, - from_slot: u8, - to_slot: u8, - ) -> Result { + fn move_item(&self, actor: Actor, from_slot: u8, to_slot: u8) -> Result { self.move_requests .lock() .unwrap() - .push((account_id, actor_guid, from_slot, to_slot)); - answer(&self.move_result) + .push((actor.guid(), from_slot, to_slot)); + self.answer("gw_move_item", self.move_result) } - fn use_item(&self, account_id: u64, actor_guid: u64, slot: u8) -> Result { - self.use_requests - .lock() - .unwrap() - .push((account_id, actor_guid, slot)); - answer(&self.use_result) + fn use_item(&self, actor: Actor, slot: u8) -> Result { + self.use_requests.lock().unwrap().push((actor.guid(), slot)); + self.answer("gw_use_item", self.use_result) } } @@ -443,14 +403,14 @@ pub(super) mod tests { handled_without_outbound(outcome); assert_eq!( actions.equip_requests.lock().unwrap().as_slice(), - &[(7, 42, 24)] + &[(42, 24)] ); } #[test] fn equip_refusal_returns_inventory_failure_without_ending_the_session() { let actions = InMemoryItemActions { - equip_result: Some(Ok(ItemRefusal::CannotEquip.into())), + equip_result: Some(ItemRefusal::CannotEquip.into()), ..Default::default() }; @@ -477,14 +437,14 @@ pub(super) mod tests { handled_without_outbound(outcome); assert_eq!( actions.unequip_requests.lock().unwrap().as_slice(), - &[(7, 42, 15)] + &[(42, 15)] ); } #[test] fn unequip_refusal_returns_inventory_failure_without_ending_the_session() { let actions = InMemoryItemActions { - unequip_result: Some(Ok(ItemRefusal::InventoryFull.into())), + unequip_result: Some(ItemRefusal::InventoryFull.into()), ..Default::default() }; @@ -553,7 +513,6 @@ pub(super) mod tests { assert_eq!( actions.move_requests.lock().unwrap().as_slice(), &[( - 7, 42, ItemSlot::MainHand.as_int(), ItemSlot::Inventory1.as_int() @@ -564,7 +523,7 @@ pub(super) mod tests { #[test] fn move_refusal_returns_inventory_failure_without_ending_the_session() { let actions = InMemoryItemActions { - move_result: Some(Ok(ItemRefusal::WrongSlot.into())), + move_result: Some(ItemRefusal::WrongSlot.into()), ..Default::default() }; @@ -592,14 +551,14 @@ pub(super) mod tests { handled_without_outbound(outcome); assert_eq!( actions.move_requests.lock().unwrap().as_slice(), - &[(7, 42, 23, 30)] + &[(42, 23, 30)] ); } #[test] fn swap_refusal_returns_inventory_failure_without_ending_the_session() { let actions = InMemoryItemActions { - move_result: Some(Ok(ItemRefusal::WrongSlot.into())), + move_result: Some(ItemRefusal::WrongSlot.into()), ..Default::default() }; @@ -624,16 +583,13 @@ pub(super) mod tests { let outcome = dispatch_item_action(&actions, player(), use_item(MAIN_BAG)).unwrap(); handled_without_outbound(outcome); - assert_eq!( - actions.use_requests.lock().unwrap().as_slice(), - &[(7, 42, 5)] - ); + assert_eq!(actions.use_requests.lock().unwrap().as_slice(), &[(42, 5)]); } #[test] fn use_refusal_returns_inventory_failure_without_ending_the_session() { let actions = InMemoryItemActions { - use_result: Some(Ok(ItemRefusal::ItemNotUsable.into())), + use_result: Some(ItemRefusal::ItemNotUsable.into()), ..Default::default() }; @@ -687,7 +643,7 @@ pub(super) mod tests { // ── Player context and error classification ────────────────────────────── #[test] - fn unresolved_player_uses_the_legacy_zero_actor_for_every_durable_inventory_action() { + fn unresolved_player_requests_nothing_and_every_inventory_action_answers_a_refusal() { let actions = InMemoryItemActions::default(); let player = ItemActionPlayer { account_id: 7, @@ -704,37 +660,14 @@ pub(super) mod tests { swap(MAIN_BAG), use_item(MAIN_BAG), ] { - dispatch_item_action(&actions, player, msg).unwrap(); + inventory_failure(dispatch_item_action(&actions, player, msg).unwrap()); } - assert_eq!( - actions.equip_requests.lock().unwrap().as_slice(), - &[(7, 0, 24)] - ); - assert_eq!( - actions.unequip_requests.lock().unwrap().as_slice(), - &[(7, 0, 15)] - ); - assert_eq!( - actions.move_requests.lock().unwrap().as_slice(), - &[ - ( - 7, - 0, - ItemSlot::MainHand.as_int(), - ItemSlot::Inventory1.as_int() - ), - (7, 0, 23, 30) - ] - ); - assert_eq!( - actions.use_requests.lock().unwrap().as_slice(), - &[(7, 0, 5)] - ); + assert_no_durable_requests(&actions); } #[test] - fn unresolved_player_skips_quest_start_routing_and_requests_ordinary_use() { + fn unresolved_player_skips_quest_start_routing_and_answers_a_refusal() { let actions = InMemoryItemActions { start_quest: Some((0x4000_0000_0000_0099, 1234)), quest_detail: Some(quest_detail(1234)), @@ -747,11 +680,8 @@ pub(super) mod tests { let outcome = dispatch_item_action(&actions, player, use_item(MAIN_BAG)).unwrap(); - handled_without_outbound(outcome); - assert_eq!( - actions.use_requests.lock().unwrap().as_slice(), - &[(7, 0, 5)] - ); + inventory_failure(outcome); + assert_no_durable_requests(&actions); } #[test] @@ -824,7 +754,7 @@ pub(super) mod tests { }; for refusal in ItemRefusal::ALL { let actions = InMemoryItemActions { - equip_result: Some(Ok(refusal.into())), + equip_result: Some(refusal.into()), ..Default::default() }; @@ -844,35 +774,22 @@ pub(super) mod tests { } #[test] - fn a_reducer_timeout_is_not_answered_as_a_refusal() { - let actions = InMemoryItemActions { - move_result: Some(Err("gw_move_item reducer timed out after 10s".into())), + fn a_transport_loss_ends_the_session_where_a_refusal_keeps_it_alive() { + let lost = InMemoryItemActions { + transport_lost: true, ..Default::default() }; - - let error = match dispatch_item_action(&actions, player(), swap(MAIN_BAG)) { + let error = match dispatch_item_action(&lost, player(), equip(MAIN_BAG)) { Err(error) => error, - Ok(_) => panic!("an unknown move outcome must end the session"), + Ok(_) => panic!("a lost transport must end the session"), }; + assert!(matches!(classify(&error), DurableFailure::TransportLoss)); - assert!(error.to_string().contains("timed out")); - } - - #[test] - fn reducer_transport_failure_is_session_fatal() { - let actions = InMemoryItemActions { - equip_result: Some(Err( - "equip_item reducer transport disconnected: channel closed".into(), - )), + let refusing = InMemoryItemActions { + equip_result: Some(ItemRefusal::CannotEquip.into()), ..Default::default() }; - - let error = match dispatch_item_action(&actions, player(), equip(MAIN_BAG)) { - Err(error) => error, - Ok(_) => panic!("a dead reducer transport must end the session"), - }; - - assert!(format!("{error:#}").contains("reducer transport disconnected")); + inventory_failure(dispatch_item_action(&refusing, player(), equip(MAIN_BAG)).unwrap()); } // ── Pass-through ───────────────────────────────────────────────────────── diff --git a/gateway/src/world/item_tests.rs b/gateway/src/world/item_tests.rs index 071847de9..7acc4e61c 100644 --- a/gateway/src/world/item_tests.rs +++ b/gateway/src/world/item_tests.rs @@ -33,7 +33,7 @@ fn equip_item_err_sends_smsg_inventory_change_failure() { // A gameplay refusal reaches the client as an SMSG_INVENTORY_CHANGE_FAILURE and the next // action is served normally. let actions = InMemoryItemActions { - equip_result: Some(Ok(ItemActionResult::Refused(ItemRefusal::CannotEquip))), + equip_result: Some(ItemActionResult::Refused(ItemRefusal::CannotEquip)), ..Default::default() }; for slot in [24, 25] { @@ -51,32 +51,34 @@ fn equip_item_err_sends_smsg_inventory_change_failure() { } #[test] -fn item_action_before_player_login_is_handled_without_panicking() { +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: the legacy zero-actor fallback stays a - // handled gameplay context. + // 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 sent = try_run(&actions, player, autoequip(24)) - .expect("the legacy zero-actor fallback remains a handled gameplay context"); - assert!(sent.is_empty()); + .expect("a session without a Character stays alive"); + assert!(matches!( + sent.as_slice(), + [Outbound::One( + ServerOpcodeMessage::SMSG_INVENTORY_CHANGE_FAILURE(_) + )] + )); + assert!(actions.equip_requests.lock().unwrap().is_empty()); } #[test] fn item_reducer_transport_loss_ends_the_world_session() { - // Reducer transport loss comes back as an error, which ends the session, instead of being + // A Transport Loss comes back as an error, which ends the session, instead of being // translated into gameplay feedback. let actions = InMemoryItemActions { - equip_result: Some(Err( - "equip_item reducer transport disconnected: channel closed".into(), - )), + transport_lost: true, ..Default::default() }; - let error = try_run(&actions, PLAYER, autoequip(24)) + try_run(&actions, PLAYER, autoequip(24)) .err() - .expect("a disconnected item reducer transport must be session-fatal"); - assert!(format!("{error:#}").contains("reducer transport disconnected")); + .expect("a lost item reducer transport must be session-fatal"); } diff --git a/gateway/src/world/test_support/world_fake/item.rs b/gateway/src/world/test_support/world_fake/item.rs index d66290ab4..23112030b 100644 --- a/gateway/src/world/test_support/world_fake/item.rs +++ b/gateway/src/world/test_support/world_fake/item.rs @@ -2,35 +2,19 @@ use super::super::*; /// Item behaviour is tested through `InMemoryItemActions`; every verb here succeeds. impl ItemActionStore for WorldFake { - fn equip_item( - &self, - _account_id: u64, - _self_guid: u64, - _from_slot: u8, - ) -> Result { + fn equip_item(&self, _actor: Actor, _from_slot: u8) -> Result { Ok(ItemActionResult::Done) } - fn unequip_item( - &self, - _account_id: u64, - _self_guid: u64, - _from_slot: u8, - ) -> Result { + fn unequip_item(&self, _actor: Actor, _from_slot: u8) -> Result { Ok(ItemActionResult::Done) } - fn move_item( - &self, - _account_id: u64, - _self_guid: u64, - _from_slot: u8, - _to_slot: u8, - ) -> Result { + fn move_item(&self, _actor: Actor, _from_slot: u8, _to_slot: u8) -> Result { Ok(ItemActionResult::Done) } - fn use_item(&self, _account_id: u64, _self_guid: u64, _slot: u8) -> Result { + fn use_item(&self, _actor: Actor, _slot: u8) -> Result { Ok(ItemActionResult::Done) } } From ce7b0cf32c7585f41459d20cd081de9baa4246ba Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 18:38:33 +0200 Subject: [PATCH 19/33] refactor(gateway): cast Store takes an Actor and classifies durable failures Inline the cast Coordinator wrappers into the trait methods, drop the unused account id and the zero-guid guards, and replace is_transport_failure with classify. Requests the Gateway rejects before the Module (no item target, no Actor) answer the client as a Refusal. A Transport Loss while stopping a ranged loop now ends the World Session. Co-Authored-By: Claude Sonnet 5.5 --- gateway/src/stdb/store/cast.rs | 228 ++---------- gateway/src/world/cast_tests.rs | 15 +- gateway/src/world/handlers/cast/cancel.rs | 54 ++- gateway/src/world/handlers/cast/manual.rs | 75 ++-- gateway/src/world/handlers/cast/mod.rs | 335 +++++++----------- gateway/src/world/handlers/cast/ranged.rs | 93 ++--- .../src/world/test_support/world_fake/cast.rs | 45 +-- 7 files changed, 297 insertions(+), 548 deletions(-) diff --git a/gateway/src/stdb/store/cast.rs b/gateway/src/stdb/store/cast.rs index 64acf4be5..3a0bd4376 100644 --- a/gateway/src/stdb/store/cast.rs +++ b/gateway/src/stdb/store/cast.rs @@ -1,6 +1,6 @@ //! `Coordinator`'s [`CastStore`] adapter. -use anyhow::{anyhow, Result}; +use anyhow::Result; use spacetimedb_sdk::Table; use crate::codec; @@ -10,143 +10,11 @@ use crate::stdb::views::item_template_view; use crate::stdb::Coordinator; use crate::world::{Actor, CastStore, EnchantRoute}; -impl CastStore for crate::stdb::Coordinator { - fn spell_is_ranged_auto_repeat(&self, spell_id: u32) -> bool { - crate::stdb::Coordinator::spell_is_ranged_auto_repeat(self, spell_id) - } - - fn enchant_route(&self, spell_id: u32) -> Option { - crate::stdb::Coordinator::enchant_route(self, spell_id) - } - - fn spell_is_fishing(&self, spell_id: u32) -> bool { - crate::stdb::Coordinator::spell_is_fishing(self, spell_id) - } - - fn spell_is_open_lock(&self, spell_id: u32) -> bool { - crate::stdb::Coordinator::spell_is_open_lock(self, spell_id) - } - - fn spell_is_ground_area(&self, spell_id: u32) -> bool { - crate::stdb::Coordinator::spell_is_ground_area(self, spell_id) - } - - fn spell_cast_time(&self, spell_id: u32) -> Option { - crate::stdb::Coordinator::spell_cast_time(self, spell_id) - } - - fn spell_queues_next_swing(&self, spell_id: u32) -> bool { - crate::stdb::Coordinator::spell_queues_next_swing(self, spell_id) - } - - fn cast_spell( - &self, - account_id: u64, - self_guid: u64, - spell_id: u32, - target_guid: u64, - ) -> Result<()> { - crate::stdb::Coordinator::cast_spell(self, account_id, self_guid, spell_id, target_guid) - } - - fn cast_spell_at( - &self, - account_id: u64, - self_guid: u64, - spell_id: u32, - target_guid: u64, - x: f32, - y: f32, - z: f32, - ) -> Result<()> { - crate::stdb::Coordinator::cast_spell_at( - self, - account_id, - self_guid, - spell_id, - target_guid, - x, - y, - z, - ) - } - - fn cast_item_target( - &self, - account_id: u64, - self_guid: u64, - spell_id: u32, - slot: u8, - ) -> Result<()> { - crate::stdb::Coordinator::cast_item_target(self, account_id, self_guid, spell_id, slot) - } - - fn start_ranged_attack( - &self, - account_id: u64, - self_guid: u64, - target_guid: u64, - spell_id: u32, - ) -> Result<()> { - crate::stdb::Coordinator::start_ranged_attack( - self, - account_id, - self_guid, - target_guid, - spell_id, - ) - } - - fn item_slot_by_guid(&self, account_id: u64, item_guid: u64) -> Option { - crate::stdb::Coordinator::item_slot_by_guid(self, account_id, item_guid) - } - - fn disenchant_item(&self, account_id: u64, self_guid: u64, slot: u8) -> Result<()> { - crate::stdb::Coordinator::disenchant_item(self, account_id, self_guid, slot) - } - - fn enchant_item_on_slot( - &self, - account_id: u64, - self_guid: u64, - slot: u8, - enchant_id: u32, - ) -> Result<()> { - crate::stdb::Coordinator::enchant_item_on_slot( - self, account_id, self_guid, slot, enchant_id, - ) - } - - fn fish(&self, account_id: u64, self_guid: u64) -> Result<()> { - crate::stdb::Coordinator::fish(self, account_id, self_guid) - } - - fn pick_lock(&self, account_id: u64, self_guid: u64, go_guid: u64) -> Result<()> { - crate::stdb::Coordinator::pick_lock(self, account_id, self_guid, go_guid) - } - - fn cancel_cast(&self, account_id: u64, self_guid: u64) -> Result<()> { - crate::stdb::Coordinator::cancel_cast(self, account_id, self_guid) - } - - fn cancel_aura(&self, account_id: u64, self_guid: u64, spell_id: u32) -> Result<()> { - crate::stdb::Coordinator::cancel_aura(self, account_id, self_guid, spell_id) - } - - fn player_items(&self, owner_guid: u64) -> Result> { - crate::stdb::Coordinator::player_items(self, owner_guid) - } - - fn item_template(&self, entry: u32) -> Result> { - crate::stdb::Coordinator::item_template(self, entry) - } -} - -impl Coordinator { +impl CastStore for Coordinator { /// The spell's cast time (ms) from the static `game_spell` header — 0 = instant. Used by the /// CMSG_CAST_SPELL handler to clear an instant cast SYNCHRONOUSLY (the async relay delivers START/GO /// after the aura effects, which wedges the 5875 client's cast slot). None = unknown spell. - pub fn spell_cast_time(&self, spell_id: u32) -> Option { + fn spell_cast_time(&self, spell_id: u32) -> Option { self.0 .coord() .conn @@ -161,8 +29,7 @@ impl Coordinator { /// has no item-target enchanting effect (a normal cast). The gateway uses this to route ITEM-target /// casts by effect KIND, with the enchant id carried in the effect's `p_0`, instead of a hardcoded /// spell-id list — a new enchant spell is a data row, no gateway change. - pub fn enchant_route(&self, spell_id: u32) -> Option { - use crate::world::EnchantRoute; + fn enchant_route(&self, spell_id: u32) -> Option { const E_ENCHANT_ITEM: u8 = 0x17; // taxonomy E_ENCHANT_ITEM (p_0 = enchant_id) const E_DISENCHANT: u8 = 0x18; // taxonomy E_DISENCHANT self.0 @@ -184,7 +51,7 @@ impl Coordinator { /// default self-cast fallback put the CASTER in `hits[]`, and the 5875 client rendered the /// spell's impact animation ON the paladin ("hit animation on the caster"). Kind-routed /// like `enchant_route` — a new ground spell is a data row. - pub fn spell_is_ground_area(&self, spell_id: u32) -> bool { + fn spell_is_ground_area(&self, spell_id: u32) -> bool { const E_PERSISTENT_AREA: u8 = 0x1B; // lockstep with module taxonomy self.0 .coord() @@ -197,7 +64,7 @@ impl Coordinator { /// True iff `spell_id` is a FISHING cast (taxonomy `E_FISH` 0x1C — the three tier ids carry a /// synthesized marker effect row). Kind-routed like `enchant_route`/`spell_is_ground_area`. - pub fn spell_is_fishing(&self, spell_id: u32) -> bool { + fn spell_is_fishing(&self, spell_id: u32) -> bool { const E_FISH: u8 = 0x1C; // lockstep with module taxonomy self.0 .coord() @@ -211,7 +78,7 @@ impl Coordinator { /// True iff `spell_id` is an OPEN-LOCK cast (taxonomy `E_OPEN_LOCK` 0x1D — Pick Lock 1804). Routed to /// the `pick_lock` reducer, gateway-intercepted exactly like `spell_is_fishing`/`enchant_route`. The /// GO guid the pick targets rides the cast's SpellCastTargets (GAMEOBJECT flag). - pub fn spell_is_open_lock(&self, spell_id: u32) -> bool { + fn spell_is_open_lock(&self, spell_id: u32) -> bool { const E_OPEN_LOCK: u8 = 0x1D; // lockstep with module taxonomy self.0 .coord() @@ -232,7 +99,7 @@ impl Coordinator { /// E_NEXT_SWING). The CMSG_CAST_SPELL handler then sends NO synchronous START/CAST_RESULT/GO: the /// 5875 client lights the button locally on the press and holds it as a pending cast until the /// swing-fire GO arrives. Kind value LOCKSTEP with module taxonomy.rs::E_NEXT_SWING (0x13). - pub fn spell_queues_next_swing(&self, spell_id: u32) -> bool { + fn spell_queues_next_swing(&self, spell_id: u32) -> bool { const E_NEXT_SWING: u8 = 0x13; let guard = self.0.coord(); let queues = guard @@ -248,7 +115,7 @@ impl Coordinator { /// `SPELL_ATTR_RANGED_AUTO_REPEAT` cast_flags bit set by the importer (from the DBC AttributesEx2 /// AUTOREPEAT bit / by name). The CMSG_CAST_SPELL handler routes on this instead of a hardcoded /// `spell == 75 || 5019` id list, so a new ranged auto-repeat ability onboards as data. - pub fn spell_is_ranged_auto_repeat(&self, spell_id: u32) -> bool { + fn spell_is_ranged_auto_repeat(&self, spell_id: u32) -> bool { const SPELL_ATTR_RANGED_AUTO_REPEAT: u32 = 0x0200; // lockstep with importer::spell.rs let guard = self.0.coord(); guard @@ -261,7 +128,7 @@ impl Coordinator { } /// Read an item template by entry for a `CMSG_ITEM_QUERY_SINGLE` reply. - pub fn item_template(&self, entry: u32) -> Result> { + fn item_template(&self, entry: u32) -> Result> { Ok(self .0 .coord() @@ -276,15 +143,7 @@ impl Coordinator { /// Start the player's RANGED auto-attack on `target_guid` with `spell_id` (75 Auto Shot / 5019 Shoot) /// over the coordinator connection so the module attributes the shot to the caller. /// Rides the coordinator connection as `gw_ranged_attack`. - pub fn start_ranged_attack( - &self, - _account_id: u64, - actor_guid: u64, - target_guid: u64, - spell_id: u32, - ) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("start_ranged_attack: actor_guid unresolved"))?; + fn start_ranged_attack(&self, actor: Actor, target_guid: u64, spell_id: u32) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, @@ -296,15 +155,7 @@ impl Coordinator { /// Cast a spell (`CMSG_CAST_SPELL`, aura tracer) over the coordinator connection so the module /// attributes the cast to the caller. `target_guid` is the client's selected unit (0 = none/self → /// the module substitutes the caster), threaded so target-keyed effects see the real target. - pub fn cast_spell( - &self, - _account_id: u64, - actor_guid: u64, - spell_id: u32, - target_guid: u64, - ) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("cast_spell: actor_guid unresolved"))?; + fn cast_spell(&self, actor: Actor, spell_id: u32, target_guid: u64) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, @@ -314,15 +165,7 @@ impl Coordinator { } /// Resolve an item-target cast through the module's generic item-effect seam. - pub fn cast_item_target( - &self, - _account_id: u64, - actor_guid: u64, - spell_id: u32, - slot: u8, - ) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("cast_item_target: actor_guid unresolved"))?; + fn cast_item_target(&self, actor: Actor, spell_id: u32, slot: u8) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, @@ -336,18 +179,15 @@ impl Coordinator { /// the ground click so the module anchors the AoE/patch there. // The ground point rides flat because it mirrors the `gw_cast_spell_at` reducer signature. #[allow(clippy::too_many_arguments)] - pub fn cast_spell_at( + fn cast_spell_at( &self, - _account_id: u64, - actor_guid: u64, + actor: Actor, spell_id: u32, target_guid: u64, x: f32, y: f32, z: f32, ) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("cast_spell_at: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, @@ -358,9 +198,7 @@ impl Coordinator { /// Cancel one of the caller's own auras by spell id (`CMSG_CANCEL_AURA`) over the coordinator /// connection so the module attributes the removal to the caller. - pub fn cancel_aura(&self, _account_id: u64, actor_guid: u64, spell_id: u32) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("cancel_aura: actor_guid unresolved"))?; + fn cancel_aura(&self, actor: Actor, spell_id: u32) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, @@ -371,9 +209,7 @@ impl Coordinator { /// Cancel the caller's in-progress cast (`CMSG_CANCEL_CAST`) over the coordinator connection so the /// module clears the caller's pending cast — no phantom completion GO. - pub fn cancel_cast(&self, _account_id: u64, actor_guid: u64) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("cancel_cast: actor_guid unresolved"))?; + fn cancel_cast(&self, actor: Actor) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, @@ -382,9 +218,7 @@ impl Coordinator { ) } - pub fn disenchant_item(&self, _account_id: u64, actor_guid: u64, slot: u8) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("disenchant_item: actor_guid unresolved"))?; + fn disenchant_item(&self, actor: Actor, slot: u8) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, @@ -393,15 +227,7 @@ impl Coordinator { ) } - pub fn enchant_item_on_slot( - &self, - _account_id: u64, - actor_guid: u64, - slot: u8, - enchant_id: u32, - ) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("enchant_item_on_slot: actor_guid unresolved"))?; + fn enchant_item_on_slot(&self, actor: Actor, slot: u8, enchant_id: u32) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, @@ -412,8 +238,7 @@ impl Coordinator { /// Fishing cast: instant-resolve catch — the module's lenient alpha gate auto-learns the /// skill and grants the fish straight to the bag. Caller resolved via ctx.sender. - pub fn fish(&self, _account_id: u64, actor_guid: u64) -> Result<()> { - let actor = Actor::new(actor_guid).ok_or_else(|| anyhow!("fish: actor_guid unresolved"))?; + fn fish(&self, actor: Actor) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, @@ -425,9 +250,7 @@ impl Coordinator { /// Pick Lock: unlock the locked GameObject `go_guid` over the coordinator connection (so the /// module attributes the pick to the caller via ctx.sender). The module gates range / lock /// requirement / Lockpicking skill; on success it records the GO unlocked + climbs the skill. - pub fn pick_lock(&self, _account_id: u64, actor_guid: u64, go_guid: u64) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("pick_lock: actor_guid unresolved"))?; + fn pick_lock(&self, actor: Actor, go_guid: u64) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, @@ -435,4 +258,13 @@ impl Coordinator { gw_pick_lock_then(self.session_actor(actor), go_guid) ) } + + // The shared reads stay inherent: vendor and the Character reads call them too. + fn item_slot_by_guid(&self, account_id: u64, item_guid: u64) -> Option { + Coordinator::item_slot_by_guid(self, account_id, item_guid) + } + + fn player_items(&self, owner_guid: u64) -> Result> { + Coordinator::player_items(self, owner_guid) + } } diff --git a/gateway/src/world/cast_tests.rs b/gateway/src/world/cast_tests.rs index d069ff86e..fd2e0d3d2 100644 --- a/gateway/src/world/cast_tests.rs +++ b/gateway/src/world/cast_tests.rs @@ -164,10 +164,10 @@ fn auto_shot_intercept_starts_the_ranged_attack_instead_of_casting() { } #[test] -fn a_cast_before_entering_the_world_answers_nothing_and_does_not_fail() { +fn a_cast_before_entering_the_world_makes_no_request_and_does_not_fail() { // A cast can arrive while the session is still at character select: a stale addon macro, a - // reconnect race. The seam answers no frames and names a zero caster; the dispatcher returns - // Ok, so the session serves the next opcode. + // reconnect race. The seam has no Actor to cast as, so it makes no durable request and + // answers a failed cast. The dispatcher returns Ok, so the session serves the next opcode. let store = InMemoryCasts::instant(); let sent = run( &store, @@ -177,7 +177,14 @@ fn a_cast_before_entering_the_world_answers_nothing_and_does_not_fail() { targets: unit_targets(77), }, ); - assert!(sent.is_empty()); + assert!(matches!( + sent.as_slice(), + [Outbound::Raw { + opcode: OP_CAST_RESULT, + .. + }] + )); + assert!(store.casts.lock().unwrap().is_empty()); } #[test] diff --git a/gateway/src/world/handlers/cast/cancel.rs b/gateway/src/world/handlers/cast/cancel.rs index b7730b95f..2fb9af7e9 100644 --- a/gateway/src/world/handlers/cast/cancel.rs +++ b/gateway/src/world/handlers/cast/cancel.rs @@ -15,11 +15,7 @@ pub(super) fn cancel_cast( store: &St, player: CastPlayer, ) -> Result { - best_effort( - player, - "cancel_cast", - store.cancel_cast(player.account_id, player.self_guid.unwrap_or(0)), - ) + best_effort(player, "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 @@ -29,22 +25,25 @@ pub(super) fn cancel_aura( player: CastPlayer, spell_id: u32, ) -> Result { - best_effort( - player, - "cancel_aura", - store.cancel_aura(player.account_id, player.self_guid.unwrap_or(0), spell_id), - ) + best_effort(player, "cancel_aura", |actor| { + store.cancel_aura(actor, spell_id) + }) } -/// The shared cancellation outcome: nothing on the wire, no session transition, and a refusal that -/// is logged rather than raised. Only a dead reducer transport ends the session, because no later -/// request could be served either. -fn best_effort(player: CastPlayer, what: &str, result: Result<()>) -> Result { - if let Err(e) = result { - if is_transport_failure(&e) { - return Err(e); - } - log::debug!("world: {what} ignored (account {}): {e}", player.account_id); +/// The shared cancellation outcome: nothing on the wire, no session transition, and a Refusal that +/// 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, + what: &str, + request: impl FnOnce(Actor) -> Result<()>, +) -> Result { + if let Some(Err(e)) = player.actor().map(request) { + let reason = refusal_reason(e)?; + log::debug!( + "world: {what} ignored (account {}): {reason}", + player.account_id + ); } Ok(CastOutcome::Handled { transition: CastTransition::default(), @@ -83,7 +82,7 @@ mod tests { assert_eq!( store.cancel_cast_calls.lock().unwrap().as_slice(), - &[(ACCOUNT, CASTER)], + &[CASTER], "the caller names the cast; the client's spell id is unused" ); assert!( @@ -102,7 +101,7 @@ mod tests { assert_eq!( store.cancel_aura_calls.lock().unwrap().as_slice(), - &[(ACCOUNT, CASTER, 5555)] + &[(CASTER, 5555)] ); assert!(outbound.is_empty(), "the aura relay re-syncs the buff bar"); } @@ -123,13 +122,16 @@ mod tests { #[test] fn a_dead_reducer_transport_during_cancellation_is_session_fatal() { - let store = refusing_store("gw_cancel_cast reducer transport disconnected: channel closed"); + let store = InMemoryCasts { + transport_lost: true, + ..Default::default() + }; assert!(dispatch_cast(&store, player(), cancel_cast_msg()).is_err()); } #[test] - fn a_player_with_no_character_in_world_cancels_against_a_zero_actor() { + fn a_player_with_no_character_in_world_has_nothing_to_cancel() { let store = InMemoryCasts::default(); let player = CastPlayer { self_guid: None, @@ -138,10 +140,6 @@ mod tests { handled(dispatch_cast(&store, player, cancel_aura_msg(5555)).unwrap()); - assert_eq!( - store.cancel_aura_calls.lock().unwrap().as_slice(), - &[(ACCOUNT, 0, 5555)], - "the durable call rejects guid 0 rather than the gateway panicking" - ); + 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 d8c6502ed..1111443e1 100644 --- a/gateway/src/world/handlers/cast/manual.rs +++ b/gateway/src/world/handlers/cast/manual.rs @@ -47,47 +47,47 @@ pub(crate) fn manual_completion_cast( route: ManualRoute, ) -> Result { let spell = c.spell; - let self_guid = player.self_guid.unwrap_or(0); - let result = match route { - ManualRoute::Enchant(enchant_route) => { - let item_guid = item_target(c); - if item_guid == 0 { - Err(anyhow!("enchant: no item target in cast")) - } else { - match store.item_slot_by_guid(player.account_id, item_guid) { - Some(slot) => match enchant_route { - EnchantRoute::Disenchant => { - store.disenchant_item(player.account_id, self_guid, slot) - } - EnchantRoute::Enchant(enchant_id) => store.enchant_item_on_slot( - player.account_id, - self_guid, - slot, - enchant_id, - ), - }, - None => Err(anyhow!("enchant: item {item_guid} not in player bag")), + // `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 { + break 'request Err(NO_ACTOR.to_string()); + }; + let result = match route { + ManualRoute::Enchant(enchant_route) => { + let item_guid = item_target(c); + if item_guid == 0 { + break 'request Err("enchant: no item target in cast".to_string()); + } + let Some(slot) = store.item_slot_by_guid(player.account_id, item_guid) else { + break 'request Err(format!("enchant: item {item_guid} not in player bag")); + }; + match enchant_route { + EnchantRoute::Disenchant => store.disenchant_item(actor, slot), + EnchantRoute::Enchant(enchant_id) => { + store.enchant_item_on_slot(actor, slot, enchant_id) + } } } - } - ManualRoute::Fish => store.fish(player.account_id, self_guid), - ManualRoute::OpenLock => { - let go_guid = gameobject_target(c); - if go_guid == 0 { - Err(anyhow!("pick_lock: no gameobject target in cast")) - } else { - store.pick_lock(player.account_id, self_guid, go_guid) + ManualRoute::Fish => store.fish(actor), + ManualRoute::OpenLock => { + let go_guid = gameobject_target(c); + if go_guid == 0 { + break 'request Err("pick_lock: no gameobject target in cast".to_string()); + } + store.pick_lock(actor, go_guid) } + }; + match result { + Ok(()) => Ok(actor), + Err(e) => Err(refusal_reason(e)?), } }; - let outbound = match result { - Err(e) => { - if is_transport_failure(&e) { - return Err(e); - } + let outbound = match completed { + Err(reason) => { log::debug!( - "world: manual-completion cast {spell} rejected (account {}): {e}", + "world: manual-completion cast {spell} rejected (account {}): {reason}", player.account_id ); vec![Outbound::One(ServerOpcodeMessage::SMSG_CAST_RESULT( @@ -97,19 +97,18 @@ pub(crate) fn manual_completion_cast( }), ))] } - Ok(()) if player.self_guid.is_some() => vec![ + Ok(actor) => vec![ Outbound::One(ServerOpcodeMessage::SMSG_SPELL_START(Box::new( - codec::build_spell_start(self_guid, spell, 0, 0, None), + codec::build_spell_start(actor.guid(), spell, 0, 0, None), ))), Outbound::Raw { opcode: OP_CAST_RESULT, body: codec::build_cast_result_ok(spell), }, Outbound::One(ServerOpcodeMessage::SMSG_SPELL_GO(Box::new( - codec::build_spell_go(self_guid, spell, 0, None), + codec::build_spell_go(actor.guid(), spell, 0, None), ))), ], - Ok(()) => Vec::new(), }; Ok(CastOutcome::Handled { transition: CastTransition::default(), diff --git a/gateway/src/world/handlers/cast/mod.rs b/gateway/src/world/handlers/cast/mod.rs index 19c1f7832..2bc8682c9 100644 --- a/gateway/src/world/handlers/cast/mod.rs +++ b/gateway/src/world/handlers/cast/mod.rs @@ -17,6 +17,7 @@ mod ranged; use super::super::*; use super::MeleeActionStore; +use crate::stdb::{classify, DurableFailure}; use wow_world_messages::vanilla::CMSG_CAST_SPELL; /// `SMSG_CAST_RESULT`. Both bodies are hand-rolled (gtker's typed message inverts the status @@ -50,20 +51,12 @@ pub(crate) trait CastStore: MeleeActionStore + Send + Sync { /// Cast a spell at a unit. `target_guid` 0 means no unit target — the module then applies its /// own self-cast rule. - fn cast_spell( - &self, - account_id: u64, - self_guid: u64, - spell_id: u32, - target_guid: u64, - ) -> Result<()>; + fn cast_spell(&self, actor: Actor, spell_id: u32, target_guid: u64) -> Result<()>; /// Cast a ground-targeted spell at the exact point the client clicked. - #[allow(clippy::too_many_arguments)] fn cast_spell_at( &self, - account_id: u64, - self_guid: u64, + actor: Actor, spell_id: u32, target_guid: u64, x: f32, @@ -73,23 +66,11 @@ pub(crate) trait CastStore: MeleeActionStore + Send + Sync { /// Cast a spell whose explicit target is an owned inventory item. The module validates the /// effect kind and consumes the item only after the gameplay gates pass. - fn cast_item_target( - &self, - account_id: u64, - self_guid: u64, - spell_id: u32, - slot: u8, - ) -> Result<()>; + 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. - fn start_ranged_attack( - &self, - account_id: u64, - self_guid: u64, - target_guid: u64, - spell_id: u32, - ) -> Result<()>; + 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 /// disenchant operations receive a slot rather than a guid. @@ -97,31 +78,25 @@ pub(crate) trait CastStore: MeleeActionStore + Send + Sync { /// Disenchant the item in `slot`. The module validates skill and disenchantability, and yields /// the resulting reagents into the bag. - fn disenchant_item(&self, account_id: u64, self_guid: u64, slot: u8) -> Result<()>; + fn disenchant_item(&self, actor: Actor, slot: u8) -> Result<()>; /// Apply `enchant_id` to the item in `slot`. The module validates skill, consumes the reagent /// and stamps the enchant on the item instance. - fn enchant_item_on_slot( - &self, - account_id: u64, - self_guid: u64, - slot: u8, - enchant_id: u32, - ) -> Result<()>; + fn enchant_item_on_slot(&self, actor: Actor, slot: u8, enchant_id: u32) -> Result<()>; /// The instant-resolve Fishing catch. - fn fish(&self, account_id: u64, self_guid: u64) -> Result<()>; + 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. - fn pick_lock(&self, account_id: u64, self_guid: u64, go_guid: u64) -> Result<()>; + 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 /// one-pending-cast rule the caller identifies the cast, so the client's spell id is unused. - fn cancel_cast(&self, account_id: u64, self_guid: u64) -> Result<()>; + fn cancel_cast(&self, actor: Actor) -> Result<()>; /// Remove the caller's own aura named by the wire spell id. The aura relay re-syncs the buff bar. - fn cancel_aura(&self, account_id: u64, self_guid: u64, spell_id: u32) -> Result<()>; + fn cancel_aura(&self, actor: Actor, spell_id: u32) -> Result<()>; // The two reads below are shared with the character, vendor and query paths. They are declared // on this family only, because a second declaration of the same name on another family would @@ -138,8 +113,7 @@ pub(crate) trait CastStore: MeleeActionStore + Send + Sync { } /// Everything the cast module knows about the caller. `self_guid` is `None` when the session has no -/// character in the world: the durable call then carries guid 0 and no synchronous message is sent, -/// which is what the pre-seam handler did. +/// 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, @@ -148,6 +122,12 @@ pub(crate) struct CastPlayer { 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)] @@ -193,12 +173,16 @@ fn route_for(store: &St, spell_id: u32) -> CastRoute { } } -/// A dead reducer transport cannot serve any further request, so it ends the session. Every other -/// durable error is a gameplay refusal the player sees as a cast failure. -fn is_transport_failure(error: &anyhow::Error) -> bool { - error - .chain() - .any(|cause| cause.to_string().contains("reducer transport disconnected")) +/// What the cast failure mapping reads when the session has no Actor to cast as. +const NO_ACTOR: &str = "no resolved actor"; + +/// The Module's reason for a Refusal, which the client sees as a cast failure. A Transport Loss +/// cannot serve any further request, so it ends the World Session. +fn refusal_reason(error: anyhow::Error) -> Result { + match classify(&error) { + DurableFailure::Refusal { reason } => Ok(reason.to_string()), + DurableFailure::TransportLoss => Err(error), + } } /// The client's unit target, or 0 for "none" — the module substitutes the caster. @@ -232,7 +216,7 @@ pub(crate) fn dispatch_cast( manual::manual_completion_cast(store, player, &c, route) } }, - ClientOpcodeMessage::CMSG_CANCEL_AUTO_REPEAT_SPELL => Ok(ranged::cancel(store, player)), + ClientOpcodeMessage::CMSG_CANCEL_AUTO_REPEAT_SPELL => ranged::cancel(store, player), ClientOpcodeMessage::CMSG_CANCEL_CAST(_) => cancel::cancel_cast(store, player), ClientOpcodeMessage::CMSG_CANCEL_AURA(c) => cancel::cancel_aura(store, player, c.id), other => Ok(CastOutcome::PassThrough(other)), @@ -262,7 +246,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.self_guid { + if let Some(caster) = player.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. @@ -296,43 +280,45 @@ fn ordinary_cast( // A DEST_LOCATION block is a ground click (Flamestrike/Blizzard/Rain of Fire) — the durable // ground cast anchors the area there. The clearing sequence above is unchanged either way. - let self_guid = player.self_guid.unwrap_or(0); let item_guid = match c.targets.target_flags.get_item() { Some(wow_world_messages::vanilla::SpellCastTargets_SpellCastTargetFlags_Item::Item { item, }) => item.guid(), _ => 0, }; - let result = if item_guid != 0 { - store - .item_slot_by_guid(player.account_id, item_guid) - .ok_or_else(|| anyhow!("item target {item_guid} is not in the player's bag")) - .and_then(|slot| store.cast_item_target(player.account_id, self_guid, spell, slot)) - } else { - match dest_target(c) { - Some((x, y, z)) => { - store.cast_spell_at(player.account_id, self_guid, spell, target, x, y, z) + let refusal = 'request: { + let Some(actor) = player.actor() else { + break 'request Some(NO_ACTOR.to_string()); + }; + let result = if item_guid != 0 { + let Some(slot) = store.item_slot_by_guid(player.account_id, item_guid) else { + break 'request Some(format!( + "item target {item_guid} is not in the player's bag" + )); + }; + store.cast_item_target(actor, spell, slot) + } else { + match dest_target(c) { + Some((x, y, z)) => store.cast_spell_at(actor, spell, target, x, y, z), + None => store.cast_spell(actor, spell, target), } - None => store.cast_spell(player.account_id, self_guid, spell, target), + }; + match result { + Ok(()) => None, + Err(e) => Some(refusal_reason(e)?), } }; - if let Err(e) = result { - if is_transport_failure(&e) { - return Err(e); - } + if let Some(reason) = refusal { // Carry the mapped REASON so the client prints the red error line ("Not enough rage", // "You must be behind your target"). A bare failure only resets the button and leaves // server-only gates — behind, stealth, stance, react window — invisible. log::debug!( - "world: cast {spell} rejected (account {}): {e}", + "world: cast {spell} rejected (account {}): {reason}", player.account_id ); outbound.push(Outbound::Raw { opcode: OP_CAST_RESULT, - body: codec::build_cast_result_failed( - spell, - codec::cast_failure_reason_for(&e.to_string()), - ), + body: codec::build_cast_result_failed(spell, codec::cast_failure_reason_for(&reason)), }); } Ok(CastOutcome::Handled { @@ -346,6 +332,7 @@ fn ordinary_cast( #[cfg(test)] pub(super) mod tests { use super::*; + use crate::stdb::ReducerCallError; use std::sync::Mutex; use wow_world_messages::vanilla::{ Guid, SpellCastTargets, SpellCastTargets_SpellCastTargetFlags, @@ -355,20 +342,18 @@ pub(super) mod tests { Vector3d, CMSG_PING, }; - /// One recorded durable cast: account, caster, spell and unit target. - pub(crate) type Cast = (u64, u64, u32, u64); + /// 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 GroundCast = (u64, u64, u32, u64, f32, f32, f32); - /// One recorded ranged activation: account, caster, unit target and spell. - pub(crate) type RangedAttack = (u64, u64, u64, u32); + 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); /// One recorded `enchant_item_on_slot` call: slot and enchant id. pub(crate) type EnchantCall = (u8, u32); - /// One recorded `fish` call: account and caster. - pub(crate) type FishCall = (u64, u64); - /// One recorded `pick_lock` call: account, caster and GameObject guid. - pub(crate) type PickLockCall = (u64, u64, u64); - /// One recorded `cancel_aura` call: account, caster and wire spell id. - pub(crate) type CancelAuraCall = (u64, u64, u32); + /// One recorded `pick_lock` call: caster and GameObject guid. + pub(crate) type PickLockCall = (u64, u64); + /// One recorded `cancel_aura` call: caster and wire spell id. + pub(crate) type CancelAuraCall = (u64, u32); /// Spell-route metadata, item state and durable results for the cast routes, plus a record of /// every durable call. Nothing else: no vendors, parties, mail, transfer or unrelated world @@ -382,9 +367,12 @@ pub(super) mod tests { pub(crate) enchant: Option, pub(crate) fishing: Vec, pub(crate) open_lock: Vec, + /// The Module's reason when the ordinary cast operations refuse. pub(crate) cast_error: Option, /// When set, `start_ranged_attack` refuses with this reason. pub(crate) ranged_error: Option, + /// Every durable operation fails with a Transport Loss. + pub(crate) transport_lost: bool, /// The caller's owned items, and the templates their entries resolve to. pub(crate) items: Vec, pub(crate) templates: Vec, @@ -396,14 +384,14 @@ pub(super) mod tests { pub(crate) cancel_error: Option, pub(crate) casts: Mutex>, pub(crate) ground_casts: Mutex>, - pub(crate) item_target_casts: Mutex>, + pub(crate) item_target_casts: Mutex>, pub(crate) ranged_attacks: Mutex>, pub(crate) stop_attacks: Mutex>, pub(crate) disenchant_calls: Mutex>, pub(crate) enchant_calls: Mutex>, - pub(crate) fish_calls: Mutex>, + pub(crate) fish_calls: Mutex>, pub(crate) pick_lock_calls: Mutex>, - pub(crate) cancel_cast_calls: Mutex>, + pub(crate) cancel_cast_calls: Mutex>, pub(crate) cancel_aura_calls: Mutex>, /// The attackers with a live engagement: an armed ranged loop adds its caster, a stop /// removes it. @@ -429,22 +417,14 @@ pub(super) mod tests { } } - fn durable_result(&self) -> Result<()> { - self.cast_error - .as_ref() - .map_or_else(|| Ok(()), |e| Err(anyhow!("{e}"))) - } - - fn manual_result(&self) -> Result<()> { - self.manual_error - .as_ref() - .map_or_else(|| Ok(()), |e| Err(anyhow!("{e}"))) - } - - fn cancel_result(&self) -> Result<()> { - self.cancel_error - .as_ref() - .map_or_else(|| Ok(()), |e| Err(anyhow!("{e}"))) + /// The Module's answer to a durable request: a Refusal with `reason`, or success. + fn answer(&self, operation: &str, reason: &Option) -> Result<()> { + if self.transport_lost { + return Err(ReducerCallError::transport_lost(operation).into()); + } + reason.as_ref().map_or(Ok(()), |reason| { + Err(ReducerCallError::refused(operation, reason).into()) + }) } } @@ -477,54 +457,36 @@ pub(super) mod tests { self.queues_next_swing } - fn cast_spell( - &self, - account_id: u64, - self_guid: u64, - spell_id: u32, - target_guid: u64, - ) -> Result<()> { + fn cast_spell(&self, actor: Actor, spell_id: u32, target_guid: u64) -> Result<()> { self.casts .lock() .unwrap() - .push((account_id, self_guid, spell_id, target_guid)); - self.durable_result() + .push((actor.guid(), spell_id, target_guid)); + self.answer("gw_cast_spell", &self.cast_error) } fn cast_spell_at( &self, - account_id: u64, - self_guid: u64, + actor: Actor, spell_id: u32, target_guid: u64, x: f32, y: f32, z: f32, ) -> Result<()> { - self.ground_casts.lock().unwrap().push(( - account_id, - self_guid, - spell_id, - target_guid, - x, - y, - z, - )); - self.durable_result() + self.ground_casts + .lock() + .unwrap() + .push((actor.guid(), spell_id, target_guid, x, y, z)); + self.answer("gw_cast_spell_at", &self.cast_error) } - fn cast_item_target( - &self, - account_id: u64, - self_guid: u64, - spell_id: u32, - slot: u8, - ) -> Result<()> { + fn cast_item_target(&self, actor: Actor, spell_id: u32, slot: u8) -> Result<()> { self.item_target_casts .lock() .unwrap() - .push((account_id, self_guid, spell_id, slot)); - self.durable_result() + .push((actor.guid(), spell_id, slot)); + self.answer("gw_cast_item_target", &self.cast_error) } fn item_slot_by_guid(&self, _account_id: u64, item_guid: u64) -> Option { @@ -534,80 +496,58 @@ pub(super) mod tests { .map(|&(_, s)| s) } - fn disenchant_item(&self, _account_id: u64, _self_guid: u64, slot: u8) -> Result<()> { + fn disenchant_item(&self, _actor: Actor, slot: u8) -> Result<()> { self.disenchant_calls.lock().unwrap().push(slot); - self.manual_result() + self.answer("gw_disenchant", &self.manual_error) } - fn enchant_item_on_slot( - &self, - _account_id: u64, - _self_guid: u64, - slot: u8, - enchant_id: u32, - ) -> Result<()> { + fn enchant_item_on_slot(&self, _actor: Actor, slot: u8, enchant_id: u32) -> Result<()> { self.enchant_calls.lock().unwrap().push((slot, enchant_id)); - self.manual_result() + self.answer("gw_enchant_item", &self.manual_error) } - fn fish(&self, account_id: u64, self_guid: u64) -> Result<()> { - self.fish_calls - .lock() - .unwrap() - .push((account_id, self_guid)); - self.manual_result() + fn fish(&self, actor: Actor) -> Result<()> { + self.fish_calls.lock().unwrap().push(actor.guid()); + self.answer("gw_fish", &self.manual_error) } - fn pick_lock(&self, account_id: u64, self_guid: u64, go_guid: u64) -> Result<()> { + fn pick_lock(&self, actor: Actor, go_guid: u64) -> Result<()> { self.pick_lock_calls .lock() .unwrap() - .push((account_id, self_guid, go_guid)); - self.manual_result() + .push((actor.guid(), go_guid)); + self.answer("gw_pick_lock", &self.manual_error) } - fn cancel_cast(&self, account_id: u64, self_guid: u64) -> Result<()> { - self.cancel_cast_calls - .lock() - .unwrap() - .push((account_id, self_guid)); - self.cancel_result()?; + fn cancel_cast(&self, actor: Actor) -> Result<()> { + self.cancel_cast_calls.lock().unwrap().push(actor.guid()); + self.answer("gw_cancel_cast", &self.cancel_error)?; self.pending_casts .lock() .unwrap() - .retain(|&caster| caster != self_guid); + .retain(|&caster| caster != actor.guid()); Ok(()) } - fn cancel_aura(&self, account_id: u64, self_guid: u64, spell_id: u32) -> Result<()> { + fn cancel_aura(&self, actor: Actor, spell_id: u32) -> Result<()> { self.cancel_aura_calls .lock() .unwrap() - .push((account_id, self_guid, spell_id)); - self.cancel_result()?; + .push((actor.guid(), spell_id)); + self.answer("gw_cancel_aura", &self.cancel_error)?; self.auras.lock().unwrap().retain(|&aura| aura != spell_id); Ok(()) } - fn start_ranged_attack( - &self, - account_id: u64, - self_guid: u64, - target_guid: u64, - spell_id: u32, - ) -> Result<()> { - if let Some(e) = &self.ranged_error { - return Err(anyhow!("{e}")); - } - self.ranged_attacks.lock().unwrap().push(( - account_id, - self_guid, - target_guid, - spell_id, - )); + fn start_ranged_attack(&self, actor: Actor, target_guid: u64, spell_id: u32) -> Result<()> { + self.answer("gw_ranged_attack", &self.ranged_error)?; + self.ranged_attacks + .lock() + .unwrap() + .push((actor.guid(), target_guid, spell_id)); let mut engaged = self.engaged.lock().unwrap(); - if !engaged.contains(&self_guid) { - engaged.push(self_guid); + if !engaged.contains(&actor.guid()) { + engaged.push(actor.guid()); } Ok(()) } @@ -629,6 +569,7 @@ pub(super) mod tests { } fn stop_attack(&self, actor: Actor) -> Result<()> { + self.answer("gw_stop_attack", &None)?; self.stop_attacks.lock().unwrap().push(actor.guid()); self.engaged .lock() @@ -763,10 +704,7 @@ pub(super) mod tests { "the 5875 client needs the OK ack between START and GO" ); assert_eq!(transition, CastTransition::default()); - assert_eq!( - store.casts.lock().unwrap().as_slice(), - &[(ACCOUNT, CASTER, 100, 77)] - ); + assert_eq!(store.casts.lock().unwrap().as_slice(), &[(CASTER, 100, 77)]); } #[test] @@ -777,7 +715,7 @@ pub(super) mod tests { assert_eq!( store.casts.lock().unwrap().as_slice(), - &[(ACCOUNT, CASTER, 100, 0)], + &[(CASTER, 100, 0)], "target 0 preserves the module's self-cast fallback" ); } @@ -867,7 +805,7 @@ pub(super) mod tests { assert_eq!((point.x, point.y, point.z), (-8913.5, 554.25, 93.75)); assert_eq!( store.ground_casts.lock().unwrap().as_slice(), - &[(ACCOUNT, CASTER, 1725, 0, -8913.5, 554.25, 93.75)] + &[(CASTER, 1725, 0, -8913.5, 554.25, 93.75)] ); } @@ -887,10 +825,7 @@ pub(super) mod tests { outbound.is_empty(), "the cast-event relay owns a timed cast's lifecycle" ); - assert_eq!( - store.casts.lock().unwrap().as_slice(), - &[(ACCOUNT, CASTER, 100, 77)] - ); + assert_eq!(store.casts.lock().unwrap().as_slice(), &[(CASTER, 100, 77)]); } #[test] @@ -907,10 +842,7 @@ pub(super) mod tests { outbound.is_empty(), "the client holds the button until the durable swing fires the completion" ); - assert_eq!( - store.casts.lock().unwrap().as_slice(), - &[(ACCOUNT, CASTER, 78, 77)] - ); + assert_eq!(store.casts.lock().unwrap().as_slice(), &[(CASTER, 78, 77)]); } // ── Ground targeting ───────────────────────────────────────────────────── @@ -933,7 +865,7 @@ pub(super) mod tests { assert_eq!( store.ground_casts.lock().unwrap().as_slice(), - &[(ACCOUNT, CASTER, 2120, 0, -8913.5, 554.25, 93.75)] + &[(CASTER, 2120, 0, -8913.5, 554.25, 93.75)] ); assert!(store.casts.lock().unwrap().is_empty()); } @@ -974,7 +906,8 @@ pub(super) mod tests { fn a_dead_reducer_transport_is_session_fatal() { let store = InMemoryCasts { cast_time_ms: Some(1500), - ..InMemoryCasts::refusing("cast_spell reducer transport disconnected: channel closed") + transport_lost: true, + ..Default::default() }; let error = match dispatch_cast(&store, player(), cast(100, unit_targets(77))) { @@ -982,13 +915,13 @@ pub(super) mod tests { Ok(_) => panic!("a dead reducer transport must end the session"), }; - assert!(format!("{error:#}").contains("reducer transport disconnected")); + assert!(matches!(classify(&error), DurableFailure::TransportLoss)); } // ── Player context ─────────────────────────────────────────────────────── #[test] - fn a_player_with_no_character_in_world_gets_no_messages_and_a_zero_actor() { + 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, @@ -999,11 +932,8 @@ pub(super) mod tests { let (_, outbound) = handled(dispatch_cast(&store, player, cast(100, unit_targets(77))).unwrap()); - assert!(outbound.is_empty()); - assert_eq!( - store.casts.lock().unwrap().as_slice(), - &[(ACCOUNT, 0, 100, 77)] - ); + assert_eq!(sequence(&outbound), ["CAST_RESULT(FAILED 0x17)"]); + assert!(store.casts.lock().unwrap().is_empty()); } #[test] @@ -1106,10 +1036,7 @@ pub(super) mod tests { ); assert_eq!(sequence(&outbound), ["START", "CAST_RESULT(OK)", "GO"]); - assert_eq!( - store.fish_calls.lock().unwrap().as_slice(), - &[(ACCOUNT, CASTER)] - ); + assert_eq!(store.fish_calls.lock().unwrap().as_slice(), &[CASTER]); } #[test] @@ -1131,7 +1058,7 @@ pub(super) mod tests { fn a_dead_reducer_transport_on_a_manual_route_is_session_fatal() { let store = InMemoryCasts { fishing: vec![7620], - manual_error: Some("fish reducer transport disconnected: channel closed".into()), + transport_lost: true, ..Default::default() }; @@ -1139,7 +1066,7 @@ pub(super) mod tests { Err(error) => error, Ok(_) => panic!("a dead reducer transport must end the session"), }; - assert!(format!("{error:#}").contains("reducer transport disconnected")); + assert!(matches!(classify(&error), DurableFailure::TransportLoss)); } #[test] @@ -1162,7 +1089,7 @@ pub(super) mod tests { assert_eq!(sequence(&outbound), ["START", "CAST_RESULT(OK)", "GO"]); assert_eq!( store.pick_lock_calls.lock().unwrap().as_slice(), - &[(ACCOUNT, CASTER, 0xABCD)], + &[(CASTER, 0xABCD)], "unk_shape={unk_shape}" ); } @@ -1195,7 +1122,7 @@ pub(super) mod tests { assert_eq!(sequence(&outbound), ["START", "CAST_RESULT(OK)", "GO"]); assert_eq!( *store.item_target_casts.lock().unwrap(), - [(ACCOUNT, CASTER, 6991, 7)] + [(CASTER, 6991, 7)] ); assert!(store.casts.lock().unwrap().is_empty()); } diff --git a/gateway/src/world/handlers/cast/ranged.rs b/gateway/src/world/handlers/cast/ranged.rs index 18f1ce12a..32123b698 100644 --- a/gateway/src/world/handlers/cast/ranged.rs +++ b/gateway/src/world/handlers/cast/ranged.rs @@ -27,30 +27,27 @@ pub(super) fn activate( // 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. - if let Err(e) = store.start_ranged_attack( - player.account_id, - player.self_guid.unwrap_or(0), - target, - spell, - ) { - return refuse(store, player, spell, e); + let Some(actor) = player.actor() else { + return refuse(store, player, spell, NO_ACTOR); + }; + if let Err(e) = store.start_ranged_attack(actor, target, spell) { + return refuse(store, player, spell, &refusal_reason(e)?); } // The activation ack is `SMSG_SPELL_START` alone: timer 0 (the wind-up is an attack timer, not // a cast bar — the client animates its own), the ammo block that nocks the projectile, and the // real unit target. No CAST_RESULT(OK) and no GO: this cast parks in the client's auto-repeat // slot and never resolves, and each shot's GO comes from the swing-tick relay. - let outbound = player.self_guid.map_or_else(Vec::new, |caster| { - vec![Outbound::One(ServerOpcodeMessage::SMSG_SPELL_START( - Box::new(codec::build_spell_start( - caster, - spell, - 0, - target, - ammo_display(store, caster), - )), - ))] - }); + let caster = actor.guid(); + let outbound = vec![Outbound::One(ServerOpcodeMessage::SMSG_SPELL_START( + Box::new(codec::build_spell_start( + caster, + spell, + 0, + target, + ammo_display(store, caster), + )), + ))]; Ok(CastOutcome::Handled { transition: CastTransition { ranged_repeat: Some(true), @@ -59,26 +56,20 @@ pub(super) fn activate( }) } -/// A refused activation. Gameplay refusals are handled outcomes; a dead reducer transport is not. +/// A refused activation. A Refusal is a handled outcome; a Transport Loss never reaches here. fn refuse( store: &St, player: CastPlayer, spell: u32, - e: anyhow::Error, + reason: &str, ) -> Result { - if is_transport_failure(&e) { - return Err(e); - } log::info!( - "world[autoshot]: start_ranged_attack refused spell={spell} (account {}): {e}", + "world[autoshot]: start_ranged_attack refused spell={spell} (account {}): {reason}", player.account_id ); let outbound = vec![Outbound::Raw { opcode: OP_CAST_RESULT, - body: codec::build_cast_result_failed( - spell, - codec::cast_failure_reason_for(&e.to_string()), - ), + body: codec::build_cast_result_failed(spell, codec::cast_failure_reason_for(reason)), }]; // 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 @@ -89,7 +80,7 @@ fn refuse( outbound, }); } - stop_loop(store, player, "reject-teardown"); + stop_loop(store, player, "reject-teardown")?; Ok(CastOutcome::Handled { transition: CastTransition { ranged_repeat: Some(false), @@ -99,7 +90,10 @@ fn refuse( } /// `CMSG_CANCEL_AUTO_REPEAT_SPELL`: the client toggled the loop off, or auto-switched to melee. -pub(super) fn cancel(store: &St, player: CastPlayer) -> CastOutcome { +pub(super) fn cancel( + store: &St, + player: CastPlayer, +) -> Result { log::info!( "world[autoshot]: cancel auto-repeat active={} (account {})", player.ranged_repeat, @@ -110,28 +104,30 @@ pub(super) fn cancel(store: &St, player: CastPlayer) -> // 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"); + stop_loop(store, player, "cancel_auto_repeat")?; } - CastOutcome::Handled { + Ok(CastOutcome::Handled { transition: CastTransition { ranged_repeat: Some(false), }, outbound: Vec::new(), - } + }) } -/// Ask the module to tear the engagement down. Best effort: a refused stop (nothing armed, a race -/// with the swing tick) must not end the session or reach the client. -fn stop_loop(store: &St, player: CastPlayer, context: &str) { - let Some(actor) = player.self_guid.and_then(Actor::new) else { - return; +/// 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 { + return Ok(()); }; if let Err(e) = store.stop_attack(actor) { + let reason = refusal_reason(e)?; log::debug!( - "world: {context} stop_attack ignored (account {}): {e}", + "world: {context} stop_attack ignored (account {}): {reason}", player.account_id ); } + Ok(()) } /// The `(display_id, inv_type)` ammo block for the activation START. `Some` only when the ranged @@ -252,7 +248,7 @@ mod tests { assert_eq!( store.ranged_attacks.lock().unwrap().as_slice(), - &[(ACCOUNT, CASTER, 88, AUTO_SHOT)], + &[(CASTER, 88, AUTO_SHOT)], "the cast's unit target arms the loop, with no selection fallback" ); assert_eq!( @@ -332,13 +328,28 @@ mod tests { #[test] fn a_dead_reducer_transport_during_activation_is_session_fatal() { let store = InMemoryCasts { - ranged_error: Some("start_ranged_attack reducer transport disconnected".into()), + transport_lost: true, ..ranged_store() }; assert!(dispatch_cast(&store, player(), cast(AUTO_SHOT, unit_targets(88))).is_err()); } + #[test] + fn a_dead_reducer_transport_while_stopping_the_loop_is_session_fatal() { + let store = InMemoryCasts { + transport_lost: true, + ..ranged_store() + }; + + assert!(dispatch_cast( + &store, + repeating(), + ClientOpcodeMessage::CMSG_CANCEL_AUTO_REPEAT_SPELL + ) + .is_err()); + } + // ── Cancellation ───────────────────────────────────────────────────────── #[test] diff --git a/gateway/src/world/test_support/world_fake/cast.rs b/gateway/src/world/test_support/world_fake/cast.rs index c534505ab..d6b2309c0 100644 --- a/gateway/src/world/test_support/world_fake/cast.rs +++ b/gateway/src/world/test_support/world_fake/cast.rs @@ -12,38 +12,20 @@ pub(crate) struct CastState { /// Cast behaviour is tested through `InMemoryCasts`. Here every cast operation succeeds and every /// spell reads as an unknown, ordinary one; only the two item reads world entry makes are live. impl CastStore for WorldFake { - fn cast_item_target( - &self, - _account_id: u64, - _self_guid: u64, - _spell_id: u32, - _slot: u8, - ) -> Result<()> { + fn cast_item_target(&self, _actor: Actor, _spell_id: u32, _slot: u8) -> Result<()> { Ok(()) } - fn cancel_aura(&self, _account_id: u64, _self_guid: u64, _spell_id: u32) -> Result<()> { + fn cancel_aura(&self, _actor: Actor, _spell_id: u32) -> Result<()> { Ok(()) } - fn cancel_cast(&self, _account_id: u64, _self_guid: u64) -> Result<()> { + fn cancel_cast(&self, _actor: Actor) -> Result<()> { Ok(()) } - fn cast_spell( - &self, - _account_id: u64, - _self_guid: u64, - _spell_id: u32, - _target_guid: u64, - ) -> Result<()> { + fn cast_spell(&self, _actor: Actor, _spell_id: u32, _target_guid: u64) -> Result<()> { Ok(()) } - fn start_ranged_attack( - &self, - _account_id: u64, - _self_guid: u64, - _target_guid: u64, - _spell_id: u32, - ) -> Result<()> { + fn start_ranged_attack(&self, _actor: Actor, _target_guid: u64, _spell_id: u32) -> Result<()> { Ok(()) } fn spell_is_ranged_auto_repeat(&self, _spell_id: u32) -> bool { @@ -69,8 +51,7 @@ impl CastStore for WorldFake { } fn cast_spell_at( &self, - _account_id: u64, - _self_guid: u64, + _actor: Actor, _spell_id: u32, _target_guid: u64, _x: f32, @@ -82,22 +63,16 @@ impl CastStore for WorldFake { fn item_slot_by_guid(&self, _account_id: u64, _item_guid: u64) -> Option { None } - fn disenchant_item(&self, _account_id: u64, _self_guid: u64, _slot: u8) -> Result<()> { + fn disenchant_item(&self, _actor: Actor, _slot: u8) -> Result<()> { Ok(()) } - fn enchant_item_on_slot( - &self, - _account_id: u64, - _self_guid: u64, - _slot: u8, - _enchant_id: u32, - ) -> Result<()> { + fn enchant_item_on_slot(&self, _actor: Actor, _slot: u8, _enchant_id: u32) -> Result<()> { Ok(()) } - fn fish(&self, _account_id: u64, _self_guid: u64) -> Result<()> { + fn fish(&self, _actor: Actor) -> Result<()> { Ok(()) } - fn pick_lock(&self, _account_id: u64, _self_guid: u64, _go_guid: u64) -> Result<()> { + fn pick_lock(&self, _actor: Actor, _go_guid: u64) -> Result<()> { Ok(()) } From 23ac15fb9d9a5e11b545f2692ea29003543c5321 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 18:39:03 +0200 Subject: [PATCH 20/33] refactor(gateway): session, character, social and party Stores act as an Actor Each family's Coordinator methods now carry the reducer call inside the trait method, the unused account ids are gone, and a Character acts as `Actor`. Handlers resolve the session's Actor once; outside the world the contact ops and the addon bridge drop silently, and a Character delete for guid 0 answers Failed. Decoders classify with `classify`: a Refusal answers the client as before, a Transport Loss ends the World Session. Character create and delete and the addon bridge now end the session on a Transport Loss instead of answering Failed or dropping it. Co-Authored-By: Claude Opus 5.5 (1M context) --- gateway/src/stdb/account_sessions.rs | 22 +- gateway/src/stdb/store/character.rs | 349 +++---- gateway/src/stdb/store/party.rs | 861 +++++++----------- gateway/src/stdb/store/session.rs | 279 +++--- gateway/src/stdb/store/social.rs | 236 ++--- ...scriptions_character_gone_durable_tests.rs | 39 +- ...bscriptions_party_command_durable_tests.rs | 4 +- ...ubscriptions_roster_relay_durable_tests.rs | 14 +- gateway/src/world/framing_tests.rs | 4 +- gateway/src/world/handlers/char.rs | 83 +- gateway/src/world/loot_tests.rs | 14 +- gateway/src/world/member_stats_tests.rs | 18 +- gateway/src/world/mod.rs | 44 +- gateway/src/world/party.rs | 126 +-- gateway/src/world/party_tests.rs | 653 ++++++++++--- gateway/src/world/session_tests.rs | 58 +- gateway/src/world/social.rs | 163 ++-- gateway/src/world/social_tests.rs | 10 +- gateway/src/world/store.rs | 20 +- .../test_support/world_fake/character.rs | 4 +- .../world/test_support/world_fake/party.rs | 44 +- .../world/test_support/world_fake/session.rs | 49 +- .../world/test_support/world_fake/social.rs | 32 +- gateway/src/world/trainer_tests.rs | 2 +- gateway/src/world/transfer_tests.rs | 3 +- 25 files changed, 1611 insertions(+), 1520 deletions(-) diff --git a/gateway/src/stdb/account_sessions.rs b/gateway/src/stdb/account_sessions.rs index eea251d6b..44881377a 100644 --- a/gateway/src/stdb/account_sessions.rs +++ b/gateway/src/stdb/account_sessions.rs @@ -689,10 +689,16 @@ mod tests { a.establish_session(account_id, &[7; 40], identity).unwrap(); let first = a.claim_session(account_id, 1).unwrap(); let old = a.bind_session(first).unwrap(); - old.player_login(account_id, 1, crate::codec::WorldEntry::FreshLogin) - .unwrap(); + let character = Actor::new(1).unwrap(); + crate::world::SessionStore::player_login( + &old, + account_id, + character, + crate::codec::WorldEntry::FreshLogin, + ) + .unwrap(); assert!(matches!( - b.delete_character(account_id, 1).unwrap(), + crate::world::CharacterStore::delete_character(&b, account_id, character).unwrap(), crate::codec::CharDeleteOutcome::Failed )); assert_eq!( @@ -732,9 +738,13 @@ mod tests { let second = b.claim_session(account_id, 1).unwrap(); assert!(second.generation > first.generation); let winner = b.bind_session(second).unwrap(); - winner - .player_login(account_id, 1, crate::codec::WorldEntry::FreshLogin) - .unwrap(); + crate::world::SessionStore::player_login( + &winner, + account_id, + character, + crate::codec::WorldEntry::FreshLogin, + ) + .unwrap(); old.release_session(first).unwrap(); old.release_session(first).unwrap(); let batch = super::super::movement_batch::MovementBatch::new(); diff --git a/gateway/src/stdb/store/character.rs b/gateway/src/stdb/store/character.rs index b118e0496..8df54f406 100644 --- a/gateway/src/stdb/store/character.rs +++ b/gateway/src/stdb/store/character.rs @@ -8,7 +8,8 @@ use crate::stdb::bindings::*; use crate::stdb::connection::{call_reducer, CharacterPresenceSnapshot, Coordinator}; use crate::stdb::reads::spell_ranks_stack_in_book; use crate::stdb::views::character_view; -use crate::world::CharacterStore; +use crate::stdb::{classify, DurableFailure}; +use crate::world::{Actor, CharacterStore}; fn stable_character_absence( first: &[CharacterPresenceSnapshot], @@ -89,6 +90,9 @@ impl CharacterStore for Coordinator { /// end-to-end rather than merely reusing already-tested machinery by assumption: /// `a_freshly_created_characters_first_login_transfers_off_the_default_shard` /// (`world/shard_routing_tests.rs`). + /// + /// The `create_character` reducer runs on the owner connection. A Refusal maps to a game + /// outcome; a Transport Loss is `Err`. fn create_character( &self, account_id: u64, @@ -98,7 +102,25 @@ impl CharacterStore for Coordinator { gender: u8, appearance: codec::Appearance, ) -> Result { - self.create_character(account_id, name, race, class, gender, appearance) + // The SpacetimeDB-generated reducer binding takes the five appearance bytes positionally; + // unbundle `Appearance` here, at the single generated-boundary call. + let result = call_reducer!( + self.0.call_pipe().conn.reducers, + "create_character", + create_character_then( + account_id, + name.to_string(), + race, + class, + gender, + appearance.skin, + appearance.face, + appearance.hair_style, + appearance.hair_color, + appearance.facial_hair + ) + ); + create_outcome(result, self.shard_name()) } /// Delete a character wherever it actually LIVES, not only on `self`. @@ -129,11 +151,11 @@ impl CharacterStore for Coordinator { fn delete_character( &self, account_id: u64, - character_guid: u64, + character: Actor, ) -> Result { - match crate::realm_core::resolve_delete_shard(self, character_guid) { - Ok(Some(owner)) => owner.delete_character(account_id, character_guid), - Ok(None) => self.delete_character(account_id, character_guid), + match crate::realm_core::resolve_delete_shard(self, character.guid()) { + Ok(Some(owner)) => owner.delete_here(account_id, character), + Ok(None) => self.delete_here(account_id, character), Err(e) => { log::warn!("world: {e:#}"); Ok(codec::CharDeleteOutcome::Failed) @@ -175,94 +197,10 @@ impl CharacterStore for Coordinator { self.effective_magic_resistances(guid) } - fn spell_modifiers(&self, character_guid: u64) -> Vec<(u32, u8, i32, bool)> { - self.spell_modifiers(character_guid) - } - - fn player_learned_spells(&self, player_guid: u64) -> Result> { - self.player_learned_spells(player_guid) - } - - fn player_reputations(&self, player_guid: u64) -> Result> { - self.player_reputations(player_guid) - } - - fn player_actions(&self, player_guid: u64) -> Result> { - self.player_actions(player_guid) - } -} - -impl Coordinator { - /// Read an account's characters for the character-select screen. In production - /// this reads the per-player `game_character` subscription cache (RLS-restricted to owner). - /// Equipment slots (0..=18) are populated from `game_item_instance` + `game_item_template` - /// so the client renders the character's gear on the select screen instead of all-naked. - pub fn characters(&self, account_id: u64) -> Result> { - use wow_world_messages::vanilla::{CharacterGear, InventoryType}; - let guard = self.0.coord(); - let db = &guard.conn.db; - let mut views: Vec = db - .game_character() - .iter() - .filter(|c| c.account_id == account_id) - .map(character_view) - .collect(); - // Fill equipment slots 0..=18 from item instances. Slots ≥ 19 are backpack/bag slots; - // skip them. An unknown inventory_type degrades to InventoryType::default (Non) which - // the client treats the same as display_id=0 — no model shown, no crash. - for view in &mut views { - for item in db - .game_item_instance() - .iter() - .filter(|i| i.owner_guid == view.guid && i.slot <= 18) - { - let Some(tmpl) = db.game_item_template().entry().find(&item.entry) else { - continue; - }; - let inv_type = - InventoryType::try_from(u32::from(tmpl.inventory_type)).unwrap_or_default(); - view.equipment[item.slot as usize] = CharacterGear { - equipment_display_id: tmpl.display_id, - inventory_type: inv_type, - }; - } - } - Ok(views) - } - - /// Read a single character by guid (any owner) for a `CMSG_NAME_QUERY` reply. The queried guid - /// is usually a *peer*, so this reads across owners via the privileged cache (no RLS on the - /// owner connection), unlike `characters` which filters to one account. - pub fn character_by_guid(&self, guid: u64) -> Result> { - Ok(self - .0 - .coord() - .conn - .db - .game_character() - .guid() - .find(&guid) - .map(character_view)) - } - - /// folded from the coordinator's privileged cache: base (`game_world_entity.armor`) + worn gear armor. - /// The coordinator isn't subscribed to `game_aura`, so the aura term is 0 here — login-present armor - /// auras are pushed by the on_aura relay the instant they insert. Delegates to the shared - /// `stdb::armor::effective_armor` so CREATE and the relays compute the IDENTICAL fold. - pub fn effective_armor(&self, guid: u64) -> u32 { - let guard = self.0.coord(); - super::super::armor::effective_armor(&guard.conn.db, guid) - } - - pub fn effective_magic_resistances(&self, guid: u64) -> [u32; 6] { - let guard = self.0.coord(); - super::super::armor::effective_magic_resistances(&guard.conn.db, guid) - } - /// The character's active SPELL-MODIFIER auras as raw `(family_mask, op, amount, is_pct)` /// rows — the client-mirror source for SMSG_SET_FLAT/PCT_SPELL_MODIFIER (aggregation by /// (op, mask-bit) happens in the codec helper; mangos sends the TOTAL per bit). - pub fn spell_modifiers(&self, character_guid: u64) -> Vec<(u32, u8, i32, bool)> { + fn spell_modifiers(&self, character_guid: u64) -> Vec<(u32, u8, i32, bool)> { const A_SPELLMOD_FLAT: u8 = 0xAC; // lockstep with module taxonomy const A_SPELLMOD_PCT: u8 = 0xAD; self.0 @@ -289,7 +227,7 @@ impl Coordinator { /// The player's LEARNED spells — `game_player_spell` rows for this character (the coordinator /// bypasses RLS so it reads any player's). Chained into the login spellbook so a taught ability /// (Auto Shot) reaches the client and `CastSpellByName` can fire it. - pub fn player_learned_spells(&self, player_guid: u64) -> Result> { + fn player_learned_spells(&self, player_guid: u64) -> Result> { let guard = self.0.coord(); let db = &guard.conn.db; let known: Vec = db @@ -326,7 +264,7 @@ impl Coordinator { /// no dump has been imported, the common case today). Chained into the login codec /// (`login_sequence_messages`), which builds the bar from these when non-empty and falls back to /// the spellbook synth otherwise. RLS-bypassed read, like `player_learned_spells`. - pub fn player_actions(&self, player_guid: u64) -> Result> { + fn player_actions(&self, player_guid: u64) -> Result> { let guard = self.0.coord(); let db = &guard.conn.db; Ok(db @@ -342,7 +280,7 @@ impl Coordinator { /// real standing + the At-War checkbox instead of the all-neutral stub. Rows with /// `reputation_index < 0` (stale pre-migration filler) are skipped — there is no slot to /// address. RLS-bypassed read, like `player_learned_spells`. - pub fn player_reputations(&self, player_guid: u64) -> Result> { + fn player_reputations(&self, player_guid: u64) -> Result> { let guard = self.0.coord(); let db = &guard.conn.db; Ok(db @@ -352,6 +290,74 @@ impl Coordinator { .map(|r| (r.reputation_index, r.standing, r.at_war)) .collect()) } +} + +impl Coordinator { + /// Read an account's characters for the character-select screen. In production + /// this reads the per-player `game_character` subscription cache (RLS-restricted to owner). + /// Equipment slots (0..=18) are populated from `game_item_instance` + `game_item_template` + /// so the client renders the character's gear on the select screen instead of all-naked. + pub fn characters(&self, account_id: u64) -> Result> { + use wow_world_messages::vanilla::{CharacterGear, InventoryType}; + let guard = self.0.coord(); + let db = &guard.conn.db; + let mut views: Vec = db + .game_character() + .iter() + .filter(|c| c.account_id == account_id) + .map(character_view) + .collect(); + // Fill equipment slots 0..=18 from item instances. Slots ≥ 19 are backpack/bag slots; + // skip them. An unknown inventory_type degrades to InventoryType::default (Non) which + // the client treats the same as display_id=0 — no model shown, no crash. + for view in &mut views { + for item in db + .game_item_instance() + .iter() + .filter(|i| i.owner_guid == view.guid && i.slot <= 18) + { + let Some(tmpl) = db.game_item_template().entry().find(&item.entry) else { + continue; + }; + let inv_type = + InventoryType::try_from(u32::from(tmpl.inventory_type)).unwrap_or_default(); + view.equipment[item.slot as usize] = CharacterGear { + equipment_display_id: tmpl.display_id, + inventory_type: inv_type, + }; + } + } + Ok(views) + } + + /// Read a single character by guid (any owner) for a `CMSG_NAME_QUERY` reply. The queried guid + /// is usually a *peer*, so this reads across owners via the privileged cache (no RLS on the + /// owner connection), unlike `characters` which filters to one account. + pub fn character_by_guid(&self, guid: u64) -> Result> { + Ok(self + .0 + .coord() + .conn + .db + .game_character() + .guid() + .find(&guid) + .map(character_view)) + } + + /// folded from the coordinator's privileged cache: base (`game_world_entity.armor`) + worn gear armor. + /// The coordinator isn't subscribed to `game_aura`, so the aura term is 0 here — login-present armor + /// auras are pushed by the on_aura relay the instant they insert. Delegates to the shared + /// `stdb::armor::effective_armor` so CREATE and the relays compute the IDENTICAL fold. + pub fn effective_armor(&self, guid: u64) -> u32 { + let guard = self.0.coord(); + super::super::armor::effective_armor(&guard.conn.db, guid) + } + + pub fn effective_magic_resistances(&self, guid: u64) -> [u32; 6] { + let guard = self.0.coord(); + super::super::armor::effective_magic_resistances(&guard.conn.db, guid) + } /// Does `character_guid` sell, or lead the bidding on, an Auction on THIS handle? fn has_auction(&self, character_guid: u64) -> bool { @@ -368,64 +374,17 @@ impl Coordinator { }) } - /// Create a character via the `create_character` reducer (owner connection), mapping the - /// reducer result to a game outcome. A distinguished `NAME_IN_USE` error → `NameInUse`; any - /// other reducer/transport error → `Failed` (never propagated as a hard error, so a bad - /// creation can't drop the world session). - pub fn create_character( - &self, - account_id: u64, - name: &str, - race: u8, - class: u8, - gender: u8, - appearance: crate::codec::Appearance, - ) -> Result { - use crate::codec::CharCreateOutcome; - // The SpacetimeDB-generated reducer binding takes the five appearance bytes positionally; - // unbundle `Appearance` here, at the single generated-boundary call. - let result = call_reducer!( - self.0.call_pipe().conn.reducers, - "create_character", - create_character_then( - account_id, - name.to_string(), - race, - class, - gender, - appearance.skin, - appearance.face, - appearance.hair_style, - appearance.hair_color, - appearance.facial_hair - ) - ); - Ok(match result { - Ok(()) => CharCreateOutcome::Success, - Err(e) if e.to_string().contains("NAME_IN_USE") => CharCreateOutcome::NameInUse, - Err(e) if e.to_string().contains("SERVER_LIMIT") => CharCreateOutcome::ServerLimit, - // The 5875 client has no code for "this database may not mint guids", so the outcome is - // the generic failure — but the REASON must not be swallowed: the whole point of - // guid-range licensing is that an unlicensed shard fails loudly instead of minting - // into someone else's range. - Err(e) => { - log::warn!("create_character on {} failed: {e:#}", self.shard_name()); - CharCreateOutcome::Failed - } - }) - } - - /// Delete a character via the `delete_character` reducer (owner connection — the reducer is - /// operator-gated, mirroring `create_character`). Ownership is enforced module-side (`NOT_OWNER` - /// if `character_guid` isn't `account_id`'s), so a malicious/buggy client can't delete another - /// account's character. Maps to a game outcome the same way `create_character` does: never - /// propagated as a hard error, so a bad delete can't drop the world session. - pub fn delete_character( + /// Delete `character` on THIS handle via the `delete_character` reducer (owner connection — the + /// reducer is operator-gated, mirroring `create_character`). Ownership is enforced module-side + /// (`NOT_OWNER` if `character` isn't `account_id`'s), so a malicious/buggy client can't delete + /// another account's character. A Refusal maps to `Failed`; a Transport Loss is `Err`. + fn delete_here( &self, account_id: u64, - character_guid: u64, + character: Actor, ) -> Result { use crate::codec::CharDeleteOutcome; + let character_guid = character.guid(); match self.character_has_auction_value(character_guid) { Ok(true) => return Ok(CharDeleteOutcome::Failed), Ok(false) => {} @@ -436,21 +395,14 @@ impl Coordinator { return Ok(CharDeleteOutcome::Failed); } } - let result = call_reducer!( - self.0.call_pipe().conn.reducers, - "delete_character", - delete_character_then(account_id, self.actor_or_owner(character_guid)) - ); - Ok(match result { - Ok(()) => CharDeleteOutcome::Success, - // The 1.12 client has one reason for every refusal, so the reason goes to the log: - // CHAR_HAS_GUILD_FEE_HOLD clears when the Character next enters the world and the - // Gateway finishes its Fee Hold. - Err(error) => { - log::info!("delete_character: {character_guid} not deleted: {error:#}"); - CharDeleteOutcome::Failed - } - }) + delete_outcome( + call_reducer!( + self.0.call_pipe().conn.reducers, + "delete_character", + delete_character_then(account_id, self.session_actor(character)) + ), + character_guid, + ) } fn character_has_auction_value(&self, character_guid: u64) -> Result { @@ -468,6 +420,77 @@ impl Coordinator { } } +/// A create Refusal is an outcome the client renders; a Transport Loss stays `Err`. +fn create_outcome(result: Result<()>, shard_name: &str) -> Result { + use codec::CharCreateOutcome; + let Err(error) = result else { + return Ok(CharCreateOutcome::Success); + }; + match classify(&error) { + DurableFailure::TransportLoss => Err(error), + DurableFailure::Refusal { reason } if reason.contains("NAME_IN_USE") => { + Ok(CharCreateOutcome::NameInUse) + } + DurableFailure::Refusal { reason } if reason.contains("SERVER_LIMIT") => { + Ok(CharCreateOutcome::ServerLimit) + } + // The 5875 client has no code for "this database may not mint guids", so the outcome is + // the generic failure — but the REASON must not be swallowed: the whole point of + // guid-range licensing is that an unlicensed shard fails loudly instead of minting + // into someone else's range. + DurableFailure::Refusal { .. } => { + log::warn!("create_character on {shard_name} failed: {error:#}"); + Ok(CharCreateOutcome::Failed) + } + } +} + +/// A delete Refusal answers `Failed`; a Transport Loss stays `Err`. +fn delete_outcome(result: Result<()>, character_guid: u64) -> Result { + let Err(error) = result else { + return Ok(codec::CharDeleteOutcome::Success); + }; + match classify(&error) { + // The 1.12 client has one reason for every refusal, so the reason goes to the log: + // CHAR_HAS_GUILD_FEE_HOLD clears when the Character next enters the world and the + // Gateway finishes its Fee Hold. + DurableFailure::Refusal { .. } => { + log::info!("delete_character: {character_guid} not deleted: {error:#}"); + Ok(codec::CharDeleteOutcome::Failed) + } + DurableFailure::TransportLoss => Err(error), + } +} + +#[cfg(test)] +mod outcome_tests { + use super::{create_outcome, delete_outcome}; + use crate::codec::{CharCreateOutcome, CharDeleteOutcome}; + use crate::stdb::ReducerCallError; + + #[test] + fn a_create_refusal_is_an_outcome_and_a_transport_loss_is_an_error() { + let refused = ReducerCallError::refused("create_character", "NAME_IN_USE"); + assert!(matches!( + create_outcome(Err(refused.into()), "world"), + Ok(CharCreateOutcome::NameInUse) + )); + let lost = ReducerCallError::transport_lost("create_character"); + assert!(create_outcome(Err(lost.into()), "world").is_err()); + } + + #[test] + fn a_delete_refusal_fails_and_a_transport_loss_is_an_error() { + let refused = ReducerCallError::refused("delete_character", "NOT_OWNER"); + assert!(matches!( + delete_outcome(Err(refused.into()), 7), + Ok(CharDeleteOutcome::Failed) + )); + let lost = ReducerCallError::transport_lost("delete_character"); + assert!(delete_outcome(Err(lost.into()), 7).is_err()); + } +} + #[cfg(test)] mod stable_absence_tests { #[test] diff --git a/gateway/src/stdb/store/party.rs b/gateway/src/stdb/store/party.rs index 7a119ef58..829996293 100644 --- a/gateway/src/stdb/store/party.rs +++ b/gateway/src/stdb/store/party.rs @@ -6,402 +6,23 @@ use spacetimedb_sdk::{Identity, Table}; use crate::stdb::bindings::game_world_entity_table::GameWorldEntityTableAccess; use crate::stdb::bindings::*; -use crate::stdb::connection::{call_reducer, reducer_refusal_reason}; +use crate::stdb::connection::call_reducer; use crate::stdb::Coordinator; +use crate::stdb::{classify, DurableFailure}; use crate::world::party::{AdmittedCompanionCommand, CompanionCommandOutcome, PartyOutcome}; use crate::world::{Actor, PartyStore, ShardRoutingStore}; impl PartyStore for Coordinator { - // The SINGLE-DATABASE party path: unchanged reducer calls on the player's own connection. - // `self_guid` is unused here on purpose — the module resolves the actor from `ctx.sender()`'s - // live entity, which is the whole reason this arm needs no operator gate. `world::party` is what - // chooses between this and the realm-core arm below. - fn group_invite( - &self, - account_id: u64, - self_guid: u64, - target_guid: u64, - ) -> Result { - self.group_invite(account_id, self_guid, target_guid) - } - - fn group_accept( - &self, - account_id: u64, - self_guid: u64, - ) -> Result { - self.group_accept(account_id, self_guid) - } - - fn group_decline( - &self, - account_id: u64, - self_guid: u64, - ) -> Result { - self.group_decline(account_id, self_guid) - } - - fn group_leave( - &self, - account_id: u64, - self_guid: u64, - ) -> Result { - self.group_leave(account_id, self_guid) - } - - fn group_uninvite( - &self, - account_id: u64, - self_guid: u64, - target_guid: u64, - ) -> Result { - self.group_uninvite(account_id, self_guid, target_guid) - } - - fn group_loot_method( - &self, - account_id: u64, - self_guid: u64, - loot_setting: u8, - master_guid: u64, - loot_threshold: u8, - ) -> Result { - self.group_loot_method( - account_id, - self_guid, - loot_setting, - master_guid, - loot_threshold, - ) - } - - fn claim_bot_invite_intent(&self, intent_id: u64) -> Result { - self.claim_bot_invite_intent(intent_id) + /// Claim and admit a Group Intent in one World Shard transaction. + fn claim_bot_invite_intent(&self, intent_id: u64) -> Result { + party_outcome(call_reducer!( + self.0.call_pipe().conn.reducers, + "claim_bot_invite_intent", + claim_bot_invite_intent_then(intent_id) + )) } fn claim_party_command_intent(&self, intent_id: u64, claim_token: u64) -> Result<()> { - Coordinator::claim_party_command_intent(self, intent_id, claim_token) - } - - fn admit_party_command_authority( - &self, - group_id: u64, - leader_guid: u64, - bot_guid: u64, - authority_member_guid: u64, - expected_members: Vec, - ) -> Result { - Coordinator::admit_party_command_authority( - self, - group_id, - leader_guid, - bot_guid, - authority_member_guid, - expected_members, - ) - } - - fn apply_admitted_party_command( - &self, - command: &crate::world::party::AdmittedCompanionCommand, - ) -> Result { - Coordinator::apply_admitted_party_command(self, command) - } - - fn finish_party_command_intent( - &self, - intent_id: u64, - claim_token: u64, - outcome: crate::world::party::CompanionCommandOutcome, - ) -> Result<()> { - Coordinator::finish_party_command_intent(self, intent_id, claim_token, outcome) - } - - fn confirm_party_command_receipt( - &self, - source_identity: spacetimedb_sdk::Identity, - intent_id: u64, - ) -> Result> { - Coordinator::confirm_party_command_receipt(self, source_identity, intent_id) - } - - fn confirm_party_command_holder( - &self, - bot_guid: u64, - ) -> Result { - Coordinator::confirm_party_command_holder(self, bot_guid) - } - - fn entity_partition(&self, guid: u64) -> Option<(u32, u64)> { - self.0 - .coord() - .conn - .db - .game_world_entity() - .guid() - .find(&guid) - .map(|entity| (entity.map_id, entity.instance_id)) - } - - fn admit_sessionless_group_action( - &self, - character_guid: u64, - ) -> Result { - self.admit_sessionless_group_action(character_guid) - } - - fn realm_group_op( - &self, - op: u8, - actor_guid: u64, - target_guid: u64, - arg_a: u8, - arg_b: u8, - arg_c: u64, - ) -> Result { - self.realm_group_op(op, actor_guid, target_guid, arg_a, arg_b, arg_c) - } - - fn realm_group_op_visible( - &self, - op: u8, - actor_guid: u64, - target_guid: u64, - arg_a: u8, - arg_b: u8, - arg_c: u64, - ) -> Result { - self.realm_group_op_visible(op, actor_guid, target_guid, arg_a, arg_b, arg_c) - } - - fn deleted_character_party_leave( - &self, - character_guid: u64, - ) -> Result { - self.deleted_character_party_leave(character_guid) - } - - fn group_roster( - &self, - character_guid: u64, - ) -> Result> { - Ok(self.group_roster(character_guid)) - } - - fn party_command_group_roster( - &self, - character_guid: u64, - ) -> Result> { - self.party_command_group_roster(character_guid) - } - - fn group_roster_by_id( - &self, - group_id: u64, - ) -> Result> { - Ok(self.group_roster_by_id(group_id)) - } - - fn group_roster_revision(&self, group_id: u64) -> Result { - Ok(self.group_roster_revision(group_id)) - } - - fn held_roster_revision(&self, group_id: u64) -> Result> { - Ok(self.held_roster_revision(group_id)) - } - - fn realm_character_partition( - &self, - character_guid: u64, - ) -> Result> { - self.realm_character_partition(character_guid) - } - - fn party_holder_observation( - &self, - character_guid: u64, - map_id: u32, - instance_id: u64, - ) -> Result { - let serves_locator = self.shard_for_location(map_id, instance_id).is_none(); - self.stable_party_holder_observation(character_guid, serves_locator) - } - - fn party_cleanup_group_roster_by_id( - &self, - group_id: u64, - ) -> Result> { - let healthy = { - let live = self.0.coord(); - live.is_healthy() - }; - if !healthy { - anyhow::bail!( - "{} has no healthy Coordinator subscription for party cleanup", - self.shard_name() - ); - } - Ok(self.group_roster_by_id(group_id)) - } - - fn party_member_guids(&self) -> Result> { - if !self.0.coord().is_healthy() { - anyhow::bail!( - "{} has no healthy Coordinator subscription for party cleanup", - self.shard_name() - ); - } - Ok(self.party_member_guids()) - } - - fn party_group_ids(&self) -> Result> { - if !self.0.coord().is_healthy() { - anyhow::bail!( - "{} has no healthy Coordinator subscription for party cleanup", - self.shard_name() - ); - } - Ok(self.party_group_ids()) - } - - fn sync_group_mirror(&self, roster: &crate::world::party::GroupRoster) -> Result<()> { - self.sync_group_mirror(roster) - } -} - -impl Coordinator { - pub(crate) fn stable_party_holder_observation( - &self, - character_guid: u64, - serves_locator: bool, - ) -> Result { - let guard = self.0.coord(); - if !guard.is_healthy() { - anyhow::bail!( - "{} has no healthy Coordinator subscription for party partition certification", - self.shard_name() - ); - } - let has_escrow = guard - .conn - .db - .game_transfer_out() - .transfer_id() - .find(&crate::world::transfer::transfer_id_for(character_guid)) - .is_some_and(|row| row.character_guid == character_guid); - let character_partition = guard - .conn - .db - .game_character() - .guid() - .find(&character_guid) - .map(|character| (character.map_id, character.pending_instance_id)); - Ok(crate::world::party::PartyHolderObservation { - serves_locator, - has_escrow, - character_partition, - }) - } - - /// Every party in this Realm-core or World Shard cache. - pub fn party_group_ids(&self) -> Vec { - self.0 - .coord() - .conn - .db - .game_group() - .iter() - .map(|group| group.group_id) - .collect() - } - - /// Every Character with party membership in this Realm-core cache. - pub fn party_member_guids(&self) -> Vec { - self.0 - .coord() - .conn - .db - .game_group_member() - .iter() - .map(|member| member.character_guid) - .collect() - } - - /// Read only the bounded roster projection accepted by companion-command authority. The bound - /// is the Raid cap: a longer list is a damaged cache. - pub fn party_command_group_roster( - &self, - character_guid: u64, - ) -> anyhow::Result> { - let guard = self.0.coord(); - let db = &guard.conn.db; - let Some((group_id, rows)) = guard - .party_memberships - .read() - .unwrap() - .bounded_member_rows(character_guid, lyracore_shared::group::RAID_MAX_MEMBERS)? - else { - return Ok(None); - }; - let Some(group) = db.game_group().group_id().find(&group_id) else { - return Ok(None); - }; - let members = rows - .into_iter() - .map(|(row_id, guid)| crate::world::party::GroupRosterMember { - guid, - slot: db - .game_group_member() - .id() - .find(&row_id) - .map_or_else(Default::default, |row| { - RaidSlot::from_wire_or_default(row.raid_slot) - }), - }) - .collect(); - Ok(Some(crate::world::party::GroupRoster { - group_id, - roster_revision: db - .game_group_roster_revision() - .group_id() - .find(&group_id) - .map_or(1, |row| row.revision), - leader_guid: group.leader_guid, - loot_method: group.loot_method, - loot_threshold: group.loot_threshold, - master_looter_guid: group.master_looter_guid, - kind: GroupKind::from_wire_or_default(group.group_type), - members, - partitions: Vec::new(), - })) - } - - /// Realm-core's roster order survives disband in `game_group_roster_revision`. - pub fn group_roster_revision(&self, group_id: u64) -> u64 { - self.0 - .coord() - .conn - .db - .game_group_roster_revision() - .group_id() - .find(&group_id) - .map_or(1, |row| row.revision) - } - - /// The Roster Revision this database holds for one party, or `None` when it holds no row. On a - /// World Shard this is the mirror's revision, which outlives a disband. - pub fn held_roster_revision(&self, group_id: u64) -> Option { - self.0 - .coord() - .conn - .db - .game_group_roster_revision() - .group_id() - .find(&group_id) - .map(|row| row.revision) - } - - pub fn claim_party_command_intent(&self, intent_id: u64, claim_token: u64) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, "claim_party_command_intent", @@ -409,7 +30,7 @@ impl Coordinator { ) } - pub fn admit_party_command_authority( + fn admit_party_command_authority( &self, group_id: u64, leader_guid: u64, @@ -433,7 +54,7 @@ impl Coordinator { } } - pub fn apply_admitted_party_command( + fn apply_admitted_party_command( &self, command: &AdmittedCompanionCommand, ) -> Result { @@ -463,7 +84,7 @@ impl Coordinator { .ok_or_else(|| anyhow!("party command apply committed without a receipt")) } - pub fn finish_party_command_intent( + fn finish_party_command_intent( &self, intent_id: u64, claim_token: u64, @@ -480,7 +101,7 @@ impl Coordinator { ) } - pub fn confirm_party_command_receipt( + fn confirm_party_command_receipt( &self, source_identity: Identity, intent_id: u64, @@ -495,7 +116,7 @@ impl Coordinator { } } - pub fn confirm_party_command_holder( + fn confirm_party_command_holder( &self, bot_guid: u64, ) -> Result { @@ -505,7 +126,7 @@ impl Coordinator { confirm_party_command_holder_then(bot_guid) ) { Ok(()) => Ok(crate::world::party::PartyCommandHolder::Present), - Err(error) => match reducer_refusal_reason(&error) { + Err(error) => match refusal_tag(&error) { Some("MissingBot") => Ok(crate::world::party::PartyCommandHolder::Missing), Some("TransferInProgress") => { Ok(crate::world::party::PartyCommandHolder::InTransit) @@ -515,120 +136,23 @@ impl Coordinator { } } - /// Claim and admit a Group Intent in one World Shard transaction. - pub fn claim_bot_invite_intent(&self, intent_id: u64) -> Result { - party_outcome(call_reducer!( - self.0.call_pipe().conn.reducers, - "claim_bot_invite_intent", - claim_bot_invite_intent_then(intent_id) - )) - } - - /// Wait for the owning World Shard's current consent Gate, without subscription readback. - pub fn admit_sessionless_group_action(&self, character_guid: u64) -> Result { - party_outcome(call_reducer!( - self.0.call_pipe().conn.reducers, - "admit_sessionless_group_action", - admit_sessionless_group_action_then(character_guid) - )) - } - - /// `CMSG_GROUP_INVITE` — `target_guid` is already resolved by the gateway. - pub fn group_invite( - &self, - _account_id: u64, - actor_guid: u64, - target_guid: u64, - ) -> Result { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("group_invite: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - party_outcome(call_reducer!( - coord.conn.reducers, - "gw_group_invite", - gw_group_invite_then(self.session_actor(actor), target_guid) - )) - } - - /// `CMSG_GROUP_ACCEPT`. Rides the coordinator connection as - /// `gw_accept_group_invite`. - pub fn group_accept(&self, _account_id: u64, actor_guid: u64) -> Result { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("group_accept: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - party_outcome(call_reducer!( - coord.conn.reducers, - "gw_accept_group_invite", - gw_accept_group_invite_then(self.session_actor(actor)) - )) - } - - /// `CMSG_GROUP_DECLINE`. - pub fn group_decline(&self, _account_id: u64, actor_guid: u64) -> Result { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("group_decline: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - party_outcome(call_reducer!( - coord.conn.reducers, - "gw_group_decline", - gw_group_decline_then(self.session_actor(actor)) - )) - } - - /// `CMSG_GROUP_DISBAND` — leave the caller's group. - pub fn group_leave(&self, _account_id: u64, actor_guid: u64) -> Result { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("group_leave: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - party_outcome(call_reducer!( - coord.conn.reducers, - "gw_group_leave", - gw_group_leave_then(self.session_actor(actor)) - )) - } - - /// `CMSG_GROUP_UNINVITE` or `CMSG_GROUP_UNINVITE_GUID` — the leader or an Assistant kicks - /// `target_guid`. - pub fn group_uninvite( - &self, - _account_id: u64, - actor_guid: u64, - target_guid: u64, - ) -> Result { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("group_uninvite: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - party_outcome(call_reducer!( - coord.conn.reducers, - "gw_group_uninvite", - gw_group_uninvite_then(self.session_actor(actor), target_guid) - )) - } - - /// `CMSG_LOOT_METHOD` — the leader sets the party's loot method/ - /// threshold/master. Echoed to every member via the existing `SMSG_GROUP_LIST` relay (the - /// module's `group_loot_method` reducer re-renders the roster payload); no separate ack packet - /// (vanilla sends none for this opcode either). - pub fn group_loot_method( - &self, - _account_id: u64, - actor_guid: u64, - loot_setting: u8, - master_guid: u64, - loot_threshold: u8, - ) -> Result { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("group_loot_method: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - party_outcome(call_reducer!( - coord.conn.reducers, - "gw_group_loot_method", - gw_group_loot_method_then( - self.session_actor(actor), - loot_setting, - master_guid, - loot_threshold - ) + fn entity_partition(&self, guid: u64) -> Option<(u32, u64)> { + self.0 + .coord() + .conn + .db + .game_world_entity() + .guid() + .find(&guid) + .map(|entity| (entity.map_id, entity.instance_id)) + } + + /// Wait for the owning World Shard's current consent Gate, without subscription readback. + fn admit_sessionless_group_action(&self, character_guid: u64) -> Result { + party_outcome(call_reducer!( + self.0.call_pipe().conn.reducers, + "admit_sessionless_group_action", + admit_sessionless_group_action_then(character_guid) )) } @@ -644,10 +168,10 @@ impl Coordinator { /// still hold the old roster when it returns. Only an op that pushes no Group mirror after it /// uses it: a Group Broadcast or a Target Icon request. Every roster op uses /// [`Self::realm_group_op_visible`]. - pub fn realm_group_op( + fn realm_group_op( &self, op: u8, - actor_guid: u64, + actor: Actor, target_guid: u64, arg_a: u8, arg_b: u8, @@ -658,7 +182,7 @@ impl Coordinator { "realm_group_op", realm_group_op_then( op, - self.actor_or_owner(actor_guid), + self.session_actor(actor), target_guid, arg_a, arg_b, @@ -670,10 +194,10 @@ impl Coordinator { /// [`Self::realm_group_op`] on the visibility pipe: it returns only after the Coordinator cache /// holds the committed roster, so the mirror push that follows reads the op's own result. The /// caller must not run on the Coordinator pump: World Sessions and the bot intent threads. - pub fn realm_group_op_visible( + fn realm_group_op_visible( &self, op: u8, - actor_guid: u64, + actor: Actor, target_guid: u64, arg_a: u8, arg_b: u8, @@ -685,7 +209,7 @@ impl Coordinator { "realm_group_op", realm_group_op_then( op, - self.actor_or_owner(actor_guid), + self.session_actor(actor), target_guid, arg_a, arg_b, @@ -696,14 +220,14 @@ impl Coordinator { /// Remove a deleted Character from a party and return only after the Coordinator cache holds /// the committed Realm-core roster. The caller always runs off the Coordinator pump. - pub fn deleted_character_party_leave(&self, character_guid: u64) -> Result { + fn deleted_character_party_leave(&self, character: Actor) -> Result { let coordinator = self.0.visibility_pipe(); party_outcome(call_reducer!( coordinator.conn.reducers, "realm_group_op", realm_group_op_then( lyracore_shared::group::realm_op::LEAVE, - self.actor_or_owner(character_guid), + self.session_actor(character), 0, lyracore_shared::group::leave_cause::CHARACTER_DELETED, 0, @@ -712,10 +236,185 @@ impl Coordinator { )) } + fn group_roster( + &self, + character_guid: u64, + ) -> Result> { + Ok(self.group_roster(character_guid)) + } + + /// Read only the bounded roster projection accepted by companion-command authority. The bound + /// is the Raid cap: a longer list is a damaged cache. + fn party_command_group_roster( + &self, + character_guid: u64, + ) -> anyhow::Result> { + let guard = self.0.coord(); + let db = &guard.conn.db; + let Some((group_id, rows)) = guard + .party_memberships + .read() + .unwrap() + .bounded_member_rows(character_guid, lyracore_shared::group::RAID_MAX_MEMBERS)? + else { + return Ok(None); + }; + let Some(group) = db.game_group().group_id().find(&group_id) else { + return Ok(None); + }; + let members = rows + .into_iter() + .map(|(row_id, guid)| crate::world::party::GroupRosterMember { + guid, + slot: db + .game_group_member() + .id() + .find(&row_id) + .map_or_else(Default::default, |row| { + RaidSlot::from_wire_or_default(row.raid_slot) + }), + }) + .collect(); + Ok(Some(crate::world::party::GroupRoster { + group_id, + roster_revision: db + .game_group_roster_revision() + .group_id() + .find(&group_id) + .map_or(1, |row| row.revision), + leader_guid: group.leader_guid, + loot_method: group.loot_method, + loot_threshold: group.loot_threshold, + master_looter_guid: group.master_looter_guid, + kind: GroupKind::from_wire_or_default(group.group_type), + members, + partitions: Vec::new(), + })) + } + + fn group_roster_by_id( + &self, + group_id: u64, + ) -> Result> { + Ok(self.group_roster_by_id(group_id)) + } + + /// Realm-core's roster order survives disband in `game_group_roster_revision`. + fn group_roster_revision(&self, group_id: u64) -> Result { + Ok(self + .0 + .coord() + .conn + .db + .game_group_roster_revision() + .group_id() + .find(&group_id) + .map_or(1, |row| row.revision)) + } + + fn held_roster_revision(&self, group_id: u64) -> Result> { + Ok(self.held_roster_revision(group_id)) + } + + fn realm_character_partition( + &self, + character_guid: u64, + ) -> Result> { + self.realm_character_partition(character_guid) + } + + fn party_holder_observation( + &self, + character_guid: u64, + map_id: u32, + instance_id: u64, + ) -> Result { + let serves_locator = self.shard_for_location(map_id, instance_id).is_none(); + let guard = self.0.coord(); + if !guard.is_healthy() { + anyhow::bail!( + "{} has no healthy Coordinator subscription for party partition certification", + self.shard_name() + ); + } + let has_escrow = guard + .conn + .db + .game_transfer_out() + .transfer_id() + .find(&crate::world::transfer::transfer_id_for(character_guid)) + .is_some_and(|row| row.character_guid == character_guid); + let character_partition = guard + .conn + .db + .game_character() + .guid() + .find(&character_guid) + .map(|character| (character.map_id, character.pending_instance_id)); + Ok(crate::world::party::PartyHolderObservation { + serves_locator, + has_escrow, + character_partition, + }) + } + + fn party_cleanup_group_roster_by_id( + &self, + group_id: u64, + ) -> Result> { + let healthy = { + let live = self.0.coord(); + live.is_healthy() + }; + if !healthy { + anyhow::bail!( + "{} has no healthy Coordinator subscription for party cleanup", + self.shard_name() + ); + } + Ok(self.group_roster_by_id(group_id)) + } + + fn party_member_guids(&self) -> Result> { + if !self.0.coord().is_healthy() { + anyhow::bail!( + "{} has no healthy Coordinator subscription for party cleanup", + self.shard_name() + ); + } + Ok(self + .0 + .coord() + .conn + .db + .game_group_member() + .iter() + .map(|member| member.character_guid) + .collect()) + } + + fn party_group_ids(&self) -> Result> { + if !self.0.coord().is_healthy() { + anyhow::bail!( + "{} has no healthy Coordinator subscription for party cleanup", + self.shard_name() + ); + } + Ok(self + .0 + .coord() + .conn + .db + .game_group() + .iter() + .map(|group| group.group_id) + .collect()) + } + /// `sync_group_mirror` — replace THIS shard's mirror of one party with realm-core's roster. /// Operator-gated, coordinator connection, same reasoning as above; called /// on each WORLD shard after a party op and at world entry. - pub fn sync_group_mirror(&self, roster: &crate::world::party::GroupRoster) -> Result<()> { + fn sync_group_mirror(&self, roster: &crate::world::party::GroupRoster) -> Result<()> { let partitions = roster .partitions .iter() @@ -760,16 +459,101 @@ impl Coordinator { ) ) } + + /// `CMSG_GROUP_INVITE` — `target_guid` is already resolved by the gateway. + fn group_invite(&self, actor: Actor, target_guid: u64) -> Result { + party_outcome(call_reducer!( + self.0.call_pipe().conn.reducers, + "gw_group_invite", + gw_group_invite_then(self.session_actor(actor), target_guid) + )) + } + + /// `CMSG_GROUP_ACCEPT`. Rides the coordinator connection as `gw_accept_group_invite`. + fn group_accept(&self, actor: Actor) -> Result { + party_outcome(call_reducer!( + self.0.call_pipe().conn.reducers, + "gw_accept_group_invite", + gw_accept_group_invite_then(self.session_actor(actor)) + )) + } + + fn group_decline(&self, actor: Actor) -> Result { + party_outcome(call_reducer!( + self.0.call_pipe().conn.reducers, + "gw_group_decline", + gw_group_decline_then(self.session_actor(actor)) + )) + } + + /// `CMSG_GROUP_DISBAND` — leave the caller's group. + fn group_leave(&self, actor: Actor) -> Result { + party_outcome(call_reducer!( + self.0.call_pipe().conn.reducers, + "gw_group_leave", + gw_group_leave_then(self.session_actor(actor)) + )) + } + + /// `CMSG_GROUP_UNINVITE` or `CMSG_GROUP_UNINVITE_GUID` — the leader or an Assistant kicks + /// `target_guid`. + fn group_uninvite(&self, actor: Actor, target_guid: u64) -> Result { + party_outcome(call_reducer!( + self.0.call_pipe().conn.reducers, + "gw_group_uninvite", + gw_group_uninvite_then(self.session_actor(actor), target_guid) + )) + } + + /// `CMSG_LOOT_METHOD` — the leader sets the party's loot method/threshold/master. Echoed to + /// every member via the existing `SMSG_GROUP_LIST` relay (the module's `group_loot_method` + /// reducer re-renders the roster payload); no separate ack packet (vanilla sends none for this + /// opcode either). + fn group_loot_method( + &self, + actor: Actor, + loot_setting: u8, + master_guid: u64, + loot_threshold: u8, + ) -> Result { + party_outcome(call_reducer!( + self.0.call_pipe().conn.reducers, + "gw_group_loot_method", + gw_group_loot_method_then( + self.session_actor(actor), + loot_setting, + master_guid, + loot_threshold + ) + )) + } +} + +impl Coordinator { + /// The Roster Revision this database holds for one party, or `None` when it holds no row. On a + /// World Shard this is the mirror's revision, which outlives a disband. + pub fn held_roster_revision(&self, group_id: u64) -> Option { + self.0 + .coord() + .conn + .db + .game_group_roster_revision() + .group_id() + .find(&group_id) + .map(|row| row.revision) + } } -/// The Module's typed party Refusal. Only a reducer the Module rejected carries a tag; a timeout, -/// transport, or SDK failure stays an error with an unknown outcome. -fn group_refusal(error: &anyhow::Error) -> Option { - reducer_refusal_reason(error).and_then(GroupRefusal::parse_tag) +/// The Module's Refusal tag. A Transport Loss carries none, so its outcome stays unknown. +fn refusal_tag(error: &anyhow::Error) -> Option<&str> { + match classify(error) { + DurableFailure::Refusal { reason } => Some(reason), + DurableFailure::TransportLoss => None, + } } fn command_refusal(error: &anyhow::Error) -> Option { - let tag = reducer_refusal_reason(error)?; + let tag = refusal_tag(error)?; Some(match tag { "Applied" => CompanionCommandOutcome::Applied, "Unchanged" => CompanionCommandOutcome::Unchanged, @@ -817,13 +601,32 @@ fn command_outcome_binding( } } -/// A refused party reducer is an outcome the client renders; anything else ends the session. +/// A tagged party Refusal is an outcome the client renders; an untagged one or a Transport Loss +/// stays `Err` and ends the session. fn party_outcome(result: Result<()>) -> Result { - match result { - Ok(()) => Ok(PartyOutcome::Ran), - Err(error) => match group_refusal(&error) { - Some(refusal) => Ok(refusal.into()), - None => Err(error), - }, + let Err(error) = result else { + return Ok(PartyOutcome::Ran); + }; + match refusal_tag(&error).and_then(GroupRefusal::parse_tag) { + Some(refusal) => Ok(refusal.into()), + None => Err(error), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::stdb::ReducerCallError; + + #[test] + fn a_party_refusal_is_an_outcome_and_a_transport_loss_is_an_error() { + let refused = + ReducerCallError::refused("gw_group_invite", GroupRefusal::GroupFull.as_tag()); + assert_eq!( + party_outcome(Err(refused.into())).unwrap(), + PartyOutcome::Refused(GroupRefusal::GroupFull) + ); + let lost = ReducerCallError::transport_lost("gw_group_invite"); + assert!(party_outcome(Err(lost.into())).is_err()); } } diff --git a/gateway/src/stdb/store/session.rs b/gateway/src/stdb/store/session.rs index f29b4afb4..246e6dd9e 100644 --- a/gateway/src/stdb/store/session.rs +++ b/gateway/src/stdb/store/session.rs @@ -18,179 +18,28 @@ impl SessionStore for Coordinator { crate::realm_core::lookup_session(self, account_name) } - fn player_login( - &self, - account_id: u64, - character_guid: u64, - entry: codec::WorldEntry, - ) -> Result { - self.player_login(account_id, character_guid, entry) - } - - fn movement_update( - &self, - _account_id: u64, - self_guid: u64, - opcode: u32, - info: &MovementInfo, - ) -> Result<()> { - let actor = Actor::new(self_guid) - .ok_or_else(|| anyhow!("movement_update: actor_guid unresolved"))?; - // Carry the MovementInfo verbatim so the module can relay it to in-range observers under - // the same opcode. Queueing is the only completion observable on the shared batch; - // individual reducer outcomes are intentionally unavailable. - let body = codec::movement_info_to_bytes(info)?; - self.0.motion_batch.push(GwMove { - actor: self.session_actor(actor), - opcode: opcode as u16, - movement_info: body, - x: info.position.x, - y: info.position.y, - z: info.position.z, - o: info.orientation, - move_time_ms: info.timestamp, - }); - MOVE_SUBMITTED.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - Ok(()) - } - - fn subscribe_player_events( - &self, - account_id: u64, - self_guid: u64, - arrival: &crate::codec::EntityView, - tx: SessionTx, - ) -> Result { - self.subscribe_player_events(account_id, self_guid, arrival, tx) - } - - fn client_command( - &self, - account_id: u64, - self_guid: u64, - cmd: String, - payload: String, - ) -> Result<()> { - self.client_command(account_id, self_guid, cmd, payload) - } - - fn pending_system_messages(&self, self_guid: u64) -> Vec { - self.system_messages_for(self_guid) - } - - fn entity_in_world(&self, guid: u64) -> bool { - self.entity_in_world(guid) - } - - fn entity_max_health(&self, guid: u64) -> u32 { - self.entity_max_health(guid) - } - - fn claim_session( - &self, - account_id: u64, - character_guid: u64, - ) -> Result { - self.claim_session(account_id, character_guid) - } - - fn bind_session( - &self, - token: crate::world::WorldSessionToken, - ) -> Result>> { - Ok(Some(std::sync::Arc::new(self.bind_session(token)?))) - } - - fn release_session(&self, token: crate::world::WorldSessionToken) -> Result<()> { - self.release_session(token) - } - - fn player_combat_until_ms(&self, player_guid: u64) -> u64 { - self.player_combat_until_ms(player_guid) - } -} - -impl Coordinator { - /// The EFFECTIVE armor for `guid` for the character-sheet CREATE (`UNIT_FIELD_RESISTANCES[0]`), - /// Presence check for the WORLDPORT_ACK gate: is the guid's live entity in the world? - pub fn entity_in_world(&self, guid: u64) -> bool { - let guard = self.0.coord(); - guard - .conn - .db - .game_world_entity() - .guid() - .find(&guid) - .is_some() - } - - /// The live entity's `max_health` from the privileged cache — 0 if not in world. Feeds the - /// fall-damage flavor line; the module applies the authoritative damage itself. - pub fn entity_max_health(&self, guid: u64) -> u32 { - let guard = self.0.coord(); - guard - .conn - .db - .game_world_entity() - .guid() - .find(&guid) - .map(|e| e.max_health) - .unwrap_or(0) - } - - /// Return the `combat_until_ms` value for `player_guid`'s entity row (0 if the entity is not - /// found or was never in combat). Read from the privileged coordinator cache. - pub fn player_combat_until_ms(&self, player_guid: u64) -> u64 { - let guard = self.0.coord(); - guard - .conn - .db - .game_world_entity() - .guid() - .find(&player_guid) - .map_or(0, |e| e.combat_until_ms) - } - - /// Undelivered private System Messages addressed to `self_guid`, oldest-first, from the - /// shard's `game_system_message_event` cache. Rows live until the module's event GC reaps - /// them, so a message emitted inside `player_login` is still here at world entry. - pub fn system_messages_for(&self, self_guid: u64) -> Vec { - let mut rows: Vec<(u64, String)> = self - .0 - .coord() - .conn - .db - .game_system_message_event() - .iter() - .filter(|row| row.recipient_guid == self_guid) - .map(|row| (row.id, row.message)) - .collect(); - rows.sort_unstable_by_key(|(id, _)| *id); - rows.into_iter().map(|(_, message)| message).collect() - } - /// Enter the world: call the `player_login` reducer on the coordinator connection /// (so `ctx.sender` is the player's bound identity), then read the resulting /// `game_world_entity` row back through the privileged cache as an `EntityView`. - pub fn player_login( + fn player_login( &self, account_id: u64, - character_guid: u64, - entry: crate::codec::WorldEntry, - ) -> Result { + character: Actor, + entry: codec::WorldEntry, + ) -> Result { // Login rides `gw_player_login` on the COORDINATOR connection (module half: delegates to // apply_player_login with the account's bound identity as row owner, binds entity→lease, // fail-closed on either missing) — no per-player connection exists anywhere. A world-port // rides its twin `gw_player_world_port`, which keeps the Away Status. let coord = self.0.call_pipe(); - let actor = self.actor_or_owner(character_guid); + let actor = self.session_actor(character); match entry { - crate::codec::WorldEntry::FreshLogin => call_reducer!( + codec::WorldEntry::FreshLogin => call_reducer!( coord.conn.reducers, "gw_player_login", gw_player_login_then(account_id, actor) )?, - crate::codec::WorldEntry::WorldPort => call_reducer!( + codec::WorldEntry::WorldPort => call_reducer!( coord.conn.reducers, "gw_player_world_port", gw_player_world_port_then(account_id, actor) @@ -200,6 +49,7 @@ impl Coordinator { // The reducer committed; the row propagates to the owner cache asynchronously. Poll // briefly until it appears (home_* ride along from the game_character row, and its // zone_id is the fallback for a live row the Module could not resolve a zone for). + let character_guid = character.guid(); let char_row = self .0 .coord() @@ -244,21 +94,112 @@ impl Coordinator { )) } - /// Forward an addon-bridge command to the module's `client_command` dispatch. - pub fn client_command( + fn movement_update(&self, actor: Actor, opcode: u32, info: &MovementInfo) -> Result<()> { + // Carry the MovementInfo verbatim so the module can relay it to in-range observers under + // the same opcode. Queueing is the only completion observable on the shared batch; + // individual reducer outcomes are intentionally unavailable. + let body = codec::movement_info_to_bytes(info)?; + self.0.motion_batch.push(GwMove { + actor: self.session_actor(actor), + opcode: opcode as u16, + movement_info: body, + x: info.position.x, + y: info.position.y, + z: info.position.z, + o: info.orientation, + move_time_ms: info.timestamp, + }); + MOVE_SUBMITTED.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + Ok(()) + } + + fn subscribe_player_events( &self, - _account_id: u64, - actor_guid: u64, - cmd: String, - payload: String, - ) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("client_command: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); + account_id: u64, + self_guid: u64, + arrival: &crate::codec::EntityView, + tx: SessionTx, + ) -> Result { + self.subscribe_player_events(account_id, self_guid, arrival, tx) + } + + /// Forward an addon-bridge command to the module's `client_command` dispatch. + fn client_command(&self, actor: Actor, cmd: String, payload: String) -> Result<()> { call_reducer!( - coord.conn.reducers, + self.0.call_pipe().conn.reducers, "gw_client_command", gw_client_command_then(self.session_actor(actor), cmd, payload) ) } + + /// Undelivered private System Messages addressed to `self_guid`, oldest-first, from the + /// shard's `game_system_message_event` cache. Rows live until the module's event GC reaps + /// them, so a message emitted inside `player_login` is still here at world entry. + fn pending_system_messages(&self, self_guid: u64) -> Vec { + let mut rows: Vec<(u64, String)> = self + .0 + .coord() + .conn + .db + .game_system_message_event() + .iter() + .filter(|row| row.recipient_guid == self_guid) + .map(|row| (row.id, row.message)) + .collect(); + rows.sort_unstable_by_key(|(id, _)| *id); + rows.into_iter().map(|(_, message)| message).collect() + } + + fn entity_in_world(&self, guid: u64) -> bool { + self.0 + .coord() + .conn + .db + .game_world_entity() + .guid() + .find(&guid) + .is_some() + } + + /// Read from the privileged cache; the module applies the authoritative fall damage itself. + fn entity_max_health(&self, guid: u64) -> u32 { + self.0 + .coord() + .conn + .db + .game_world_entity() + .guid() + .find(&guid) + .map_or(0, |e| e.max_health) + } + + fn claim_session( + &self, + account_id: u64, + character_guid: u64, + ) -> Result { + self.claim_session(account_id, character_guid) + } + + fn bind_session( + &self, + token: crate::world::WorldSessionToken, + ) -> Result>> { + Ok(Some(std::sync::Arc::new(self.bind_session(token)?))) + } + + fn release_session(&self, token: crate::world::WorldSessionToken) -> Result<()> { + self.release_session(token) + } + + fn player_combat_until_ms(&self, player_guid: u64) -> u64 { + self.0 + .coord() + .conn + .db + .game_world_entity() + .guid() + .find(&player_guid) + .map_or(0, |e| e.combat_until_ms) + } } diff --git a/gateway/src/stdb/store/social.rs b/gateway/src/stdb/store/social.rs index e4a3d5416..2dbf31637 100644 --- a/gateway/src/stdb/store/social.rs +++ b/gateway/src/stdb/store/social.rs @@ -1,102 +1,19 @@ //! `Coordinator`'s [`SocialStore`] adapter. -use anyhow::{anyhow, Result}; +use anyhow::Result; use lyracore_shared::social::ContactRefusal; use spacetimedb_sdk::Table; -use crate::codec; use crate::stdb::bindings::*; -use crate::stdb::connection::{call_reducer, reducer_refusal_reason}; +use crate::stdb::connection::call_reducer; use crate::stdb::Coordinator; +use crate::stdb::{classify, DurableFailure}; use crate::world::{Actor, ContactOutcome, SocialStore}; impl SocialStore for Coordinator { - fn character_identity( - &self, - guid: u64, - ) -> Result> { - self.character_identity(guid) - } - - fn live_entity(&self, guid: u64) -> Option { - self.live_entity(guid) - } - - fn character_in_transit(&self, guid: u64) -> bool { - self.character_in_transit(guid) - } - - fn auto_reply_text(&self, guid: u64) -> Result> { - Ok(self.auto_reply_text(guid)) - } - - fn every_shard_vouches_for_absence(&self) -> Result<()> { - self.world_shards_for_absence()?; - Ok(()) - } - - fn in_world_players(&self) -> Result> { - self.in_world_players() - } - - fn zone_name(&self, zone_id: u32) -> String { - self.zone_name(zone_id) - } - - fn contact_lists(&self, self_guid: u64) -> Result<(Vec, Vec)> { - self.contact_lists(self_guid) - } - - fn ignored_guids(&self, owner_guid: u64) -> Result> { - self.ignored_guids(owner_guid) - } - - fn character_guid_by_name(&self, name: &str) -> Result> { - self.character_guid_by_name(name) - } - - fn add_friend( - &self, - account_id: u64, - self_guid: u64, - target_guid: u64, - target_race: u8, - ) -> Result { - self.add_friend(account_id, self_guid, target_guid, target_race) - } - - fn del_friend( - &self, - account_id: u64, - self_guid: u64, - target_guid: u64, - ) -> Result { - self.del_friend(account_id, self_guid, target_guid) - } - - fn add_ignore( - &self, - account_id: u64, - self_guid: u64, - target_guid: u64, - ) -> Result { - self.add_ignore(account_id, self_guid, target_guid) - } - - fn del_ignore( - &self, - account_id: u64, - self_guid: u64, - target_guid: u64, - ) -> Result { - self.del_ignore(account_id, self_guid, target_guid) - } -} - -impl Coordinator { /// This Shard's durable Character row for `guid`: identity plus the session flag. `None` if /// this Shard holds no `game_character` row for it. - pub fn character_identity( + fn character_identity( &self, guid: u64, ) -> Result> { @@ -122,7 +39,7 @@ impl Coordinator { /// This Shard's live `game_world_entity` row for `guid`, if any — the Member Stats columns /// [`crate::codec::MemberEntity`] carries, joined with nothing else: level and zone come /// straight off the entity, current unlike the durable row. - pub fn live_entity(&self, guid: u64) -> Option { + fn live_entity(&self, guid: u64) -> Option { let guard = self.0.coord(); let entity = guard.conn.db.game_world_entity().guid().find(&guid)?; Some(crate::codec::MemberEntity { @@ -146,7 +63,7 @@ impl Coordinator { /// Does this Shard show `guid` between two places: its own Character row reading online with /// no live entity here, or a Transfer Intent naming a session-less bot mid-crossing? The /// Transfer Intent table is bounded by the Module's writer Gate, so the scan is short. - pub fn character_in_transit(&self, guid: u64) -> bool { + fn character_in_transit(&self, guid: u64) -> bool { let guard = self.0.coord(); let db = &guard.conn.db; let session_online = db @@ -161,12 +78,31 @@ impl Coordinator { .any(|intent| intent.bot_guid == guid) } + /// The stored Auto-Reply for `guid` on this Shard: one primary-key read of the private + /// `game_character_away` cache. + fn auto_reply_text(&self, guid: u64) -> Result> { + Ok(self + .0 + .coord() + .conn + .db + .game_character_away() + .character_guid() + .find(&guid) + .map(|row| row.message)) + } + + fn every_shard_vouches_for_absence(&self) -> Result<()> { + self.world_shards_for_absence()?; + Ok(()) + } + /// Every in-world player Character on this Shard, for `CMSG_WHO → SMSG_WHO` /// (`presence::in_world_characters`'s per-Shard input, replacing the former `online_players`). /// Iterates `game_world_entity` for entries with `entry == 0` (player entities; creatures have a /// non-zero entry), then joins each against `game_character`. The coordinator bypasses RLS so it /// sees every player's entity regardless of the caller's scope. - pub fn in_world_players(&self) -> Result> { + fn in_world_players(&self) -> Result> { let guard = self.0.coord(); let db = &guard.conn.db; let shard_name = self.shard_name().to_string(); @@ -213,7 +149,7 @@ impl Coordinator { /// `game_area.name` for `zone_id`, `/who`'s search-string match against a zone name — a static /// catalogue, subscribed unconditionally. Empty when the catalogue holds no row for it. - pub fn zone_name(&self, zone_id: u32) -> String { + fn zone_name(&self, zone_id: u32) -> String { self.0 .coord() .conn @@ -225,20 +161,6 @@ impl Coordinator { .unwrap_or_default() } - /// Resolve a typed name to a Character guid on this Shard, ASCII case-insensitive like the - /// Module's own `helpers::character_by_name`, from the Shard's [`CharacterNameIndex`]. `None` - /// if no Character here has that name. Every realm-wide name lookup (whisper, party and guild - /// invites, contacts, channel moderation) reaches this through `presence::resolve_by_name` or - /// `presence::resolve_all_by_name`. - pub fn character_guid_by_name(&self, name: &str) -> Result> { - let guard = self.0.coord(); - let names = guard - .character_names - .read() - .unwrap_or_else(|p| p.into_inner()); - Ok(names.guid_named(name)) - } - /// `owner_guid`'s friend guids and ignore guids for `CMSG_FRIEND_LIST`, per /// [`crate::world::SocialStore::contact_lists`]. `owner_guid` is always the CALLING /// World Session's own guid, which is always live and registered right now, so this reads the @@ -248,7 +170,7 @@ impl Coordinator { /// Presence composition (online/team/Away Status) lives over the Store seam in /// `world::social::friend_views`, not here, so a Store Fake exercises the same code a /// Coordinator does. - pub fn contact_lists(&self, owner_guid: u64) -> Result<(Vec, Vec)> { + fn contact_lists(&self, owner_guid: u64) -> Result<(Vec, Vec)> { let Some(viewer) = self .world_view() .viewer_of_owner(super::super::world_view::OwnerGuid(owner_guid)) @@ -263,103 +185,95 @@ impl Coordinator { /// `owner_guid` here is a Character this Gateway process may never have a `Viewer` for at all (a /// whisper sender or a guild-invite target is usually a PEER, not the connected session), so /// this cannot route through one. - pub fn ignored_guids(&self, owner_guid: u64) -> Result> { + fn ignored_guids(&self, owner_guid: u64) -> Result> { let guard = self.0.coord(); let contacts = guard.contacts.read().unwrap_or_else(|p| p.into_inner()); Ok(contacts.ignored_by(owner_guid)) } - /// The stored Auto-Reply for `guid` on this Shard: one primary-key read of the private - /// `game_character_away` cache. - pub(crate) fn auto_reply_text(&self, guid: u64) -> Option { - self.0 - .coord() - .conn - .db - .game_character_away() - .character_guid() - .find(&guid) - .map(|row| row.message) + /// Resolve a typed name to a Character guid on this Shard, ASCII case-insensitive like the + /// Module's own `helpers::character_by_name`, from the Shard's [`CharacterNameIndex`]. `None` + /// if no Character here has that name. Every realm-wide name lookup (whisper, party and guild + /// invites, contacts, channel moderation) reaches this through `presence::resolve_by_name` or + /// `presence::resolve_all_by_name`. + fn character_guid_by_name(&self, name: &str) -> Result> { + let guard = self.0.coord(); + let names = guard + .character_names + .read() + .unwrap_or_else(|p| p.into_inner()); + Ok(names.guid_named(name)) } /// `CMSG_ADD_FRIEND` — `target_guid` is already resolved by the gateway. `target_race` is the /// target's Speaker Fact, read realm-wide (`presence::of`): the Module's Enemy Gate needs it. - pub fn add_friend( + fn add_friend( &self, - _account_id: u64, - actor_guid: u64, + actor: Actor, target_guid: u64, target_race: u8, ) -> Result { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("add_friend: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); contact_outcome(call_reducer!( - coord.conn.reducers, + self.0.call_pipe().conn.reducers, "gw_add_friend", gw_add_friend_then(self.session_actor(actor), target_guid, target_race) )) } - /// `CMSG_DEL_FRIEND`. - pub fn del_friend( - &self, - _account_id: u64, - actor_guid: u64, - target_guid: u64, - ) -> Result { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("del_friend: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); + fn del_friend(&self, actor: Actor, target_guid: u64) -> Result { contact_outcome(call_reducer!( - coord.conn.reducers, + self.0.call_pipe().conn.reducers, "gw_del_friend", gw_del_friend_then(self.session_actor(actor), target_guid) )) } /// `CMSG_ADD_IGNORE` — `target_guid` is already resolved by the gateway. - pub fn add_ignore( - &self, - _account_id: u64, - actor_guid: u64, - target_guid: u64, - ) -> Result { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("add_ignore: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); + fn add_ignore(&self, actor: Actor, target_guid: u64) -> Result { contact_outcome(call_reducer!( - coord.conn.reducers, + self.0.call_pipe().conn.reducers, "gw_add_ignore", gw_add_ignore_then(self.session_actor(actor), target_guid) )) } - /// `CMSG_DEL_IGNORE`. - pub fn del_ignore( - &self, - _account_id: u64, - actor_guid: u64, - target_guid: u64, - ) -> Result { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("del_ignore: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); + fn del_ignore(&self, actor: Actor, target_guid: u64) -> Result { contact_outcome(call_reducer!( - coord.conn.reducers, + self.0.call_pipe().conn.reducers, "gw_del_ignore", gw_del_ignore_then(self.session_actor(actor), target_guid) )) } } -/// [`party_outcome`] for the friends and ignore lists. +/// The friends and ignore lists' Refusal decoder: a tagged Refusal is an outcome the client +/// renders, and an untagged one or a Transport Loss stays `Err`. fn contact_outcome(result: Result<()>) -> Result { - match result { - Ok(()) => Ok(ContactOutcome::Done), - Err(error) => match reducer_refusal_reason(&error).and_then(ContactRefusal::parse_tag) { + let Err(error) = result else { + return Ok(ContactOutcome::Done); + }; + match classify(&error) { + DurableFailure::Refusal { reason } => match ContactRefusal::parse_tag(reason) { Some(refusal) => Ok(refusal.into()), None => Err(error), }, + DurableFailure::TransportLoss => Err(error), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::stdb::ReducerCallError; + + #[test] + fn a_contact_refusal_is_an_outcome_and_a_transport_loss_is_an_error() { + let refused = ReducerCallError::refused("gw_add_friend", ContactRefusal::ListFull.as_tag()); + assert_eq!( + contact_outcome(Err(refused.into())).unwrap(), + ContactOutcome::Refused(ContactRefusal::ListFull) + ); + let lost = ReducerCallError::transport_lost("gw_add_friend"); + assert!(contact_outcome(Err(lost.into())).is_err()); } } diff --git a/gateway/src/stdb/subscriptions_character_gone_durable_tests.rs b/gateway/src/stdb/subscriptions_character_gone_durable_tests.rs index 793891997..b97a1a1ee 100644 --- a/gateway/src/stdb/subscriptions_character_gone_durable_tests.rs +++ b/gateway/src/stdb/subscriptions_character_gone_durable_tests.rs @@ -3,6 +3,7 @@ use crate::accept::BlockingTaskCapacity; use crate::config::GatewayConfig; use crate::durable_test_support::{poll_until, Standalone, POLL_TIMEOUT}; use crate::world::party::PartyOutcome; +use crate::world::{Actor, PartyStore}; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; @@ -232,25 +233,53 @@ fn another_gateway_waits_for_the_transfer_then_cleans_the_deleted_character() { use lyracore_shared::group::realm_op; assert_eq!( realm - .realm_group_op(realm_op::INVITE, 1, INSTANCES_SURVIVOR, 0, 0, 0) + .realm_group_op( + realm_op::INVITE, + Actor::new(1).unwrap(), + INSTANCES_SURVIVOR, + 0, + 0, + 0 + ) .unwrap(), PartyOutcome::Ran ); assert_eq!( realm - .realm_group_op(realm_op::ACCEPT, INSTANCES_SURVIVOR, 0, 0, 0, 0) + .realm_group_op( + realm_op::ACCEPT, + Actor::new(INSTANCES_SURVIVOR).unwrap(), + 0, + 0, + 0, + 0 + ) .unwrap(), PartyOutcome::Ran ); assert_eq!( realm - .realm_group_op(realm_op::INVITE, 1, OTHER_SURVIVOR, 0, 0, 0) + .realm_group_op( + realm_op::INVITE, + Actor::new(1).unwrap(), + OTHER_SURVIVOR, + 0, + 0, + 0 + ) .unwrap(), PartyOutcome::Ran ); assert_eq!( realm - .realm_group_op(realm_op::ACCEPT, OTHER_SURVIVOR, 0, 0, 0, 0) + .realm_group_op( + realm_op::ACCEPT, + Actor::new(OTHER_SURVIVOR).unwrap(), + 0, + 0, + 0, + 0 + ) .unwrap(), PartyOutcome::Ran ); @@ -369,7 +398,7 @@ fn another_gateway_waits_for_the_transfer_then_cleans_the_deleted_character() { assert!(poll_until(POLL_TIMEOUT, || { reconciliation_is_idle(&observer) && realm.group_roster(OTHER_SURVIVOR).is_none() - && realm.group_roster_revision(group_id) > survivor_revision + && realm.group_roster_revision(group_id).unwrap() > survivor_revision && world .world_shards() .into_iter() diff --git a/gateway/src/stdb/subscriptions_party_command_durable_tests.rs b/gateway/src/stdb/subscriptions_party_command_durable_tests.rs index 997d4ef07..4123fe553 100644 --- a/gateway/src/stdb/subscriptions_party_command_durable_tests.rs +++ b/gateway/src/stdb/subscriptions_party_command_durable_tests.rs @@ -8,7 +8,7 @@ use crate::config::GatewayConfig; use crate::durable_test_support::{module_bytes, poll_until, Standalone, POLL_TIMEOUT}; use crate::stdb::bindings::GamePartyCommandIntentTableAccess; use crate::world::party::{self, AdmittedCompanionCommand, CompanionCommandOutcome}; -use crate::world::TransferStore; +use crate::world::{Actor, PartyStore, SessionStore, TransferStore}; use spacetimedb_sdk::Table; use std::collections::BTreeMap; use std::ffi::OsString; @@ -525,7 +525,7 @@ fn enter_transferred_actor( bound .player_login( account_id, - character_guid, + Actor::new(character_guid).unwrap(), crate::codec::WorldEntry::FreshLogin, ) .unwrap(); diff --git a/gateway/src/stdb/subscriptions_roster_relay_durable_tests.rs b/gateway/src/stdb/subscriptions_roster_relay_durable_tests.rs index 051896726..5f9b8932d 100644 --- a/gateway/src/stdb/subscriptions_roster_relay_durable_tests.rs +++ b/gateway/src/stdb/subscriptions_roster_relay_durable_tests.rs @@ -3,6 +3,7 @@ use crate::accept::BlockingTaskCapacity; use crate::config::GatewayConfig; use crate::durable_test_support::{module_bytes, poll_until, Standalone, POLL_TIMEOUT}; use crate::world::party::PartyOutcome; +use crate::world::{Actor, PartyStore}; use lyracore_shared::group::realm_op; /// Holds the process-wide topology variables for the whole test, then restores them. @@ -102,13 +103,20 @@ fn a_roster_change_made_only_on_realm_core_reaches_every_world_shard_mirror() { // Straight to Realm-core: no `party::run`, so only the relay can update the mirrors. assert_eq!( realm - .realm_group_op(realm_op::INVITE, LEADER, MEMBER, 0, 0, 0) + .realm_group_op( + realm_op::INVITE, + Actor::new(LEADER).unwrap(), + MEMBER, + 0, + 0, + 0 + ) .unwrap(), PartyOutcome::Ran ); assert_eq!( realm - .realm_group_op(realm_op::ACCEPT, MEMBER, 0, 1, 0, 0) + .realm_group_op(realm_op::ACCEPT, Actor::new(MEMBER).unwrap(), 0, 1, 0, 0) .unwrap(), PartyOutcome::Ran ); @@ -132,7 +140,7 @@ fn a_roster_change_made_only_on_realm_core_reaches_every_world_shard_mirror() { // A party of one disbands, so the mirrors get the tombstone and forget the party. assert_eq!( realm - .realm_group_op(realm_op::LEAVE, MEMBER, 0, 0, 0, 0) + .realm_group_op(realm_op::LEAVE, Actor::new(MEMBER).unwrap(), 0, 0, 0, 0) .unwrap(), PartyOutcome::Ran ); diff --git a/gateway/src/world/framing_tests.rs b/gateway/src/world/framing_tests.rs index 367d94cc2..bf179db55 100644 --- a/gateway/src/world/framing_tests.rs +++ b/gateway/src/world/framing_tests.rs @@ -464,8 +464,8 @@ fn an_authenticated_stc_order_uses_the_logged_in_actor_and_keeps_the_session_liv assert_eq!( *store.session.client_commands.lock().unwrap(), vec![ - (42, 1, "example.order".into(), "follow|77".into()), - (42, 1, "example.order".into(), "follow|77".into()), + (1, "example.order".into(), "follow|77".into()), + (1, "example.order".into(), "follow|77".into()), ] ); drop(client); diff --git a/gateway/src/world/handlers/char.rs b/gateway/src/world/handlers/char.rs index 2a1737e91..6833798d5 100644 --- a/gateway/src/world/handlers/char.rs +++ b/gateway/src/world/handlers/char.rs @@ -12,7 +12,7 @@ pub(crate) trait CharacterStore: Send + Sync { fn characters(&self, account_id: u64) -> Result>; /// Create a character for the account (`CMSG_CHAR_CREATE`). Returns the game outcome - /// (success / name-in-use / failed); `Err` only for an unrecoverable transport failure. + /// (success / name-in-use / failed); `Err` only for a Transport Loss. fn create_character( &self, account_id: u64, @@ -23,13 +23,13 @@ pub(crate) trait CharacterStore: Send + Sync { appearance: codec::Appearance, ) -> Result; - /// Delete a character for the account (`CMSG_CHAR_DELETE`). Returns the game - /// outcome (success/failed); `Err` only for an unrecoverable transport failure. Ownership is - /// enforced module-side (the character must belong to `account_id`). + /// Delete `character` for the account (`CMSG_CHAR_DELETE`). Returns the game outcome + /// (success/failed); `Err` only for a Transport Loss. Ownership is enforced module-side (the + /// character must belong to `account_id`). fn delete_character( &self, account_id: u64, - character_guid: u64, + character: Actor, ) -> Result; /// Look up a character by guid (any owner) to answer `CMSG_NAME_QUERY` — the queried guid is @@ -145,12 +145,13 @@ fn enter_world( tx: &SessionTx, store: &dyn WorldStore, conn: &mut WorldConn, - character_guid: u64, + character: Actor, entry: codec::WorldEntry, ) -> Result<()> { conn.state = WorldState::CharSelect; - let mut entity = store.player_login(conn.account_id, character_guid, entry)?; + 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 @@ -244,7 +245,7 @@ fn enter_world( // // 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_guid) { + 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 @@ -344,8 +345,8 @@ pub(crate) fn handle_char< )?; } // Character creation. Create the row, reply SMSG_CHAR_CREATE; on success the client - // re-sends CMSG_CHAR_ENUM and the new character appears. A creation failure is NOT - // session-fatal — report it as a result, never drop the connection. + // 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, @@ -354,16 +355,14 @@ pub(crate) fn handle_char< 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, - ) - .unwrap_or(codec::CharCreateOutcome::Failed); + 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( @@ -373,7 +372,8 @@ pub(crate) fn handle_char< } // 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 failure is NOT session-fatal, same treatment as CMSG_CHAR_CREATE above. + // 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 @@ -381,15 +381,19 @@ pub(crate) fn handle_char< // nothing either. ClientOpcodeMessage::CMSG_CHAR_DELETE(d) => { let character_guid = d.guid.guid(); - let outcome = match super::leads_a_guild(store, character_guid) { - Ok(false) => store - .delete_character(conn.account_id, character_guid) - .unwrap_or(codec::CharDeleteOutcome::Failed), - Ok(true) => { + 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 } - Err(error) => { + (Some(_), Err(error)) => { log::warn!( "world: Guild Leader check for {character_guid} failed, not deleted: \ {error:#}" @@ -411,6 +415,9 @@ pub(crate) fn handle_char< 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)? { @@ -423,13 +430,7 @@ pub(crate) fn handle_char< // 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_guid, - codec::WorldEntry::FreshLogin, - )?; + 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 @@ -447,10 +448,11 @@ pub(crate) fn handle_char< // 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) => Some(iw.self_guid), + WorldState::InWorld(iw) => Actor::new(iw.self_guid), WorldState::CharSelect => None, }; - if let Some(character_guid) = resume { + 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. @@ -487,13 +489,8 @@ pub(crate) fn handle_char< let mut ported = conn.route_home(character_guid); if ported.is_ok() { let home = conn.store.current(); - ported = enter_world( - tx, - &*home, - conn, - character_guid, - codec::WorldEntry::WorldPort, - ); + ported = + enter_world(tx, &*home, conn, character, codec::WorldEntry::WorldPort); } if let Err(e) = ported { abort_pending_transfer(tx, store, character_guid, &e); diff --git a/gateway/src/world/loot_tests.rs b/gateway/src/world/loot_tests.rs index 0f1015c97..5d4611fdd 100644 --- a/gateway/src/world/loot_tests.rs +++ b/gateway/src/world/loot_tests.rs @@ -355,7 +355,12 @@ fn a_leave_flushes_every_connected_shards_pending_rolls_before_the_disband_reach // Vim leaving a 2-member party disbands it (vanilla: a party of one is no party) — the exact // shape `remove_member`'s disband branch, and therefore `force_resolve_rolls_for_disband`, needs. - party::run(instances.as_ref(), 8, VIM, party::Op::Leave).expect("Vim leaves"); + party::run( + instances.as_ref(), + Actor::new(VIM).unwrap(), + party::Op::Leave, + ) + .expect("Vim leaves"); assert_eq!( realm.loot_roll.realm_loot_ops.lock().unwrap().clone(), @@ -416,7 +421,12 @@ fn a_non_disband_capable_op_never_flushes_pending_rolls() { recipients: vec![GINGER], random_property_id: 0, }]; - party::run(world.as_ref(), 7, GINGER, party::Op::Invite(TRIN)).expect("the invite runs"); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(TRIN), + ) + .expect("the invite runs"); assert!( realm.loot_roll.realm_loot_ops.lock().unwrap().is_empty(), "an op that cannot shrink a group must not trigger a flush" diff --git a/gateway/src/world/member_stats_tests.rs b/gateway/src/world/member_stats_tests.rs index 0b96b11d1..e564c0c91 100644 --- a/gateway/src/world/member_stats_tests.rs +++ b/gateway/src/world/member_stats_tests.rs @@ -217,8 +217,13 @@ fn ginger_and_trin() -> ( std::sync::Arc, ) { let (realm, world, instances, _) = party_topology(); - party::run(world.as_ref(), 7, GINGER, party::Op::Invite(TRIN)).expect("invite Trin"); - party::run(world.as_ref(), 9, TRIN, party::Op::Accept).expect("Trin accepts"); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(TRIN), + ) + .expect("invite Trin"); + party::run(world.as_ref(), Actor::new(TRIN).unwrap(), party::Op::Accept).expect("Trin accepts"); place(&world, GINGER, caster()); place(&world, TRIN, caster()); (realm, world, instances) @@ -403,7 +408,7 @@ fn a_mate_who_leaves_the_group_is_forgotten() { let (_realm, world, _instances) = ginger_and_trin(); let mut snapshots = Snapshots::new(); tick(&world, &[GINGER], &mut snapshots); - party::run(world.as_ref(), 9, TRIN, party::Op::Leave).expect("Trin leaves"); + party::run(world.as_ref(), Actor::new(TRIN).unwrap(), party::Op::Leave).expect("Trin leaves"); assert!(tick(&world, &[GINGER], &mut snapshots).is_empty()); assert!(!snapshots.contains_key(&TRIN)); @@ -551,7 +556,12 @@ fn a_failed_read_answers_nothing_and_is_never_offline() { #[test] fn a_bot_crossing_between_shards_is_never_reported_offline() { let (_realm, world, _instances, _) = party_topology(); - party::run(world.as_ref(), 7, GINGER, party::Op::Invite(BOT)).expect("invite the bot"); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(BOT), + ) + .expect("invite the bot"); place(&world, BOT, caster()); let mut snapshots = Snapshots::new(); tick(&world, &[GINGER], &mut snapshots); diff --git a/gateway/src/world/mod.rs b/gateway/src/world/mod.rs index 1400c2998..e8a529d05 100644 --- a/gateway/src/world/mod.rs +++ b/gateway/src/world/mod.rs @@ -1107,7 +1107,7 @@ fn run_world_session_with_queue_and_deadline( addon_refill_at = now; if addon_tokens >= 1.0 { addon_tokens -= 1.0; - handle_addon_message(&*conn.store.current(), &conn, &text); + handle_addon_message(&*conn.store.current(), &conn, &text)?; } else if addon_drop_logged_at .is_none_or(|t| now.duration_since(t).as_secs() >= 60) { @@ -1206,22 +1206,36 @@ fn speaker_race(store: &St, character_guid: u64) - /// Route one bridge-prefixed addon chat frame: parse the `STC` v1 envelope and forward to the /// module's `client_command` reducer as the player. The caller already checked the prefix; a /// malformed envelope past that point still drops silently-with-a-debug-line (a truncated or -/// hand-edited frame is not session-fatal); reducer errors log and drop the same way. -fn handle_addon_message(store: &St, conn: &WorldConn, text: &str) { +/// hand-edited frame is not session-fatal), and so does a frame outside the world or a Refusal. +/// A Transport Loss ends the World Session. +fn handle_addon_message( + store: &St, + conn: &WorldConn, + text: &str, +) -> Result<()> { let Some((cmd, payload)) = codec::addon::parse_bridge_envelope(text) else { log::debug!("addon bridge: non-STC or malformed frame dropped: {text:?}"); - return; + return Ok(()); }; - if let Err(e) = store.client_command( - conn.account_id, - social::self_guid(conn).unwrap_or(0), - cmd.clone(), - payload, - ) { + let Some(actor) = social::self_actor(conn) else { log::info!( - "addon bridge: command {cmd:?} from account {} failed: {e:#}", + "addon bridge: command {cmd:?} from account {} dropped outside the world", conn.account_id ); + return Ok(()); + }; + match store.client_command(actor, cmd.clone(), payload) { + Ok(()) => Ok(()), + Err(e) => match crate::stdb::classify(&e) { + crate::stdb::DurableFailure::Refusal { .. } => { + log::info!( + "addon bridge: command {cmd:?} from account {} refused: {e:#}", + conn.account_id + ); + Ok(()) + } + crate::stdb::DurableFailure::TransportLoss => Err(e), + }, } } @@ -1684,12 +1698,12 @@ fn forward_movement( info: &MovementInfo, ) -> Result<()> { // The shared-call path names the mover by guid instead of by connection. - // Movement only flows in-world, so the guid is always known here; the store rejects 0. let self_guid = match &conn.state { WorldState::InWorld(iw) => iw.self_guid, _ => 0, }; - if !store.entity_in_world(self_guid) { + let in_world = store.entity_in_world(self_guid); + let Some(actor) = Actor::new(self_guid).filter(|_| in_world) else { conn.move_desync_drops += 1; if conn.move_desync_drops > MOVE_DESYNC_TOLERANCE { let e = anyhow!("player not in world (guid {self_guid})"); @@ -1707,9 +1721,9 @@ fn forward_movement( conn.move_desync_drops ); return Ok(()); - } + }; conn.move_desync_drops = 0; - store.movement_update(conn.account_id, self_guid, opcode, info)?; + store.movement_update(actor, opcode, info)?; // AOI: recenter this player's grid-scoped entity subscription if they crossed a cell. No-op // when AOI is disabled (no tracker) or the player stayed in-cell. Same-map (the tracker holds // the login map; teleport/zone changes re-anchor in a later phase). diff --git a/gateway/src/world/party.rs b/gateway/src/world/party.rs index 4a01aae86..62ff4a812 100644 --- a/gateway/src/world/party.rs +++ b/gateway/src/world/party.rs @@ -43,7 +43,7 @@ use std::time::{Duration, Instant}; use anyhow::Result; -use super::{presence, send, Outbound, SessionTx, WorldStore}; +use super::{presence, send, Actor, Outbound, SessionTx, WorldStore}; use crate::codec; use lyracore_shared::group::{ bot_op, realm_op, GroupKind, GroupRefusal, RaidSlot, RosterMember, RosterPayload, @@ -99,7 +99,7 @@ pub(crate) trait PartyStore: Send + Sync { fn realm_group_op( &self, op: u8, - actor_guid: u64, + actor: Actor, target_guid: u64, arg_a: u8, arg_b: u8, @@ -112,7 +112,7 @@ pub(crate) trait PartyStore: Send + Sync { fn realm_group_op_visible( &self, op: u8, - actor_guid: u64, + actor: Actor, target_guid: u64, arg_a: u8, arg_b: u8, @@ -121,7 +121,7 @@ pub(crate) trait PartyStore: Send + Sync { /// Realm-core LEAVE for a deleted Character. Production returns after its Coordinator cache /// has the committed roster; Fakes may reuse the ordinary party operation. - fn deleted_character_party_leave(&self, character_guid: u64) -> Result; + fn deleted_character_party_leave(&self, character: Actor) -> Result; /// The party `character_guid` is in, as THIS handle's database sees it: authoritative on /// realm-core, a mirror on a world shard. `None` = not in a party there. @@ -170,37 +170,24 @@ pub(crate) trait PartyStore: Send + Sync { /// An empty `roster.members` is the disband tombstone. fn sync_group_mirror(&self, roster: &GroupRoster) -> Result<()>; - // The SINGLE-DATABASE party path (`world::party::run`'s `None` arm). Each takes the caller's - // `self_guid` as well as its account: the account is what identifies the player CONNECTION these - // reducers run on, and the guid is what identifies the CHARACTER to realm-core on the other arm. - // Both are threaded through one call site (`world::social`), so the two planes take the same - // arguments and a mock sees which character the op was for either way. + // The SINGLE-DATABASE party path (`world::party::run`'s `None` arm): the player-facing + // reducers, acting as the session's Character. /// `CMSG_GROUP_INVITE` (name gateway-resolved). A `GroupRefusal` arrives as an outcome. - fn group_invite( - &self, - account_id: u64, - self_guid: u64, - target_guid: u64, - ) -> Result; + fn group_invite(&self, actor: Actor, target_guid: u64) -> Result; /// `CMSG_GROUP_ACCEPT`. - fn group_accept(&self, account_id: u64, self_guid: u64) -> Result; + fn group_accept(&self, actor: Actor) -> Result; /// `CMSG_GROUP_DECLINE`. - fn group_decline(&self, account_id: u64, self_guid: u64) -> Result; + fn group_decline(&self, actor: Actor) -> Result; /// `CMSG_GROUP_DISBAND` (the client's "Leave Party"). - fn group_leave(&self, account_id: u64, self_guid: u64) -> Result; + fn group_leave(&self, actor: Actor) -> Result; /// `CMSG_GROUP_UNINVITE` (name gateway-resolved) or `CMSG_GROUP_UNINVITE_GUID` — the leader or /// an Assistant kicks a member. - fn group_uninvite( - &self, - account_id: u64, - self_guid: u64, - target_guid: u64, - ) -> Result; + fn group_uninvite(&self, actor: Actor, target_guid: u64) -> Result; /// `CMSG_LOOT_METHOD` — the leader sets the party's loot method/ /// threshold/master. `loot_setting`/`loot_threshold` are the gateway-decoded `GroupLootSetting`/ @@ -208,8 +195,7 @@ pub(crate) trait PartyStore: Send + Sync { /// verbatim — zero translation). fn group_loot_method( &self, - account_id: u64, - self_guid: u64, + actor: Actor, loot_setting: u8, master_guid: u64, loot_threshold: u8, @@ -673,18 +659,18 @@ impl Op { } } -/// Run `op` as `self_guid` through `realm_group_op` on `authority`, the database that holds the +/// Run `op` as `actor` through `realm_group_op` on `authority`, the database that holds the /// party: Realm-core when sharded, the home shard otherwise. It returns before the Coordinator /// cache holds the commit, so it is only for an op that pushes no Group mirror after it: a Group /// Broadcast, a Target Icon request, a declined invite, or an op on a single database. An ACCEPT /// changes a roster, so it never comes here. fn run_on_authority( authority: &A, - self_guid: u64, + actor: Actor, op: Op, ) -> Result { let (code, target, arg_a, arg_b, arg_c) = op.realm_args(AcceptorFacts::NONE); - authority.realm_group_op(code, self_guid, target, arg_a, arg_b, arg_c) + authority.realm_group_op(code, actor, target, arg_a, arg_b, arg_c) } /// [`run_on_authority`] that returns only after the Coordinator cache holds the commit, so the @@ -693,12 +679,12 @@ fn run_on_authority( /// caller must run on its own thread. `acceptor` is read only for an ACCEPT. fn run_on_authority_visible( authority: &A, - self_guid: u64, + actor: Actor, op: Op, acceptor: AcceptorFacts, ) -> Result { let (code, target, arg_a, arg_b, arg_c) = op.realm_args(acceptor); - authority.realm_group_op_visible(code, self_guid, target, arg_a, arg_b, arg_c) + authority.realm_group_op_visible(code, actor, target, arg_a, arg_b, arg_c) } /// What one party op answered. A [`GroupRefusal`] is a gameplay answer the client renders, so it @@ -1087,6 +1073,9 @@ fn answer_for_session_less< realm: &dyn WorldStore, guid: u64, ) { + let Some(actor) = Actor::new(guid) else { + return; + }; let admission = admit_sessionless_answer(store, guid); match admission { Ok(PartyOutcome::Ran) => {} @@ -1097,7 +1086,7 @@ fn answer_for_session_less< } } let acceptor = AcceptorFacts::of(store, guid); - let joined = match run_on_authority_visible(realm, guid, Op::Accept, acceptor) { + let joined = match run_on_authority_visible(realm, actor, Op::Accept, acceptor) { Ok(PartyOutcome::Ran) => { log::info!("party: session-less {guid} accepted its group invite"); return; @@ -1106,7 +1095,7 @@ fn answer_for_session_less< Err(e) => format!("{e:#}"), }; log::info!("party: session-less {guid} cannot join ({joined}), declining explicitly"); - match run_on_authority(realm, guid, Op::Decline) { + match run_on_authority(realm, actor, Op::Decline) { Ok(PartyOutcome::Ran) => {} outcome => log::warn!( "party: session-less {guid} could neither join nor decline ({outcome:?}). The \ @@ -1115,7 +1104,7 @@ fn answer_for_session_less< } } -/// Run one party op for the session that owns `self_guid`. +/// Run one party op as the session's Character, `actor`. /// /// Unsharded → the pre-realm-core path, verbatim: the player's own connection calls the player-facing /// reducer on the player's own shard, and nothing else happens. A raid op, a leadership op or a @@ -1130,10 +1119,10 @@ pub(crate) fn run< St: CharacterStore + PartyStore + SessionStore + ShardRoutingStore + SocialStore + ?Sized, >( store: &St, - account_id: u64, - self_guid: u64, + actor: Actor, op: Op, ) -> Result { + let self_guid = actor.guid(); if let Op::Invite(target) = op { if cross_faction_invite(store, self_guid, target)? { return Ok(GroupRefusal::WrongFaction.into()); @@ -1148,16 +1137,16 @@ pub(crate) fn run< } let Some(realm) = store.realm_store() else { return match op { - Op::Invite(target) => store.group_invite(account_id, self_guid, target), - Op::Accept => store.group_accept(account_id, self_guid), - Op::Decline => store.group_decline(account_id, self_guid), - Op::Leave => store.group_leave(account_id, self_guid), - Op::Uninvite(target) => store.group_uninvite(account_id, self_guid, target), + Op::Invite(target) => store.group_invite(actor, target), + Op::Accept => store.group_accept(actor), + Op::Decline => store.group_decline(actor), + Op::Leave => store.group_leave(actor), + Op::Uninvite(target) => store.group_uninvite(actor, target), Op::LootMethod { setting, master, threshold, - } => store.group_loot_method(account_id, self_guid, setting, master, threshold), + } => store.group_loot_method(actor, setting, master, threshold), // A raid op, a leadership op or a Group Broadcast has no player-facing reducer. With one // database, the home shard holds the party, so it runs the same `realm_group_op` // Realm-core would. @@ -1170,11 +1159,11 @@ pub(crate) fn run< | Op::ReadyCheckAnswer(_) | Op::TargetIcon { .. } | Op::MinimapPing { .. } - | Op::RandomRoll { .. } => run_on_authority(store, self_guid, op), + | Op::RandomRoll { .. } => run_on_authority(store, actor, op), }; }; if op.is_group_broadcast() { - return run_on_authority(realm.as_ref(), self_guid, op); + return run_on_authority(realm.as_ref(), actor, op); } // The two gates realm-core cannot run for itself, because the directory database holds neither // characters nor live entities: does the target EXIST, and is it ONLINE. The gateway is the only @@ -1199,7 +1188,7 @@ pub(crate) fn run< } let acceptor = AcceptorFacts::for_op(store, self_guid, op); if let PartyOutcome::Refused(refusal) = - run_on_authority_visible(realm.as_ref(), self_guid, op, acceptor)? + run_on_authority_visible(realm.as_ref(), actor, op, acceptor)? { return Ok(PartyOutcome::Refused(refusal)); } @@ -1370,6 +1359,10 @@ pub(crate) fn run_bot_invite( inviter_guid: u64, target_guid: u64, ) -> Result { + // Guid 0 names no Character, so it can act in no party. + let Some(inviter) = Actor::new(inviter_guid) else { + return Ok(GroupRefusal::ActorUnavailable.into()); + }; let owned_realm; let realm: &dyn WorldStore = match store.realm_store() { Some(r) => { @@ -1382,12 +1375,9 @@ pub(crate) fn run_bot_invite( return Ok(refusal.into()); } let before = realm.group_roster(inviter_guid)?; - if let PartyOutcome::Refused(refusal) = run_on_authority_visible( - realm, - inviter_guid, - Op::Invite(target_guid), - AcceptorFacts::NONE, - )? { + if let PartyOutcome::Refused(refusal) = + run_on_authority_visible(realm, inviter, Op::Invite(target_guid), AcceptorFacts::NONE)? + { return Ok(PartyOutcome::Refused(refusal)); } answer_for_session_less(store, realm, target_guid); @@ -1410,6 +1400,9 @@ pub(crate) fn run_bot_invite( /// can shrink a group below two members and reach realm-core's disband branch, which force-resolves /// live loot rolls that the periodic relay may not have promoted yet. pub(crate) fn run_bot_leave(store: &St, leaver_guid: u64) -> Result { + let Some(leaver) = Actor::new(leaver_guid) else { + return Ok(GroupRefusal::ActorUnavailable.into()); + }; let owned_realm; let realm: &dyn WorldStore = match store.realm_store() { Some(r) => { @@ -1418,8 +1411,8 @@ pub(crate) fn run_bot_leave(store: &St, leaver_guid: u64) -> Res } None => store, }; - let leave = run_server_leave(store, realm, leaver_guid, 1, |realm, character_guid| { - run_on_authority_visible(realm, character_guid, Op::Leave, AcceptorFacts::NONE) + let leave = run_server_leave(store, realm, leaver, 1, |realm, leaver| { + run_on_authority_visible(realm, leaver, Op::Leave, AcceptorFacts::NONE) })?; if leave.outcome == PartyOutcome::Ran { sync_membership_mirrors(store, realm, leaver_guid, leave.previous_roster); @@ -1435,15 +1428,16 @@ struct ServerLeave { fn run_server_leave( store: &St, realm: &dyn WorldStore, - leaver_guid: u64, + leaver: Actor, attempts: usize, - leave_party: impl Fn(&dyn WorldStore, u64) -> Result, + leave_party: impl Fn(&dyn WorldStore, Actor) -> Result, ) -> Result { + let leaver_guid = leaver.guid(); let before = realm.group_roster(leaver_guid)?; crate::world::loot::flush_pending_promotions(store, realm); let mut last_error = None; for _ in 0..attempts { - match leave_party(realm, leaver_guid) { + match leave_party(realm, leaver) { Ok(PartyOutcome::Ran) => { return Ok(ServerLeave { outcome: PartyOutcome::Ran, @@ -1511,15 +1505,19 @@ pub(crate) fn cleanup_deleted_character( let Some(realm) = store.party_cleanup_realm()? else { return Ok(DeletedCharacterPartyCleanup::AlreadyClean); }; + // Guid 0 names no Character, so no party holds it. + let Some(character) = Actor::new(character_guid) else { + return Ok(DeletedCharacterPartyCleanup::AlreadyClean); + }; if store.character_exists_on_any_world_shard(character_guid)? { return Ok(DeletedCharacterPartyCleanup::Preserved); } let leave = run_server_leave( store, realm.as_ref(), - character_guid, + character, DELETED_CHARACTER_LEAVE_ATTEMPTS, - |realm, character_guid| realm.deleted_character_party_leave(character_guid), + |realm, character| realm.deleted_character_party_leave(character), )?; match leave.outcome { PartyOutcome::Ran => { @@ -1740,14 +1738,15 @@ pub(crate) fn on_world_entry< >( tx: &SessionTx, store: &St, - self_guid: u64, + character: Actor, ) -> Result<()> { + let self_guid = character.guid(); let Some(roster) = sync_arrival_mirror(store, self_guid)? else { return Ok(()); }; send(tx, render_list(store, self_guid, &roster.list_payload()))?; if roster.kind == GroupKind::Party { - request_target_icons(store, self_guid); + request_target_icons(store, character); } Ok(()) } @@ -1756,7 +1755,7 @@ pub(crate) fn on_world_entry< /// above carries no Target Icons, so ask the party authority for the full list, as the client's /// own `0xFF` request does. The answer rides the group event relay onto the same session writer /// and so lands after the list. A failure costs only the marks, so it logs. -fn request_target_icons(store: &St, self_guid: u64) { +fn request_target_icons(store: &St, character: Actor) { let Some(realm) = store.realm_store() else { return; }; @@ -1764,10 +1763,11 @@ fn request_target_icons(store: &St, self_guid: u icon: lyracore_shared::group::TARGET_ICON_LIST_REQUEST, target: 0, }; - match run_on_authority(realm.as_ref(), self_guid, op) { + match run_on_authority(realm.as_ref(), character, op) { Ok(PartyOutcome::Ran) => {} outcome => log::warn!( - "party: Target Icon list for {self_guid} at world entry not requested: {outcome:?}" + "party: Target Icon list for {} at world entry not requested: {outcome:?}", + character.guid() ), } } diff --git a/gateway/src/world/party_tests.rs b/gateway/src/world/party_tests.rs index 6ca4443c4..00cc2bfe9 100644 --- a/gateway/src/world/party_tests.rs +++ b/gateway/src/world/party_tests.rs @@ -32,8 +32,14 @@ fn topology_after_vim_is_deleted() -> ( ) { let (realm, world, instances, calls) = party_topology(); form_split_party(&world, &instances); - party::run(world.as_ref(), 7, GINGER, party::Op::Invite(TRIN)).expect("invite the survivor"); - party::run(world.as_ref(), 9, TRIN, party::Op::Accept).expect("the survivor accepts"); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(TRIN), + ) + .expect("invite the survivor"); + party::run(world.as_ref(), Actor::new(TRIN).unwrap(), party::Op::Accept) + .expect("the survivor accepts"); let deleted_from_instances = std::sync::Arc::new(WorldFake { topology: TopologyState { @@ -203,8 +209,9 @@ pub(super) fn party_topology() -> ( /// Vim accepts. `pub(super)` (`loot_tests` reuses it — a disband-capable op needs a real /// party to disband). pub(super) fn form_split_party(world: &WorldFake, instances: &WorldFake) { - party::run(world, 7, GINGER, party::Op::Invite(VIM)).expect("the invite crosses"); - party::run(instances, 8, VIM, party::Op::Accept).expect("the accept lands"); + party::run(world, Actor::new(GINGER).unwrap(), party::Op::Invite(VIM)) + .expect("the invite crosses"); + party::run(instances, Actor::new(VIM).unwrap(), party::Op::Accept).expect("the accept lands"); } fn command_intent(bot_guid: u64) -> party::PartyCommandIntent { @@ -240,7 +247,12 @@ fn party_command_abort_configuration_names_only_the_committed_apply_boundary() { #[test] fn a_companion_command_uses_realm_authority_and_the_bots_actual_world_shard() { let (realm, world, instances, _) = party_topology(); - party::run(world.as_ref(), 7, GINGER, party::Op::Invite(FAR_BOT)).unwrap(); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(FAR_BOT), + ) + .unwrap(); let outcome = party::run_party_command_intent(world.as_ref(), &command_intent(FAR_BOT), 9001).unwrap(); assert_eq!(outcome, party::CompanionCommandOutcome::Applied); @@ -266,7 +278,12 @@ fn a_companion_command_uses_realm_authority_and_the_bots_actual_world_shard() { #[test] fn changed_leadership_is_terminal_before_target_application() { let (realm, world, _instances, _) = party_topology(); - party::run(world.as_ref(), 7, GINGER, party::Op::Invite(BOT)).unwrap(); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(BOT), + ) + .unwrap(); let group_id = realm.party.party.lock().unwrap().group_of(GINGER).unwrap(); realm .party @@ -337,7 +354,12 @@ fn a_missing_bot_is_terminal_before_party_authority_is_consulted() { #[test] fn a_nonmember_assist_target_is_refused_by_realm_authority() { let (_realm, world, _instances, _) = party_topology(); - party::run(world.as_ref(), 7, GINGER, party::Op::Invite(BOT)).unwrap(); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(BOT), + ) + .unwrap(); let mut intent = command_intent(BOT); intent.kind = 2; intent.authority_member_guid = TRIN; @@ -354,7 +376,12 @@ fn a_nonmember_assist_target_is_refused_by_realm_authority() { #[test] fn a_stale_target_mirror_cannot_grant_command_authority() { let (_realm, world, _instances, _) = party_topology(); - party::run(world.as_ref(), 7, GINGER, party::Op::Invite(BOT)).unwrap(); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(BOT), + ) + .unwrap(); world .party .mirror @@ -476,8 +503,18 @@ fn a_roster_longer_than_a_raid_is_not_sent_for_command_authority() { #[test] fn realm_admission_rejects_a_roster_changed_after_the_gateway_read() { let (realm, world, instances, _) = party_topology(); - party::run(world.as_ref(), 7, GINGER, party::Op::Invite(BOT)).unwrap(); - party::run(world.as_ref(), 8, GINGER, party::Op::Invite(FAR_BOT)).unwrap(); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(BOT), + ) + .unwrap(); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(FAR_BOT), + ) + .unwrap(); let mut changed = realm.group_roster(GINGER).unwrap().unwrap().member_guids(); changed.retain(|guid| *guid != BOT); *realm.party.party_command_authority_members.lock().unwrap() = Some(changed); @@ -497,7 +534,12 @@ fn realm_admission_rejects_a_roster_changed_after_the_gateway_read() { #[test] fn an_unavailable_receipt_shard_keeps_the_source_intent_pending() { let (_realm, world, instances, _) = party_topology(); - party::run(world.as_ref(), 7, GINGER, party::Op::Invite(FAR_BOT)).unwrap(); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(FAR_BOT), + ) + .unwrap(); *instances.party.party_command_receipt_error.lock().unwrap() = Some("receipt read unavailable".to_string()); @@ -522,7 +564,12 @@ fn an_unavailable_receipt_shard_keeps_the_source_intent_pending() { #[test] fn an_in_transit_holder_is_retried_without_a_missing_bot_result() { let (_realm, world, instances, _) = party_topology(); - party::run(world.as_ref(), 7, GINGER, party::Op::Invite(FAR_BOT)).unwrap(); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(FAR_BOT), + ) + .unwrap(); instances .party .party_command_in_transit @@ -551,7 +598,12 @@ fn an_in_transit_holder_is_retried_without_a_missing_bot_result() { #[test] fn a_remote_party_member_cannot_direct_a_bot_in_another_partition() { let (_realm, world, instances, _) = party_topology(); - party::run(world.as_ref(), 7, GINGER, party::Op::Invite(FAR_BOT)).unwrap(); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(FAR_BOT), + ) + .unwrap(); instances .party .entity_partitions @@ -575,7 +627,12 @@ fn a_remote_party_member_cannot_direct_a_bot_in_another_partition() { #[test] fn a_bot_without_a_partition_cannot_reach_target_application() { let (_realm, world, _instances, _) = party_topology(); - party::run(world.as_ref(), 7, GINGER, party::Op::Invite(BOT)).unwrap(); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(BOT), + ) + .unwrap(); world .party .entity_partitions @@ -597,9 +654,19 @@ fn a_bot_without_a_partition_cannot_reach_target_application() { #[test] fn an_assist_member_without_a_partition_cannot_reach_target_application() { let (_realm, world, _instances, _) = party_topology(); - party::run(world.as_ref(), 7, GINGER, party::Op::Invite(BOT)).unwrap(); - party::run(world.as_ref(), 8, GINGER, party::Op::Invite(TRIN)).unwrap(); - party::run(world.as_ref(), 9, TRIN, party::Op::Accept).unwrap(); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(BOT), + ) + .unwrap(); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(TRIN), + ) + .unwrap(); + party::run(world.as_ref(), Actor::new(TRIN).unwrap(), party::Op::Accept).unwrap(); world .party .entity_partitions @@ -650,7 +717,12 @@ fn an_unsharded_gateway_uses_the_owning_local_party_authority() { #[test] fn a_target_receipt_finishes_a_crashed_attempt_without_reapplying() { let (_realm, world, instances, _) = party_topology(); - party::run(world.as_ref(), 7, GINGER, party::Op::Invite(FAR_BOT)).unwrap(); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(FAR_BOT), + ) + .unwrap(); let intent = command_intent(FAR_BOT); let authority = world .topology @@ -715,7 +787,12 @@ fn an_expired_intent_past_the_receipt_guarantee_reports_unknown() { #[test] fn equal_numeric_intents_from_distinct_modules_have_distinct_receipts() { let (_realm, world, instances, _) = party_topology(); - party::run(world.as_ref(), 7, GINGER, party::Op::Invite(FAR_BOT)).unwrap(); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(FAR_BOT), + ) + .unwrap(); let first = command_intent(FAR_BOT); let mut second = first.clone(); second.source_identity = spacetimedb_sdk::Identity::from_byte_array([8; 32]); @@ -881,7 +958,12 @@ fn a_disbanded_party_is_tombstoned_on_every_world_shard() { ); // Two members: one leaving disbands the party (vanilla — a party of one is no party). - party::run(instances.as_ref(), 8, VIM, party::Op::Leave).expect("Vim leaves"); + party::run( + instances.as_ref(), + Actor::new(VIM).unwrap(), + party::Op::Leave, + ) + .expect("Vim leaves"); assert!( realm.group_roster(GINGER).unwrap().is_none(), @@ -905,10 +987,21 @@ fn leaving_a_party_re_pushes_the_roster_of_the_group_the_leaver_left() { let (realm, world, instances, _calls) = party_topology(); // Three members, so the party SURVIVES the leave and there is a remaining roster to compare. form_split_party(&world, &instances); - party::run(world.as_ref(), 7, GINGER, party::Op::Invite(TRIN)).expect("invite the third"); - party::run(world.as_ref(), 9, TRIN, party::Op::Accept).expect("the third accepts"); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(TRIN), + ) + .expect("invite the third"); + party::run(world.as_ref(), Actor::new(TRIN).unwrap(), party::Op::Accept) + .expect("the third accepts"); - party::run(instances.as_ref(), 8, VIM, party::Op::Leave).expect("Vim leaves"); + party::run( + instances.as_ref(), + Actor::new(VIM).unwrap(), + party::Op::Leave, + ) + .expect("Vim leaves"); let remaining = realm .group_roster(GINGER) @@ -1350,7 +1443,8 @@ fn an_unsharded_gateway_runs_every_party_op_on_the_players_own_shard() { threshold: 3, }, ] { - party::run(store.as_ref(), 7, GINGER, op).expect("the legacy path answers"); + party::run(store.as_ref(), Actor::new(GINGER).unwrap(), op) + .expect("the legacy path answers"); } let log = calls.lock().unwrap().clone(); @@ -1401,7 +1495,8 @@ fn world_entry_pushes_the_authoritative_roster_onto_the_shard_the_player_arrives instances.party.mirror.lock().unwrap().clear(); let (tx, rx) = crate::world::SessionTx::with_depth(0); - party::on_world_entry(&tx, instances.as_ref(), VIM).expect("world entry syncs the party"); + party::on_world_entry(&tx, instances.as_ref(), Actor::new(VIM).unwrap()) + .expect("world entry syncs the party"); let authoritative = realm.group_roster(VIM).unwrap().unwrap(); assert_eq!( @@ -1421,7 +1516,8 @@ fn world_entry_pushes_the_authoritative_roster_onto_the_shard_the_player_arrives fn world_entry_is_a_no_op_for_an_ungrouped_character_and_on_a_single_database() { let (_realm, world, _instances, calls) = party_topology(); let (tx, rx) = crate::world::SessionTx::with_depth(0); - party::on_world_entry(&tx, world.as_ref(), GINGER).expect("ungrouped entry is fine"); + party::on_world_entry(&tx, world.as_ref(), Actor::new(GINGER).unwrap()) + .expect("ungrouped entry is fine"); assert!(rx.try_recv().is_err(), "no party, no party frame"); assert!( !calls @@ -1440,7 +1536,8 @@ fn world_entry_is_a_no_op_for_an_ungrouped_character_and_on_a_single_database() ..Default::default() }); let (tx2, rx2) = crate::world::SessionTx::with_depth(0); - party::on_world_entry(&tx2, solo.as_ref(), GINGER).expect("unsharded entry is fine"); + party::on_world_entry(&tx2, solo.as_ref(), Actor::new(GINGER).unwrap()) + .expect("unsharded entry is fine"); assert!( rx2.try_recv().is_err(), "a single-database login sends no extra packet" @@ -1461,8 +1558,14 @@ fn world_entry_is_a_no_op_for_an_ungrouped_character_and_on_a_single_database() fn world_entry_clears_a_mirror_that_still_lists_a_character_the_authority_dropped() { let (realm, world, instances, _calls) = party_topology(); form_split_party(&world, &instances); - party::run(world.as_ref(), 7, GINGER, party::Op::Invite(TRIN)).expect("invite the third"); - party::run(world.as_ref(), 9, TRIN, party::Op::Accept).expect("the third accepts"); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(TRIN), + ) + .expect("invite the third"); + party::run(world.as_ref(), Actor::new(TRIN).unwrap(), party::Op::Accept) + .expect("the third accepts"); let group_id = realm.group_roster(VIM).unwrap().unwrap().group_id; // Vim leaves while the instances shard is unreachable: the authority commits, that shard's push @@ -1478,7 +1581,8 @@ fn world_entry_clears_a_mirror_that_still_lists_a_character_the_authority_droppe // Vim comes back to that shard. This is the "or world entry" half of the self-healing promise. let (tx, _rx) = crate::world::SessionTx::with_depth(0); - party::on_world_entry(&tx, instances.as_ref(), VIM).expect("world entry is fine for a loner"); + party::on_world_entry(&tx, instances.as_ref(), Actor::new(VIM).unwrap()) + .expect("world entry is fine for a loner"); assert_eq!( instances.group_roster(VIM).unwrap(), @@ -1506,11 +1610,21 @@ fn an_invite_to_a_missing_or_offline_target_never_reaches_realm_core() { let (realm, world, _instances, _calls) = party_topology(); assert_eq!( - party::run(world.as_ref(), 7, GINGER, party::Op::Invite(DORMANT)).unwrap(), + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(DORMANT) + ) + .unwrap(), PartyOutcome::Refused(GroupRefusal::TargetOffline) ); assert_eq!( - party::run(world.as_ref(), 7, GINGER, party::Op::Invite(999)).unwrap(), + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(999) + ) + .unwrap(), PartyOutcome::Refused(GroupRefusal::NoSuchPlayer) ); @@ -1585,11 +1699,21 @@ fn a_cross_faction_invite_is_refused_before_realm_core() { } assert_eq!( - party::run(world.as_ref(), 7, GINGER, party::Op::Invite(GRUNT)).unwrap(), + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(GRUNT) + ) + .unwrap(), PartyOutcome::Refused(GroupRefusal::WrongFaction) ); assert_eq!( - party::run(world.as_ref(), 7, GINGER, party::Op::Invite(SLEEPING_GRUNT)).unwrap(), + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(SLEEPING_GRUNT) + ) + .unwrap(), PartyOutcome::Refused(GroupRefusal::TargetOffline), "an offline target is refused as offline first, as vanilla's online lookup does" ); @@ -1617,7 +1741,12 @@ fn an_unsharded_gateway_refuses_a_cross_faction_invite_too() { ..Default::default() }); assert_eq!( - party::run(store.as_ref(), 7, GINGER, party::Op::Invite(GRUNT)).unwrap(), + party::run( + store.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(GRUNT) + ) + .unwrap(), PartyOutcome::Refused(GroupRefusal::WrongFaction) ); assert!( @@ -1650,8 +1779,12 @@ fn a_playerbot_is_invitable_because_the_online_gate_reads_the_entity_not_the_ses "fixture: …but its live entity is right there" ); - party::run(world.as_ref(), 7, GINGER, party::Op::Invite(BOT)) - .expect("the invite gate must read the LIVE ENTITY, exactly as the module's own gate does"); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(BOT), + ) + .expect("the invite gate must read the LIVE ENTITY, exactly as the module's own gate does"); assert_eq!( realm.party.party.lock().unwrap().ops.first().copied(), Some(( @@ -1676,7 +1809,12 @@ fn a_players_invite_to_a_session_less_bot_is_answered_by_the_bot_itself() { use lyracore_shared::group::realm_op; let (realm, world, _instances, _calls) = party_topology(); - party::run(world.as_ref(), 7, GINGER, party::Op::Invite(BOT)).expect("the invite lands"); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(BOT), + ) + .expect("the invite lands"); let party_state = realm.party.party.lock().unwrap(); let group_id = party_state @@ -1752,7 +1890,12 @@ fn a_stale_character_row_on_another_shard_cannot_make_a_logged_in_player_look_se *shard.topology.peers.lock().unwrap() = vec![home.clone(), far.clone()]; } // …and end to end: an inviter on the far shard must leave that player's dialog alone. - party::run(far.as_ref(), 9, VIM, party::Op::Invite(SEEDED)).expect("the invite itself is fine"); + party::run( + far.as_ref(), + Actor::new(VIM).unwrap(), + party::Op::Invite(SEEDED), + ) + .expect("the invite itself is fine"); let state = realm.party.party.lock().unwrap(); assert_eq!( state.ops.clone(), @@ -1782,7 +1925,12 @@ fn a_stale_character_row_on_another_shard_cannot_make_a_logged_in_player_look_se #[test] fn the_bots_new_membership_is_mirrored_onto_its_own_shard_by_the_same_op() { let (realm, world, instances, _calls) = party_topology(); - party::run(world.as_ref(), 7, GINGER, party::Op::Invite(BOT)).expect("the invite lands"); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(BOT), + ) + .expect("the invite lands"); let group_id = realm .party .party @@ -1826,8 +1974,12 @@ fn the_bots_new_membership_is_mirrored_onto_its_own_shard_by_the_same_op() { #[test] fn a_bot_standing_on_another_shard_answers_the_invite_too() { let (realm, world, _instances, _calls) = party_topology(); - party::run(world.as_ref(), 7, GINGER, party::Op::Invite(FAR_BOT)) - .expect("a cross-shard bot invite lands"); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(FAR_BOT), + ) + .expect("a cross-shard bot invite lands"); let state = realm.party.party.lock().unwrap(); let group_id = state.group_of(GINGER).expect("Ginger's party formed"); assert_eq!( @@ -1844,7 +1996,12 @@ fn a_bot_standing_on_another_shard_answers_the_invite_too() { fn a_real_players_invite_dialog_is_left_for_their_own_client_to_answer() { use lyracore_shared::group::realm_op; let (realm, world, _instances, _calls) = party_topology(); - party::run(world.as_ref(), 7, GINGER, party::Op::Invite(TRIN)).expect("the invite lands"); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(TRIN), + ) + .expect("the invite lands"); let state = realm.party.party.lock().unwrap(); assert_eq!( @@ -1876,8 +2033,12 @@ fn a_bot_that_cannot_join_declines_out_loud_instead_of_leaving_the_dialog_hangin let (realm, world, _instances, _calls) = party_topology_with(None, Some("the party is already full")); - party::run(world.as_ref(), 7, GINGER, party::Op::Invite(BOT)) - .expect("a bot that cannot join must not fail the PLAYER's invite — it already committed"); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(BOT), + ) + .expect("a bot that cannot join must not fail the PLAYER's invite — it already committed"); let state = realm.party.party.lock().unwrap(); assert_eq!( @@ -1909,7 +2070,12 @@ fn a_bot_that_cannot_join_declines_out_loud_instead_of_leaving_the_dialog_hangin #[test] fn the_bot_answers_within_the_invite_op_itself_with_no_second_call() { let (realm, world, _instances, calls) = party_topology(); - party::run(world.as_ref(), 7, GINGER, party::Op::Invite(BOT)).expect("the invite lands"); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(BOT), + ) + .expect("the invite lands"); assert!( realm.party.party.lock().unwrap().group_of(BOT).is_some(), "joined already" @@ -1937,8 +2103,7 @@ fn every_party_op_reaches_realm_core_in_its_declared_argument_slots() { form_split_party(&world, &instances); party::run( world.as_ref(), - 7, - GINGER, + Actor::new(GINGER).unwrap(), party::Op::LootMethod { setting: 2, master: VIM, @@ -1946,8 +2111,18 @@ fn every_party_op_reaches_realm_core_in_its_declared_argument_slots() { }, ) .expect("the leader sets master loot"); - party::run(world.as_ref(), 7, GINGER, party::Op::RaidConvert).expect("convert"); - party::run(world.as_ref(), 7, GINGER, party::Op::Uninvite(VIM)).expect("kick"); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::RaidConvert, + ) + .expect("convert"); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Uninvite(VIM), + ) + .expect("kick"); assert_eq!( realm.party.party.lock().unwrap().ops.clone(), @@ -2139,9 +2314,18 @@ fn a_shard_that_refuses_the_mirror_does_not_fail_the_party_op() { let (realm, world, instances, _calls) = party_topology_with(Some("instances is unreachable"), None); - party::run(world.as_ref(), 7, GINGER, party::Op::Invite(VIM)) - .expect("the invite must succeed even though one shard cannot be mirrored"); - party::run(instances.as_ref(), 8, VIM, party::Op::Accept).expect("and so must the accept"); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(VIM), + ) + .expect("the invite must succeed even though one shard cannot be mirrored"); + party::run( + instances.as_ref(), + Actor::new(VIM).unwrap(), + party::Op::Accept, + ) + .expect("and so must the accept"); assert!( realm.group_roster(GINGER).unwrap().is_some(), @@ -2539,7 +2723,12 @@ fn suppressed_automatic_answers_leave_human_invitations_pending_on_realm_core() .insert(FAR_BOT, GroupRefusal::ActionSuppressed); assert_eq!( - party::run(world.as_ref(), 7, GINGER, party::Op::Invite(FAR_BOT)).unwrap(), + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(FAR_BOT) + ) + .unwrap(), PartyOutcome::Ran ); @@ -2581,7 +2770,12 @@ fn unavailable_admission_leaves_the_invitation_unanswered() { .unwrap() .push(FAR_BOT); - party::run(world.as_ref(), 7, GINGER, party::Op::Invite(FAR_BOT)).unwrap(); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(FAR_BOT), + ) + .unwrap(); let state = realm.party.party.lock().unwrap(); assert!(state.group_of(FAR_BOT).is_none()); @@ -2601,7 +2795,12 @@ fn current_admission_refuses_a_stale_sessionless_presence_read() { .unwrap() .insert(FAR_BOT, GroupRefusal::ActorUnavailable); - party::run(world.as_ref(), 7, GINGER, party::Op::Invite(FAR_BOT)).unwrap(); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(FAR_BOT), + ) + .unwrap(); let state = realm.party.party.lock().unwrap(); assert!(state.group_of(FAR_BOT).is_none()); @@ -2646,7 +2845,12 @@ fn a_controller_selection_after_admission_does_not_recall_the_answer() { .unwrap() .push(FAR_BOT); - party::run(world.as_ref(), 7, GINGER, party::Op::Invite(FAR_BOT)).unwrap(); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(FAR_BOT), + ) + .unwrap(); assert!(realm .party @@ -2731,11 +2935,21 @@ fn mirror_calls(calls: &ShardCallLog) -> usize { fn a_leader_converts_the_party_on_realm_core_and_every_shard_mirrors_the_raid() { let (realm, world, instances, _calls) = party_topology(); form_split_party(&world, &instances); - party::run(world.as_ref(), 7, GINGER, party::Op::Invite(TRIN)).unwrap(); - party::run(world.as_ref(), 9, TRIN, party::Op::Accept).unwrap(); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(TRIN), + ) + .unwrap(); + party::run(world.as_ref(), Actor::new(TRIN).unwrap(), party::Op::Accept).unwrap(); let events_before = realm.party.party.lock().unwrap().events.len(); - let outcome = party::run(world.as_ref(), 7, GINGER, party::Op::RaidConvert).unwrap(); + let outcome = party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::RaidConvert, + ) + .unwrap(); assert_eq!(outcome, PartyOutcome::Ran); let state = realm.party.party.lock().unwrap(); @@ -2788,10 +3002,20 @@ fn a_leader_converts_the_party_on_realm_core_and_every_shard_mirrors_the_raid() fn converting_a_raid_again_pushes_no_mirror() { let (_realm, world, instances, calls) = party_topology(); form_split_party(&world, &instances); - party::run(world.as_ref(), 7, GINGER, party::Op::RaidConvert).unwrap(); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::RaidConvert, + ) + .unwrap(); let mirrors_before = mirror_calls(&calls); - let outcome = party::run(world.as_ref(), 7, GINGER, party::Op::RaidConvert).unwrap(); + let outcome = party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::RaidConvert, + ) + .unwrap(); assert_eq!(outcome, PartyOutcome::Ran, "the client still hears success"); assert_eq!(mirror_calls(&calls), mirrors_before); @@ -2805,7 +3029,12 @@ fn a_member_who_does_not_lead_cannot_convert_and_no_mirror_is_pushed() { let events_before = realm.party.party.lock().unwrap().events.len(); let mirrors_before = mirror_calls(&calls); - let outcome = party::run(instances.as_ref(), 8, VIM, party::Op::RaidConvert).unwrap(); + let outcome = party::run( + instances.as_ref(), + Actor::new(VIM).unwrap(), + party::Op::RaidConvert, + ) + .unwrap(); assert_eq!(outcome, PartyOutcome::Refused(GroupRefusal::NotLeader)); assert_eq!( @@ -2839,7 +3068,12 @@ fn an_unsharded_gateway_converts_through_realm_group_op_on_its_own_shard() { p.members.push((5, VIM)); } - let outcome = party::run(store.as_ref(), 7, GINGER, party::Op::RaidConvert).unwrap(); + let outcome = party::run( + store.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::RaidConvert, + ) + .unwrap(); assert_eq!(outcome, PartyOutcome::Ran); assert_eq!( @@ -2870,8 +3104,14 @@ fn a_raid_joiner_past_a_full_first_subgroup_shows_subgroup_one_in_every_list() { } p.raids.push(9); } - party::run(world.as_ref(), 7, GINGER, party::Op::Invite(TRIN)).expect("the leader invites"); - party::run(world.as_ref(), 9, TRIN, party::Op::Accept).expect("the sixth member accepts"); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(TRIN), + ) + .expect("the leader invites"); + party::run(world.as_ref(), Actor::new(TRIN).unwrap(), party::Op::Accept) + .expect("the sixth member accepts"); let authority = realm.group_roster(GINGER).unwrap().unwrap(); let joiner = authority @@ -3070,8 +3310,13 @@ fn party_of_three() -> ( ) { let (realm, world, instances, calls) = party_topology(); form_split_party(&world, &instances); - party::run(world.as_ref(), 7, GINGER, party::Op::Invite(TRIN)).unwrap(); - party::run(world.as_ref(), 9, TRIN, party::Op::Accept).unwrap(); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(TRIN), + ) + .unwrap(); + party::run(world.as_ref(), Actor::new(TRIN).unwrap(), party::Op::Accept).unwrap(); (realm, world, instances, calls) } @@ -3093,7 +3338,12 @@ fn the_leader_passes_the_lead_to_a_member_live_on_the_far_shard() { let (realm, world, instances, _calls) = party_of_three(); let events_before = realm.party.party.lock().unwrap().events.len(); - let outcome = party::run(world.as_ref(), 7, GINGER, party::Op::SetLeader(VIM)).unwrap(); + let outcome = party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::SetLeader(VIM), + ) + .unwrap(); assert_eq!(outcome, PartyOutcome::Ran); let authority = realm.group_roster(GINGER).unwrap().unwrap(); @@ -3122,7 +3372,12 @@ fn the_lead_passes_only_to_a_member_live_on_some_shard() { let ops_before = realm.party.party.lock().unwrap().ops.len(); let mirrors_before = mirror_calls(&calls); - let outcome = party::run(world.as_ref(), 7, GINGER, party::Op::SetLeader(DORMANT)).unwrap(); + let outcome = party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::SetLeader(DORMANT), + ) + .unwrap(); assert_eq!(outcome, PartyOutcome::Refused(GroupRefusal::TargetOffline)); assert_eq!(realm.party.party.lock().unwrap().ops.len(), ops_before); @@ -3133,7 +3388,12 @@ fn the_lead_passes_only_to_a_member_live_on_some_shard() { ); for (leader, next) in [(GINGER, TRIN), (TRIN, VIM)] { - let outcome = party::run(world.as_ref(), 7, leader, party::Op::SetLeader(next)).unwrap(); + let outcome = party::run( + world.as_ref(), + Actor::new(leader).unwrap(), + party::Op::SetLeader(next), + ) + .unwrap(); assert_eq!( outcome, PartyOutcome::Ran, @@ -3151,7 +3411,12 @@ fn the_lead_passes_only_to_a_member_live_on_some_shard() { #[test] fn leadership_ops_reach_realm_core_in_their_declared_argument_slots() { let (realm, world, _instances, _calls) = party_of_three(); - party::run(world.as_ref(), 7, GINGER, party::Op::RaidConvert).unwrap(); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::RaidConvert, + ) + .unwrap(); let ops_before = realm.party.party.lock().unwrap().ops.len(); for op in [ @@ -3165,7 +3430,7 @@ fn leadership_ops_reach_realm_core_in_their_declared_argument_slots() { }, party::Op::SetLeader(VIM), ] { - party::run(world.as_ref(), 7, GINGER, op).unwrap(); + party::run(world.as_ref(), Actor::new(GINGER).unwrap(), op).unwrap(); } assert_eq!( @@ -3183,14 +3448,24 @@ fn leadership_ops_reach_realm_core_in_their_declared_argument_slots() { #[test] fn a_promoted_assistant_shows_0x80_in_every_list_and_every_mirror() { let (realm, world, instances, _calls) = party_of_three(); - party::run(world.as_ref(), 7, GINGER, party::Op::RaidConvert).unwrap(); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::RaidConvert, + ) + .unwrap(); let assistant = |promote| party::Op::SetAssistant { target: VIM, promote, }; for (promote, flags) in [(true, 0x80), (false, 0x00)] { - let outcome = party::run(world.as_ref(), 7, GINGER, assistant(promote)).unwrap(); + let outcome = party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + assistant(promote), + ) + .unwrap(); assert_eq!(outcome, PartyOutcome::Ran); let authority = realm.group_roster(GINGER).unwrap().unwrap(); for shard in [&world, &instances] { @@ -3214,15 +3489,20 @@ fn a_promoted_assistant_shows_0x80_in_every_list_and_every_mirror() { #[test] fn repeating_a_promotion_pushes_no_mirror() { let (_realm, world, _instances, calls) = party_of_three(); - party::run(world.as_ref(), 7, GINGER, party::Op::RaidConvert).unwrap(); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::RaidConvert, + ) + .unwrap(); let promote = party::Op::SetAssistant { target: VIM, promote: true, }; - party::run(world.as_ref(), 7, GINGER, promote).unwrap(); + party::run(world.as_ref(), Actor::new(GINGER).unwrap(), promote).unwrap(); let mirrors_before = mirror_calls(&calls); - let outcome = party::run(world.as_ref(), 7, GINGER, promote).unwrap(); + let outcome = party::run(world.as_ref(), Actor::new(GINGER).unwrap(), promote).unwrap(); assert_eq!(outcome, PartyOutcome::Ran); assert_eq!(mirror_calls(&calls), mirrors_before); @@ -3257,8 +3537,13 @@ fn an_unsharded_gateway_runs_leadership_ops_on_its_own_shard() { promote: true, }; - party::run(store.as_ref(), 7, GINGER, promote).unwrap(); - party::run(store.as_ref(), 7, GINGER, party::Op::SetLeader(VIM)).unwrap(); + party::run(store.as_ref(), Actor::new(GINGER).unwrap(), promote).unwrap(); + party::run( + store.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::SetLeader(VIM), + ) + .unwrap(); let ran: Vec<_> = calls .lock() @@ -3693,12 +3978,16 @@ fn subgroup_ops_reach_realm_core_in_their_declared_argument_slots() { use lyracore_shared::group::realm_op; let (realm, world, instances, _calls) = party_topology(); form_split_party(&world, &instances); - party::run(world.as_ref(), 7, GINGER, party::Op::RaidConvert).unwrap(); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::RaidConvert, + ) + .unwrap(); party::run( world.as_ref(), - 7, - GINGER, + Actor::new(GINGER).unwrap(), party::Op::ChangeSubgroup { target: VIM, subgroup: 2, @@ -3707,8 +3996,7 @@ fn subgroup_ops_reach_realm_core_in_their_declared_argument_slots() { .expect("the leader moves Vim"); party::run( world.as_ref(), - 7, - GINGER, + Actor::new(GINGER).unwrap(), party::Op::SwapSubgroup { first: GINGER, second: VIM, @@ -3732,7 +4020,12 @@ fn subgroup_ops_reach_realm_core_in_their_declared_argument_slots() { fn a_leader_moves_a_member_to_another_subgroup_and_every_shard_mirrors_the_slot() { let (realm, world, instances, _calls) = party_topology(); form_split_party(&world, &instances); - party::run(world.as_ref(), 7, GINGER, party::Op::RaidConvert).unwrap(); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::RaidConvert, + ) + .unwrap(); realm .party .party @@ -3744,8 +4037,7 @@ fn a_leader_moves_a_member_to_another_subgroup_and_every_shard_mirrors_the_slot( let outcome = party::run( world.as_ref(), - 7, - GINGER, + Actor::new(GINGER).unwrap(), party::Op::ChangeSubgroup { target: VIM, subgroup: 2, @@ -3779,13 +4071,17 @@ fn a_leader_moves_a_member_to_another_subgroup_and_every_shard_mirrors_the_slot( fn changing_into_the_same_subgroup_pushes_no_mirror() { let (_realm, world, instances, calls) = party_topology(); form_split_party(&world, &instances); - party::run(world.as_ref(), 7, GINGER, party::Op::RaidConvert).unwrap(); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::RaidConvert, + ) + .unwrap(); let mirrors_before = mirror_calls(&calls); let outcome = party::run( world.as_ref(), - 7, - GINGER, + Actor::new(GINGER).unwrap(), party::Op::ChangeSubgroup { target: VIM, subgroup: 0, @@ -3802,9 +4098,19 @@ fn changing_into_the_same_subgroup_pushes_no_mirror() { fn an_assistant_can_move_a_member_a_plain_member_cannot() { let (realm, world, instances, _calls) = party_topology(); form_split_party(&world, &instances); - party::run(world.as_ref(), 7, GINGER, party::Op::Invite(TRIN)).unwrap(); - party::run(world.as_ref(), 9, TRIN, party::Op::Accept).unwrap(); - party::run(world.as_ref(), 7, GINGER, party::Op::RaidConvert).unwrap(); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(TRIN), + ) + .unwrap(); + party::run(world.as_ref(), Actor::new(TRIN).unwrap(), party::Op::Accept).unwrap(); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::RaidConvert, + ) + .unwrap(); realm .party .party @@ -3815,8 +4121,7 @@ fn an_assistant_can_move_a_member_a_plain_member_cannot() { let outcome = party::run( instances.as_ref(), - 8, - VIM, + Actor::new(VIM).unwrap(), party::Op::ChangeSubgroup { target: GINGER, subgroup: 3, @@ -3840,8 +4145,7 @@ fn an_assistant_can_move_a_member_a_plain_member_cannot() { let before = realm.group_roster(GINGER).unwrap().unwrap(); let outcome = party::run( world.as_ref(), - 9, - TRIN, + Actor::new(TRIN).unwrap(), party::Op::ChangeSubgroup { target: GINGER, subgroup: 4, @@ -3863,7 +4167,12 @@ fn an_assistant_can_move_a_member_a_plain_member_cannot() { fn an_assistant_moves_a_member_and_marks_a_target_icon() { let (realm, world, instances, _calls) = party_topology(); form_split_party(&world, &instances); - party::run(world.as_ref(), 7, GINGER, party::Op::RaidConvert).unwrap(); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::RaidConvert, + ) + .unwrap(); realm .party .party @@ -3874,8 +4183,7 @@ fn an_assistant_moves_a_member_and_marks_a_target_icon() { let moved = party::run( instances.as_ref(), - 8, - VIM, + Actor::new(VIM).unwrap(), party::Op::ChangeSubgroup { target: GINGER, subgroup: 3, @@ -3898,8 +4206,7 @@ fn an_assistant_moves_a_member_and_marks_a_target_icon() { let marked = party::run( instances.as_ref(), - 8, - VIM, + Actor::new(VIM).unwrap(), party::Op::TargetIcon { icon: 7, target: 900, @@ -3923,13 +4230,17 @@ fn an_assistant_moves_a_member_and_marks_a_target_icon() { fn a_move_to_subgroup_eight_is_refused() { let (realm, world, instances, _calls) = party_topology(); form_split_party(&world, &instances); - party::run(world.as_ref(), 7, GINGER, party::Op::RaidConvert).unwrap(); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::RaidConvert, + ) + .unwrap(); let before = realm.group_roster(GINGER).unwrap().unwrap(); let outcome = party::run( world.as_ref(), - 7, - GINGER, + Actor::new(GINGER).unwrap(), party::Op::ChangeSubgroup { target: VIM, subgroup: 8, @@ -3953,8 +4264,7 @@ fn subgroup_ops_in_a_party_are_refused_and_change_nothing() { let change = party::run( world.as_ref(), - 7, - GINGER, + Actor::new(GINGER).unwrap(), party::Op::ChangeSubgroup { target: VIM, subgroup: 1, @@ -3963,8 +4273,7 @@ fn subgroup_ops_in_a_party_are_refused_and_change_nothing() { .unwrap(); let swap = party::run( world.as_ref(), - 7, - GINGER, + Actor::new(GINGER).unwrap(), party::Op::SwapSubgroup { first: GINGER, second: VIM, @@ -4005,8 +4314,7 @@ fn a_move_into_a_full_subgroup_is_refused() { let outcome = party::run( world.as_ref(), - 7, - GINGER, + Actor::new(GINGER).unwrap(), party::Op::ChangeSubgroup { target: GINGER, subgroup: 1, @@ -4029,8 +4337,7 @@ fn swapping_members_of_two_full_subgroups_succeeds_and_sends_one_list_per_member let outcome = party::run( world.as_ref(), - 7, - GINGER, + Actor::new(GINGER).unwrap(), party::Op::SwapSubgroup { first: GINGER, second: VIM, @@ -4070,13 +4377,17 @@ fn swapping_members_of_two_full_subgroups_succeeds_and_sends_one_list_per_member fn swapping_members_of_one_subgroup_pushes_no_mirror() { let (_realm, world, instances, calls) = party_topology(); form_split_party(&world, &instances); - party::run(world.as_ref(), 7, GINGER, party::Op::RaidConvert).unwrap(); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::RaidConvert, + ) + .unwrap(); let mirrors_before = mirror_calls(&calls); let outcome = party::run( world.as_ref(), - 7, - GINGER, + Actor::new(GINGER).unwrap(), party::Op::SwapSubgroup { first: GINGER, second: VIM, @@ -4168,7 +4479,7 @@ fn a_group_broadcast_runs_once_on_realm_core_and_pushes_no_mirror() { .group_roster_reads .load(std::sync::atomic::Ordering::SeqCst); - let outcome = party::run(world.as_ref(), 7, GINGER, op).unwrap(); + let outcome = party::run(world.as_ref(), Actor::new(GINGER).unwrap(), op).unwrap(); assert_eq!(outcome, PartyOutcome::Ran, "{op:?}"); assert_eq!( @@ -4214,7 +4525,7 @@ fn an_unsharded_gateway_runs_every_group_broadcast_on_its_own_shard() { for (op, (code, target, arg_a, arg_b, arg_c)) in broadcast_ops() { calls.lock().unwrap().clear(); - let outcome = party::run(store.as_ref(), 7, GINGER, op).unwrap(); + let outcome = party::run(store.as_ref(), Actor::new(GINGER).unwrap(), op).unwrap(); assert_eq!(outcome, PartyOutcome::Ran, "{op:?}"); assert_eq!( @@ -4633,9 +4944,19 @@ fn accepts(realm: &WorldFake) -> Vec<(u8, u64, u64, u8, u8, u64)> { #[test] fn a_client_accept_carries_the_acceptors_class() { let (realm, world, instances) = dwarf_priest_topology(); - party::run(world.as_ref(), 7, GINGER, party::Op::Invite(VIM)).unwrap(); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(VIM), + ) + .unwrap(); - party::run(instances.as_ref(), 8, VIM, party::Op::Accept).unwrap(); + party::run( + instances.as_ref(), + Actor::new(VIM).unwrap(), + party::Op::Accept, + ) + .unwrap(); assert_eq!(accepts(&realm), [(realm_op::ACCEPT, VIM, 0, 5, 0, 0)]); } @@ -4646,7 +4967,12 @@ fn a_client_accept_carries_the_acceptors_class() { fn a_playerbot_accept_carries_its_class_from_its_own_shard() { let (realm, world, _instances) = dwarf_priest_topology(); - party::run(world.as_ref(), 7, GINGER, party::Op::Invite(FAR_BOT)).unwrap(); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(FAR_BOT), + ) + .unwrap(); assert_eq!(accepts(&realm), [(realm_op::ACCEPT, FAR_BOT, 0, 5, 0, 0)]); } @@ -4657,7 +4983,7 @@ fn an_accept_nobody_can_name_conveys_zero_class() { let (realm, world, _instances, _calls) = party_topology(); realm.party.party.lock().unwrap().invites.push((99, GINGER)); - party::run(world.as_ref(), 9, 99, party::Op::Accept).unwrap(); + party::run(world.as_ref(), Actor::new(99).unwrap(), party::Op::Accept).unwrap(); assert_eq!(accepts(&realm), [(realm_op::ACCEPT, 99, 0, 0, 0, 0)]); } @@ -4731,7 +5057,7 @@ fn world_entry_asks_for_a_partys_target_icons_after_the_list() { form_split_party(&world, &instances); let (tx, rx) = crate::world::SessionTx::with_depth(0); - party::on_world_entry(&tx, instances.as_ref(), VIM).expect("world entry"); + party::on_world_entry(&tx, instances.as_ref(), Actor::new(VIM).unwrap()).expect("world entry"); group_list(rx.try_recv().expect("the list goes out first")); assert_eq!( @@ -4740,10 +5066,15 @@ fn world_entry_asks_for_a_partys_target_icons_after_the_list() { "then the list request, whose answer rides the relay behind the list" ); - party::run(world.as_ref(), 7, GINGER, party::Op::RaidConvert).unwrap(); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::RaidConvert, + ) + .unwrap(); let ops_before = realm.party.party.lock().unwrap().ops.len(); let (tx, _rx) = crate::world::SessionTx::with_depth(0); - party::on_world_entry(&tx, instances.as_ref(), VIM).expect("world entry"); + party::on_world_entry(&tx, instances.as_ref(), Actor::new(VIM).unwrap()).expect("world entry"); assert_eq!( realm.party.party.lock().unwrap().ops.len(), ops_before, @@ -4798,8 +5129,13 @@ fn mirror_lists(shard: &WorldFake, group_id: u64, member: u64) -> bool { fn lagging_three_member_party() -> (std::sync::Arc, std::sync::Arc, u64) { let (realm, world, instances, _) = party_topology(); form_split_party(&world, &instances); - party::run(world.as_ref(), 7, GINGER, party::Op::Invite(TRIN)).unwrap(); - party::run(world.as_ref(), 9, TRIN, party::Op::Accept).unwrap(); + party::run( + world.as_ref(), + Actor::new(GINGER).unwrap(), + party::Op::Invite(TRIN), + ) + .unwrap(); + party::run(world.as_ref(), Actor::new(TRIN).unwrap(), party::Op::Accept).unwrap(); let group_id = realm.group_roster(VIM).unwrap().unwrap().group_id; realm .party @@ -4816,14 +5152,29 @@ fn lagging_three_member_party() -> (std::sync::Arc, std::sync::Arc crate::stdb::ReducerCallError| WorldFake { + session: SessionState { + client_command_error: Some(error), + ..Default::default() + }, + ..Default::default() + }; + + let refusing = answering(|op| crate::stdb::ReducerCallError::refused(op, "UNKNOWN_COMMAND")); + assert!(handle_addon_message(&refusing, &conn, text).is_ok()); + + let lost = answering(crate::stdb::ReducerCallError::transport_lost); + assert!(handle_addon_message(&lost, &conn, text).is_err()); +} diff --git a/gateway/src/world/social.rs b/gateway/src/world/social.rs index cc397072b..b8082c0a9 100644 --- a/gateway/src/world/social.rs +++ b/gateway/src/world/social.rs @@ -3,7 +3,7 @@ //! shape as the rest of the dispatch chain. use super::party::PartyOutcome; -use super::{party, presence, send, who, Outbound, SessionTx, WorldConn, WorldState}; +use super::{party, presence, send, who, Actor, Outbound, SessionTx, WorldConn, WorldState}; use crate::codec; use crate::world::{CharacterStore, GuildActionStore, PartyStore, SessionStore, ShardRoutingStore}; use anyhow::Result; @@ -80,42 +80,22 @@ pub(crate) trait SocialStore: Send + Sync { /// `CMSG_ADD_FRIEND` (the name is already resolved to `target_guid` by the gateway). /// `target_race` is the target's own Speaker Fact, read realm-wide by the gateway: the Module's /// Enemy Gate needs it and holds no Characters of its own to read it from. - fn add_friend( - &self, - account_id: u64, - self_guid: u64, - target_guid: u64, - target_race: u8, - ) -> Result; + fn add_friend(&self, actor: Actor, target_guid: u64, target_race: u8) + -> Result; /// `CMSG_DEL_FRIEND`. - fn del_friend( - &self, - account_id: u64, - self_guid: u64, - target_guid: u64, - ) -> Result; + fn del_friend(&self, actor: Actor, target_guid: u64) -> Result; /// `CMSG_ADD_IGNORE` (the name is already resolved to `target_guid` by the gateway). - fn add_ignore( - &self, - account_id: u64, - self_guid: u64, - target_guid: u64, - ) -> Result; + fn add_ignore(&self, actor: Actor, target_guid: u64) -> Result; /// `CMSG_DEL_IGNORE`. - fn del_ignore( - &self, - account_id: u64, - self_guid: u64, - target_guid: u64, - ) -> Result; + fn del_ignore(&self, actor: Actor, target_guid: u64) -> Result; } /// What one contact-list op answered. A [`ContactRefusal`] is a gameplay answer `SMSG_FRIEND_STATUS` -/// renders; a timeout, transport failure, or untagged reducer error stays `Err` and ends the -/// session, because the durable outcome is then unknown. +/// renders; a Transport Loss or an untagged Refusal stays `Err` and ends the session, because the +/// durable outcome is then unknown. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum ContactOutcome { Done, @@ -191,51 +171,36 @@ pub(super) fn handle_social< } // 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. + // 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) => { - let (result, guid, online) = resolve_add_contact( - store, - conn.account_id, - self_guid(conn).unwrap_or(0), - &c.name, - false, - )?; - let (opcode, body) = codec::build_friend_status_raw(result, guid, online); - send(tx, Outbound::Raw { opcode, body })?; + 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 })?; + } } ClientOpcodeMessage::CMSG_ADD_IGNORE(c) => { - let (result, guid, online) = resolve_add_contact( - store, - conn.account_id, - self_guid(conn).unwrap_or(0), - &c.name, - true, - )?; - let (opcode, body) = codec::build_friend_status_raw(result, guid, online); - send(tx, Outbound::Raw { opcode, body })?; + 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 })?; + } } // Remove a friend/ignore by guid (the client already has it from the list row). ClientOpcodeMessage::CMSG_DEL_FRIEND(c) => { - let (result, guid) = resolve_del_contact( - store, - conn.account_id, - self_guid(conn).unwrap_or(0), - c.guid.guid(), - false, - )?; - let (opcode, body) = codec::build_friend_status_raw(result, guid, None); - send(tx, Outbound::Raw { opcode, body })?; + 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_DEL_IGNORE(c) => { - let (result, guid) = resolve_del_contact( - store, - conn.account_id, - self_guid(conn).unwrap_or(0), - c.guid.guid(), - true, - )?; - let (opcode, body) = codec::build_friend_status_raw(result, guid, None); - send(tx, Outbound::Raw { opcode, body })?; + 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 })?; + } } // Party/group. The invite/uninvite names resolve gateway-side (the add_friend // convention); outcomes echo as SMSG_PARTY_COMMAND_RESULT. The cross-player packets @@ -247,13 +212,10 @@ pub(super) fn handle_social< // 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_guid(conn), presence::resolve_by_name(store, &c.name)?) { - (Some(me), Some(guid)) => party_result(party::run( - store, - conn.account_id, - me, - party::Op::Invite(guid), - )?), + 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))?) + } _ => PartyResult::BadPlayerName, }; send( @@ -269,13 +231,10 @@ pub(super) fn handle_social< run_answering_refusal(tx, store, conn, party::Op::Leave)? } ClientOpcodeMessage::CMSG_GROUP_UNINVITE(c) => { - let result = match (self_guid(conn), presence::resolve_by_name(store, &c.name)?) { - (Some(me), Some(guid)) => party_result(party::run( - store, - conn.account_id, - me, - party::Op::Uninvite(guid), - )?), + 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))?) + } _ => PartyResult::BadPlayerName, }; if result != PartyResult::Success { @@ -363,10 +322,10 @@ fn run_answering_refusal< conn: &WorldConn, op: party::Op, ) -> Result<()> { - let Some(me) = self_guid(conn) else { + let Some(me) = self_actor(conn) else { return Ok(()); }; - match party::run(store, conn.account_id, me, op)? { + match party::run(store, me, op)? { PartyOutcome::Ran => Ok(()), PartyOutcome::Refused(refusal) => send( tx, @@ -391,10 +350,10 @@ fn raid_convert< store: &St, conn: &WorldConn, ) -> Result<()> { - let Some(me) = self_guid(conn) else { + let Some(me) = self_actor(conn) else { return Ok(()); }; - match party::run(store, conn.account_id, me, party::Op::RaidConvert)? { + match party::run(store, me, party::Op::RaidConvert)? { PartyOutcome::Ran => send( tx, Outbound::One(ServerOpcodeMessage::SMSG_PARTY_COMMAND_RESULT(Box::new( @@ -425,10 +384,10 @@ fn run_unanswered< conn: &WorldConn, op: party::Op, ) -> Result<()> { - let Some(me) = self_guid(conn) else { + let Some(me) = self_actor(conn) else { return Ok(()); }; - if let PartyOutcome::Refused(refusal) = party::run(store, conn.account_id, me, op)? { + if let PartyOutcome::Refused(refusal) = party::run(store, me, op)? { log::debug!( "world: {op:?} refused (account {}): {refusal:?}", conn.account_id @@ -449,10 +408,10 @@ fn change_subgroup< name: &str, subgroup: u8, ) -> Result<()> { - let Some(me) = self_guid(conn) else { + let Some(me) = self_actor(conn) else { return Ok(()); }; - match party::resolve_roster_member_by_name(store, me, name)? { + match party::resolve_roster_member_by_name(store, me.guid(), name)? { Some(target) => run_unanswered(store, conn, party::Op::ChangeSubgroup { target, subgroup }), None => { log::debug!( @@ -477,10 +436,11 @@ fn swap_subgroup< name: &str, swap_with_name: &str, ) -> Result<()> { - let Some(me) = self_guid(conn) else { + let Some(me) = self_actor(conn) else { return Ok(()); }; - let (first, second) = party::resolve_roster_members_by_name(store, me, name, swap_with_name)?; + 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 }) @@ -507,7 +467,7 @@ pub(super) fn run_group_broadcast< conn: &mut WorldConn, op: party::Op, ) { - let Some(me) = self_guid(conn) else { + let Some(me) = self_actor(conn) else { return; }; if !conn.admit_group_broadcast(op) { @@ -517,7 +477,7 @@ pub(super) fn run_group_broadcast< ); return; } - match party::run(store, conn.account_id, me, op) { + match party::run(store, me, op) { Ok(PartyOutcome::Ran) => {} Ok(PartyOutcome::Refused(refusal)) => log::debug!( "world: group broadcast {op:?} refused (account {}): {refusal:?}", @@ -568,6 +528,11 @@ pub(super) fn self_guid(conn: &WorldConn) -> Option { } } +/// [`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 { @@ -698,8 +663,7 @@ pub(crate) fn friend_views( /// (self/duplicate/cap/team) is the module's own typed Refusal. fn resolve_add_contact( store: &St, - account_id: u64, - actor_guid: u64, + actor: Actor, name: &str, is_ignore: bool, ) -> Result<(FriendResult, u64, Option)> { @@ -712,7 +676,7 @@ fn resolve_add_contact FriendResult::IgnoreAdded, ContactOutcome::Refused(refusal) => friend_result_for(refusal, true), @@ -741,11 +705,11 @@ fn resolve_add_contact { let actor_race = store - .character_by_guid(actor_guid)? + .character_by_guid(actor.guid())? .map_or(0, |character| character.race); match target.as_ref().filter(|presence| { presence.session_online @@ -768,15 +732,14 @@ fn resolve_add_contact( store: &St, - account_id: u64, - actor_guid: u64, + actor: Actor, target_guid: u64, is_ignore: bool, ) -> Result<(FriendResult, u64)> { let outcome = if is_ignore { - store.del_ignore(account_id, actor_guid, target_guid)? + store.del_ignore(actor, target_guid)? } else { - store.del_friend(account_id, actor_guid, target_guid)? + store.del_friend(actor, target_guid)? }; let result = match outcome { ContactOutcome::Done if is_ignore => FriendResult::IgnoreRemoved, diff --git a/gateway/src/world/social_tests.rs b/gateway/src/world/social_tests.rs index 948557426..fbabeac0d 100644 --- a/gateway/src/world/social_tests.rs +++ b/gateway/src/world/social_tests.rs @@ -400,9 +400,9 @@ fn every_contact_refusal_reaches_the_client_as_one_friend_result() { } } -/// The contact half of the same rule: a timed-out add left the list in an unknown state. +/// The contact half of the same rule: a Transport Loss left the list in an unknown state. #[test] -fn an_add_friend_timeout_is_not_answered_as_a_refusal() { +fn an_add_friend_transport_loss_is_not_answered_as_a_refusal() { let mut s = quest_store(); s.characters = vec![codec::CharacterView { guid: 2, @@ -410,7 +410,7 @@ fn an_add_friend_timeout_is_not_answered_as_a_refusal() { ..Default::default() }]; s.session.login_entity = Some(warrior_entity()); - s.trade_error = Some("gw_add_friend reducer timed out after 10s".into()); + s.trade_error = Some("transport lost".into()); let store = std::sync::Arc::new(s); let (mut client, server_end) = world_session_socket_pair(); let server_store = store.clone(); @@ -433,8 +433,8 @@ fn an_add_friend_timeout_is_not_answered_as_a_refusal() { let error = result_rx .recv_timeout(std::time::Duration::from_secs(1)) .expect("an unknown contact outcome must end the session promptly") - .expect_err("a timed-out contact reducer must be session-fatal"); - assert!(format!("{error:#}").contains("timed out")); + .expect_err("a contact Transport Loss must be session-fatal"); + assert!(format!("{error:#}").contains("transport disconnected")); } #[test] diff --git a/gateway/src/world/store.rs b/gateway/src/world/store.rs index b63e17015..f622b6215 100644 --- a/gateway/src/world/store.rs +++ b/gateway/src/world/store.rs @@ -160,27 +160,21 @@ pub(crate) trait SessionStore: Send + Sync { /// is still parked in the shard cache. fn pending_system_messages(&self, self_guid: u64) -> Vec; - /// Enter the world with `character_guid`: calls the `player_login` reducer and + /// Enter the world as `character`: calls the `player_login` reducer and /// returns the live entity to spawn (from the resulting `game_world_entity` row). Errors if /// the character isn't the caller's. `entry` picks the reducer: a world-port keeps the Away /// Status, a fresh login ends it. fn player_login( &self, account_id: u64, - character_guid: u64, + character: Actor, entry: codec::WorldEntry, ) -> Result; /// Enqueue an accepted inbound movement on this shard's shared movement batch. The live store /// serializes `info` once and preserves the mover, opcode, position, orientation, and timestamp /// in the queued entry. Relayed peer events arrive back through the shared dispatch. - fn movement_update( - &self, - account_id: u64, - self_guid: u64, - opcode: u32, - info: &MovementInfo, - ) -> Result<()>; + fn movement_update(&self, actor: Actor, opcode: u32, info: &MovementInfo) -> Result<()>; /// Subscribe this player's connection to its per-player views (nearby `game_world_entity`, /// addressed `game_movement_event`) and push the resulting peer-spawn / movement-relay / destroy @@ -201,13 +195,7 @@ pub(crate) trait SessionStore: Send + Sync { /// Forward a parsed addon-bridge command to the module's `client_command` reducer ON /// THE PLAYER'S CONNECTION — the handler runs with exactly the player's reducer authority. - fn client_command( - &self, - account_id: u64, - self_guid: u64, - cmd: String, - payload: String, - ) -> Result<()>; + fn client_command(&self, actor: Actor, cmd: String, payload: String) -> Result<()>; /// Is `guid`'s live entity currently in the world? The WORLDPORT_ACK gate: a cross-map /// transfer despawns the entity until the ack rebuilds it, so diff --git a/gateway/src/world/test_support/world_fake/character.rs b/gateway/src/world/test_support/world_fake/character.rs index b315cabe6..6e2b0726b 100644 --- a/gateway/src/world/test_support/world_fake/character.rs +++ b/gateway/src/world/test_support/world_fake/character.rs @@ -84,13 +84,13 @@ impl CharacterStore for WorldFake { fn delete_character( &self, account_id: u64, - character_guid: u64, + character: Actor, ) -> Result { self.character .deleted .lock() .unwrap() - .push((account_id, character_guid)); + .push((account_id, character.guid())); Ok(self .character .delete_outcome diff --git a/gateway/src/world/test_support/world_fake/party.rs b/gateway/src/world/test_support/world_fake/party.rs index 1863942c8..1f52e082e 100644 --- a/gateway/src/world/test_support/world_fake/party.rs +++ b/gateway/src/world/test_support/world_fake/party.rs @@ -80,10 +80,10 @@ pub(crate) struct PartyState { /// The Fake's reducer edge for a party op: the Module answers a Refusal as the bare tag, and /// anything else — a timeout, a dead transport — is a failure with an unknown durable outcome. -pub(crate) fn faked_party(error: &str) -> Result { +pub(crate) fn faked_party(operation: &str, error: &str) -> Result { match lyracore_shared::group::GroupRefusal::parse_tag(error) { Some(refusal) => Ok(refusal.into()), - None => Err(anyhow!("{error}")), + None => Err(crate::stdb::ReducerCallError::transport_lost(operation).into()), } } @@ -140,46 +140,40 @@ impl PartyStore for WorldFake { // Each of these records the SHARD it ran on (`rec`), so a test can tell the // single-database path (the op lands on the player's own shard, here) apart from the realm-core // one (it lands in `FakeParty::ops` and never reaches these at all). - fn group_invite( - &self, - _account_id: u64, - _self_guid: u64, - target_guid: u64, - ) -> Result { + fn group_invite(&self, _actor: Actor, target_guid: u64) -> Result { self.rec("group_invite"); if let Some(e) = &self.trade_error { - return faked_party(e); + return faked_party("gw_group_invite", e); } self.party.group_invites.lock().unwrap().push(target_guid); Ok(PartyOutcome::Ran) } - fn group_accept(&self, _account_id: u64, _self_guid: u64) -> Result { + fn group_accept(&self, _actor: Actor) -> Result { self.rec("group_accept"); Ok(PartyOutcome::Ran) } - fn group_decline(&self, _account_id: u64, _self_guid: u64) -> Result { + fn group_decline(&self, _actor: Actor) -> Result { self.rec("group_decline"); Ok(PartyOutcome::Ran) } - fn group_leave(&self, _account_id: u64, _self_guid: u64) -> Result { + fn group_leave(&self, _actor: Actor) -> Result { self.rec("group_leave"); Ok(PartyOutcome::Ran) } fn group_loot_method( &self, - _account_id: u64, - _self_guid: u64, + _actor: Actor, loot_setting: u8, master_guid: u64, loot_threshold: u8, ) -> Result { self.rec("group_loot_method"); if let Some(e) = &self.trade_error { - return faked_party(e); + return faked_party("gw_group_loot_method", e); } self.party.group_loot_methods.lock().unwrap().push(( loot_setting, @@ -397,7 +391,7 @@ impl PartyStore for WorldFake { fn realm_group_op( &self, op: u8, - actor_guid: u64, + actor: Actor, target_guid: u64, arg_a: u8, arg_b: u8, @@ -405,6 +399,7 @@ impl PartyStore for WorldFake { ) -> Result { use lyracore_shared::group::{event_kind as kind, realm_op, GroupRefusal}; self.rec("realm_group_op"); + let actor_guid = actor.guid(); let mut p = self.party.party.lock().unwrap(); if self .party @@ -437,7 +432,7 @@ impl PartyStore for WorldFake { } realm_op::ACCEPT => { if let Some(e) = &self.party.party_accept_error { - return faked_party(e); + return faked_party("realm_group_op", e); } let Some(inviter) = p .invites @@ -617,24 +612,24 @@ impl PartyStore for WorldFake { fn realm_group_op_visible( &self, op: u8, - actor_guid: u64, + actor: Actor, target_guid: u64, arg_a: u8, arg_b: u8, arg_c: u64, ) -> Result { - let outcome = self.realm_group_op(op, actor_guid, target_guid, arg_a, arg_b, arg_c); + let outcome = self.realm_group_op(op, actor, target_guid, arg_a, arg_b, arg_c); *self.party.stale_party.lock().unwrap() = None; outcome } fn deleted_character_party_leave( &self, - character_guid: u64, + character: Actor, ) -> Result { self.realm_group_op( lyracore_shared::group::realm_op::LEAVE, - character_guid, + character, 0, lyracore_shared::group::leave_cause::CHARACTER_DELETED, 0, @@ -796,12 +791,7 @@ impl PartyStore for WorldFake { Ok(()) } - fn group_uninvite( - &self, - _account_id: u64, - _self_guid: u64, - _target_guid: u64, - ) -> Result { + fn group_uninvite(&self, _actor: Actor, _target_guid: u64) -> Result { self.rec("group_uninvite"); Ok(PartyOutcome::Ran) } diff --git a/gateway/src/world/test_support/world_fake/session.rs b/gateway/src/world/test_support/world_fake/session.rs index 547602938..042a39ab9 100644 --- a/gateway/src/world/test_support/world_fake/session.rs +++ b/gateway/src/world/test_support/world_fake/session.rs @@ -1,4 +1,5 @@ use super::super::*; +use crate::stdb::ReducerCallError; /// One recorded `movement_update`: (opcode, x, y, z, orientation, timestamp). pub(crate) type MoveRecord = (u32, f32, f32, f32, f32, u32); @@ -15,7 +16,10 @@ pub(crate) struct SessionState { pub(crate) session: Option, pub(crate) login_entity: Option, pub(crate) moves: std::sync::Mutex>, - pub(crate) client_commands: std::sync::Mutex>, + /// Recorded `client_command` calls: (actor guid, command, payload). + pub(crate) client_commands: std::sync::Mutex>, + /// When set, every `client_command` fails with the error this builds from the reducer name. + pub(crate) client_command_error: Option ReducerCallError>, /// Optional item row whose subscribed relay is queued during a successful turn-in. The paired /// sender is retained only for that test and consumed by `turn_in_quest`, so it cannot keep the /// session writer alive during teardown. @@ -26,9 +30,9 @@ pub(crate) struct SessionState { pub(crate) combat_until_ms: u64, /// Tracks whether `logout` was called (entity removal path taken). pub(crate) logout_called: std::sync::atomic::AtomicBool, - /// When set, teardown cannot reach the database after a session-fatal transport loss. + /// When set, teardown cannot reach the database after a session-fatal Transport Loss. /// The world session must still close and relinquish its admission seat. - pub(crate) logout_error: Option, + pub(crate) logout_transport_lost: bool, /// The `WorldEntry` of every `player_login`, in order. pub(crate) login_entries: std::sync::Mutex>, /// Parked private System Messages world entry replays (a Package `on_login` hook's output). @@ -61,10 +65,8 @@ pub(crate) struct SessionState { /// realm-wide party frame's online flags are built from. Empty = the single `entity_in_world` /// flag above decides, as it did before. pub(crate) live_guids: Vec, - /// When set, every `movement_update` fails with this message. The case that matters is - /// `"mover not in world"` — the module's answer for a packet that arrives after - /// `teleport_player` despawned the entity, i.e. the tail of every cross-map port. - pub(crate) movement_error: Option, + /// When set, every `movement_update` fails with a Transport Loss. + pub(crate) movement_transport_lost: bool, /// Optional real shared view used by world-port ordering tests. pub(crate) relay_view: Option>, /// With `relay_view`, the Member Stats a Relay tick delivered between viewer registration and @@ -86,7 +88,7 @@ impl SessionStore for WorldFake { fn player_login( &self, _account_id: u64, - _character_guid: u64, + _character: Actor, entry: codec::WorldEntry, ) -> Result { self.rec("player_login"); @@ -109,16 +111,10 @@ impl SessionStore for WorldFake { .ok_or_else(|| anyhow!("no login entity configured")) } - fn movement_update( - &self, - _account_id: u64, - _self_guid: u64, - opcode: u32, - info: &MovementInfo, - ) -> Result<()> { + fn movement_update(&self, _actor: Actor, opcode: u32, info: &MovementInfo) -> Result<()> { self.rec("movement_update"); - if let Some(e) = &self.session.movement_error { - return Err(anyhow!("movement_update reducer failed: {e}")); + if self.session.movement_transport_lost { + return Err(ReducerCallError::transport_lost("gw_movement_batch").into()); } self.session.moves.lock().unwrap().push(( opcode, @@ -165,18 +161,15 @@ impl SessionStore for WorldFake { Ok(subs) } - fn client_command( - &self, - account_id: u64, - self_guid: u64, - cmd: String, - payload: String, - ) -> Result<()> { + fn client_command(&self, actor: Actor, cmd: String, payload: String) -> Result<()> { + if let Some(error) = self.session.client_command_error { + return Err(error("gw_client_command").into()); + } self.session .client_commands .lock() .unwrap() - .push((account_id, self_guid, cmd, payload)); + .push((actor.guid(), cmd, payload)); Ok(()) } @@ -217,10 +210,10 @@ impl SessionStore for WorldFake { self.session .logout_called .store(true, std::sync::atomic::Ordering::SeqCst); - match &self.session.logout_error { - Some(e) => Err(anyhow!("{e}")), - None => Ok(()), + if self.session.logout_transport_lost { + return Err(ReducerCallError::transport_lost("logout").into()); } + Ok(()) } fn player_combat_until_ms(&self, _player_guid: u64) -> u64 { diff --git a/gateway/src/world/test_support/world_fake/social.rs b/gateway/src/world/test_support/world_fake/social.rs index d034c8bc4..01cfbd21c 100644 --- a/gateway/src/world/test_support/world_fake/social.rs +++ b/gateway/src/world/test_support/world_fake/social.rs @@ -27,10 +27,10 @@ pub(crate) struct SocialState { } /// [`faked_party`] for the friends and ignore lists. -pub(crate) fn faked_contact(error: &str) -> Result { +pub(crate) fn faked_contact(operation: &str, error: &str) -> Result { match lyracore_shared::social::ContactRefusal::parse_tag(error) { Some(refusal) => Ok(refusal.into()), - None => Err(anyhow!("{error}")), + None => Err(crate::stdb::ReducerCallError::transport_lost(operation).into()), } } @@ -183,13 +183,12 @@ impl SocialStore for WorldFake { fn add_friend( &self, - _account_id: u64, - _self_guid: u64, + _actor: Actor, target_guid: u64, _target_race: u8, ) -> Result { if let Some(e) = &self.trade_error { - return faked_contact(e); + return faked_contact("gw_add_friend", e); } let owner = self .session @@ -205,23 +204,13 @@ impl SocialStore for WorldFake { Ok(ContactOutcome::Done) } - fn del_friend( - &self, - _account_id: u64, - _self_guid: u64, - target_guid: u64, - ) -> Result { + fn del_friend(&self, _actor: Actor, target_guid: u64) -> Result { self.remove_contact(target_guid, false) } - fn add_ignore( - &self, - _account_id: u64, - _self_guid: u64, - target_guid: u64, - ) -> Result { + fn add_ignore(&self, _actor: Actor, target_guid: u64) -> Result { if let Some(e) = &self.trade_error { - return faked_contact(e); + return faked_contact("gw_add_ignore", e); } let owner = self .session @@ -237,12 +226,7 @@ impl SocialStore for WorldFake { Ok(ContactOutcome::Done) } - fn del_ignore( - &self, - _account_id: u64, - _self_guid: u64, - target_guid: u64, - ) -> Result { + fn del_ignore(&self, _actor: Actor, target_guid: u64) -> Result { self.remove_contact(target_guid, true) } } diff --git a/gateway/src/world/trainer_tests.rs b/gateway/src/world/trainer_tests.rs index 653ec22a5..229b6eacd 100644 --- a/gateway/src/world/trainer_tests.rs +++ b/gateway/src/world/trainer_tests.rs @@ -69,7 +69,7 @@ impl CharacterStore for TrainerFake { fn delete_character( &self, _account_id: u64, - _character_guid: u64, + _character: Actor, ) -> Result { unimplemented!("delete_character") } diff --git a/gateway/src/world/transfer_tests.rs b/gateway/src/world/transfer_tests.rs index 1f63fbdab..4a072e681 100644 --- a/gateway/src/world/transfer_tests.rs +++ b/gateway/src/world/transfer_tests.rs @@ -1831,8 +1831,7 @@ fn the_bots_arrival_fence_survives_a_party_mirror_failure_and_retry() { } super::party::run( world.as_ref(), - 7, - GINGER, + Actor::new(GINGER).unwrap(), super::party::Op::Invite(BOT_GUID), ) .expect("the bot joins the leader's party"); From 0ab8ae2ee545523e89945c9eaf92e3c5dd5336ea Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 18:41:36 +0200 Subject: [PATCH 21/33] refactor(gateway): the Transfer Store freezes the Character as an Actor `begin_transfer` takes the Character it freezes as an `Actor`, resolved once from the plan in `run_transfer`, so the owner fallback is gone. The forwarding wrappers whose only caller was the trait method now live inside it; the escrow steps the account-session test drives keep their inherent methods. Co-Authored-By: Claude Opus 5.5 (1M context) --- gateway/src/stdb/account_sessions.rs | 11 +- gateway/src/stdb/store/transfer.rs | 264 +++++++----------- ...bscriptions_party_command_durable_tests.rs | 4 +- .../world/test_support/world_fake/transfer.rs | 6 +- gateway/src/world/transfer.rs | 11 +- gateway/src/world/transfer_tests.rs | 9 +- 6 files changed, 122 insertions(+), 183 deletions(-) diff --git a/gateway/src/stdb/account_sessions.rs b/gateway/src/stdb/account_sessions.rs index 44881377a..50b44d772 100644 --- a/gateway/src/stdb/account_sessions.rs +++ b/gateway/src/stdb/account_sessions.rs @@ -568,8 +568,10 @@ mod tests { old.2.clone(), ); let transfer_id = crate::world::transfer::transfer_id_for(1); - winner - .begin_transfer(&crate::world::transfer::TransferPlan { + crate::world::TransferStore::begin_transfer( + winner, + Actor::new(1).unwrap(), + &crate::world::transfer::TransferPlan { transfer_id, character_guid: 1, dest_map_id: 0, @@ -578,8 +580,9 @@ mod tests { dest_y: 100.0, dest_z: 20.0, dest_o: 0.0, - }) - .unwrap(); + }, + ) + .unwrap(); assert!(crate::durable_test_support::poll_until( Duration::from_secs(5), || winner.escrow_row(1).is_some() diff --git a/gateway/src/stdb/store/transfer.rs b/gateway/src/stdb/store/transfer.rs index e337d5b5a..1bada1391 100644 --- a/gateway/src/stdb/store/transfer.rs +++ b/gateway/src/stdb/store/transfer.rs @@ -7,7 +7,7 @@ use crate::stdb::bindings::game_transfer_in_table::GameTransferInTableAccess; use crate::stdb::bindings::*; use crate::stdb::connection::call_reducer; use crate::stdb::Coordinator; -use crate::world::TransferStore; +use crate::world::{Actor, TransferStore}; impl TransferStore for Coordinator { fn escrowed_transfer( @@ -41,8 +41,28 @@ impl TransferStore for Coordinator { }) } - fn begin_transfer(&self, plan: &crate::world::transfer::TransferPlan) -> Result<()> { - self.begin_transfer(plan) + /// `begin_transfer` — freeze the character, serialize it (row + every manifest table's rows), + /// and delete its live entity, in ONE transaction. Idempotent on `transfer_id`. + fn begin_transfer( + &self, + character: Actor, + plan: &crate::world::transfer::TransferPlan, + ) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "begin_transfer", + begin_transfer_then( + plan.transfer_id, + self.session_actor(character), + plan.dest_map_id, + plan.dest_instance_id, + plan.dest_x, + plan.dest_y, + plan.dest_z, + plan.dest_o, + true + ) + ) } fn import_character_blob( @@ -63,7 +83,22 @@ impl TransferStore for Coordinator { { anyhow::bail!("bot Transfer Realm locator binding changed before import"); } - self.import_bot_character_blob(transfer_id, blob, intent) + call_reducer!( + self.0.call_pipe().conn.reducers, + "import_bot_character_blob", + import_bot_character_blob_then( + transfer_id, + blob.to_vec(), + intent.source_module_identity, + intent.id, + intent.controller_generation, + intent.created_micros, + intent.source_map, + intent.source_instance, + intent.source_locator_revision, + self.owner_actor() + ) + ) } None => self.import_player_character_blob(transfer_id, blob, source), } @@ -77,8 +112,13 @@ impl TransferStore for Coordinator { self.finish_transfer(transfer_id) } + /// `release_transfer` — drop the arrival copy's fence at the destination. fn release_transfer(&self, transfer_id: u64) -> Result<()> { - self.release_transfer(transfer_id) + call_reducer!( + self.0.call_pipe().conn.reducers, + "release_transfer", + release_transfer_then(transfer_id, self.owner_actor()) + ) } fn release_player_transfer_arrival( @@ -123,12 +163,23 @@ impl TransferStore for Coordinator { .map(|row| (row.map_id, row.party_id)) } + /// `ensure_instance` — mirror an instance id onto this shard (idempotent), spawning its + /// population the first time. fn ensure_instance(&self, instance_id: u64, map_id: u32, party_id: u64) -> Result<()> { - self.ensure_instance(instance_id, map_id, party_id) + call_reducer!( + self.0.call_pipe().conn.reducers, + "ensure_instance", + ensure_instance_then(instance_id, map_id, party_id, self.owner_actor()) + ) } + /// `evict_instance_population` — stop this shard ticking an instance whose run moved elsewhere. fn evict_instance_population(&self, instance_id: u64) -> Result<()> { - self.evict_instance_population(instance_id) + call_reducer!( + self.0.call_pipe().conn.reducers, + "evict_instance_population", + evict_instance_population_then(instance_id, self.owner_actor()) + ) } fn begin_shard_index_transfer( @@ -177,7 +228,19 @@ impl TransferStore for Coordinator { source_revision: u64, claim_token: u64, ) -> Result<()> { - self.bind_bot_transfer_locator(intent, source_revision, claim_token) + call_reducer!( + self.0.call_pipe().conn.reducers, + "bind_bot_transfer_locator", + bind_bot_transfer_locator_then( + intent.id, + intent.bot_guid, + intent.controller_generation, + claim_token, + intent.source_map, + intent.source_instance, + source_revision + ) + ) } fn publish_bot_shard_index( @@ -202,12 +265,15 @@ impl TransferStore for Coordinator { controller_generation: u64, claim_token: u64, ) -> Result<()> { - Coordinator::mark_bot_transfer_arrival_ready( - self, - intent_id, - bot_guid, - controller_generation, - claim_token, + call_reducer!( + self.0.call_pipe().conn.reducers, + "mark_bot_transfer_arrival_ready", + mark_bot_transfer_arrival_ready_then( + intent_id, + bot_guid, + controller_generation, + claim_token + ) ) } @@ -240,17 +306,20 @@ impl TransferStore for Coordinator { transfer_id: u64, intent: &crate::world::transfer::BotTransferIntent, ) -> Result<()> { - Coordinator::release_bot_transfer_arrival( - self, - transfer_id, - intent.bot_guid, - intent.source_module_identity, - intent.id, - intent.controller_generation, - intent.created_micros, - intent.source_map, - intent.source_instance, - intent.source_locator_revision, + call_reducer!( + self.0.call_pipe().conn.reducers, + "release_bot_transfer_arrival", + release_bot_transfer_arrival_then( + transfer_id, + intent.bot_guid, + intent.source_module_identity, + intent.id, + intent.controller_generation, + intent.created_micros, + intent.source_map, + intent.source_instance, + intent.source_locator_revision + ) ) } } @@ -278,97 +347,6 @@ impl Coordinator { ) } - pub fn mark_bot_transfer_arrival_ready( - &self, - intent_id: u64, - bot_guid: u64, - controller_generation: u64, - claim_token: u64, - ) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "mark_bot_transfer_arrival_ready", - mark_bot_transfer_arrival_ready_then( - intent_id, - bot_guid, - controller_generation, - claim_token - ) - ) - } - - // The arguments mirror the World reducer's exact Transfer Gate. - #[allow(clippy::too_many_arguments)] - pub fn release_bot_transfer_arrival( - &self, - transfer_id: u64, - bot_guid: u64, - source_module_identity: spacetimedb_sdk::Identity, - intent_id: u64, - controller_generation: u64, - intent_created_micros: i64, - source_map: u32, - source_instance: u64, - source_locator_revision: u64, - ) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "release_bot_transfer_arrival", - release_bot_transfer_arrival_then( - transfer_id, - bot_guid, - source_module_identity, - intent_id, - controller_generation, - intent_created_micros, - source_map, - source_instance, - source_locator_revision - ) - ) - } - - pub fn bind_bot_transfer_locator( - &self, - intent: &crate::world::transfer::BotTransferIntent, - source_revision: u64, - claim_token: u64, - ) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "bind_bot_transfer_locator", - bind_bot_transfer_locator_then( - intent.id, - intent.bot_guid, - intent.controller_generation, - claim_token, - intent.source_map, - intent.source_instance, - source_revision - ) - ) - } - - /// `begin_transfer` — freeze the character, serialize it (row + every manifest table's rows), - /// and delete its live entity, in ONE transaction. Idempotent on `transfer_id`. - pub fn begin_transfer(&self, plan: &crate::world::transfer::TransferPlan) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "begin_transfer", - begin_transfer_then( - plan.transfer_id, - self.actor_or_owner(plan.character_guid), - plan.dest_map_id, - plan.dest_instance_id, - plan.dest_x, - plan.dest_y, - plan.dest_z, - plan.dest_o, - true - ) - ) - } - /// `import_player_character_blob`, with the Realm locator predecessor written as part of the /// destination import transaction. pub fn import_player_character_blob( @@ -391,32 +369,6 @@ impl Coordinator { ) } - /// `import_bot_character_blob`, with the exact source intent identity written as part of the - /// destination import transaction. - pub fn import_bot_character_blob( - &self, - transfer_id: u64, - blob: &[u8], - intent: &crate::world::transfer::BotTransferIntent, - ) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "import_bot_character_blob", - import_bot_character_blob_then( - transfer_id, - blob.to_vec(), - intent.source_module_identity, - intent.id, - intent.controller_generation, - intent.created_micros, - intent.source_map, - intent.source_instance, - intent.source_locator_revision, - self.owner_actor() - ) - ) - } - /// `confirm_import` — attest ON THE SOURCE that the destination copy committed. Called only /// after `import_character_blob` returned Ok; see `world::transfer::run_transfer`. pub fn confirm_import(&self, transfer_id: u64) -> Result<()> { @@ -435,32 +387,4 @@ impl Coordinator { finish_transfer_then(transfer_id, self.owner_actor()) ) } - - /// `release_transfer` — drop the arrival copy's fence at the destination. - pub fn release_transfer(&self, transfer_id: u64) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "release_transfer", - release_transfer_then(transfer_id, self.owner_actor()) - ) - } - - /// `ensure_instance` — mirror an instance id onto this shard (idempotent), spawning its - /// population the first time. - pub fn ensure_instance(&self, instance_id: u64, map_id: u32, party_id: u64) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "ensure_instance", - ensure_instance_then(instance_id, map_id, party_id, self.owner_actor()) - ) - } - - /// `evict_instance_population` — stop this shard ticking an instance whose run moved elsewhere. - pub fn evict_instance_population(&self, instance_id: u64) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "evict_instance_population", - evict_instance_population_then(instance_id, self.owner_actor()) - ) - } } diff --git a/gateway/src/stdb/subscriptions_party_command_durable_tests.rs b/gateway/src/stdb/subscriptions_party_command_durable_tests.rs index 4123fe553..5b01de4e4 100644 --- a/gateway/src/stdb/subscriptions_party_command_durable_tests.rs +++ b/gateway/src/stdb/subscriptions_party_command_durable_tests.rs @@ -630,7 +630,9 @@ fn command_receipts_recover_both_gateway_crash_boundaries() { |plan| (plan.dest_map_id, plan.dest_instance_id) == (destination_map, 0) ))); let transfer_plan = target.character_destination(bot).unwrap(); - target.begin_transfer(&transfer_plan).unwrap(); + target + .begin_transfer(Actor::new(bot).unwrap(), &transfer_plan) + .unwrap(); let mut delayed = admitted.clone(); delayed.intent_id = target_applied + 1_000_000; let in_transit = target.apply_admitted_party_command(&delayed).unwrap_err(); diff --git a/gateway/src/world/test_support/world_fake/transfer.rs b/gateway/src/world/test_support/world_fake/transfer.rs index 311146fdf..2616038ae 100644 --- a/gateway/src/world/test_support/world_fake/transfer.rs +++ b/gateway/src/world/test_support/world_fake/transfer.rs @@ -78,7 +78,11 @@ impl TransferStore for WorldFake { /// now the transfer id IS the character guid: a database holding an unreleased ARRIVAL in-row /// for this character answers `BeginPlan::Replay` to a genuine new transfer, i.e. reports /// success while freezing nothing. A mock that only looked at `out_rows` could not see it. - fn begin_transfer(&self, plan: &crate::world::transfer::TransferPlan) -> Result<()> { + fn begin_transfer( + &self, + _character: Actor, + plan: &crate::world::transfer::TransferPlan, + ) -> Result<()> { let db = self.xstep("begin_transfer")?; let mut out = lk(&db.out_rows); let escrowed_guid = out diff --git a/gateway/src/world/transfer.rs b/gateway/src/world/transfer.rs index b50d33165..54f45a826 100644 --- a/gateway/src/world/transfer.rs +++ b/gateway/src/world/transfer.rs @@ -2,7 +2,7 @@ use anyhow::{anyhow, Result}; -use super::{party, WorldStore}; +use super::{party, Actor, WorldStore}; /// The escrowed cross-shard Transfer steps, and the instance lease a crossing needs. pub(crate) trait TransferStore: Send + Sync { @@ -14,8 +14,9 @@ pub(crate) trait TransferStore: Send + Sync { /// the destination there before despawning the entity). `None` = this shard has no row for it. fn character_destination(&self, character_guid: u64) -> Option; - /// `begin_transfer` — freeze + serialize + delete the live entity, in one transaction. - fn begin_transfer(&self, plan: &TransferPlan) -> Result<()>; + /// `begin_transfer` — freeze + serialize + delete the live entity of `character`, the Character + /// `plan` names, in one transaction. + fn begin_transfer(&self, character: Actor, plan: &TransferPlan) -> Result<()>; /// Materialise the arrival copy from the carried blob. A session-less crossing binds its exact /// source intent identity to the destination fence in the same transaction as the import. @@ -432,7 +433,9 @@ pub(super) fn run_transfer_injected_for_intent( .map(|(intent, token)| (intent, *token)); // 1. FREEZE + SERIALIZE on the source, in one transaction. Idempotent on the transfer id. - src.begin_transfer(plan)?; + let character = Actor::new(plan.character_guid) + .ok_or_else(|| anyhow!("transfer {}: the plan names no Character", plan.transfer_id))?; + src.begin_transfer(character, plan)?; abort_point(abort_after, "begin_transfer", plan.transfer_id); // Read the escrow back rather than trusting `plan`: after a resume the row on disk is the diff --git a/gateway/src/world/transfer_tests.rs b/gateway/src/world/transfer_tests.rs index 4a072e681..bafd6d771 100644 --- a/gateway/src/world/transfer_tests.rs +++ b/gateway/src/world/transfer_tests.rs @@ -255,7 +255,8 @@ fn a_resumed_transfer_publishes_the_escrow_destination_not_the_callers_plan() { let opened = src .character_destination(XGUID) .expect("the durable row names a destination"); - src.begin_transfer(&opened).expect("the escrow opens"); + src.begin_transfer(Actor::new(XGUID).unwrap(), &opened) + .expect("the escrow opens"); // Now drive with a plan naming somewhere ELSE. `begin_transfer` answers `Replay` — the row // on disk is the authority and the plan is ignored — so the transfer settles at 36/7. let stale = super::transfer::TransferPlan { @@ -528,7 +529,8 @@ fn a_transfer_is_never_finished_before_the_destination_copy_is_durable() { let plan = src .character_destination(XGUID) .expect("the durable row names the destination"); - src.begin_transfer(&plan).expect("escrow opens"); + src.begin_transfer(Actor::new(XGUID).unwrap(), &plan) + .expect("escrow opens"); let err = src .finish_transfer(plan.transfer_id) @@ -547,7 +549,8 @@ fn the_arrival_copy_is_fenced_until_the_source_copy_is_destroyed() { let dst = xstore("instances", dst_db.clone(), calls, None); let plan = src.character_destination(XGUID).unwrap(); - src.begin_transfer(&plan).unwrap(); + src.begin_transfer(Actor::new(XGUID).unwrap(), &plan) + .unwrap(); assert!( !src_db.live(XGUID) && !dst_db.has(XGUID), "frozen on the source, nothing arrived yet" From 2cc4bdf072e5427f39926536f657805ceddfc4ba Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 18:53:27 +0200 Subject: [PATCH 22/33] refactor(gateway): mail acts as an Actor and ends the session on a Transport Loss Every mail Store step that acts for a Character takes an `Actor`; the trait methods carry the reducer calls, and the owner fallback is gone. Mail ops answer `MailFailure`: a Refusal from a Gateway Gate or the Module answers the client as before, and a Transport Loss ends the World Session. A drive the transport lost mid-escrow, a cache read that failed and an exhausted escrow id range used to answer an internal error and keep the session; they now end it, and the next mailbox visit or login re-drives the fence. The mail Fake answers typed Refusals. Co-Authored-By: Claude Opus 5.5 (1M context) --- gateway/src/stdb/store/mail.rs | 501 ++++--------- gateway/src/world/handlers/mail.rs | 163 +++-- gateway/src/world/mail.rs | 507 ++++++++------ gateway/src/world/mail_tests.rs | 658 +++++++++++------- .../src/world/test_support/world_fake/mail.rs | 133 ++-- 5 files changed, 989 insertions(+), 973 deletions(-) diff --git a/gateway/src/stdb/store/mail.rs b/gateway/src/stdb/store/mail.rs index 1f0650daa..ef0f4e203 100644 --- a/gateway/src/stdb/store/mail.rs +++ b/gateway/src/stdb/store/mail.rs @@ -3,246 +3,16 @@ use anyhow::Result; use spacetimedb_sdk::Table; -use crate::codec; use crate::stdb::bindings::*; use crate::stdb::connection::call_reducer; use crate::stdb::Coordinator; -use crate::world::MailStore; +use crate::world::{Actor, MailStore}; impl MailStore for Coordinator { - fn mail_list(&self, recipient_guid: u64) -> Result> { - self.mail_list(recipient_guid) - } - - fn mail_by_id(&self, mail_id: u64) -> Result> { - Ok(self.mail_by_id(mail_id)) - } - - fn realm_account_name(&self, character_guid: u64) -> Result> { - Ok(self.realm_account_name(character_guid)) - } - - fn mailbox_in_range(&self, mailbox_guid: u64, player_guid: u64) -> Result { - self.mailbox_in_range(mailbox_guid, player_guid) - } - - fn mail_mark_read(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { - self.mail_mark_read(recipient_guid, mail_id) - } - - fn mail_delete(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { - self.mail_delete(recipient_guid, mail_id) - } - - fn mail_return(&self, recipient_guid: u64, mail_id: u64, same_account: bool) -> Result<()> { - self.mail_return(recipient_guid, mail_id, same_account) - } - - fn mail_send( - &self, - sender_guid: u64, - recipient_guid: u64, - subject: String, - body: String, - money: u32, - cod: u32, - item_guid: u64, - same_account: bool, - ) -> Result<()> { - self.mail_send( - sender_guid, - recipient_guid, - subject, - body, - money, - cod, - item_guid, - same_account, - ) - } - - fn mail_take_money(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { - self.mail_take_money(recipient_guid, mail_id) - } - - fn mail_take_item(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { - self.mail_take_item(recipient_guid, mail_id) - } - - fn mail_item_room(&self, payee_guid: u64) -> Result<()> { - self.mail_item_room(payee_guid) - } - - fn mail_copy_text(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { - self.mail_copy_text(recipient_guid, mail_id) - } - - fn mail_grant_letter(&self, payee_guid: u64, item_text_id: u32) -> Result<()> { - self.mail_grant_letter(payee_guid, item_text_id) - } - - fn mail_mark_letter_granted(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { - self.mail_mark_letter_granted(recipient_guid, mail_id) - } - - fn item_text(&self, item_text_id: u32) -> Result> { - self.item_text(item_text_id) - } - - fn owns_item_with_text( - &self, - owner_guid: u64, - item_text_id: u32, - hint_item_guid: u64, - ) -> Result { - self.owns_item_with_text(owner_guid, item_text_id, hint_item_guid) - } - - fn mail_fence( - &self, - escrow_id: u64, - sender_guid: u64, - recipient_guid: u64, - subject: String, - body: String, - money: u32, - postage: u32, - item_guid: u64, - cod: u32, - cod_source_mail_id: u64, - same_account: bool, - ) -> Result<()> { - self.mail_fence( - escrow_id, - sender_guid, - recipient_guid, - subject, - body, - money, - postage, - item_guid, - cod, - cod_source_mail_id, - same_account, - ) - } - - fn mail_commit( - &self, - escrow_id: u64, - sender_guid: u64, - recipient_guid: u64, - subject: String, - body: String, - money: u32, - item: crate::world::mail::AttachedItem, - cod: u32, - cod_source_mail_id: u64, - delivery_delay_secs: u32, - reward: Option, - ) -> Result<()> { - self.mail_commit( - escrow_id, - sender_guid, - recipient_guid, - subject, - body, - money, - item, - cod, - cod_source_mail_id, - delivery_delay_secs, - reward, - ) - } - - fn mail_take_money_fence( - &self, - escrow_id: u64, - payee_guid: u64, - mail_id: u64, - expect_money: u32, - ) -> Result<()> { - self.mail_take_money_fence(escrow_id, payee_guid, mail_id, expect_money) - } - - fn mail_payout( - &self, - escrow_id: u64, - payee_guid: u64, - mail_id: u64, - amount: u32, - ) -> Result<()> { - self.mail_payout(escrow_id, payee_guid, mail_id, amount) - } - - fn mail_take_item_fence( - &self, - escrow_id: u64, - payee_guid: u64, - mail_id: u64, - expect_entry: u32, - ) -> Result<()> { - self.mail_take_item_fence(escrow_id, payee_guid, mail_id, expect_entry) - } - - fn mail_item_payout( - &self, - escrow_id: u64, - payee_guid: u64, - mail_id: u64, - item: crate::world::mail::AttachedItem, - ) -> Result<()> { - self.mail_item_payout(escrow_id, payee_guid, mail_id, item) - } - - fn mail_confirm_delivery(&self, escrow_id: u64) -> Result<()> { - self.mail_confirm_delivery(escrow_id) - } - - fn mail_settle(&self, escrow_id: u64) -> Result<()> { - self.mail_settle(escrow_id) - } - - fn mail_escrows_of(&self, sender_guid: u64) -> Result> { - self.mail_escrows_of(sender_guid) - } -} - -impl Coordinator { - /// Does `owner_guid` hold an item carrying `item_text_id`? `CMSG_ITEM_TEXT_QUERY`'s ownership - /// Gate: a client cannot use this to probe what anyone else holds, since the answer is a bare - /// bool. Read from the privileged cache. - /// - /// `hint_item_guid` is the wire's own second field — a bag item guid when the client is - /// reading that item's tooltip (`cm:MailHandler.cpp:630-646`), and vmangos always sends it for - /// this shape of the query. A PK lookup answers in O(1); a miss (item deleted, wrong id, or a - /// crafted query) answers `false` rather than falling back to a table-wide scan — this runs on - /// every such query, including ones for an id that names nothing, and the SDK exposes no - /// owner-keyed index to narrow it by. - pub fn owns_item_with_text( - &self, - owner_guid: u64, - item_text_id: u32, - hint_item_guid: u64, - ) -> Result { - if item_text_id == 0 || hint_item_guid == 0 { - return Ok(false); - } - let guard = self.0.coord(); - let db = &guard.conn.db; - let owns = db - .game_item_instance() - .guid() - .find(&hint_item_guid) - .is_some_and(|item| item.owner_guid == owner_guid && item.item_text_id == item_text_id); - Ok(owns) - } - /// Every mail addressed to `recipient_guid`, delivered or not, in no set order. /// `codec::build_mail_list` orders the inbox. The SDK exposes only the PK index, so this /// iterates and filters like every other per-owner read here (`player_items`, `player_skills`). - pub fn mail_list(&self, recipient_guid: u64) -> Result> { + fn mail_list(&self, recipient_guid: u64) -> Result> { let guard = self.0.coord(); let db = &guard.conn.db; Ok(db @@ -254,81 +24,14 @@ impl Coordinator { } /// The mail `mail_id`, delivered or not, by its primary key. - pub fn mail_by_id(&self, mail_id: u64) -> Option { + fn mail_by_id(&self, mail_id: u64) -> Result> { let guard = self.0.coord(); let db = &guard.conn.db; - db.game_mail().id().find(&mail_id).map(|m| mail_view(db, m)) + Ok(db.game_mail().id().find(&mail_id).map(|m| mail_view(db, m))) } - /// Every mail escrow this database is holding for `sender_guid` — the fences a drive filed and - /// never finished. - /// - /// The one module→gateway data flow the escrow adds, and it exists for the same reason - /// `escrowed_transfer` does: the gateway is the only component that can see both databases, so - /// re-driving a stalled fence means re-deriving the whole letter from its row. A Character's fresh - /// send reads nothing more than its own fence; a Reward Letter, which the Module files at - /// turn-in, is always driven from its row. Private table, read through the owner token. - /// - /// Keyed by `sender_guid`, which on a payout escrow is the PAYEE — the character owed the - /// copper either way, and the one whose session is about to re-drive it. - pub fn mail_escrows_of(&self, sender_guid: u64) -> Result> { - let guard = self.0.coord(); - let ids = guard.mail_escrows.read().unwrap().of(sender_guid); - let escrows = guard.conn.db.game_mail_escrow(); - Ok(ids - .into_iter() - .filter_map(|id| escrows.escrow_id().find(&id)) - .filter_map(|e| { - // A row no letter could have written stays held rather than commit as the wrong - // letter. - let reward = lyracore_shared::mail::RewardHeader::from_columns( - e.sender_kind, - e.sender_entry, - e.mail_template_id, - ) - .map_err(|refusal| { - log::error!("mail escrow {}: not driven: {refusal}", e.escrow_id) - }) - .ok()?; - Some((e, reward)) - }) - .map(|(e, reward)| crate::world::mail::HeldEscrow { - escrow_id: e.escrow_id, - recipient_guid: e.recipient_guid, - subject: e.subject, - body: e.body, - money: e.money, - postage: e.postage, - payout: e.payout, - mail_id: e.mail_id, - item: crate::world::mail::AttachedItem { - entry: e.item_entry, - stack_count: e.item_stack_count, - durability: e.item_durability, - enchant_id: e.item_enchant_id, - soulbound: e.item_soulbound, - random_property_id: e.random_property_id, - item_text_id: e.item_text_id, - }, - cod: e.cod, - delivery_delay_secs: e.delivery_delay_secs, - reward, - }) - .collect()) - } - - /// A copied letter's text, read from `game_item_text` on THIS handle's database. Private table, - /// read through the owner token, by its PK — the same shape `mailbox_in_range` uses to resolve - /// a gameobject. - pub fn item_text(&self, item_text_id: u32) -> Result> { - let guard = self.0.coord(); - Ok(guard - .conn - .db - .game_item_text() - .id() - .find(&item_text_id) - .map(|t| t.text)) + fn realm_account_name(&self, character_guid: u64) -> Result> { + Ok(self.realm_account_name(character_guid)) } /// Is `player_guid` standing at the mailbox `mailbox_guid` names? @@ -340,7 +43,7 @@ impl Coordinator { /// /// `false` for an unknown guid, a non-mailbox gameobject, another map or instance, and anything /// out of range — the gate answers the question, and the caller decides what a refusal costs. - pub fn mailbox_in_range(&self, mailbox_guid: u64, player_guid: u64) -> Result { + fn mailbox_in_range(&self, mailbox_guid: u64, player_guid: u64) -> Result { let guard = self.0.coord(); let db = &guard.conn.db; let (Some(go), Some(player)) = ( @@ -362,34 +65,34 @@ impl Coordinator { } /// `realm_mail_mark_read` — flip a mail's read state against the database THIS handle points - /// at. Operator-gated, `recipient_guid` passed + /// at. Operator-gated, `recipient` passed /// explicitly (the plane may hold no live entity), called on whichever handle `world::mail` /// picked — realm-core when sharded, this shard's own database when not. - pub fn mail_mark_read(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { + fn mail_mark_read(&self, recipient: Actor, mail_id: u64) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, "realm_mail_mark_read", - realm_mail_mark_read_then(self.actor_or_owner(recipient_guid), mail_id) + realm_mail_mark_read_then(self.session_actor(recipient), mail_id) ) } /// `realm_mail_delete` — [`mail_mark_read`](Self::mail_mark_read)'s twin for delete. - pub fn mail_delete(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { + fn mail_delete(&self, recipient: Actor, mail_id: u64) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, "realm_mail_delete", - realm_mail_delete_then(self.actor_or_owner(recipient_guid), mail_id) + realm_mail_delete_then(self.session_actor(recipient), mail_id) ) } /// `realm_mail_return` — [`mail_delete`](Self::mail_delete)'s twin for return-to-sender: the row /// is re-addressed in place, on the database THIS handle points at. No sharded variant — the row /// never leaves the plane that already holds it. - pub fn mail_return(&self, recipient_guid: u64, mail_id: u64, same_account: bool) -> Result<()> { + fn mail_return(&self, recipient: Actor, mail_id: u64, same_account: bool) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, "realm_mail_return", - realm_mail_return_then(self.actor_or_owner(recipient_guid), mail_id, same_account) + realm_mail_return_then(self.session_actor(recipient), mail_id, same_account) ) } @@ -401,9 +104,9 @@ impl Coordinator { /// **The single-database plane only.** A sharded realm cannot have that one transaction and /// drives the escrow below instead. #[allow(clippy::too_many_arguments)] - pub fn mail_send( + fn mail_send( &self, - sender_guid: u64, + sender: Actor, recipient_guid: u64, subject: String, body: String, @@ -416,7 +119,7 @@ impl Coordinator { self.0.call_pipe().conn.reducers, "realm_mail_send", realm_mail_send_then( - self.actor_or_owner(sender_guid), + self.session_actor(sender), recipient_guid, subject, body, @@ -430,72 +133,115 @@ impl Coordinator { /// `realm_mail_take_money` — credit a mail's copper to the recipient and empty the row, in one /// transaction. The single-database plane's whole take, for the same reason. - pub fn mail_take_money(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { + fn mail_take_money(&self, recipient: Actor, mail_id: u64) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, "realm_mail_take_money", - realm_mail_take_money_then(self.actor_or_owner(recipient_guid), mail_id) + realm_mail_take_money_then(self.session_actor(recipient), mail_id) ) } /// `realm_mail_take_item` — re-create a mail's attachment in the recipient's bags and empty the /// row, in one transaction. The single-database plane's whole item take. - pub fn mail_take_item(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { + fn mail_take_item(&self, recipient: Actor, mail_id: u64) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, "realm_mail_take_item", - realm_mail_take_item_then(self.actor_or_owner(recipient_guid), mail_id) + realm_mail_take_item_then(self.session_actor(recipient), mail_id) ) } /// `realm_mail_item_room` — the bag-space probe a sharded item take runs BEFORE it fences, on /// the taker's own handle. A read dressed as a reducer, because only the module can answer it /// without a second copy of the bag search. - pub fn mail_item_room(&self, payee_guid: u64) -> Result<()> { + fn mail_item_room(&self, payee: Actor) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, "realm_mail_item_room", - realm_mail_item_room_then(self.actor_or_owner(payee_guid)) + realm_mail_item_room_then(self.session_actor(payee)) ) } /// `realm_mail_copy_text` — Letter Copy step 1, against the database THIS handle points at. - /// Same trust shape as `mail_mark_read`: operator-gated, `recipient_guid` passed explicitly. - pub fn mail_copy_text(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { + /// Same trust shape as `mail_mark_read`: operator-gated, `recipient` passed explicitly. + fn mail_copy_text(&self, recipient: Actor, mail_id: u64) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, "realm_mail_copy_text", - realm_mail_copy_text_then(self.actor_or_owner(recipient_guid), mail_id) + realm_mail_copy_text_then(self.session_actor(recipient), mail_id) ) } /// `gw_mail_grant_letter` — Letter Copy step 2, on the PAYEE's own handle: grants one Plain /// Letter carrying `item_text_id`. - pub fn mail_grant_letter(&self, payee_guid: u64, item_text_id: u32) -> Result<()> { + fn mail_grant_letter(&self, payee: Actor, item_text_id: u32) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, "gw_mail_grant_letter", - gw_mail_grant_letter_then(self.actor_or_owner(payee_guid), item_text_id) + gw_mail_grant_letter_then(self.session_actor(payee), item_text_id) ) } /// `realm_mail_mark_letter_granted` — Letter Copy step 3, against the database THIS handle /// points at: the durable record that the Home Shard grant landed. - pub fn mail_mark_letter_granted(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { + fn mail_mark_letter_granted(&self, recipient: Actor, mail_id: u64) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, "realm_mail_mark_letter_granted", - realm_mail_mark_letter_granted_then(self.actor_or_owner(recipient_guid), mail_id) + realm_mail_mark_letter_granted_then(self.session_actor(recipient), mail_id) ) } + /// A copied letter's text, read from `game_item_text` on THIS handle's database. Private table, + /// read through the owner token, by its PK — the same shape `mailbox_in_range` uses to resolve + /// a gameobject. + fn item_text(&self, item_text_id: u32) -> Result> { + let guard = self.0.coord(); + Ok(guard + .conn + .db + .game_item_text() + .id() + .find(&item_text_id) + .map(|t| t.text)) + } + + /// Does `owner_guid` hold an item carrying `item_text_id`? `CMSG_ITEM_TEXT_QUERY`'s ownership + /// Gate: a client cannot use this to probe what anyone else holds, since the answer is a bare + /// bool. Read from the privileged cache. + /// + /// `hint_item_guid` is the wire's own second field — a bag item guid when the client is + /// reading that item's tooltip (`cm:MailHandler.cpp:630-646`), and vmangos always sends it for + /// this shape of the query. A PK lookup answers in O(1); a miss (item deleted, wrong id, or a + /// crafted query) answers `false` rather than falling back to a table-wide scan — this runs on + /// every such query, including ones for an id that names nothing, and the SDK exposes no + /// owner-keyed index to narrow it by. + fn owns_item_with_text( + &self, + owner_guid: u64, + item_text_id: u32, + hint_item_guid: u64, + ) -> Result { + if item_text_id == 0 || hint_item_guid == 0 { + return Ok(false); + } + let guard = self.0.coord(); + let db = &guard.conn.db; + let owns = db + .game_item_instance() + .guid() + .find(&hint_item_guid) + .is_some_and(|item| item.owner_guid == owner_guid && item.item_text_id == item_text_id); + Ok(owns) + } + /// `realm_mail_fence` — step 1 of a sharded SEND, on the SENDER's own handle: the postage plus /// the attached coin leave the purse into an escrow row keyed by the caller-chosen `escrow_id`. #[allow(clippy::too_many_arguments)] - pub fn mail_fence( + fn mail_fence( &self, escrow_id: u64, - sender_guid: u64, + sender: Actor, recipient_guid: u64, subject: String, body: String, @@ -511,7 +257,7 @@ impl Coordinator { "realm_mail_fence", realm_mail_fence_then( escrow_id, - self.actor_or_owner(sender_guid), + self.session_actor(sender), recipient_guid, subject, body, @@ -529,10 +275,10 @@ impl Coordinator { /// receipt under the same `escrow_id`, so a replay writes one letter and not two. A Reward /// Letter commits here on every plane, with its `reward` header. #[allow(clippy::too_many_arguments)] - pub fn mail_commit( + fn mail_commit( &self, escrow_id: u64, - sender_guid: u64, + sender: Actor, recipient_guid: u64, subject: String, body: String, @@ -550,7 +296,7 @@ impl Coordinator { "realm_mail_commit", realm_mail_commit_then( escrow_id, - self.actor_or_owner(sender_guid), + self.session_actor(sender), recipient_guid, subject, body, @@ -574,10 +320,10 @@ impl Coordinator { /// `realm_mail_take_money_fence` — step 1 of a sharded TAKE, on the handle that OWNS THE MAIL /// ROW: the copper leaves the row into an escrow there. The mirror of `mail_fence`. - pub fn mail_take_money_fence( + fn mail_take_money_fence( &self, escrow_id: u64, - payee_guid: u64, + payee: Actor, mail_id: u64, expect_money: u32, ) -> Result<()> { @@ -586,7 +332,7 @@ impl Coordinator { "realm_mail_take_money_fence", realm_mail_take_money_fence_then( escrow_id, - self.actor_or_owner(payee_guid), + self.session_actor(payee), mail_id, expect_money ) @@ -595,26 +341,20 @@ impl Coordinator { /// `realm_mail_payout` — step 2 of a sharded take, on the TAKER's own handle: the purse plus a /// receipt under the same `escrow_id`. `mail_commit`'s twin. - pub fn mail_payout( - &self, - escrow_id: u64, - payee_guid: u64, - mail_id: u64, - amount: u32, - ) -> Result<()> { + fn mail_payout(&self, escrow_id: u64, payee: Actor, mail_id: u64, amount: u32) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, "realm_mail_payout", - realm_mail_payout_then(escrow_id, self.actor_or_owner(payee_guid), mail_id, amount) + realm_mail_payout_then(escrow_id, self.session_actor(payee), mail_id, amount) ) } /// `realm_mail_take_item_fence` — step 1 of a sharded ITEM take, on the handle that OWNS THE /// MAIL ROW: the attachment leaves the row into an escrow there. - pub fn mail_take_item_fence( + fn mail_take_item_fence( &self, escrow_id: u64, - payee_guid: u64, + payee: Actor, mail_id: u64, expect_entry: u32, ) -> Result<()> { @@ -623,7 +363,7 @@ impl Coordinator { "realm_mail_take_item_fence", realm_mail_take_item_fence_then( escrow_id, - self.actor_or_owner(payee_guid), + self.session_actor(payee), mail_id, expect_entry ) @@ -632,10 +372,10 @@ impl Coordinator { /// `realm_mail_item_payout` — step 2 of a sharded item take, on the TAKER's own handle: the /// item plus a receipt under the same `escrow_id`. `mail_payout`'s twin. - pub fn mail_item_payout( + fn mail_item_payout( &self, escrow_id: u64, - payee_guid: u64, + payee: Actor, mail_id: u64, item: crate::world::mail::AttachedItem, ) -> Result<()> { @@ -644,7 +384,7 @@ impl Coordinator { "realm_mail_item_payout", realm_mail_item_payout_then( escrow_id, - self.actor_or_owner(payee_guid), + self.session_actor(payee), mail_id, item.entry, item.stack_count, @@ -659,7 +399,7 @@ impl Coordinator { /// `realm_mail_confirm_delivery` — step 3, on the handle that HOLDS THE FENCE. The attestation /// that the other database committed, and the only thing that licenses the settle. - pub fn mail_confirm_delivery(&self, escrow_id: u64) -> Result<()> { + fn mail_confirm_delivery(&self, escrow_id: u64) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, "realm_mail_confirm_delivery", @@ -668,13 +408,70 @@ impl Coordinator { } /// `realm_mail_settle` — step 4, on the handle that holds the fence: destroy it. Delete-last. - pub fn mail_settle(&self, escrow_id: u64) -> Result<()> { + fn mail_settle(&self, escrow_id: u64) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, "realm_mail_settle", realm_mail_settle_then(escrow_id, self.owner_actor()) ) } + + /// Every mail escrow this database is holding for `sender_guid` — the fences a drive filed and + /// never finished. + /// + /// The one module→gateway data flow the escrow adds, and it exists for the same reason + /// `escrowed_transfer` does: the gateway is the only component that can see both databases, so + /// re-driving a stalled fence means re-deriving the whole letter from its row. A Character's fresh + /// send reads nothing more than its own fence; a Reward Letter, which the Module files at + /// turn-in, is always driven from its row. Private table, read through the owner token. + /// + /// Keyed by `sender_guid`, which on a payout escrow is the PAYEE — the character owed the + /// copper either way, and the one whose session is about to re-drive it. + fn mail_escrows_of(&self, sender_guid: u64) -> Result> { + let guard = self.0.coord(); + let ids = guard.mail_escrows.read().unwrap().of(sender_guid); + let escrows = guard.conn.db.game_mail_escrow(); + Ok(ids + .into_iter() + .filter_map(|id| escrows.escrow_id().find(&id)) + .filter_map(|e| { + // A row no letter could have written stays held rather than commit as the wrong + // letter. + let reward = lyracore_shared::mail::RewardHeader::from_columns( + e.sender_kind, + e.sender_entry, + e.mail_template_id, + ) + .map_err(|refusal| { + log::error!("mail escrow {}: not driven: {refusal}", e.escrow_id) + }) + .ok()?; + Some((e, reward)) + }) + .map(|(e, reward)| crate::world::mail::HeldEscrow { + escrow_id: e.escrow_id, + recipient_guid: e.recipient_guid, + subject: e.subject, + body: e.body, + money: e.money, + postage: e.postage, + payout: e.payout, + mail_id: e.mail_id, + item: crate::world::mail::AttachedItem { + entry: e.item_entry, + stack_count: e.item_stack_count, + durability: e.item_durability, + enchant_id: e.item_enchant_id, + soulbound: e.item_soulbound, + random_property_id: e.random_property_id, + item_text_id: e.item_text_id, + }, + cod: e.cod, + delivery_delay_secs: e.delivery_delay_secs, + reward, + }) + .collect()) + } } fn mail_view(db: &RemoteTables, m: Mail) -> crate::codec::MailView { diff --git a/gateway/src/world/handlers/mail.rs b/gateway/src/world/handlers/mail.rs index 5acd1afec..76c7fa0a1 100644 --- a/gateway/src/world/handlers/mail.rs +++ b/gateway/src/world/handlers/mail.rs @@ -2,8 +2,8 @@ //! a mail read or delete it, post a letter with copper and one item attached (at a //! cash-on-delivery price, or not), take either back out, and return a mail to its sender. //! -//! Every failure is per-action: log, and either answer the packet the client is blocked on or send -//! nothing. Nothing here tears a session down, matching the vendor/loot/combat arms. +//! Every Refusal is per-action: log, and either answer the packet the client is blocked on or send +//! nothing. A Transport Loss ends the World Session. use super::super::*; use wow_world_messages::vanilla::SMSG_SEND_MAIL_RESULT_MailResultTwo; @@ -24,17 +24,17 @@ pub(crate) fn handle_mail< // 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) => { - let self_guid = social::self_guid(conn); - match mail::open_mailbox(store, self_guid, c.mailbox.guid()) { + 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(e) => log::debug!( - "world: mail list refused (account {}): {e}", - conn.account_id + Err(failure) => log::debug!( + "world: mail list refused (account {}): {}", + conn.account_id, + failure.refusal()? ), } } @@ -43,13 +43,17 @@ pub(crate) fn handle_mail< // 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 = mail::has_unread(store, social::self_guid(conn)).unwrap_or_else(|e| { - log::debug!( - "world: mail poll answered empty (account {}): {e}", - conn.account_id - ); - false - }); + 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( @@ -67,19 +71,22 @@ pub(crate) fn handle_mail< // 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 = mail::item_text( + let body = match mail::item_text( store, - social::self_guid(conn), + social::self_actor(conn), c.item_text_id, u64::from(c.mail_id), - ) - .unwrap_or_else(|e| { - log::debug!( - "world: item text query refused (account {}): {e}", - conn.account_id - ); - None - }); + ) { + Ok(body) => body, + Err(failure) => { + log::debug!( + "world: item text query refused (account {}): {}", + conn.account_id, + failure.refusal()? + ); + None + } + }; send( tx, Outbound::One(ServerOpcodeMessage::SMSG_ITEM_TEXT_QUERY_RESPONSE( @@ -95,13 +102,14 @@ pub(crate) fn handle_mail< // 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 self_guid = social::self_guid(conn); - if let Err(e) = - mail::mark_read(store, self_guid, c.mailbox.guid(), u64::from(c.mail_id)) + 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 {}): {e}", - conn.account_id + "world: mail mark-as-read refused (account {}): {}", + conn.account_id, + failure.refusal()? ); } } @@ -110,14 +118,14 @@ pub(crate) fn handle_mail< // 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 self_guid = social::self_guid(conn); - let ok = match mail::delete(store, self_guid, c.mailbox_id.guid(), u64::from(c.mail_id)) - { + 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(e) => { + Err(failure) => { log::debug!( - "world: mail delete refused (account {}): {e}", - conn.account_id + "world: mail delete refused (account {}): {}", + conn.account_id, + failure.refusal()? ); false } @@ -134,18 +142,19 @@ pub(crate) fn handle_mail< // 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 self_guid = social::self_guid(conn); + let actor = social::self_actor(conn); let ok = match mail::return_to_sender( store, - self_guid, + actor, c.mailbox_id.guid(), u64::from(c.mail_id), ) { Ok(()) => true, - Err(e) => { + Err(failure) => { log::debug!( - "world: mail return refused (account {}): {e}", - conn.account_id + "world: mail return refused (account {}): {}", + conn.account_id, + failure.refusal()? ); false } @@ -163,18 +172,18 @@ pub(crate) fn handle_mail< // outcomes ack through `SMSG_SEND_MAIL_RESULT`/MoneyTaken, which is what closes the // client's spinner. ClientOpcodeMessage::CMSG_MAIL_TAKE_MONEY(c) => { - let self_guid = social::self_guid(conn); - let ok = - match mail::take_money(store, self_guid, c.mailbox.guid(), u64::from(c.mail_id)) { - Ok(()) => true, - Err(e) => { - log::debug!( - "world: mail take-money refused (account {}): {e}", - conn.account_id - ); - false - } - }; + 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) => { + log::debug!( + "world: mail take-money refused (account {}): {}", + conn.account_id, + failure.refusal()? + ); + false + } + }; send( tx, Outbound::One(ServerOpcodeMessage::SMSG_SEND_MAIL_RESULT(Box::new( @@ -188,11 +197,12 @@ pub(crate) fn handle_mail< // 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 self_guid = social::self_guid(conn); + let actor = social::self_actor(conn); let outcome = - match mail::take_item(store, self_guid, c.mailbox.guid(), u64::from(c.mail_id)) { + match mail::take_item(store, actor, c.mailbox.guid(), u64::from(c.mail_id)) { Ok(taken) => Ok(taken), - Err(e) => { + Err(failure) => { + let e = failure.refusal()?; log::debug!( "world: mail take-item refused (account {}): {e}", conn.account_id @@ -224,10 +234,9 @@ pub(crate) fn handle_mail< // 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 self_guid = social::self_guid(conn); let result2 = match mail::send( store, - self_guid, + social::self_actor(conn), c.mailbox.guid(), &c.receiver, c.subject.clone(), @@ -237,7 +246,8 @@ pub(crate) fn handle_mail< c.item.guid(), ) { Ok(()) => Some(SMSG_SEND_MAIL_RESULT_MailResultTwo::Ok), - Err(e) => { + Err(failure) => { + let e = failure.refusal()?; log::debug!( "world: mail send refused (account {}): {e}", conn.account_id @@ -287,26 +297,27 @@ pub(crate) fn handle_mail< // `SMSG_SEND_MAIL_RESULT`/MadePermanent; the mailbox gate alone stays silent, matching the // other arms. ClientOpcodeMessage::CMSG_MAIL_CREATE_TEXT_ITEM(c) => { - let self_guid = social::self_guid(conn); - let made = - match mail::copy_letter(store, self_guid, c.mailbox.guid(), u64::from(c.mail_id)) { - Ok(()) => Some(Ok(())), - Err(e) => { - 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)) - } + 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)) } } - }; + } + }; if let Some(made) = made { send( tx, diff --git a/gateway/src/world/mail.rs b/gateway/src/world/mail.rs index 0d5426742..74a028fb4 100644 --- a/gateway/src/world/mail.rs +++ b/gateway/src/world/mail.rs @@ -5,8 +5,9 @@ use crate::world::{CharacterStore, SessionStore, ShardRoutingStore, SocialStore}; use anyhow::Result; -use super::{presence, WorldStore}; +use super::{presence, Actor, WorldStore}; use crate::codec::MailView; +use crate::stdb::{classify, DurableFailure}; use lyracore_shared::mail as mail_rules; /// Mail rows, Letter Copy text, and the cross-shard mail Escrow steps. @@ -36,30 +37,30 @@ pub(crate) trait MailStore: Send + Sync { /// never a scan of the spatial gameobject table. fn mailbox_in_range(&self, mailbox_guid: u64, player_guid: u64) -> Result; - /// Flip `mail_id`'s read state for `recipient_guid`, on the database THIS handle names. + /// Flip `mail_id`'s read state for `recipient`, on the database THIS handle names. /// /// Called on the realm-core handle when there is one and on the session's own handle when there /// is not — the SAME two-plane routing [`mail_list`](Self::mail_list) takes, because the write /// and the read must never disagree about which database owns the rows. `Err` when `mail_id` - /// does not exist, is not `recipient_guid`'s or has not arrived yet — the gates ran in + /// does not exist, is not `recipient`'s or has not arrived yet — the gates ran in /// `world::mail` before this is ever called, so a refusal here means a crafted id or a Gateway /// clock that runs ahead of the Module's. - fn mail_mark_read(&self, recipient_guid: u64, mail_id: u64) -> Result<()>; + fn mail_mark_read(&self, recipient: Actor, mail_id: u64) -> Result<()>; - /// Delete `mail_id` for `recipient_guid`, on the database THIS handle names — same two-plane + /// Delete `mail_id` for `recipient`, on the database THIS handle names — same two-plane /// routing as [`mail_mark_read`](Self::mail_mark_read). Destroys any attachment the row still /// carries, as vanilla does after its (client-side) confirmation prompt. `Err` for a mail with /// a cash on delivery price or one that has not arrived yet. - fn mail_delete(&self, recipient_guid: u64, mail_id: u64) -> Result<()>; + fn mail_delete(&self, recipient: Actor, mail_id: u64) -> Result<()>; /// Return `mail_id` to whoever sent it, on the database THIS handle names — same two-plane /// routing as [`mail_delete`](Self::mail_delete). The row is re-addressed IN PLACE: it never /// leaves the plane that already holds it, so there is no sharded variant and no escrow, unlike /// [`mail_send`](Self::mail_send) and the takes below. `Err` when `mail_id` does not exist, is - /// not `recipient_guid`'s, is not delivered yet, has no Character sender, or was returned - /// already. `same_account` says whether `recipient_guid` and the mail's sender belong to one + /// not `recipient`'s, is not delivered yet, has no Character sender, or was returned + /// already. `same_account` says whether `recipient` and the mail's sender belong to one /// Realm Account; the Module turns it into the return's Delivery Delay. - fn mail_return(&self, recipient_guid: u64, mail_id: u64, same_account: bool) -> Result<()>; + fn mail_return(&self, recipient: Actor, mail_id: u64, same_account: bool) -> Result<()>; /// Write one sent letter on the database THIS handle names, charging the sender the postage /// plus the attached `money` in the SAME transaction. @@ -69,7 +70,7 @@ pub(crate) trait MailStore: Send + Sync { /// friends instead. /// /// Every gate that decides who may write to whom has already run in `world::mail` — realm-core - /// can answer none of them — so `sender_guid` must be the guid the socket authenticated. + /// can answer none of them — so `sender` must be the guid the socket authenticated. /// /// `cod` is the price the RECIPIENT will owe for the attachment. It costs the sender nothing /// and is not part of the debit; it only rides the row until somebody takes the item. @@ -78,7 +79,7 @@ pub(crate) trait MailStore: Send + Sync { #[allow(clippy::too_many_arguments)] fn mail_send( &self, - sender_guid: u64, + sender: Actor, recipient_guid: u64, subject: String, body: String, @@ -88,24 +89,24 @@ pub(crate) trait MailStore: Send + Sync { same_account: bool, ) -> Result<()>; - /// Credit `mail_id`'s copper to `recipient_guid` and empty the row, in one transaction. The + /// Credit `mail_id`'s copper to `recipient` and empty the row, in one transaction. The /// single-database twin of [`mail_send`](Self::mail_send), and refused for a mail that is not /// the caller's, is not delivered yet, or has nothing left in it. - fn mail_take_money(&self, recipient_guid: u64, mail_id: u64) -> Result<()>; + fn mail_take_money(&self, recipient: Actor, mail_id: u64) -> Result<()>; - /// Re-create `mail_id`'s attached item in `recipient_guid`'s bags and empty the row's + /// Re-create `mail_id`'s attached item in `recipient`'s bags and empty the row's /// attachment columns, in one transaction. [`mail_take_money`](Self::mail_take_money)'s twin, /// and refused for a mail that is not the caller's or not delivered yet, one with no /// attachment, or a full bag — /// where the refusal rolls the clear back, so the item stays in the letter. - fn mail_take_item(&self, recipient_guid: u64, mail_id: u64) -> Result<()>; + fn mail_take_item(&self, recipient: Actor, mail_id: u64) -> Result<()>; - /// Has `payee_guid` room in their bags here for one more item? + /// Has `payee` room in their bags here for one more item? /// /// Asked of the TAKER's own handle, before a sharded item take fences anything: the fence is a /// one-way move, so a full bag found afterwards would strand the item in an escrow instead of /// leaving it in the letter. `Err` is the refusal. - fn mail_item_room(&self, payee_guid: u64) -> Result<()>; + fn mail_item_room(&self, payee: Actor) -> Result<()>; /// `CMSG_MAIL_CREATE_TEXT_ITEM` step 1 (Letter Copy) — set COPIED on `mail_id` and file its /// body as durable item text, on the database that OWNS THE MAIL ROW (realm-core when sharded, @@ -113,7 +114,7 @@ pub(crate) trait MailStore: Send + Sync { /// takes). `Err` for a mail that is not the caller's, is not delivered, has no body, or is /// already GRANTED. A replay before GRANTED is set is `Ok` (a no-op on the mail plane), so a /// retry can still reach the Home Shard grant. - fn mail_copy_text(&self, recipient_guid: u64, mail_id: u64) -> Result<()>; + fn mail_copy_text(&self, recipient: Actor, mail_id: u64) -> Result<()>; /// `CMSG_MAIL_CREATE_TEXT_ITEM` step 2 — store one Plain Letter carrying `item_text_id`, on the /// PAYEE's own handle. [`mail_item_room`](Self::mail_item_room)'s real Gate: a full bag found @@ -122,13 +123,13 @@ pub(crate) trait MailStore: Send + Sync { /// no-op `Ok` when the payee already holds an item carrying `item_text_id`: the crash-window /// guard between this call landing and [`mail_mark_letter_granted`](Self::mail_mark_letter_granted) /// recording that it did. - fn mail_grant_letter(&self, payee_guid: u64, item_text_id: u32) -> Result<()>; + fn mail_grant_letter(&self, payee: Actor, item_text_id: u32) -> Result<()>; /// `CMSG_MAIL_CREATE_TEXT_ITEM` step 3 — the durable record that the grant landed, on the same /// database `mail_copy_text` wrote to. Called once [`mail_grant_letter`](Self::mail_grant_letter) /// returns `Ok`. Unlike the item itself, this bit cannot be destroyed, mailed away, or traded, /// so it is what refuses a second grant for good. - fn mail_mark_letter_granted(&self, recipient_guid: u64, mail_id: u64) -> Result<()>; + fn mail_mark_letter_granted(&self, recipient: Actor, mail_id: u64) -> Result<()>; /// The durable text behind `item_text_id`, read from `game_item_text` on the database that /// OWNS THE MAIL PLANE (same two-plane routing as [`mail_copy_text`](Self::mail_copy_text)). A @@ -153,7 +154,7 @@ pub(crate) trait MailStore: Send + Sync { hint_item_guid: u64, ) -> Result; - /// **Escrow step 1 (send)** — take the postage plus the attached coin out of `sender_guid`'s + /// **Escrow step 1 (send)** — take the postage plus the attached coin out of `sender`'s /// purse into a fence keyed by the caller-chosen `escrow_id`, on the database THIS handle names. /// /// Always the SENDER's own handle: the purse is `game_world_entity.money`, on the shard they @@ -169,7 +170,7 @@ pub(crate) trait MailStore: Send + Sync { fn mail_fence( &self, escrow_id: u64, - sender_guid: u64, + sender: Actor, recipient_guid: u64, subject: String, body: String, @@ -190,12 +191,12 @@ pub(crate) trait MailStore: Send + Sync { /// /// `delivery_delay_secs` is the Delivery Delay the fence stored. The letter arrives that long /// after this commit. `reward` names a Reward Letter's quest giver and Mail Template; `None` is - /// a Character's letter from `sender_guid`. + /// a Character's letter from `sender`. #[allow(clippy::too_many_arguments)] fn mail_commit( &self, escrow_id: u64, - sender_guid: u64, + sender: Actor, recipient_guid: u64, subject: String, body: String, @@ -213,15 +214,14 @@ pub(crate) trait MailStore: Send + Sync { fn mail_take_money_fence( &self, escrow_id: u64, - payee_guid: u64, + payee: Actor, mail_id: u64, expect_money: u32, ) -> Result<()>; - /// **Escrow step 2 (take)** — credit `amount` to `payee_guid` and file a receipt under + /// **Escrow step 2 (take)** — credit `amount` to `payee` and file a receipt under /// `escrow_id`, on the PAYEE's own handle. Idempotent: a replay credits nothing. - fn mail_payout(&self, escrow_id: u64, payee_guid: u64, mail_id: u64, amount: u32) - -> Result<()>; + fn mail_payout(&self, escrow_id: u64, payee: Actor, mail_id: u64, amount: u32) -> Result<()>; /// **Escrow step 1 (item take)** — take `mail_id`'s attachment out of the row into a fence, on /// the database that OWNS THE ROW. `expect_entry` is the item the caller is about to grant; a @@ -229,18 +229,18 @@ pub(crate) trait MailStore: Send + Sync { fn mail_take_item_fence( &self, escrow_id: u64, - payee_guid: u64, + payee: Actor, mail_id: u64, expect_entry: u32, ) -> Result<()>; - /// **Escrow step 2 (item take)** — re-create the fenced item in `payee_guid`'s bags and file a + /// **Escrow step 2 (item take)** — re-create the fenced item in `payee`'s bags and file a /// receipt under `escrow_id`, on the PAYEE's own handle. Idempotent: a replay grants nothing. /// `Err` on a full bag, which leaves the fence holding the item for the next re-drive. fn mail_item_payout( &self, escrow_id: u64, - payee_guid: u64, + payee: Actor, mail_id: u64, item: AttachedItem, ) -> Result<()>; @@ -296,6 +296,91 @@ impl std::fmt::Display for SendRefusal { } } } + +impl FromReason for SendRefusal { + fn from_reason(reason: &str) -> Self { + let reason = reason.to_string(); + if reason.contains(mail_rules::NOT_ENOUGH_MONEY) { + SendRefusal::NotEnoughMoney(reason) + } else if reason.contains(mail_rules::ITEM_IS_SOULBOUND) { + SendRefusal::AttachmentSoulbound(reason) + } else if reason.contains(mail_rules::NOT_YOUR_ITEM) { + SendRefusal::AttachmentInvalid(reason) + } else { + SendRefusal::Internal(reason) + } + } +} + +/// Why a mail op did not complete. A Refusal, from a Gateway Gate or the Module, is the client's +/// answer. A Transport Loss leaves the durable outcome unknown and ends the World Session. +#[derive(Debug)] +pub(crate) enum MailFailure { + Refused(R), + Lost(anyhow::Error), +} + +impl MailFailure { + /// The Refusal to answer, or the Transport Loss to end the World Session with. + pub(crate) fn refusal(self) -> Result { + match self { + Self::Refused(refusal) => Ok(refusal), + Self::Lost(error) => Err(error), + } + } + + /// The Refusal a test expects; a Transport Loss fails the test. + #[cfg(test)] + pub(crate) fn into_refusal(self) -> R { + match self { + Self::Refused(refusal) => refusal, + Self::Lost(error) => panic!("expected a Refusal, got a Transport Loss: {error:#}"), + } + } + + fn map(self, f: impl FnOnce(R) -> S) -> MailFailure { + match self { + Self::Refused(refusal) => MailFailure::Refused(f(refusal)), + Self::Lost(error) => MailFailure::Lost(error), + } + } +} + +impl std::fmt::Display for MailFailure { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Refused(refusal) => refusal.fmt(f), + Self::Lost(error) => write!(f, "{error:#}"), + } + } +} + +/// One mail op's Refusal, built from a Gate's reason. +pub(crate) trait FromReason { + fn from_reason(reason: &str) -> Self; +} + +impl FromReason for String { + fn from_reason(reason: &str) -> Self { + reason.to_string() + } +} + +impl From for MailFailure { + fn from(error: anyhow::Error) -> Self { + match classify(&error) { + DurableFailure::Refusal { reason } => Self::Refused(R::from_reason(reason)), + DurableFailure::TransportLoss => Self::Lost(error), + } + } +} + +pub(crate) type MailResult = std::result::Result>; + +fn refused(reason: &str) -> MailFailure { + MailFailure::Refused(R::from_reason(reason)) +} + /// The mailbox as its owner sees it, on whichever plane holds it. A mail whose delivery instant is /// still ahead is absent, so the list, the unread poll, the body read and every take skip it /// (cmangos `MailHandler.cpp:561`, `Player.cpp:3079-3096`). @@ -319,64 +404,64 @@ pub(crate) fn now_secs() -> i64 { } pub(crate) fn open_mailbox( store: &St, - self_guid: Option, + actor: Option, mailbox_guid: u64, -) -> Result> { - let self_guid = at_mailbox(store, self_guid, mailbox_guid)?; - redrive(store, self_guid); - mail_of(store, self_guid) +) -> MailResult> { + let actor = at_mailbox(store, actor, mailbox_guid)?; + redrive(store, actor.guid()); + Ok(mail_of(store, actor.guid())?) } pub(crate) fn has_unread( store: &St, - self_guid: Option, -) -> Result { - let self_guid = - self_guid.ok_or_else(|| anyhow::anyhow!(lyracore_shared::mail::NOT_IN_WORLD))?; - Ok(mail_of(store, self_guid)?.iter().any(|m| !m.was_read)) + actor: Option, +) -> MailResult { + let actor = actor.ok_or_else(|| refused(mail_rules::NOT_IN_WORLD))?; + Ok(mail_of(store, actor.guid())?.iter().any(|m| !m.was_read)) } pub(crate) fn letter_body( store: &St, - self_guid: Option, + actor: Option, mail_id: u64, -) -> Result> { - let self_guid = - self_guid.ok_or_else(|| anyhow::anyhow!(lyracore_shared::mail::NOT_IN_WORLD))?; - Ok(mail_of(store, self_guid)? +) -> MailResult> { + let actor = actor.ok_or_else(|| refused(mail_rules::NOT_IN_WORLD))?; + Ok(mail_of(store, actor.guid())? .into_iter() .find(|m| m.id == mail_id) .map(|m| m.body)) } pub(crate) fn mark_read( store: &St, - self_guid: Option, + actor: Option, mailbox_guid: u64, mail_id: u64, -) -> Result<()> { - let self_guid = at_mailbox(store, self_guid, mailbox_guid)?; +) -> MailResult<()> { + let actor = at_mailbox(store, actor, mailbox_guid)?; match store.realm_store() { - Some(realm) => realm.mail_mark_read(self_guid, mail_id), - None => store.mail_mark_read(self_guid, mail_id), - } + Some(realm) => realm.mail_mark_read(actor, mail_id), + None => store.mail_mark_read(actor, mail_id), + }?; + Ok(()) } pub(crate) fn delete( store: &St, - self_guid: Option, + actor: Option, mailbox_guid: u64, mail_id: u64, -) -> Result<()> { - let self_guid = at_mailbox(store, self_guid, mailbox_guid)?; +) -> MailResult<()> { + let actor = at_mailbox(store, actor, mailbox_guid)?; match store.realm_store() { - Some(realm) => realm.mail_delete(self_guid, mail_id), - None => store.mail_delete(self_guid, mail_id), - } + Some(realm) => realm.mail_delete(actor, mail_id), + None => store.mail_delete(actor, mail_id), + }?; + Ok(()) } pub(crate) fn return_to_sender( store: &St, - self_guid: Option, + actor: Option, mailbox_guid: u64, mail_id: u64, -) -> Result<()> { - let self_guid = at_mailbox(store, self_guid, mailbox_guid)?; +) -> MailResult<()> { + let actor = at_mailbox(store, actor, mailbox_guid)?; let realm = store.realm_store(); let mail = match &realm { Some(realm) => realm.mail_by_id(mail_id)?, @@ -384,13 +469,14 @@ pub(crate) fn return_to_sender( }; // A mail that is not there is refused by the Module, so its Accounts do not matter. let same_account = match mail { - Some(mail) => same_realm_account(store, self_guid, mail.sender_guid)?, + Some(mail) => same_realm_account(store, actor.guid(), mail.sender_guid)?, None => false, }; match realm { - Some(realm) => realm.mail_return(self_guid, mail_id, same_account), - None => store.mail_return(self_guid, mail_id, same_account), - } + Some(realm) => realm.mail_return(actor, mail_id, same_account), + None => store.mail_return(actor, mail_id, same_account), + }?; + Ok(()) } /// Do two Characters belong to one Realm Account? The Module turns the answer into the Delivery /// Delay, but it cannot read a Character's Account on another Shard, so the Gateway reads both @@ -433,7 +519,7 @@ pub(crate) fn send< St: CharacterStore + MailStore + SessionStore + ShardRoutingStore + SocialStore + ?Sized, >( store: &St, - self_guid: Option, + actor: Option, mailbox_guid: u64, recipient_name: &str, subject: String, @@ -441,30 +527,30 @@ pub(crate) fn send< money: u32, cod: u32, item_guid: u64, -) -> std::result::Result<(), SendRefusal> { - let sender_guid = at_mailbox(store, self_guid, mailbox_guid) - .map_err(|e| SendRefusal::NoMailbox(e.to_string()))?; +) -> MailResult<(), SendRefusal> { + let sender_actor = at_mailbox::(store, actor, mailbox_guid) + .map_err(|failure| failure.map(SendRefusal::NoMailbox))?; + let sender_guid = sender_actor.guid(); if !presence::live_anywhere(store, sender_guid) { - return Err(SendRefusal::NoMailbox(mail_rules::NOT_IN_WORLD.to_string())); + return Err(MailFailure::Refused(SendRefusal::NoMailbox( + mail_rules::NOT_IN_WORLD.to_string(), + ))); } - let candidates = - presence::resolve_all_by_name(store, recipient_name).map_err(refusal_from_module)?; + let candidates = presence::resolve_all_by_name(store, recipient_name)?; if candidates.is_empty() { - return Err(SendRefusal::RecipientNotFound( + return Err(MailFailure::Refused(SendRefusal::RecipientNotFound( mail_rules::no_recipient_named(recipient_name), - )); + ))); } if candidates.contains(&sender_guid) { - return Err(SendRefusal::CannotSendToSelf); + return Err(MailFailure::Refused(SendRefusal::CannotSendToSelf)); } - let sender = presence::character_anywhere(store, sender_guid) - .map_err(refusal_from_module)? - .ok_or_else(|| SendRefusal::Internal(mail_rules::NOT_IN_WORLD.to_string()))?; + let sender = presence::character_anywhere(store, sender_guid)?.ok_or_else(|| { + MailFailure::Refused(SendRefusal::Internal(mail_rules::NOT_IN_WORLD.to_string())) + })?; let mut reachable = Vec::new(); for guid in candidates { - let Some(candidate) = - presence::character_anywhere(store, guid).map_err(refusal_from_module)? - else { + let Some(candidate) = presence::character_anywhere(store, guid)? else { continue; }; if lyracore_shared::faction::same_team(sender.race, candidate.race) { @@ -472,54 +558,48 @@ pub(crate) fn send< } } let recipient_guid = match reachable.as_slice() { - [] => return Err(SendRefusal::NotYourTeam), + [] => return Err(MailFailure::Refused(SendRefusal::NotYourTeam)), [only] => *only, _ => { - return Err(SendRefusal::RecipientNotFound( + return Err(MailFailure::Refused(SendRefusal::RecipientNotFound( mail_rules::ambiguous_recipient(recipient_name), - )) + ))) } }; let cod = mail_rules::cod_at_send(cod, item_guid != 0); - let same_account = - same_realm_account(store, sender_guid, recipient_guid).map_err(refusal_from_module)?; + let same_account = same_realm_account(store, sender_guid, recipient_guid)?; match store.realm_store() { - None => store - .mail_send( - sender_guid, + None => store.mail_send( + sender_actor, + recipient_guid, + subject, + body, + money, + cod, + item_guid, + same_account, + )?, + Some(realm) => { + let escrow_id = next_escrow_id()?; + store.mail_fence( + escrow_id, + sender_actor, recipient_guid, - subject, - body, + subject.clone(), + body.clone(), money, - cod, + mail_rules::postage(), item_guid, + cod, + NO_COD_SOURCE, same_account, - ) - .map_err(refusal_from_module), - Some(realm) => { - let escrow_id = - next_escrow_id().map_err(|e| SendRefusal::Internal(format!("{e:#}")))?; - store - .mail_fence( - escrow_id, - sender_guid, - recipient_guid, - subject.clone(), - body.clone(), - money, - mail_rules::postage(), - item_guid, - cod, - NO_COD_SOURCE, - same_account, - ) - .map_err(refusal_from_module)?; - let held = held_fence(store, sender_guid, escrow_id) - .map_err(|e| SendRefusal::Internal(format!("{e:#}")))?; + )?; + let held = held_fence(store, sender_guid, escrow_id)?; + // Past the fence, any Refusal is the drive's own and answers as an internal error. drive(store, escrow_id, || { realm.mail_commit( escrow_id, - sender_guid, + sender_actor, recipient_guid, subject, body, @@ -531,9 +611,10 @@ pub(crate) fn send< held.reward, ) }) - .map_err(|e| SendRefusal::Internal(format!("{e:#}"))) + .map_err(|error| MailFailure::::from(error).map(SendRefusal::Internal))?; } } + Ok(()) } fn drive(source: &St, escrow_id: u64, commit: F) -> Result<()> where @@ -619,14 +700,18 @@ pub struct HeldEscrow { /// turn-in. On a single-database realm the mail plane is the same database: a Character's send files /// no Escrow there, but a Reward Letter does. pub(crate) fn redrive(store: &St, self_guid: u64) { + // Guid 0 names no Character, so it holds no Escrow. + let Some(actor) = Actor::new(self_guid) else { + return; + }; let realm = store.realm_store(); for held in store.mail_escrows_of(self_guid).unwrap_or_default() { if held.payout { continue; // A payout fence never lives on a shard; ignore a stray rather than mis-drive it. } let outcome = drive(store, held.escrow_id, || match &realm { - Some(realm) => commit_held(realm.as_ref(), self_guid, &held), - None => commit_held(store, self_guid, &held), + Some(realm) => commit_held(realm.as_ref(), actor, &held), + None => commit_held(store, actor, &held), }); let kind = if held.reward.is_some() { "Reward Letter" @@ -644,24 +729,20 @@ pub(crate) fn redrive(store: &St, se } let outcome = drive(realm.as_ref(), held.escrow_id, || { if held.item.is_empty() { - store.mail_payout(held.escrow_id, self_guid, held.mail_id, held.money) + store.mail_payout(held.escrow_id, actor, held.mail_id, held.money) } else { - store.mail_item_payout(held.escrow_id, self_guid, held.mail_id, held.item.clone()) + store.mail_item_payout(held.escrow_id, actor, held.mail_id, held.item.clone()) } }); log_redrive("take", held.escrow_id, outcome); } } -/// Commit the letter `held` describes on the mail plane `plane`, fenced by `sender_guid`. -fn commit_held( - plane: &P, - sender_guid: u64, - held: &HeldEscrow, -) -> Result<()> { +/// Commit the letter `held` describes on the mail plane `plane`, fenced by `sender`. +fn commit_held(plane: &P, sender: Actor, held: &HeldEscrow) -> Result<()> { plane.mail_commit( held.escrow_id, - sender_guid, + sender, held.recipient_guid, held.subject.clone(), held.body.clone(), @@ -684,27 +765,28 @@ fn log_redrive(kind: &str, escrow_id: u64, outcome: Result<()>) { } pub(crate) fn take_money( store: &St, - self_guid: Option, + actor: Option, mailbox_guid: u64, mail_id: u64, -) -> Result<()> { - let self_guid = at_mailbox(store, self_guid, mailbox_guid)?; +) -> MailResult<()> { + let actor = at_mailbox(store, actor, mailbox_guid)?; let Some(realm) = store.realm_store() else { - return store.mail_take_money(self_guid, mail_id); + return Ok(store.mail_take_money(actor, mail_id)?); }; - let amount = mail_of(store, self_guid)? + let amount = mail_of(store, actor.guid())? .into_iter() .find(|m| m.id == mail_id) .map(|m| m.money) - .ok_or_else(|| anyhow::anyhow!(mail_rules::NOT_YOUR_MAIL))?; + .ok_or_else(|| refused(mail_rules::NOT_YOUR_MAIL))?; if amount == 0 { - anyhow::bail!(mail_rules::NOTHING_TO_TAKE); + return Err(refused(mail_rules::NOTHING_TO_TAKE)); } let escrow_id = next_escrow_id()?; - realm.mail_take_money_fence(escrow_id, self_guid, mail_id, amount)?; + realm.mail_take_money_fence(escrow_id, actor, mail_id, amount)?; drive(realm.as_ref(), escrow_id, || { - store.mail_payout(escrow_id, self_guid, mail_id, amount) - }) + store.mail_payout(escrow_id, actor, mail_id, amount) + })?; + Ok(()) } #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum TakeItemRefusal { @@ -721,63 +803,56 @@ impl std::fmt::Display for TakeItemRefusal { } } -fn take_item_refusal(e: anyhow::Error) -> TakeItemRefusal { - let text = format!("{e:#}"); - if text.contains(mail_rules::INVENTORY_FULL) { - TakeItemRefusal::BagsFull(text) - } else if text.contains(mail_rules::COD_NOT_AFFORDABLE) - || text.contains(mail_rules::NOT_ENOUGH_MONEY) - { - TakeItemRefusal::CannotAffordCod(text) - } else { - TakeItemRefusal::Other(text) +impl FromReason for TakeItemRefusal { + fn from_reason(reason: &str) -> Self { + let text = reason.to_string(); + if text.contains(mail_rules::INVENTORY_FULL) { + TakeItemRefusal::BagsFull(text) + } else if text.contains(mail_rules::COD_NOT_AFFORDABLE) + || text.contains(mail_rules::NOT_ENOUGH_MONEY) + { + TakeItemRefusal::CannotAffordCod(text) + } else { + TakeItemRefusal::Other(text) + } } } pub(crate) fn take_item( store: &St, - self_guid: Option, + actor: Option, mailbox_guid: u64, mail_id: u64, -) -> std::result::Result<(u32, u32), TakeItemRefusal> { - let self_guid = at_mailbox(store, self_guid, mailbox_guid).map_err(take_item_refusal)?; - let row = mail_of(store, self_guid) - .map_err(take_item_refusal)? +) -> MailResult<(u32, u32), TakeItemRefusal> { + let actor = at_mailbox(store, actor, mailbox_guid)?; + let row = mail_of(store, actor.guid())? .into_iter() .find(|m| m.id == mail_id) - .ok_or_else(|| TakeItemRefusal::Other(mail_rules::NOT_YOUR_MAIL.to_string()))?; + .ok_or_else(|| refused(mail_rules::NOT_YOUR_MAIL))?; if row.item_entry == 0 { - return Err(TakeItemRefusal::Other( - mail_rules::NOTHING_TO_TAKE.to_string(), - )); + return Err(refused(mail_rules::NOTHING_TO_TAKE)); } let taken = (row.item_entry, row.item_stack_count); let Some(realm) = store.realm_store() else { - store - .mail_take_item(self_guid, mail_id) - .map_err(take_item_refusal)?; + store.mail_take_item(actor, mail_id)?; return Ok(taken); }; - store.mail_item_room(self_guid).map_err(take_item_refusal)?; - pay_cod(store, realm.as_ref(), self_guid, &row).map_err(take_item_refusal)?; - let escrow_id = next_escrow_id().map_err(take_item_refusal)?; - realm - .mail_take_item_fence(escrow_id, self_guid, mail_id, row.item_entry) - .map_err(take_item_refusal)?; - let item = held_fence(realm.as_ref(), self_guid, escrow_id) - .map_err(take_item_refusal)? - .item; + store.mail_item_room(actor)?; + pay_cod(store, realm.as_ref(), actor, &row)?; + let escrow_id = next_escrow_id()?; + realm.mail_take_item_fence(escrow_id, actor, mail_id, row.item_entry)?; + let item = held_fence(realm.as_ref(), actor.guid(), escrow_id)?.item; drive(realm.as_ref(), escrow_id, || { - store.mail_item_payout(escrow_id, self_guid, mail_id, item) - }) - .map_err(take_item_refusal)?; + store.mail_item_payout(escrow_id, actor, mail_id, item) + })?; Ok(taken) } fn pay_cod( store: &St, realm: &dyn WorldStore, - taker_guid: u64, + taker: Actor, row: &MailView, ) -> Result<()> { + let taker_guid = taker.guid(); let Some(settlement) = mail_rules::cod_settlement(row.cod, row.sender_guid, &row.subject, taker_guid) else { @@ -791,9 +866,10 @@ fn pay_cod( .map(|e| e.escrow_id) .map(Ok) .unwrap_or_else(next_escrow_id)?; + // The taker pays: `cod_settlement` names them as the payer. store.mail_fence( escrow_id, - settlement.payer_guid, + taker, settlement.payee_guid, settlement.subject.clone(), String::new(), @@ -808,7 +884,7 @@ fn pay_cod( drive(store, escrow_id, || { realm.mail_commit( escrow_id, - settlement.payer_guid, + taker, settlement.payee_guid, settlement.subject.clone(), String::new(), @@ -851,29 +927,17 @@ pub(crate) fn install_escrow_id_range(next: u64, end: u64) -> Result<()> { } Ok(()) } -fn refusal_from_module(e: anyhow::Error) -> SendRefusal { - let text = format!("{e:#}"); - if text.contains(mail_rules::NOT_ENOUGH_MONEY) { - SendRefusal::NotEnoughMoney(text) - } else if text.contains(mail_rules::ITEM_IS_SOULBOUND) { - SendRefusal::AttachmentSoulbound(text) - } else if text.contains(mail_rules::NOT_YOUR_ITEM) { - SendRefusal::AttachmentInvalid(text) - } else { - SendRefusal::Internal(text) - } -} -fn at_mailbox( +/// The mailbox Gate: the session is in the world and stands at `mailbox_guid`. +fn at_mailbox( store: &St, - self_guid: Option, + actor: Option, mailbox_guid: u64, -) -> Result { - let self_guid = - self_guid.ok_or_else(|| anyhow::anyhow!(lyracore_shared::mail::NOT_IN_WORLD))?; - if !store.mailbox_in_range(mailbox_guid, self_guid)? { - anyhow::bail!(lyracore_shared::mail::not_at_mailbox(mailbox_guid)); +) -> MailResult { + let actor = actor.ok_or_else(|| refused(mail_rules::NOT_IN_WORLD))?; + if !store.mailbox_in_range(mailbox_guid, actor.guid())? { + return Err(refused(&mail_rules::not_at_mailbox(mailbox_guid))); } - Ok(self_guid) + Ok(actor) } #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum CopyLetterRefusal { @@ -890,12 +954,14 @@ impl std::fmt::Display for CopyLetterRefusal { } } -fn copy_letter_refusal(e: anyhow::Error) -> CopyLetterRefusal { - let text = format!("{e:#}"); - if text.contains(mail_rules::INVENTORY_FULL) { - CopyLetterRefusal::BagsFull(text) - } else { - CopyLetterRefusal::Other(text) +impl FromReason for CopyLetterRefusal { + fn from_reason(reason: &str) -> Self { + let text = reason.to_string(); + if text.contains(mail_rules::INVENTORY_FULL) { + CopyLetterRefusal::BagsFull(text) + } else { + CopyLetterRefusal::Other(text) + } } } /// `CMSG_MAIL_CREATE_TEXT_ITEM`: turn a delivered letter's body into a Plain Letter in the bags. @@ -909,31 +975,26 @@ fn copy_letter_refusal(e: anyhow::Error) -> CopyLetterRefusal { /// covers the narrow window before this call lands. pub(crate) fn copy_letter( store: &St, - self_guid: Option, + actor: Option, mailbox_guid: u64, mail_id: u64, -) -> std::result::Result<(), CopyLetterRefusal> { - let self_guid = at_mailbox(store, self_guid, mailbox_guid) - .map_err(|e| CopyLetterRefusal::NoMailbox(e.to_string()))?; - store - .mail_item_room(self_guid) - .map_err(copy_letter_refusal)?; +) -> MailResult<(), CopyLetterRefusal> { + let actor = at_mailbox::(store, actor, mailbox_guid) + .map_err(|failure| failure.map(CopyLetterRefusal::NoMailbox))?; + store.mail_item_room(actor)?; match store.realm_store() { - Some(realm) => realm.mail_copy_text(self_guid, mail_id), - None => store.mail_copy_text(self_guid, mail_id), - } - .map_err(copy_letter_refusal)?; + Some(realm) => realm.mail_copy_text(actor, mail_id), + None => store.mail_copy_text(actor, mail_id), + }?; // The text id is the mail id narrowed to u32 (`lyracore_shared::mail::item_text_id_for`'s // non-empty-body case) — the copy above just proved the body is non-empty. let item_text_id = u32::try_from(mail_id).unwrap_or(0); - store - .mail_grant_letter(self_guid, item_text_id) - .map_err(copy_letter_refusal)?; + store.mail_grant_letter(actor, item_text_id)?; match store.realm_store() { - Some(realm) => realm.mail_mark_letter_granted(self_guid, mail_id), - None => store.mail_mark_letter_granted(self_guid, mail_id), - } - .map_err(copy_letter_refusal) + Some(realm) => realm.mail_mark_letter_granted(actor, mail_id), + None => store.mail_mark_letter_granted(actor, mail_id), + }?; + Ok(()) } /// `CMSG_ITEM_TEXT_QUERY`: the text behind `item_text_id`, for a caller who has PROVEN they may see /// it — either they hold an item carrying that id, or they own the mail it names. `game_item_text` @@ -952,16 +1013,16 @@ pub(crate) fn copy_letter( /// what a letter still sitting in the mailbox resolves through today. pub(crate) fn item_text( store: &St, - self_guid: Option, + actor: Option, item_text_id: u32, hint_item_guid: u64, -) -> Result> { - let own_mail_body = letter_body(store, self_guid, u64::from(item_text_id))?; +) -> MailResult> { + let own_mail_body = letter_body(store, actor, u64::from(item_text_id))?; let owns_item = if own_mail_body.is_some() { false } else { - match self_guid { - Some(guid) => store.owns_item_with_text(guid, item_text_id, hint_item_guid)?, + match actor { + Some(actor) => store.owns_item_with_text(actor.guid(), item_text_id, hint_item_guid)?, None => false, } }; diff --git a/gateway/src/world/mail_tests.rs b/gateway/src/world/mail_tests.rs index 0e25f0036..67a0ee8f3 100644 --- a/gateway/src/world/mail_tests.rs +++ b/gateway/src/world/mail_tests.rs @@ -87,12 +87,14 @@ fn seeded_mail() -> Vec<(u64, codec::MailView)> { fn a_seeded_mail_is_listed_for_its_recipient_and_for_nobody_else() { let (_realm, world, _calls) = sharded_mailbox(); - let mails = mail::open_mailbox(world.as_ref(), Some(GINGER), MAILBOX).expect("the gate opens"); + let mails = + mail::open_mailbox(world.as_ref(), Actor::new(GINGER), MAILBOX).expect("the gate opens"); assert_eq!(mails.len(), 1); assert_eq!(mails[0].subject, "Your sword"); assert_eq!(mails[0].sender_guid, VIM); - let trins = mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).expect("the gate opens"); + let trins = + mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX).expect("the gate opens"); assert_eq!( trins.iter().map(|m| m.id).collect::>(), vec![2], @@ -106,7 +108,8 @@ fn a_player_with_no_mail_gets_an_empty_list_and_not_a_refusal() { realm.mail.mails.lock().unwrap().clear(); assert_eq!( - mail::open_mailbox(world.as_ref(), Some(GINGER), MAILBOX).expect("an empty mailbox opens"), + mail::open_mailbox(world.as_ref(), Actor::new(GINGER), MAILBOX) + .expect("an empty mailbox opens"), vec![] ); } @@ -115,7 +118,7 @@ fn a_player_with_no_mail_gets_an_empty_list_and_not_a_refusal() { fn a_sharded_mailbox_is_read_from_realm_core_and_never_from_the_players_own_shard() { let (_realm, world, calls) = sharded_mailbox(); - mail::open_mailbox(world.as_ref(), Some(GINGER), MAILBOX).expect("the gate opens"); + mail::open_mailbox(world.as_ref(), Actor::new(GINGER), MAILBOX).expect("the gate opens"); let log = calls.lock().unwrap().clone(); assert!( @@ -133,7 +136,7 @@ fn a_sharded_mailbox_is_read_from_realm_core_and_never_from_the_players_own_shar fn the_mailbox_proximity_gate_is_asked_of_the_players_own_shard() { let (_realm, world, calls) = sharded_mailbox(); - mail::open_mailbox(world.as_ref(), Some(GINGER), MAILBOX).expect("the gate opens"); + mail::open_mailbox(world.as_ref(), Actor::new(GINGER), MAILBOX).expect("the gate opens"); let log = calls.lock().unwrap().clone(); assert!( @@ -151,8 +154,10 @@ fn the_realm_plane_and_the_single_database_fallback_render_the_same_mailbox() { let (_realm, world, calls) = sharded_mailbox(); let single = unsharded_mailbox(); - let sharded = mail::open_mailbox(world.as_ref(), Some(GINGER), MAILBOX).expect("realm plane"); - let unsharded = mail::open_mailbox(single.as_ref(), Some(GINGER), MAILBOX).expect("fallback"); + let sharded = + mail::open_mailbox(world.as_ref(), Actor::new(GINGER), MAILBOX).expect("realm plane"); + let unsharded = + mail::open_mailbox(single.as_ref(), Actor::new(GINGER), MAILBOX).expect("fallback"); assert_eq!(sharded, unsharded, "the two planes must render one mailbox"); let log = calls.lock().unwrap().clone(); @@ -170,7 +175,8 @@ fn an_unsharded_gateway_reads_the_mailbox_on_its_own_database() { "fixture: this is the single-database gateway" ); - let mails = mail::open_mailbox(single.as_ref(), Some(GINGER), MAILBOX).expect("the gate opens"); + let mails = + mail::open_mailbox(single.as_ref(), Actor::new(GINGER), MAILBOX).expect("the gate opens"); assert_eq!(mails.len(), 1); assert_eq!(mails[0].id, 1); } @@ -200,7 +206,7 @@ fn every_mail_read_is_refused_at_character_select() { fn a_mailbox_the_player_is_not_standing_at_is_refused() { let (_realm, world, calls) = sharded_mailbox(); - let err = mail::open_mailbox(world.as_ref(), Some(GINGER), FAR_MAILBOX) + let err = mail::open_mailbox(world.as_ref(), Actor::new(GINGER), FAR_MAILBOX) .expect_err("a mailbox out of reach refuses"); assert!(err.to_string().contains("not at mailbox"), "got {err}"); let log = calls.lock().unwrap().clone(); @@ -214,16 +220,16 @@ fn a_mailbox_the_player_is_not_standing_at_is_refused() { #[test] fn the_unread_poll_follows_the_same_mailbox_the_window_lists() { let (realm, world, _calls) = sharded_mailbox(); - assert!(mail::has_unread(world.as_ref(), Some(GINGER)).unwrap()); + assert!(mail::has_unread(world.as_ref(), Actor::new(GINGER)).unwrap()); realm.mail.mails.lock().unwrap()[0].1.was_read = true; assert!( - !mail::has_unread(world.as_ref(), Some(GINGER)).unwrap(), + !mail::has_unread(world.as_ref(), Actor::new(GINGER)).unwrap(), "a mailbox holding only READ mail must not light the envelope" ); realm.mail.mails.lock().unwrap().clear(); - assert!(!mail::has_unread(world.as_ref(), Some(GINGER)).unwrap()); + assert!(!mail::has_unread(world.as_ref(), Actor::new(GINGER)).unwrap()); } #[test] @@ -231,16 +237,16 @@ fn a_letter_body_is_readable_by_its_recipient_and_by_nobody_else() { let (_realm, world, _calls) = sharded_mailbox(); assert_eq!( - mail::letter_body(world.as_ref(), Some(GINGER), 1).unwrap(), + mail::letter_body(world.as_ref(), Actor::new(GINGER), 1).unwrap(), Some("left it at the inn".to_string()) ); assert_eq!( - mail::letter_body(world.as_ref(), Some(GINGER), 2).unwrap(), + mail::letter_body(world.as_ref(), Actor::new(GINGER), 2).unwrap(), None, "mail 2 is addressed to Trin — a crafted id must not read another player's letter" ); assert_eq!( - mail::letter_body(world.as_ref(), Some(GINGER), 999).unwrap(), + mail::letter_body(world.as_ref(), Actor::new(GINGER), 999).unwrap(), None ); } @@ -255,7 +261,8 @@ fn a_mail_with_no_body_advertises_text_id_zero() { .unwrap() .push((GINGER, mail(3, VIM, "No letter", ""))); - let mails = mail::open_mailbox(world.as_ref(), Some(GINGER), MAILBOX).expect("the gate opens"); + let mails = + mail::open_mailbox(world.as_ref(), Actor::new(GINGER), MAILBOX).expect("the gate opens"); let packet = codec::build_mail_list(&mails, 1_000); let text_ids: Vec<(u32, u32)> = packet .mails @@ -375,21 +382,22 @@ fn a_seeded_mail_reaches_the_client_as_a_mail_list_row() { fn mark_read_flips_the_row_and_the_next_list_shows_it() { let (_realm, world, _calls) = sharded_mailbox(); - mail::mark_read(world.as_ref(), Some(GINGER), MAILBOX, 1).expect("Ginger owns mail 1"); + mail::mark_read(world.as_ref(), Actor::new(GINGER), MAILBOX, 1).expect("Ginger owns mail 1"); - let mails = mail::open_mailbox(world.as_ref(), Some(GINGER), MAILBOX).expect("the gate opens"); + let mails = + mail::open_mailbox(world.as_ref(), Actor::new(GINGER), MAILBOX).expect("the gate opens"); assert!(mails[0].was_read, "the next list read must show the flip"); } #[test] fn a_read_mail_no_longer_lights_the_unread_poll() { let (_realm, world, _calls) = sharded_mailbox(); - assert!(mail::has_unread(world.as_ref(), Some(GINGER)).unwrap()); + assert!(mail::has_unread(world.as_ref(), Actor::new(GINGER)).unwrap()); - mail::mark_read(world.as_ref(), Some(GINGER), MAILBOX, 1).unwrap(); + mail::mark_read(world.as_ref(), Actor::new(GINGER), MAILBOX, 1).unwrap(); assert!( - !mail::has_unread(world.as_ref(), Some(GINGER)).unwrap(), + !mail::has_unread(world.as_ref(), Actor::new(GINGER)).unwrap(), "the poll must derive from the SAME read the list uses, so marking read is visible to it too" ); } @@ -398,9 +406,10 @@ fn a_read_mail_no_longer_lights_the_unread_poll() { fn delete_removes_the_row_and_the_next_list_no_longer_shows_it() { let (_realm, world, _calls) = sharded_mailbox(); - mail::delete(world.as_ref(), Some(GINGER), MAILBOX, 1).expect("Ginger owns mail 1"); + mail::delete(world.as_ref(), Actor::new(GINGER), MAILBOX, 1).expect("Ginger owns mail 1"); - let mails = mail::open_mailbox(world.as_ref(), Some(GINGER), MAILBOX).expect("the gate opens"); + let mails = + mail::open_mailbox(world.as_ref(), Actor::new(GINGER), MAILBOX).expect("the gate opens"); assert!( mails.is_empty(), "a deleted mail must be gone from the next list read" @@ -410,19 +419,20 @@ fn delete_removes_the_row_and_the_next_list_no_longer_shows_it() { #[test] fn both_are_refused_for_a_mail_the_caller_does_not_own() { let (_realm, world, _calls) = sharded_mailbox(); - let err = mail::mark_read(world.as_ref(), Some(GINGER), MAILBOX, 2) + let err = mail::mark_read(world.as_ref(), Actor::new(GINGER), MAILBOX, 2) .expect_err("mail 2 is not Ginger's"); assert!( err.to_string().contains("not addressed to you"), "got {err}" ); - let err = - mail::delete(world.as_ref(), Some(GINGER), MAILBOX, 2).expect_err("mail 2 is not Ginger's"); + let err = mail::delete(world.as_ref(), Actor::new(GINGER), MAILBOX, 2) + .expect_err("mail 2 is not Ginger's"); assert!( err.to_string().contains("not addressed to you"), "got {err}" ); - let trins = mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).expect("the gate opens"); + let trins = + mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX).expect("the gate opens"); assert_eq!( trins.len(), 1, @@ -436,11 +446,11 @@ fn both_write_ops_behave_identically_on_the_realm_plane_and_the_fallback() { let (_realm, world, _calls) = sharded_mailbox(); let single = unsharded_mailbox(); - mail::mark_read(world.as_ref(), Some(GINGER), MAILBOX, 1).expect("sharded plane"); - mail::mark_read(single.as_ref(), Some(GINGER), MAILBOX, 1).expect("fallback plane"); + mail::mark_read(world.as_ref(), Actor::new(GINGER), MAILBOX, 1).expect("sharded plane"); + mail::mark_read(single.as_ref(), Actor::new(GINGER), MAILBOX, 1).expect("fallback plane"); assert_eq!( - mail::open_mailbox(world.as_ref(), Some(GINGER), MAILBOX).unwrap(), - mail::open_mailbox(single.as_ref(), Some(GINGER), MAILBOX).unwrap(), + mail::open_mailbox(world.as_ref(), Actor::new(GINGER), MAILBOX).unwrap(), + mail::open_mailbox(single.as_ref(), Actor::new(GINGER), MAILBOX).unwrap(), "both planes must show the same mark-read result" ); } @@ -449,8 +459,8 @@ fn both_write_ops_behave_identically_on_the_realm_plane_and_the_fallback() { fn a_sharded_mailbox_write_lands_on_realm_core_and_never_on_the_players_own_shard() { let (_realm, world, calls) = sharded_mailbox(); - mail::mark_read(world.as_ref(), Some(GINGER), MAILBOX, 1).unwrap(); - mail::delete(world.as_ref(), Some(TRIN), MAILBOX, 2).unwrap(); + mail::mark_read(world.as_ref(), Actor::new(GINGER), MAILBOX, 1).unwrap(); + mail::delete(world.as_ref(), Actor::new(TRIN), MAILBOX, 2).unwrap(); let log = calls.lock().unwrap().clone(); assert!(log.contains(&("lyracore-realm".to_string(), "mail_mark_read".to_string()))); @@ -466,7 +476,7 @@ fn both_write_ops_are_gated_like_the_read_path() { let err = mail::mark_read(world.as_ref(), None, MAILBOX, 1).unwrap_err(); assert!(err.to_string().contains("not in world"), "got {err}"); - let err = mail::delete(world.as_ref(), Some(GINGER), FAR_MAILBOX, 1).unwrap_err(); + let err = mail::delete(world.as_ref(), Actor::new(GINGER), FAR_MAILBOX, 1).unwrap_err(); assert!(err.to_string().contains("not at mailbox"), "got {err}"); let log = calls.lock().unwrap().clone(); @@ -481,10 +491,11 @@ fn both_write_ops_are_gated_like_the_read_path() { fn returning_a_mail_moves_it_from_the_recipients_list_to_the_senders() { let (_realm, world, _calls) = sharded_mailbox(); - mail::return_to_sender(world.as_ref(), Some(GINGER), MAILBOX, 1).expect("Ginger owns mail 1"); + mail::return_to_sender(world.as_ref(), Actor::new(GINGER), MAILBOX, 1) + .expect("Ginger owns mail 1"); let gingers = - mail::open_mailbox(world.as_ref(), Some(GINGER), MAILBOX).expect("the gate opens"); + mail::open_mailbox(world.as_ref(), Actor::new(GINGER), MAILBOX).expect("the gate opens"); assert!( gingers.is_empty(), "the mail must leave the recipient's list" @@ -519,7 +530,8 @@ fn returning_a_mail_carries_its_attachment_and_copper_unchanged() { // Vim is Ginger's alt here, so the item comes back at once. *world.mail.realm_accounts.lock().unwrap() = alts(&[GINGER, VIM]); - mail::return_to_sender(world.as_ref(), Some(GINGER), MAILBOX, 1).expect("Ginger owns mail 1"); + mail::return_to_sender(world.as_ref(), Actor::new(GINGER), MAILBOX, 1) + .expect("Ginger owns mail 1"); let vims = mail::mail_of(world.as_ref(), VIM).unwrap(); assert_eq!(vims.len(), 1); @@ -551,14 +563,15 @@ fn returning_an_already_taken_mail_does_not_duplicate_the_attachment() { }, )]; - mail::take_item(world.as_ref(), Some(GINGER), MAILBOX, 1).expect("Ginger takes the item"); + mail::take_item(world.as_ref(), Actor::new(GINGER), MAILBOX, 1).expect("Ginger takes the item"); assert_eq!( world.bags_of(GINGER).len(), 1, "exactly one copy landed in the taker's bags" ); - mail::return_to_sender(world.as_ref(), Some(GINGER), MAILBOX, 1).expect("still Ginger's mail"); + mail::return_to_sender(world.as_ref(), Actor::new(GINGER), MAILBOX, 1) + .expect("still Ginger's mail"); let vims = mail::mail_of(world.as_ref(), VIM).unwrap(); assert_eq!(vims.len(), 1); @@ -580,14 +593,15 @@ fn returning_an_already_taken_mail_does_not_duplicate_the_attachment() { #[test] fn returning_a_mail_is_refused_for_a_caller_who_does_not_own_it() { let (_realm, world, _calls) = sharded_mailbox(); - let err = mail::return_to_sender(world.as_ref(), Some(GINGER), MAILBOX, 2) + let err = mail::return_to_sender(world.as_ref(), Actor::new(GINGER), MAILBOX, 2) .expect_err("mail 2 is not Ginger's"); assert!( err.to_string().contains("not addressed to you"), "got {err}" ); - let trins = mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).expect("the gate opens"); + let trins = + mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX).expect("the gate opens"); assert_eq!( trins.len(), 1, @@ -600,8 +614,9 @@ fn returning_a_mail_behaves_identically_on_the_realm_plane_and_the_fallback() { let (_realm, world, _calls) = sharded_mailbox(); let single = unsharded_mailbox(); - mail::return_to_sender(world.as_ref(), Some(GINGER), MAILBOX, 1).expect("sharded plane"); - mail::return_to_sender(single.as_ref(), Some(GINGER), MAILBOX, 1).expect("fallback plane"); + mail::return_to_sender(world.as_ref(), Actor::new(GINGER), MAILBOX, 1).expect("sharded plane"); + mail::return_to_sender(single.as_ref(), Actor::new(GINGER), MAILBOX, 1) + .expect("fallback plane"); assert_eq!( mail::mail_of(world.as_ref(), VIM).unwrap(), @@ -614,7 +629,7 @@ fn returning_a_mail_behaves_identically_on_the_realm_plane_and_the_fallback() { fn a_sharded_return_lands_on_realm_core_and_never_on_the_players_own_shard() { let (_realm, world, calls) = sharded_mailbox(); - mail::return_to_sender(world.as_ref(), Some(GINGER), MAILBOX, 1).unwrap(); + mail::return_to_sender(world.as_ref(), Actor::new(GINGER), MAILBOX, 1).unwrap(); let log = calls.lock().unwrap().clone(); assert!(log.contains(&("lyracore-realm".to_string(), "mail_return".to_string()))); @@ -631,7 +646,8 @@ fn returning_a_mail_is_gated_like_the_read_path() { let err = mail::return_to_sender(world.as_ref(), None, MAILBOX, 1).unwrap_err(); assert!(err.to_string().contains("not in world"), "got {err}"); - let err = mail::return_to_sender(world.as_ref(), Some(GINGER), FAR_MAILBOX, 1).unwrap_err(); + let err = + mail::return_to_sender(world.as_ref(), Actor::new(GINGER), FAR_MAILBOX, 1).unwrap_err(); assert!(err.to_string().contains("not at mailbox"), "got {err}"); let log = calls.lock().unwrap().clone(); @@ -997,20 +1013,17 @@ fn unsharded_send() -> std::sync::Arc { store } -fn post( - store: &St, - to: &str, -) -> std::result::Result<(), mail::SendRefusal> { +fn post(store: &St, to: &str) -> mail::MailResult<(), mail::SendRefusal> { post_money(store, to, 0) } fn post_money( store: &St, to: &str, money: u32, -) -> std::result::Result<(), mail::SendRefusal> { +) -> mail::MailResult<(), mail::SendRefusal> { mail::send( store, - Some(GINGER), + Actor::new(GINGER), MAILBOX, to, "Your sword".into(), @@ -1027,7 +1040,8 @@ fn a_letter_to_a_character_on_the_same_shard_arrives_in_their_list() { post(world.as_ref(), "Trin").expect("Trin is a Human on the same shard"); - let trins = mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).expect("the gate opens"); + let trins = + mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX).expect("the gate opens"); assert_eq!(trins.len(), 1); assert_eq!(trins[0].subject, "Your sword"); assert_eq!(trins[0].sender_guid, GINGER); @@ -1041,7 +1055,7 @@ fn a_letter_to_an_offline_character_is_waiting_in_their_mailbox() { post(world.as_ref(), "Dormant").expect("an offline character is a valid recipient"); assert_eq!( - mail::open_mailbox(world.as_ref(), Some(DORMANT), MAILBOX) + mail::open_mailbox(world.as_ref(), Actor::new(DORMANT), MAILBOX) .expect("the gate opens") .len(), 1 @@ -1059,7 +1073,8 @@ fn a_letter_crosses_a_database_boundary_to_a_recipient_homed_on_another_shard() post(world.as_ref(), "Vim").expect("the realm-wide name union finds Vim"); - let vims = mail::open_mailbox(instances.as_ref(), Some(VIM), MAILBOX).expect("the gate opens"); + let vims = + mail::open_mailbox(instances.as_ref(), Actor::new(VIM), MAILBOX).expect("the gate opens"); assert_eq!(vims.len(), 1); assert_eq!(vims[0].sender_guid, GINGER); @@ -1102,7 +1117,9 @@ fn a_sender_who_cannot_afford_the_postage_is_refused_and_charged_nothing() { let (_realm, world, _instances, calls) = sharded_send(); *world.mail.purses.lock().unwrap() = vec![(GINGER, 10)]; - let refusal = post(world.as_ref(), "Trin").expect_err("10 copper does not cover the postage"); + let refusal = post(world.as_ref(), "Trin") + .expect_err("10 copper does not cover the postage") + .into_refusal(); assert!(matches!(refusal, mail::SendRefusal::NotEnoughMoney(_))); assert_eq!( @@ -1110,9 +1127,11 @@ fn a_sender_who_cannot_afford_the_postage_is_refused_and_charged_nothing() { 10, "charged nothing" ); - assert!(mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX) - .unwrap() - .is_empty()); + assert!( + mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX) + .unwrap() + .is_empty() + ); let log = calls.lock().unwrap().clone(); assert!( !log.iter().any(|(_, call)| call == "mail_send"), @@ -1125,15 +1144,15 @@ fn each_refused_gate_produces_its_own_distinct_refusal() { let (_realm, world, _instances, calls) = sharded_send(); assert!(matches!( - post(world.as_ref(), "Nobody").unwrap_err(), + post(world.as_ref(), "Nobody").unwrap_err().into_refusal(), mail::SendRefusal::RecipientNotFound(_) )); assert_eq!( - post(world.as_ref(), "Ginger").unwrap_err(), + post(world.as_ref(), "Ginger").unwrap_err().into_refusal(), mail::SendRefusal::CannotSendToSelf ); assert_eq!( - post(world.as_ref(), "Grug").unwrap_err(), + post(world.as_ref(), "Grug").unwrap_err().into_refusal(), mail::SendRefusal::NotYourTeam ); @@ -1156,7 +1175,7 @@ fn the_faction_gate_refuses_in_both_directions() { let refusal = mail::send( world.as_ref(), - Some(GRUG), + Actor::new(GRUG), MAILBOX, "Trin", "Hail".into(), @@ -1165,7 +1184,8 @@ fn the_faction_gate_refuses_in_both_directions() { NO_COD, NO_ITEM, ) - .expect_err("a Horde sender cannot write to an Alliance recipient either"); + .expect_err("a Horde sender cannot write to an Alliance recipient either") + .into_refusal(); assert_eq!(refusal, mail::SendRefusal::NotYourTeam); assert!(lyracore_shared::faction::same_team(2, 2), "fixture sanity"); } @@ -1179,7 +1199,9 @@ fn a_homonym_recipient_is_refused_rather_than_guessed() { "fixture: one name, two characters, on two databases" ); - let refusal = post(world.as_ref(), "Echo").expect_err("nothing can choose between them"); + let refusal = post(world.as_ref(), "Echo") + .expect_err("nothing can choose between them") + .into_refusal(); assert!( matches!(refusal, mail::SendRefusal::RecipientNotFound(_)), "an ambiguous name is answered as 'no such recipient' — the client's closest text, and the \ @@ -1199,13 +1221,13 @@ fn the_senders_own_list_never_shows_the_mail_they_sent() { post(world.as_ref(), "Trin").expect("the send goes through"); assert!( - mail::open_mailbox(world.as_ref(), Some(GINGER), MAILBOX) + mail::open_mailbox(world.as_ref(), Actor::new(GINGER), MAILBOX) .expect("the gate opens") .is_empty(), "the sender's mailbox must stay empty" ); assert!( - !mail::has_unread(world.as_ref(), Some(GINGER)).unwrap(), + !mail::has_unread(world.as_ref(), Actor::new(GINGER)).unwrap(), "and their envelope must not light for their own letter" ); } @@ -1225,7 +1247,8 @@ fn sending_is_refused_at_character_select_and_away_from_a_mailbox() { NO_COD, NO_ITEM, ) - .expect_err("character select drives no mailbox"); + .expect_err("character select drives no mailbox") + .into_refusal(); assert!( refusal.to_string().contains("not in world"), "got {refusal}" @@ -1233,7 +1256,7 @@ fn sending_is_refused_at_character_select_and_away_from_a_mailbox() { let refusal = mail::send( world.as_ref(), - Some(GINGER), + Actor::new(GINGER), FAR_MAILBOX, "Trin", "Hi".into(), @@ -1242,7 +1265,8 @@ fn sending_is_refused_at_character_select_and_away_from_a_mailbox() { NO_COD, NO_ITEM, ) - .expect_err("a mailbox out of reach refuses"); + .expect_err("a mailbox out of reach refuses") + .into_refusal(); assert!( refusal.to_string().contains("not at mailbox"), "got {refusal}" @@ -1266,8 +1290,8 @@ fn both_planes_produce_the_same_row_for_the_same_letter() { post(single.as_ref(), "Trin").expect("fallback plane"); assert_eq!( - mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).unwrap(), - mail::open_mailbox(single.as_ref(), Some(TRIN), MAILBOX).unwrap(), + mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX).unwrap(), + mail::open_mailbox(single.as_ref(), Actor::new(TRIN), MAILBOX).unwrap(), "the two planes must produce one letter" ); assert_eq!( @@ -1304,12 +1328,13 @@ fn a_sent_letters_body_is_readable_through_the_item_text_query_path() { post(world.as_ref(), "Trin").expect("the send goes through"); - let mails = mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).expect("the gate opens"); + let mails = + mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX).expect("the gate opens"); let packet = codec::build_mail_list(&mails, 1_000); let text_id = packet.mails[0].item_text_id; assert_ne!(text_id, 0, "a letter WITH a body must advertise its own id"); assert_eq!( - mail::letter_body(world.as_ref(), Some(TRIN), u64::from(text_id)).unwrap(), + mail::letter_body(world.as_ref(), Actor::new(TRIN), u64::from(text_id)).unwrap(), Some("left it at the inn".to_string()) ); } @@ -1393,7 +1418,8 @@ fn a_letter_sent_over_the_wire_is_acked_ok_and_lands_in_the_recipients_mailbox() wow_world_messages::vanilla::SMSG_SEND_MAIL_RESULT_MailResultTwo::Ok ); - let trins = mail::open_mailbox(store.as_ref(), Some(TRIN), MAILBOX).expect("the gate opens"); + let trins = + mail::open_mailbox(store.as_ref(), Actor::new(TRIN), MAILBOX).expect("the gate opens"); assert_eq!(trins.len(), 1); assert_eq!(trins[0].body, "left it at the inn"); assert_eq!(store.mail.purses.lock().unwrap()[0].1, PURSE - 30); @@ -1456,7 +1482,8 @@ fn a_real_session_gets_the_send_mail_result_once_a_lagging_escrow_cache_catches_ wow_world_messages::vanilla::SMSG_SEND_MAIL_RESULT_MailResultTwo::Ok ); - let trins = mail::open_mailbox(store.as_ref(), Some(TRIN), MAILBOX).expect("the gate opens"); + let trins = + mail::open_mailbox(store.as_ref(), Actor::new(TRIN), MAILBOX).expect("the gate opens"); assert_eq!(trins.len(), 1); assert_eq!(trins[0].body, "left it at the inn"); assert_eq!(store.mail.purses.lock().unwrap()[0].1, PURSE - 30); @@ -1557,7 +1584,8 @@ fn attached_copper_leaves_the_senders_purse_at_send_and_rides_the_letter() { PURSE - lyracore_shared::mail::total_cost(ATTACHED), "one debit for the postage AND the coin" ); - let trins = mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).expect("the gate opens"); + let trins = + mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX).expect("the gate opens"); assert_eq!(trins[0].money, ATTACHED); } @@ -1568,7 +1596,8 @@ fn a_sender_who_cannot_afford_the_attachment_is_refused_and_charged_nothing() { *world.mail.purses.lock().unwrap() = vec![(GINGER, barely)]; let refusal = post_money(world.as_ref(), "Trin", ATTACHED) - .expect_err("the postage alone is affordable, the letter is not"); + .expect_err("the postage alone is affordable, the letter is not") + .into_refusal(); assert!(matches!(refusal, mail::SendRefusal::NotEnoughMoney(_))); assert_eq!( @@ -1576,9 +1605,11 @@ fn a_sender_who_cannot_afford_the_attachment_is_refused_and_charged_nothing() { barely, "charged nothing" ); - assert!(mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX) - .unwrap() - .is_empty()); + assert!( + mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX) + .unwrap() + .is_empty() + ); let log = calls.lock().unwrap().clone(); assert!( !log.iter().any(|(_, call)| call == "mail_commit"), @@ -1591,16 +1622,18 @@ fn taking_a_mails_money_credits_the_purse_and_leaves_the_letter_readable() { let (_realm, world, _instances, _calls) = sharded_send(); *world.mail.purses.lock().unwrap() = vec![(GINGER, PURSE), (TRIN, 0)]; post_money(world.as_ref(), "Trin", ATTACHED).expect("the send goes through"); - let mail_id = mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).unwrap()[0].id; + let mail_id = mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX).unwrap()[0].id; - mail::take_money(world.as_ref(), Some(TRIN), MAILBOX, mail_id).expect("the take goes through"); + mail::take_money(world.as_ref(), Actor::new(TRIN), MAILBOX, mail_id) + .expect("the take goes through"); assert_eq!(world.mail.purses.lock().unwrap()[1].1, ATTACHED); - let trins = mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).expect("the gate opens"); + let trins = + mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX).expect("the gate opens"); assert_eq!(trins.len(), 1, "a mail emptied of money is still a letter"); assert_eq!(trins[0].money, 0); assert_eq!( - mail::letter_body(world.as_ref(), Some(TRIN), mail_id).unwrap(), + mail::letter_body(world.as_ref(), Actor::new(TRIN), mail_id).unwrap(), Some("left it at the inn".to_string()) ); } @@ -1610,10 +1643,10 @@ fn taking_the_money_twice_credits_the_purse_once() { let (_realm, world, _instances, _calls) = sharded_send(); *world.mail.purses.lock().unwrap() = vec![(GINGER, PURSE), (TRIN, 0)]; post_money(world.as_ref(), "Trin", ATTACHED).expect("the send goes through"); - let mail_id = mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).unwrap()[0].id; + let mail_id = mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX).unwrap()[0].id; - mail::take_money(world.as_ref(), Some(TRIN), MAILBOX, mail_id).expect("the first take"); - let err = mail::take_money(world.as_ref(), Some(TRIN), MAILBOX, mail_id) + mail::take_money(world.as_ref(), Actor::new(TRIN), MAILBOX, mail_id).expect("the first take"); + let err = mail::take_money(world.as_ref(), Actor::new(TRIN), MAILBOX, mail_id) .expect_err("there is nothing left in it"); assert!(err.to_string().contains("nothing to take"), "got {err}"); @@ -1625,11 +1658,11 @@ fn taking_money_from_somebody_elses_mail_is_refused() { let (_realm, world, _instances, _calls) = sharded_send(); *world.mail.purses.lock().unwrap() = vec![(GINGER, PURSE), (TRIN, 0)]; post_money(world.as_ref(), "Trin", ATTACHED).expect("the send goes through"); - let mail_id = mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).unwrap()[0].id; + let mail_id = mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX).unwrap()[0].id; - let theft = mail::take_money(world.as_ref(), Some(GINGER), MAILBOX, mail_id) + let theft = mail::take_money(world.as_ref(), Actor::new(GINGER), MAILBOX, mail_id) .expect_err("Ginger wrote it, they cannot empty it"); - let phantom = mail::take_money(world.as_ref(), Some(GINGER), MAILBOX, mail_id + 999) + let phantom = mail::take_money(world.as_ref(), Actor::new(GINGER), MAILBOX, mail_id + 999) .expect_err("no such mail"); assert_eq!( @@ -1642,7 +1675,7 @@ fn taking_money_from_somebody_elses_mail_is_refused() { PURSE - lyracore_shared::mail::total_cost(ATTACHED) ); assert_eq!( - mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).unwrap()[0].money, + mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX).unwrap()[0].money, ATTACHED, "and the copper is still in the letter" ); @@ -1658,8 +1691,8 @@ fn both_planes_attach_and_take_the_same_copper() { for store in [world.as_ref(), single.as_ref()] { post_money(store, "Trin", ATTACHED).expect("posted"); - let mail_id = mail::open_mailbox(store, Some(TRIN), MAILBOX).unwrap()[0].id; - mail::take_money(store, Some(TRIN), MAILBOX, mail_id).expect("taken"); + let mail_id = mail::open_mailbox(store, Actor::new(TRIN), MAILBOX).unwrap()[0].id; + mail::take_money(store, Actor::new(TRIN), MAILBOX, mail_id).expect("taken"); } assert_eq!( @@ -1668,8 +1701,8 @@ fn both_planes_attach_and_take_the_same_copper() { "the two planes must move the same copper" ); assert_eq!( - mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).unwrap(), - mail::open_mailbox(single.as_ref(), Some(TRIN), MAILBOX).unwrap(), + mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX).unwrap(), + mail::open_mailbox(single.as_ref(), Actor::new(TRIN), MAILBOX).unwrap(), ); assert!( single.mail.mail_escrows.lock().unwrap().is_empty() @@ -1723,7 +1756,8 @@ fn a_sharded_send_waits_out_a_lagging_escrow_cache_and_still_settles() { ], "the send must still commit, confirm and settle once the row catches up" ); - let trins = mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).expect("the gate opens"); + let trins = + mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX).expect("the gate opens"); assert_eq!(trins.len(), 1); assert_eq!(trins[0].money, ATTACHED); assert_eq!( @@ -1745,10 +1779,10 @@ fn a_sharded_take_drives_the_same_four_steps_the_other_way() { let (_realm, world, _instances, calls) = sharded_send(); *world.mail.purses.lock().unwrap() = vec![(GINGER, PURSE), (TRIN, 0)]; post_money(world.as_ref(), "Trin", ATTACHED).expect("posted"); - let mail_id = mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).unwrap()[0].id; + let mail_id = mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX).unwrap()[0].id; calls.lock().unwrap().clear(); - mail::take_money(world.as_ref(), Some(TRIN), MAILBOX, mail_id).expect("taken"); + mail::take_money(world.as_ref(), Actor::new(TRIN), MAILBOX, mail_id).expect("taken"); assert_eq!( escrow_steps(&calls), @@ -1766,22 +1800,28 @@ fn a_send_killed_before_the_commit_is_re_driven_at_the_next_mailbox_visit() { let (realm, world, _instances, _calls) = sharded_send(); *realm.mail.mail_kill_at.lock().unwrap() = Some("mail_commit".into()); - let refusal = post_money(world.as_ref(), "Trin", ATTACHED) + let lost = post_money(world.as_ref(), "Trin", ATTACHED) .expect_err("realm-core never answered the commit"); - assert!(matches!(refusal, mail::SendRefusal::Internal(_))); + assert!( + matches!(lost, mail::MailFailure::Lost(_)), + "a gateway that died mid-drive leaves the outcome unknown: {lost}" + ); assert_eq!( world.mail.purses.lock().unwrap()[0].1, PURSE - lyracore_shared::mail::total_cost(ATTACHED), "the sender has PAID, and nothing refunds a fence" ); assert_eq!(world.mail.mail_escrows.lock().unwrap().len(), 1, "held"); - assert!(mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX) - .unwrap() - .is_empty()); + assert!( + mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX) + .unwrap() + .is_empty() + ); *realm.mail.mail_kill_at.lock().unwrap() = None; - mail::open_mailbox(world.as_ref(), Some(GINGER), MAILBOX).expect("the gate opens"); + mail::open_mailbox(world.as_ref(), Actor::new(GINGER), MAILBOX).expect("the gate opens"); - let trins = mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).expect("the gate opens"); + let trins = + mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX).expect("the gate opens"); assert_eq!(trins.len(), 1, "the letter finally landed — exactly once"); assert_eq!(trins[0].money, ATTACHED); assert!( @@ -1802,7 +1842,7 @@ fn a_send_killed_after_the_commit_re_drives_into_one_letter() { post_money(world.as_ref(), "Trin", ATTACHED).expect_err("the attestation never landed"); assert_eq!( - mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX) + mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX) .unwrap() .len(), 1 @@ -1814,10 +1854,10 @@ fn a_send_killed_after_the_commit_re_drives_into_one_letter() { ); *world.mail.mail_kill_at.lock().unwrap() = None; - mail::open_mailbox(world.as_ref(), Some(GINGER), MAILBOX).expect("the gate opens"); + mail::open_mailbox(world.as_ref(), Actor::new(GINGER), MAILBOX).expect("the gate opens"); assert_eq!( - mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX) + mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX) .unwrap() .len(), 1, @@ -1835,14 +1875,14 @@ fn a_take_killed_before_the_payout_is_re_driven_at_the_next_mailbox_visit() { let (realm, world, _instances, _calls) = sharded_send(); *world.mail.purses.lock().unwrap() = vec![(GINGER, PURSE), (TRIN, 0)]; post_money(world.as_ref(), "Trin", ATTACHED).expect("posted"); - let mail_id = mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).unwrap()[0].id; + let mail_id = mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX).unwrap()[0].id; *world.mail.mail_kill_at.lock().unwrap() = Some("mail_payout".into()); - mail::take_money(world.as_ref(), Some(TRIN), MAILBOX, mail_id) + mail::take_money(world.as_ref(), Actor::new(TRIN), MAILBOX, mail_id) .expect_err("the purse was never credited"); assert_eq!(world.mail.purses.lock().unwrap()[1].1, 0); assert_eq!( - mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).unwrap()[0].money, + mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX).unwrap()[0].money, 0, "the copper has left the row" ); @@ -1853,7 +1893,7 @@ fn a_take_killed_before_the_payout_is_re_driven_at_the_next_mailbox_visit() { ); *world.mail.mail_kill_at.lock().unwrap() = None; - mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).expect("the gate opens"); + mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX).expect("the gate opens"); assert_eq!( world.mail.purses.lock().unwrap()[1].1, @@ -1871,10 +1911,10 @@ fn a_take_killed_after_the_payout_re_drives_into_one_credit() { let (realm, world, _instances, _calls) = sharded_send(); *world.mail.purses.lock().unwrap() = vec![(GINGER, PURSE), (TRIN, 0)]; post_money(world.as_ref(), "Trin", ATTACHED).expect("posted"); - let mail_id = mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).unwrap()[0].id; + let mail_id = mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX).unwrap()[0].id; *realm.mail.mail_kill_at.lock().unwrap() = Some("mail_confirm_delivery".into()); - mail::take_money(world.as_ref(), Some(TRIN), MAILBOX, mail_id) + mail::take_money(world.as_ref(), Actor::new(TRIN), MAILBOX, mail_id) .expect_err("the attestation never landed, so the click is reported as failed"); assert_eq!( world.mail.purses.lock().unwrap()[1].1, @@ -1888,7 +1928,7 @@ fn a_take_killed_after_the_payout_re_drives_into_one_credit() { ); *realm.mail.mail_kill_at.lock().unwrap() = None; - mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).expect("the gate opens"); + mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX).expect("the gate opens"); assert_eq!( world.mail.purses.lock().unwrap()[1].1, @@ -1919,7 +1959,7 @@ fn a_settle_without_an_attestation_is_refused_and_the_fence_survives() { fn taking_money_is_refused_at_character_select_and_away_from_a_mailbox() { let (_realm, world, _instances, calls) = sharded_send(); post_money(world.as_ref(), "Trin", ATTACHED).expect("posted"); - let mail_id = mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).unwrap()[0].id; + let mail_id = mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX).unwrap()[0].id; calls.lock().unwrap().clear(); assert!(mail::take_money(world.as_ref(), None, MAILBOX, mail_id) @@ -1927,7 +1967,7 @@ fn taking_money_is_refused_at_character_select_and_away_from_a_mailbox() { .to_string() .contains("not in world")); assert!( - mail::take_money(world.as_ref(), Some(TRIN), FAR_MAILBOX, mail_id) + mail::take_money(world.as_ref(), Actor::new(TRIN), FAR_MAILBOX, mail_id) .unwrap_err() .to_string() .contains("not at mailbox") @@ -2027,10 +2067,10 @@ fn claimable_swords(shards: &[&WorldFake], realm: &WorldFake) -> usize { fn post_item( store: &St, to: &str, -) -> std::result::Result<(), mail::SendRefusal> { +) -> mail::MailResult<(), mail::SendRefusal> { mail::send( store, - Some(GINGER), + Actor::new(GINGER), MAILBOX, to, "Your sword".into(), @@ -2052,7 +2092,8 @@ fn a_mailed_item_leaves_the_senders_bags_and_lists_with_its_state_intact() { world.bags_of(GINGER).is_empty(), "the item leaves the bags at SEND — otherwise a send-and-logout duplicates it" ); - let trins = mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).expect("the gate opens"); + let trins = + mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX).expect("the gate opens"); assert_eq!(trins.len(), 1); assert_eq!(trins[0].item_entry, sword().entry); assert_eq!(trins[0].item_stack_count, sword().stack_count); @@ -2079,8 +2120,8 @@ fn the_realm_plane_and_the_single_database_fallback_deliver_the_same_attachment( post_item(world.as_ref(), "Trin").expect("realm plane"); post_item(single.as_ref(), "Trin").expect("fallback"); - let sharded = mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).unwrap(); - let unsharded = mail::open_mailbox(single.as_ref(), Some(TRIN), MAILBOX).unwrap(); + let sharded = mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX).unwrap(); + let unsharded = mail::open_mailbox(single.as_ref(), Actor::new(TRIN), MAILBOX).unwrap(); assert_eq!(sharded, unsharded, "the two planes must deliver one letter"); assert!(single.bags_of(GINGER).is_empty()); } @@ -2099,7 +2140,9 @@ fn a_soulbound_attachment_is_refused_at_send_and_stays_in_the_senders_bags() { }, ); - let refusal = post_item(world.as_ref(), "Trin").expect_err("a bound item is not mailable"); + let refusal = post_item(world.as_ref(), "Trin") + .expect_err("a bound item is not mailable") + .into_refusal(); assert!(matches!(refusal, mail::SendRefusal::AttachmentSoulbound(_))); assert_eq!(world.bags_of(GINGER).len(), 1, "still theirs"); @@ -2118,7 +2161,7 @@ fn an_unworn_bind_on_equip_attachment_is_mailable() { post_item(world.as_ref(), "Trin").expect("an unbound instance mails"); - let trins = mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).unwrap(); + let trins = mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX).unwrap(); assert!(!trins[0].item_soulbound, "it arrives as unbound as it left"); } @@ -2127,11 +2170,15 @@ fn attaching_an_item_the_sender_does_not_own_is_refused() { let (_realm, world, _instances, _calls) = sharded_send(); give_item(&world, TRIN, SWORD_GUID, sword()); - let refusal = post_item(world.as_ref(), "Trin").expect_err("it is not Ginger's"); + let refusal = post_item(world.as_ref(), "Trin") + .expect_err("it is not Ginger's") + .into_refusal(); assert!(matches!(refusal, mail::SendRefusal::AttachmentInvalid(_))); world.mail.mail_items.lock().unwrap().clear(); - let refusal = post_item(world.as_ref(), "Trin").expect_err("and nothing answers to that guid"); + let refusal = post_item(world.as_ref(), "Trin") + .expect_err("and nothing answers to that guid") + .into_refusal(); assert!(matches!(refusal, mail::SendRefusal::AttachmentInvalid(_))); assert!(world.mail.mail_escrows.lock().unwrap().is_empty()); } @@ -2144,8 +2191,9 @@ fn an_item_in_flight_cannot_be_attached_to_a_second_letter() { post_item(world.as_ref(), "Trin").expect_err("realm-core never answered"); assert_eq!(world.mail.mail_escrows.lock().unwrap().len(), 1, "held"); - let refusal = - post_item(world.as_ref(), "Trin").expect_err("it is in flight, so it is nobody's"); + let refusal = post_item(world.as_ref(), "Trin") + .expect_err("it is in flight, so it is nobody's") + .into_refusal(); assert!(matches!(refusal, mail::SendRefusal::AttachmentInvalid(_))); assert_eq!( @@ -2163,7 +2211,7 @@ fn delivered_item() -> ( let (realm, world, _instances, calls) = sharded_send(); give_item(&world, GINGER, SWORD_GUID, sword()); post_item(world.as_ref(), "Trin").expect("posted"); - let mail_id = mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).unwrap()[0].id; + let mail_id = mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX).unwrap()[0].id; calls.lock().unwrap().clear(); (realm, world, calls, mail_id) } @@ -2172,8 +2220,8 @@ fn delivered_item() -> ( fn taking_an_item_puts_it_in_the_takers_bags_with_its_state_unchanged() { let (realm, world, _calls, mail_id) = delivered_item(); - let taken = - mail::take_item(world.as_ref(), Some(TRIN), MAILBOX, mail_id).expect("the take completes"); + let taken = mail::take_item(world.as_ref(), Actor::new(TRIN), MAILBOX, mail_id) + .expect("the take completes"); assert_eq!( taken, @@ -2181,7 +2229,7 @@ fn taking_an_item_puts_it_in_the_takers_bags_with_its_state_unchanged() { "the wire's success arm names what the client just gained" ); assert_eq!(world.bags_of(TRIN), vec![sword()]); - let trins = mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).unwrap(); + let trins = mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX).unwrap(); assert_eq!( trins.len(), 1, @@ -2210,9 +2258,10 @@ fn a_taken_letter_keeps_its_text_id() { let (realm, world, _instances, _calls) = sharded_send(); give_item(&world, GINGER, SWORD_GUID, letter.clone()); post_item(world.as_ref(), "Trin").expect("posted"); - let mail_id = mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).unwrap()[0].id; + let mail_id = mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX).unwrap()[0].id; - mail::take_item(world.as_ref(), Some(TRIN), MAILBOX, mail_id).expect("the take completes"); + mail::take_item(world.as_ref(), Actor::new(TRIN), MAILBOX, mail_id) + .expect("the take completes"); assert_eq!(world.bags_of(TRIN), vec![letter]); assert!( @@ -2225,7 +2274,7 @@ fn a_taken_letter_keeps_its_text_id() { fn a_sharded_item_take_probes_for_room_before_it_fences_anything() { let (_realm, world, calls, mail_id) = delivered_item(); - mail::take_item(world.as_ref(), Some(TRIN), MAILBOX, mail_id).expect("taken"); + mail::take_item(world.as_ref(), Actor::new(TRIN), MAILBOX, mail_id).expect("taken"); assert_eq!( calls @@ -2259,7 +2308,7 @@ fn a_sharded_item_take_waits_out_a_lagging_escrow_cache_and_still_settles() { .mail_escrow_reads_before_visible .store(3, std::sync::atomic::Ordering::SeqCst); - let taken = mail::take_item(world.as_ref(), Some(TRIN), MAILBOX, mail_id) + let taken = mail::take_item(world.as_ref(), Actor::new(TRIN), MAILBOX, mail_id) .expect("a late escrow row is lag, not a missing fence"); assert_eq!(taken, (sword().entry, sword().stack_count)); @@ -2278,8 +2327,9 @@ fn a_take_into_a_full_bag_is_refused_and_the_item_stays_in_the_mail() { .bags_full .store(true, std::sync::atomic::Ordering::Relaxed); - let refusal = mail::take_item(world.as_ref(), Some(TRIN), MAILBOX, mail_id) - .expect_err("there is nowhere to put it"); + let refusal = mail::take_item(world.as_ref(), Actor::new(TRIN), MAILBOX, mail_id) + .expect_err("there is nowhere to put it") + .into_refusal(); assert!( matches!(refusal, mail::TakeItemRefusal::BagsFull(_)), @@ -2287,7 +2337,7 @@ fn a_take_into_a_full_bag_is_refused_and_the_item_stays_in_the_mail() { ); assert!(world.bags_of(TRIN).is_empty()); assert_eq!( - mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).unwrap()[0].item_entry, + mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX).unwrap()[0].item_entry, sword().entry, "the item is still in the letter — nothing was fenced" ); @@ -2297,7 +2347,8 @@ fn a_take_into_a_full_bag_is_refused_and_the_item_stays_in_the_mail() { .mail .bags_full .store(false, std::sync::atomic::Ordering::Relaxed); - mail::take_item(world.as_ref(), Some(TRIN), MAILBOX, mail_id).expect("once there is room"); + mail::take_item(world.as_ref(), Actor::new(TRIN), MAILBOX, mail_id) + .expect("once there is room"); assert_eq!(world.bags_of(TRIN), vec![sword()]); } @@ -2306,18 +2357,19 @@ fn a_take_into_a_full_bag_on_one_database_leaves_the_item_in_the_mail() { let single = unsharded_send(); give_item(&single, GINGER, SWORD_GUID, sword()); post_item(single.as_ref(), "Trin").expect("posted"); - let mail_id = mail::open_mailbox(single.as_ref(), Some(TRIN), MAILBOX).unwrap()[0].id; + let mail_id = mail::open_mailbox(single.as_ref(), Actor::new(TRIN), MAILBOX).unwrap()[0].id; single .mail .bags_full .store(true, std::sync::atomic::Ordering::Relaxed); - let refusal = mail::take_item(single.as_ref(), Some(TRIN), MAILBOX, mail_id) - .expect_err("there is nowhere to put it"); + let refusal = mail::take_item(single.as_ref(), Actor::new(TRIN), MAILBOX, mail_id) + .expect_err("there is nowhere to put it") + .into_refusal(); assert!(matches!(refusal, mail::TakeItemRefusal::BagsFull(_))); assert_eq!( - mail::open_mailbox(single.as_ref(), Some(TRIN), MAILBOX).unwrap()[0].item_entry, + mail::open_mailbox(single.as_ref(), Actor::new(TRIN), MAILBOX).unwrap()[0].item_entry, sword().entry ); assert!(single.bags_of(TRIN).is_empty()); @@ -2328,9 +2380,9 @@ fn taking_an_item_from_a_mail_the_caller_is_not_the_recipient_of_is_refused() { let (realm, world, _calls, mail_id) = delivered_item(); for (who, why) in [(GINGER, "the sender cannot take it back"), (TRIN, "sanity")] { - let outcome = mail::take_item(world.as_ref(), Some(who), MAILBOX, mail_id); + let outcome = mail::take_item(world.as_ref(), Actor::new(who), MAILBOX, mail_id); if who == GINGER { - let refusal = outcome.expect_err(why); + let refusal = outcome.expect_err(why).into_refusal(); assert!(matches!(refusal, mail::TakeItemRefusal::Other(_))); assert!( world.bags_of(GINGER).is_empty(), @@ -2347,8 +2399,8 @@ fn taking_an_item_from_a_mail_the_caller_is_not_the_recipient_of_is_refused() { fn taking_the_same_item_twice_grants_it_once() { let (realm, world, _calls, mail_id) = delivered_item(); - mail::take_item(world.as_ref(), Some(TRIN), MAILBOX, mail_id).expect("taken"); - mail::take_item(world.as_ref(), Some(TRIN), MAILBOX, mail_id) + mail::take_item(world.as_ref(), Actor::new(TRIN), MAILBOX, mail_id).expect("taken"); + mail::take_item(world.as_ref(), Actor::new(TRIN), MAILBOX, mail_id) .expect_err("there is nothing left in it"); assert_eq!(world.bags_of(TRIN), vec![sword()]); @@ -2367,9 +2419,9 @@ fn an_item_send_killed_before_the_commit_is_re_driven_at_the_next_mailbox_visit( assert_eq!(claimable_swords(&[&world], &realm), 0); *realm.mail.mail_kill_at.lock().unwrap() = None; - mail::open_mailbox(world.as_ref(), Some(GINGER), MAILBOX).expect("the gate opens"); + mail::open_mailbox(world.as_ref(), Actor::new(GINGER), MAILBOX).expect("the gate opens"); - let trins = mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).unwrap(); + let trins = mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX).unwrap(); assert_eq!(trins.len(), 1, "the letter finally landed — exactly once"); assert_eq!(trins[0].item_durability, sword().durability); assert!( @@ -2398,10 +2450,10 @@ fn an_item_send_killed_after_the_commit_re_drives_into_one_item() { ); *world.mail.mail_kill_at.lock().unwrap() = None; - mail::open_mailbox(world.as_ref(), Some(GINGER), MAILBOX).expect("the gate opens"); + mail::open_mailbox(world.as_ref(), Actor::new(GINGER), MAILBOX).expect("the gate opens"); assert_eq!( - mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX) + mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX) .unwrap() .len(), 1, @@ -2422,7 +2474,7 @@ fn an_item_send_killed_after_the_attestation_settles_on_the_next_visit() { assert_eq!(claimable_swords(&[&world], &realm), 1); *world.mail.mail_kill_at.lock().unwrap() = None; - mail::open_mailbox(world.as_ref(), Some(GINGER), MAILBOX).expect("the gate opens"); + mail::open_mailbox(world.as_ref(), Actor::new(GINGER), MAILBOX).expect("the gate opens"); assert!( world.mail.mail_escrows.lock().unwrap().is_empty(), @@ -2436,14 +2488,14 @@ fn an_item_take_killed_before_the_payout_is_re_driven_at_the_next_mailbox_visit( let (realm, world, _calls, mail_id) = delivered_item(); *world.mail.mail_kill_at.lock().unwrap() = Some("mail_item_payout".into()); - mail::take_item(world.as_ref(), Some(TRIN), MAILBOX, mail_id) + mail::take_item(world.as_ref(), Actor::new(TRIN), MAILBOX, mail_id) .expect_err("the bags were never granted"); assert!(world.bags_of(TRIN).is_empty()); assert_eq!(claimable_swords(&[&world], &realm), 0, "held, not lost"); assert_eq!(realm.mail.mail_escrows.lock().unwrap().len(), 1); *world.mail.mail_kill_at.lock().unwrap() = None; - mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).expect("the gate opens"); + mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX).expect("the gate opens"); assert_eq!(world.bags_of(TRIN), vec![sword()], "granted, once"); assert!( @@ -2457,7 +2509,7 @@ fn an_item_take_killed_after_the_payout_re_drives_into_one_item() { let (realm, world, _calls, mail_id) = delivered_item(); *realm.mail.mail_kill_at.lock().unwrap() = Some("mail_confirm_delivery".into()); - mail::take_item(world.as_ref(), Some(TRIN), MAILBOX, mail_id) + mail::take_item(world.as_ref(), Actor::new(TRIN), MAILBOX, mail_id) .expect_err("the attestation never landed"); assert_eq!(world.bags_of(TRIN), vec![sword()]); assert_eq!( @@ -2467,7 +2519,7 @@ fn an_item_take_killed_after_the_payout_re_drives_into_one_item() { ); *realm.mail.mail_kill_at.lock().unwrap() = None; - mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).expect("the gate opens"); + mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX).expect("the gate opens"); assert_eq!( world.bags_of(TRIN), @@ -2482,10 +2534,10 @@ fn post_cod( store: &St, to: &str, cod: u32, -) -> std::result::Result<(), mail::SendRefusal> { +) -> mail::MailResult<(), mail::SendRefusal> { mail::send( store, - Some(GINGER), + Actor::new(GINGER), MAILBOX, to, "Your sword".into(), @@ -2505,7 +2557,7 @@ fn delivered_cod() -> ( *world.mail.purses.lock().unwrap() = vec![(GINGER, PURSE), (TRIN, PURSE)]; give_item(&world, GINGER, SWORD_GUID, sword()); post_cod(world.as_ref(), "Trin", COD).expect("posted"); - let mail_id = mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).unwrap()[0].id; + let mail_id = mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX).unwrap()[0].id; calls.lock().unwrap().clear(); (realm, world, calls, mail_id) } @@ -2514,7 +2566,7 @@ fn delivered_cod_unsharded() -> (std::sync::Arc, u64) { *single.mail.purses.lock().unwrap() = vec![(GINGER, PURSE), (TRIN, PURSE)]; give_item(&single, GINGER, SWORD_GUID, sword()); post_cod(single.as_ref(), "Trin", COD).expect("posted"); - let mail_id = mail::open_mailbox(single.as_ref(), Some(TRIN), MAILBOX).unwrap()[0].id; + let mail_id = mail::open_mailbox(single.as_ref(), Actor::new(TRIN), MAILBOX).unwrap()[0].id; (single, mail_id) } fn purse_of(store: &WorldFake, guid: u64) -> u32 { @@ -2533,7 +2585,8 @@ fn purse_of(store: &WorldFake, guid: u64) -> u32 { fn a_cod_price_is_listed_before_the_recipient_takes_anything() { let (_realm, world, _calls, _mail_id) = delivered_cod(); - let trins = mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).expect("the gate opens"); + let trins = + mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX).expect("the gate opens"); assert_eq!(trins[0].cod, COD); assert_eq!( @@ -2548,7 +2601,8 @@ fn a_cod_price_is_listed_before_the_recipient_takes_anything() { fn taking_a_priced_item_debits_the_buyer_and_posts_the_copper_to_the_seller() { let (realm, world, _calls, mail_id) = delivered_cod(); - mail::take_item(world.as_ref(), Some(TRIN), MAILBOX, mail_id).expect("Trin can afford it"); + mail::take_item(world.as_ref(), Actor::new(TRIN), MAILBOX, mail_id) + .expect("Trin can afford it"); assert_eq!(world.bags_of(TRIN), vec![sword()], "the buyer has the item"); assert_eq!( @@ -2557,11 +2611,11 @@ fn taking_a_priced_item_debits_the_buyer_and_posts_the_copper_to_the_seller() { "and paid exactly the price" ); let sellers = - mail::open_mailbox(world.as_ref(), Some(GINGER), MAILBOX).expect("the gate opens"); + mail::open_mailbox(world.as_ref(), Actor::new(GINGER), MAILBOX).expect("the gate opens"); assert_eq!(sellers.len(), 1, "one payment mail"); assert_eq!(sellers[0].money, COD, "carrying the price"); assert_eq!(sellers[0].sender_guid, TRIN, "from the buyer"); - mail::take_money(world.as_ref(), Some(GINGER), MAILBOX, sellers[0].id).expect("paid"); + mail::take_money(world.as_ref(), Actor::new(GINGER), MAILBOX, sellers[0].id).expect("paid"); assert_eq!( purse_of(&world, GINGER), PURSE - lyracore_shared::mail::total_cost(0) + COD, @@ -2579,8 +2633,9 @@ fn a_buyer_who_cannot_afford_the_price_is_refused_and_nothing_moves() { let (realm, world, _calls, mail_id) = delivered_cod(); *world.mail.purses.lock().unwrap() = vec![(GINGER, PURSE), (TRIN, COD - 1)]; - let refusal = mail::take_item(world.as_ref(), Some(TRIN), MAILBOX, mail_id) - .expect_err("one copper short"); + let refusal = mail::take_item(world.as_ref(), Actor::new(TRIN), MAILBOX, mail_id) + .expect_err("one copper short") + .into_refusal(); assert!( matches!(refusal, mail::TakeItemRefusal::CannotAffordCod(_)), @@ -2588,7 +2643,7 @@ fn a_buyer_who_cannot_afford_the_price_is_refused_and_nothing_moves() { ); assert_eq!(purse_of(&world, TRIN), COD - 1, "charged nothing"); assert!(world.bags_of(TRIN).is_empty()); - let trins = mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).unwrap(); + let trins = mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX).unwrap(); assert_eq!( trins[0].item_entry, sword().entry, @@ -2596,7 +2651,7 @@ fn a_buyer_who_cannot_afford_the_price_is_refused_and_nothing_moves() { ); assert_eq!(trins[0].cod, COD, "and still owed"); assert!( - mail::open_mailbox(world.as_ref(), Some(GINGER), MAILBOX) + mail::open_mailbox(world.as_ref(), Actor::new(GINGER), MAILBOX) .unwrap() .is_empty(), "and the seller was paid nothing" @@ -2608,14 +2663,16 @@ fn a_buyer_who_cannot_afford_the_price_is_refused_and_nothing_moves() { fn a_buyer_who_refuses_the_price_can_return_the_mail_instead() { let (realm, world, _calls, mail_id) = delivered_cod(); *world.mail.purses.lock().unwrap() = vec![(GINGER, PURSE), (TRIN, COD - 1)]; - mail::take_item(world.as_ref(), Some(TRIN), MAILBOX, mail_id).expect_err("cannot pay"); + mail::take_item(world.as_ref(), Actor::new(TRIN), MAILBOX, mail_id).expect_err("cannot pay"); - mail::return_to_sender(world.as_ref(), Some(TRIN), MAILBOX, mail_id).expect("declined"); + mail::return_to_sender(world.as_ref(), Actor::new(TRIN), MAILBOX, mail_id).expect("declined"); - assert!(mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX) - .unwrap() - .is_empty()); - let back = mail::open_mailbox(world.as_ref(), Some(GINGER), MAILBOX).unwrap(); + assert!( + mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX) + .unwrap() + .is_empty() + ); + let back = mail::open_mailbox(world.as_ref(), Actor::new(GINGER), MAILBOX).unwrap(); assert_eq!(back.len(), 1); assert_eq!(back[0].item_entry, sword().entry, "the sword went home"); assert_eq!(claimable_swords(&[&world], &realm), 1); @@ -2624,15 +2681,16 @@ fn a_buyer_who_refuses_the_price_can_return_the_mail_instead() { #[test] fn a_returned_priced_mail_does_not_charge_its_own_sender() { let (_realm, world, _calls, mail_id) = delivered_cod(); - mail::return_to_sender(world.as_ref(), Some(TRIN), MAILBOX, mail_id).expect("declined"); - let back = mail::open_mailbox(world.as_ref(), Some(GINGER), MAILBOX).unwrap(); + mail::return_to_sender(world.as_ref(), Actor::new(TRIN), MAILBOX, mail_id).expect("declined"); + let back = mail::open_mailbox(world.as_ref(), Actor::new(GINGER), MAILBOX).unwrap(); assert_eq!( back[0].cod, 0, "the price does not travel home with the item" ); let before = purse_of(&world, GINGER); - mail::take_item(world.as_ref(), Some(GINGER), MAILBOX, back[0].id).expect("their own sword"); + mail::take_item(world.as_ref(), Actor::new(GINGER), MAILBOX, back[0].id) + .expect("their own sword"); assert_eq!(purse_of(&world, GINGER), before, "and nobody was charged"); assert_eq!( @@ -2647,13 +2705,13 @@ fn a_returned_priced_mail_does_not_charge_its_own_sender() { fn taking_a_priced_mail_twice_charges_once() { let (realm, world, _calls, mail_id) = delivered_cod(); - mail::take_item(world.as_ref(), Some(TRIN), MAILBOX, mail_id).expect("bought"); - mail::take_item(world.as_ref(), Some(TRIN), MAILBOX, mail_id) + mail::take_item(world.as_ref(), Actor::new(TRIN), MAILBOX, mail_id).expect("bought"); + mail::take_item(world.as_ref(), Actor::new(TRIN), MAILBOX, mail_id) .expect_err("there is nothing left in it"); assert_eq!(purse_of(&world, TRIN), PURSE - COD, "debited once"); assert_eq!( - mail::open_mailbox(world.as_ref(), Some(GINGER), MAILBOX) + mail::open_mailbox(world.as_ref(), Actor::new(GINGER), MAILBOX) .unwrap() .len(), 1, @@ -2668,8 +2726,8 @@ fn both_planes_settle_the_same_cod() { let (_realm, world, _calls, sharded_id) = delivered_cod(); let (single, single_id) = delivered_cod_unsharded(); - mail::take_item(world.as_ref(), Some(TRIN), MAILBOX, sharded_id).expect("sharded"); - mail::take_item(single.as_ref(), Some(TRIN), MAILBOX, single_id).expect("one database"); + mail::take_item(world.as_ref(), Actor::new(TRIN), MAILBOX, sharded_id).expect("sharded"); + mail::take_item(single.as_ref(), Actor::new(TRIN), MAILBOX, single_id).expect("one database"); assert_eq!( purse_of(&world, TRIN), @@ -2677,8 +2735,8 @@ fn both_planes_settle_the_same_cod() { "the two planes must charge the same buyer the same price" ); assert_eq!(world.bags_of(TRIN), single.bags_of(TRIN)); - let sharded_seller = mail::open_mailbox(world.as_ref(), Some(GINGER), MAILBOX).unwrap(); - let single_seller = mail::open_mailbox(single.as_ref(), Some(GINGER), MAILBOX).unwrap(); + let sharded_seller = mail::open_mailbox(world.as_ref(), Actor::new(GINGER), MAILBOX).unwrap(); + let single_seller = mail::open_mailbox(single.as_ref(), Actor::new(GINGER), MAILBOX).unwrap(); assert_eq!(sharded_seller.len(), single_seller.len()); assert_eq!(sharded_seller[0].money, single_seller[0].money); assert_eq!(sharded_seller[0].subject, single_seller[0].subject); @@ -2693,7 +2751,7 @@ fn both_planes_settle_the_same_cod() { fn a_sharded_cod_take_pays_before_it_fences_the_item() { let (_realm, world, calls, mail_id) = delivered_cod(); - mail::take_item(world.as_ref(), Some(TRIN), MAILBOX, mail_id).expect("bought"); + mail::take_item(world.as_ref(), Actor::new(TRIN), MAILBOX, mail_id).expect("bought"); assert_eq!( calls @@ -2724,19 +2782,20 @@ fn a_full_bag_refuses_a_priced_take_before_any_copper_moves() { .bags_full .store(true, std::sync::atomic::Ordering::Relaxed); - let refusal = mail::take_item(world.as_ref(), Some(TRIN), MAILBOX, mail_id) - .expect_err("nowhere to put it"); + let refusal = mail::take_item(world.as_ref(), Actor::new(TRIN), MAILBOX, mail_id) + .expect_err("nowhere to put it") + .into_refusal(); assert!(matches!(refusal, mail::TakeItemRefusal::BagsFull(_))); assert_eq!(purse_of(&world, TRIN), PURSE, "charged nothing"); assert!( - mail::open_mailbox(world.as_ref(), Some(GINGER), MAILBOX) + mail::open_mailbox(world.as_ref(), Actor::new(GINGER), MAILBOX) .unwrap() .is_empty(), "and the seller was paid nothing" ); assert_eq!( - mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).unwrap()[0].cod, + mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX).unwrap()[0].cod, COD, "still owed" ); @@ -2784,22 +2843,22 @@ fn a_cod_payment_killed_before_the_commit_is_re_driven_at_the_next_mailbox_visit let (realm, world, _calls, mail_id) = delivered_cod(); *realm.mail.mail_kill_at.lock().unwrap() = Some("mail_commit".into()); - mail::take_item(world.as_ref(), Some(TRIN), MAILBOX, mail_id) + mail::take_item(world.as_ref(), Actor::new(TRIN), MAILBOX, mail_id) .expect_err("realm-core never answered the payment"); assert_eq!(purse_of(&world, TRIN), PURSE - COD, "the buyer has PAID"); assert_eq!(world.mail.mail_escrows.lock().unwrap().len(), 1, "held"); assert_eq!( - mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).unwrap()[0].item_entry, + mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX).unwrap()[0].item_entry, sword().entry, "and the item is still in the letter" ); assert_the_price_is_in_exactly_one_place(&world, &realm); *realm.mail.mail_kill_at.lock().unwrap() = None; - mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).expect("the gate opens"); + mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX).expect("the gate opens"); assert_eq!( - mail::open_mailbox(world.as_ref(), Some(GINGER), MAILBOX).unwrap()[0].money, + mail::open_mailbox(world.as_ref(), Actor::new(GINGER), MAILBOX).unwrap()[0].money, COD, "the seller is paid, once" ); @@ -2815,14 +2874,16 @@ fn a_cod_payment_killed_before_the_commit_is_re_driven_at_the_next_mailbox_visit fn a_second_click_resumes_a_held_payment_rather_than_charging_twice() { let (realm, world, _calls, mail_id) = delivered_cod(); *realm.mail.mail_kill_at.lock().unwrap() = Some("mail_commit".into()); - mail::take_item(world.as_ref(), Some(TRIN), MAILBOX, mail_id).expect_err("never committed"); + mail::take_item(world.as_ref(), Actor::new(TRIN), MAILBOX, mail_id) + .expect_err("never committed"); *realm.mail.mail_kill_at.lock().unwrap() = None; - mail::take_item(world.as_ref(), Some(TRIN), MAILBOX, mail_id).expect("the second click lands"); + mail::take_item(world.as_ref(), Actor::new(TRIN), MAILBOX, mail_id) + .expect("the second click lands"); assert_eq!(purse_of(&world, TRIN), PURSE - COD, "charged once"); assert_eq!( - mail::open_mailbox(world.as_ref(), Some(GINGER), MAILBOX) + mail::open_mailbox(world.as_ref(), Actor::new(GINGER), MAILBOX) .unwrap() .len(), 1, @@ -2838,18 +2899,18 @@ fn a_cod_take_killed_after_the_payment_hands_the_item_over_for_free_on_the_next_ let (realm, world, _calls, mail_id) = delivered_cod(); *realm.mail.mail_kill_at.lock().unwrap() = Some("mail_take_item_fence".into()); - mail::take_item(world.as_ref(), Some(TRIN), MAILBOX, mail_id) + mail::take_item(world.as_ref(), Actor::new(TRIN), MAILBOX, mail_id) .expect_err("the item was never fenced"); assert_eq!(purse_of(&world, TRIN), PURSE - COD, "paid"); assert_eq!( - mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).unwrap()[0].cod, + mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX).unwrap()[0].cod, 0, "and the price is settled, so a retry must not charge again" ); assert_the_price_is_in_exactly_one_place(&world, &realm); *realm.mail.mail_kill_at.lock().unwrap() = None; - mail::take_item(world.as_ref(), Some(TRIN), MAILBOX, mail_id).expect("the retry lands"); + mail::take_item(world.as_ref(), Actor::new(TRIN), MAILBOX, mail_id).expect("the retry lands"); assert_eq!(purse_of(&world, TRIN), PURSE - COD, "charged once in total"); assert_eq!(world.bags_of(TRIN), vec![sword()]); @@ -2861,7 +2922,7 @@ fn a_cod_take_killed_before_the_item_payout_is_re_driven_at_the_next_mailbox_vis let (realm, world, _calls, mail_id) = delivered_cod(); *world.mail.mail_kill_at.lock().unwrap() = Some("mail_item_payout".into()); - mail::take_item(world.as_ref(), Some(TRIN), MAILBOX, mail_id) + mail::take_item(world.as_ref(), Actor::new(TRIN), MAILBOX, mail_id) .expect_err("the bags were never granted"); assert_eq!(purse_of(&world, TRIN), PURSE - COD, "paid"); assert_eq!(claimable_swords(&[&world], &realm), 0, "held, not lost"); @@ -2869,7 +2930,7 @@ fn a_cod_take_killed_before_the_item_payout_is_re_driven_at_the_next_mailbox_vis assert_the_price_is_in_exactly_one_place(&world, &realm); *world.mail.mail_kill_at.lock().unwrap() = None; - mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).expect("the gate opens"); + mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX).expect("the gate opens"); assert_eq!(world.bags_of(TRIN), vec![sword()], "granted, once"); assert_eq!(purse_of(&world, TRIN), PURSE - COD, "and charged once"); @@ -2888,9 +2949,9 @@ fn a_priced_send_killed_before_the_commit_re_drives_with_its_price_intact() { post_cod(world.as_ref(), "Trin", COD).expect_err("realm-core never answered"); *realm.mail.mail_kill_at.lock().unwrap() = None; - mail::open_mailbox(world.as_ref(), Some(GINGER), MAILBOX).expect("the gate opens"); + mail::open_mailbox(world.as_ref(), Actor::new(GINGER), MAILBOX).expect("the gate opens"); - let trins = mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).unwrap(); + let trins = mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX).unwrap(); assert_eq!(trins.len(), 1); assert_eq!(trins[0].cod, COD, "the price survived the re-drive"); assert_eq!(trins[0].item_entry, sword().entry); @@ -2902,7 +2963,7 @@ fn a_price_on_a_letter_with_no_attachment_is_dropped() { mail::send( world.as_ref(), - Some(GINGER), + Actor::new(GINGER), MAILBOX, "Trin", "Nothing".into(), @@ -2914,7 +2975,7 @@ fn a_price_on_a_letter_with_no_attachment_is_dropped() { .expect("posted"); assert_eq!( - mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).unwrap()[0].cod, + mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX).unwrap()[0].cod, 0 ); } @@ -2995,7 +3056,7 @@ fn both_planes( [(world, realm), (single.clone(), single)] } fn listed(store: &WorldFake, guid: u64) -> Vec { - let mails = mail::open_mailbox(store, Some(guid), MAILBOX).expect("the gate opens"); + let mails = mail::open_mailbox(store, Actor::new(guid), MAILBOX).expect("the gate opens"); codec::build_mail_list(&mails, mail::now_secs()).mails } fn listed_ids(store: &WorldFake, guid: u64) -> Vec { @@ -3011,7 +3072,8 @@ fn a_read_mail_lists_with_the_read_bit_on_both_planes() { "a legacy unread row lists as before" ); - mail::mark_read(store.as_ref(), Some(GINGER), MAILBOX, 1).expect("Ginger owns mail 1"); + mail::mark_read(store.as_ref(), Actor::new(GINGER), MAILBOX, 1) + .expect("Ginger owns mail 1"); assert_eq!(listed(&store, GINGER)[0].checked_timestamp, 0x01); } @@ -3024,7 +3086,7 @@ fn a_posted_letter_lists_with_its_body_bit_and_an_empty_one_as_already_copied() post(store.as_ref(), "Trin").expect("a letter with a body"); mail::send( store.as_ref(), - Some(GINGER), + Actor::new(GINGER), MAILBOX, "Trin", "No letter".into(), @@ -3052,7 +3114,7 @@ fn a_cod_payment_lists_under_the_letters_own_subject_with_the_payment_bit_on_bot let (_realm, world, _calls, sharded_id) = delivered_cod(); let (single, single_id) = delivered_cod_unsharded(); for (store, mail_id) in [(world, sharded_id), (single, single_id)] { - mail::take_item(store.as_ref(), Some(TRIN), MAILBOX, mail_id).expect("Trin buys it"); + mail::take_item(store.as_ref(), Actor::new(TRIN), MAILBOX, mail_id).expect("Trin buys it"); let payment = &listed(&store, GINGER)[0]; assert_eq!( @@ -3102,7 +3164,7 @@ fn the_mailbox_lists_the_fifty_newest_mails_and_the_poll_still_sees_the_rest() { assert_eq!(ids.len(), 50); assert_eq!((ids[0], ids[49]), (60, 11)); assert!( - mail::has_unread(store.as_ref(), Some(GINGER)).unwrap(), + mail::has_unread(store.as_ref(), Actor::new(GINGER)).unwrap(), "the ten oldest mails are unread and past the cap, and they still light the envelope" ); } @@ -3130,13 +3192,13 @@ fn a_mail_not_yet_delivered_is_absent_from_the_list_the_poll_and_the_body_read() "only the mail whose delivery instant has passed" ); assert_eq!( - mail::letter_body(store.as_ref(), Some(GINGER), 1).unwrap(), + mail::letter_body(store.as_ref(), Actor::new(GINGER), 1).unwrap(), None ); - mail::mark_read(store.as_ref(), Some(GINGER), MAILBOX, 5).unwrap(); + mail::mark_read(store.as_ref(), Actor::new(GINGER), MAILBOX, 5).unwrap(); assert!( - !mail::has_unread(store.as_ref(), Some(GINGER)).unwrap(), + !mail::has_unread(store.as_ref(), Actor::new(GINGER)).unwrap(), "an unread mail still on its way does not light the envelope" ); } @@ -3146,11 +3208,14 @@ fn a_mail_not_yet_delivered_is_absent_from_the_list_the_poll_and_the_body_read() fn a_mail_not_yet_delivered_cannot_be_read_deleted_taken_from_or_returned() { let rows = vec![arriving_in(3_600, 1)]; for (store, holder) in both_planes(rows.clone()) { - mail::mark_read(store.as_ref(), Some(GINGER), MAILBOX, 1).expect_err("nothing to read yet"); - mail::delete(store.as_ref(), Some(GINGER), MAILBOX, 1).expect_err("nothing to delete yet"); - mail::take_money(store.as_ref(), Some(GINGER), MAILBOX, 1).expect_err("no copper yet"); - mail::take_item(store.as_ref(), Some(GINGER), MAILBOX, 1).expect_err("no item yet"); - mail::return_to_sender(store.as_ref(), Some(GINGER), MAILBOX, 1) + mail::mark_read(store.as_ref(), Actor::new(GINGER), MAILBOX, 1) + .expect_err("nothing to read yet"); + mail::delete(store.as_ref(), Actor::new(GINGER), MAILBOX, 1) + .expect_err("nothing to delete yet"); + mail::take_money(store.as_ref(), Actor::new(GINGER), MAILBOX, 1) + .expect_err("no copper yet"); + mail::take_item(store.as_ref(), Actor::new(GINGER), MAILBOX, 1).expect_err("no item yet"); + mail::return_to_sender(store.as_ref(), Actor::new(GINGER), MAILBOX, 1) .expect_err("nothing to return yet"); assert_eq!( @@ -3185,7 +3250,7 @@ fn a_mail_with_nobody_to_take_it_back_cannot_be_returned_on_either_plane() { ]; for (store, holder) in both_planes(rows.clone()) { for id in [1, 3, 4] { - let err = mail::return_to_sender(store.as_ref(), Some(GINGER), MAILBOX, id) + let err = mail::return_to_sender(store.as_ref(), Actor::new(GINGER), MAILBOX, id) .expect_err("nobody to return it to"); assert!( err.to_string() @@ -3204,9 +3269,10 @@ fn a_mail_with_nobody_to_take_it_back_cannot_be_returned_on_either_plane() { #[test] fn a_returned_mail_cannot_be_returned_again() { for (store, _) in both_planes(seeded_mail()) { - mail::return_to_sender(store.as_ref(), Some(GINGER), MAILBOX, 1).expect("back to Vim"); + mail::return_to_sender(store.as_ref(), Actor::new(GINGER), MAILBOX, 1) + .expect("back to Vim"); - let err = mail::return_to_sender(store.as_ref(), Some(VIM), MAILBOX, 1) + let err = mail::return_to_sender(store.as_ref(), Actor::new(VIM), MAILBOX, 1) .expect_err("a mail goes back once"); assert!( @@ -3234,7 +3300,7 @@ fn a_returned_mail_lists_as_returned_and_unread_with_no_price_and_a_fresh_countd for (store, _) in both_planes(vec![priced]) { // Vim is Ginger's alt here, so the item comes back at once. *store.mail.realm_accounts.lock().unwrap() = alts(&[GINGER, VIM]); - mail::return_to_sender(store.as_ref(), Some(GINGER), MAILBOX, 1).expect("declined"); + mail::return_to_sender(store.as_ref(), Actor::new(GINGER), MAILBOX, 1).expect("declined"); let back = &listed(&store, VIM)[0]; assert_eq!(back.checked_timestamp, 0x02, "RETURNED, and unread"); @@ -3252,7 +3318,7 @@ fn a_priced_mail_cannot_be_deleted_on_either_plane() { let (_realm, world, _calls, sharded_id) = delivered_cod(); let (single, single_id) = delivered_cod_unsharded(); for (store, mail_id) in [(world, sharded_id), (single, single_id)] { - let err = mail::delete(store.as_ref(), Some(TRIN), MAILBOX, mail_id) + let err = mail::delete(store.as_ref(), Actor::new(TRIN), MAILBOX, mail_id) .expect_err("a price is owed"); assert!( @@ -3334,14 +3400,14 @@ fn an_item_to_another_account_is_hidden_for_its_delivery_delay() { assert_eq!(same_account_seen(&world), [("mail_fence", false)]); assert!( - mail::open_mailbox(instances.as_ref(), Some(VIM), MAILBOX) + mail::open_mailbox(instances.as_ref(), Actor::new(VIM), MAILBOX) .unwrap() .is_empty(), "Vim sees nothing for the hour" ); let held = held_for(&realm, VIM); assert!(held[0].deliver_secs > mail::now_secs(), "{held:?}"); - mail::take_item(instances.as_ref(), Some(VIM), MAILBOX, held[0].id) + mail::take_item(instances.as_ref(), Actor::new(VIM), MAILBOX, held[0].id) .expect_err("nothing to take yet"); } @@ -3353,7 +3419,7 @@ fn copper_to_another_account_arrives_at_once() { assert_eq!(same_account_seen(&world), [("mail_fence", false)]); assert_eq!( - mail::open_mailbox(instances.as_ref(), Some(VIM), MAILBOX) + mail::open_mailbox(instances.as_ref(), Actor::new(VIM), MAILBOX) .unwrap() .len(), 1 @@ -3371,7 +3437,7 @@ fn an_item_to_an_alt_on_another_shard_arrives_at_once() { assert_eq!(same_account_seen(&world), [("mail_fence", true)]); assert_eq!( - mail::open_mailbox(instances.as_ref(), Some(VIM), MAILBOX) + mail::open_mailbox(instances.as_ref(), Actor::new(VIM), MAILBOX) .unwrap() .len(), 1 @@ -3391,7 +3457,7 @@ fn a_shard_that_holds_a_character_on_a_shadow_account_defers_to_another_shard() assert_eq!(same_account_seen(&world), [("mail_fence", true)]); assert_eq!( - mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX) + mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX) .unwrap() .len(), 1 @@ -3407,9 +3473,11 @@ fn a_character_no_shard_can_name_counts_as_another_account() { post_item(world.as_ref(), "Trin").expect("posted"); assert_eq!(same_account_seen(&world), [("mail_fence", false)]); - assert!(mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX) - .unwrap() - .is_empty()); + assert!( + mail::open_mailbox(world.as_ref(), Actor::new(TRIN), MAILBOX) + .unwrap() + .is_empty() + ); } #[test] @@ -3426,7 +3494,7 @@ fn the_single_database_send_carries_the_same_account_answer() { assert_eq!(same_account_seen(&single), [("mail_send", same_account)]); assert_eq!( - mail::open_mailbox(single.as_ref(), Some(TRIN), MAILBOX) + mail::open_mailbox(single.as_ref(), Actor::new(TRIN), MAILBOX) .unwrap() .len(), listed @@ -3448,11 +3516,11 @@ fn an_item_returned_to_another_account_waits_and_to_an_alt_does_not() { }, )]; - mail::return_to_sender(world.as_ref(), Some(GINGER), MAILBOX, 1).expect("returned"); + mail::return_to_sender(world.as_ref(), Actor::new(GINGER), MAILBOX, 1).expect("returned"); assert_eq!(same_account_seen(&realm), [("mail_return", same_account)]); assert_eq!( - mail::open_mailbox(instances.as_ref(), Some(VIM), MAILBOX) + mail::open_mailbox(instances.as_ref(), Actor::new(VIM), MAILBOX) .unwrap() .len(), listed, @@ -3482,7 +3550,7 @@ fn an_item_send_re_driven_after_a_restart_keeps_its_delivery_delay() { ); *realm.mail.mail_kill_at.lock().unwrap() = None; - mail::open_mailbox(world.as_ref(), Some(GINGER), MAILBOX).expect("the gate opens"); + mail::open_mailbox(world.as_ref(), Actor::new(GINGER), MAILBOX).expect("the gate opens"); assert!( world.mail.mail_escrows.lock().unwrap().is_empty(), @@ -3490,7 +3558,7 @@ fn an_item_send_re_driven_after_a_restart_keeps_its_delivery_delay() { ); assert_eq!(held_for(&realm, VIM).len(), 1, "committed once"); assert!( - mail::open_mailbox(instances.as_ref(), Some(VIM), MAILBOX) + mail::open_mailbox(instances.as_ref(), Actor::new(VIM), MAILBOX) .unwrap() .is_empty(), "the re-driven letter still waits its hour" @@ -3506,17 +3574,18 @@ fn a_cod_letter_to_another_account_waits_and_its_payment_arrives_at_once() { post_cod(world.as_ref(), "Vim", COD).expect("posted"); let id = held_for(&realm, VIM)[0].id; - mail::take_item(instances.as_ref(), Some(VIM), MAILBOX, id).expect_err("nothing to buy yet"); + mail::take_item(instances.as_ref(), Actor::new(VIM), MAILBOX, id) + .expect_err("nothing to buy yet"); assert_eq!(purse_of(&instances, VIM), PURSE, "and nothing is charged"); // An hour later. for (_, m) in realm.mail.mails.lock().unwrap().iter_mut() { m.deliver_secs = mail::now_secs(); } - mail::take_item(instances.as_ref(), Some(VIM), MAILBOX, id).expect("bought"); + mail::take_item(instances.as_ref(), Actor::new(VIM), MAILBOX, id).expect("bought"); assert_eq!(purse_of(&instances, VIM), PURSE - COD); - let gingers = mail::open_mailbox(world.as_ref(), Some(GINGER), MAILBOX).unwrap(); + let gingers = mail::open_mailbox(world.as_ref(), Actor::new(GINGER), MAILBOX).unwrap(); assert_eq!( (gingers.len(), gingers[0].money), (1, COD), @@ -3536,7 +3605,7 @@ const GINGERS_LETTER_TEXT_ID: u32 = 1; fn copying_a_letter_sets_copied_and_grants_one_plain_letter() { let (realm, world, _calls) = sharded_mailbox(); - mail::copy_letter(world.as_ref(), Some(GINGER), MAILBOX, 1).expect("the copy completes"); + mail::copy_letter(world.as_ref(), Actor::new(GINGER), MAILBOX, 1).expect("the copy completes"); let flags = realm.mail.mails.lock().unwrap()[0].1.check_flags; assert_eq!( @@ -3572,7 +3641,7 @@ fn copying_a_letter_sets_copied_and_grants_one_plain_letter() { fn a_letter_copy_on_one_database_grants_the_same_way() { let single = unsharded_mailbox(); - mail::copy_letter(single.as_ref(), Some(GINGER), MAILBOX, 1).expect("the copy completes"); + mail::copy_letter(single.as_ref(), Actor::new(GINGER), MAILBOX, 1).expect("the copy completes"); assert_eq!( *single.mail.granted_letters.lock().unwrap(), @@ -3597,8 +3666,9 @@ fn copying_into_a_full_bag_is_refused_and_leaves_the_mail_uncopied() { .bags_full .store(true, std::sync::atomic::Ordering::Relaxed); - let refusal = mail::copy_letter(world.as_ref(), Some(GINGER), MAILBOX, 1) - .expect_err("there is nowhere to put the letter"); + let refusal = mail::copy_letter(world.as_ref(), Actor::new(GINGER), MAILBOX, 1) + .expect_err("there is nowhere to put the letter") + .into_refusal(); assert!( matches!(refusal, mail::CopyLetterRefusal::BagsFull(_)), @@ -3618,8 +3688,9 @@ fn copying_into_a_full_bag_is_refused_and_leaves_the_mail_uncopied() { fn a_letter_already_granted_refuses_a_second_copy_and_grants_no_second_letter() { let (_realm, world, _calls) = sharded_mailbox(); - mail::copy_letter(world.as_ref(), Some(GINGER), MAILBOX, 1).expect("the first copy completes"); - mail::copy_letter(world.as_ref(), Some(GINGER), MAILBOX, 1) + mail::copy_letter(world.as_ref(), Actor::new(GINGER), MAILBOX, 1) + .expect("the first copy completes"); + mail::copy_letter(world.as_ref(), Actor::new(GINGER), MAILBOX, 1) .expect_err("a completed grant refuses a second click for good"); assert_eq!( @@ -3635,11 +3706,13 @@ fn a_letter_already_granted_refuses_a_second_copy_and_grants_no_second_letter() #[test] fn destroying_the_granted_item_does_not_reopen_a_second_grant() { let (_realm, world, _calls) = sharded_mailbox(); - mail::copy_letter(world.as_ref(), Some(GINGER), MAILBOX, 1).expect("the first copy completes"); + mail::copy_letter(world.as_ref(), Actor::new(GINGER), MAILBOX, 1) + .expect("the first copy completes"); world.mail.granted_letters.lock().unwrap().clear(); // the Plain Letter is gone from the bags - let refusal = mail::copy_letter(world.as_ref(), Some(GINGER), MAILBOX, 1) - .expect_err("GRANTED survives the item's destruction"); + let refusal = mail::copy_letter(world.as_ref(), Actor::new(GINGER), MAILBOX, 1) + .expect_err("GRANTED survives the item's destruction") + .into_refusal(); assert!(matches!(refusal, mail::CopyLetterRefusal::Other(_))); assert!( @@ -3666,7 +3739,7 @@ fn retrying_after_an_interrupted_grant_still_grants_the_letter() { "the fixture models a grant that never landed" ); - mail::copy_letter(world.as_ref(), Some(GINGER), MAILBOX, 1).expect("the retry completes"); + mail::copy_letter(world.as_ref(), Actor::new(GINGER), MAILBOX, 1).expect("the retry completes"); assert_eq!( *world.mail.granted_letters.lock().unwrap(), @@ -3679,11 +3752,17 @@ fn retrying_after_an_interrupted_grant_still_grants_the_letter() { fn the_copied_letters_text_is_readable_after_the_mail_is_deleted() { let (realm, world, _calls) = sharded_mailbox(); - mail::copy_letter(world.as_ref(), Some(GINGER), MAILBOX, 1).expect("the copy completes"); + mail::copy_letter(world.as_ref(), Actor::new(GINGER), MAILBOX, 1).expect("the copy completes"); realm.mail.mails.lock().unwrap().retain(|(_, m)| m.id != 1); assert_eq!( - mail::item_text(world.as_ref(), Some(GINGER), GINGERS_LETTER_TEXT_ID, 0).unwrap(), + mail::item_text( + world.as_ref(), + Actor::new(GINGER), + GINGERS_LETTER_TEXT_ID, + 0 + ) + .unwrap(), Some("left it at the inn".to_string()), "a copied letter's text outlives the mail row that created it" ); @@ -3792,11 +3871,12 @@ fn a_letter_copy_into_a_full_bag_over_the_wire_answers_equip_error() { #[test] fn item_text_query_refuses_a_caller_who_neither_owns_the_item_nor_the_mail() { let (realm, world, _calls) = sharded_mailbox(); - mail::copy_letter(world.as_ref(), Some(GINGER), MAILBOX, 1).expect("Ginger copies their own"); + mail::copy_letter(world.as_ref(), Actor::new(GINGER), MAILBOX, 1) + .expect("Ginger copies their own"); realm.mail.mails.lock().unwrap().retain(|(_, m)| m.id != 1); assert_eq!( - mail::item_text(world.as_ref(), Some(TRIN), GINGERS_LETTER_TEXT_ID, 0).unwrap(), + mail::item_text(world.as_ref(), Actor::new(TRIN), GINGERS_LETTER_TEXT_ID, 0).unwrap(), None, "Trin holds no such item and never owned this mail — a walked id must read empty, not \ Ginger's letter" @@ -3806,10 +3886,17 @@ fn item_text_query_refuses_a_caller_who_neither_owns_the_item_nor_the_mail() { #[test] fn item_text_query_answers_for_an_owner_who_still_holds_the_granted_item() { let (_realm, world, _calls) = sharded_mailbox(); - mail::copy_letter(world.as_ref(), Some(GINGER), MAILBOX, 1).expect("Ginger copies their own"); + mail::copy_letter(world.as_ref(), Actor::new(GINGER), MAILBOX, 1) + .expect("Ginger copies their own"); assert_eq!( - mail::item_text(world.as_ref(), Some(GINGER), GINGERS_LETTER_TEXT_ID, 0).unwrap(), + mail::item_text( + world.as_ref(), + Actor::new(GINGER), + GINGERS_LETTER_TEXT_ID, + 0 + ) + .unwrap(), Some("left it at the inn".to_string()), "the copy's own owner must still read it back" ); @@ -3823,11 +3910,17 @@ fn item_text_query_falls_back_to_the_callers_own_undeleted_mail() { // the caller's own delivered mail under the same id, matching what a letter still sitting in // the mailbox has always done. assert_eq!( - mail::item_text(world.as_ref(), Some(GINGER), GINGERS_LETTER_TEXT_ID, 0).unwrap(), + mail::item_text( + world.as_ref(), + Actor::new(GINGER), + GINGERS_LETTER_TEXT_ID, + 0 + ) + .unwrap(), Some("left it at the inn".to_string()) ); assert_eq!( - mail::item_text(world.as_ref(), Some(TRIN), GINGERS_LETTER_TEXT_ID, 0).unwrap(), + mail::item_text(world.as_ref(), Actor::new(TRIN), GINGERS_LETTER_TEXT_ID, 0).unwrap(), None, "and it stays scoped to the caller's own mail — not a crafted read of Ginger's" ); @@ -3917,7 +4010,7 @@ fn a_reward_letter_held_on_the_home_shard_is_committed_on_realm_core_at_the_mail hold_reward_letter(&world, GINGER, card_renewal_letter(GINGER, 86_400)); let visible = - mail::open_mailbox(world.as_ref(), Some(GINGER), MAILBOX).expect("the gate opens"); + mail::open_mailbox(world.as_ref(), Actor::new(GINGER), MAILBOX).expect("the gate opens"); let letters = reward_letters(&realm, GINGER); assert_eq!(letters.len(), 1, "{letters:?}"); @@ -3949,7 +4042,7 @@ fn a_reward_letter_whose_drive_died_after_the_commit_is_delivered_once() { let (realm, world, _instances, _calls) = sharded_send(); hold_reward_letter(&world, GINGER, card_renewal_letter(GINGER, 0)); *world.mail.mail_kill_at.lock().unwrap() = Some("mail_confirm_delivery".into()); - mail::open_mailbox(world.as_ref(), Some(GINGER), MAILBOX).expect("the gate opens"); + mail::open_mailbox(world.as_ref(), Actor::new(GINGER), MAILBOX).expect("the gate opens"); assert_eq!( world.mail.mail_escrows.lock().unwrap().len(), 1, @@ -3958,7 +4051,7 @@ fn a_reward_letter_whose_drive_died_after_the_commit_is_delivered_once() { *world.mail.mail_kill_at.lock().unwrap() = None; let visible = - mail::open_mailbox(world.as_ref(), Some(GINGER), MAILBOX).expect("the gate opens"); + mail::open_mailbox(world.as_ref(), Actor::new(GINGER), MAILBOX).expect("the gate opens"); assert_eq!(reward_letters(&realm, GINGER).len(), 1, "one letter"); assert_eq!( @@ -3980,7 +4073,7 @@ fn a_single_database_drives_a_reward_letter_on_its_own_database() { let store = unsharded_send(); hold_reward_letter(&store, GINGER, card_renewal_letter(GINGER, 86_400)); - mail::open_mailbox(store.as_ref(), Some(GINGER), MAILBOX).expect("the gate opens"); + mail::open_mailbox(store.as_ref(), Actor::new(GINGER), MAILBOX).expect("the gate opens"); let letters = reward_letters(&store, GINGER); assert_eq!(letters.len(), 1, "{letters:?}"); @@ -3999,8 +4092,8 @@ fn both_planes_write_the_same_reward_letter() { hold_reward_letter(shard, GINGER, card_renewal_letter(GINGER, 0)); } - mail::open_mailbox(world.as_ref(), Some(GINGER), MAILBOX).expect("the gate opens"); - mail::open_mailbox(single.as_ref(), Some(GINGER), MAILBOX).expect("the gate opens"); + mail::open_mailbox(world.as_ref(), Actor::new(GINGER), MAILBOX).expect("the gate opens"); + mail::open_mailbox(single.as_ref(), Actor::new(GINGER), MAILBOX).expect("the gate opens"); assert_eq!( reward_letters(&realm, GINGER), @@ -4099,3 +4192,40 @@ fn a_reward_letter_a_turn_in_files_is_delivered_on_either_plane() { ); } } + +/// A mail Refusal answers the client and the World Session continues; a Transport Loss mid-drive +/// leaves the letter's outcome unknown and ends the session. +#[test] +fn a_send_refusal_is_answered_and_a_transport_loss_ends_the_session() { + let send = |receiver: &str| { + ClientOpcodeMessage::CMSG_SEND_MAIL(Box::new(wow_world_messages::vanilla::CMSG_SEND_MAIL { + mailbox: Guid::new(MAILBOX), + receiver: receiver.into(), + subject: "Your sword".into(), + body: "left it at the inn".into(), + ..Default::default() + })) + }; + let (realm, world, _instances, _calls) = sharded_send(); + let (tx, rx) = SessionTx::with_depth(0); + let mut conn = in_world_conn(7, GINGER); + + handle_mail(&tx, world.as_ref(), &mut conn, send("Nobody")) + .expect("a Refusal keeps the session"); + match drain_outbound(&rx).as_slice() { + [ServerOpcodeMessage::SMSG_SEND_MAIL_RESULT(m)] => assert_eq!( + m.action, + wow_world_messages::vanilla::SMSG_SEND_MAIL_RESULT_MailAction::Send { + result2: wow_world_messages::vanilla::SMSG_SEND_MAIL_RESULT_MailResultTwo::ErrRecipientNotFound + } + ), + other => panic!("expected the recipient Refusal, got {other:?}"), + } + + *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" + ); +} diff --git a/gateway/src/world/test_support/world_fake/mail.rs b/gateway/src/world/test_support/world_fake/mail.rs index a11d75598..d9161528a 100644 --- a/gateway/src/world/test_support/world_fake/mail.rs +++ b/gateway/src/world/test_support/world_fake/mail.rs @@ -1,5 +1,10 @@ use super::super::*; +/// The Module refusing a mail reducer with `reason`, as the Fake's reducer edge reports it. +fn refused(reason: impl std::fmt::Display) -> anyhow::Error { + crate::stdb::ReducerCallError::refused("mail", &reason.to_string()).into() +} + /// The Module's Delivery Delay rule (`module/src/mail.rs::delivery_delay_secs`): an item sent to /// another Realm Account waits one hour, and everything else arrives at once. pub(crate) fn delivery_delay_secs(has_item: bool, same_account: bool) -> u32 { @@ -124,7 +129,8 @@ impl MailStore for WorldFake { /// Models the module's `apply_mark_read`: the row lookup scoped to `recipient_guid` IS the /// authorization, so a mail that exists but belongs to someone else, or has not arrived yet, /// fails the same way a nonexistent id does. - fn mail_mark_read(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { + fn mail_mark_read(&self, recipient: Actor, mail_id: u64) -> Result<()> { + let recipient_guid = recipient.guid(); self.rec("mail_mark_read"); let mut mails = self.mail.mails.lock().unwrap(); let now = crate::world::mail::now_secs(); @@ -136,13 +142,14 @@ impl MailStore for WorldFake { m.was_read = true; Ok(()) } - None => Err(anyhow!(lyracore_shared::mail::NOT_YOUR_MAIL)), + None => Err(refused(lyracore_shared::mail::NOT_YOUR_MAIL)), } } /// Models the module's `apply_delete`: same merged not-found/not-yours/not-arrived refusal as /// mark-read, and a priced mail is refused. - fn mail_delete(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { + fn mail_delete(&self, recipient: Actor, mail_id: u64) -> Result<()> { + let recipient_guid = recipient.guid(); self.rec("mail_delete"); let mut mails = self.mail.mails.lock().unwrap(); let now = crate::world::mail::now_secs(); @@ -150,10 +157,10 @@ impl MailStore for WorldFake { .iter() .position(|(to, m)| *to == recipient_guid && m.id == mail_id && m.is_delivered(now)) else { - return Err(anyhow!(lyracore_shared::mail::NOT_YOUR_MAIL)); + return Err(refused(lyracore_shared::mail::NOT_YOUR_MAIL)); }; if mails[at].1.cod > 0 { - return Err(anyhow!(lyracore_shared::mail::COD_MAIL_UNDELETABLE)); + return Err(refused(lyracore_shared::mail::COD_MAIL_UNDELETABLE)); } mails.remove(at); Ok(()) @@ -164,7 +171,8 @@ impl MailStore for WorldFake { /// price, which is dropped, because the row is going back to whoever set it. Only a delivered /// Character mail with a sender goes back, and only once. An item going back to another /// Account waits its Delivery Delay. - fn mail_return(&self, recipient_guid: u64, mail_id: u64, same_account: bool) -> Result<()> { + fn mail_return(&self, recipient: Actor, mail_id: u64, same_account: bool) -> Result<()> { + let recipient_guid = recipient.guid(); self.rec("mail_return"); self.saw_same_account("mail_return", same_account); let mut mails = self.mail.mails.lock().unwrap(); @@ -173,13 +181,13 @@ impl MailStore for WorldFake { .iter_mut() .find(|(to, m)| *to == recipient_guid && m.id == mail_id && m.is_delivered(now)) else { - return Err(anyhow!(lyracore_shared::mail::NOT_YOUR_MAIL)); + return Err(refused(lyracore_shared::mail::NOT_YOUR_MAIL)); }; let lyracore_shared::mail::MailSender::Character(sender @ 1..) = m.sender() else { - return Err(anyhow!(lyracore_shared::mail::NO_SENDER_TO_RETURN_TO)); + return Err(refused(lyracore_shared::mail::NO_SENDER_TO_RETURN_TO)); }; if m.check_flags & lyracore_shared::mail::CHECK_MASK_RETURNED != 0 { - return Err(anyhow!(lyracore_shared::mail::ALREADY_RETURNED)); + return Err(refused(lyracore_shared::mail::ALREADY_RETURNED)); } m.sender_guid = recipient_guid; m.was_read = false; @@ -196,7 +204,7 @@ impl MailStore for WorldFake { #[allow(clippy::too_many_arguments)] fn mail_send( &self, - sender_guid: u64, + sender: Actor, recipient_guid: u64, subject: String, body: String, @@ -205,6 +213,7 @@ impl MailStore for WorldFake { item_guid: u64, same_account: bool, ) -> Result<()> { + let sender_guid = sender.guid(); self.rec("mail_send"); self.saw_same_account("mail_send", same_account); let item = self.detach(sender_guid, item_guid)?; @@ -233,7 +242,8 @@ impl MailStore for WorldFake { /// Models the module's `apply_take_item`: the COD debit, the grant, the clear and the seller's /// payout row are ONE transaction, so a full bag or a price the taker cannot pay leaves the /// letter exactly as it was, and a second take finds an empty one. - fn mail_take_item(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { + fn mail_take_item(&self, recipient: Actor, mail_id: u64) -> Result<()> { + let recipient_guid = recipient.guid(); let (item, settlement) = { let mails = self.mail.mails.lock().unwrap(); let now = crate::world::mail::now_secs(); @@ -241,10 +251,10 @@ impl MailStore for WorldFake { .iter() .find(|(to, m)| *to == recipient_guid && m.id == mail_id && m.is_delivered(now)) else { - return Err(anyhow!(lyracore_shared::mail::NOT_YOUR_MAIL)); + return Err(refused(lyracore_shared::mail::NOT_YOUR_MAIL)); }; if m.item_entry == 0 { - return Err(anyhow!(lyracore_shared::mail::NOTHING_TO_TAKE)); + return Err(refused(lyracore_shared::mail::NOTHING_TO_TAKE)); } ( crate::world::mail::AttachedItem { @@ -267,7 +277,7 @@ impl MailStore for WorldFake { self.rec("mail_take_item"); if let Some(s) = &settlement { self.debit(s.payer_guid, s.copper) - .map_err(|_| anyhow!(lyracore_shared::mail::COD_NOT_AFFORDABLE))?; + .map_err(|_| refused(lyracore_shared::mail::COD_NOT_AFFORDABLE))?; } if let Err(e) = self.store_snapshot(recipient_guid, &item) { // The fake cannot roll back, so it undoes the one write it made — the real module gets @@ -307,14 +317,14 @@ impl MailStore for WorldFake { Ok(()) } - fn mail_item_room(&self, _payee_guid: u64) -> Result<()> { + fn mail_item_room(&self, _payee: Actor) -> Result<()> { self.rec("mail_item_room"); if self .mail .bags_full .load(std::sync::atomic::Ordering::Relaxed) { - return Err(anyhow!(lyracore_shared::mail::INVENTORY_FULL)); + return Err(refused(lyracore_shared::mail::INVENTORY_FULL)); } Ok(()) } @@ -324,7 +334,8 @@ impl MailStore for WorldFake { /// delivered, has no body, or is already GRANTED — the same Gates the plan function pins. A /// replay before GRANTED is set is `Ok`, and the text insert is skipped when the id already has /// a row (a returned mail keeps its id, so a second recipient's copy can reuse it). - fn mail_copy_text(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { + fn mail_copy_text(&self, recipient: Actor, mail_id: u64) -> Result<()> { + let recipient_guid = recipient.guid(); self.rec("mail_copy_text"); let mut mails = self.mail.mails.lock().unwrap(); let now = crate::world::mail::now_secs(); @@ -332,13 +343,13 @@ impl MailStore for WorldFake { .iter_mut() .find(|(to, m)| *to == recipient_guid && m.id == mail_id && m.is_delivered(now)) else { - return Err(anyhow!(lyracore_shared::mail::NOT_YOUR_MAIL)); + return Err(refused(lyracore_shared::mail::NOT_YOUR_MAIL)); }; if m.body.is_empty() { - return Err(anyhow!("mail: this mail has no text to copy")); + return Err(refused("mail: this mail has no text to copy")); } if m.check_flags & lyracore_shared::mail::CHECK_FLAG_LETTER_GRANTED != 0 { - return Err(anyhow!("mail: this letter was already made permanent")); + return Err(refused("mail: this letter was already made permanent")); } m.check_flags |= lyracore_shared::mail::CHECK_MASK_COPIED; let text_id = lyracore_shared::mail::item_text_id_for(m.id, &m.body); @@ -356,7 +367,8 @@ impl MailStore for WorldFake { /// — the crash-window guard for a retry between this call landing and `mail_mark_letter_granted` /// recording that it did, not the durable "already got one" record (that is GRANTED, on the mail /// row). - fn mail_grant_letter(&self, payee_guid: u64, item_text_id: u32) -> Result<()> { + fn mail_grant_letter(&self, payee: Actor, item_text_id: u32) -> Result<()> { + let payee_guid = payee.guid(); self.rec("mail_grant_letter"); if self .mail @@ -373,7 +385,7 @@ impl MailStore for WorldFake { .bags_full .load(std::sync::atomic::Ordering::Relaxed) { - return Err(anyhow!(lyracore_shared::mail::INVENTORY_FULL)); + return Err(refused(lyracore_shared::mail::INVENTORY_FULL)); } self.mail .granted_letters @@ -385,14 +397,15 @@ impl MailStore for WorldFake { /// Models `mail_text::apply_mark_letter_granted`: sets GRANTED on the mail row. Once this lands, /// `mail_copy_text` refuses for good, whether or not the granted item still exists. - fn mail_mark_letter_granted(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { + fn mail_mark_letter_granted(&self, recipient: Actor, mail_id: u64) -> Result<()> { + let recipient_guid = recipient.guid(); self.rec("mail_mark_letter_granted"); let mut mails = self.mail.mails.lock().unwrap(); let Some((_, m)) = mails .iter_mut() .find(|(to, m)| *to == recipient_guid && m.id == mail_id) else { - return Err(anyhow!(lyracore_shared::mail::NOT_YOUR_MAIL)); + return Err(refused(lyracore_shared::mail::NOT_YOUR_MAIL)); }; m.check_flags |= lyracore_shared::mail::CHECK_FLAG_LETTER_GRANTED; Ok(()) @@ -433,7 +446,8 @@ impl MailStore for WorldFake { /// Models the module's `apply_take_money`: the credit and the clear are one transaction, so a /// second take finds an empty row. - fn mail_take_money(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { + fn mail_take_money(&self, recipient: Actor, mail_id: u64) -> Result<()> { + let recipient_guid = recipient.guid(); self.rec("mail_take_money"); let money = { let mut mails = self.mail.mails.lock().unwrap(); @@ -442,10 +456,10 @@ impl MailStore for WorldFake { .iter_mut() .find(|(to, m)| *to == recipient_guid && m.id == mail_id && m.is_delivered(now)) else { - return Err(anyhow!(lyracore_shared::mail::NOT_YOUR_MAIL)); + return Err(refused(lyracore_shared::mail::NOT_YOUR_MAIL)); }; if m.money == 0 { - return Err(anyhow!(lyracore_shared::mail::NOTHING_TO_TAKE)); + return Err(refused(lyracore_shared::mail::NOTHING_TO_TAKE)); } std::mem::take(&mut m.money) }; @@ -457,7 +471,7 @@ impl MailStore for WorldFake { fn mail_fence( &self, escrow_id: u64, - sender_guid: u64, + sender: Actor, recipient_guid: u64, subject: String, body: String, @@ -468,6 +482,7 @@ impl MailStore for WorldFake { cod_source_mail_id: u64, same_account: bool, ) -> Result<()> { + let sender_guid = sender.guid(); self.rec("mail_fence"); self.saw_same_account("mail_fence", same_account); self.mail_kill("mail_fence")?; @@ -506,7 +521,7 @@ impl MailStore for WorldFake { fn mail_commit( &self, escrow_id: u64, - sender_guid: u64, + sender: Actor, recipient_guid: u64, subject: String, body: String, @@ -517,6 +532,7 @@ impl MailStore for WorldFake { delivery_delay_secs: u32, reward: Option, ) -> Result<()> { + let sender_guid = sender.guid(); self.rec("mail_commit"); self.mail_kill("mail_commit")?; let mut receipts = self.mail.mail_receipts.lock().unwrap(); @@ -530,9 +546,9 @@ impl MailStore for WorldFake { m.id == cod_source_mail_id && *to == sender_guid && m.cod > 0 && m.is_delivered(now) }); if !owed { - return Err(anyhow!( + return Err(refused(format!( "mail {cod_source_mail_id} owes {sender_guid} no delivered price" - )); + ))); } } receipts.push((escrow_id, recipient_guid)); @@ -594,10 +610,11 @@ impl MailStore for WorldFake { fn mail_take_money_fence( &self, escrow_id: u64, - payee_guid: u64, + payee: Actor, mail_id: u64, expect_money: u32, ) -> Result<()> { + let payee_guid = payee.guid(); self.rec("mail_take_money_fence"); self.mail_kill("mail_take_money_fence")?; if self @@ -617,14 +634,14 @@ impl MailStore for WorldFake { .iter_mut() .find(|(to, m)| *to == payee_guid && m.id == mail_id && m.is_delivered(now)) else { - return Err(anyhow!(lyracore_shared::mail::NOT_YOUR_MAIL)); + return Err(refused(lyracore_shared::mail::NOT_YOUR_MAIL)); }; if m.money == 0 { - return Err(anyhow!(lyracore_shared::mail::NOTHING_TO_TAKE)); + return Err(refused(lyracore_shared::mail::NOTHING_TO_TAKE)); } if m.money != expect_money { - return Err(anyhow!( - "refusing to fence an amount the payout would not match" + return Err(refused( + "refusing to fence an amount the payout would not match", )); } std::mem::take(&mut m.money) @@ -654,10 +671,11 @@ impl MailStore for WorldFake { fn mail_take_item_fence( &self, escrow_id: u64, - payee_guid: u64, + payee: Actor, mail_id: u64, expect_entry: u32, ) -> Result<()> { + let payee_guid = payee.guid(); self.rec("mail_take_item_fence"); self.mail_kill("mail_take_item_fence")?; if self @@ -677,14 +695,14 @@ impl MailStore for WorldFake { .iter_mut() .find(|(to, m)| *to == payee_guid && m.id == mail_id && m.is_delivered(now)) else { - return Err(anyhow!(lyracore_shared::mail::NOT_YOUR_MAIL)); + return Err(refused(lyracore_shared::mail::NOT_YOUR_MAIL)); }; if m.item_entry == 0 { - return Err(anyhow!(lyracore_shared::mail::NOTHING_TO_TAKE)); + return Err(refused(lyracore_shared::mail::NOTHING_TO_TAKE)); } if m.item_entry != expect_entry { - return Err(anyhow!( - "refusing to fence an item the grant would not match" + return Err(refused( + "refusing to fence an item the grant would not match", )); } let item = crate::world::mail::AttachedItem { @@ -729,10 +747,11 @@ impl MailStore for WorldFake { fn mail_item_payout( &self, escrow_id: u64, - payee_guid: u64, + payee: Actor, _mail_id: u64, item: crate::world::mail::AttachedItem, ) -> Result<()> { + let payee_guid = payee.guid(); self.rec("mail_item_payout"); self.mail_kill("mail_item_payout")?; let receipts = self.mail.mail_receipts.lock().unwrap(); @@ -750,13 +769,8 @@ impl MailStore for WorldFake { } /// Models `mail_escrow::apply_payout`: the credit plus a receipt, idempotent on the escrow id. - fn mail_payout( - &self, - escrow_id: u64, - payee_guid: u64, - _mail_id: u64, - amount: u32, - ) -> Result<()> { + fn mail_payout(&self, escrow_id: u64, payee: Actor, _mail_id: u64, amount: u32) -> Result<()> { + let payee_guid = payee.guid(); self.rec("mail_payout"); self.mail_kill("mail_payout")?; let mut receipts = self.mail.mail_receipts.lock().unwrap(); @@ -771,7 +785,7 @@ impl MailStore for WorldFake { .iter() .any(|(g, _)| *g == payee_guid) { - return Err(anyhow!(lyracore_shared::mail::NOT_IN_WORLD)); + return Err(refused(lyracore_shared::mail::NOT_IN_WORLD)); } receipts.push((escrow_id, payee_guid)); drop(receipts); @@ -788,7 +802,9 @@ impl MailStore for WorldFake { *done = true; Ok(()) } - None => Err(anyhow!("mail escrow {escrow_id}: nothing fenced here")), + None => Err(refused(format!( + "mail escrow {escrow_id}: nothing fenced here" + ))), } } @@ -806,9 +822,9 @@ impl MailStore for WorldFake { .map(|(_, done)| *done); match attested { None => Ok(()), // already settled, or this call reached the wrong database - Some(false) => Err(anyhow!( + Some(false) => Err(refused(format!( "mail escrow {escrow_id}: delivery not attested — refusing to destroy the fence" - )), + ))), Some(true) => { self.mail .mail_escrows @@ -856,10 +872,11 @@ impl WorldFake { /// call never lands, and nothing after it in the drive runs either. pub(crate) fn mail_kill(&self, step: &str) -> Result<()> { if self.mail.mail_kill_at.lock().unwrap().as_deref() == Some(step) { - anyhow::bail!( + log::info!( "injected: the gateway died before {step} on {}", self.topology.shard ); + return Err(crate::stdb::ReducerCallError::transport_lost(step).into()); } Ok(()) } @@ -873,7 +890,7 @@ impl WorldFake { *money -= copper; Ok(()) } - _ => Err(anyhow!(lyracore_shared::mail::NOT_ENOUGH_MONEY)), + _ => Err(refused(lyracore_shared::mail::NOT_ENOUGH_MONEY)), } } @@ -899,9 +916,9 @@ impl WorldFake { .iter() .position(|(g, owner, _)| *g == item_guid && *owner == sender_guid) { - None => Err(anyhow!(lyracore_shared::mail::NOT_YOUR_ITEM)), + None => Err(refused(lyracore_shared::mail::NOT_YOUR_ITEM)), Some(i) if items[i].2.soulbound => { - Err(anyhow!(lyracore_shared::mail::ITEM_IS_SOULBOUND)) + Err(refused(lyracore_shared::mail::ITEM_IS_SOULBOUND)) } Some(i) => Ok(items.remove(i).2), } @@ -919,7 +936,7 @@ impl WorldFake { .bags_full .load(std::sync::atomic::Ordering::Relaxed) { - return Err(anyhow!(lyracore_shared::mail::INVENTORY_FULL)); + return Err(refused(lyracore_shared::mail::INVENTORY_FULL)); } let mut items = self.mail.mail_items.lock().unwrap(); let guid = items.iter().map(|(g, _, _)| *g).max().unwrap_or(0) + 1; From 9f2eb66a36224f188e693887aba61fda3edb6b2e Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 18:56:41 +0200 Subject: [PATCH 23/33] refactor(gateway): inline the weather and member stats Coordinator reads Both families are read-only. The trait methods now hold the cache reads that only they called. Neither family has a Durable Request, so neither has a Refusal or Transport Loss answer: a failed read still shows fine weather or skips one Member Stats tick. Co-Authored-By: Claude Sonnet 5.5 --- gateway/src/stdb/store/member_stats.rs | 31 +++++++++----------------- gateway/src/stdb/store/weather.rs | 11 ++------- 2 files changed, 12 insertions(+), 30 deletions(-) diff --git a/gateway/src/stdb/store/member_stats.rs b/gateway/src/stdb/store/member_stats.rs index d24723681..ac31db320 100644 --- a/gateway/src/stdb/store/member_stats.rs +++ b/gateway/src/stdb/store/member_stats.rs @@ -7,21 +7,11 @@ use crate::stdb::bindings::*; use crate::stdb::Coordinator; use crate::world::{MemberPresence, MemberStatsStore}; -impl MemberStatsStore for crate::stdb::Coordinator { - fn group_mates(&self, self_guid: u64) -> Result> { - crate::stdb::Coordinator::group_mates(self, self_guid) - } - - fn member_presence(&self, guid: u64) -> Result { - crate::stdb::Coordinator::member_presence(self, guid) - } -} - -impl Coordinator { +impl MemberStatsStore for Coordinator { /// Every other member of `self_guid`'s group, from the party authority's membership index: /// Realm-core on a sharded Realm, this database otherwise. A list longer than a Raid is a /// damaged cache, not a group. - pub(crate) fn group_mates(&self, self_guid: u64) -> Result> { + fn group_mates(&self, self_guid: u64) -> Result> { let authority = if self.is_sharded() { self.realm_core()? } else { @@ -44,10 +34,9 @@ impl Coordinator { .unwrap_or_default()) } - /// Find `guid` for Member Stats from its Realm Presence — the same live-entity / in-transit / - /// absence-gated-offline decision every other realm-wide read now shares, so there is no - /// second discovery left to disagree with it. - pub(crate) fn member_presence(&self, guid: u64) -> Result { + /// Find `guid` for Member Stats from its Realm Presence, the same live-entity / in-transit / + /// absence-gated-offline decision every other realm-wide read shares. + fn member_presence(&self, guid: u64) -> Result { use crate::world::presence::Whereabouts; Ok( match crate::world::presence::of(self, guid)?.map(|presence| presence.whereabouts) { @@ -55,16 +44,16 @@ impl Coordinator { entity, shard_name, .. }) => { let entity = self.with_member_shard_stats(&shard_name, guid, entity); - crate::world::MemberPresence::Live(Box::new( - crate::codec::MemberStats::from_entity(&entity), - )) + MemberPresence::Live(Box::new(crate::codec::MemberStats::from_entity(&entity))) } - Some(Whereabouts::InTransit) => crate::world::MemberPresence::InTransit, - Some(Whereabouts::Offline) | None => crate::world::MemberPresence::Offline, + Some(Whereabouts::InTransit) => MemberPresence::InTransit, + Some(Whereabouts::Offline) | None => MemberPresence::Offline, }, ) } +} +impl Coordinator { /// Overlay `guid`'s aura slots and live pet onto `entity` — the one piece of Member Stats a /// generic Realm Presence read cannot supply, because it does not know about `ShardId` or /// `WorldView`'s `AuraIndex`. Reads from the exact Shard named `shard_name`, the same one diff --git a/gateway/src/stdb/store/weather.rs b/gateway/src/stdb/store/weather.rs index ab6e4e153..86cac243e 100644 --- a/gateway/src/stdb/store/weather.rs +++ b/gateway/src/stdb/store/weather.rs @@ -2,26 +2,19 @@ use anyhow::Result; -use crate::codec; use crate::codec::ZoneWeatherView; use crate::stdb::bindings::*; use crate::stdb::Coordinator; use crate::world::WeatherStore; -impl WeatherStore for crate::stdb::Coordinator { - fn zone_weather(&self, zone_id: u32) -> Result> { - crate::stdb::Coordinator::zone_weather(self, zone_id) - } -} - -impl Coordinator { +impl WeatherStore for Coordinator { /// One zone's current sky, read from the shard's `game_zone_weather` cache. /// /// `None` means the zone has no row, which the Module defines as fine weather; zone 0 is the /// unresolved zone (`WorldEntity.zone_id`'s "no terrain covers this position") and never has /// one. Callers that only need a packet go through /// [`crate::world::zone_weather_message`], which folds both cases into fine weather. - pub fn zone_weather(&self, zone_id: u32) -> Result, anyhow::Error> { + fn zone_weather(&self, zone_id: u32) -> Result> { if zone_id == 0 { return Ok(None); } From 9a4bce297a8fc49ef19c9eebd210302dfe7740f1 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 18:56:41 +0200 Subject: [PATCH 24/33] refactor(gateway): bank Store takes an Actor and classifies failures Inline the two bank reducer wrappers into the trait, drop the unused account id, and resolve the Character once in the handler. A Refusal answers as before. A Transport Loss now ends the World Session. The buy-slot answer reads the Module's reason, not the reducer error text, so the bracketed code decodes. Co-Authored-By: Claude Sonnet 5.5 --- gateway/src/stdb/store/bank.rs | 40 +++----- gateway/src/world/bank_tests.rs | 61 ++++++++++-- gateway/src/world/handlers/bank.rs | 92 ++++++++++--------- .../src/world/test_support/world_fake/bank.rs | 4 +- 4 files changed, 115 insertions(+), 82 deletions(-) diff --git a/gateway/src/stdb/store/bank.rs b/gateway/src/stdb/store/bank.rs index bc7b6ed36..f65c20083 100644 --- a/gateway/src/stdb/store/bank.rs +++ b/gateway/src/stdb/store/bank.rs @@ -1,6 +1,6 @@ //! `Coordinator`'s [`BankStore`] adapter. -use anyhow::{anyhow, Result}; +use anyhow::Result; use crate::stdb::bindings::*; use crate::stdb::connection::call_reducer; @@ -8,40 +8,24 @@ use crate::stdb::Coordinator; use crate::world::{Actor, BankStore}; impl BankStore for Coordinator { - fn auto_bank_item(&self, account_id: u64, self_guid: u64, slot: u8) -> Result<()> { - self.auto_bank_item(account_id, self_guid, slot) - } - - fn buy_bank_slot(&self, account_id: u64, self_guid: u64, banker_guid: u64) -> Result<()> { - self.buy_bank_slot(account_id, self_guid, banker_guid) + /// Auto-bank/auto-store-bank the item in `slot` (`CMSG_AUTOBANK_ITEM`/`CMSG_AUTOSTORE_BANK_ITEM`) + /// over the coordinator connection. The module infers deposit vs. withdraw from `slot` and + /// resolves the receiving free slot itself. + fn auto_bank_item(&self, actor: Actor, slot: u8) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "gw_auto_bank_item", + gw_auto_bank_item_then(self.session_actor(actor), slot) + ) } -} -impl Coordinator { /// Buy the next bank bag slot from `banker_guid` (`CMSG_BUY_BANK_SLOT`) over the coordinator /// connection. A refusal carries the module's `[N]` `SMSG_BUY_BANK_SLOT_RESULT` code tag. - pub fn buy_bank_slot(&self, _account_id: u64, actor_guid: u64, banker_guid: u64) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("buy_bank_slot: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); + fn buy_bank_slot(&self, actor: Actor, banker_guid: u64) -> Result<()> { call_reducer!( - coord.conn.reducers, + self.0.call_pipe().conn.reducers, "gw_buy_bank_slot", gw_buy_bank_slot_then(self.session_actor(actor), banker_guid) ) } - - /// Auto-bank/auto-store-bank the item in `slot` (`CMSG_AUTOBANK_ITEM`/`CMSG_AUTOSTORE_BANK_ITEM`) - /// over the coordinator connection. The module infers deposit vs. withdraw from `slot` and - /// resolves the receiving free slot itself. - pub fn auto_bank_item(&self, _account_id: u64, actor_guid: u64, slot: u8) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("auto_bank_item: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_auto_bank_item", - gw_auto_bank_item_then(self.session_actor(actor), slot) - ) - } } diff --git a/gateway/src/world/bank_tests.rs b/gateway/src/world/bank_tests.rs index a1fec55fb..7da868612 100644 --- a/gateway/src/world/bank_tests.rs +++ b/gateway/src/world/bank_tests.rs @@ -3,6 +3,7 @@ use super::handlers::{handle_bank, BankStore, NpcStore}; use super::*; +use crate::stdb::ReducerCallError; use std::collections::BTreeSet; use std::sync::Mutex; @@ -13,10 +14,12 @@ struct BankFake { npc_refuses: bool, /// Every banker the Module accepts a bag-slot purchase from. bankers: BTreeSet, - /// Refuses `auto_bank_item` with this text, like a full bank. + /// Refuses `auto_bank_item` with this reason, like a full bank. move_error: Option, - /// Refuses `buy_bank_slot` with this text. + /// Refuses `buy_bank_slot` with this reason. purchase_error: Option, + /// Loses the transport on every Durable Request. + transport_lost: bool, carried: Mutex>, banked: Mutex>, bought_slots: Mutex, @@ -47,9 +50,12 @@ impl BankFake { npc_store_refusing_by!(BankFake, npc_refuses); impl BankStore for BankFake { - fn auto_bank_item(&self, _account_id: u64, _self_guid: u64, slot: u8) -> Result<()> { - if let Some(error) = &self.move_error { - return Err(anyhow!("{error}")); + fn auto_bank_item(&self, _actor: Actor, slot: u8) -> Result<()> { + if self.transport_lost { + return Err(ReducerCallError::transport_lost("gw_auto_bank_item").into()); + } + if let Some(reason) = &self.move_error { + return Err(ReducerCallError::refused("gw_auto_bank_item", reason).into()); } let slot = u64::from(slot); let (mut carried, mut banked) = (self.carried.lock().unwrap(), self.banked.lock().unwrap()); @@ -61,12 +67,19 @@ impl BankStore for BankFake { Ok(()) } - fn buy_bank_slot(&self, _account_id: u64, _self_guid: u64, banker_guid: u64) -> Result<()> { - if let Some(error) = &self.purchase_error { - return Err(anyhow!("{error}")); + fn buy_bank_slot(&self, _actor: Actor, banker_guid: u64) -> Result<()> { + if self.transport_lost { + return Err(ReducerCallError::transport_lost("gw_buy_bank_slot").into()); + } + if let Some(reason) = &self.purchase_error { + return Err(ReducerCallError::refused("gw_buy_bank_slot", reason).into()); } if !self.bankers.contains(&banker_guid) { - return Err(anyhow!("[2] target is not a banker")); + return Err(ReducerCallError::refused( + "gw_buy_bank_slot", + "[2] target is not a banker", + ) + .into()); } *self.bought_slots.lock().unwrap() += 1; Ok(()) @@ -83,6 +96,16 @@ fn run(store: &BankFake, msg: impl Into) -> Vec) -> 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()) +} + fn kinds(sent: &[ServerOpcodeMessage]) -> String { sent.iter() .map(ToString::to_string) @@ -264,3 +287,23 @@ fn buy_bank_slot_failure_maps_the_bracketed_code_to_the_matching_result() { assert_eq!(*store.bought_slots.lock().unwrap(), 0); } } + +/// A Transport Loss leaves the durable outcome unknown, so it ends the World Session instead of +/// posing as a failed bank move or a refused purchase. +#[test] +fn a_transport_loss_ends_the_world_session() { + assert!(run_transport_lost(CMSG_AUTOBANK_ITEM { + bag_index: 255, + slot_index: 23, + }) + .is_err()); + assert!(run_transport_lost(CMSG_AUTOSTORE_BANK_ITEM { + bag_index: 255, + slot_index: 39, + }) + .is_err()); + assert!(run_transport_lost(CMSG_BUY_BANK_SLOT { + guid: Guid::new(88), + }) + .is_err()); +} diff --git a/gateway/src/world/handlers/bank.rs b/gateway/src/world/handlers/bank.rs index bfa810cc8..66e53e12a 100644 --- a/gateway/src/world/handlers/bank.rs +++ b/gateway/src/world/handlers/bank.rs @@ -3,6 +3,7 @@ use super::super::*; use super::send_show_bank; +use crate::stdb::{classify, DurableFailure}; /// Bank Durable Requests. pub(crate) trait BankStore: Send + Sync { @@ -10,11 +11,40 @@ pub(crate) trait BankStore: Send + Sync { /// — right-click to bank, right-click to withdraw). The module infers the direction from `slot` /// and resolves the receiving free slot itself; a full destination (bank or carry space) is a /// per-action `Err`. - fn auto_bank_item(&self, account_id: u64, self_guid: u64, slot: u8) -> Result<()>; + fn auto_bank_item(&self, actor: Actor, slot: u8) -> Result<()>; /// Buy the next bank bag slot from `banker_guid` (`CMSG_BUY_BANK_SLOT`). A refusal `Err` leads /// with its `SMSG_BUY_BANK_SLOT_RESULT` code in brackets (the trainer `[N]` precedent). - fn buy_bank_slot(&self, account_id: u64, self_guid: u64, banker_guid: u64) -> Result<()>; + fn buy_bank_slot(&self, actor: Actor, banker_guid: u64) -> Result<()>; +} + +/// 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, + store: &St, + conn: &WorldConn, + slot_index: u8, + direction: &str, +) -> Result<()> { + if let Some(actor) = social::self_guid(conn).and_then(Actor::new) { + let Err(error) = store.auto_bank_item(actor, slot_index) else { + return Ok(()); + }; + if matches!(classify(&error), DurableFailure::TransportLoss) { + return Err(error); + } + log::debug!( + "world: auto_bank_item ({direction}) rejected (account {}): {error}", + conn.account_id + ); + } + send( + tx, + Outbound::One(ServerOpcodeMessage::SMSG_INVENTORY_CHANGE_FAILURE( + Box::new(codec::build_inventory_change_failure()), + )), + ) } /// Bank family: `CMSG_BANKER_ACTIVATE` opens the bank window (a standing-refusing banker gets no @@ -46,22 +76,7 @@ pub(crate) fn handle_bank( // 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 { - if let Err(e) = store.auto_bank_item( - conn.account_id, - social::self_guid(conn).unwrap_or(0), - c.slot_index, - ) { - log::debug!( - "world: auto_bank_item (deposit) rejected (account {}): {e}", - conn.account_id - ); - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_INVENTORY_CHANGE_FAILURE( - Box::new(codec::build_inventory_change_failure()), - )), - )?; - } + auto_bank(tx, store, conn, c.slot_index, "deposit")?; } else { log::debug!( "world: autobank from sub-bag {} unsupported (account {})", @@ -74,44 +89,35 @@ pub(crate) fn handle_bank( // `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(); - let outcome = store.buy_bank_slot( - conn.account_id, - social::self_guid(conn).unwrap_or(0), - banker_guid, - ); - let err_text = outcome.as_ref().err().map(ToString::to_string); - if let Some(e) = &err_text { + // 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 {}): {e}", + "world: buy_bank_slot rejected (account {}): {reason}", conn.account_id ); } send( tx, Outbound::One(ServerOpcodeMessage::SMSG_BUY_BANK_SLOT_RESULT( - codec::build_buy_bank_slot_reply(err_text.as_deref().map_or(Ok(()), Err)), + 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 { - if let Err(e) = store.auto_bank_item( - conn.account_id, - social::self_guid(conn).unwrap_or(0), - c.slot_index, - ) { - log::debug!( - "world: auto_bank_item (withdraw) rejected (account {}): {e}", - conn.account_id - ); - send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_INVENTORY_CHANGE_FAILURE( - Box::new(codec::build_inventory_change_failure()), - )), - )?; - } + auto_bank(tx, store, conn, c.slot_index, "withdraw")?; } else { log::debug!( "world: autostore-bank from sub-bag {} unsupported (account {})", diff --git a/gateway/src/world/test_support/world_fake/bank.rs b/gateway/src/world/test_support/world_fake/bank.rs index a184df7d8..49044b956 100644 --- a/gateway/src/world/test_support/world_fake/bank.rs +++ b/gateway/src/world/test_support/world_fake/bank.rs @@ -1,11 +1,11 @@ use super::super::*; impl BankStore for WorldFake { - fn auto_bank_item(&self, _account_id: u64, _self_guid: u64, _slot: u8) -> Result<()> { + fn auto_bank_item(&self, _actor: Actor, _slot: u8) -> Result<()> { Ok(()) } - fn buy_bank_slot(&self, _account_id: u64, _self_guid: u64, _banker_guid: u64) -> Result<()> { + fn buy_bank_slot(&self, _actor: Actor, _banker_guid: u64) -> Result<()> { Ok(()) } } From 6c87bad54f8b1266b8fd02edce1f8867e2a044ab Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 18:56:41 +0200 Subject: [PATCH 25/33] refactor(gateway): meeting stone Store takes an Actor and classifies failures Inline the four Meeting Stone wrappers into the trait and replace the text match on the transport message with classify. Add a test for the adapter decoder with typed errors. Co-Authored-By: Claude Sonnet 5.5 --- gateway/src/stdb/store/meeting_stone.rs | 135 +++++++++--------- ...bscriptions_meeting_stone_durable_tests.rs | 4 +- gateway/src/world/handlers/meeting_stone.rs | 91 ++++++------ .../test_support/world_fake/meeting_stone.rs | 4 +- 4 files changed, 114 insertions(+), 120 deletions(-) diff --git a/gateway/src/stdb/store/meeting_stone.rs b/gateway/src/stdb/store/meeting_stone.rs index 1464a0d22..cc3392219 100644 --- a/gateway/src/stdb/store/meeting_stone.rs +++ b/gateway/src/stdb/store/meeting_stone.rs @@ -7,15 +7,34 @@ use lyracore_shared::meeting_stone::MeetingStoneRefusal; use crate::stdb::bindings::*; use crate::stdb::connection::{call_reducer, reducer_refusal_reason}; use crate::stdb::Coordinator; -use crate::world::{presence, MeetingStoneActionStore, MeetingStoneOutcome}; +use crate::world::{presence, Actor, MeetingStoneActionStore, MeetingStoneOutcome}; -impl MeetingStoneActionStore for crate::stdb::Coordinator { - fn admit_meeting_stone(&self, actor_guid: u64, go_guid: u64) -> Result { - crate::stdb::Coordinator::admit_meeting_stone(self, actor_guid, go_guid) +impl MeetingStoneActionStore for Coordinator { + /// `gw_admit_meeting_stone` on this Home Shard: may the actor use the stone? Writes nothing. + fn admit_meeting_stone(&self, actor: Actor, go_guid: u64) -> Result { + meeting_stone_outcome(call_reducer!( + self.0.call_pipe().conn.reducers, + "gw_admit_meeting_stone", + gw_admit_meeting_stone_then(self.session_actor(actor), go_guid) + )) } + /// The dungeon area of the Meeting Stone spawned as `go_guid` on this database, if any. fn meeting_stone_area(&self, go_guid: u64) -> Result> { - crate::stdb::Coordinator::meeting_stone_area(self, go_guid) + let guard = self.0.coord(); + let db = &guard.conn.db; + Ok(db + .game_gameobject() + .guid() + .find(&go_guid) + .and_then(|go| { + db.game_gameobject_template() + .entry() + .find(&go.template_entry) + }) + .filter(|template| template.type_id == go_type::MEETINGSTONE) + .and_then(|template| db.game_meeting_stone().entry().find(&template.entry)) + .map(|stone| stone.area_id)) } fn party_members(&self, actor_guid: u64) -> Result>> { @@ -38,72 +57,11 @@ impl MeetingStoneActionStore for crate::stdb::Coordinator { ) } - fn meeting_stone_op( - &self, - actor_guid: u64, - op: u8, - area_id: u32, - seekers: Vec, - ) -> Result { - crate::stdb::Coordinator::meeting_stone_op(self, actor_guid, op, area_id, seekers) - } - - fn queued_area(&self, character_guid: u64) -> Result> { - crate::stdb::Coordinator::queued_area(self, character_guid) - } -} - -impl Coordinator { - /// The dungeon area of the Meeting Stone spawned as `go_guid` on this database, if any. - pub(crate) fn meeting_stone_area(&self, go_guid: u64) -> Result> { - let guard = self.0.coord(); - let db = &guard.conn.db; - Ok(db - .game_gameobject() - .guid() - .find(&go_guid) - .and_then(|go| { - db.game_gameobject_template() - .entry() - .find(&go.template_entry) - }) - .filter(|template| template.type_id == go_type::MEETINGSTONE) - .and_then(|template| db.game_meeting_stone().entry().find(&template.entry)) - .map(|stone| stone.area_id)) - } - - /// The area `character_guid` is queued for, read from the party authority. - pub(crate) fn queued_area(&self, character_guid: u64) -> Result> { - let realm = self.realm_core()?; - let guard = realm.0.coord(); - Ok(guard - .conn - .db - .game_meeting_stone_seeker() - .character_guid() - .find(&character_guid) - .map(|seeker| seeker.area_id)) - } - - /// `gw_admit_meeting_stone` on this Home Shard: may the actor use the stone? Writes nothing. - pub fn admit_meeting_stone( - &self, - actor_guid: u64, - go_guid: u64, - ) -> Result { - let coord = self.0.call_pipe(); - meeting_stone_outcome(call_reducer!( - coord.conn.reducers, - "gw_admit_meeting_stone", - gw_admit_meeting_stone_then(self.actor_or_owner(actor_guid), go_guid) - )) - } - /// `realm_meeting_stone_op`: run one Meeting Stone Queue op on Realm-core, or on the one /// database of an unsharded Realm. The Module applies every queue rule. - pub fn meeting_stone_op( + fn meeting_stone_op( &self, - actor_guid: u64, + actor: Actor, op: u8, area_id: u32, seekers: Vec, @@ -120,9 +78,22 @@ impl Coordinator { meeting_stone_outcome(call_reducer!( realm.0.call_pipe().conn.reducers, "realm_meeting_stone_op", - realm_meeting_stone_op_then(op, realm.actor_or_owner(actor_guid), area_id, seekers) + realm_meeting_stone_op_then(op, realm.session_actor(actor), area_id, seekers) )) } + + /// The area `character_guid` is queued for, read from the party authority. + fn queued_area(&self, character_guid: u64) -> Result> { + let realm = self.realm_core()?; + let guard = realm.0.coord(); + Ok(guard + .conn + .db + .game_meeting_stone_seeker() + .character_guid() + .find(&character_guid) + .map(|seeker| seeker.area_id)) + } } fn meeting_stone_outcome(result: Result<()>) -> Result { @@ -135,3 +106,29 @@ fn meeting_stone_outcome(result: Result<()>) -> Result { }, } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::stdb::ReducerCallError; + + #[test] + fn a_tagged_rejection_is_an_outcome_and_a_transport_loss_stays_an_error() { + let refused = ReducerCallError::refused( + "gw_admit_meeting_stone", + MeetingStoneRefusal::PartyFull.as_tag(), + ); + assert_eq!( + meeting_stone_outcome(Err(refused.into())).unwrap(), + MeetingStoneOutcome::Refused(MeetingStoneRefusal::PartyFull) + ); + + let lost = ReducerCallError::transport_lost("gw_admit_meeting_stone"); + assert!(meeting_stone_outcome(Err(lost.into())).is_err()); + + assert_eq!( + meeting_stone_outcome(Ok(())).unwrap(), + MeetingStoneOutcome::Ran + ); + } +} diff --git a/gateway/src/stdb/subscriptions_meeting_stone_durable_tests.rs b/gateway/src/stdb/subscriptions_meeting_stone_durable_tests.rs index 2862d6f26..fdd64153f 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, MeetingStonePlayer, Outbound, - WorldSessionToken, + dispatch_meeting_stone_action, MeetingStoneActionOutcome, MeetingStoneActionStore, + MeetingStonePlayer, Outbound, WorldSessionToken, }; use lyracore_shared::meeting_stone::queue_status::{JOINED_QUEUE, NONE}; use std::sync::mpsc::Receiver; diff --git a/gateway/src/world/handlers/meeting_stone.rs b/gateway/src/world/handlers/meeting_stone.rs index c99fcc081..ee96e1574 100644 --- a/gateway/src/world/handlers/meeting_stone.rs +++ b/gateway/src/world/handlers/meeting_stone.rs @@ -5,7 +5,7 @@ //! the group event relay; only a refused party JOIN is answered here. use super::super::*; -use super::chat::is_transport_failure; +use crate::stdb::{classify, DurableFailure}; use lyracore_shared::group::GROUP_MAX_MEMBERS; use lyracore_shared::meeting_stone::{ join_failure_for, queue_status, realm_op, MeetingStoneRefusal, @@ -28,7 +28,7 @@ pub(crate) enum MeetingStoneOutcome { pub(crate) trait MeetingStoneActionStore { /// Durable Request on the actor's Home Shard. Writes nothing. - fn admit_meeting_stone(&self, actor_guid: u64, go_guid: u64) -> Result; + fn admit_meeting_stone(&self, actor: Actor, go_guid: u64) -> Result; /// Durable Read of the Home Shard cache. fn meeting_stone_area(&self, go_guid: u64) -> Result>; /// Durable Read of the party authority, in join order. `None` outside a party. @@ -38,7 +38,7 @@ pub(crate) trait MeetingStoneActionStore { /// Durable Request on the party authority. fn meeting_stone_op( &self, - actor_guid: u64, + actor: Actor, op: u8, area_id: u32, seekers: Vec, @@ -73,25 +73,25 @@ pub(crate) fn dispatch_meeting_stone_action Action::LookingForGroup, other => return Ok(MeetingStoneActionOutcome::PassThrough(other)), }; - let Some(actor_guid) = player.self_guid else { + 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_guid, go_guid)?, + Action::Join(go_guid) => join(store, player.account_id, actor, go_guid)?, Action::Leave => { run_op( store, player.account_id, - actor_guid, + actor, realm_op::LEAVE, 0, Vec::new(), )?; Vec::new() } - Action::Info => info(store, player.account_id, actor_guid)?, + Action::Info => info(store, player.account_id, actor)?, Action::LookingForGroup => vec![Outbound::One(ServerOpcodeMessage::MSG_LOOKING_FOR_GROUP( codec::build_looking_for_group(), ))], @@ -106,16 +106,18 @@ enum Action { LookingForGroup, } -/// Only a transport loss ends the World Session. Anything else is logged and answered with nothing, +/// Only a Transport Loss ends the World Session. A Refusal is logged and answered with nothing, /// because the client has no message for it. fn recoverable(what: &str, account_id: u64, result: Result) -> Result> { match result { Ok(value) => Ok(Some(value)), - Err(error) if is_transport_failure(&error) => Err(error), - Err(error) => { - log::debug!("world: meeting stone {what} dropped (account {account_id}): {error:#}"); - Ok(None) - } + Err(error) => match classify(&error) { + DurableFailure::Refusal { reason } => { + log::debug!("world: meeting stone {what} refused (account {account_id}): {reason}"); + Ok(None) + } + DurableFailure::TransportLoss => Err(error), + }, } } @@ -124,13 +126,13 @@ fn recoverable(what: &str, account_id: u64, result: Result) -> Result( store: &St, account_id: u64, - actor_guid: u64, + actor: Actor, go_guid: u64, ) -> Result> { match recoverable( "admission", account_id, - store.admit_meeting_stone(actor_guid, go_guid), + store.admit_meeting_stone(actor, go_guid), )? { Some(MeetingStoneOutcome::Ran) => {} Some(MeetingStoneOutcome::Refused(refusal)) => { @@ -158,19 +160,12 @@ fn join( let Some(seekers) = recoverable( "seeker facts read", account_id, - facts_for_join(store, actor_guid), + facts_for_join(store, actor), )? else { return Ok(Vec::new()); }; - let outcome = run_op( - store, - account_id, - actor_guid, - realm_op::JOIN, - area_id, - seekers, - )?; + let outcome = run_op(store, account_id, actor, realm_op::JOIN, area_id, seekers)?; let failure = match outcome { Some(MeetingStoneOutcome::Refused(refusal)) => join_failure_for(refusal), _ => None, @@ -186,11 +181,11 @@ fn join( /// a Raid, so a Raid's actor conveys only its own. fn facts_for_join( store: &St, - actor_guid: u64, + actor: Actor, ) -> Result> { - let guids = match store.party_members(actor_guid)? { + let guids = match store.party_members(actor.guid())? { Some(members) if members.len() <= GROUP_MAX_MEMBERS => members, - _ => vec![actor_guid], + _ => vec![actor.guid()], }; let mut facts = Vec::with_capacity(guids.len()); for guid in guids { @@ -203,7 +198,7 @@ fn facts_for_join( fn run_op( store: &St, account_id: u64, - actor_guid: u64, + actor: Actor, op: u8, area_id: u32, seekers: Vec, @@ -211,7 +206,7 @@ fn run_op( let outcome = recoverable( "op", account_id, - store.meeting_stone_op(actor_guid, op, area_id, seekers), + store.meeting_stone_op(actor, op, area_id, seekers), )?; if let Some(MeetingStoneOutcome::Refused(refusal)) = outcome { log::debug!( @@ -227,9 +222,9 @@ fn run_op( fn info( store: &St, account_id: u64, - actor_guid: u64, + actor: Actor, ) -> Result> { - let Some(queued) = recoverable("status read", account_id, store.queued_area(actor_guid))? + let Some(queued) = recoverable("status read", account_id, store.queued_area(actor.guid()))? else { return Ok(Vec::new()); }; @@ -242,7 +237,10 @@ fn info( ServerOpcodeMessage::SMSG_MEETINGSTONE_SETQUEUE(packet), )], None => { - log::warn!("world: meeting stone status for {actor_guid} names unknown area {area_id}"); + log::warn!( + "world: meeting stone status for {} names unknown area {area_id}", + actor.guid() + ); Vec::new() } }) @@ -251,6 +249,7 @@ fn info( #[cfg(test)] mod tests { use super::*; + use crate::stdb::ReducerCallError; use std::collections::HashMap; use std::sync::Mutex; use wow_world_messages::vanilla::{CMSG_MEETINGSTONE_JOIN, CMSG_PING}; @@ -278,7 +277,7 @@ mod tests { party: Option>, /// Facts by guid, as the World Shards hold them. facts: HashMap, - op_outcome: Option>, + op_outcome: Option anyhow::Error>>, queued: Option, calls: Mutex>, ops: Mutex>, @@ -316,11 +315,7 @@ mod tests { } impl MeetingStoneActionStore for FakeMeetingStones { - fn admit_meeting_stone( - &self, - _actor_guid: u64, - _go_guid: u64, - ) -> Result { + fn admit_meeting_stone(&self, _actor: Actor, _go_guid: u64) -> Result { self.record("home", "admit"); Ok(self.admission.expect("admission configured")) } @@ -341,7 +336,7 @@ mod tests { fn meeting_stone_op( &self, - actor_guid: u64, + actor: Actor, op: u8, area_id: u32, seekers: Vec, @@ -350,11 +345,11 @@ mod tests { self.ops .lock() .unwrap() - .push((actor_guid, op, area_id, seekers)); + .push((actor.guid(), op, area_id, seekers)); match &self.op_outcome { None => Ok(MeetingStoneOutcome::Ran), Some(Ok(outcome)) => Ok(*outcome), - Some(Err(error)) => Err(anyhow!(error.clone())), + Some(Err(error)) => Err(error()), } } @@ -585,19 +580,21 @@ mod tests { assert!(store.calls().is_empty()); } - /// A Refusal or an untagged error keeps the World Session; a lost transport ends it. + /// A Refusal the Module did not tag keeps the World Session; a Transport Loss ends it. #[test] - fn only_a_lost_transport_ends_the_session() { + fn only_a_transport_loss_ends_the_session() { let untagged = FakeMeetingStones { - op_outcome: Some(Err("realm_meeting_stone_op reducer failed: boom".into())), + op_outcome: Some(Err(|| { + ReducerCallError::refused("realm_meeting_stone_op", "boom").into() + })), ..FakeMeetingStones::at_stone() }; assert!(handled(&untagged, join_stone()).is_empty()); let lost = FakeMeetingStones { - op_outcome: Some(Err( - "realm_meeting_stone_op reducer transport disconnected: channel closed".into(), - )), + op_outcome: Some(Err(|| { + ReducerCallError::transport_lost("realm_meeting_stone_op").into() + })), ..FakeMeetingStones::at_stone() }; assert!(dispatch_meeting_stone_action(&lost, in_world(), join_stone()).is_err()); diff --git a/gateway/src/world/test_support/world_fake/meeting_stone.rs b/gateway/src/world/test_support/world_fake/meeting_stone.rs index b5c53eee1..916b8d07f 100644 --- a/gateway/src/world/test_support/world_fake/meeting_stone.rs +++ b/gateway/src/world/test_support/world_fake/meeting_stone.rs @@ -3,7 +3,7 @@ use super::super::*; /// The Meeting Stone family is tested against its own Fake in `handlers/meeting_stone.rs`. Here no /// stone exists and nobody is queued. impl MeetingStoneActionStore for WorldFake { - fn admit_meeting_stone(&self, _actor_guid: u64, _go_guid: u64) -> Result { + fn admit_meeting_stone(&self, _actor: Actor, _go_guid: u64) -> Result { Ok(MeetingStoneOutcome::Refused( lyracore_shared::meeting_stone::MeetingStoneRefusal::NotAMeetingStone, )) @@ -23,7 +23,7 @@ impl MeetingStoneActionStore for WorldFake { fn meeting_stone_op( &self, - _actor_guid: u64, + _actor: Actor, _op: u8, _area_id: u32, _seekers: Vec, From a47ddb81b73c9bb932f4436ff3c966731a4b1937 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 18:56:41 +0200 Subject: [PATCH 26/33] refactor(gateway): taxi Store takes an Actor and classifies failures Inline the taxi wrappers into the trait and remove the zero-guid and owner fallbacks. A Refusal on a query or an activation answers with no packet or the server-error reply. A Transport Loss, or any failure to arm a committed flight, ends the World Session. Co-Authored-By: Claude Sonnet 5.5 --- gateway/src/stdb/store/taxi.rs | 173 +++++-------- gateway/src/world/handlers/taxi.rs | 241 +++++++++++------- gateway/src/world/taxi_tests.rs | 3 +- .../src/world/test_support/world_fake/taxi.rs | 18 +- 4 files changed, 219 insertions(+), 216 deletions(-) diff --git a/gateway/src/stdb/store/taxi.rs b/gateway/src/stdb/store/taxi.rs index 44bfbd399..c29b8dd0e 100644 --- a/gateway/src/stdb/store/taxi.rs +++ b/gateway/src/stdb/store/taxi.rs @@ -12,94 +12,13 @@ use crate::stdb::connection::call_reducer; use crate::stdb::Coordinator; use crate::world::{Actor, TaxiActionStore}; -impl TaxiActionStore for crate::stdb::Coordinator { +impl TaxiActionStore for Coordinator { + /// Cohesive status request: module resolution + policy runs before this returns one known bit. fn taxi_node_status( &self, - character_guid: u64, + actor: Actor, npc_guid: u64, ) -> Result> { - crate::stdb::Coordinator::taxi_node_status(self, character_guid, npc_guid) - } - - fn open_taxi(&self, character_guid: u64, npc_guid: u64) -> Result> { - crate::stdb::Coordinator::open_taxi(self, character_guid, npc_guid) - } - - fn activate_taxi( - &self, - character_guid: u64, - npc_guid: u64, - source_client_node_id: u32, - destination_client_node_id: u32, - ) -> Result { - crate::stdb::Coordinator::activate_taxi( - self, - character_guid, - npc_guid, - source_client_node_id, - destination_client_node_id, - ) - } - - fn arm_taxi_flight(&self, character_guid: u64) -> Result<()> { - crate::stdb::Coordinator::arm_taxi_flight(self, character_guid) - } -} - -impl Coordinator { - fn await_taxi_reply( - &self, - character_guid: u64, - npc_guid: u64, - request_id: u64, - operation: u8, - ) -> Result> { - // Reducer completion and subscription propagation travel on the same SDK connection but - // are separate events. Poll by the caller-chosen id so an older cached reply can never be - // mistaken for this operation's result. - for _ in 0..100 { - let reply = self - .0 - .coord() - .conn - .db - .game_taxi_service_reply() - .request_id() - .find(&request_id); - if let Some(reply) = - reply.filter(|reply| taxi_reply_matches(reply, character_guid, npc_guid, operation)) - { - // Copy the cache row before acknowledging it. The ack deletes only this request's - // module row; a failed ack is infrastructure loss and remains session-fatal. - call_reducer!( - self.0.call_pipe().conn.reducers, - "gw_ack_taxi_reply", - gw_ack_taxi_reply_then(self.actor_or_owner(character_guid), request_id) - )?; - if !reply.accepted { - log::debug!( - "taxi operation {operation} refused for character {character_guid}: {}", - reply.refusal - ); - } - return Ok(Some(reply)); - } - std::thread::sleep(Duration::from_millis(10)); - } - Err(anyhow!( - "taxi operation {operation} committed but reply {request_id} was not visible within 1s" - )) - } - - /// Cohesive status request: module resolution + policy runs before this returns one known bit. - pub fn taxi_node_status( - &self, - character_guid: u64, - npc_guid: u64, - ) -> Result> { - let Some(actor) = Actor::new(character_guid) else { - return Ok(None); - }; let request_id = next_taxi_request_id(); call_reducer!( self.0.call_pipe().conn.reducers, @@ -108,26 +27,19 @@ impl Coordinator { )?; Ok(self .await_taxi_reply( - character_guid, + actor, npc_guid, request_id, lyracore_shared::constants::taxi_protocol::REPLY_STATUS, )? .filter(|reply| reply.accepted) - .map(|reply| crate::codec::TaxiNodeStatusView { + .map(|reply| codec::TaxiNodeStatusView { npc_guid: reply.npc_guid, known: reply.known, })) } - pub fn open_taxi( - &self, - character_guid: u64, - npc_guid: u64, - ) -> Result> { - let Some(actor) = Actor::new(character_guid) else { - return Ok(None); - }; + fn open_taxi(&self, actor: Actor, npc_guid: u64) -> Result> { let request_id = next_taxi_request_id(); call_reducer!( self.0.call_pipe().conn.reducers, @@ -136,13 +48,13 @@ impl Coordinator { )?; Ok(self .await_taxi_reply( - character_guid, + actor, npc_guid, request_id, lyracore_shared::constants::taxi_protocol::REPLY_OPEN, )? .filter(|reply| reply.accepted) - .map(|reply| crate::codec::TaxiMapView { + .map(|reply| codec::TaxiMapView { npc_guid: reply.npc_guid, source_client_node_id: reply.source_client_node_id, available_client_node_ids: reply.available_client_node_ids, @@ -150,20 +62,14 @@ impl Coordinator { } /// Cohesive direct-flight activation. The module commits every gameplay outcome as a stable - /// result code; only reducer transport/timeout failures escape as `Err` and end the session. - pub fn activate_taxi( + /// result code; only a failed Durable Request escapes as `Err`. + fn activate_taxi( &self, - character_guid: u64, + actor: Actor, npc_guid: u64, source_client_node_id: u32, destination_client_node_id: u32, - ) -> Result { - let Some(actor) = Actor::new(character_guid) else { - return Ok(crate::codec::TaxiActivationResult { - result_code: - lyracore_shared::constants::taxi_protocol::ACTIVATE_UNSPECIFIED_SERVER_ERROR, - }); - }; + ) -> Result { let request_id = next_taxi_request_id(); call_reducer!( self.0.call_pipe().conn.reducers, @@ -178,26 +84,73 @@ impl Coordinator { )?; let reply = self .await_taxi_reply( - character_guid, + actor, npc_guid, request_id, lyracore_shared::constants::taxi_protocol::REPLY_ACTIVATE, )? .ok_or_else(|| anyhow!("taxi activation reply {request_id} disappeared"))?; - Ok(crate::codec::TaxiActivationResult { + Ok(codec::TaxiActivationResult { result_code: reply.result_code, }) } - pub fn arm_taxi_flight(&self, character_guid: u64) -> Result<()> { + fn arm_taxi_flight(&self, actor: Actor) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, "gw_arm_taxi_flight", - gw_arm_taxi_flight_then(self.actor_or_owner(character_guid)) + gw_arm_taxi_flight_then(self.session_actor(actor)) ) } } +impl Coordinator { + fn await_taxi_reply( + &self, + actor: Actor, + npc_guid: u64, + request_id: u64, + operation: u8, + ) -> Result> { + let character_guid = actor.guid(); + // Reducer completion and subscription propagation travel on the same SDK connection but + // are separate events. Poll by the caller-chosen id so an older cached reply can never be + // mistaken for this operation's result. + for _ in 0..100 { + let reply = self + .0 + .coord() + .conn + .db + .game_taxi_service_reply() + .request_id() + .find(&request_id); + if let Some(reply) = + reply.filter(|reply| taxi_reply_matches(reply, character_guid, npc_guid, operation)) + { + // Copy the cache row before acknowledging it. The ack deletes only this request's + // module row; a failed ack is infrastructure loss and remains session-fatal. + call_reducer!( + self.0.call_pipe().conn.reducers, + "gw_ack_taxi_reply", + gw_ack_taxi_reply_then(self.session_actor(actor), request_id) + )?; + if !reply.accepted { + log::debug!( + "taxi operation {operation} refused for character {character_guid}: {}", + reply.refusal + ); + } + return Ok(Some(reply)); + } + std::thread::sleep(Duration::from_millis(10)); + } + Err(anyhow!( + "taxi operation {operation} committed but reply {request_id} was not visible within 1s" + )) + } +} + static NEXT_TAXI_REQUEST_ID: OnceLock = OnceLock::new(); fn next_taxi_request_id() -> u64 { diff --git a/gateway/src/world/handlers/taxi.rs b/gateway/src/world/handlers/taxi.rs index be82e9652..a772138ca 100644 --- a/gateway/src/world/handlers/taxi.rs +++ b/gateway/src/world/handlers/taxi.rs @@ -2,25 +2,26 @@ //! catalogue, discovery, range, or topology tables and therefore cannot fork module policy. use super::super::*; +use crate::stdb::{classify, DurableFailure}; pub(crate) trait TaxiActionStore: Send + Sync { fn taxi_node_status( &self, - character_guid: u64, + actor: Actor, npc_guid: u64, ) -> Result>; - fn open_taxi(&self, character_guid: u64, npc_guid: u64) -> Result>; + fn open_taxi(&self, actor: Actor, npc_guid: u64) -> Result>; fn activate_taxi( &self, - character_guid: u64, + actor: Actor, npc_guid: u64, source_client_node_id: u32, destination_client_node_id: u32, ) -> Result; - fn arm_taxi_flight(&self, character_guid: u64) -> Result<()>; + fn arm_taxi_flight(&self, actor: Actor) -> Result<()>; } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -40,35 +41,56 @@ pub(crate) enum TaxiActionOutcome { PassThrough(ClientOpcodeMessage), } +/// A Refusal means the Module said no and nothing changed, so the caller answers with `refused`. A +/// Transport Loss leaves the outcome unknown and ends the World Session. +fn or_refused(result: Result, refused: T) -> Result { + result.or_else(|error| match classify(&error) { + DurableFailure::Refusal { reason } => { + log::debug!("world: taxi request refused: {reason}"); + Ok(refused) + } + DurableFailure::TransportLoss => Err(error), + }) +} + fn status_outbound( store: &St, - character_guid: u64, + actor: Option, npc_guid: u64, ) -> Result> { - match store.taxi_node_status(character_guid, npc_guid) { - Ok(Some(view)) => Ok(vec![Outbound::One( + let Some(actor) = actor else { + return Ok(Vec::new()); + }; + match or_refused(store.taxi_node_status(actor, npc_guid), None)? { + Some(view) => Ok(vec![Outbound::One( ServerOpcodeMessage::SMSG_TAXINODE_STATUS(Box::new(codec::build_taxi_node_status( view, ))), )]), - Ok(None) => Ok(Vec::new()), - Err(error) => Err(error), + None => Ok(Vec::new()), } } fn activate_taxi_outbound( store: &St, - character_guid: u64, + actor: Option, npc_guid: u64, source_client_node_id: u32, destination_client_node_id: u32, ) -> Result<(Vec, bool)> { - let result = store.activate_taxi( - character_guid, - npc_guid, - source_client_node_id, - destination_client_node_id, - )?; + let server_error = codec::TaxiActivationResult::default(); + let result = match actor { + Some(actor) => or_refused( + store.activate_taxi( + actor, + npc_guid, + source_client_node_id, + destination_client_node_id, + ), + server_error, + )?, + None => server_error, + }; let arm = result.result_code == lyracore_shared::constants::taxi_protocol::ACTIVATE_OK; Ok(( vec![Outbound::One(ServerOpcodeMessage::SMSG_ACTIVATETAXIREPLY( @@ -78,6 +100,8 @@ 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, @@ -88,26 +112,27 @@ pub(crate) fn queue_reply_then_arm( for message in outbound { send(tx, message)?; } - if arm { - store.arm_taxi_flight(character_guid) - } else { - Ok(()) + 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. +/// gossip selection call this exact function. No Character yet gets no window. pub(crate) fn open_taxi_outbound( store: &St, - character_guid: u64, + actor: Option, npc_guid: u64, ) -> Result> { - match store.open_taxi(character_guid, npc_guid) { - Ok(Some(view)) => Ok(vec![Outbound::One( + let Some(actor) = actor else { + return Ok(Vec::new()); + }; + match or_refused(store.open_taxi(actor, npc_guid), None)? { + Some(view) => Ok(vec![Outbound::One( ServerOpcodeMessage::SMSG_SHOWTAXINODES(Box::new(codec::build_show_taxi_nodes(&view))), )]), - Ok(None) => Ok(Vec::new()), - Err(error) => Err(error), + None => Ok(Vec::new()), } } @@ -116,24 +141,21 @@ pub(crate) fn dispatch_taxi_action( 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, player.self_guid.unwrap_or(0), query.guid.guid())?, + outbound: status_outbound(store, actor, query.guid.guid())?, }), ClientOpcodeMessage::CMSG_TAXIQUERYAVAILABLENODES(query) => { Ok(TaxiActionOutcome::Handled { - outbound: open_taxi_outbound( - store, - player.self_guid.unwrap_or(0), - query.guid.guid(), - )?, + 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, - character_guid, + actor, request.guid.guid(), request.source_node, request.destination_node, @@ -151,6 +173,7 @@ pub(crate) fn dispatch_taxi_action( #[cfg(test)] pub(super) mod tests { use super::*; + use crate::stdb::ReducerCallError; use std::sync::Mutex; use wow_world_messages::{ vanilla::{CMSG_ACTIVATETAXI, CMSG_TAXINODE_STATUS_QUERY, CMSG_TAXIQUERYAVAILABLENODES}, @@ -164,7 +187,8 @@ pub(super) mod tests { pub(crate) map: Mutex>, pub(crate) activation: Mutex, pub(crate) activation_inputs: Mutex>, - pub(crate) fail: bool, + /// Every Durable Request fails with this error. + pub(crate) fail: Option anyhow::Error>, /// 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)>, @@ -175,30 +199,26 @@ pub(super) mod tests { impl TaxiActionStore for InMemoryTaxiActions { fn taxi_node_status( &self, - character_guid: u64, + actor: Actor, npc_guid: u64, ) -> Result> { self.calls .lock() .unwrap() - .push(("status", character_guid, npc_guid)); - if self.fail { - anyhow::bail!("offline") + .push(("status", actor.guid(), npc_guid)); + if let Some(error) = self.fail { + return Err(error()); } Ok((*self.status.lock().unwrap()).filter(|view| view.npc_guid == npc_guid)) } - fn open_taxi( - &self, - character_guid: u64, - npc_guid: u64, - ) -> Result> { + fn open_taxi(&self, actor: Actor, npc_guid: u64) -> Result> { self.calls .lock() .unwrap() - .push(("open", character_guid, npc_guid)); - if self.fail { - anyhow::bail!("offline") + .push(("open", actor.guid(), npc_guid)); + if let Some(error) = self.fail { + return Err(error()); } Ok(self .map @@ -210,7 +230,7 @@ pub(super) mod tests { fn activate_taxi( &self, - character_guid: u64, + actor: Actor, npc_guid: u64, source_client_node_id: u32, destination_client_node_id: u32, @@ -218,15 +238,15 @@ pub(super) mod tests { self.calls .lock() .unwrap() - .push(("activate", character_guid, npc_guid)); + .push(("activate", actor.guid(), npc_guid)); self.activation_inputs.lock().unwrap().push(( - character_guid, + actor.guid(), npc_guid, source_client_node_id, destination_client_node_id, )); - if self.fail { - anyhow::bail!("offline") + if let Some(error) = self.fail { + return Err(error()); } if self .route @@ -239,8 +259,8 @@ pub(super) mod tests { Ok(*self.activation.lock().unwrap()) } - fn arm_taxi_flight(&self, character_guid: u64) -> Result<()> { - self.calls.lock().unwrap().push(("arm", character_guid, 0)); + 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()); } @@ -289,11 +309,35 @@ pub(super) mod tests { assert_eq!(*store.calls.lock().unwrap(), vec![("open", 9, 77)]); } + fn transport_lost() -> anyhow::Error { + ReducerCallError::transport_lost("gw_open_taxi").into() + } + + fn refused() -> anyhow::Error { + ReducerCallError::refused("gw_open_taxi", "not a flight master").into() + } + + fn status_query() -> ClientOpcodeMessage { + CMSG_TAXINODE_STATUS_QUERY { + guid: Guid::new(77), + } + .into() + } + + fn activate_query() -> ClientOpcodeMessage { + CMSG_ACTIVATETAXI { + guid: Guid::new(77), + source_node: 255, + destination_node: 256, + } + .into() + } + #[test] - fn gameplay_refusals_are_nonfatal_but_store_failures_propagate() { - let refused = InMemoryTaxiActions::default(); + fn an_unanswered_query_is_nonfatal() { + let store = InMemoryTaxiActions::default(); let outcome = dispatch_taxi_action( - &refused, + &store, TaxiActionPlayer { self_guid: Some(9) }, CMSG_TAXIQUERYAVAILABLENODES { guid: Guid::new(77), @@ -302,24 +346,55 @@ pub(super) mod tests { ) .unwrap(); assert!(matches!(outcome, TaxiActionOutcome::Handled { outbound } if outbound.is_empty())); + } - let failed = InMemoryTaxiActions { - fail: true, + #[test] + fn a_transport_loss_ends_the_world_session() { + let store = InMemoryTaxiActions { + fail: Some(transport_lost), ..Default::default() }; - let result = dispatch_taxi_action( - &failed, - TaxiActionPlayer { self_guid: Some(9) }, - CMSG_TAXINODE_STATUS_QUERY { - guid: Guid::new(77), - } - .into(), - ); - let error = match result { - Err(error) => error, - Ok(_) => panic!("infrastructure failures must end the world session"), + for msg in [status_query(), activate_query()] { + let result = dispatch_taxi_action(&store, TaxiActionPlayer { self_guid: Some(9) }, msg); + assert!( + result.is_err(), + "a lost transport must end the world session" + ); + } + } + + #[test] + fn a_refusal_answers_without_arming_and_keeps_the_session() { + let store = InMemoryTaxiActions { + fail: Some(refused), + ..Default::default() }; - assert!(error.to_string().contains("offline")); + let player = TaxiActionPlayer { self_guid: Some(9) }; + + let status = dispatch_taxi_action(&store, player, status_query()).unwrap(); + assert!(matches!(status, TaxiActionOutcome::Handled { outbound } if outbound.is_empty())); + + let (outbound, arm) = match dispatch_taxi_action(&store, player, activate_query()).unwrap() + { + TaxiActionOutcome::Activated { outbound, arm, .. } => (outbound, arm), + _ => panic!("activate must be consumed"), + }; + assert!(!arm); + assert!(matches!( + outbound.as_slice(), + [Outbound::One(ServerOpcodeMessage::SMSG_ACTIVATETAXIREPLY(reply))] + if reply.reply == wow_world_messages::vanilla::ActivateTaxiReply::UnspecifiedServerError + )); + } + + #[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())); + assert!(store.calls.lock().unwrap().is_empty()); } #[test] @@ -363,7 +438,7 @@ pub(super) mod tests { }; let (tx, rx) = SessionTx::with_depth(0); *store.arm_tx_probe.lock().unwrap() = Some(tx.clone()); - let (outbound, arm) = activate_taxi_outbound(&store, 9, 77, 255, 256).unwrap(); + let (outbound, arm) = activate_taxi_outbound(&store, Actor::new(9), 77, 255, 256).unwrap(); queue_reply_then_arm(&tx, &store, outbound, 9, arm).unwrap(); @@ -384,7 +459,7 @@ pub(super) mod tests { }; let (tx, rx) = SessionTx::with_depth(0); *store.arm_tx_probe.lock().unwrap() = Some(tx.clone()); - let (outbound, arm) = activate_taxi_outbound(&store, 9, 77, 255, 256).unwrap(); + let (outbound, arm) = activate_taxi_outbound(&store, Actor::new(9), 77, 255, 256).unwrap(); queue_reply_then_arm(&tx, &store, outbound, 9, arm).unwrap(); @@ -400,26 +475,4 @@ pub(super) mod tests { matches!(rx.try_recv(), Ok(Outbound::One(ServerOpcodeMessage::SMSG_ACTIVATETAXIREPLY(reply))) if reply.reply == wow_world_messages::vanilla::ActivateTaxiReply::NotEnoughMoney) ); } - - #[test] - fn activate_infrastructure_failure_is_session_fatal() { - let store = InMemoryTaxiActions { - fail: true, - ..Default::default() - }; - let error = match dispatch_taxi_action( - &store, - TaxiActionPlayer { self_guid: Some(9) }, - CMSG_ACTIVATETAXI { - guid: Guid::new(77), - source_node: 255, - destination_node: 256, - } - .into(), - ) { - Err(error) => error, - Ok(_) => panic!("transport failure must propagate"), - }; - assert!(error.to_string().contains("offline")); - } } diff --git a/gateway/src/world/taxi_tests.rs b/gateway/src/world/taxi_tests.rs index b80778753..f85f2da4d 100644 --- a/gateway/src/world/taxi_tests.rs +++ b/gateway/src/world/taxi_tests.rs @@ -188,7 +188,8 @@ fn taxi_gossip_transport_failure_ends_the_world_session() { use lyracore_shared::constants::gossip_option; let mut s = quest_store(); s.npc.gossip_opts = vec![opt(0, "Show me your flight routes.", gossip_option::TAXI)]; - s.taxi.taxi_error = Some("taxi reducer transport disconnected: channel closed".into()); + s.taxi.taxi_error = + Some(|| crate::stdb::ReducerCallError::transport_lost("gw_open_taxi").into()); let store = std::sync::Arc::new(s); let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); gossip_hello(&mut client, &mut c_enc, &mut c_dec, 90); diff --git a/gateway/src/world/test_support/world_fake/taxi.rs b/gateway/src/world/test_support/world_fake/taxi.rs index d86038f08..163526996 100644 --- a/gateway/src/world/test_support/world_fake/taxi.rs +++ b/gateway/src/world/test_support/world_fake/taxi.rs @@ -4,7 +4,7 @@ use super::super::*; 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, + pub(crate) taxi_error: Option anyhow::Error>, } /// Taxi behavior is tested through `InMemoryTaxiActions`. This adapter serves only the TAXI gossip @@ -12,26 +12,22 @@ pub(crate) struct TaxiState { impl TaxiActionStore for WorldFake { fn taxi_node_status( &self, - _character_guid: u64, + _actor: Actor, _npc_guid: u64, ) -> Result> { Ok(None) } - fn open_taxi( - &self, - _character_guid: u64, - _npc_guid: u64, - ) -> Result> { - if let Some(error) = &self.taxi.taxi_error { - return Err(anyhow!("{error}")); + fn open_taxi(&self, _actor: Actor, _npc_guid: u64) -> Result> { + if let Some(error) = self.taxi.taxi_error { + return Err(error()); } Ok(self.taxi.taxi_map.clone()) } fn activate_taxi( &self, - _character_guid: u64, + _actor: Actor, _npc_guid: u64, _source_client_node_id: u32, _destination_client_node_id: u32, @@ -39,7 +35,7 @@ impl TaxiActionStore for WorldFake { Ok(codec::TaxiActivationResult::default()) } - fn arm_taxi_flight(&self, _character_guid: u64) -> Result<()> { + fn arm_taxi_flight(&self, _actor: Actor) -> Result<()> { Ok(()) } } From e23543ae7c414c4314280e185d94df74fc3fc54d Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 18:56:41 +0200 Subject: [PATCH 27/33] refactor(gateway): trainer Store takes an Actor and classifies failures Inline the trainer wrappers into the trait and drop the unused account id. Per-action requests (action bar, at-war, talents) log a Refusal and end the World Session on a Transport Loss. Co-Authored-By: Claude Sonnet 5.5 --- gateway/src/stdb/store/trainer.rs | 148 +++-------------- gateway/src/world/handlers/trainer.rs | 150 +++++++++--------- .../world/test_support/world_fake/trainer.rs | 27 ++-- gateway/src/world/trainer_tests.rs | 60 ++++--- 4 files changed, 149 insertions(+), 236 deletions(-) diff --git a/gateway/src/stdb/store/trainer.rs b/gateway/src/stdb/store/trainer.rs index b31459fec..4e2f3383f 100644 --- a/gateway/src/stdb/store/trainer.rs +++ b/gateway/src/stdb/store/trainer.rs @@ -1,10 +1,9 @@ //! `Coordinator`'s [`TrainerStore`] adapter. -use anyhow::{anyhow, Result}; +use anyhow::Result; use lyracore_shared::trainer::TrainerRefusal; use spacetimedb_sdk::Table; -use crate::codec; use crate::stdb::bindings::*; use crate::stdb::connection::{call_reducer, reducer_refusal_reason}; use crate::stdb::reads::spell_ranks_stack_in_book; @@ -12,92 +11,9 @@ use crate::stdb::Coordinator; use crate::world::{Actor, TrainerStore}; impl TrainerStore for Coordinator { - fn trainer_serves(&self, player_guid: u64, trainer_guid: u64) -> Result { - self.trainer_serves(player_guid, trainer_guid) - } - - fn trainer_list( - &self, - player_guid: u64, - trainer_guid: u64, - ) -> Result> { - self.trainer_list(player_guid, trainer_guid) - } - - fn buy_trainer_spell( - &self, - account_id: u64, - self_guid: u64, - trainer_guid: u64, - spell_id: u32, - ) -> Result { - self.buy_trainer_spell(account_id, self_guid, trainer_guid, spell_id) - } - - fn trainer_offer_skill_line(&self, trainer_guid: u64, spell_id: u32) -> u32 { - self.trainer_offer_skill_line(trainer_guid, spell_id) - } - - fn talent_grant_spell(&self, talent_id: u32) -> u32 { - self.talent_by_id(talent_id) - .map(|t| t.grant_spell_id) - .unwrap_or(0) - } - - fn set_faction_at_war( - &self, - account_id: u64, - self_guid: u64, - reputation_index: u32, - at_war: bool, - ) -> Result<()> { - self.set_faction_at_war(account_id, self_guid, reputation_index, at_war) - } - - fn set_action_button( - &self, - account_id: u64, - self_guid: u64, - button: u8, - action: u32, - action_type: u8, - ) -> Result<()> { - self.set_action_button(account_id, self_guid, button, action, action_type) - } - - fn talent_pane_sync(&self, character_guid: u64, talent_id: u32) -> (u32, u32, u32) { - self.talent_pane_sync(character_guid, talent_id) - } - - fn talent_points_spent(&self, character_guid: u64) -> u32 { - self.talent_points_spent(character_guid) - } - - fn learn_talent(&self, account_id: u64, self_guid: u64, talent_id: u32) -> Result<()> { - self.learn_talent(account_id, self_guid, talent_id) - } - - fn reset_talents(&self, account_id: u64, self_guid: u64, trainer_guid: u64) -> Result<()> { - self.reset_talents(account_id, self_guid, trainer_guid) - } - - fn resolve_learn_target(&self, spell_id: u32) -> u32 { - self.resolve_learn_target(spell_id) - } - - fn superseded_old_rank(&self, new_spell: u32, player_guid: u64) -> Option { - self.superseded_old_rank(new_spell, player_guid) - } - - fn character_presence(&self, guid: u64) -> Result> { - self.character_presence(guid) - } -} - -impl Coordinator { /// A character's live presence `(online, level, class, zone_id)` for `SMSG_FRIEND_STATUS`/ /// `SMSG_FRIEND_LIST`. `None` if the guid doesn't resolve to any character (a stale reference). - pub fn character_presence(&self, guid: u64) -> Result> { + fn character_presence(&self, guid: u64) -> Result> { Ok(self .0 .coord() @@ -111,7 +27,7 @@ impl Coordinator { /// Does this trainer serve `player_guid`'s class? Fail-open on any missing read (trainer, /// character, template), matching `npc_refuses_interaction`. - pub fn trainer_serves(&self, player_guid: u64, trainer_guid: u64) -> Result { + fn trainer_serves(&self, player_guid: u64, trainer_guid: u64) -> Result { let guard = self.0.coord(); let db = &guard.conn.db; let (Some(trainer), Some(player)) = ( @@ -132,7 +48,7 @@ impl Coordinator { /// The skill line offering `spell_id` teaches at trainer `trainer_guid`, or 0 for an ordinary spell /// offering (and for any missing trainer/offering row). The offering list is keyed by the trainer's /// creature-template `entry`, like the vendor stock. Read from the privileged cache. - pub fn trainer_offer_skill_line(&self, trainer_guid: u64, spell_id: u32) -> u32 { + fn trainer_offer_skill_line(&self, trainer_guid: u64, spell_id: u32) -> u32 { let guard = self.0.coord(); let db = &guard.conn.db; let Some(entry) = db @@ -156,7 +72,7 @@ impl Coordinator { /// codec can render each Green/Red/Gray. The trainer's creature-template `entry` keys the list (like /// the vendor stock); `known` = a `game_player_spell` row (the one castability source). A missing /// trainer/player → empty list. Read from the privileged cache (coordinator bypasses RLS). - pub fn trainer_list( + fn trainer_list( &self, player_guid: u64, trainer_guid: u64, @@ -245,7 +161,7 @@ impl Coordinator { /// downranking Holy Light is a real thing): MANA spells keep EVERY rank visible (casters /// downrank for mana efficiency); rage/energy/health-cost spells and PASSIVES supersede /// (Heroic Strike replaces its old rank — there is no "downranked HS"). - pub fn superseded_old_rank(&self, new_spell: u32, player_guid: u64) -> Option { + fn superseded_old_rank(&self, new_spell: u32, player_guid: u64) -> Option { let guard = self.0.coord(); let db = &guard.conn.db; if spell_ranks_stack_in_book(db, new_spell) { @@ -266,7 +182,7 @@ impl Coordinator { knows_prev.then_some(prev) } - pub fn resolve_learn_target(&self, spell_id: u32) -> u32 { + fn resolve_learn_target(&self, spell_id: u32) -> u32 { const EXCLUDED: [u8; 4] = [0x93, 0xBE, 0xAB, 0x05]; let guard = self.0.coord(); let resolved = guard @@ -281,9 +197,9 @@ impl Coordinator { resolved.unwrap_or(spell_id) } - /// Look up the static `Talent` metadata for `talent_id` (from the coordinator's `game_talent` cache). - /// Returns `None` if the talent isn't seeded (the dispatch treats an unknown talent as a noop). - pub fn talent_by_id(&self, talent_id: u32) -> Option { + /// The `grant_spell_id` for `talent_id` from the coordinator's `game_talent` cache (0 = passive, + /// no ability granted, or an unseeded talent). + fn talent_grant_spell(&self, talent_id: u32) -> u32 { self.0 .coord() .conn @@ -291,12 +207,14 @@ impl Coordinator { .game_talent() .iter() .find(|t| t.talent_id == talent_id) + .map(|t| t.grant_spell_id) + .unwrap_or(0) } /// Sum of the character's spent talent ranks (`game_character_talent`, coordinator RLS-bypassed). /// Non-zero gates the post-CREATE login correction of `PLAYER_CHARACTER_POINTS1` (the CREATE's /// formula counts points EARNED only — see `codec/entity.rs`). - pub fn talent_points_spent(&self, character_guid: u64) -> u32 { + fn talent_points_spent(&self, character_guid: u64) -> u32 { let guard = self.0.coord(); guard .conn @@ -317,7 +235,7 @@ impl Coordinator { /// rank's now-replaced spell (drives SMSG_SUPERCEDED_SPELL; 0 for rank 1 / same-spell demo /// trees). `points_remaining` = earned (level−9, floor 0) minus spent — the live /// `PLAYER_CHARACTER_POINTS1` value. Callers may pass `talent_id = 0` to get just the points. - pub fn talent_pane_sync(&self, character_guid: u64, talent_id: u32) -> (u32, u32, u32) { + fn talent_pane_sync(&self, character_guid: u64, talent_id: u32) -> (u32, u32, u32) { let guard = self.0.coord(); let db = &guard.conn.db; let level = db @@ -369,15 +287,12 @@ impl Coordinator { /// connection. The module gates it (range / level / cost / not-already-known) and charges copper. /// A Refusal the Module tagged comes back as an outcome; anything else stays an error. /// Rides the coordinator connection as `gw_trainer_buy`. - pub fn buy_trainer_spell( + fn buy_trainer_spell( &self, - _account_id: u64, - actor_guid: u64, + actor: Actor, trainer_guid: u64, spell_id: u32, ) -> Result { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("buy_trainer_spell: actor_guid unresolved"))?; let coord = self.0.call_pipe(); let result: Result<()> = call_reducer!( coord.conn.reducers, @@ -393,9 +308,7 @@ impl Coordinator { } } - pub fn learn_talent(&self, _account_id: u64, actor_guid: u64, talent_id: u32) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("learn_talent: actor_guid unresolved"))?; + fn learn_talent(&self, actor: Actor, talent_id: u32) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, @@ -407,14 +320,7 @@ impl Coordinator { /// Respec at a trainer (the "I wish to unlearn my talents." gossip option), clears every /// learned talent for the calling player's escalating gold cost. Rides the coordinator /// connection as `gw_reset_talents` (deleted the per-player sender path). - pub fn reset_talents( - &self, - _account_id: u64, - actor_guid: u64, - trainer_guid: u64, - ) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("reset_talents: actor_guid unresolved"))?; + fn reset_talents(&self, actor: Actor, trainer_guid: u64) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, @@ -425,16 +331,13 @@ impl Coordinator { /// Persist one action-bar button (`CMSG_SET_ACTION_BUTTON`): upsert by (character, button); /// action 0 clears. Without this every bar drag was lost on relog (only creation seeds survived). - pub fn set_action_button( + fn set_action_button( &self, - _account_id: u64, - actor_guid: u64, + actor: Actor, button: u8, action: u32, action_type: u8, ) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("set_action_button: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, @@ -446,15 +349,7 @@ impl Coordinator { /// Persist the rep pane's At-War checkbox (`CMSG_SET_FACTION_ATWAR`): the wire's /// u16 is the client's 0..63 rep-array slot (ReputationListID — the gtker `Faction` field name /// lies, same as SET_FACTION_STANDING); the module reverse-resolves the faction and upserts. - pub fn set_faction_at_war( - &self, - _account_id: u64, - actor_guid: u64, - reputation_index: u32, - at_war: bool, - ) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("set_faction_at_war: actor_guid unresolved"))?; + fn set_faction_at_war(&self, actor: Actor, reputation_index: u32, at_war: bool) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, @@ -492,6 +387,7 @@ fn trainer_refusal(error: &anyhow::Error) -> Option { mod trainer_reducer_tests { use super::*; use crate::stdb::connection::ReducerCallError; + use anyhow::anyhow; #[test] fn only_a_rejected_reducer_carries_a_typed_refusal() { diff --git a/gateway/src/world/handlers/trainer.rs b/gateway/src/world/handlers/trainer.rs index be61585a3..d4dff864e 100644 --- a/gateway/src/world/handlers/trainer.rs +++ b/gateway/src/world/handlers/trainer.rs @@ -2,6 +2,7 @@ //! `world/mod.rs`. use super::super::*; +use crate::stdb::{classify, DurableFailure}; use lyracore_shared::trainer::TrainerRefusal; use wow_world_messages::vanilla::TrainingFailureReason; @@ -24,8 +25,7 @@ pub(crate) trait TrainerStore: Send + Sync { /// the durable result is unknown. fn buy_trainer_spell( &self, - account_id: u64, - self_guid: u64, + actor: Actor, trainer_guid: u64, spell_id: u32, ) -> Result; @@ -44,8 +44,7 @@ pub(crate) trait TrainerStore: Send + Sync { /// Persist one action-bar button (`CMSG_SET_ACTION_BUTTON`); action 0 clears the slot. fn set_action_button( &self, - account_id: u64, - self_guid: u64, + actor: Actor, button: u8, action: u32, action_type: u8, @@ -53,13 +52,7 @@ pub(crate) trait TrainerStore: Send + Sync { /// Persist the rep pane's At-War checkbox (`CMSG_SET_FACTION_ATWAR`). /// `reputation_index` is the client's 0..63 rep-array slot, NOT a faction id. - fn set_faction_at_war( - &self, - account_id: u64, - self_guid: u64, - reputation_index: u32, - at_war: bool, - ) -> Result<()>; + fn set_faction_at_war(&self, actor: Actor, reputation_index: u32, at_war: bool) -> Result<()>; /// Talent-pane sync after a successful `learn_talent`: `(teach_spell, superseded_prev, /// points_remaining)` — the rank-spell to relay as LEARNED/SUPERCEDED (the 1.12 TalentFrame @@ -72,12 +65,12 @@ pub(crate) trait TrainerStore: Send + Sync { /// Spend a talent point on `talent_id` (`CMSG_LEARN_TALENT`). The module gates it (points available /// / max rank / prerequisites); a gameplay `Err` is per-action, not session-fatal. - fn learn_talent(&self, account_id: u64, self_guid: u64, talent_id: u32) -> Result<()>; + fn learn_talent(&self, actor: Actor, talent_id: u32) -> Result<()>; /// Respec at `trainer_guid` (the "I wish to unlearn my talents." gossip option, gated to level /// 10+ by `filtered_gossip_options`). Errors (out of range / not enough gold) are /// per-action; the caller just closes the gossip window either way. - fn reset_talents(&self, account_id: u64, self_guid: u64, trainer_guid: u64) -> Result<()>; + fn reset_talents(&self, actor: Actor, trainer_guid: u64) -> Result<()>; /// The rank a trainer offering actually teaches (LearnSpell wrapper → its trigger; a /// self-contained rank resolves to itself). Mirrors the module's buy-time resolution so @@ -92,6 +85,21 @@ pub(crate) trait TrainerStore: Send + Sync { fn character_presence(&self, guid: u64) -> Result>; } +/// Settle a Durable Request whose failure the client never hears about: a Refusal is logged and the +/// World Session goes on, and a Transport Loss ends it. +pub(super) fn settle_per_action(what: &str, account_id: u64, result: Result<()>) -> Result<()> { + match result { + Ok(()) => Ok(()), + Err(error) => match classify(&error) { + DurableFailure::Refusal { reason } => { + log::debug!("world: {what} ignored (account {account_id}): {reason}"); + Ok(()) + } + DurableFailure::TransportLoss => Err(error), + }, + } +} + /// How the Module answered a trainer purchase. A Refusal is a gameplay answer the client can render; /// a failed Durable Request is not, so it never reaches here. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -127,10 +135,13 @@ pub(crate) fn handle_trainer Result> { - let self_guid = match &conn.state { - WorldState::InWorld(iw) => iw.self_guid, - WorldState::CharSelect => return Ok(Some(msg)), + 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(); @@ -164,7 +175,7 @@ pub(crate) fn handle_trainer { // Confirm + push the spell live so it appears on the action bar without a relog. send( @@ -249,14 +260,11 @@ pub(crate) fn handle_trainer { let action = c.action as u32 | ((c.misc as u32) << 16); - if let Err(e) = - store.set_action_button(conn.account_id, self_guid, c.button, action, c.action_type) - { - log::debug!( - "world: set_action_button ignored (account {}): {e}", - conn.account_id - ); - } + settle_per_action( + "set_action_button", + conn.account_id, + store.set_action_button(actor, c.button, action, c.action_type), + )?; } // The rep pane's At-War checkbox. The wire's `faction` u16 is the client's // 0..63 rep-array slot (ReputationListID — gtker's field name lies, the same @@ -265,19 +273,16 @@ pub(crate) fn handle_trainer { let reputation_index = c.faction.as_int() as u32; let at_war = c.flags.is_at_war(); - if let Err(e) = - store.set_faction_at_war(conn.account_id, self_guid, reputation_index, at_war) - { - log::debug!( - "world: set_faction_at_war ignored (account {}): {e}", - conn.account_id - ); - } + settle_per_action( + "set_faction_at_war", + conn.account_id, + store.set_faction_at_war(actor, reputation_index, at_war), + )?; } 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(conn.account_id, self_guid, talent_id) { + match store.learn_talent(actor, talent_id) { Ok(()) => { if grant_spell_id != 0 { send( @@ -288,52 +293,43 @@ pub(crate) fn handle_trainer { - log::debug!( - "world: learn_talent ignored (account {}): {e}", - conn.account_id - ); - } + Err(error) => settle_per_action("learn_talent", conn.account_id, Err(error))?, } } other => return Ok(Some(other)), diff --git a/gateway/src/world/test_support/world_fake/trainer.rs b/gateway/src/world/test_support/world_fake/trainer.rs index ad6992ba1..4a7ad365f 100644 --- a/gateway/src/world/test_support/world_fake/trainer.rs +++ b/gateway/src/world/test_support/world_fake/trainer.rs @@ -5,9 +5,9 @@ pub(crate) struct TrainerState { /// Spelled as a refusal so derive-Default (false) keeps every fixture trainer serving; the /// trait method reads the negation. pub(crate) trainer_refuses_class: bool, - /// Recorded `reset_talents` dispatches: (account_id, self_guid, trainer_guid) — the unlearn-talents + /// Recorded `reset_talents` dispatches: (actor guid, trainer_guid) — the unlearn-talents /// gossip select. - pub(crate) reset_talents_calls: std::sync::Mutex>, + pub(crate) reset_talents_calls: std::sync::Mutex>, } impl TrainerStore for WorldFake { @@ -25,8 +25,7 @@ impl TrainerStore for WorldFake { fn buy_trainer_spell( &self, - _account_id: u64, - _self_guid: u64, + _actor: Actor, _trainer_guid: u64, _spell_id: u32, ) -> Result { @@ -39,8 +38,7 @@ impl TrainerStore for WorldFake { fn set_faction_at_war( &self, - _account_id: u64, - _self_guid: u64, + _actor: Actor, _reputation_index: u32, _at_war: bool, ) -> Result<()> { @@ -49,8 +47,7 @@ impl TrainerStore for WorldFake { fn set_action_button( &self, - _account_id: u64, - _self_guid: u64, + _actor: Actor, _button: u8, _action: u32, _action_type: u8, @@ -66,16 +63,16 @@ impl TrainerStore for WorldFake { 0 // login stays byte-identical in every existing harness test } - fn learn_talent(&self, _account_id: u64, _self_guid: u64, _talent_id: u32) -> Result<()> { + fn learn_talent(&self, _actor: Actor, _talent_id: u32) -> Result<()> { Ok(()) } - fn reset_talents(&self, account_id: u64, self_guid: u64, trainer_guid: u64) -> Result<()> { - self.trainer.reset_talents_calls.lock().unwrap().push(( - account_id, - self_guid, - trainer_guid, - )); + fn reset_talents(&self, actor: Actor, trainer_guid: u64) -> Result<()> { + self.trainer + .reset_talents_calls + .lock() + .unwrap() + .push((actor.guid(), trainer_guid)); Ok(()) } diff --git a/gateway/src/world/trainer_tests.rs b/gateway/src/world/trainer_tests.rs index 653ec22a5..4f93a7d4b 100644 --- a/gateway/src/world/trainer_tests.rs +++ b/gateway/src/world/trainer_tests.rs @@ -3,6 +3,7 @@ use super::handlers::{handle_trainer, CharacterStore, TrainerBuyOutcome, TrainerStore}; use super::*; +use crate::stdb::ReducerCallError; use lyracore_shared::constants::armor_proficiency::{ PLATE_PASSIVE_SPELL_ID, PLATE_TRAINER_SPELL_ID, }; @@ -17,8 +18,10 @@ struct TrainerFake { /// `learn_skill_line` per offering id. An offering absent here teaches an ordinary spell. skill_lines: std::collections::HashMap, buy_refusal: Option, - /// A failed Durable Request: the result is unknown. - buy_error: Option, + /// A failed Durable Request on the buy. + buy_error: Option anyhow::Error>, + /// A failed Durable Request on the talent pick. + talent_error: Option anyhow::Error>, /// The known previous rank a buy of a non-stacking chain replaces. superseded: Option, /// The spellbook after the buy. @@ -118,16 +121,15 @@ impl TrainerStore for TrainerFake { fn buy_trainer_spell( &self, - _account_id: u64, - _self_guid: u64, + _actor: Actor, _trainer_guid: u64, _spell_id: u32, ) -> Result { if let Some(refusal) = self.buy_refusal { return Ok(refusal.into()); } - match &self.buy_error { - Some(error) => Err(anyhow!("{error}")), + match self.buy_error { + Some(error) => Err(error()), None => Ok(TrainerBuyOutcome::Learned), } } @@ -142,8 +144,7 @@ impl TrainerStore for TrainerFake { fn set_action_button( &self, - _account_id: u64, - _self_guid: u64, + _actor: Actor, _button: u8, _action: u32, _action_type: u8, @@ -153,8 +154,7 @@ impl TrainerStore for TrainerFake { fn set_faction_at_war( &self, - _account_id: u64, - _self_guid: u64, + _actor: Actor, _reputation_index: u32, _at_war: bool, ) -> Result<()> { @@ -169,11 +169,14 @@ impl TrainerStore for TrainerFake { unimplemented!("talent_points_spent") } - fn learn_talent(&self, _account_id: u64, _self_guid: u64, _talent_id: u32) -> Result<()> { - Ok(()) + fn learn_talent(&self, _actor: Actor, _talent_id: u32) -> Result<()> { + match self.talent_error { + Some(error) => Err(error()), + None => Ok(()), + } } - fn reset_talents(&self, _account_id: u64, _self_guid: u64, _trainer_guid: u64) -> Result<()> { + fn reset_talents(&self, _actor: Actor, _trainer_guid: u64) -> Result<()> { unimplemented!("reset_talents") } @@ -380,17 +383,16 @@ fn every_module_refusal_has_one_client_failure_reason() { } } -/// A reducer timeout leaves the durable result unknown, so it must not reach the client as a +/// A Transport Loss leaves the durable result unknown, so it must not reach the client as a /// gameplay Refusal that says the purchase did not happen. The handler fails; the session ends. #[test] -fn a_trainer_reducer_timeout_is_not_answered_as_a_refusal() { +fn a_trainer_transport_loss_is_not_answered_as_a_refusal() { let store = TrainerFake { - buy_error: Some("gw_trainer_buy reducer timed out after 10s".into()), + buy_error: Some(|| ReducerCallError::transport_lost("gw_trainer_buy").into()), ..Default::default() }; let (verdict, sent) = drive(&store, buy(1234)); - let error = verdict.expect_err("a timed-out trainer reducer must be session-fatal"); - assert!(format!("{error:#}").contains("timed out")); + verdict.expect_err("a lost trainer reducer transport must be session-fatal"); assert!( sent.is_empty(), "nothing claims the purchase failed: [{}]", @@ -398,6 +400,28 @@ fn a_trainer_reducer_timeout_is_not_answered_as_a_refusal() { ); } +/// A refused talent pick is per-action: the client hears nothing and the session goes on. A lost +/// transport leaves the pick's outcome unknown and ends the session. +#[test] +fn a_refused_talent_pick_keeps_the_session_and_a_transport_loss_ends_it() { + let refused = TrainerFake { + talent_error: Some(|| { + ReducerCallError::refused("gw_learn_talent", "no talent points left").into() + }), + ..Default::default() + }; + let (verdict, sent) = drive(&refused, pick_talent()); + verdict.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"); +} + /// An ability talent (`grant_spell_id != 0`) pushes the granted spell so the new button works /// without a relog, then the points push. #[test] From f745b6e2720c58d21190a6c13a3dcb8465e0c86b Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 18:56:41 +0200 Subject: [PATCH 28/33] refactor(gateway): npc Store takes an Actor and classifies failures Inline the NPC wrappers into the trait and drop the unused account id. The query handler resolves the Character once. Gossip, bind home, inspect, text emote and area trigger log a Refusal and end the World Session on a Transport Loss. The handler's speech arms and their tests ride in this commit because they share the file. Co-Authored-By: Claude Sonnet 5.5 --- gateway/src/stdb/store/npc.rs | 109 ++---------- gateway/src/world/handle_loot_fake.rs | 13 +- gateway/src/world/handlers/loot.rs | 15 +- gateway/src/world/handlers/query.rs | 166 ++++++++++-------- gateway/src/world/npc_tests.rs | 106 ++++++++++- gateway/src/world/test_support/family.rs | 16 +- .../src/world/test_support/world_fake/npc.rs | 24 ++- 7 files changed, 251 insertions(+), 198 deletions(-) diff --git a/gateway/src/stdb/store/npc.rs b/gateway/src/stdb/store/npc.rs index a3987bf75..81d23bd75 100644 --- a/gateway/src/stdb/store/npc.rs +++ b/gateway/src/stdb/store/npc.rs @@ -1,6 +1,6 @@ //! `Coordinator`'s [`NpcStore`] adapter. -use anyhow::{anyhow, Result}; +use anyhow::Result; use spacetimedb_sdk::Table; use crate::codec; @@ -11,75 +11,18 @@ use crate::stdb::Coordinator; use crate::world::{Actor, NpcStore}; impl NpcStore for Coordinator { - fn creature_template(&self, entry: u32) -> Result> { - self.creature_template(entry) - } - - fn pet_name( - &self, - requester_guid: u64, - pet_number: u32, - pet_guid: u64, - ) -> Result> { - self.pet_name(requester_guid, pet_number, pet_guid) - } - - fn gameobject_template(&self, entry: u32) -> Result> { - self.gameobject_template(entry) - } - - fn gameobject_type(&self, go_guid: u64) -> Result> { - self.gameobject_type(go_guid) - } - - fn enter_areatrigger(&self, account_id: u64, self_guid: u64, trigger_id: u32) -> Result<()> { - self.enter_areatrigger(account_id, self_guid, trigger_id) - } - + /// Standing-derived reaction gate. The inherent read has other callers (vendor, guild, quest). fn npc_refuses_interaction(&self, npc_guid: u64, player_guid: u64) -> Result { - self.npc_refuses_interaction(npc_guid, player_guid) - } - - fn bind_home(&self, account_id: u64, self_guid: u64) -> Result<()> { - self.bind_home(account_id, self_guid) + Coordinator::npc_refuses_interaction(self, npc_guid, player_guid) } - fn npc_is_innkeeper(&self, guid: u64) -> Result { - self.npc_is_innkeeper(guid) - } - - fn npc_gossip_text_id(&self, npc_guid: u64) -> u32 { - self.npc_gossip_text_id(npc_guid) - } - - fn npc_text_for_id(&self, text_id: u32) -> Option { - self.npc_text_for_id(text_id) - } - - fn gossip_options(&self, npc_guid: u64) -> Result> { - self.gossip_options(npc_guid) - } - - fn inspect(&self, account_id: u64, self_guid: u64, target_guid: u64) -> Result<()> { - self.inspect(account_id, self_guid, target_guid) - } - - fn gossip_select( - &self, - account_id: u64, - self_guid: u64, - npc_guid: u64, - option_id: u32, - option_row_id: u32, - ) -> Result<()> { - self.gossip_select(account_id, self_guid, npc_guid, option_id, option_row_id) + fn gameobject_template(&self, entry: u32) -> Result> { + Coordinator::gameobject_template(self, entry) } -} -impl Coordinator { /// Resolve the active title text for one NPC, with a per-creature menu override before the /// entry-owned default menu. - pub fn npc_gossip_text_id(&self, npc_guid: u64) -> u32 { + fn npc_gossip_text_id(&self, npc_guid: u64) -> u32 { let guard = self.0.coord(); let db = &guard.conn.db; let entity = match db.game_world_entity().guid().find(&npc_guid) { @@ -109,7 +52,7 @@ impl Coordinator { /// are used verbatim (real per-slot probabilities from the dump) and slot 0's base-row `text` is /// NOT separately re-applied (the slot-0 row, always emitted by the importer alongside the others, /// is the source of truth once any slot row exists). - pub fn npc_text_for_id(&self, text_id: u32) -> Option { + fn npc_text_for_id(&self, text_id: u32) -> Option { let guard = self.0.coord(); let db = &guard.conn.db; let base = db.game_npc_text().text_id().find(&text_id)?; @@ -136,7 +79,7 @@ impl Coordinator { /// Return the active imported options for one NPC. A live per-creature override replaces the /// entry-owned default options. The dispatcher applies conditions at both HELLO and SELECT. - pub fn gossip_options(&self, npc_guid: u64) -> Result> { + fn gossip_options(&self, npc_guid: u64) -> Result> { let guard = self.0.coord(); let db = &guard.conn.db; let Some(entity) = db.game_world_entity().guid().find(&npc_guid) else { @@ -193,7 +136,7 @@ impl Coordinator { /// Does the NPC at `guid` carry the innkeeper flag? Gates the "Make this inn your home." gossip /// option + the `bind_home` select. Reads `npc_flags` off the entity (privileged cache); absent → false. - pub fn npc_is_innkeeper(&self, guid: u64) -> Result { + fn npc_is_innkeeper(&self, guid: u64) -> Result { let guard = self.0.coord(); let db = &guard.conn.db; Ok(db @@ -206,13 +149,13 @@ impl Coordinator { /// Resolve a live pet visible to the world. Pet names are public unit presentation: Hunter /// names come from the bounded durable projection, while summoned-pet names remain authored /// creature-template data. The requester must be in world, but need not own the observed pet. - pub fn pet_name( + fn pet_name( &self, - requester_guid: u64, + _requester: Actor, pet_number: u32, pet_guid: u64, ) -> Result, anyhow::Error> { - if requester_guid == 0 || pet_guid == 0 { + if pet_guid == 0 { return Ok(None); } let guard = self.0.coord(); @@ -244,7 +187,7 @@ impl Coordinator { } /// Read a creature template by entry for a `CMSG_CREATURE_QUERY` reply (Tier 2 / NPCs). - pub fn creature_template(&self, entry: u32) -> Result> { + fn creature_template(&self, entry: u32) -> Result> { Ok(self .0 .coord() @@ -271,7 +214,7 @@ impl Coordinator { /// opens the quest window instead of rolling loot / toggling state — that is what a questgiver /// gameobject does in vanilla. `None` for an unspawned/unknown guid (the caller falls back to the /// ordinary use-reducer path, which itself no-ops on an unknown guid). - pub fn gameobject_type(&self, go_guid: u64) -> Result> { + fn gameobject_type(&self, go_guid: u64) -> Result> { let guard = self.0.coord(); let db = &guard.conn.db; Ok(db @@ -289,9 +232,7 @@ impl Coordinator { /// Validate a `CMSG_INSPECT` request (target is a real in-world player, on the caller's map, in /// range, friendly) over the coordinator connection so the module resolves the caller from /// `ctx.sender`. `Err` (out of range / hostile / no such target) → the caller ignores it. - pub fn inspect(&self, _account_id: u64, actor_guid: u64, target_guid: u64) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("inspect: actor_guid unresolved"))?; + fn inspect(&self, actor: Actor, target_guid: u64) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, @@ -301,14 +242,7 @@ impl Coordinator { } /// Enter an area trigger (`CMSG_AREATRIGGER`) — credit any active explore quest tied to `trigger_id`. - pub fn enter_areatrigger( - &self, - _account_id: u64, - actor_guid: u64, - trigger_id: u32, - ) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("enter_areatrigger: actor_guid unresolved"))?; + fn enter_areatrigger(&self, actor: Actor, trigger_id: u32) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, @@ -319,16 +253,13 @@ impl Coordinator { /// `CMSG_GOSSIP_SELECT_OPTION` — the NOTIFY-ONLY module chokepoint. Fired /// best-effort BEFORE the gateway's own gossip behavior; a failure never blocks the reply. - pub fn gossip_select( + fn gossip_select( &self, - _account_id: u64, - actor_guid: u64, + actor: Actor, npc_guid: u64, option_id: u32, option_row_id: u32, ) -> Result<()> { - let actor = Actor::new(actor_guid) - .ok_or_else(|| anyhow!("gossip_select: actor_guid unresolved"))?; let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, @@ -345,9 +276,7 @@ impl Coordinator { /// Bind the caller's hearthstone home to their current position (`CMSG_GOSSIP_SELECT_OPTION` on an /// innkeeper's "Make this inn your home.") over the coordinator connection so the module attributes /// it to the caller's entity. No args — `bind_home` resolves the caller via `ctx.sender`. - pub fn bind_home(&self, _account_id: u64, actor_guid: u64) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("bind_home: actor_guid unresolved"))?; + fn bind_home(&self, actor: Actor) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( coord.conn.reducers, diff --git a/gateway/src/world/handle_loot_fake.rs b/gateway/src/world/handle_loot_fake.rs index 359061f8a..b298a3320 100644 --- a/gateway/src/world/handle_loot_fake.rs +++ b/gateway/src/world/handle_loot_fake.rs @@ -7,7 +7,7 @@ use crate::world::handlers::{ DeathStore, LootActionStatus, LootWindowRequestStatus, LootWindowStore, NpcStore, }; use crate::world::loot::{LootRollStore, PendingLootRoll}; -use crate::world::{ShardRoutingStore, WorldStore}; +use crate::world::{Actor, ShardRoutingStore, WorldStore}; use anyhow::{anyhow, Result}; use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; @@ -215,7 +215,7 @@ impl NpcStore for HandleLootFake { fn pet_name( &self, - _requester_guid: u64, + _requester: Actor, _pet_number: u32, _pet_guid: u64, ) -> Result> { @@ -230,7 +230,7 @@ impl NpcStore for HandleLootFake { unreachable!("no test reaches the NPC family") } - fn enter_areatrigger(&self, _account_id: u64, _self_guid: u64, _trigger_id: u32) -> Result<()> { + fn enter_areatrigger(&self, _actor: Actor, _trigger_id: u32) -> Result<()> { unreachable!("no test reaches the NPC family") } @@ -238,7 +238,7 @@ impl NpcStore for HandleLootFake { unreachable!("no test reaches the NPC family") } - fn bind_home(&self, _account_id: u64, _self_guid: u64) -> Result<()> { + fn bind_home(&self, _actor: Actor) -> Result<()> { unreachable!("no test reaches the NPC family") } @@ -258,14 +258,13 @@ impl NpcStore for HandleLootFake { unreachable!("no test reaches the NPC family") } - fn inspect(&self, _account_id: u64, _self_guid: u64, _target_guid: u64) -> Result<()> { + fn inspect(&self, _actor: Actor, _target_guid: u64) -> Result<()> { unreachable!("no test reaches the NPC family") } fn gossip_select( &self, - _account_id: u64, - _self_guid: u64, + _actor: Actor, _npc_guid: u64, _option_id: u32, _option_row_id: u32, diff --git a/gateway/src/world/handlers/loot.rs b/gateway/src/world/handlers/loot.rs index 2be94c4f2..4f06694e4 100644 --- a/gateway/src/world/handlers/loot.rs +++ b/gateway/src/world/handlers/loot.rs @@ -411,15 +411,12 @@ pub(crate) fn handle_loot< // 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 Err(e) = store.enter_areatrigger( - conn.account_id, - social::self_guid(conn).unwrap_or(0), - a.trigger_id, - ) { - log::debug!( - "world: enter_areatrigger ignored (account {}): {e}", - conn.account_id - ); + 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), + )?; } } // Gameobject template query (CMSG_GAMEOBJECT_QUERY): the client asks for a GO's name/type/display diff --git a/gateway/src/world/handlers/query.rs b/gateway/src/world/handlers/query.rs index 99ca66047..9b9f0e1d8 100644 --- a/gateway/src/world/handlers/query.rs +++ b/gateway/src/world/handlers/query.rs @@ -4,9 +4,14 @@ use super::super::*; use super::quest; use super::taxi::open_taxi_outbound; +use super::trainer::settle_per_action; use super::vendor::{vendor_has_stock, vendor_open_outbound}; +use crate::stdb::{classify, DurableFailure}; use lyracore_shared::chat::broadcast_chat; +/// What the GM reads when a dot-command arrives before the session has a Character. +const NO_CHARACTER_FOR_GM_COMMAND: &str = "gm_command: actor_guid unresolved"; + /// NPC and gameobject templates, gossip, area triggers and inspect. pub(crate) trait NpcStore: Send + Sync { /// Look up a creature template by entry to answer `CMSG_CREATURE_QUERY` (Tier 2 / NPCs). @@ -15,7 +20,7 @@ pub(crate) trait NpcStore: Send + Sync { /// Resolve the public name of a live pet for an in-world requester. fn pet_name( &self, - requester_guid: u64, + requester: Actor, pet_number: u32, pet_guid: u64, ) -> Result>; @@ -29,7 +34,7 @@ pub(crate) trait NpcStore: Send + Sync { fn gameobject_type(&self, go_guid: u64) -> Result>; /// Enter an area trigger (`CMSG_AREATRIGGER`): credit any active "explore" quest tied to it. - fn enter_areatrigger(&self, account_id: u64, self_guid: u64, trigger_id: u32) -> Result<()>; + fn enter_areatrigger(&self, actor: Actor, trigger_id: u32) -> Result<()>; /// Standing-derived reaction gate: does this NPC refuse `player_guid` its /// interaction WINDOW? Rep-bar factions refuse at Unfriendly-or-below standing; bar-less @@ -41,7 +46,7 @@ pub(crate) trait NpcStore: Send + Sync { /// Bind the caller's hearthstone home to their current position (innkeeper gossip "Make this inn /// your home."). No args — the module resolves the caller via `ctx.sender`. - fn bind_home(&self, account_id: u64, self_guid: u64) -> Result<()>; + fn bind_home(&self, actor: Actor) -> Result<()>; /// Does the NPC at `guid` carry the innkeeper flag? Gates the "Make this inn your home." gossip /// option + the bind select. @@ -65,14 +70,13 @@ pub(crate) trait NpcStore: Send + Sync { /// caller's map, in range, and friendly. `Ok(())` → the gateway replies `SMSG_INSPECT(target_guid)`; /// `Err` (out of range / hostile / no such target) → silently ignored, matching the other /// stateless-gate reducers (`enter_areatrigger`, `use_gameobject`). - fn inspect(&self, account_id: u64, self_guid: u64, target_guid: u64) -> Result<()>; + fn inspect(&self, actor: Actor, target_guid: u64) -> Result<()>; /// NOTIFY-ONLY module chokepoint for a gossip-option click — fired best-effort /// before the gateway's own gossip handling; failure never blocks the gossip reply. fn gossip_select( &self, - account_id: u64, - self_guid: u64, + actor: Actor, npc_guid: u64, option_id: u32, option_row_id: u32, @@ -124,32 +128,42 @@ fn filtered_gossip_options< } /// Say, yell or `/e` (a `broadcast_chat` type) through the speaker's Home Shard. The line itself -/// returns on the Relay; a Refusal gets the answer every chat line shares, and only a lost reducer -/// transport ends the World Session. +/// 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, chat_type: u8, language: u8, message: String, ) -> Result<()> { + let Some(actor) = actor else { + log::debug!( + "world: broadcast chat type {chat_type} dropped (account {}): no Character", + conn.account_id + ); + return Ok(()); + }; let player = super::ChatActionPlayer { account_id: conn.account_id, self_guid: social::self_guid(conn), }; - let sent = store.send_chat( - conn.account_id, - player.self_guid.unwrap_or(0), - chat_type, - language, - message, - ); - let refusal = super::chat::settle( - player, - format_args!("broadcast chat type {chat_type}"), - sent, - )?; + let refusal = match store.send_chat(actor, chat_type, language, message) { + Ok(ChatOutcome::Delivered) => None, + Ok(ChatOutcome::Refused(refusal)) => Some(refusal), + Err(error) => match classify(&error) { + DurableFailure::Refusal { reason } => { + log::debug!( + "world: broadcast chat type {chat_type} dropped (account {}): {reason}", + player.account_id + ); + None + } + DurableFailure::TransportLoss => return Err(error), + }, + }; for message in super::chat::refusal_outbound(player, refusal) { send(tx, message)?; } @@ -181,11 +195,10 @@ pub(crate) fn handle_query< conn: &mut WorldConn, msg: ClientOpcodeMessage, ) -> Result> { - // The shared-call path names the actor by guid; 0 (not in world) forces the - // per-player path in the store. - let self_guid = match &conn.state { - WorldState::InWorld(iw) => iw.self_guid, - _ => 0, + // `None` until the session has a Character in the world. + let actor = match &conn.state { + WorldState::InWorld(iw) => Actor::new(iw.self_guid), + _ => None, }; match msg { @@ -210,7 +223,11 @@ pub(crate) fn handle_query< } ClientOpcodeMessage::CMSG_PET_NAME_QUERY(q) => { let pet_guid = q.guid.guid(); - match store.pet_name(self_guid, q.pet_number, pet_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( @@ -230,14 +247,16 @@ pub(crate) fn handle_query< // stateless gates (CMSG_GAMEOBJ_USE, CMSG_AREATRIGGER). ClientOpcodeMessage::CMSG_INSPECT(i) => { let target_guid = i.guid.guid(); - match store.inspect(conn.account_id, self_guid, target_guid) { - Ok(()) => send( - tx, - Outbound::One(ServerOpcodeMessage::SMSG_INSPECT( - codec::build_inspect_response(target_guid), - )), - )?, - Err(e) => log::debug!("world: inspect ignored (account {}): {e}", conn.account_id), + if let Some(actor) = actor { + match store.inspect(actor, target_guid) { + Ok(()) => send( + tx, + Outbound::One(ServerOpcodeMessage::SMSG_INSPECT( + codec::build_inspect_response(target_guid), + )), + )?, + Err(error) => settle_per_action("inspect", conn.account_id, Err(error))?, + } } } // Creature name resolution (the NPC analogue of CMSG_NAME_QUERY). @@ -261,10 +280,7 @@ pub(crate) fn handle_query< // unchanged). ClientOpcodeMessage::CMSG_GOSSIP_HELLO(h) => { let npc = h.guid.guid(); - let player_guid = match &conn.state { - WorldState::InWorld(iw) => iw.self_guid, - WorldState::CharSelect => 0, - }; + 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 @@ -324,10 +340,7 @@ pub(crate) fn handle_query< // deferred (`action_menu_id` stays inert). ClientOpcodeMessage::CMSG_GOSSIP_SELECT_OPTION(c) => { let npc = c.guid.guid(); - let player_guid = match &conn.state { - WorldState::InWorld(iw) => iw.self_guid, - WorldState::CharSelect => 0, - }; + 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 @@ -341,13 +354,13 @@ pub(crate) fn handle_query< 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. - let _ = store.gossip_select( - conn.account_id, - self_guid, - npc, - c.gossip_list_id, - option_row_id, - ); + 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), + )?; + } 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 @@ -361,7 +374,9 @@ pub(crate) fn handle_query< // Bind failure (not in world) is per-action; close the window either way (the // post-bind SMSG_BINDPOINTUPDATE confirmation is cosmetic — sent fresh at next // login; the recall is server-authoritative regardless). - let _ = store.bind_home(conn.account_id, social::self_guid(conn).unwrap_or(0)); + if let Some(actor) = actor { + settle_per_action("bind_home", conn.account_id, store.bind_home(actor))?; + } send(tx, Outbound::One(ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE))?; } Some(gossip_option::TRAINER) => { @@ -376,11 +391,17 @@ pub(crate) fn handle_query< Some(gossip_option::UNLEARNTALENTS) => { // Respec. Errors (out of range / not enough gold) are per-action, // the window closes either way, same as bind_home above. - let _ = store.reset_talents(conn.account_id, player_guid, npc); + if let Some(actor) = actor { + settle_per_action( + "reset_talents", + conn.account_id, + store.reset_talents(actor, npc), + )?; + } send(tx, Outbound::One(ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE))?; } Some(gossip_option::TAXI) => { - for message in open_taxi_outbound(store, player_guid, npc)? { + for message in open_taxi_outbound(store, actor, npc)? { send(tx, message)?; } } @@ -443,29 +464,34 @@ pub(crate) fn handle_query< // The GM reads the Module's Refusal text verbatim. Any other failure ends the // World Session. CMSG_MESSAGECHAT_ChatType::Say if message.starts_with('.') => { - if let Err(error) = store.gm_command(&conn.account_name, self_guid, message) { - let crate::stdb::DurableFailure::Refusal { reason } = - crate::stdb::classify(&error) - else { - return Err(error); - }; + 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(reason.to_string()), + codec::build_gm_system_message(line), ))), )?; } } CMSG_MESSAGECHAT_ChatType::Say => { - speak_nearby(tx, store, conn, broadcast_chat::SAY, lang, message)?; + speak_nearby(tx, store, conn, actor, broadcast_chat::SAY, lang, message)?; } CMSG_MESSAGECHAT_ChatType::Yell => { - speak_nearby(tx, store, conn, broadcast_chat::YELL, lang, message)?; + speak_nearby(tx, store, conn, actor, broadcast_chat::YELL, lang, message)?; } // `/e` custom emote: same broadcast path as Say/Yell, EMOTE type. CMSG_MESSAGECHAT_ChatType::Emote => { - speak_nearby(tx, store, conn, broadcast_chat::EMOTE, lang, message)?; + speak_nearby(tx, store, conn, actor, broadcast_chat::EMOTE, lang, message)?; } // Whisper, party, raid, channel, guild and officer lines and `/afk` `/dnd` never // get here: `dispatch_chat_action` consumes them. @@ -475,15 +501,15 @@ pub(crate) fn handle_query< // 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. Failure is dropped. + // gateway resolves the target guid to a name on relay. A Refusal is dropped. ClientOpcodeMessage::CMSG_TEXT_EMOTE(c) => { - let _ = store.send_emote( - conn.account_id, - self_guid, - c.text_emote.as_int(), - c.emote, - c.target.guid(), - ); + 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. diff --git a/gateway/src/world/npc_tests.rs b/gateway/src/world/npc_tests.rs index d4ac87c71..886366e97 100644 --- a/gateway/src/world/npc_tests.rs +++ b/gateway/src/world/npc_tests.rs @@ -109,6 +109,108 @@ fn inspect_in_range_friendly_target_replies_smsg_inspect_with_the_target_guid() server.join().unwrap(); } +/// `enter_world` for a test that needs to see how the session ended: the server thread hands back +/// its verdict instead of unwrapping it. +fn enter_world_unjoined( + store: std::sync::Arc, + guid: u64, +) -> ( + std::os::unix::net::UnixStream, + EncrypterHalf, + DecrypterHalf, + std::thread::JoinHandle>, +) { + let (mut client, server_end) = world_session_socket_pair(); + let server = std::thread::spawn(move || run_world_session(server_end, store)); + let (mut c_enc, mut c_dec) = client_handshake(&mut client, "TESTER", K); + CMSG_PLAYER_LOGIN { + guid: Guid::new(guid), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + drain_world_entry(&mut client, &mut c_dec); + (client, c_enc, c_dec, server) +} + +/// A Transport Loss leaves the inspect gate's outcome unknown, so it ends the World Session. A +/// Refusal (above) only drops the request. +#[test] +fn inspect_transport_loss_ends_the_world_session() { + let mut s = quest_store(); + s.npc.inspect_error = + Some(|| crate::stdb::ReducerCallError::transport_lost("gw_inspect").into()); + let store = std::sync::Arc::new(s); + let (mut client, mut c_enc, mut c_dec, server) = enter_world_unjoined(store, 1); + CMSG_INSPECT { + guid: Guid::new(55), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + while let Ok(message) = ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec) { + assert!( + !matches!(message, ServerOpcodeMessage::SMSG_INSPECT(_)), + "a lost inspect is not an acknowledgement" + ); + } + assert!( + server.join().unwrap().is_err(), + "a Transport Loss ends the World Session" + ); +} + +/// Say, yell and `/e` lines go to the speaker's Home Shard. A lost transport leaves the line's +/// fate unknown, so it ends the World Session. +#[test] +fn a_say_lost_to_a_transport_loss_ends_the_world_session() { + let mut s = quest_store(); + s.speech.send_chat_error = + Some(|| crate::stdb::ReducerCallError::transport_lost("gw_send_chat").into()); + let store = std::sync::Arc::new(s); + let (mut client, mut c_enc, _c_dec, server) = enter_world_unjoined(store, 1); + CMSG_MESSAGECHAT { + chat_type: CMSG_MESSAGECHAT_ChatType::Say, + language: Language::Universal, + message: "hello".into(), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + assert!( + server.join().unwrap().is_err(), + "a Transport Loss ends the World Session" + ); +} + +/// The Gateway finds a mistyped GM Account itself. The GM reads the reason as a system line and +/// the World Session goes on. +#[test] +fn a_gm_command_for_an_unknown_account_answers_a_system_line_and_keeps_the_session() { + let mut s = quest_store(); + s.speech.gm_command_error = Some(|| { + crate::stdb::ReducerCallError::refused( + "gm_command", + "no Account named TESTER on Realm-core", + ) + .into() + }); + let store = std::sync::Arc::new(s); + let (mut client, mut c_enc, mut c_dec, server) = enter_world_unjoined(store, 1); + CMSG_MESSAGECHAT { + chat_type: CMSG_MESSAGECHAT_ChatType::Say, + language: Language::Universal, + message: ".speed 3".into(), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_MESSAGECHAT(m) => { + assert_eq!(m.message, "no Account named TESTER on Realm-core"); + } + other => panic!("expected SMSG_MESSAGECHAT, got {other}"), + } + drop(client); + assert!(server.join().unwrap().is_ok(), "the session goes on"); +} + #[test] fn inspect_refused_target_sends_no_reply() { // CMSG_PLAYED_TIME (below) always replies as long as `character_by_guid` resolves the caller's @@ -715,8 +817,8 @@ fn gossip_hello_shows_unlearn_talents_at_level_10_and_select_routes_to_reset_tal let calls = store.trainer.reset_talents_calls.lock().unwrap(); assert_eq!( calls.as_slice(), - &[(7, 1, 90)], - "reset_talents must have been called with (account_id, self_guid, trainer_guid)" + &[(1, 90)], + "reset_talents must have been called with (actor guid, trainer_guid)" ); } diff --git a/gateway/src/world/test_support/family.rs b/gateway/src/world/test_support/family.rs index 62d39d5e8..77b2291fd 100644 --- a/gateway/src/world/test_support/family.rs +++ b/gateway/src/world/test_support/family.rs @@ -70,7 +70,7 @@ macro_rules! npc_store_refusing_by { fn pet_name( &self, - _requester_guid: u64, + _requester: Actor, _pet_number: u32, _pet_guid: u64, ) -> Result> { @@ -88,16 +88,11 @@ macro_rules! npc_store_refusing_by { unimplemented!("gameobject_type") } - fn enter_areatrigger( - &self, - _account_id: u64, - _self_guid: u64, - _trigger_id: u32, - ) -> Result<()> { + fn enter_areatrigger(&self, _actor: Actor, _trigger_id: u32) -> Result<()> { unimplemented!("enter_areatrigger") } - fn bind_home(&self, _account_id: u64, _self_guid: u64) -> Result<()> { + fn bind_home(&self, _actor: Actor) -> Result<()> { unimplemented!("bind_home") } @@ -117,14 +112,13 @@ macro_rules! npc_store_refusing_by { unimplemented!("gossip_options") } - fn inspect(&self, _account_id: u64, _self_guid: u64, _target_guid: u64) -> Result<()> { + fn inspect(&self, _actor: Actor, _target_guid: u64) -> Result<()> { unimplemented!("inspect") } fn gossip_select( &self, - _account_id: u64, - _self_guid: u64, + _actor: Actor, _npc_guid: u64, _option_id: u32, _option_row_id: u32, diff --git a/gateway/src/world/test_support/world_fake/npc.rs b/gateway/src/world/test_support/world_fake/npc.rs index a3fdd8b16..8531bb4b2 100644 --- a/gateway/src/world/test_support/world_fake/npc.rs +++ b/gateway/src/world/test_support/world_fake/npc.rs @@ -1,4 +1,5 @@ use super::super::*; +use crate::stdb::ReducerCallError; #[derive(Default)] pub(crate) struct NpcState { @@ -17,6 +18,9 @@ pub(crate) struct NpcState { pub(crate) npc_text_view: Option, /// Spawned GameObject type returned for the `CMSG_GAMEOBJ_USE` questgiver classification. pub(crate) gameobject_type: Option, + /// When set, `inspect` fails with this error: a Refusal the client never hears about, or a + /// Transport Loss that ends the World Session. + pub(crate) inspect_error: Option anyhow::Error>, } impl NpcStore for WorldFake { @@ -26,7 +30,7 @@ impl NpcStore for WorldFake { fn pet_name( &self, - _requester_guid: u64, + _requester: Actor, _pet_number: u32, _pet_guid: u64, ) -> Result> { @@ -41,7 +45,7 @@ impl NpcStore for WorldFake { Ok(self.npc.gameobject_type) } - fn enter_areatrigger(&self, _account_id: u64, _self_guid: u64, _trigger_id: u32) -> Result<()> { + fn enter_areatrigger(&self, _actor: Actor, _trigger_id: u32) -> Result<()> { Ok(()) } @@ -49,7 +53,7 @@ impl NpcStore for WorldFake { Ok(self.npc_refuses) // default false — every existing fixture NPC keeps interacting } - fn bind_home(&self, _account_id: u64, _self_guid: u64) -> Result<()> { + fn bind_home(&self, _actor: Actor) -> Result<()> { self.npc .home_bound .store(true, std::sync::atomic::Ordering::SeqCst); @@ -72,23 +76,25 @@ impl NpcStore for WorldFake { Ok(self.npc.gossip_opts.clone()) } - fn inspect(&self, _account_id: u64, _self_guid: u64, target_guid: u64) -> Result<()> { - if let Some(e) = &self.trade_error { - return Err(anyhow!("{e}")); + fn inspect(&self, _actor: Actor, target_guid: u64) -> Result<()> { + if let Some(error) = self.npc.inspect_error { + return Err(error()); + } + if let Some(reason) = &self.trade_error { + return Err(ReducerCallError::refused("gw_inspect", reason).into()); } // Mirrors the module gate's own-map/in-range/friendly checks with a fixed stub: any nonzero // guid "passes" (in range + friendly) so a test can drive both the ack and the ignore path via // `trade_error`; a 0 guid stands in for "no such target". if target_guid == 0 { - return Err(anyhow!("no such inspect target")); + return Err(ReducerCallError::refused("gw_inspect", "no such inspect target").into()); } Ok(()) } fn gossip_select( &self, - _account_id: u64, - _self_guid: u64, + _actor: Actor, _npc_guid: u64, option_id: u32, option_row_id: u32, From 5b9e767074418546ac5e16d1a2551d51aa8484e2 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 18:56:41 +0200 Subject: [PATCH 29/33] refactor(gateway): speech Store takes an Actor and classifies failures Inline the speech wrappers into the trait. The GM dot-command keeps two sides: an unknown Account, which the Gateway finds itself, is a Refusal the GM reads as a system line. Realm-core being unreachable is a Transport Loss and ends the World Session. Co-Authored-By: Claude Sonnet 5.5 --- gateway/src/realm_core.rs | 37 +++++++++++- gateway/src/stdb/mod.rs | 1 - gateway/src/stdb/store/speech.rs | 58 ++++--------------- gateway/src/world/handlers/chat.rs | 8 +-- .../world/test_support/world_fake/speech.rs | 13 +++-- gateway/src/world/whisper_tests.rs | 9 ++- 6 files changed, 64 insertions(+), 62 deletions(-) diff --git a/gateway/src/realm_core.rs b/gateway/src/realm_core.rs index 8bc6a04c7..cf729c849 100644 --- a/gateway/src/realm_core.rs +++ b/gateway/src/realm_core.rs @@ -157,6 +157,17 @@ pub(crate) trait RealmDb: Clone + Sized + Send + Sync { ) -> Result<()>; } +/// The GM named an Account Realm-core does not hold. The Gateway detects this itself, so it is a +/// Refusal the GM reads as a system line. An unreachable Realm-core stays an untyped error and is a +/// Transport Loss. +fn unknown_account(account_name: &str) -> anyhow::Error { + crate::stdb::ReducerCallError::Rejected { + operation: "gm_command".to_string(), + reason: format!("no Account named {account_name} on Realm-core"), + } + .into() +} + /// Read current Alpha Test Tools authority from Realm-core and convey it to the Actor's Home Shard /// in one caller-facing Store operation. Account ids never cross between databases. pub(crate) fn run_gm_command( @@ -169,7 +180,7 @@ pub(crate) fn run_gm_command( let realm_core = home_shard.realm_core()?; let authority = realm_core .account_by_username(&account_name)? - .ok_or_else(|| anyhow::anyhow!("no Account named {account_name} on Realm-core"))? + .ok_or_else(|| unknown_account(&account_name))? .alpha_test_tools; home_shard.request_gm_command(actor_guid, authority, text) } @@ -1389,10 +1400,32 @@ mod tests { ); } + /// Realm-core being unreachable is infrastructure: the GM's command outcome is unknown, so it + /// ends the World Session. #[test] fn a_dead_realm_core_fails_before_the_home_shard_command_request() { let h = realm_with_dead_core(&[WORLD, CORE], "", CORE); - assert!(run_gm_command(&h, USER, 42, ".speed 3".into()).is_err()); + let error = run_gm_command(&h, USER, 42, ".speed 3".into()) + .expect_err("a dead Realm-core cannot authorize a command"); + assert_eq!( + crate::stdb::classify(&error), + crate::stdb::DurableFailure::TransportLoss + ); + assert!(h.db_at(WORLD).gm_commands.lock().unwrap().is_empty()); + } + + /// The Gateway finds a mistyped Account itself. The GM reads the reason, so it is a Refusal. + #[test] + fn an_unknown_account_is_a_refusal_the_gm_reads() { + let h = split_realm(); + let error = run_gm_command(&h, "NOBODY", 42, ".speed 3".into()) + .expect_err("Realm-core holds no such Account"); + assert_eq!( + crate::stdb::classify(&error), + crate::stdb::DurableFailure::Refusal { + reason: "no Account named NOBODY on Realm-core" + } + ); assert!(h.db_at(WORLD).gm_commands.lock().unwrap().is_empty()); } diff --git a/gateway/src/stdb/mod.rs b/gateway/src/stdb/mod.rs index fe0319e08..23771e385 100644 --- a/gateway/src/stdb/mod.rs +++ b/gateway/src/stdb/mod.rs @@ -36,7 +36,6 @@ mod world_store; // impl RealmDb for Coordinator pub(crate) mod world_view; // shared per-shard spatial, broadcast, private, and owner dispatch pub use connection::Coordinator; -#[cfg(test)] pub(crate) use connection::ReducerCallError; pub(crate) use connection::{classify, DurableFailure}; pub use subscriptions::PlayerSubscriptions; diff --git a/gateway/src/stdb/store/speech.rs b/gateway/src/stdb/store/speech.rs index 861c3791e..24efa6d6a 100644 --- a/gateway/src/stdb/store/speech.rs +++ b/gateway/src/stdb/store/speech.rs @@ -1,6 +1,6 @@ //! `Coordinator`'s [`SpeechStore`] adapter. -use anyhow::{anyhow, Result}; +use anyhow::Result; use super::chat::chat_outcome; use crate::stdb::bindings::*; @@ -9,67 +9,31 @@ use crate::stdb::Coordinator; use crate::world::{Actor, ChatOutcome, SpeechStore}; impl SpeechStore for Coordinator { - fn send_chat( - &self, - account_id: u64, - self_guid: u64, - chat_type: u8, - language: u8, - message: String, - ) -> Result { - self.send_chat(account_id, self_guid, chat_type, language, message) - } - - fn send_emote( - &self, - account_id: u64, - self_guid: u64, - text_emote: u32, - emote_anim: u32, - target_guid: u64, - ) -> Result<()> { - self.send_emote(account_id, self_guid, text_emote, emote_anim, target_guid) - } - - fn gm_command(&self, account_name: &str, self_guid: u64, text: String) -> Result<()> { - self.gm_command(account_name, self_guid, text) - } -} - -impl Coordinator { /// Say, yell or `/e` on the speaker's Home Shard. The Module's language Gate answers a /// `chat:*` Refusal. - pub fn send_chat( + fn send_chat( &self, - _account_id: u64, - actor_guid: u64, + actor: Actor, chat_type: u8, language: u8, message: String, ) -> Result { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("send_chat: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); chat_outcome(call_reducer!( - coord.conn.reducers, + self.0.call_pipe().conn.reducers, "gw_send_chat", gw_send_chat_then(self.session_actor(actor), chat_type, language, message) )) } - pub fn send_emote( + fn send_emote( &self, - _account_id: u64, - actor_guid: u64, + actor: Actor, text_emote: u32, emote_anim: u32, target_guid: u64, ) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("send_emote: actor_guid unresolved"))?; - let coord = self.0.call_pipe(); call_reducer!( - coord.conn.reducers, + self.0.call_pipe().conn.reducers, "gw_send_emote", gw_send_emote_then( self.session_actor(actor), @@ -81,8 +45,10 @@ impl Coordinator { } /// GM playtest dot-command: resolve current Account authority on Realm-core, then convey it to - /// the Home Shard through one Store operation. - pub fn gm_command(&self, account_name: &str, actor_guid: u64, text: String) -> Result<()> { - crate::realm_core::run_gm_command(self, account_name, actor_guid, text) + /// the Home Shard through one Store operation. A Refusal carries the text the GM reads: the + /// Module's reason, or the Gateway's own when the Account is unknown. Realm-core being + /// unreachable is a Transport Loss. + fn gm_command(&self, account_name: &str, actor: Actor, text: String) -> Result<()> { + crate::realm_core::run_gm_command(self, account_name, actor.guid(), text) } } diff --git a/gateway/src/world/handlers/chat.rs b/gateway/src/world/handlers/chat.rs index 18813ee1f..c9a5be552 100644 --- a/gateway/src/world/handlers/chat.rs +++ b/gateway/src/world/handlers/chat.rs @@ -91,8 +91,7 @@ pub(crate) trait SpeechStore: Send + Sync { /// Refusal. fn send_chat( &self, - account_id: u64, - self_guid: u64, + actor: Actor, chat_type: u8, language: u8, message: String, @@ -103,8 +102,7 @@ pub(crate) trait SpeechStore: Send + Sync { /// name so the chat line reads "X waves at ." fn send_emote( &self, - account_id: u64, - self_guid: u64, + actor: Actor, text_emote: u32, emote_anim: u32, target_guid: u64, @@ -116,7 +114,7 @@ pub(crate) trait SpeechStore: Send + Sync { /// verbatim (module-side parsing keeps the command set data-free). A Refusal's reason is relayed /// back to the SENDER ONLY as a system chat line (never broadcast, never a `game_chat_event` /// row). Any other `Err` ends the World Session. - fn gm_command(&self, account_name: &str, self_guid: u64, text: String) -> Result<()>; + fn gm_command(&self, account_name: &str, actor: Actor, text: String) -> Result<()>; } #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/gateway/src/world/test_support/world_fake/speech.rs b/gateway/src/world/test_support/world_fake/speech.rs index 2a0911256..aa99192e2 100644 --- a/gateway/src/world/test_support/world_fake/speech.rs +++ b/gateway/src/world/test_support/world_fake/speech.rs @@ -4,6 +4,8 @@ use super::super::*; pub(crate) struct SpeechState { /// What `send_chat` answers; `None` delivers. pub(crate) send_chat_outcome: Option, + /// When set, `send_chat` fails with this error: a Transport Loss ends the World Session. + pub(crate) send_chat_error: Option anyhow::Error>, /// When set, `gm_command` fails with this error: a Refusal the GM reads, or a Transport Loss. pub(crate) gm_command_error: Option anyhow::Error>, /// Recorded `gm_command` dispatches — the dot-command divert test asserts the @@ -25,12 +27,14 @@ pub(crate) struct SpeechState { impl SpeechStore for WorldFake { fn send_chat( &self, - _account_id: u64, - _self_guid: u64, + _actor: Actor, chat_type: u8, language: u8, message: String, ) -> Result { + if let Some(error) = self.speech.send_chat_error { + return Err(error()); + } // Recorded per SHARD like every other player-scoped call, so the partition rule (say/ // yell stay shard-local and range-scoped) is assertable rather than merely stated. self.rec("send_chat"); @@ -47,8 +51,7 @@ impl SpeechStore for WorldFake { fn send_emote( &self, - _account_id: u64, - _self_guid: u64, + _actor: Actor, _text_emote: u32, _emote_anim: u32, _target_guid: u64, @@ -57,7 +60,7 @@ impl SpeechStore for WorldFake { Ok(()) } - fn gm_command(&self, account_name: &str, _self_guid: u64, text: String) -> Result<()> { + fn gm_command(&self, account_name: &str, _actor: Actor, text: String) -> Result<()> { if let Some(alpha_test_tools) = &self.speech.gm_alpha_test_tools { let authorized = alpha_test_tools.load(std::sync::atomic::Ordering::SeqCst); self.speech diff --git a/gateway/src/world/whisper_tests.rs b/gateway/src/world/whisper_tests.rs index 7e5f9db26..fe0c757f9 100644 --- a/gateway/src/world/whisper_tests.rs +++ b/gateway/src/world/whisper_tests.rs @@ -304,11 +304,14 @@ fn an_unreadable_candidate_is_an_error_not_a_missing_character() { fn say_yell_and_emotes_stay_on_the_players_own_shard_when_sharded() { let (realm, world, _instances, calls) = party_topology(); + let speaker = Actor::new(7).expect("speaker guid"); world - .send_chat(7, 0, 0, 0, "hello Elwynn".into()) + .send_chat(speaker, 0, 0, "hello Elwynn".into()) .expect("say"); - world.send_chat(7, 0, 1, 0, "HELP".into()).expect("yell"); - world.send_emote(7, 0, 4, 4, TRIN).expect("targeted emote"); + world.send_chat(speaker, 1, 0, "HELP".into()).expect("yell"); + world + .send_emote(speaker, 4, 4, TRIN) + .expect("targeted emote"); assert_eq!( world.speech.chats.lock().unwrap().clone(), From bc44b62a48b7512a7236028bc029e1d11103cb55 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 18:57:55 +0200 Subject: [PATCH 30/33] refactor(gateway): drop the last text classifier after merging the family conversions Co-Authored-By: Claude Opus 5.5 (1M context) --- gateway/src/world/handle_loot_fake.rs | 2 +- gateway/src/world/handlers/chat.rs | 8 -------- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/gateway/src/world/handle_loot_fake.rs b/gateway/src/world/handle_loot_fake.rs index 03eed8680..3cda92355 100644 --- a/gateway/src/world/handle_loot_fake.rs +++ b/gateway/src/world/handle_loot_fake.rs @@ -9,7 +9,7 @@ use crate::world::handlers::{ }; use crate::world::loot::{LootRollStore, PendingLootRoll}; use crate::world::{Actor, ShardRoutingStore, WorldStore}; -use anyhow::{anyhow, Result}; +use anyhow::Result; use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; diff --git a/gateway/src/world/handlers/chat.rs b/gateway/src/world/handlers/chat.rs index c658499c8..d877fccda 100644 --- a/gateway/src/world/handlers/chat.rs +++ b/gateway/src/world/handlers/chat.rs @@ -411,14 +411,6 @@ pub(super) fn drop_unrecognised_refusal( } } -/// The meeting stone handler still classifies by message text through this. Delete it with that -/// handler's conversion to `classify`. -pub(super) fn is_transport_failure(error: &anyhow::Error) -> bool { - error - .chain() - .any(|cause| cause.to_string().contains("reducer transport disconnected")) -} - #[cfg(test)] mod tests { use super::*; From 15363b53b8669fb6b00c924aaaf08615ea69cb1f Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 19:06:10 +0200 Subject: [PATCH 31/33] refactor(gateway): finish the one-hop Coordinator Drop the temporary owner fallback: the Realm index reducers act for the Character they move, and the GM command takes the Actor its handler resolved. RealmDb for Coordinator moves to stdb/realm_db.rs and calls its Realm-only reducers directly; world_store.rs is gone and reducers.rs keeps only the calls no Store trait owns. item_slot_by_guid loses its unused account id. Session tests assert classify gives TransportLoss instead of matching the transport text. The opcode recipe, architecture doc and Actor glossary entry describe the one-hop shape. Co-Authored-By: Claude Opus 5.5 (1M context) --- CONTEXT.md | 4 +- CORE_TERMS.md | 4 +- docs/architecture.md | 5 + docs/recipes/add-an-opcode.md | 52 ++-- gateway/src/realm_core.rs | 43 ++- gateway/src/stdb/account_sessions.rs | 5 - gateway/src/stdb/connection.rs | 15 -- gateway/src/stdb/mod.rs | 17 +- gateway/src/stdb/reads/account.rs | 14 - gateway/src/stdb/reads/items.rs | 7 +- gateway/src/stdb/realm_db.rs | 254 ++++++++++++++++++ gateway/src/stdb/reducers.rs | 220 +-------------- gateway/src/stdb/store/auction.rs | 2 +- gateway/src/stdb/store/cast.rs | 4 +- gateway/src/stdb/store/guild_fee.rs | 2 +- gateway/src/stdb/store/mod.rs | 34 +++ gateway/src/stdb/store/speech.rs | 2 +- gateway/src/stdb/store/vendor.rs | 2 +- ...scriptions_character_gone_durable_tests.rs | 1 + ...bscriptions_party_command_durable_tests.rs | 1 + gateway/src/stdb/world_store.rs | 166 ------------ gateway/src/world/handlers/cast/manual.rs | 2 +- gateway/src/world/handlers/cast/mod.rs | 6 +- gateway/src/world/party_tests.rs | 5 +- gateway/src/world/session_tests.rs | 10 +- gateway/src/world/social_tests.rs | 5 +- .../src/world/test_support/world_fake/cast.rs | 2 +- 27 files changed, 396 insertions(+), 488 deletions(-) create mode 100644 gateway/src/stdb/realm_db.rs delete mode 100644 gateway/src/stdb/world_store.rs diff --git a/CONTEXT.md b/CONTEXT.md index de87e2643..9c4474d9c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -214,7 +214,9 @@ A guid-owned player entity. _Avoid_: player (as a noun in code) **Actor**: -The guid a Gateway Verb acts as. +The Character a Gateway Verb acts as. In the Gateway it is the `Actor` type, a nonzero guid. +`Actor::new` returns `None` for guid 0, so no request acts as guid 0. The Coordinator signs an Actor +into a SessionActor. **Session**: The Module's record that an Account is logged in on a Character. diff --git a/CORE_TERMS.md b/CORE_TERMS.md index 8d079b68e..96ba933a6 100644 --- a/CORE_TERMS.md +++ b/CORE_TERMS.md @@ -42,7 +42,9 @@ The identity that publishes and owns the shards, and the only caller of Gateway A `gw_*` reducer the Operator calls on a character's behalf, with the Actor named by guid. **Actor**: -The guid a Gateway Verb acts as. +The Character a Gateway Verb acts as. In the Gateway it is the `Actor` type, a nonzero guid. +`Actor::new` returns `None` for guid 0, so no request acts as guid 0. The Coordinator signs an Actor +into the `SessionActor` a reducer receives. **Gate**: A rule that refuses a request. Gates live in the Module, except the realm-wide reads only the Gateway can perform (presence, name resolution, loot-roll fan-out). diff --git a/docs/architecture.md b/docs/architecture.md index 789b41b5a..3da2d6a68 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -93,6 +93,11 @@ routing fact; module game logic never reads one, and an architecture test fails character, and the nine-boundary Transfer driver. See §6. - **The subscription plane** — the AOI tracker and every `on_insert` relay that turns a table delta into an SMSG. See §5. +- **Durable Requests in one hop.** A family handler in `gateway/src/world/handlers/` resolves its + session facts to an `Actor` once and calls its Store trait. The trait's `Coordinator` impl in + `gateway/src/stdb/store/.rs` calls the reducer directly. When the call fails, + `classify` in `gateway/src/stdb/connection.rs` decides. A Refusal gets the family's answer and + the session continues. Every other failure is a Transport Loss and ends the World Session. ### 2.3 The honest exception: gates that live in the gateway diff --git a/docs/recipes/add-an-opcode.md b/docs/recipes/add-an-opcode.md index d7c7bc0e7..4f150b74e 100644 --- a/docs/recipes/add-an-opcode.md +++ b/docs/recipes/add-an-opcode.md @@ -35,8 +35,8 @@ The Gateway carries the client's intent. The Module decides. - A Realm-core operation is a `realm_` reducer and takes the realm-wide facts the Gateway read. - A Refusal returns `Err` with a tag the Gateway can parse back into a typed Refusal. The Meeting - Stone Queue keeps that type in `lyracore_shared::meeting_stone`. A transport failure stays an - error, so the two never mix. + Stone Queue keeps that type in `lyracore_shared::meeting_stone`. The Gateway's `classify` treats + every other error as a Transport Loss, so the two never mix. - Declare a new module file in `module/src/lib.rs`. ## 3. Store what the Relay or a Durable Read needs @@ -57,28 +57,34 @@ Required for every new table and every new reducer. `coordinator_queries` in `gateway/src/stdb/connection.rs`, then add a `parity_test!` and the table name to `MANIFEST_TABLES` in `gateway/tests/schema_parity.rs`. -## 5. Call the reducer from the Coordinator +## 5. Call the reducer from the Store trait method -File: `gateway/src/stdb/store/.rs`, declared in `gateway/src/stdb/store/mod.rs`. Add an -`impl Coordinator` method that makes the Durable Request as the `Actor` the handler resolved: +File: `gateway/src/stdb/store/.rs`, declared in `gateway/src/stdb/store/mod.rs`. It holds +`impl ActionStore for Coordinator`. Each trait method makes its Durable Request itself, as +the `Actor` the handler resolved: ```rust -let coord = self.0.call_pipe(); -call_reducer!( - coord.conn.reducers, - "gw_admit_meeting_stone", - gw_admit_meeting_stone_then(self.session_actor(actor), go_guid) -) +fn admit_meeting_stone(&self, actor: Actor, go_guid: u64) -> Result { + meeting_stone_outcome(call_reducer!( + self.0.call_pipe().conn.reducers, + "gw_admit_meeting_stone", + gw_admit_meeting_stone_then(self.session_actor(actor), go_guid) + )) +} ``` -A Realm-core call goes through `self.realm_core()?` first. Turn a parsed Refusal into a typed -outcome with `reducer_refusal_reason`, and return every other error unchanged. +Do not add an inherent `impl Coordinator` method for the trait method to forward to. Keep one only +when a Relay, logon or another family calls it too. A Realm-core call goes through +`self.realm_core()?` first. A Durable Request the Gateway makes for the World Session, with no +client acting, uses `self.owner_actor()`. A family with a typed Refusal parses it with +`reducer_refusal_reason` and returns every other error unchanged. ## 6. Add the Durable Reads -File: `gateway/src/stdb/store/.rs`. A read that a Relay or another family also calls goes -in `gateway/src/stdb/reads/`. Each read is an `impl Coordinator` method that finds rows 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. +File: `gateway/src/stdb/store/.rs`. Write the read in the trait method. A read that a Relay +or another family also calls is an `impl Coordinator` method in `gateway/src/stdb/reads/`. Find rows +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 @@ -87,9 +93,13 @@ a `pub(crate) use` of its public items. For an opcode in an existing family, add 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. -- `impl ActionStore for Coordinator` in `gateway/src/stdb/store/.rs`, each method - one call to step 5 or 6. + 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 @@ -133,7 +143,9 @@ File: `gateway/src/stdb/world_view.rs`. ## 12. Test each seam -- The dispatcher, through its local Fake, in the family file. +- The dispatcher, through its local Fake, in the family file. One Fake answers + `ReducerCallError::refused` and the client gets the Refusal answer. One answers + `ReducerCallError::transport_lost` and the dispatcher returns `Err`. - The packet bytes, in the codec file. - The Relay's recipient choice and packet, in the `world_view.rs` test modules. - The Module rule, in a durable test `module/tests/.rs`. CI picks up a new target with no diff --git a/gateway/src/realm_core.rs b/gateway/src/realm_core.rs index cf729c849..56ae890d7 100644 --- a/gateway/src/realm_core.rs +++ b/gateway/src/realm_core.rs @@ -6,7 +6,7 @@ use anyhow::{anyhow, Result}; use crate::config::ShardMap; use crate::stdb::{AccountRow, RealmRow}; -use crate::world::WorldSession; +use crate::world::{Actor, WorldSession}; /// The `game_session` row as the world handshake reads it: K and the end of its validity. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -30,9 +30,8 @@ fn now_micros() -> i64 { /// A SpacetimeDB database handle, reduced to exactly what the realm-core split touches. /// -/// Implemented by [`crate::stdb::Coordinator`] (production) and [`fake::Handle`] (tests). Every -/// method has an identically-named inherent method on `Coordinator`, so the production impl is a -/// block of forwards and no behaviour moves when a caller becomes generic. +/// Implemented by [`crate::stdb::Coordinator`] (production, `stdb/realm_db.rs`) and +/// [`fake::Handle`] (tests). pub(crate) trait RealmDb: Clone + Sized + Send + Sync { /// The database this handle targets. fn shard_name(&self) -> &str; @@ -64,12 +63,7 @@ pub(crate) trait RealmDb: Clone + Sized + Send + Sync { ) -> Result<()>; // --- Account-authorized command routing - fn request_gm_command( - &self, - actor_guid: u64, - alpha_test_tools: bool, - text: String, - ) -> Result<()>; + fn request_gm_command(&self, actor: Actor, alpha_test_tools: bool, text: String) -> Result<()>; // --- the character→shard index /// Where THIS database's own rows say the character is. `None` = not here. @@ -130,10 +124,7 @@ pub(crate) trait RealmDb: Clone + Sized + Send + Sync { ) -> Result<()>; /// Does THIS database hold an in-flight escrow (`game_transfer_out`) for `guid`? A shard /// answering `true` is the SOURCE of a resumed transfer and wins outright over any shard merely - /// holding a durable row for the guid — see [`locate_home_shard`]. The one method here whose - /// `Coordinator` forward is not a bare call (it narrows `escrow_row`'s `Option` to - /// a bool) — the narrowing itself carries no routing decision, so it cannot silently pick the - /// wrong database the way a real forward-body change could. + /// holding a durable row for the guid — see [`locate_home_shard`]. fn has_escrow(&self, guid: u64) -> bool; // --- Load sampling. The gateway is the one component that can see the whole realm (every @@ -173,7 +164,7 @@ fn unknown_account(account_name: &str) -> anyhow::Error { pub(crate) fn run_gm_command( home_shard: &D, account_name: &str, - actor_guid: u64, + actor: Actor, text: String, ) -> Result<()> { let account_name = account_name.to_ascii_uppercase(); @@ -182,7 +173,7 @@ pub(crate) fn run_gm_command( .account_by_username(&account_name)? .ok_or_else(|| unknown_account(&account_name))? .alpha_test_tools; - home_shard.request_gm_command(actor_guid, authority, text) + home_shard.request_gm_command(actor, authority, text) } // =============================================================================================== @@ -1058,16 +1049,16 @@ pub(crate) mod fake { } fn request_gm_command( &self, - actor_guid: u64, + actor: Actor, alpha_test_tools: bool, text: String, ) -> Result<()> { let db = self.store(); - db.note(&format!("request_gm_command({actor_guid})")); + db.note(&format!("request_gm_command({})", actor.guid())); db.gm_commands .lock() .unwrap() - .push((actor_guid, alpha_test_tools, text.clone())); + .push((actor.guid(), alpha_test_tools, text.clone())); if alpha_test_tools && text.starts_with(".speed") { Ok(()) } else { @@ -1321,6 +1312,10 @@ mod tests { const USER: &str = "PONYTAIL"; const K: [u8; 40] = [7u8; 40]; + fn gm() -> Actor { + Actor::new(42).unwrap() + } + fn split_realm() -> super::fake::Handle { let h = realm(&[WORLD, CORE], "", Some(CORE)); // realm-core: the authority. id 9, salt 0xAA. @@ -1348,7 +1343,7 @@ mod tests { .get_mut(USER) .expect("Realm-core Account") .alpha_test_tools = true; - run_gm_command(&h, &USER.to_ascii_lowercase(), 42, ".speed 3".into()) + run_gm_command(&h, &USER.to_ascii_lowercase(), gm(), ".speed 3".into()) .expect("Alpha Test Tools permit speed"); assert_eq!( @@ -1372,7 +1367,7 @@ mod tests { .get_mut(USER) .expect("Realm-core Account") .alpha_test_tools = true; - run_gm_command(&h, USER, 42, ".speed 3".into()).expect("first command is authorized"); + run_gm_command(&h, USER, gm(), ".speed 3".into()).expect("first command is authorized"); h.db_at(CORE) .accounts @@ -1381,7 +1376,7 @@ mod tests { .get_mut(USER) .expect("Realm-core Account") .alpha_test_tools = false; - let refusal = run_gm_command(&h, USER, 42, ".speed 4".into()) + let refusal = run_gm_command(&h, USER, gm(), ".speed 4".into()) .expect_err("the same World Session must see the revocation"); assert_eq!( @@ -1405,7 +1400,7 @@ mod tests { #[test] fn a_dead_realm_core_fails_before_the_home_shard_command_request() { let h = realm_with_dead_core(&[WORLD, CORE], "", CORE); - let error = run_gm_command(&h, USER, 42, ".speed 3".into()) + let error = run_gm_command(&h, USER, gm(), ".speed 3".into()) .expect_err("a dead Realm-core cannot authorize a command"); assert_eq!( crate::stdb::classify(&error), @@ -1418,7 +1413,7 @@ mod tests { #[test] fn an_unknown_account_is_a_refusal_the_gm_reads() { let h = split_realm(); - let error = run_gm_command(&h, "NOBODY", 42, ".speed 3".into()) + let error = run_gm_command(&h, "NOBODY", gm(), ".speed 3".into()) .expect_err("Realm-core holds no such Account"); assert_eq!( crate::stdb::classify(&error), diff --git a/gateway/src/stdb/account_sessions.rs b/gateway/src/stdb/account_sessions.rs index a1e723cf7..6db537e08 100644 --- a/gateway/src/stdb/account_sessions.rs +++ b/gateway/src/stdb/account_sessions.rs @@ -127,11 +127,6 @@ impl Coordinator { self.signed_actor(self.2.as_ref().map_or(0, |owner| owner.character_guid)) } - /// `guid` as the Actor, or the bound owner's Character when `guid` is 0. - pub(crate) fn actor_or_owner(&self, guid: u64) -> SessionActor { - Actor::new(guid).map_or_else(|| self.owner_actor(), |actor| self.session_actor(actor)) - } - fn signed_actor(&self, guid: u64) -> SessionActor { SessionActor { guid, diff --git a/gateway/src/stdb/connection.rs b/gateway/src/stdb/connection.rs index 9273a7df2..3c765072b 100644 --- a/gateway/src/stdb/connection.rs +++ b/gateway/src/stdb/connection.rs @@ -2518,21 +2518,6 @@ impl Coordinator { .send(PumpCommand::Resume) .unwrap(); } - - /// Live player-session count on THIS shard: player entities (`account_id != 0`) in the - /// shard's coordinator cache. Shard truth, so horizontally-scaled gateways report the same - /// number instead of each undercounting to its own connections. Good enough for an ops gauge. - pub(crate) fn session_count(&self) -> usize { - let guard = self.0.coord(); - let n = guard - .conn - .db - .game_world_entity() - .iter() - .filter(|e| e.account_id != 0) - .count(); - n - } } impl Coordinator { diff --git a/gateway/src/stdb/mod.rs b/gateway/src/stdb/mod.rs index 23771e385..6e4ff1d8a 100644 --- a/gateway/src/stdb/mod.rs +++ b/gateway/src/stdb/mod.rs @@ -5,12 +5,17 @@ //! //! Wired via `spacetimedb_sdk` (connect, subscribe, reducer calls). //! -//! This module is split by concern behind a thin facade (pure code-motion): +//! Each Store family trait is implemented on `Coordinator` in `store/.rs`; its trait +//! methods call the reducer directly. `classify` decides whether a failed call is a Refusal or a +//! Transport Loss. //! - `connection`: the `Coordinator` facade + inner state + live connections + watchdog + -//! the `call_reducer!` macro + lifecycle constructors. -//! - `account_sessions`: durable Account ownership and socket closure on renewal failure. -//! - `reads`: cache-accessor `Coordinator` methods (RLS-bypass reads → codec views). -//! - `reducers`: reducer-call wrapper `Coordinator` methods. +//! the `call_reducer!` macro, `classify` + lifecycle constructors. +//! - `account_sessions`: durable Account ownership, the Actor's signed `SessionActor`, and +//! socket closure on renewal failure. +//! - `reads`: cache reads that several families, relays or logon share. +//! - `reducers`: reducer calls no Store trait owns (relays, logon, provisioning, heartbeat). +//! - `realm_db`: `RealmDb` for `Coordinator`, the Realm-core routing seam. +//! - `store`: one Store family adapter per file. //! - `subscriptions`: `PlayerSubscriptions`, viewer setup, and shared packet builders. //! - `views`: row→view converters + the thin `RealmRow`/`AccountRow` mirrors. @@ -27,12 +32,12 @@ mod connection; mod member_stats_relay; // Member Stats for group mates outside the viewer's AOI mod movement_batch; mod reads; +mod realm_db; // impl RealmDb for Coordinator mod reducers; mod store; // one Store family adapter per file pub(crate) mod subscriptions; mod views; pub(crate) mod world_index; -mod world_store; // impl RealmDb for Coordinator pub(crate) mod world_view; // shared per-shard spatial, broadcast, private, and owner dispatch pub use connection::Coordinator; diff --git a/gateway/src/stdb/reads/account.rs b/gateway/src/stdb/reads/account.rs index d1d51f0eb..1aaf50db1 100644 --- a/gateway/src/stdb/reads/account.rs +++ b/gateway/src/stdb/reads/account.rs @@ -111,20 +111,6 @@ impl Coordinator { .find(|r| r.character_guid == guid) } - /// The realm-core character→shard index entry for `guid`: the `(map_id, instance_id)` the realm - /// believes the character is at. A HINT — `realm_core::locate_home_shard` confirms it against - /// the shard that actually holds the row before routing anything to it. - pub fn character_shard(&self, guid: u64) -> Option<(u32, u64)> { - self.0 - .coord() - .conn - .db - .game_character_shard() - .character_guid() - .find(&guid) - .map(|s| (s.map_id, s.instance_id)) - } - pub(crate) fn realm_character_partition( &self, guid: u64, diff --git a/gateway/src/stdb/reads/items.rs b/gateway/src/stdb/reads/items.rs index fe4f54960..d818c5db2 100644 --- a/gateway/src/stdb/reads/items.rs +++ b/gateway/src/stdb/reads/items.rs @@ -54,10 +54,9 @@ impl Coordinator { .collect() } - /// Bag slot of the item instance with `item_guid`. Reads from the coordinator's privileged cache - /// (same source as `player_items`). Item GUIDs are globally unique so account_id isn't needed - /// for the lookup — ownership is enforced by the module reducer on the call. - pub fn item_slot_by_guid(&self, _account_id: u64, item_guid: u64) -> Option { + /// Bag slot of the item instance with `item_guid`, from the privileged cache. The Module + /// reducer that uses the slot enforces ownership. + pub fn item_slot_by_guid(&self, item_guid: u64) -> Option { // `collect()` forces the iterator to complete (and drop its borrow from `guard`) before `guard` // itself drops — same pattern as `player_items`. let guard = self.0.coord(); diff --git a/gateway/src/stdb/realm_db.rs b/gateway/src/stdb/realm_db.rs new file mode 100644 index 000000000..928ec90de --- /dev/null +++ b/gateway/src/stdb/realm_db.rs @@ -0,0 +1,254 @@ +//! `Coordinator`'s [`RealmDb`] adapter. + +use anyhow::{anyhow, Result}; +use spacetimedb_sdk::Table; + +use crate::realm_core::{RealmDb, SessionKey}; +use crate::stdb::bindings::*; +use crate::stdb::connection::call_reducer; +use crate::stdb::{AccountRow, Coordinator, RealmRow}; +use crate::world::Actor; + +/// `realm_core.rs`'s Fake replaces this whole block. Reads that other code paths also use stay +/// inherent on `Coordinator`; a Realm-only read or reducer call lives here. +impl RealmDb for Coordinator { + fn shard_name(&self) -> &str { + self.shard_name() + } + fn is_sharded(&self) -> bool { + self.is_sharded() + } + fn shard_map(&self) -> &crate::config::ShardMap { + self.shard_map() + } + fn realm_core(&self) -> Result { + self.realm_core() + } + fn world_shards(&self) -> Vec<(String, Coordinator)> { + self.world_shards() + } + fn account_by_username(&self, username: &str) -> Result> { + self.account_by_username(username) + } + fn session_key(&self, account_id: u64) -> Result> { + self.session_key(account_id) + } + fn bound_identity(&self, account_id: u64) -> Result<[u8; 32]> { + self.bound_identity(account_id) + } + fn character_count(&self, account_id: u64) -> Result { + self.character_count(account_id) + } + fn realm(&self) -> Result { + self.realm() + } + fn establish_session( + &self, + account_id: u64, + session_key: &[u8; 40], + bound_identity: [u8; 32], + ) -> Result<()> { + self.establish_session(account_id, session_key, bound_identity) + } + + /// The Module combines the conveyed Account authority with its own Character GM level and + /// remains the final Gate. Its Refusal reason is the text the GM sees. + fn request_gm_command(&self, actor: Actor, alpha_test_tools: bool, text: String) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "gw_gm_command", + gw_gm_command_then(self.session_actor(actor), alpha_test_tools, text) + ) + } + + fn character_location(&self, guid: u64) -> Option<(u32, u64)> { + self.character_location(guid) + } + + fn character_shard(&self, guid: u64) -> Option<(u32, u64)> { + self.0 + .coord() + .conn + .db + .game_character_shard() + .character_guid() + .find(&guid) + .map(|s| (s.map_id, s.instance_id)) + } + + /// Call it on the Realm-core handle: on a World Shard `finish_transfer` maintains the index + /// in the same transaction. + fn set_character_shard(&self, guid: u64, map_id: u32, instance_id: u64) -> Result<()> { + let actor = character_actor(self, guid)?; + call_reducer!( + self.0.call_pipe().conn.reducers, + "set_character_shard", + set_character_shard_then(guid, map_id, instance_id, actor) + ) + } + + fn realm_character_partition( + &self, + guid: u64, + ) -> Result> { + self.realm_character_partition(guid) + } + + // The arguments mirror the Realm reducer's exact Transfer Gate. + fn begin_character_shard_transfer( + &self, + source_map: u32, + source_instance: u64, + source_revision: u64, + destination_map: u32, + destination_instance: u64, + source_module_identity: spacetimedb_sdk::Identity, + intent_id: u64, + controller_generation: u64, + character_guid: u64, + ) -> Result<()> { + let actor = character_actor(self, character_guid)?; + call_reducer!( + self.0.call_pipe().conn.reducers, + "begin_character_shard_transfer", + begin_character_shard_transfer_then( + character_guid, + source_map, + source_instance, + source_revision, + destination_map, + destination_instance, + source_module_identity, + intent_id, + controller_generation, + actor + ) + ) + } + + fn finish_character_shard_transfer( + &self, + intent: &crate::world::transfer::BotTransferIntent, + ) -> Result<()> { + let actor = character_actor(self, intent.bot_guid)?; + call_reducer!( + self.0.call_pipe().conn.reducers, + "finish_character_shard_transfer", + finish_character_shard_transfer_then( + intent.bot_guid, + intent.source_map, + intent.source_instance, + intent.source_locator_revision, + intent.destination_map, + intent.destination_instance, + intent.source_module_identity, + intent.id, + intent.controller_generation, + actor + ) + ) + } + + fn finish_player_character_shard_transfer( + &self, + character_guid: u64, + source_map: u32, + source_instance: u64, + source_revision: u64, + destination_map: u32, + destination_instance: u64, + ) -> Result<()> { + let actor = character_actor(self, character_guid)?; + call_reducer!( + self.0.call_pipe().conn.reducers, + "finish_character_shard_transfer", + finish_character_shard_transfer_then( + character_guid, + source_map, + source_instance, + source_revision, + destination_map, + destination_instance, + spacetimedb_sdk::Identity::ZERO, + 0, + 0, + actor + ) + ) + } + + fn finish_pending_character_shard_transfer( + &self, + character_guid: u64, + source_map: u32, + source_instance: u64, + source_revision: u64, + destination_map: u32, + destination_instance: u64, + source_module_identity: spacetimedb_sdk::Identity, + transfer_intent_id: u64, + controller_generation: u64, + ) -> Result<()> { + let actor = character_actor(self, character_guid)?; + call_reducer!( + self.0.call_pipe().conn.reducers, + "finish_pending_character_shard_transfer", + finish_pending_character_shard_transfer_then( + character_guid, + source_map, + source_instance, + source_revision, + destination_map, + destination_instance, + source_module_identity, + transfer_intent_id, + controller_generation, + actor + ) + ) + } + + fn has_escrow(&self, guid: u64) -> bool { + self.escrow_row(guid).is_some() + } + + /// Player entities (`account_id != 0`) in this shard's cache. Shard truth, so every Gateway + /// reports the same number. + fn session_count(&self) -> usize { + self.0 + .coord() + .conn + .db + .game_world_entity() + .iter() + .filter(|e| e.account_id != 0) + .count() + } + + /// Callers hold the Realm-core handle: only Realm-core reads `game_shard_load`. + fn record_shard_load( + &self, + shard: &str, + writer_occupancy_pct: f32, + sessions: u32, + gateway_key: u64, + ) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "record_shard_load", + record_shard_load_then( + shard.to_string(), + writer_occupancy_pct, + sessions, + gateway_key + ) + ) + } +} + +/// The Realm index reducers act for the Character they move. +fn character_actor(coordinator: &Coordinator, character_guid: u64) -> Result { + let actor = Actor::new(character_guid) + .ok_or_else(|| anyhow!("Character guid 0 cannot act in the Realm index"))?; + Ok(coordinator.session_actor(actor)) +} diff --git a/gateway/src/stdb/reducers.rs b/gateway/src/stdb/reducers.rs index dc6f8ebbe..ac9bdf58e 100644 --- a/gateway/src/stdb/reducers.rs +++ b/gateway/src/stdb/reducers.rs @@ -1,14 +1,13 @@ -//! Durable Requests on `Coordinator` that no single Store family owns: realm-core routing, -//! Transfer intents, provisioning and the helpers several families share. A family's own -//! reducer calls live in `store/.rs`. +//! Durable Requests on `Coordinator` that no Store trait owns: the relays' intent claims, logon, +//! provisioning and the Gateway heartbeat. A family's reducer calls live in `store/.rs`, +//! the Realm index calls in `realm_db.rs`. -use anyhow::{anyhow, Result}; +use anyhow::Result; use spacetimedb_sdk::Identity; use std::time::Duration; use super::bindings::*; use super::connection::{call_reducer, Coordinator}; -use crate::world::Actor; impl Coordinator { pub fn claim_bot_transfer_intent( @@ -111,215 +110,4 @@ impl Coordinator { ) ) } - - /// Publish `character_guid`'s location into this handle's character→shard index. Call it - /// on the REALM-CORE handle: on a world shard the index is already maintained transactionally by - /// `finish_transfer`. Operator-gated module-side (the index is a routing input). - pub fn set_character_shard( - &self, - character_guid: u64, - map_id: u32, - instance_id: u64, - ) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "set_character_shard", - set_character_shard_then( - character_guid, - map_id, - instance_id, - self.actor_or_owner(character_guid) - ) - ) - } - - // The arguments mirror the Realm reducer's exact Transfer Gate. - #[allow(clippy::too_many_arguments)] - pub fn begin_character_shard_transfer( - &self, - source_map: u32, - source_instance: u64, - source_revision: u64, - destination_map: u32, - destination_instance: u64, - source_module_identity: spacetimedb_sdk::Identity, - intent_id: u64, - controller_generation: u64, - character_guid: u64, - ) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "begin_character_shard_transfer", - begin_character_shard_transfer_then( - character_guid, - source_map, - source_instance, - source_revision, - destination_map, - destination_instance, - source_module_identity, - intent_id, - controller_generation, - self.actor_or_owner(character_guid) - ) - ) - } - - pub fn finish_character_shard_transfer( - &self, - intent: &crate::world::transfer::BotTransferIntent, - ) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "finish_character_shard_transfer", - finish_character_shard_transfer_then( - intent.bot_guid, - intent.source_map, - intent.source_instance, - intent.source_locator_revision, - intent.destination_map, - intent.destination_instance, - intent.source_module_identity, - intent.id, - intent.controller_generation, - self.actor_or_owner(intent.bot_guid) - ) - ) - } - - pub fn finish_player_character_shard_transfer( - &self, - character_guid: u64, - source_map: u32, - source_instance: u64, - source_revision: u64, - destination_map: u32, - destination_instance: u64, - ) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "finish_character_shard_transfer", - finish_character_shard_transfer_then( - character_guid, - source_map, - source_instance, - source_revision, - destination_map, - destination_instance, - spacetimedb_sdk::Identity::ZERO, - 0, - 0, - self.actor_or_owner(character_guid) - ) - ) - } - - // The arguments mirror the Realm reducer's exact Transfer Gate. - #[allow(clippy::too_many_arguments)] - pub fn finish_pending_character_shard_transfer( - &self, - character_guid: u64, - source_map: u32, - source_instance: u64, - source_revision: u64, - destination_map: u32, - destination_instance: u64, - source_module_identity: spacetimedb_sdk::Identity, - transfer_intent_id: u64, - controller_generation: u64, - ) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "finish_pending_character_shard_transfer", - finish_pending_character_shard_transfer_then( - character_guid, - source_map, - source_instance, - source_revision, - destination_map, - destination_instance, - source_module_identity, - transfer_intent_id, - controller_generation, - self.actor_or_owner(character_guid) - ) - ) - } - - /// Send one classified command request to this Home Shard. The Module combines the conveyed - /// Account authority with its own Character GM level and remains the final Gate. Its Refusal - /// reason is the text the GM sees. - pub(crate) fn request_gm_command( - &self, - actor_guid: u64, - alpha_test_tools: bool, - text: String, - ) -> Result<()> { - let actor = - Actor::new(actor_guid).ok_or_else(|| anyhow!("gm_command: actor_guid unresolved"))?; - call_reducer!( - self.0.call_pipe().conn.reducers, - "gw_gm_command", - gw_gm_command_then(self.session_actor(actor), alpha_test_tools, text) - ) - } - - // ------------------------------------------------------------------------------------- - // Cross-database transfer. ALL of these are operator-gated orchestration (`require_operator`), - // and the - // destination shard has no bound player identity until the character has arrived on it — which - // is precisely what they exist to make happen. - // ------------------------------------------------------------------------------------- - - /// `record_shard_load` — fired against THIS handle's connection. Callers hold the - /// **realm-core** handle: `game_shard_load` is only ever read from there. - pub fn record_shard_load( - &self, - shard: &str, - writer_occupancy_pct: f32, - sessions: u32, - gateway_key: u64, - ) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "record_shard_load", - record_shard_load_then( - shard.to_string(), - writer_occupancy_pct, - sessions, - gateway_key - ) - ) - } -} - -/// Wait up to two seconds for a row a value-flow reducer just committed to reach this handle's -/// cache. -pub(super) fn wait_for_cache_row( - operation_id: u64, - row_name: &str, - mut read: impl FnMut() -> Option, -) -> Result { - for _ in 0..100 { - if let Some(row) = read() { - return Ok(row); - } - std::thread::sleep(Duration::from_millis(20)); - } - Err(anyhow!( - "{row_name} {operation_id} committed but is not visible in the coordinator cache" - )) -} - -/// A random nonzero operation id for one auction or guild fee value flow. -pub(super) fn next_operation_id() -> Result { - loop { - let mut bytes = [0; 8]; - getrandom::fill(&mut bytes) - .map_err(|error| anyhow!("OS randomness unavailable: {error}"))?; - let operation_id = u64::from_le_bytes(bytes); - if operation_id != 0 { - return Ok(operation_id); - } - } } diff --git a/gateway/src/stdb/store/auction.rs b/gateway/src/stdb/store/auction.rs index 98d7ba5de..3c5866cee 100644 --- a/gateway/src/stdb/store/auction.rs +++ b/gateway/src/stdb/store/auction.rs @@ -7,9 +7,9 @@ use lyracore_shared::auction::AuctionRefusal; use lyracore_shared::item::Proficiency; use spacetimedb_sdk::Table; +use super::{next_operation_id, wait_for_cache_row}; use crate::stdb::bindings::*; use crate::stdb::connection::{call_reducer, classify, DurableFailure}; -use crate::stdb::reducers::{next_operation_id, wait_for_cache_row}; use crate::stdb::Coordinator; use crate::world::{ Actor, AuctionActionStore, AuctionBrowseRequest, AuctionHousePolicy, AuctionInteraction, diff --git a/gateway/src/stdb/store/cast.rs b/gateway/src/stdb/store/cast.rs index 3a0bd4376..541a76ae6 100644 --- a/gateway/src/stdb/store/cast.rs +++ b/gateway/src/stdb/store/cast.rs @@ -260,8 +260,8 @@ impl CastStore for Coordinator { } // The shared reads stay inherent: vendor and the Character reads call them too. - fn item_slot_by_guid(&self, account_id: u64, item_guid: u64) -> Option { - Coordinator::item_slot_by_guid(self, account_id, item_guid) + fn item_slot_by_guid(&self, item_guid: u64) -> Option { + Coordinator::item_slot_by_guid(self, item_guid) } fn player_items(&self, owner_guid: u64) -> Result> { diff --git a/gateway/src/stdb/store/guild_fee.rs b/gateway/src/stdb/store/guild_fee.rs index c9ece3187..4ab15200f 100644 --- a/gateway/src/stdb/store/guild_fee.rs +++ b/gateway/src/stdb/store/guild_fee.rs @@ -3,9 +3,9 @@ use anyhow::{anyhow, Result}; use lyracore_shared::guild::GuildRefusal; +use super::{next_operation_id, wait_for_cache_row}; use crate::stdb::bindings::*; use crate::stdb::connection::{call_reducer, reducer_refusal_reason}; -use crate::stdb::reducers::{next_operation_id, wait_for_cache_row}; use crate::stdb::Coordinator; use crate::world::guild_fee; use crate::world::guild_fee::{FeeHold, FeeOutcome, FeeRequest, FeeTerms, GuildFeeStore}; diff --git a/gateway/src/stdb/store/mod.rs b/gateway/src/stdb/store/mod.rs index 9f934fe15..c7af60df1 100644 --- a/gateway/src/stdb/store/mod.rs +++ b/gateway/src/stdb/store/mod.rs @@ -6,6 +6,9 @@ //! `self.characters(..)` here calls the inherent `Coordinator::characters`, not the trait method: //! these are thin views, not recursion. +use anyhow::{anyhow, Result}; +use std::time::Duration; + mod auction; mod bank; mod cast; @@ -37,3 +40,34 @@ mod trainer; mod transfer; mod vendor; mod weather; + +/// Wait up to two seconds for a row a value-flow reducer just committed to reach this handle's +/// cache. +fn wait_for_cache_row( + operation_id: u64, + row_name: &str, + mut read: impl FnMut() -> Option, +) -> Result { + for _ in 0..100 { + if let Some(row) = read() { + return Ok(row); + } + std::thread::sleep(Duration::from_millis(20)); + } + Err(anyhow!( + "{row_name} {operation_id} committed but is not visible in the coordinator cache" + )) +} + +/// A random nonzero operation id for one auction or guild fee value flow. +fn next_operation_id() -> Result { + loop { + let mut bytes = [0; 8]; + getrandom::fill(&mut bytes) + .map_err(|error| anyhow!("OS randomness unavailable: {error}"))?; + let operation_id = u64::from_le_bytes(bytes); + if operation_id != 0 { + return Ok(operation_id); + } + } +} diff --git a/gateway/src/stdb/store/speech.rs b/gateway/src/stdb/store/speech.rs index 24efa6d6a..6edb28684 100644 --- a/gateway/src/stdb/store/speech.rs +++ b/gateway/src/stdb/store/speech.rs @@ -49,6 +49,6 @@ impl SpeechStore for Coordinator { /// Module's reason, or the Gateway's own when the Account is unknown. Realm-core being /// unreachable is a Transport Loss. fn gm_command(&self, account_name: &str, actor: Actor, text: String) -> Result<()> { - crate::realm_core::run_gm_command(self, account_name, actor.guid(), text) + crate::realm_core::run_gm_command(self, account_name, actor, text) } } diff --git a/gateway/src/stdb/store/vendor.rs b/gateway/src/stdb/store/vendor.rs index f03f5099f..b4e6981eb 100644 --- a/gateway/src/stdb/store/vendor.rs +++ b/gateway/src/stdb/store/vendor.rs @@ -95,7 +95,7 @@ impl VendorActionStore for crate::stdb::Coordinator { } fn vendor_item_slot(&self, item_guid: u64) -> Option { - crate::stdb::Coordinator::item_slot_by_guid(self, 0, item_guid) + crate::stdb::Coordinator::item_slot_by_guid(self, item_guid) } /// The module gates the REPAIR NPC and charges copper; the player's item and purse replicate diff --git a/gateway/src/stdb/subscriptions_character_gone_durable_tests.rs b/gateway/src/stdb/subscriptions_character_gone_durable_tests.rs index b97a1a1ee..5776a40c3 100644 --- a/gateway/src/stdb/subscriptions_character_gone_durable_tests.rs +++ b/gateway/src/stdb/subscriptions_character_gone_durable_tests.rs @@ -2,6 +2,7 @@ use super::*; use crate::accept::BlockingTaskCapacity; use crate::config::GatewayConfig; use crate::durable_test_support::{poll_until, Standalone, POLL_TIMEOUT}; +use crate::realm_core::RealmDb as _; use crate::world::party::PartyOutcome; use crate::world::{Actor, PartyStore}; use std::path::{Path, PathBuf}; diff --git a/gateway/src/stdb/subscriptions_party_command_durable_tests.rs b/gateway/src/stdb/subscriptions_party_command_durable_tests.rs index 5b01de4e4..e3d6b57ae 100644 --- a/gateway/src/stdb/subscriptions_party_command_durable_tests.rs +++ b/gateway/src/stdb/subscriptions_party_command_durable_tests.rs @@ -6,6 +6,7 @@ use super::{party_command_intent, Coordinator, DURABLE_TOPOLOGY_ENV_LOCK}; use crate::accept::BlockingTaskCapacity; use crate::config::GatewayConfig; use crate::durable_test_support::{module_bytes, poll_until, Standalone, POLL_TIMEOUT}; +use crate::realm_core::RealmDb as _; use crate::stdb::bindings::GamePartyCommandIntentTableAccess; use crate::world::party::{self, AdmittedCompanionCommand, CompanionCommandOutcome}; use crate::world::{Actor, PartyStore, SessionStore, TransferStore}; diff --git a/gateway/src/stdb/world_store.rs b/gateway/src/stdb/world_store.rs deleted file mode 100644 index de42c9abf..000000000 --- a/gateway/src/stdb/world_store.rs +++ /dev/null @@ -1,166 +0,0 @@ -//! `Coordinator`'s [`RealmDb`](crate::realm_core::RealmDb) adapter. - -use anyhow::Result; - -use crate::realm_core::SessionKey; - -use super::connection::Coordinator; -use super::views::{AccountRow, RealmRow}; - -/// The realm-core split's store seam. Every method here is a view onto -/// `Coordinator`'s like-named INHERENT method — Rust resolves inherent methods first, so these are -/// forwards, not recursion, exactly like the Store family impls in `stdb::store`. -/// -/// This block is the ONE layer `realm_core.rs`'s fake substitutes for wholesale. Keep it a block of -/// forwards; any logic that grows here is untested by construction. `has_escrow` narrows -/// `Option` to a bool. -impl crate::realm_core::RealmDb for Coordinator { - fn shard_name(&self) -> &str { - self.shard_name() - } - fn is_sharded(&self) -> bool { - self.is_sharded() - } - fn shard_map(&self) -> &crate::config::ShardMap { - self.shard_map() - } - fn realm_core(&self) -> Result { - self.realm_core() - } - fn world_shards(&self) -> Vec<(String, Coordinator)> { - self.world_shards() - } - fn account_by_username(&self, username: &str) -> Result> { - self.account_by_username(username) - } - fn session_key(&self, account_id: u64) -> Result> { - self.session_key(account_id) - } - fn bound_identity(&self, account_id: u64) -> Result<[u8; 32]> { - self.bound_identity(account_id) - } - fn character_count(&self, account_id: u64) -> Result { - self.character_count(account_id) - } - fn realm(&self) -> Result { - self.realm() - } - fn establish_session( - &self, - account_id: u64, - session_key: &[u8; 40], - bound_identity: [u8; 32], - ) -> Result<()> { - self.establish_session(account_id, session_key, bound_identity) - } - fn request_gm_command( - &self, - actor_guid: u64, - alpha_test_tools: bool, - text: String, - ) -> Result<()> { - self.request_gm_command(actor_guid, alpha_test_tools, text) - } - fn character_location(&self, guid: u64) -> Option<(u32, u64)> { - self.character_location(guid) - } - fn character_shard(&self, guid: u64) -> Option<(u32, u64)> { - self.character_shard(guid) - } - fn set_character_shard(&self, guid: u64, map_id: u32, instance_id: u64) -> Result<()> { - self.set_character_shard(guid, map_id, instance_id) - } - fn realm_character_partition( - &self, - guid: u64, - ) -> Result> { - self.realm_character_partition(guid) - } - fn begin_character_shard_transfer( - &self, - source_map: u32, - source_instance: u64, - source_revision: u64, - destination_map: u32, - destination_instance: u64, - source_module_identity: spacetimedb_sdk::Identity, - intent_id: u64, - controller_generation: u64, - character_guid: u64, - ) -> Result<()> { - self.begin_character_shard_transfer( - source_map, - source_instance, - source_revision, - destination_map, - destination_instance, - source_module_identity, - intent_id, - controller_generation, - character_guid, - ) - } - fn finish_character_shard_transfer( - &self, - intent: &crate::world::transfer::BotTransferIntent, - ) -> Result<()> { - self.finish_character_shard_transfer(intent) - } - fn finish_player_character_shard_transfer( - &self, - character_guid: u64, - source_map: u32, - source_instance: u64, - source_revision: u64, - destination_map: u32, - destination_instance: u64, - ) -> Result<()> { - self.finish_player_character_shard_transfer( - character_guid, - source_map, - source_instance, - source_revision, - destination_map, - destination_instance, - ) - } - fn finish_pending_character_shard_transfer( - &self, - character_guid: u64, - source_map: u32, - source_instance: u64, - source_revision: u64, - destination_map: u32, - destination_instance: u64, - source_module_identity: spacetimedb_sdk::Identity, - transfer_intent_id: u64, - controller_generation: u64, - ) -> Result<()> { - self.finish_pending_character_shard_transfer( - character_guid, - source_map, - source_instance, - source_revision, - destination_map, - destination_instance, - source_module_identity, - transfer_intent_id, - controller_generation, - ) - } - fn has_escrow(&self, guid: u64) -> bool { - self.escrow_row(guid).is_some() - } - fn session_count(&self) -> usize { - self.session_count() - } - fn record_shard_load( - &self, - shard: &str, - writer_occupancy_pct: f32, - sessions: u32, - gateway_key: u64, - ) -> Result<()> { - self.record_shard_load(shard, writer_occupancy_pct, sessions, gateway_key) - } -} diff --git a/gateway/src/world/handlers/cast/manual.rs b/gateway/src/world/handlers/cast/manual.rs index 1111443e1..1334a3ffd 100644 --- a/gateway/src/world/handlers/cast/manual.rs +++ b/gateway/src/world/handlers/cast/manual.rs @@ -59,7 +59,7 @@ pub(crate) fn manual_completion_cast( if item_guid == 0 { break 'request Err("enchant: no item target in cast".to_string()); } - let Some(slot) = store.item_slot_by_guid(player.account_id, item_guid) else { + let Some(slot) = store.item_slot_by_guid(item_guid) else { break 'request Err(format!("enchant: item {item_guid} not in player bag")); }; match enchant_route { diff --git a/gateway/src/world/handlers/cast/mod.rs b/gateway/src/world/handlers/cast/mod.rs index 2bc8682c9..fc3d695c7 100644 --- a/gateway/src/world/handlers/cast/mod.rs +++ b/gateway/src/world/handlers/cast/mod.rs @@ -74,7 +74,7 @@ pub(crate) trait CastStore: MeleeActionStore + Send + Sync { /// The bag slot holding the item instance a client spell-target names, so the enchant and /// disenchant operations receive a slot rather than a guid. - fn item_slot_by_guid(&self, account_id: u64, item_guid: u64) -> Option; + fn item_slot_by_guid(&self, item_guid: u64) -> Option; /// Disenchant the item in `slot`. The module validates skill and disenchantability, and yields /// the resulting reagents into the bag. @@ -291,7 +291,7 @@ fn ordinary_cast( break 'request Some(NO_ACTOR.to_string()); }; let result = if item_guid != 0 { - let Some(slot) = store.item_slot_by_guid(player.account_id, item_guid) else { + 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" )); @@ -489,7 +489,7 @@ pub(super) mod tests { self.answer("gw_cast_item_target", &self.cast_error) } - fn item_slot_by_guid(&self, _account_id: u64, item_guid: u64) -> Option { + fn item_slot_by_guid(&self, item_guid: u64) -> Option { self.item_slots .iter() .find(|(g, _)| *g == item_guid) diff --git a/gateway/src/world/party_tests.rs b/gateway/src/world/party_tests.rs index 00cc2bfe9..260a165a0 100644 --- a/gateway/src/world/party_tests.rs +++ b/gateway/src/world/party_tests.rs @@ -5359,7 +5359,10 @@ fn a_group_invite_transport_loss_is_not_answered_as_a_refusal() { .recv_timeout(std::time::Duration::from_secs(1)) .expect("an unknown party outcome must end the session promptly") .expect_err("a party Transport Loss must be session-fatal"); - assert!(format!("{error:#}").contains("transport disconnected")); + assert_eq!( + crate::stdb::classify(&error), + crate::stdb::DurableFailure::TransportLoss + ); } #[test] diff --git a/gateway/src/world/session_tests.rs b/gateway/src/world/session_tests.rs index 352829d52..c4756b69f 100644 --- a/gateway/src/world/session_tests.rs +++ b/gateway/src/world/session_tests.rs @@ -1373,8 +1373,9 @@ fn a_movement_failure_that_is_not_a_desync_is_still_session_fatal() { .join() .unwrap() .expect_err("a non-desync movement failure must still end the session"); - assert!( - format!("{err:#}").contains("transport disconnected"), + assert_eq!( + crate::stdb::classify(&err), + crate::stdb::DurableFailure::TransportLoss, "{err:#}" ); } @@ -2064,7 +2065,10 @@ fn reducer_transport_loss_ends_an_admitted_session_and_frees_one_queue_seat() { .recv_timeout(std::time::Duration::from_secs(1)) .expect("transport loss must end the session promptly") .expect_err("a disconnected reducer transport must end the world session"); - assert!(format!("{err:#}").contains("transport disconnected")); + assert_eq!( + crate::stdb::classify(&err), + crate::stdb::DurableFailure::TransportLoss + ); assert!( ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).is_err(), "the world socket closes after the fatal reducer result" diff --git a/gateway/src/world/social_tests.rs b/gateway/src/world/social_tests.rs index fbabeac0d..a9ab608f5 100644 --- a/gateway/src/world/social_tests.rs +++ b/gateway/src/world/social_tests.rs @@ -434,7 +434,10 @@ fn an_add_friend_transport_loss_is_not_answered_as_a_refusal() { .recv_timeout(std::time::Duration::from_secs(1)) .expect("an unknown contact outcome must end the session promptly") .expect_err("a contact Transport Loss must be session-fatal"); - assert!(format!("{error:#}").contains("transport disconnected")); + assert_eq!( + crate::stdb::classify(&error), + crate::stdb::DurableFailure::TransportLoss + ); } #[test] diff --git a/gateway/src/world/test_support/world_fake/cast.rs b/gateway/src/world/test_support/world_fake/cast.rs index d6b2309c0..22b4d61bc 100644 --- a/gateway/src/world/test_support/world_fake/cast.rs +++ b/gateway/src/world/test_support/world_fake/cast.rs @@ -60,7 +60,7 @@ impl CastStore for WorldFake { ) -> Result<()> { Ok(()) } - fn item_slot_by_guid(&self, _account_id: u64, _item_guid: u64) -> Option { + fn item_slot_by_guid(&self, _item_guid: u64) -> Option { None } fn disenchant_item(&self, _actor: Actor, _slot: u8) -> Result<()> { From e09a41e62f11e8d22221d37dbbad59354da1e573 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 19:29:25 +0200 Subject: [PATCH 32/33] fix(gateway): keep taxi failures fatal, as before the one-hop change An activation commits before its reply is acknowledged, so answering a later Refusal with a server error would leave a durable activation and no flight. Co-Authored-By: Claude Opus 5.5 (1M context) --- gateway/src/world/handlers/taxi.rs | 53 ++++++++---------------------- 1 file changed, 13 insertions(+), 40 deletions(-) diff --git a/gateway/src/world/handlers/taxi.rs b/gateway/src/world/handlers/taxi.rs index a772138ca..4d09d1e33 100644 --- a/gateway/src/world/handlers/taxi.rs +++ b/gateway/src/world/handlers/taxi.rs @@ -2,7 +2,6 @@ //! catalogue, discovery, range, or topology tables and therefore cannot fork module policy. use super::super::*; -use crate::stdb::{classify, DurableFailure}; pub(crate) trait TaxiActionStore: Send + Sync { fn taxi_node_status( @@ -41,18 +40,6 @@ pub(crate) enum TaxiActionOutcome { PassThrough(ClientOpcodeMessage), } -/// A Refusal means the Module said no and nothing changed, so the caller answers with `refused`. A -/// Transport Loss leaves the outcome unknown and ends the World Session. -fn or_refused(result: Result, refused: T) -> Result { - result.or_else(|error| match classify(&error) { - DurableFailure::Refusal { reason } => { - log::debug!("world: taxi request refused: {reason}"); - Ok(refused) - } - DurableFailure::TransportLoss => Err(error), - }) -} - fn status_outbound( store: &St, actor: Option, @@ -61,7 +48,7 @@ fn status_outbound( let Some(actor) = actor else { return Ok(Vec::new()); }; - match or_refused(store.taxi_node_status(actor, npc_guid), None)? { + match store.taxi_node_status(actor, npc_guid)? { Some(view) => Ok(vec![Outbound::One( ServerOpcodeMessage::SMSG_TAXINODE_STATUS(Box::new(codec::build_taxi_node_status( view, @@ -80,14 +67,11 @@ fn activate_taxi_outbound( ) -> Result<(Vec, bool)> { let server_error = codec::TaxiActivationResult::default(); let result = match actor { - Some(actor) => or_refused( - store.activate_taxi( - actor, - npc_guid, - source_client_node_id, - destination_client_node_id, - ), - server_error, + Some(actor) => store.activate_taxi( + actor, + npc_guid, + source_client_node_id, + destination_client_node_id, )?, None => server_error, }; @@ -128,7 +112,7 @@ pub(crate) fn open_taxi_outbound( let Some(actor) = actor else { return Ok(Vec::new()); }; - match or_refused(store.open_taxi(actor, npc_guid), None)? { + match store.open_taxi(actor, npc_guid)? { Some(view) => Ok(vec![Outbound::One( ServerOpcodeMessage::SMSG_SHOWTAXINODES(Box::new(codec::build_show_taxi_nodes(&view))), )]), @@ -363,28 +347,17 @@ pub(super) mod tests { } } + /// A taxi answer arrives only after the request commits, so any failure ends the World Session. #[test] - fn a_refusal_answers_without_arming_and_keeps_the_session() { + fn a_refusal_ends_the_world_session() { let store = InMemoryTaxiActions { fail: Some(refused), ..Default::default() }; - let player = TaxiActionPlayer { self_guid: Some(9) }; - - let status = dispatch_taxi_action(&store, player, status_query()).unwrap(); - assert!(matches!(status, TaxiActionOutcome::Handled { outbound } if outbound.is_empty())); - - let (outbound, arm) = match dispatch_taxi_action(&store, player, activate_query()).unwrap() - { - TaxiActionOutcome::Activated { outbound, arm, .. } => (outbound, arm), - _ => panic!("activate must be consumed"), - }; - assert!(!arm); - assert!(matches!( - outbound.as_slice(), - [Outbound::One(ServerOpcodeMessage::SMSG_ACTIVATETAXIREPLY(reply))] - if reply.reply == wow_world_messages::vanilla::ActivateTaxiReply::UnspecifiedServerError - )); + for msg in [status_query(), activate_query()] { + let result = dispatch_taxi_action(&store, TaxiActionPlayer { self_guid: Some(9) }, msg); + assert!(result.is_err(), "a taxi Refusal ends the world session"); + } } #[test] From f2860993e459664e8cf37f39a6ed429a0f9a24a5 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 20:04:55 +0200 Subject: [PATCH 33/33] fix(gateway): an unknown Realm Presence no longer ends the World Session presence::of now wraps a failed absence vouch in PresenceUnknown. Whisper, channel targeted ops, guild invite, petition offer and .guild create answer it as they did before the one-hop change. A real Transport Loss still ends the session. Co-Authored-By: Claude Opus 5.5 (1M context) --- CONTEXT.md | 3 +- gateway/src/world/handlers/channel.rs | 33 +++++++++--- gateway/src/world/handlers/chat.rs | 27 ++++++++-- gateway/src/world/handlers/guild.rs | 77 +++++++++++++++++++++++++-- gateway/src/world/presence.rs | 38 ++++++++++++- gateway/src/world/presence_tests.rs | 6 ++- 6 files changed, 164 insertions(+), 20 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 94cb005a4..268123824 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -518,7 +518,8 @@ _Avoid_: online status, presence cache Realm Presence's own state for where a Character is: in world (with a live entity and an Away Status), in transit between two places (a pending Transfer, or a Shard's own row reading online with no entity there), or offline. A negative Whereabouts, offline or no Character found at all, -needs every configured World Shard to vouch that none of them is hiding the Character. +needs every configured World Shard to vouch that none of them is hiding the Character. When one +cannot, the Whereabouts is unknown: another Shard's health, not a Transport Loss. _Avoid_: presence state, location status **Away Status**: diff --git a/gateway/src/world/handlers/channel.rs b/gateway/src/world/handlers/channel.rs index 642b7912a..b4adcf386 100644 --- a/gateway/src/world/handlers/channel.rs +++ b/gateway/src/world/handlers/channel.rs @@ -330,8 +330,8 @@ fn run_op( /// cm:Channel.cpp:678-685). cmangos checks membership, then rights, then the target name, so an /// unresolved name still reaches the Module: it goes out as `target_guid` 0, the sentinel no real /// Character ever holds, and the op core runs its own NotMember, NotModerator or NotOwner Gate -/// first and only then answers PLAYER_NOT_FOUND. A failed target read is a Transport Loss and -/// ends the World Session. +/// first and only then answers PLAYER_NOT_FOUND. A target whose Realm Presence is unknown goes out +/// the same way. Any other failed target read ends the World Session. fn run_targeted_op( store: &St, player: ChatActionPlayer, @@ -342,8 +342,7 @@ fn run_targeted_op( let Some((actor, speaker)) = actor_and_speaker(store, player)? else { return Ok(Vec::new()); }; - let target = store - .online_character_by_name(&typed_name)? + let target = presence::unknown_as_absent(store.online_character_by_name(&typed_name))? .unwrap_or(ResolvedTarget { guid: 0, race: 0, @@ -446,6 +445,8 @@ mod tests { /// The Module rejects with a tag this Gateway does not know. Refused, TransportLost, + /// Another World Shard cannot vouch for a Character's absence. + PresenceUnknown, } impl Failure { @@ -453,6 +454,10 @@ mod tests { match self { Self::Refused => ReducerCallError::refused(op, "mystery").into(), Self::TransportLost => ReducerCallError::transport_lost(op).into(), + Self::PresenceUnknown => presence::PresenceUnknown(anyhow::anyhow!( + "World Shard instances has no healthy Coordinator subscription" + )) + .into(), } } } @@ -1032,16 +1037,30 @@ mod tests { assert_eq!(ops[0].2.target_name, "Ghost"); } - /// A failed target lookup is a Transport Loss: no Durable Request goes out. + /// A target whose Realm Presence is unknown goes out unresolved, so the Module answers. #[test] - fn a_failed_target_lookup_ends_the_session() { + fn an_unknown_target_presence_goes_out_unresolved() { + let store = InMemoryChannelActions { + lookup_failure: Some(Failure::PresenceUnknown), + ..store(None) + }; + handled(dispatch_channel_action(&store, player(), kick("Raiders", "Ghost")).unwrap()); + let ops = store.ops.lock().unwrap(); + assert_eq!(ops.len(), 1); + assert_eq!(ops[0].2.target_guid, 0); + assert_eq!(ops[0].2.target_name, "Ghost"); + } + + /// A lost target lookup is a Transport Loss: no Durable Request goes out. + #[test] + fn a_lost_target_lookup_ends_the_session() { let store = InMemoryChannelActions { lookup_failure: Some(Failure::TransportLost), ..store(None) }; let error = dispatch_channel_action(&store, player(), kick("Raiders", "Ghost")) .err() - .expect("a failed read is fatal"); + .expect("a lost read is fatal"); assert_eq!(classify(&error), DurableFailure::TransportLoss); assert!(store.ops.lock().unwrap().is_empty()); } diff --git a/gateway/src/world/handlers/chat.rs b/gateway/src/world/handlers/chat.rs index d877fccda..8e4d7f070 100644 --- a/gateway/src/world/handlers/chat.rs +++ b/gateway/src/world/handlers/chat.rs @@ -249,7 +249,8 @@ pub(crate) fn dispatch_chat_action( /// One whisper: the target is resolved realm-wide, the Module applies the Gates on Realm-core, and /// the lines return on the Relay. A name no online Character holds answers -/// `SMSG_CHAT_PLAYER_NOT_FOUND` with the typed name (cm:ChatHandler.cpp:243-266). +/// `SMSG_CHAT_PLAYER_NOT_FOUND` with the typed name (cm:ChatHandler.cpp:243-266), and so does a +/// target whose Realm Presence is unknown. fn whisper( store: &St, player: ChatActionPlayer, @@ -263,7 +264,8 @@ fn whisper( let Some(speaker) = store.speaker_facts(actor.guid())? else { return Ok(Vec::new()); }; - let target = match store.whisper_target(actor.guid(), &typed_name)? { + let read = store.whisper_target(actor.guid(), &typed_name); + let target = match presence::unknown_as_absent(read)? { Some(target) => target, None => return Ok(vec![player_not_found(typed_name)]), }; @@ -425,6 +427,8 @@ mod tests { /// The Module rejects with a tag this Gateway does not know. Refused, TransportLost, + /// Another World Shard cannot vouch for a Character's absence. + PresenceUnknown, } impl Failure { @@ -432,6 +436,10 @@ mod tests { match self { Self::Refused => ReducerCallError::refused(op, "mystery").into(), Self::TransportLost => ReducerCallError::transport_lost(op).into(), + Self::PresenceUnknown => presence::PresenceUnknown(anyhow::anyhow!( + "World Shard instances has no healthy Coordinator subscription" + )) + .into(), } } } @@ -929,9 +937,20 @@ mod tests { } #[test] - fn a_failed_target_read_ends_the_session() { + 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()); + assert_eq!(not_found(outbound), "vIm"); + assert!(store.whispers.lock().unwrap().is_empty()); + } + + #[test] + fn a_lost_target_read_ends_the_session() { let store = whisper_store(Err(Failure::TransportLost), None); - assert!(dispatch_chat_action(&store, player(), whisper_to("Vim")).is_err()); + let error = dispatch_chat_action(&store, player(), whisper_to("Vim")) + .err() + .expect("a lost read is fatal"); + assert_eq!(classify(&error), DurableFailure::TransportLoss); assert!(store.whispers.lock().unwrap().is_empty()); } diff --git a/gateway/src/world/handlers/guild.rs b/gateway/src/world/handlers/guild.rs index c804f75f4..6bdcc9204 100644 --- a/gateway/src/world/handlers/guild.rs +++ b/gateway/src/world/handlers/guild.rs @@ -231,9 +231,12 @@ impl GuildActionPlayer { } } -/// A Transport Loss ends the World Session. A Refusal this Gateway has no answer for comes back -/// for the caller to drop, and the client hears silence. +/// 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 { + if presence::is_unknown(&error) { + return Ok(error); + } match classify(&error) { DurableFailure::TransportLoss => Err(error), DurableFailure::Refusal { .. } => Ok(error), @@ -2332,6 +2335,14 @@ mod tests { ReducerCallError::transport_lost("realm_guild_op").into() } + /// Another World Shard cannot vouch for a Character's absence. + fn presence_unknown() -> anyhow::Error { + presence::PresenceUnknown(anyhow::anyhow!( + "World Shard instances has no healthy Coordinator subscription" + )) + .into() + } + /// A Module rejection whose tag this Gateway does not know. fn unknown_refusal() -> anyhow::Error { ReducerCallError::refused("realm_guild_op", "mystery").into() @@ -2481,13 +2492,25 @@ mod tests { } #[test] - fn dot_create_ends_the_session_when_presence_cannot_answer() { + fn dot_create_answers_not_created_when_presence_is_unknown() { + let store = InMemoryGuildActions { + facts_error: Some(presence_unknown), + ..realm() + }; + let line = + 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()); + } + + #[test] + fn dot_create_ends_the_session_when_the_leader_read_is_lost() { let store = InMemoryGuildActions { facts_error: Some(lost_transport), ..realm() }; let error = run_guild_dot_command(&store, in_world(GM), ".guild create \"Knights\"") - .expect_err("a failed read is a Transport Loss"); + .expect_err("a lost read is a Transport Loss"); assert_eq!(classify(&error), DurableFailure::TransportLoss); assert!(store.ops.lock().unwrap().is_empty()); } @@ -3649,6 +3672,41 @@ mod tests { ); } + #[test] + fn a_guild_invite_drops_silently_when_presence_is_unknown() { + let mut store = founded_with_members(); + store.characters.push(facts(DAVE, "Dave")); + store.facts_error = Some(presence_unknown); + let ops_before = store.ops.lock().unwrap().len(); + let outbound = dispatch( + &store, + in_world(GM), + ClientOpcodeMessage::CMSG_GUILD_INVITE(Box::new( + wow_world_messages::vanilla::CMSG_GUILD_INVITE { + invited_player: "Dave".into(), + }, + )), + ); + assert!(outbound.is_empty()); + assert_eq!(store.ops.lock().unwrap().len(), ops_before); + } + + #[test] + fn a_guild_invite_ends_the_session_when_the_target_read_is_lost() { + let mut store = founded_with_members(); + store.characters.push(facts(DAVE, "Dave")); + store.facts_error = Some(lost_transport); + let msg = ClientOpcodeMessage::CMSG_GUILD_INVITE(Box::new( + wow_world_messages::vanilla::CMSG_GUILD_INVITE { + invited_player: "Dave".into(), + }, + )); + let error = dispatch_guild_action(&store, in_world(GM), msg) + .err() + .expect("a lost read is a Transport Loss"); + assert_eq!(classify(&error), DurableFailure::TransportLoss); + } + #[test] fn every_membership_opcode_reaches_its_durable_request() { use wow_world_messages::vanilla as wire; @@ -4436,6 +4494,17 @@ mod tests { ); } + #[test] + fn an_offer_drops_silently_when_presence_is_unknown() { + let store = InMemoryGuildActions { + characters: vec![facts(BOB, "Bob"), horde_facts(CAROL, "Carol")], + facts_error: Some(presence_unknown), + ..bobs_petition() + }; + assert!(offer(&store, CAROL).is_empty()); + assert!(recorded_ops(&store).is_empty()); + } + #[test] fn offer_refusals_answer_not_allied_and_already_in_guild_with_the_targets_name() { let store = bobs_petition(); diff --git a/gateway/src/world/presence.rs b/gateway/src/world/presence.rs index 9329b344f..c4752c037 100644 --- a/gateway/src/world/presence.rs +++ b/gateway/src/world/presence.rs @@ -187,7 +187,8 @@ impl CharacterIdentity { /// Failing that, a pending Transfer (Realm-core's own signal, or a Shard's between-places match) /// answers `InTransit`. Only once neither fires does the answer become a negative claim — /// `Offline`, or `None` for a guid no Shard ever named — and a negative claim needs every -/// configured Shard to vouch first (see the module doc). +/// configured Shard to vouch first (see the module doc), and fails with [`PresenceUnknown`] when +/// one cannot. pub(crate) fn of( store: &St, guid: u64, @@ -206,7 +207,9 @@ pub(crate) fn of( // Both `InTransit` (via a Shard's own between-places match) and `Offline` are negative claims // from here on: no configured Shard may be unreachable, or a stale cache could be hiding the // Character. - store.every_shard_vouches_for_absence()?; + store + .every_shard_vouches_for_absence() + .map_err(PresenceUnknown)?; let between_places = store.character_in_transit(guid) || store .world_stores() @@ -221,6 +224,37 @@ pub(crate) fn of( }) } +/// A negative claim [`of`] could not make: some configured World Shard did not vouch for the +/// absence. That is another Shard's health, not this World Session's transport, so a caller answers +/// as it would for a Character it cannot see. +#[derive(Debug)] +pub(crate) struct PresenceUnknown(pub(crate) anyhow::Error); + +impl std::fmt::Display for PresenceUnknown { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Realm Presence unknown: {:#}", self.0) + } +} + +impl std::error::Error for PresenceUnknown {} + +/// Is `error`, or any cause it wraps, a [`PresenceUnknown`]? +pub(crate) fn is_unknown(error: &anyhow::Error) -> bool { + error.chain().any(|cause| cause.is::()) +} + +/// `result` with a [`PresenceUnknown`] read as `None`, the answer for a Character nobody can see. +/// Every other error passes through. +pub(crate) fn unknown_as_absent(result: Result>) -> Result> { + match result { + Err(error) if is_unknown(&error) => { + log::debug!("world: answering as absent: {error:#}"); + Ok(None) + } + other => other, + } +} + /// [`CharacterIdentity`] from whichever connected Shard answers first: this handle, then every /// `world_stores()` peer. Best-effort — a hit is a positive signal that needs no health proof. /// diff --git a/gateway/src/world/presence_tests.rs b/gateway/src/world/presence_tests.rs index 238ef7dee..7d83c7f2f 100644 --- a/gateway/src/world/presence_tests.rs +++ b/gateway/src/world/presence_tests.rs @@ -257,9 +257,11 @@ fn offline_needs_every_configured_shard_healthy() { ..Default::default() }); *world.topology.peers.lock().unwrap() = vec![world.clone(), down.clone()]; + let error = presence::of(world.as_ref(), DORMANT) + .expect_err("an unhealthy configured Shard must refuse to let Offline through"); assert!( - presence::of(world.as_ref(), DORMANT).is_err(), - "an unhealthy configured Shard must refuse to let Offline through" + presence::is_unknown(&error), + "another Shard's health is not this session's transport" ); let healthy = std::sync::Arc::new(WorldFake {