diff --git a/CONTEXT.md b/CONTEXT.md index ae91d7e3d..268123824 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**: @@ -208,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. @@ -510,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/CORE_TERMS.md b/CORE_TERMS.md index e83e2bb31..96ba933a6 100644 --- a/CORE_TERMS.md +++ b/CORE_TERMS.md @@ -42,16 +42,24 @@ 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). _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/docs/architecture.md b/docs/architecture.md index 7279a28ab..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 @@ -107,7 +112,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..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/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`. 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_guid), 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/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 -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,8 +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 crate::stdb::Coordinator`, 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 @@ -132,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 0731484a3..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 @@ -157,21 +148,32 @@ 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( home_shard: &D, account_name: &str, - actor_guid: u64, + actor: Actor, text: String, ) -> Result<()> { let account_name = account_name.to_ascii_uppercase(); 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) + home_shard.request_gm_command(actor, authority, text) } // =============================================================================================== @@ -1047,20 +1049,23 @@ 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 { - Err(anyhow!("permission denied")) + Err( + crate::stdb::ReducerCallError::refused("gw_gm_command", "permission denied") + .into(), + ) } } fn character_location(&self, guid: u64) -> Option<(u32, u64)> { @@ -1307,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. @@ -1334,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!( @@ -1358,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 @@ -1367,10 +1376,15 @@ 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!(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(), &[ @@ -1381,10 +1395,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, gm(), ".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", gm(), ".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/account_sessions.rs b/gateway/src/stdb/account_sessions.rs index 4b87af8cf..6db537e08 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,20 @@ 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)) + } + + 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)), } } @@ -556,8 +563,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, @@ -566,8 +575,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() @@ -677,10 +687,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!( @@ -690,7 +706,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, @@ -720,14 +736,18 @@ 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(); 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() @@ -745,7 +765,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/connection.rs b/gateway/src/stdb/connection.rs index ce2155df0..bc8f33989 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. @@ -2227,10 +2255,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; @@ -2259,7 +2344,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")); } @@ -2274,7 +2358,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:")); } @@ -2291,7 +2375,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" @@ -2450,21 +2534,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 d40eace80..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,15 +32,17 @@ 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; +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/reads/account.rs b/gateway/src/stdb/reads/account.rs index f21f40d7f..1aaf50db1 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) @@ -163,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, @@ -207,60 +141,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 +166,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..d818c5db2 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,45 +54,9 @@ 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. - 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(); @@ -108,113 +70,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 +103,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/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 aa87acf2b..ac9bdf58e 100644 --- a/gateway/src/stdb/reducers.rs +++ b/gateway/src/stdb/reducers.rs @@ -1,85 +1,15 @@ -//! 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 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::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, recv_reducer_on, 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, InteractionOutcome, 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}; 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.session_actor(0) - ) - ) - } - pub fn claim_bot_transfer_intent( &self, intent_id: u64, @@ -113,64 +43,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 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 defer_party_command_intent(&self, intent_id: u64, claim_token: u64) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, @@ -179,4975 +51,63 @@ impl Coordinator { ) } - 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) - )) - } - - 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.session_actor(character_guid), request_id) - )?; - if !reply.accepted { - log::debug!( - "taxi operation {operation} refused for character {character_guid}: {}", - reply.refusal - ); + /// 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)); + } } - 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> { - if character_guid == 0 { - 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) - )?; - 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> { - if character_guid == 0 { - 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) - )?; - 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 { - if character_guid == 0 { - 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), - 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.session_actor(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)?; + 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}" + ); + } } - 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.session_actor(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.session_actor(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.session_actor(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.session_actor(0)) - ) - } - - 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.session_actor(0)) - ) - } - - 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.session_actor(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.session_actor(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.session_actor(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<()> { + /// 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, - "gw_auction_hold_bid", - gw_auction_hold_bid_then( - operation_id, - self.session_actor(request.actor_guid), - request.auctioneer_guid, - request.auction_id, - request.house_id, - request.offer - ) + "provision_account", + provision_account_then(username.to_string(), salt.to_vec(), verifier.to_vec()) ) } - fn auction_hold_cancel( + /// Logon writes K + the bound per-account identity. + pub fn establish_session( &self, - operation_id: u64, - request: crate::world::CancelAuctionRequest, - cut: u32, + account_id: u64, + session_key: &[u8; 40], + bound_identity: [u8; 32], ) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, - "gw_auction_hold_cancel", - gw_auction_hold_cancel_then( - operation_id, - self.session_actor(request.actor_guid), - request.auctioneer_guid, - request.auction_id, - request.house_id, - cut + "establish_session", + establish_session_then( + account_id, + session_key.to_vec(), + Identity::from_byte_array(bound_identity) ) ) } - - 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.session_actor(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.session_actor(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.session_actor(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.session_actor(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.session_actor(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.session_actor(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.session_actor(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.session_actor(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.session_actor(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.session_actor(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.session_actor(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.session_actor(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.session_actor(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<()> { - if actor_guid == 0 { - return Err(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) - ) - } - - /// 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<()> { - if actor_guid == 0 { - return Err(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) - ) - } - - /// 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 { - if actor_guid == 0 { - return Err(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) - )) - } - - /// 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<()> { - if actor_guid == 0 { - return Err(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) - ) - } - - /// 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<()> { - if actor_guid == 0 { - return Err(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) - ) - } - - /// 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 coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_attack", - gw_attack_then(self.session_actor(actor_guid), 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<()> { - if actor_guid == 0 { - return Err(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) - ) - } - - /// 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<()> { - if actor_guid == 0 { - return Err(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) - ) - } - - /// 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 coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_stop_attack", - gw_stop_attack_then(self.session_actor(actor_guid)) - ) - } - - /// 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 coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_set_sheathed", - gw_set_sheathed_then(self.session_actor(actor_guid), 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<()> { - if actor_guid == 0 { - return Err(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) - ) - } - - /// 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<()> { - if actor_guid == 0 { - return Err(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) - ) - } - - /// 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<()> { - if actor_guid == 0 { - return Err(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 - ) - ) - } - - /// 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 coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_cancel_aura", - gw_cancel_aura_then(self.session_actor(actor_guid), 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 coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_cancel_cast", - gw_cancel_cast_then(self.session_actor(actor_guid)) - ) - } - - /// 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 { - if actor_guid == 0 { - return Err(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) - )) - } - - pub fn send_emote( - &self, - _account_id: u64, - actor_guid: u64, - text_emote: u32, - emote_anim: u32, - target_guid: u64, - ) -> Result<()> { - if actor_guid == 0 { - return Err(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), - 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.session_actor(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.session_actor(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.session_actor(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.session_actor(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.session_actor(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.session_actor(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. - /// 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. - 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), - } - } - - /// `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 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) - ) - } - - /// `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 { - if actor_guid == 0 { - return Err(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) - )) - } - - /// `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<()> { - if actor_guid == 0 { - return Err(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) - ) - } - - /// `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 coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_begin_trade", - gw_begin_trade_then(self.session_actor(actor_guid)) - ) - } - - /// `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 coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_cancel_trade", - gw_cancel_trade_then(self.session_actor(actor_guid)) - ) - } - - 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 coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_duel_accept", - gw_duel_accept_then(self.session_actor(actor_guid), 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 coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_duel_cancel", - gw_duel_cancel_then(self.session_actor(actor_guid), 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<()> { - if actor_guid == 0 { - return Err(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) - ) - } - - /// `CMSG_CLEAR_TRADE_ITEM`. - pub fn clear_trade_item( - &self, - _account_id: u64, - actor_guid: u64, - trade_slot: u8, - ) -> Result<()> { - if actor_guid == 0 { - return Err(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) - ) - } - - /// `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 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) - ) - } - - /// `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 coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_accept_trade", - gw_accept_trade_then(self.session_actor(actor_guid)) - ) - } - - /// `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 coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_unaccept_trade", - gw_unaccept_trade_then(self.session_actor(actor_guid)) - ) - } - - /// `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 coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_busy_trade", - gw_busy_trade_then(self.session_actor(actor_guid)) - ) - } - - /// `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 coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_ignore_trade", - gw_ignore_trade_then(self.session_actor(actor_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 { - if actor_guid == 0 { - return Err(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)) - )) - } - - /// `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 coord = self.0.call_pipe(); - party_outcome(call_reducer!( - coord.conn.reducers, - "gw_group_decline", - gw_group_decline_then(self.session_actor(actor_guid)) - )) - } - - /// `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 coord = self.0.call_pipe(); - party_outcome(call_reducer!( - coord.conn.reducers, - "gw_group_leave", - gw_group_leave_then(self.session_actor(actor_guid)) - )) - } - - /// `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 { - if actor_guid == 0 { - return Err(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) - )) - } - - /// `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 { - if actor_guid == 0 { - return Err(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), - 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<()> { - if actor_guid == 0 { - return Err(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), - 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 { - if actor_guid == 0 { - return Err(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) - )) - } - - /// `CMSG_DEL_FRIEND`. - pub fn del_friend( - &self, - _account_id: u64, - actor_guid: u64, - target_guid: u64, - ) -> Result { - if actor_guid == 0 { - return Err(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) - )) - } - - /// `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 { - if actor_guid == 0 { - return Err(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) - )) - } - - /// `CMSG_DEL_IGNORE`. - pub fn del_ignore( - &self, - _account_id: u64, - actor_guid: u64, - target_guid: u64, - ) -> Result { - if actor_guid == 0 { - return Err(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) - )) - } - - /// 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 { - if actor_guid == 0 { - return Err(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) - )) - } - - /// 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 { - if actor_guid == 0 { - return Err(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) - )) - } - - /// 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 { - if actor_guid == 0 { - return Err(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) - )) - } - - pub fn skin_corpse( - &self, - _account_id: u64, - actor_guid: u64, - corpse_guid: u64, - ) -> Result { - if actor_guid == 0 { - return Err(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) - )) - } - - /// `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 { - if actor_guid == 0 { - return Err(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) - )) - } - - /// `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 { - if actor_guid == 0 { - return Err(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), - corpse_guid, - loot_slot, - target_guid - ) - )) - } - - 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 coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_disenchant", - gw_disenchant_then(self.session_actor(actor_guid), slot) - ) - } - - pub fn enchant_item_on_slot( - &self, - _account_id: u64, - actor_guid: u64, - slot: u8, - enchant_id: u32, - ) -> Result<()> { - if actor_guid == 0 { - return Err(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) - ) - } - - /// 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<()> { - if actor_guid == 0 { - return Err(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 - ) - ) - } - - /// 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 { - if actor_guid == 0 { - return Err(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) - ); - 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<()> { - if actor_guid == 0 { - return Err(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) - ) - } - - 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 coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_learn_talent", - gw_learn_talent_then(self.session_actor(actor_guid), 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 { - if actor_guid == 0 { - return Err(anyhow!("reset_talents: actor_guid unresolved")); - } - let coord = self.0.call_pipe(); - interaction_outcome(call_reducer!( - coord.conn.reducers, - "gw_reset_talents", - gw_reset_talents_then(self.session_actor(actor_guid), 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 coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_fish", - gw_fish_then(self.session_actor(actor_guid)) - ) - } - - /// 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<()> { - if actor_guid == 0 { - return Err(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) - ) - } - - /// 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<()> { - if actor_guid == 0 { - return Err(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) - ) - } - - /// Persist the rep pane's At-War checkbox (`CMSG_SET_FACTION_ATWAR`): the wire's - /// u32 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 { - if actor_guid == 0 { - return Err(anyhow!("set_faction_at_war: actor_guid unresolved")); - } - let coord = self.0.call_pipe(); - interaction_outcome(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) - )) - } - - /// 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<()> { - if actor_guid == 0 { - return Err(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) - ) - } - - pub fn buyback_item( - &self, - _account_id: u64, - actor_guid: u64, - vendor_guid: u64, - slot: u8, - ) -> Result<()> { - if actor_guid == 0 { - return Err(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) - ) - } - - /// 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<()> { - if actor_guid == 0 { - return Err(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) - ) - } - - /// 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_guid) = 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) - )) - } - - /// 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_guid) = 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) - )) - } - - /// 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_guid) = 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) - )) - } - - /// Bind the Character's home at the selected innkeeper. - pub fn bind_home( - &self, - _account_id: u64, - actor_guid: u64, - innkeeper_guid: u64, - ) -> Result { - if actor_guid == 0 { - return Err(anyhow!("bind_home: actor_guid unresolved")); - } - let coord = self.0.call_pipe(); - interaction_outcome(call_reducer!( - coord.conn.reducers, - "gw_bind_home", - gw_bind_home_then(self.session_actor(actor_guid), innkeeper_guid) - )) - } - - /// 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_guid) = 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) - )) - } - - pub fn split_item( - &self, - _account_id: u64, - actor_guid: u64, - from_slot: u8, - to_slot: u8, - count: u32, - ) -> Result { - let Some(actor_guid) = resolved_item_actor("split_item", actor_guid) else { - return Ok(ItemRefusal::Internal.into()); - }; - let coord = self.0.call_pipe(); - item_action(call_reducer!( - coord.conn.reducers, - "gw_split_item", - gw_split_item_then(self.session_actor(actor_guid), from_slot, to_slot, count) - )) - } - - pub fn destroy_item( - &self, - _account_id: u64, - actor_guid: u64, - slot: u8, - count: u32, - ) -> Result { - let Some(actor_guid) = resolved_item_actor("destroy_item", actor_guid) else { - return Ok(ItemRefusal::Internal.into()); - }; - let coord = self.0.call_pipe(); - item_action(call_reducer!( - coord.conn.reducers, - "gw_destroy_item", - gw_destroy_item_then(self.session_actor(actor_guid), slot, count) - )) - } - - /// 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<()> { - if actor_guid == 0 { - return Err(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) - ) - } - - /// 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<()> { - if actor_guid == 0 { - return Err(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) - ) - } - - /// 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<()> { - if actor_guid == 0 { - return Err(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), - 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<()> { - if actor_guid == 0 { - return Err(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) - ) - } - - /// 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 coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_repop", - gw_repop_then(self.session_actor(actor_guid)) - ) - } - - /// 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<()> { - if actor_guid == 0 { - return Err(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) - ) - } - - /// 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<()> { - if actor_guid == 0 { - return Err(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) - ) - } - - /// 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 coord = self.0.call_pipe(); - call_reducer!( - coord.conn.reducers, - "gw_self_resurrect", - gw_self_resurrect_then(self.session_actor(actor_guid)) - ) - } - - /// 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<()> { - if actor_guid == 0 { - return Err(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)) - ) - } - - // ------------------------------------------------------------------------------------- - // 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. - // ------------------------------------------------------------------------------------- - - /// `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.session_actor(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.session_actor(0) - ) - ) - } - - /// `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.session_actor(0) - ) - ) - } - - /// `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.session_actor(0)) - ) - } - - /// `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.session_actor(0)) - ) - } - - /// `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.session_actor(0)) - ) - } - - /// `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.session_actor(0)) - ) - } - - /// `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.session_actor(0)) - ) - } - - /// `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 - ) - ) - } - - /// `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.session_actor(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.session_actor(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.session_actor(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.session_actor(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.session_actor(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.session_actor(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.session_actor(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.session_actor(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.session_actor(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.session_actor(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.session_actor(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.session_actor(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.session_actor(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.session_actor(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.session_actor(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.session_actor(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.session_actor(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.session_actor(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.session_actor(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.session_actor(0)) - ) - } - - /// `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.session_actor(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.session_actor(0), - 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, - ) -> Result<()> { - call_reducer!( - self.0.call_pipe().conn.reducers, - "realm_loot_op", - realm_loot_op_then( - op, - corpse_guid, - slot, - item_entry, - self.session_actor(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.session_actor(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.session_actor(0)) - ) - } - - /// `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.session_actor(0)) - ) - } -} - -#[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( - 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" - )) -} - -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 { - 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); - } - } -} - -#[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), - }, - } -} - -fn interaction_outcome(result: Result<()>) -> Result { - match result { - Ok(()) => Ok(InteractionOutcome::Done), - Err(error) => match reducer_refusal_reason(&error) { - Some(reason) => Ok(InteractionOutcome::Refused(reason.to_owned())), - 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 { - if actor_guid == 0 { - log::warn!("stdb: {operation} has no resolved actor"); - return None; - } - Some(actor_guid) -} - -/// 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.session_actor(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.session_actor(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.session_actor(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.session_actor(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.session_actor(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 interaction_reducer_tests { - use super::*; - use crate::stdb::connection::ReducerCallError; - - #[test] - fn interaction_outcomes_keep_module_refusals_separate_from_infrastructure_failures() { - assert_eq!( - interaction_outcome(Ok(())).unwrap(), - InteractionOutcome::Done - ); - let refusal = anyhow::Error::from(ReducerCallError::Rejected { - operation: "gw_bind_home".into(), - reason: "innkeeper out of range".into(), - }) - .context("request completion"); - assert_eq!( - interaction_outcome(Err(refusal)).unwrap(), - InteractionOutcome::Refused("innkeeper out of range".into()) - ); - for failure in [ - anyhow::Error::from(ReducerCallError::fatal("request timed out".into())), - anyhow::Error::from(ReducerCallError::fatal("transport disconnected".into())), - anyhow!("innkeeper out of range"), - ] { - assert!(interaction_outcome(Err(failure)).is_err()); - } - } -} - -#[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..3c5866cee --- /dev/null +++ b/gateway/src/stdb/store/auction.rs @@ -0,0 +1,1683 @@ +//! `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 super::{next_operation_id, wait_for_cache_row}; +use crate::stdb::bindings::*; +use crate::stdb::connection::{call_reducer, classify, DurableFailure}; +use crate::stdb::Coordinator; +use crate::world::{ + Actor, AuctionActionStore, AuctionBrowseRequest, AuctionHousePolicy, AuctionInteraction, + AuctionPage, AuctionQuery, CancelAuctionOutcome, CancelAuctionRequest, CreateAuctionOutcome, + CreateAuctionRequest, PlaceBidOutcome, PlaceBidRequest, +}; + +impl AuctionActionStore for crate::stdb::Coordinator { + fn auction_interaction( + &self, + actor: Actor, + 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, 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 { + 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), + }, + } + } + + /// 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), + }, + } + } + + /// 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), + }, + } + } + + /// 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(()) + } + + fn auction_query( + &self, + 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; + 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, + }) + } +} + +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, + ) + } + 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 matches!(classify(&error), DurableFailure::Refusal { .. }) + && !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.session_actor(request.actor), + 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.session_actor(request.actor), + 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<()> { + 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, + actor, + 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<()> { + 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, + actor, + 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<()> { + 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, 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() + .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) + }) + } + + 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) + } + + /// 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.session_actor(request.actor), + 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.session_actor(request.actor), + 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.session_actor(request.actor), + 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.session_actor(request.actor), + 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<()> { + let actor = self.hold_actor(hold.bidder_guid)?; + 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, + actor, + 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, + actor, + 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<()> { + 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" + )); + } + call_reducer!( + self.0.call_pipe().conn.reducers, + "gw_auction_finish_bid", + gw_auction_finish_bid_then( + hold.operation_id, + actor, + 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<()> { + 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, + actor, + hold.auction_id, + hold.house, + hold.offer, + hold.deferred_refund + ) + ) + } + + 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, + actor, + 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 Transport +/// Loss stays an error with an unknown outcome. +fn auction_refusal(error: &anyhow::Error) -> Option { + match classify(error) { + DurableFailure::Refusal { reason } => AuctionRefusal::parse_tag(reason), + DurableFailure::TransportLoss => None, + } +} + +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: Actor::new(5_090_099).unwrap(), + 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::transport_lost("gw_auction_hold_listing")), + 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: Actor::new(7).unwrap(), + 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..f65c20083 100644 --- a/gateway/src/stdb/store/bank.rs +++ b/gateway/src/stdb/store/bank.rs @@ -2,16 +2,30 @@ use anyhow::Result; -use crate::world::BankStore; - +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<()> { - self.auto_bank_item(account_id, self_guid, 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. + 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) + ) } - 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) + /// 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. + fn buy_bank_slot(&self, actor: Actor, banker_guid: u64) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "gw_buy_bank_slot", + gw_buy_bank_slot_then(self.session_actor(actor), banker_guid) + ) } } diff --git a/gateway/src/stdb/store/cast.rs b/gateway/src/stdb/store/cast.rs new file mode 100644 index 000000000..541a76ae6 --- /dev/null +++ b/gateway/src/stdb/store/cast.rs @@ -0,0 +1,270 @@ +//! `Coordinator`'s [`CastStore`] adapter. + +use 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 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. + 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. + 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 + .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. + 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`. + 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). + 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). + 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. + 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. + 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`. + 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, + "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. + fn cast_spell(&self, actor: Actor, spell_id: u32, target_guid: u64) -> Result<()> { + 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. + fn cast_item_target(&self, actor: Actor, spell_id: u32, slot: u8) -> Result<()> { + 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)] + fn cast_spell_at( + &self, + actor: Actor, + spell_id: u32, + target_guid: u64, + x: f32, + y: f32, + z: f32, + ) -> Result<()> { + 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. + fn cancel_aura(&self, actor: Actor, spell_id: u32) -> Result<()> { + 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. + fn cancel_cast(&self, actor: Actor) -> Result<()> { + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_cancel_cast", + gw_cancel_cast_then(self.session_actor(actor)) + ) + } + + fn disenchant_item(&self, actor: Actor, slot: u8) -> Result<()> { + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_disenchant", + gw_disenchant_then(self.session_actor(actor), slot) + ) + } + + 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, + "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. + fn fish(&self, actor: Actor) -> Result<()> { + 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. + fn pick_lock(&self, actor: Actor, go_guid: u64) -> Result<()> { + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_pick_lock", + 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, item_guid: u64) -> Option { + Coordinator::item_slot_by_guid(self, item_guid) + } + + fn player_items(&self, owner_guid: u64) -> Result> { + Coordinator::player_items(self, owner_guid) + } +} diff --git a/gateway/src/stdb/store/channel.rs b/gateway/src/stdb/store/channel.rs new file mode 100644 index 000000000..3feb041e0 --- /dev/null +++ b/gateway/src/stdb/store/channel.rs @@ -0,0 +1,147 @@ +//! `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, Actor, ChannelActionStore, ChannelOutcome, ChannelRoster, + ResolvedTarget, +}; + +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: Actor, + 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.session_actor(actor), op, request) + )) + } + + /// 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. + fn channel_roster(&self, team: u32, channel_name: &str) -> 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, + })) + } + + 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 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), + }, + } +} + +#[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/stdb/store/character.rs b/gateway/src/stdb/store/character.rs index 2621d4d90..8df54f406 100644 --- a/gateway/src/stdb/store/character.rs +++ b/gateway/src/stdb/store/character.rs @@ -1,11 +1,15 @@ //! `Coordinator`'s [`CharacterStore`] adapter. use anyhow::Result; +use spacetimedb_sdk::Table; use crate::codec; -use crate::world::CharacterStore; - -use crate::stdb::connection::{CharacterPresenceSnapshot, Coordinator}; +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::stdb::{classify, DurableFailure}; +use crate::world::{Actor, CharacterStore}; fn stable_character_absence( first: &[CharacterPresenceSnapshot], @@ -86,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, @@ -95,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`. @@ -126,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) @@ -172,20 +197,297 @@ impl CharacterStore for Coordinator { self.effective_magic_resistances(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). fn spell_modifiers(&self, character_guid: u64) -> Vec<(u32, u8, i32, bool)> { - self.spell_modifiers(character_guid) + 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. fn player_learned_spells(&self, player_guid: u64) -> Result> { - self.player_learned_spells(player_guid) + 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`. + 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`. fn player_reputations(&self, player_guid: u64) -> Result> { - self.player_reputations(player_guid) + 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()) + } +} + +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) } - fn player_actions(&self, player_guid: u64) -> Result> { - self.player_actions(player_guid) + /// 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 { + 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 + }) + }) + } + + /// 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: 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) => {} + Err(error) => { + log::warn!( + "delete_character: could not verify auction value for {character_guid}: {error:#}" + ); + return Ok(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 { + 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)) + } +} + +/// 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()); } } diff --git a/gateway/src/stdb/store/chat.rs b/gateway/src/stdb/store/chat.rs new file mode 100644 index 000000000..a75d22a65 --- /dev/null +++ b/gateway/src/stdb/store/chat.rs @@ -0,0 +1,161 @@ +//! `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, Actor, ChatActionStore, ChatOutcome}; + +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. + 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 { + 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, + })) + } + + /// `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. + fn realm_chat( + &self, + actor: Actor, + 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.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. + fn realm_whisper( + &self, + actor: Actor, + 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.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, +/// 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), + }, + } +} + +#[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/stdb/store/combat.rs b/gateway/src/stdb/store/combat.rs index f078699df..9c509a5a6 100644 --- a/gateway/src/stdb/store/combat.rs +++ b/gateway/src/stdb/store/combat.rs @@ -2,26 +2,38 @@ use anyhow::Result; -use crate::world::CombatStore; - +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<()> { - self.set_target(account_id, self_guid, target_guid) + /// 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, + "gw_set_target", + gw_set_target_then(self.session_actor(actor), 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 pet_command(&self, actor: Actor, data: u32, target_guid: u64) -> Result<()> { + 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) + ) } - fn set_sheathed(&self, account_id: u64, self_guid: u64, state: u8) -> Result<()> { - self.set_sheathed(account_id, self_guid, state) + fn set_sheathed(&self, actor: Actor, state: u8) -> Result<()> { + 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..8dc58ad9c 100644 --- a/gateway/src/stdb/store/death.rs +++ b/gateway/src/stdb/store/death.rs @@ -1,33 +1,70 @@ //! `Coordinator`'s [`DeathStore`] adapter. use anyhow::Result; +use spacetimedb_sdk::Table; -use crate::world::DeathStore; - +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<()> { - self.repop(account_id, self_guid) + fn repop(&self, actor: Actor) -> Result<()> { + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_repop", + gw_repop_then(self.session_actor(actor)) + ) } - fn reclaim_corpse(&self, account_id: u64, self_guid: u64, corpse_guid: u64) -> Result<()> { - self.reclaim_corpse(account_id, self_guid, corpse_guid) + fn reclaim_corpse(&self, actor: Actor, corpse_guid: u64) -> Result<()> { + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_reclaim_corpse", + gw_reclaim_corpse_then(self.session_actor(actor), corpse_guid) + ) } - fn resurrect_response(&self, account_id: u64, self_guid: u64, accept: bool) -> Result<()> { - self.resurrect_response(account_id, self_guid, accept) + fn resurrect_response(&self, actor: Actor, accept: bool) -> Result<()> { + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_respond_resurrect", + gw_respond_resurrect_then(self.session_actor(actor), accept) + ) } - fn self_resurrect(&self, account_id: u64, self_guid: u64) -> Result<()> { - self.self_resurrect(account_id, self_guid) + fn self_resurrect(&self, actor: Actor) -> Result<()> { + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_self_resurrect", + gw_self_resurrect_then(self.session_actor(actor)) + ) } - 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) + /// `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, + "gw_spirit_res", + 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> { - self.corpse_location(owner_guid) + 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/stdb/store/duel.rs b/gateway/src/stdb/store/duel.rs new file mode 100644 index 000000000..4d0ecd974 --- /dev/null +++ b/gateway/src/stdb/store/duel.rs @@ -0,0 +1,27 @@ +//! `Coordinator`'s [`DuelActionStore`] adapter. + +use anyhow::Result; + +use crate::stdb::bindings::*; +use crate::stdb::connection::call_reducer; +use crate::world::{Actor, DuelActionStore}; + +impl DuelActionStore for crate::stdb::Coordinator { + fn duel_accept(&self, actor: Actor, flag_guid: u64) -> Result<()> { + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_duel_accept", + gw_duel_accept_then(self.session_actor(actor), flag_guid) + ) + } + + fn duel_cancel(&self, actor: Actor, flag_guid: u64) -> Result<()> { + 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..118da271e --- /dev/null +++ b/gateway/src/stdb/store/guild.rs @@ -0,0 +1,406 @@ +//! `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, Actor, CharacterFacts, GuildActionStore, GuildOutcome, GuildRequest, +}; + +impl GuildActionStore for Coordinator { + /// The membership row of `character_guid` on Realm-core. + fn guild_member(&self, character_guid: u64) -> Result> { + let realm = self.realm_core()?; + let member = realm + .0 + .coord() + .conn + .db + .game_guild_member() + .character_guid() + .find(&character_guid) + .map(member_view); + Ok(member) + } + + /// 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 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 + .into_iter() + .filter_map(|id| ranks_table.id().find(&id)) + .map(|rank| codec::GuildRankView { + rank_id: rank.rank_id, + name: rank.name, + rights: rank.rights, + }) + .collect(); + ranks.sort_by_key(|rank| rank.rank_id); + Ok(Some(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 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(); + Ok(guids + .into_iter() + .filter_map(|guid| members.character_guid().find(&guid)) + .filter(|member| member.guild_id == guild_id) + .map(member_view) + .collect()) + } + + 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(self.home_gm_level(actor_guid)) + } + + /// 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 + .db + .game_world_entity() + .guid() + .find(&actor_guid) + .map_or(0, |entity| entity.target_guid) + } + + 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 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, + 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, + }; + guild_outcome(call_reducer!( + realm.0.call_pipe().conn.reducers, + "realm_guild_op", + 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) + } + + fn guild_petition_of_charter( + &self, + charter_item_guid: u64, + ) -> 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.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 { + 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, + } +} + +#[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/stdb/store/guild_fee.rs b/gateway/src/stdb/store/guild_fee.rs new file mode 100644 index 000000000..4ab15200f --- /dev/null +++ b/gateway/src/stdb/store/guild_fee.rs @@ -0,0 +1,197 @@ +//! `Coordinator`'s [`GuildFeeStore`] adapter. + +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::Coordinator; +use crate::world::guild_fee; +use crate::world::guild_fee::{FeeHold, FeeOutcome, FeeRequest, FeeTerms, GuildFeeStore}; +use crate::world::Actor; + +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: Actor, + request: FeeRequest, + ) -> Result> { + let operation_id = next_operation_id()?; + let request = match request { + FeeRequest::Emblem { npc_guid, emblem } => { + GuildFeeRequest::Emblem(GuildEmblemPurchase { + npc_guid, + emblem: guild_emblem(emblem), + }) + } + 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.session_actor(actor), request) + ); + match result { + Ok(()) => wait_for_cache_row(operation_id, "guild Fee Hold", || { + self.guild_fee_hold_row(actor) + .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 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, + } => { + 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!( + realm.0.call_pipe().conn.reducers, + "realm_guild_fee_decide", + 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", || { + realm + .0 + .coord() + .conn + .db + .game_guild_fee_decision() + .operation_id() + .find(&hold.operation_id) + })?; + fee_outcome(&decision) + } + + /// `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.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, + 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..bafd4f3ef --- /dev/null +++ b/gateway/src/stdb/store/item.rs @@ -0,0 +1,135 @@ +//! `Coordinator`'s [`ItemActionStore`] adapter. + +use anyhow::Result; +use lyracore_shared::item::ItemRefusal; + +use crate::stdb::bindings::*; +use crate::stdb::connection::{call_reducer, classify, DurableFailure}; +use crate::stdb::Coordinator; +use crate::world::{Actor, ItemActionResult, ItemActionStore}; + +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. + fn equip_item(&self, actor: Actor, from_slot: u8) -> Result { + 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". + fn unequip_item(&self, actor: Actor, from_slot: u8) -> Result { + 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) + )) + } + + /// 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_move_item", + gw_move_item_then(self.session_actor(actor), from_slot, to_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. + fn use_item(&self, actor: Actor, 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) + )) + } + + fn split_item( + &self, + actor: Actor, + from_slot: u8, + to_slot: u8, + count: u32, + ) -> Result { + let coord = self.0.call_pipe(); + item_action(call_reducer!( + coord.conn.reducers, + "gw_split_item", + gw_split_item_then(self.session_actor(actor), from_slot, to_slot, count) + )) + } + + fn destroy_item(&self, actor: Actor, slot: u8, count: u32) -> Result { + let coord = self.0.call_pipe(); + item_action(call_reducer!( + coord.conn.reducers, + "gw_destroy_item", + gw_destroy_item_then(self.session_actor(actor), slot, count) + )) + } +} + +/// 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 classify(&error) { + DurableFailure::Refusal { reason } => match ItemRefusal::parse_tag(reason) { + Some(refusal) => Ok(refusal.into()), + None => Err(error), + }, + DurableFailure::TransportLoss => Err(error), + }, + } +} + +#[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::refused( + "gw_move_item", + refusal.as_tag(), + )) + .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::transport_lost("gw_use_item")), + anyhow::Error::from(ReducerCallError::refused("gw_equip_item", "operator only")), + 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..7bb173481 100644 --- a/gateway/src/stdb/store/loot_roll.rs +++ b/gateway/src/stdb/store/loot_roll.rs @@ -1,50 +1,48 @@ //! `Coordinator`'s [`LootRollStore`] adapter. use anyhow::Result; +use lyracore_shared::loot::LootRefusal; +use spacetimedb_sdk::Table; +use crate::stdb::bindings::*; +use crate::stdb::connection::call_reducer; +use crate::stdb::{classify, Coordinator, DurableFailure}; use crate::world::loot::{LootRollStore, PendingLootRoll}; -use crate::world::LootActionStatus; - -use crate::stdb::Coordinator; +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 + ) ) } @@ -52,36 +50,204 @@ 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) + 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 + ) + )) } + /// 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`). fn pending_local_rolls(&self) -> Result> { - self.pending_local_rolls() + 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(); + 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()) } + /// 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<()> { - self.settle_loot_roll(corpse_guid, slot, winner_guid) + 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<()> { - self.clear_promoted_loot_roll(roll_id) + 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. fn loot_won_since(&self, after_id: u64) -> Result<(u64, Vec<(u64, u8, u64)>)> { - self.loot_won_since(after_id) + 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)) + } + + /// 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, + actor: Actor, + corpse_guid: u64, + loot_slot: u32, + vote: u8, + ) -> Result { + 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) + )) } fn loot_master_give( &self, - account_id: u64, - self_guid: u64, + actor: Actor, corpse_guid: u64, loot_slot: u8, target_guid: u64, ) -> Result { - self.loot_master_give(account_id, self_guid, corpse_guid, loot_slot, target_guid) + 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 + ) + )) + } +} + +/// 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 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), + }, + } +} + +#[cfg(test)] +mod loot_reducer_tests { + use super::*; + use crate::stdb::connection::ReducerCallError; + + fn refused(reason: &str) -> Result<()> { + Err( + anyhow::Error::from(ReducerCallError::refused("gw_loot_roll", reason)) + .context("loot roll"), + ) + } + + #[test] + fn a_known_refusal_tag_is_an_answer() { + for refusal in [ + LootRefusal::RollUnavailable, + LootRefusal::NotMasterLooter, + LootRefusal::RecipientUnavailable, + LootRefusal::RecipientInventoryFull, + ] { + assert_eq!( + loot_action_status(refused(refusal.as_tag())).unwrap(), + LootActionStatus::Refused(refusal) + ); + } + } + + #[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(result).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..bc5960b04 --- /dev/null +++ b/gateway/src/stdb/store/loot_window.rs @@ -0,0 +1,558 @@ +//! `Coordinator`'s [`LootWindowStore`] adapter. + +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, LiveConn}; +use crate::stdb::reads::player_item_count; +use crate::stdb::{classify, Coordinator, DurableFailure}; +use crate::world::{Actor, LootWindowRefusal, LootWindowRequestStatus, LootWindowStore}; + +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 { + Ok(self + .0 + .coord() + .conn + .db + .game_world_entity() + .guid() + .find(&target_guid) + .filter(|e| e.dead) + .map(|e| e.money) + .unwrap_or(0)) + } + + /// 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 + /// 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). + fn loot_target_items( + &self, + target_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 == target_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) + } + + /// 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, + "gw_use_gameobject", + gw_use_gameobject_then(self.session_actor(actor), go_guid) + )) + } + + /// Authorizes opening a creature corpse before the Gateway reads its loot rows. + fn open_creature_loot( + &self, + actor: Actor, + corpse_guid: u64, + ) -> Result { + 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) + )) + } + + fn skin_corpse(&self, actor: Actor, corpse_guid: u64) -> Result { + 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) + )) + } + + 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_loot_money", + gw_loot_money_then(self.session_actor(actor), target_guid) + )) + } + + /// 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, + actor: Actor, + corpse_guid: u64, + loot_slot: u8, + ) -> 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) + )) + } +} + +/// 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 classify(&error) { + DurableFailure::Refusal { 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), + } + } + DurableFailure::TransportLoss => 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::refused("gw_take_loot", reason)) + .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_transport_loss_is_not_answered_as_a_refusal() { + let not_refusals = [ + anyhow::Error::from(ReducerCallError::transport_lost("gw_loot_money")), + anyhow::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..ef0f4e203 100644 --- a/gateway/src/stdb/store/mail.rs +++ b/gateway/src/stdb/store/mail.rs @@ -1,44 +1,112 @@ //! `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::{Actor, MailStore}; impl MailStore for Coordinator { - fn mail_list(&self, recipient_guid: u64) -> Result> { - self.mail_list(recipient_guid) + /// 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`). + 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()) } - fn mail_by_id(&self, mail_id: u64) -> Result> { - Ok(self.mail_by_id(mail_id)) + /// The mail `mail_id`, delivered or not, by its primary key. + fn mail_by_id(&self, mail_id: u64) -> Result> { + let guard = self.0.coord(); + let db = &guard.conn.db; + Ok(db.game_mail().id().find(&mail_id).map(|m| mail_view(db, m))) } 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? + /// + /// 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. fn mailbox_in_range(&self, mailbox_guid: u64, player_guid: u64) -> Result { - self.mailbox_in_range(mailbox_guid, player_guid) + 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) } - fn mail_mark_read(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { - self.mail_mark_read(recipient_guid, mail_id) + /// `realm_mail_mark_read` — flip a mail's read state against the database THIS handle points + /// 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. + 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.session_actor(recipient), mail_id) + ) } - fn mail_delete(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { - self.mail_delete(recipient_guid, mail_id) + /// `realm_mail_delete` — [`mail_mark_read`](Self::mail_mark_read)'s twin for delete. + 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.session_actor(recipient), 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) + /// `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. + 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.session_actor(recipient), 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)] fn mail_send( &self, - sender_guid: u64, + sender: Actor, recipient_guid: u64, subject: String, body: String, @@ -47,59 +115,133 @@ impl MailStore for Coordinator { item_guid: u64, same_account: bool, ) -> Result<()> { - self.mail_send( - sender_guid, - recipient_guid, - subject, - body, - money, - cod, - item_guid, - same_account, + call_reducer!( + self.0.call_pipe().conn.reducers, + "realm_mail_send", + realm_mail_send_then( + self.session_actor(sender), + 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) + /// `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. + 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.session_actor(recipient), mail_id) + ) } - fn mail_take_item(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { - self.mail_take_item(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. + 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.session_actor(recipient), mail_id) + ) } - fn mail_item_room(&self, payee_guid: u64) -> Result<()> { - self.mail_item_room(payee_guid) + /// `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. + 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.session_actor(payee)) + ) } - fn mail_copy_text(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { - self.mail_copy_text(recipient_guid, mail_id) + /// `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` 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.session_actor(recipient), mail_id) + ) } - fn mail_grant_letter(&self, payee_guid: u64, item_text_id: u32) -> Result<()> { - self.mail_grant_letter(payee_guid, item_text_id) + /// `gw_mail_grant_letter` — Letter Copy step 2, on the PAYEE's own handle: grants one Plain + /// Letter carrying `item_text_id`. + 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.session_actor(payee), 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) + /// `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. + 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.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> { - self.item_text(item_text_id) + 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 { - self.owns_item_with_text(owner_guid, item_text_id, hint_item_guid) + 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)] fn mail_fence( &self, escrow_id: u64, - sender_guid: u64, + sender: Actor, recipient_guid: u64, subject: String, body: String, @@ -110,25 +252,33 @@ impl MailStore for Coordinator { 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, + call_reducer!( + self.0.call_pipe().conn.reducers, + "realm_mail_fence", + realm_mail_fence_then( + escrow_id, + self.session_actor(sender), + 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)] fn mail_commit( &self, escrow_id: u64, - sender_guid: u64, + sender: Actor, recipient_guid: u64, subject: String, body: String, @@ -139,70 +289,226 @@ impl MailStore for Coordinator { 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, + 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.session_actor(sender), + 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`. fn mail_take_money_fence( &self, escrow_id: u64, - payee_guid: u64, + payee: Actor, mail_id: u64, expect_money: u32, ) -> Result<()> { - self.mail_take_money_fence(escrow_id, payee_guid, mail_id, expect_money) + call_reducer!( + self.0.call_pipe().conn.reducers, + "realm_mail_take_money_fence", + realm_mail_take_money_fence_then( + escrow_id, + self.session_actor(payee), + 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) + /// `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. + 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.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. fn mail_take_item_fence( &self, escrow_id: u64, - payee_guid: u64, + payee: Actor, mail_id: u64, expect_entry: u32, ) -> Result<()> { - self.mail_take_item_fence(escrow_id, payee_guid, mail_id, expect_entry) + call_reducer!( + self.0.call_pipe().conn.reducers, + "realm_mail_take_item_fence", + realm_mail_take_item_fence_then( + escrow_id, + self.session_actor(payee), + 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. fn mail_item_payout( &self, escrow_id: u64, - payee_guid: u64, + payee: Actor, mail_id: u64, item: crate::world::mail::AttachedItem, ) -> Result<()> { - self.mail_item_payout(escrow_id, payee_guid, mail_id, item) + call_reducer!( + self.0.call_pipe().conn.reducers, + "realm_mail_item_payout", + realm_mail_item_payout_then( + escrow_id, + self.session_actor(payee), + 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. fn mail_confirm_delivery(&self, escrow_id: u64) -> Result<()> { - self.mail_confirm_delivery(escrow_id) + 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. fn mail_settle(&self, escrow_id: u64) -> Result<()> { - self.mail_settle(escrow_id) + 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> { - self.mail_escrows_of(sender_guid) + 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 { + 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..cc3392219 --- /dev/null +++ b/gateway/src/stdb/store/meeting_stone.rs @@ -0,0 +1,134 @@ +//! `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, Actor, MeetingStoneActionStore, MeetingStoneOutcome}; + +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> { + 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>> { + 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, + } + }), + ) + } + + /// `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. + fn meeting_stone_op( + &self, + actor: Actor, + 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.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 { + 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), + }, + } +} + +#[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/store/melee.rs b/gateway/src/stdb/store/melee.rs new file mode 100644 index 000000000..51ff4ac98 --- /dev/null +++ b/gateway/src/stdb/store/melee.rs @@ -0,0 +1,31 @@ +//! `Coordinator`'s [`MeleeActionStore`] adapter. + +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 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. + fn start_attack(&self, actor: Actor, target_guid: u64) -> Result<()> { + 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). + fn stop_attack(&self, actor: Actor) -> Result<()> { + 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..ac31db320 --- /dev/null +++ b/gateway/src/stdb/store/member_stats.rs @@ -0,0 +1,148 @@ +//! `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 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. + 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 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) { + Some(Whereabouts::InWorld { + entity, shard_name, .. + }) => { + let entity = self.with_member_shard_stats(&shard_name, guid, entity); + MemberPresence::Live(Box::new(crate::codec::MemberStats::from_entity(&entity))) + } + 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 + /// `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..1dacd8f1c 100644 --- a/gateway/src/stdb/store/mod.rs +++ b/gateway/src/stdb/store/mod.rs @@ -1,22 +1,119 @@ -//! `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. +use anyhow::{anyhow, Result}; +use std::time::Duration; + +use crate::stdb::connection::{classify, DurableFailure}; +use crate::world::InteractionOutcome; + +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; + +/// 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); + } + } +} + +/// A Character request whose Refusal the client reads as a system message. +fn interaction_outcome(result: Result<()>) -> Result { + match result { + Ok(()) => Ok(InteractionOutcome::Done), + Err(error) => match classify(&error) { + DurableFailure::Refusal { reason } => { + Ok(InteractionOutcome::Refused(reason.to_owned())) + } + DurableFailure::TransportLoss => Err(error), + }, + } +} + +#[cfg(test)] +mod interaction_outcome_tests { + use super::*; + use crate::stdb::connection::ReducerCallError; + + #[test] + fn interaction_outcomes_keep_module_refusals_separate_from_transport_loss() { + assert_eq!( + interaction_outcome(Ok(())).unwrap(), + InteractionOutcome::Done + ); + let refusal = anyhow::Error::from(ReducerCallError::refused( + "gw_bind_home", + "innkeeper out of range", + )) + .context("request completion"); + assert_eq!( + interaction_outcome(Err(refusal)).unwrap(), + InteractionOutcome::Refused("innkeeper out of range".into()) + ); + for failure in [ + anyhow::Error::from(ReducerCallError::fatal("request timed out".into())), + anyhow::Error::from(ReducerCallError::transport_lost("gw_bind_home")), + anyhow!("innkeeper out of range"), + ] { + assert!(interaction_outcome(Err(failure)).is_err()); + } + } +} diff --git a/gateway/src/stdb/store/npc.rs b/gateway/src/stdb/store/npc.rs index 71b06dabf..0734ee09f 100644 --- a/gateway/src/stdb/store/npc.rs +++ b/gateway/src/stdb/store/npc.rs @@ -1,79 +1,393 @@ //! `Coordinator`'s [`NpcStore`] adapter. use anyhow::Result; +use spacetimedb_sdk::Table; use crate::codec; -use crate::world::{InteractionOutcome, NpcStore}; - +use crate::codec::PetNameView; +use crate::stdb::bindings::*; +use crate::stdb::connection::call_reducer; use crate::stdb::Coordinator; +use crate::world::{Actor, InteractionOutcome, NpcStore}; + +use super::interaction_outcome; impl NpcStore for Coordinator { - fn creature_template(&self, entry: u32) -> Result> { - self.creature_template(entry) + /// 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 { + Coordinator::npc_refuses_interaction(self, npc_guid, player_guid) + } + + fn gameobject_template(&self, entry: u32) -> Result> { + Coordinator::gameobject_template(self, entry) + } + + /// Resolve the active title text for one NPC, with a per-creature menu override before the + /// entry-owned default menu. + 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). + 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. + 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? Shows its gossip option. Reads `npc_flags` + /// off the entity (privileged cache); absent → false. + 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. fn pet_name( &self, - requester_guid: u64, + _requester: Actor, pet_number: u32, pet_guid: u64, - ) -> Result> { - self.pet_name(requester_guid, pet_number, pet_guid) + ) -> Result, anyhow::Error> { + if 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)) } - fn gameobject_template(&self, entry: u32) -> Result> { - self.gameobject_template(entry) + /// Read a creature template by entry for a `CMSG_CREATURE_QUERY` reply (Tier 2 / NPCs). + 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). fn gameobject_type(&self, go_guid: u64) -> Result> { - self.gameobject_type(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) + }) + .map(|t| t.type_id)) } - fn enter_areatrigger(&self, account_id: u64, self_guid: u64, trigger_id: u32) -> Result<()> { - self.enter_areatrigger(account_id, self_guid, trigger_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. + fn inspect(&self, actor: Actor, target_guid: u64) -> Result<()> { + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_inspect", + gw_inspect_then(self.session_actor(actor), target_guid) + ) } - fn npc_refuses_interaction(&self, npc_guid: u64, player_guid: u64) -> Result { - self.npc_refuses_interaction(npc_guid, player_guid) + /// Enter an area trigger (`CMSG_AREATRIGGER`) — credit any active explore quest tied to `trigger_id`. + fn enter_areatrigger(&self, actor: Actor, trigger_id: u32) -> Result<()> { + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_enter_areatrigger", + gw_enter_areatrigger_then(self.session_actor(actor), trigger_id) + ) } - fn bind_home( + /// `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. + fn gossip_select( &self, - account_id: u64, - self_guid: u64, - innkeeper_guid: u64, - ) -> Result { - self.bind_home(account_id, self_guid, innkeeper_guid) + actor: Actor, + npc_guid: u64, + option_id: u32, + option_row_id: u32, + ) -> Result<()> { + 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 + ) + ) } - fn npc_is_innkeeper(&self, guid: u64) -> Result { - self.npc_is_innkeeper(guid) + /// Bind the Character's home after the Module checks the selected innkeeper. + fn bind_home(&self, actor: Actor, innkeeper_guid: u64) -> Result { + let coord = self.0.call_pipe(); + interaction_outcome(call_reducer!( + coord.conn.reducers, + "gw_bind_home", + gw_bind_home_then(self.session_actor(actor), innkeeper_guid) + )) } +} - fn npc_gossip_text_id(&self, npc_guid: u64) -> u32 { - self.npc_gossip_text_id(npc_guid) +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, + }) +} - fn npc_text_for_id(&self, text_id: u32) -> Option { - self.npc_text_for_id(text_id) +#[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); } +} - fn gossip_options(&self, npc_guid: u64) -> Result> { - self.gossip_options(npc_guid) +#[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, + } } - fn inspect(&self, account_id: u64, self_guid: u64, target_guid: u64) -> Result<()> { - self.inspect(account_id, self_guid, target_guid) + #[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()); } - 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) + #[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..829996293 100644 --- a/gateway/src/stdb/store/party.rs +++ b/gateway/src/stdb/store/party.rs @@ -1,82 +1,33 @@ //! `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; 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) + call_reducer!( + self.0.call_pipe().conn.reducers, + "claim_party_command_intent", + claim_party_command_intent_then(intent_id, claim_token) + ) } fn admit_party_command_authority( @@ -86,46 +37,103 @@ impl PartyStore for Coordinator { 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, - ) + ) -> 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), + } } fn apply_admitted_party_command( &self, - command: &crate::world::party::AdmittedCompanionCommand, - ) -> Result { - Coordinator::apply_admitted_party_command(self, command) + 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")) } fn finish_party_command_intent( &self, intent_id: u64, claim_token: u64, - outcome: crate::world::party::CompanionCommandOutcome, + outcome: CompanionCommandOutcome, ) -> Result<()> { - Coordinator::finish_party_command_intent(self, intent_id, claim_token, outcome) + 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) + ) + ) } fn confirm_party_command_receipt( &self, - source_identity: spacetimedb_sdk::Identity, + source_identity: Identity, intent_id: u64, - ) -> Result> { - Coordinator::confirm_party_command_receipt(self, source_identity, intent_id) + ) -> 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), + } } fn confirm_party_command_holder( &self, bot_guid: u64, ) -> Result { - Coordinator::confirm_party_command_holder(self, bot_guid) + 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 refusal_tag(&error) { + Some("MissingBot") => Ok(crate::world::party::PartyCommandHolder::Missing), + Some("TransferInProgress") => { + Ok(crate::world::party::PartyCommandHolder::InTransit) + } + _ => Err(error), + }, + } } fn entity_partition(&self, guid: u64) -> Option<(u32, u64)> { @@ -139,42 +147,93 @@ impl PartyStore for Coordinator { .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) + /// 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) + )) } + /// `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`]. fn realm_group_op( &self, op: u8, - actor_guid: u64, + actor: Actor, 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) + ) -> Result { + party_outcome(call_reducer!( + self.0.call_pipe().conn.reducers, + "realm_group_op", + realm_group_op_then( + op, + self.session_actor(actor), + 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. 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 { - self.realm_group_op_visible(op, actor_guid, target_guid, arg_a, arg_b, arg_c) + ) -> Result { + let coordinator = self.0.visibility_pipe(); + party_outcome(call_reducer!( + coordinator.conn.reducers, + "realm_group_op", + realm_group_op_then( + op, + self.session_actor(actor), + 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) + /// 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. + 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.session_actor(character), + 0, + lyracore_shared::group::leave_cause::CHARACTER_DELETED, + 0, + 0 + ) + )) } fn group_roster( @@ -184,11 +243,53 @@ impl PartyStore for Coordinator { 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, - ) -> Result> { - self.party_command_group_roster(character_guid) + ) -> 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( @@ -198,8 +299,17 @@ impl PartyStore for Coordinator { 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.group_roster_revision(group_id)) + 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> { @@ -220,7 +330,32 @@ impl PartyStore for Coordinator { 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) + 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( @@ -247,7 +382,15 @@ impl PartyStore for Coordinator { self.shard_name() ); } - Ok(self.party_member_guids()) + Ok(self + .0 + .coord() + .conn + .db + .game_group_member() + .iter() + .map(|member| member.character_guid) + .collect()) } fn party_group_ids(&self) -> Result> { @@ -257,10 +400,233 @@ impl PartyStore for Coordinator { self.shard_name() ); } - Ok(self.party_group_ids()) + 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. fn sync_group_mirror(&self, roster: &crate::world::party::GroupRoster) -> Result<()> { - self.sync_group_mirror(roster) + 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() + ) + ) + } + + /// `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 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 = refusal_tag(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 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 { + 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/quest.rs b/gateway/src/stdb/store/quest.rs new file mode 100644 index 000000000..dc2a9ffd2 --- /dev/null +++ b/gateway/src/stdb/store/quest.rs @@ -0,0 +1,456 @@ +//! `Coordinator`'s [`QuestActionStore`] adapter. + +use anyhow::Result; +use spacetimedb_sdk::Table; + +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 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. + 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 + /// `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)`. + 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. + fn giver_quest_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. + 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. + 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. + fn push_quest(&self, actor: Actor, quest_id: u32) -> Result<()> { + 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`. + fn accept_quest(&self, actor: Actor, giver_guid: u64, quest_id: u32) -> Result<()> { + 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. + fn turn_in_quest( + &self, + actor: Actor, + giver_guid: u64, + quest_id: u32, + reward_index: u32, + ) -> Result<()> { + 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. + fn abandon_quest(&self, actor: Actor, quest_id: u32) -> Result<()> { + 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 `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` +/// (`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 9fccf9bc7..246e6dd9e 100644 --- a/gateway/src/stdb/store/session.rs +++ b/gateway/src/stdb/store/session.rs @@ -1,45 +1,106 @@ //! `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::world::{SessionStore, SessionTx, WorldSession, WorldStore, MOVE_SUBMITTED}; - -use crate::stdb::bindings::GwMove; -use crate::stdb::Coordinator; -use crate::stdb::PlayerSubscriptions; +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}; impl SessionStore for Coordinator { fn lookup_session(&self, account_name: &str) -> Result> { crate::realm_core::lookup_session(self, account_name) } + /// 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`. fn player_login( &self, account_id: u64, - character_guid: u64, + character: Actor, entry: codec::WorldEntry, ) -> Result { - self.player_login(account_id, character_guid, entry) - } + // 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.session_actor(character); + match entry { + codec::WorldEntry::FreshLogin => call_reducer!( + coord.conn.reducers, + "gw_player_login", + gw_player_login_then(account_id, actor) + )?, + codec::WorldEntry::WorldPort => call_reducer!( + coord.conn.reducers, + "gw_player_world_port", + gw_player_world_port_then(account_id, actor) + )?, + } - fn movement_update( - &self, - _account_id: u64, - self_guid: u64, - opcode: u32, - info: &MovementInfo, - ) -> Result<()> { - if self_guid == 0 { - return Err(anyhow!("movement_update: actor_guid unresolved")); + // 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() + .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" + )) + } + + 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(self_guid), + actor: self.session_actor(actor), opcode: opcode as u16, movement_info: body, x: info.position.x, @@ -62,26 +123,54 @@ impl SessionStore for Coordinator { 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) + /// 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!( + 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 { - self.system_messages_for(self_guid) + 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.entity_in_world(guid) + 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.entity_max_health(guid) + self.0 + .coord() + .conn + .db + .game_world_entity() + .guid() + .find(&guid) + .map_or(0, |e| e.max_health) } fn claim_session( @@ -104,6 +193,13 @@ impl SessionStore for Coordinator { } fn player_combat_until_ms(&self, player_guid: u64) -> u64 { - self.player_combat_until_ms(player_guid) + 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/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..2dbf31637 100644 --- a/gateway/src/stdb/store/social.rs +++ b/gateway/src/stdb/store/social.rs @@ -1,30 +1,95 @@ //! `Coordinator`'s [`SocialStore`] adapter. use 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; use crate::stdb::Coordinator; +use crate::stdb::{classify, DurableFailure}; +use crate::world::{Actor, ContactOutcome, SocialStore}; impl SocialStore for 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. fn character_identity( &self, guid: u64, ) -> Result> { - self.character_identity(guid) + 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, + })) } - fn live_entity(&self, guid: u64) -> Option { - self.live_entity(guid) + /// 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. + 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. fn character_in_transit(&self, guid: u64) -> bool { - self.character_in_transit(guid) + 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) } + /// 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.auto_reply_text(guid)) + 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<()> { @@ -32,60 +97,183 @@ impl SocialStore for Coordinator { 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. fn in_world_players(&self) -> Result> { - self.in_world_players() + 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. fn zone_name(&self, zone_id: u32) -> String { - self.zone_name(zone_id) + self.0 + .coord() + .conn + .db + .game_area() + .id() + .find(&zone_id) + .map(|a| a.name) + .unwrap_or_default() } - fn contact_lists(&self, self_guid: u64) -> Result<(Vec, Vec)> { - self.contact_lists(self_guid) + /// `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. + 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. fn ignored_guids(&self, owner_guid: u64) -> Result> { - self.ignored_guids(owner_guid) + let guard = self.0.coord(); + let contacts = guard.contacts.read().unwrap_or_else(|p| p.into_inner()); + Ok(contacts.ignored_by(owner_guid)) } + /// 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> { - self.character_guid_by_name(name) + 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. fn add_friend( &self, - account_id: u64, - self_guid: u64, + actor: Actor, target_guid: u64, target_race: u8, - ) -> Result { - self.add_friend(account_id, self_guid, target_guid, target_race) + ) -> Result { + contact_outcome(call_reducer!( + self.0.call_pipe().conn.reducers, + "gw_add_friend", + gw_add_friend_then(self.session_actor(actor), 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 del_friend(&self, actor: Actor, target_guid: u64) -> Result { + contact_outcome(call_reducer!( + self.0.call_pipe().conn.reducers, + "gw_del_friend", + gw_del_friend_then(self.session_actor(actor), 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) + /// `CMSG_ADD_IGNORE` — `target_guid` is already resolved by the gateway. + fn add_ignore(&self, actor: Actor, target_guid: u64) -> Result { + contact_outcome(call_reducer!( + self.0.call_pipe().conn.reducers, + "gw_add_ignore", + gw_add_ignore_then(self.session_actor(actor), 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) + fn del_ignore(&self, actor: Actor, target_guid: u64) -> Result { + contact_outcome(call_reducer!( + self.0.call_pipe().conn.reducers, + "gw_del_ignore", + gw_del_ignore_then(self.session_actor(actor), target_guid) + )) + } +} + +/// 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 { + 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/store/speech.rs b/gateway/src/stdb/store/speech.rs index 0274de298..6edb28684 100644 --- a/gateway/src/stdb/store/speech.rs +++ b/gateway/src/stdb/store/speech.rs @@ -2,34 +2,53 @@ use anyhow::Result; -use crate::world::{ChatOutcome, SpeechStore}; - +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 { + /// Say, yell or `/e` on the speaker's Home Shard. The Module's language Gate answers a + /// `chat:*` Refusal. fn send_chat( &self, - account_id: u64, - self_guid: u64, + actor: Actor, chat_type: u8, language: u8, message: String, ) -> Result { - self.send_chat(account_id, self_guid, chat_type, language, message) + chat_outcome(call_reducer!( + self.0.call_pipe().conn.reducers, + "gw_send_chat", + gw_send_chat_then(self.session_actor(actor), chat_type, language, message) + )) } fn send_emote( &self, - account_id: u64, - self_guid: u64, + actor: Actor, text_emote: u32, emote_anim: u32, target_guid: u64, ) -> Result<()> { - self.send_emote(account_id, self_guid, text_emote, emote_anim, target_guid) + call_reducer!( + self.0.call_pipe().conn.reducers, + "gw_send_emote", + gw_send_emote_then( + self.session_actor(actor), + 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) + /// GM playtest dot-command: resolve current Account authority on Realm-core, then convey it to + /// 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, text) } } diff --git a/gateway/src/stdb/store/taxi.rs b/gateway/src/stdb/store/taxi.rs new file mode 100644 index 000000000..c29b8dd0e --- /dev/null +++ b/gateway/src/stdb/store/taxi.rs @@ -0,0 +1,241 @@ +//! `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 Coordinator { + /// Cohesive status request: module resolution + policy runs before this returns one known bit. + fn taxi_node_status( + &self, + actor: Actor, + npc_guid: u64, + ) -> Result> { + 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( + actor, + npc_guid, + request_id, + lyracore_shared::constants::taxi_protocol::REPLY_STATUS, + )? + .filter(|reply| reply.accepted) + .map(|reply| codec::TaxiNodeStatusView { + npc_guid: reply.npc_guid, + known: reply.known, + })) + } + + 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, + "gw_open_taxi", + gw_open_taxi_then(self.session_actor(actor), npc_guid, request_id) + )?; + Ok(self + .await_taxi_reply( + actor, + npc_guid, + request_id, + lyracore_shared::constants::taxi_protocol::REPLY_OPEN, + )? + .filter(|reply| reply.accepted) + .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, + })) + } + + /// Cohesive direct-flight activation. The module commits every gameplay outcome as a stable + /// result code; only a failed Durable Request escapes as `Err`. + fn activate_taxi( + &self, + actor: Actor, + npc_guid: u64, + source_client_node_id: u32, + destination_client_node_id: u32, + ) -> Result { + 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( + actor, + npc_guid, + request_id, + lyracore_shared::constants::taxi_protocol::REPLY_ACTIVATE, + )? + .ok_or_else(|| anyhow!("taxi activation reply {request_id} disappeared"))?; + Ok(codec::TaxiActivationResult { + result_code: reply.result_code, + }) + } + + 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.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 { + 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..631987bd5 100644 --- a/gateway/src/stdb/store/trade.rs +++ b/gateway/src/stdb/store/trade.rs @@ -2,54 +2,98 @@ use anyhow::Result; -use crate::world::TradeStore; +use crate::stdb::bindings::*; +use crate::stdb::connection::call_reducer; +use crate::world::{Actor, TradeStore}; -use crate::stdb::Coordinator; - -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) +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, + "gw_initiate_trade", + gw_initiate_trade_then(self.session_actor(actor), target_guid) + ) } - fn begin_trade(&self, account_id: u64, self_guid: u64) -> Result<()> { - self.begin_trade(account_id, self_guid) + fn begin_trade(&self, actor: Actor) -> Result<()> { + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_begin_trade", + gw_begin_trade_then(self.session_actor(actor)) + ) } - fn cancel_trade(&self, account_id: u64, self_guid: u64) -> Result<()> { - self.cancel_trade(account_id, self_guid) + fn cancel_trade(&self, actor: Actor) -> Result<()> { + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_cancel_trade", + gw_cancel_trade_then(self.session_actor(actor)) + ) } - 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 set_trade_item(&self, actor: Actor, trade_slot: u8, inv_slot: u8) -> Result<()> { + 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) + ) } - 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 clear_trade_item(&self, actor: Actor, trade_slot: u8) -> Result<()> { + 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) + ) } - fn set_trade_gold(&self, account_id: u64, self_guid: u64, copper: u32) -> Result<()> { - self.set_trade_gold(account_id, self_guid, copper) + fn set_trade_gold(&self, actor: Actor, copper: u32) -> Result<()> { + 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) + ) } - fn accept_trade(&self, account_id: u64, self_guid: u64) -> Result<()> { - self.accept_trade(account_id, self_guid) + fn accept_trade(&self, actor: Actor) -> Result<()> { + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_accept_trade", + gw_accept_trade_then(self.session_actor(actor)) + ) } - fn unaccept_trade(&self, account_id: u64, self_guid: u64) -> Result<()> { - self.unaccept_trade(account_id, self_guid) + fn unaccept_trade(&self, actor: Actor) -> Result<()> { + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_unaccept_trade", + gw_unaccept_trade_then(self.session_actor(actor)) + ) } - fn busy_trade(&self, account_id: u64, self_guid: u64) -> Result<()> { - self.busy_trade(account_id, self_guid) + fn busy_trade(&self, actor: Actor) -> Result<()> { + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_busy_trade", + gw_busy_trade_then(self.session_actor(actor)) + ) } - fn ignore_trade(&self, account_id: u64, self_guid: u64) -> Result<()> { - self.ignore_trade(account_id, self_guid) + fn ignore_trade(&self, actor: Actor) -> Result<()> { + 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 349b8b035..e54ece9cf 100644 --- a/gateway/src/stdb/store/trainer.rs +++ b/gateway/src/stdb/store/trainer.rs @@ -1,15 +1,20 @@ //! `Coordinator`'s [`TrainerStore`] adapter. use anyhow::Result; +use lyracore_shared::trainer::TrainerRefusal; +use spacetimedb_sdk::Table; -use crate::codec; -use crate::world::{InteractionOutcome, 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, InteractionOutcome, TrainerStore}; + +use super::interaction_outcome; impl TrainerStore for Coordinator { + /// The Module's current talent reset price for the confirmation dialog. fn talent_reset_cost(&self, character_guid: u64) -> Option { - use crate::stdb::bindings::game_character_table::GameCharacterTableAccess; self.0 .coord() .conn @@ -20,89 +25,413 @@ impl TrainerStore for Coordinator { .map(|character| lyracore_shared::talent::respec_cost_copper(character.respec_count)) } + /// 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). + 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`. fn trainer_serves(&self, player_guid: u64, trainer_guid: u64) -> Result { - self.trainer_serves(player_guid, trainer_guid) + 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. + 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). fn trainer_list( &self, player_guid: u64, trainer_guid: u64, - ) -> Result> { - self.trainer_list(player_guid, trainer_guid) + ) -> 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()) } - 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) + /// 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"). + 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) } - fn trainer_offer_skill_line(&self, trainer_guid: u64, spell_id: u32) -> u32 { - self.trainer_offer_skill_line(trainer_guid, spell_id) + 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) } + /// 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.talent_by_id(talent_id) + self.0 + .coord() + .conn + .db + .game_talent() + .iter() + .find(|t| t.talent_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) + /// 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`). + 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() } - fn set_action_button( + /// 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. + 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`. + fn buy_trainer_spell( &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) + actor: Actor, + trainer_guid: u64, + spell_id: u32, + ) -> Result { + 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), + }, + } } - fn talent_pane_sync(&self, character_guid: u64, talent_id: u32) -> (u32, u32, u32) { - self.talent_pane_sync(character_guid, talent_id) + fn learn_talent(&self, actor: Actor, talent_id: u32) -> Result<()> { + let coord = self.0.call_pipe(); + call_reducer!( + coord.conn.reducers, + "gw_learn_talent", + gw_learn_talent_then(self.session_actor(actor), talent_id) + ) } - fn talent_points_spent(&self, character_guid: u64) -> u32 { - self.talent_points_spent(character_guid) + /// Confirm a talent reset. The Module checks the trainer, range and price. + fn reset_talents(&self, actor: Actor, trainer_guid: u64) -> Result { + let coord = self.0.call_pipe(); + interaction_outcome(call_reducer!( + coord.conn.reducers, + "gw_reset_talents", + gw_reset_talents_then(self.session_actor(actor), trainer_guid) + )) } - fn learn_talent(&self, account_id: u64, self_guid: u64, talent_id: u32) -> Result<()> { - self.learn_talent(account_id, self_guid, talent_id) + /// 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). + fn set_action_button( + &self, + actor: Actor, + button: u8, + action: u32, + action_type: u8, + ) -> Result<()> { + 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) + ) } - fn reset_talents( + /// Persist the rep pane's At-War checkbox (`CMSG_SET_FACTION_ATWAR`): the wire's + /// u32 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. + fn set_faction_at_war( &self, - account_id: u64, - self_guid: u64, - trainer_guid: u64, + actor: Actor, + reputation_index: u32, + at_war: bool, ) -> Result { - self.reset_talents(account_id, self_guid, trainer_guid) + let coord = self.0.call_pipe(); + interaction_outcome(call_reducer!( + coord.conn.reducers, + "gw_set_faction_at_war", + gw_set_faction_at_war_then(self.session_actor(actor), reputation_index, at_war) + )) } +} - fn resolve_learn_target(&self, spell_id: u32) -> u32 { - self.resolve_learn_target(spell_id) +/// 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 } +} - fn superseded_old_rank(&self, new_spell: u32, player_guid: u64) -> Option { - self.superseded_old_rank(new_spell, player_guid) - } +/// 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) +} - fn character_presence(&self, guid: u64) -> Result> { - self.character_presence(guid) +#[cfg(test)] +mod trainer_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 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..1bada1391 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::{Actor, TransferStore}; impl TransferStore for Coordinator { fn escrowed_transfer( @@ -40,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( @@ -62,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), } @@ -76,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( @@ -122,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( @@ -176,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( @@ -201,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 + ) ) } @@ -239,17 +306,85 @@ 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 + ) + ) + } +} + +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() + ) + ) + } + + /// `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() + ) + ) + } + + /// `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()) ) } } diff --git a/gateway/src/stdb/store/vendor.rs b/gateway/src/stdb/store/vendor.rs new file mode 100644 index 000000000..b4e6981eb --- /dev/null +++ b/gateway/src/stdb/store/vendor.rs @@ -0,0 +1,128 @@ +//! `Coordinator`'s [`VendorActionStore`] adapter. + +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::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> { + 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| { + db.game_item_template() + .entry() + .find(&v.item_entry) + .map(|t| 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) + } + + 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, + actor: Actor, + vendor_guid: u64, + item_entry: u32, + count: u32, + ) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "gw_buy_item", + gw_buy_item_then(self.session_actor(actor), vendor_guid, item_entry, count) + ) + } + + /// 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, 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!( + self.0.call_pipe().conn.reducers, + "gw_repair_item", + gw_repair_item_then(self.session_actor(actor), npc_guid, slot) + ) + } + + /// 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!( + self.0.call_pipe().conn.reducers, + "gw_sell_item", + gw_sell_item_then(self.session_actor(actor), vendor_guid, slot) + ) + } + + fn vendor_buyback(&self, actor: Actor, vendor_guid: u64, slot: u8) -> Result<()> { + call_reducer!( + 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/stdb/reads/weather.rs b/gateway/src/stdb/store/weather.rs similarity index 75% rename from gateway/src/stdb/reads/weather.rs rename to gateway/src/stdb/store/weather.rs index c8cc8ed0a..86cac243e 100644 --- a/gateway/src/stdb/reads/weather.rs +++ b/gateway/src/stdb/store/weather.rs @@ -1,14 +1,20 @@ -use super::*; +//! `Coordinator`'s [`WeatherStore`] adapter. + +use anyhow::Result; + use crate::codec::ZoneWeatherView; +use crate::stdb::bindings::*; +use crate::stdb::Coordinator; +use crate::world::WeatherStore; -impl super::super::connection::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); } diff --git a/gateway/src/stdb/subscriptions_character_gone_durable_tests.rs b/gateway/src/stdb/subscriptions_character_gone_durable_tests.rs index 793891997..5776a40c3 100644 --- a/gateway/src/stdb/subscriptions_character_gone_durable_tests.rs +++ b/gateway/src/stdb/subscriptions_character_gone_durable_tests.rs @@ -2,7 +2,9 @@ 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}; use std::process::{Command, Output}; @@ -232,25 +234,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 +399,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_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/stdb/subscriptions_party_command_durable_tests.rs b/gateway/src/stdb/subscriptions_party_command_durable_tests.rs index 997d4ef07..e3d6b57ae 100644 --- a/gateway/src/stdb/subscriptions_party_command_durable_tests.rs +++ b/gateway/src/stdb/subscriptions_party_command_durable_tests.rs @@ -6,9 +6,10 @@ 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::TransferStore; +use crate::world::{Actor, PartyStore, SessionStore, TransferStore}; use spacetimedb_sdk::Table; use std::collections::BTreeMap; use std::ffi::OsString; @@ -525,7 +526,7 @@ fn enter_transferred_actor( bound .player_login( account_id, - character_guid, + Actor::new(character_guid).unwrap(), crate::codec::WorldEntry::FreshLogin, ) .unwrap(); @@ -630,7 +631,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/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/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/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/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/benilla_tests.rs b/gateway/src/world/benilla_tests.rs index ff8084ce9..cb9bbbf25 100644 --- a/gateway/src/world/benilla_tests.rs +++ b/gateway/src/world/benilla_tests.rs @@ -1262,7 +1262,7 @@ fn benilla_bind_and_respec_confirmations_reach_existing_gameplay() { assert!(matches!(client.recv(), ServerPacket::GossipComplete)); assert_eq!( *store.trainer.reset_talents_calls.lock().unwrap(), - [(7, 1, 99)] + [(1, 99)] ); client.logout(); drop(client); diff --git a/gateway/src/world/benilla_tests/durable.rs b/gateway/src/world/benilla_tests/durable.rs index bf842609b..d3e631dee 100644 --- a/gateway/src/world/benilla_tests/durable.rs +++ b/gateway/src/world/benilla_tests/durable.rs @@ -5,6 +5,7 @@ use crate::accept::BlockingTaskCapacity; use crate::config::GatewayConfig; use crate::durable_test_support::Standalone; use crate::stdb::Coordinator; +use crate::world::CharacterStore; use std::time::{Duration, Instant}; struct Realm { 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/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/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/death_tests.rs b/gateway/src/world/death_tests.rs index ef61a5547..34dce197c 100644 --- a/gateway/src/world/death_tests.rs +++ b/gateway/src/world/death_tests.rs @@ -173,3 +173,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/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/guild_fee.rs b/gateway/src/world/guild_fee.rs index 5cef98211..87c925b30 100644 --- a/gateway/src/world/guild_fee.rs +++ b/gateway/src/world/guild_fee.rs @@ -9,9 +9,11 @@ //! 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; +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,68 +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) } -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 crate::stdb::ReducerCallError; use lyracore_shared::guild::EMBLEM_COST_COPPER; use std::sync::{Arc, Mutex}; @@ -178,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, @@ -251,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(()) } @@ -267,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() @@ -279,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)); @@ -306,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 @@ -335,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 @@ -367,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)); } @@ -380,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); } @@ -393,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); @@ -409,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:?}"); @@ -424,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); @@ -444,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); @@ -452,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); } } diff --git a/gateway/src/world/handle_loot_fake.rs b/gateway/src/world/handle_loot_fake.rs index dc3d86fa3..1fd775671 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::{InteractionOutcome, ShardRoutingStore, WorldStore}; -use anyhow::{anyhow, Result}; +use crate::world::{Actor, InteractionOutcome, 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 { @@ -215,7 +239,7 @@ impl NpcStore for HandleLootFake { fn pet_name( &self, - _requester_guid: u64, + _requester: Actor, _pet_number: u32, _pet_guid: u64, ) -> Result> { @@ -230,7 +254,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,12 +262,7 @@ impl NpcStore for HandleLootFake { unreachable!("no test reaches the NPC family") } - fn bind_home( - &self, - _account_id: u64, - _self_guid: u64, - _innkeeper_guid: u64, - ) -> Result { + fn bind_home(&self, _actor: Actor, _innkeeper_guid: u64) -> Result { unreachable!("no test reaches the NPC family") } @@ -263,14 +282,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, @@ -280,14 +298,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, @@ -301,7 +316,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") @@ -325,8 +340,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, @@ -336,8 +350,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/auction.rs b/gateway/src/world/handlers/auction.rs index 422864c8f..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,93 +159,19 @@ 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; } -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, @@ -323,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 { @@ -335,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( @@ -355,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 { @@ -502,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)); @@ -510,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(), @@ -520,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, @@ -541,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 { @@ -549,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, @@ -558,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(), @@ -581,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), )) @@ -592,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, @@ -612,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::{ @@ -622,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 { @@ -662,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 { @@ -672,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 { @@ -682,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()) } } @@ -747,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)), @@ -1156,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, @@ -1174,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, @@ -1370,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] @@ -1500,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, @@ -1540,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] @@ -1568,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/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/handlers/cast/cancel.rs b/gateway/src/world/handlers/cast/cancel.rs index 7da9a8d0d..fb3488ae3 100644 --- a/gateway/src/world/handlers/cast/cancel.rs +++ b/gateway/src/world/handlers/cast/cancel.rs @@ -9,11 +9,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 @@ -23,22 +19,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(), @@ -77,7 +76,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!( @@ -96,7 +95,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"); } @@ -117,13 +116,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, @@ -132,10 +134,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..1334a3ffd 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(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 e9f574b9e..5ef8113b3 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,55 +66,37 @@ 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. - 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. - 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 @@ -137,141 +112,8 @@ 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. +/// 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, @@ -280,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)] @@ -325,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. @@ -364,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(_) | ClientOpcodeMessage::CMSG_CANCEL_CHANNELLING(_) => cancel::cancel_cast(store, player), ClientOpcodeMessage::CMSG_CANCEL_AURA(c) => cancel::cancel_aura(store, player, c.id), @@ -395,7 +247,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. @@ -429,43 +281,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(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 { @@ -479,6 +333,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, @@ -488,20 +343,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 @@ -515,9 +368,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, @@ -529,14 +385,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) 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. @@ -562,22 +418,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()) + }) } } @@ -610,137 +458,97 @@ 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 { + fn item_slot_by_guid(&self, item_guid: u64) -> Option { self.item_slots .iter() .find(|(g, _)| *g == item_guid) .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(()) } @@ -757,24 +565,17 @@ 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.answer("gw_stop_attack", &None)?; + self.stop_attacks.lock().unwrap().push(actor.guid()); self.engaged .lock() .unwrap() - .retain(|&guid| guid != actor_guid); + .retain(|&guid| guid != actor.guid()); Ok(()) } } @@ -904,10 +705,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] @@ -918,7 +716,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" ); } @@ -1008,7 +806,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)] ); } @@ -1028,10 +826,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] @@ -1048,10 +843,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 ───────────────────────────────────────────────────── @@ -1074,7 +866,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()); } @@ -1115,7 +907,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))) { @@ -1123,13 +916,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, @@ -1140,11 +933,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] @@ -1247,10 +1037,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] @@ -1272,7 +1059,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() }; @@ -1280,7 +1067,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] @@ -1303,7 +1090,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}" ); } @@ -1336,7 +1123,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 be5b25e24..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,25 +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) { - if let Err(e) = store.stop_attack(player.account_id, player.self_guid.unwrap_or(0)) { +/// 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 @@ -249,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!( @@ -321,7 +320,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" ); } @@ -329,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] @@ -357,10 +371,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/channel.rs b/gateway/src/world/handlers/channel.rs index c72d48fa1..b4adcf386 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 @@ -73,29 +68,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 @@ -272,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 @@ -292,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, @@ -310,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()) } } @@ -327,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 { @@ -341,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 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, @@ -391,38 +339,17 @@ 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 = presence::unknown_as_absent(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(), @@ -434,8 +361,8 @@ fn run_targeted_op( }; submit_channel_op( store, - player.account_id, - actor_guid, + player, + actor, op, request, RefusalContext { @@ -454,29 +381,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(), ), }]) @@ -510,6 +429,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, @@ -519,21 +439,44 @@ 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, + /// Another World Shard cannot vouch for a Character's absence. + PresenceUnknown, + } + + 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(), + Self::PresenceUnknown => presence::PresenceUnknown(anyhow::anyhow!( + "World Shard instances has no healthy Coordinator subscription" + )) + .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>, @@ -544,15 +487,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(()) } @@ -564,11 +503,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) } @@ -580,15 +515,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")), } } @@ -604,16 +539,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) } @@ -629,7 +564,7 @@ mod tests { } } - fn store(outcome: Option>) -> InMemoryChannelActions { + fn store(outcome: Option>) -> InMemoryChannelActions { InMemoryChannelActions { facts: Some(orc()), outcome, @@ -886,20 +821,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()); } @@ -993,13 +924,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] @@ -1107,29 +1037,32 @@ 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 target whose Realm Presence is unknown goes out unresolved, so the Module answers. #[test] - fn a_failed_target_lookup_reaches_the_module_as_guid_zero_and_keeps_the_session() { + fn an_unknown_target_presence_goes_out_unresolved() { 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::PresenceUnknown), ..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()) - ); + handled(dispatch_channel_action(&store, player(), kick("Raiders", "Ghost")).unwrap()); let ops = store.ops.lock().unwrap(); - assert_eq!( - ops.len(), - 1, - "a recoverable lookup failure still reaches the Module" - ); + 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 lost 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 @@ -1153,40 +1086,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/handlers/char.rs b/gateway/src/world/handlers/char.rs index adcf1a5e2..b6d77114c 100644 --- a/gateway/src/world/handlers/char.rs +++ b/gateway/src/world/handlers/char.rs @@ -11,7 +11,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, @@ -22,13 +22,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 @@ -144,12 +144,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 @@ -252,7 +253,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 @@ -375,8 +376,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, @@ -385,16 +386,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( @@ -404,7 +403,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 @@ -412,15 +412,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:#}" @@ -442,6 +446,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)? { @@ -454,13 +461,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 @@ -478,10 +479,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. @@ -518,13 +520,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/handlers/chat.rs b/gateway/src/world/handlers/chat.rs index a088f5052..8e4d7f070 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; @@ -91,8 +92,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 +103,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, @@ -113,39 +112,10 @@ 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). - 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)) - } + /// 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, actor: Actor, text: String) -> Result<()>; } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -154,6 +124,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), @@ -272,8 +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). 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), and so does a +/// target whose Realm Presence is unknown. fn whisper( store: &St, player: ChatActionPlayer, @@ -281,23 +258,16 @@ 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 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)]), }; let request = WhisperRequest { language, @@ -305,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()) } } @@ -337,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()) } @@ -371,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); @@ -388,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, @@ -403,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) } } @@ -437,39 +394,79 @@ pub(crate) fn refusal_outbound( } } -/// A dead reducer transport cannot serve any further request, so it ends the World Session. -pub(super) fn is_transport_failure(error: &anyhow::Error) -> bool { - error - .chain() - .any(|cause| cause.to_string().contains("reducer transport disconnected")) +/// 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(()) + } + } } #[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, + /// Another World Shard cannot vouch for a Character's absence. + PresenceUnknown, + } + + 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(), + Self::PresenceUnknown => presence::PresenceUnknown(anyhow::anyhow!( + "World Shard instances has no healthy Coordinator subscription" + )) + .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)), } } @@ -479,19 +476,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")), } } @@ -504,12 +501,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 { @@ -525,7 +526,7 @@ mod tests { } } - fn store(outcome: Option>) -> InMemoryChatActions { + fn store(outcome: Option>) -> InMemoryChatActions { InMemoryChatActions { facts: Some(speaker()), outcome, @@ -766,20 +767,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()); @@ -881,8 +878,8 @@ mod tests { } fn whisper_store( - target: Result, String>, - outcome: Option>, + target: Result, Failure>, + outcome: Option>, ) -> InMemoryChatActions { InMemoryChatActions { target: Some(target), @@ -940,10 +937,20 @@ 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 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); + 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()); } @@ -989,19 +996,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()); } @@ -1030,15 +1031,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/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/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/handlers/duel.rs b/gateway/src/world/handlers/duel.rs index b22647034..b7917ed33 100644 --- a/gateway/src/world/handlers/duel.rs +++ b/gateway/src/world/handlers/duel.rs @@ -3,18 +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<()>; -} - -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) - } + fn duel_accept(&self, actor: Actor, flag_guid: u64) -> Result<()>; + fn duel_cancel(&self, actor: Actor, flag_guid: u64) -> Result<()>; } #[derive(Clone, Copy)] @@ -38,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 @@ -66,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) } } @@ -143,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/handlers/guild.rs b/gateway/src/world/handlers/guild.rs index 4b50c38e7..6bdcc9204 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>; @@ -212,85 +213,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, @@ -302,12 +224,23 @@ 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, 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), + } } pub(crate) fn dispatch_guild_action( @@ -421,8 +354,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 @@ -619,17 +552,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:?}"); } @@ -671,9 +605,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, @@ -681,15 +616,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, }, @@ -710,17 +645,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 { @@ -734,16 +670,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 { @@ -761,14 +698,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( @@ -792,10 +730,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) => { @@ -823,7 +761,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 { @@ -834,7 +772,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) => { @@ -947,10 +885,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()], @@ -969,10 +907,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()], @@ -995,7 +933,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 { @@ -1006,7 +944,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, @@ -1046,11 +984,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, @@ -1066,10 +1004,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)) } @@ -1078,10 +1016,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)) } @@ -1101,9 +1039,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())); @@ -1114,16 +1053,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()), @@ -1131,8 +1067,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()) } @@ -1220,9 +1156,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, @@ -1237,13 +1174,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 } @@ -1308,9 +1245,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()); } @@ -1319,7 +1257,7 @@ fn petition_buy_outbound( return Ok(Vec::new()); } let closed = store.guild_op( - actor_guid, + actor, GuildRequest::ClosePetition { petition_id: petition.petition_id, }, @@ -1345,7 +1283,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, )) @@ -1449,14 +1387,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(), @@ -1491,9 +1430,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) @@ -1504,7 +1444,7 @@ fn offer_petition_outbound( return Ok(Vec::new()); } let outcome = store.guild_op( - actor_guid, + actor, GuildRequest::OfferPetition { charter_item_guid, target_guid, @@ -1537,18 +1477,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 { @@ -1569,12 +1510,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. @@ -1589,13 +1530,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:?}"); } @@ -1611,9 +1550,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()); } @@ -1626,16 +1566,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 @@ -1724,6 +1659,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; } @@ -1733,30 +1671,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:?}"); @@ -1780,10 +1717,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 { @@ -1795,7 +1735,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" @@ -1848,7 +1788,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!( @@ -2022,6 +1965,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::{ @@ -2050,7 +1994,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. @@ -2060,9 +2005,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 @@ -2174,8 +2119,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 @@ -2212,10 +2157,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 @@ -2274,7 +2222,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() @@ -2306,13 +2254,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()); @@ -2334,11 +2282,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, @@ -2348,7 +2296,7 @@ mod tests { fn guild_fee_finish( &self, - _actor_guid: u64, + _actor: Actor, _operation_id: u64, _accepted: bool, ) -> Result<()> { @@ -2383,6 +2331,23 @@ mod tests { } } + fn lost_transport() -> anyhow::Error { + 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() + } + fn in_world(guid: u64) -> GuildActionPlayer { GuildActionPlayer { account_id: 7, @@ -2527,17 +2492,26 @@ mod tests { } #[test] - fn dot_create_answers_guild_not_created_when_presence_cannot_answer() { + fn dot_create_answers_not_created_when_presence_is_unknown() { let store = InMemoryGuildActions { - facts_error: Some( - "World Shard instances has no healthy Coordinator subscription".into(), - ), + facts_error: Some(presence_unknown), ..realm() }; - assert_eq!( - run_guild_dot_command(&store, in_world(GM), ".guild create \"Knights\"").unwrap(), - Some("guild not created".into()) - ); + 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 lost read is a Transport Loss"); + assert_eq!(classify(&error), DurableFailure::TransportLoss); assert!(store.ops.lock().unwrap().is_empty()); } @@ -2618,10 +2592,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] @@ -2908,7 +2905,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)); } @@ -2984,19 +2981,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}"), @@ -3019,9 +3016,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); @@ -3663,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; @@ -4450,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/handlers/item.rs b/gateway/src/world/handlers/item.rs index e358978a8..65b046e2a 100644 --- a/gateway/src/world/handlers/item.rs +++ b/gateway/src/world/handlers/item.rs @@ -22,98 +22,20 @@ impl From for ItemActionResult { } pub(crate) trait ItemActionStore: Send + Sync { - fn destroy_item( - &self, - account_id: u64, - actor_guid: u64, - slot: u8, - count: u32, - ) -> Result; - 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 destroy_item(&self, actor: Actor, slot: u8, count: u32) -> 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; fn split_item( &self, - account_id: u64, - actor_guid: u64, + actor: Actor, from_slot: u8, to_slot: u8, count: u32, ) -> Result; } -impl ItemActionStore for crate::stdb::Coordinator { - fn destroy_item( - &self, - account_id: u64, - actor_guid: u64, - slot: u8, - count: u32, - ) -> Result { - crate::stdb::Coordinator::destroy_item(self, account_id, actor_guid, slot, count) - } - fn split_item( - &self, - account_id: u64, - actor_guid: u64, - from_slot: u8, - to_slot: u8, - count: u32, - ) -> Result { - crate::stdb::Coordinator::split_item( - self, account_id, actor_guid, from_slot, to_slot, count, - ) - } - 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, @@ -125,6 +47,35 @@ 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 request the Gateway refuses before any Durable Request, such as a bag position it cannot map. +fn refuse(player: ItemActionPlayer, operation: &str, refusal: ItemRefusal) -> ItemActionOutcome { + ItemActionOutcome::Handled { + outbound: inventory_action_outbound(player.account_id, operation, refusal.into()), + } +} + /// A Refusal answers the client with its own result code; a completed action answers nothing. fn inventory_action_outbound( account_id: u64, @@ -155,129 +106,65 @@ pub(crate) fn dispatch_item_action Result { match msg { - ClientOpcodeMessage::CMSG_DESTROYITEM(c) => { - let result = if let Some(slot) = codec::inventory_slot(c.bag, c.slot) { - store.destroy_item( - player.account_id, - player.self_guid.unwrap_or(0), - slot, - u32::from(c.amount), - )? - } else { - ItemRefusal::WrongSlot.into() - }; - Ok(ItemActionOutcome::Handled { - outbound: inventory_action_outbound(player.account_id, "destroy_item", result), - }) - } - ClientOpcodeMessage::CMSG_SPLIT_ITEM(c) => { - let result = if let (Some(source), Some(destination)) = ( - codec::inventory_slot(c.source_bag, c.source_slot), - codec::inventory_slot(c.destination_bag, c.destination_slot), - ) { - store.split_item( - player.account_id, - player.self_guid.unwrap_or(0), - source, - destination, - u32::from(c.amount), - )? - } else { - ItemRefusal::WrongSlot.into() - }; - Ok(ItemActionOutcome::Handled { - outbound: inventory_action_outbound(player.account_id, "split_item", result), - }) - } + ClientOpcodeMessage::CMSG_DESTROYITEM(c) => match codec::inventory_slot(c.bag, c.slot) { + Some(slot) => act(player, "destroy_item", |actor| { + store.destroy_item(actor, slot, u32::from(c.amount)) + }), + None => Ok(refuse(player, "destroy_item", ItemRefusal::WrongSlot)), + }, + ClientOpcodeMessage::CMSG_SPLIT_ITEM(c) => match ( + codec::inventory_slot(c.source_bag, c.source_slot), + codec::inventory_slot(c.destination_bag, c.destination_slot), + ) { + (Some(source), Some(destination)) => act(player, "split_item", |actor| { + store.split_item(actor, source, destination, u32::from(c.amount)) + }), + _ => Ok(refuse(player, "split_item", ItemRefusal::WrongSlot)), + }, ClientOpcodeMessage::CMSG_AUTOEQUIP_ITEM(c) => { - let result = if let Some(slot) = codec::inventory_slot(c.source_bag, c.source_slot) { - store.equip_item(player.account_id, player.self_guid.unwrap_or(0), slot)? - } else { - ItemRefusal::WrongSlot.into() - }; - Ok(ItemActionOutcome::Handled { - outbound: inventory_action_outbound(player.account_id, "equip_item", result), - }) + match codec::inventory_slot(c.source_bag, c.source_slot) { + Some(slot) => act(player, "equip_item", |actor| store.equip_item(actor, slot)), + None => Ok(refuse(player, "equip_item", ItemRefusal::WrongSlot)), + } } ClientOpcodeMessage::CMSG_AUTOSTORE_BAG_ITEM(c) if c.source_bag == MAIN_BAG && c.destination_bag == MAIN_BAG && c.source_slot <= EQUIP_SLOT_END => { - 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 }) - } - ClientOpcodeMessage::CMSG_AUTOSTORE_BAG_ITEM(_) => Ok(ItemActionOutcome::Handled { - outbound: inventory_action_outbound( - player.account_id, - "autostore", - ItemRefusal::NotRightNow.into(), - ), - }), - ClientOpcodeMessage::CMSG_SWAP_INV_ITEM(c) => { - 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_ITEM(c) => { - let result = if let (Some(source), Some(destination)) = ( - codec::inventory_slot(c.source_bag, c.source_slot), - codec::inventory_slot(c.destination_bag, c.destionation_slot), - ) { - store.move_item( - player.account_id, - player.self_guid.unwrap_or(0), - source, - destination, - )? - } else { - ItemRefusal::WrongSlot.into() - }; - Ok(ItemActionOutcome::Handled { - outbound: inventory_action_outbound(player.account_id, "move_item", result), + act(player, "unequip_item", |actor| { + store.unequip_item(actor, c.source_slot) }) } + ClientOpcodeMessage::CMSG_AUTOSTORE_BAG_ITEM(_) => { + Ok(refuse(player, "autostore", ItemRefusal::NotRightNow)) + } + ClientOpcodeMessage::CMSG_SWAP_INV_ITEM(c) => act(player, "move_item", |actor| { + store.move_item(actor, c.source_slot.as_int(), c.destination_slot.as_int()) + }), + ClientOpcodeMessage::CMSG_SWAP_ITEM(c) => match ( + codec::inventory_slot(c.source_bag, c.source_slot), + codec::inventory_slot(c.destination_bag, c.destionation_slot), + ) { + (Some(source), Some(destination)) => act(player, "move_item", |actor| { + store.move_item(actor, source, destination) + }), + _ => Ok(refuse(player, "move_item", ItemRefusal::WrongSlot)), + }, // Using an item that starts a quest opens that quest instead of consuming the item, so the // quest family gets first refusal on the slot. ClientOpcodeMessage::CMSG_USE_ITEM(c) => { let Some(slot) = codec::inventory_slot(c.bag_index, c.bag_slot) else { - return Ok(ItemActionOutcome::Handled { - outbound: inventory_action_outbound( - player.account_id, - "use_item", - ItemRefusal::WrongSlot.into(), - ), - }); + return Ok(refuse(player, "use_item", ItemRefusal::WrongSlot)); }; let quest_player = QuestActionPlayer { account_id: player.account_id, self_guid: player.self_guid, }; - let outbound = match item_started_quest(store, quest_player, slot)? { - Some(details) => details, - None => inventory_action_outbound( - player.account_id, - "use_item", - store.use_item(player.account_id, player.self_guid.unwrap_or(0), slot)?, - ), - }; - Ok(ItemActionOutcome::Handled { outbound }) + match item_started_quest(store, quest_player, slot)? { + Some(outbound) => Ok(ItemActionOutcome::Handled { outbound }), + None => act(player, "use_item", |actor| store.use_item(actor, slot)), + } } other => Ok(ItemActionOutcome::PassThrough(other)), } @@ -286,6 +173,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 destroy_item( - &self, - _account_id: u64, - _actor_guid: u64, - _slot: u8, - _count: u32, - ) -> Result { + fn destroy_item(&self, _actor: Actor, _slot: u8, _count: u32) -> Result { Ok(ItemRefusal::ItemNotFound.into()) } fn split_item( &self, - _account_id: u64, - _actor_guid: u64, + _actor: Actor, _from_slot: u8, _to_slot: u8, _count: u32, ) -> Result { Ok(ItemRefusal::ItemNotFound.into()) } - 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) } } @@ -411,8 +280,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, @@ -420,13 +288,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") } @@ -441,11 +303,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") } @@ -550,14 +412,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() }; @@ -584,14 +446,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() }; @@ -656,7 +518,6 @@ pub(super) mod tests { assert_eq!( actions.move_requests.lock().unwrap().as_slice(), &[( - 7, 42, ItemSlot::MainHand.as_int(), ItemSlot::Inventory1.as_int() @@ -667,7 +528,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() }; @@ -695,14 +556,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() }; @@ -727,16 +588,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() }; @@ -751,7 +609,7 @@ pub(super) mod tests { assert_eq!( actions.use_requests.lock().unwrap().as_slice(), - &[(7, 42, 125)] + &[(42, 125)] ); } @@ -793,7 +651,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, @@ -810,37 +668,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)), @@ -853,11 +688,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] @@ -940,7 +772,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() }; @@ -960,35 +792,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/handlers/loot.rs b/gateway/src/world/handlers/loot.rs index 230da9ef0..656e69343 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), @@ -98,66 +79,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 { @@ -172,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) } } @@ -214,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()); @@ -239,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, @@ -249,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 { @@ -259,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, @@ -277,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, @@ -287,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 { @@ -298,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) @@ -316,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, @@ -326,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), @@ -343,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, @@ -356,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), @@ -401,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 @@ -411,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, @@ -429,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 { @@ -471,15 +393,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 @@ -503,12 +422,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 @@ -528,15 +443,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 @@ -545,30 +454,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 @@ -577,22 +477,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 }, + )), + )?; } } } @@ -612,36 +507,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), } @@ -672,75 +583,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, @@ -880,10 +795,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()); } @@ -947,7 +859,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 { @@ -958,7 +870,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"] @@ -970,7 +882,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() }; @@ -985,7 +897,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"] @@ -1024,10 +936,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()); @@ -1036,7 +945,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() }; @@ -1049,7 +958,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()); } @@ -1137,7 +1046,7 @@ mod tests { )); assert_eq!( store.money_take_requests.lock().unwrap().as_slice(), - &[(7, 42, 60)] + &[(42, 60)] ); } @@ -1171,7 +1080,7 @@ mod tests { )); assert_eq!( store.money_take_requests.lock().unwrap().as_slice(), - &[(7, 42, 60)] + &[(42, 60)] ); } @@ -1284,7 +1193,7 @@ mod tests { assert_didnt_kill(&outbound, 60); assert_eq!( store.money_take_requests.lock().unwrap().as_slice(), - &[(7, 42, 60)] + &[(42, 60)] ); } @@ -1340,7 +1249,7 @@ mod tests { )); assert_eq!( store.item_take_requests.lock().unwrap().as_slice(), - &[(7, 42, 75, 3)] + &[(42, 75, 3)] ); } @@ -1376,7 +1285,7 @@ mod tests { )); assert_eq!( store.item_take_requests.lock().unwrap().as_slice(), - &[(7, 42, 75, 3)] + &[(42, 75, 3)] ); } @@ -1418,7 +1327,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)] ); } @@ -1448,70 +1357,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); } } @@ -1559,10 +1420,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()); } @@ -1680,10 +1538,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] @@ -1716,29 +1571,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. @@ -1806,7 +1654,7 @@ mod tests { )); assert_eq!( store.item_take_requests.lock().unwrap().as_slice(), - &[(7, 42, 90, 4)] + &[(42, 90, 4)] ); } @@ -1830,7 +1678,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)] ); } @@ -1843,7 +1691,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)] ); } 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/handlers/meeting_stone.rs b/gateway/src/world/handlers/meeting_stone.rs index f656efc87..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, @@ -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, @@ -115,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(), ))], @@ -148,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), + }, } } @@ -166,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)) => { @@ -200,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, @@ -228,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 { @@ -245,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, @@ -253,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!( @@ -269,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()); }; @@ -284,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() } }) @@ -293,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}; @@ -320,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>, @@ -358,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")) } @@ -383,7 +336,7 @@ mod tests { fn meeting_stone_op( &self, - actor_guid: u64, + actor: Actor, op: u8, area_id: u32, seekers: Vec, @@ -392,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()), } } @@ -627,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/handlers/melee.rs b/gateway/src/world/handlers/melee.rs index 77e89c3af..8a896d8e8 100644 --- a/gateway/src/world/handlers/melee.rs +++ b/gateway/src/world/handlers/melee.rs @@ -1,22 +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<()>; -} - -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) - } + fn stop_attack(&self, actor: Actor) -> Result<()>; } /// The melee-relevant session facts. `self_guid` is `None` outside the world. @@ -122,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")); @@ -150,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)), ))], }) } @@ -182,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")); } @@ -193,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, @@ -210,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(()) } } @@ -312,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, @@ -392,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, @@ -401,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 } @@ -420,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, @@ -449,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 } @@ -471,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!( @@ -484,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, @@ -494,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 } @@ -588,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/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 29dff9a97..0729dd58d 100644 --- a/gateway/src/world/handlers/mod.rs +++ b/gateway/src/world/handlers/mod.rs @@ -43,7 +43,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)] @@ -53,7 +52,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/query.rs b/gateway/src/world/handlers/query.rs index 2a355660b..e4d4882af 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 @@ -40,12 +45,7 @@ pub(crate) trait NpcStore: Send + Sync { fn npc_refuses_interaction(&self, npc_guid: u64, player_guid: u64) -> Result; /// Bind the Character's home after the Module checks the selected innkeeper. - fn bind_home( - &self, - account_id: u64, - self_guid: u64, - innkeeper_guid: u64, - ) -> Result; + fn bind_home(&self, actor: Actor, innkeeper_guid: u64) -> Result; /// Does the NPC at `guid` carry the innkeeper flag? Used to display its gossip option. fn npc_is_innkeeper(&self, guid: u64) -> Result; @@ -68,14 +68,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, @@ -127,32 +126,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)?; } @@ -184,20 +193,20 @@ 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 { ClientOpcodeMessage::CMSG_BINDER_ACTIVATE(request) => { - if self_guid == 0 { + let Some(actor) = actor else { return Ok(None); - } - let result = store.bind_home(conn.account_id, self_guid, request.guid.guid()); - if let InteractionOutcome::Refused(reason) = result? { + }; + if let InteractionOutcome::Refused(reason) = + store.bind_home(actor, request.guid.guid())? + { send( tx, Outbound::One(ServerOpcodeMessage::SMSG_MESSAGECHAT(Box::new( @@ -208,11 +217,11 @@ pub(crate) fn handle_query< send(tx, Outbound::One(ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE))?; } ClientOpcodeMessage::MSG_TALENT_WIPE_CONFIRM(request) => { - if self_guid == 0 { + let Some(actor) = actor else { return Ok(None); - } + }; if let InteractionOutcome::Refused(reason) = - store.reset_talents(conn.account_id, self_guid, request.wiping_npc.guid())? + store.reset_talents(actor, request.wiping_npc.guid())? { send( tx, @@ -245,7 +254,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( @@ -265,14 +278,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). @@ -296,10 +311,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 @@ -359,10 +371,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 @@ -376,13 +385,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 @@ -435,7 +444,7 @@ pub(crate) fn handle_query< } } 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)?; } } @@ -495,25 +504,37 @@ 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) { + 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(e.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. @@ -523,15 +544,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/handlers/quest.rs b/gateway/src/world/handlers/quest.rs index cfb5c07b2..bb16fe5d7 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,84 +56,18 @@ 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. 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)] @@ -160,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(()), } } @@ -347,9 +271,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) => { @@ -366,22 +291,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() @@ -411,19 +321,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(), }) @@ -439,13 +341,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 { @@ -458,10 +354,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, @@ -492,19 +385,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(), @@ -514,19 +399,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(), }) @@ -538,6 +415,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, @@ -552,7 +430,6 @@ mod tests { #[derive(Clone, Debug, PartialEq, Eq)] enum TurnInCall { TurnIn { - account_id: u64, self_guid: u64, giver: u64, quest_id: u32, @@ -566,17 +443,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, @@ -584,6 +460,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 { @@ -617,26 +507,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)> { @@ -649,22 +531,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( @@ -675,24 +553,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) { @@ -889,18 +763,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); @@ -1062,7 +924,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 = @@ -1071,7 +933,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)] ); } @@ -1091,9 +953,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() }; @@ -1102,7 +962,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 ─────────────────────────────────────────────── @@ -1202,7 +1062,7 @@ mod tests { ); assert_eq!( actions.abandon_requests.lock().unwrap().as_slice(), - &[(7, SELF_GUID, 777)] + &[(SELF_GUID, 777)] ); } @@ -1248,9 +1108,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() }; @@ -1259,7 +1117,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] @@ -1325,11 +1183,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) }; @@ -1338,7 +1194,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 ───────────────────────────────────────────────────────── @@ -1395,7 +1251,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, @@ -1549,9 +1404,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() }; @@ -1560,7 +1413,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 ──────────────────────────────────────────────────────── @@ -1582,7 +1435,7 @@ mod tests { ); assert_eq!( actions.push_requests.lock().unwrap().as_slice(), - &[(7, SELF_GUID, QUEST)] + &[(SELF_GUID, QUEST)] ); } @@ -1602,7 +1455,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() }; @@ -1611,7 +1464,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 ──────────────────────────────────────────── @@ -1727,8 +1580,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/handlers/taxi.rs b/gateway/src/world/handlers/taxi.rs index 5c78c9240..4d09d1e33 100644 --- a/gateway/src/world/handlers/taxi.rs +++ b/gateway/src/world/handlers/taxi.rs @@ -6,55 +6,21 @@ use super::super::*; 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<()>; -} - -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) - } + fn arm_taxi_flight(&self, actor: Actor) -> Result<()>; } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -76,33 +42,39 @@ pub(crate) enum TaxiActionOutcome { 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 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, ))), )]), - 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) => store.activate_taxi( + actor, + npc_guid, + source_client_node_id, + destination_client_node_id, + )?, + None => server_error, + }; let arm = result.result_code == lyracore_shared::constants::taxi_protocol::ACTIVATE_OK; Ok(( vec![Outbound::One(ServerOpcodeMessage::SMSG_ACTIVATETAXIREPLY( @@ -112,6 +84,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, @@ -122,26 +96,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 store.open_taxi(actor, npc_guid)? { + 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()), } } @@ -150,24 +125,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, @@ -185,6 +157,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}, @@ -198,7 +171,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)>, @@ -209,30 +183,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 @@ -244,7 +214,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, @@ -252,15 +222,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 @@ -273,8 +243,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()); } @@ -323,11 +293,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), @@ -336,24 +330,44 @@ 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" + ); + } + } + + /// A taxi answer arrives only after the request commits, so any failure ends the World Session. + #[test] + fn a_refusal_ends_the_world_session() { + let store = InMemoryTaxiActions { + fail: Some(refused), + ..Default::default() }; - assert!(error.to_string().contains("offline")); + 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] + 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] @@ -397,7 +411,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(); @@ -418,7 +432,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(); @@ -434,26 +448,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/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/handlers/trainer.rs b/gateway/src/world/handlers/trainer.rs index 57d63e9df..ec3aa8e4c 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, @@ -55,8 +54,7 @@ pub(crate) trait TrainerStore: Send + Sync { /// `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, + actor: Actor, reputation_index: u32, at_war: bool, ) -> Result; @@ -72,18 +70,13 @@ 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<()>; /// Quote the Module's current talent reset cost for the confirmation dialog. fn talent_reset_cost(&self, character_guid: u64) -> Option; /// Confirm a talent reset. The Module checks the trainer, range and price. - 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 @@ -98,6 +91,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)] @@ -133,10 +141,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(); @@ -170,7 +181,7 @@ pub(crate) fn handle_trainer { // Confirm + push the spell live so it appears on the action bar without a relog. send( @@ -255,19 +266,16 @@ 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), + )?; } 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( @@ -278,52 +286,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)), @@ -377,7 +376,7 @@ pub(crate) fn handle_at_war( body.len() == 5 && body[4] <= 1, "invalid CMSG_SET_FACTION_ATWAR body" ); - let Some(character_guid) = social::self_guid(conn) else { + let Some(actor) = social::self_guid(conn).and_then(Actor::new) else { return Ok(()); }; let index = u32::from_le_bytes(body[..4].try_into()?); @@ -385,7 +384,7 @@ pub(crate) fn handle_at_war( forward_movement(store, conn, opcode, &info)?; } if let InteractionOutcome::Refused(reason) = - store.set_faction_at_war(conn.account_id, character_guid, index, body[4] != 0)? + store.set_faction_at_war(actor, index, body[4] != 0)? { send( tx, diff --git a/gateway/src/world/handlers/vendor.rs b/gateway/src/world/handlers/vendor.rs index 5555ef0bc..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,96 +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<()>; -} - -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) - } + 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 @@ -143,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 @@ -209,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(), }); @@ -228,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 }) } @@ -260,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(), }); @@ -276,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 @@ -300,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(), }); @@ -310,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 @@ -334,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 @@ -449,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, @@ -469,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(()), } } @@ -545,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() { @@ -686,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() }; @@ -700,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), @@ -735,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), @@ -750,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); } } @@ -799,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, @@ -811,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() }; @@ -828,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] @@ -865,7 +733,7 @@ pub(super) mod tests { )); assert_eq!( actions.repair_requests.lock().unwrap().as_slice(), - &[(7, 42, NPC, 7)] + &[(42, NPC, 7)] ); } @@ -881,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)] ); } @@ -901,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 } @@ -930,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), @@ -955,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() }; @@ -1180,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)] ); } @@ -1203,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() @@ -1219,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/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/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/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..ac1f8e1bd 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() @@ -355,7 +352,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 +418,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" @@ -530,9 +537,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 +560,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/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/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/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 ed27ec687..47b053b94 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, @@ -71,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, @@ -1107,7 +1110,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) { @@ -1221,22 +1224,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), + }, } } @@ -1726,12 +1743,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})"); @@ -1749,9 +1766,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/npc_tests.rs b/gateway/src/world/npc_tests.rs index 8771df053..532d0b6a0 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 @@ -804,8 +906,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/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..260a165a0 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( 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 { 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/session_tests.rs b/gateway/src/world/session_tests.rs index f84cfaec3..c4756b69f 100644 --- a/gateway/src/world/session_tests.rs +++ b/gateway/src/world/session_tests.rs @@ -1090,6 +1090,7 @@ fn inbound_movement_is_recorded_under_its_opcode() { WorldFake { session: SessionState { entity_in_world: true, + login_entity: Some(warrior_entity()), ..base.session }, ..base @@ -1102,7 +1103,11 @@ fn inbound_movement_is_recorded_under_its_opcode() { run_world_session(server_end, server_store.clone()).unwrap(); }); - let (mut c_enc, _c_dec) = client_handshake(&mut client, "TESTER", K); + 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); let info = MovementInfo { flags: MovementInfo_MovementFlags::empty(), timestamp: 12345, @@ -1150,6 +1155,7 @@ fn a_movement_packet_for_a_despawned_entity_never_kills_the_session() { }, session: SessionState { entity_presence: Some(entity_presence.clone()), + login_entity: Some(warrior_entity()), ..base.session }, ..base @@ -1160,7 +1166,11 @@ fn a_movement_packet_for_a_despawned_entity_never_kills_the_session() { let server_store = store.clone(); let server = std::thread::spawn(move || run_world_session(server_end, server_store.clone())); - let (mut c_enc, _c_dec) = client_handshake(&mut client, "TESTER", K); + 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); let beat = |t: u32| MSG_MOVE_HEARTBEAT_Client { info: MovementInfo { flags: MovementInfo_MovementFlags::empty(), @@ -1232,6 +1242,7 @@ fn a_reappearing_entity_resets_the_movement_desync_tolerance() { WorldFake { session: SessionState { entity_presence: Some(present.clone()), + login_entity: Some(warrior_entity()), ..base.session }, ..base @@ -1240,7 +1251,11 @@ fn a_reappearing_entity_resets_the_movement_desync_tolerance() { let (mut client, server_end) = world_session_socket_pair(); let server_store = store.clone(); let server = std::thread::spawn(move || run_world_session(server_end, server_store.clone())); - let (mut c_enc, _c_dec) = client_handshake(&mut client, "TESTER", K); + 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); let info = |t| MovementInfo { flags: MovementInfo_MovementFlags::empty(), timestamp: t, @@ -1323,7 +1338,8 @@ fn a_movement_failure_that_is_not_a_desync_is_still_session_fatal() { WorldFake { session: SessionState { entity_in_world: true, - movement_error: Some("timed out after 10s".into()), + movement_transport_lost: true, + login_entity: Some(warrior_entity()), ..base.session }, ..base @@ -1332,7 +1348,11 @@ fn a_movement_failure_that_is_not_a_desync_is_still_session_fatal() { let (mut client, server_end) = world_session_socket_pair(); let server_store = store.clone(); let server = std::thread::spawn(move || run_world_session(server_end, server_store.clone())); - let (mut c_enc, _c_dec) = client_handshake(&mut client, "TESTER", K); + 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); MSG_MOVE_HEARTBEAT_Client { info: MovementInfo { flags: MovementInfo_MovementFlags::empty(), @@ -1353,7 +1373,11 @@ 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("timed out"), "{err:#}"); + assert_eq!( + crate::stdb::classify(&err), + crate::stdb::DurableFailure::TransportLoss, + "{err:#}" + ); } #[test] @@ -2000,11 +2024,11 @@ fn reducer_transport_loss_ends_an_admitted_session_and_frees_one_queue_seat() { login_entity: Some(warrior_entity()), // The same dead transport makes leave-world cleanup unreachable. Teardown is best-effort, // but the client session and its admission seat must not wait for that reducer. - logout_error: Some("transport disconnected".into()), + logout_transport_lost: true, ..base.session }, combat: CombatState { - set_target_error: Some("transport disconnected".into()), + set_target_transport_lost: true, }, ..base } @@ -2041,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" @@ -2277,3 +2304,24 @@ fn closing_a_world_session_interrupts_a_full_socket_without_draining_its_queue() assert!(matches!(queued.try_recv().unwrap(), Outbound::Raw { .. })); writer.join().unwrap(); } + +/// A refused addon-bridge command is dropped and the World Session continues; a Transport Loss +/// leaves its outcome unknown and ends the session. +#[test] +fn an_addon_command_refusal_is_dropped_and_a_transport_loss_ends_the_session() { + let conn = in_world_conn(42, 1); + let text = "STC\tv1|example.order|same|1/1|follow|77"; + let answering = |error: fn(&str) -> 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..a9ab608f5 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,11 @@ 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_eq!( + crate::stdb::classify(&error), + crate::stdb::DurableFailure::TransportLoss + ); } #[test] diff --git a/gateway/src/world/store.rs b/gateway/src/world/store.rs index 4b3ee3f2f..8e02a8563 100644 --- a/gateway/src/world/store.rs +++ b/gateway/src/world/store.rs @@ -167,27 +167,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 @@ -208,13 +202,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/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/family.rs b/gateway/src/world/test_support/family.rs index c9fbe6f66..95b33e4dd 100644 --- a/gateway/src/world/test_support/family.rs +++ b/gateway/src/world/test_support/family.rs @@ -71,7 +71,7 @@ macro_rules! npc_store_refusing_by { fn pet_name( &self, - _requester_guid: u64, + _requester: Actor, _pet_number: u32, _pet_guid: u64, ) -> Result> { @@ -89,21 +89,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, - _innkeeper_guid: u64, - ) -> Result { + fn bind_home(&self, _actor: Actor, _innkeeper_guid: u64) -> Result { unimplemented!("bind_home") } @@ -123,14 +113,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/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 { 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(()) } } diff --git a/gateway/src/world/test_support/world_fake/cast.rs b/gateway/src/world/test_support/world_fake/cast.rs index c3927b5df..9aeee90e8 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, @@ -79,25 +60,19 @@ 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, _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(()) } 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 { 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/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) } diff --git a/gateway/src/world/test_support/world_fake/combat.rs b/gateway/src/world/test_support/world_fake/combat.rs index 10f4d307f..284825f46 100644 --- a/gateway/src/world/test_support/world_fake/combat.rs +++ b/gateway/src/world/test_support/world_fake/combat.rs @@ -1,36 +1,29 @@ 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 => { - if let Some(state) = &self.benilla_gameplay { - state.lock().unwrap().selected = target_guid; - } - 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()); } + if let Some(state) = &self.benilla_gameplay { + state.lock().unwrap().selected = target_guid; + } + 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(()) } } 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(()) } 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(()) } } 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(()) diff --git a/gateway/src/world/test_support/world_fake/item.rs b/gateway/src/world/test_support/world_fake/item.rs index 8f42f1f63..767ac87fe 100644 --- a/gateway/src/world/test_support/world_fake/item.rs +++ b/gateway/src/world/test_support/world_fake/item.rs @@ -2,19 +2,13 @@ use super::super::*; /// Item actions shared by the family and Benilla World Session tests. impl ItemActionStore for WorldFake { - fn destroy_item( - &self, - _account_id: u64, - _self_guid: u64, - _slot: u8, - _count: u32, - ) -> Result { + fn destroy_item(&self, _actor: Actor, _slot: u8, _count: u32) -> Result { Ok(lyracore_shared::item::ItemRefusal::ItemNotFound.into()) } + fn split_item( &self, - _account_id: u64, - _self_guid: u64, + _actor: Actor, _from_slot: u8, _to_slot: u8, _count: u32, @@ -22,31 +16,15 @@ impl ItemActionStore for WorldFake { Ok(lyracore_shared::item::ItemRefusal::ItemNotFound.into()) } - 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 { if let Some(state) = &self.benilla_gameplay { let mut state = state.lock().unwrap(); for item in &mut state.inventory { @@ -60,7 +38,7 @@ impl ItemActionStore for WorldFake { 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) } } 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 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 1ac5ef504..1d9e261d6 100644 --- a/gateway/src/world/test_support/world_fake/loot_window.rs +++ b/gateway/src/world/test_support/world_fake/loot_window.rs @@ -39,12 +39,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() @@ -55,28 +50,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() @@ -91,8 +75,7 @@ impl LootWindowStore for WorldFake { fn take_loot( &self, - _account_id: u64, - actor_guid: u64, + actor: Actor, target_guid: u64, loot_slot: u8, ) -> Result { @@ -107,7 +90,7 @@ impl LootWindowStore for WorldFake { let (_, entry, stack_count, _, random_property_id) = state.loot.remove(index); state.inventory.push(codec::ItemInstanceView { guid: 0x4000_0000_0000_0000 | (u64::from(loot_slot) + 1), - owner_guid: actor_guid, + owner_guid: actor.guid(), entry, stack_count, random_property_id, 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; 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, diff --git a/gateway/src/world/test_support/world_fake/melee.rs b/gateway/src/world/test_support/world_fake/melee.rs index 55141949d..473e33556 100644 --- a/gateway/src/world/test_support/world_fake/melee.rs +++ b/gateway/src/world/test_support/world_fake/melee.rs @@ -3,14 +3,14 @@ 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"); if let Some(state) = &self.benilla_gameplay { state.lock().unwrap().attacking = Some(target_guid); } Ok(()) } - fn stop_attack(&self, _account_id: u64, _self_guid: u64) -> Result<()> { + fn stop_attack(&self, _actor: Actor) -> Result<()> { if let Some(state) = &self.benilla_gameplay { state.lock().unwrap().attacking = None; } diff --git a/gateway/src/world/test_support/world_fake/npc.rs b/gateway/src/world/test_support/world_fake/npc.rs index 77c69ab0a..cdc02e509 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,12 +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, - _innkeeper_guid: u64, - ) -> Result { + fn bind_home(&self, _actor: Actor, _innkeeper_guid: u64) -> Result { if !self.npc.innkeeper { return Ok(InteractionOutcome::Refused( "target is not an innkeeper".into(), @@ -82,23 +81,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, 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/quest.rs b/gateway/src/world/test_support/world_fake/quest.rs index 7e91bc2b5..d2d0beed8 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 @@ -53,13 +53,7 @@ impl QuestActionStore for WorldFake { Ok(self.npc_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<()> { if self.benilla_gameplay.is_some() { let mut log = self.quest.quest_log.lock().unwrap(); if !log.iter().any(|&(id, _)| id == quest_id) { @@ -78,11 +72,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(()) } @@ -102,34 +96,36 @@ 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()); } if let Some(state) = &self.benilla_gameplay { + let refused = + |reason| crate::stdb::ReducerCallError::refused("gw_turn_in_quest", reason); let mut log = self.quest.quest_log.lock().unwrap(); let Some((_, rewarded)) = log.iter_mut().find(|(id, _)| *id == quest_id) else { - return Err(anyhow!("quest not active")); + return Err(refused("quest not active").into()); }; if *rewarded { - return Err(anyhow!("quest already rewarded")); + return Err(refused("quest already rewarded").into()); } let detail = self .quest_detail_view(quest_id)? - .ok_or_else(|| anyhow!("quest absent"))?; + .ok_or_else(|| refused("quest absent"))?; state.lock().unwrap().copper += detail.money_reward; *rewarded = true; } - 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 diff --git a/gateway/src/world/test_support/world_fake/session.rs b/gateway/src/world/test_support/world_fake/session.rs index 988bb0200..33538ad00 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); @@ -17,7 +18,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. @@ -28,9 +32,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). @@ -63,10 +67,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 @@ -88,7 +90,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"); @@ -111,16 +113,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, @@ -131,7 +127,7 @@ impl SessionStore for WorldFake { info.timestamp, )); if let Some(world) = &self.session.movement_world { - world.update(self_guid, opcode, info)?; + world.update(actor.guid(), opcode, info)?; } Ok(()) } @@ -170,18 +166,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(()) } @@ -222,10 +215,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/test_support/world_fake/speech.rs b/gateway/src/world/test_support/world_fake/speech.rs index 7d0444a67..aa99192e2 100644 --- a/gateway/src/world/test_support/world_fake/speech.rs +++ b/gateway/src/world/test_support/world_fake/speech.rs @@ -4,9 +4,10 @@ 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, `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 /// RIGHT raw text (still carrying its leading `.`) reached the reducer call, and that a NON-dot /// Say never reaches this vec at all. @@ -26,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"); @@ -48,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, @@ -58,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 @@ -75,10 +77,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 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(()) } } 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/test_support/world_fake/trainer.rs b/gateway/src/world/test_support/world_fake/trainer.rs index 153cebb69..abc22e046 100644 --- a/gateway/src/world/test_support/world_fake/trainer.rs +++ b/gateway/src/world/test_support/world_fake/trainer.rs @@ -8,9 +8,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 { @@ -32,8 +32,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 { @@ -46,8 +45,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 { @@ -61,8 +59,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, @@ -78,24 +75,19 @@ 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 { + fn reset_talents(&self, actor: Actor, trainer_guid: u64) -> Result { if let Some(e) = &self.trainer.reset_talents_refusal { return Ok(InteractionOutcome::Refused(e.clone())); } - self.trainer.reset_talents_calls.lock().unwrap().push(( - account_id, - self_guid, - trainer_guid, - )); + self.trainer + .reset_talents_calls + .lock() + .unwrap() + .push((actor.guid(), trainer_guid)); Ok(InteractionOutcome::Done) } 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/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/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()); +} diff --git a/gateway/src/world/trainer_tests.rs b/gateway/src/world/trainer_tests.rs index f019262f1..9dc26bfeb 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. @@ -69,7 +72,7 @@ impl CharacterStore for TrainerFake { fn delete_character( &self, _account_id: u64, - _character_guid: u64, + _character: Actor, ) -> Result { unimplemented!("delete_character") } @@ -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,20 +169,18 @@ 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 talent_reset_cost(&self, _character_guid: u64) -> Option { unimplemented!("talent_reset_cost") } - 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") } @@ -389,17 +387,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: [{}]", @@ -407,6 +404,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] 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 1f63fbdab..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" @@ -1831,8 +1834,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"); 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( 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(),