diff --git a/CONTEXT.md b/CONTEXT.md index a69bad899..3aeb0a109 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -33,11 +33,11 @@ A World of Warcraft 1.12.1 server. Game state lives in SpacetimeDB; the gateway **O** [Officer Note](#guilds) · [Official Package Collection](#packages) · [Official Package Source](#packages) · [Open Role](#meeting-stones) · [Operator](#realm-topology) · [Owner Token](#gateway-and-module) -**P** [Package](#packages) · [Package API](#packages) · [Package Cast Range](#packages) · [Package Config](#packages) · [Package Creature Range](#packages) · [Package Delta](#packages) · [Package Event](#runtime-scripts) · [Package EventAI Range](#packages) · [Package Fixture](#packages) · [Package Gameobject Range](#packages) · [Package Globals Range](#packages) · [Package Gossip Range](#packages) · [Package Identifier Range](#packages) · [Package Import](#packages) · [Package Inventory](#packages) · [Package Item Range](#packages) · [Package Loot Range](#packages) · [Package Quest Range](#packages) · [Package Script Range](#packages) · [Package Source](#packages) · [Package Spell](#packages) · [Package Spell Metadata Range](#packages) · [Package Spell Range](#packages) · [Package Teardown](#packages) · [Package Trainer Range](#packages) · [Package-owned Account](#accounts-characters-and-sessions) · [Party](#sharding-and-transfer) · [Party Partition](#sharding-and-transfer) · [Patrol Pause](#creature-ai) · [Petition](#guilds) · [Petitioner](#guilds) · [Pre-auth I/O Deadline](#accounts-characters-and-sessions) · [Proc](#procs) · [Proficiency](#gateway-and-module) · [Property Pool](#random-properties) · [Prototype](#working-method) · [Provenance Stamp](#packages) · [Provisioning Profile](#sharding-and-transfer) · [Public Note](#guilds) +**P** [Package](#packages) · [Package API](#packages) · [Package Cast Range](#packages) · [Package Config](#packages) · [Package Creature Range](#packages) · [Package Delta](#packages) · [Package Event](#runtime-scripts) · [Package EventAI Range](#packages) · [Package Fixture](#packages) · [Package Gameobject Range](#packages) · [Package Globals Range](#packages) · [Package Gossip Range](#packages) · [Package Identifier Range](#packages) · [Package Import](#packages) · [Package Inventory](#packages) · [Package Item Range](#packages) · [Package Loot Range](#packages) · [Package Quest Range](#packages) · [Package Script Range](#packages) · [Package Source](#packages) · [Package Spell](#packages) · [Package Spell Metadata Range](#packages) · [Package Spell Range](#packages) · [Package Teardown](#packages) · [Package Trainer Range](#packages) · [Package-owned Account](#accounts-characters-and-sessions) · [Party](#sharding-and-transfer) · [Party Partition](#sharding-and-transfer) · [Patrol Pause](#creature-ai) · [Petition](#guilds) · [Petitioner](#guilds) · [Pre-auth I/O Deadline](#accounts-characters-and-sessions) · [Proc](#procs) · [Proficiency](#gateway-and-module) · [Property Pool](#random-properties) · [Protocol Family](#working-method) · [Prototype](#working-method) · [Provenance Stamp](#packages) · [Provisioning Profile](#sharding-and-transfer) · [Public Note](#guilds) **R** [Raid](#sharding-and-transfer) · [Raid Quest](#sharding-and-transfer) · [Raid Slot](#sharding-and-transfer) · [Random Property](#random-properties) · [Ranged Posture](#creature-ai) · [Rank Rights](#guilds) · [Ready Check](#sharding-and-transfer) · [Realm](#realm-topology) · [Realm Chat Line](#chat) · [Realm Clock](#world-clock-and-weather) · [Realm Presence](#chat) · [Realm-core](#realm-topology) · [Recorded Revision](#packages) · [Recovery Attempt](#sharding-and-transfer) · [Recovery Scan](#sharding-and-transfer) · [Reference Datascript](#packages) · [Reference Package](#packages) · [Refusal](#gateway-and-module) · [Relay](#gateway-and-module) · [Relay Definition](#realm-topology) · [Relay Run](#realm-topology) · [Returned Mail](#mail) · [Reward Letter](#mail) · [Roster Revision](#sharding-and-transfer) · [Roster Revision Relay](#sharding-and-transfer) · [Route Path](#procs) · [Route Step](#procs) · [Rule State](#creature-ai) · [Runtime Script](#runtime-scripts) · [Runtime Script Host](#runtime-scripts) · [Runtime Script Toolchain](#packages) -**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) · [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) +**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) @@ -495,6 +495,11 @@ _Avoid_: chat type (in new names) The speaker's race and chat tag, read by the Gateway on the Home Shard and conveyed in the Durable Request. Realm-core holds no Characters. +**Speech**: +A line a Character speaks to the Characters near it: a say, yell or `/e` line, or a text emote. Its +Durable Request goes to the speaker's Home Shard, and a Relay delivers it to listeners in range. +_Avoid_: local chat, proximity chat + **Realm Presence**: The Gateway's realm-wide read of one Character: Whereabouts, session online, race, class, level and zone, from whichever World Shard holds it. Guild rosters, friends, `/who`, whisper and Member Stats @@ -1223,6 +1228,10 @@ _Avoid_: client patch, distribution, release bundle, player package **Seam**: The interface where session or protocol handling hands work to durable state, expressed as a trait so tests can substitute the far side. Not the Shard Boundary, not any arbitrary trait. +**Protocol Family**: +A group of client opcodes one Gateway handler owns, with the Store trait that handler calls (for example the vendor family and `VendorActionStore`). `WorldStore` is the umbrella over every family; a handler takes only its family's Store. +_Avoid_: family (unqualified), domain + **Store**: The durable side of a Seam: the trait a handler calls for Durable Reads and Durable Requests. The Coordinator implements it in production, a Fake in tests. _Avoid_: repository, adapter, port, backend, service diff --git a/CORE_TERMS.md b/CORE_TERMS.md index 8bc604fd8..e83e2bb31 100644 --- a/CORE_TERMS.md +++ b/CORE_TERMS.md @@ -66,6 +66,11 @@ The Gateway's subscribed connection per shard, authenticated with the Owner Toke Gateway code that turns a table change into a client message. _Avoid_: forwarder, pusher +**Speech**: +A line a Character speaks to the Characters near it: a say, yell or `/e` line, or a text emote. Its +Durable Request goes to the speaker's Home Shard, and a Relay delivers it to listeners in range. +_Avoid_: local chat, proximity chat + **AOI**: The area of interest that decides which entities a World Session sees. _Avoid_: visibility set, interest radius @@ -114,6 +119,10 @@ _Avoid_: SDK, plugin API, public API, allowlist **Seam**: The interface where session or protocol handling hands work to durable state, expressed as a trait so tests can substitute the far side. Not the Shard Boundary, not any arbitrary trait. +**Protocol Family**: +A group of client opcodes one Gateway handler owns, with the Store trait that handler calls (for example the vendor family and `VendorActionStore`). `WorldStore` is the umbrella over every family; a handler takes only its family's Store. +_Avoid_: family (unqualified), domain + **Store**: The durable side of a Seam: the trait a handler calls for Durable Reads and Durable Requests. The Coordinator implements it in production, a Fake in tests. _Avoid_: repository, adapter, port, backend, service diff --git a/docs/architecture.md b/docs/architecture.md index 09e02529c..7279a28ab 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -577,7 +577,7 @@ This is the sharding model in full: the hierarchy, routing, and transfer. ### 6.2 Resolving "which database owns this position" ``` -world entry: WorldStore::settle_home_shard (gateway/src/stdb/world_store.rs) +world entry: ShardRoutingStore::settle_home_shard (gateway/src/stdb/store/shard_routing.rs) 1. realm-core's character→shard index — a HINT, confirmed against the shard that actually holds the row, self-healing on disagreement 2. the (map_id, instance_id) shard map — shard_for, with instance-pool routing diff --git a/docs/recipes/add-an-opcode.md b/docs/recipes/add-an-opcode.md index 0cc280c58..5c2cafde2 100644 --- a/docs/recipes/add-an-opcode.md +++ b/docs/recipes/add-an-opcode.md @@ -97,9 +97,10 @@ family's dispatcher and skip to step 10. ## 8. Join the family to `WorldStore` -- File: `gateway/src/world/store.rs`. Add the trait to the `WorldStore` supertrait list. -- File: `gateway/src/world/tests.rs`. Implement the trait for `InMemoryStore`, the shared Fake that - implements every family. +- File: `gateway/src/world/store.rs`. Add the trait to the `WorldStore` supertrait list and to the + bounds of its blanket impl. +- File: `gateway/src/world/test_support/world_fake/.rs`. Implement the trait for `WorldFake`, + the shared Fake that implements every family, and keep the family's state in its own struct there. ## 9. Link the dispatcher into the chain diff --git a/gateway/src/codec/tests.rs b/gateway/src/codec/tests.rs index 0ba0c532c..b0bed8291 100644 --- a/gateway/src/codec/tests.rs +++ b/gateway/src/codec/tests.rs @@ -1315,7 +1315,7 @@ fn raw_movement_relay_is_byte_identical_to_the_typed_path() { const GUIDS: &[u64] = &[1, 0x0100, u64::MAX, 0, 0x0000_00F3_0000_0042]; let mut compared = 0usize; for (label, info) in movement_info_cases() { - // The bytes the module stores are produced by exactly this call (`WorldStore::movement_update`). + // The bytes the module stores are produced by exactly this call (`SessionStore::movement_update`). let stored = movement_info_to_bytes(&info).unwrap(); for &opcode in movement_opcodes::SLICE_MOVE_OPCODES { for &guid in GUIDS { diff --git a/gateway/src/config.rs b/gateway/src/config.rs index 36445880f..a5c19d9c4 100644 --- a/gateway/src/config.rs +++ b/gateway/src/config.rs @@ -331,8 +331,8 @@ impl ShardMap { /// Every distinct WORLD SHARD this map can resolve to, default first. These are the databases /// that hold characters, so these — and only these — are what a character-location probe walks. /// Realm-core is deliberately not here: it holds no characters, so probing it is pure waste. - /// ⚠ **Default stays FIRST.** `resolve_home_shard`'s fallback probe walks the connected set in - /// this order and must try the default database before any rule shard. + /// ⚠ **Default stays FIRST.** The fallback census walks the connected set in this order and + /// must try the default database before any rule shard. pub fn shards(&self) -> Vec { let mut dbs = vec![self.default_db.clone()]; for r in &self.rules { @@ -533,66 +533,6 @@ impl InstanceHosting { } } -/// Where the realm believes a character lives, and whether the realm-core index needs correcting. -#[derive(Clone, Debug, PartialEq)] -pub struct HomeShard { - /// The database that owns the character. - pub db: String, - /// The `(map_id, instance_id)` that resolved it — the value written back on a heal. - pub location: (u32, u64), - /// The realm-core index disagreed (or had nothing) — write `location` back to it. - pub heal: bool, -} - -/// Resolve which database owns `character_guid`, from the realm-core index hint plus a probe of the -/// connected world shards. Pure, so the whole self-heal rule is unit-testable without a -/// node — deliberately, because an earlier shard-routing change shipped a bug hidden by a mock that -/// hardcoded exactly the answer under test. -/// -/// - `hint` — the realm-core `game_character_shard` entry, or `None` when the index has no idea. -/// - `connected` — the world shards that actually connected, DEFAULT FIRST (probe order). -/// - `probe(db)` — "does `db` hold this character, and at what location" (the coordinator's -/// `character_location` against that shard's cache; an in-memory lookup, not a round trip). -/// -/// The rule: **the index is a hint, the row is the truth.** A hint is accepted only when the shard it -/// names actually holds the character; anything else falls through to walking the shards, and the -/// answer is marked for write-back. With ONE connected shard this degenerates to "probe the default -/// shard and resolve its location through the map" — i.e. the original shard-routing behavior, byte -/// for byte. -pub fn resolve_home_shard( - map: &ShardMap, - connected: &[String], - hint: Option<(u32, u64)>, - probe: impl Fn(&str) -> Option<(u32, u64)>, -) -> Option { - let is_connected = |d: &str| connected.iter().any(|c| c == d); - let settle = |location: (u32, u64)| HomeShard { - db: map - .resolve_connected(location.0, location.1, is_connected) - .to_string(), - location, - heal: hint != Some(location), - }; - - // 1. Fast path: trust the hint only far enough to know WHICH shard to ask, then confirm with - // that shard's own row. A wrong hint therefore costs one extra probe, never a wrong route. - if let Some((map_id, instance_id)) = hint { - let hinted = map.resolve_connected(map_id, instance_id, is_connected); - if let Some(location) = probe(hinted) { - return Some(settle(location)); - } - log::info!( - "realm-core index points at map {map_id}/instance {instance_id} (shard {hinted}), but \ - that shard does not hold the character — probing; the index will self-heal" - ); - } - - // 2. Fallback probe (the self-heal): ask every connected shard, default first, and route by the - // location the owning shard reports — exactly how the shard map routes, just sourced by - // probe rather than by assuming the default shard still holds the row. - connected.iter().find_map(|db| probe(db)).map(settle) -} - /// `[:]=` → a rule, or `None` if it doesn't parse. fn parse_shard_rule(rule: &str) -> Option { let (loc, db) = rule.split_once('=')?; @@ -1548,103 +1488,6 @@ mod shard_map_tests { assert_eq!(m.realm_core_db(), Some("lyracore-realm")); } - #[test] - fn one_shard_resolves_home_from_the_probe_alone_exactly_like_before_realm_core() { - // Backward compatibility, stated as a test: single shard, no index entry → "ask the default - // shard where the character is, resolve that through the map" verbatim. - let m = ShardMap::parse("world", ""); - let connected = vec!["world".to_string()]; - let got = resolve_home_shard(&m, &connected, None, |db| (db == "world").then_some((0, 0))); - assert_eq!( - got, - Some(HomeShard { - db: "world".into(), - location: (0, 0), - heal: true - }) - ); - // Nobody holds the character → no answer at all (the caller keeps its current handle). - assert_eq!(resolve_home_shard(&m, &connected, None, |_| None), None); - } - - #[test] - fn a_matching_index_entry_routes_without_a_heal() { - let m = ShardMap::parse("world", "389:*=instances"); - let connected = vec!["world".to_string(), "instances".to_string()]; - let got = resolve_home_shard(&m, &connected, Some((389, 12)), |db| { - (db == "instances").then_some((389, 12)) - }); - assert_eq!( - got, - Some(HomeShard { - db: "instances".into(), - location: (389, 12), - heal: false - }), - "an index entry the owning shard confirms is used as-is, with no write-back" - ); - } - - #[test] - fn a_deliberately_stale_index_entry_self_heals_via_the_fallback_probe() { - // The index says the character is in a Deadmines instance; it actually walked out and - // is standing in the open world. The hinted shard doesn't hold it, so the probe walks the - // shards, finds it on `world`, routes there, and flags the entry for correction. - let m = ShardMap::parse("world", "389:*=instances"); - let connected = vec!["world".to_string(), "instances".to_string()]; - let got = resolve_home_shard(&m, &connected, Some((389, 12)), |db| { - (db == "world").then_some((0, 0)) - }); - assert_eq!( - got, - Some(HomeShard { - db: "world".into(), - location: (0, 0), - heal: true - }), - "a stale entry must route by the row that actually exists, and mark itself for repair" - ); - } - - #[test] - fn an_index_entry_naming_a_shard_that_lies_about_the_location_still_routes_by_the_row() { - // The hinted shard DOES hold the character, but at a location that belongs to a different - // shard (mid-transfer, or a shard-map edit since the entry was written). The location the - // row reports is the authority for routing, and the entry gets corrected. - let m = ShardMap::parse("world", "389:*=instances"); - let connected = vec!["world".to_string(), "instances".to_string()]; - let got = resolve_home_shard(&m, &connected, Some((0, 0)), |db| { - (db == "world").then_some((389, 3)) - }); - assert_eq!( - got, - Some(HomeShard { - db: "instances".into(), - location: (389, 3), - heal: true - }) - ); - } - - #[test] - fn an_index_entry_pointing_at_a_disconnected_shard_degrades_to_the_default_then_probes() { - // `resolve_connected` sends the hint to the default database when the named shard is down; - // if the default doesn't hold the character either, the probe still walks the rest. - let m = ShardMap::parse("world", "389:*=instances, 1:*=pool-b"); - let connected = vec!["world".to_string(), "instances".to_string()]; // pool-b never connected - let got = resolve_home_shard(&m, &connected, Some((1, 0)), |db| { - (db == "instances").then_some((389, 7)) - }); - assert_eq!( - got, - Some(HomeShard { - db: "instances".into(), - location: (389, 7), - heal: true - }) - ); - } - // ========================================================================================== // The SINGLE-DATABASE realm, and the parser's totality // ========================================================================================== diff --git a/gateway/src/realm_core.rs b/gateway/src/realm_core.rs index 5c1f1a10e..0731484a3 100644 --- a/gateway/src/realm_core.rs +++ b/gateway/src/realm_core.rs @@ -4,7 +4,7 @@ use anyhow::{anyhow, Result}; -use crate::config::{HomeShard, ShardMap}; +use crate::config::ShardMap; use crate::stdb::{AccountRow, RealmRow}; use crate::world::WorldSession; @@ -179,7 +179,7 @@ pub(crate) fn run_gm_command( // =============================================================================================== /// The world handshake's account→K lookup, split across the two databases that own the two halves -/// of the answer. The body of `impl WorldStore for Coordinator::lookup_session`. +/// of the answer. The body of `SessionStore::lookup_session` for `Coordinator`. /// /// **K comes from realm-core**, which is the whole point of a realm-wide session table: the session /// key is realm state, not @@ -241,79 +241,6 @@ pub(crate) fn lookup_session( // The character→shard index // =============================================================================================== -/// Resolve where `character_guid` lives, from the realm-core index plus a probe of the connected -/// world shards, and repair the index when the two disagree. The first half of -/// `impl WorldStore for Coordinator::home_shard`. -/// -/// `None` means "this handle already owns it" — which is the only answer a single-database gateway -/// can give, and it is given without a single read (the `is_sharded` short-circuit below is what -/// makes "`LYRACORE_SHARD_MAP` unset ⇒ byte-identical to the pre-sharding gateway" literal on the login -/// hot path). -/// -/// **Still only reachable through `WorldStore::home_shard`, which `world::route_home` never calls** -/// — `stdb::world_store` overrides it with `settle_home_shard`, so this exact function stays a test -/// fixture in production. That no longer means the index goes unread, though: -/// `settle_home_shard` has its OWN index-first lookup, [`locate_home_shard`], built on the same -/// `RealmDb` trait and the same hint→probe→heal shape as this function, so the hint read and the -/// self-heal write both run on the live world-entry path now — through the sibling, not through -/// this one. This function remains the trait DEFAULT's resolver (every mock that does not override -/// `settle_home_shard` still exercises it) and its own tests below. -pub(crate) fn settle_shard_index(db: &D, character_guid: u64) -> Option { - // The single-shard short-circuit. With one database in the set the answer is unconditionally - // "you are already there", and this costs the login neither the index read, nor the - // `character_location` probe, nor an index write. - if !db.is_sharded() { - return None; - } - // Consult the realm-core character→shard index first, then confirm/repair it against the shard - // that actually holds the character. `realm_core()` failing (configured, not connected) costs - // us the hint, not the login — routing degrades to the probe, the pre-index behavior. Only the - // AUTH paths fail closed on a missing realm-core. - let index = db.realm_core().ok(); - let hint = index - .as_ref() - .and_then(|rc| rc.character_shard(character_guid)); - let shards = db.world_shards(); - let names: Vec = shards.iter().map(|(name, _)| name.clone()).collect(); - let resolved = crate::config::resolve_home_shard(db.shard_map(), &names, hint, |want| { - shards - .iter() - .find(|(name, _)| name == want) - .and_then(|(_, handle)| handle.character_location(character_guid)) - })?; - // The self-heal write-back. Best effort by design: a failed index write costs the NEXT login - // one extra probe and nothing else, so it must never fail a login that already routed fine. - if resolved.heal { - if let Some(rc) = &index { - if let Err(e) = - rc.set_character_shard(character_guid, resolved.location.0, resolved.location.1) - { - log::warn!( - "character {character_guid}: could not heal the realm-core shard index to map \ - {}/instance {} ({e:#}) — routing is unaffected, the probe will run again", - resolved.location.0, - resolved.location.1 - ); - } - } - } - Some(resolved) -} - -/// Replicate a settled destination into the Realm-core Character locator after source completion. -/// The source receipt is transactional on its own database; Realm-core replication follows it. -/// Recovery includes the observed predecessor and crossing identity in the compare-and-set -/// so a delayed recovery cannot settle a later crossing to the same partition. -pub(crate) fn publish_shard_index( - db: &D, - character_guid: u64, - map_id: u32, - instance_id: u64, -) -> Result<()> { - db.realm_core()? - .set_character_shard(character_guid, map_id, instance_id) -} - pub(crate) fn publish_bot_shard_index( db: &D, intent: &crate::world::transfer::BotTransferIntent, @@ -676,7 +603,7 @@ pub(crate) fn locate_home_shard(db: &D, guid: u64) -> Option { /// Write `guid`'s ACTUAL location (`shard`'s own `character_location`) back into the realm-core /// index when it disagrees with what the index currently holds — including "holds nothing yet". -/// Best effort, mirroring `settle_shard_index`'s identical rule: a failed heal costs the next login +/// Best effort: a failed heal costs the next login /// one extra probe and nothing else, so it must never fail a login that already routed fine. fn heal_shard_entry(rc: &D, guid: u64, shard: &D) { let Some(actual) = shard.character_location(guid) else { @@ -698,7 +625,7 @@ fn heal_shard_entry(rc: &D, guid: u64, shard: &D) { // only), resolved owner == holder, and `settle_transfer` took its no-op branch — leaving the // source copy and its escrow stranded with no component left that would ever re-drive them. // - // The legitimate way this index moves to the destination is `publish_shard_index`, driven only + // The legitimate way this index moves to the destination is `finish_player_shard_index_transfer`, driven only // after `finish_transfer` returned Ok. This function only ever confirms where a character IS. // // Note: declining also covers the case where a shard genuinely holds a character whose map @@ -1801,85 +1728,6 @@ mod tests { h } - #[test] - fn a_deliberately_stale_index_entry_still_routes_correctly_and_is_healed_in_place() { - let h = routed_realm(); - // The index insists the character is in the open world on the default shard. It is not. - h.db_at(CORE) - .shard_index - .lock() - .unwrap() - .insert(100, (0, 0)); - - let resolved = settle_shard_index(&h, 100).expect("the probe finds the character"); - assert_eq!( - (resolved.db.as_str(), resolved.location), - (INSTANCES, (36, 7)), - "a wrong hint must cost one extra probe, never a wrong route — the index is a HINT and \ - the character's own row is the truth" - ); - assert_eq!( - h.db_at(CORE).shard_index.lock().unwrap().get(&100).copied(), - Some((36, 7)), - "the stale entry was not healed. Without the write-back every login pays the full \ - shard probe forever, and the index — the thing world entry and instance entry route on \ - — never becomes \ - true. This is the fallback that covers a gateway killed between `finish_transfer` and \ - `publish_shard_index`, so it must keep working." - ); - } - - #[test] - fn an_absent_index_entry_is_written_the_first_time_the_probe_answers() { - let h = routed_realm(); - assert!(settle_shard_index(&h, 100).is_some()); - assert_eq!( - h.db_at(CORE).shard_index.lock().unwrap().get(&100).copied(), - Some((36, 7)), - "the index must be populated by the probe that had to run without it" - ); - } - - #[test] - fn a_correct_index_entry_is_not_rewritten() { - let h = routed_realm(); - h.db_at(CORE) - .shard_index - .lock() - .unwrap() - .insert(100, (36, 7)); - settle_shard_index(&h, 100).expect("resolves"); - assert!( - !h.db_at(CORE).touched().iter().any(|c| c.starts_with("set_character_shard")), - "an index entry the probe agrees with must not be rewritten — a reducer round trip per \ - login, forever, for a row that is already correct" - ); - } - - #[test] - fn a_single_database_gateway_resolves_the_home_shard_without_reading_anything() { - let h = realm(&[WORLD], "", None); - h.db_at(WORLD) - .characters - .lock() - .unwrap() - .insert(100, (1, (0, 0))); - assert_eq!( - settle_shard_index(&h, 100), - None, - "one database can only ever answer `stay put`" - ); - assert!( - h.db_at(WORLD).touched().is_empty(), - "the `is_sharded` short-circuit is gone: an unconfigured gateway now pays an index \ - read, a character-location probe and (because the index starts empty) a \ - `set_character_shard` reducer round trip on EVERY login, to reach the one answer it \ - could have given for free. `LYRACORE_SHARD_MAP` unset must be byte-identical to the \ - pre-sharding gateway on the login hot path. Reads were: {:?}", - h.db_at(WORLD).touched() - ); - } - // ------------------------------------------------------------------------------------- // `locate_home_shard`: `settle_home_shard`'s holder lookup, index-first // ------------------------------------------------------------------------------------- @@ -2061,9 +1909,7 @@ mod tests { assert_eq!( h.db_at(CORE).shard_index.lock().unwrap().get(&100).copied(), Some((36, 7)), - "the stale entry must be healed on THIS path — `settle_shard_index`'s identical rule \ - does not run in production (it hangs off the unreachable `home_shard`); this is the \ - function `settle_home_shard` actually calls" + "the stale entry must be healed on the path `settle_home_shard` calls" ); } @@ -2109,8 +1955,7 @@ mod tests { assert_eq!( found.shard_name(), INSTANCES, - "a dead realm-core must cost the hint, not the login — routing degrades to the scan, \ - exactly like `settle_shard_index`" + "a dead realm-core must cost the hint, not the login — routing degrades to the scan" ); } @@ -2252,27 +2097,6 @@ mod tests { ); } - // ------------------------------------------------------------------------------------- - // The realm-core index write on transfer completion - // ------------------------------------------------------------------------------------- - - #[test] - fn publishing_a_settled_transfer_writes_the_realm_core_index_not_the_world_shards() { - let h = routed_realm(); - publish_shard_index(&h, 100, 36, 7).expect("the publish lands"); - assert_eq!( - h.db_at(CORE).shard_index.lock().unwrap().get(&100).copied(), - Some((36, 7)), - "the transfer's destination was not published to REALM-CORE — the only copy of the \ - index `home_shard` reads. Writing it on the world handle instead lands it on a copy \ - nothing consults." - ); - assert!( - h.db_at(WORLD).shard_index.lock().unwrap().is_empty(), - "the publish must go to realm-core, not to whichever world handle drove the transfer" - ); - } - #[test] fn the_realm_fake_refuses_a_repair_during_transfer_and_keeps_both_views_coherent() { let h = realm(&[WORLD, CORE], "", Some(CORE)); @@ -2315,14 +2139,4 @@ mod tests { assert_eq!(partition.revision, 3); assert!(!partition.transfer_pending); } - - #[test] - fn publishing_the_index_fails_loudly_when_realm_core_is_unreachable() { - let h = realm_with_dead_core(&[WORLD, INSTANCES, CORE], "36:*=instances", CORE); - assert!( - publish_shard_index(&h, 100, 36, 7).is_err(), - "a publish that silently swallowed an unreachable realm-core would be exactly the \ - best-effort, independently-committing write this replication exists to remove" - ); - } } diff --git a/gateway/src/stdb/connection.rs b/gateway/src/stdb/connection.rs index e902af469..d7a297ec5 100644 --- a/gateway/src/stdb/connection.rs +++ b/gateway/src/stdb/connection.rs @@ -1004,7 +1004,7 @@ fn call_pipe_needs_repair(is_healthy: bool, already_reconnecting: bool) -> bool /// before the module was republished never comes back, in a configuration (`LYRACORE_SHARD_MAP` and /// `LYRACORE_REALM_CORE` both unset) that multi-shard routing and realm-core both promise costs /// nothing. Nothing reads -/// any of them on a single-database gateway anyway — `WorldStore::home_shard` short-circuits on +/// any of them on a single-database gateway anyway — `settle_home_shard` short-circuits on /// `is_sharded()` before it looks at the index. fn coordinator_queries(sharded_tables: bool) -> Vec<&'static str> { let mut queries = vec![ diff --git a/gateway/src/stdb/mod.rs b/gateway/src/stdb/mod.rs index de7685d35..d40eace80 100644 --- a/gateway/src/stdb/mod.rs +++ b/gateway/src/stdb/mod.rs @@ -28,10 +28,11 @@ mod member_stats_relay; // Member Stats for group mates outside the viewer's AOI mod movement_batch; mod reads; 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 WorldStore for Coordinator (replaces the former WorldCoordinatorStore newtype) +mod world_store; // impl RealmDb for Coordinator pub(crate) mod world_view; // shared per-shard spatial, broadcast, private, and owner dispatch pub use connection::Coordinator; diff --git a/gateway/src/stdb/reads/account.rs b/gateway/src/stdb/reads/account.rs index c66a16171..f21f40d7f 100644 --- a/gateway/src/stdb/reads/account.rs +++ b/gateway/src/stdb/reads/account.rs @@ -164,7 +164,7 @@ impl Coordinator { } /// The realm-core character→shard index entry for `guid`: the `(map_id, instance_id)` the realm - /// believes the character is at. A HINT — `config::resolve_home_shard` confirms it against + /// 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 @@ -461,7 +461,7 @@ impl Coordinator { } /// `owner_guid`'s friend guids and ignore guids for `CMSG_FRIEND_LIST`, per - /// [`crate::world::store::WorldStore::contact_lists`]. `owner_guid` is always the CALLING + /// [`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 @@ -480,7 +480,7 @@ impl Coordinator { } /// `owner_guid`'s ignore guids from this Shard's [`ContactIndex`], realm-wide safe for ANY - /// owner, per [`crate::world::store::WorldStore::ignored_guids`]. Unlike `contact_lists`, + /// 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. diff --git a/gateway/src/stdb/store/bank.rs b/gateway/src/stdb/store/bank.rs new file mode 100644 index 000000000..33444d3f9 --- /dev/null +++ b/gateway/src/stdb/store/bank.rs @@ -0,0 +1,17 @@ +//! `Coordinator`'s [`BankStore`] adapter. + +use anyhow::Result; + +use crate::world::BankStore; + +use crate::stdb::Coordinator; + +impl BankStore for Coordinator { + fn auto_bank_item(&self, account_id: u64, self_guid: u64, slot: u8) -> Result<()> { + self.auto_bank_item(account_id, self_guid, slot) + } + + fn buy_bank_slot(&self, account_id: u64, self_guid: u64, banker_guid: u64) -> Result<()> { + self.buy_bank_slot(account_id, self_guid, banker_guid) + } +} diff --git a/gateway/src/stdb/store/character.rs b/gateway/src/stdb/store/character.rs new file mode 100644 index 000000000..2621d4d90 --- /dev/null +++ b/gateway/src/stdb/store/character.rs @@ -0,0 +1,213 @@ +//! `Coordinator`'s [`CharacterStore`] adapter. + +use anyhow::Result; + +use crate::codec; +use crate::world::CharacterStore; + +use crate::stdb::connection::{CharacterPresenceSnapshot, Coordinator}; + +fn stable_character_absence( + first: &[CharacterPresenceSnapshot], + second: &[CharacterPresenceSnapshot], +) -> bool { + first.len() == second.len() + && first.iter().zip(second).all(|(before, after)| { + !before.present + && !after.present + && before.generation == after.generation + && before.revision == after.revision + }) +} + +fn durable_character_presence_snapshots( + shards: &[(String, Coordinator)], + guid: u64, +) -> Result> { + shards + .iter() + .map(|(name, shard)| { + shard + .0 + .coord() + .durable_character_presence_snapshot(guid, name) + }) + .collect() +} + +impl CharacterStore for Coordinator { + /// Union the account's Characters across connected World Shards. Deduplicate by guid + /// during interrupted Transfers, keeping the first copy in default-first probe order. + /// Guild membership comes from Realm-core; an unavailable authority leaves guild names empty. + fn characters(&self, account_id: u64) -> Result> { + let mut out: Vec = if self.is_sharded() { + let mut out: Vec = Vec::new(); + for shard in self.all_shards() { + for c in shard.characters(account_id)? { + if !out.iter().any(|existing| existing.guid == c.guid) { + out.push(c); + } + } + } + out + } else { + self.characters(account_id)? + }; + match self.realm_core() { + Ok(realm) => { + for c in &mut out { + c.guild_id = realm.guild_projection(c.guid).0; + } + } + Err(error) => log::warn!("world: character list without guild ids: {error:#}"), + } + Ok(out) + } + + /// Create a character on `self` — the DEFAULT/realm shard — always, even when the race's start + /// position (`module/src/auth.rs`'s per-race spawn table) routes to a DIFFERENT world shard + /// under the configured `LYRACORE_SHARD_MAP` (e.g. an Orc/Tauren/Troll/Night Elf's Kalimdor + /// start position on a two-continent split). The DECISION, made explicit: + /// + /// **Create-then-transfer-on-first-login, not create-directly-on-the-owning-shard.** The row + /// lands on `self` and stays there until the character's very first `CMSG_PLAYER_LOGIN` runs + /// `route_home`/`settle_home_shard` — the SAME resolve-and-transfer every OTHER + /// login already goes through, freshly created or not. Creating directly on the owning shard + /// instead would need its own routing resolved BEFORE the character exists (nothing to look up + /// in the character→shard index yet), built from scratch for a path that runs exactly once per + /// character and is not latency-sensitive the way login is. + /// + /// This costs nothing NEW: the escrowed transfer it rides is the one already proven against a + /// full gateway-kill crash matrix + /// (`a_gateway_kill_at_every_transfer_step_recovers_to_exactly_one_whole_copy`, + /// `world/transfer_tests.rs`), and a fresh character has no live history to lose in transit — if + /// anything the simplest case that machinery handles. What it did NOT have at first is a + /// test that the first login of a freshly created character actually drives that transfer + /// 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`). + fn create_character( + &self, + account_id: u64, + name: &str, + race: u8, + class: u8, + gender: u8, + appearance: codec::Appearance, + ) -> Result { + self.create_character(account_id, name, race, class, gender, appearance) + } + + /// Delete a character wherever it actually LIVES, not only on `self`. + /// + /// `characters()` above is a cross-shard UNION: a character resident on ANY connected + /// shard shows at char-select. `delete_character` did not match that — the reducer + /// call only ever ran on `self`, so a character resident on a non-default shard was NOT_FOUND + /// there (surfaced as `Failed`) even though the row was real and deletable. + /// The resolution is `realm_core::resolve_delete_shard` — the SAME index-first + /// lookup (`locate_home_shard`) the world-entry path already trusts, not a second routing + /// mechanism — and delete runs EXACTLY ONCE, on the resolved owner: turning "the wrong shard" + /// into "every shard" would trade a correctness bug for a data-loss footgun. A character caught + /// mid-transfer (an in-flight escrow) is refused rather than raced — see + /// `resolve_delete_shard`'s own doc for why. + /// + /// `account_id` is forwarded UNCHANGED to whichever shard `resolve_delete_shard` names — not + /// re-resolved for that database. This is safe, not merely assumed: `game_character.account_id` + /// is a per-database `#[auto_inc]` surrogate in general (`realm_core::lookup_session`'s doc + /// covers that boundary — the REALM-CORE ↔ world-shard one, where ids legitimately differ), but + /// a character can only ever REACH a non-default world shard through the escrowed transfer + /// (`create_character` never targets one directly — see its own doc comment), and + /// `ensure_shadow_account` (`module/src/auth.rs`) creates that shard's shadow `game_account` row + /// with the EXACT numeric id carried in the import blob, bypassing `#[auto_inc]` — so + /// `account_id` is preserved bit-for-bit, world-shard to world-shard, across every transfer. + /// `characters()`'s cross-shard union above and `route_home`'s `bind_shard_session` already + /// rely on this same invariant (`world/mod.rs`), so this is the established precedent, not a + /// new assumption introduced here. + fn delete_character( + &self, + account_id: u64, + character_guid: u64, + ) -> 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), + Err(e) => { + log::warn!("world: {e:#}"); + Ok(codec::CharDeleteOutcome::Failed) + } + } + } + + fn character_by_guid(&self, guid: u64) -> Result> { + self.character_by_guid(guid) + } + + fn character_exists_on_any_world_shard(&self, guid: u64) -> Result { + let shards = self.world_shards_for_absence()?; + let first = durable_character_presence_snapshots(&shards, guid)?; + if first.iter().any(|snapshot| snapshot.present) { + return Ok(true); + } + let second = durable_character_presence_snapshots(&shards, guid)?; + if second.iter().any(|snapshot| snapshot.present) { + return Ok(true); + } + if !stable_character_absence(&first, &second) { + anyhow::bail!( + "World Shard Character presence changed while checking {guid}; cleanup is deferred" + ); + } + Ok(false) + } + + fn player_skills(&self, character_guid: u64) -> Result> { + self.player_skills(character_guid) + } + + fn effective_armor(&self, guid: u64) -> u32 { + self.effective_armor(guid) + } + + fn effective_magic_resistances(&self, guid: u64) -> [u32; 6] { + self.effective_magic_resistances(guid) + } + + fn spell_modifiers(&self, character_guid: u64) -> Vec<(u32, u8, i32, bool)> { + self.spell_modifiers(character_guid) + } + + fn player_learned_spells(&self, player_guid: u64) -> Result> { + self.player_learned_spells(player_guid) + } + + fn player_reputations(&self, player_guid: u64) -> Result> { + self.player_reputations(player_guid) + } + + fn player_actions(&self, player_guid: u64) -> Result> { + self.player_actions(player_guid) + } +} + +#[cfg(test)] +mod stable_absence_tests { + #[test] + fn a_transfer_between_durable_scans_cannot_prove_stable_absence() { + use super::{stable_character_absence, CharacterPresenceSnapshot}; + + let snapshot = |generation, revision, present| CharacterPresenceSnapshot { + generation, + revision, + present, + }; + let first = [snapshot(1, 0, false), snapshot(2, 1, false)]; + let destination_visible = [snapshot(1, 1, true), snapshot(2, 1, false)]; + assert!(!stable_character_absence(&first, &destination_visible)); + + let moved_again = [snapshot(1, 2, false), snapshot(2, 2, false)]; + assert!(!stable_character_absence(&first, &moved_again)); + + let unchanged = [snapshot(1, 0, false), snapshot(2, 1, false)]; + assert!(stable_character_absence(&first, &unchanged)); + } +} diff --git a/gateway/src/stdb/store/combat.rs b/gateway/src/stdb/store/combat.rs new file mode 100644 index 000000000..f078699df --- /dev/null +++ b/gateway/src/stdb/store/combat.rs @@ -0,0 +1,27 @@ +//! `Coordinator`'s [`CombatStore`] adapter. + +use anyhow::Result; + +use crate::world::CombatStore; + +use crate::stdb::Coordinator; + +impl CombatStore for Coordinator { + fn set_target(&self, account_id: u64, self_guid: u64, target_guid: u64) -> Result<()> { + self.set_target(account_id, self_guid, target_guid) + } + + fn pet_command( + &self, + account_id: u64, + self_guid: u64, + data: u32, + target_guid: u64, + ) -> Result<()> { + self.pet_command(account_id, self_guid, data, target_guid) + } + + fn set_sheathed(&self, account_id: u64, self_guid: u64, state: u8) -> Result<()> { + self.set_sheathed(account_id, self_guid, state) + } +} diff --git a/gateway/src/stdb/store/death.rs b/gateway/src/stdb/store/death.rs new file mode 100644 index 000000000..3247dbedc --- /dev/null +++ b/gateway/src/stdb/store/death.rs @@ -0,0 +1,33 @@ +//! `Coordinator`'s [`DeathStore`] adapter. + +use anyhow::Result; + +use crate::world::DeathStore; + +use crate::stdb::Coordinator; + +impl DeathStore for Coordinator { + fn repop(&self, account_id: u64, self_guid: u64) -> Result<()> { + self.repop(account_id, self_guid) + } + + fn reclaim_corpse(&self, account_id: u64, self_guid: u64, corpse_guid: u64) -> Result<()> { + self.reclaim_corpse(account_id, self_guid, corpse_guid) + } + + fn resurrect_response(&self, account_id: u64, self_guid: u64, accept: bool) -> Result<()> { + self.resurrect_response(account_id, self_guid, accept) + } + + fn self_resurrect(&self, account_id: u64, self_guid: u64) -> Result<()> { + self.self_resurrect(account_id, self_guid) + } + + fn spirit_healer_res(&self, account_id: u64, self_guid: u64, healer_guid: u64) -> Result<()> { + self.spirit_healer_res(account_id, self_guid, healer_guid) + } + + fn corpse_location(&self, owner_guid: u64) -> Result> { + self.corpse_location(owner_guid) + } +} diff --git a/gateway/src/stdb/store/loot_roll.rs b/gateway/src/stdb/store/loot_roll.rs new file mode 100644 index 000000000..a898c8341 --- /dev/null +++ b/gateway/src/stdb/store/loot_roll.rs @@ -0,0 +1,87 @@ +//! `Coordinator`'s [`LootRollStore`] adapter. + +use anyhow::Result; + +use crate::world::loot::{LootRollStore, PendingLootRoll}; +use crate::world::LootActionStatus; + +use crate::stdb::Coordinator; + +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( + &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, + ) + } + + fn realm_loot_vote( + &self, + corpse_guid: u64, + slot: u8, + actor_guid: u64, + vote: u8, + ) -> Result { + self.realm_loot_vote(corpse_guid, slot, actor_guid, vote) + } + + fn pending_local_rolls(&self) -> Result> { + self.pending_local_rolls() + } + + fn settle_loot_roll(&self, corpse_guid: u64, slot: u8, winner_guid: u64) -> Result<()> { + self.settle_loot_roll(corpse_guid, slot, winner_guid) + } + + fn clear_promoted_loot_roll(&self, roll_id: u64) -> Result<()> { + self.clear_promoted_loot_roll(roll_id) + } + + fn loot_won_since(&self, after_id: u64) -> Result<(u64, Vec<(u64, u8, u64)>)> { + self.loot_won_since(after_id) + } + + fn loot_master_give( + &self, + account_id: u64, + self_guid: u64, + corpse_guid: u64, + loot_slot: u8, + target_guid: u64, + ) -> Result { + self.loot_master_give(account_id, self_guid, corpse_guid, loot_slot, target_guid) + } +} diff --git a/gateway/src/stdb/store/mail.rs b/gateway/src/stdb/store/mail.rs new file mode 100644 index 000000000..6889bce42 --- /dev/null +++ b/gateway/src/stdb/store/mail.rs @@ -0,0 +1,208 @@ +//! `Coordinator`'s [`MailStore`] adapter. + +use anyhow::Result; + +use crate::codec; +use crate::world::MailStore; + +use crate::stdb::Coordinator; + +impl MailStore for Coordinator { + fn mail_list(&self, recipient_guid: u64) -> Result> { + self.mail_list(recipient_guid) + } + + fn mail_by_id(&self, mail_id: u64) -> Result> { + Ok(self.mail_by_id(mail_id)) + } + + fn realm_account_name(&self, character_guid: u64) -> Result> { + Ok(self.realm_account_name(character_guid)) + } + + fn mailbox_in_range(&self, mailbox_guid: u64, player_guid: u64) -> Result { + self.mailbox_in_range(mailbox_guid, player_guid) + } + + fn mail_mark_read(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { + self.mail_mark_read(recipient_guid, mail_id) + } + + fn mail_delete(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { + self.mail_delete(recipient_guid, mail_id) + } + + fn mail_return(&self, recipient_guid: u64, mail_id: u64, same_account: bool) -> Result<()> { + self.mail_return(recipient_guid, mail_id, same_account) + } + + fn mail_send( + &self, + sender_guid: u64, + recipient_guid: u64, + subject: String, + body: String, + money: u32, + cod: u32, + item_guid: u64, + same_account: bool, + ) -> Result<()> { + self.mail_send( + sender_guid, + recipient_guid, + subject, + body, + money, + cod, + item_guid, + same_account, + ) + } + + fn mail_take_money(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { + self.mail_take_money(recipient_guid, mail_id) + } + + fn mail_take_item(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { + self.mail_take_item(recipient_guid, mail_id) + } + + fn mail_item_room(&self, payee_guid: u64) -> Result<()> { + self.mail_item_room(payee_guid) + } + + fn mail_copy_text(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { + self.mail_copy_text(recipient_guid, mail_id) + } + + fn mail_grant_letter(&self, payee_guid: u64, item_text_id: u32) -> Result<()> { + self.mail_grant_letter(payee_guid, item_text_id) + } + + fn mail_mark_letter_granted(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { + self.mail_mark_letter_granted(recipient_guid, mail_id) + } + + fn item_text(&self, item_text_id: u32) -> Result> { + self.item_text(item_text_id) + } + + fn owns_item_with_text( + &self, + owner_guid: u64, + item_text_id: u32, + hint_item_guid: u64, + ) -> Result { + self.owns_item_with_text(owner_guid, item_text_id, hint_item_guid) + } + + fn mail_fence( + &self, + escrow_id: u64, + sender_guid: u64, + recipient_guid: u64, + subject: String, + body: String, + money: u32, + postage: u32, + item_guid: u64, + cod: u32, + cod_source_mail_id: u64, + same_account: bool, + ) -> Result<()> { + self.mail_fence( + escrow_id, + sender_guid, + recipient_guid, + subject, + body, + money, + postage, + item_guid, + cod, + cod_source_mail_id, + same_account, + ) + } + + fn mail_commit( + &self, + escrow_id: u64, + sender_guid: u64, + recipient_guid: u64, + subject: String, + body: String, + money: u32, + item: crate::world::mail::AttachedItem, + cod: u32, + cod_source_mail_id: u64, + delivery_delay_secs: u32, + reward: Option, + ) -> Result<()> { + self.mail_commit( + escrow_id, + sender_guid, + recipient_guid, + subject, + body, + money, + item, + cod, + cod_source_mail_id, + delivery_delay_secs, + reward, + ) + } + + fn mail_take_money_fence( + &self, + escrow_id: u64, + payee_guid: u64, + mail_id: u64, + expect_money: u32, + ) -> Result<()> { + self.mail_take_money_fence(escrow_id, payee_guid, mail_id, expect_money) + } + + fn mail_payout( + &self, + escrow_id: u64, + payee_guid: u64, + mail_id: u64, + amount: u32, + ) -> Result<()> { + self.mail_payout(escrow_id, payee_guid, mail_id, amount) + } + + fn mail_take_item_fence( + &self, + escrow_id: u64, + payee_guid: u64, + mail_id: u64, + expect_entry: u32, + ) -> Result<()> { + self.mail_take_item_fence(escrow_id, payee_guid, mail_id, expect_entry) + } + + fn mail_item_payout( + &self, + escrow_id: u64, + payee_guid: u64, + mail_id: u64, + item: crate::world::mail::AttachedItem, + ) -> Result<()> { + self.mail_item_payout(escrow_id, payee_guid, mail_id, item) + } + + fn mail_confirm_delivery(&self, escrow_id: u64) -> Result<()> { + self.mail_confirm_delivery(escrow_id) + } + + fn mail_settle(&self, escrow_id: u64) -> Result<()> { + self.mail_settle(escrow_id) + } + + fn mail_escrows_of(&self, sender_guid: u64) -> Result> { + self.mail_escrows_of(sender_guid) + } +} diff --git a/gateway/src/stdb/store/mod.rs b/gateway/src/stdb/store/mod.rs new file mode 100644 index 000000000..c72d6a6d1 --- /dev/null +++ b/gateway/src/stdb/store/mod.rs @@ -0,0 +1,22 @@ +//! `Coordinator`'s Store family adapters, one file per 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. + +mod bank; +mod character; +mod combat; +mod death; +mod loot_roll; +mod mail; +mod npc; +mod party; +mod session; +mod shard_routing; +mod social; +mod speech; +mod trade; +mod trainer; +mod transfer; diff --git a/gateway/src/stdb/store/npc.rs b/gateway/src/stdb/store/npc.rs new file mode 100644 index 000000000..07365314d --- /dev/null +++ b/gateway/src/stdb/store/npc.rs @@ -0,0 +1,74 @@ +//! `Coordinator`'s [`NpcStore`] adapter. + +use anyhow::Result; + +use crate::codec; +use crate::world::NpcStore; + +use crate::stdb::Coordinator; + +impl NpcStore for Coordinator { + fn creature_template(&self, entry: u32) -> Result> { + self.creature_template(entry) + } + + fn pet_name( + &self, + requester_guid: u64, + pet_number: u32, + pet_guid: u64, + ) -> Result> { + self.pet_name(requester_guid, pet_number, pet_guid) + } + + fn gameobject_template(&self, entry: u32) -> Result> { + self.gameobject_template(entry) + } + + fn gameobject_type(&self, go_guid: u64) -> Result> { + self.gameobject_type(go_guid) + } + + fn enter_areatrigger(&self, account_id: u64, self_guid: u64, trigger_id: u32) -> Result<()> { + self.enter_areatrigger(account_id, self_guid, trigger_id) + } + + fn npc_refuses_interaction(&self, npc_guid: u64, player_guid: u64) -> Result { + self.npc_refuses_interaction(npc_guid, player_guid) + } + + fn bind_home(&self, account_id: u64, self_guid: u64) -> Result<()> { + self.bind_home(account_id, self_guid) + } + + fn npc_is_innkeeper(&self, guid: u64) -> Result { + self.npc_is_innkeeper(guid) + } + + fn npc_gossip_text_id(&self, npc_guid: u64) -> u32 { + self.npc_gossip_text_id(npc_guid) + } + + fn npc_text_for_id(&self, text_id: u32) -> Option { + self.npc_text_for_id(text_id) + } + + fn gossip_options(&self, npc_guid: u64) -> Result> { + self.gossip_options(npc_guid) + } + + fn inspect(&self, account_id: u64, self_guid: u64, target_guid: u64) -> Result<()> { + self.inspect(account_id, self_guid, target_guid) + } + + fn gossip_select( + &self, + account_id: u64, + self_guid: u64, + npc_guid: u64, + option_id: u32, + option_row_id: u32, + ) -> Result<()> { + self.gossip_select(account_id, self_guid, npc_guid, option_id, option_row_id) + } +} diff --git a/gateway/src/stdb/store/party.rs b/gateway/src/stdb/store/party.rs new file mode 100644 index 000000000..90dd2b781 --- /dev/null +++ b/gateway/src/stdb/store/party.rs @@ -0,0 +1,266 @@ +//! `Coordinator`'s [`PartyStore`] adapter. + +use anyhow::Result; + +use crate::world::{PartyStore, ShardRoutingStore}; + +use crate::stdb::bindings::game_world_entity_table::GameWorldEntityTableAccess; +use crate::stdb::Coordinator; + +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) + } + + fn claim_party_command_intent(&self, intent_id: u64, claim_token: u64) -> Result<()> { + Coordinator::claim_party_command_intent(self, intent_id, claim_token) + } + + fn admit_party_command_authority( + &self, + group_id: u64, + leader_guid: u64, + bot_guid: u64, + authority_member_guid: u64, + expected_members: Vec, + ) -> Result { + Coordinator::admit_party_command_authority( + self, + group_id, + leader_guid, + bot_guid, + authority_member_guid, + expected_members, + ) + } + + fn apply_admitted_party_command( + &self, + command: &crate::world::party::AdmittedCompanionCommand, + ) -> Result { + Coordinator::apply_admitted_party_command(self, command) + } + + fn finish_party_command_intent( + &self, + intent_id: u64, + claim_token: u64, + outcome: crate::world::party::CompanionCommandOutcome, + ) -> Result<()> { + Coordinator::finish_party_command_intent(self, intent_id, claim_token, outcome) + } + + fn confirm_party_command_receipt( + &self, + source_identity: spacetimedb_sdk::Identity, + intent_id: u64, + ) -> Result> { + Coordinator::confirm_party_command_receipt(self, source_identity, intent_id) + } + + fn confirm_party_command_holder( + &self, + bot_guid: u64, + ) -> Result { + Coordinator::confirm_party_command_holder(self, bot_guid) + } + + fn entity_partition(&self, guid: u64) -> Option<(u32, u64)> { + self.0 + .coord() + .conn + .db + .game_world_entity() + .guid() + .find(&guid) + .map(|entity| (entity.map_id, entity.instance_id)) + } + + fn admit_sessionless_group_action( + &self, + character_guid: u64, + ) -> Result { + self.admit_sessionless_group_action(character_guid) + } + + fn realm_group_op( + &self, + op: u8, + actor_guid: u64, + target_guid: u64, + arg_a: u8, + arg_b: u8, + arg_c: u64, + ) -> Result { + self.realm_group_op(op, actor_guid, target_guid, arg_a, arg_b, arg_c) + } + + fn realm_group_op_visible( + &self, + op: u8, + actor_guid: u64, + target_guid: u64, + arg_a: u8, + arg_b: u8, + arg_c: u64, + ) -> Result { + self.realm_group_op_visible(op, actor_guid, target_guid, arg_a, arg_b, arg_c) + } + + fn deleted_character_party_leave( + &self, + character_guid: u64, + ) -> Result { + self.deleted_character_party_leave(character_guid) + } + + fn group_roster( + &self, + character_guid: u64, + ) -> Result> { + Ok(self.group_roster(character_guid)) + } + + fn party_command_group_roster( + &self, + character_guid: u64, + ) -> Result> { + self.party_command_group_roster(character_guid) + } + + fn group_roster_by_id( + &self, + group_id: u64, + ) -> Result> { + Ok(self.group_roster_by_id(group_id)) + } + + fn group_roster_revision(&self, group_id: u64) -> Result { + Ok(self.group_roster_revision(group_id)) + } + + fn held_roster_revision(&self, group_id: u64) -> Result> { + Ok(self.held_roster_revision(group_id)) + } + + fn realm_character_partition( + &self, + character_guid: u64, + ) -> Result> { + self.realm_character_partition(character_guid) + } + + fn party_holder_observation( + &self, + character_guid: u64, + map_id: u32, + instance_id: u64, + ) -> Result { + let serves_locator = self.shard_for_location(map_id, instance_id).is_none(); + self.stable_party_holder_observation(character_guid, serves_locator) + } + + fn party_cleanup_group_roster_by_id( + &self, + group_id: u64, + ) -> Result> { + let healthy = { + let live = self.0.coord(); + live.is_healthy() + }; + if !healthy { + anyhow::bail!( + "{} has no healthy Coordinator subscription for party cleanup", + self.shard_name() + ); + } + Ok(self.group_roster_by_id(group_id)) + } + + fn party_member_guids(&self) -> Result> { + if !self.0.coord().is_healthy() { + anyhow::bail!( + "{} has no healthy Coordinator subscription for party cleanup", + self.shard_name() + ); + } + Ok(self.party_member_guids()) + } + + fn party_group_ids(&self) -> Result> { + if !self.0.coord().is_healthy() { + anyhow::bail!( + "{} has no healthy Coordinator subscription for party cleanup", + self.shard_name() + ); + } + Ok(self.party_group_ids()) + } + + fn sync_group_mirror(&self, roster: &crate::world::party::GroupRoster) -> Result<()> { + self.sync_group_mirror(roster) + } +} diff --git a/gateway/src/stdb/store/session.rs b/gateway/src/stdb/store/session.rs new file mode 100644 index 000000000..9fccf9bc7 --- /dev/null +++ b/gateway/src/stdb/store/session.rs @@ -0,0 +1,109 @@ +//! `Coordinator`'s [`SessionStore`] adapter. + +use anyhow::{anyhow, Result}; +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; + +impl SessionStore for Coordinator { + fn lookup_session(&self, account_name: &str) -> Result> { + crate::realm_core::lookup_session(self, account_name) + } + + fn player_login( + &self, + account_id: u64, + character_guid: u64, + entry: codec::WorldEntry, + ) -> Result { + self.player_login(account_id, character_guid, entry) + } + + fn movement_update( + &self, + _account_id: u64, + self_guid: u64, + opcode: u32, + info: &MovementInfo, + ) -> Result<()> { + if self_guid == 0 { + return Err(anyhow!("movement_update: actor_guid unresolved")); + } + // Carry the MovementInfo verbatim so the module can relay it to in-range observers under + // the same opcode. Queueing is the only completion observable on the shared batch; + // individual reducer outcomes are intentionally unavailable. + let body = codec::movement_info_to_bytes(info)?; + self.0.motion_batch.push(GwMove { + actor: self.session_actor(self_guid), + opcode: opcode as u16, + movement_info: body, + x: info.position.x, + y: info.position.y, + z: info.position.z, + o: info.orientation, + move_time_ms: info.timestamp, + }); + MOVE_SUBMITTED.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + Ok(()) + } + + fn subscribe_player_events( + &self, + account_id: u64, + self_guid: u64, + arrival: &crate::codec::EntityView, + tx: SessionTx, + ) -> Result { + self.subscribe_player_events(account_id, self_guid, arrival, tx) + } + + fn client_command( + &self, + account_id: u64, + self_guid: u64, + cmd: String, + payload: String, + ) -> Result<()> { + self.client_command(account_id, self_guid, cmd, payload) + } + + fn pending_system_messages(&self, self_guid: u64) -> Vec { + self.system_messages_for(self_guid) + } + + fn entity_in_world(&self, guid: u64) -> bool { + self.entity_in_world(guid) + } + + fn entity_max_health(&self, guid: u64) -> u32 { + self.entity_max_health(guid) + } + + fn claim_session( + &self, + account_id: u64, + character_guid: u64, + ) -> Result { + self.claim_session(account_id, character_guid) + } + + fn bind_session( + &self, + token: crate::world::WorldSessionToken, + ) -> Result>> { + Ok(Some(std::sync::Arc::new(self.bind_session(token)?))) + } + + fn release_session(&self, token: crate::world::WorldSessionToken) -> Result<()> { + self.release_session(token) + } + + fn player_combat_until_ms(&self, player_guid: u64) -> u64 { + self.player_combat_until_ms(player_guid) + } +} diff --git a/gateway/src/stdb/store/shard_routing.rs b/gateway/src/stdb/store/shard_routing.rs new file mode 100644 index 000000000..f92a0a901 --- /dev/null +++ b/gateway/src/stdb/store/shard_routing.rs @@ -0,0 +1,173 @@ +//! `Coordinator`'s [`ShardRoutingStore`] adapter. + +use anyhow::Result; + +use crate::world::{ShardRoutingStore, WorldStore}; + +use crate::stdb::Coordinator; + +impl ShardRoutingStore for Coordinator { + fn shard_name(&self) -> &str { + // Inherent method (see the module note): this is a view, not recursion. + self.shard_name() + } + + /// The production world-entry resolver: find where the character actually LIVES, resolve who + /// should own it, and run the escrowed transfer when those differ. Single-shard → the + /// `is_sharded` short-circuit, so the unconfigured gateway does not even read a row. + /// + /// The HOLDER lookup below is `realm_core::locate_home_shard` — the realm-core + /// index consulted FIRST, the shard scan only on a miss, self-heal write-back on either path. + fn settle_home_shard( + &self, + character_guid: u64, + ) -> Result>> { + if !self.is_sharded() { + return Ok(None); + } + // The shard that currently holds the durable row (or the in-flight escrow). Realm-core's + // character→shard index is consulted first; the scan (`Coordinator::all_shards`) is the + // fallback, not the only path. + let Some(holder) = crate::realm_core::locate_home_shard(self, character_guid) else { + // Unknown character: leave the session where it is. The + // module's own ownership check is what refuses the login. + return Ok(None); + }; + // The ESCROW's destination outranks the durable row's location: `settle_transfer` RESUMES + // an existing escrow against the destination the escrow was opened for, so an owner + // resolved from anything else would hand `run_transfer` a `dst` that is not the database the + // blob is already filed against — importing the character into a third shard and orphaning + // the fenced copy. `run_transfer` cannot catch that for itself: a `&dyn WorldStore` cannot be + // asked which `(map, instance)` it owns, so the obligation is here, at the resolution site. + // With no escrow (the fresh-transfer path) this is the durable row, exactly as before — the + // same fields `character_destination` builds the fresh plan from. + let escrow = holder.escrow_row(character_guid); + let (map_id, instance_id) = escrow + .as_ref() + .map(|r| (r.dest_map_id, r.dest_instance_id)) + .or_else(|| holder.character_location(character_guid)) + .ok_or_else(|| anyhow::anyhow!("character {character_guid} vanished mid-routing"))?; + // OWNER RESOLUTION. The shard map answers, in DATABASE NAMES resolved through + // `shard_for`/`instance_shard_for`, so "the answer names the shard I already am" decodes to + // `None` in both, and `self` is the handle that means "stay put". The NAME never depends on + // which handle asked (`shard_for` and `world_shards` both read the shared `ShardSet`), so + // this replaces the older "resolve from the default handle" without reintroducing + // `realm_shard()`, which realm-core deleted. + // + // The instance-pool stickiness applies ONLY when there is no escrow in flight. With an + // escrow the destination is already fixed by the row (see the paragraph above): being + // sticky about the holder there would resolve an owner that differs from the database the + // blob is filed against, which is the third-shard hazard this comment block exists to + // prevent. Without one, the holder is the only durable evidence of which pool member a + // live run is on, and it is what stops a pool resize from forking a party. + let owner = if escrow.is_none() { + self.instance_shard_for(map_id, instance_id, holder.shard_name()) + } else { + self.shard_for(map_id, instance_id) + } + .unwrap_or_else(|| self.clone()); + // The three facts that decide whether a resume is DRIVEN or silently skipped. Nothing logged + // them, so every diagnosis of a stranded copy (twice, live) had to infer the holder from what + // did not happen — and `settle_transfer`'s `holder == owner` branch is silent by design. + log::info!( + "settle {character_guid}: holder={} owner={} escrow={} ({map_id}/{instance_id})", + holder.shard_name(), + owner.shard_name(), + escrow.is_some() + ); + crate::world::transfer::settle_transfer(&holder, &owner, character_guid)?; + Ok(Some( + std::sync::Arc::new(owner) as std::sync::Arc + )) + } + + /// `instance_shard_for`, not `shard_for`: this handle is the character's HOLDER, and the + /// stickiness rule that keeps a live dungeon run on its pool member is written against exactly + /// that pair. `settle_home_shard` above resolves a fresh (escrow-free) hop the same way. + fn shard_for_location( + &self, + map_id: u32, + instance_id: u64, + ) -> Option> { + let shard = self.instance_shard_for(map_id, instance_id, Coordinator::shard_name(self))?; + Some(std::sync::Arc::new(shard) as std::sync::Arc) + } + + fn bind_shard_session(&self, account_id: u64, session_key: &[u8; 40]) -> Result<()> { + // `bound_identity` DERIVES this shard's identity for the account + // (`synthetic_owner_identity`); `establish_session` then binds it to the shadow account + // row that `import_character_blob` created. On the realm shard this is the same binding + // the logon tier already wrote, re-asserted — idempotent, and cheap next to a login. + let identity = self.bound_identity(account_id)?; + self.establish_session(account_id, session_key, identity) + } + + /// The realm-core handle, but ONLY on a multi-database gateway. + /// + /// The `is_sharded()` short-circuit is the whole of "unset env vars ⇒ today's gateway" for the + /// realm-wide party plane: with one database `realm_core()` would answer THIS handle, and routing + /// party ops through the operator-gated realm reducers against the same database would be a + /// different code path, a different gate, and a different set of writes for no gain. Answering + /// `None` here sends `world::party` down the arm that calls exactly the reducers it called + /// before realm-core existed. + /// + /// Configured-but-unreachable realm-core answers `None` as well (`realm_core()` is `Err`), so a + /// realm-core outage degrades party ops to shard-local rather than failing them. That is a + /// deliberate split from the AUTH paths, which fail CLOSED: a stale auth cache lets someone in + /// with a revoked credential, while a shard-local party op is only ever a smaller party. + fn realm_store(&self) -> Option> { + if !self.is_sharded() { + return None; + } + self.realm_core() + .ok() + .map(|rc| std::sync::Arc::new(rc) as std::sync::Arc) + } + + fn party_cleanup_realm(&self) -> Result>> { + if !self.is_sharded() { + return Ok(None); + } + self.realm_core() + .map(|realm| Some(std::sync::Arc::new(realm) as std::sync::Arc)) + } + + fn party_command_realm(&self) -> Result>> { + if !self.is_sharded() { + return Ok(None); + } + self.realm_core() + .map(|realm| Some(std::sync::Arc::new(realm) as std::sync::Arc)) + } + + fn transfer_realm(&self) -> Result>> { + if !self.is_sharded() { + return Ok(None); + } + self.realm_core() + .map(|realm| Some(std::sync::Arc::new(realm) as std::sync::Arc)) + } + + /// Every connected WORLD shard (realm-core excluded by `ShardMap::shards`, as always) — the + /// mirror fan-out set. Empty when unsharded, so the push costs a single-database gateway nothing. + fn world_stores(&self) -> Vec> { + if !self.is_sharded() { + return Vec::new(); + } + self.all_shards() + .into_iter() + .map(|c| std::sync::Arc::new(c) as std::sync::Arc) + .collect() + } + + fn party_command_worlds(&self) -> Result>> { + if !self.is_sharded() { + return Ok(Vec::new()); + } + Ok(self + .configured_world_shards()? + .into_iter() + .map(|coordinator| std::sync::Arc::new(coordinator) as std::sync::Arc) + .collect()) + } +} diff --git a/gateway/src/stdb/store/social.rs b/gateway/src/stdb/store/social.rs new file mode 100644 index 000000000..4ac80ae68 --- /dev/null +++ b/gateway/src/stdb/store/social.rs @@ -0,0 +1,91 @@ +//! `Coordinator`'s [`SocialStore`] adapter. + +use anyhow::Result; + +use crate::codec; +use crate::world::SocialStore; + +use crate::stdb::Coordinator; + +impl SocialStore for Coordinator { + fn character_identity( + &self, + guid: u64, + ) -> Result> { + self.character_identity(guid) + } + + fn live_entity(&self, guid: u64) -> Option { + self.live_entity(guid) + } + + fn character_in_transit(&self, guid: u64) -> bool { + self.character_in_transit(guid) + } + + fn auto_reply_text(&self, guid: u64) -> Result> { + Ok(self.auto_reply_text(guid)) + } + + fn every_shard_vouches_for_absence(&self) -> Result<()> { + self.world_shards_for_absence()?; + Ok(()) + } + + fn in_world_players(&self) -> Result> { + self.in_world_players() + } + + fn zone_name(&self, zone_id: u32) -> String { + self.zone_name(zone_id) + } + + fn contact_lists(&self, self_guid: u64) -> Result<(Vec, Vec)> { + self.contact_lists(self_guid) + } + + fn ignored_guids(&self, owner_guid: u64) -> Result> { + self.ignored_guids(owner_guid) + } + + fn character_guid_by_name(&self, name: &str) -> Result> { + self.character_guid_by_name(name) + } + + fn add_friend( + &self, + account_id: u64, + self_guid: u64, + target_guid: u64, + target_race: u8, + ) -> Result { + self.add_friend(account_id, self_guid, target_guid, target_race) + } + + fn del_friend( + &self, + account_id: u64, + self_guid: u64, + target_guid: u64, + ) -> Result { + self.del_friend(account_id, self_guid, target_guid) + } + + fn add_ignore( + &self, + account_id: u64, + self_guid: u64, + target_guid: u64, + ) -> Result { + self.add_ignore(account_id, self_guid, target_guid) + } + + fn del_ignore( + &self, + account_id: u64, + self_guid: u64, + target_guid: u64, + ) -> Result { + self.del_ignore(account_id, self_guid, target_guid) + } +} diff --git a/gateway/src/stdb/store/speech.rs b/gateway/src/stdb/store/speech.rs new file mode 100644 index 000000000..0274de298 --- /dev/null +++ b/gateway/src/stdb/store/speech.rs @@ -0,0 +1,35 @@ +//! `Coordinator`'s [`SpeechStore`] adapter. + +use anyhow::Result; + +use crate::world::{ChatOutcome, SpeechStore}; + +use crate::stdb::Coordinator; + +impl SpeechStore for Coordinator { + fn send_chat( + &self, + account_id: u64, + self_guid: u64, + chat_type: u8, + language: u8, + message: String, + ) -> Result { + self.send_chat(account_id, self_guid, chat_type, language, message) + } + + fn send_emote( + &self, + account_id: u64, + self_guid: u64, + text_emote: u32, + emote_anim: u32, + target_guid: u64, + ) -> Result<()> { + self.send_emote(account_id, self_guid, text_emote, emote_anim, target_guid) + } + + fn gm_command(&self, account_name: &str, self_guid: u64, text: String) -> Result<()> { + self.gm_command(account_name, self_guid, text) + } +} diff --git a/gateway/src/stdb/store/trade.rs b/gateway/src/stdb/store/trade.rs new file mode 100644 index 000000000..8f9b8fe49 --- /dev/null +++ b/gateway/src/stdb/store/trade.rs @@ -0,0 +1,55 @@ +//! `Coordinator`'s [`TradeStore`] adapter. + +use anyhow::Result; + +use crate::world::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) + } + + fn begin_trade(&self, account_id: u64, self_guid: u64) -> Result<()> { + self.begin_trade(account_id, self_guid) + } + + fn cancel_trade(&self, account_id: u64, self_guid: u64) -> Result<()> { + self.cancel_trade(account_id, self_guid) + } + + fn set_trade_item( + &self, + account_id: u64, + self_guid: u64, + trade_slot: u8, + inv_slot: u8, + ) -> Result<()> { + self.set_trade_item(account_id, self_guid, trade_slot, inv_slot) + } + + fn clear_trade_item(&self, account_id: u64, self_guid: u64, trade_slot: u8) -> Result<()> { + self.clear_trade_item(account_id, self_guid, trade_slot) + } + + fn set_trade_gold(&self, account_id: u64, self_guid: u64, copper: u32) -> Result<()> { + self.set_trade_gold(account_id, self_guid, copper) + } + + fn accept_trade(&self, account_id: u64, self_guid: u64) -> Result<()> { + self.accept_trade(account_id, self_guid) + } + + fn unaccept_trade(&self, account_id: u64, self_guid: u64) -> Result<()> { + self.unaccept_trade(account_id, self_guid) + } + + fn busy_trade(&self, account_id: u64, self_guid: u64) -> Result<()> { + self.busy_trade(account_id, self_guid) + } + + fn ignore_trade(&self, account_id: u64, self_guid: u64) -> Result<()> { + self.ignore_trade(account_id, self_guid) + } +} diff --git a/gateway/src/stdb/store/trainer.rs b/gateway/src/stdb/store/trainer.rs new file mode 100644 index 000000000..9bd61ae0f --- /dev/null +++ b/gateway/src/stdb/store/trainer.rs @@ -0,0 +1,91 @@ +//! `Coordinator`'s [`TrainerStore`] adapter. + +use anyhow::Result; + +use crate::codec; +use crate::world::TrainerStore; + +use crate::stdb::Coordinator; + +impl TrainerStore for Coordinator { + fn trainer_serves(&self, player_guid: u64, trainer_guid: u64) -> Result { + self.trainer_serves(player_guid, trainer_guid) + } + + fn trainer_list( + &self, + player_guid: u64, + trainer_guid: u64, + ) -> Result> { + self.trainer_list(player_guid, trainer_guid) + } + + fn buy_trainer_spell( + &self, + account_id: u64, + self_guid: u64, + trainer_guid: u64, + spell_id: u32, + ) -> Result { + self.buy_trainer_spell(account_id, self_guid, trainer_guid, spell_id) + } + + fn trainer_offer_skill_line(&self, trainer_guid: u64, spell_id: u32) -> u32 { + self.trainer_offer_skill_line(trainer_guid, spell_id) + } + + fn talent_grant_spell(&self, talent_id: u32) -> u32 { + self.talent_by_id(talent_id) + .map(|t| t.grant_spell_id) + .unwrap_or(0) + } + + fn set_faction_at_war( + &self, + account_id: u64, + self_guid: u64, + reputation_index: u32, + at_war: bool, + ) -> Result<()> { + self.set_faction_at_war(account_id, self_guid, reputation_index, at_war) + } + + fn set_action_button( + &self, + account_id: u64, + self_guid: u64, + button: u8, + action: u32, + action_type: u8, + ) -> Result<()> { + self.set_action_button(account_id, self_guid, button, action, action_type) + } + + fn talent_pane_sync(&self, character_guid: u64, talent_id: u32) -> (u32, u32, u32) { + self.talent_pane_sync(character_guid, talent_id) + } + + fn talent_points_spent(&self, character_guid: u64) -> u32 { + self.talent_points_spent(character_guid) + } + + fn learn_talent(&self, account_id: u64, self_guid: u64, talent_id: u32) -> Result<()> { + self.learn_talent(account_id, self_guid, talent_id) + } + + fn reset_talents(&self, account_id: u64, self_guid: u64, trainer_guid: u64) -> Result<()> { + self.reset_talents(account_id, self_guid, trainer_guid) + } + + fn resolve_learn_target(&self, spell_id: u32) -> u32 { + self.resolve_learn_target(spell_id) + } + + fn superseded_old_rank(&self, new_spell: u32, player_guid: u64) -> Option { + self.superseded_old_rank(new_spell, player_guid) + } + + fn character_presence(&self, guid: u64) -> Result> { + self.character_presence(guid) + } +} diff --git a/gateway/src/stdb/store/transfer.rs b/gateway/src/stdb/store/transfer.rs new file mode 100644 index 000000000..60211db07 --- /dev/null +++ b/gateway/src/stdb/store/transfer.rs @@ -0,0 +1,255 @@ +//! `Coordinator`'s [`TransferStore`] adapter. + +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::Coordinator; + +impl TransferStore for Coordinator { + fn escrowed_transfer( + &self, + character_guid: u64, + ) -> Option { + self.escrow_row(character_guid) + .map(|r| crate::world::transfer::EscrowedTransfer { + transfer_id: r.transfer_id, + character_guid: r.character_guid, + dest_map_id: r.dest_map_id, + dest_instance_id: r.dest_instance_id, + blob: r.blob, + }) + } + + fn character_destination( + &self, + character_guid: u64, + ) -> Option { + let c = self.character_row(character_guid)?; + Some(crate::world::transfer::TransferPlan { + transfer_id: crate::world::transfer::transfer_id_for(character_guid), + character_guid, + dest_map_id: c.map_id, + dest_instance_id: c.pending_instance_id, + dest_x: c.x, + dest_y: c.y, + dest_z: c.z, + dest_o: c.orientation, + }) + } + + fn begin_transfer(&self, plan: &crate::world::transfer::TransferPlan) -> Result<()> { + self.begin_transfer(plan) + } + + fn import_character_blob( + &self, + transfer_id: u64, + blob: &[u8], + source: crate::world::transfer::RealmLocatorPredecessor, + bot_arrival: Option<&crate::world::transfer::BotTransferIntent>, + ) -> Result<()> { + match bot_arrival { + Some(intent) => { + if (source.map_id, source.instance_id, source.revision) + != ( + intent.source_map, + intent.source_instance, + intent.source_locator_revision, + ) + { + anyhow::bail!("bot Transfer Realm locator binding changed before import"); + } + self.import_bot_character_blob(transfer_id, blob, intent) + } + None => self.import_player_character_blob(transfer_id, blob, source), + } + } + + fn confirm_import(&self, transfer_id: u64) -> Result<()> { + self.confirm_import(transfer_id) + } + + fn finish_transfer(&self, transfer_id: u64) -> Result<()> { + self.finish_transfer(transfer_id) + } + + fn release_transfer(&self, transfer_id: u64) -> Result<()> { + self.release_transfer(transfer_id) + } + + fn release_player_transfer_arrival( + &self, + transfer_id: u64, + character_guid: u64, + source: crate::world::transfer::RealmLocatorPredecessor, + ) -> Result<()> { + Coordinator::release_player_transfer_arrival(self, transfer_id, character_guid, source) + } + + fn transfer_arrival( + &self, + transfer_id: u64, + ) -> Option { + self.0 + .coord() + .conn + .db + .game_transfer_in() + .transfer_id() + .find(&transfer_id) + .map(|row| crate::world::transfer::TransferArrival { + character_guid: row.character_guid, + source_map: row.source_map_id, + source_instance: row.source_instance_id, + source_locator_revision: row.source_locator_revision, + bot_source_identity: row.bot_intent_source, + bot_transfer_intent_id: row.bot_intent_id, + bot_controller_generation: row.bot_controller_generation, + }) + } + + fn instance_partition(&self, instance_id: u64) -> Option<(u32, u64)> { + self.0 + .coord() + .conn + .db + .game_instance() + .instance_id() + .find(&instance_id) + .map(|row| (row.map_id, row.party_id)) + } + + fn ensure_instance(&self, instance_id: u64, map_id: u32, party_id: u64) -> Result<()> { + self.ensure_instance(instance_id, map_id, party_id) + } + + fn evict_instance_population(&self, instance_id: u64) -> Result<()> { + self.evict_instance_population(instance_id) + } + + fn begin_shard_index_transfer( + &self, + plan: &crate::world::transfer::TransferPlan, + bot_intent: Option<(&crate::world::transfer::BotTransferIntent, u64)>, + ) -> Result { + crate::realm_core::begin_shard_index_transfer(self, plan, bot_intent) + } + + fn finish_player_shard_index_transfer( + &self, + plan: &crate::world::transfer::TransferPlan, + source_map: u32, + source_instance: u64, + source_revision: u64, + ) -> Result<()> { + crate::realm_core::finish_player_shard_index_transfer( + self, + plan, + source_map, + source_instance, + source_revision, + ) + } + + fn finish_pending_shard_index_transfer( + &self, + character_guid: u64, + destination_map: u32, + destination_instance: u64, + arrival: &crate::world::transfer::TransferArrival, + ) -> Result<()> { + crate::realm_core::finish_pending_shard_index_transfer( + self, + character_guid, + destination_map, + destination_instance, + arrival, + ) + } + + fn bind_bot_transfer_locator( + &self, + intent: &crate::world::transfer::BotTransferIntent, + source_revision: u64, + claim_token: u64, + ) -> Result<()> { + self.bind_bot_transfer_locator(intent, source_revision, claim_token) + } + + fn publish_bot_shard_index( + &self, + intent: &crate::world::transfer::BotTransferIntent, + ) -> Result<()> { + crate::realm_core::publish_bot_shard_index(self, intent) + } + + fn sync_transfer_arrival(&self, character_guid: u64) -> Result<()> { + crate::world::party::sync_transfer_arrival_mirror(self, character_guid) + } + + fn sync_transfer_pending(&self, character_guid: u64) -> Result<()> { + crate::world::party::sync_transfer_arrival_mirror(self, character_guid) + } + + fn mark_bot_transfer_arrival_ready( + &self, + intent_id: u64, + bot_guid: u64, + controller_generation: u64, + claim_token: u64, + ) -> Result<()> { + Coordinator::mark_bot_transfer_arrival_ready( + self, + intent_id, + bot_guid, + controller_generation, + claim_token, + ) + } + + fn bot_transfer_arrival_matches( + &self, + transfer_id: u64, + intent: &crate::world::transfer::BotTransferIntent, + ) -> bool { + self.0 + .coord() + .conn + .db + .game_transfer_in() + .transfer_id() + .find(&transfer_id) + .is_some_and(|arrival| { + arrival.character_guid == intent.bot_guid + && arrival.bot_intent_source == intent.source_module_identity + && arrival.bot_intent_id == intent.id + && arrival.bot_controller_generation == intent.controller_generation + && arrival.bot_intent_created_micros == intent.created_micros + && arrival.source_map_id == intent.source_map + && arrival.source_instance_id == intent.source_instance + && arrival.source_locator_revision == intent.source_locator_revision + }) + } + + fn release_bot_transfer_arrival( + &self, + 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, + ) + } +} diff --git a/gateway/src/stdb/subscriptions.rs b/gateway/src/stdb/subscriptions.rs index 40c7ef0fa..efee55be9 100644 --- a/gateway/src/stdb/subscriptions.rs +++ b/gateway/src/stdb/subscriptions.rs @@ -15,6 +15,7 @@ //! queue runs the cache reads, state updates, and packet construction. use crate::codec::{self, CreateKind}; +use crate::world::{CharacterStore, QuestActionStore, SessionStore, ShardRoutingStore}; use crate::world::{Outbound, SessionTx}; use anyhow::{anyhow, Result}; use lyracore_shared::item_property::client_enchantment_id; @@ -2146,7 +2147,9 @@ fn trade_offer_extended( /// the shared leg). `store` is the connection the row came from: Realm-core on a sharded Realm. /// The LIST render reads every World Shard through it, and QUEST_SHARE reads the quest detail. #[allow(clippy::too_many_lines)] // One arm per group event kind. -pub(crate) fn group_event_outbound( +pub(crate) fn group_event_outbound< + St: CharacterStore + QuestActionStore + SessionStore + ShardRoutingStore + ?Sized, +>( store: &St, self_guid: u64, row: &GroupEvent, @@ -2434,7 +2437,7 @@ pub(crate) fn group_event_outbound( /// committed durably, so dropping the packet would strand it for the whole GC window. SET_LEADER /// skips the packet instead: the roster mirror the Gateway already pushed carries the new leader, /// so the line is redundant rather than load-bearing. -fn other_character_name( +fn other_character_name( store: &St, row: &GroupEvent, relay: &str, diff --git a/gateway/src/stdb/subscriptions_party_command_durable_tests.rs b/gateway/src/stdb/subscriptions_party_command_durable_tests.rs index cfe9a9154..997d4ef07 100644 --- a/gateway/src/stdb/subscriptions_party_command_durable_tests.rs +++ b/gateway/src/stdb/subscriptions_party_command_durable_tests.rs @@ -8,7 +8,7 @@ use crate::config::GatewayConfig; use crate::durable_test_support::{module_bytes, poll_until, Standalone, POLL_TIMEOUT}; use crate::stdb::bindings::GamePartyCommandIntentTableAccess; use crate::world::party::{self, AdmittedCompanionCommand, CompanionCommandOutcome}; -use crate::world::WorldStore; +use crate::world::TransferStore; use spacetimedb_sdk::Table; use std::collections::BTreeMap; use std::ffi::OsString; diff --git a/gateway/src/stdb/world_store.rs b/gateway/src/stdb/world_store.rs index cca23fda5..de42c9abf 100644 --- a/gateway/src/stdb/world_store.rs +++ b/gateway/src/stdb/world_store.rs @@ -1,1640 +1,15 @@ -//! `Coordinator` is the production [`WorldStore`]. Focused protocol-family supertraits are -//! implemented beside their dispatchers; this file adapts the remaining broad world-session seam. -//! -//! 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 -//! this trait method — these are thin views, NOT recursion. The inherent methods can't simply *be* this -//! impl: a trait impl must be one contiguous block, but `subscribe_player_events` builds viewer state -//! in `subscriptions.rs` and the logon tier calls some inherent reads directly — so the real -//! bodies stay distributed by concern and this block is the unifying world-facing view. The two methods -//! that are NOT 1:1 forwards do real work: `lookup_session` composes two reads, and `movement_update` -//! serializes the `MovementInfo` before enqueueing it on the shard's shared batch. +//! `Coordinator`'s [`RealmDb`](crate::realm_core::RealmDb) adapter. -use anyhow::{anyhow, Result}; -use wow_world_messages::vanilla::MovementInfo; +use anyhow::Result; -use crate::codec; use crate::realm_core::SessionKey; -use crate::world::{SessionTx, WorldSession, WorldStore, MOVE_SUBMITTED}; -use super::bindings::game_instance_table::GameInstanceTableAccess; -use super::bindings::game_transfer_in_table::GameTransferInTableAccess; -use super::bindings::game_world_entity_table::GameWorldEntityTableAccess; -use super::bindings::GwMove; -use super::connection::{CharacterPresenceSnapshot, Coordinator}; +use super::connection::Coordinator; use super::views::{AccountRow, RealmRow}; -use super::PlayerSubscriptions; - -fn stable_character_absence( - first: &[CharacterPresenceSnapshot], - second: &[CharacterPresenceSnapshot], -) -> bool { - first.len() == second.len() - && first.iter().zip(second).all(|(before, after)| { - !before.present - && !after.present - && before.generation == after.generation - && before.revision == after.revision - }) -} - -fn durable_character_presence_snapshots( - shards: &[(String, Coordinator)], - guid: u64, -) -> Result> { - shards - .iter() - .map(|(name, shard)| { - shard - .0 - .coord() - .durable_character_presence_snapshot(guid, name) - }) - .collect() -} - -impl WorldStore for Coordinator { - /// Multi-shard routing, driven by the realm-core character→shard index. - /// - /// Finds where `character_guid` lives, resolves that location through the shard map, and returns - /// the owning shard's handle. `None` = this handle already owns it, which is the only answer a - /// single-entry shard map can give. The original routing answered "where does it live" by reading - /// `game_character` on the default shard — true only until a transfer actually moves someone off - /// it. Realm-core answers - /// it from its index, CONFIRMED against the shard that holds the row, and repairs the - /// index when the two disagree. The decision itself is the pure, unit-tested - /// [`crate::config::resolve_home_shard`]; this method only supplies it with live probes. - // Note: routing is all this does — it does NOT provision the player on the destination. - // Ceiling: a session that actually routes to a second database opens a fresh player connection - // there whose node-issued identity was never bound by `establish_session` on THAT database, so - // `player_login` will be refused by the module's owner check. Realm-core narrowed that gap but - // did not close it: accounts and sessions ARE now shared, but the per-shard player - // connection's identity binding still has to be made on the shard the player lands on. Upgrade - // path: the escrowed transfer moves the character rows, instance entry drives it, - // and the arriving shard's `establish_session` (or `player_login`'s restamp) does the binding. - fn home_shard(&self, character_guid: u64) -> Option> { - let resolved = crate::realm_core::settle_shard_index(self, character_guid)?; - // The handle comes from `shard_for`, unchanged: it resolves the same location - // through the same map and returns `None` when that is already this handle's shard. All - // the index lookup adds in front of it is a better answer to "what IS the location". This runs at - // world ENTRY only: a session already in the world holds its pinned handle and is never - // asked again (see `WorldConn::route_home`). - let shard = self.shard_for(resolved.location.0, resolved.location.1); - debug_assert!( - resolved.db - == shard.as_ref().map_or( - crate::stdb::Coordinator::shard_name(self), - Coordinator::shard_name - ), - "resolve_home_shard and shard_for must agree — they resolve the same location" - ); - Some(std::sync::Arc::new(shard?) as std::sync::Arc) - } - - fn shard_name(&self) -> &str { - // Inherent method (see the module note): this is a view, not recursion. - self.shard_name() - } - - // ------------------------------------------------------------------------------------- - // Cross-database transfer — the production side of `world::transfer`. - // ------------------------------------------------------------------------------------- - - /// The transfer-aware upgrade of `home_shard`: find where the character actually LIVES, resolve who should - /// own it, and run the escrowed transfer when those differ. Single-shard → the `is_sharded` - /// short-circuit, so the unconfigured gateway does not even read a row. - /// - /// **This, not `home_shard`, is the production world-entry resolver.** `world::route_home` - /// calls `settle_home_shard`, and this impl OVERRIDES it — so any routing rule that is - /// applied only in `home_shard` is dead code in production while every suite stays green (every - /// mock takes the trait default, which forwards to `home_shard`). - /// - /// The HOLDER lookup below is `realm_core::locate_home_shard` — the realm-core - /// index consulted FIRST, the shard scan only on a miss, self-heal write-back on either path — - /// not `settle_shard_index`/`home_shard`'s copy of the same rule, which this override still - /// never calls and which the login hot path therefore still never reaches. - fn settle_home_shard( - &self, - character_guid: u64, - ) -> Result>> { - if !self.is_sharded() { - return Ok(None); - } - // The shard that currently holds the durable row (or the in-flight escrow). Realm-core's - // character→shard index is consulted first; the scan (`Coordinator::all_shards`) is the - // fallback, not the only path. - let Some(holder) = crate::realm_core::locate_home_shard(self, character_guid) else { - // Unknown character: leave the session where it is, exactly as `home_shard` does. The - // module's own ownership check is what refuses the login. - return Ok(None); - }; - // The ESCROW's destination outranks the durable row's location: `settle_transfer` RESUMES - // an existing escrow against the destination the escrow was opened for, so an owner - // resolved from anything else would hand `run_transfer` a `dst` that is not the database the - // blob is already filed against — importing the character into a third shard and orphaning - // the fenced copy. `run_transfer` cannot catch that for itself: a `&dyn WorldStore` cannot be - // asked which `(map, instance)` it owns, so the obligation is here, at the resolution site. - // With no escrow (the fresh-transfer path) this is the durable row, exactly as before — the - // same fields `character_destination` builds the fresh plan from. - let escrow = holder.escrow_row(character_guid); - let (map_id, instance_id) = escrow - .as_ref() - .map(|r| (r.dest_map_id, r.dest_instance_id)) - .or_else(|| holder.character_location(character_guid)) - .ok_or_else(|| anyhow::anyhow!("character {character_guid} vanished mid-routing"))?; - // OWNER RESOLUTION. The shard map answers, in DATABASE NAMES resolved through - // `shard_for`/`instance_shard_for`, so "the answer names the shard I already am" decodes to - // `None` in both, and `self` is the handle that means "stay put". The NAME never depends on - // which handle asked (`shard_for` and `world_shards` both read the shared `ShardSet`), so - // this replaces the older "resolve from the default handle" without reintroducing - // `realm_shard()`, which realm-core deleted. - // - // The instance-pool stickiness applies ONLY when there is no escrow in flight. With an - // escrow the destination is already fixed by the row (see the paragraph above): being - // sticky about the holder there would resolve an owner that differs from the database the - // blob is filed against, which is the third-shard hazard this comment block exists to - // prevent. Without one, the holder is the only durable evidence of which pool member a - // live run is on, and it is what stops a pool resize from forking a party. - let owner = if escrow.is_none() { - self.instance_shard_for(map_id, instance_id, holder.shard_name()) - } else { - self.shard_for(map_id, instance_id) - } - .unwrap_or_else(|| self.clone()); - // The three facts that decide whether a resume is DRIVEN or silently skipped. Nothing logged - // them, so every diagnosis of a stranded copy (twice, live) had to infer the holder from what - // did not happen — and `settle_transfer`'s `holder == owner` branch is silent by design. - log::info!( - "settle {character_guid}: holder={} owner={} escrow={} ({map_id}/{instance_id})", - holder.shard_name(), - owner.shard_name(), - escrow.is_some() - ); - crate::world::transfer::settle_transfer(&holder, &owner, character_guid)?; - Ok(Some( - std::sync::Arc::new(owner) as std::sync::Arc - )) - } - - /// `instance_shard_for`, not `shard_for`: this handle is the character's HOLDER, and the - /// stickiness rule that keeps a live dungeon run on its pool member is written against exactly - /// that pair. `settle_home_shard` above resolves a fresh (escrow-free) hop the same way. - fn shard_for_location( - &self, - map_id: u32, - instance_id: u64, - ) -> Option> { - let shard = self.instance_shard_for(map_id, instance_id, Coordinator::shard_name(self))?; - Some(std::sync::Arc::new(shard) as std::sync::Arc) - } - - fn escrowed_transfer( - &self, - character_guid: u64, - ) -> Option { - self.escrow_row(character_guid) - .map(|r| crate::world::transfer::EscrowedTransfer { - transfer_id: r.transfer_id, - character_guid: r.character_guid, - dest_map_id: r.dest_map_id, - dest_instance_id: r.dest_instance_id, - blob: r.blob, - }) - } - - fn character_destination( - &self, - character_guid: u64, - ) -> Option { - let c = self.character_row(character_guid)?; - Some(crate::world::transfer::TransferPlan { - transfer_id: crate::world::transfer::transfer_id_for(character_guid), - character_guid, - dest_map_id: c.map_id, - dest_instance_id: c.pending_instance_id, - dest_x: c.x, - dest_y: c.y, - dest_z: c.z, - dest_o: c.orientation, - }) - } - - fn begin_transfer(&self, plan: &crate::world::transfer::TransferPlan) -> Result<()> { - self.begin_transfer(plan) - } - - fn import_character_blob( - &self, - transfer_id: u64, - blob: &[u8], - source: crate::world::transfer::RealmLocatorPredecessor, - bot_arrival: Option<&crate::world::transfer::BotTransferIntent>, - ) -> Result<()> { - match bot_arrival { - Some(intent) => { - if (source.map_id, source.instance_id, source.revision) - != ( - intent.source_map, - intent.source_instance, - intent.source_locator_revision, - ) - { - anyhow::bail!("bot Transfer Realm locator binding changed before import"); - } - self.import_bot_character_blob(transfer_id, blob, intent) - } - None => self.import_player_character_blob(transfer_id, blob, source), - } - } - - fn confirm_import(&self, transfer_id: u64) -> Result<()> { - self.confirm_import(transfer_id) - } - - fn finish_transfer(&self, transfer_id: u64) -> Result<()> { - self.finish_transfer(transfer_id) - } - - fn release_transfer(&self, transfer_id: u64) -> Result<()> { - self.release_transfer(transfer_id) - } - - fn release_player_transfer_arrival( - &self, - transfer_id: u64, - character_guid: u64, - source: crate::world::transfer::RealmLocatorPredecessor, - ) -> Result<()> { - Coordinator::release_player_transfer_arrival(self, transfer_id, character_guid, source) - } - - fn transfer_arrival( - &self, - transfer_id: u64, - ) -> Option { - self.0 - .coord() - .conn - .db - .game_transfer_in() - .transfer_id() - .find(&transfer_id) - .map(|row| crate::world::transfer::TransferArrival { - character_guid: row.character_guid, - source_map: row.source_map_id, - source_instance: row.source_instance_id, - source_locator_revision: row.source_locator_revision, - bot_source_identity: row.bot_intent_source, - bot_transfer_intent_id: row.bot_intent_id, - bot_controller_generation: row.bot_controller_generation, - }) - } - - fn instance_partition(&self, instance_id: u64) -> Option<(u32, u64)> { - self.0 - .coord() - .conn - .db - .game_instance() - .instance_id() - .find(&instance_id) - .map(|row| (row.map_id, row.party_id)) - } - - fn ensure_instance(&self, instance_id: u64, map_id: u32, party_id: u64) -> Result<()> { - self.ensure_instance(instance_id, map_id, party_id) - } - - fn evict_instance_population(&self, instance_id: u64) -> Result<()> { - self.evict_instance_population(instance_id) - } - - fn bind_shard_session(&self, account_id: u64, session_key: &[u8; 40]) -> Result<()> { - // `bound_identity` DERIVES this shard's identity for the account - // (`synthetic_owner_identity`); `establish_session` then binds it to the shadow account - // row that `import_character_blob` created. On the realm shard this is the same binding - // the logon tier already wrote, re-asserted — idempotent, and cheap next to a login. - let identity = self.bound_identity(account_id)?; - self.establish_session(account_id, session_key, identity) - } - - fn lookup_session(&self, account_name: &str) -> Result> { - crate::realm_core::lookup_session(self, account_name) - } - - /// Publish a settled transfer's destination into the REALM-CORE character→shard index. - /// Step 5b of `world::transfer::run_transfer` — see `realm_core::publish_shard_index` for why - /// this is a replication of `finish_transfer`'s own transactional write and not a best-effort - /// side call. - fn publish_shard_index( - &self, - character_guid: u64, - map_id: u32, - instance_id: u64, - ) -> Result<()> { - crate::realm_core::publish_shard_index(self, character_guid, map_id, instance_id) - } - - fn begin_shard_index_transfer( - &self, - plan: &crate::world::transfer::TransferPlan, - bot_intent: Option<(&crate::world::transfer::BotTransferIntent, u64)>, - ) -> Result { - crate::realm_core::begin_shard_index_transfer(self, plan, bot_intent) - } - - fn finish_player_shard_index_transfer( - &self, - plan: &crate::world::transfer::TransferPlan, - source_map: u32, - source_instance: u64, - source_revision: u64, - ) -> Result<()> { - crate::realm_core::finish_player_shard_index_transfer( - self, - plan, - source_map, - source_instance, - source_revision, - ) - } - - fn finish_pending_shard_index_transfer( - &self, - character_guid: u64, - destination_map: u32, - destination_instance: u64, - arrival: &crate::world::transfer::TransferArrival, - ) -> Result<()> { - crate::realm_core::finish_pending_shard_index_transfer( - self, - character_guid, - destination_map, - destination_instance, - arrival, - ) - } - - fn bind_bot_transfer_locator( - &self, - intent: &crate::world::transfer::BotTransferIntent, - source_revision: u64, - claim_token: u64, - ) -> Result<()> { - self.bind_bot_transfer_locator(intent, source_revision, claim_token) - } - - fn publish_bot_shard_index( - &self, - intent: &crate::world::transfer::BotTransferIntent, - ) -> Result<()> { - crate::realm_core::publish_bot_shard_index(self, intent) - } - - /// Union the account's Characters across connected World Shards. Deduplicate by guid - /// during interrupted Transfers, keeping the first copy in default-first probe order. - /// Guild membership comes from Realm-core; an unavailable authority leaves guild names empty. - fn characters(&self, account_id: u64) -> Result> { - let mut out: Vec = if self.is_sharded() { - let mut out: Vec = Vec::new(); - for shard in self.all_shards() { - for c in shard.characters(account_id)? { - if !out.iter().any(|existing| existing.guid == c.guid) { - out.push(c); - } - } - } - out - } else { - self.characters(account_id)? - }; - match self.realm_core() { - Ok(realm) => { - for c in &mut out { - c.guild_id = realm.guild_projection(c.guid).0; - } - } - Err(error) => log::warn!("world: character list without guild ids: {error:#}"), - } - Ok(out) - } - - /// Create a character on `self` — the DEFAULT/realm shard — always, even when the race's start - /// position (`module/src/auth.rs`'s per-race spawn table) routes to a DIFFERENT world shard - /// under the configured `LYRACORE_SHARD_MAP` (e.g. an Orc/Tauren/Troll/Night Elf's Kalimdor - /// start position on a two-continent split). The DECISION, made explicit: - /// - /// **Create-then-transfer-on-first-login, not create-directly-on-the-owning-shard.** The row - /// lands on `self` and stays there until the character's very first `CMSG_PLAYER_LOGIN` runs - /// `route_home`/`settle_home_shard` — the SAME resolve-and-transfer every OTHER - /// login already goes through, freshly created or not. Creating directly on the owning shard - /// instead would need its own routing resolved BEFORE the character exists (nothing to look up - /// in the character→shard index yet), built from scratch for a path that runs exactly once per - /// character and is not latency-sensitive the way login is. - /// - /// This costs nothing NEW: the escrowed transfer it rides is the one already proven against a - /// full gateway-kill crash matrix - /// (`a_gateway_kill_at_every_transfer_step_recovers_to_exactly_one_whole_copy`, - /// `world/tests.rs`), and a fresh character has no live history to lose in transit — if - /// anything the simplest case that machinery handles. What it did NOT have at first is a - /// test that the first login of a freshly created character actually drives that transfer - /// end-to-end rather than merely reusing already-tested machinery by assumption: - /// `a_freshly_created_characters_first_login_transfers_off_the_default_shard` (`world/tests.rs`). - fn create_character( - &self, - account_id: u64, - name: &str, - race: u8, - class: u8, - gender: u8, - appearance: codec::Appearance, - ) -> Result { - self.create_character(account_id, name, race, class, gender, appearance) - } - - /// Delete a character wherever it actually LIVES, not only on `self`. - /// - /// `characters()` above is a cross-shard UNION: a character resident on ANY connected - /// shard shows at char-select. `delete_character` did not match that — the reducer - /// call only ever ran on `self`, so a character resident on a non-default shard was NOT_FOUND - /// there (surfaced as `Failed`) even though the row was real and deletable. - /// The resolution is `realm_core::resolve_delete_shard` — the SAME index-first - /// lookup (`locate_home_shard`) the world-entry path already trusts, not a second routing - /// mechanism — and delete runs EXACTLY ONCE, on the resolved owner: turning "the wrong shard" - /// into "every shard" would trade a correctness bug for a data-loss footgun. A character caught - /// mid-transfer (an in-flight escrow) is refused rather than raced — see - /// `resolve_delete_shard`'s own doc for why. - /// - /// `account_id` is forwarded UNCHANGED to whichever shard `resolve_delete_shard` names — not - /// re-resolved for that database. This is safe, not merely assumed: `game_character.account_id` - /// is a per-database `#[auto_inc]` surrogate in general (`realm_core::lookup_session`'s doc - /// covers that boundary — the REALM-CORE ↔ world-shard one, where ids legitimately differ), but - /// a character can only ever REACH a non-default world shard through the escrowed transfer - /// (`create_character` never targets one directly — see its own doc comment), and - /// `ensure_shadow_account` (`module/src/auth.rs`) creates that shard's shadow `game_account` row - /// with the EXACT numeric id carried in the import blob, bypassing `#[auto_inc]` — so - /// `account_id` is preserved bit-for-bit, world-shard to world-shard, across every transfer. - /// `characters()`'s cross-shard union above and `route_home`'s `bind_shard_session` already - /// rely on this same invariant (`world/mod.rs`), so this is the established precedent, not a - /// new assumption introduced here. - fn delete_character( - &self, - account_id: u64, - character_guid: u64, - ) -> 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), - Err(e) => { - log::warn!("world: {e:#}"); - Ok(codec::CharDeleteOutcome::Failed) - } - } - } - - fn player_login( - &self, - account_id: u64, - character_guid: u64, - entry: codec::WorldEntry, - ) -> Result { - self.player_login(account_id, character_guid, entry) - } - - fn movement_update( - &self, - _account_id: u64, - self_guid: u64, - opcode: u32, - info: &MovementInfo, - ) -> Result<()> { - if self_guid == 0 { - return Err(anyhow!("movement_update: actor_guid unresolved")); - } - // Carry the MovementInfo verbatim so the module can relay it to in-range observers under - // the same opcode. Queueing is the only completion observable on the shared batch; - // individual reducer outcomes are intentionally unavailable. - let body = codec::movement_info_to_bytes(info)?; - self.0.motion_batch.push(GwMove { - actor: self.session_actor(self_guid), - opcode: opcode as u16, - movement_info: body, - x: info.position.x, - y: info.position.y, - z: info.position.z, - o: info.orientation, - move_time_ms: info.timestamp, - }); - MOVE_SUBMITTED.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - Ok(()) - } - - fn subscribe_player_events( - &self, - account_id: u64, - self_guid: u64, - arrival: &crate::codec::EntityView, - tx: SessionTx, - ) -> Result { - self.subscribe_player_events(account_id, self_guid, arrival, tx) - } - - fn character_by_guid(&self, guid: u64) -> Result> { - self.character_by_guid(guid) - } - - fn character_exists_on_any_world_shard(&self, guid: u64) -> Result { - let shards = self.world_shards_for_absence()?; - let first = durable_character_presence_snapshots(&shards, guid)?; - if first.iter().any(|snapshot| snapshot.present) { - return Ok(true); - } - let second = durable_character_presence_snapshots(&shards, guid)?; - if second.iter().any(|snapshot| snapshot.present) { - return Ok(true); - } - if !stable_character_absence(&first, &second) { - anyhow::bail!( - "World Shard Character presence changed while checking {guid}; cleanup is deferred" - ); - } - Ok(false) - } - - fn creature_template(&self, entry: u32) -> Result> { - self.creature_template(entry) - } - - fn pet_name( - &self, - requester_guid: u64, - pet_number: u32, - pet_guid: u64, - ) -> Result> { - self.pet_name(requester_guid, pet_number, pet_guid) - } - - fn gameobject_template(&self, entry: u32) -> Result> { - self.gameobject_template(entry) - } - - fn gameobject_type(&self, go_guid: u64) -> Result> { - self.gameobject_type(go_guid) - } - - fn enter_areatrigger(&self, account_id: u64, self_guid: u64, trigger_id: u32) -> Result<()> { - self.enter_areatrigger(account_id, self_guid, trigger_id) - } - - fn client_command( - &self, - account_id: u64, - self_guid: u64, - cmd: String, - payload: String, - ) -> Result<()> { - self.client_command(account_id, self_guid, cmd, payload) - } - - fn player_skills(&self, character_guid: u64) -> Result> { - self.player_skills(character_guid) - } - - fn effective_armor(&self, guid: u64) -> u32 { - self.effective_armor(guid) - } - - fn effective_magic_resistances(&self, guid: u64) -> [u32; 6] { - self.effective_magic_resistances(guid) - } - - fn pending_system_messages(&self, self_guid: u64) -> Vec { - self.system_messages_for(self_guid) - } - - fn npc_refuses_interaction(&self, npc_guid: u64, player_guid: u64) -> Result { - self.npc_refuses_interaction(npc_guid, player_guid) - } - - fn mail_list(&self, recipient_guid: u64) -> Result> { - self.mail_list(recipient_guid) - } - - fn mail_by_id(&self, mail_id: u64) -> Result> { - Ok(self.mail_by_id(mail_id)) - } - - fn realm_account_name(&self, character_guid: u64) -> Result> { - Ok(self.realm_account_name(character_guid)) - } - - fn mailbox_in_range(&self, mailbox_guid: u64, player_guid: u64) -> Result { - self.mailbox_in_range(mailbox_guid, player_guid) - } - - fn mail_mark_read(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { - self.mail_mark_read(recipient_guid, mail_id) - } - - fn mail_delete(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { - self.mail_delete(recipient_guid, mail_id) - } - fn trainer_serves(&self, player_guid: u64, trainer_guid: u64) -> Result { - self.trainer_serves(player_guid, trainer_guid) - } - - fn mail_return(&self, recipient_guid: u64, mail_id: u64, same_account: bool) -> Result<()> { - self.mail_return(recipient_guid, mail_id, same_account) - } - - fn mail_send( - &self, - sender_guid: u64, - recipient_guid: u64, - subject: String, - body: String, - money: u32, - cod: u32, - item_guid: u64, - same_account: bool, - ) -> Result<()> { - self.mail_send( - sender_guid, - recipient_guid, - subject, - body, - money, - cod, - item_guid, - same_account, - ) - } - - fn mail_take_money(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { - self.mail_take_money(recipient_guid, mail_id) - } - - fn mail_take_item(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { - self.mail_take_item(recipient_guid, mail_id) - } - - fn mail_item_room(&self, payee_guid: u64) -> Result<()> { - self.mail_item_room(payee_guid) - } - - fn mail_copy_text(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { - self.mail_copy_text(recipient_guid, mail_id) - } - - fn mail_grant_letter(&self, payee_guid: u64, item_text_id: u32) -> Result<()> { - self.mail_grant_letter(payee_guid, item_text_id) - } - - fn mail_mark_letter_granted(&self, recipient_guid: u64, mail_id: u64) -> Result<()> { - self.mail_mark_letter_granted(recipient_guid, mail_id) - } - - fn item_text(&self, item_text_id: u32) -> Result> { - self.item_text(item_text_id) - } - - fn owns_item_with_text( - &self, - owner_guid: u64, - item_text_id: u32, - hint_item_guid: u64, - ) -> Result { - self.owns_item_with_text(owner_guid, item_text_id, hint_item_guid) - } - - fn mail_fence( - &self, - escrow_id: u64, - sender_guid: u64, - recipient_guid: u64, - subject: String, - body: String, - money: u32, - postage: u32, - item_guid: u64, - cod: u32, - cod_source_mail_id: u64, - same_account: bool, - ) -> Result<()> { - self.mail_fence( - escrow_id, - sender_guid, - recipient_guid, - subject, - body, - money, - postage, - item_guid, - cod, - cod_source_mail_id, - same_account, - ) - } - - fn mail_commit( - &self, - escrow_id: u64, - sender_guid: u64, - recipient_guid: u64, - subject: String, - body: String, - money: u32, - item: crate::world::mail::AttachedItem, - cod: u32, - cod_source_mail_id: u64, - delivery_delay_secs: u32, - reward: Option, - ) -> Result<()> { - self.mail_commit( - escrow_id, - sender_guid, - recipient_guid, - subject, - body, - money, - item, - cod, - cod_source_mail_id, - delivery_delay_secs, - reward, - ) - } - - fn mail_take_money_fence( - &self, - escrow_id: u64, - payee_guid: u64, - mail_id: u64, - expect_money: u32, - ) -> Result<()> { - self.mail_take_money_fence(escrow_id, payee_guid, mail_id, expect_money) - } - - fn mail_payout( - &self, - escrow_id: u64, - payee_guid: u64, - mail_id: u64, - amount: u32, - ) -> Result<()> { - self.mail_payout(escrow_id, payee_guid, mail_id, amount) - } - - fn mail_take_item_fence( - &self, - escrow_id: u64, - payee_guid: u64, - mail_id: u64, - expect_entry: u32, - ) -> Result<()> { - self.mail_take_item_fence(escrow_id, payee_guid, mail_id, expect_entry) - } - - fn mail_item_payout( - &self, - escrow_id: u64, - payee_guid: u64, - mail_id: u64, - item: crate::world::mail::AttachedItem, - ) -> Result<()> { - self.mail_item_payout(escrow_id, payee_guid, mail_id, item) - } - - fn mail_confirm_delivery(&self, escrow_id: u64) -> Result<()> { - self.mail_confirm_delivery(escrow_id) - } - - fn mail_settle(&self, escrow_id: u64) -> Result<()> { - self.mail_settle(escrow_id) - } - - fn mail_escrows_of(&self, sender_guid: u64) -> Result> { - self.mail_escrows_of(sender_guid) - } - - fn trainer_list( - &self, - player_guid: u64, - trainer_guid: u64, - ) -> Result> { - self.trainer_list(player_guid, trainer_guid) - } - - fn buy_trainer_spell( - &self, - account_id: u64, - self_guid: u64, - trainer_guid: u64, - spell_id: u32, - ) -> Result { - self.buy_trainer_spell(account_id, self_guid, trainer_guid, spell_id) - } - - fn trainer_offer_skill_line(&self, trainer_guid: u64, spell_id: u32) -> u32 { - self.trainer_offer_skill_line(trainer_guid, spell_id) - } - - fn talent_grant_spell(&self, talent_id: u32) -> u32 { - self.talent_by_id(talent_id) - .map(|t| t.grant_spell_id) - .unwrap_or(0) - } - - fn set_faction_at_war( - &self, - account_id: u64, - self_guid: u64, - reputation_index: u32, - at_war: bool, - ) -> Result<()> { - self.set_faction_at_war(account_id, self_guid, reputation_index, at_war) - } - - fn set_action_button( - &self, - account_id: u64, - self_guid: u64, - button: u8, - action: u32, - action_type: u8, - ) -> Result<()> { - self.set_action_button(account_id, self_guid, button, action, action_type) - } - - fn talent_pane_sync(&self, character_guid: u64, talent_id: u32) -> (u32, u32, u32) { - self.talent_pane_sync(character_guid, talent_id) - } - - fn talent_points_spent(&self, character_guid: u64) -> u32 { - self.talent_points_spent(character_guid) - } - - fn spell_modifiers(&self, character_guid: u64) -> Vec<(u32, u8, i32, bool)> { - self.spell_modifiers(character_guid) - } - - fn learn_talent(&self, account_id: u64, self_guid: u64, talent_id: u32) -> Result<()> { - self.learn_talent(account_id, self_guid, talent_id) - } - - fn bind_home(&self, account_id: u64, self_guid: u64) -> Result<()> { - self.bind_home(account_id, self_guid) - } - - fn npc_is_innkeeper(&self, guid: u64) -> Result { - self.npc_is_innkeeper(guid) - } - - fn npc_gossip_text_id(&self, npc_guid: u64) -> u32 { - self.npc_gossip_text_id(npc_guid) - } - - fn npc_text_for_id(&self, text_id: u32) -> Option { - self.npc_text_for_id(text_id) - } - - fn gossip_options(&self, npc_guid: u64) -> Result> { - self.gossip_options(npc_guid) - } - - fn reset_talents(&self, account_id: u64, self_guid: u64, trainer_guid: u64) -> Result<()> { - self.reset_talents(account_id, self_guid, trainer_guid) - } - - fn auto_bank_item(&self, account_id: u64, self_guid: u64, slot: u8) -> Result<()> { - self.auto_bank_item(account_id, self_guid, slot) - } - - fn buy_bank_slot(&self, account_id: u64, self_guid: u64, banker_guid: u64) -> Result<()> { - self.buy_bank_slot(account_id, self_guid, banker_guid) - } - - fn player_learned_spells(&self, player_guid: u64) -> Result> { - self.player_learned_spells(player_guid) - } - fn player_reputations(&self, player_guid: u64) -> Result> { - self.player_reputations(player_guid) - } - fn resolve_learn_target(&self, spell_id: u32) -> u32 { - self.resolve_learn_target(spell_id) - } - - fn player_actions(&self, player_guid: u64) -> Result> { - self.player_actions(player_guid) - } - fn entity_in_world(&self, guid: u64) -> bool { - self.entity_in_world(guid) - } - - fn set_target(&self, account_id: u64, self_guid: u64, target_guid: u64) -> Result<()> { - self.set_target(account_id, self_guid, target_guid) - } - - fn inspect(&self, account_id: u64, self_guid: u64, target_guid: u64) -> Result<()> { - self.inspect(account_id, self_guid, target_guid) - } - - fn pet_command( - &self, - account_id: u64, - self_guid: u64, - data: u32, - target_guid: u64, - ) -> Result<()> { - self.pet_command(account_id, self_guid, data, target_guid) - } - fn set_sheathed(&self, account_id: u64, self_guid: u64, state: u8) -> Result<()> { - self.set_sheathed(account_id, self_guid, state) - } - - fn entity_max_health(&self, guid: u64) -> u32 { - self.entity_max_health(guid) - } - - fn superseded_old_rank(&self, new_spell: u32, player_guid: u64) -> Option { - self.superseded_old_rank(new_spell, player_guid) - } - - fn send_chat( - &self, - account_id: u64, - self_guid: u64, - chat_type: u8, - language: u8, - message: String, - ) -> Result { - self.send_chat(account_id, self_guid, chat_type, language, message) - } - - fn send_emote( - &self, - account_id: u64, - self_guid: u64, - text_emote: u32, - emote_anim: u32, - target_guid: u64, - ) -> Result<()> { - self.send_emote(account_id, self_guid, text_emote, emote_anim, target_guid) - } - - fn gm_command(&self, account_name: &str, self_guid: u64, text: String) -> Result<()> { - self.gm_command(account_name, self_guid, text) - } - - fn repop(&self, account_id: u64, self_guid: u64) -> Result<()> { - self.repop(account_id, self_guid) - } - - fn claim_session( - &self, - account_id: u64, - character_guid: u64, - ) -> Result { - self.claim_session(account_id, character_guid) - } - - fn bind_session( - &self, - token: crate::world::WorldSessionToken, - ) -> Result>> { - Ok(Some(std::sync::Arc::new(self.bind_session(token)?))) - } - - fn release_session(&self, token: crate::world::WorldSessionToken) -> Result<()> { - self.release_session(token) - } - - fn reclaim_corpse(&self, account_id: u64, self_guid: u64, corpse_guid: u64) -> Result<()> { - self.reclaim_corpse(account_id, self_guid, corpse_guid) - } - - fn resurrect_response(&self, account_id: u64, self_guid: u64, accept: bool) -> Result<()> { - self.resurrect_response(account_id, self_guid, accept) - } - - fn self_resurrect(&self, account_id: u64, self_guid: u64) -> Result<()> { - self.self_resurrect(account_id, self_guid) - } - - fn spirit_healer_res(&self, account_id: u64, self_guid: u64, healer_guid: u64) -> Result<()> { - self.spirit_healer_res(account_id, self_guid, healer_guid) - } - - fn corpse_location(&self, owner_guid: u64) -> Result> { - self.corpse_location(owner_guid) - } - - fn player_combat_until_ms(&self, player_guid: u64) -> u64 { - self.player_combat_until_ms(player_guid) - } - - fn character_identity( - &self, - guid: u64, - ) -> Result> { - self.character_identity(guid) - } - - fn live_entity(&self, guid: u64) -> Option { - self.live_entity(guid) - } - - fn character_in_transit(&self, guid: u64) -> bool { - self.character_in_transit(guid) - } - - fn auto_reply_text(&self, guid: u64) -> Result> { - Ok(self.auto_reply_text(guid)) - } - - fn every_shard_vouches_for_absence(&self) -> Result<()> { - self.world_shards_for_absence()?; - Ok(()) - } - - fn in_world_players(&self) -> Result> { - self.in_world_players() - } - - fn zone_name(&self, zone_id: u32) -> String { - self.zone_name(zone_id) - } - - fn contact_lists(&self, self_guid: u64) -> Result<(Vec, Vec)> { - self.contact_lists(self_guid) - } - - fn ignored_guids(&self, owner_guid: u64) -> Result> { - self.ignored_guids(owner_guid) - } - - fn character_guid_by_name(&self, name: &str) -> Result> { - self.character_guid_by_name(name) - } - - fn character_presence(&self, guid: u64) -> Result> { - self.character_presence(guid) - } - - // 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 initiate_trade(&self, account_id: u64, self_guid: u64, target_guid: u64) -> Result<()> { - self.initiate_trade(account_id, self_guid, target_guid) - } - fn begin_trade(&self, account_id: u64, self_guid: u64) -> Result<()> { - self.begin_trade(account_id, self_guid) - } - fn cancel_trade(&self, account_id: u64, self_guid: u64) -> Result<()> { - self.cancel_trade(account_id, self_guid) - } - fn set_trade_item( - &self, - account_id: u64, - self_guid: u64, - trade_slot: u8, - inv_slot: u8, - ) -> Result<()> { - self.set_trade_item(account_id, self_guid, trade_slot, inv_slot) - } - fn clear_trade_item(&self, account_id: u64, self_guid: u64, trade_slot: u8) -> Result<()> { - self.clear_trade_item(account_id, self_guid, trade_slot) - } - fn set_trade_gold(&self, account_id: u64, self_guid: u64, copper: u32) -> Result<()> { - self.set_trade_gold(account_id, self_guid, copper) - } - fn accept_trade(&self, account_id: u64, self_guid: u64) -> Result<()> { - self.accept_trade(account_id, self_guid) - } - fn unaccept_trade(&self, account_id: u64, self_guid: u64) -> Result<()> { - self.unaccept_trade(account_id, self_guid) - } - fn busy_trade(&self, account_id: u64, self_guid: u64) -> Result<()> { - self.busy_trade(account_id, self_guid) - } - fn ignore_trade(&self, account_id: u64, self_guid: u64) -> Result<()> { - self.ignore_trade(account_id, self_guid) - } - 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, - ) - } - - // --- Realm-wide party state (the realm-core group slice) --- - - /// The realm-core handle, but ONLY on a multi-database gateway. - /// - /// The `is_sharded()` short-circuit is the whole of "unset env vars ⇒ today's gateway" for the - /// realm-wide party plane: with one database `realm_core()` would answer THIS handle, and routing - /// party ops through the operator-gated realm reducers against the same database would be a - /// different code path, a different gate, and a different set of writes for no gain. Answering - /// `None` here sends `world::party` down the arm that calls exactly the reducers it called - /// before realm-core existed. - /// - /// Configured-but-unreachable realm-core answers `None` as well (`realm_core()` is `Err`), so a - /// realm-core outage degrades party ops to shard-local rather than failing them. That is a - /// deliberate split from the AUTH paths, which fail CLOSED: a stale auth cache lets someone in - /// with a revoked credential, while a shard-local party op is only ever a smaller party. - fn realm_store(&self) -> Option> { - if !self.is_sharded() { - return None; - } - self.realm_core() - .ok() - .map(|rc| std::sync::Arc::new(rc) as std::sync::Arc) - } - - fn party_cleanup_realm(&self) -> Result>> { - if !self.is_sharded() { - return Ok(None); - } - self.realm_core() - .map(|realm| Some(std::sync::Arc::new(realm) as std::sync::Arc)) - } - - fn party_command_realm(&self) -> Result>> { - if !self.is_sharded() { - return Ok(None); - } - self.realm_core() - .map(|realm| Some(std::sync::Arc::new(realm) as std::sync::Arc)) - } - - fn sync_transfer_arrival(&self, character_guid: u64) -> Result<()> { - crate::world::party::sync_transfer_arrival_mirror(self, character_guid) - } - - fn transfer_realm(&self) -> Result>> { - if !self.is_sharded() { - return Ok(None); - } - self.realm_core() - .map(|realm| Some(std::sync::Arc::new(realm) as std::sync::Arc)) - } - - fn sync_transfer_pending(&self, character_guid: u64) -> Result<()> { - crate::world::party::sync_transfer_arrival_mirror(self, character_guid) - } - - /// Every connected WORLD shard (realm-core excluded by `ShardMap::shards`, as always) — the - /// mirror fan-out set. Empty when unsharded, so the push costs a single-database gateway nothing. - fn world_stores(&self) -> Vec> { - if !self.is_sharded() { - return Vec::new(); - } - self.all_shards() - .into_iter() - .map(|c| std::sync::Arc::new(c) as std::sync::Arc) - .collect() - } - - fn party_command_worlds(&self) -> Result>> { - if !self.is_sharded() { - return Ok(Vec::new()); - } - Ok(self - .configured_world_shards()? - .into_iter() - .map(|coordinator| std::sync::Arc::new(coordinator) as std::sync::Arc) - .collect()) - } - - fn claim_bot_invite_intent(&self, intent_id: u64) -> Result { - self.claim_bot_invite_intent(intent_id) - } - - fn claim_party_command_intent(&self, intent_id: u64, claim_token: u64) -> Result<()> { - Coordinator::claim_party_command_intent(self, intent_id, claim_token) - } - - fn admit_party_command_authority( - &self, - group_id: u64, - leader_guid: u64, - bot_guid: u64, - authority_member_guid: u64, - expected_members: Vec, - ) -> Result { - Coordinator::admit_party_command_authority( - self, - group_id, - leader_guid, - bot_guid, - authority_member_guid, - expected_members, - ) - } - - fn apply_admitted_party_command( - &self, - command: &crate::world::party::AdmittedCompanionCommand, - ) -> Result { - Coordinator::apply_admitted_party_command(self, command) - } - - fn finish_party_command_intent( - &self, - intent_id: u64, - claim_token: u64, - outcome: crate::world::party::CompanionCommandOutcome, - ) -> Result<()> { - Coordinator::finish_party_command_intent(self, intent_id, claim_token, outcome) - } - - fn confirm_party_command_receipt( - &self, - source_identity: spacetimedb_sdk::Identity, - intent_id: u64, - ) -> Result> { - Coordinator::confirm_party_command_receipt(self, source_identity, intent_id) - } - - fn confirm_party_command_holder( - &self, - bot_guid: u64, - ) -> Result { - Coordinator::confirm_party_command_holder(self, bot_guid) - } - - fn entity_partition(&self, guid: u64) -> Option<(u32, u64)> { - self.0 - .coord() - .conn - .db - .game_world_entity() - .guid() - .find(&guid) - .map(|entity| (entity.map_id, entity.instance_id)) - } - - fn mark_bot_transfer_arrival_ready( - &self, - intent_id: u64, - bot_guid: u64, - controller_generation: u64, - claim_token: u64, - ) -> Result<()> { - Coordinator::mark_bot_transfer_arrival_ready( - self, - intent_id, - bot_guid, - controller_generation, - claim_token, - ) - } - - fn bot_transfer_arrival_matches( - &self, - transfer_id: u64, - intent: &crate::world::transfer::BotTransferIntent, - ) -> bool { - self.0 - .coord() - .conn - .db - .game_transfer_in() - .transfer_id() - .find(&transfer_id) - .is_some_and(|arrival| { - arrival.character_guid == intent.bot_guid - && arrival.bot_intent_source == intent.source_module_identity - && arrival.bot_intent_id == intent.id - && arrival.bot_controller_generation == intent.controller_generation - && arrival.bot_intent_created_micros == intent.created_micros - && arrival.source_map_id == intent.source_map - && arrival.source_instance_id == intent.source_instance - && arrival.source_locator_revision == intent.source_locator_revision - }) - } - - fn release_bot_transfer_arrival( - &self, - 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, - ) - } - - fn admit_sessionless_group_action( - &self, - character_guid: u64, - ) -> Result { - self.admit_sessionless_group_action(character_guid) - } - - fn realm_group_op( - &self, - op: u8, - actor_guid: u64, - target_guid: u64, - arg_a: u8, - arg_b: u8, - arg_c: u64, - ) -> Result { - self.realm_group_op(op, actor_guid, target_guid, arg_a, arg_b, arg_c) - } - - fn realm_group_op_visible( - &self, - op: u8, - actor_guid: u64, - target_guid: u64, - arg_a: u8, - arg_b: u8, - arg_c: u64, - ) -> Result { - self.realm_group_op_visible(op, actor_guid, target_guid, arg_a, arg_b, arg_c) - } - - fn deleted_character_party_leave( - &self, - character_guid: u64, - ) -> Result { - self.deleted_character_party_leave(character_guid) - } - - fn group_roster( - &self, - character_guid: u64, - ) -> Result> { - Ok(self.group_roster(character_guid)) - } - - fn party_command_group_roster( - &self, - character_guid: u64, - ) -> Result> { - self.party_command_group_roster(character_guid) - } - - fn group_roster_by_id( - &self, - group_id: u64, - ) -> Result> { - Ok(self.group_roster_by_id(group_id)) - } - - fn group_roster_revision(&self, group_id: u64) -> Result { - Ok(self.group_roster_revision(group_id)) - } - - fn held_roster_revision(&self, group_id: u64) -> Result> { - Ok(self.held_roster_revision(group_id)) - } - - fn realm_character_partition( - &self, - character_guid: u64, - ) -> Result> { - self.realm_character_partition(character_guid) - } - - fn party_holder_observation( - &self, - character_guid: u64, - map_id: u32, - instance_id: u64, - ) -> Result { - let serves_locator = self.shard_for_location(map_id, instance_id).is_none(); - self.stable_party_holder_observation(character_guid, serves_locator) - } - - fn party_cleanup_group_roster_by_id( - &self, - group_id: u64, - ) -> Result> { - let healthy = { - let live = self.0.coord(); - live.is_healthy() - }; - if !healthy { - anyhow::bail!( - "{} has no healthy Coordinator subscription for party cleanup", - self.shard_name() - ); - } - Ok(self.group_roster_by_id(group_id)) - } - - fn party_member_guids(&self) -> Result> { - if !self.0.coord().is_healthy() { - anyhow::bail!( - "{} has no healthy Coordinator subscription for party cleanup", - self.shard_name() - ); - } - Ok(self.party_member_guids()) - } - - fn party_group_ids(&self) -> Result> { - if !self.0.coord().is_healthy() { - anyhow::bail!( - "{} has no healthy Coordinator subscription for party cleanup", - self.shard_name() - ); - } - Ok(self.party_group_ids()) - } - - fn sync_group_mirror(&self, roster: &crate::world::party::GroupRoster) -> Result<()> { - self.sync_group_mirror(roster) - } - - 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) - } - - // --- Realm-wide loot rolls --- - - 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<()> { - self.realm_loot_op( - op, - corpse_guid, - slot, - item_entry, - actor_guid, - vote, - deadline_micros, - recipients, - random_property_id, - promotion_source, - source_roll_id, - ) - } - - fn realm_loot_vote( - &self, - corpse_guid: u64, - slot: u8, - actor_guid: u64, - vote: u8, - ) -> Result { - self.realm_loot_vote(corpse_guid, slot, actor_guid, vote) - } - - fn pending_local_rolls(&self) -> Result> { - self.pending_local_rolls() - } - - fn settle_loot_roll(&self, corpse_guid: u64, slot: u8, winner_guid: u64) -> Result<()> { - self.settle_loot_roll(corpse_guid, slot, winner_guid) - } - - fn clear_promoted_loot_roll(&self, roll_id: u64) -> Result<()> { - self.clear_promoted_loot_roll(roll_id) - } - - fn loot_won_since(&self, after_id: u64) -> Result<(u64, Vec<(u64, u8, u64)>)> { - self.loot_won_since(after_id) - } - fn loot_master_give( - &self, - account_id: u64, - self_guid: u64, - corpse_guid: u64, - loot_slot: u8, - target_guid: u64, - ) -> Result { - self.loot_master_give(account_id, self_guid, corpse_guid, loot_slot, target_guid) - } - fn gossip_select( - &self, - account_id: u64, - self_guid: u64, - npc_guid: u64, - option_id: u32, - option_row_id: u32, - ) -> Result<()> { - self.gossip_select(account_id, self_guid, npc_guid, option_id, option_row_id) - } - fn add_friend( - &self, - account_id: u64, - self_guid: u64, - target_guid: u64, - target_race: u8, - ) -> Result { - self.add_friend(account_id, self_guid, target_guid, target_race) - } - - fn del_friend( - &self, - account_id: u64, - self_guid: u64, - target_guid: u64, - ) -> Result { - self.del_friend(account_id, self_guid, target_guid) - } - - fn add_ignore( - &self, - account_id: u64, - self_guid: u64, - target_guid: u64, - ) -> Result { - self.add_ignore(account_id, self_guid, target_guid) - } - - fn del_ignore( - &self, - account_id: u64, - self_guid: u64, - target_guid: u64, - ) -> Result { - self.del_ignore(account_id, self_guid, target_guid) - } -} /// 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 `WorldStore` impl above. +/// 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 @@ -1789,26 +164,3 @@ impl crate::realm_core::RealmDb for Coordinator { self.record_shard_load(shard, writer_occupancy_pct, sessions, gateway_key) } } - -#[cfg(test)] -mod stable_absence_tests { - #[test] - fn a_transfer_between_durable_scans_cannot_prove_stable_absence() { - use super::{stable_character_absence, CharacterPresenceSnapshot}; - - let snapshot = |generation, revision, present| CharacterPresenceSnapshot { - generation, - revision, - present, - }; - let first = [snapshot(1, 0, false), snapshot(2, 1, false)]; - let destination_visible = [snapshot(1, 1, true), snapshot(2, 1, false)]; - assert!(!stable_character_absence(&first, &destination_visible)); - - let moved_again = [snapshot(1, 2, false), snapshot(2, 2, false)]; - assert!(!stable_character_absence(&first, &moved_again)); - - let unchanged = [snapshot(1, 0, false), snapshot(2, 1, false)]; - assert!(stable_character_absence(&first, &unchanged)); - } -} diff --git a/gateway/src/world/alpha_test_tools_tests.rs b/gateway/src/world/alpha_test_tools_tests.rs index c373982ce..8a69a5f6f 100644 --- a/gateway/src/world/alpha_test_tools_tests.rs +++ b/gateway/src/world/alpha_test_tools_tests.rs @@ -9,13 +9,19 @@ use super::*; fn alpha_test_tools_store( enabled: bool, ) -> ( - std::sync::Arc, + std::sync::Arc, std::sync::Arc, ) { let authority = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(enabled)); - let store = std::sync::Arc::new(InMemoryStore { - gm_alpha_test_tools: Some(authority.clone()), - ..quest_store() + let store = std::sync::Arc::new({ + let base = quest_store(); + WorldFake { + speech: SpeechState { + gm_alpha_test_tools: Some(authority.clone()), + ..base.speech + }, + ..base + } }); (store, authority) } @@ -59,23 +65,23 @@ fn alpha_test_tools_dispatch_speed_and_tele_without_say_chat() { drop(client); server.join().unwrap(); assert_eq!( - store.gm_commands.lock().unwrap().as_slice(), + store.speech.gm_commands.lock().unwrap().as_slice(), &[ ("TESTER".to_string(), ".speed 3".to_string()), ("TESTER".to_string(), ".tele stormwind".to_string()), ] ); assert_eq!( - store.gm_authority_results.lock().unwrap().as_slice(), + store.speech.gm_authority_results.lock().unwrap().as_slice(), &[true, true], "each command carries the Realm-core authority result" ); assert_eq!( - store.gm_gameplay_changes.lock().unwrap().as_slice(), + store.speech.gm_gameplay_changes.lock().unwrap().as_slice(), &[".speed 3".to_string(), ".tele stormwind".to_string()] ); assert!( - store.chats.lock().unwrap().is_empty(), + store.speech.chats.lock().unwrap().is_empty(), "dot-Say commands never create ordinary Say chat" ); } @@ -101,11 +107,11 @@ fn alpha_only_destructive_command_is_private_and_changes_nothing() { drop(client); server.join().unwrap(); assert!( - store.gm_gameplay_changes.lock().unwrap().is_empty(), + store.speech.gm_gameplay_changes.lock().unwrap().is_empty(), "a refused command makes no Home Shard gameplay change" ); assert!( - store.chats.lock().unwrap().is_empty(), + store.speech.chats.lock().unwrap().is_empty(), "a refusal never creates ordinary Say chat" ); } @@ -135,13 +141,13 @@ fn revocation_refuses_the_next_command_without_ending_the_world_session() { drop(client); server.join().unwrap(); assert_eq!( - store.gm_authority_results.lock().unwrap().as_slice(), + store.speech.gm_authority_results.lock().unwrap().as_slice(), &[true, false] ); assert_eq!( - store.gm_gameplay_changes.lock().unwrap().as_slice(), + store.speech.gm_gameplay_changes.lock().unwrap().as_slice(), &[".speed 3".to_string()], "revocation stops the next Home Shard gameplay change" ); - assert!(store.chats.lock().unwrap().is_empty()); + assert!(store.speech.chats.lock().unwrap().is_empty()); } diff --git a/gateway/src/world/auction_tests.rs b/gateway/src/world/auction_tests.rs new file mode 100644 index 000000000..5323be5ba --- /dev/null +++ b/gateway/src/world/auction_tests.rs @@ -0,0 +1,158 @@ +//! The auction house: the views through their dispatcher, and the raw browse path over an +//! encrypted World Session. + +use super::handlers::{store_with, InMemoryAuctionActions}; +use super::*; + +const PLAYER: AuctionActionPlayer = AuctionActionPlayer { self_guid: Some(1) }; + +/// Dispatch one auction message and return the packets the session would send for it. +fn run(actions: &InMemoryAuctionActions, msg: impl Into) -> Vec { + match dispatch_auction_action(actions, PLAYER, msg.into()).unwrap() { + AuctionActionOutcome::Handled { outbound } => outbound, + AuctionActionOutcome::PassThrough(_) => { + panic!("the auction dispatcher passed the message on") + } + } +} + +/// The one typed packet a request answered with. +fn only_message(mut sent: Vec) -> ServerOpcodeMessage { + match (sent.pop(), sent.is_empty()) { + (Some(Outbound::One(message)), true) => message, + _ => panic!("expected exactly one typed packet"), + } +} + +#[test] +fn auction_house_round_trip_stays_typed_over_the_dispatcher() { + let actions = store_with(Some(imported_auction_interaction())); + let auctioneer = Guid::new(42); + + match only_message(run(&actions, MSG_AUCTION_HELLO_Client { auctioneer })) { + ServerOpcodeMessage::MSG_AUCTION_HELLO(message) => { + assert_eq!(message.auctioneer, auctioneer); + assert_eq!(message.auction_house.as_int(), 1); + } + other => panic!("expected auction hello, got {other}"), + } + + let owner = CMSG_AUCTION_LIST_OWNER_ITEMS { + auctioneer, + ..Default::default() + }; + match only_message(run(&actions, owner)) { + ServerOpcodeMessage::SMSG_AUCTION_OWNER_LIST_RESULT(result) => { + assert!(result.auctions.is_empty()); + assert_eq!(result.total_amount_of_auctions, 0); + } + other => panic!("expected empty owner view, got {other}"), + } + + let bidder = CMSG_AUCTION_LIST_BIDDER_ITEMS { + auctioneer, + ..Default::default() + }; + match only_message(run(&actions, bidder)) { + ServerOpcodeMessage::SMSG_AUCTION_BIDDER_LIST_RESULT(result) => { + assert!(result.auctions.is_empty()); + assert_eq!(result.total_amount_of_auctions, 0); + } + other => panic!("expected empty bidder view, got {other}"), + } +} + +#[test] +fn raw_auction_browse_answers_an_empty_page_over_the_encrypted_session() { + // The raw CMSG_AUCTION_LIST_ITEMS path runs before typed dispatch in the session read loop, + // so only a socket reaches it. + let store = std::sync::Arc::new({ + let base = quest_store(); + WorldFake { + auction: AuctionState { + auction_interaction: Some(imported_auction_interaction()), + }, + ..base + } + }); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); + + CMSG_AUCTION_LIST_ITEMS { + auctioneer: Guid::new(42), + ..Default::default() + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_AUCTION_LIST_RESULT(result) => { + assert!(result.auctions.is_empty()); + assert_eq!(result.total_amount_of_auctions, 0); + } + other => panic!("expected empty browse view, got {other}"), + } + + drop(client); + server.join().unwrap(); +} + +#[test] +fn an_unresolved_auctioneer_gets_no_reply() { + // An auctioneer the Module does not resolve (no interaction) gets no reply at all: the refusal + // is silent and the dispatcher returns Ok, so the session keeps serving. + let actions = store_with(None); + let sent = run( + &actions, + MSG_AUCTION_HELLO_Client { + auctioneer: Guid::new(999), + }, + ); + assert!(sent.is_empty()); +} + +#[test] +fn refused_auctioneer_interaction_keeps_the_encrypted_world_session_alive() { + // A resolvable requester with no seeded in-world Characters (`entity_in_world: false` + // overrides `quest_store`'s blanket flag), so the WHO answer this test cares about is the + // empty-but-present reply, not "no answer for an unknown requester" (a different rule, pinned + // in `social.rs`'s own WHO tests). It stays a socket test because the sentinel proves the + // session survives the silent refusal, and nothing else pins that WHO reply. + let store = std::sync::Arc::new({ + let base = quest_store(); + WorldFake { + characters: vec![codec::CharacterView { + guid: 1, + name: "Tester".into(), + race: 1, + ..Default::default() + }], + session: SessionState { + entity_in_world: false, + ..base.session + }, + ..base + } + }); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); + + MSG_AUCTION_HELLO_Client { + auctioneer: Guid::new(999), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + CMSG_WHO::default() + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + + // RAW-encoded (codec::build_who_response_raw); the auction refusal must be silent and leave + // WHO's own empty-roster reply next. + let (opcode, body) = read_raw_frame(&mut client, &mut c_dec); + assert_eq!(opcode, codec::social::SMSG_WHO_OPCODE); + assert_eq!( + &body[0..8], + &[0u8; 8], + "no in-world Characters: listed and online both 0" + ); + + drop(client); + server.join().unwrap(); +} diff --git a/gateway/src/world/bank_tests.rs b/gateway/src/world/bank_tests.rs new file mode 100644 index 000000000..a1fec55fb --- /dev/null +++ b/gateway/src/world/bank_tests.rs @@ -0,0 +1,266 @@ +//! Bank opcodes, run through `handle_bank` against a Fake that holds only the Bank and Npc Stores +//! the handler is bounded on. + +use super::handlers::{handle_bank, BankStore, NpcStore}; +use super::*; +use std::collections::BTreeSet; +use std::sync::Mutex; + +/// A bank that moves an item by its source slot: a carried slot goes into the bank, a banked slot +/// comes back. The Module infers that direction from the slot, so the Fake does too. +#[derive(Default)] +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. + move_error: Option, + /// Refuses `buy_bank_slot` with this text. + purchase_error: Option, + carried: Mutex>, + banked: Mutex>, + bought_slots: Mutex, +} + +impl BankFake { + fn carrying(slot: u8) -> Self { + let bank = Self::default(); + bank.carried.lock().unwrap().insert(slot.into()); + bank + } + + fn banking(slot: u8) -> Self { + let bank = Self::default(); + bank.banked.lock().unwrap().insert(slot.into()); + bank + } + + fn carried(&self) -> Vec { + self.carried.lock().unwrap().iter().copied().collect() + } + + fn banked(&self) -> Vec { + self.banked.lock().unwrap().iter().copied().collect() + } +} + +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}")); + } + let slot = u64::from(slot); + let (mut carried, mut banked) = (self.carried.lock().unwrap(), self.banked.lock().unwrap()); + if carried.remove(&slot) { + banked.insert(slot); + } else if banked.remove(&slot) { + carried.insert(slot); + } + 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}")); + } + if !self.bankers.contains(&banker_guid) { + return Err(anyhow!("[2] target is not a banker")); + } + *self.bought_slots.lock().unwrap() += 1; + Ok(()) + } +} + +/// Send `msg` as Character 1 of account 7 and return what the handler sent, in order. +/// Phase 5 retargets only this helper. +fn run(store: &BankFake, msg: impl Into) -> Vec { + let (tx, rx) = SessionTx::with_depth(0); + let mut conn = in_world_conn(7, 1); + let passed_on = handle_bank(&tx, store, &mut conn, msg.into()).unwrap(); + assert!(passed_on.is_none(), "the bank family owns this opcode"); + drain_outbound(&rx) +} + +fn kinds(sent: &[ServerOpcodeMessage]) -> String { + sent.iter() + .map(ToString::to_string) + .collect::>() + .join(", ") +} + +#[test] +fn banker_activate_sends_smsg_show_bank_with_the_banker_guid() { + let sent = run( + &BankFake::default(), + CMSG_BANKER_ACTIVATE { + guid: Guid::new(77), + }, + ); + assert!( + matches!(sent.as_slice(), [ServerOpcodeMessage::SMSG_SHOW_BANK(p)] if p.guid.guid() == 77), + "expected SMSG_SHOW_BANK for banker 77, got [{}]", + kinds(&sent) + ); +} + +#[test] +fn banker_activate_on_a_standing_refusing_banker_sends_no_reply() { + let store = BankFake { + npc_refuses: true, + ..Default::default() + }; + let sent = run( + &store, + CMSG_BANKER_ACTIVATE { + guid: Guid::new(77), + }, + ); + assert!(sent.is_empty(), "got [{}]", kinds(&sent)); +} + +/// Right-click a bag item with the bank open: the gateway names the source slot and the Module +/// resolves the free bank slot. +#[test] +fn autobank_item_from_the_main_bag_deposits_the_item() { + let store = BankFake::carrying(23); + let sent = run( + &store, + CMSG_AUTOBANK_ITEM { + bag_index: 255, + slot_index: 23, + }, + ); + assert!(sent.is_empty(), "got [{}]", kinds(&sent)); + assert_eq!((store.carried(), store.banked()), (vec![], vec![23])); +} + +/// Right-click a banked item: withdraw, through the same Store method as the deposit. +#[test] +fn autostore_bank_item_from_the_main_bag_withdraws_the_item() { + let store = BankFake::banking(39); + let sent = run( + &store, + CMSG_AUTOSTORE_BANK_ITEM { + bag_index: 255, + slot_index: 39, + }, + ); + assert!(sent.is_empty(), "got [{}]", kinds(&sent)); + assert_eq!((store.carried(), store.banked()), (vec![39], vec![])); +} + +/// A full destination (bank or carry space) is a per-action error. The client gets the existing +/// inventory-change failure, and the session goes on. +#[test] +fn autobank_item_err_sends_smsg_inventory_change_failure() { + let store = BankFake { + move_error: Some("bank full".into()), + ..BankFake::carrying(23) + }; + let sent = run( + &store, + CMSG_AUTOBANK_ITEM { + bag_index: 255, + slot_index: 23, + }, + ); + assert!( + matches!( + sent.as_slice(), + [ServerOpcodeMessage::SMSG_INVENTORY_CHANGE_FAILURE(_)] + ), + "expected SMSG_INVENTORY_CHANGE_FAILURE, got [{}]", + kinds(&sent) + ); + assert_eq!((store.carried(), store.banked()), (vec![23], vec![])); +} + +/// Only the main pseudo-bag (255) is addressed, like the item handler. A sub-bag index is logged +/// and ignored, never fatal. +#[test] +fn autobank_item_from_a_sub_bag_is_unsupported_and_moves_nothing() { + let store = BankFake::carrying(0); + let sent = run( + &store, + CMSG_AUTOBANK_ITEM { + bag_index: 19, + slot_index: 0, + }, + ); + assert!(sent.is_empty(), "got [{}]", kinds(&sent)); + assert_eq!((store.carried(), store.banked()), (vec![0], vec![])); +} + +#[test] +fn autostore_bank_item_from_a_sub_bag_is_unsupported_and_moves_nothing() { + let store = BankFake::banking(0); + let sent = run( + &store, + CMSG_AUTOSTORE_BANK_ITEM { + bag_index: 19, + slot_index: 0, + }, + ); + assert!(sent.is_empty(), "got [{}]", kinds(&sent)); + assert_eq!((store.carried(), store.banked()), (vec![], vec![0])); +} + +/// The Fake accepts a purchase only from a banker it knows, so an `Ok` reply shows the wire's +/// banker guid reached the Store. +#[test] +fn buy_bank_slot_success_sends_ok_and_reaches_the_named_banker() { + let store = BankFake { + bankers: [88].into(), + ..Default::default() + }; + let sent = run( + &store, + CMSG_BUY_BANK_SLOT { + guid: Guid::new(88), + }, + ); + assert!( + matches!( + sent.as_slice(), + [ServerOpcodeMessage::SMSG_BUY_BANK_SLOT_RESULT(p)] if p.result == BuyBankSlotResult::Ok + ), + "expected an Ok slot purchase, got [{}]", + kinds(&sent) + ); + assert_eq!(*store.bought_slots.lock().unwrap(), 1); +} + +/// The Module tags a refusal with its `SMSG_BUY_BANK_SLOT_RESULT` code in brackets. The gateway +/// reads the code, not the prose. +#[test] +fn buy_bank_slot_failure_maps_the_bracketed_code_to_the_matching_result() { + for (err, want) in [ + ( + "[0] no bank bag slots left to buy", + BuyBankSlotResult::FailedTooMany, + ), + ( + "[1] not enough money (need 1000)", + BuyBankSlotResult::InsufficientFunds, + ), + ("[2] target is not a banker", BuyBankSlotResult::NotBanker), + ] { + let store = BankFake { + purchase_error: Some(err.into()), + ..Default::default() + }; + let sent = run( + &store, + CMSG_BUY_BANK_SLOT { + guid: Guid::new(88), + }, + ); + let [ServerOpcodeMessage::SMSG_BUY_BANK_SLOT_RESULT(p)] = sent.as_slice() else { + panic!("expected SMSG_BUY_BANK_SLOT_RESULT, got [{}]", kinds(&sent)); + }; + assert_eq!(p.result, want, "store error {err:?} must map to {want:?}"); + assert_eq!(*store.bought_slots.lock().unwrap(), 0); + } +} diff --git a/gateway/src/world/cast_tests.rs b/gateway/src/world/cast_tests.rs new file mode 100644 index 000000000..d069ff86e --- /dev/null +++ b/gateway/src/world/cast_tests.rs @@ -0,0 +1,237 @@ +//! Cast opcodes through their dispatcher. Route selection, target decoding and durable dispatch +//! are covered per route by the focused seam tests in `handlers/cast`; these tests pin what the +//! session sees: the ordered batch, the ranged auto-repeat state it carries between casts, and +//! the effect of each cancellation. + +use super::handlers::InMemoryCasts; +use super::*; +use std::sync::Mutex; + +/// `SMSG_CAST_RESULT`, a raw frame. The synchronous ORDER across raw and typed packets is the +/// contract under test. +const OP_CAST_RESULT: u16 = 0x0130; + +/// What the session keeps between two casts. +struct Caster { + self_guid: Option, + ranged_repeat: bool, +} + +impl Caster { + fn in_world() -> Self { + Self { + self_guid: Some(1), + ranged_repeat: false, + } + } + + fn at_character_select() -> Self { + Self { + self_guid: None, + ranged_repeat: false, + } + } +} + +/// Dispatch one cast message, apply the session transition it asks for, and return the packets +/// the session would send for it. +fn run( + store: &InMemoryCasts, + caster: &mut Caster, + msg: impl Into, +) -> Vec { + let player = CastPlayer { + account_id: 7, + self_guid: caster.self_guid, + ranged_repeat: caster.ranged_repeat, + }; + match dispatch_cast(store, player, msg.into()).unwrap() { + CastOutcome::Handled { + transition, + outbound, + } => { + if let Some(armed) = transition.ranged_repeat { + caster.ranged_repeat = armed; + } + outbound + } + CastOutcome::PassThrough(_) => panic!("the cast dispatcher passed the message on"), + } +} + +/// `SpellCastTargets` carrying a UNIT target (the client's selected mob). +fn unit_targets(guid: u64) -> SpellCastTargets { + SpellCastTargets { + target_flags: SpellCastTargets_SpellCastTargetFlags::new_unit( + SpellCastTargets_SpellCastTargetFlags_Unit { + unit_target: Guid::new(guid), + }, + ), + } +} + +#[test] +fn instant_cast_sends_start_then_raw_cast_result_ok_then_go_and_threads_the_target() { + // Root-cause client-wedge fix: an INSTANT cast must emit START(0) -> raw CAST_RESULT(OK, + // opcode 0x0130, 5-byte body) -> GO, IN THAT ORDER. That order is a real invariant: the 5875 + // client needs the OK ack between START and GO to clear its cast slot. The unit target must + // reach the cast, which shows in the GO's hit list. + let store = InMemoryCasts::instant(); + let sent = run( + &store, + &mut Caster::in_world(), + CMSG_CAST_SPELL { + spell: 100, + targets: unit_targets(77), + }, + ); + + let mut want = 100u32.to_le_bytes().to_vec(); + want.push(0x00); // SPELL_RESULT_STATUS_OKAY: 5 bytes, NO trailing reason byte + match sent.as_slice() { + [Outbound::One(ServerOpcodeMessage::SMSG_SPELL_START(_)), Outbound::Raw { + opcode: OP_CAST_RESULT, + body, + }, Outbound::One(ServerOpcodeMessage::SMSG_SPELL_GO(go))] => { + assert_eq!( + *body, want, + "CAST_RESULT body is spell_id(u32 LE) + OKAY(0x00)" + ); + let hits: Vec = go.hits.iter().map(|g| g.guid()).collect(); + assert_eq!(hits, [77], "the client's unit target is the GO's hit"); + } + _ => panic!( + "expected START, raw CAST_RESULT, GO; got {} packets", + sent.len() + ), + } +} + +#[test] +fn auto_shot_intercept_starts_the_ranged_attack_instead_of_casting() { + // Vanilla shape: Auto Shot (75) and wand Shoot (5019) are auto-repeat ranged attacks. The + // activation arms the loop with the cast's unit target, then the ack is SMSG_SPELL_START ALONE + // with timer 0 (no CAST_RESULT, no GO: the cast parks in the client's AUTOREPEAT slot and + // never resolves; each shot's GO comes from the swing-tick relay). The ordinary cast route + // would send all three packets, so a lone START also shows `cast_spell` did not run. + for spell in [75u32, 5019] { + let store = InMemoryCasts { + ranged_auto_repeat: vec![75, 5019], + ..InMemoryCasts::instant() + }; + let mut caster = Caster::in_world(); + let sent = run( + &store, + &mut caster, + CMSG_CAST_SPELL { + spell, + targets: unit_targets(88), + }, + ); + match sent.as_slice() { + [Outbound::One(ServerOpcodeMessage::SMSG_SPELL_START(start))] => { + assert_eq!(start.spell, spell, "spell id in START"); + assert_eq!( + start.timer, 0, + "spell {spell}: START timer must be 0 (the 0.5s wind-up is an attack-timer, not a cast bar)" + ); + let target = start + .targets + .target_flags + .get_unit() + .map(|u| u.unit_target.guid()); + assert_eq!( + target, + Some(88), + "spell {spell}: START names the unit target" + ); + } + _ => panic!( + "spell {spell}: expected the activation START alone, got {} packets", + sent.len() + ), + } + assert!( + caster.ranged_repeat, + "spell {spell}: the session arms the loop" + ); + assert_eq!( + store.engaged.lock().unwrap().as_slice(), + &[1], + "spell {spell}: the durable loop engages the caster" + ); + } +} + +#[test] +fn a_cast_before_entering_the_world_answers_nothing_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. + let store = InMemoryCasts::instant(); + let sent = run( + &store, + &mut Caster::at_character_select(), + CMSG_CAST_SPELL { + spell: 100, + targets: unit_targets(77), + }, + ); + assert!(sent.is_empty()); +} + +#[test] +fn cancelling_auto_repeat_still_tears_the_ranged_loop_down_through_stop_attack() { + // The cancel tears the loop down only when one is armed (the `was_repeat` gate). Arm Auto + // Shot, then cancel, and check that the durable engagement for this attacker is gone. + let store = InMemoryCasts { + ranged_auto_repeat: vec![75], + ..InMemoryCasts::instant() + }; + let mut caster = Caster::in_world(); + run( + &store, + &mut caster, + CMSG_CAST_SPELL { + spell: 75, + targets: unit_targets(88), + }, + ); + assert_eq!(store.engaged.lock().unwrap().as_slice(), &[1]); + + // The cancel sends no ack of its own (the on_delete relay does). + let sent = run(&store, &mut caster, CMSG_CANCEL_AUTO_REPEAT_SPELL {}); + assert!(sent.is_empty()); + assert!(!caster.ranged_repeat); + assert!(store.engaged.lock().unwrap().is_empty()); +} + +#[test] +fn cancel_aura_removes_the_aura_the_wire_spell_id_names() { + let store = InMemoryCasts { + auras: Mutex::new(vec![5555, 6000]), + ..Default::default() + }; + let sent = run( + &store, + &mut Caster::in_world(), + CMSG_CANCEL_AURA { id: 5555 }, + ); + assert!(sent.is_empty()); + assert_eq!(store.auras.lock().unwrap().as_slice(), &[6000]); +} + +#[test] +fn cancel_cast_drops_the_pending_cast_of_the_caller_only() { + let store = InMemoryCasts { + pending_casts: Mutex::new(vec![1, 2]), + ..Default::default() + }; + let sent = run( + &store, + &mut Caster::in_world(), + CMSG_CANCEL_CAST { id: 133 }, + ); + assert!(sent.is_empty()); + assert_eq!(store.pending_casts.lock().unwrap().as_slice(), &[2]); +} diff --git a/gateway/src/world/character_tests.rs b/gateway/src/world/character_tests.rs new file mode 100644 index 000000000..e1f18e5e3 --- /dev/null +++ b/gateway/src/world/character_tests.rs @@ -0,0 +1,205 @@ +//! Character select over an encrypted World Session: enumerate, create and delete. + +use super::*; + +#[test] +fn char_enum_returns_the_seeded_character() { + // The pre-seeded Human Warrior "Tester" must appear on the character-select screen. + let tester = codec::CharacterView { + guid: 1, + name: "Tester".into(), + race: 1, // Human + class: 1, // Warrior + gender: 0, // Male + level: 1, + map_id: 0, // Eastern Kingdoms + zone_id: 12, // Elwynn Forest + x: -8949.95, + y: -132.493, + z: 83.5312, + first_login: true, + ..Default::default() + }; + let store = std::sync::Arc::new({ + let base = tester_store(7); + WorldFake { + characters: vec![tester], + ..base + } + }); + + 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()).unwrap(); + }); + + // Full handshake, then request the character list over the encrypted channel. + let (mut c_enc, mut c_dec) = client_handshake(&mut client, "TESTER", K); + CMSG_CHAR_ENUM {} + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_CHAR_ENUM(e) => { + assert_eq!(e.characters.len(), 1); + let ch = &e.characters[0]; + assert_eq!(ch.name, "Tester"); + assert_eq!(ch.guid.guid(), 1); + assert_eq!(ch.race, Race::Human); + assert_eq!(ch.class, Class::Warrior); + assert_eq!(ch.level, Level::new(1)); + assert_eq!(ch.map, Map::EasternKingdoms); + } + other => panic!("expected SMSG_CHAR_ENUM, got {other}"), + } + + drop(client); + server.join().unwrap(); +} + +#[test] +fn char_create_replies_success_then_name_in_use() { + // The fake store reports a name already among its characters as in-use, else success. + let tester = codec::CharacterView { + guid: 1, + name: "Tester".into(), + race: 1, + class: 1, + level: 1, + ..Default::default() + }; + let store = std::sync::Arc::new({ + let base = tester_store(7); + WorldFake { + characters: vec![tester], + ..base + } + }); + 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()).unwrap(); + }); + + let (mut c_enc, mut c_dec) = client_handshake(&mut client, "TESTER", K); + let mk = |name: &str| CMSG_CHAR_CREATE { + name: name.to_string(), + race: Race::Human, + class: Class::Warrior, + gender: Gender::Male, + skin_color: 0, + face: 0, + hair_style: 0, + hair_color: 0, + facial_hair: 0, + }; + + // A fresh name → CharCreateSuccess. + mk("Newbie") + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_CHAR_CREATE(m) => { + assert_eq!(m.result, WorldResult::CharCreateSuccess) + } + other => panic!("expected SMSG_CHAR_CREATE, got {other}"), + } + + // An existing name → CharCreateNameInUse, and the session stays alive. + mk("Tester") + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_CHAR_CREATE(m) => { + assert_eq!(m.result, WorldResult::CharCreateNameInUse) + } + other => panic!("expected SMSG_CHAR_CREATE, got {other}"), + } + + drop(client); + server.join().unwrap(); +} + +#[test] +fn char_delete_replies_success_and_dispatches_owned_guid() { + let tester = codec::CharacterView { + guid: 5, + name: "Tester".into(), + race: 1, + class: 1, + level: 1, + ..Default::default() + }; + let store = std::sync::Arc::new({ + let base = tester_store(7); + WorldFake { + characters: vec![tester], + ..base + } + }); + 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()).unwrap(); + }); + + let (mut c_enc, mut c_dec) = client_handshake(&mut client, "TESTER", K); + CMSG_CHAR_DELETE { guid: Guid::new(5) } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_CHAR_DELETE(m) => { + assert_eq!(m.result, WorldResult::CharDeleteSuccess) + } + other => panic!("expected SMSG_CHAR_DELETE, got {other}"), + } + assert_eq!(*store.character.deleted.lock().unwrap(), vec![(7, 5)]); + + drop(client); + server.join().unwrap(); +} + +#[test] +fn char_delete_failure_replies_failed_and_keeps_session_alive() { + let store = std::sync::Arc::new({ + let base = tester_store(7); + WorldFake { + character: CharacterState { + delete_outcome: Some(codec::CharDeleteOutcome::Failed), + ..base.character + }, + ..base + } + }); + 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()).unwrap(); + }); + + let (mut c_enc, mut c_dec) = client_handshake(&mut client, "TESTER", K); + CMSG_CHAR_DELETE { + guid: Guid::new(999), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_CHAR_DELETE(m) => { + assert_eq!(m.result, WorldResult::CharDeleteFailed) + } + other => panic!("expected SMSG_CHAR_DELETE, got {other}"), + } + + // Session should still be alive: CMSG_CHAR_ENUM gets a normal reply, not a dropped socket. + CMSG_CHAR_ENUM {} + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_CHAR_ENUM(_) => {} + other => panic!("expected SMSG_CHAR_ENUM, got {other}"), + } + + drop(client); + server.join().unwrap(); +} diff --git a/gateway/src/world/chat_tests.rs b/gateway/src/world/chat_tests.rs new file mode 100644 index 000000000..41d8d298c --- /dev/null +++ b/gateway/src/world/chat_tests.rs @@ -0,0 +1,278 @@ +//! Chat lines over an encrypted World Session: Speech, the GM dot-command and the Chat Flood +//! Limiter. Realm Chat Lines and away status are tested against `InMemoryChatActions` in +//! `handlers/chat.rs`. + +use super::*; + +#[test] +fn messagechat_say_and_yell_route_to_chat_types_0_and_1() { + let store = std::sync::Arc::new(quest_store()); + let (mut client, mut c_enc, _c_dec, server) = enter_world(store.clone(), 1); + CMSG_MESSAGECHAT { + chat_type: CMSG_MESSAGECHAT_ChatType::Say, + language: Language::Universal, + message: "hi".into(), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + CMSG_MESSAGECHAT { + chat_type: CMSG_MESSAGECHAT_ChatType::Yell, + language: Language::Universal, + message: "HEY".into(), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + drop(client); // no reply on success — the speaker sees their line via the broadcast relay + server.join().unwrap(); + assert_eq!( + store.speech.chats.lock().unwrap().as_slice(), + &[(0, 0, "hi".to_string()), (1, 0, "HEY".to_string())], + "Say → type 0, Yell → type 1, language threaded" + ); +} + +#[test] +fn messagechat_emote_routes_to_chat_type_3() { + let store = std::sync::Arc::new(quest_store()); + let (mut client, mut c_enc, _c_dec, server) = enter_world(store.clone(), 1); + CMSG_MESSAGECHAT { + chat_type: CMSG_MESSAGECHAT_ChatType::Emote, + language: Language::Common, + message: "waves wildly.".into(), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + drop(client); // no reply on success — the speaker sees their line via the broadcast relay + server.join().unwrap(); + assert_eq!( + store.speech.chats.lock().unwrap().as_slice(), + &[(3, 7, "waves wildly.".to_string())], + "/e → type 3, language threaded to the Module (which stores it as Universal)" + ); +} + +#[test] +fn messagechat_dot_say_diverts_to_gm_command_never_touching_chat() { + let store = std::sync::Arc::new(quest_store()); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); + CMSG_MESSAGECHAT { + chat_type: CMSG_MESSAGECHAT_ChatType::Say, + language: Language::Universal, + message: ".heal".into(), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + CMSG_QUESTGIVER_STATUS_QUERY { + guid: Guid::new(50), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_QUESTGIVER_STATUS(_) => {} // nothing was sent for a successful dot-command + other => panic!("expected the sentinel (no reply on gm_command success), got {other}"), + } + drop(client); + server.join().unwrap(); + assert_eq!( + store.speech.gm_commands.lock().unwrap().as_slice(), + &[("TESTER".to_string(), ".heal".to_string())], + "the proof-validated Account name and raw dot-command must reach the Store together" + ); + assert!( + store.speech.chats.lock().unwrap().is_empty(), + "a dot-command must NEVER reach send_chat" + ); +} + +#[test] +fn messagechat_non_dot_say_is_byte_identical_to_before_223() { + // The 223 divert must be a no-op for ordinary chat: a Say line NOT starting with '.' still routes + // to send_chat exactly as before, and never touches gm_command. + let store = std::sync::Arc::new(quest_store()); + let (mut client, mut c_enc, _c_dec, server) = enter_world(store.clone(), 1); + CMSG_MESSAGECHAT { + chat_type: CMSG_MESSAGECHAT_ChatType::Say, + language: Language::Universal, + message: "hi".into(), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + drop(client); + server.join().unwrap(); + assert_eq!( + store.speech.chats.lock().unwrap().as_slice(), + &[(0u8, 0u8, "hi".to_string())] + ); + assert!( + store.speech.gm_commands.lock().unwrap().is_empty(), + "a plain Say line must never reach gm_command" + ); +} + +#[test] +fn messagechat_dot_say_error_relays_a_system_chat_line_to_the_sender_only() { + // A rejected dot-command (bad gm_level, unknown command, bad args) is relayed back + // 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()); + let store = std::sync::Arc::new(s); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); + CMSG_MESSAGECHAT { + chat_type: CMSG_MESSAGECHAT_ChatType::Say, + language: Language::Universal, + message: ".god".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, "permission denied"); + assert!( + matches!( + m.chat_type, + wow_world_messages::vanilla::SMSG_MESSAGECHAT_ChatType::System { .. } + ), + "expected a System chat line, got {:?}", + m.chat_type + ); + } + other => panic!("expected SMSG_MESSAGECHAT System, got {other}"), + } + drop(client); + server.join().unwrap(); + assert!(store.speech.chats.lock().unwrap().is_empty()); +} + +/// Character 1 in the world, with a Character row so `sync`'s sentinel is answered. +fn chat_store() -> WorldFake { + let base = quest_store(); + WorldFake { + chat: ChatState { + speaker_facts: Some(human_speaker()), + ..base.chat + }, + characters: vec![codec::CharacterView { + guid: 1, + name: "Tester".into(), + ..Default::default() + }], + ..base + } +} + +fn say_line(message: &str) -> CMSG_MESSAGECHAT { + CMSG_MESSAGECHAT { + chat_type: CMSG_MESSAGECHAT_ChatType::Say, + language: Language::Common, + message: message.into(), + } +} + +fn notification(message: ServerOpcodeMessage) -> String { + match message { + ServerOpcodeMessage::SMSG_NOTIFICATION(notice) => notice.notification, + other => panic!("expected SMSG_NOTIFICATION, got {other}"), + } +} + +#[test] +fn a_flooding_session_is_muted_before_any_chat_durable_request() { + // cm:Player.cpp:16344-16377: eleven fast lines mute the speaker for ten seconds. The twelfth + // line and the party line after it answer the cmangos notice and reach no reducer. + let store = std::sync::Arc::new(chat_store()); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); + for n in 0..12 { + say_line(&format!("line {n}")) + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + } + CMSG_MESSAGECHAT { + chat_type: CMSG_MESSAGECHAT_ChatType::Party, + language: Language::Common, + message: "form up".into(), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + for _ in 0..2 { + assert_eq!( + notification(ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap()), + "You must wait 10 Second(s). before speaking again." + ); + } + sync(&mut client, &mut c_enc, &mut c_dec, |_, _| {}); + drop(client); + server.join().unwrap(); + assert_eq!(store.speech.chats.lock().unwrap().len(), 11); + assert!(store.chat.realm_chats.lock().unwrap().is_empty()); +} + +#[test] +fn a_muted_session_still_sends_addon_lines() { + // cm:ChatHandler.cpp:113-119: the addon language is never flood-controlled. + let store = std::sync::Arc::new(chat_store()); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); + for n in 0..12 { + say_line(&format!("line {n}")) + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + } + CMSG_MESSAGECHAT { + chat_type: CMSG_MESSAGECHAT_ChatType::Guild, + language: Language::Addon, + message: "LCTEST\tping".into(), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + notification(ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap()); + sync(&mut client, &mut c_enc, &mut c_dec, |_, _| {}); + drop(client); + server.join().unwrap(); + let requests = store.chat.realm_chats.lock().unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].1.language, 0xFFFF_FFFF); +} + +#[test] +fn a_game_master_is_never_muted_for_flooding() { + // cm:Player.cpp:16346-16348 skips the flood count for any account above SEC_PLAYER. + let mut s = chat_store(); + s.chat.gm_level = 1; + let store = std::sync::Arc::new(s); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); + sync(&mut client, &mut c_enc, &mut c_dec, |c, e| { + for n in 0..15 { + say_line(&format!("line {n}")) + .write_encrypted_client(&mut *c, &mut *e) + .unwrap(); + } + }); + drop(client); + server.join().unwrap(); + assert_eq!(store.speech.chats.lock().unwrap().len(), 15); +} + +#[test] +fn a_say_line_in_a_language_the_speaker_does_not_know_answers_the_vanilla_notice() { + // cm:ChatHandler.cpp:107-110 with cm mangos.sql:4044. + let mut s = chat_store(); + s.speech.send_chat_outcome = Some(ChatOutcome::Refused( + lyracore_shared::chat::ChatRefusal::UnknownLanguage, + )); + let store = std::sync::Arc::new(s); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); + CMSG_MESSAGECHAT { + chat_type: CMSG_MESSAGECHAT_ChatType::Say, + language: Language::Orcish, + message: "zug zug".into(), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + assert_eq!( + notification(ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap()), + "You don't know that language" + ); + sync(&mut client, &mut c_enc, &mut c_dec, |_, _| {}); + drop(client); + server.join().unwrap(); +} diff --git a/gateway/src/world/coalesce.rs b/gateway/src/world/coalesce.rs index 13c1f6a38..da7cb36ac 100644 --- a/gateway/src/world/coalesce.rs +++ b/gateway/src/world/coalesce.rs @@ -66,7 +66,7 @@ impl CoalesceState { } /// Production entry point: classify + coalesce one inbound movement packet against the real - /// clock. Returns the packets to actually forward to `WorldStore::movement_update`, IN ORDER + /// clock. Returns the packets to actually forward to `SessionStore::movement_update`, IN ORDER /// (0, 1, or 2 items — a flushed pending heartbeat followed by a state change, when both fire /// in the same call; ordering matters for peer extrapolation, rule 2b). pub fn on_movement_now( diff --git a/gateway/src/world/combat_tests.rs b/gateway/src/world/combat_tests.rs new file mode 100644 index 000000000..90b930314 --- /dev/null +++ b/gateway/src/world/combat_tests.rs @@ -0,0 +1,82 @@ +//! Targeting and sheathing, run through `handle_combat` against a Fake that holds only the Combat +//! Store the handler is bounded on. + +use super::handlers::{handle_combat, CombatStore}; +use super::*; +use std::collections::BTreeMap; +use std::sync::Mutex; + +/// What each Character has selected and how it carries its weapons. +#[derive(Default)] +struct CombatFake { + selected: Mutex>, + sheath: Mutex>, +} + +impl CombatFake { + fn target_of(&self, character: u64) -> Option { + self.selected.lock().unwrap().get(&character).copied() + } + + fn sheath_of(&self, character: u64) -> Option { + self.sheath.lock().unwrap().get(&character).copied() + } +} + +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); + Ok(()) + } + + fn pet_command( + &self, + _account_id: u64, + _self_guid: u64, + _data: u32, + _target_guid: u64, + ) -> Result<()> { + unimplemented!("pet_command") + } + + fn set_sheathed(&self, _account_id: u64, self_guid: u64, state: u8) -> Result<()> { + self.sheath.lock().unwrap().insert(self_guid, state); + Ok(()) + } +} + +/// Send `msg` as Character 1 of account 7. Phase 5 retargets only this helper. +fn run(store: &CombatFake, msg: impl Into) { + let mut conn = in_world_conn(7, 1); + let passed_on = handle_combat(store, &mut conn, msg.into()).unwrap(); + assert!(passed_on.is_none(), "the combat family owns this opcode"); +} + +#[test] +fn set_sheathed_routes_the_clients_z_press_to_the_store() { + for (sent, expect) in [ + (SheathState::Unarmed, 0u8), + (SheathState::Melee, 1), + (SheathState::Ranged, 2), + ] { + let store = CombatFake::default(); + run(&store, CMSG_SETSHEATHED { sheathed: sent }); + assert_eq!( + store.sheath_of(1), + Some(expect), + "{sent:?} must reach the Store as byte {expect}" + ); + } +} + +#[test] +fn set_selection_records_the_wire_guid_as_the_target() { + let store = CombatFake::default(); + run( + &store, + CMSG_SET_SELECTION { + target: Guid::new(321), + }, + ); + assert_eq!(store.target_of(1), Some(321)); +} diff --git a/gateway/src/world/death_tests.rs b/gateway/src/world/death_tests.rs new file mode 100644 index 000000000..39e4700df --- /dev/null +++ b/gateway/src/world/death_tests.rs @@ -0,0 +1,174 @@ +//! Death and resurrection requests, handled against a Fake that models only who is alive. + +use super::*; +use handle_loot_fake::{HandleLootFake, Life}; + +#[path = "handle_loot_fake.rs"] +pub(crate) mod handle_loot_fake; + +pub(crate) const SELF_GUID: u64 = 1; + +/// A World Session connection in the world as `SELF_GUID`. `handle_loot` never reads the +/// connection's Store, but `WorldConn` needs one. +pub(crate) fn in_world_conn() -> WorldConn { + let (_, crypto) = ProofSeed::new().into_client_header_crypto(&ns("TESTER"), K, 0); + let (_, decrypt) = crypto.split(); + WorldConn { + session_claim: None, + account_id: 7, + account_name: "TESTER".into(), + decrypt, + state: WorldState::InWorld(InWorld { + self_guid: SELF_GUID, + subs: PlayerSubscriptions::empty(), + attacking_target: None, + open_loot: OpenLootState::default(), + ranged_repeat: false, + }), + move_coalesce: Default::default(), + gossip_menu: None, + store: RoutedStore::new(std::sync::Arc::new(WorldFake::default())), + session_key: None, + guild_signed_on: None, + move_desync_drops: 0, + who_throttled_until: None, + group_broadcast_cooldowns: Default::default(), + chat_flood: Default::default(), + } +} + +/// Handle one request and return what the client is sent. Fails when the handler ends the session +/// or passes the request on. +pub(crate) fn run( + store: &HandleLootFake, + conn: &mut WorldConn, + msg: ClientOpcodeMessage, +) -> Vec { + let (tx, rx) = SessionTx::with_depth(0); + let passed_on = handle_loot(&tx, store, conn, msg).expect("the request ends the session"); + assert!(passed_on.is_none(), "the request was passed on"); + drop(tx); + rx.try_iter().collect() +} + +#[test] +fn repop_revives_the_caller_and_no_one_else() { + let store = HandleLootFake::default() + .with_life(SELF_GUID, Life::Dead) + .with_life(2, Life::Dead); + + // The revive reaches the client through the entity relay, not as a direct reply. + let sent = run( + &store, + &mut in_world_conn(), + ClientOpcodeMessage::CMSG_REPOP_REQUEST, + ); + + assert!(sent.is_empty()); + assert_eq!(store.life(SELF_GUID), Life::Alive); + assert_eq!(store.life(2), Life::Dead); +} + +#[test] +fn reclaim_corpse_uses_the_corpse_guid_from_the_wire() { + let store = HandleLootFake::default() + .with_life(SELF_GUID, Life::Ghost) + .with_corpse(SELF_GUID, 777); + let mut conn = in_world_conn(); + let reclaim = |guid| { + ClientOpcodeMessage::CMSG_RECLAIM_CORPSE(CMSG_RECLAIM_CORPSE { + guid: Guid::new(guid), + }) + }; + + run(&store, &mut conn, reclaim(778)); + assert_eq!(store.life(SELF_GUID), Life::Ghost, "another corpse"); + + run(&store, &mut conn, reclaim(777)); + assert_eq!(store.life(SELF_GUID), Life::Alive); +} + +#[test] +fn accepting_a_resurrect_offer_revives_the_caller() { + let store = HandleLootFake::default() + .with_life(SELF_GUID, Life::Dead) + .with_res_offer(SELF_GUID); + + run(&store, &mut in_world_conn(), resurrect_response(1)); + + assert_eq!(store.life(SELF_GUID), Life::Alive); +} + +#[test] +fn declining_a_resurrect_offer_leaves_the_caller_dead_and_spends_the_offer() { + let store = HandleLootFake::default() + .with_life(SELF_GUID, Life::Dead) + .with_res_offer(SELF_GUID); + + run(&store, &mut in_world_conn(), resurrect_response(0)); + + assert_eq!(store.life(SELF_GUID), Life::Dead); + assert!(!store.has_res_offer(SELF_GUID)); +} + +#[test] +fn self_res_revives_the_caller_and_spends_the_option() { + let store = HandleLootFake::default() + .with_life(SELF_GUID, Life::Dead) + .with_self_res_option(SELF_GUID); + + // The revive reaches the client through the entity relay, not as a direct reply. + let sent = run( + &store, + &mut in_world_conn(), + ClientOpcodeMessage::CMSG_SELF_RES, + ); + + assert!(sent.is_empty()); + assert_eq!(store.life(SELF_GUID), Life::Alive); + assert!(!store.has_self_res_option(SELF_GUID)); +} + +#[test] +fn a_refused_self_res_sends_nothing_and_keeps_the_session() { + let store = HandleLootFake::default().with_life(SELF_GUID, Life::Dead); + + // `run` fails the test when the handler ends the session. + let sent = run( + &store, + &mut in_world_conn(), + ClientOpcodeMessage::CMSG_SELF_RES, + ); + + assert!(sent.is_empty()); + assert_eq!(store.life(SELF_GUID), Life::Dead); +} + +#[test] +fn spirit_healer_revives_the_ghost_and_confirms_the_healer_guid() { + let store = HandleLootFake::default() + .with_life(SELF_GUID, Life::Ghost) + .with_spirit_healer(888); + + let sent = run( + &store, + &mut in_world_conn(), + ClientOpcodeMessage::CMSG_SPIRIT_HEALER_ACTIVATE(CMSG_SPIRIT_HEALER_ACTIVATE { + guid: Guid::new(888), + }), + ); + + let [Outbound::One(ServerOpcodeMessage::SMSG_SPIRIT_HEALER_CONFIRM(confirm))] = sent.as_slice() + else { + panic!("expected one SMSG_SPIRIT_HEALER_CONFIRM"); + }; + assert_eq!(confirm.guid, Guid::new(888), "echoes the healer's own guid"); + assert_eq!(store.life(SELF_GUID), Life::Alive); +} + +fn resurrect_response(status: u8) -> ClientOpcodeMessage { + ClientOpcodeMessage::CMSG_RESURRECT_RESPONSE(Box::new(CMSG_RESURRECT_RESPONSE { + guid: Guid::new(42), + status, + })) +} diff --git a/gateway/src/world/framing_tests.rs b/gateway/src/world/framing_tests.rs index c32c0bda8..367d94cc2 100644 --- a/gateway/src/world/framing_tests.rs +++ b/gateway/src/world/framing_tests.rs @@ -1,7 +1,7 @@ //! The INBOUND framing boundary: what an authenticated socket does with a header or a //! body it cannot make sense of. //! -//! Everything else in `world/tests.rs` sends well-formed `wow_world_messages` types, so the read +//! Every other World Session socket test sends well-formed `wow_world_messages` types, so the read //! loop in `run_world_session_with_queue` (`world/mod.rs`) is only ever exercised on its happy path. //! That loop is nonetheless the first thing a hostile or buggy client reaches after the handshake, //! and it makes four separate decisions no typed test can reach: @@ -30,13 +30,16 @@ use std::os::unix::net::UnixStream; /// The store every test here uses: one account, session key [`K`], nothing else configured. The /// frames below never reach a handler, so no fixture beyond the handshake is needed. -fn framing_store() -> std::sync::Arc { - std::sync::Arc::new(InMemoryStore { - username: "TESTER".into(), - session: Some(WorldSession { - account_id: 42, - session_key: K, - }), +fn framing_store() -> std::sync::Arc { + std::sync::Arc::new(WorldFake { + session: SessionState { + username: "TESTER".into(), + session: Some(WorldSession { + account_id: 42, + session_key: K, + }), + ..Default::default() + }, ..Default::default() }) } @@ -50,7 +53,7 @@ fn framing_session() -> ( ) { let store = framing_store(); let (mut client, server_end) = world_session_socket_pair(); - let server = std::thread::spawn(move || run_world_session(server_end, store.as_ref())); + let server = std::thread::spawn(move || run_world_session(server_end, store.clone())); let (c_enc, c_dec) = client_handshake(&mut client, "TESTER", K); (client, c_enc, c_dec, server) } @@ -284,7 +287,7 @@ fn a_session_ending_in_a_framing_error_still_gives_its_seat_back() { let (mut client, server_end) = world_session_socket_pair(); let server_queue = queue.clone(); let server = std::thread::spawn(move || { - run_world_session_with_queue(server_end, store.as_ref(), &server_queue) + run_world_session_with_queue(server_end, store.clone(), &server_queue) }); let (mut c_enc, _c_dec) = client_handshake(&mut client, "TESTER", K); assert_eq!( @@ -332,7 +335,7 @@ fn a_session_that_dies_in_world_still_deletes_the_players_entity() { let server_store = store.clone(); let server_queue = queue.clone(); let server = std::thread::spawn(move || { - run_world_session_with_queue(server_end, server_store.as_ref(), &server_queue) + run_world_session_with_queue(server_end, server_store.clone(), &server_queue) }); let (mut c_enc, mut c_dec) = client_handshake(&mut client, "TESTER", K); @@ -346,6 +349,7 @@ fn a_session_that_dies_in_world_still_deletes_the_players_entity() { } assert!( !store + .session .logout_called .load(std::sync::atomic::Ordering::SeqCst), "precondition: the player is in the world and has not logged out" @@ -363,6 +367,7 @@ fn a_session_that_dies_in_world_still_deletes_the_players_entity() { assert!( store + .session .logout_called .load(std::sync::atomic::Ordering::SeqCst), "an in-world session that ends on an error must still run `logout` — otherwise the \ @@ -382,7 +387,7 @@ fn a_session_that_dies_in_world_still_deletes_the_players_entity() { fn a_malformed_addon_envelope_is_dropped_and_the_session_keeps_serving() { let store = framing_store(); let (mut client, server_end) = world_session_socket_pair(); - let server = std::thread::spawn(move || run_world_session(server_end, store.as_ref())); + let server = std::thread::spawn(move || run_world_session(server_end, store.clone())); let (mut c_enc, mut c_dec) = client_handshake(&mut client, "TESTER", K); // A well-FORMED addon chat frame carrying a body the `STC` envelope parser rejects. @@ -419,9 +424,15 @@ fn a_malformed_addon_envelope_is_dropped_and_the_session_keeps_serving() { #[test] fn an_authenticated_stc_order_uses_the_logged_in_actor_and_keeps_the_session_live() { - let store = std::sync::Arc::new(InMemoryStore { - login_entity: Some(warrior_entity()), - ..tester_store(42) + let store = std::sync::Arc::new({ + let base = tester_store(42); + WorldFake { + session: SessionState { + login_entity: Some(warrior_entity()), + ..base.session + }, + ..base + } }); let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); @@ -451,7 +462,7 @@ fn an_authenticated_stc_order_uses_the_logged_in_actor_and_keeps_the_session_liv ServerOpcodeMessage::SMSG_CHAR_ENUM(_) )); assert_eq!( - *store.client_commands.lock().unwrap(), + *store.session.client_commands.lock().unwrap(), vec![ (42, 1, "example.order".into(), "follow|77".into()), (42, 1, "example.order".into(), "follow|77".into()), diff --git a/gateway/src/world/guild_tests.rs b/gateway/src/world/guild_tests.rs new file mode 100644 index 000000000..907143993 --- /dev/null +++ b/gateway/src/world/guild_tests.rs @@ -0,0 +1,362 @@ +//! Guild session facts over an encrypted World Session: sign-on at world entry, sign-off at +//! logout, and the guild cleanup a Character delete triggers. Guild opcode behaviour is tested +//! against `InMemoryGuildActions` in `handlers/guild.rs`. + +use super::*; + +/// Character 1 as a member of Guild 7 at rank 3. +fn guild_member_store() -> WorldFake { + let base = tester_store(7); + WorldFake { + session: SessionState { + login_entity: Some(warrior_entity()), + ..base.session + }, + guild: GuildState { + guild_memberships: vec![codec::GuildMemberView { + character_guid: 1, + guild_id: 7, + rank_id: 3, + name: "Warrior".into(), + ..Default::default() + }], + ..base.guild + }, + ..base + } +} + +/// The recorded operations, in call order, without their Shard. +fn recorded(store: &WorldFake) -> Vec { + store + .topology + .calls + .lock() + .unwrap() + .iter() + .map(|(_, what)| what.clone()) + .collect() +} + +#[test] +fn a_member_enters_the_world_with_its_guild_on_the_self_create_and_signs_on() { + let store = std::sync::Arc::new(guild_member_store()); + 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()).unwrap(); + }); + 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(); + let mut guild = None; + for message in drain_world_entry(&mut client, &mut c_dec) { + if let ServerOpcodeMessage::SMSG_UPDATE_OBJECT(update) = message { + if let [Object::CreateObject2 { + mask2: wow_world_messages::vanilla::UpdateMask::Player(player), + .. + }] = update.objects.as_slice() + { + guild = Some((player.player_guildid(), player.player_guildrank())); + } + } + } + assert_eq!(guild, Some((Some(7), Some(3)))); + drop(client); + server.join().unwrap(); + assert!(recorded(&store).contains(&"guild_op:SignOn".to_string())); +} + +#[test] +fn logout_signs_the_member_off_before_the_account_claim_is_released() { + let store = std::sync::Arc::new(guild_member_store()); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); + CMSG_LOGOUT_REQUEST {} + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + for _ in 0..2 { + ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); + } + drop(client); + server.join().unwrap(); + let calls = recorded(&store); + assert!( + position(&calls, "guild_op:SignOff") < position(&calls, "logout"), + "Realm-core refuses a sign-off after the Account Claim closes: {calls:?}" + ); +} + +#[test] +fn world_entry_finishes_a_leftover_fee_hold_on_the_home_shard() { + let store = std::sync::Arc::new({ + let base = guild_member_store(); + WorldFake { + guild: GuildState { + guild_fee_hold: std::sync::Mutex::new(Some(crate::world::guild_fee::FeeHold { + operation_id: 5_090_401, + terms: crate::world::guild_fee::FeeTerms::Emblem(Default::default()), + })), + ..base.guild + }, + ..base + } + }); + let (client, _c_enc, _c_dec, server) = enter_world(store.clone(), 1); + drop(client); + server.join().unwrap(); + let calls = recorded(&store); + assert!( + position(&calls, "player_login") < position(&calls, "guild_fee_decide") + && position(&calls, "guild_fee_decide") < position(&calls, "guild_fee_finish"), + "{calls:?}" + ); + assert_eq!(*store.guild.guild_fee_hold.lock().unwrap(), None); +} + +#[test] +fn a_world_port_that_fails_after_sign_on_still_signs_off() { + let store = std::sync::Arc::new({ + let base = guild_member_store(); + WorldFake { + session: SessionState { + // The world-port ack is only actionable after teleport_player removed the old entity. + entity_in_world: false, + worldport_login_error: Some("character 1 is stranded on map 36".into()), + ..base.session + }, + ..base + } + }); + 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, 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_WORLDPORT_ACK {} + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + while ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).is_ok() {} + drop(client); + assert!( + server.join().unwrap().is_err(), + "the failed entry ends the session" + ); + let calls = recorded(&store); + assert!( + position(&calls, "guild_op:SignOn") < position(&calls, "guild_op:SignOff"), + "{calls:?}" + ); + assert!(position(&calls, "guild_op:SignOff") < position(&calls, "logout")); +} + +/// Guild state on Realm-core for the deleted-Character reconciliation: Leader 5 and member 6 in +/// one Guild, and a Petition owned by 7 with a Signature by 8. Characters 5 and 7 still exist on +/// the `instances` peer; 6 and 8 exist on no World Shard. +fn guild_cleanup_topology() -> std::sync::Arc { + guild_cleanup_topology_failing_lookup_for(None) +} + +fn guild_cleanup_topology_failing_lookup_for( + guild_lookup_error_for: Option, +) -> std::sync::Arc { + let instances = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "instances".into(), + ..Default::default() + }, + characters: [5, 7] + .into_iter() + .map(|guid| codec::CharacterView { + guid, + name: format!("Kept{guid}"), + ..Default::default() + }) + .collect(), + ..Default::default() + }); + let world = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "world".into(), + ..Default::default() + }, + guild: GuildState { + guild_memberships: [(5, 0), (6, 4)] + .into_iter() + .map(|(character_guid, rank_id)| codec::GuildMemberView { + character_guid, + guild_id: 7, + rank_id, + ..Default::default() + }) + .collect(), + guild_petitions: vec![codec::PetitionView { + petition_id: 3, + charter_item_guid: 90, + owner_guid: 7, + name: "Boundary Test".into(), + signers: vec![8], + }], + guild_lookup_error_for, + ..Default::default() + }, + ..Default::default() + }); + *world.topology.peers.lock().unwrap() = vec![world.clone(), instances]; + world +} + +fn forgotten(store: &WorldFake) -> Vec { + store + .topology + .calls + .lock() + .unwrap() + .iter() + .map(|(_, call)| call.clone()) + .filter(|call| call.starts_with("guild_op:ForgetDeletedCharacter")) + .collect() +} + +fn sweep() -> crate::world::GuildCleanup { + crate::world::GuildCleanup { + sweep: true, + ..Default::default() + } +} + +fn deleted(guids: &[u64]) -> crate::world::GuildCleanup { + crate::world::GuildCleanup { + sweep: false, + deleted: guids.iter().copied().collect(), + } +} + +fn durable_absence_checks(store: &WorldFake) -> usize { + store + .character + .durable_absence_checks + .load(std::sync::atomic::Ordering::SeqCst) +} + +#[test] +fn only_characters_absent_from_every_world_shard_are_forgotten() { + let world = guild_cleanup_topology(); + crate::world::reconcile_deleted_guild_characters(world.as_ref(), &sweep()).unwrap(); + assert_eq!( + forgotten(&world), + [ + "guild_op:ForgetDeletedCharacter:6", + "guild_op:ForgetDeletedCharacter:8" + ] + ); +} + +/// A Character row in any Shard's cache proves the Character exists, so the pass takes no durable +/// snapshot for it. Only 6 and 8, gone from every cache, pay for the absence check. +#[test] +fn a_character_still_in_a_shard_cache_takes_no_durable_snapshot() { + let world = guild_cleanup_topology(); + crate::world::reconcile_deleted_guild_characters(world.as_ref(), &sweep()).unwrap(); + assert_eq!(durable_absence_checks(&world), 2); + + let kept = guild_cleanup_topology(); + crate::world::reconcile_deleted_guild_characters(kept.as_ref(), &deleted(&[5, 7])).unwrap(); + assert_eq!(durable_absence_checks(&kept), 0); + assert!(forgotten(&kept).is_empty()); +} + +/// A `game_character` delete owes cleanup for that Character only, and none at all for a +/// Character that no Guild, Petition or Signature names. +#[test] +fn a_single_delete_checks_only_its_own_character() { + let world = guild_cleanup_topology(); + crate::world::reconcile_deleted_guild_characters(world.as_ref(), &deleted(&[8, 40])).unwrap(); + assert_eq!(forgotten(&world), ["guild_op:ForgetDeletedCharacter:8"]); + assert_eq!(durable_absence_checks(&world), 1); +} + +/// A lookup that fails for one deleted Character does not hold up the others. The pass still +/// fails, so the worker keeps the work and retries it. +#[test] +fn a_failed_lookup_still_cleans_the_other_deleted_characters() { + let world = guild_cleanup_topology_failing_lookup_for(Some(6)); + let error = crate::world::reconcile_deleted_guild_characters(world.as_ref(), &deleted(&[6, 8])) + .expect_err("the failed lookup is owed a retry"); + assert!(error.to_string().contains("lookup for 6")); + assert_eq!(forgotten(&world), ["guild_op:ForgetDeletedCharacter:8"]); +} + +#[test] +fn an_unavailable_world_shard_defers_every_guild_cleanup() { + let world = guild_cleanup_topology(); + let incomplete = WorldFake { + topology: TopologyState { + shard: "world".into(), + calls: world.topology.calls.clone(), + world_shard_set_error: Some("instances has no healthy Coordinator subscription".into()), + ..Default::default() + }, + guild: GuildState { + guild_memberships: world.guild.guild_memberships.clone(), + guild_petitions: world.guild.guild_petitions.clone(), + ..Default::default() + }, + ..Default::default() + }; + let error = crate::world::reconcile_deleted_guild_characters(&incomplete, &sweep()) + .expect_err("an incomplete Shard set cannot prove a deletion"); + assert!(error.to_string().contains("no healthy Coordinator")); + assert!(forgotten(&incomplete).is_empty()); +} + +/// A Guild Leader's delete answers 0x3A, FAILED_GUILD_LEADER in mangos, and never reaches the +/// Home Shard (`cm:CharacterHandler.cpp:540-546`). +#[test] +fn char_delete_of_a_guild_leader_replies_failed_and_deletes_nothing() { + let store = std::sync::Arc::new({ + let base = tester_store(7); + WorldFake { + guild: GuildState { + guild_memberships: vec![codec::GuildMemberView { + character_guid: 5, + guild_id: 7, + rank_id: 0, + name: "Tester".into(), + ..Default::default() + }], + guilds: vec![codec::GuildView { + guild_id: 7, + name: "Boundary Test".into(), + leader_guid: 5, + ..Default::default() + }], + ..base.guild + }, + ..base + } + }); + 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()).unwrap(); + }); + + let (mut c_enc, mut c_dec) = client_handshake(&mut client, "TESTER", K); + CMSG_CHAR_DELETE { guid: Guid::new(5) } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_CHAR_DELETE(m) => { + assert_eq!(m.result.as_int(), 0x3A) + } + other => panic!("expected SMSG_CHAR_DELETE, got {other}"), + } + assert!(store.character.deleted.lock().unwrap().is_empty()); + + drop(client); + server.join().unwrap(); +} diff --git a/gateway/src/world/handle_loot_fake.rs b/gateway/src/world/handle_loot_fake.rs new file mode 100644 index 000000000..359061f8a --- /dev/null +++ b/gateway/src/world/handle_loot_fake.rs @@ -0,0 +1,385 @@ +//! A Fake for the traits `handle_loot` is bounded on: Death, Loot Window, Npc, Shard Routing and +//! Loot Roll. Death and the Loot Window carry state. The other three are not reachable from the +//! tests that use it. + +use crate::codec; +use crate::world::handlers::{ + DeathStore, LootActionStatus, LootWindowRequestStatus, LootWindowStore, NpcStore, +}; +use crate::world::loot::{LootRollStore, PendingLootRoll}; +use crate::world::{ShardRoutingStore, WorldStore}; +use anyhow::{anyhow, Result}; +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Life { + Dead, + Ghost, + Alive, +} + +#[derive(Default)] +pub(crate) struct HandleLootFake { + life: Mutex>, + /// Owner guid to the guid of the corpse that owner can reclaim. + corpses: HashMap, + /// Characters with an unanswered resurrect offer. + res_offers: Mutex>, + /// Characters holding an unspent Self-Resurrection Option. + self_res_options: Mutex>, + spirit_healers: HashSet, + loot_by_viewer: HashMap>, +} + +impl HandleLootFake { + pub(crate) fn with_life(self, guid: u64, life: Life) -> Self { + self.life.lock().unwrap().insert(guid, life); + self + } + + pub(crate) fn with_corpse(mut self, owner: u64, corpse: u64) -> Self { + self.corpses.insert(owner, corpse); + self + } + + pub(crate) fn with_res_offer(self, guid: u64) -> Self { + self.res_offers.lock().unwrap().insert(guid); + self + } + + pub(crate) fn with_self_res_option(self, guid: u64) -> Self { + self.self_res_options.lock().unwrap().insert(guid); + self + } + + pub(crate) fn with_spirit_healer(mut self, guid: u64) -> Self { + self.spirit_healers.insert(guid); + self + } + + pub(crate) fn with_loot(mut self, viewer: u64, items: Vec) -> Self { + self.loot_by_viewer.insert(viewer, items); + self + } + + /// Characters nobody set up are alive. + pub(crate) fn life(&self, guid: u64) -> Life { + self.life + .lock() + .unwrap() + .get(&guid) + .copied() + .unwrap_or(Life::Alive) + } + + pub(crate) fn has_res_offer(&self, guid: u64) -> bool { + self.res_offers.lock().unwrap().contains(&guid) + } + + pub(crate) fn has_self_res_option(&self, guid: u64) -> bool { + self.self_res_options.lock().unwrap().contains(&guid) + } + + fn require(&self, guid: u64, needed: Life) -> Result<()> { + match self.life(guid) { + life if life == needed => Ok(()), + life => Err(anyhow!("character {guid} is {life:?}, not {needed:?}")), + } + } + + 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)?; + 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)?; + if self.corpses.get(&self_guid) != Some(&corpse_guid) { + return Err(anyhow!( + "corpse {corpse_guid} is not character {self_guid}'s" + )); + } + self.revive(self_guid); + Ok(()) + } + + fn resurrect_response(&self, _account_id: u64, self_guid: u64, accept: bool) -> Result<()> { + if !self.res_offers.lock().unwrap().remove(&self_guid) { + return Err(anyhow!("character {self_guid} has no pending offer")); + } + if accept { + self.revive(self_guid); + } + Ok(()) + } + + fn self_resurrect(&self, _account_id: u64, self_guid: u64) -> Result<()> { + self.require(self_guid, Life::Dead)?; + if !self.self_res_options.lock().unwrap().remove(&self_guid) { + return Err(anyhow!("no Self-Resurrection Option")); + } + 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)?; + if !self.spirit_healers.contains(&healer_guid) { + return Err(anyhow!("{healer_guid} is not a Spirit Healer")); + } + self.revive(self_guid); + Ok(()) + } + + fn corpse_location(&self, _owner_guid: u64) -> Result> { + unreachable!("no test asks for a corpse location") + } +} + +impl LootWindowStore for HandleLootFake { + fn loot_target_money(&self, _target_guid: u64) -> Result { + unreachable!("handle_loot reads no loot money") + } + + fn loot_target_items( + &self, + _target_guid: u64, + viewer_guid: u64, + ) -> Result> { + Ok(self + .loot_by_viewer + .get(&viewer_guid) + .cloned() + .unwrap_or_default()) + } + + fn use_gameobject( + &self, + _account_id: u64, + _actor_guid: u64, + _target_guid: u64, + ) -> Result { + Ok(LootWindowRequestStatus::Applied) + } + + fn open_creature_loot( + &self, + _account_id: u64, + _actor_guid: u64, + _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 { + unreachable!("handle_loot skins no corpse") + } + + fn loot_money( + &self, + _account_id: u64, + _actor_guid: u64, + _target_guid: u64, + ) -> Result { + unreachable!("handle_loot takes no money") + } + + fn take_loot( + &self, + _account_id: u64, + _actor_guid: u64, + _target_guid: u64, + _loot_slot: u8, + ) -> Result { + unreachable!("handle_loot takes no item") + } +} + +impl NpcStore for HandleLootFake { + fn creature_template(&self, _entry: u32) -> Result> { + unreachable!("no test reaches the NPC family") + } + + fn pet_name( + &self, + _requester_guid: u64, + _pet_number: u32, + _pet_guid: u64, + ) -> Result> { + unreachable!("no test reaches the NPC family") + } + + fn gameobject_template(&self, _entry: u32) -> Result> { + unreachable!("no test reaches the NPC family") + } + + fn gameobject_type(&self, _go_guid: u64) -> Result> { + unreachable!("no test reaches the NPC family") + } + + fn enter_areatrigger(&self, _account_id: u64, _self_guid: u64, _trigger_id: u32) -> Result<()> { + unreachable!("no test reaches the NPC family") + } + + fn npc_refuses_interaction(&self, _npc_guid: u64, _player_guid: u64) -> Result { + unreachable!("no test reaches the NPC family") + } + + fn bind_home(&self, _account_id: u64, _self_guid: u64) -> Result<()> { + unreachable!("no test reaches the NPC family") + } + + fn npc_is_innkeeper(&self, _guid: u64) -> Result { + unreachable!("no test reaches the NPC family") + } + + fn npc_gossip_text_id(&self, _npc_guid: u64) -> u32 { + unreachable!("no test reaches the NPC family") + } + + fn npc_text_for_id(&self, _text_id: u32) -> Option { + unreachable!("no test reaches the NPC family") + } + + fn gossip_options(&self, _npc_guid: u64) -> Result> { + unreachable!("no test reaches the NPC family") + } + + fn inspect(&self, _account_id: u64, _self_guid: u64, _target_guid: u64) -> Result<()> { + unreachable!("no test reaches the NPC family") + } + + fn gossip_select( + &self, + _account_id: u64, + _self_guid: u64, + _npc_guid: u64, + _option_id: u32, + _option_row_id: u32, + ) -> Result<()> { + unreachable!("no test reaches the NPC family") + } +} + +impl LootRollStore for HandleLootFake { + 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<()> { + unreachable!("no test rolls for loot") + } + + fn realm_loot_vote( + &self, + _corpse_guid: u64, + _slot: u8, + _actor_guid: u64, + _vote: u8, + ) -> Result { + unreachable!("no test rolls for loot") + } + + fn pending_local_rolls(&self) -> Result> { + unreachable!("no test rolls for loot") + } + + fn settle_loot_roll(&self, _corpse_guid: u64, _slot: u8, _winner_guid: u64) -> Result<()> { + unreachable!("no test rolls for loot") + } + + fn clear_promoted_loot_roll(&self, _roll_id: u64) -> Result<()> { + unreachable!("no test rolls for loot") + } + + fn loot_won_since(&self, _after_id: u64) -> Result<(u64, Vec<(u64, u8, u64)>)> { + unreachable!("no test rolls for loot") + } + + fn loot_roll( + &self, + _account_id: u64, + _self_guid: u64, + _corpse_guid: u64, + _loot_slot: u32, + _vote: u8, + ) -> Result { + unreachable!("no test rolls for loot") + } + + fn loot_master_give( + &self, + _account_id: u64, + _self_guid: u64, + _corpse_guid: u64, + _loot_slot: u8, + _target_guid: u64, + ) -> Result { + unreachable!("no test rolls for loot") + } +} + +/// A single-database gateway: every handle serves every location, and nothing routes. +impl ShardRoutingStore for HandleLootFake { + fn shard_name(&self) -> &str { + "handle-loot-fake" + } + + fn settle_home_shard(&self, _character_guid: u64) -> Result>> { + Ok(None) + } + + fn shard_for_location(&self, _map_id: u32, _instance_id: u64) -> Option> { + None + } + + fn bind_shard_session(&self, _account_id: u64, _session_key: &[u8; 40]) -> Result<()> { + Ok(()) + } + + fn realm_store(&self) -> Option> { + None + } + + fn party_cleanup_realm(&self) -> Result>> { + Ok(None) + } + + fn party_command_realm(&self) -> Result>> { + Ok(None) + } + + fn transfer_realm(&self) -> Result>> { + Ok(None) + } + + fn world_stores(&self) -> Vec> { + Vec::new() + } + + fn party_command_worlds(&self) -> Result>> { + Ok(Vec::new()) + } +} diff --git a/gateway/src/world/handlers/auction.rs b/gateway/src/world/handlers/auction.rs index c7a3a0fb3..422864c8f 100644 --- a/gateway/src/world/handlers/auction.rs +++ b/gateway/src/world/handlers/auction.rs @@ -610,7 +610,7 @@ pub(crate) fn dispatch_auction_action( } #[cfg(test)] -mod tests { +pub(super) mod tests { use super::*; use std::sync::Mutex; use wow_world_messages::shared::Gold; @@ -622,7 +622,7 @@ mod tests { CMSG_AUCTION_SELL_ITEM, }; - struct InMemoryAuctionActions { + pub(crate) struct InMemoryAuctionActions { result: Mutex, String>>, lookups: Mutex>, creates: Mutex>, @@ -724,7 +724,7 @@ mod tests { } } - fn store_with(interaction: Option) -> InMemoryAuctionActions { + pub(crate) fn store_with(interaction: Option) -> InMemoryAuctionActions { InMemoryAuctionActions { result: Mutex::new(Ok(interaction)), lookups: Mutex::default(), diff --git a/gateway/src/world/handlers/bank.rs b/gateway/src/world/handlers/bank.rs index 310530508..bfa810cc8 100644 --- a/gateway/src/world/handlers/bank.rs +++ b/gateway/src/world/handlers/bank.rs @@ -4,13 +4,26 @@ use super::super::*; use super::send_show_bank; +/// Bank Durable Requests. +pub(crate) trait BankStore: Send + Sync { + /// Auto-bank/auto-store-bank the item in `slot` (`CMSG_AUTOBANK_ITEM`/`CMSG_AUTOSTORE_BANK_ITEM` + /// — 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<()>; + + /// 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<()>; +} + /// Bank family: `CMSG_BANKER_ACTIVATE` opens the bank window (a standing-refusing banker gets no /// reply at all, matching the vendor `CMSG_LIST_INVENTORY` gate); `CMSG_AUTOBANK_ITEM` deposits a /// carried item into the first free bank slot, `CMSG_AUTOSTORE_BANK_ITEM` withdraws a banked item /// into the first free carry slot — both resolve to the same module entry point, which infers the /// direction from the source slot and carries the banker-proximity gate for free (it reuses the /// move core). Only the main pseudo-bag (255) is addressed, matching the item handler's restriction. -pub(crate) fn handle_bank( +pub(crate) fn handle_bank( tx: &SessionTx, store: &St, conn: &mut WorldConn, diff --git a/gateway/src/world/handlers/cast/mod.rs b/gateway/src/world/handlers/cast/mod.rs index 610a9b770..64ffd9ff4 100644 --- a/gateway/src/world/handlers/cast/mod.rs +++ b/gateway/src/world/handlers/cast/mod.rs @@ -124,8 +124,8 @@ pub(crate) trait CastStore: MeleeActionStore + Send + Sync { fn cancel_aura(&self, account_id: u64, self_guid: u64, spell_id: u32) -> Result<()>; // The two reads below are shared with the character, vendor and query paths. They are declared - // here rather than on `WorldStore` because a second declaration of the same name would make - // every `St: WorldStore` call ambiguous; `WorldStore: CastStore` keeps them reachable. The + // on this family only, because a second declaration of the same name on another family would + // make every call through the `WorldStore` umbrella ambiguous. The // ranged teardown comes from `MeleeActionStore`: melee and ranged share one durable row, so it // has one declaration, on the seam that owns that row. @@ -537,6 +537,13 @@ pub(super) mod tests { pub(crate) pick_lock_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. + pub(crate) engaged: Mutex>, + /// Spell ids of the auras on the caster; a cancellation removes the one it names. + pub(crate) auras: Mutex>, + /// Casters with a pending cast; a cancellation removes the caller's. + pub(crate) pending_casts: Mutex>, } impl InMemoryCasts { @@ -696,7 +703,12 @@ pub(super) mod tests { .lock() .unwrap() .push((account_id, self_guid)); - self.cancel_result() + self.cancel_result()?; + self.pending_casts + .lock() + .unwrap() + .retain(|&caster| caster != self_guid); + Ok(()) } fn cancel_aura(&self, account_id: u64, self_guid: u64, spell_id: u32) -> Result<()> { @@ -704,7 +716,9 @@ pub(super) mod tests { .lock() .unwrap() .push((account_id, self_guid, spell_id)); - self.cancel_result() + self.cancel_result()?; + self.auras.lock().unwrap().retain(|&aura| aura != spell_id); + Ok(()) } fn start_ranged_attack( @@ -723,6 +737,10 @@ pub(super) mod tests { target_guid, spell_id, )); + let mut engaged = self.engaged.lock().unwrap(); + if !engaged.contains(&self_guid) { + engaged.push(self_guid); + } Ok(()) } @@ -752,6 +770,10 @@ pub(super) mod tests { .lock() .unwrap() .push((account_id, actor_guid)); + self.engaged + .lock() + .unwrap() + .retain(|&guid| guid != actor_guid); Ok(()) } } diff --git a/gateway/src/world/handlers/channel.rs b/gateway/src/world/handlers/channel.rs index 6b9dd454c..c72d48fa1 100644 --- a/gateway/src/world/handlers/channel.rs +++ b/gateway/src/world/handlers/channel.rs @@ -100,7 +100,7 @@ impl ChannelActionStore for crate::stdb::Coordinator { /// 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 /// notify. -pub(crate) fn resolve_online_character( +pub(crate) fn resolve_online_character( store: &St, name: &str, ) -> Result> { diff --git a/gateway/src/world/handlers/char.rs b/gateway/src/world/handlers/char.rs index 795c65e0d..2a1737e91 100644 --- a/gateway/src/world/handlers/char.rs +++ b/gateway/src/world/handlers/char.rs @@ -5,6 +5,72 @@ use super::super::*; use super::quest::{quest_log_update, QuestActionStore}; use super::vendor::build_buyback_view_replay; +/// Character select, and the per-Character reads world entry builds the self CREATE from. +pub(crate) trait CharacterStore: Send + Sync { + /// The account's characters for the character-select screen. In production this + /// reads the per-player `game_character` subscription (RLS-restricted to the owner). + 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. + fn create_character( + &self, + account_id: u64, + name: &str, + race: u8, + class: u8, + gender: u8, + 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`). + fn delete_character( + &self, + account_id: u64, + character_guid: u64, + ) -> Result; + + /// Look up a character by guid (any owner) to answer `CMSG_NAME_QUERY` — the queried guid is + /// usually a peer, so this is not account-scoped. + fn character_by_guid(&self, guid: u64) -> Result>; + + /// Does any World Shard hold this Character? `false` means every configured Shard was readable + /// and had no row. An incomplete, unhealthy, or changing Shard set must return `Err`. + fn character_exists_on_any_world_shard(&self, guid: u64) -> Result; + + /// The character's learned skill lines as `(skill_line, current, max_rank)` — feeds the self + /// CREATE's SkillInfo block. Empty when no `game_player_skill` rows exist. + fn player_skills(&self, character_guid: u64) -> Result>; + + /// The EFFECTIVE armor for `guid` (base + worn gear armor) for the self-login CREATE's + /// `UNIT_FIELD_RESISTANCES[0]` — so the character sheet shows real worn armor on relog. Auras aren't + /// folded here (they self-correct via the on_aura relay). Mirrors the module's combat `effective_armor`. + fn effective_armor(&self, guid: u64) -> u32; + + fn effective_magic_resistances(&self, guid: u64) -> [u32; 6]; + + /// The character's active spell-modifier auras as raw (family_mask, op, amount, is_pct) rows — + /// the SMSG_SET_FLAT/PCT_SPELL_MODIFIER mirror source. + fn spell_modifiers(&self, character_guid: u64) -> Vec<(u32, u8, i32, bool)>; + + /// The player's LEARNED spells (`game_player_spell`, beyond the class kit) — chained into the + /// login SMSG_INITIAL_SPELLS so a taught ability (e.g. Auto Shot) reaches the client spellbook. + fn player_learned_spells(&self, player_guid: u64) -> Result>; + + /// The player's persisted reputation standings (`game_player_reputation`) as `(reputation_index, + /// standing)` pairs — folded into the login `SMSG_INITIALIZE_FACTIONS` so a relog shows + /// the real standing instead of the all-neutral stub. + fn player_reputations(&self, player_guid: u64) -> Result>; + + /// The player's IMPORTED action-bar rows (`game_player_action`) as `(button, + /// action, action_type)` triples — empty pre-import (the common case today), in which case the + /// login codec falls back to synthesizing the bar from the spellbook (byte-identical to before + /// this method existed). + fn player_actions(&self, player_guid: u64) -> Result>; +} + /// Tell a client whose world-port cannot complete that it is off, so its loading screen ends with an /// error instead of never ending. Best-effort and infallible by design: it runs on /// a path that is already failing, and every one of its own failure modes (an unmapped destination @@ -15,7 +81,7 @@ use super::vendor::build_buyback_view_replay; /// `world::teleport_player` wrote the destination into before it despawned the entity, i.e. the map /// the client is loading right now. `TransferAbortReason::NotFound` is the closest vanilla reason to /// "the shard that owns this instance would not take you"; the operator-facing detail is the log line. -fn abort_pending_transfer( +fn abort_pending_transfer( tx: &SessionTx, store: &St, character_guid: u64, @@ -75,9 +141,9 @@ fn send_quest_log( /// Rebuild the Character's entity and subscriptions, then send its entry batch. /// The bound Store retains Account ownership across a map change. Entry after a map change omits /// `SMSG_LOGIN_VERIFY_WORLD`, which would tell the client to load the map again. -fn enter_world( +fn enter_world( tx: &SessionTx, - store: &St, + store: &dyn WorldStore, conn: &mut WorldConn, character_guid: u64, entry: codec::WorldEntry, @@ -259,7 +325,9 @@ fn enter_world( /// Char / world-entry family (§4/§5): character enum + creation (character-select), then enter world /// (`CMSG_PLAYER_LOGIN`) + graceful logout — the session-lifecycle opcodes. -pub(crate) fn handle_char( +pub(crate) fn handle_char< + St: CharacterStore + GuildActionStore + SessionStore + TransferStore + ?Sized, +>( tx: &SessionTx, store: &St, conn: &mut WorldConn, @@ -346,22 +414,22 @@ pub(crate) fn handle_char( let token = store.claim_session(conn.account_id, character_guid)?; conn.session_claim = Some(token); if let Some(bound) = store.bind_session(token)? { - conn.home = Some(bound); + conn.store.pin(bound); } // Multi-shard routing: pin this session to the shard that owns the character's // location BEFORE `player_login` runs, so the login reducer and viewer registration // land on the home shard — and so does every message after this one - // (`run_world_session` re-reads `conn.home` per frame). - // A single-entry shard map never pins anything → `enter_world` runs on `store`, as it - // always did. - conn.route_home(store, character_guid)?; - on_home_shard!(conn, store, |st| enter_world( + // (`run_world_session` reads `conn.store` per frame). + // A single-entry shard map never pins anything → `enter_world` runs on `store`. + conn.route_home(character_guid)?; + let home = conn.store.current(); + enter_world( tx, - st, + &*home, conn, character_guid, - codec::WorldEntry::FreshLogin - ))?; + 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 @@ -387,7 +455,7 @@ pub(crate) fn handle_char( // despawns the entity until this ack; a live entity means no transfer is in // flight and the ack is spurious — ignore it instead of re-entering the world. // `store` is ALREADY the home-shard handle — the read loop routes every frame - // through `on_home_shard!` — so this reads the cache the entity actually lives in. + // through `conn.store` — so this reads the cache the entity actually lives in. if store.entity_in_world(character_guid) { log::debug!("world: spurious WORLDPORT_ACK ignored (guid {character_guid} still in world)"); } else { @@ -416,15 +484,16 @@ pub(crate) fn handle_char( // the escrow is idempotent and the next login re-drives it from the same rows. // - let mut ported = conn.route_home(store, character_guid); + let mut ported = conn.route_home(character_guid); if ported.is_ok() { - ported = on_home_shard!(conn, store, |st| enter_world( + let home = conn.store.current(); + ported = enter_world( tx, - st, + &*home, conn, character_guid, - codec::WorldEntry::WorldPort - )); + codec::WorldEntry::WorldPort, + ); } if let Err(e) = ported { abort_pending_transfer(tx, store, character_guid, &e); @@ -459,7 +528,7 @@ pub(crate) fn handle_char( // Leave the world: InWorld → CharSelect drops the relay subs; delete the entity only if // we still own it — a newer login on this account supersedes us, and deleting then would // vanish them. A `logout` failure here is session-fatal (propagated), as before. - conn.leave_world(store)?; + conn.leave_world()?; } // /played: read the durable total + the live session stamp off the // character row and fold them in `build_played_time` so an online player's total keeps diff --git a/gateway/src/world/handlers/chat.rs b/gateway/src/world/handlers/chat.rs index 8bb04929d..a088f5052 100644 --- a/gateway/src/world/handlers/chat.rs +++ b/gateway/src/world/handlers/chat.rs @@ -84,6 +84,40 @@ pub(crate) trait ChatActionStore: Send + Sync { fn speaker_gm_level(&self, speaker_guid: u64) -> Result; } +/// Durable Requests for Speech, and for the GM dot-command a Say line can carry. +pub(crate) trait SpeechStore: Send + Sync { + /// Speak (`CMSG_MESSAGECHAT`, social tier): broadcast a say, yell or `/e` line + /// (`lyracore_shared::chat::broadcast_chat`). A language the speaker's race does not know is a + /// Refusal. + fn send_chat( + &self, + account_id: u64, + self_guid: u64, + chat_type: u8, + language: u8, + message: String, + ) -> Result; + + /// Perform an emote (`CMSG_TEXT_EMOTE`, social tier): broadcast the "X dances." line + animation. + /// `target_guid` (0 = untargeted) is the client's selected target — the gateway resolves it to a + /// name so the chat line reads "X waves at ." + fn send_emote( + &self, + account_id: u64, + self_guid: u64, + text_emote: u32, + emote_anim: u32, + target_guid: u64, + ) -> Result<()>; + + /// 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) diff --git a/gateway/src/world/handlers/combat.rs b/gateway/src/world/handlers/combat.rs index 3cfbae8b5..a4295ae42 100644 --- a/gateway/src/world/handlers/combat.rs +++ b/gateway/src/world/handlers/combat.rs @@ -4,10 +4,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<()>; + + /// 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<()>; + + /// 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<()>; +} + /// Combat family leftovers: selection, pet commands, the run-speed ack and sheathing. Each arm is /// best-effort. The session-fatal desync exits went to the melee seam with the two melee opcodes /// that owned them. -pub(crate) fn handle_combat( +pub(crate) fn handle_combat( store: &St, conn: &mut WorldConn, msg: ClientOpcodeMessage, diff --git a/gateway/src/world/handlers/death.rs b/gateway/src/world/handlers/death.rs new file mode 100644 index 000000000..ac5f56db0 --- /dev/null +++ b/gateway/src/world/handlers/death.rs @@ -0,0 +1,33 @@ +//! Death family: Release Spirit, corpse reclaim and the resurrection paths. + +use super::super::*; + +/// Release Spirit, corpse reclaim and resurrection. +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<()>; + + /// 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<()>; + + /// 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<()>; + + /// 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<()>; + + /// 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<()>; + + /// 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/guild.rs b/gateway/src/world/handlers/guild.rs index e794c23df..4b50c38e7 100644 --- a/gateway/src/world/handlers/guild.rs +++ b/gateway/src/world/handlers/guild.rs @@ -1837,7 +1837,9 @@ pub(crate) enum DeletedCharacterGuildCleanup { /// snapshot: a Transfer deletes the source row only after the destination holds one, and a row /// that is stale in a cache starts another pass when its delete arrives. Only then comes the /// party cleanup's absence check, two durable snapshots of every configured World Shard. -pub(crate) fn forget_deleted_character( +pub(crate) fn forget_deleted_character< + St: CharacterStore + GuildActionStore + ShardRoutingStore + ?Sized, +>( store: &St, character_guid: u64, ) -> Result { @@ -1875,7 +1877,9 @@ impl GuildCleanup { /// Run the guild cleanup `work` asks for. Every Character is tried; the first failure is /// returned, so the worker keeps the work and retries it. -pub(crate) fn reconcile_deleted_guild_characters( +pub(crate) fn reconcile_deleted_guild_characters< + St: CharacterStore + GuildActionStore + ShardRoutingStore + ?Sized, +>( store: &St, work: &GuildCleanup, ) -> Result<()> { @@ -3659,6 +3663,76 @@ mod tests { ); } + #[test] + fn every_membership_opcode_reaches_its_durable_request() { + use wow_world_messages::vanilla as wire; + let mut store = founded_with_members(); + store.characters.push(facts(DAVE, "Dave")); + let named = snapshot_name(BOB); + let messages = [ + ( + DAVE, + ClientOpcodeMessage::CMSG_GUILD_ACCEPT, + GuildRequest::Accept { + actor_name: "Dave".into(), + actor_team: lyracore_shared::faction::TEAM_ALLIANCE, + }, + ), + ( + DAVE, + ClientOpcodeMessage::CMSG_GUILD_DECLINE, + GuildRequest::Decline { + actor_name: "Dave".into(), + }, + ), + ( + GM, + ClientOpcodeMessage::CMSG_GUILD_REMOVE(Box::new(wire::CMSG_GUILD_REMOVE { + player_name: named.clone(), + })), + GuildRequest::Remove { target_guid: BOB }, + ), + ( + GM, + ClientOpcodeMessage::CMSG_GUILD_PROMOTE(Box::new(wire::CMSG_GUILD_PROMOTE { + player_name: named.clone(), + })), + GuildRequest::Promote { target_guid: BOB }, + ), + ( + GM, + ClientOpcodeMessage::CMSG_GUILD_DEMOTE(Box::new(wire::CMSG_GUILD_DEMOTE { + player_name: named.clone(), + })), + GuildRequest::Demote { target_guid: BOB }, + ), + ( + GM, + ClientOpcodeMessage::CMSG_GUILD_LEADER(Box::new(wire::CMSG_GUILD_LEADER { + new_guild_leader_name: named, + })), + GuildRequest::SetLeader { target_guid: BOB }, + ), + ( + BOB, + ClientOpcodeMessage::CMSG_GUILD_LEAVE, + GuildRequest::Leave, + ), + ( + GM, + ClientOpcodeMessage::CMSG_GUILD_DISBAND, + GuildRequest::Disband, + ), + ]; + for (actor, message, expected) in messages { + dispatch(&store, in_world(actor), message); + assert_eq!( + store.ops.lock().unwrap().last().cloned(), + Some((actor, expected)) + ); + } + } + #[test] fn guild_motd_opcode_runs_the_op_and_replies_nothing_on_success() { let store = founded_with_members(); diff --git a/gateway/src/world/handlers/item.rs b/gateway/src/world/handlers/item.rs index 5254c737d..2959c30c3 100644 --- a/gateway/src/world/handlers/item.rs +++ b/gateway/src/world/handlers/item.rs @@ -237,7 +237,7 @@ pub(crate) fn dispatch_item_action>, - unequip_requests: Mutex>, - move_requests: Mutex>, - use_requests: Mutex>, - equip_result: Option>, - unequip_result: Option>, - move_result: Option>, - use_result: Option>, - start_quest: Option<(u64, u32)>, - quest_detail: Option, + pub(crate) struct InMemoryItemActions { + 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>, + 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, diff --git a/gateway/src/world/handlers/loot.rs b/gateway/src/world/handlers/loot.rs index a8c2fd43d..230da9ef0 100644 --- a/gateway/src/world/handlers/loot.rs +++ b/gateway/src/world/handlers/loot.rs @@ -393,7 +393,9 @@ pub(crate) fn dispatch_loot_window( /// Remaining group-loot, non-window GameObject, and death-recovery operations not yet migrated to /// a focused action interface. -pub(crate) fn handle_loot( +pub(crate) fn handle_loot< + St: DeathStore + LootRollStore + LootWindowStore + NpcStore + ShardRoutingStore + ?Sized, +>( tx: &SessionTx, store: &St, conn: &mut WorldConn, @@ -1738,4 +1740,132 @@ mod tests { assert_eq!(error.to_string(), message); } } + + /// Run one request as the session does: apply the state transition, return the client traffic. + fn run( + store: &InMemoryLootWindow, + state: &mut OpenLootState, + msg: ClientOpcodeMessage, + ) -> Vec { + let LootWindowOutcome::Handled { + next_state, + outbound, + .. + } = dispatch_loot_window(store, player(), *state, msg).unwrap() + else { + panic!("the request passed through") + }; + *state = next_state; + outbound + } + + fn take_money() -> ClientOpcodeMessage { + ClientOpcodeMessage::CMSG_LOOT_MONEY + } + + fn take_item(item_slot: u8) -> ClientOpcodeMessage { + ClientOpcodeMessage::CMSG_AUTOSTORE_LOOT_ITEM(CMSG_AUTOSTORE_LOOT_ITEM { item_slot }) + } + + /// `(slot, item_id, count, display_id)` of item `index` in a raw loot window body: the header is + /// 8 guid + 1 method + 4 money + 1 count bytes, then 22 bytes per item. + fn window_item(body: &[u8], index: usize) -> (u8, u32, u32, u32) { + let base = 14 + index * 22; + let word = + |at: usize| u32::from_le_bytes(body[base + at..base + at + 4].try_into().unwrap()); + (body[base], word(1), word(5), word(9)) + } + + fn assert_clear_money(outbound: &[Outbound]) { + // A solo looter gets no SMSG_LOOT_MONEY_NOTIFY: the client prints its own copper line. + assert!(matches!( + outbound, + [Outbound::One(ServerOpcodeMessage::SMSG_LOOT_CLEAR_MONEY)] + )); + } + + #[test] + fn chest_use_then_item_take_removes_the_slot_from_the_tracked_chest() { + let mut store = InMemoryLootWindow::default(); + store.items_by_viewer.insert(42, vec![(4, 117, 2, 321, 0)]); + let mut state = OpenLootState::default(); + + let opened = run(&store, &mut state, open_chest(90)); + let [Outbound::Raw { opcode, body }] = opened.as_slice() else { + panic!("expected one raw loot window") + }; + assert_eq!(*opcode, 0x0160); + assert_eq!(&body[0..8], &90u64.to_le_bytes()); + assert_eq!(window_item(body, 0), (4, 117, 2, 321)); + assert_eq!(state.target_guid, Some(90)); + + let taken = run(&store, &mut state, take_item(4)); + assert!(matches!( + taken.as_slice(), + [Outbound::One(ServerOpcodeMessage::SMSG_LOOT_REMOVED(removed))] if removed.slot == 4 + )); + assert_eq!( + store.item_take_requests.lock().unwrap().as_slice(), + &[(7, 42, 90, 4)] + ); + } + + #[test] + fn corpse_with_money_opens_unskinned_and_money_take_loots_the_tracked_corpse() { + let store = InMemoryLootWindow { + money: 25, + ..Default::default() + }; + let mut state = OpenLootState::default(); + + let opened = run(&store, &mut state, open_creature(60)); + let [Outbound::Raw { opcode, body }] = opened.as_slice() else { + panic!("expected one raw loot window") + }; + assert_eq!(*opcode, 0x0160); + assert_eq!(&body[0..8], &60u64.to_le_bytes()); + assert_eq!(&body[9..13], &25u32.to_le_bytes()); + assert!(store.skin_requests.lock().unwrap().is_empty()); + + assert_clear_money(&run(&store, &mut state, take_money())); + assert_eq!( + store.money_take_requests.lock().unwrap().as_slice(), + &[(7, 42, 60)] + ); + } + + #[test] + fn money_take_on_a_copperless_corpse_still_clears_the_money_row() { + let store = InMemoryLootWindow::default(); + let mut state = OpenLootState::default(); + run(&store, &mut state, open_creature(60)); + + assert_clear_money(&run(&store, &mut state, take_money())); + assert_eq!( + store.money_take_requests.lock().unwrap().as_slice(), + &[(7, 42, 60)] + ); + } + + #[test] + fn takes_after_a_release_do_nothing() { + let store = InMemoryLootWindow { + money: 25, + ..Default::default() + }; + let mut state = OpenLootState::default(); + run(&store, &mut state, open_creature(60)); + + let released = run(&store, &mut state, release(60)); + assert!(matches!( + released.as_slice(), + [Outbound::One(ServerOpcodeMessage::SMSG_LOOT_RELEASE_RESPONSE(response))] + if response.guid.guid() == 60 + )); + + assert!(run(&store, &mut state, take_money()).is_empty()); + assert!(run(&store, &mut state, take_item(3)).is_empty()); + assert!(store.money_take_requests.lock().unwrap().is_empty()); + assert!(store.item_take_requests.lock().unwrap().is_empty()); + } } diff --git a/gateway/src/world/handlers/mail.rs b/gateway/src/world/handlers/mail.rs index dea2eef9f..5acd1afec 100644 --- a/gateway/src/world/handlers/mail.rs +++ b/gateway/src/world/handlers/mail.rs @@ -9,7 +9,9 @@ use super::super::*; use wow_world_messages::vanilla::SMSG_SEND_MAIL_RESULT_MailResultTwo; #[allow(clippy::too_many_lines)] // One arm per mail opcode. -pub(crate) fn handle_mail( +pub(crate) fn handle_mail< + St: CharacterStore + MailStore + SessionStore + ShardRoutingStore + SocialStore + ?Sized, +>( tx: &SessionTx, store: &St, conn: &mut WorldConn, diff --git a/gateway/src/world/handlers/melee.rs b/gateway/src/world/handlers/melee.rs index 3527c6f89..77e89c3af 100644 --- a/gateway/src/world/handlers/melee.rs +++ b/gateway/src/world/handlers/melee.rs @@ -208,17 +208,19 @@ fn attack_stop( } #[cfg(test)] -mod tests { +pub(super) mod tests { use super::*; use std::sync::Mutex; use wow_world_messages::vanilla::{Guid, CMSG_ATTACKSWING, CMSG_PING}; #[derive(Default)] - struct InMemoryMeleeActions { - start_requests: Mutex>, - start_error: Option, - stop_requests: Mutex>, - stop_error: Option, + pub(crate) struct InMemoryMeleeActions { + pub(crate) start_requests: Mutex>, + pub(crate) start_error: Option, + pub(crate) stop_requests: Mutex>, + pub(crate) stop_error: Option, + /// The attackers with a live engagement: a started attack adds its actor, a stop removes it. + pub(crate) engaged: Mutex>, } impl MeleeActionStore for InMemoryMeleeActions { @@ -227,9 +229,14 @@ mod tests { .lock() .unwrap() .push((account_id, actor_guid, target_guid)); - self.start_error - .as_ref() - .map_or_else(|| Ok(()), |error| Err(anyhow::anyhow!("{error}"))) + if let Some(error) = &self.start_error { + return Err(anyhow::anyhow!("{error}")); + } + let mut engaged = self.engaged.lock().unwrap(); + if !engaged.contains(&actor_guid) { + engaged.push(actor_guid); + } + Ok(()) } fn stop_attack(&self, account_id: u64, actor_guid: u64) -> Result<()> { @@ -237,9 +244,14 @@ mod tests { .lock() .unwrap() .push((account_id, actor_guid)); - self.stop_error - .as_ref() - .map_or_else(|| Ok(()), |error| Err(anyhow::anyhow!("{error}"))) + if let Some(error) = &self.stop_error { + return Err(anyhow::anyhow!("{error}")); + } + self.engaged + .lock() + .unwrap() + .retain(|&guid| guid != actor_guid); + Ok(()) } } diff --git a/gateway/src/world/handlers/mod.rs b/gateway/src/world/handlers/mod.rs index c9337885e..2c18cfb6a 100644 --- a/gateway/src/world/handlers/mod.rs +++ b/gateway/src/world/handlers/mod.rs @@ -17,6 +17,7 @@ mod channel; mod char; mod chat; mod combat; +mod death; mod duel; mod guild; mod item; @@ -33,6 +34,8 @@ mod trainer; mod vendor; mod weather; +#[cfg(test)] +pub(crate) use auction::tests::{store_with, InMemoryAuctionActions}; pub(crate) use auction::{ decode_auction_browse, dispatch_auction_action, dispatch_auction_browse_action, AuctionActionOutcome, AuctionActionPlayer, AuctionActionStore, AuctionBrowseRequest, @@ -41,7 +44,9 @@ pub(crate) use auction::{ }; #[cfg(test)] pub(crate) use auction::{AuctionHousePolicy, AuctionInteraction}; -pub(crate) use bank::handle_bank; +pub(crate) use bank::{handle_bank, BankStore}; +#[cfg(test)] +pub(crate) use cast::tests::InMemoryCasts; pub(crate) use cast::{dispatch_cast, CastOutcome, CastPlayer, CastStore, CastTransition}; pub(crate) use channel::{ dispatch_channel_action, ChannelActionOutcome, ChannelActionStore, ChannelOutcome, @@ -49,12 +54,13 @@ pub(crate) use channel::{ }; #[cfg(test)] pub(crate) use channel::{resolve_online_character, ResolvedTarget}; -pub(crate) use char::handle_char; +pub(crate) use char::{handle_char, CharacterStore}; pub(crate) use chat::{ dispatch_chat_action, ChatActionOutcome, ChatActionPlayer, ChatActionStore, ChatOutcome, - RealmChatRequest, SpeakerFacts, WhisperRequest, WhisperTargetFacts, + RealmChatRequest, SpeakerFacts, SpeechStore, WhisperRequest, WhisperTargetFacts, }; -pub(crate) use combat::handle_combat; +pub(crate) use combat::{handle_combat, CombatStore}; +pub(crate) use death::DeathStore; pub(crate) use duel::{dispatch_duel_action, DuelActionOutcome, DuelActionPlayer, DuelActionStore}; pub(crate) use guild::{ character_facts, destroy_inert_charters, dispatch_guild_action, guild_projection, @@ -63,6 +69,8 @@ pub(crate) use guild::{ DurableCharacterFacts, GuildActionOutcome, GuildActionPlayer, GuildActionStore, GuildCleanup, GuildEventSnapshot, GuildOutcome, GuildRequest, }; +#[cfg(test)] +pub(crate) use item::tests::InMemoryItemActions; pub(crate) use item::{ dispatch_item_action, ItemActionOutcome, ItemActionPlayer, ItemActionResult, ItemActionStore, }; @@ -75,6 +83,8 @@ pub(crate) use meeting_stone::{ dispatch_meeting_stone_action, MeetingStoneActionOutcome, MeetingStoneActionStore, MeetingStoneOutcome, MeetingStonePlayer, SeekerFacts, }; +#[cfg(test)] +pub(crate) use melee::tests::InMemoryMeleeActions; pub(crate) use melee::{ dispatch_melee_action, MeleeActionOutcome, MeleeActionPlayer, MeleeActionStore, }; @@ -84,17 +94,21 @@ pub(crate) use member_stats::{ dispatch_member_stats, member_stats_tick, MemberPresence, MemberStatsOutcome, MemberStatsPlayer, MemberStatsRecord, MemberStatsStore, }; -pub(crate) use query::handle_query; +pub(crate) use query::{handle_query, NpcStore}; pub(crate) use quest::{ dispatch_quest_action, quest_giver_menu, QuestActionOutcome, QuestActionPlayer, QuestActionStore, }; +#[cfg(test)] +pub(crate) use taxi::tests::InMemoryTaxiActions; pub(crate) use taxi::{ dispatch_taxi_action, queue_reply_then_arm, TaxiActionOutcome, TaxiActionPlayer, TaxiActionStore, }; -pub(crate) use trade::handle_trade; -pub(crate) use trainer::{handle_trainer, TrainerBuyOutcome}; +pub(crate) use trade::{handle_trade, TradeStore}; +pub(crate) use trainer::{handle_trainer, TrainerBuyOutcome, TrainerStore}; +#[cfg(test)] +pub(crate) use vendor::tests::InMemoryVendorActions; pub(crate) use vendor::{ dispatch_vendor_action, VendorActionOutcome, VendorActionPlayer, VendorActionStore, }; diff --git a/gateway/src/world/handlers/query.rs b/gateway/src/world/handlers/query.rs index 701a7e9b9..7de7b3c0b 100644 --- a/gateway/src/world/handlers/query.rs +++ b/gateway/src/world/handlers/query.rs @@ -7,13 +7,87 @@ use super::taxi::open_taxi_outbound; use super::vendor::{vendor_has_stock, vendor_open_outbound}; use lyracore_shared::chat::broadcast_chat; +/// 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). + fn creature_template(&self, entry: u32) -> Result>; + + /// Resolve the public name of a live pet for an in-world requester. + fn pet_name( + &self, + requester_guid: u64, + pet_number: u32, + pet_guid: u64, + ) -> Result>; + + /// Look up a gameobject template by entry to answer `CMSG_GAMEOBJECT_QUERY`. + fn gameobject_template(&self, entry: u32) -> Result>; + + /// The `type_id` of a spawned GameObject by its live guid. `CMSG_GAMEOBJ_USE` sends questgivers + /// to the quest window, chests to the loot-window lifecycle, and every other type to the general + /// use path. + 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<()>; + + /// Standing-derived reaction gate: does this NPC refuse `player_guid` its + /// interaction WINDOW? Rep-bar factions refuse at Unfriendly-or-below standing; bar-less + /// factions fall back to the FactionTemplate hostility masks. Fail-open on missing data. + /// Gossip, trainer and banker ask here; the migrated families ask the same read through their + /// own traits (`QuestActionStore::giver_refuses_interaction`, + /// `VendorActionStore::vendor_refuses_interaction`). + fn npc_refuses_interaction(&self, npc_guid: u64, player_guid: u64) -> Result; + + /// Bind the caller's hearthstone home to their current position (innkeeper gossip "Make this inn + /// your home."). No args — the module resolves the caller via `ctx.sender`. + fn bind_home(&self, account_id: u64, self_guid: u64) -> Result<()>; + + /// Does the NPC at `guid` carry the innkeeper flag? Gates the "Make this inn your home." gossip + /// option + the bind select. + fn npc_is_innkeeper(&self, guid: u64) -> Result; + + /// Resolve the `title_text_id` to embed in `SMSG_GOSSIP_MESSAGE` for the NPC at `guid`. + /// Looks up `game_gossip_menu` by creature entry; falls back to 1 (generic greeting). + fn npc_gossip_text_id(&self, npc_guid: u64) -> u32; + + /// Look up the full weighted greeting (all 8 `npc_text` slots) for a `text_id`. + /// Returns `None` when no imported `game_npc_text` row exists (the gateway falls back to the + /// generic greeting string). + fn npc_text_for_id(&self, text_id: u32) -> Option; + + /// The imported gossip menu options for the NPC at `guid`, sorted by + /// `option_index`, RAW/unfiltered by condition. Empty when nothing is imported for this creature + /// (the gateway falls back to the flag-derived vendor/innkeeper synthesis). + fn gossip_options(&self, npc_guid: u64) -> Result>; + + /// Validate a `CMSG_INSPECT` request: `target_guid` must be a real in-world player, on the + /// 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<()>; + + /// 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, + npc_guid: u64, + option_id: u32, + option_row_id: u32, + ) -> Result<()>; +} + /// The NPC's imported gossip options, condition-filtered against `player_guid`'s quest /// state — the SINGLE chokepoint both `CMSG_GOSSIP_HELLO` (render) and `CMSG_GOSSIP_SELECT_OPTION` /// (re-derive the click) call, so the two can never disagree about which options are visible (the /// "HELLO/SELECT_OPTION alignment" trap: a click's `gossip_list_id` indexes into whatever list HELLO /// actually sent, so SELECT must reproduce that exact list, not just re-read the raw unfiltered rows). /// Preserves `option_index` order (already sorted by the store read). -fn filtered_gossip_options( +fn filtered_gossip_options< + St: CharacterStore + NpcStore + QuestActionStore + TrainerStore + ?Sized, +>( store: &St, npc_guid: u64, player_guid: u64, @@ -52,7 +126,7 @@ 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. -fn speak_nearby( +fn speak_nearby( tx: &SessionTx, store: &St, conn: &WorldConn, @@ -86,7 +160,22 @@ fn speak_nearby( /// the social tier (say / yell / `/e` chat + text emotes), grouped as the stateless /// request→reply / broadcast opcodes. #[allow(clippy::too_many_lines)] // One arm per query and social opcode. -pub(crate) fn handle_query( +pub(crate) fn handle_query< + St: CastStore + + CharacterStore + + GuildActionStore + + NpcStore + + PartyStore + + QuestActionStore + + SessionStore + + ShardRoutingStore + + SocialStore + + SpeechStore + + TaxiActionStore + + TrainerStore + + VendorActionStore + + ?Sized, +>( tx: &SessionTx, store: &St, conn: &mut WorldConn, diff --git a/gateway/src/world/handlers/quest.rs b/gateway/src/world/handlers/quest.rs index 69cb68092..8b262ff83 100644 --- a/gateway/src/world/handlers/quest.rs +++ b/gateway/src/world/handlers/quest.rs @@ -3,7 +3,7 @@ //! `dispatch_quest_action`; the gameobject giver, the item-started quest, the world-entry //! descriptor block and the gossip quest section call the shared builders here rather than //! reaching for the store. Every quest read and reducer the world session needs lives on -//! `QuestActionStore`; `WorldStore` carries none of them. The stdb-tier relays in +//! `QuestActionStore`; no other Store family carries them. The stdb-tier relays in //! `subscriptions.rs` (quest-log sync, the shared-quest details screen) sit below the session and //! render their own copies from the same `codec` builders these functions use. diff --git a/gateway/src/world/handlers/taxi.rs b/gateway/src/world/handlers/taxi.rs index 7151e790a..5c78c9240 100644 --- a/gateway/src/world/handlers/taxi.rs +++ b/gateway/src/world/handlers/taxi.rs @@ -183,7 +183,7 @@ pub(crate) fn dispatch_taxi_action( } #[cfg(test)] -mod tests { +pub(super) mod tests { use super::*; use std::sync::Mutex; use wow_world_messages::{ @@ -192,15 +192,18 @@ mod tests { }; #[derive(Default)] - struct InMemoryTaxiActions { - calls: Mutex>, - status: Mutex>, - map: Mutex>, - activation: Mutex, - activation_inputs: Mutex>, - fail: bool, - arm_tx_probe: Mutex>, - arm_observed_depth: Mutex>, + pub(crate) struct InMemoryTaxiActions { + pub(crate) calls: Mutex>, + pub(crate) status: Mutex>, + pub(crate) map: Mutex>, + pub(crate) activation: Mutex, + pub(crate) activation_inputs: Mutex>, + pub(crate) fail: bool, + /// The one (source, destination) node pair the Module accepts; any other pair answers + /// `ACTIVATE_NO_SUCH_PATH`. `None` accepts every pair. + pub(crate) route: Option<(u32, u32)>, + pub(crate) arm_tx_probe: Mutex>, + pub(crate) arm_observed_depth: Mutex>, } impl TaxiActionStore for InMemoryTaxiActions { @@ -216,7 +219,7 @@ mod tests { if self.fail { anyhow::bail!("offline") } - Ok(*self.status.lock().unwrap()) + Ok((*self.status.lock().unwrap()).filter(|view| view.npc_guid == npc_guid)) } fn open_taxi( @@ -231,7 +234,12 @@ mod tests { if self.fail { anyhow::bail!("offline") } - Ok(self.map.lock().unwrap().clone()) + Ok(self + .map + .lock() + .unwrap() + .clone() + .filter(|view| view.npc_guid == npc_guid)) } fn activate_taxi( @@ -254,6 +262,14 @@ mod tests { if self.fail { anyhow::bail!("offline") } + if self + .route + .is_some_and(|route| route != (source_client_node_id, destination_client_node_id)) + { + return Ok(codec::TaxiActivationResult { + result_code: lyracore_shared::constants::taxi_protocol::ACTIVATE_NO_SUCH_PATH, + }); + } Ok(*self.activation.lock().unwrap()) } diff --git a/gateway/src/world/handlers/trade.rs b/gateway/src/world/handlers/trade.rs index fd2b71484..14eca1de4 100644 --- a/gateway/src/world/handlers/trade.rs +++ b/gateway/src/world/handlers/trade.rs @@ -8,7 +8,50 @@ use super::super::social::self_guid; use super::super::*; -pub(crate) fn handle_trade( +/// 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). +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<()>; + + /// `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<()>; + + /// `CMSG_CANCEL_TRADE` — tear the caller's Trade Session down (`TradeCanceled` to both). + fn cancel_trade(&self, account_id: u64, self_guid: u64) -> 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<()>; + + /// `CMSG_CLEAR_TRADE_ITEM`. + fn clear_trade_item(&self, account_id: u64, self_guid: u64, 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<()>; + + /// `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<()>; + + /// `CMSG_UNACCEPT_TRADE`, withdraw an accept; partner hears `BackToTrade`. + fn unaccept_trade(&self, account_id: u64, self_guid: u64) -> Result<()>; + + /// `CMSG_BUSY_TRADE`, decline a pending proposal as busy; initiator hears `Busy`. + fn busy_trade(&self, account_id: u64, self_guid: u64) -> Result<()>; + + /// `CMSG_IGNORE_TRADE`, decline via ignore; initiator hears `IgnoreYou`. + fn ignore_trade(&self, account_id: u64, self_guid: u64) -> Result<()>; +} + +pub(crate) fn handle_trade( _tx: &SessionTx, store: &St, conn: &mut WorldConn, diff --git a/gateway/src/world/handlers/trainer.rs b/gateway/src/world/handlers/trainer.rs index 6007151e5..be61585a3 100644 --- a/gateway/src/world/handlers/trainer.rs +++ b/gateway/src/world/handlers/trainer.rs @@ -5,6 +5,93 @@ use super::super::*; use lyracore_shared::trainer::TrainerRefusal; use wow_world_messages::vanilla::TrainingFailureReason; +/// Trainer windows, talents, and the action-bar and reputation settings. +pub(crate) trait TrainerStore: Send + Sync { + /// Does this trainer serve `player_guid`'s class? Gates the window and the "train" gossip + /// option through the same predicate the module buys with. Fail-open on missing data. + fn trainer_serves(&self, player_guid: u64, trainer_guid: u64) -> Result; + + /// The spells a class trainer (`trainer_guid`) teaches, each pre-folded with the player's level + + /// known-state for the `SMSG_TRAINER_LIST` Green/Red/Gray rendering (`CMSG_TRAINER_LIST`). + fn trainer_list( + &self, + player_guid: u64, + trainer_guid: u64, + ) -> Result>; + + /// Buy/learn `spell_id` from trainer `trainer_guid` (`CMSG_TRAINER_BUY_SPELL`). The module gates it + /// (range / level / cost / not-already-known) and a Refusal comes back as an outcome; `Err` means + /// the durable result is unknown. + fn buy_trainer_spell( + &self, + account_id: u64, + self_guid: u64, + trainer_guid: u64, + spell_id: u32, + ) -> Result; + + /// The skill line a trainer offering teaches (`game_trainer_spell.learn_skill_line`), or 0 for an + /// ordinary spell offering. The buy path reads it to tell a SKILL purchase from a SPELL purchase: + /// a riding offering's id is a marker with no Spell.dbc row, so echoing it as a learned spell would + /// push the client something it cannot resolve. 0 on any missing read (fail toward the spell echo, + /// which is the pre-existing behaviour). + fn trainer_offer_skill_line(&self, trainer_guid: u64, spell_id: u32) -> u32; + + /// Return the `grant_spell_id` for `talent_id` (0 = passive, no ability granted), so the gateway + /// can push `SMSG_LEARNED_SPELL` for ability talents after a successful `learn_talent`. + fn talent_grant_spell(&self, talent_id: u32) -> u32; + + /// 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, + button: u8, + action: u32, + action_type: u8, + ) -> Result<()>; + + /// Persist the rep pane's At-War checkbox (`CMSG_SET_FACTION_ATWAR`). + /// `reputation_index` is the client's 0..63 rep-array slot, NOT a faction id. + fn set_faction_at_war( + &self, + account_id: u64, + self_guid: u64, + reputation_index: u32, + at_war: bool, + ) -> Result<()>; + + /// Talent-pane sync after a successful `learn_talent`: `(teach_spell, superseded_prev, + /// points_remaining)` — the rank-spell to relay as LEARNED/SUPERCEDED (the 1.12 TalentFrame + /// derives shown ranks from known rank-spells) and the live PLAYER_CHARACTER_POINTS1 value + /// (earned − spent). `talent_id = 0` → just the points. + fn talent_pane_sync(&self, character_guid: u64, talent_id: u32) -> (u32, u32, u32); + + /// Sum of the character's spent talent ranks — non-zero gates the login points correction. + fn talent_points_spent(&self, character_guid: u64) -> u32; + + /// 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<()>; + + /// Respec at `trainer_guid` (the "I wish to unlearn my talents." gossip option, gated to level + /// 10+ by `filtered_gossip_options`). Errors (out of range / not enough gold) are + /// per-action; the caller just closes the gossip window either way. + fn reset_talents(&self, account_id: u64, self_guid: u64, trainer_guid: u64) -> Result<()>; + + /// 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 + /// SMSG_LEARNED_SPELL books the granted spell, never the wrapper. + fn resolve_learn_target(&self, spell_id: u32) -> u32; + + /// The KNOWN rank `new_spell` supersedes — Some(prev) drives SMSG_SUPERCEDED_SPELL on a buy. + fn superseded_old_rank(&self, new_spell: u32, player_guid: u64) -> Option; + + /// A character's live presence `(online, level, class, zone_id)` on THIS Shard only. `None` if + /// the guid doesn't resolve to any character here. + fn character_presence(&self, guid: u64) -> Result>; +} + /// 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)] @@ -34,7 +121,7 @@ impl From for TrainerBuyOutcome { /// `SMSG_TRAINER_BUY_*` + a live `SMSG_LEARNED_SPELL` so it hits the action bar without a relog). /// Needs the in-world player guid (a trainer is only clicked in-world); in CharSelect the opcodes /// pass through. A buy Refusal is per-action — surfaced as `SMSG_TRAINER_BUY_FAILED`. -pub(crate) fn handle_trainer( +pub(crate) fn handle_trainer( tx: &SessionTx, store: &St, conn: &mut WorldConn, @@ -272,7 +359,7 @@ fn teaches_armor_proficiency(spell_id: u32) -> bool { /// re-tints its bags without a relog. Read after the buy: the mask states what the Character knows /// now, not what the purchase was meant to grant, so a buy the Module only half-applied never /// tints an item the equip Gate would still refuse. -fn send_armor_proficiency( +fn send_armor_proficiency( tx: &SessionTx, store: &St, self_guid: u64, diff --git a/gateway/src/world/handlers/vendor.rs b/gateway/src/world/handlers/vendor.rs index f11756292..5555ef0bc 100644 --- a/gateway/src/world/handlers/vendor.rs +++ b/gateway/src/world/handlers/vendor.rs @@ -447,7 +447,7 @@ fn render_buyback_view( } #[cfg(test)] -mod tests { +pub(super) mod tests { use super::*; use std::sync::Mutex; use wow_world_messages::vanilla::{ @@ -456,7 +456,7 @@ mod tests { }; #[derive(Debug, Eq, PartialEq)] - struct BuyRequest { + pub(crate) struct BuyRequest { account_id: u64, actor_guid: u64, vendor_guid: u64, @@ -465,24 +465,25 @@ mod tests { } #[derive(Default)] - struct InMemoryVendorActions { - stock_requests: Mutex>, - gate_requests: Mutex>, - buy_requests: Mutex>, - repair_requests: Mutex>, - sell_requests: Mutex>, - stock: Vec, - refuses: bool, - stock_error: Option, - gate_error: Option, - buy_error: Option, - ring: Vec<(u32, u32, u32, u32)>, - random_properties: Vec<(u32, [u32; 3])>, - item_slots: Vec<(u64, u8)>, - repair_error: Option, - sell_error: Option, - buyback_requests: Mutex>, - buyback_error: Option, + pub(crate) struct InMemoryVendorActions { + 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) stock: Vec, + pub(crate) refuses: bool, + 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, } impl VendorActionStore for InMemoryVendorActions { @@ -527,7 +528,7 @@ mod tests { } fn buyback_slots(&self, _player_guid: u64) -> Vec<(u32, u32, u32, u32)> { - self.ring.clone() + self.ring.lock().unwrap().clone() } fn random_property_enchant_ids(&self, random_property_id: u32) -> [u32; 3] { @@ -589,10 +590,14 @@ mod tests { .lock() .unwrap() .push((account_id, self_guid, vendor_guid, slot)); - match &self.buyback_error { - Some(error) => Err(anyhow::anyhow!("{error}")), - None => Ok(()), + if let Some(error) = &self.buyback_error { + return Err(anyhow::anyhow!("{error}")); } + let mut ring = self.ring.lock().unwrap(); + if usize::from(slot) < ring.len() { + ring.remove(usize::from(slot)); + } + Ok(()) } } @@ -917,7 +922,7 @@ mod tests { let ring = vec![(2589, 1, 120, 0)]; let actions = InMemoryVendorActions { item_slots: vec![(ITEM, 30)], - ring: ring.clone(), + ring: Mutex::new(ring.clone()), ..Default::default() }; @@ -1064,7 +1069,7 @@ mod tests { fn the_buyback_view_renders_one_fabricated_item_per_ring_entry_plus_the_descriptor_update() { let ring = vec![(2589, 5, 120, 117), (4540, 1, 30, 0)]; let actions = InMemoryVendorActions { - ring: ring.clone(), + ring: Mutex::new(ring.clone()), ..Default::default() }; @@ -1076,7 +1081,7 @@ mod tests { #[test] fn a_buyback_entry_with_a_random_property_fills_its_enchantment_slots_after_its_create() { let actions = InMemoryVendorActions { - ring: vec![(2589, 1, 120, 22)], + ring: Mutex::new(vec![(2589, 1, 120, 22)]), random_properties: vec![(22, [73, 0, 0])], ..Default::default() }; @@ -1101,7 +1106,7 @@ mod tests { fn a_full_ring_renders_all_thirteen_wire_slots_from_the_shared_base() { let ring: Vec<(u32, u32, u32, u32)> = (0..12).map(|i| (100 + i, 1, 10 * i, 0)).collect(); let actions = InMemoryVendorActions { - ring: ring.clone(), + ring: Mutex::new(ring.clone()), ..Default::default() }; @@ -1130,7 +1135,7 @@ mod tests { fn a_login_replay_of_a_persisted_ring_renders_it_like_an_in_session_refresh() { let ring = vec![(2589, 5, 120, 0)]; let actions = InMemoryVendorActions { - ring: ring.clone(), + ring: Mutex::new(ring.clone()), ..Default::default() }; @@ -1183,7 +1188,7 @@ mod tests { fn a_successful_buyback_returns_the_full_rebuilt_view() { let ring = vec![(2589, 5, 120, 0), (4540, 1, 30, 0)]; let actions = InMemoryVendorActions { - ring: ring.clone(), + ring: Mutex::new(ring.clone()), ..Default::default() }; @@ -1194,13 +1199,13 @@ mod tests { VendorActionOutcome::Handled { outbound } => outbound, VendorActionOutcome::PassThrough(_) => panic!("buyback must be handled"), }; - assert_renders_ring(&outbound, &ring); + assert_renders_ring(&outbound, &ring[1..]); } #[test] fn a_buyback_without_an_actor_falls_back_to_the_legacy_zero_actor_and_renders_no_view() { let actions = InMemoryVendorActions { - ring: vec![(2589, 5, 120, 0)], + ring: Mutex::new(vec![(2589, 5, 120, 0)]), ..Default::default() }; let player = VendorActionPlayer { diff --git a/gateway/src/world/item_tests.rs b/gateway/src/world/item_tests.rs new file mode 100644 index 000000000..071847de9 --- /dev/null +++ b/gateway/src/world/item_tests.rs @@ -0,0 +1,82 @@ +//! Item actions through their dispatcher. + +use super::handlers::InMemoryItemActions; +use super::*; + +const PLAYER: ItemActionPlayer = ItemActionPlayer { + account_id: 7, + self_guid: Some(1), +}; + +/// Dispatch one item message and return the packets the session would send for it. +fn try_run( + actions: &InMemoryItemActions, + player: ItemActionPlayer, + msg: impl Into, +) -> Result> { + match dispatch_item_action(actions, player, msg.into())? { + ItemActionOutcome::Handled { outbound } => Ok(outbound), + ItemActionOutcome::PassThrough(_) => panic!("the item dispatcher passed the message on"), + } +} + +/// Slot 24 is a backpack slot (>= 23); bag 255 = INVENTORY_SLOT_BAG_0 (main bag). +fn autoequip(source_slot: u8) -> CMSG_AUTOEQUIP_ITEM { + CMSG_AUTOEQUIP_ITEM { + source_bag: 255, + source_slot, + } +} + +#[test] +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))), + ..Default::default() + }; + for slot in [24, 25] { + let sent = try_run(&actions, PLAYER, autoequip(slot)).unwrap(); + assert!( + matches!( + sent.as_slice(), + [Outbound::One( + ServerOpcodeMessage::SMSG_INVENTORY_CHANGE_FAILURE(_) + )] + ), + "slot {slot}: expected one SMSG_INVENTORY_CHANGE_FAILURE" + ); + } +} + +#[test] +fn item_action_before_player_login_is_handled_without_panicking() { + // 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. + 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()); +} + +#[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 + // translated into gameplay feedback. + let actions = InMemoryItemActions { + equip_result: Some(Err( + "equip_item reducer transport disconnected: channel closed".into(), + )), + ..Default::default() + }; + let error = try_run(&actions, PLAYER, autoequip(24)) + .err() + .expect("a disconnected item reducer transport must be session-fatal"); + assert!(format!("{error:#}").contains("reducer transport disconnected")); +} diff --git a/gateway/src/world/loot.rs b/gateway/src/world/loot.rs index e2647e611..c7391de53 100644 --- a/gateway/src/world/loot.rs +++ b/gateway/src/world/loot.rs @@ -1,15 +1,16 @@ //! Realm loot routing. See `docs/realm-loot-routing.md` for the contract and rationale. +use crate::world::ShardRoutingStore; use anyhow::Result; use super::{LootActionStatus, WorldStore}; use lyracore_shared::loot_roll::loot_op; /// One unresolved loot roll a world shard has created but not yet had promoted onto realm-core — -/// [`WorldStore::pending_local_rolls`]'s answer, and [`relay_tick`]'s promotion input. +/// [`LootRollStore::pending_local_rolls`]'s answer, and [`relay_tick`]'s promotion input. #[derive(Clone, Debug, PartialEq, Eq)] pub struct PendingLootRoll { - /// This shard's own row id — what [`WorldStore::clear_promoted_loot_roll`] addresses. + /// This shard's own row id — what [`LootRollStore::clear_promoted_loot_roll`] addresses. pub roll_id: u64, pub corpse_guid: u64, pub slot: u8, @@ -22,6 +23,82 @@ pub struct PendingLootRoll { pub promotion_source: spacetimedb_sdk::Identity, } +/// 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. + #[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( + &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<()>; + + /// Cast one player vote on realm-core. The Store returns a typed gameplay answer while keeping + /// failures with an unknown durable result as `Err`. + fn realm_loot_vote( + &self, + corpse_guid: u64, + slot: u8, + actor_guid: u64, + 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 + /// method. + fn pending_local_rolls(&self) -> Result>; + + /// `settle_loot_roll` — grant a resolved roll's item on THIS world shard, if it holds the + /// matching corpse row. A shard that does not hold the corpse is unaffected: the module's own + /// `withheld` guard makes a wrong-shard call harmless. + fn settle_loot_roll(&self, corpse_guid: u64, slot: u8, winner_guid: u64) -> Result<()>; + + /// `clear_promoted_loot_roll` — delete a staging roll's rows on THIS world shard, once the relay + /// has promoted it onto realm-core. + fn clear_promoted_loot_roll(&self, roll_id: u64) -> Result<()>; + + // Same shape as `Coordinator::loot_won_since` (watermark + `(corpse, slot, winner)` triples) — the trait mirrors the read it fronts. + #[allow(clippy::type_complexity)] + /// Every `ROLL_WON` `game_group_event` row realm-core has pushed with an id greater than + /// `after_id` — `(corpse_guid, slot, winner_guid)` triples, plus the new high-water mark to + /// poll from next. Called on the **realm-core** handle. + fn loot_won_since(&self, after_id: u64) -> Result<(u64, Vec<(u64, u8, u64)>)>; + + /// `CMSG_LOOT_ROLL` — record the caller's need/greed/pass vote. + fn loot_roll( + &self, + account_id: u64, + self_guid: u64, + corpse_guid: u64, + loot_slot: u32, + vote: u8, + ) -> Result; + + /// `CMSG_LOOT_MASTER_GIVE` — the master looter assigns an above- + /// threshold row to `target_guid`. + fn loot_master_give( + &self, + account_id: u64, + self_guid: u64, + corpse_guid: u64, + loot_slot: u8, + target_guid: u64, + ) -> Result; +} + /// Route `CMSG_LOOT_ROLL` for the session that owns `self_guid`. /// /// Unsharded → the pre-realm-core path, verbatim: `loot_roll` on the player's own connection. Sharded → @@ -31,7 +108,7 @@ pub struct PendingLootRoll { /// /// A client can vote as soon as `SMSG_LOOT_START_ROLL` arrives, before the next [`relay_tick`]. /// Realm-core refuses a vote for a roll it does not hold, so pending promotions are flushed first. -pub(crate) fn run_vote( +pub(crate) fn run_vote( store: &St, account_id: u64, self_guid: u64, @@ -101,7 +178,7 @@ fn promote_one(shard: &dyn WorldStore, realm: &dyn WorldStore, roll: &PendingLoo /// reached leaves its own pending rolls unpromoted for THIS call, and the periodic relay retries them /// on its own cadence — a flush that failed must not turn a vote or LEAVE/UNINVITE that would /// otherwise succeed into an error. -pub(crate) fn flush_pending_promotions( +pub(crate) fn flush_pending_promotions( store: &St, realm: &dyn WorldStore, ) { @@ -139,7 +216,7 @@ pub(crate) fn flush_pending_promotions( /// Ordinary promotion latency (a roll NOT caught by [`flush_pending_promotions`]) is bounded by this /// function's own caller's poll interval, not by anything in here — see this module's doc for why /// that caller is a poll rather than a persistent callback. -pub(crate) fn relay_tick(store: &St, won_watermark: &mut u64) { +pub(crate) fn relay_tick(store: &St, won_watermark: &mut u64) { let Some(realm) = store.realm_store() else { return; }; diff --git a/gateway/src/world/loot_tests.rs b/gateway/src/world/loot_tests.rs index 077b70f8b..0f1015c97 100644 --- a/gateway/src/world/loot_tests.rs +++ b/gateway/src/world/loot_tests.rs @@ -21,7 +21,7 @@ const SOURCE: spacetimedb_sdk::Identity = spacetimedb_sdk::Identity::from_byte_a /// realm-core (there isn't one) never hears about it. #[test] fn an_unsharded_store_routes_the_vote_through_the_players_own_reducer() { - let store = InMemoryStore::default(); + let store = WorldFake::default(); loot::run_vote( &store, 7, @@ -32,12 +32,12 @@ fn an_unsharded_store_routes_the_vote_through_the_players_own_reducer() { ) .unwrap(); assert_eq!( - store.loot_rolls.lock().unwrap().clone(), + store.loot_roll.loot_rolls.lock().unwrap().clone(), vec![(500, 2, lyracore_shared::loot_roll::vote_kind::NEED)], "unsharded, the vote must land on the SHARD's own `loot_roll` reducer" ); assert!( - store.realm_loot_ops.lock().unwrap().is_empty(), + store.loot_roll.realm_loot_ops.lock().unwrap().is_empty(), "unsharded, nothing must be routed to a realm-core plane" ); } @@ -58,7 +58,7 @@ fn a_sharded_store_routes_the_vote_to_realm_core_with_the_authenticated_guid() { ) .unwrap(); assert_eq!( - realm.realm_loot_ops.lock().unwrap().clone(), + realm.loot_roll.realm_loot_ops.lock().unwrap().clone(), vec![( loot_op::VOTE, 500, @@ -75,7 +75,7 @@ fn a_sharded_store_routes_the_vote_to_realm_core_with_the_authenticated_guid() { "sharded, the vote must reach REALM-CORE, addressed by the AUTHENTICATED guid" ); assert!( - world.loot_rolls.lock().unwrap().is_empty(), + world.loot_roll.loot_rolls.lock().unwrap().is_empty(), "a multi-database gateway must not run the vote through the shard's own reducer — that is \ exactly the shard-local behaviour the realm-wide loot relay removes for a promoted roll" ); @@ -86,7 +86,7 @@ fn a_sharded_store_routes_the_vote_to_realm_core_with_the_authenticated_guid() { #[test] fn a_vote_on_a_staged_roll_reaches_realm_core_after_its_promotion() { let (realm, world, _instances, _calls) = party_topology(); - *world.pending_rolls.lock().unwrap() = vec![loot::PendingLootRoll { + *world.loot_roll.pending_rolls.lock().unwrap() = vec![loot::PendingLootRoll { promotion_source: SOURCE, roll_id: 77, corpse_guid: 500, @@ -108,6 +108,7 @@ fn a_vote_on_a_staged_roll_reaches_realm_core_after_its_promotion() { .unwrap(); let ops: Vec<(u8, u64, u8)> = realm + .loot_roll .realm_loot_ops .lock() .unwrap() @@ -115,7 +116,7 @@ fn a_vote_on_a_staged_roll_reaches_realm_core_after_its_promotion() { .map(|op| (op.0, op.1, op.2)) .collect(); assert_eq!(ops, vec![(loot_op::START, 500, 2), (loot_op::VOTE, 500, 2)]); - assert_eq!(*world.cleared_rolls.lock().unwrap(), vec![77]); + assert_eq!(*world.loot_roll.cleared_rolls.lock().unwrap(), vec![77]); } // ---- `relay_tick` (promotion + settlement) ---- @@ -124,11 +125,14 @@ fn a_vote_on_a_staged_roll_reaches_realm_core_after_its_promotion() { /// and `relay_tick` returns before `world_stores()` is ever consulted. #[test] fn an_unsharded_store_does_nothing_even_with_pending_rolls_on_a_connected_peer() { - let peer = std::sync::Arc::new(InMemoryStore { - shard: "peer".into(), + let peer = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "peer".into(), + ..Default::default() + }, ..Default::default() }); - *peer.pending_rolls.lock().unwrap() = vec![loot::PendingLootRoll { + *peer.loot_roll.pending_rolls.lock().unwrap() = vec![loot::PendingLootRoll { promotion_source: SOURCE, roll_id: 1, corpse_guid: 500, @@ -138,14 +142,17 @@ fn an_unsharded_store_does_nothing_even_with_pending_rolls_on_a_connected_peer() recipients: vec![GINGER], random_property_id: 0, }]; - let store = InMemoryStore { - peers: std::sync::Mutex::new(vec![peer.clone()]), + let store = WorldFake { + topology: TopologyState { + peers: std::sync::Mutex::new(vec![peer.clone()]), + ..Default::default() + }, ..Default::default() }; let mut watermark = 0u64; loot::relay_tick(&store, &mut watermark); assert!( - peer.cleared_rolls.lock().unwrap().is_empty(), + peer.loot_roll.cleared_rolls.lock().unwrap().is_empty(), "no realm-core ⇒ nothing promoted, ever" ); assert_eq!( @@ -160,7 +167,7 @@ fn an_unsharded_store_does_nothing_even_with_pending_rolls_on_a_connected_peer() #[test] fn relay_tick_promotes_a_staging_roll_and_clears_it() { let (realm, world, instances, _calls) = party_topology(); - *world.pending_rolls.lock().unwrap() = vec![loot::PendingLootRoll { + *world.loot_roll.pending_rolls.lock().unwrap() = vec![loot::PendingLootRoll { promotion_source: SOURCE, roll_id: 77, corpse_guid: 500, @@ -173,7 +180,7 @@ fn relay_tick_promotes_a_staging_roll_and_clears_it() { let mut watermark = 0u64; loot::relay_tick(world.as_ref(), &mut watermark); assert_eq!( - realm.realm_loot_ops.lock().unwrap().clone(), + realm.loot_roll.realm_loot_ops.lock().unwrap().clone(), vec![( loot_op::START, 500, @@ -190,27 +197,33 @@ fn relay_tick_promotes_a_staging_roll_and_clears_it() { "the promoted roll must carry the ORIGINAL deadline and the FULL recipient snapshot" ); assert_eq!( - world.cleared_rolls.lock().unwrap().clone(), + world.loot_roll.cleared_rolls.lock().unwrap().clone(), vec![77], "the staging copy must be cleared on the shard that created it, by its own roll id" ); assert!( - instances.cleared_rolls.lock().unwrap().is_empty(), + instances.loot_roll.cleared_rolls.lock().unwrap().is_empty(), "a shard with no pending rolls of its own must not be touched" ); } #[test] fn a_refused_promotion_keeps_its_source_identity_and_staging_row_for_retry() { - let realm = std::sync::Arc::new(InMemoryStore { - realm_loot_op_error: Some("another Loot Roll is active in this slot".into()), + let realm = std::sync::Arc::new(WorldFake { + loot_roll: LootRollState { + realm_loot_op_error: Some("another Loot Roll is active in this slot".into()), + ..Default::default() + }, ..Default::default() }); - let world = std::sync::Arc::new(InMemoryStore { - realm: Some(realm.clone()), + let world = std::sync::Arc::new(WorldFake { + topology: TopologyState { + realm: Some(realm.clone()), + ..Default::default() + }, ..Default::default() }); - *world.peers.lock().unwrap() = vec![world.clone()]; + *world.topology.peers.lock().unwrap() = vec![world.clone()]; let pending = loot::PendingLootRoll { roll_id: 78, corpse_guid: 500, @@ -221,13 +234,16 @@ fn a_refused_promotion_keeps_its_source_identity_and_staging_row_for_retry() { random_property_id: 117, promotion_source: SOURCE, }; - *world.pending_rolls.lock().unwrap() = vec![pending.clone()]; + *world.loot_roll.pending_rolls.lock().unwrap() = vec![pending.clone()]; let mut watermark = 0; loot::relay_tick(world.as_ref(), &mut watermark); loot::relay_tick(world.as_ref(), &mut watermark); - assert_eq!(*world.pending_rolls.lock().unwrap(), vec![pending]); - assert!(world.cleared_rolls.lock().unwrap().is_empty()); - let attempts = realm.realm_loot_ops.lock().unwrap(); + assert_eq!( + *world.loot_roll.pending_rolls.lock().unwrap(), + vec![pending] + ); + assert!(world.loot_roll.cleared_rolls.lock().unwrap().is_empty()); + let attempts = realm.loot_roll.realm_loot_ops.lock().unwrap(); assert_eq!(attempts.len(), 2); assert_eq!(attempts[0], attempts[1]); assert_eq!(attempts[0].8, 117); @@ -241,15 +257,15 @@ fn a_refused_promotion_keeps_its_source_identity_and_staging_row_for_retry() { #[test] fn relay_tick_settles_every_connected_shard_for_a_resolved_roll() { let (realm, world, instances, _calls) = party_topology(); - *realm.won_events.lock().unwrap() = vec![(500, 2, GINGER)]; + *realm.loot_roll.won_events.lock().unwrap() = vec![(500, 2, GINGER)]; let mut watermark = 0u64; loot::relay_tick(world.as_ref(), &mut watermark); assert_eq!( - world.settled_rolls.lock().unwrap().clone(), + world.loot_roll.settled_rolls.lock().unwrap().clone(), vec![(500, 2, GINGER)] ); assert_eq!( - instances.settled_rolls.lock().unwrap().clone(), + instances.loot_roll.settled_rolls.lock().unwrap().clone(), vec![(500, 2, GINGER)], "settlement must fan out to EVERY connected shard, not just the one that happens to hold \ the corpse — the relay cannot know which one that is in advance" @@ -262,7 +278,7 @@ fn relay_tick_settles_every_connected_shard_for_a_resolved_roll() { // A second tick with the SAME advanced watermark must not re-settle the same win. loot::relay_tick(world.as_ref(), &mut watermark); assert_eq!( - world.settled_rolls.lock().unwrap().len(), + world.loot_roll.settled_rolls.lock().unwrap().len(), 1, "an already-settled win must not be re-applied on the next tick" ); @@ -271,29 +287,46 @@ fn relay_tick_settles_every_connected_shard_for_a_resolved_roll() { #[test] fn a_failed_settlement_retries_after_the_shard_recovers() { let (realm, world, instances, _) = party_topology(); - let unavailable = std::sync::Arc::new(InMemoryStore { - shard: instances.shard.clone(), - settle_loot_roll_error: Some("Shard connection unavailable".into()), + let unavailable = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: instances.topology.shard.clone(), + ..Default::default() + }, + loot_roll: LootRollState { + settle_loot_roll_error: Some("Shard connection unavailable".into()), + ..Default::default() + }, ..Default::default() }); - *world.peers.lock().unwrap() = vec![unavailable.clone(), world.clone()]; + *world.topology.peers.lock().unwrap() = vec![unavailable.clone(), world.clone()]; let win = (500, 2, GINGER); - *realm.won_events.lock().unwrap() = vec![win]; + *realm.loot_roll.won_events.lock().unwrap() = vec![win]; let mut watermark = 0; loot::relay_tick(world.as_ref(), &mut watermark); - assert!(unavailable.settled_rolls.lock().unwrap().is_empty()); - assert_eq!(*world.settled_rolls.lock().unwrap(), vec![win]); + assert!(unavailable + .loot_roll + .settled_rolls + .lock() + .unwrap() + .is_empty()); + assert_eq!(*world.loot_roll.settled_rolls.lock().unwrap(), vec![win]); assert_eq!(watermark, 0, "the failed Shard still needs this result"); - *world.peers.lock().unwrap() = vec![instances.clone(), world.clone()]; + *world.topology.peers.lock().unwrap() = vec![instances.clone(), world.clone()]; loot::relay_tick(world.as_ref(), &mut watermark); - assert_eq!(*instances.settled_rolls.lock().unwrap(), vec![win]); + assert_eq!( + *instances.loot_roll.settled_rolls.lock().unwrap(), + vec![win] + ); assert_eq!(watermark, 1); loot::relay_tick(world.as_ref(), &mut watermark); - assert_eq!(*instances.settled_rolls.lock().unwrap(), vec![win]); + assert_eq!( + *instances.loot_roll.settled_rolls.lock().unwrap(), + vec![win] + ); } /// **AC: a disband-capable op promotes every connected shard's pending rolls BEFORE it reaches @@ -309,7 +342,7 @@ fn a_failed_settlement_retries_after_the_shard_recovers() { fn a_leave_flushes_every_connected_shards_pending_rolls_before_the_disband_reaches_realm_core() { let (realm, world, instances, calls) = party_topology(); form_split_party(&world, &instances); // Ginger (world) + Vim (instances) — a 2-member party - *world.pending_rolls.lock().unwrap() = vec![loot::PendingLootRoll { + *world.loot_roll.pending_rolls.lock().unwrap() = vec![loot::PendingLootRoll { promotion_source: SOURCE, roll_id: 99, corpse_guid: 500, @@ -325,7 +358,7 @@ fn a_leave_flushes_every_connected_shards_pending_rolls_before_the_disband_reach party::run(instances.as_ref(), 8, VIM, party::Op::Leave).expect("Vim leaves"); assert_eq!( - realm.realm_loot_ops.lock().unwrap().clone(), + realm.loot_roll.realm_loot_ops.lock().unwrap().clone(), vec![( loot_op::START, 500, @@ -342,7 +375,7 @@ fn a_leave_flushes_every_connected_shards_pending_rolls_before_the_disband_reach "the pending roll on the OTHER shard must be promoted before the disband, not left behind" ); assert_eq!( - world.cleared_rolls.lock().unwrap().clone(), + world.loot_roll.cleared_rolls.lock().unwrap().clone(), vec![99], "the staging copy must be cleared once promoted, same as a periodic relay tick would" ); @@ -373,7 +406,7 @@ fn a_leave_flushes_every_connected_shards_pending_rolls_before_the_disband_reach #[test] fn a_non_disband_capable_op_never_flushes_pending_rolls() { let (realm, world, _instances, _calls) = party_topology(); - *world.pending_rolls.lock().unwrap() = vec![loot::PendingLootRoll { + *world.loot_roll.pending_rolls.lock().unwrap() = vec![loot::PendingLootRoll { promotion_source: SOURCE, roll_id: 1, corpse_guid: 500, @@ -385,8 +418,156 @@ fn a_non_disband_capable_op_never_flushes_pending_rolls() { }]; party::run(world.as_ref(), 7, GINGER, party::Op::Invite(TRIN)).expect("the invite runs"); assert!( - realm.realm_loot_ops.lock().unwrap().is_empty(), + realm.loot_roll.realm_loot_ops.lock().unwrap().is_empty(), "an op that cannot shrink a group must not trigger a flush" ); - assert!(world.cleared_rolls.lock().unwrap().is_empty()); + assert!(world.loot_roll.cleared_rolls.lock().unwrap().is_empty()); +} + +#[test] +fn loot_roll_dispatches_the_corpse_slot_and_vote() { + let store = std::sync::Arc::new(quest_store()); + let (mut client, mut c_enc, _c_dec, server) = enter_world(store.clone(), 1); + CMSG_LOOT_ROLL { + item: Guid::new(60), + item_slot: 2, + vote: RollVote::Need, + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + CMSG_LOOT_MONEY {} + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + drop(client); + server.join().unwrap(); + assert_eq!( + store.loot_roll.loot_rolls.lock().unwrap().as_slice(), + &[(60, 2, RollVote::Need.as_int())] + ); +} + +#[test] +fn loot_master_give_dispatches_the_corpse_slot_and_target() { + let store = std::sync::Arc::new(quest_store()); + let (mut client, mut c_enc, _c_dec, server) = enter_world(store.clone(), 1); + CMSG_LOOT_MASTER_GIVE { + loot: Guid::new(60), + slot_id: 3, + player: Guid::new(9), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + CMSG_LOOT_MONEY {} + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + drop(client); + server.join().unwrap(); + assert_eq!( + store.loot_roll.loot_master_gives.lock().unwrap().as_slice(), + &[(60, 3, 9)] + ); +} + +#[test] +fn loot_roll_rejection_is_logged_and_ignored_not_session_fatal() { + // A rejection (no roll open / already voted / not eligible) must not tear the connection down — + // the SAME session keeps working afterward (mirrors take_loot's per-action ignore discipline). + let mut s = quest_store(); + s.loot_roll.loot_action_refusal = Some(LootRefusal::RollUnavailable); + let store = std::sync::Arc::new(s); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); + CMSG_LOOT_ROLL { + item: Guid::new(60), + item_slot: 2, + vote: RollVote::Greed, + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + // The session survives: a subsequent CMSG_LOOT still gets a normal reply. + CMSG_LOOT { + guid: Guid::new(61), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + let (op, _) = read_raw_frame(&mut client, &mut c_dec); + assert_eq!( + op, OP_LOOT_RESPONSE, + "the session must survive a rejected loot_roll" + ); + drop(client); + server.join().unwrap(); +} + +#[test] +fn loot_master_give_refusals_keep_the_world_session_alive() { + for refusal in [ + LootRefusal::NotMasterLooter, + LootRefusal::RecipientUnavailable, + LootRefusal::RecipientInventoryFull, + ] { + let mut s = quest_store(); + s.loot_roll.loot_action_refusal = Some(refusal); + let store = std::sync::Arc::new(s); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); + CMSG_LOOT_MASTER_GIVE { + loot: Guid::new(60), + slot_id: 3, + player: Guid::new(9), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + CMSG_LOOT { + guid: Guid::new(61), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + + let (op, _) = read_raw_frame(&mut client, &mut c_dec); + assert_eq!(op, OP_LOOT_RESPONSE, "{refusal:?}"); + drop(client); + server.join().unwrap(); + } +} + +#[test] +fn loot_roll_timeout_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()); + let store = std::sync::Arc::new(s); + let (mut client, mut c_enc, _c_dec, server) = enter_world(store, 1); + CMSG_LOOT_ROLL { + item: Guid::new(60), + item_slot: 2, + vote: RollVote::Greed, + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + drop(client); + + assert!( + server.join().is_err(), + "an unknown vote result must end 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()); + let store = std::sync::Arc::new(s); + let (mut client, mut c_enc, _c_dec, server) = enter_world(store, 1); + CMSG_LOOT_MASTER_GIVE { + loot: Guid::new(60), + slot_id: 3, + player: Guid::new(9), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + drop(client); + + assert!( + server.join().is_err(), + "an unknown master-loot result must end the World Session" + ); } diff --git a/gateway/src/world/loot_window_tests.rs b/gateway/src/world/loot_window_tests.rs new file mode 100644 index 000000000..01b329960 --- /dev/null +++ b/gateway/src/world/loot_window_tests.rs @@ -0,0 +1,136 @@ +//! GameObject use: its routing by GameObject type over a World Session, and the general-use path. + +use super::death_tests::handle_loot_fake::HandleLootFake; +use super::death_tests::{in_world_conn, run, SELF_GUID}; +use super::*; +use wow_world_messages::vanilla::CMSG_AUTOSTORE_LOOT_ITEM; + +#[test] +fn questgiver_gameobject_bypasses_the_chest_lifecycle() { + let mut s = quest_store(); + s.npc.gameobject_type = Some(lyracore_shared::constants::go_type::QUESTGIVER); + s.quest.quest_evals = vec![eval(1234, codec::ROLE_START, false, false)]; + s.quest.quest_details = vec![detail_view(1234, "A Threat Within")]; + s.loot_window + .corpse_loot_by_viewer + .insert(1, vec![(0, 2589, 1, 200, 0)]); + let store = std::sync::Arc::new(s); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); + + CMSG_GAMEOBJ_USE { + guid: Guid::new(68), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + CMSG_QUESTGIVER_STATUS_QUERY { + guid: Guid::new(50), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_QUESTGIVER_QUEST_DETAILS(details) => { + assert_eq!(details.quest_id, 1234) + } + other => panic!("expected quest details from a questgiver GameObject, got {other}"), + } + // The loot window would have answered before the status query's reply. + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_QUESTGIVER_STATUS(_) => {} + other => panic!("a questgiver GameObject opened a loot window: {other}"), + } + drop(client); + server.join().unwrap(); +} + +#[test] +fn chest_gameobject_use_opens_the_shared_loot_window_for_that_chest() { + let mut s = quest_store(); + s.npc.gameobject_type = Some(lyracore_shared::constants::go_type::CHEST); + s.loot_window + .corpse_loot_by_viewer + .insert(1, vec![(0, 2589, 1, 200, 0)]); + let store = std::sync::Arc::new(s); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); + + CMSG_GAMEOBJ_USE { + guid: Guid::new(77), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + CMSG_AUTOSTORE_LOOT_ITEM { item_slot: 0 } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_LOOT_RESPONSE(response) => { + assert_eq!(response.guid.guid(), 77); + assert_eq!(response.items.len(), 1); + } + other => panic!("expected the chest's loot window, got {other}"), + } + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_LOOT_REMOVED(removed) => assert_eq!(removed.slot, 0), + other => panic!("expected the take to clear the slot, got {other}"), + } + drop(client); + server.join().unwrap(); + assert_eq!(store.loot_window.gameobjects_used.lock().unwrap()[..], [77]); + assert_eq!(store.loot_window.items_taken.lock().unwrap()[..], [(77, 0)]); +} + +#[test] +fn door_gameobject_use_takes_the_general_path_and_opens_no_loot_window() { + let mut s = quest_store(); + s.npc.gameobject_type = Some(0); + let store = std::sync::Arc::new(s); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); + + CMSG_GAMEOBJ_USE { + guid: Guid::new(91), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + CMSG_AUTOSTORE_LOOT_ITEM { item_slot: 0 } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + CMSG_QUESTGIVER_STATUS_QUERY { + guid: Guid::new(50), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + + // A loot window or a take reply would arrive before the status query's answer. + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_QUESTGIVER_STATUS(_) => {} + other => panic!("a door GameObject opened a loot window: {other}"), + } + drop(client); + server.join().unwrap(); + assert_eq!(store.loot_window.gameobjects_used.lock().unwrap()[..], [91]); + assert!(store.loot_window.items_taken.lock().unwrap().is_empty()); +} + +#[test] +fn gameobject_use_outside_the_chest_path_opens_the_window_for_its_loot() { + let store = HandleLootFake::default().with_loot(SELF_GUID, vec![(4, 117, 2, 321, 0)]); + let mut conn = in_world_conn(); + + let sent = run( + &store, + &mut conn, + ClientOpcodeMessage::CMSG_GAMEOBJ_USE(CMSG_GAMEOBJ_USE { + guid: Guid::new(91), + }), + ); + + let [Outbound::Raw { opcode, body }] = sent.as_slice() else { + panic!("expected one loot window"); + }; + assert_eq!(*opcode, OP_LOOT_RESPONSE); + assert_eq!(&body[0..8], &91u64.to_le_bytes()); + let WorldState::InWorld(iw) = &conn.state else { + panic!("the connection left the world"); + }; + assert_eq!(iw.open_loot.target_guid, Some(91)); +} diff --git a/gateway/src/world/mail.rs b/gateway/src/world/mail.rs index dcd051a49..0d5426742 100644 --- a/gateway/src/world/mail.rs +++ b/gateway/src/world/mail.rs @@ -2,12 +2,264 @@ //! Sharded moves are fence → commit → attest → settle; local moves stay one transaction. The //! turn-in files a Reward Letter as Escrow on every plane, and it is driven the same way. +use crate::world::{CharacterStore, SessionStore, ShardRoutingStore, SocialStore}; use anyhow::Result; use super::{presence, WorldStore}; use crate::codec::MailView; use lyracore_shared::mail as mail_rules; +/// Mail rows, Letter Copy text, and the cross-shard mail Escrow steps. +pub(crate) trait MailStore: Send + Sync { + /// Every mail addressed to `recipient_guid`, 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 two-plane read, which is why this is one method rather than a realm-only twin. + /// The real refusals are the gates in `world::mail`, which run before this. + fn mail_list(&self, recipient_guid: u64) -> Result>; + + /// The mail `mail_id`, delivered or not, on the database THIS handle names: the same two-plane + /// routing as [`mail_list`](Self::mail_list), as one primary key read. + fn mail_by_id(&self, mail_id: u64) -> Result>; + + /// The name of the Realm Account that owns `character_guid`, read on THIS handle only. The + /// Account Character Owner names it when this Shard retains one. Otherwise the Character's + /// local Account names it, unless that Account is a shadow Account, whose name is not a Realm + /// Account's. `None` when this handle cannot name it. `world::mail` asks every World Shard, + /// because the Delivery Delay compares the Realm Accounts of two Characters on any Shards. + fn realm_account_name(&self, character_guid: u64) -> Result>; + + /// Is `player_guid` in range of the gameobject `mailbox_guid` names, and is it a mailbox at all? + /// + /// Always asked of the session's OWN handle: the mailbox is a gameobject on the shard the player + /// is standing on, and realm-core holds none. A PK lookup plus a map/instance/range check — + /// 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. + /// + /// 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 + /// `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<()>; + + /// Delete `mail_id` for `recipient_guid`, 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<()>; + + /// 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 + /// 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<()>; + + /// Write one sent letter on the database THIS handle names, charging the sender the postage + /// plus the attached `money` in the SAME transaction. + /// + /// **The single-database gateway only**, where the purse and the row are on one database. A + /// sharded realm cannot have that transaction and drives [`mail_fence`](Self::mail_fence) and + /// 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. + /// + /// `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. + /// `same_account` says whether the sender and the recipient belong to one Realm Account; the + /// Module turns it into the letter's Delivery Delay. + #[allow(clippy::too_many_arguments)] + 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<()>; + + /// Credit `mail_id`'s copper to `recipient_guid` 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<()>; + + /// Re-create `mail_id`'s attached item in `recipient_guid`'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<()>; + + /// Has `payee_guid` 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<()>; + + /// `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, + /// this shard's own database otherwise — the same two-plane routing `mail_take_item_fence` + /// 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<()>; + + /// `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 + /// here refuses and leaves the mail COPIED with no letter granted. The Plain Letter sells for + /// 0, so a grant lost to that race costs nothing — this is deliberately not an escrow. Also a + /// 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<()>; + + /// `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<()>; + + /// 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 + /// copied letter's text outlives the mail row that created it, so this answers even after that + /// mail is deleted. + /// + /// The mail plane holds every copied letter's text keyed by a small, sequential id, so this + /// must never be read for a caller who has not proven they may see it — see + /// [`owns_item_with_text`](Self::owns_item_with_text). + fn item_text(&self, item_text_id: u32) -> Result>; + + /// Does `owner_guid` hold an item carrying `item_text_id` in their own bags, on THIS handle? + /// The ownership Gate `mail::item_text` checks before it answers from `game_item_text`: a + /// client walking `item_text_id` values must not read another player's Letter Copy that way. + /// `hint_item_guid` is `CMSG_ITEM_TEXT_QUERY`'s overloaded second field — often the queried + /// item's own guid when it names an item rather than a mail — so an implementation can try a + /// cheap PK lookup before falling back to a scan of `owner_guid`'s rows. + fn owns_item_with_text( + &self, + owner_guid: u64, + item_text_id: u32, + hint_item_guid: u64, + ) -> Result; + + /// **Escrow step 1 (send)** — take the postage plus the attached coin out of `sender_guid`'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 + /// are standing on. `Err` is the atomic affordability refusal — a refused send costs nothing. + /// + /// A COD PAYMENT is fenced through here too, because it is a letter out of a purse like any + /// other: `cod_source_mail_id` names the mail whose price it pays (0 for an ordinary letter), + /// and it rides the fence so a re-drive can settle that price without re-deriving anything. + /// + /// `same_account` says whether the sender and the recipient belong to one Realm Account. The + /// fence resolves the letter's Delivery Delay from it and stores it for the commit. + #[allow(clippy::too_many_arguments)] + 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<()>; + + /// **Escrow step 2 (send)** — write the mail row and its receipt under `escrow_id`, on the + /// database THIS handle names (realm-core). Idempotent: a replay writes nothing. + /// + /// `cod_source_mail_id` (0 for an ordinary letter) is the mail this one PAYS FOR: its price is + /// settled in the same transaction as the payout row, which is what makes a COD take charge + /// once however the drive is interrupted. + /// + /// `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`. + #[allow(clippy::too_many_arguments)] + fn mail_commit( + &self, + escrow_id: u64, + sender_guid: u64, + recipient_guid: u64, + subject: String, + body: String, + money: u32, + item: AttachedItem, + cod: u32, + cod_source_mail_id: u64, + delivery_delay_secs: u32, + reward: Option, + ) -> Result<()>; + + /// **Escrow step 1 (take)** — take `mail_id`'s copper out of the row into a fence, on the + /// database that OWNS THE ROW. `expect_money` is the amount the caller is about to pay out; a + /// mismatch is refused rather than fenced, because the gateway carries that number across. + fn mail_take_money_fence( + &self, + escrow_id: u64, + payee_guid: u64, + mail_id: u64, + expect_money: u32, + ) -> Result<()>; + + /// **Escrow step 2 (take)** — credit `amount` to `payee_guid` 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<()>; + + /// **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 + /// mismatch is refused rather than fenced, because the gateway carries the snapshot across. + fn mail_take_item_fence( + &self, + escrow_id: u64, + payee_guid: u64, + 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 + /// 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, + mail_id: u64, + item: AttachedItem, + ) -> Result<()>; + + /// **Escrow step 3** — attest, on the handle HOLDING the fence, that the other database + /// committed. The only thing that licenses step 4. + fn mail_confirm_delivery(&self, escrow_id: u64) -> Result<()>; + + /// **Escrow step 4** — destroy the fence, on the handle holding it. Delete-last: it refuses + /// while unattested. + fn mail_settle(&self, escrow_id: u64) -> Result<()>; + + /// Every unfinished mail escrow this database holds for `sender_guid` (the payee, on a payout). + /// + /// The read that makes re-driving possible at all: a fence carries its whole letter, so a drive + /// abandoned by a dead gateway is resumable from the row. + fn mail_escrows_of(&self, sender_guid: u64) -> Result>; +} + struct EscrowIdRange { next: std::sync::atomic::AtomicU64, start: u64, @@ -47,7 +299,7 @@ impl std::fmt::Display for SendRefusal { /// 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`). -pub(crate) fn mail_of( +pub(crate) fn mail_of( store: &St, self_guid: u64, ) -> Result> { @@ -65,7 +317,7 @@ pub(crate) fn now_secs() -> i64 { .map(|d| d.as_secs() as i64) .unwrap_or(0) } -pub(crate) fn open_mailbox( +pub(crate) fn open_mailbox( store: &St, self_guid: Option, mailbox_guid: u64, @@ -74,7 +326,7 @@ pub(crate) fn open_mailbox( redrive(store, self_guid); mail_of(store, self_guid) } -pub(crate) fn has_unread( +pub(crate) fn has_unread( store: &St, self_guid: Option, ) -> Result { @@ -82,7 +334,7 @@ pub(crate) fn has_unread( 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)) } -pub(crate) fn letter_body( +pub(crate) fn letter_body( store: &St, self_guid: Option, mail_id: u64, @@ -94,7 +346,7 @@ pub(crate) fn letter_body( .find(|m| m.id == mail_id) .map(|m| m.body)) } -pub(crate) fn mark_read( +pub(crate) fn mark_read( store: &St, self_guid: Option, mailbox_guid: u64, @@ -106,7 +358,7 @@ pub(crate) fn mark_read( None => store.mail_mark_read(self_guid, mail_id), } } -pub(crate) fn delete( +pub(crate) fn delete( store: &St, self_guid: Option, mailbox_guid: u64, @@ -118,7 +370,7 @@ pub(crate) fn delete( None => store.mail_delete(self_guid, mail_id), } } -pub(crate) fn return_to_sender( +pub(crate) fn return_to_sender( store: &St, self_guid: Option, mailbox_guid: u64, @@ -144,7 +396,11 @@ pub(crate) fn return_to_sender( /// Delay, but it cannot read a Character's Account on another Shard, so the Gateway reads both /// realm-wide (`docs/architecture.md` §2.3). An Account that no Shard can name counts as another /// Account, so an item waits. -fn same_realm_account(store: &St, a: u64, b: u64) -> Result { +fn same_realm_account( + store: &St, + a: u64, + b: u64, +) -> Result { Ok( match ( realm_account_anywhere(store, a)?, @@ -158,7 +414,7 @@ fn same_realm_account(store: &St, a: u64, b: u64) -> Re /// The Realm Account name the first handle that can name one holds for `guid`: this handle, then /// every World Shard. Admission refuses two Shards that name different Accounts for one Character, /// so the first name is the name. -fn realm_account_anywhere( +fn realm_account_anywhere( store: &St, guid: u64, ) -> Result> { @@ -173,7 +429,9 @@ fn realm_account_anywhere( Ok(None) } #[allow(clippy::too_many_arguments)] -pub(crate) fn send( +pub(crate) fn send< + St: CharacterStore + MailStore + SessionStore + ShardRoutingStore + SocialStore + ?Sized, +>( store: &St, self_guid: Option, mailbox_guid: u64, @@ -279,7 +537,7 @@ pub(crate) fn send( } fn drive(source: &St, escrow_id: u64, commit: F) -> Result<()> where - St: WorldStore + ?Sized, + St: MailStore + ?Sized, F: FnOnce() -> Result<()>, { commit()?; @@ -314,7 +572,7 @@ const ESCROW_POLL: std::time::Duration = std::time::Duration::from_millis(20); /// Delay, from it, exactly as a re-drive does. Waits out the coordinator cache's lag behind the /// fence's own call-pipe commit instead of refusing on the first miss. Each read re-acquires the /// store's own cache guard, so the wait holds none of it while it sleeps. -fn held_fence( +fn held_fence( store: &St, sender_guid: u64, escrow_id: u64, @@ -360,7 +618,7 @@ pub struct HeldEscrow { /// abandoned, and it is the only thing that delivers a Reward Letter, which the Module files at /// 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) { +pub(crate) fn redrive(store: &St, self_guid: u64) { let realm = store.realm_store(); for held in store.mail_escrows_of(self_guid).unwrap_or_default() { if held.payout { @@ -396,7 +654,7 @@ pub(crate) fn redrive(store: &St, self_guid: u64) { } /// Commit the letter `held` describes on the mail plane `plane`, fenced by `sender_guid`. -fn commit_held( +fn commit_held( plane: &P, sender_guid: u64, held: &HeldEscrow, @@ -424,7 +682,7 @@ fn log_redrive(kind: &str, escrow_id: u64, outcome: Result<()>) { ), } } -pub(crate) fn take_money( +pub(crate) fn take_money( store: &St, self_guid: Option, mailbox_guid: u64, @@ -475,7 +733,7 @@ fn take_item_refusal(e: anyhow::Error) -> TakeItemRefusal { TakeItemRefusal::Other(text) } } -pub(crate) fn take_item( +pub(crate) fn take_item( store: &St, self_guid: Option, mailbox_guid: u64, @@ -514,7 +772,7 @@ pub(crate) fn take_item( .map_err(take_item_refusal)?; Ok(taken) } -fn pay_cod( +fn pay_cod( store: &St, realm: &dyn WorldStore, taker_guid: u64, @@ -605,7 +863,7 @@ fn refusal_from_module(e: anyhow::Error) -> SendRefusal { SendRefusal::Internal(text) } } -fn at_mailbox( +fn at_mailbox( store: &St, self_guid: Option, mailbox_guid: u64, @@ -649,7 +907,7 @@ fn copy_letter_refusal(e: anyhow::Error) -> CopyLetterRefusal { /// mark is what makes a completed grant refuse a second one for good, even after the player /// destroys, mails away, or trades the letter — `mail_grant_letter`'s own held-item check only /// covers the narrow window before this call lands. -pub(crate) fn copy_letter( +pub(crate) fn copy_letter( store: &St, self_guid: Option, mailbox_guid: u64, @@ -684,7 +942,7 @@ pub(crate) fn copy_letter( /// gets empty text, the same answer a stale or foreign id has always produced. /// /// `hint_item_guid` is the wire's own overloaded second field, forwarded to -/// [`WorldStore::owns_item_with_text`] so it can try a cheap PK lookup before scanning. The +/// [`MailStore::owns_item_with_text`] so it can try a cheap PK lookup before scanning. The /// ownership scan runs only when the mail check does not already answer the question — most /// queries are either "read my own undeleted mail" or "reread my own bagged letter," so one lookup /// usually settles it. @@ -692,7 +950,7 @@ pub(crate) fn copy_letter( /// A copied letter's text lives in `game_item_text` on the mail plane and outlives the mail that /// held it; anything else falls back to the caller's own mail body under the same id, which is /// what a letter still sitting in the mailbox resolves through today. -pub(crate) fn item_text( +pub(crate) fn item_text( store: &St, self_guid: Option, item_text_id: u32, diff --git a/gateway/src/world/mail_tests.rs b/gateway/src/world/mail_tests.rs index 77358cc1e..0e25f0036 100644 --- a/gateway/src/world/mail_tests.rs +++ b/gateway/src/world/mail_tests.rs @@ -1,3 +1,5 @@ +//! Mail routing tests through the shared WorldFake topology. + use super::party_tests::{character, DORMANT, GINGER, TRIN, VIM}; use super::*; const MAILBOX: u64 = 0xF110_0000_0000_0042; @@ -16,38 +18,62 @@ fn mail(id: u64, from: u64, subject: &str, body: &str) -> codec::MailView { } } fn sharded_mailbox() -> ( - std::sync::Arc, - std::sync::Arc, + std::sync::Arc, + std::sync::Arc, ShardCallLog, ) { let calls: ShardCallLog = Default::default(); - let realm = std::sync::Arc::new(InMemoryStore { - shard: "lyracore-realm".into(), - calls: calls.clone(), - is_realm: true, + let realm = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "lyracore-realm".into(), + calls: calls.clone(), + ..Default::default() + }, + party: PartyState { + is_realm: true, + ..Default::default() + }, ..Default::default() }); - let world = std::sync::Arc::new(InMemoryStore { - shard: "world".into(), - calls: calls.clone(), - realm: Some(realm.clone()), + let world = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "world".into(), + calls: calls.clone(), + realm: Some(realm.clone()), + ..Default::default() + }, characters: vec![character(GINGER, "Ginger"), character(TRIN, "Trin")], - live_guids: vec![GINGER, TRIN], - mailboxes: vec![MAILBOX], + session: SessionState { + live_guids: vec![GINGER, TRIN], + ..Default::default() + }, + mail: MailState { + mailboxes: vec![MAILBOX], + ..Default::default() + }, ..Default::default() }); - *realm.mails.lock().unwrap() = seeded_mail(); + *realm.mail.mails.lock().unwrap() = seeded_mail(); (realm, world, calls) } -fn unsharded_mailbox() -> std::sync::Arc { - let store = std::sync::Arc::new(InMemoryStore { - shard: "lyracore".into(), +fn unsharded_mailbox() -> std::sync::Arc { + let store = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "lyracore".into(), + ..Default::default() + }, characters: vec![character(GINGER, "Ginger"), character(TRIN, "Trin")], - live_guids: vec![GINGER, TRIN], - mailboxes: vec![MAILBOX], + session: SessionState { + live_guids: vec![GINGER, TRIN], + ..Default::default() + }, + mail: MailState { + mailboxes: vec![MAILBOX], + ..Default::default() + }, ..Default::default() }); - *store.mails.lock().unwrap() = seeded_mail(); + *store.mail.mails.lock().unwrap() = seeded_mail(); store } fn seeded_mail() -> Vec<(u64, codec::MailView)> { @@ -77,7 +103,7 @@ fn a_seeded_mail_is_listed_for_its_recipient_and_for_nobody_else() { #[test] fn a_player_with_no_mail_gets_an_empty_list_and_not_a_refusal() { let (realm, world, _calls) = sharded_mailbox(); - realm.mails.lock().unwrap().clear(); + realm.mail.mails.lock().unwrap().clear(); assert_eq!( mail::open_mailbox(world.as_ref(), Some(GINGER), MAILBOX).expect("an empty mailbox opens"), @@ -190,13 +216,13 @@ 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()); - realm.mails.lock().unwrap()[0].1.was_read = true; + realm.mail.mails.lock().unwrap()[0].1.was_read = true; assert!( !mail::has_unread(world.as_ref(), Some(GINGER)).unwrap(), "a mailbox holding only READ mail must not light the envelope" ); - realm.mails.lock().unwrap().clear(); + realm.mail.mails.lock().unwrap().clear(); assert!(!mail::has_unread(world.as_ref(), Some(GINGER)).unwrap()); } @@ -223,6 +249,7 @@ fn a_letter_body_is_readable_by_its_recipient_and_by_nobody_else() { fn a_mail_with_no_body_advertises_text_id_zero() { let (realm, world, _calls) = sharded_mailbox(); realm + .mail .mails .lock() .unwrap() @@ -237,34 +264,41 @@ fn a_mail_with_no_body_advertises_text_id_zero() { .collect(); assert_eq!(text_ids, vec![(3, 0), (1, 1)]); } -fn seated_store() -> std::sync::Arc { - let store = std::sync::Arc::new(InMemoryStore { - login_entity: Some(warrior_entity()), - mailboxes: vec![MAILBOX], - ..tester_store(7) +fn seated_store() -> std::sync::Arc { + let store = std::sync::Arc::new({ + let base = tester_store(7); + WorldFake { + session: SessionState { + login_entity: Some(warrior_entity()), + ..base.session + }, + mail: MailState { + mailboxes: vec![MAILBOX], + ..base.mail + }, + ..base + } }); - *store.mails.lock().unwrap() = vec![(1, mail(1, VIM, "Your sword", "left it at the inn"))]; + *store.mail.mails.lock().unwrap() = vec![(1, mail(1, VIM, "Your sword", "left it at the inn"))]; store } #[test] fn an_empty_mailbox_is_answered_with_an_empty_list_packet() { let store = seated_store(); - store.mails.lock().unwrap().clear(); + store.mail.mails.lock().unwrap().clear(); 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.as_ref()).unwrap(); + run_world_session(server_end, server_store.clone()).unwrap(); }); 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(); - for _ in 0..WORLD_ENTRY_PACKETS { - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - } + drain_world_entry(&mut client, &mut c_dec); wow_world_messages::vanilla::CMSG_GET_MAIL_LIST { mailbox: Guid::new(MAILBOX), @@ -288,16 +322,14 @@ fn a_seeded_mail_reaches_the_client_as_a_mail_list_row() { 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.as_ref()).unwrap(); + run_world_session(server_end, server_store.clone()).unwrap(); }); 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(); - for _ in 0..WORLD_ENTRY_PACKETS { - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - } + drain_world_entry(&mut client, &mut c_dec); wow_world_messages::vanilla::CMSG_GET_MAIL_LIST { mailbox: Guid::new(MAILBOX), @@ -468,7 +500,7 @@ fn returning_a_mail_moves_it_from_the_recipients_list_to_the_senders() { #[test] fn returning_a_mail_carries_its_attachment_and_copper_unchanged() { let world = unsharded_mailbox(); - *world.mails.lock().unwrap() = vec![( + *world.mail.mails.lock().unwrap() = vec![( GINGER, codec::MailView { id: 1, @@ -485,7 +517,7 @@ fn returning_a_mail_carries_its_attachment_and_copper_unchanged() { }, )]; // Vim is Ginger's alt here, so the item comes back at once. - *world.realm_accounts.lock().unwrap() = alts(&[GINGER, VIM]); + *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"); @@ -505,7 +537,7 @@ fn returning_a_mail_carries_its_attachment_and_copper_unchanged() { #[test] fn returning_an_already_taken_mail_does_not_duplicate_the_attachment() { let world = unsharded_mailbox(); - *world.mails.lock().unwrap() = vec![( + *world.mail.mails.lock().unwrap() = vec![( GINGER, codec::MailView { id: 1, @@ -616,16 +648,14 @@ fn return_acks_with_send_mail_result_and_the_next_list_is_empty() { let (mut client, server_end) = UnixStream::pair().unwrap(); let server_store = store.clone(); let server = std::thread::spawn(move || { - run_world_session(server_end, server_store.as_ref()).unwrap(); + run_world_session(server_end, server_store.clone()).unwrap(); }); 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(); - for _ in 0..WORLD_ENTRY_PACKETS { - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - } + drain_world_entry(&mut client, &mut c_dec); wow_world_messages::vanilla::CMSG_MAIL_RETURN_TO_SENDER { mailbox_id: Guid::new(MAILBOX), @@ -668,21 +698,19 @@ fn return_acks_with_send_mail_result_and_the_next_list_is_empty() { #[test] fn a_refused_return_still_acks_and_never_kills_the_session() { let store = seated_store(); - store.mails.lock().unwrap()[0].0 = 999; + store.mail.mails.lock().unwrap()[0].0 = 999; let (mut client, server_end) = UnixStream::pair().unwrap(); let server_store = store.clone(); let server = std::thread::spawn(move || { - run_world_session(server_end, server_store.as_ref()).unwrap(); + run_world_session(server_end, server_store.clone()).unwrap(); }); 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(); - for _ in 0..WORLD_ENTRY_PACKETS { - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - } + drain_world_entry(&mut client, &mut c_dec); wow_world_messages::vanilla::CMSG_MAIL_RETURN_TO_SENDER { mailbox_id: Guid::new(MAILBOX), @@ -723,16 +751,14 @@ fn mark_as_read_sends_no_reply_but_the_next_list_shows_it() { let (mut client, server_end) = UnixStream::pair().unwrap(); let server_store = store.clone(); let server = std::thread::spawn(move || { - run_world_session(server_end, server_store.as_ref()).unwrap(); + run_world_session(server_end, server_store.clone()).unwrap(); }); 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(); - for _ in 0..WORLD_ENTRY_PACKETS { - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - } + drain_world_entry(&mut client, &mut c_dec); wow_world_messages::vanilla::CMSG_MAIL_MARK_AS_READ { mailbox: Guid::new(MAILBOX), @@ -765,16 +791,14 @@ fn delete_acks_with_send_mail_result_and_the_next_list_is_empty() { let (mut client, server_end) = UnixStream::pair().unwrap(); let server_store = store.clone(); let server = std::thread::spawn(move || { - run_world_session(server_end, server_store.as_ref()).unwrap(); + run_world_session(server_end, server_store.clone()).unwrap(); }); 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(); - for _ in 0..WORLD_ENTRY_PACKETS { - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - } + drain_world_entry(&mut client, &mut c_dec); wow_world_messages::vanilla::CMSG_MAIL_DELETE { mailbox_id: Guid::new(MAILBOX), @@ -817,21 +841,19 @@ fn delete_acks_with_send_mail_result_and_the_next_list_is_empty() { #[test] fn a_refused_delete_still_acks_and_never_kills_the_session() { let store = seated_store(); - store.mails.lock().unwrap()[0].0 = 999; + store.mail.mails.lock().unwrap()[0].0 = 999; let (mut client, server_end) = UnixStream::pair().unwrap(); let server_store = store.clone(); let server = std::thread::spawn(move || { - run_world_session(server_end, server_store.as_ref()).unwrap(); + run_world_session(server_end, server_store.clone()).unwrap(); }); 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(); - for _ in 0..WORLD_ENTRY_PACKETS { - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - } + drain_world_entry(&mut client, &mut c_dec); wow_world_messages::vanilla::CMSG_MAIL_DELETE { mailbox_id: Guid::new(MAILBOX), @@ -881,22 +903,31 @@ fn alts(guids: &[u64]) -> Vec<(u64, String)> { guids.iter().map(|guid| (*guid, ALTS.to_string())).collect() } fn sharded_send() -> ( - std::sync::Arc, - std::sync::Arc, - std::sync::Arc, + std::sync::Arc, + std::sync::Arc, + std::sync::Arc, ShardCallLog, ) { let calls: ShardCallLog = Default::default(); - let realm = std::sync::Arc::new(InMemoryStore { - shard: "lyracore-realm".into(), - calls: calls.clone(), - is_realm: true, + let realm = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "lyracore-realm".into(), + calls: calls.clone(), + ..Default::default() + }, + party: PartyState { + is_realm: true, + ..Default::default() + }, ..Default::default() }); - let world = std::sync::Arc::new(InMemoryStore { - shard: "world".into(), - calls: calls.clone(), - realm: Some(realm.clone()), + let world = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "world".into(), + calls: calls.clone(), + realm: Some(realm.clone()), + ..Default::default() + }, characters: vec![ character(GINGER, "Ginger"), character(TRIN, "Trin"), @@ -904,38 +935,65 @@ fn sharded_send() -> ( character(ECHO_WORLD, "Echo"), orc(GRUG, "Grug"), ], - live_guids: vec![GINGER, TRIN, GRUG], - offline_guids: vec![DORMANT], - mailboxes: vec![MAILBOX], + session: SessionState { + live_guids: vec![GINGER, TRIN, GRUG], + ..Default::default() + }, + social: SocialState { + offline_guids: vec![DORMANT], + ..Default::default() + }, + mail: MailState { + mailboxes: vec![MAILBOX], + ..Default::default() + }, ..Default::default() }); - let instances = std::sync::Arc::new(InMemoryStore { - shard: "instances".into(), - calls: calls.clone(), - realm: Some(realm.clone()), + let instances = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "instances".into(), + calls: calls.clone(), + realm: Some(realm.clone()), + ..Default::default() + }, characters: vec![character(VIM, "Vim"), character(ECHO_INSTANCES, "Echo")], - live_guids: vec![VIM], - mailboxes: vec![MAILBOX], + session: SessionState { + live_guids: vec![VIM], + ..Default::default() + }, + mail: MailState { + mailboxes: vec![MAILBOX], + ..Default::default() + }, ..Default::default() }); - *world.purses.lock().unwrap() = vec![(GINGER, PURSE)]; - *world.realm_accounts.lock().unwrap() = alts(&[GINGER, TRIN, DORMANT]); - *instances.realm_accounts.lock().unwrap() = vec![(VIM, VIMS_ACCOUNT.to_string())]; + *world.mail.purses.lock().unwrap() = vec![(GINGER, PURSE)]; + *world.mail.realm_accounts.lock().unwrap() = alts(&[GINGER, TRIN, DORMANT]); + *instances.mail.realm_accounts.lock().unwrap() = vec![(VIM, VIMS_ACCOUNT.to_string())]; for shard in [&world, &instances] { - *shard.peers.lock().unwrap() = vec![world.clone(), instances.clone()]; + *shard.topology.peers.lock().unwrap() = vec![world.clone(), instances.clone()]; } (realm, world, instances, calls) } -fn unsharded_send() -> std::sync::Arc { - let store = std::sync::Arc::new(InMemoryStore { - shard: "lyracore".into(), +fn unsharded_send() -> std::sync::Arc { + let store = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "lyracore".into(), + ..Default::default() + }, characters: vec![character(GINGER, "Ginger"), character(TRIN, "Trin")], - live_guids: vec![GINGER, TRIN], - mailboxes: vec![MAILBOX], + session: SessionState { + live_guids: vec![GINGER, TRIN], + ..Default::default() + }, + mail: MailState { + mailboxes: vec![MAILBOX], + ..Default::default() + }, ..Default::default() }); - *store.purses.lock().unwrap() = vec![(GINGER, PURSE)]; - *store.realm_accounts.lock().unwrap() = alts(&[GINGER, TRIN]); + *store.mail.purses.lock().unwrap() = vec![(GINGER, PURSE)]; + *store.mail.realm_accounts.lock().unwrap() = alts(&[GINGER, TRIN]); store } @@ -1025,7 +1083,7 @@ fn postage_is_debited_from_the_senders_own_shard_at_send() { post(world.as_ref(), "Trin").expect("the send goes through"); assert_eq!( - world.purses.lock().unwrap()[0].1, + world.mail.purses.lock().unwrap()[0].1, PURSE - lyracore_shared::mail::postage() ); let log = calls.lock().unwrap().clone(); @@ -1042,12 +1100,16 @@ fn postage_is_debited_from_the_senders_own_shard_at_send() { #[test] fn a_sender_who_cannot_afford_the_postage_is_refused_and_charged_nothing() { let (_realm, world, _instances, calls) = sharded_send(); - *world.purses.lock().unwrap() = vec![(GINGER, 10)]; + *world.mail.purses.lock().unwrap() = vec![(GINGER, 10)]; let refusal = post(world.as_ref(), "Trin").expect_err("10 copper does not cover the postage"); assert!(matches!(refusal, mail::SendRefusal::NotEnoughMoney(_))); - assert_eq!(world.purses.lock().unwrap()[0].1, 10, "charged nothing"); + assert_eq!( + world.mail.purses.lock().unwrap()[0].1, + 10, + "charged nothing" + ); assert!(mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX) .unwrap() .is_empty()); @@ -1075,7 +1137,11 @@ fn each_refused_gate_produces_its_own_distinct_refusal() { mail::SendRefusal::NotYourTeam ); - assert_eq!(world.purses.lock().unwrap()[0].1, PURSE, "charged nothing"); + assert_eq!( + world.mail.purses.lock().unwrap()[0].1, + PURSE, + "charged nothing" + ); let log = calls.lock().unwrap().clone(); assert!( !log.iter().any(|(_, call)| call == "mail_send"), @@ -1086,7 +1152,7 @@ fn each_refused_gate_produces_its_own_distinct_refusal() { #[test] fn the_faction_gate_refuses_in_both_directions() { let (_realm, world, _instances, _calls) = sharded_send(); - *world.purses.lock().unwrap() = vec![(GRUG, PURSE)]; + *world.mail.purses.lock().unwrap() = vec![(GRUG, PURSE)]; let refusal = mail::send( world.as_ref(), @@ -1182,7 +1248,7 @@ fn sending_is_refused_at_character_select_and_away_from_a_mailbox() { "got {refusal}" ); - assert_eq!(world.purses.lock().unwrap()[0].1, PURSE); + assert_eq!(world.mail.purses.lock().unwrap()[0].1, PURSE); let log = calls.lock().unwrap().clone(); assert!( !log.iter() @@ -1205,7 +1271,7 @@ fn both_planes_produce_the_same_row_for_the_same_letter() { "the two planes must produce one letter" ); assert_eq!( - single.purses.lock().unwrap()[0].1, + single.mail.purses.lock().unwrap()[0].1, PURSE - lyracore_shared::mail::postage(), "and both must charge the same postage" ); @@ -1221,10 +1287,10 @@ fn the_whole_cost_leaves_the_purse_once_on_either_plane() { post_money(world.as_ref(), "Trin", attached).expect("realm plane"); post_money(single.as_ref(), "Trin", attached).expect("fallback plane"); - assert_eq!(world.purses.lock().unwrap()[0].1, PURSE - cost); - assert_eq!(single.purses.lock().unwrap()[0].1, PURSE - cost); - assert_eq!(single.sent_mail.lock().unwrap()[0].4, attached); - assert!(single.mail_escrows.lock().unwrap().is_empty()); + assert_eq!(world.mail.purses.lock().unwrap()[0].1, PURSE - cost); + assert_eq!(single.mail.purses.lock().unwrap()[0].1, PURSE - cost); + assert_eq!(single.mail.sent_mail.lock().unwrap()[0].4, attached); + assert!(single.mail.mail_escrows.lock().unwrap().is_empty()); let log = calls.lock().unwrap().clone(); assert!( !log.iter().any(|(_, call)| call == "mail_send"), @@ -1247,38 +1313,45 @@ fn a_sent_letters_body_is_readable_through_the_item_text_query_path() { Some("left it at the inn".to_string()) ); } -fn seated_sender() -> std::sync::Arc { - let store = std::sync::Arc::new(InMemoryStore { - login_entity: Some(warrior_entity()), - mailboxes: vec![MAILBOX], - characters: vec![ - character(1, "Tester"), - character(TRIN, "Trin"), - orc(GRUG, "Grug"), - ], - live_guids: vec![1, TRIN, GRUG], - ..tester_store(7) +fn seated_sender() -> std::sync::Arc { + let store = std::sync::Arc::new({ + let base = tester_store(7); + WorldFake { + session: SessionState { + login_entity: Some(warrior_entity()), + live_guids: vec![1, TRIN, GRUG], + ..base.session + }, + mail: MailState { + mailboxes: vec![MAILBOX], + ..base.mail + }, + characters: vec![ + character(1, "Tester"), + character(TRIN, "Trin"), + orc(GRUG, "Grug"), + ], + ..base + } }); - *store.purses.lock().unwrap() = vec![(1, PURSE)]; + *store.mail.purses.lock().unwrap() = vec![(1, PURSE)]; store } fn send_over_the_wire( - store: &std::sync::Arc, + store: &std::sync::Arc, receiver: &str, ) -> wow_world_messages::vanilla::SMSG_SEND_MAIL_RESULT_MailResultTwo { let (mut client, server_end) = UnixStream::pair().unwrap(); let server_store = store.clone(); let server = std::thread::spawn(move || { - run_world_session(server_end, server_store.as_ref()).unwrap(); + run_world_session(server_end, server_store.clone()).unwrap(); }); 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(); - for _ in 0..WORLD_ENTRY_PACKETS { - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - } + drain_world_entry(&mut client, &mut c_dec); wow_world_messages::vanilla::CMSG_SEND_MAIL { mailbox: Guid::new(MAILBOX), @@ -1323,32 +1396,50 @@ fn a_letter_sent_over_the_wire_is_acked_ok_and_lands_in_the_recipients_mailbox() let trins = mail::open_mailbox(store.as_ref(), Some(TRIN), MAILBOX).expect("the gate opens"); assert_eq!(trins.len(), 1); assert_eq!(trins[0].body, "left it at the inn"); - assert_eq!(store.purses.lock().unwrap()[0].1, PURSE - 30); + assert_eq!(store.mail.purses.lock().unwrap()[0].1, PURSE - 30); } /// `seated_sender`'s sharded twin: the sender's own handle plus a realm-core handle, so /// `CMSG_SEND_MAIL` drives the fence → commit → confirm → settle escrow instead of the /// single-database fallback. -fn sharded_seated_sender() -> std::sync::Arc { - let realm = std::sync::Arc::new(InMemoryStore { - shard: "lyracore-realm".into(), - is_realm: true, +fn sharded_seated_sender() -> std::sync::Arc { + let realm = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "lyracore-realm".into(), + ..Default::default() + }, + party: PartyState { + is_realm: true, + ..Default::default() + }, ..Default::default() }); - let store = std::sync::Arc::new(InMemoryStore { - shard: "world".into(), - realm: Some(realm), - login_entity: Some(warrior_entity()), - mailboxes: vec![MAILBOX], - characters: vec![ - character(1, "Tester"), - character(TRIN, "Trin"), - orc(GRUG, "Grug"), - ], - live_guids: vec![1, TRIN, GRUG], - ..tester_store(7) + let store = std::sync::Arc::new({ + let base = tester_store(7); + WorldFake { + topology: TopologyState { + shard: "world".into(), + realm: Some(realm), + ..base.topology + }, + session: SessionState { + login_entity: Some(warrior_entity()), + live_guids: vec![1, TRIN, GRUG], + ..base.session + }, + mail: MailState { + mailboxes: vec![MAILBOX], + ..base.mail + }, + characters: vec![ + character(1, "Tester"), + character(TRIN, "Trin"), + orc(GRUG, "Grug"), + ], + ..base + } }); - *store.purses.lock().unwrap() = vec![(1, PURSE)]; + *store.mail.purses.lock().unwrap() = vec![(1, PURSE)]; store } @@ -1356,6 +1447,7 @@ fn sharded_seated_sender() -> std::sync::Arc { fn a_real_session_gets_the_send_mail_result_once_a_lagging_escrow_cache_catches_up() { let store = sharded_seated_sender(); store + .mail .mail_escrow_reads_before_visible .store(3, std::sync::atomic::Ordering::SeqCst); @@ -1367,7 +1459,7 @@ fn a_real_session_gets_the_send_mail_result_once_a_lagging_escrow_cache_catches_ let trins = mail::open_mailbox(store.as_ref(), Some(TRIN), MAILBOX).expect("the gate opens"); assert_eq!(trins.len(), 1); assert_eq!(trins[0].body, "left it at the inn"); - assert_eq!(store.purses.lock().unwrap()[0].1, PURSE - 30); + assert_eq!(store.mail.purses.lock().unwrap()[0].1, PURSE - 30); } #[test] @@ -1382,13 +1474,13 @@ fn each_refused_send_reaches_the_client_as_its_own_wire_error() { let store = seated_sender(); assert_eq!(send_over_the_wire(&store, receiver), want, "for {receiver}"); assert_eq!( - store.purses.lock().unwrap()[0].1, + store.mail.purses.lock().unwrap()[0].1, PURSE, "a refused send charges nothing" ); } let store = seated_sender(); - *store.purses.lock().unwrap() = vec![(1, 10)]; + *store.mail.purses.lock().unwrap() = vec![(1, 10)]; assert_eq!(send_over_the_wire(&store, "Trin"), R::ErrNotEnoughMoney); } @@ -1399,7 +1491,7 @@ fn a_refused_mail_opcode_costs_a_packet_and_never_the_session() { 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.as_ref()).unwrap(); + run_world_session(server_end, server_store.clone()).unwrap(); }); let (mut c_enc, mut c_dec) = client_handshake(&mut client, "TESTER", K); @@ -1416,9 +1508,7 @@ fn a_refused_mail_opcode_costs_a_packet_and_never_the_session() { CMSG_PLAYER_LOGIN { guid: Guid::new(1) } .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); - for _ in 0..WORLD_ENTRY_PACKETS { - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - } + drain_world_entry(&mut client, &mut c_dec); wow_world_messages::vanilla::CMSG_GET_MAIL_LIST { mailbox: Guid::new(FAR_MAILBOX), } @@ -1463,7 +1553,7 @@ fn attached_copper_leaves_the_senders_purse_at_send_and_rides_the_letter() { post_money(world.as_ref(), "Trin", ATTACHED).expect("the send goes through"); assert_eq!( - world.purses.lock().unwrap()[0].1, + world.mail.purses.lock().unwrap()[0].1, PURSE - lyracore_shared::mail::total_cost(ATTACHED), "one debit for the postage AND the coin" ); @@ -1475,13 +1565,17 @@ fn attached_copper_leaves_the_senders_purse_at_send_and_rides_the_letter() { fn a_sender_who_cannot_afford_the_attachment_is_refused_and_charged_nothing() { let (_realm, world, _instances, calls) = sharded_send(); let barely = lyracore_shared::mail::total_cost(ATTACHED) - 1; - *world.purses.lock().unwrap() = vec![(GINGER, barely)]; + *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"); assert!(matches!(refusal, mail::SendRefusal::NotEnoughMoney(_))); - assert_eq!(world.purses.lock().unwrap()[0].1, barely, "charged nothing"); + assert_eq!( + world.mail.purses.lock().unwrap()[0].1, + barely, + "charged nothing" + ); assert!(mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX) .unwrap() .is_empty()); @@ -1495,13 +1589,13 @@ fn a_sender_who_cannot_afford_the_attachment_is_refused_and_charged_nothing() { #[test] fn taking_a_mails_money_credits_the_purse_and_leaves_the_letter_readable() { let (_realm, world, _instances, _calls) = sharded_send(); - *world.purses.lock().unwrap() = vec![(GINGER, PURSE), (TRIN, 0)]; + *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; mail::take_money(world.as_ref(), Some(TRIN), MAILBOX, mail_id).expect("the take goes through"); - assert_eq!(world.purses.lock().unwrap()[1].1, ATTACHED); + 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"); assert_eq!(trins.len(), 1, "a mail emptied of money is still a letter"); assert_eq!(trins[0].money, 0); @@ -1514,7 +1608,7 @@ fn taking_a_mails_money_credits_the_purse_and_leaves_the_letter_readable() { #[test] fn taking_the_money_twice_credits_the_purse_once() { let (_realm, world, _instances, _calls) = sharded_send(); - *world.purses.lock().unwrap() = vec![(GINGER, PURSE), (TRIN, 0)]; + *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; @@ -1523,13 +1617,13 @@ fn taking_the_money_twice_credits_the_purse_once() { .expect_err("there is nothing left in it"); assert!(err.to_string().contains("nothing to take"), "got {err}"); - assert_eq!(world.purses.lock().unwrap()[1].1, ATTACHED); + assert_eq!(world.mail.purses.lock().unwrap()[1].1, ATTACHED); } #[test] fn taking_money_from_somebody_elses_mail_is_refused() { let (_realm, world, _instances, _calls) = sharded_send(); - *world.purses.lock().unwrap() = vec![(GINGER, PURSE), (TRIN, 0)]; + *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; @@ -1544,7 +1638,7 @@ fn taking_money_from_somebody_elses_mail_is_refused() { "'not yours' and 'no such mail' must read the same, or a crafted id enumerates mailboxes" ); assert_eq!( - world.purses.lock().unwrap()[0].1, + world.mail.purses.lock().unwrap()[0].1, PURSE - lyracore_shared::mail::total_cost(ATTACHED) ); assert_eq!( @@ -1559,7 +1653,7 @@ fn both_planes_attach_and_take_the_same_copper() { let (_realm, world, _instances, calls) = sharded_send(); let single = unsharded_send(); for store in [&world, &single] { - *store.purses.lock().unwrap() = vec![(GINGER, PURSE), (TRIN, 0)]; + *store.mail.purses.lock().unwrap() = vec![(GINGER, PURSE), (TRIN, 0)]; } for store in [world.as_ref(), single.as_ref()] { @@ -1569,8 +1663,8 @@ fn both_planes_attach_and_take_the_same_copper() { } assert_eq!( - world.purses.lock().unwrap().clone(), - single.purses.lock().unwrap().clone(), + world.mail.purses.lock().unwrap().clone(), + single.mail.purses.lock().unwrap().clone(), "the two planes must move the same copper" ); assert_eq!( @@ -1578,8 +1672,8 @@ fn both_planes_attach_and_take_the_same_copper() { mail::open_mailbox(single.as_ref(), Some(TRIN), MAILBOX).unwrap(), ); assert!( - single.mail_escrows.lock().unwrap().is_empty() - && single.mail_receipts.lock().unwrap().is_empty(), + single.mail.mail_escrows.lock().unwrap().is_empty() + && single.mail.mail_receipts.lock().unwrap().is_empty(), "the single-database plane must not route through the escrow — it HAS the transaction" ); assert!(!escrow_steps(&calls).is_empty(), "and the sharded one must"); @@ -1612,6 +1706,7 @@ fn a_sharded_send_drives_fence_then_commit_then_confirm_then_settle() { fn a_sharded_send_waits_out_a_lagging_escrow_cache_and_still_settles() { let (_realm, world, _instances, calls) = sharded_send(); world + .mail .mail_escrow_reads_before_visible .store(3, std::sync::atomic::Ordering::SeqCst); @@ -1632,11 +1727,12 @@ fn a_sharded_send_waits_out_a_lagging_escrow_cache_and_still_settles() { assert_eq!(trins.len(), 1); assert_eq!(trins[0].money, ATTACHED); assert_eq!( - world.purses.lock().unwrap()[0].1, + world.mail.purses.lock().unwrap()[0].1, PURSE - lyracore_shared::mail::total_cost(ATTACHED) ); assert_eq!( world + .mail .mail_escrow_reads_before_visible .load(std::sync::atomic::Ordering::SeqCst), 0, @@ -1647,7 +1743,7 @@ fn a_sharded_send_waits_out_a_lagging_escrow_cache_and_still_settles() { #[test] fn a_sharded_take_drives_the_same_four_steps_the_other_way() { let (_realm, world, _instances, calls) = sharded_send(); - *world.purses.lock().unwrap() = vec![(GINGER, PURSE), (TRIN, 0)]; + *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; calls.lock().unwrap().clear(); @@ -1668,29 +1764,32 @@ fn a_sharded_take_drives_the_same_four_steps_the_other_way() { #[test] fn a_send_killed_before_the_commit_is_re_driven_at_the_next_mailbox_visit() { let (realm, world, _instances, _calls) = sharded_send(); - *realm.mail_kill_at.lock().unwrap() = Some("mail_commit".into()); + *realm.mail.mail_kill_at.lock().unwrap() = Some("mail_commit".into()); let refusal = post_money(world.as_ref(), "Trin", ATTACHED) .expect_err("realm-core never answered the commit"); assert!(matches!(refusal, mail::SendRefusal::Internal(_))); assert_eq!( - world.purses.lock().unwrap()[0].1, + 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_escrows.lock().unwrap().len(), 1, "held"); + assert_eq!(world.mail.mail_escrows.lock().unwrap().len(), 1, "held"); assert!(mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX) .unwrap() .is_empty()); - *realm.mail_kill_at.lock().unwrap() = None; + *realm.mail.mail_kill_at.lock().unwrap() = None; mail::open_mailbox(world.as_ref(), Some(GINGER), MAILBOX).expect("the gate opens"); let trins = mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).expect("the gate opens"); assert_eq!(trins.len(), 1, "the letter finally landed — exactly once"); assert_eq!(trins[0].money, ATTACHED); - assert!(world.mail_escrows.lock().unwrap().is_empty(), "and settled"); + assert!( + world.mail.mail_escrows.lock().unwrap().is_empty(), + "and settled" + ); assert_eq!( - world.purses.lock().unwrap()[0].1, + world.mail.purses.lock().unwrap()[0].1, PURSE - lyracore_shared::mail::total_cost(ATTACHED), "debited once across the whole episode" ); @@ -1699,7 +1798,7 @@ fn a_send_killed_before_the_commit_is_re_driven_at_the_next_mailbox_visit() { #[test] fn a_send_killed_after_the_commit_re_drives_into_one_letter() { let (_realm, world, _instances, _calls) = sharded_send(); - *world.mail_kill_at.lock().unwrap() = Some("mail_confirm_delivery".into()); + *world.mail.mail_kill_at.lock().unwrap() = Some("mail_confirm_delivery".into()); post_money(world.as_ref(), "Trin", ATTACHED).expect_err("the attestation never landed"); assert_eq!( @@ -1708,9 +1807,13 @@ fn a_send_killed_after_the_commit_re_drives_into_one_letter() { .len(), 1 ); - assert_eq!(world.mail_escrows.lock().unwrap().len(), 1, "still fenced"); + assert_eq!( + world.mail.mail_escrows.lock().unwrap().len(), + 1, + "still fenced" + ); - *world.mail_kill_at.lock().unwrap() = None; + *world.mail.mail_kill_at.lock().unwrap() = None; mail::open_mailbox(world.as_ref(), Some(GINGER), MAILBOX).expect("the gate opens"); assert_eq!( @@ -1720,9 +1823,9 @@ fn a_send_killed_after_the_commit_re_drives_into_one_letter() { 1, "one letter — the replayed commit found its receipt" ); - assert!(world.mail_escrows.lock().unwrap().is_empty()); + assert!(world.mail.mail_escrows.lock().unwrap().is_empty()); assert_eq!( - world.purses.lock().unwrap()[0].1, + world.mail.purses.lock().unwrap()[0].1, PURSE - lyracore_shared::mail::total_cost(ATTACHED) ); } @@ -1730,63 +1833,86 @@ fn a_send_killed_after_the_commit_re_drives_into_one_letter() { #[test] fn a_take_killed_before_the_payout_is_re_driven_at_the_next_mailbox_visit() { let (realm, world, _instances, _calls) = sharded_send(); - *world.purses.lock().unwrap() = vec![(GINGER, PURSE), (TRIN, 0)]; + *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; - *world.mail_kill_at.lock().unwrap() = Some("mail_payout".into()); + *world.mail.mail_kill_at.lock().unwrap() = Some("mail_payout".into()); mail::take_money(world.as_ref(), Some(TRIN), MAILBOX, mail_id) .expect_err("the purse was never credited"); - assert_eq!(world.purses.lock().unwrap()[1].1, 0); + assert_eq!(world.mail.purses.lock().unwrap()[1].1, 0); assert_eq!( mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).unwrap()[0].money, 0, "the copper has left the row" ); - assert_eq!(realm.mail_escrows.lock().unwrap().len(), 1, "and is held"); + assert_eq!( + realm.mail.mail_escrows.lock().unwrap().len(), + 1, + "and is held" + ); - *world.mail_kill_at.lock().unwrap() = None; + *world.mail.mail_kill_at.lock().unwrap() = None; mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).expect("the gate opens"); - assert_eq!(world.purses.lock().unwrap()[1].1, ATTACHED, "paid, once"); - assert!(realm.mail_escrows.lock().unwrap().is_empty(), "and settled"); + assert_eq!( + world.mail.purses.lock().unwrap()[1].1, + ATTACHED, + "paid, once" + ); + assert!( + realm.mail.mail_escrows.lock().unwrap().is_empty(), + "and settled" + ); } #[test] fn a_take_killed_after_the_payout_re_drives_into_one_credit() { let (realm, world, _instances, _calls) = sharded_send(); - *world.purses.lock().unwrap() = vec![(GINGER, PURSE), (TRIN, 0)]; + *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; - *realm.mail_kill_at.lock().unwrap() = Some("mail_confirm_delivery".into()); + *realm.mail.mail_kill_at.lock().unwrap() = Some("mail_confirm_delivery".into()); mail::take_money(world.as_ref(), Some(TRIN), MAILBOX, mail_id) .expect_err("the attestation never landed, so the click is reported as failed"); assert_eq!( - world.purses.lock().unwrap()[1].1, + world.mail.purses.lock().unwrap()[1].1, ATTACHED, "but the purse WAS credited — which is exactly why the re-drive must not credit it again" ); - assert_eq!(realm.mail_escrows.lock().unwrap().len(), 1, "still fenced"); + assert_eq!( + realm.mail.mail_escrows.lock().unwrap().len(), + 1, + "still fenced" + ); - *realm.mail_kill_at.lock().unwrap() = None; + *realm.mail.mail_kill_at.lock().unwrap() = None; mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).expect("the gate opens"); - assert_eq!(world.purses.lock().unwrap()[1].1, ATTACHED, "credited once"); - assert!(realm.mail_escrows.lock().unwrap().is_empty()); + assert_eq!( + world.mail.purses.lock().unwrap()[1].1, + ATTACHED, + "credited once" + ); + assert!(realm.mail.mail_escrows.lock().unwrap().is_empty()); } #[test] fn a_settle_without_an_attestation_is_refused_and_the_fence_survives() { let (_realm, world, _instances, _calls) = sharded_send(); - *world.mail_kill_at.lock().unwrap() = Some("mail_confirm_delivery".into()); + *world.mail.mail_kill_at.lock().unwrap() = Some("mail_confirm_delivery".into()); post_money(world.as_ref(), "Trin", ATTACHED).expect_err("no attestation"); - let escrow_id = world.mail_escrows.lock().unwrap()[0].1.escrow_id; + let escrow_id = world.mail.mail_escrows.lock().unwrap()[0].1.escrow_id; let err = world.mail_settle(escrow_id).expect_err("not attested"); assert!(err.to_string().contains("not attested"), "got {err}"); - assert_eq!(world.mail_escrows.lock().unwrap().len(), 1, "still held"); + assert_eq!( + world.mail.mail_escrows.lock().unwrap().len(), + 1, + "still held" + ); } #[test] @@ -1816,7 +1942,7 @@ fn taking_money_over_the_wire_acks_and_credits_the_purse() { SMSG_SEND_MAIL_RESULT_MailAction, SMSG_SEND_MAIL_RESULT_MailResultTwo, }; let store = seated_sender(); - *store.mails.lock().unwrap() = vec![( + *store.mail.mails.lock().unwrap() = vec![( 1, codec::MailView { id: 7, @@ -1831,15 +1957,13 @@ fn taking_money_over_the_wire_acks_and_credits_the_purse() { let (mut client, server_end) = UnixStream::pair().unwrap(); let server_store = store.clone(); let server = std::thread::spawn(move || { - run_world_session(server_end, server_store.as_ref()).unwrap(); + run_world_session(server_end, server_store.clone()).unwrap(); }); 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(); - for _ in 0..WORLD_ENTRY_PACKETS { - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - } + drain_world_entry(&mut client, &mut c_dec); wow_world_messages::vanilla::CMSG_MAIL_TAKE_MONEY { mailbox: Guid::new(MAILBOX), @@ -1862,8 +1986,8 @@ fn taking_money_over_the_wire_acks_and_credits_the_purse() { drop(client); server.join().unwrap(); - assert_eq!(store.purses.lock().unwrap()[0].1, PURSE + ATTACHED); - assert_eq!(store.mails.lock().unwrap()[0].1.money, 0); + assert_eq!(store.mail.purses.lock().unwrap()[0].1, PURSE + ATTACHED); + assert_eq!(store.mail.mails.lock().unwrap()[0].1.money, 0); } const SWORD_GUID: u64 = 0x4000_0000_0000_0011; fn sword() -> mail::AttachedItem { @@ -1877,15 +2001,21 @@ fn sword() -> mail::AttachedItem { item_text_id: 0, } } -fn give_item(shard: &InMemoryStore, owner: u64, guid: u64, item: mail::AttachedItem) { - shard.mail_items.lock().unwrap().push((guid, owner, item)); +fn give_item(shard: &WorldFake, owner: u64, guid: u64, item: mail::AttachedItem) { + shard + .mail + .mail_items + .lock() + .unwrap() + .push((guid, owner, item)); } -fn claimable_swords(shards: &[&InMemoryStore], realm: &InMemoryStore) -> usize { +fn claimable_swords(shards: &[&WorldFake], realm: &WorldFake) -> usize { let in_bags: usize = shards .iter() - .map(|s| s.mail_items.lock().unwrap().len()) + .map(|s| s.mail.mail_items.lock().unwrap().len()) .sum(); let in_mail = realm + .mail .mails .lock() .unwrap() @@ -1974,11 +2104,11 @@ fn a_soulbound_attachment_is_refused_at_send_and_stays_in_the_senders_bags() { assert!(matches!(refusal, mail::SendRefusal::AttachmentSoulbound(_))); assert_eq!(world.bags_of(GINGER).len(), 1, "still theirs"); assert_eq!( - world.purses.lock().unwrap()[0].1, + world.mail.purses.lock().unwrap()[0].1, PURSE, "and a refused send costs nothing" ); - assert!(world.mail_escrows.lock().unwrap().is_empty()); + assert!(world.mail.mail_escrows.lock().unwrap().is_empty()); } #[test] @@ -2000,19 +2130,19 @@ fn attaching_an_item_the_sender_does_not_own_is_refused() { let refusal = post_item(world.as_ref(), "Trin").expect_err("it is not Ginger's"); assert!(matches!(refusal, mail::SendRefusal::AttachmentInvalid(_))); - world.mail_items.lock().unwrap().clear(); + world.mail.mail_items.lock().unwrap().clear(); let refusal = post_item(world.as_ref(), "Trin").expect_err("and nothing answers to that guid"); assert!(matches!(refusal, mail::SendRefusal::AttachmentInvalid(_))); - assert!(world.mail_escrows.lock().unwrap().is_empty()); + assert!(world.mail.mail_escrows.lock().unwrap().is_empty()); } #[test] fn an_item_in_flight_cannot_be_attached_to_a_second_letter() { let (realm, world, _instances, _calls) = sharded_send(); give_item(&world, GINGER, SWORD_GUID, sword()); - *realm.mail_kill_at.lock().unwrap() = Some("mail_commit".into()); + *realm.mail.mail_kill_at.lock().unwrap() = Some("mail_commit".into()); post_item(world.as_ref(), "Trin").expect_err("realm-core never answered"); - assert_eq!(world.mail_escrows.lock().unwrap().len(), 1, "held"); + 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"); @@ -2025,8 +2155,8 @@ fn an_item_in_flight_cannot_be_attached_to_a_second_letter() { ); } fn delivered_item() -> ( - std::sync::Arc, - std::sync::Arc, + std::sync::Arc, + std::sync::Arc, ShardCallLog, u64, ) { @@ -2058,7 +2188,10 @@ fn taking_an_item_puts_it_in_the_takers_bags_with_its_state_unchanged() { "a mail emptied of its item is still a letter" ); assert_eq!(trins[0].item_entry, 0); - assert!(realm.mail_escrows.lock().unwrap().is_empty(), "settled"); + assert!( + realm.mail.mail_escrows.lock().unwrap().is_empty(), + "settled" + ); } /// A Letter Copy mailed on and taken again keeps its text id, so the taker can still read it. The @@ -2082,7 +2215,10 @@ fn a_taken_letter_keeps_its_text_id() { mail::take_item(world.as_ref(), Some(TRIN), MAILBOX, mail_id).expect("the take completes"); assert_eq!(world.bags_of(TRIN), vec![letter]); - assert!(realm.mail_escrows.lock().unwrap().is_empty(), "settled"); + assert!( + realm.mail.mail_escrows.lock().unwrap().is_empty(), + "settled" + ); } #[test] @@ -2119,6 +2255,7 @@ fn a_sharded_item_take_probes_for_room_before_it_fences_anything() { fn a_sharded_item_take_waits_out_a_lagging_escrow_cache_and_still_settles() { let (realm, world, _calls, mail_id) = delivered_item(); realm + .mail .mail_escrow_reads_before_visible .store(3, std::sync::atomic::Ordering::SeqCst); @@ -2127,13 +2264,17 @@ fn a_sharded_item_take_waits_out_a_lagging_escrow_cache_and_still_settles() { assert_eq!(taken, (sword().entry, sword().stack_count)); assert_eq!(world.bags_of(TRIN), vec![sword()]); - assert!(realm.mail_escrows.lock().unwrap().is_empty(), "settled"); + assert!( + realm.mail.mail_escrows.lock().unwrap().is_empty(), + "settled" + ); } #[test] fn a_take_into_a_full_bag_is_refused_and_the_item_stays_in_the_mail() { let (realm, world, _calls, mail_id) = delivered_item(); world + .mail .bags_full .store(true, std::sync::atomic::Ordering::Relaxed); @@ -2150,9 +2291,10 @@ fn a_take_into_a_full_bag_is_refused_and_the_item_stays_in_the_mail() { sword().entry, "the item is still in the letter — nothing was fenced" ); - assert!(realm.mail_escrows.lock().unwrap().is_empty()); + assert!(realm.mail.mail_escrows.lock().unwrap().is_empty()); world + .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"); @@ -2166,6 +2308,7 @@ fn a_take_into_a_full_bag_on_one_database_leaves_the_item_in_the_mail() { post_item(single.as_ref(), "Trin").expect("posted"); let mail_id = mail::open_mailbox(single.as_ref(), Some(TRIN), MAILBOX).unwrap()[0].id; single + .mail .bags_full .store(true, std::sync::atomic::Ordering::Relaxed); @@ -2216,20 +2359,23 @@ fn taking_the_same_item_twice_grants_it_once() { fn an_item_send_killed_before_the_commit_is_re_driven_at_the_next_mailbox_visit() { let (realm, world, _instances, _calls) = sharded_send(); give_item(&world, GINGER, SWORD_GUID, sword()); - *realm.mail_kill_at.lock().unwrap() = Some("mail_commit".into()); + *realm.mail.mail_kill_at.lock().unwrap() = Some("mail_commit".into()); post_item(world.as_ref(), "Trin").expect_err("realm-core never answered the commit"); assert!(world.bags_of(GINGER).is_empty()); - assert_eq!(world.mail_escrows.lock().unwrap().len(), 1, "held"); + assert_eq!(world.mail.mail_escrows.lock().unwrap().len(), 1, "held"); assert_eq!(claimable_swords(&[&world], &realm), 0); - *realm.mail_kill_at.lock().unwrap() = None; + *realm.mail.mail_kill_at.lock().unwrap() = None; mail::open_mailbox(world.as_ref(), Some(GINGER), MAILBOX).expect("the gate opens"); let trins = mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).unwrap(); assert_eq!(trins.len(), 1, "the letter finally landed — exactly once"); assert_eq!(trins[0].item_durability, sword().durability); - assert!(world.mail_escrows.lock().unwrap().is_empty(), "and settled"); + assert!( + world.mail.mail_escrows.lock().unwrap().is_empty(), + "and settled" + ); assert_eq!(claimable_swords(&[&world], &realm), 1); } @@ -2237,7 +2383,7 @@ fn an_item_send_killed_before_the_commit_is_re_driven_at_the_next_mailbox_visit( fn an_item_send_killed_after_the_commit_re_drives_into_one_item() { let (realm, world, _instances, _calls) = sharded_send(); give_item(&world, GINGER, SWORD_GUID, sword()); - *world.mail_kill_at.lock().unwrap() = Some("mail_confirm_delivery".into()); + *world.mail.mail_kill_at.lock().unwrap() = Some("mail_confirm_delivery".into()); post_item(world.as_ref(), "Trin").expect_err("the attestation never landed"); assert_eq!( @@ -2245,9 +2391,13 @@ fn an_item_send_killed_after_the_commit_re_drives_into_one_item() { 1, "in the mailbox only" ); - assert_eq!(world.mail_escrows.lock().unwrap().len(), 1, "still fenced"); + assert_eq!( + world.mail.mail_escrows.lock().unwrap().len(), + 1, + "still fenced" + ); - *world.mail_kill_at.lock().unwrap() = None; + *world.mail.mail_kill_at.lock().unwrap() = None; mail::open_mailbox(world.as_ref(), Some(GINGER), MAILBOX).expect("the gate opens"); assert_eq!( @@ -2258,55 +2408,65 @@ fn an_item_send_killed_after_the_commit_re_drives_into_one_item() { "one letter — the replayed commit found its receipt" ); assert_eq!(claimable_swords(&[&world], &realm), 1); - assert!(world.mail_escrows.lock().unwrap().is_empty()); + assert!(world.mail.mail_escrows.lock().unwrap().is_empty()); } #[test] fn an_item_send_killed_after_the_attestation_settles_on_the_next_visit() { let (realm, world, _instances, _calls) = sharded_send(); give_item(&world, GINGER, SWORD_GUID, sword()); - *world.mail_kill_at.lock().unwrap() = Some("mail_settle".into()); + *world.mail.mail_kill_at.lock().unwrap() = Some("mail_settle".into()); post_item(world.as_ref(), "Trin").expect_err("the settle never landed"); - assert_eq!(world.mail_escrows.lock().unwrap().len(), 1); + assert_eq!(world.mail.mail_escrows.lock().unwrap().len(), 1); assert_eq!(claimable_swords(&[&world], &realm), 1); - *world.mail_kill_at.lock().unwrap() = None; + *world.mail.mail_kill_at.lock().unwrap() = None; mail::open_mailbox(world.as_ref(), Some(GINGER), MAILBOX).expect("the gate opens"); - assert!(world.mail_escrows.lock().unwrap().is_empty(), "settled"); + assert!( + world.mail.mail_escrows.lock().unwrap().is_empty(), + "settled" + ); assert_eq!(claimable_swords(&[&world], &realm), 1); } #[test] 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_kill_at.lock().unwrap() = Some("mail_item_payout".into()); + *world.mail.mail_kill_at.lock().unwrap() = Some("mail_item_payout".into()); mail::take_item(world.as_ref(), Some(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_escrows.lock().unwrap().len(), 1); + assert_eq!(realm.mail.mail_escrows.lock().unwrap().len(), 1); - *world.mail_kill_at.lock().unwrap() = None; + *world.mail.mail_kill_at.lock().unwrap() = None; mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).expect("the gate opens"); assert_eq!(world.bags_of(TRIN), vec![sword()], "granted, once"); - assert!(realm.mail_escrows.lock().unwrap().is_empty(), "and settled"); + assert!( + realm.mail.mail_escrows.lock().unwrap().is_empty(), + "and settled" + ); } #[test] fn an_item_take_killed_after_the_payout_re_drives_into_one_item() { let (realm, world, _calls, mail_id) = delivered_item(); - *realm.mail_kill_at.lock().unwrap() = Some("mail_confirm_delivery".into()); + *realm.mail.mail_kill_at.lock().unwrap() = Some("mail_confirm_delivery".into()); mail::take_item(world.as_ref(), Some(TRIN), MAILBOX, mail_id) .expect_err("the attestation never landed"); assert_eq!(world.bags_of(TRIN), vec![sword()]); - assert_eq!(realm.mail_escrows.lock().unwrap().len(), 1, "still fenced"); + assert_eq!( + realm.mail.mail_escrows.lock().unwrap().len(), + 1, + "still fenced" + ); - *realm.mail_kill_at.lock().unwrap() = None; + *realm.mail.mail_kill_at.lock().unwrap() = None; mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).expect("the gate opens"); assert_eq!( @@ -2315,7 +2475,7 @@ fn an_item_take_killed_after_the_payout_re_drives_into_one_item() { "granted once, not twice" ); assert_eq!(claimable_swords(&[&world], &realm), 1); - assert!(realm.mail_escrows.lock().unwrap().is_empty()); + assert!(realm.mail.mail_escrows.lock().unwrap().is_empty()); } const COD: u32 = 250; fn post_cod( @@ -2336,29 +2496,30 @@ fn post_cod( ) } fn delivered_cod() -> ( - std::sync::Arc, - std::sync::Arc, + std::sync::Arc, + std::sync::Arc, ShardCallLog, u64, ) { let (realm, world, _instances, calls) = sharded_send(); - *world.purses.lock().unwrap() = vec![(GINGER, PURSE), (TRIN, PURSE)]; + *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; calls.lock().unwrap().clear(); (realm, world, calls, mail_id) } -fn delivered_cod_unsharded() -> (std::sync::Arc, u64) { +fn delivered_cod_unsharded() -> (std::sync::Arc, u64) { let single = unsharded_send(); - *single.purses.lock().unwrap() = vec![(GINGER, PURSE), (TRIN, PURSE)]; + *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; (single, mail_id) } -fn purse_of(store: &InMemoryStore, guid: u64) -> u32 { +fn purse_of(store: &WorldFake, guid: u64) -> u32 { store + .mail .purses .lock() .unwrap() @@ -2406,14 +2567,17 @@ fn taking_a_priced_item_debits_the_buyer_and_posts_the_copper_to_the_seller() { PURSE - lyracore_shared::mail::total_cost(0) + COD, "the seller is up the price, less the postage they paid to post the sword" ); - assert!(realm.mail_escrows.lock().unwrap().is_empty(), "settled"); + assert!( + realm.mail.mail_escrows.lock().unwrap().is_empty(), + "settled" + ); assert_eq!(claimable_swords(&[&world], &realm), 1); } #[test] fn a_buyer_who_cannot_afford_the_price_is_refused_and_nothing_moves() { let (realm, world, _calls, mail_id) = delivered_cod(); - *world.purses.lock().unwrap() = vec![(GINGER, PURSE), (TRIN, COD - 1)]; + *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"); @@ -2443,7 +2607,7 @@ fn a_buyer_who_cannot_afford_the_price_is_refused_and_nothing_moves() { #[test] fn a_buyer_who_refuses_the_price_can_return_the_mail_instead() { let (realm, world, _calls, mail_id) = delivered_cod(); - *world.purses.lock().unwrap() = vec![(GINGER, PURSE), (TRIN, COD - 1)]; + *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::return_to_sender(world.as_ref(), Some(TRIN), MAILBOX, mail_id).expect("declined"); @@ -2519,8 +2683,8 @@ fn both_planes_settle_the_same_cod() { assert_eq!(sharded_seller[0].money, single_seller[0].money); assert_eq!(sharded_seller[0].subject, single_seller[0].subject); assert!( - single.mail_escrows.lock().unwrap().is_empty() - && single.mail_receipts.lock().unwrap().is_empty(), + single.mail.mail_escrows.lock().unwrap().is_empty() + && single.mail.mail_receipts.lock().unwrap().is_empty(), "the single-database plane must not route through the escrow — it HAS the transaction" ); } @@ -2556,6 +2720,7 @@ fn a_sharded_cod_take_pays_before_it_fences_the_item() { fn a_full_bag_refuses_a_priced_take_before_any_copper_moves() { let (realm, world, _calls, mail_id) = delivered_cod(); world + .mail .bags_full .store(true, std::sync::atomic::Ordering::Relaxed); @@ -2577,7 +2742,7 @@ fn a_full_bag_refuses_a_priced_take_before_any_copper_moves() { ); assert_eq!(claimable_swords(&[&world], &realm), 1); } -fn assert_the_price_is_in_exactly_one_place(world: &InMemoryStore, realm: &InMemoryStore) { +fn assert_the_price_is_in_exactly_one_place(world: &WorldFake, realm: &WorldFake) { let purse = purse_of(world, TRIN); assert!( purse == PURSE || purse == PURSE - COD, @@ -2585,6 +2750,7 @@ fn assert_the_price_is_in_exactly_one_place(world: &InMemoryStore, realm: &InMem nothing credits it back" ); let fenced: u32 = world + .mail .mail_escrows .lock() .unwrap() @@ -2592,6 +2758,7 @@ fn assert_the_price_is_in_exactly_one_place(world: &InMemoryStore, realm: &InMem .map(|(_, e)| e.money) .sum(); let paid: u32 = realm + .mail .mails .lock() .unwrap() @@ -2615,12 +2782,12 @@ fn assert_the_price_is_in_exactly_one_place(world: &InMemoryStore, realm: &InMem #[test] 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_kill_at.lock().unwrap() = Some("mail_commit".into()); + *realm.mail.mail_kill_at.lock().unwrap() = Some("mail_commit".into()); mail::take_item(world.as_ref(), Some(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_escrows.lock().unwrap().len(), 1, "held"); + 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, sword().entry, @@ -2628,7 +2795,7 @@ fn a_cod_payment_killed_before_the_commit_is_re_driven_at_the_next_mailbox_visit ); assert_the_price_is_in_exactly_one_place(&world, &realm); - *realm.mail_kill_at.lock().unwrap() = None; + *realm.mail.mail_kill_at.lock().unwrap() = None; mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).expect("the gate opens"); assert_eq!( @@ -2636,7 +2803,10 @@ fn a_cod_payment_killed_before_the_commit_is_re_driven_at_the_next_mailbox_visit COD, "the seller is paid, once" ); - assert!(world.mail_escrows.lock().unwrap().is_empty(), "and settled"); + assert!( + world.mail.mail_escrows.lock().unwrap().is_empty(), + "and settled" + ); assert_the_price_is_in_exactly_one_place(&world, &realm); assert_eq!(claimable_swords(&[&world], &realm), 1); } @@ -2644,9 +2814,9 @@ fn a_cod_payment_killed_before_the_commit_is_re_driven_at_the_next_mailbox_visit #[test] fn a_second_click_resumes_a_held_payment_rather_than_charging_twice() { let (realm, world, _calls, mail_id) = delivered_cod(); - *realm.mail_kill_at.lock().unwrap() = Some("mail_commit".into()); + *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"); - *realm.mail_kill_at.lock().unwrap() = None; + *realm.mail.mail_kill_at.lock().unwrap() = None; mail::take_item(world.as_ref(), Some(TRIN), MAILBOX, mail_id).expect("the second click lands"); @@ -2659,14 +2829,14 @@ fn a_second_click_resumes_a_held_payment_rather_than_charging_twice() { "and the seller was paid once" ); assert_eq!(world.bags_of(TRIN), vec![sword()]); - assert!(world.mail_escrows.lock().unwrap().is_empty()); + assert!(world.mail.mail_escrows.lock().unwrap().is_empty()); assert_eq!(claimable_swords(&[&world], &realm), 1); } #[test] fn a_cod_take_killed_after_the_payment_hands_the_item_over_for_free_on_the_next_click() { let (realm, world, _calls, mail_id) = delivered_cod(); - *realm.mail_kill_at.lock().unwrap() = Some("mail_take_item_fence".into()); + *realm.mail.mail_kill_at.lock().unwrap() = Some("mail_take_item_fence".into()); mail::take_item(world.as_ref(), Some(TRIN), MAILBOX, mail_id) .expect_err("the item was never fenced"); @@ -2678,7 +2848,7 @@ fn a_cod_take_killed_after_the_payment_hands_the_item_over_for_free_on_the_next_ ); assert_the_price_is_in_exactly_one_place(&world, &realm); - *realm.mail_kill_at.lock().unwrap() = None; + *realm.mail.mail_kill_at.lock().unwrap() = None; mail::take_item(world.as_ref(), Some(TRIN), MAILBOX, mail_id).expect("the retry lands"); assert_eq!(purse_of(&world, TRIN), PURSE - COD, "charged once in total"); @@ -2689,21 +2859,24 @@ fn a_cod_take_killed_after_the_payment_hands_the_item_over_for_free_on_the_next_ #[test] fn a_cod_take_killed_before_the_item_payout_is_re_driven_at_the_next_mailbox_visit() { let (realm, world, _calls, mail_id) = delivered_cod(); - *world.mail_kill_at.lock().unwrap() = Some("mail_item_payout".into()); + *world.mail.mail_kill_at.lock().unwrap() = Some("mail_item_payout".into()); mail::take_item(world.as_ref(), Some(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"); - assert_eq!(realm.mail_escrows.lock().unwrap().len(), 1); + assert_eq!(realm.mail.mail_escrows.lock().unwrap().len(), 1); assert_the_price_is_in_exactly_one_place(&world, &realm); - *world.mail_kill_at.lock().unwrap() = None; + *world.mail.mail_kill_at.lock().unwrap() = None; mail::open_mailbox(world.as_ref(), Some(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"); - assert!(realm.mail_escrows.lock().unwrap().is_empty(), "settled"); + assert!( + realm.mail.mail_escrows.lock().unwrap().is_empty(), + "settled" + ); assert_eq!(claimable_swords(&[&world], &realm), 1); } @@ -2711,10 +2884,10 @@ fn a_cod_take_killed_before_the_item_payout_is_re_driven_at_the_next_mailbox_vis fn a_priced_send_killed_before_the_commit_re_drives_with_its_price_intact() { let (realm, world, _instances, _calls) = sharded_send(); give_item(&world, GINGER, SWORD_GUID, sword()); - *realm.mail_kill_at.lock().unwrap() = Some("mail_commit".into()); + *realm.mail.mail_kill_at.lock().unwrap() = Some("mail_commit".into()); post_cod(world.as_ref(), "Trin", COD).expect_err("realm-core never answered"); - *realm.mail_kill_at.lock().unwrap() = None; + *realm.mail.mail_kill_at.lock().unwrap() = None; mail::open_mailbox(world.as_ref(), Some(GINGER), MAILBOX).expect("the gate opens"); let trins = mail::open_mailbox(world.as_ref(), Some(TRIN), MAILBOX).unwrap(); @@ -2752,7 +2925,7 @@ fn a_refused_priced_take_reaches_the_client_as_not_enough_money() { SMSG_SEND_MAIL_RESULT_MailAction, SMSG_SEND_MAIL_RESULT_MailResult, }; let store = seated_sender(); - *store.mails.lock().unwrap() = vec![( + *store.mail.mails.lock().unwrap() = vec![( 1, codec::MailView { id: 7, @@ -2769,15 +2942,13 @@ fn a_refused_priced_take_reaches_the_client_as_not_enough_money() { let (mut client, server_end) = UnixStream::pair().unwrap(); let server_store = store.clone(); let server = std::thread::spawn(move || { - run_world_session(server_end, server_store.as_ref()).unwrap(); + run_world_session(server_end, server_store.clone()).unwrap(); }); 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(); - for _ in 0..WORLD_ENTRY_PACKETS { - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - } + drain_world_entry(&mut client, &mut c_dec); wow_world_messages::vanilla::CMSG_MAIL_TAKE_ITEM { mailbox: Guid::new(MAILBOX), @@ -2801,26 +2972,33 @@ fn a_refused_priced_take_reaches_the_client_as_not_enough_money() { drop(client); server.join().unwrap(); - assert_eq!(store.purses.lock().unwrap()[0].1, PURSE, "charged nothing"); - assert_eq!(store.mails.lock().unwrap()[0].1.item_entry, sword().entry); + assert_eq!( + store.mail.purses.lock().unwrap()[0].1, + PURSE, + "charged nothing" + ); + assert_eq!( + store.mail.mails.lock().unwrap()[0].1.item_entry, + sword().entry + ); } /// The same mail rows on both planes, as `(the session's store, the store that holds the rows)`: /// a sharded world handle whose rows live on realm-core, and a single-database store. fn both_planes( mails: Vec<(u64, codec::MailView)>, -) -> [(std::sync::Arc, std::sync::Arc); 2] { +) -> [(std::sync::Arc, std::sync::Arc); 2] { let (realm, world, _calls) = sharded_mailbox(); - *realm.mails.lock().unwrap() = mails.clone(); + *realm.mail.mails.lock().unwrap() = mails.clone(); let single = unsharded_mailbox(); - *single.mails.lock().unwrap() = mails; + *single.mail.mails.lock().unwrap() = mails; [(world, realm), (single.clone(), single)] } -fn listed(store: &InMemoryStore, guid: u64) -> Vec { +fn listed(store: &WorldFake, guid: u64) -> Vec { let mails = mail::open_mailbox(store, Some(guid), MAILBOX).expect("the gate opens"); codec::build_mail_list(&mails, mail::now_secs()).mails } -fn listed_ids(store: &InMemoryStore, guid: u64) -> Vec { +fn listed_ids(store: &WorldFake, guid: u64) -> Vec { listed(store, guid).iter().map(|m| m.message_id).collect() } @@ -2976,7 +3154,7 @@ fn a_mail_not_yet_delivered_cannot_be_read_deleted_taken_from_or_returned() { .expect_err("nothing to return yet"); assert_eq!( - *holder.mails.lock().unwrap(), + *holder.mail.mails.lock().unwrap(), rows, "every refusal left the mail as it was" ); @@ -3016,7 +3194,7 @@ fn a_mail_with_nobody_to_take_it_back_cannot_be_returned_on_either_plane() { ); } assert_eq!( - *holder.mails.lock().unwrap(), + *holder.mail.mails.lock().unwrap(), rows, "the copper stays with Ginger" ); @@ -3055,7 +3233,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.realm_accounts.lock().unwrap() = alts(&[GINGER, VIM]); + *store.mail.realm_accounts.lock().unwrap() = alts(&[GINGER, VIM]); mail::return_to_sender(store.as_ref(), Some(GINGER), MAILBOX, 1).expect("declined"); let back = &listed(&store, VIM)[0]; @@ -3089,20 +3267,18 @@ fn a_priced_mail_cannot_be_deleted_on_either_plane() { #[test] fn deleting_a_priced_mail_answers_the_internal_error_and_keeps_the_mail() { let store = seated_store(); - store.mails.lock().unwrap()[0].1.cod = COD; + store.mail.mails.lock().unwrap()[0].1.cod = COD; let (mut client, server_end) = UnixStream::pair().unwrap(); let server_store = store.clone(); let server = std::thread::spawn(move || { - run_world_session(server_end, server_store.as_ref()).unwrap(); + run_world_session(server_end, server_store.clone()).unwrap(); }); 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(); - for _ in 0..WORLD_ENTRY_PACKETS { - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - } + drain_world_entry(&mut client, &mut c_dec); wow_world_messages::vanilla::CMSG_MAIL_DELETE { mailbox_id: Guid::new(MAILBOX), @@ -3126,15 +3302,16 @@ fn deleting_a_priced_mail_answers_the_internal_error_and_keeps_the_mail() { drop(client); server.join().unwrap(); assert_eq!( - store.mails.lock().unwrap().len(), + store.mail.mails.lock().unwrap().len(), 1, "the priced mail stays" ); } /// Every row `recipient` holds on `plane`, delivered or not. -fn held_for(plane: &InMemoryStore, recipient: u64) -> Vec { +fn held_for(plane: &WorldFake, recipient: u64) -> Vec { plane + .mail .mails .lock() .unwrap() @@ -3144,8 +3321,8 @@ fn held_for(plane: &InMemoryStore, recipient: u64) -> Vec { .collect() } /// The `same_account` answer each mail send, fence and return on `store` carried. -fn same_account_seen(store: &InMemoryStore) -> Vec<(&'static str, bool)> { - store.same_account_seen.lock().unwrap().clone() +fn same_account_seen(store: &WorldFake) -> Vec<(&'static str, bool)> { + store.mail.same_account_seen.lock().unwrap().clone() } #[test] @@ -3187,7 +3364,7 @@ fn copper_to_another_account_arrives_at_once() { fn an_item_to_an_alt_on_another_shard_arrives_at_once() { let (_realm, world, instances, _calls) = sharded_send(); // Vim is Ginger's alt. Only the instances Shard, where Vim lives, names his Realm Account. - *instances.realm_accounts.lock().unwrap() = alts(&[VIM]); + *instances.mail.realm_accounts.lock().unwrap() = alts(&[VIM]); give_item(&world, GINGER, SWORD_GUID, sword()); post_item(world.as_ref(), "Vim").expect("posted"); @@ -3206,8 +3383,8 @@ fn a_shard_that_holds_a_character_on_a_shadow_account_defers_to_another_shard() let (_realm, world, instances, _calls) = sharded_send(); // Ginger came over from the instances Shard. The world Shard holds her on a shadow Account and // cannot name her Realm Account, and the instances Shard kept her Account Character Owner. - *world.realm_accounts.lock().unwrap() = alts(&[TRIN]); - *instances.realm_accounts.lock().unwrap() = alts(&[GINGER]); + *world.mail.realm_accounts.lock().unwrap() = alts(&[TRIN]); + *instances.mail.realm_accounts.lock().unwrap() = alts(&[GINGER]); give_item(&world, GINGER, SWORD_GUID, sword()); post_item(world.as_ref(), "Trin").expect("posted"); @@ -3224,7 +3401,7 @@ fn a_shard_that_holds_a_character_on_a_shadow_account_defers_to_another_shard() #[test] fn a_character_no_shard_can_name_counts_as_another_account() { let (_realm, world, _instances, _calls) = sharded_send(); - *world.realm_accounts.lock().unwrap() = alts(&[GINGER]); + *world.mail.realm_accounts.lock().unwrap() = alts(&[GINGER]); give_item(&world, GINGER, SWORD_GUID, sword()); post_item(world.as_ref(), "Trin").expect("posted"); @@ -3242,7 +3419,7 @@ fn the_single_database_send_carries_the_same_account_answer() { (vec![(GINGER, ALTS.to_string())], false, 0), ] { let single = unsharded_send(); - *single.realm_accounts.lock().unwrap() = accounts; + *single.mail.realm_accounts.lock().unwrap() = accounts; give_item(&single, GINGER, SWORD_GUID, sword()); post_item(single.as_ref(), "Trin").expect("posted"); @@ -3261,8 +3438,8 @@ fn the_single_database_send_carries_the_same_account_answer() { fn an_item_returned_to_another_account_waits_and_to_an_alt_does_not() { for (vims_account, same_account, listed) in [(VIMS_ACCOUNT, false, 0), (ALTS, true, 1)] { let (realm, world, instances, _calls) = sharded_send(); - *instances.realm_accounts.lock().unwrap() = vec![(VIM, vims_account.to_string())]; - *realm.mails.lock().unwrap() = vec![( + *instances.mail.realm_accounts.lock().unwrap() = vec![(VIM, vims_account.to_string())]; + *realm.mail.mails.lock().unwrap() = vec![( GINGER, codec::MailView { item_entry: sword().entry, @@ -3293,19 +3470,24 @@ fn an_item_returned_to_another_account_waits_and_to_an_alt_does_not() { fn an_item_send_re_driven_after_a_restart_keeps_its_delivery_delay() { let (realm, world, instances, _calls) = sharded_send(); give_item(&world, GINGER, SWORD_GUID, sword()); - *realm.mail_kill_at.lock().unwrap() = Some("mail_commit".into()); + *realm.mail.mail_kill_at.lock().unwrap() = Some("mail_commit".into()); post_item(world.as_ref(), "Vim").expect_err("realm-core never answered the commit"); assert_eq!( - world.mail_escrows.lock().unwrap()[0].1.delivery_delay_secs, + world.mail.mail_escrows.lock().unwrap()[0] + .1 + .delivery_delay_secs, 3_600, "the fence holds the hour" ); - *realm.mail_kill_at.lock().unwrap() = None; + *realm.mail.mail_kill_at.lock().unwrap() = None; mail::open_mailbox(world.as_ref(), Some(GINGER), MAILBOX).expect("the gate opens"); - assert!(world.mail_escrows.lock().unwrap().is_empty(), "settled"); + assert!( + world.mail.mail_escrows.lock().unwrap().is_empty(), + "settled" + ); assert_eq!(held_for(&realm, VIM).len(), 1, "committed once"); assert!( mail::open_mailbox(instances.as_ref(), Some(VIM), MAILBOX) @@ -3318,7 +3500,7 @@ fn an_item_send_re_driven_after_a_restart_keeps_its_delivery_delay() { #[test] fn a_cod_letter_to_another_account_waits_and_its_payment_arrives_at_once() { let (realm, world, instances, _calls) = sharded_send(); - *instances.purses.lock().unwrap() = vec![(VIM, PURSE)]; + *instances.mail.purses.lock().unwrap() = vec![(VIM, PURSE)]; give_item(&world, GINGER, SWORD_GUID, sword()); post_cod(world.as_ref(), "Vim", COD).expect("posted"); @@ -3328,7 +3510,7 @@ fn a_cod_letter_to_another_account_waits_and_its_payment_arrives_at_once() { assert_eq!(purse_of(&instances, VIM), PURSE, "and nothing is charged"); // An hour later. - for (_, m) in realm.mails.lock().unwrap().iter_mut() { + 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"); @@ -3356,7 +3538,7 @@ fn copying_a_letter_sets_copied_and_grants_one_plain_letter() { mail::copy_letter(world.as_ref(), Some(GINGER), MAILBOX, 1).expect("the copy completes"); - let flags = realm.mails.lock().unwrap()[0].1.check_flags; + let flags = realm.mail.mails.lock().unwrap()[0].1.check_flags; assert_eq!( flags & lyracore_shared::mail::CHECK_MASK_COPIED, lyracore_shared::mail::CHECK_MASK_COPIED, @@ -3368,12 +3550,13 @@ fn copying_a_letter_sets_copied_and_grants_one_plain_letter() { "and the durable GRANTED record, once the Home Shard grant is confirmed" ); assert_eq!( - *world.granted_letters.lock().unwrap(), + *world.mail.granted_letters.lock().unwrap(), vec![(GINGER, GINGERS_LETTER_TEXT_ID)], "exactly one Plain Letter, on the Home Shard, carrying the mail's own id as its text id" ); assert_eq!( realm + .mail .item_texts .lock() .unwrap() @@ -3392,10 +3575,10 @@ fn a_letter_copy_on_one_database_grants_the_same_way() { mail::copy_letter(single.as_ref(), Some(GINGER), MAILBOX, 1).expect("the copy completes"); assert_eq!( - *single.granted_letters.lock().unwrap(), + *single.mail.granted_letters.lock().unwrap(), vec![(GINGER, GINGERS_LETTER_TEXT_ID)] ); - let flags = single.mails.lock().unwrap()[0].1.check_flags; + let flags = single.mail.mails.lock().unwrap()[0].1.check_flags; assert_eq!( flags & lyracore_shared::mail::CHECK_MASK_COPIED, lyracore_shared::mail::CHECK_MASK_COPIED @@ -3410,6 +3593,7 @@ fn a_letter_copy_on_one_database_grants_the_same_way() { fn copying_into_a_full_bag_is_refused_and_leaves_the_mail_uncopied() { let (realm, world, _calls) = sharded_mailbox(); world + .mail .bags_full .store(true, std::sync::atomic::Ordering::Relaxed); @@ -3421,12 +3605,13 @@ fn copying_into_a_full_bag_is_refused_and_leaves_the_mail_uncopied() { "the client is told to make room, not handed a generic error: {refusal}" ); assert_eq!( - realm.mails.lock().unwrap()[0].1.check_flags & lyracore_shared::mail::CHECK_MASK_COPIED, + realm.mail.mails.lock().unwrap()[0].1.check_flags + & lyracore_shared::mail::CHECK_MASK_COPIED, 0, "a refused copy must not touch the mail plane at all" ); - assert!(world.granted_letters.lock().unwrap().is_empty()); - assert!(realm.item_texts.lock().unwrap().is_empty()); + assert!(world.mail.granted_letters.lock().unwrap().is_empty()); + assert!(realm.mail.item_texts.lock().unwrap().is_empty()); } #[test] @@ -3438,7 +3623,7 @@ fn a_letter_already_granted_refuses_a_second_copy_and_grants_no_second_letter() .expect_err("a completed grant refuses a second click for good"); assert_eq!( - *world.granted_letters.lock().unwrap(), + *world.mail.granted_letters.lock().unwrap(), vec![(GINGER, GINGERS_LETTER_TEXT_ID)], "one letter, not two" ); @@ -3451,14 +3636,14 @@ fn a_letter_already_granted_refuses_a_second_copy_and_grants_no_second_letter() 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"); - world.granted_letters.lock().unwrap().clear(); // the Plain Letter is gone from the bags + 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"); assert!(matches!(refusal, mail::CopyLetterRefusal::Other(_))); assert!( - world.granted_letters.lock().unwrap().is_empty(), + world.mail.granted_letters.lock().unwrap().is_empty(), "no letter is minted for a mail that already used its one grant" ); } @@ -3469,21 +3654,22 @@ fn destroying_the_granted_item_does_not_reopen_a_second_grant() { #[test] fn retrying_after_an_interrupted_grant_still_grants_the_letter() { let (realm, world, _calls) = sharded_mailbox(); - realm.mails.lock().unwrap()[0].1.check_flags |= lyracore_shared::mail::CHECK_MASK_COPIED; + realm.mail.mails.lock().unwrap()[0].1.check_flags |= lyracore_shared::mail::CHECK_MASK_COPIED; realm + .mail .item_texts .lock() .unwrap() .push((GINGERS_LETTER_TEXT_ID, "left it at the inn".to_string())); assert!( - world.granted_letters.lock().unwrap().is_empty(), + world.mail.granted_letters.lock().unwrap().is_empty(), "the fixture models a grant that never landed" ); mail::copy_letter(world.as_ref(), Some(GINGER), MAILBOX, 1).expect("the retry completes"); assert_eq!( - *world.granted_letters.lock().unwrap(), + *world.mail.granted_letters.lock().unwrap(), vec![(GINGER, GINGERS_LETTER_TEXT_ID)], "the retry must reach the Home Shard grant, not stop at the already-COPIED mail" ); @@ -3494,7 +3680,7 @@ 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"); - realm.mails.lock().unwrap().retain(|(_, m)| m.id != 1); + 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(), @@ -3510,15 +3696,13 @@ fn a_letter_copy_over_the_wire_acks_made_permanent_ok() { 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.as_ref()).unwrap(); + run_world_session(server_end, server_store.clone()).unwrap(); }); 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(); - for _ in 0..WORLD_ENTRY_PACKETS { - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - } + drain_world_entry(&mut client, &mut c_dec); wow_world_messages::vanilla::CMSG_MAIL_CREATE_TEXT_ITEM { mailbox: Guid::new(MAILBOX), @@ -3545,28 +3729,27 @@ fn a_letter_copy_over_the_wire_acks_made_permanent_ok() { drop(client); server.join().unwrap(); - assert_eq!(store.granted_letters.lock().unwrap().len(), 1); + assert_eq!(store.mail.granted_letters.lock().unwrap().len(), 1); } #[test] fn a_letter_copy_into_a_full_bag_over_the_wire_answers_equip_error() { let store = seated_store(); store + .mail .bags_full .store(true, std::sync::atomic::Ordering::Relaxed); 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.as_ref()).unwrap(); + run_world_session(server_end, server_store.clone()).unwrap(); }); 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(); - for _ in 0..WORLD_ENTRY_PACKETS { - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - } + drain_world_entry(&mut client, &mut c_dec); wow_world_messages::vanilla::CMSG_MAIL_CREATE_TEXT_ITEM { mailbox: Guid::new(MAILBOX), @@ -3594,9 +3777,10 @@ fn a_letter_copy_into_a_full_bag_over_the_wire_answers_equip_error() { drop(client); server.join().unwrap(); - assert!(store.granted_letters.lock().unwrap().is_empty()); + assert!(store.mail.granted_letters.lock().unwrap().is_empty()); assert_eq!( - store.mails.lock().unwrap()[0].1.check_flags & lyracore_shared::mail::CHECK_MASK_COPIED, + store.mail.mails.lock().unwrap()[0].1.check_flags + & lyracore_shared::mail::CHECK_MASK_COPIED, 0, "a refused copy leaves the mail uncopied" ); @@ -3609,7 +3793,7 @@ fn a_letter_copy_into_a_full_bag_over_the_wire_answers_equip_error() { 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"); - realm.mails.lock().unwrap().retain(|(_, m)| m.id != 1); + 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(), @@ -3677,16 +3861,23 @@ fn card_renewal_letter(recipient: u64, delivery_delay_secs: u32) -> mail::HeldEs } } /// File `letter` on `shard` for `owner`, as the turn-in's transaction leaves it: unattested. -fn hold_reward_letter(shard: &InMemoryStore, owner: u64, letter: mail::HeldEscrow) { +fn hold_reward_letter(shard: &WorldFake, owner: u64, letter: mail::HeldEscrow) { shard + .mail .attested .lock() .unwrap() .push((letter.escrow_id, false)); - shard.mail_escrows.lock().unwrap().push((owner, letter)); + shard + .mail + .mail_escrows + .lock() + .unwrap() + .push((owner, letter)); } -fn reward_letters(plane: &InMemoryStore, recipient: u64) -> Vec { +fn reward_letters(plane: &WorldFake, recipient: u64) -> Vec { plane + .mail .mails .lock() .unwrap() @@ -3739,7 +3930,10 @@ fn a_reward_letter_held_on_the_home_shard_is_committed_on_realm_core_at_the_mail visible.iter().all(|m| m.sender_kind != 3), "and stays hidden until then" ); - assert!(world.mail_escrows.lock().unwrap().is_empty(), "settled"); + assert!( + world.mail.mail_escrows.lock().unwrap().is_empty(), + "settled" + ); assert_eq!( escrow_steps(&calls), vec![ @@ -3754,11 +3948,15 @@ fn a_reward_letter_held_on_the_home_shard_is_committed_on_realm_core_at_the_mail 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_kill_at.lock().unwrap() = Some("mail_confirm_delivery".into()); + *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"); - assert_eq!(world.mail_escrows.lock().unwrap().len(), 1, "still held"); + assert_eq!( + world.mail.mail_escrows.lock().unwrap().len(), + 1, + "still held" + ); - *world.mail_kill_at.lock().unwrap() = None; + *world.mail.mail_kill_at.lock().unwrap() = None; let visible = mail::open_mailbox(world.as_ref(), Some(GINGER), MAILBOX).expect("the gate opens"); @@ -3771,7 +3969,10 @@ fn a_reward_letter_whose_drive_died_after_the_commit_is_delivered_once() { 1, "a Reward Letter with no delay is in the list at once" ); - assert!(world.mail_escrows.lock().unwrap().is_empty(), "settled"); + assert!( + world.mail.mail_escrows.lock().unwrap().is_empty(), + "settled" + ); } #[test] @@ -3784,7 +3985,10 @@ fn a_single_database_drives_a_reward_letter_on_its_own_database() { let letters = reward_letters(&store, GINGER); assert_eq!(letters.len(), 1, "{letters:?}"); assert_card_renewal(&letters[0]); - assert!(store.mail_escrows.lock().unwrap().is_empty(), "settled"); + assert!( + store.mail.mail_escrows.lock().unwrap().is_empty(), + "settled" + ); } #[test] @@ -3807,16 +4011,31 @@ fn both_planes_write_the_same_reward_letter() { #[test] fn a_reward_letter_held_at_world_entry_is_delivered_on_either_plane() { for sharded in [false, true] { - let realm = std::sync::Arc::new(InMemoryStore { - shard: "lyracore-realm".into(), - is_realm: true, + let realm = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "lyracore-realm".into(), + ..Default::default() + }, + party: PartyState { + is_realm: true, + ..Default::default() + }, ..Default::default() }); - let home = std::sync::Arc::new(InMemoryStore { - shard: "world".into(), - login_entity: Some(warrior_entity()), - realm: sharded.then(|| realm.clone()), - ..tester_store(7) + let home = std::sync::Arc::new({ + let base = tester_store(7); + WorldFake { + topology: TopologyState { + shard: "world".into(), + realm: sharded.then(|| realm.clone()), + ..base.topology + }, + session: SessionState { + login_entity: Some(warrior_entity()), + ..base.session + }, + ..base + } }); hold_reward_letter(&home, 1, card_renewal_letter(1, 86_400)); @@ -3829,7 +4048,7 @@ fn a_reward_letter_held_at_world_entry_is_delivered_on_either_plane() { assert_eq!(letters.len(), 1, "sharded {sharded}: {letters:?}"); assert_card_renewal(&letters[0]); assert!( - home.mail_escrows.lock().unwrap().is_empty(), + home.mail.mail_escrows.lock().unwrap().is_empty(), "sharded {sharded}: settled" ); } @@ -3838,15 +4057,21 @@ fn a_reward_letter_held_at_world_entry_is_delivered_on_either_plane() { #[test] fn a_reward_letter_a_turn_in_files_is_delivered_on_either_plane() { for sharded in [false, true] { - let realm = std::sync::Arc::new(InMemoryStore { - shard: "lyracore-realm".into(), - is_realm: true, + let realm = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "lyracore-realm".into(), + ..Default::default() + }, + party: PartyState { + is_realm: true, + ..Default::default() + }, ..Default::default() }); let mut home = quest_store(); - home.realm = sharded.then(|| realm.clone()); - home.quest_details = vec![detail_view(3645, "Membership Card Renewal")]; - home.turn_in_reward_letter = Some(card_renewal_letter(1, 86_400)); + home.topology.realm = sharded.then(|| realm.clone()); + home.quest.quest_details = vec![detail_view(3645, "Membership Card Renewal")]; + home.quest.turn_in_reward_letter = Some(card_renewal_letter(1, 86_400)); let home = std::sync::Arc::new(home); let (mut client, mut c_enc, mut c_dec, server) = enter_world(home.clone(), 1); @@ -3869,7 +4094,7 @@ fn a_reward_letter_a_turn_in_files_is_delivered_on_either_plane() { assert_eq!(letters.len(), 1, "sharded {sharded}: {letters:?}"); assert_card_renewal(&letters[0]); assert!( - home.mail_escrows.lock().unwrap().is_empty(), + home.mail.mail_escrows.lock().unwrap().is_empty(), "sharded {sharded}: settled" ); } diff --git a/gateway/src/world/melee_tests.rs b/gateway/src/world/melee_tests.rs new file mode 100644 index 000000000..32f6faf87 --- /dev/null +++ b/gateway/src/world/melee_tests.rs @@ -0,0 +1,133 @@ +//! Melee attack opcodes through their dispatcher. + +use super::handlers::InMemoryMeleeActions; +use super::*; + +/// What the session keeps between two melee messages: its combat state. +struct Session { + state: WorldState, +} + +impl Session { + fn in_world() -> Self { + Self { + state: WorldState::InWorld(InWorld { + self_guid: 1, + subs: PlayerSubscriptions::empty(), + attacking_target: None, + open_loot: OpenLootState::default(), + ranged_repeat: false, + }), + } + } + + fn at_character_select() -> Self { + Self { + state: WorldState::CharSelect, + } + } + + fn player(&self) -> MeleeActionPlayer { + let (self_guid, attacking_target, ranged_repeat) = match &self.state { + WorldState::InWorld(iw) => (Some(iw.self_guid), iw.attacking_target, iw.ranged_repeat), + WorldState::CharSelect => (None, None, false), + }; + MeleeActionPlayer { + account_id: 7, + self_guid, + attacking_target, + ranged_repeat, + } + } +} + +/// Dispatch one melee message, apply the session transition it asks for, and return the packets +/// the session would send for it. +fn try_run( + actions: &InMemoryMeleeActions, + session: &mut Session, + msg: impl Into, +) -> Result> { + match dispatch_melee_action(actions, session.player(), msg.into())? { + MeleeActionOutcome::Handled { + transition, + outbound, + } => { + transition.apply(&mut session.state); + Ok(outbound) + } + MeleeActionOutcome::PassThrough(_) => panic!("the melee dispatcher passed the message on"), + } +} + +fn run( + actions: &InMemoryMeleeActions, + session: &mut Session, + msg: impl Into, +) -> Vec { + try_run(actions, session, msg).unwrap() +} + +fn swing(target: u64) -> CMSG_ATTACKSWING { + CMSG_ATTACKSWING { + guid: Guid::new(target), + } +} + +#[test] +fn attackswing_ok_replies_attackstart_and_stop_echoes_then_clears() { + let actions = InMemoryMeleeActions::default(); + let mut session = Session::in_world(); + + match run(&actions, &mut session, swing(90)).as_slice() { + [Outbound::One(ServerOpcodeMessage::SMSG_ATTACKSTART(a))] => { + assert_eq!(a.attacker.guid(), 1, "self guid"); + assert_eq!(a.victim.guid(), 90); + } + _ => panic!("expected one SMSG_ATTACKSTART"), + } + // Stop echoes the armed target and clears it. + match run(&actions, &mut session, CMSG_ATTACKSTOP {}).as_slice() { + [Outbound::One(ServerOpcodeMessage::SMSG_ATTACKSTOP(a))] => { + assert_eq!(a.player.guid(), 1); + assert_eq!(a.enemy.guid(), 90); + } + _ => panic!("expected one SMSG_ATTACKSTOP"), + } + // A second stop finds no armed target, so there is no second echo. + assert!(run(&actions, &mut session, CMSG_ATTACKSTOP {}).is_empty()); +} + +#[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. + 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()); +} + +#[test] +fn attackswing_desync_error_is_session_fatal() { + // A desync-classified start_attack failure (the player's OWN entity is gone) must PROPAGATE + // as session-fatal so the socket tears down for a clean relog. Which failures are fatal, and + // which answer a refusal instead, belongs to the melee seam's own tests. + let actions = InMemoryMeleeActions { + start_error: Some("no live entity for guid 1".into()), + ..Default::default() + }; + let mut session = Session::in_world(); + + let err = try_run(&actions, &mut session, swing(90)) + .err() + .expect("a desync on attackswing must end the session with an error"); + assert!( + format!("{err:#}").contains("desync"), + "the error should carry the desync context, got: {err:#}" + ); +} diff --git a/gateway/src/world/member_stats_tests.rs b/gateway/src/world/member_stats_tests.rs index ca23142b9..0b96b11d1 100644 --- a/gateway/src/world/member_stats_tests.rs +++ b/gateway/src/world/member_stats_tests.rs @@ -195,14 +195,15 @@ fn stats() -> codec::MemberStats { codec::MemberStats::from_entity(&caster()) } -fn place(shard: &InMemoryStore, guid: u64, entity: codec::MemberEntity) { - let mut entities = shard.member_entities.lock().unwrap(); +fn place(shard: &WorldFake, guid: u64, entity: codec::MemberEntity) { + let mut entities = shard.social.member_entities.lock().unwrap(); entities.retain(|(g, _)| *g != guid); entities.push((guid, entity)); } -fn despawn(shard: &InMemoryStore, guid: u64) { +fn despawn(shard: &WorldFake, guid: u64) { shard + .social .member_entities .lock() .unwrap() @@ -211,9 +212,9 @@ fn despawn(shard: &InMemoryStore, guid: u64) { /// Ginger and Trin, both on `world`. fn ginger_and_trin() -> ( - std::sync::Arc, - std::sync::Arc, - std::sync::Arc, + std::sync::Arc, + std::sync::Arc, + std::sync::Arc, ) { let (realm, world, instances, _) = party_topology(); party::run(world.as_ref(), 7, GINGER, party::Op::Invite(TRIN)).expect("invite Trin"); @@ -224,7 +225,7 @@ fn ginger_and_trin() -> ( } /// One tick for Ginger, whose client has created only the guids in `created`. -fn tick(store: &InMemoryStore, created: &[u64], snapshots: &mut Snapshots) -> Vec<(u16, Vec)> { +fn tick(store: &WorldFake, created: &[u64], snapshots: &mut Snapshots) -> Vec<(u16, Vec)> { member_stats_tick(store, GINGER, |guid| created.contains(&guid), snapshots) .expect("the tick reads") .into_iter() @@ -365,11 +366,11 @@ fn a_mate_in_transfer_gets_nothing_and_is_never_reported_offline() { tick(&world, &[GINGER], &mut snapshots); despawn(&instances, VIM); - realm.members_in_transit.lock().unwrap().push(VIM); + realm.party.members_in_transit.lock().unwrap().push(VIM); assert!(tick(&world, &[GINGER], &mut snapshots).is_empty()); // Arrival with nothing changed: the record survived the Transfer, so nothing is resent. - realm.members_in_transit.lock().unwrap().clear(); + realm.party.members_in_transit.lock().unwrap().clear(); place(&world, VIM, caster()); assert!(tick(&world, &[GINGER], &mut snapshots).is_empty()); } @@ -387,7 +388,13 @@ fn a_viewer_with_no_group_gets_nothing_and_reads_no_member() { "a viewer with no group keeps no records" ); for shard in [&world, &instances] { - assert_eq!(shard.member_presence_reads.load(Ordering::SeqCst), 0); + assert_eq!( + shard + .member_stats + .member_presence_reads + .load(Ordering::SeqCst), + 0 + ); } } @@ -403,7 +410,7 @@ fn a_mate_who_leaves_the_group_is_forgotten() { } fn request_with( - store: &InMemoryStore, + store: &WorldFake, self_guid: Option, record: Option<&MemberStatsRecord>, guid: u64, @@ -419,7 +426,7 @@ fn request_with( ) } -fn request(store: &InMemoryStore, self_guid: Option, guid: u64) -> MemberStatsOutcome { +fn request(store: &WorldFake, self_guid: Option, guid: u64) -> MemberStatsOutcome { request_with(store, self_guid, None, guid) } @@ -483,11 +490,16 @@ fn a_request_for_a_mate_between_two_places_answers_online_and_zone_out() { form_split_party(&world, &instances); let online_zone_out = vec![(0x02F2, vec![0x01, 0x02, 0x01, 0x00, 0x00, 0x00, 0x21])]; - realm.members_in_transit.lock().unwrap().push(VIM); + realm.party.members_in_transit.lock().unwrap().push(VIM); assert_eq!(answer(request(&world, Some(GINGER), VIM)), online_zone_out); - realm.members_in_transit.lock().unwrap().clear(); - instances.members_between_places.lock().unwrap().push(VIM); + realm.party.members_in_transit.lock().unwrap().clear(); + instances + .social + .members_between_places + .lock() + .unwrap() + .push(VIM); assert_eq!(answer(request(&world, Some(GINGER), VIM)), online_zone_out); } @@ -503,9 +515,9 @@ fn after_a_full_answer_the_next_tick_sends_every_field() { tick(&world, &[GINGER], &mut record.lock()); despawn(&instances, VIM); - realm.members_in_transit.lock().unwrap().push(VIM); + realm.party.members_in_transit.lock().unwrap().push(VIM); answer(request_with(&world, Some(GINGER), Some(&record), VIM)); - realm.members_in_transit.lock().unwrap().clear(); + realm.party.members_in_transit.lock().unwrap().clear(); place(&world, VIM, caster()); let packets = tick(&world, &[GINGER], &mut record.lock()); @@ -517,13 +529,16 @@ fn a_failed_read_answers_nothing_and_is_never_offline() { let (realm, world, instances, _) = party_topology(); form_split_party(&world, &instances); // Vim has no entity, and one World Shard cannot prove the absence. - let unhealthy = InMemoryStore { - shard: "world".into(), - realm: Some(realm), - world_shard_set_error: Some("instances has no healthy Coordinator subscription".into()), + let unhealthy = WorldFake { + topology: TopologyState { + shard: "world".into(), + realm: Some(realm), + world_shard_set_error: Some("instances has no healthy Coordinator subscription".into()), + ..Default::default() + }, ..Default::default() }; - *unhealthy.peers.lock().unwrap() = vec![world.clone(), instances.clone()]; + *unhealthy.topology.peers.lock().unwrap() = vec![world.clone(), instances.clone()]; assert!(answer(request(&unhealthy, Some(GINGER), VIM)).is_empty()); let mut snapshots = Snapshots::new(); @@ -543,7 +558,12 @@ fn a_bot_crossing_between_shards_is_never_reported_offline() { // The Package placed the bot and wrote its Transfer Intent; the Gateway has not claimed it. despawn(&world, BOT); - world.members_between_places.lock().unwrap().push(BOT); + world + .social + .members_between_places + .lock() + .unwrap() + .push(BOT); assert!(tick(&world, &[GINGER], &mut snapshots).is_empty()); assert_eq!( @@ -565,14 +585,20 @@ fn a_stats_request_outside_the_world_passes_through() { /// where the party lives in the shard's own tables. #[test] fn a_stats_request_is_answered_through_the_encrypted_session() { - let store = InMemoryStore { - mirror: std::sync::Mutex::new(vec![party::GroupRoster { - group_id: 1, - leader_guid: 1, - members: party_members(&[1, 2]), - ..Default::default() - }]), - ..quest_store() + let store = { + let base = quest_store(); + WorldFake { + party: PartyState { + mirror: std::sync::Mutex::new(vec![party::GroupRoster { + group_id: 1, + leader_guid: 1, + members: party_members(&[1, 2]), + ..Default::default() + }]), + ..base.party + }, + ..base + } }; place(&store, 2, caster()); let (mut client, mut c_enc, mut c_dec, server) = enter_world(std::sync::Arc::new(store), 1); @@ -591,15 +617,26 @@ fn a_stats_request_is_answered_through_the_encrypted_session() { #[test] fn a_stats_request_that_cannot_be_read_does_not_end_the_session() { - let store = InMemoryStore { - mirror: std::sync::Mutex::new(vec![party::GroupRoster { - group_id: 1, - leader_guid: 1, - members: party_members(&[1, 2]), - ..Default::default() - }]), - world_shard_set_error: Some("instances has no healthy Coordinator subscription".into()), - ..quest_store() + let store = { + let base = quest_store(); + WorldFake { + party: PartyState { + mirror: std::sync::Mutex::new(vec![party::GroupRoster { + group_id: 1, + leader_guid: 1, + members: party_members(&[1, 2]), + ..Default::default() + }]), + ..base.party + }, + topology: TopologyState { + world_shard_set_error: Some( + "instances has no healthy Coordinator subscription".into(), + ), + ..base.topology + }, + ..base + } }; let (mut client, mut c_enc, mut c_dec, server) = enter_world(std::sync::Arc::new(store), 1); @@ -622,26 +659,32 @@ fn a_stats_request_that_cannot_be_read_does_not_end_the_session() { fn world_entry_forgets_member_stats_sent_before_the_party_frame() { let view = std::sync::Arc::new(crate::stdb::world_view::WorldView::new(true)); let (realm, _world, _instances, calls) = party_topology(); - let session_shard = std::sync::Arc::new(InMemoryStore { - shard: "world".into(), - calls, - username: "TESTER".into(), - session: Some(WorldSession { - account_id: 7, - session_key: K, - }), - login_entity: Some(warrior_entity()), - realm: Some(realm.clone()), + let session_shard = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "world".into(), + calls, + realm: Some(realm.clone()), + ..Default::default() + }, + session: SessionState { + username: "TESTER".into(), + session: Some(WorldSession { + account_id: 7, + session_key: K, + }), + login_entity: Some(warrior_entity()), + live_guids: vec![GINGER, VIM], + relay_view: Some(view.clone()), + member_stats_before_party_frame: Some(VIM), + ..Default::default() + }, characters: vec![character(GINGER, "Ginger"), character(VIM, "Vim")], - live_guids: vec![GINGER, VIM], - relay_view: Some(view.clone()), - member_stats_before_party_frame: Some(VIM), ..Default::default() }); - *session_shard.peers.lock().unwrap() = vec![session_shard.clone()]; + *session_shard.topology.peers.lock().unwrap() = vec![session_shard.clone()]; place(&session_shard, VIM, caster()); { - let mut party = realm.party.lock().unwrap(); + let mut party = realm.party.party.lock().unwrap(); party.next_group_id = 5; party.groups.push((5, GINGER, 3, 2, 0)); party.members.push((5, GINGER)); @@ -650,7 +693,7 @@ fn world_entry_forgets_member_stats_sent_before_the_party_frame() { let (mut client, server_end) = world_session_socket_pair(); let server_store = session_shard.clone(); let server = std::thread::spawn(move || { - let _ = run_world_session(server_end, server_store.as_ref()); + let _ = run_world_session(server_end, server_store.clone()); }); let (mut c_enc, mut c_dec) = client_handshake(&mut client, "TESTER", K); CMSG_PLAYER_LOGIN { diff --git a/gateway/src/world/mod.rs b/gateway/src/world/mod.rs index 11b34cc53..f2a323d26 100644 --- a/gateway/src/world/mod.rs +++ b/gateway/src/world/mod.rs @@ -79,11 +79,22 @@ pub(crate) use handlers::{ MemberStatsStore, PlaceBidOutcome, PlaceBidRequest, RealmChatRequest, SeekerFacts, SpeakerFacts, TrainerBuyOutcome, WeatherStore, WhisperRequest, WhisperTargetFacts, }; +pub(crate) use handlers::{ + AuctionActionStore, BankStore, CastStore, ChannelActionStore, CharacterStore, ChatActionStore, + CombatStore, DeathStore, DuelActionStore, GuildActionStore, ItemActionStore, LootWindowStore, + MeetingStoneActionStore, MeleeActionStore, NpcStore, QuestActionStore, SpeechStore, + TaxiActionStore, TradeStore, TrainerStore, VendorActionStore, +}; use login_queue::{Admission, LoginQueue}; +pub(crate) use loot::LootRollStore; +pub(crate) use mail::MailStore; +pub(crate) use party::PartyStore; use social::handle_social; pub(crate) use social::ContactOutcome; +pub(crate) use social::SocialStore; +pub(crate) use store::{SessionStore, ShardRoutingStore}; pub use store::{WorldSessionToken, WorldStore}; -use transfer::{EscrowedTransfer, TransferPlan}; +pub(crate) use transfer::TransferStore; /// One unit of outbound traffic for the single writer thread. A `Batch` is written contiguously so /// the login sequence + self-spawn can never be spliced by an async peer event mid-sequence. @@ -366,16 +377,8 @@ pub struct WorldConn { /// condition-filtered list, so re-deriving that list at click time renumbers it under a quest /// accepted while the window was open. pub(crate) gossip_menu: Option, - /// Multi-shard routing: the HOME-shard store handle for the character this session is - /// playing, resolved at `CMSG_PLAYER_LOGIN` (and re-resolved on a world-port, which can change - /// map and therefore shard) from the character's location via `WorldStore::home_shard`. `None` - /// = "the handle the listener already handed us is the right one" — the single-shard default, - /// and the state of every pre-login message, so nothing about the old path changes. - /// - /// This is derived, not authoritative: it is recomputed from durable state on every world - /// entry, so a gateway restart mid-session re-derives it on the client's relog — the - /// gateway stays stateless w.r.t. sessions. - home: Option>, + /// The Store every message of this session runs against. + store: RoutedStore, /// The shared session key K, kept from the world handshake so a session that lands on a NON- /// realm shard can call `establish_session` THERE — the module's `account_by_identity` /// gate on `player_login` needs the account row on that database bound to this connection's @@ -424,29 +427,36 @@ const MOVE_DESYNC_TOLERANCE: u32 = 32; /// the one Gateway-side realm-wide scan `/who` runs. const WHO_THROTTLE: Duration = Duration::from_secs(1); -/// Run `$body` against the session's HOME-shard store handle. `$store` is the handle the -/// caller holds (the default/realm shard); `$conn.home` overrides it once the player is in the -/// world. Two arms rather than a coercion because `$store` is a `?Sized` generic and cannot be -/// unsize-coerced to `&dyn WorldStore`; only one arm ever runs. -// Deliberate simplification: a macro instead of threading a routed handle through ~15 handler -// signatures. Ceiling — it only works where `conn` is in scope. Upgrade path: if routing ever -// needs to change MID-session, fold the handle into a session context struct instead. -macro_rules! on_home_shard { - ($conn:expr, $store:expr, |$s:ident| $body:expr) => {{ - let home = $conn.home.clone(); - match home { - Some(h) => { - let $s: &dyn WorldStore = h.as_ref(); - $body - } - None => { - let $s = $store; - $body - } +/// The Store a World Session talks to: the listener's handle until world entry pins the +/// Character's Home Shard. Re-resolved from durable state on every world entry (login and +/// world-port), so a gateway restart mid-session re-derives it on the client's relog. +pub(crate) struct RoutedStore { + default: Arc, + home: Option>, +} + +impl RoutedStore { + fn new(default: Arc) -> Self { + Self { + default, + home: None, } - }}; + } + + /// The pinned Home Shard, else the listener's handle. A clone, so the caller can hold it + /// while it mutates the session. + pub(crate) fn current(&self) -> Arc { + self.home.as_ref().unwrap_or(&self.default).clone() + } + + fn pin(&mut self, home: Arc) { + self.home = Some(home); + } + + fn unpin(&mut self) { + self.home = None; + } } -pub(crate) use on_home_shard; impl WorldConn { /// Re-pin this session to the shard that owns `character_guid`'s location. Asked of the @@ -458,22 +468,17 @@ impl WorldConn { /// fail its login loudly, never proceed into the world on whichever shard happened to answer. /// Both are recoverable — the escrow holds and the next login re-drives it — but only one of /// them is honest to the player. - fn route_home( - &mut self, - store: &St, - character_guid: u64, - ) -> Result<()> { - // `settle_home_shard`, not `home_shard`: for a session that actually crosses a database - // boundary this RUNS the escrowed transfer before answering. Its default impl IS - // `home_shard`, so the single-shard path and every mock are unchanged. - let resolved = on_home_shard!(self, store, |st| st.settle_home_shard(character_guid))?; + fn route_home(&mut self, character_guid: u64) -> Result<()> { + // For a session that actually crosses a database boundary this RUNS the escrowed transfer + // before answering. + let resolved = self.store.current().settle_home_shard(character_guid)?; if let Some(h) = resolved { log::info!( "world: account {} (guid {character_guid}) routed to shard {}", self.account_id, h.shard_name() ); - self.home = Some(h); + self.store.pin(h); } // Bind this account's identity on the shard we ended up on. On the realm shard the logon // tier already did it; on an instance shard the character arrived through @@ -481,7 +486,7 @@ impl WorldConn { // `world::player_login` resolves its caller through `account_by_identity`, so without this // the arriving player cannot log in at all. Idempotent, and re-run on every world entry // because a gateway restart establishes a fresh bound identity. - if let (Some(h), Some(key)) = (&self.home, &self.session_key) { + if let (Some(h), Some(key)) = (&self.store.home, &self.session_key) { h.bind_shard_session(self.account_id, key)?; } Ok(()) @@ -491,7 +496,7 @@ impl WorldConn { /// A Guild member signs off first, while Realm-core still accepts the Account Claim. The /// Character that signed on this session signs off even when a failed world-port left the /// state at CharSelect. - fn leave_world(&mut self, store: &St) -> Result<()> { + fn leave_world(&mut self) -> Result<()> { let previous = std::mem::replace(&mut self.state, WorldState::CharSelect); let signed_on = self.guild_signed_on.take(); let left = match &previous { @@ -499,18 +504,16 @@ impl WorldConn { WorldState::CharSelect => signed_on, }; drop(previous); + let store = self.store.current(); if let Some(character_guid) = left { - on_home_shard!(self, store, |st| handlers::guild_world_exit( - st, - character_guid - )); + handlers::guild_world_exit(&*store, character_guid); } let outcome = if let Some(token) = self.session_claim.take() { - on_home_shard!(self, store, |st| st.release_session(token)) + store.release_session(token) } else { Ok(()) }; - self.home = None; + self.store.unpin(); outcome } @@ -556,9 +559,9 @@ const QUEUE_RESEND_INTERVAL: Duration = Duration::from_millis(50); /// Test-only: the listener in [`run`] supplies the process-wide [`LoginQueue`] and the deadline it /// started at accept, so an unqueued handshake exists purely for tests. #[cfg(test)] -pub fn world_handshake( +pub fn world_handshake( stream: &mut S, - store: &St, + store: Arc, ) -> Result> { world_handshake_with_queue(stream, store, &LoginQueue::unlimited()) } @@ -580,22 +583,18 @@ pub fn world_handshake( /// departs its own seat before propagating the error. A caller that never sees `Ok(Some(..))` never /// owes a `depart()` call. #[cfg(test)] -pub fn world_handshake_with_queue( +pub fn world_handshake_with_queue( stream: &mut S, - store: &St, + store: Arc, queue: &LoginQueue, ) -> Result> { let mut deadline = PreAuthDeadline::after(WORLD_AUTH_DEADLINE); world_handshake_with_queue_and_deadline(stream, store, queue, &mut deadline) } -fn world_handshake_with_queue_and_deadline< - S: Read + Write + IoDeadline, - St: WorldStore + ?Sized, - C: DeadlineClock, ->( +fn world_handshake_with_queue_and_deadline( stream: &mut S, - store: &St, + store: Arc, queue: &LoginQueue, deadline: &mut PreAuthDeadline, ) -> Result> { @@ -695,7 +694,7 @@ fn world_handshake_with_queue_and_deadline< state: WorldState::CharSelect, move_coalesce: CoalesceState::default(), gossip_menu: None, - home: None, // resolved at CMSG_PLAYER_LOGIN + store: RoutedStore::new(store), // pinned at CMSG_PLAYER_LOGIN session_key: Some(session_key), // for establish_session on a non-realm shard guild_signed_on: None, move_desync_drops: 0, @@ -1010,10 +1009,7 @@ fn spawn_writer( /// Test-only, for the same reason as [`world_handshake`]: production supplies the listener's shared /// queue and the deadline started at accept. #[cfg(test)] -pub fn run_world_session( - stream: S, - store: &St, -) -> Result<()> { +pub fn run_world_session(stream: S, store: Arc) -> Result<()> { run_world_session_with_queue(stream, store, &LoginQueue::unlimited()) } @@ -1042,22 +1038,18 @@ impl Drop for AdmissionSeat<'_> { /// admitted (`Ok(Some(..))`), this connection holds a seat in `queue` for the rest of the function — /// released by its [`AdmissionSeat`] exactly once, no matter which branch got there. #[cfg(test)] -pub fn run_world_session_with_queue( +pub fn run_world_session_with_queue( stream: S, - store: &St, + store: Arc, queue: &LoginQueue, ) -> Result<()> { let mut deadline = PreAuthDeadline::after(WORLD_AUTH_DEADLINE); run_world_session_with_queue_and_deadline(stream, store, queue, &mut deadline) } -fn run_world_session_with_queue_and_deadline< - S: DuplexStream, - St: WorldStore + ?Sized, - C: DeadlineClock, ->( +fn run_world_session_with_queue_and_deadline( mut stream: S, - store: &St, + store: Arc, queue: &LoginQueue, deadline: &mut PreAuthDeadline, ) -> Result<()> { @@ -1112,7 +1104,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(store, &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) { @@ -1135,9 +1127,8 @@ fn run_world_session_with_queue_and_deadline< if hdr.opcode == CMSG_AUCTION_LIST_ITEMS_OPCODE { let request = decode_auction_browse(&body).map_err(|e| anyhow!("world read error: {e}"))?; - on_home_shard!(conn, store, |st| { - dispatch_raw_auction_browse(&tx, st, &mut conn, request) - })?; + let store = conn.store.current(); + dispatch_raw_auction_browse(&tx, &*store, &mut conn, request)?; continue; } if hdr.opcode == CMSG_AUTH_SESSION_OPCODE { @@ -1155,8 +1146,9 @@ fn run_world_session_with_queue_and_deadline< }; // Every in-world message is dispatched against the player's HOME shard; until // CMSG_PLAYER_LOGIN resolves one (and always, with a single-entry shard map) this is - // the handle the listener passed in — the unchanged single-database path. - on_home_shard!(conn, store, |st| dispatch(&tx, st, &mut conn, msg))?; + // the handle the listener passed in. + let store = conn.store.current(); + dispatch(&tx, &*store, &mut conn, msg)?; } })(); @@ -1173,7 +1165,7 @@ fn run_world_session_with_queue_and_deadline< // entity delete) and delete the entity ONLY if THIS session still owns it — a stale socket whose // player already re-logged on a newer session declines, so we don't vanish the live player. // Teardown is already ending the session, so a `logout` failure is logged + swallowed (not fatal). - if let Err(e) = conn.leave_world(store) { + if let Err(e) = conn.leave_world() { log::warn!("logout for account {} failed: {e:#}", conn.account_id); } drop(tx); @@ -1185,7 +1177,7 @@ fn run_world_session_with_queue_and_deadline< /// Does the Character hold a GM level on its Home Shard? A failed read counts as no: the only /// effect is that a flooding game master is muted like any other speaker. -fn is_game_master(store: &St, character_guid: u64) -> bool { +fn is_game_master(store: &St, character_guid: u64) -> bool { match store.speaker_gm_level(character_guid) { Ok(gm_level) => gm_level > 0, Err(error) => { @@ -1198,7 +1190,7 @@ fn is_game_master(store: &St, character_guid: u64) -> b /// The speaker's race, for the Chat Flood Limiter's language check. `None` when the read fails or /// the speaker has no live entity here: the line still counts as usual, and the Module's own /// language Gate stays the fallback authority. -fn speaker_race(store: &St, character_guid: u64) -> Option { +fn speaker_race(store: &St, character_guid: u64) -> Option { match store.speaker_facts(character_guid) { Ok(facts) => facts.map(|facts| facts.race), Err(error) => { @@ -1212,17 +1204,17 @@ fn speaker_race(store: &St, character_guid: u64) -> Opt /// 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) { +fn handle_addon_message(store: &St, conn: &WorldConn, text: &str) { let Some((cmd, payload)) = codec::addon::parse_bridge_envelope(text) else { log::debug!("addon bridge: non-STC or malformed frame dropped: {text:?}"); return; }; - if let Err(e) = on_home_shard!(conn, store, |st| st.client_command( + if let Err(e) = store.client_command( conn.account_id, social::self_guid(conn).unwrap_or(0), cmd.clone(), payload, - )) { + ) { log::info!( "addon bridge: command {cmd:?} from account {} failed: {e:#}", conn.account_id @@ -1260,9 +1252,9 @@ fn apply_cast_transition(conn: &mut WorldConn, transition: CastTransition) { /// Route one decrypted client message through the per-family handlers. Each stage either consumes /// its opcode or passes it onward, so the disjoint-family chain ends in the movement-relay catch-all. #[allow(clippy::too_many_lines)] // One stage per opcode family. -fn dispatch( +fn dispatch( tx: &SessionTx, - store: &St, + store: &dyn WorldStore, conn: &mut WorldConn, msg: ClientOpcodeMessage, ) -> Result<()> { @@ -1654,7 +1646,7 @@ fn dispatch( Ok(()) } -fn dispatch_raw_auction_browse( +fn dispatch_raw_auction_browse( tx: &SessionTx, store: &St, conn: &mut WorldConn, @@ -1682,7 +1674,7 @@ fn dispatch_raw_auction_browse( } } -fn forward_movement( +fn forward_movement( store: &St, conn: &mut WorldConn, opcode: u32, @@ -1942,17 +1934,21 @@ pub async fn run( } tokio::task::spawn_blocking(move || { let _task_permit = task_permit; - // `Coordinator` implements `WorldStore` directly (see `stdb::world_store`) — no wrapper. // `queue` gates admission INSIDE the handshake — a queued connection just blocks // this one `spawn_blocking` thread, never the accept loop above. - if let Err(e) = - run_world_session_with_queue_and_deadline(std_sock, &coord, &queue, &mut deadline) - { + if let Err(e) = run_world_session_with_queue_and_deadline( + std_sock, + Arc::new(coord), + &queue, + &mut deadline, + ) { log::warn!("world session {peer} ended: {e:#}"); } }); } } +#[cfg(all(test, unix))] +mod test_support; #[cfg(all(test, unix))] mod tests; diff --git a/gateway/src/world/npc_tests.rs b/gateway/src/world/npc_tests.rs new file mode 100644 index 000000000..d4ac87c71 --- /dev/null +++ b/gateway/src/world/npc_tests.rs @@ -0,0 +1,933 @@ +//! NPC and query opcodes over an encrypted World Session: item and NPC text queries, inspect, and +//! gossip menus. + +use super::*; + +/// A restrictive imported equipment template. The query response is cached by the client and is +/// shared by inventory, vendor, and quest-reward screens, so one socket test pins that definition. +fn human_warrior_sword_template() -> codec::ItemTemplateView { + codec::ItemTemplateView { + entry: 910_261, + class: 2, + subclass: 7, + name: "Quartermaster's Practice Sword".into(), + display_id: 1542, + quality: 2, + inventory_type: 13, + item_level: 20, + required_level: 15, + max_durability: 40, + buy_price: 4_000, + sell_price: 800, + max_stack: 1, + damage_min: 9.0, + damage_max: 17.0, + delay_ms: 2_400, + required_skill: 43, // Swords + required_skill_rank: 150, + required_reputation_faction: 72, + required_reputation_rank: 5, + allowed_class: 0x01, // Warrior + allowed_race: 0x01, // Human + ..Default::default() + } +} + +#[test] +fn item_query_preserves_imported_eligibility_through_the_encrypted_world_session() { + let template = human_warrior_sword_template(); + let mut fixture = quest_store(); + fixture.cast.item_templates = vec![template.clone()]; + let store = std::sync::Arc::new(fixture); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); + + CMSG_ITEM_QUERY_SINGLE { + item: template.entry, + guid: Guid::new(0), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + + let found = match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_ITEM_QUERY_SINGLE_RESPONSE(reply) => { + assert_eq!(reply.item, template.entry); + reply + .found + .expect("the imported template must reach the item query reply") + } + other => panic!("expected SMSG_ITEM_QUERY_SINGLE_RESPONSE, got {other}"), + }; + + assert_eq!(found.allowed_class.as_int(), template.allowed_class); + assert_eq!(found.allowed_race.as_int(), template.allowed_race); + assert_eq!( + u32::from(found.required_skill.as_int()), + template.required_skill + ); + assert_eq!(found.required_skill_rank, template.required_skill_rank); + assert_eq!( + u32::from(found.required_faction.as_int()), + template.required_reputation_faction + ); + assert_eq!( + found.required_faction_rank, + template.required_reputation_rank + ); + + // These are client-local eligibility terms, deliberately limited to the fields this cached + // definition contains. They prove the fixture is restrictive rather than an all-bits mask. + let client_can_use = |class: u32, race: u32, skill: Option<(u32, u32)>| { + found.allowed_class.as_int() & class != 0 + && found.allowed_race.as_int() & race != 0 + && skill.is_some_and(|(id, rank)| { + id == u32::from(found.required_skill.as_int()) && rank >= found.required_skill_rank + }) + }; + assert!(client_can_use(0x01, 0x01, Some((43, 150)))); // Human Warrior with Swords 150 + assert!(!client_can_use(0x80, 0x01, Some((43, 150)))); // excluded Mage + assert!(!client_can_use(0x01, 0x02, Some((43, 150)))); // excluded Orc + assert!(!client_can_use(0x01, 0x01, None)); // missing sword proficiency + + drop(client); + server.join().unwrap(); +} + +#[test] +fn inspect_in_range_friendly_target_replies_smsg_inspect_with_the_target_guid() { + let store = std::sync::Arc::new(quest_store()); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); + CMSG_INSPECT { + guid: Guid::new(55), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_INSPECT(r) => assert_eq!(r.guid.guid(), 55), + other => panic!("expected SMSG_INSPECT, got {other}"), + } + drop(client); + server.join().unwrap(); +} + +#[test] +fn inspect_refused_target_sends_no_reply() { + // CMSG_PLAYED_TIME (below) always replies as long as `character_by_guid` resolves the caller's + // own guid, so give the store a character row for guid 1 (quest_store() has none). + let store = std::sync::Arc::new({ + let base = quest_store(); + WorldFake { + characters: vec![codec::CharacterView { + guid: 1, + ..Default::default() + }], + ..base + } + }); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); + // guid 0 is the mock store's stand-in for "out of range / no such target" — the gate rejects it + // and the handler drops the request silently (mirrors CMSG_GAMEOBJ_USE/CMSG_AREATRIGGER). + CMSG_INSPECT { guid: Guid::new(0) } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + // Sentinel: a follow-up request with a guaranteed reply. If the refused CMSG_INSPECT had + // wrongly produced an SMSG_INSPECT, it would arrive first and this match would fail. + CMSG_PLAYED_TIME {} + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_PLAYED_TIME(_) => {} // no SMSG_INSPECT was sent for the refused target + other => { + panic!("expected SMSG_PLAYED_TIME (no SMSG_INSPECT for refused target), got {other}") + } + } + drop(client); + server.join().unwrap(); +} + +#[test] +fn gossip_select_on_an_imported_banker_option_opens_the_bank_window() { + use lyracore_shared::constants::gossip_option; + let mut s = quest_store(); + s.npc.gossip_opts = vec![opt( + 0, + "I would like to check my deposit box.", + gossip_option::BANKER, + )]; + 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); + CMSG_GOSSIP_SELECT_OPTION { + guid: Guid::new(90), + gossip_list_id: 0, + unknown: None, + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_SHOW_BANK(p) => assert_eq!(p.guid.guid(), 90), + other => panic!("expected SMSG_SHOW_BANK, got {other}"), + } + drop(client); + server.join().unwrap(); +} + +#[test] +fn gossip_select_on_a_petitioner_option_opens_the_charter_list() { + use lyracore_shared::constants::gossip_option; + let mut s = quest_store(); + s.npc.gossip_opts = vec![opt(0, "How do I form a guild?", gossip_option::PETITIONER)]; + 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); + CMSG_GOSSIP_SELECT_OPTION { + guid: Guid::new(90), + gossip_list_id: 0, + unknown: None, + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE => {} + other => panic!("expected SMSG_GOSSIP_COMPLETE, got {other}"), + } + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_PETITION_SHOWLIST(list) => { + assert_eq!(list.npc.guid(), 90); + assert_eq!(list.petitions[0].charter_entry, 5863); + assert_eq!(list.petitions[0].guild_charter_cost, 1000); + } + other => panic!("expected SMSG_PETITION_SHOWLIST, got {other}"), + } + drop(client); + server.join().unwrap(); +} + +#[test] +fn gossip_select_on_a_vendor_opens_the_inventory_window() { + // Option 0 on a stocked NPC is "browse goods" → the RAW SMSG_LIST_INVENTORY, same as the + // direct CMSG_LIST_INVENTORY path. + let mut s = quest_store(); + s.vendor.vendor_stock = vec![codec::VendorItemView { + item_entry: 4540, + display_id: 6353, + buy_price: 25, + ..Default::default() + }]; + let store = std::sync::Arc::new(s); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); + let menu = gossip_hello(&mut client, &mut c_enc, &mut c_dec, 80); + assert_eq!(menu.gossips[0].message, "I'd like to browse your goods."); + CMSG_GOSSIP_SELECT_OPTION { + guid: Guid::new(80), + gossip_list_id: 0, + unknown: None, + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + let (op, body) = read_raw_frame(&mut client, &mut c_dec); + assert_eq!(op, codec::SMSG_LIST_INVENTORY_OPCODE); + assert_eq!( + &body[0..8], + &80u64.to_le_bytes(), + "the vendor window names the NPC" + ); + drop(client); + server.join().unwrap(); + assert!(!store + .npc + .home_bound + .load(std::sync::atomic::Ordering::SeqCst)); +} + +#[test] +fn gossip_select_on_an_innkeeper_binds_home_and_completes() { + // A non-vendor innkeeper's "Make this inn your home." is option 0 → bind_home + GOSSIP_COMPLETE. + let mut s = quest_store(); + s.npc.innkeeper = true; + let store = std::sync::Arc::new(s); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); + let menu = gossip_hello(&mut client, &mut c_enc, &mut c_dec, 81); + assert_eq!(menu.gossips[0].message, "Make this inn your home."); + CMSG_GOSSIP_SELECT_OPTION { + guid: Guid::new(81), + gossip_list_id: 0, + unknown: None, + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE => {} + other => panic!("expected SMSG_GOSSIP_COMPLETE, got {other}"), + } + drop(client); + server.join().unwrap(); + assert!( + store + .npc + .home_bound + .load(std::sync::atomic::Ordering::SeqCst), + "bind_home must have run" + ); +} + +#[test] +fn gossip_select_of_any_other_option_completes_without_binding() { + // Farewell (option 1 on an innkeeper NPC) → GOSSIP_COMPLETE only; no bind, no vendor window. + let mut s = quest_store(); + s.npc.innkeeper = true; + let store = std::sync::Arc::new(s); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); + gossip_hello(&mut client, &mut c_enc, &mut c_dec, 81); + CMSG_GOSSIP_SELECT_OPTION { + guid: Guid::new(81), + gossip_list_id: 1, + unknown: None, + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE => {} + other => panic!("expected SMSG_GOSSIP_COMPLETE, got {other}"), + } + drop(client); + server.join().unwrap(); + assert!(!store + .npc + .home_bound + .load(std::sync::atomic::Ordering::SeqCst)); +} + +#[test] +fn gossip_hello_renders_imported_options_verbatim_with_a_trailing_farewell() { + // The 217 acceptance criterion: an Elwynn-innkeeper-shaped NPC with 3 imported options (chat, + // browse goods, make-home) renders them VERBATIM (real dump text, not the hardcoded fallback + // strings) — the vendor/innkeeper flags are ignored entirely once options are imported. + use lyracore_shared::constants::gossip_option; + let mut s = quest_store(); + s.npc.gossip_opts = vec![ + opt(0, "Well met, traveler.", gossip_option::GOSSIP), + opt(1, "I'd like to browse your goods.", gossip_option::VENDOR), + opt( + 0, + "I'd like to stay here a while.", + gossip_option::INNKEEPER, + ), + ]; + // Fallback signals present too — must be ignored while options are imported. + s.vendor.vendor_stock = vec![codec::VendorItemView { + item_entry: 1, + ..Default::default() + }]; + s.npc.innkeeper = true; + let store = std::sync::Arc::new(s); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); + CMSG_GOSSIP_HELLO { + guid: Guid::new(90), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_GOSSIP_MESSAGE(m) => { + assert_eq!( + m.gossips.len(), + 4, + "3 imported + a trailing Farewell: {:?}", + m.gossips + ); + assert_eq!(m.gossips[0].message, "Well met, traveler."); + assert_eq!(m.gossips[1].message, "I'd like to browse your goods."); + assert_eq!(m.gossips[2].message, "I'd like to stay here a while."); + assert_eq!(m.gossips[3].message, "Farewell."); + } + other => panic!("expected SMSG_GOSSIP_MESSAGE, got {other}"), + } + drop(client); + server.join().unwrap(); +} + +#[test] +fn gossip_select_on_an_imported_vendor_option_opens_the_inventory_window() { + use lyracore_shared::constants::gossip_option; + let mut s = quest_store(); + s.npc.gossip_opts = vec![opt(1, "Browse.", gossip_option::VENDOR)]; + s.vendor.vendor_stock = vec![codec::VendorItemView { + item_entry: 4540, + display_id: 6353, + buy_price: 25, + ..Default::default() + }]; + let store = std::sync::Arc::new(s); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); + gossip_hello(&mut client, &mut c_enc, &mut c_dec, 90); + CMSG_GOSSIP_SELECT_OPTION { + guid: Guid::new(90), + gossip_list_id: 0, + unknown: None, + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + let (op, body) = read_raw_frame(&mut client, &mut c_dec); + assert_eq!(op, codec::SMSG_LIST_INVENTORY_OPCODE); + assert_eq!( + &body[0..8], + &90u64.to_le_bytes(), + "the vendor window names the NPC" + ); + drop(client); + server.join().unwrap(); + assert!(!store + .npc + .home_bound + .load(std::sync::atomic::Ordering::SeqCst)); +} + +#[test] +fn gossip_select_on_an_imported_innkeeper_option_binds_home() { + use lyracore_shared::constants::gossip_option; + let mut s = quest_store(); + s.npc.gossip_opts = vec![ + opt(0, "Chat.", gossip_option::GOSSIP), + opt(0, "Stay here.", gossip_option::INNKEEPER), + ]; + let store = std::sync::Arc::new(s); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); + gossip_hello(&mut client, &mut c_enc, &mut c_dec, 90); + CMSG_GOSSIP_SELECT_OPTION { + guid: Guid::new(90), + gossip_list_id: 1, + unknown: None, + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE => {} + other => panic!("expected SMSG_GOSSIP_COMPLETE, got {other}"), + } + drop(client); + server.join().unwrap(); + assert!( + store + .npc + .home_bound + .load(std::sync::atomic::Ordering::SeqCst), + "bind_home must have run" + ); +} + +#[test] +fn the_same_option_row_reaches_the_module_by_row_id_from_either_viewer() { + use lyracore_shared::constants::{gossip_condition, gossip_option}; + let menu = || { + let mut gated = opt(0, "About that favor...", gossip_option::GOSSIP); + gated.row_id = 4001; + gated.cond_type = gossip_condition::QUEST_TAKEN; + gated.cond_value1 = 60; + let mut always = opt(0, "Stay here.", gossip_option::INNKEEPER); + always.row_id = 4002; + vec![gated, always] + }; + // Viewer A has not taken quest 60 → the gated row is hidden, so "Stay here." renders at 0. + let mut a = quest_store(); + a.npc.gossip_opts = menu(); + let a = std::sync::Arc::new(a); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(a.clone(), 1); + let rendered = gossip_hello(&mut client, &mut c_enc, &mut c_dec, 90); + assert_eq!(rendered.gossips[0].message, "Stay here."); + CMSG_GOSSIP_SELECT_OPTION { + guid: Guid::new(90), + gossip_list_id: 0, + unknown: None, + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + let _ = ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); + drop(client); + server.join().unwrap(); + + // Viewer B HAS taken it → the gated row renders first and pushes "Stay here." to position 1. + let mut b = quest_store(); + b.npc.gossip_opts = menu(); + b.quest.quest_log = vec![(60, false)].into(); + let b = std::sync::Arc::new(b); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(b.clone(), 1); + let rendered = gossip_hello(&mut client, &mut c_enc, &mut c_dec, 90); + assert_eq!(rendered.gossips[1].message, "Stay here."); + CMSG_GOSSIP_SELECT_OPTION { + guid: Guid::new(90), + gossip_list_id: 1, + unknown: None, + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + let _ = ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); + drop(client); + server.join().unwrap(); + + let (a_pos, a_row) = a.npc.gossip_selects.lock().unwrap()[0]; + let (b_pos, b_row) = b.npc.gossip_selects.lock().unwrap()[0]; + assert_ne!(a_pos, b_pos, "the POSITION differs between the two viewers"); + assert_eq!( + (a_row, b_row), + (4002, 4002), + "the row_id is the same option for both" + ); +} + +#[test] +fn a_quest_taken_while_the_window_is_open_does_not_shift_the_click() { + use lyracore_shared::constants::{gossip_condition, gossip_option}; + let mut s = quest_store(); + let mut gated = opt(0, "About that favor...", gossip_option::GOSSIP); + gated.row_id = 4001; + gated.cond_type = gossip_condition::QUEST_TAKEN; + gated.cond_value1 = 60; + let mut inn = opt(0, "Stay here.", gossip_option::INNKEEPER); + inn.row_id = 4002; + s.npc.gossip_opts = vec![gated, inn]; + let store = std::sync::Arc::new(s); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); + // Rendered while the quest is untaken: the gated line is hidden, "Stay here." is position 0. + let rendered = gossip_hello(&mut client, &mut c_enc, &mut c_dec, 90); + assert_eq!(rendered.gossips[0].message, "Stay here."); + // The player accepts quest 60 elsewhere (another window, a party member's turn-in) — a fresh + // filter would now put the gated line at 0 and push "Stay here." to 1. + store.quest.quest_log.lock().unwrap().push((60, false)); + CMSG_GOSSIP_SELECT_OPTION { + guid: Guid::new(90), + gossip_list_id: 0, + unknown: None, + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE => {} + other => panic!("expected SMSG_GOSSIP_COMPLETE, got {other}"), + } + drop(client); + server.join().unwrap(); + assert!( + store + .npc + .home_bound + .load(std::sync::atomic::Ordering::SeqCst), + "the click must still select the innkeeper line the player was shown" + ); + assert_eq!( + store.npc.gossip_selects.lock().unwrap()[0], + (0, 4002), + "and the module hears the row the player saw, not the one that moved into that slot" + ); +} + +#[test] +fn a_select_with_no_open_menu_just_closes_the_window() { + use lyracore_shared::constants::gossip_option; + let mut s = quest_store(); + s.npc.gossip_opts = vec![opt(0, "Stay here.", gossip_option::INNKEEPER)]; + let store = std::sync::Arc::new(s); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); + gossip_hello(&mut client, &mut c_enc, &mut c_dec, 90); + // Same position, a DIFFERENT npc — the open menu is 90's, so this selects nothing. + CMSG_GOSSIP_SELECT_OPTION { + guid: Guid::new(91), + gossip_list_id: 0, + unknown: None, + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE => {} + other => panic!("expected SMSG_GOSSIP_COMPLETE, got {other}"), + } + drop(client); + server.join().unwrap(); + assert!(!store + .npc + .home_bound + .load(std::sync::atomic::Ordering::SeqCst)); + assert_eq!( + store.npc.gossip_selects.lock().unwrap()[0].1, + codec::SYNTHESIZED_ROW_ID, + "no imported row was selected" + ); +} + +#[test] +fn an_imported_menu_missing_its_vendor_row_still_reaches_the_stock() { + use lyracore_shared::constants::gossip_option; + let mut s = quest_store(); + s.npc.gossip_opts = vec![opt(0, "What is Children's Week?", gossip_option::GOSSIP)]; + s.vendor.vendor_stock = vec![codec::VendorItemView { + item_entry: 4540, + display_id: 6353, + buy_price: 25, + ..Default::default() + }]; + let store = std::sync::Arc::new(s); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); + let menu = gossip_hello(&mut client, &mut c_enc, &mut c_dec, 90); + assert_eq!(menu.gossips.len(), 3, "chat + browse goods + Farewell"); + assert_eq!(menu.gossips[0].message, "What is Children's Week?"); + assert_eq!(menu.gossips[1].message, "I'd like to browse your goods."); + CMSG_GOSSIP_SELECT_OPTION { + guid: Guid::new(90), + gossip_list_id: 1, + unknown: None, + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + let (op, body) = read_raw_frame(&mut client, &mut c_dec); + assert_eq!(op, codec::SMSG_LIST_INVENTORY_OPCODE); + assert_eq!(&body[0..8], &90u64.to_le_bytes()); + drop(client); + server.join().unwrap(); +} + +#[test] +fn an_imported_menu_missing_its_bind_row_still_offers_the_hearth() { + use lyracore_shared::constants::gossip_option; + let mut s = quest_store(); + s.npc.gossip_opts = vec![opt(0, "Tell me about the inn.", gossip_option::GOSSIP)]; + s.npc.innkeeper = true; + let store = std::sync::Arc::new(s); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); + let menu = gossip_hello(&mut client, &mut c_enc, &mut c_dec, 90); + assert_eq!(menu.gossips[1].message, "Make this inn your home."); + CMSG_GOSSIP_SELECT_OPTION { + guid: Guid::new(90), + gossip_list_id: 1, + unknown: None, + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE => {} + other => panic!("expected SMSG_GOSSIP_COMPLETE, got {other}"), + } + drop(client); + server.join().unwrap(); + assert!(store + .npc + .home_bound + .load(std::sync::atomic::Ordering::SeqCst)); +} + +/// A `quest_store()` fixture whose logged-in character (guid 1) reports `level`, so +/// `filtered_gossip_options`' level gate has something to read (`quest_store()` itself leaves +/// `characters` empty, which reads as level 0 — every below-10 test can lean on that default). +fn quest_store_at_level(level: u8) -> WorldFake { + let base = quest_store(); + WorldFake { + characters: vec![codec::CharacterView { + guid: 1, + level, + ..Default::default() + }], + ..base + } +} + +#[test] +fn gossip_hello_hides_unlearn_talents_below_level_10() { + // The imported "I wish to unlearn my talents." row (reclassified by the importer to + // `UNLEARNTALENTS`, since the raw dump column never carries it) must not render for a character + // who cannot yet have a talent point. + use lyracore_shared::constants::gossip_option; + let mut s = quest_store_at_level(5); + s.npc.gossip_opts = vec![ + opt(0, "I require warrior training.", gossip_option::TRAINER), + opt( + 0, + "I wish to unlearn my talents.", + gossip_option::UNLEARNTALENTS, + ), + ]; + let store = std::sync::Arc::new(s); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); + CMSG_GOSSIP_HELLO { + guid: Guid::new(90), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_GOSSIP_MESSAGE(m) => { + assert_eq!( + m.gossips.len(), + 2, + "training + trailing Farewell only, no unlearn option: {:?}", + m.gossips + ); + assert_eq!(m.gossips[0].message, "I require warrior training."); + assert_eq!(m.gossips[1].message, "Farewell."); + } + other => panic!("expected SMSG_GOSSIP_MESSAGE, got {other}"), + } + drop(client); + server.join().unwrap(); +} + +#[test] +fn gossip_hello_shows_unlearn_talents_at_level_10_and_select_routes_to_reset_talents() { + use lyracore_shared::constants::gossip_option; + let mut s = quest_store_at_level(10); + s.npc.gossip_opts = vec![ + opt(0, "I require warrior training.", gossip_option::TRAINER), + opt( + 0, + "I wish to unlearn my talents.", + gossip_option::UNLEARNTALENTS, + ), + ]; + let store = std::sync::Arc::new(s); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); + CMSG_GOSSIP_HELLO { + guid: Guid::new(90), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_GOSSIP_MESSAGE(m) => { + assert_eq!( + m.gossips.len(), + 3, + "training + unlearn + trailing Farewell: {:?}", + m.gossips + ); + assert_eq!(m.gossips[1].message, "I wish to unlearn my talents."); + } + other => panic!("expected SMSG_GOSSIP_MESSAGE, got {other}"), + } + // Click it (index 1, same list HELLO just rendered) — must route to reset_talents, not just + // close the window inert. + CMSG_GOSSIP_SELECT_OPTION { + guid: Guid::new(90), + gossip_list_id: 1, + unknown: None, + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE => {} + other => panic!("expected SMSG_GOSSIP_COMPLETE, got {other}"), + } + drop(client); + server.join().unwrap(); + 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)" + ); +} + +#[test] +fn gossip_hello_shows_a_quest_gated_option_once_the_quest_is_taken() { + // The socket-level contract: the player's quest state reaches the gossip filter through the + // quest seam's `quest_gate_state`, and the gated row is assembled into the gossip message the + // client actually receives. The hidden case is covered by the position-alignment test below. + use lyracore_shared::constants::{gossip_condition, gossip_option}; + let mut s = quest_store(); + s.npc.gossip_opts = vec![opt(0, "About that favor...", gossip_option::GOSSIP)]; + s.npc.gossip_opts[0].cond_type = gossip_condition::QUEST_TAKEN; + s.npc.gossip_opts[0].cond_value1 = 60; + s.quest.quest_log = vec![(60, false)].into(); // taken, not yet turned in + let store = std::sync::Arc::new(s); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); + CMSG_GOSSIP_HELLO { + guid: Guid::new(90), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_GOSSIP_MESSAGE(m) => { + assert_eq!(m.gossips.len(), 2, "{:?}", m.gossips); + assert_eq!(m.gossips[0].message, "About that favor..."); + } + other => panic!("expected SMSG_GOSSIP_MESSAGE, got {other}"), + } + drop(client); + server.join().unwrap(); +} + +#[test] +fn gossip_hello_and_select_option_stay_position_aligned_under_a_hidden_option() { + // The sharpest trap (danger-zones-adjacent): 3 imported options where the MIDDLE one is + // quest-gated and hidden. HELLO sends only 2 lines (positions 0,1); a SELECT of position 1 MUST + // route to the THIRD raw option (innkeeper), not the hidden middle one — proving + // `filtered_gossip_options` re-derives the IDENTICAL list rather than indexing the raw rows. + use lyracore_shared::constants::{gossip_condition, gossip_option}; + let mut s = quest_store(); + s.npc.gossip_opts = vec![ + opt(0, "Chat.", gossip_option::GOSSIP), // raw index 0 -> rendered index 0 + opt(0, "Hidden favor.", gossip_option::GOSSIP), // raw index 1 -> HIDDEN (quest-gated) + opt(0, "Stay here.", gossip_option::INNKEEPER), // raw index 2 -> rendered index 1 + ]; + s.npc.gossip_opts[1].cond_type = gossip_condition::QUEST_TAKEN; + s.npc.gossip_opts[1].cond_value1 = 60; // never taken in this store + let store = std::sync::Arc::new(s); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); + CMSG_GOSSIP_HELLO { + guid: Guid::new(90), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_GOSSIP_MESSAGE(m) => { + assert_eq!(m.gossips.len(), 3, "2 visible + Farewell: {:?}", m.gossips); // hidden option excluded + assert_eq!(m.gossips[0].message, "Chat."); + assert_eq!(m.gossips[1].message, "Stay here."); + } + other => panic!("expected SMSG_GOSSIP_MESSAGE, got {other}"), + } + // Click rendered position 1 ("Stay here.") — must bind home, NOT be swallowed by the hidden + // middle option that was never actually sent to the client. + CMSG_GOSSIP_SELECT_OPTION { + guid: Guid::new(90), + gossip_list_id: 1, + unknown: None, + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE => {} + other => panic!("expected SMSG_GOSSIP_COMPLETE, got {other}"), + } + drop(client); + server.join().unwrap(); + assert!( + store + .npc + .home_bound + .load(std::sync::atomic::Ordering::SeqCst), + "position 1 must resolve to the innkeeper option, not the hidden one" + ); +} + +#[test] +fn npc_text_query_ships_the_imported_8_slot_view() { + let mut view = codec::NpcTextView::default(); + view.slots[0] = ( + "Well met.".to_string(), + "Well met, traveler.".to_string(), + 0.6, + ); + view.slots[3] = ( + "Watch yourself.".to_string(), + "Watch yourself.".to_string(), + 0.4, + ); + let mut s = quest_store(); + s.npc.npc_text_view = Some(view); + let store = std::sync::Arc::new(s); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); + CMSG_NPC_TEXT_QUERY { + text_id: 77, + guid: Guid::new(90), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_NPC_TEXT_UPDATE(u) => { + assert_eq!(u.text_id, 77); + assert_eq!(u.texts[0].texts[0], "Well met."); + assert_eq!(u.texts[0].probability, 0.6); + assert_eq!(u.texts[3].texts[0], "Watch yourself."); + assert_eq!(u.texts[3].probability, 0.4); + assert_eq!(u.texts[1].probability, 0.0); // untouched slot stays silent + } + other => panic!("expected SMSG_NPC_TEXT_UPDATE, got {other}"), + } + drop(client); + server.join().unwrap(); +} + +#[test] +fn gossip_hides_the_train_and_unlearn_options_for_a_class_the_trainer_does_not_serve() { + use lyracore_shared::constants::gossip_option; + // Level 20 matters: the respec option is independently hidden below level 10, so at the default + // fixture level this would pass without the class gate doing any work. + let mut s = quest_store_at_level(20); + s.npc.gossip_opts = vec![ + opt(0, "Well met, traveler.", gossip_option::GOSSIP), + opt(1, "I would like to train.", gossip_option::TRAINER), + opt( + 0, + "I wish to unlearn my talents.", + gossip_option::UNLEARNTALENTS, + ), + opt(1, "I'd like to browse your goods.", gossip_option::VENDOR), + ]; + s.trainer.trainer_refuses_class = true; + let store = std::sync::Arc::new(s); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); + CMSG_GOSSIP_HELLO { + guid: Guid::new(90), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_GOSSIP_MESSAGE(m) => { + let lines: Vec<&str> = m.gossips.iter().map(|g| g.message.as_str()).collect(); + assert!( + !lines.contains(&"I would like to train."), + "the train option must be hidden: {lines:?}" + ); + assert!( + !lines.contains(&"I wish to unlearn my talents."), + "the respec option must be hidden too: {lines:?}" + ); + // The NPC is not silenced — it still talks, and still sells. + assert!( + lines.contains(&"Well met, traveler."), + "plain gossip lines survive: {lines:?}" + ); + assert!( + lines.contains(&"I'd like to browse your goods."), + "the vendor line on the same NPC survives: {lines:?}" + ); + } + other => panic!("expected SMSG_GOSSIP_MESSAGE, got {other}"), + } + drop(client); + server.join().unwrap(); +} + +#[test] +fn gossip_keeps_the_train_and_unlearn_options_for_a_class_the_trainer_serves() { + use lyracore_shared::constants::gossip_option; + // Same level as its counterpart, so the only difference between the two tests is the gate. + let mut s = quest_store_at_level(20); + s.npc.gossip_opts = vec![ + opt(0, "Well met, traveler.", gossip_option::GOSSIP), + opt(1, "I would like to train.", gossip_option::TRAINER), + opt( + 0, + "I wish to unlearn my talents.", + gossip_option::UNLEARNTALENTS, + ), + opt(1, "I'd like to browse your goods.", gossip_option::VENDOR), + ]; + let store = std::sync::Arc::new(s); // trainer_refuses_class stays false (derive-Default) + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); + CMSG_GOSSIP_HELLO { + guid: Guid::new(90), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_GOSSIP_MESSAGE(m) => { + let lines: Vec<&str> = m.gossips.iter().map(|g| g.message.as_str()).collect(); + assert!( + lines.contains(&"I would like to train."), + "a served class still gets the train option: {lines:?}" + ); + assert!( + lines.contains(&"I wish to unlearn my talents."), + "and the respec option: {lines:?}" + ); + } + other => panic!("expected SMSG_GOSSIP_MESSAGE, got {other}"), + } + drop(client); + server.join().unwrap(); +} diff --git a/gateway/src/world/party.rs b/gateway/src/world/party.rs index 001c49509..4a01aae86 100644 --- a/gateway/src/world/party.rs +++ b/gateway/src/world/party.rs @@ -11,7 +11,7 @@ //! SPLIT across the boundary never saw itself. Both were observed live (2026-07-25), not theorised. //! //! This module is the ROUTING half of the fix and the only place that decides which database a party -//! op runs against. Everything in it is generic over [`WorldStore`], so the decisions execute under +//! op runs against. Everything in it is generic over the Store families, so the decisions execute under //! test against the same in-memory shard topology the cross-database transfer uses — the seam the //! transfer-transport test harness built. The database-specific halves are thin: `Coordinator`'s //! trait impl (which database a handle names) and the module's `realm_group_op` (the rules, unchanged). @@ -26,7 +26,7 @@ //! (kill-XP split, quest credit, loot rules, the party's dungeon binding) resolve //! membership locally on the hot path and must not become cross-database calls. Same relationship //! `game_account`/`game_session` have had with realm-core from the start. -//! 3. **A single-database gateway** has no realm-core to route to ([`WorldStore::realm_store`] +//! 3. **A single-database gateway** has no realm-core to route to ([`ShardRoutingStore::realm_store`] //! answers `None`), so every op takes the pre-realm-core path: the player's own connection, the //! player-facing reducer, the shard's own tables. Byte-identical, and pinned by //! `an_unsharded_gateway_runs_every_party_op_on_the_players_own_shard`. @@ -38,6 +38,7 @@ //! Realm Chat Line: Realm-core reads its own membership in the transaction that writes the line, so //! the mirror plays no part in who hears it. +use crate::world::{CharacterStore, SessionStore, ShardRoutingStore, SocialStore}; use std::time::{Duration, Instant}; use anyhow::Result; @@ -49,6 +50,172 @@ use lyracore_shared::group::{ COMMAND_RESULT_WINDOW_MICROS, }; +/// Party membership: Realm-core group ops, roster reads, World Shard mirrors and companion commands. +pub(crate) trait PartyStore: Send + Sync { + /// Admit and claim one Group Intent against current World Shard state. Refusals include a + /// consumed intent or suppressed action. Transport failures remain distinct. + fn claim_bot_invite_intent(&self, intent_id: u64) -> Result; + + fn claim_party_command_intent(&self, intent_id: u64, claim_token: u64) -> Result<()>; + + fn admit_party_command_authority( + &self, + group_id: u64, + leader_guid: u64, + bot_guid: u64, + authority_member_guid: u64, + expected_members: Vec, + ) -> Result; + + fn apply_admitted_party_command( + &self, + command: &AdmittedCompanionCommand, + ) -> Result; + + fn finish_party_command_intent( + &self, + intent_id: u64, + claim_token: u64, + outcome: CompanionCommandOutcome, + ) -> Result<()>; + + fn confirm_party_command_receipt( + &self, + source_identity: spacetimedb_sdk::Identity, + intent_id: u64, + ) -> Result>; + + fn confirm_party_command_holder(&self, bot_guid: u64) -> Result; + + fn entity_partition(&self, guid: u64) -> Option<(u32, u64)>; + + /// Acknowledged World Shard admission for one automatic group action. A later controller + /// selection cannot undo admission or membership already committed on Realm-core. + fn admit_sessionless_group_action(&self, character_guid: u64) -> Result; + + /// `realm_group_op` — run one party op against the database this handle names: realm-core + /// when sharded, the only shard otherwise. The op byte and argument slots are + /// `lyracore_shared::group::realm_op`. + fn realm_group_op( + &self, + op: u8, + actor_guid: u64, + target_guid: u64, + arg_a: u8, + arg_b: u8, + arg_c: u64, + ) -> Result; + + /// [`Self::realm_group_op`] that returns only after this handle's Coordinator cache holds the + /// commit, so a roster read right after it sees the op. It waits on the Coordinator pump, so its + /// caller must run on another thread. Fakes may reuse the ordinary op. + fn realm_group_op_visible( + &self, + op: u8, + actor_guid: u64, + target_guid: u64, + arg_a: u8, + arg_b: u8, + arg_c: u64, + ) -> Result; + + /// 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; + + /// 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. + fn group_roster(&self, character_guid: u64) -> Result>; + + /// Bounded party projection used by companion-command authority. A roster longer than a Raid, + /// or an otherwise unreadable projection, is an infrastructure failure, never proof of + /// membership. + fn party_command_group_roster(&self, character_guid: u64) -> Result>; + + /// [`group_roster`](Self::group_roster) keyed by the group — the read the mirror push needs for a + /// party the acting character has just left. + fn group_roster_by_id(&self, group_id: u64) -> Result>; + + /// Realm-core's durable order for a complete party roster, including a disbanded party. + fn group_roster_revision(&self, group_id: u64) -> Result; + + /// The Roster Revision this database holds for one party, or `None` when it holds no row. On a + /// World Shard it is the mirror's revision, kept after a disband; on Realm-core it is the + /// authoritative one. + fn held_roster_revision(&self, group_id: u64) -> Result>; + + /// Realm-core's ordered locator for one Character. World Shards never supply this fact. + fn realm_character_partition( + &self, + character_guid: u64, + ) -> Result>; + + fn party_holder_observation( + &self, + character_guid: u64, + map_id: u32, + instance_id: u64, + ) -> Result; + + fn party_cleanup_group_roster_by_id(&self, group_id: u64) -> Result>; + + /// Every Character with authoritative Realm-core party membership. + fn party_member_guids(&self) -> Result>; + + /// Every party id held by this store. Realm-core rows are authoritative; World Shard rows are + /// mirrors that reconnect reconciliation must also inspect for stale parties. + fn party_group_ids(&self) -> Result>; + + /// `sync_group_mirror` — replace this shard's mirror of one party with realm-core's roster. + /// 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. + + /// `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; + + /// `CMSG_GROUP_ACCEPT`. + fn group_accept(&self, account_id: u64, self_guid: u64) -> Result; + + /// `CMSG_GROUP_DECLINE`. + fn group_decline(&self, account_id: u64, self_guid: u64) -> Result; + + /// `CMSG_GROUP_DISBAND` (the client's "Leave Party"). + fn group_leave(&self, account_id: u64, self_guid: u64) -> 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; + + /// `CMSG_LOOT_METHOD` — the leader sets the party's loot method/ + /// threshold/master. `loot_setting`/`loot_threshold` are the gateway-decoded `GroupLootSetting`/ + /// `ItemQuality` wire bytes, passed straight through (the module adopted the wire ordering + /// verbatim — zero translation). + fn group_loot_method( + &self, + account_id: u64, + self_guid: u64, + loot_setting: u8, + master_guid: u64, + loot_threshold: u8, + ) -> Result; +} + /// One group, as the database that holds it sees it. Read from realm-core it is the authority; read /// from a world shard it is that shard's mirror. Names and online flags are deliberately NOT in it — /// realm-core has no character rows to resolve either from, so they are filled at render time from @@ -185,7 +352,7 @@ fn classify_party_partition( Ok(state.unwrap_or(PartyPartitionState::Unknown)) } -fn certify_roster_partitions( +fn certify_roster_partitions( store: &St, realm: &dyn WorldStore, mut roster: GroupRoster, @@ -412,7 +579,7 @@ impl AcceptorFacts { /// The facts an ACCEPT conveys, read realm-wide. A failed read conveys 0 rather than losing /// the accept: the Module treats an unknown class as a seat with no role. - fn of(store: &St, guid: u64) -> Self { + fn of(store: &St, guid: u64) -> Self { match presence::character_anywhere(store, guid) { Ok(Some(character)) => Self { class: character.class, @@ -426,7 +593,11 @@ impl AcceptorFacts { } /// [`Self::of`] for an ACCEPT, [`Self::NONE`] for any other op. - fn for_op(store: &St, guid: u64, op: Op) -> Self { + fn for_op( + store: &St, + guid: u64, + op: Op, + ) -> Self { match op { Op::Accept => Self::of(store, guid), _ => Self::NONE, @@ -507,7 +678,7 @@ impl Op { /// 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( +fn run_on_authority( authority: &A, self_guid: u64, op: Op, @@ -520,7 +691,7 @@ fn run_on_authority( /// mirror push after it reads the op's own roster. Every op that changes a roster uses it, for a /// World Session and for a session-less Character alike. It waits on the Coordinator pump, so the /// caller must run on its own thread. `acceptor` is read only for an ACCEPT. -fn run_on_authority_visible( +fn run_on_authority_visible( authority: &A, self_guid: u64, op: Op, @@ -597,7 +768,7 @@ pub enum PartyCommandHolder { InTransit, } -fn receipt_anywhere( +fn receipt_anywhere( source: &St, source_identity: spacetimedb_sdk::Identity, intent_id: u64, @@ -616,7 +787,7 @@ fn receipt_anywhere( Ok(found) } -pub(crate) fn finish_expired_party_command_intent( +pub(crate) fn finish_expired_party_command_intent( source: &St, intent: &PartyCommandIntent, claim_token: u64, @@ -821,7 +992,7 @@ impl From for PartyOutcome { /// [`resolve_roster_members_by_name`] so a caller resolving more than one name reads the roster /// once rather than once per name, which would let two names resolve against two different /// snapshots of a roster another op changed in between. -fn own_group_roster( +fn own_group_roster( store: &St, self_guid: u64, ) -> Result> { @@ -832,7 +1003,7 @@ fn own_group_roster( } /// Resolve a typed name against `roster`'s members: `None` for a name matching nobody there. -fn resolve_in_roster( +fn resolve_in_roster( store: &St, roster: &GroupRoster, name: &str, @@ -853,7 +1024,9 @@ fn resolve_in_roster( /// refuses afterward when the result is not a member. LyraCore applies the member-list rule to /// both opcodes, so neither can reach a namesake standing outside the Raid, unlike /// [`presence::resolve_by_name`]. `None` for no Group, or a name matching no member. -pub(crate) fn resolve_roster_member_by_name( +pub(crate) fn resolve_roster_member_by_name< + St: CharacterStore + PartyStore + ShardRoutingStore + ?Sized, +>( store: &St, self_guid: u64, name: &str, @@ -868,7 +1041,9 @@ pub(crate) fn resolve_roster_member_by_name( /// ONE roster read rather than two: reading the roster separately per name could resolve the pair /// against two different snapshots if another op changed the roster in between, letting a swap /// name a member who had already left. `(None, None)` for no Group. -pub(crate) fn resolve_roster_members_by_name( +pub(crate) fn resolve_roster_members_by_name< + St: CharacterStore + PartyStore + ShardRoutingStore + ?Sized, +>( store: &St, self_guid: u64, first_name: &str, @@ -885,7 +1060,7 @@ pub(crate) fn resolve_roster_members_by_name( /// Route admission to the World Shard that reports the live entity. The acknowledged operation /// checks current Session ownership and consent there, even if that presence read was stale. -fn admit_sessionless_answer( +fn admit_sessionless_answer( store: &St, guid: u64, ) -> Result { @@ -905,7 +1080,13 @@ fn admit_sessionless_answer( /// Admit the automatic answer on the owning World Shard, then apply party rules on Realm-core. /// Suppression or unavailable admission leaves the invite untouched. Admission and membership /// commit on separate Shards, so a later controller selection cannot recall an admitted answer. -fn answer_for_session_less(store: &St, realm: &dyn WorldStore, guid: u64) { +fn answer_for_session_less< + St: CharacterStore + PartyStore + SessionStore + ShardRoutingStore + ?Sized, +>( + store: &St, + realm: &dyn WorldStore, + guid: u64, +) { let admission = admit_sessionless_answer(store, guid); match admission { Ok(PartyOutcome::Ran) => {} @@ -945,7 +1126,9 @@ fn answer_for_session_less(store: &St, realm: &dyn Worl /// roster. The refresh never fails the op (see [`sync_mirrors`]); an op that changes membership /// retries it first ([`sync_membership_mirrors`]). A Group Broadcast runs on Realm-core and nothing /// else happens. -pub(crate) fn run( +pub(crate) fn run< + St: CharacterStore + PartyStore + SessionStore + ShardRoutingStore + SocialStore + ?Sized, +>( store: &St, account_id: u64, self_guid: u64, @@ -1042,7 +1225,7 @@ pub(crate) fn run( /// shows back in its Group. Each touched Group gets [`sync_group_mirrors_required`]'s retries. The /// op has already committed, so a push that still fails is logged, not returned: the next op or /// world entry repairs it, as [`sync_mirrors`] documents. -fn sync_membership_mirrors( +fn sync_membership_mirrors( store: &St, realm: &dyn WorldStore, self_guid: u64, @@ -1107,7 +1290,10 @@ fn op_changed_nothing(op: Op, before: Option<&GroupRoster>) -> bool { /// The invite gates realm-core cannot run for itself: does the target exist anywhere, and is it in /// the world anywhere. `None` means the invite may proceed. -fn invite_gate(store: &St, target: u64) -> Result> { +fn invite_gate( + store: &St, + target: u64, +) -> Result> { if presence::of(store, target)?.is_none() { return Ok(Some(GroupRefusal::NoSuchPlayer)); } @@ -1122,7 +1308,7 @@ fn invite_gate(store: &St, target: u64) -> Result( +fn cross_faction_invite( store: &St, inviter: u64, target: u64, @@ -1246,7 +1432,7 @@ struct ServerLeave { previous_roster: Option, } -fn run_server_leave( +fn run_server_leave( store: &St, realm: &dyn WorldStore, leaver_guid: u64, @@ -1318,7 +1504,7 @@ pub(crate) enum DeletedCharacterPartyCleanup { const DELETED_CHARACTER_LEAVE_ATTEMPTS: usize = 3; /// Remove a deleted Character from its realm-core party after every World Shard confirms absence. -pub(crate) fn cleanup_deleted_character( +pub(crate) fn cleanup_deleted_character( store: &St, character_guid: u64, ) -> Result { @@ -1359,7 +1545,9 @@ pub(crate) fn cleanup_deleted_character( /// Recheck authoritative party members after startup or a Coordinator reconnect. Row-delete /// callbacks are not replayed, so this closes cleanup attempts deferred while a Shard was down. -pub(crate) fn reconcile_deleted_character_parties(store: &St) -> Result<()> { +pub(crate) fn reconcile_deleted_character_parties( + store: &St, +) -> Result<()> { let Some(realm) = store.party_cleanup_realm()? else { return Ok(()); }; @@ -1421,7 +1609,7 @@ const MIRROR_RETRY_WINDOW: std::time::Duration = std::time::Duration::from_secs( /// Push Realm-core's roster or disband tombstone for one party to each of `shards`, with up to /// [`MIRROR_PUSH_ATTEMPTS`] attempts per Shard inside [`MIRROR_RETRY_WINDOW`]. Partition /// certification still reads every World Shard. -pub(crate) fn sync_group_mirrors_required( +pub(crate) fn sync_group_mirrors_required( store: &St, realm: &dyn WorldStore, group_id: u64, @@ -1477,7 +1665,7 @@ pub(crate) fn sync_group_mirrors_required( /// local reads (XP split, loot rules) use a stale roster until the next op or the next world entry /// re-pushes it, and the party FRAME — which is what the player sees — is rendered from realm-core /// through the relay, not from the mirror. -pub(crate) fn sync_mirrors( +pub(crate) fn sync_mirrors( store: &St, realm: &dyn WorldStore, self_guid: u64, @@ -1547,7 +1735,9 @@ pub(crate) fn sync_mirrors( /// /// Unsharded → returns immediately, before any read: the shard's own tables already are the /// authority and the login path is unchanged. -pub(crate) fn on_world_entry( +pub(crate) fn on_world_entry< + St: CharacterStore + PartyStore + SessionStore + ShardRoutingStore + ?Sized, +>( tx: &SessionTx, store: &St, self_guid: u64, @@ -1566,7 +1756,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, self_guid: u64) { let Some(realm) = store.realm_store() else { return; }; @@ -1588,7 +1778,7 @@ fn request_target_icons(store: &St, self_guid: u64) { /// settlement uses the strict sibling below before it drops an arrival fence. /// /// Unsharded → `Ok(None)` before any read: the shard's own tables already are the authority. -pub(crate) fn sync_arrival_mirror( +pub(crate) fn sync_arrival_mirror( store: &St, self_guid: u64, ) -> Result> { @@ -1627,7 +1817,7 @@ pub(crate) fn sync_arrival_mirror( return Ok(None); }; // The shard the player just entered. `store` is already the session's home-shard handle (every - // world-entry caller runs under `on_home_shard!`), so this is the one mirror that must exist + // world-entry caller runs on the session's routed Store), so this is the one mirror that must exist // before the player takes a single action here. store.sync_group_mirror(&roster)?; Ok(Some(roster)) @@ -1636,7 +1826,7 @@ pub(crate) fn sync_arrival_mirror( /// Reconcile the same authoritative roster as [`sync_arrival_mirror`], but make every destination /// write part of Transfer settlement. The arrival fence stays up when realm-core or the mirror is /// unavailable, and a later Transfer retry repeats this operation before release. -pub(crate) fn sync_transfer_arrival_mirror( +pub(crate) fn sync_transfer_arrival_mirror( store: &St, character_guid: u64, ) -> Result<()> { @@ -1682,7 +1872,7 @@ pub(crate) fn sync_transfer_arrival_mirror( /// payload already carries is kept; the Module wrote it with the change. A member whose name will /// not resolve (a shard that is down) renders with an empty name rather than being dropped from the /// frame: a missing row in the party UI reads as "they left", which is a worse lie than a blank one. -pub(crate) fn render_list( +pub(crate) fn render_list( store: &St, self_guid: u64, roster: &RosterPayload, diff --git a/gateway/src/world/party_mirror.rs b/gateway/src/world/party_mirror.rs index d3603f88c..59bb203fb 100644 --- a/gateway/src/world/party_mirror.rs +++ b/gateway/src/world/party_mirror.rs @@ -8,6 +8,7 @@ use super::party; use super::WorldStore; +use crate::world::ShardRoutingStore; use std::collections::BTreeSet; use std::sync::{Arc, Condvar, Mutex, PoisonError}; @@ -19,7 +20,9 @@ pub(crate) struct RosterRevisionRelay { impl RosterRevisionRelay { /// Start the worker thread. `store` is any Coordinator handle. - pub(crate) fn spawn(store: St) -> std::io::Result> { + pub(crate) fn spawn( + store: St, + ) -> std::io::Result> { let relay = Arc::new(Self::default()); let worker = relay.clone(); std::thread::Builder::new() @@ -61,7 +64,7 @@ impl RosterRevisionRelay { /// Push every dirty party to the World Shards whose mirror is stale. A party marked during the /// pass waits for the next one. - pub(crate) fn push_dirty(&self, store: &St) { + pub(crate) fn push_dirty(&self, store: &St) { let parties = std::mem::take(&mut *self.dirty.lock().unwrap_or_else(PoisonError::into_inner)); if parties.is_empty() { @@ -86,7 +89,7 @@ impl RosterRevisionRelay { } } -fn push_party( +fn push_party( store: &St, realm: &dyn WorldStore, shards: &[Arc], diff --git a/gateway/src/world/party_mirror_tests.rs b/gateway/src/world/party_mirror_tests.rs index 913d1cdb3..b83c83656 100644 --- a/gateway/src/world/party_mirror_tests.rs +++ b/gateway/src/world/party_mirror_tests.rs @@ -9,7 +9,7 @@ use super::*; use crate::world::party_mirror::RosterRevisionRelay; /// The split party's id after `form_split_party`. -fn split_party_id(realm: &InMemoryStore) -> u64 { +fn split_party_id(realm: &WorldFake) -> u64 { realm .group_roster(GINGER) .unwrap() @@ -18,8 +18,8 @@ fn split_party_id(realm: &InMemoryStore) -> u64 { } /// Add `guid` to `group_id` on Realm-core and advance its Roster Revision, with no party op. -fn join_on_realm(realm: &InMemoryStore, group_id: u64, guid: u64) { - let mut party = realm.party.lock().unwrap(); +fn join_on_realm(realm: &WorldFake, group_id: u64, guid: u64) { + let mut party = realm.party.party.lock().unwrap(); party.members.push((group_id, guid)); *party.revisions.entry(group_id).or_insert(0) += 1; } @@ -52,13 +52,13 @@ fn a_roster_change_no_party_op_made_reaches_every_shard_mirror() { mirror.member_guids(), [GINGER, VIM, TRIN], "{}", - shard.shard + shard.topology.shard ); assert_eq!( shard.held_roster_revision(group_id).unwrap(), realm_revision, "{} holds Realm-core's Roster Revision", - shard.shard + shard.topology.shard ); } } @@ -69,7 +69,7 @@ fn a_disband_reaches_every_shard_as_the_tombstone() { form_split_party(&world, &instances); let group_id = split_party_id(&realm); { - let mut party = realm.party.lock().unwrap(); + let mut party = realm.party.party.lock().unwrap(); party.members.retain(|(group, _)| *group != group_id); party.groups.retain(|(group, ..)| *group != group_id); *party.revisions.get_mut(&group_id).unwrap() += 1; @@ -85,13 +85,13 @@ fn a_disband_reaches_every_shard_as_the_tombstone() { shard.group_roster_by_id(group_id).unwrap(), None, "{} forgets the party", - shard.shard + shard.topology.shard ); assert_eq!( shard.held_roster_revision(group_id).unwrap(), realm_revision, "{} keeps the tombstone's Roster Revision", - shard.shard + shard.topology.shard ); } } @@ -123,6 +123,7 @@ fn five_revisions_before_the_worker_runs_cause_one_push_per_stale_shard() { let relay = RosterRevisionRelay::default(); for _ in 0..5 { *realm + .party .party .lock() .unwrap() diff --git a/gateway/src/world/party_tests.rs b/gateway/src/world/party_tests.rs index 447a7ceed..6ca4443c4 100644 --- a/gateway/src/world/party_tests.rs +++ b/gateway/src/world/party_tests.rs @@ -6,7 +6,7 @@ //! test — there is no `ReducerContext`), and each shard's `mirror` is exactly what //! `sync_group_mirror` wrote there. //! -//! A child module of `world::tests` so it can reach `InMemoryStore` without widening anything. +//! A child module of `world::tests` so it can reach `WorldFake` without widening anything. use super::*; use lyracore_shared::group::{realm_op, GroupRefusal, RosterMember, RosterPayload}; @@ -25,9 +25,9 @@ pub(super) const BOT: u64 = 5; const FAR_BOT: u64 = 6; fn topology_after_vim_is_deleted() -> ( - std::sync::Arc, - std::sync::Arc, - std::sync::Arc, + std::sync::Arc, + std::sync::Arc, + std::sync::Arc, ShardCallLog, ) { let (realm, world, instances, calls) = party_topology(); @@ -35,35 +35,47 @@ fn topology_after_vim_is_deleted() -> ( 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"); - let deleted_from_instances = std::sync::Arc::new(InMemoryStore { - shard: "instances".into(), - calls: calls.clone(), - realm: Some(realm.clone()), - mirror: std::sync::Mutex::new(instances.mirror.lock().unwrap().clone()), + let deleted_from_instances = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "instances".into(), + calls: calls.clone(), + realm: Some(realm.clone()), + ..Default::default() + }, + party: PartyState { + mirror: std::sync::Mutex::new(instances.party.mirror.lock().unwrap().clone()), + ..Default::default() + }, ..Default::default() }); - *world.peers.lock().unwrap() = vec![world.clone(), deleted_from_instances.clone()]; - *deleted_from_instances.peers.lock().unwrap() = + *world.topology.peers.lock().unwrap() = vec![world.clone(), deleted_from_instances.clone()]; + *deleted_from_instances.topology.peers.lock().unwrap() = vec![world.clone(), deleted_from_instances.clone()]; (realm, world, deleted_from_instances, calls) } fn two_member_topology_after_vim_is_deleted() -> ( - std::sync::Arc, - std::sync::Arc, - std::sync::Arc, + std::sync::Arc, + std::sync::Arc, + std::sync::Arc, ) { let (realm, world, instances, calls) = party_topology(); form_split_party(&world, &instances); - let deleted_from_instances = std::sync::Arc::new(InMemoryStore { - shard: "instances".into(), - calls, - realm: Some(realm.clone()), - mirror: std::sync::Mutex::new(instances.mirror.lock().unwrap().clone()), + let deleted_from_instances = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "instances".into(), + calls, + realm: Some(realm.clone()), + ..Default::default() + }, + party: PartyState { + mirror: std::sync::Mutex::new(instances.party.mirror.lock().unwrap().clone()), + ..Default::default() + }, ..Default::default() }); - *world.peers.lock().unwrap() = vec![world.clone(), deleted_from_instances.clone()]; - *deleted_from_instances.peers.lock().unwrap() = + *world.topology.peers.lock().unwrap() = vec![world.clone(), deleted_from_instances.clone()]; + *deleted_from_instances.topology.peers.lock().unwrap() = vec![world.clone(), deleted_from_instances.clone()]; (realm, world, deleted_from_instances) } @@ -96,58 +108,92 @@ pub(super) fn party_topology_with( mirror_error: Option<&str>, accept_error: Option<&str>, ) -> ( - std::sync::Arc, // realm-core - std::sync::Arc, // the open-world shard - std::sync::Arc, // the instances shard + std::sync::Arc, // realm-core + std::sync::Arc, // the open-world shard + std::sync::Arc, // the instances shard ShardCallLog, ) { let calls: ShardCallLog = Default::default(); - let realm = std::sync::Arc::new(InMemoryStore { - shard: "lyracore-realm".into(), - calls: calls.clone(), - is_realm: true, - party_accept_error: accept_error.map(|e| e.to_string()), + let realm = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "lyracore-realm".into(), + calls: calls.clone(), + ..Default::default() + }, + party: PartyState { + is_realm: true, + party_accept_error: accept_error.map(|e| e.to_string()), + ..Default::default() + }, ..Default::default() }); - let world = std::sync::Arc::new(InMemoryStore { - shard: "world".into(), - calls: calls.clone(), - realm: Some(realm.clone()), + let world = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "world".into(), + calls: calls.clone(), + realm: Some(realm.clone()), + ..Default::default() + }, characters: vec![ character(GINGER, "Ginger"), character(TRIN, "Trin"), character(DORMANT, "Dormant"), character(BOT, "Botty"), ], - // `live_guids` = `game_world_entity`; `offline_guids` = `game_character.online == false`. - // The bot is in BOTH, which is the production shape a playerbot has (spawned straight into - // the entity table, never logged in) and the case the two gates disagree about. - live_guids: vec![GINGER, TRIN, BOT], - entity_partitions: std::sync::Mutex::new(vec![(GINGER, 0, 0), (TRIN, 0, 0), (BOT, 0, 0)]), - offline_guids: vec![DORMANT, BOT], + session: SessionState { + // `live_guids` = `game_world_entity`; `offline_guids` = `game_character.online == false`. + // The bot is in BOTH, which is the production shape a playerbot has (spawned straight into + // the entity table, never logged in) and the case the two gates disagree about. + live_guids: vec![GINGER, TRIN, BOT], + ..Default::default() + }, + party: PartyState { + entity_partitions: std::sync::Mutex::new(vec![ + (GINGER, 0, 0), + (TRIN, 0, 0), + (BOT, 0, 0), + ]), + ..Default::default() + }, + social: SocialState { + offline_guids: vec![DORMANT, BOT], + ..Default::default() + }, ..Default::default() }); - let instances = std::sync::Arc::new(InMemoryStore { - shard: "instances".into(), - calls: calls.clone(), - realm: Some(realm.clone()), + let instances = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "instances".into(), + calls: calls.clone(), + realm: Some(realm.clone()), + ..Default::default() + }, characters: vec![character(VIM, "Vim"), character(FAR_BOT, "Farbotty")], - live_guids: vec![VIM, FAR_BOT], - entity_partitions: std::sync::Mutex::new(vec![(VIM, 0, 0), (FAR_BOT, 0, 0)]), - offline_guids: vec![FAR_BOT], - mirror_error: mirror_error.map(|e| e.to_string()), + session: SessionState { + live_guids: vec![VIM, FAR_BOT], + ..Default::default() + }, + party: PartyState { + entity_partitions: std::sync::Mutex::new(vec![(VIM, 0, 0), (FAR_BOT, 0, 0)]), + mirror_error: mirror_error.map(|e| e.to_string()), + ..Default::default() + }, + social: SocialState { + offline_guids: vec![FAR_BOT], + ..Default::default() + }, ..Default::default() }); for shard in [&world, &instances] { - *shard.peers.lock().unwrap() = vec![world.clone(), instances.clone()]; + *shard.topology.peers.lock().unwrap() = vec![world.clone(), instances.clone()]; } (realm, world, instances, calls) } pub(super) fn party_topology() -> ( - std::sync::Arc, - std::sync::Arc, - std::sync::Arc, + std::sync::Arc, + std::sync::Arc, + std::sync::Arc, ShardCallLog, ) { party_topology_with(None, None) @@ -156,7 +202,7 @@ pub(super) fn party_topology() -> ( /// Form the split party the live run could not: Ginger (open world) invites Vim (inside Deadmines), /// 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: &InMemoryStore, instances: &InMemoryStore) { +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"); } @@ -198,9 +244,22 @@ fn a_companion_command_uses_realm_authority_and_the_bots_actual_world_shard() { let outcome = party::run_party_command_intent(world.as_ref(), &command_intent(FAR_BOT), 9001).unwrap(); assert_eq!(outcome, party::CompanionCommandOutcome::Applied); - assert_eq!(instances.admitted_party_commands.lock().unwrap().len(), 1); - assert!(world.admitted_party_commands.lock().unwrap().is_empty()); - assert_eq!(world.party_command_finishes.lock().unwrap().len(), 1); + assert_eq!( + instances + .party + .admitted_party_commands + .lock() + .unwrap() + .len(), + 1 + ); + assert!(world + .party + .admitted_party_commands + .lock() + .unwrap() + .is_empty()); + assert_eq!(world.party.party_command_finishes.lock().unwrap().len(), 1); assert!(realm.group_roster(GINGER).unwrap().is_some()); } @@ -208,8 +267,9 @@ fn a_companion_command_uses_realm_authority_and_the_bots_actual_world_shard() { 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(); - let group_id = realm.party.lock().unwrap().group_of(GINGER).unwrap(); + let group_id = realm.party.party.lock().unwrap().group_of(GINGER).unwrap(); realm + .party .party .lock() .unwrap() @@ -220,22 +280,44 @@ fn changed_leadership_is_terminal_before_target_application() { .1 = BOT; let outcome = party::run_party_command_intent(world.as_ref(), &command_intent(BOT), 9).unwrap(); assert_eq!(outcome, party::CompanionCommandOutcome::NotLeader); - assert!(world.admitted_party_commands.lock().unwrap().is_empty()); - assert_eq!(world.party_command_finishes.lock().unwrap()[0].2, outcome); + assert!(world + .party + .admitted_party_commands + .lock() + .unwrap() + .is_empty()); + assert_eq!( + world.party.party_command_finishes.lock().unwrap()[0].2, + outcome + ); } #[test] fn configured_realm_core_unavailability_fails_closed() { - let store = InMemoryStore { + let store = WorldFake { characters: vec![character(BOT, "Bot")], - entity_in_world: true, - entity_partitions: std::sync::Mutex::new(vec![(GINGER, 0, 0), (BOT, 0, 0)]), - party_command_realm_error: Some("Realm-core unavailable".into()), + session: SessionState { + entity_in_world: true, + ..Default::default() + }, + party: PartyState { + entity_partitions: std::sync::Mutex::new(vec![(GINGER, 0, 0), (BOT, 0, 0)]), + ..Default::default() + }, + topology: TopologyState { + party_command_realm_error: Some("Realm-core unavailable".into()), + ..Default::default() + }, ..Default::default() }; let error = party::run_party_command_intent(&store, &command_intent(BOT), 9).unwrap_err(); assert!(error.to_string().contains("Realm-core unavailable")); - assert!(store.party_command_finishes.lock().unwrap().is_empty()); + assert!(store + .party + .party_command_finishes + .lock() + .unwrap() + .is_empty()); } #[test] @@ -244,7 +326,12 @@ fn a_missing_bot_is_terminal_before_party_authority_is_consulted() { let outcome = party::run_party_command_intent(world.as_ref(), &command_intent(99_999), 9).unwrap(); assert_eq!(outcome, party::CompanionCommandOutcome::MissingBot); - assert!(world.admitted_party_commands.lock().unwrap().is_empty()); + assert!(world + .party + .admitted_party_commands + .lock() + .unwrap() + .is_empty()); } #[test] @@ -256,7 +343,12 @@ fn a_nonmember_assist_target_is_refused_by_realm_authority() { intent.authority_member_guid = TRIN; let outcome = party::run_party_command_intent(world.as_ref(), &intent, 9).unwrap(); assert_eq!(outcome, party::CompanionCommandOutcome::NotMember); - assert!(world.admitted_party_commands.lock().unwrap().is_empty()); + assert!(world + .party + .admitted_party_commands + .lock() + .unwrap() + .is_empty()); } #[test] @@ -264,6 +356,7 @@ 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(); world + .party .mirror .lock() .unwrap() @@ -271,24 +364,35 @@ fn a_stale_target_mirror_cannot_grant_command_authority() { .for_each(|roster| roster.members.retain(|member| member.guid != BOT)); let outcome = party::run_party_command_intent(world.as_ref(), &command_intent(BOT), 9).unwrap(); assert_eq!(outcome, party::CompanionCommandOutcome::StalePartyMirror); - assert!(world.admitted_party_commands.lock().unwrap().is_empty()); + assert!(world + .party + .admitted_party_commands + .lock() + .unwrap() + .is_empty()); } /// A local roster of `extra + 2` members: Ginger leads, and the bot takes Companion Orders. -fn store_with_roster_of(extra: usize) -> InMemoryStore { +fn store_with_roster_of(extra: usize) -> WorldFake { let mut members = vec![GINGER, BOT]; members.extend((0..extra).map(|offset| 80_000 + offset as u64)); - InMemoryStore { - entity_in_world: true, + WorldFake { + session: SessionState { + entity_in_world: true, + ..Default::default() + }, characters: vec![character(GINGER, "Ginger"), character(BOT, "Bot")], - entity_partitions: std::sync::Mutex::new(vec![(GINGER, 0, 0), (BOT, 0, 0)]), - mirror: std::sync::Mutex::new(vec![party::GroupRoster { - group_id: 7, - leader_guid: GINGER, - kind: GroupKind::Raid, - members: party_members(&members), + party: PartyState { + entity_partitions: std::sync::Mutex::new(vec![(GINGER, 0, 0), (BOT, 0, 0)]), + mirror: std::sync::Mutex::new(vec![party::GroupRoster { + group_id: 7, + leader_guid: GINGER, + kind: GroupKind::Raid, + members: party_members(&members), + ..Default::default() + }]), ..Default::default() - }]), + }, ..Default::default() } } @@ -302,7 +406,7 @@ fn a_companion_order_in_a_ten_member_raid_reaches_target_application() { let store = store_with_roster_of(8); let named_member = 80_007; { - let mut mirror = store.mirror.lock().unwrap(); + let mut mirror = store.party.mirror.lock().unwrap(); for member in &mut mirror[0].members { let subgroup = match member.guid { GINGER | 80_000..=80_003 => 0, @@ -313,6 +417,7 @@ fn a_companion_order_in_a_ten_member_raid_reaches_target_application() { } } store + .party .entity_partitions .lock() .unwrap() @@ -324,12 +429,17 @@ fn a_companion_order_in_a_ten_member_raid_reaches_target_application() { from_a_member.issuer_guid = 80_000; let refused = party::run_party_command_intent(&store, &from_a_member, 9).unwrap(); assert_eq!(refused, party::CompanionCommandOutcome::NotLeader); - assert!(store.admitted_party_commands.lock().unwrap().is_empty()); + assert!(store + .party + .admitted_party_commands + .lock() + .unwrap() + .is_empty()); let outcome = party::run_party_command_intent(&store, &intent, 9).unwrap(); assert_eq!(outcome, party::CompanionCommandOutcome::Applied); - let admitted = store.admitted_party_commands.lock().unwrap(); + let admitted = store.party.admitted_party_commands.lock().unwrap(); assert_eq!(admitted.len(), 1); let mut certified = admitted[0].members.clone(); certified.sort_unstable(); @@ -349,8 +459,18 @@ fn a_roster_longer_than_a_raid_is_not_sent_for_command_authority() { let error = party::run_party_command_intent(&store, &command_intent(BOT), 9).unwrap_err(); assert!(error.to_string().contains("member limit")); - assert!(store.party_command_finishes.lock().unwrap().is_empty()); - assert!(store.admitted_party_commands.lock().unwrap().is_empty()); + assert!(store + .party + .party_command_finishes + .lock() + .unwrap() + .is_empty()); + assert!(store + .party + .admitted_party_commands + .lock() + .unwrap() + .is_empty()); } #[test] @@ -360,28 +480,43 @@ fn realm_admission_rejects_a_roster_changed_after_the_gateway_read() { party::run(world.as_ref(), 8, GINGER, party::Op::Invite(FAR_BOT)).unwrap(); let mut changed = realm.group_roster(GINGER).unwrap().unwrap().member_guids(); changed.retain(|guid| *guid != BOT); - *realm.party_command_authority_members.lock().unwrap() = Some(changed); + *realm.party.party_command_authority_members.lock().unwrap() = Some(changed); let outcome = party::run_party_command_intent(world.as_ref(), &command_intent(FAR_BOT), 9).unwrap(); assert_eq!(outcome, party::CompanionCommandOutcome::StalePartyMirror); - assert!(instances.admitted_party_commands.lock().unwrap().is_empty()); + assert!(instances + .party + .admitted_party_commands + .lock() + .unwrap() + .is_empty()); } #[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(); - *instances.party_command_receipt_error.lock().unwrap() = + *instances.party.party_command_receipt_error.lock().unwrap() = Some("receipt read unavailable".to_string()); let error = party::run_party_command_intent(world.as_ref(), &command_intent(FAR_BOT), 9).unwrap_err(); assert!(error.to_string().contains("receipt read unavailable")); - assert!(world.party_command_finishes.lock().unwrap().is_empty()); - assert!(instances.admitted_party_commands.lock().unwrap().is_empty()); + assert!(world + .party + .party_command_finishes + .lock() + .unwrap() + .is_empty()); + assert!(instances + .party + .admitted_party_commands + .lock() + .unwrap() + .is_empty()); } #[test] @@ -389,6 +524,7 @@ 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(); instances + .party .party_command_in_transit .lock() .unwrap() @@ -398,8 +534,18 @@ fn an_in_transit_holder_is_retried_without_a_missing_bot_result() { party::run_party_command_intent(world.as_ref(), &command_intent(FAR_BOT), 9).unwrap_err(); assert!(error.to_string().contains("Transfer")); - assert!(world.party_command_finishes.lock().unwrap().is_empty()); - assert!(instances.admitted_party_commands.lock().unwrap().is_empty()); + assert!(world + .party + .party_command_finishes + .lock() + .unwrap() + .is_empty()); + assert!(instances + .party + .admitted_party_commands + .lock() + .unwrap() + .is_empty()); } #[test] @@ -407,6 +553,7 @@ 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(); instances + .party .entity_partitions .lock() .unwrap() @@ -417,7 +564,12 @@ fn a_remote_party_member_cannot_direct_a_bot_in_another_partition() { let outcome = party::run_party_command_intent(world.as_ref(), &command_intent(FAR_BOT), 9).unwrap(); assert_eq!(outcome, party::CompanionCommandOutcome::WrongPartition); - assert!(instances.admitted_party_commands.lock().unwrap().is_empty()); + assert!(instances + .party + .admitted_party_commands + .lock() + .unwrap() + .is_empty()); } #[test] @@ -425,6 +577,7 @@ 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(); world + .party .entity_partitions .lock() .unwrap() @@ -433,7 +586,12 @@ fn a_bot_without_a_partition_cannot_reach_target_application() { let outcome = party::run_party_command_intent(world.as_ref(), &command_intent(BOT), 9).unwrap(); assert_eq!(outcome, party::CompanionCommandOutcome::WrongPartition); - assert!(world.admitted_party_commands.lock().unwrap().is_empty()); + assert!(world + .party + .admitted_party_commands + .lock() + .unwrap() + .is_empty()); } #[test] @@ -443,6 +601,7 @@ fn an_assist_member_without_a_partition_cannot_reach_target_application() { party::run(world.as_ref(), 8, GINGER, party::Op::Invite(TRIN)).unwrap(); party::run(world.as_ref(), 9, TRIN, party::Op::Accept).unwrap(); world + .party .entity_partitions .lock() .unwrap() @@ -454,7 +613,12 @@ fn an_assist_member_without_a_partition_cannot_reach_target_application() { let outcome = party::run_party_command_intent(world.as_ref(), &intent, 10).unwrap(); assert_eq!(outcome, party::CompanionCommandOutcome::WrongPartition); - assert!(world.admitted_party_commands.lock().unwrap().is_empty()); + assert!(world + .party + .admitted_party_commands + .lock() + .unwrap() + .is_empty()); } #[test] @@ -465,16 +629,22 @@ fn an_unsharded_gateway_uses_the_owning_local_party_authority() { members: party_members(&[GINGER, BOT]), ..Default::default() }; - let store = InMemoryStore { + let store = WorldFake { characters: vec![character(BOT, "Bot")], - entity_in_world: true, - entity_partitions: std::sync::Mutex::new(vec![(GINGER, 0, 0), (BOT, 0, 0)]), - mirror: std::sync::Mutex::new(vec![roster]), + session: SessionState { + entity_in_world: true, + ..Default::default() + }, + party: PartyState { + entity_partitions: std::sync::Mutex::new(vec![(GINGER, 0, 0), (BOT, 0, 0)]), + mirror: std::sync::Mutex::new(vec![roster]), + ..Default::default() + }, ..Default::default() }; let outcome = party::run_party_command_intent(&store, &command_intent(BOT), 9).unwrap(); assert_eq!(outcome, party::CompanionCommandOutcome::Applied); - assert_eq!(store.admitted_party_commands.lock().unwrap().len(), 1); + assert_eq!(store.party.admitted_party_commands.lock().unwrap().len(), 1); } #[test] @@ -483,6 +653,7 @@ fn a_target_receipt_finishes_a_crashed_attempt_without_reapplying() { party::run(world.as_ref(), 7, GINGER, party::Op::Invite(FAR_BOT)).unwrap(); let intent = command_intent(FAR_BOT); let authority = world + .topology .realm .as_ref() .unwrap() @@ -510,13 +681,21 @@ fn a_target_receipt_finishes_a_crashed_attempt_without_reapplying() { ); let outcome = party::run_party_command_intent(world.as_ref(), &intent, 10).unwrap(); assert_eq!(outcome, party::CompanionCommandOutcome::Applied); - assert_eq!(instances.admitted_party_commands.lock().unwrap().len(), 1); - assert_eq!(world.party_command_finishes.lock().unwrap().len(), 1); + assert_eq!( + instances + .party + .admitted_party_commands + .lock() + .unwrap() + .len(), + 1 + ); + assert_eq!(world.party.party_command_finishes.lock().unwrap().len(), 1); } #[test] fn an_expired_intent_past_the_receipt_guarantee_reports_unknown() { - let store = InMemoryStore::default(); + let store = WorldFake::default(); let mut intent = command_intent(BOT); intent.expires_micros = 0; @@ -524,7 +703,7 @@ fn an_expired_intent_past_the_receipt_guarantee_reports_unknown() { assert_eq!(outcome, party::CompanionCommandOutcome::OutcomeUnknown); assert_eq!( - store.party_command_finishes.lock().unwrap()[0], + store.party.party_command_finishes.lock().unwrap()[0], ( intent.id, 55, @@ -548,8 +727,19 @@ fn equal_numeric_intents_from_distinct_modules_have_distinct_receipts() { party::run_party_command_intent(world.as_ref(), &second, 12).unwrap(), party::CompanionCommandOutcome::Applied ); - assert_eq!(instances.admitted_party_commands.lock().unwrap().len(), 2); - assert_eq!(instances.party_command_receipts.lock().unwrap().len(), 2); + assert_eq!( + instances + .party + .admitted_party_commands + .lock() + .unwrap() + .len(), + 2 + ); + assert_eq!( + instances.party.party_command_receipts.lock().unwrap().len(), + 2 + ); } /// **AC: an invite works across a shard boundary.** @@ -588,7 +778,7 @@ fn a_cross_shard_invite_and_accept_form_one_party_on_realm_core() { let (realm, world, instances, calls) = party_topology(); form_split_party(&world, &instances); - let party_state = realm.party.lock().unwrap(); + let party_state = realm.party.party.lock().unwrap(); let group_id = party_state.group_of(GINGER).expect("Ginger is in a party"); assert_eq!( party_state.group_of(VIM), @@ -671,7 +861,7 @@ fn every_world_shard_mirrors_the_authoritative_roster_after_a_party_op() { for (name, shard) in [("world", &world), ("instances", &instances)] { assert_eq!( - shard.mirror.lock().unwrap().clone(), + shard.party.mirror.lock().unwrap().clone(), vec![authoritative.clone()], "{name} does not mirror realm-core's roster. Every in-world membership read on that \ shard — the kill-XP split, quest credit, loot rules, the party's dungeon binding — \ @@ -686,7 +876,7 @@ fn a_disbanded_party_is_tombstoned_on_every_world_shard() { let (realm, world, instances, _calls) = party_topology(); form_split_party(&world, &instances); assert!( - !world.mirror.lock().unwrap().is_empty(), + !world.party.mirror.lock().unwrap().is_empty(), "precondition: the party is mirrored" ); @@ -699,7 +889,7 @@ fn a_disbanded_party_is_tombstoned_on_every_world_shard() { ); for (name, shard) in [("world", &world), ("instances", &instances)] { assert!( - shard.mirror.lock().unwrap().is_empty(), + shard.party.mirror.lock().unwrap().is_empty(), "{name} still mirrors a party that realm-core has disbanded — this is the orphaned \ unexpected shard-local `game_group` row" ); @@ -727,7 +917,7 @@ fn leaving_a_party_re_pushes_the_roster_of_the_group_the_leaver_left() { assert_eq!(remaining.member_guids(), vec![GINGER, TRIN]); for (name, shard) in [("world", &world), ("instances", &instances)] { assert_eq!( - shard.mirror.lock().unwrap().clone(), + shard.party.mirror.lock().unwrap().clone(), vec![remaining.clone()], "{name} still lists the member who left — the mirror push must cover the group the \ actor was in BEFORE the op, not only the one they are in after it" @@ -739,13 +929,19 @@ fn leaving_a_party_re_pushes_the_roster_of_the_group_the_leaver_left() { fn an_uncertain_character_read_preserves_realm_core_membership() { let (realm, world, instances, _calls) = party_topology(); form_split_party(&world, &instances); - let uncertain_instances = std::sync::Arc::new(InMemoryStore { - shard: "instances".into(), - realm: Some(realm.clone()), - character_read_error: Some("subscription unavailable".into()), + let uncertain_instances = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "instances".into(), + realm: Some(realm.clone()), + ..Default::default() + }, + character: CharacterState { + character_read_error: Some("subscription unavailable".into()), + ..Default::default() + }, ..Default::default() }); - *world.peers.lock().unwrap() = vec![world.clone(), uncertain_instances]; + *world.topology.peers.lock().unwrap() = vec![world.clone(), uncertain_instances]; let error = party::cleanup_deleted_character(world.as_ref(), VIM) .expect_err("unknown absence must stop cleanup"); @@ -770,7 +966,7 @@ fn a_deleted_member_leaves_realm_core_and_both_shards_receive_the_surviving_rost assert_eq!(survivors.member_guids(), vec![GINGER, TRIN]); for shard in [&world, &instances] { assert_eq!( - shard.mirror.lock().unwrap().as_slice(), + shard.party.mirror.lock().unwrap().as_slice(), std::slice::from_ref(&survivors) ); } @@ -794,11 +990,14 @@ fn a_character_visible_on_the_destination_shard_keeps_party_membership() { fn an_unavailable_configured_shard_preserves_realm_core_membership() { let (realm, world, instances, calls) = party_topology(); form_split_party(&world, &instances); - let incomplete = InMemoryStore { - shard: "world".into(), - calls, - realm: Some(realm.clone()), - world_shard_set_error: Some("instances has no healthy Coordinator subscription".into()), + let incomplete = WorldFake { + topology: TopologyState { + shard: "world".into(), + calls, + realm: Some(realm.clone()), + world_shard_set_error: Some("instances has no healthy Coordinator subscription".into()), + ..Default::default() + }, ..Default::default() }; @@ -813,11 +1012,14 @@ fn an_unavailable_configured_shard_preserves_realm_core_membership() { fn an_unavailable_realm_core_preserves_party_membership() { let (realm, world, instances, calls) = party_topology(); form_split_party(&world, &instances); - let unavailable = InMemoryStore { - shard: "world".into(), - calls, - realm: Some(realm.clone()), - party_cleanup_realm_error: Some("Realm-core is not connected".into()), + let unavailable = WorldFake { + topology: TopologyState { + shard: "world".into(), + calls, + realm: Some(realm.clone()), + party_cleanup_realm_error: Some("Realm-core is not connected".into()), + ..Default::default() + }, ..Default::default() }; @@ -855,7 +1057,7 @@ fn a_deleted_characters_leave_tells_the_authority_it_was_deleted() { party::cleanup_deleted_character(world.as_ref(), VIM).unwrap(); assert_eq!( - realm.party.lock().unwrap().ops.last().copied(), + realm.party.party.lock().unwrap().ops.last().copied(), Some((realm_op::LEAVE, VIM, 0, 1, 0, 0)), "LEAVE with arg_a = CHARACTER_DELETED" ); @@ -866,6 +1068,7 @@ fn a_deleted_characters_leave_tells_the_authority_it_was_deleted() { fn deleted_character_cleanup_retries_a_transient_realm_core_leave_failure() { let (realm, world, _instances, _calls) = topology_after_vim_is_deleted(); realm + .party .party_leave_failures .store(2, std::sync::atomic::Ordering::SeqCst); @@ -876,6 +1079,7 @@ fn deleted_character_cleanup_retries_a_transient_realm_core_leave_failure() { assert_eq!(realm.group_roster(VIM).unwrap(), None); assert_eq!( realm + .party .party .lock() .unwrap() @@ -891,6 +1095,7 @@ fn deleted_character_cleanup_retries_a_transient_realm_core_leave_failure() { fn a_lost_realm_core_leave_reply_still_refreshes_the_surviving_party() { let (realm, world, instances, _calls) = topology_after_vim_is_deleted(); realm + .party .party_leave_commit_then_error .store(true, std::sync::atomic::Ordering::SeqCst); @@ -903,7 +1108,7 @@ fn a_lost_realm_core_leave_reply_still_refreshes_the_surviving_party() { assert_eq!(survivors.member_guids(), vec![GINGER, TRIN]); for shard in [&world, &instances] { assert_eq!( - shard.mirror.lock().unwrap().as_slice(), + shard.party.mirror.lock().unwrap().as_slice(), std::slice::from_ref(&survivors) ); } @@ -913,6 +1118,7 @@ fn a_lost_realm_core_leave_reply_still_refreshes_the_surviving_party() { fn deleted_character_cleanup_retries_a_transient_mirror_failure() { let (realm, world, instances, calls) = topology_after_vim_is_deleted(); instances + .party .mirror_failures .store(2, std::sync::atomic::Ordering::SeqCst); calls.lock().unwrap().clear(); @@ -924,7 +1130,7 @@ fn deleted_character_cleanup_retries_a_transient_mirror_failure() { let survivors = realm.group_roster(GINGER).unwrap().unwrap(); assert_eq!( - instances.mirror.lock().unwrap().as_slice(), + instances.party.mirror.lock().unwrap().as_slice(), std::slice::from_ref(&survivors) ); assert_eq!( @@ -942,6 +1148,7 @@ fn deleted_character_cleanup_retries_a_transient_mirror_failure() { fn reconciliation_repairs_a_mirror_left_stale_after_membership_cleanup() { let (realm, world, instances) = two_member_topology_after_vim_is_deleted(); instances + .party .mirror_failures .store(7, std::sync::atomic::Ordering::SeqCst); @@ -951,6 +1158,7 @@ fn reconciliation_repairs_a_mirror_left_stale_after_membership_cleanup() { assert_eq!(realm.group_roster(VIM).unwrap(), None); assert_eq!(realm.party_group_ids().unwrap(), Vec::::new()); assert!(instances + .party .mirror .lock() .unwrap() @@ -959,17 +1167,18 @@ fn reconciliation_repairs_a_mirror_left_stale_after_membership_cleanup() { party::reconcile_deleted_character_parties(world.as_ref()) .expect_err("the first reconciliation exhausts its bounded mirror retries"); - assert!(!instances.mirror.lock().unwrap().is_empty()); + assert!(!instances.party.mirror.lock().unwrap().is_empty()); party::reconcile_deleted_character_parties(world.as_ref()).unwrap(); - assert!(instances.mirror.lock().unwrap().is_empty()); + assert!(instances.party.mirror.lock().unwrap().is_empty()); } #[test] fn deleted_character_cleanup_stops_after_three_realm_core_leave_failures() { let (realm, world, _instances, _calls) = topology_after_vim_is_deleted(); realm + .party .party_leave_failures .store(4, std::sync::atomic::Ordering::SeqCst); @@ -980,6 +1189,7 @@ fn deleted_character_cleanup_stops_after_three_realm_core_leave_failures() { assert!(realm.group_roster(VIM).unwrap().is_some()); assert_eq!( realm + .party .party .lock() .unwrap() @@ -995,9 +1205,10 @@ fn deleted_character_cleanup_stops_after_three_realm_core_leave_failures() { fn roster_confirmation_failure_preserves_the_realm_core_leave_error() { let (realm, world, _instances, _calls) = topology_after_vim_is_deleted(); realm + .party .party_leave_failures .store(3, std::sync::atomic::Ordering::SeqCst); - *realm.group_roster_error_on_read.lock().unwrap() = + *realm.party.group_roster_error_on_read.lock().unwrap() = Some((2, "Realm-core roster confirmation unavailable".into())); let error = party::cleanup_deleted_character(world.as_ref(), VIM) @@ -1014,8 +1225,8 @@ fn roster_confirmation_failure_preserves_the_realm_core_leave_error() { fn reconciliation_continues_when_one_shard_cannot_enumerate_mirrored_groups() { let (realm, world, instances, _calls) = party_topology(); form_split_party(&world, &instances); - world.mirror.lock().unwrap().clear(); - *instances.party_group_ids_error.lock().unwrap() = + world.party.mirror.lock().unwrap().clear(); + *instances.party.party_group_ids_error.lock().unwrap() = Some("instances party subscription unavailable".into()); let error = party::reconcile_deleted_character_parties(world.as_ref()) @@ -1026,7 +1237,7 @@ fn reconciliation_continues_when_one_shard_cannot_enumerate_mirrored_groups() { assert!(diagnostic.contains("instances party subscription unavailable")); let authoritative = realm.group_roster(GINGER).unwrap().unwrap(); assert_eq!( - world.mirror.lock().unwrap().as_slice(), + world.party.mirror.lock().unwrap().as_slice(), std::slice::from_ref(&authoritative), "the known realm-core group must still repair healthy Shard mirrors" ); @@ -1034,8 +1245,11 @@ fn reconciliation_continues_when_one_shard_cannot_enumerate_mirrored_groups() { #[test] fn unsharded_deleted_character_cleanup_stays_on_the_module_sweep() { - let store = InMemoryStore { - shard: "world".into(), + let store = WorldFake { + topology: TopologyState { + shard: "world".into(), + ..Default::default() + }, ..Default::default() }; @@ -1043,7 +1257,7 @@ fn unsharded_deleted_character_cleanup_stays_on_the_module_sweep() { party::cleanup_deleted_character(&store, VIM).unwrap(), party::DeletedCharacterPartyCleanup::AlreadyClean ); - assert!(store.calls.lock().unwrap().is_empty()); + assert!(store.topology.calls.lock().unwrap().is_empty()); } #[test] @@ -1055,7 +1269,7 @@ fn reconnect_reconciliation_removes_a_member_whose_delete_event_was_missed() { let survivors = realm.group_roster(GINGER).unwrap().unwrap(); assert_eq!(survivors.member_guids(), vec![GINGER, TRIN]); assert_eq!( - instances.mirror.lock().unwrap().as_slice(), + instances.party.mirror.lock().unwrap().as_slice(), std::slice::from_ref(&survivors) ); } @@ -1063,7 +1277,7 @@ fn reconnect_reconciliation_removes_a_member_whose_delete_event_was_missed() { #[test] fn deleted_character_cleanup_flushes_pending_loot_before_leaving_realm_core() { let (_realm, world, instances, calls) = topology_after_vim_is_deleted(); - *instances.pending_rolls.lock().unwrap() = vec![loot::PendingLootRoll { + *instances.loot_roll.pending_rolls.lock().unwrap() = vec![loot::PendingLootRoll { promotion_source: spacetimedb_sdk::Identity::from_byte_array([7; 32]), roll_id: 40, corpse_guid: 90, @@ -1106,11 +1320,17 @@ fn deleted_character_cleanup_flushes_pending_loot_before_leaving_realm_core() { #[test] fn an_unsharded_gateway_runs_every_party_op_on_the_players_own_shard() { let calls: ShardCallLog = Default::default(); - let store = std::sync::Arc::new(InMemoryStore { - shard: "world".into(), - calls: calls.clone(), + let store = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "world".into(), + calls: calls.clone(), + ..Default::default() + }, characters: vec![character(VIM, "Vim")], - live_guids: vec![VIM], + session: SessionState { + live_guids: vec![VIM], + ..Default::default() + }, ..Default::default() // no `realm`, no `peers` — the unconfigured gateway }); assert!( @@ -1159,11 +1379,14 @@ fn an_unsharded_gateway_runs_every_party_op_on_the_players_own_shard() { "the realm plane and the mirror must be untouched on a single-database gateway" ); assert_eq!( - *store.group_invites.lock().unwrap(), + *store.party.group_invites.lock().unwrap(), vec![VIM], "with the same arguments as before" ); - assert_eq!(*store.group_loot_methods.lock().unwrap(), vec![(2, VIM, 3)]); + assert_eq!( + *store.party.group_loot_methods.lock().unwrap(), + vec![(2, VIM, 3)] + ); } /// World ENTRY is what carries a party across the boundary now that the character-transfer blob's @@ -1175,14 +1398,14 @@ fn world_entry_pushes_the_authoritative_roster_onto_the_shard_the_player_arrives form_split_party(&world, &instances); // A fresh instances shard: the character arrived through a transfer, so it has NO party rows — // exactly the state `import_character_blob` leaves now that membership does not ride the blob. - instances.mirror.lock().unwrap().clear(); + 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"); let authoritative = realm.group_roster(VIM).unwrap().unwrap(); assert_eq!( - instances.mirror.lock().unwrap().clone(), + instances.party.mirror.lock().unwrap().clone(), vec![authoritative], "the arriving shard must be given the party the character is ACTUALLY in — the blob no \ longer carries membership, so this push is the only thing that makes it whole" @@ -1209,8 +1432,11 @@ fn world_entry_is_a_no_op_for_an_ungrouped_character_and_on_a_single_database() "nothing to mirror for a character in no party" ); - let solo = std::sync::Arc::new(InMemoryStore { - shard: "world".into(), + let solo = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "world".into(), + ..Default::default() + }, ..Default::default() }); let (tx2, rx2) = crate::world::SessionTx::with_depth(0); @@ -1242,7 +1468,7 @@ fn world_entry_clears_a_mirror_that_still_lists_a_character_the_authority_droppe // Vim leaves while the instances shard is unreachable: the authority commits, that shard's push // is dropped on the floor (the documented best-effort ceiling), so its mirror still lists Vim. { - let mut p = realm.party.lock().unwrap(); + let mut p = realm.party.party.lock().unwrap(); p.members.retain(|(_, g)| *g != VIM); } assert!( @@ -1289,7 +1515,7 @@ fn an_invite_to_a_missing_or_offline_target_never_reaches_realm_core() { ); assert!( - realm.party.lock().unwrap().ops.is_empty(), + realm.party.party.lock().unwrap().ops.is_empty(), "a refused invite must not reach the authority — the gate runs in the gateway precisely \ because realm-core has neither characters nor live entities to run it against" ); @@ -1314,30 +1540,48 @@ fn orc(guid: u64, name: &str) -> codec::CharacterView { #[test] fn a_cross_faction_invite_is_refused_before_realm_core() { let calls: ShardCallLog = Default::default(); - let realm = std::sync::Arc::new(InMemoryStore { - shard: "lyracore-realm".into(), - calls: calls.clone(), - is_realm: true, + let realm = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "lyracore-realm".into(), + calls: calls.clone(), + ..Default::default() + }, + party: PartyState { + is_realm: true, + ..Default::default() + }, ..Default::default() }); - let world = std::sync::Arc::new(InMemoryStore { - shard: "world".into(), - calls: calls.clone(), - realm: Some(realm.clone()), + let world = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "world".into(), + calls: calls.clone(), + realm: Some(realm.clone()), + ..Default::default() + }, characters: vec![character(GINGER, "Ginger")], - live_guids: vec![GINGER], + session: SessionState { + live_guids: vec![GINGER], + ..Default::default() + }, ..Default::default() }); - let horde = std::sync::Arc::new(InMemoryStore { - shard: "horde".into(), - calls: calls.clone(), - realm: Some(realm.clone()), + let horde = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "horde".into(), + calls: calls.clone(), + realm: Some(realm.clone()), + ..Default::default() + }, characters: vec![orc(GRUNT, "Grunt"), orc(SLEEPING_GRUNT, "Sleeper")], - live_guids: vec![GRUNT], + session: SessionState { + live_guids: vec![GRUNT], + ..Default::default() + }, ..Default::default() }); for shard in [&world, &horde] { - *shard.peers.lock().unwrap() = vec![world.clone(), horde.clone()]; + *shard.topology.peers.lock().unwrap() = vec![world.clone(), horde.clone()]; } assert_eq!( @@ -1350,7 +1594,7 @@ fn a_cross_faction_invite_is_refused_before_realm_core() { "an offline target is refused as offline first, as vanilla's online lookup does" ); assert!( - realm.party.lock().unwrap().ops.is_empty(), + realm.party.party.lock().unwrap().ops.is_empty(), "a cross-faction invite must not reach the authority" ); } @@ -1359,11 +1603,17 @@ fn a_cross_faction_invite_is_refused_before_realm_core() { #[test] fn an_unsharded_gateway_refuses_a_cross_faction_invite_too() { let calls: ShardCallLog = Default::default(); - let store = std::sync::Arc::new(InMemoryStore { - shard: "world".into(), - calls: calls.clone(), + let store = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "world".into(), + calls: calls.clone(), + ..Default::default() + }, characters: vec![character(GINGER, "Ginger"), orc(GRUNT, "Grunt")], - live_guids: vec![GINGER, GRUNT], + session: SessionState { + live_guids: vec![GINGER, GRUNT], + ..Default::default() + }, ..Default::default() }); assert_eq!( @@ -1371,7 +1621,7 @@ fn an_unsharded_gateway_refuses_a_cross_faction_invite_too() { PartyOutcome::Refused(GroupRefusal::WrongFaction) ); assert!( - store.group_invites.lock().unwrap().is_empty(), + store.party.group_invites.lock().unwrap().is_empty(), "the player-facing invite reducer must not run" ); } @@ -1403,7 +1653,7 @@ fn a_playerbot_is_invitable_because_the_online_gate_reads_the_entity_not_the_ses 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"); assert_eq!( - realm.party.lock().unwrap().ops.first().copied(), + realm.party.party.lock().unwrap().ops.first().copied(), Some(( lyracore_shared::group::realm_op::INVITE, GINGER, @@ -1428,7 +1678,7 @@ fn a_players_invite_to_a_session_less_bot_is_answered_by_the_bot_itself() { party::run(world.as_ref(), 7, GINGER, party::Op::Invite(BOT)).expect("the invite lands"); - let party_state = realm.party.lock().unwrap(); + let party_state = realm.party.party.lock().unwrap(); let group_id = party_state .group_of(GINGER) .expect("the invite formed Ginger's party"); @@ -1461,31 +1711,49 @@ fn a_stale_character_row_on_another_shard_cannot_make_a_logged_in_player_look_se // `instances` carries the stale seed copy (offline)… let mut i_chars = instances.characters.clone(); i_chars.push(character(SEEDED, "Tester")); - let far = std::sync::Arc::new(InMemoryStore { - shard: "instances".into(), - realm: Some(realm.clone()), + let far = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "instances".into(), + realm: Some(realm.clone()), + ..Default::default() + }, characters: i_chars, - live_guids: vec![VIM, FAR_BOT], - offline_guids: vec![FAR_BOT, SEEDED], + session: SessionState { + live_guids: vec![VIM, FAR_BOT], + ..Default::default() + }, + social: SocialState { + offline_guids: vec![FAR_BOT, SEEDED], + ..Default::default() + }, ..Default::default() }); // …while `world` holds the real, LOGGED-IN character and its live entity. let mut w_chars = world.characters.clone(); w_chars.push(character(SEEDED, "Tester")); - let home = std::sync::Arc::new(InMemoryStore { - shard: "world".into(), - realm: Some(realm.clone()), + let home = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "world".into(), + realm: Some(realm.clone()), + ..Default::default() + }, characters: w_chars, - live_guids: vec![GINGER, TRIN, BOT, SEEDED], - offline_guids: vec![DORMANT, BOT], + session: SessionState { + live_guids: vec![GINGER, TRIN, BOT, SEEDED], + ..Default::default() + }, + social: SocialState { + offline_guids: vec![DORMANT, BOT], + ..Default::default() + }, ..Default::default() }); for shard in [&home, &far] { - *shard.peers.lock().unwrap() = vec![home.clone(), far.clone()]; + *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"); - let state = realm.party.lock().unwrap(); + let state = realm.party.party.lock().unwrap(); assert_eq!( state.ops.clone(), vec![( @@ -1516,6 +1784,7 @@ 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"); let group_id = realm + .party .party .lock() .unwrap() @@ -1559,7 +1828,7 @@ 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"); - let state = realm.party.lock().unwrap(); + let state = realm.party.party.lock().unwrap(); let group_id = state.group_of(GINGER).expect("Ginger's party formed"); assert_eq!( state.roster(group_id).unwrap().member_guids(), @@ -1577,7 +1846,7 @@ fn a_real_players_invite_dialog_is_left_for_their_own_client_to_answer() { let (realm, world, _instances, _calls) = party_topology(); party::run(world.as_ref(), 7, GINGER, party::Op::Invite(TRIN)).expect("the invite lands"); - let state = realm.party.lock().unwrap(); + let state = realm.party.party.lock().unwrap(); assert_eq!( state.ops.clone(), vec![(realm_op::INVITE, GINGER, TRIN, 0, 0, 0)], @@ -1610,7 +1879,7 @@ fn a_bot_that_cannot_join_declines_out_loud_instead_of_leaving_the_dialog_hangin 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"); - let state = realm.party.lock().unwrap(); + let state = realm.party.party.lock().unwrap(); assert_eq!( state.ops.clone(), vec![ @@ -1642,7 +1911,7 @@ 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"); assert!( - realm.party.lock().unwrap().group_of(BOT).is_some(), + realm.party.party.lock().unwrap().group_of(BOT).is_some(), "joined already" ); assert!( @@ -1681,7 +1950,7 @@ fn every_party_op_reaches_realm_core_in_its_declared_argument_slots() { party::run(world.as_ref(), 7, GINGER, party::Op::Uninvite(VIM)).expect("kick"); assert_eq!( - realm.party.lock().unwrap().ops.clone(), + realm.party.party.lock().unwrap().ops.clone(), vec![ // INVITE: the target rides `target_guid`, nothing else is used. (realm_op::INVITE, GINGER, VIM, 0, 0, 0), @@ -1705,26 +1974,32 @@ fn a_real_session_syncs_its_party_at_login_and_routes_an_invite_to_realm_core() let (realm, _world, _instances, calls) = party_topology(); // The session's own shard, wired into the same realm + peer set as the topology's `world`, // plus what `run_world_session` needs to handshake and enter the world as Ginger. - let session_shard = std::sync::Arc::new(InMemoryStore { - shard: "world".into(), - calls: calls.clone(), - username: "TESTER".into(), - session: Some(WorldSession { - account_id: 7, - session_key: K, - }), - login_entity: Some(warrior_entity()), - realm: Some(realm.clone()), + let session_shard = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "world".into(), + calls: calls.clone(), + realm: Some(realm.clone()), + ..Default::default() + }, + session: SessionState { + username: "TESTER".into(), + session: Some(WorldSession { + account_id: 7, + session_key: K, + }), + login_entity: Some(warrior_entity()), + live_guids: vec![GINGER, VIM], + ..Default::default() + }, characters: vec![character(GINGER, "Ginger"), character(VIM, "Vim")], - live_guids: vec![GINGER, VIM], ..Default::default() }); - *session_shard.peers.lock().unwrap() = vec![session_shard.clone()]; + *session_shard.topology.peers.lock().unwrap() = vec![session_shard.clone()]; // Ginger is ALREADY in a party on realm-core when they log in — a party formed while they were // on the loading screen, which is exactly what the deleted character-transfer blob mirror could // never carry. { - let mut p = realm.party.lock().unwrap(); + let mut p = realm.party.party.lock().unwrap(); p.next_group_id = 5; p.groups.push((5, GINGER, 3, 2, 0)); p.members.push((5, GINGER)); @@ -1734,7 +2009,7 @@ fn a_real_session_syncs_its_party_at_login_and_routes_an_invite_to_realm_core() let (mut client, server_end) = world_session_socket_pair(); let server_store = session_shard.clone(); let server = std::thread::spawn(move || { - run_world_session(server_end, server_store.as_ref()).unwrap(); + run_world_session(server_end, server_store.clone()).unwrap(); }); let (mut c_enc, mut c_dec) = client_handshake(&mut client, "TESTER", K); // Missing roster delivery must fail at the read deadline. @@ -1744,15 +2019,14 @@ fn a_real_session_syncs_its_party_at_login_and_routes_an_invite_to_realm_core() .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); - // The realm-wide party slice appends the party frame right after world entry. + // The realm-wide party slice appends the party frame right after world entry. A frame that + // times out or does not decode is missing; the assertions below say what. + let mut frames = drain_world_entry(&mut client, &mut c_dec); + frames.extend(ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).ok()); let mut roster_named: Option = None; - for _ in 0..WORLD_ENTRY_PACKETS + 1 { - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec) { - Ok(ServerOpcodeMessage::SMSG_GROUP_LIST(list)) => { - roster_named = list.members.first().map(|m| m.name.clone()); - } - Ok(_) => {} - Err(_) => break, // timed out or undecodable — the assertions below say what was missing + for message in frames { + if let ServerOpcodeMessage::SMSG_GROUP_LIST(list) = message { + roster_named = list.members.first().map(|m| m.name.clone()); } } assert_eq!( @@ -1762,7 +2036,7 @@ fn a_real_session_syncs_its_party_at_login_and_routes_an_invite_to_realm_core() the realm-core roster, so the arriving shard is unmirrored too" ); assert_eq!( - session_shard.mirror.lock().unwrap().len(), + session_shard.party.mirror.lock().unwrap().len(), 1, "world entry must push the authoritative roster onto the shard the player entered" ); @@ -1810,7 +2084,7 @@ fn a_real_session_syncs_its_party_at_login_and_routes_an_invite_to_realm_core() } .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); - for _ in 0..WORLD_ENTRY_PACKETS { + loop { match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec) { Ok(ServerOpcodeMessage::SMSG_PARTY_COMMAND_RESULT(r)) if r.member == "Nobodyatall" => { break; @@ -1837,7 +2111,7 @@ fn a_real_session_syncs_its_party_at_login_and_routes_an_invite_to_realm_core() // carries the authenticated Character and the correct argument slots, including Refusals. use lyracore_shared::group::realm_op; assert_eq!( - realm.party.lock().unwrap().ops.clone(), + realm.party.party.lock().unwrap().ops.clone(), vec![ // World entry asks for the Party's Target Icons after the list. (realm_op::TARGET_ICON, GINGER, 0, 0xFF, 0, 0), @@ -1874,11 +2148,11 @@ fn a_shard_that_refuses_the_mirror_does_not_fail_the_party_op() { "the authority took the change" ); assert!( - !world.mirror.lock().unwrap().is_empty(), + !world.party.mirror.lock().unwrap().is_empty(), "the shard that COULD be mirrored still was — one unreachable database must not stop the rest" ); assert!( - instances.mirror.lock().unwrap().is_empty(), + instances.party.mirror.lock().unwrap().is_empty(), "and the refusing shard is left with no mirror at all, which is the documented ceiling: it \ re-syncs at that member's next world entry or next party op" ); @@ -1905,7 +2179,7 @@ fn a_bot_invite_forms_a_party_on_realm_core_across_a_shard_boundary() { // `a_bot_standing_on_another_shard_answers_the_invite_too` pins for the player-initiated case. party::run_bot_invite(world.as_ref(), BOT, FAR_BOT).expect("a bot-initiated invite must land"); - let party_state = realm.party.lock().unwrap(); + let party_state = realm.party.party.lock().unwrap(); let group_id = party_state .group_of(BOT) .expect("the bot's invite formed a party"); @@ -1940,7 +2214,7 @@ fn a_bot_invite_forms_a_party_on_realm_core_across_a_shard_boundary() { ); for (name, shard) in [("world", &world), ("instances", &instances)] { assert_eq!( - shard.mirror.lock().unwrap().clone(), + shard.party.mirror.lock().unwrap().clone(), vec![realm.group_roster(BOT).unwrap().unwrap()], "{name} must be mirrored by the SAME op — the shard's own kill-XP/`/p`/follow-the-leader \ reads need the party immediately, not after some later push" @@ -1957,7 +2231,12 @@ fn two_relay_consumers_execute_one_bot_invite() { const INTENT_ID: u64 = 41; let (realm, world, _instances, _calls) = party_topology(); - world.bot_invite_intents.lock().unwrap().push(INTENT_ID); + world + .party + .bot_invite_intents + .lock() + .unwrap() + .push(INTENT_ID); let start = std::sync::Arc::new(std::sync::Barrier::new(3)); let consumers: Vec<_> = (0..2) @@ -1981,7 +2260,7 @@ fn two_relay_consumers_execute_one_bot_invite() { consumer.join().unwrap().unwrap(); } - let party = realm.party.lock().unwrap(); + let party = realm.party.party.lock().unwrap(); assert_eq!( party .ops @@ -1998,7 +2277,7 @@ fn two_relay_consumers_execute_one_bot_invite() { party.roster(group_id).unwrap().member_guids(), vec![BOT, FAR_BOT] ); - assert!(world.bot_invite_intents.lock().unwrap().is_empty()); + assert!(world.party.bot_invite_intents.lock().unwrap().is_empty()); } /// **AC: one intent table, two party ops.** The row's `op` byte is the whole dispatch, so an INVITE @@ -2013,6 +2292,7 @@ fn the_intent_op_byte_picks_the_party_op_that_runs() { let (realm, world, _instances, _calls) = party_topology(); world + .party .bot_invite_intents .lock() .unwrap() @@ -2021,6 +2301,7 @@ fn the_intent_op_byte_picks_the_party_op_that_runs() { party::run_bot_invite_intent(world.as_ref(), INVITE_INTENT, bot_op::INVITE, BOT, FAR_BOT) .expect("op 0 forms the party"); let group_id = realm + .party .party .lock() .unwrap() @@ -2030,7 +2311,7 @@ fn the_intent_op_byte_picks_the_party_op_that_runs() { party::run_bot_invite_intent(world.as_ref(), LEAVE_INTENT, bot_op::LEAVE, BOT, 0) .expect("op 1 leaves the party"); - let party_state = realm.party.lock().unwrap(); + let party_state = realm.party.party.lock().unwrap(); assert_eq!( party_state.ops.clone(), vec![ @@ -2062,7 +2343,12 @@ fn an_unknown_intent_op_is_refused_rather_than_run_as_an_invite() { const INTENT_ID: u64 = 73; let (realm, world, _instances, _calls) = party_topology(); - world.bot_invite_intents.lock().unwrap().push(INTENT_ID); + world + .party + .bot_invite_intents + .lock() + .unwrap() + .push(INTENT_ID); let err = party::run_bot_invite_intent(world.as_ref(), INTENT_ID, 200, BOT, FAR_BOT) .expect_err("an unknown op must not fall through to an invite"); @@ -2071,7 +2357,7 @@ fn an_unknown_intent_op_is_refused_rather_than_run_as_an_invite() { "the refusal must name the byte it could not run: {err:#}" ); assert!( - realm.party.lock().unwrap().ops.is_empty(), + realm.party.party.lock().unwrap().ops.is_empty(), "nothing may reach the party authority" ); } @@ -2089,6 +2375,7 @@ fn a_bots_party_survives_the_next_sync_group_mirror_push_that_touches_it() { // forms a party in one call — a pending invite has no mirror to survive anything. party::run_bot_invite(world.as_ref(), BOT, FAR_BOT).expect("forms the party"); let group_id = realm + .party .party .lock() .unwrap() @@ -2096,7 +2383,7 @@ fn a_bots_party_survives_the_next_sync_group_mirror_push_that_touches_it() { .expect("the bot is in a party"); let authoritative = realm.group_roster_by_id(group_id).unwrap().unwrap(); assert_eq!( - world.mirror.lock().unwrap().clone(), + world.party.mirror.lock().unwrap().clone(), vec![authoritative.clone()], "precondition: the party is already mirrored by the invite's own push" ); @@ -2111,7 +2398,7 @@ fn a_bots_party_survives_the_next_sync_group_mirror_push_that_touches_it() { ); assert_eq!( - world.mirror.lock().unwrap().clone(), + world.party.mirror.lock().unwrap().clone(), vec![authoritative], "the bot party must survive the push — realm-core has a real row for it, so the mirror must \ reconfirm the roster rather than tombstone it. Wiping it here is the serendipity-invite \ @@ -2141,7 +2428,7 @@ fn a_shard_local_only_group_realm_core_never_heard_of_is_wiped_by_the_next_push( .sync_group_mirror(&phantom) .expect("simulate the bug's pre-fix shard-local-only write"); assert_eq!( - world.mirror.lock().unwrap().clone(), + world.party.mirror.lock().unwrap().clone(), vec![phantom.clone()], "precondition" ); @@ -2156,7 +2443,7 @@ fn a_shard_local_only_group_realm_core_never_heard_of_is_wiped_by_the_next_push( party::sync_mirrors(world.as_ref(), realm.as_ref(), 0, Some(phantom)); assert!( - world.mirror.lock().unwrap().is_empty(), + world.party.mirror.lock().unwrap().is_empty(), "a group realm-core does not know about must read as tombstoned — this is the \ serendipity-invite bug's exact \ mechanism, which is why routing bot invites through realm-core (not writing shard-local rows) \ @@ -2180,7 +2467,7 @@ fn a_bot_invite_to_a_missing_or_offline_target_never_reaches_realm_core() { ); assert!( - realm.party.lock().unwrap().ops.is_empty(), + realm.party.party.lock().unwrap().ops.is_empty(), "a refused bot invite must not reach the authority" ); } @@ -2193,13 +2480,25 @@ fn a_bot_invite_to_a_missing_or_offline_target_never_reaches_realm_core() { fn an_unsharded_deployment_still_routes_a_bot_invite_through_realm_group_op() { use lyracore_shared::group::realm_op; let calls: ShardCallLog = Default::default(); - let store = std::sync::Arc::new(InMemoryStore { - shard: "world".into(), - calls: calls.clone(), - is_realm: true, // this database IS its own authority — nothing else to route to + let store = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "world".into(), + calls: calls.clone(), + ..Default::default() + }, + party: PartyState { + is_realm: true, // this database IS its own authority — nothing else to route to + ..Default::default() + }, characters: vec![character(BOT, "Botty"), character(TRIN, "Trin")], - live_guids: vec![BOT, TRIN], - offline_guids: vec![BOT], + session: SessionState { + live_guids: vec![BOT, TRIN], + ..Default::default() + }, + social: SocialState { + offline_guids: vec![BOT], + ..Default::default() + }, ..Default::default() // no `realm`, no `peers` — the unconfigured gateway }); assert!( @@ -2223,7 +2522,7 @@ fn an_unsharded_deployment_still_routes_a_bot_invite_through_realm_group_op() { were {log:?}" ); assert_eq!( - store.party.lock().unwrap().ops.first().copied(), + store.party.party.lock().unwrap().ops.first().copied(), Some((realm_op::INVITE, BOT, TRIN, 0, 0, 0)), "the invite must be recorded with the bot as inviter" ); @@ -2233,6 +2532,7 @@ fn an_unsharded_deployment_still_routes_a_bot_invite_through_realm_group_op() { fn suppressed_automatic_answers_leave_human_invitations_pending_on_realm_core() { let (realm, world, instances, _) = party_topology(); instances + .party .sessionless_admission .lock() .unwrap() @@ -2243,7 +2543,7 @@ fn suppressed_automatic_answers_leave_human_invitations_pending_on_realm_core() PartyOutcome::Ran ); - let state = realm.party.lock().unwrap(); + let state = realm.party.party.lock().unwrap(); assert!(state.group_of(FAR_BOT).is_none()); assert_eq!( state.ops, @@ -2255,6 +2555,7 @@ fn suppressed_automatic_answers_leave_human_invitations_pending_on_realm_core() fn suppressed_automatic_answers_leave_bot_invitations_pending_on_realm_core() { let (realm, world, instances, _) = party_topology(); instances + .party .sessionless_admission .lock() .unwrap() @@ -2265,7 +2566,7 @@ fn suppressed_automatic_answers_leave_bot_invitations_pending_on_realm_core() { PartyOutcome::Ran ); - let state = realm.party.lock().unwrap(); + let state = realm.party.party.lock().unwrap(); assert!(state.group_of(FAR_BOT).is_none()); assert_eq!(state.ops, vec![(realm_op::INVITE, BOT, FAR_BOT, 0, 0, 0)]); } @@ -2274,6 +2575,7 @@ fn suppressed_automatic_answers_leave_bot_invitations_pending_on_realm_core() { fn unavailable_admission_leaves_the_invitation_unanswered() { let (realm, world, instances, _) = party_topology(); instances + .party .sessionless_admission_unavailable .lock() .unwrap() @@ -2281,7 +2583,7 @@ fn unavailable_admission_leaves_the_invitation_unanswered() { party::run(world.as_ref(), 7, GINGER, party::Op::Invite(FAR_BOT)).unwrap(); - let state = realm.party.lock().unwrap(); + let state = realm.party.party.lock().unwrap(); assert!(state.group_of(FAR_BOT).is_none()); assert_eq!( state.ops, @@ -2293,6 +2595,7 @@ fn unavailable_admission_leaves_the_invitation_unanswered() { fn current_admission_refuses_a_stale_sessionless_presence_read() { let (realm, world, instances, _) = party_topology(); instances + .party .sessionless_admission .lock() .unwrap() @@ -2300,7 +2603,7 @@ fn current_admission_refuses_a_stale_sessionless_presence_read() { party::run(world.as_ref(), 7, GINGER, party::Op::Invite(FAR_BOT)).unwrap(); - let state = realm.party.lock().unwrap(); + let state = realm.party.party.lock().unwrap(); assert!(state.group_of(FAR_BOT).is_none()); assert_eq!( state.ops, @@ -2312,6 +2615,7 @@ fn current_admission_refuses_a_stale_sessionless_presence_read() { fn a_suppressed_group_intent_never_reaches_realm_core() { let (realm, world, _, _) = party_topology(); world + .party .bot_intent_claim_refusals .lock() .unwrap() @@ -2329,13 +2633,14 @@ fn a_suppressed_group_intent_never_reaches_realm_core() { PartyOutcome::Refused(GroupRefusal::ActionSuppressed) ); - assert!(realm.party.lock().unwrap().ops.is_empty()); + assert!(realm.party.party.lock().unwrap().ops.is_empty()); } #[test] fn a_controller_selection_after_admission_does_not_recall_the_answer() { let (realm, world, instances, _) = party_topology(); instances + .party .suppress_after_admission .lock() .unwrap() @@ -2343,7 +2648,13 @@ fn a_controller_selection_after_admission_does_not_recall_the_answer() { party::run(world.as_ref(), 7, GINGER, party::Op::Invite(FAR_BOT)).unwrap(); - assert!(realm.party.lock().unwrap().group_of(FAR_BOT).is_some()); + assert!(realm + .party + .party + .lock() + .unwrap() + .group_of(FAR_BOT) + .is_some()); assert_eq!( instances.admit_sessionless_group_action(FAR_BOT).unwrap(), PartyOutcome::Refused(GroupRefusal::ActionSuppressed) @@ -2352,13 +2663,20 @@ fn a_controller_selection_after_admission_does_not_recall_the_answer() { #[test] fn unsharded_bot_invitations_leave_a_suppressed_target_unanswered() { - let store = InMemoryStore { + let store = WorldFake { characters: vec![character(BOT, "Botty"), character(FAR_BOT, "Farbotty")], - live_guids: vec![BOT, FAR_BOT], - offline_guids: vec![BOT, FAR_BOT], + session: SessionState { + live_guids: vec![BOT, FAR_BOT], + ..Default::default() + }, + social: SocialState { + offline_guids: vec![BOT, FAR_BOT], + ..Default::default() + }, ..Default::default() }; store + .party .sessionless_admission .lock() .unwrap() @@ -2366,7 +2684,7 @@ fn unsharded_bot_invitations_leave_a_suppressed_target_unanswered() { party::run_bot_invite(&store, BOT, FAR_BOT).unwrap(); - let state = store.party.lock().unwrap(); + let state = store.party.party.lock().unwrap(); assert!(state.group_of(FAR_BOT).is_none()); assert_eq!(state.invites, vec![(FAR_BOT, BOT)]); assert_eq!(state.ops, vec![(realm_op::INVITE, BOT, FAR_BOT, 0, 0, 0)]); @@ -2415,12 +2733,12 @@ fn a_leader_converts_the_party_on_realm_core_and_every_shard_mirrors_the_raid() 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(); - let events_before = realm.party.lock().unwrap().events.len(); + let events_before = realm.party.party.lock().unwrap().events.len(); let outcome = party::run(world.as_ref(), 7, GINGER, party::Op::RaidConvert).unwrap(); assert_eq!(outcome, PartyOutcome::Ran); - let state = realm.party.lock().unwrap(); + let state = realm.party.party.lock().unwrap(); assert_eq!( state.ops.last().copied(), Some((realm_op::RAID_CONVERT, GINGER, 0, 0, 0, 0)) @@ -2445,7 +2763,7 @@ fn a_leader_converts_the_party_on_realm_core_and_every_shard_mirrors_the_raid() .all(|member| member.slot == RaidSlot::default())); for (name, shard) in [("world", &world), ("instances", &instances)] { assert_eq!( - shard.mirror.lock().unwrap().clone(), + shard.party.mirror.lock().unwrap().clone(), vec![authority.clone()], "{name} must mirror the kind and every Raid Slot, or its local raid reads disagree \ with Realm-core" @@ -2484,7 +2802,7 @@ fn converting_a_raid_again_pushes_no_mirror() { fn a_member_who_does_not_lead_cannot_convert_and_no_mirror_is_pushed() { let (realm, world, instances, calls) = party_topology(); form_split_party(&world, &instances); - let events_before = realm.party.lock().unwrap().events.len(); + 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(); @@ -2494,7 +2812,10 @@ fn a_member_who_does_not_lead_cannot_convert_and_no_mirror_is_pushed() { realm.group_roster(GINGER).unwrap().unwrap().kind, GroupKind::Party ); - assert_eq!(realm.party.lock().unwrap().events.len(), events_before); + assert_eq!( + realm.party.party.lock().unwrap().events.len(), + events_before + ); assert_eq!(mirror_calls(&calls), mirrors_before); } @@ -2503,13 +2824,16 @@ fn a_member_who_does_not_lead_cannot_convert_and_no_mirror_is_pushed() { #[test] fn an_unsharded_gateway_converts_through_realm_group_op_on_its_own_shard() { let calls: ShardCallLog = Default::default(); - let store = std::sync::Arc::new(InMemoryStore { - shard: "world".into(), - calls: calls.clone(), + let store = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "world".into(), + calls: calls.clone(), + ..Default::default() + }, ..Default::default() }); { - let mut p = store.party.lock().unwrap(); + let mut p = store.party.party.lock().unwrap(); p.groups.push((5, GINGER, 3, 2, 0)); p.members.push((5, GINGER)); p.members.push((5, VIM)); @@ -2523,7 +2847,7 @@ fn an_unsharded_gateway_converts_through_realm_group_op_on_its_own_shard() { vec![("world".to_string(), "realm_group_op".to_string())], "one call, on the player's own shard, and no mirror push" ); - let state = store.party.lock().unwrap(); + let state = store.party.party.lock().unwrap(); assert_eq!( state.ops, vec![(realm_op::RAID_CONVERT, GINGER, 0, 0, 0, 0)] @@ -2538,7 +2862,7 @@ fn an_unsharded_gateway_converts_through_realm_group_op_on_its_own_shard() { fn a_raid_joiner_past_a_full_first_subgroup_shows_subgroup_one_in_every_list() { let (realm, world, instances, _calls) = party_topology(); { - let mut p = realm.party.lock().unwrap(); + let mut p = realm.party.party.lock().unwrap(); p.next_group_id = 9; p.groups.push((9, GINGER, 3, 2, 0)); for guid in [GINGER, VIM, 101, 102, 103] { @@ -2558,7 +2882,7 @@ fn a_raid_joiner_past_a_full_first_subgroup_shows_subgroup_one_in_every_list() { assert_eq!(joiner.slot, RaidSlot::new(1, false).unwrap()); for shard in [&world, &instances] { assert_eq!( - shard.mirror.lock().unwrap().clone(), + shard.party.mirror.lock().unwrap().clone(), vec![authority.clone()] ); } @@ -2590,7 +2914,7 @@ fn the_realm_core_list_relay_renders_a_member_on_another_shard_online() { let (realm, world, instances, _calls) = party_topology(); form_split_party(&world, &instances); // The production Realm-core Coordinator reads every World Shard. - *realm.peers.lock().unwrap() = vec![world.clone(), instances.clone()]; + *realm.topology.peers.lock().unwrap() = vec![world.clone(), instances.clone()]; // What the Module writes on Realm-core: no names, no presence. let payload = RosterPayload { leader: GINGER, @@ -2687,7 +3011,7 @@ fn raid_convert_answers_the_leader_with_success_and_a_refusal_with_silence() { }; let s = quest_store(); { - let mut p = s.party.lock().unwrap(); + let mut p = s.party.party.lock().unwrap(); p.groups.push((5, 1, 3, 2, 0)); p.members.push((5, 1)); p.members.push((5, 2)); @@ -2706,11 +3030,14 @@ fn raid_convert_answers_the_leader_with_success_and_a_refusal_with_silence() { } other => panic!("expected SMSG_PARTY_COMMAND_RESULT, got {other}"), } - assert_eq!(store.party.lock().unwrap().kind_of(5), GroupKind::Raid); + assert_eq!( + store.party.party.lock().unwrap().kind_of(5), + GroupKind::Raid + ); // Another member now leads, so the session's convert is refused. The barrier invite's reply // must be the next packet: the refusal sent nothing. - store.party.lock().unwrap().groups[0].1 = 2; + store.party.party.lock().unwrap().groups[0].1 = 2; CMSG_GROUP_RAID_CONVERT {} .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); @@ -2736,9 +3063,9 @@ fn raid_convert_answers_the_leader_with_success_and_a_refusal_with_silence() { /// Ginger leads a Party of Ginger (world), Vim (instances) and Trin (world). fn party_of_three() -> ( - std::sync::Arc, - std::sync::Arc, - std::sync::Arc, + std::sync::Arc, + std::sync::Arc, + std::sync::Arc, ShardCallLog, ) { let (realm, world, instances, calls) = party_topology(); @@ -2749,8 +3076,8 @@ fn party_of_three() -> ( } /// The kinds `guid` received since event `from`. -fn events_for(realm: &InMemoryStore, from: usize, guid: u64) -> Vec { - realm.party.lock().unwrap().events[from..] +fn events_for(realm: &WorldFake, from: usize, guid: u64) -> Vec { + realm.party.party.lock().unwrap().events[from..] .iter() .filter(|(recipient, _)| *recipient == guid) .map(|(_, kind)| *kind) @@ -2764,7 +3091,7 @@ fn events_for(realm: &InMemoryStore, from: usize, guid: u64) -> Vec { fn the_leader_passes_the_lead_to_a_member_live_on_the_far_shard() { use lyracore_shared::group::event_kind::{LIST, SET_LEADER}; let (realm, world, instances, _calls) = party_of_three(); - let events_before = realm.party.lock().unwrap().events.len(); + let events_before = realm.party.party.lock().unwrap().events.len(); let outcome = party::run(world.as_ref(), 7, GINGER, party::Op::SetLeader(VIM)).unwrap(); @@ -2780,7 +3107,7 @@ fn the_leader_passes_the_lead_to_a_member_live_on_the_far_shard() { } for shard in [&world, &instances] { assert_eq!( - shard.mirror.lock().unwrap().clone(), + shard.party.mirror.lock().unwrap().clone(), vec![authority.clone()] ); } @@ -2791,14 +3118,14 @@ fn the_leader_passes_the_lead_to_a_member_live_on_the_far_shard() { #[test] fn the_lead_passes_only_to_a_member_live_on_some_shard() { let (realm, world, _instances, calls) = party_of_three(); - realm.party.lock().unwrap().members.push((1, DORMANT)); - let ops_before = realm.party.lock().unwrap().ops.len(); + realm.party.party.lock().unwrap().members.push((1, DORMANT)); + 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(); assert_eq!(outcome, PartyOutcome::Refused(GroupRefusal::TargetOffline)); - assert_eq!(realm.party.lock().unwrap().ops.len(), ops_before); + assert_eq!(realm.party.party.lock().unwrap().ops.len(), ops_before); assert_eq!(mirror_calls(&calls), mirrors_before); assert_eq!( realm.group_roster(GINGER).unwrap().unwrap().leader_guid, @@ -2825,7 +3152,7 @@ fn the_lead_passes_only_to_a_member_live_on_some_shard() { 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(); - let ops_before = realm.party.lock().unwrap().ops.len(); + let ops_before = realm.party.party.lock().unwrap().ops.len(); for op in [ party::Op::SetAssistant { @@ -2842,7 +3169,7 @@ fn leadership_ops_reach_realm_core_in_their_declared_argument_slots() { } assert_eq!( - realm.party.lock().unwrap().ops[ops_before..], + realm.party.party.lock().unwrap().ops[ops_before..], [ (realm_op::SET_ASSISTANT, GINGER, TRIN, 1, 0, 0), (realm_op::SET_ASSISTANT, GINGER, TRIN, 0, 0, 0), @@ -2868,7 +3195,7 @@ fn a_promoted_assistant_shows_0x80_in_every_list_and_every_mirror() { let authority = realm.group_roster(GINGER).unwrap().unwrap(); for shard in [&world, &instances] { assert_eq!( - shard.mirror.lock().unwrap().clone(), + shard.party.mirror.lock().unwrap().clone(), vec![authority.clone()] ); } @@ -2906,14 +3233,20 @@ fn repeating_a_promotion_pushes_no_mirror() { #[test] fn an_unsharded_gateway_runs_leadership_ops_on_its_own_shard() { let calls: ShardCallLog = Default::default(); - let store = std::sync::Arc::new(InMemoryStore { - shard: "world".into(), - calls: calls.clone(), - live_guids: vec![GINGER, VIM], + let store = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "world".into(), + calls: calls.clone(), + ..Default::default() + }, + session: SessionState { + live_guids: vec![GINGER, VIM], + ..Default::default() + }, ..Default::default() }); { - let mut p = store.party.lock().unwrap(); + let mut p = store.party.party.lock().unwrap(); p.groups.push((5, GINGER, 3, 2, 0)); p.members.push((5, GINGER)); p.members.push((5, VIM)); @@ -2934,7 +3267,7 @@ fn an_unsharded_gateway_runs_leadership_ops_on_its_own_shard() { .map(|(_, call)| call.clone()) .collect(); assert_eq!(ran, ["realm_group_op", "realm_group_op"]); - let state = store.party.lock().unwrap(); + let state = store.party.party.lock().unwrap(); assert_eq!(state.leader_of(5), VIM); assert!(state.slots[&VIM].is_assistant()); } @@ -2955,7 +3288,7 @@ fn set_leader_row(leader: u64) -> crate::stdb::bindings::GroupEvent { /// The one `SMSG_GROUP_SET_LEADER` name the relay sends for `row`, or `None` for no packet. fn relayed_leader_name( - realm: &InMemoryStore, + realm: &WorldFake, row: &crate::stdb::bindings::GroupEvent, ) -> Option { let packets = crate::stdb::subscriptions::group_event_outbound(realm, GINGER, row); @@ -2977,7 +3310,7 @@ fn relayed_leader_name( #[test] fn the_set_leader_relay_names_the_leader_from_the_far_shard() { let (realm, world, instances, _calls) = party_topology(); - *realm.peers.lock().unwrap() = vec![world.clone(), instances.clone()]; + *realm.topology.peers.lock().unwrap() = vec![world.clone(), instances.clone()]; assert_eq!( relayed_leader_name(&realm, &set_leader_row(VIM)).as_deref(), @@ -3003,7 +3336,7 @@ fn invite_row(inviter: u64) -> crate::stdb::bindings::GroupEvent { /// The one `SMSG_GROUP_INVITE` name the relay sends for `row`, or `None` for no packet. fn relayed_invite_name( - realm: &InMemoryStore, + realm: &WorldFake, row: &crate::stdb::bindings::GroupEvent, ) -> Option { let packets = crate::stdb::subscriptions::group_event_outbound(realm, GINGER, row); @@ -3028,7 +3361,7 @@ fn relayed_invite_name( #[test] fn the_invite_relay_names_the_inviter_from_the_far_shard() { let (realm, world, instances, _calls) = party_topology(); - *realm.peers.lock().unwrap() = vec![world.clone(), instances.clone()]; + *realm.topology.peers.lock().unwrap() = vec![world.clone(), instances.clone()]; assert_eq!( relayed_invite_name(&realm, &invite_row(VIM)).as_deref(), @@ -3058,7 +3391,7 @@ fn decline_row(decliner: u64) -> crate::stdb::bindings::GroupEvent { /// The one `SMSG_GROUP_DECLINE` name the relay sends for `row`, or `None` for no packet. fn relayed_decline_name( - realm: &InMemoryStore, + realm: &WorldFake, row: &crate::stdb::bindings::GroupEvent, ) -> Option { let packets = crate::stdb::subscriptions::group_event_outbound(realm, GINGER, row); @@ -3081,7 +3414,7 @@ fn relayed_decline_name( #[test] fn the_decline_relay_names_the_decliner_from_the_far_shard() { let (realm, world, instances, _calls) = party_topology(); - *realm.peers.lock().unwrap() = vec![world.clone(), instances.clone()]; + *realm.topology.peers.lock().unwrap() = vec![world.clone(), instances.clone()]; assert_eq!( relayed_decline_name(&realm, &decline_row(VIM)).as_deref(), @@ -3135,16 +3468,22 @@ fn leadership_opcodes_decode_from_the_vanilla_layout() { /// A session on a shard that reaches `realm`, entered as Ginger. fn realm_session( - realm: &std::sync::Arc, + realm: &std::sync::Arc, ) -> ( UnixStream, EncrypterHalf, DecrypterHalf, std::thread::JoinHandle<()>, ) { - let store = std::sync::Arc::new(InMemoryStore { - realm: Some(realm.clone()), - ..quest_store() + let store = std::sync::Arc::new({ + let base = quest_store(); + WorldFake { + topology: TopologyState { + realm: Some(realm.clone()), + ..base.topology + }, + ..base + } }); enter_world(store, GINGER) } @@ -3192,12 +3531,15 @@ fn barrier( #[test] fn a_guid_kick_of_the_leader_answers_not_leader() { use wow_world_messages::vanilla::{PartyOperation, PartyResult}; - let realm = std::sync::Arc::new(InMemoryStore { - is_realm: true, + let realm = std::sync::Arc::new(WorldFake { + party: PartyState { + is_realm: true, + ..Default::default() + }, ..Default::default() }); { - let mut p = realm.party.lock().unwrap(); + let mut p = realm.party.party.lock().unwrap(); p.groups.push((5, VIM, 3, 2, 0)); p.members.push((5, VIM)); p.members.push((5, GINGER)); @@ -3211,7 +3553,10 @@ fn a_guid_kick_of_the_leader_answers_not_leader() { assert_eq!(result.operation, PartyOperation::Leave); assert_eq!(result.member, ""); assert_eq!(result.result, PartyResult::NotLeader); - assert_eq!(realm.party.lock().unwrap().member_guids(5), [VIM, GINGER]); + assert_eq!( + realm.party.party.lock().unwrap().member_guids(5), + [VIM, GINGER] + ); drop(client); let _ = server.join(); } @@ -3223,12 +3568,15 @@ fn a_guid_kick_of_the_leader_answers_not_leader() { #[test] fn set_leader_and_set_assistant_run_as_the_session_and_answer_nothing() { use wow_world_messages::vanilla::{CMSG_GROUP_ASSISTANT_LEADER, CMSG_GROUP_SET_LEADER}; - let realm = std::sync::Arc::new(InMemoryStore { - is_realm: true, + let realm = std::sync::Arc::new(WorldFake { + party: PartyState { + is_realm: true, + ..Default::default() + }, ..Default::default() }); { - let mut p = realm.party.lock().unwrap(); + let mut p = realm.party.party.lock().unwrap(); p.groups.push((5, GINGER, 3, 2, 0)); p.members.push((5, GINGER)); p.members.push((5, VIM)); @@ -3255,7 +3603,7 @@ fn set_leader_and_set_assistant_run_as_the_session_and_answer_nothing() { first_answer.member, "Nobodyatall", "a leadership op answered" ); - let state = realm.party.lock().unwrap(); + let state = realm.party.party.lock().unwrap(); assert_eq!( state.ops[..3], [ @@ -3275,12 +3623,15 @@ fn set_leader_and_set_assistant_run_as_the_session_and_answer_nothing() { /// (cm:GroupHandler.cpp:255-260). It never reaches the party authority. #[test] fn a_guid_kick_of_yourself_answers_nothing() { - let realm = std::sync::Arc::new(InMemoryStore { - is_realm: true, + let realm = std::sync::Arc::new(WorldFake { + party: PartyState { + is_realm: true, + ..Default::default() + }, ..Default::default() }); { - let mut p = realm.party.lock().unwrap(); + let mut p = realm.party.party.lock().unwrap(); p.groups.push((5, GINGER, 3, 2, 0)); p.members.push((5, GINGER)); p.members.push((5, VIM)); @@ -3290,7 +3641,7 @@ fn a_guid_kick_of_yourself_answers_nothing() { let first_answer = kick_by_guid(&mut client, &mut c_enc, &mut c_dec, GINGER); assert_eq!(first_answer.member, "Nobodyatall", "the self kick answered"); - let state = realm.party.lock().unwrap(); + let state = realm.party.party.lock().unwrap(); assert!(state.ops.iter().all(|op| op.0 != realm_op::UNINVITE)); assert_eq!(state.member_guids(5), [GINGER, VIM]); drop(state); @@ -3302,12 +3653,15 @@ fn a_guid_kick_of_yourself_answers_nothing() { /// is still removed, and a kick that ran answers nothing: the next packet is the barrier's. #[test] fn a_guid_kick_removes_a_member_no_shard_can_name() { - let realm = std::sync::Arc::new(InMemoryStore { - is_realm: true, + let realm = std::sync::Arc::new(WorldFake { + party: PartyState { + is_realm: true, + ..Default::default() + }, ..Default::default() }); { - let mut p = realm.party.lock().unwrap(); + let mut p = realm.party.party.lock().unwrap(); p.groups.push((5, GINGER, 3, 2, 0)); for guid in [GINGER, 404, 405] { p.members.push((5, guid)); @@ -3321,7 +3675,7 @@ fn a_guid_kick_removes_a_member_no_shard_can_name() { first_answer.member, "Nobodyatall", "the kick itself answered" ); - let state = realm.party.lock().unwrap(); + let state = realm.party.party.lock().unwrap(); assert_eq!(state.member_guids(5), [GINGER, 405]); assert_eq!( state.ops.last().copied(), @@ -3362,7 +3716,7 @@ fn subgroup_ops_reach_realm_core_in_their_declared_argument_slots() { ) .expect("the leader swaps with Vim"); - let ops = realm.party.lock().unwrap().ops.clone(); + let ops = realm.party.party.lock().unwrap().ops.clone(); assert_eq!( ops[ops.len() - 2..], [ @@ -3380,6 +3734,7 @@ fn a_leader_moves_a_member_to_another_subgroup_and_every_shard_mirrors_the_slot( form_split_party(&world, &instances); party::run(world.as_ref(), 7, GINGER, party::Op::RaidConvert).unwrap(); realm + .party .party .lock() .unwrap() @@ -3412,7 +3767,7 @@ fn a_leader_moves_a_member_to_another_subgroup_and_every_shard_mirrors_the_slot( ); for (name, shard) in [("world", &world), ("instances", &instances)] { assert_eq!( - shard.mirror.lock().unwrap().clone(), + shard.party.mirror.lock().unwrap().clone(), vec![authority.clone()], "{name} must mirror Vim's new Subgroup" ); @@ -3451,6 +3806,7 @@ fn an_assistant_can_move_a_member_a_plain_member_cannot() { party::run(world.as_ref(), 9, TRIN, party::Op::Accept).unwrap(); party::run(world.as_ref(), 7, GINGER, party::Op::RaidConvert).unwrap(); realm + .party .party .lock() .unwrap() @@ -3509,6 +3865,7 @@ fn an_assistant_moves_a_member_and_marks_a_target_icon() { form_split_party(&world, &instances); party::run(world.as_ref(), 7, GINGER, party::Op::RaidConvert).unwrap(); realm + .party .party .lock() .unwrap() @@ -3555,7 +3912,7 @@ fn an_assistant_moves_a_member_and_marks_a_target_icon() { "the same Assistant may mark a Target Icon" ); assert_eq!( - realm.party.lock().unwrap().ops.last().copied(), + realm.party.party.lock().unwrap().ops.last().copied(), Some((realm_op::TARGET_ICON, VIM, 900, 7, 0, 0)), "the mark reaches the party authority as Vim's own action" ); @@ -3623,8 +3980,8 @@ fn subgroup_ops_in_a_party_are_refused_and_change_nothing() { /// A Raid whose Subgroup 0 and Subgroup 1 each hold 5 members: `GINGER` leads Subgroup 0, /// `VIM` leads Subgroup 1. AC 3 and AC 6 need Subgroups already full, which is a different /// scenario from a member joining one with room. -fn seed_raid_with_two_full_subgroups(realm: &InMemoryStore) { - let mut p = realm.party.lock().unwrap(); +fn seed_raid_with_two_full_subgroups(realm: &WorldFake) { + let mut p = realm.party.party.lock().unwrap(); let group_id = 20; p.next_group_id = group_id; p.groups.push((group_id, GINGER, 3, 2, 0)); @@ -3668,7 +4025,7 @@ fn a_move_into_a_full_subgroup_is_refused() { fn swapping_members_of_two_full_subgroups_succeeds_and_sends_one_list_per_member() { let (realm, world, instances, _calls) = party_topology(); seed_raid_with_two_full_subgroups(&realm); - let events_before = realm.party.lock().unwrap().events.len(); + let events_before = realm.party.party.lock().unwrap().events.len(); let outcome = party::run( world.as_ref(), @@ -3694,14 +4051,14 @@ fn swapping_members_of_two_full_subgroups_succeeds_and_sends_one_list_per_member }; assert_eq!(subgroup_of(GINGER), 1); assert_eq!(subgroup_of(VIM), 0); - let listed = realm.party.lock().unwrap().events[events_before..] + let listed = realm.party.party.lock().unwrap().events[events_before..] .iter() .filter(|(_, kind)| *kind == lyracore_shared::group::event_kind::LIST) .count(); assert_eq!(listed, 10, "one list per member, not two moves' worth"); for (name, shard) in [("world", &world), ("instances", &instances)] { assert_eq!( - shard.mirror.lock().unwrap().clone(), + shard.party.mirror.lock().unwrap().clone(), vec![authority.clone()], "{name} must mirror both swapped Subgroups" ); @@ -3807,6 +4164,7 @@ fn a_group_broadcast_runs_once_on_realm_core_and_pushes_no_mirror() { for (op, (code, target, arg_a, arg_b, arg_c)) in broadcast_ops() { let calls_before = calls.lock().unwrap().len(); let roster_reads_before = realm + .party .group_roster_reads .load(std::sync::atomic::Ordering::SeqCst); @@ -3820,13 +4178,14 @@ fn a_group_broadcast_runs_once_on_realm_core_and_pushes_no_mirror() { ); assert_eq!( realm + .party .group_roster_reads .load(std::sync::atomic::Ordering::SeqCst), roster_reads_before, "{op:?} read a roster it does not need" ); assert_eq!( - realm.party.lock().unwrap().ops.last().copied(), + realm.party.party.lock().unwrap().ops.last().copied(), Some((code, GINGER, target, arg_a, arg_b, arg_c)), "{op:?}" ); @@ -3838,13 +4197,16 @@ fn a_group_broadcast_runs_once_on_realm_core_and_pushes_no_mirror() { #[test] fn an_unsharded_gateway_runs_every_group_broadcast_on_its_own_shard() { let calls: ShardCallLog = Default::default(); - let store = std::sync::Arc::new(InMemoryStore { - shard: "world".into(), - calls: calls.clone(), + let store = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "world".into(), + calls: calls.clone(), + ..Default::default() + }, ..Default::default() }); { - let mut p = store.party.lock().unwrap(); + let mut p = store.party.party.lock().unwrap(); p.groups.push((5, GINGER, 3, 2, 0)); p.members.push((5, GINGER)); p.members.push((5, VIM)); @@ -3861,7 +4223,7 @@ fn an_unsharded_gateway_runs_every_group_broadcast_on_its_own_shard() { "{op:?}" ); assert_eq!( - store.party.lock().unwrap().ops.last().copied(), + store.party.party.lock().unwrap().ops.last().copied(), Some((code, GINGER, target, arg_a, arg_b, arg_c)), "{op:?}" ); @@ -3908,7 +4270,7 @@ fn assert_nothing_sent_before_the_barrier( fn every_group_broadcast_opcode_reaches_the_party_authority_with_its_client_values() { let s = quest_store(); { - let mut p = s.party.lock().unwrap(); + let mut p = s.party.party.lock().unwrap(); p.groups.push((5, 1, 3, 2, 0)); p.members.push((5, 1)); p.members.push((5, 2)); @@ -3937,7 +4299,7 @@ fn every_group_broadcast_opcode_reaches_the_party_authority_with_its_client_valu assert_nothing_sent_before_the_barrier(&mut client, &mut c_enc, &mut c_dec); assert_eq!( - store.party.lock().unwrap().ops, + store.party.party.lock().unwrap().ops, vec![ (11, 1, 0, 0, 0, 0), (12, 1, 0, 1, 0, 0), @@ -3965,7 +4327,7 @@ fn a_refused_group_broadcast_sends_nothing() { assert_nothing_sent_before_the_barrier(&mut client, &mut c_enc, &mut c_dec); - let ops: Vec<_> = store.party.lock().unwrap().ops.clone(); + let ops: Vec<_> = store.party.party.lock().unwrap().ops.clone(); assert_eq!( ops.iter().map(|op| (op.0, op.3)).collect::>(), [(11, 0), (13, 8), (14, 0)], @@ -3982,7 +4344,7 @@ fn a_refused_group_broadcast_sends_nothing() { fn a_repeated_fan_out_broadcast_inside_its_cooldown_is_dropped() { let s = quest_store(); { - let mut p = s.party.lock().unwrap(); + let mut p = s.party.party.lock().unwrap(); p.groups.push((5, 1, 3, 2, 0)); p.members.push((5, 1)); p.members.push((5, 2)); @@ -4014,6 +4376,7 @@ fn a_repeated_fan_out_broadcast_inside_its_cooldown_is_dropped() { assert_nothing_sent_before_the_barrier(&mut client, &mut c_enc, &mut c_dec); let ops: Vec = store + .party .party .lock() .unwrap() @@ -4188,45 +4551,73 @@ fn the_relay_drops_a_meeting_stone_queue_it_cannot_encode() { /// A Dwarf Priest stands on `instances`, so the acceptor's facts differ from the party fixture's /// Human Warrior and cross a shard when the asking session is on `world`. fn dwarf_priest_topology() -> ( - std::sync::Arc, - std::sync::Arc, - std::sync::Arc, + std::sync::Arc, + std::sync::Arc, + std::sync::Arc, ) { let dwarf_priest = |guid: u64, name: &str| codec::CharacterView { race: 3, class: 5, ..character(guid, name) }; - let realm = std::sync::Arc::new(InMemoryStore { - shard: "lyracore-realm".into(), - is_realm: true, + let realm = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "lyracore-realm".into(), + ..Default::default() + }, + party: PartyState { + is_realm: true, + ..Default::default() + }, ..Default::default() }); - let world = std::sync::Arc::new(InMemoryStore { - shard: "world".into(), - realm: Some(realm.clone()), + let world = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "world".into(), + realm: Some(realm.clone()), + ..Default::default() + }, characters: vec![character(GINGER, "Ginger")], - live_guids: vec![GINGER], - entity_partitions: std::sync::Mutex::new(vec![(GINGER, 0, 0)]), + session: SessionState { + live_guids: vec![GINGER], + ..Default::default() + }, + party: PartyState { + entity_partitions: std::sync::Mutex::new(vec![(GINGER, 0, 0)]), + ..Default::default() + }, ..Default::default() }); - let instances = std::sync::Arc::new(InMemoryStore { - shard: "instances".into(), - realm: Some(realm.clone()), + let instances = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "instances".into(), + realm: Some(realm.clone()), + ..Default::default() + }, characters: vec![dwarf_priest(VIM, "Vim"), dwarf_priest(FAR_BOT, "Farbotty")], - live_guids: vec![VIM, FAR_BOT], - entity_partitions: std::sync::Mutex::new(vec![(VIM, 0, 0), (FAR_BOT, 0, 0)]), - offline_guids: vec![FAR_BOT], + session: SessionState { + live_guids: vec![VIM, FAR_BOT], + ..Default::default() + }, + party: PartyState { + entity_partitions: std::sync::Mutex::new(vec![(VIM, 0, 0), (FAR_BOT, 0, 0)]), + ..Default::default() + }, + social: SocialState { + offline_guids: vec![FAR_BOT], + ..Default::default() + }, ..Default::default() }); for shard in [&world, &instances] { - *shard.peers.lock().unwrap() = vec![world.clone(), instances.clone()]; + *shard.topology.peers.lock().unwrap() = vec![world.clone(), instances.clone()]; } (realm, world, instances) } -fn accepts(realm: &InMemoryStore) -> Vec<(u8, u64, u64, u8, u8, u64)> { +fn accepts(realm: &WorldFake) -> Vec<(u8, u64, u64, u8, u8, u64)> { realm + .party .party .lock() .unwrap() @@ -4264,7 +4655,7 @@ fn a_playerbot_accept_carries_its_class_from_its_own_shard() { #[test] fn an_accept_nobody_can_name_conveys_zero_class() { let (realm, world, _instances, _calls) = party_topology(); - realm.party.lock().unwrap().invites.push((99, GINGER)); + realm.party.party.lock().unwrap().invites.push((99, GINGER)); party::run(world.as_ref(), 9, 99, party::Op::Accept).unwrap(); @@ -4300,7 +4691,7 @@ fn a_party_list_with_target_icons_sends_the_list_then_the_icons() { use lyracore_shared::group::{event_kind, TargetIcon}; let (realm, world, instances, _calls) = party_topology(); form_split_party(&world, &instances); - *realm.peers.lock().unwrap() = vec![world.clone(), instances.clone()]; + *realm.topology.peers.lock().unwrap() = vec![world.clone(), instances.clone()]; let mut roster = realm.group_roster(GINGER).unwrap().unwrap().list_payload(); roster.target_icons = vec![TargetIcon { icon: 7, @@ -4344,17 +4735,17 @@ fn world_entry_asks_for_a_partys_target_icons_after_the_list() { group_list(rx.try_recv().expect("the list goes out first")); assert_eq!( - realm.party.lock().unwrap().ops.last().copied(), + realm.party.party.lock().unwrap().ops.last().copied(), Some((realm_op::TARGET_ICON, VIM, 0, 0xFF, 0, 0)), "then the list request, whose answer rides the relay behind the list" ); party::run(world.as_ref(), 7, GINGER, party::Op::RaidConvert).unwrap(); - let ops_before = realm.party.lock().unwrap().ops.len(); + 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"); assert_eq!( - realm.party.lock().unwrap().ops.len(), + realm.party.party.lock().unwrap().ops.len(), ops_before, "a Raid keeps its marks through a list" ); @@ -4363,12 +4754,18 @@ fn world_entry_asks_for_a_partys_target_icons_after_the_list() { /// A lost broadcast must not end the session: nothing waits on a ping, and the roster is unchanged. #[test] fn a_group_broadcast_lost_in_transport_keeps_the_session() { - let s = InMemoryStore { - group_broadcast_error: true, - ..quest_store() + let s = { + let base = quest_store(); + WorldFake { + party: PartyState { + group_broadcast_error: true, + ..base.party + }, + ..base + } }; { - let mut p = s.party.lock().unwrap(); + let mut p = s.party.party.lock().unwrap(); p.groups.push((5, 1, 3, 2, 0)); p.members.push((5, 1)); p.members.push((5, 2)); @@ -4385,8 +4782,9 @@ fn a_group_broadcast_lost_in_transport_keeps_the_session() { } /// Whether `shard`'s mirror of Group `group_id` lists `member`. -fn mirror_lists(shard: &InMemoryStore, group_id: u64, member: u64) -> bool { +fn mirror_lists(shard: &WorldFake, group_id: u64, member: u64) -> bool { shard + .party .mirror .lock() .unwrap() @@ -4397,17 +4795,14 @@ fn mirror_lists(shard: &InMemoryStore, group_id: u64, member: u64) -> bool { /// A three-member party with Vim inside the dungeon on `instances`. From here on Realm-core's /// Coordinator cache lags every call-pipe commit. -fn lagging_three_member_party() -> ( - std::sync::Arc, - std::sync::Arc, - u64, -) { +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(); let group_id = realm.group_roster(VIM).unwrap().unwrap().group_id; realm + .party .cache_lags .store(true, std::sync::atomic::Ordering::SeqCst); (world, instances, group_id) @@ -4446,6 +4841,7 @@ fn a_bot_leave_that_disbands_reaches_the_instance_pool_while_the_realm_cache_lag let group_id = realm.group_roster(VIM).unwrap().unwrap().group_id; assert!(mirror_lists(&instances, group_id, VIM)); realm + .party .cache_lags .store(true, std::sync::atomic::Ordering::SeqCst); @@ -4455,6 +4851,7 @@ fn a_bot_leave_that_disbands_reaches_the_instance_pool_while_the_realm_cache_lag ); assert!( !instances + .party .mirror .lock() .unwrap() @@ -4472,8 +4869,181 @@ fn a_rejoin_retries_a_failed_mirror_push() { party::run(world.as_ref(), 7, GINGER, party::Op::Invite(VIM)).unwrap(); instances + .party .mirror_failures .store(2, std::sync::atomic::Ordering::SeqCst); party::run(instances.as_ref(), 8, VIM, party::Op::Accept).unwrap(); assert!(mirror_lists(&instances, group_id, VIM)); } + +#[test] +fn group_invite_by_name_replies_party_command_result_success() { + // CMSG_GROUP_INVITE "Buddy" resolves the name, calls the store, and echoes + // SMSG_PARTY_COMMAND_RESULT(Invite, "Buddy", Success); the store recorded the resolved guid. + let mut s = quest_store(); + s.characters = vec![codec::CharacterView { + guid: 2, + name: "Buddy".into(), + class: 1, + level: 10, + zone_id: 12, + ..Default::default() + }]; + let store = std::sync::Arc::new(s); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); + + wow_world_messages::vanilla::CMSG_GROUP_INVITE { + name: "buddy".into(), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_PARTY_COMMAND_RESULT(r) => { + assert_eq!(r.result, wow_world_messages::vanilla::PartyResult::Success); + assert_eq!( + r.operation, + wow_world_messages::vanilla::PartyOperation::Invite + ); + assert_eq!(r.member, "buddy"); + } + other => panic!("expected SMSG_PARTY_COMMAND_RESULT, got {other}"), + } + assert_eq!(store.party.group_invites.lock().unwrap().as_slice(), &[2]); + drop(client); + let _ = server.join(); +} + +/// Every party Refusal the Module can send reaches the client as exactly one `PartyResult`, through +/// the real store seam rather than the mapping function alone. +#[test] +fn every_group_refusal_reaches_the_client_as_one_party_result() { + use wow_world_messages::vanilla::PartyResult; + for refusal in GroupRefusal::ALL { + let want = match refusal { + GroupRefusal::AlreadyInGroup => PartyResult::AlreadyInGroup, + GroupRefusal::GroupFull => PartyResult::GroupFull, + GroupRefusal::NotLeader => PartyResult::NotLeader, + GroupRefusal::NotInGroup => PartyResult::NotInGroup, + GroupRefusal::TargetNotInGroup => PartyResult::TargetNotInGroup, + GroupRefusal::WrongFaction => PartyResult::PlayerWrongFaction, + _ => PartyResult::BadPlayerName, + }; + let mut s = quest_store(); + s.characters = vec![codec::CharacterView { + guid: 2, + name: "Buddy".into(), + ..Default::default() + }]; + s.trade_error = Some(refusal.as_tag().to_string()); + let store = std::sync::Arc::new(s); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); + wow_world_messages::vanilla::CMSG_GROUP_INVITE { + name: "Buddy".into(), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_PARTY_COMMAND_RESULT(r) => { + assert_eq!(r.result, want, "{refusal:?} must map to {want:?}") + } + other => panic!("expected SMSG_PARTY_COMMAND_RESULT, got {other}"), + } + drop(client); + let _ = server.join(); + } +} + +/// A reducer that timed out left the party in an unknown state, so it must end the session rather +/// than pose as a gameplay answer the client renders. +#[test] +fn a_group_invite_timeout_is_not_answered_as_a_refusal() { + let mut s = quest_store(); + s.characters = vec![codec::CharacterView { + guid: 2, + name: "Buddy".into(), + ..Default::default() + }]; + s.session.login_entity = Some(warrior_entity()); + s.trade_error = Some("gw_group_invite reducer timed out after 10s".into()); + let store = std::sync::Arc::new(s); + let (mut client, server_end) = world_session_socket_pair(); + let server_store = store.clone(); + let (result_tx, result_rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + result_tx + .send(run_world_session(server_end, server_store.clone())) + .unwrap(); + }); + 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); + wow_world_messages::vanilla::CMSG_GROUP_INVITE { + name: "Buddy".into(), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + let error = result_rx + .recv_timeout(std::time::Duration::from_secs(1)) + .expect("an unknown party outcome must end the session promptly") + .expect_err("a timed-out party reducer must be session-fatal"); + assert!(format!("{error:#}").contains("timed out")); +} + +#[test] +fn group_invite_unknown_name_replies_bad_player_name() { + // An unresolvable name never reaches the store — the reply is BadPlayerName ("player not found"). + let store = std::sync::Arc::new(quest_store()); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); + + wow_world_messages::vanilla::CMSG_GROUP_INVITE { + name: "Nobody".into(), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_PARTY_COMMAND_RESULT(r) => { + assert_eq!( + r.result, + wow_world_messages::vanilla::PartyResult::BadPlayerName + ); + } + other => panic!("expected SMSG_PARTY_COMMAND_RESULT, got {other}"), + } + assert!(store.party.group_invites.lock().unwrap().is_empty()); + drop(client); + let _ = server.join(); +} + +#[test] +fn loot_method_dispatches_the_decoded_setting_threshold_and_master() { + // CMSG_LOOT_METHOD (leader sets MASTER LOOT, Epic threshold, master guid 7) must reach the + // store with the gateway-decoded wire bytes — a direct pass-through (module adopted the wire + // ordering verbatim), no separate ack packet (vanilla sends none for this opcode either). + let store = std::sync::Arc::new(quest_store()); + let (mut client, mut c_enc, _c_dec, server) = enter_world(store.clone(), 1); + CMSG_LOOT_METHOD { + loot_setting: GroupLootSetting::MasterLoot, + loot_master: Guid::new(7), + loot_threshold: ItemQuality::Epic, + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + // No reply packet for this opcode — send a harmless follow-up (CMSG_LOOT_MONEY, a no-op here + // with no tracked target) and confirm it's the NEXT thing the server processes, proving the + // method call didn't hang the dispatch loop waiting to send something. + CMSG_LOOT_MONEY {} + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + drop(client); + server.join().unwrap(); + assert_eq!( + store.party.group_loot_methods.lock().unwrap().as_slice(), + &[( + GroupLootSetting::MasterLoot.as_int(), + 7, + ItemQuality::Epic.as_int() + )] + ); +} diff --git a/gateway/src/world/presence.rs b/gateway/src/world/presence.rs index ffea5d8db..9329b344f 100644 --- a/gateway/src/world/presence.rs +++ b/gateway/src/world/presence.rs @@ -3,12 +3,12 @@ //! signal, read best-effort from whichever connected Shard answers first. A NEGATIVE claim — //! `Whereabouts::Offline`, or [`of`] answering `None` for a guid with no row anywhere — needs every //! configured World Shard to vouch that it genuinely has nothing, because a Shard whose Coordinator -//! subscription has gone stale could be hiding the Character. [`WorldStore::every_shard_vouches_for_absence`] +//! subscription has gone stale could be hiding the Character. [`SocialStore::every_shard_vouches_for_absence`] //! is that gate, and [`of`] never returns either without it succeeding first. +use crate::world::{CharacterStore, SessionStore, ShardRoutingStore, SocialStore}; use anyhow::Result; -use super::WorldStore; use crate::codec; /// One Character's Realm Presence, as whichever World Shard holds it reports it. @@ -37,7 +37,7 @@ pub(crate) enum Whereabouts { InWorld { away: AwayStatus, entity: codec::MemberEntity, - /// [`WorldStore::shard_name`] of the Shard the live entity answered from — Member Stats' + /// [`ShardRoutingStore::shard_name`] of the Shard the live entity answered from — Member Stats' /// own aura and pet reads key off it. shard_name: String, }, @@ -83,7 +83,7 @@ pub(crate) struct AutoReply { /// Takes the Realm Presence the caller already read, so one decision reads it once. The Away /// Status comes from the live entity's `PLAYER_FLAGS`; the text is read on the Shard that /// answered with that entity, because the Auto-Reply lives beside it on the Home Shard. -pub(crate) fn auto_reply( +pub(crate) fn auto_reply( store: &St, presence: &RealmPresence, ) -> Result> { @@ -188,7 +188,10 @@ impl CharacterIdentity { /// answers `InTransit`. Only once neither fires does the answer become a negative claim — /// `Offline`, or `None` for a guid no Shard ever named — and a negative claim needs every /// configured Shard to vouch first (see the module doc). -pub(crate) fn of(store: &St, guid: u64) -> Result> { +pub(crate) fn of( + store: &St, + guid: u64, +) -> Result> { let identity = character_identity_anywhere(store, guid)?; if let Some((shard_name, entity)) = live_entity_anywhere(store, guid) { @@ -226,7 +229,7 @@ pub(crate) fn of(store: &St, guid: u64) -> Result( +pub(crate) fn character_identity_anywhere( store: &St, guid: u64, ) -> Result> { @@ -242,7 +245,7 @@ pub(crate) fn character_identity_anywhere( } /// A live entity from whichever connected Shard answers first, paired with that Shard's name. -fn live_entity_anywhere( +fn live_entity_anywhere( store: &St, guid: u64, ) -> Option<(String, codec::MemberEntity)> { @@ -258,7 +261,7 @@ fn live_entity_anywhere( /// Realm-core's own pending-Transfer signal for `guid`. `false` on an unsharded Gateway, which has /// no Realm-core to ask (`realm_store()` answers `None`). -fn realm_transfer_pending(store: &St, guid: u64) -> Result { +fn realm_transfer_pending(store: &St, guid: u64) -> Result { let Some(realm) = store.realm_store() else { return Ok(false); }; @@ -271,7 +274,7 @@ fn realm_transfer_pending(store: &St, guid: u64) -> Res /// source. Scans each Shard exactly once: `world_stores()` already includes a handle for this /// Shard's own database once there is more than one connected, so this handle is asked directly /// only when `world_stores()` is empty (unsharded). -pub(crate) fn in_world_characters( +pub(crate) fn in_world_characters( store: &St, ) -> Result> { let peers = store.world_stores(); @@ -298,7 +301,7 @@ pub(crate) fn in_world_characters( /// Resolve a typed Character name to a guid across every connected Shard: this handle first, then /// every `world_stores()` handle, first hit wins. `world_stores()` is empty on an unsharded /// Gateway, so this is the one cache read it always was. -pub(crate) fn resolve_by_name( +pub(crate) fn resolve_by_name( store: &St, name: &str, ) -> Result> { @@ -317,7 +320,7 @@ pub(crate) fn resolve_by_name( /// every connected Shard. Character names are unique per Shard, not per Realm, so the same name /// can name two different Characters at once; a caller that needs one picks among the candidates /// (see [`super::whisper`]). Deduped, because `world_stores()` includes the asking Shard. -pub(crate) fn resolve_all_by_name( +pub(crate) fn resolve_all_by_name( store: &St, name: &str, ) -> Result> { @@ -337,7 +340,7 @@ pub(crate) fn resolve_all_by_name( /// [`resolve_by_name`] inverted: the Character row for `guid` from whichever connected Shard holds /// it. Same first-hit-wins union, same unsharded short-circuit. -pub(crate) fn character_anywhere( +pub(crate) fn character_anywhere( store: &St, guid: u64, ) -> Result> { @@ -357,7 +360,10 @@ pub(crate) fn character_anywhere( /// `game_world_entity` and never runs `player_login`, so its session flag stays false for its /// whole life. [`of`] exposes both, as `Whereabouts::InWorld` and `session_online`; this is the /// `in_world`-only shortcut a caller that does not need the rest of [`RealmPresence`] keeps using. -pub(crate) fn live_anywhere(store: &St, guid: u64) -> bool { +pub(crate) fn live_anywhere( + store: &St, + guid: u64, +) -> bool { store.entity_in_world(guid) || store.world_stores().iter().any(|s| s.entity_in_world(guid)) } diff --git a/gateway/src/world/presence_tests.rs b/gateway/src/world/presence_tests.rs index 122910b1e..238ef7dee 100644 --- a/gateway/src/world/presence_tests.rs +++ b/gateway/src/world/presence_tests.rs @@ -8,14 +8,19 @@ //! //! A live entity is `member_entities` alone (`place`, below) — not `live_guids`, which models a //! different, party-eligibility presence and stays independent of it on purpose (see `live_entity` -//! in `tests.rs`). +//! in `test_support/world_fake/social.rs`). use super::party_tests::{character, party_topology, BOT, DORMANT, GINGER, VIM}; use super::*; /// Give `shard` a live Member Stats entity for `guid` — the one signal `live_entity` reads. -fn place(shard: &InMemoryStore, guid: u64, entity: codec::MemberEntity) { - shard.member_entities.lock().unwrap().push((guid, entity)); +fn place(shard: &WorldFake, guid: u64, entity: codec::MemberEntity) { + shard + .social + .member_entities + .lock() + .unwrap() + .push((guid, entity)); } /// **AC 6 (part 1): `presence::of` answers for a Character on another Shard**, reporting @@ -65,7 +70,7 @@ fn presence_of_crosses_the_shard_boundary_and_tells_in_world_apart_from_session_ /// no Away Status at all. #[test] fn presence_of_reads_away_status_from_the_live_entitys_player_flags() { - let store = std::sync::Arc::new(InMemoryStore { + let store = std::sync::Arc::new(WorldFake { characters: vec![character(GINGER, "Ginger")], ..Default::default() }); @@ -85,7 +90,7 @@ fn presence_of_reads_away_status_from_the_live_entitys_player_flags() { other => panic!("expected InWorld, got {other:?}"), } - let no_entity = std::sync::Arc::new(InMemoryStore { + let no_entity = std::sync::Arc::new(WorldFake { characters: vec![character(DORMANT, "Dormant")], ..Default::default() }); @@ -103,22 +108,28 @@ fn presence_of_reads_away_status_from_the_live_entitys_player_flags() { #[test] fn a_live_hit_beats_a_hit_without_one_regardless_of_shard_order() { let calls: ShardCallLog = Default::default(); - let source = std::sync::Arc::new(InMemoryStore { - shard: "source".into(), - calls: calls.clone(), + let source = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "source".into(), + calls: calls.clone(), + ..Default::default() + }, characters: vec![character(GINGER, "Ginger")], // No live entity: the frozen source row has none. ..Default::default() }); - let destination = std::sync::Arc::new(InMemoryStore { - shard: "destination".into(), - calls, + let destination = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "destination".into(), + calls, + ..Default::default() + }, characters: vec![character(GINGER, "Ginger")], ..Default::default() }); place(&destination, GINGER, codec::MemberEntity::default()); for shard in [&source, &destination] { - *shard.peers.lock().unwrap() = vec![source.clone(), destination.clone()]; + *shard.topology.peers.lock().unwrap() = vec![source.clone(), destination.clone()]; } let found = presence::of(source.as_ref(), GINGER).unwrap().unwrap(); @@ -135,15 +146,24 @@ fn a_live_hit_beats_a_hit_without_one_regardless_of_shard_order() { fn one_shard_topology( guid: u64, live: bool, -) -> (std::sync::Arc, std::sync::Arc) { - let realm = std::sync::Arc::new(InMemoryStore { - shard: "realm".into(), - is_realm: true, +) -> (std::sync::Arc, std::sync::Arc) { + let realm = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "realm".into(), + ..Default::default() + }, + party: PartyState { + is_realm: true, + ..Default::default() + }, ..Default::default() }); - let world = std::sync::Arc::new(InMemoryStore { - shard: "world".into(), - realm: Some(realm.clone()), + let world = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "world".into(), + realm: Some(realm.clone()), + ..Default::default() + }, characters: vec![character(guid, "Named")], ..Default::default() }); @@ -160,13 +180,16 @@ fn one_shard_topology( #[test] fn a_live_entity_on_any_connected_shard_is_live_even_while_another_shard_is_down() { let (_realm, world) = one_shard_topology(GINGER, true); - let down = std::sync::Arc::new(InMemoryStore { - shard: "instances".into(), - world_shard_set_error: Some("instances has no healthy Coordinator subscription".into()), + let down = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "instances".into(), + world_shard_set_error: Some("instances has no healthy Coordinator subscription".into()), + ..Default::default() + }, ..Default::default() }); - *world.peers.lock().unwrap() = vec![world.clone(), down.clone()]; - *down.peers.lock().unwrap() = vec![world.clone(), down.clone()]; + *world.topology.peers.lock().unwrap() = vec![world.clone(), down.clone()]; + *down.topology.peers.lock().unwrap() = vec![world.clone(), down.clone()]; let presence = presence::of(world.as_ref(), GINGER).unwrap().unwrap(); assert!( @@ -181,13 +204,16 @@ fn a_live_entity_on_any_connected_shard_is_live_even_while_another_shard_is_down #[test] fn a_pending_transfer_on_realm_core_is_in_transit() { let (realm, world) = one_shard_topology(GINGER, false); - *realm.members_in_transit.lock().unwrap() = vec![GINGER]; - let down = std::sync::Arc::new(InMemoryStore { - shard: "instances".into(), - world_shard_set_error: Some("down".into()), + *realm.party.members_in_transit.lock().unwrap() = vec![GINGER]; + let down = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "instances".into(), + world_shard_set_error: Some("down".into()), + ..Default::default() + }, ..Default::default() }); - *world.peers.lock().unwrap() = vec![world.clone(), down.clone()]; + *world.topology.peers.lock().unwrap() = vec![world.clone(), down.clone()]; let presence = presence::of(world.as_ref(), GINGER).unwrap().unwrap(); assert_eq!(presence.whereabouts, presence::Whereabouts::InTransit); @@ -199,13 +225,19 @@ fn a_pending_transfer_on_realm_core_is_in_transit() { #[test] fn a_character_between_places_on_any_shard_is_in_transit() { let (_realm, world) = one_shard_topology(BOT, false); - let instances = std::sync::Arc::new(InMemoryStore { - shard: "instances".into(), - members_between_places: vec![BOT].into(), + let instances = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "instances".into(), + ..Default::default() + }, + social: SocialState { + members_between_places: vec![BOT].into(), + ..Default::default() + }, ..Default::default() }); - *world.peers.lock().unwrap() = vec![world.clone(), instances.clone()]; - *instances.peers.lock().unwrap() = vec![world.clone(), instances.clone()]; + *world.topology.peers.lock().unwrap() = vec![world.clone(), instances.clone()]; + *instances.topology.peers.lock().unwrap() = vec![world.clone(), instances.clone()]; let presence = presence::of(world.as_ref(), BOT).unwrap().unwrap(); assert_eq!(presence.whereabouts, presence::Whereabouts::InTransit); @@ -216,22 +248,28 @@ fn a_character_between_places_on_any_shard_is_in_transit() { #[test] fn offline_needs_every_configured_shard_healthy() { let (_realm, world) = one_shard_topology(DORMANT, false); - let down = std::sync::Arc::new(InMemoryStore { - shard: "instances".into(), - world_shard_set_error: Some("instances has no healthy Coordinator subscription".into()), + let down = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "instances".into(), + world_shard_set_error: Some("instances has no healthy Coordinator subscription".into()), + ..Default::default() + }, ..Default::default() }); - *world.peers.lock().unwrap() = vec![world.clone(), down.clone()]; + *world.topology.peers.lock().unwrap() = vec![world.clone(), down.clone()]; assert!( presence::of(world.as_ref(), DORMANT).is_err(), "an unhealthy configured Shard must refuse to let Offline through" ); - let healthy = std::sync::Arc::new(InMemoryStore { - shard: "instances".into(), + let healthy = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "instances".into(), + ..Default::default() + }, ..Default::default() }); - *world.peers.lock().unwrap() = vec![world.clone(), healthy.clone()]; + *world.topology.peers.lock().unwrap() = vec![world.clone(), healthy.clone()]; let presence = presence::of(world.as_ref(), DORMANT).unwrap().unwrap(); assert_eq!(presence.whereabouts, presence::Whereabouts::Offline); } @@ -242,13 +280,16 @@ fn offline_needs_every_configured_shard_healthy() { /// no entity — reads it as `InTransit` instead, decoupled from `session_online`. #[test] fn a_frozen_pre_transfer_copy_reads_online_but_not_in_world() { - let store = std::sync::Arc::new(InMemoryStore { + let store = std::sync::Arc::new(WorldFake { characters: vec![character(GINGER, "Ginger")], - // Not in `offline_guids`: session-online. No live entity. `members_between_places` is this - // Shard's own signal for it, the same one `character_in_transit` reads in production from - // `game_character.online` with no entity present — exactly the frozen source row - // `begin_transfer` leaves behind. - members_between_places: vec![GINGER].into(), + social: SocialState { + // Not in `offline_guids`: session-online. No live entity. `members_between_places` is this + // Shard's own signal for it, the same one `character_in_transit` reads in production from + // `game_character.online` with no entity present — exactly the frozen source row + // `begin_transfer` leaves behind. + members_between_places: vec![GINGER].into(), + ..Default::default() + }, ..Default::default() }); let presence = presence::of(store.as_ref(), GINGER).unwrap().unwrap(); diff --git a/gateway/src/world/quest_tests.rs b/gateway/src/world/quest_tests.rs new file mode 100644 index 000000000..61450b44b --- /dev/null +++ b/gateway/src/world/quest_tests.rs @@ -0,0 +1,171 @@ +//! Quest traffic over the world session. Which screen a quest opens, and which durable request it +//! makes, is decided and proved at the `dispatch_quest_action` seam (`handlers/quest.rs`). What is +//! left here is only what the seam cannot see: that a quest opcode reaches the seam through the +//! full handshake + login + cipher, and that the bodies it answers with survive the encrypted +//! frame. + +use super::*; + +#[test] +fn quest_choose_reward_relays_inventory_before_completion_over_the_cipher() { + // The socket-level contract: the subscribed reducer callback has already queued the complete + // item insertion when turn_in_quest returns, so the session's completion presentation must sit + // behind CREATE, its inventory pointer and gain feedback on the one writer queue. + let mut s = quest_store(); + let detail = detail_view(1234, "A Threat Within"); + s.quest.quest_details = vec![detail.clone()]; + let reward_item = codec::ItemInstanceView { + guid: 0x4000_0000_0000_0042, + entry: 25, + owner_guid: 1, + slot: 23, + stack_count: 2, + durability: 20, + max_durability: 20, + container_slots: 0, + random_property_id: 0, + random_property_enchant_ids: [0; 3], + item_text_id: 0, + enchantment: 0, + }; + s.session.turn_in_reward_item = Some(reward_item.clone()); + let store = std::sync::Arc::new(s); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); + CMSG_QUESTGIVER_CHOOSE_REWARD { + guid: Guid::new(50), + quest_id: 1234, + reward: 2, + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + let framed = |message: ServerOpcodeMessage| { + let mut bytes = Vec::new(); + message.write_unencrypted_server(&mut bytes).unwrap(); + ( + u16::from_le_bytes([bytes[2], bytes[3]]), + bytes[4..].to_vec(), + ) + }; + let expected = [ + framed(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( + codec::build_item_create_object(&reward_item), + ))), + framed(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( + codec::build_inv_slot_values( + reward_item.owner_guid, + reward_item.slot, + reward_item.guid, + ) + .unwrap(), + ))), + framed(ServerOpcodeMessage::SMSG_ITEM_PUSH_RESULT(Box::new( + codec::build_item_push_result( + reward_item.owner_guid, + 255, + reward_item.slot as u32, + reward_item.entry, + reward_item.stack_count, + false, + 0, + ), + ))), + framed(ServerOpcodeMessage::SMSG_QUESTGIVER_QUEST_COMPLETE( + Box::new(codec::build_quest_complete(&detail)), + )), + ]; + let actual = std::array::from_fn(|_| read_raw_frame(&mut client, &mut c_dec)); + assert_eq!( + actual, expected, + "inventory visibility must precede completion" + ); + drop(client); + server.join().unwrap(); + assert_eq!( + store.quest.turned_in.lock().unwrap().as_slice(), + &[(7, 50, 1234, 2)] + ); +} + +#[test] +fn login_sends_the_quest_log_descriptor_raw_update_after_the_create_packet() { + let slots = vec![codec::update_mask::QuestLogSlot { + slot: 3, + quest_id: 777, + counts: Vec::new(), + state: 0, + timer: 0, + }]; + let mut s = quest_store(); + s.quest.quest_log_slots = slots.clone(); + let store = std::sync::Arc::new(s); + + 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()).unwrap(); + }); + 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(); + + // World entry — discarded, this test is about what comes right after. + drain_world_entry(&mut client, &mut c_dec); + // gtker's typed reader rejects this raw partial VALUES body (no OBJECT_FIELD_TYPE), so read it + // RAW and compare it against the same builder the seam's `quest_log_update` calls. + let (opcode, body) = read_raw_frame(&mut client, &mut c_dec); + let mask = codec::update_mask::full_quest_log_mask(&slots); + assert_eq!((opcode, body), codec::build_values_update_raw(1, &mask)); + + drop(client); + server.join().unwrap(); +} + +#[test] +fn quest_hello_reaches_the_quest_module_and_its_raw_details_body_survives_the_cipher() { + // The socket-level contract: dispatch routes HELLO to the quest module, and the raw-encoded + // DETAILS screen it returns crosses the encrypted frame intact. Which screen a giver opens is + // decided at the `dispatch_quest_action` seam and proved there. + const OP_QUEST_DETAILS: u16 = 0x0188; + let mut s = quest_store(); + s.quest.quest_evals = vec![eval(1234, codec::ROLE_START, false, false)]; + s.quest.quest_details = vec![detail_view(1234, "A Threat Within")]; + let store = std::sync::Arc::new(s); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); + CMSG_QUESTGIVER_HELLO { + guid: Guid::new(50), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + let (op, body) = read_raw_frame(&mut client, &mut c_dec); + assert_eq!(op, OP_QUEST_DETAILS); + assert_eq!( + &body[..12], + &codec::build_quest_details_raw(50, &detail_view(1234, "A Threat Within")).1[..12], + "giver guid + quest id reached the wire unchanged" + ); + drop(client); + server.join().unwrap(); +} + +#[test] +fn quest_query_answers_the_raw_definition_body_through_the_cipher() { + // The socket-level contract for the client's cold-cache definition query: the hand-rolled 5875 + // body crosses the encrypted frame intact. Which quests answer at all is proved at the seam. + const OP_QUEST_QUERY_RESPONSE: u16 = 0x005D; + let mut s = quest_store(); + s.quest.quest_details = vec![detail_view(1234, "A Threat Within")]; + let store = std::sync::Arc::new(s); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); + CMSG_QUEST_QUERY { quest_id: 1234 } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + let (op, body) = read_raw_frame(&mut client, &mut c_dec); + assert_eq!(op, OP_QUEST_QUERY_RESPONSE); + assert_eq!( + body, + codec::build_quest_query_response_raw(&detail_view(1234, "A Threat Within")).1 + ); + drop(client); + server.join().unwrap(); +} diff --git a/gateway/src/world/session_tests.rs b/gateway/src/world/session_tests.rs new file mode 100644 index 000000000..f84cfaec3 --- /dev/null +++ b/gateway/src/world/session_tests.rs @@ -0,0 +1,2279 @@ +//! Session-level socket tests: the handshake and its deadlines, the admission queue, world entry +//! and world-port re-entry, movement and its coalescing, logout, and the order a World Session +//! closes in. + +use super::*; + +#[derive(Clone)] +struct ManualClock(Rc>); + +impl DeadlineClock for ManualClock { + fn now(&self) -> Instant { + self.0.get() + } +} + +struct AdvancingStream { + input: Cursor>, + output: Vec, + clock: ManualClock, + per_read: Duration, + per_write: Duration, + write_timeout_calls: Rc>, +} + +impl Read for AdvancingStream { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let one_byte = buf.len().min(1); + let read = self.input.read(&mut buf[..one_byte])?; + self.clock.0.set(self.clock.0.get() + self.per_read); + Ok(read) + } +} + +impl Write for AdvancingStream { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.output.extend_from_slice(buf); + self.clock.0.set(self.clock.0.get() + self.per_write); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +impl IoDeadline for AdvancingStream { + fn set_read_timeout(&self, _timeout: Option) -> std::io::Result<()> { + Ok(()) + } + + fn set_write_timeout(&self, _timeout: Option) -> std::io::Result<()> { + self.write_timeout_calls + .set(self.write_timeout_calls.get() + 1); + Ok(()) + } +} + +#[test] +fn world_session_socket_pair_times_out_when_the_server_writes_nothing() { + let (mut client, _server) = world_session_socket_pair(); + let mut byte = [0]; + let error = client + .read(&mut byte) + .expect_err("a silent server must hit the world-session read deadline"); + assert!( + matches!( + error.kind(), + std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock + ), + "a silent server must time out, got {error}" + ); +} + +#[test] +fn handshake_succeeds_and_traffic_is_encrypted_both_ways() { + let store = std::sync::Arc::new(tester_store(42)); + + let (mut client, server_end) = world_session_socket_pair(); + let server_store = store.clone(); + let server = std::thread::spawn(move || { + let mut s = server_end; + let (mut conn, _encrypt) = world_handshake(&mut s, server_store.clone()) + .unwrap() + .expect("handshake should succeed"); + assert_eq!(conn.account_id, 42); + assert_eq!(conn.account_name, "TESTER"); + // Prove the inbound cipher works: read one encrypted client message. + match ClientOpcodeMessage::read_encrypted(&mut s, &mut conn.decrypt).unwrap() { + ClientOpcodeMessage::CMSG_CHAR_ENUM => {} + other => panic!("expected encrypted CMSG_CHAR_ENUM, got {other}"), + } + }); + + // --- client: drive the shared challenge→proof→AUTH_SESSION prefix (`drive_auth`) --- + let (mut c_enc, mut c_dec) = drive_auth(&mut client, "TESTER", K); + + // --- client: the AUTH_OK response is the first ENCRYPTED packet --- + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_AUTH_RESPONSE(r) => { + assert!(matches!(*r, SMSG_AUTH_RESPONSE::AuthOk { .. })); + } + other => panic!("expected encrypted SMSG_AUTH_RESPONSE, got {other}"), + } + + // --- client: send an encrypted CMSG the server decrypts --- + CMSG_CHAR_ENUM {} + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + + drop(client); + server.join().unwrap(); +} + +/// The typed decoder would size a buffer from the addon field and unwrap the zlib. The gateway +/// never reads the addon list, so a 4 GiB claim over garbage bytes changes nothing: the proof is +/// checked and AUTH_OK goes out. +#[test] +fn an_auth_session_with_an_absurd_addon_size_still_completes_the_handshake() { + let store = std::sync::Arc::new(tester_store(42)); + 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 server_seed = match ServerOpcodeMessage::read_unencrypted(&mut client).unwrap() { + ServerOpcodeMessage::SMSG_AUTH_CHALLENGE(c) => c.server_seed, + other => panic!("expected SMSG_AUTH_CHALLENGE, got {other}"), + }; + let client_seed = ProofSeed::new(); + let client_seed_value = client_seed.seed(); + let (client_proof, crypto) = + client_seed.into_client_header_crypto(&ns("TESTER"), K, server_seed); + let (_c_enc, mut c_dec) = crypto.split(); + + // Hand-built frame: the fixed fields, then a 4 GiB decompressed-size claim over garbage. + let mut body = Vec::new(); + body.extend_from_slice(&5875u32.to_le_bytes()); + body.extend_from_slice(&1u32.to_le_bytes()); + body.extend_from_slice(b"TESTER\0"); + body.extend_from_slice(&client_seed_value.to_le_bytes()); + body.extend_from_slice(&client_proof); + body.extend_from_slice(&u32::MAX.to_le_bytes()); + body.extend_from_slice(&[0xFF; 32]); + let mut frame = Vec::new(); + frame.extend_from_slice(&((body.len() + 4) as u16).to_be_bytes()); + frame.extend_from_slice(&CMSG_AUTH_SESSION_OPCODE.to_le_bytes()); + frame.extend_from_slice(&body); + client.write_all(&frame).unwrap(); + + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_AUTH_RESPONSE(r) => { + assert!(matches!(*r, SMSG_AUTH_RESPONSE::AuthOk { .. })); + } + other => panic!("expected encrypted AUTH_OK, got {other}"), + } + drop(client); + server.join().unwrap().unwrap(); +} + +/// A peer that reads the challenge and never answers must give its blocking thread back at the +/// total pre-auth deadline. +#[test] +fn a_silent_world_connection_is_closed_at_the_pre_auth_deadline() { + let store = tester_store(42); + let (mut client, server_end) = world_session_socket_pair(); + let mut deadline = PreAuthDeadline::after(Duration::from_millis(200)); + let server = std::thread::spawn(move || { + run_world_session_with_queue_and_deadline( + server_end, + std::sync::Arc::new(store), + &LoginQueue::unlimited(), + &mut deadline, + ) + }); + + ServerOpcodeMessage::read_unencrypted(&mut client).unwrap(); + let err = server + .join() + .unwrap() + .expect_err("a silent peer must be cut at the deadline"); + assert!( + err.to_string().contains("total pre-auth deadline"), + "{err:#}" + ); + let mut byte = [0u8]; + assert_eq!( + client.read(&mut byte).unwrap(), + 0, + "the socket must be closed" + ); +} + +#[test] +fn slow_auth_session_bytes_cannot_extend_the_pre_auth_deadline() { + let store = tester_store(42); + let mut input = Vec::new(); + auth_session("TESTER", 1, [0; 20]) + .write_unencrypted_client(&mut input) + .unwrap(); + let start = Instant::now(); + let clock = ManualClock(Rc::new(Cell::new(start))); + let mut deadline = PreAuthDeadline::with_clock(start + Duration::from_millis(8), clock.clone()); + let mut stream = AdvancingStream { + input: Cursor::new(input), + output: Vec::new(), + clock, + per_read: Duration::from_millis(1), + per_write: Duration::ZERO, + write_timeout_calls: Rc::new(Cell::new(0)), + }; + + let result = world_handshake_with_queue_and_deadline( + &mut stream, + std::sync::Arc::new(store), + &LoginQueue::unlimited(), + &mut deadline, + ); + let error = match result { + Err(error) => error, + Ok(_) => panic!("a slow auth frame must share one total deadline"), + }; + + assert!( + error.to_string().contains("total pre-auth deadline"), + "{error:#}" + ); + assert!( + !stream.output.is_empty(), + "the server challenge must be written before the slow client frame times out" + ); +} + +#[test] +fn world_challenge_write_uses_the_absolute_pre_auth_deadline() { + let store = tester_store(42); + let start = Instant::now(); + let clock = ManualClock(Rc::new(Cell::new(start))); + let write_timeout_calls = Rc::new(Cell::new(0)); + let mut deadline = + PreAuthDeadline::with_clock(start + Duration::from_millis(10), clock.clone()); + let mut stream = AdvancingStream { + input: Cursor::new(Vec::new()), + output: Vec::new(), + clock, + per_read: Duration::ZERO, + per_write: Duration::from_millis(20), + write_timeout_calls: write_timeout_calls.clone(), + }; + + let result = world_handshake_with_queue_and_deadline( + &mut stream, + std::sync::Arc::new(store), + &LoginQueue::unlimited(), + &mut deadline, + ); + let error = match result { + Err(error) => error, + Ok(_) => panic!("the world challenge must not write past the total deadline"), + }; + + assert_eq!( + write_timeout_calls.get(), + 1, + "the world challenge must write through the pre-auth I/O wrapper" + ); + assert!( + error.to_string().contains("pre-auth I/O deadline"), + "{error:#}" + ); + assert!( + !stream.output.is_empty(), + "the fake advances time after accepting the world challenge bytes" + ); +} + +/// Once client proof succeeds, authenticated World Session traffic has no pre-auth deadline. +#[test] +fn post_auth_world_traffic_has_no_pre_auth_deadline() { + let store = std::sync::Arc::new(tester_store(42)); + let (mut client, server_end) = world_session_socket_pair(); + let mut deadline = PreAuthDeadline::after(Duration::from_millis(200)); + let server_store = store.clone(); + let server = std::thread::spawn(move || { + run_world_session_with_queue_and_deadline( + server_end, + server_store.clone(), + &LoginQueue::unlimited(), + &mut deadline, + ) + }); + + let (mut c_enc, mut c_dec) = client_handshake(&mut client, "TESTER", K); + std::thread::sleep(Duration::from_millis(500)); + CMSG_CHAR_ENUM {} + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_CHAR_ENUM(_) => {} + other => panic!("expected SMSG_CHAR_ENUM, got {other}"), + } + drop(client); + server.join().unwrap().unwrap(); +} + +#[test] +fn queued_handshake_sends_wait_queue_then_admits_once_a_seat_frees() { + let store = std::sync::Arc::new(tester_store(42)); + let queue = std::sync::Arc::new(LoginQueue::new(1, 0)); + // Occupy the only seat directly — exactly what an already-connected world session holds. + assert_eq!(queue.request(), Admission::Admitted); + + let (mut client, server_end) = world_session_socket_pair(); + let server_store = store.clone(); + let server_queue = queue.clone(); + let server = std::thread::spawn(move || { + let mut s = server_end; + // Blocks in the queue's wait loop until `queue.depart()` (below) frees the one seat. + let (conn, _encrypt) = + world_handshake_with_queue(&mut s, server_store.clone(), &server_queue) + .unwrap() + .expect("handshake should eventually succeed once admitted"); + assert_eq!(conn.account_id, 42); + }); + + // --- client: drive the shared challenge→proof→AUTH_SESSION prefix (`drive_auth`) --- + let (_c_enc, mut c_dec) = drive_auth(&mut client, "TESTER", K); + + // --- client: the FIRST encrypted response must be AuthWaitQueue at position 1, not AuthOk — + // proving the handshake actually queued instead of bypassing the gate --- + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_AUTH_RESPONSE(r) => { + assert!( + matches!(*r, SMSG_AUTH_RESPONSE::AuthWaitQueue { queue_position: 1 }), + "expected AuthWaitQueue at position 1, got {r:?}" + ); + } + other => panic!("expected encrypted SMSG_AUTH_RESPONSE, got {other}"), + } + + // Free the one seat — mirrors what `run_world_session_with_queue`'s teardown does on a real + // disconnect. The queued handshake must notice on its next poll and proceed to admission. + queue.depart(); + + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_AUTH_RESPONSE(r) => { + assert!( + matches!(*r, SMSG_AUTH_RESPONSE::AuthOk { .. }), + "expected AuthOk, got {r:?}" + ); + } + other => panic!("expected encrypted SMSG_AUTH_RESPONSE, got {other}"), + } + + drop(client); + server.join().unwrap(); +} + +#[test] +fn disconnecting_while_queued_leaves_the_line_without_taking_a_seat() { + let store = std::sync::Arc::new(tester_store(7)); + let queue = std::sync::Arc::new(LoginQueue::new(1, 0)); + assert_eq!(queue.request(), Admission::Admitted); // occupy the only seat + + let (mut client, server_end) = world_session_socket_pair(); + let server_store = store.clone(); + let server_queue = queue.clone(); + let server = std::thread::spawn(move || { + let mut s = server_end; + let result = + world_handshake_with_queue(&mut s, server_store.clone(), &server_queue).unwrap(); + assert!( + result.is_none(), + "a hangup while queued must end the session cleanly, not error" + ); + }); + + let (_c_enc, mut c_dec) = drive_auth(&mut client, "TESTER", K); + + // Read (and discard) the first AuthWaitQueue so we know the server has actually queued us + // before hanging up — a hangup racing the very first send would prove nothing about `cancel`. + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_AUTH_RESPONSE(r) => { + assert!(matches!(*r, SMSG_AUTH_RESPONSE::AuthWaitQueue { .. })); + } + other => panic!("expected AuthWaitQueue, got {other}"), + } + + drop(client); // hang up while still queued + server.join().unwrap(); + + // The seat is still held by the ORIGINAL occupant (never touched); the queued-then-cancelled + // connection must have left NO trace in the line. + assert_eq!( + queue.depth(), + 0, + "the cancelled ticket must not linger in the queue" + ); + assert_eq!( + queue.active(), + 1, + "cancelling a waiter must never grant or free a seat" + ); +} + +#[test] +fn a_restarted_gateway_completes_the_handshake_from_realm_state_alone() { + // The stateless-gateway invariant, now realm-scoped. The session key K lives in + // `game_session` — on realm-core when it is configured, on the world DB when it is not — and + // the gateway keeps NOTHING about it. So killing the gateway mid-session and starting a fresh + // one must let the same account re-handshake with no re-logon, against a brand-new process + // that has never seen this client. + // + // Modelled here as two handshakes against two INDEPENDENT store instances that share only the + // realm-held session row: `store_before` is the gateway that gets killed, `store_after` is the + // replacement. Each handshake mints its own server seed and its own client seed, so nothing + // from the first run can be smuggled into the second — if any handshake input were + // gateway-local rather than realm state, the second run could not succeed. + let realm_session = || WorldSession { + account_id: 42, + session_key: K, + }; + let handshake_once = |store: WorldFake| { + let (mut client, server_end) = world_session_socket_pair(); + let server = std::thread::spawn(move || { + let mut s = server_end; + let established = world_handshake(&mut s, std::sync::Arc::new(store)) + .unwrap() + .expect("handshake should succeed"); + established.0.account_id + }); + client_handshake(&mut client, "TESTER", K); + drop(client); + server.join().unwrap() + }; + + let store_before = WorldFake { + session: SessionState { + username: "TESTER".into(), + session: Some(realm_session()), + ..Default::default() + }, + ..Default::default() + }; + assert_eq!(handshake_once(store_before), 42); + + // ---- the gateway is killed here; every byte of its in-process state is gone ---- + + let store_after = WorldFake { + session: SessionState { + username: "TESTER".into(), + session: Some(realm_session()), // re-READ from the realm, not carried over + ..Default::default() + }, + ..Default::default() + }; + assert_eq!( + handshake_once(store_after), + 42, + "a fresh gateway must re-establish the session from realm-held state alone" + ); +} + +#[test] +fn a_gateway_that_cannot_reach_the_session_store_rejects_rather_than_guessing() { + // The other half of the stateless-gateway invariant: "resume from realm state" must not + // degrade into "resume from anything". A store that cannot answer (realm-core unreachable → + // `lookup_session` yields no session) rejects the handshake plaintext instead of establishing + // a session on an unverified key. `CoordinatorStore` reaches this state by way of + // `Coordinator::realm_core()`'s Err. + let store = WorldFake { + session: SessionState { + username: "TESTER".into(), + session: None, + ..Default::default() + }, + ..Default::default() + }; + let (mut client, server_end) = world_session_socket_pair(); + let server = std::thread::spawn(move || { + let mut s = server_end; + assert!( + world_handshake(&mut s, std::sync::Arc::new(store)) + .unwrap() + .is_none(), + "no session material ⇒ no session, never a best-effort one" + ); + }); + + let (_enc, _dec) = drive_auth(&mut client, "TESTER", K); + match ServerOpcodeMessage::read_unencrypted(&mut client).unwrap() { + ServerOpcodeMessage::SMSG_AUTH_RESPONSE(r) => { + assert!(matches!(*r, SMSG_AUTH_RESPONSE::AuthUnknownAccount)); + } + other => panic!("expected a plaintext rejection, got {other}"), + } + drop(client); + server.join().unwrap(); +} + +#[test] +fn bad_proof_is_rejected() { + // The store hands out a session key, but the client computes its proof against a + // DIFFERENT key, so the server's digest check must fail. + let store = WorldFake { + session: SessionState { + username: "TESTER".into(), + session: Some(WorldSession { + account_id: 1, + session_key: K, + }), + ..Default::default() + }, + ..Default::default() + }; + + let (mut client, server_end) = world_session_socket_pair(); + let server = std::thread::spawn(move || { + let mut s = server_end; + let conn = world_handshake(&mut s, std::sync::Arc::new(store)).unwrap(); + assert!(conn.is_none(), "bad proof must not establish a connection"); + }); + + let server_seed = match ServerOpcodeMessage::read_unencrypted(&mut client).unwrap() { + ServerOpcodeMessage::SMSG_AUTH_CHALLENGE(c) => c.server_seed, + other => panic!("expected SMSG_AUTH_CHALLENGE, got {other}"), + }; + + let wrong_key = [0x11u8; 40]; + let client_seed = ProofSeed::new(); + let client_seed_value = client_seed.seed(); + let (client_proof, _crypto) = + client_seed.into_client_header_crypto(&ns("TESTER"), wrong_key, server_seed); + + auth_session("TESTER", client_seed_value, client_proof) + .write_unencrypted_client(&mut client) + .unwrap(); + + // The failure is sent plaintext (no cipher was established). + match ServerOpcodeMessage::read_unencrypted(&mut client).unwrap() { + ServerOpcodeMessage::SMSG_AUTH_RESPONSE(r) => { + assert!(matches!(*r, SMSG_AUTH_RESPONSE::AuthFailed)); + } + other => panic!("expected plaintext SMSG_AUTH_RESPONSE failure, got {other}"), + } + + drop(client); + server.join().unwrap(); +} + +#[test] +fn unknown_account_is_rejected_cleanly() { + let store = WorldFake { + session: SessionState { + username: "TESTER".into(), + session: None, // account exists nowhere / no session + ..Default::default() + }, + ..Default::default() + }; + + let (mut client, server_end) = world_session_socket_pair(); + let server = std::thread::spawn(move || { + let mut s = server_end; + assert!(world_handshake(&mut s, std::sync::Arc::new(store)) + .unwrap() + .is_none()); + }); + + let (_enc, _dec) = drive_auth(&mut client, "NOBODY", K); + + match ServerOpcodeMessage::read_unencrypted(&mut client).unwrap() { + ServerOpcodeMessage::SMSG_AUTH_RESPONSE(r) => { + assert!(matches!(*r, SMSG_AUTH_RESPONSE::AuthUnknownAccount)); + } + other => panic!("expected SMSG_AUTH_RESPONSE unknown-account, got {other}"), + } + + drop(client); + server.join().unwrap(); +} + +#[test] +fn login_replays_a_pending_package_system_message() { + // A Package `on_login` hook emits its System Message INSIDE `player_login` — before the + // session is in the viewer registry, so the live insert relay has nobody to address. World + // entry must replay the parked row right after the entry sequence. + let store = std::sync::Arc::new({ + let base = tester_store(7); + WorldFake { + session: SessionState { + login_entity: Some(warrior_entity()), + pending_system_messages: vec!["Example Package is active.".to_string()], + ..base.session + }, + ..base + } + }); + + 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()).unwrap(); + }); + + 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 replay = ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); + let ServerOpcodeMessage::SMSG_MESSAGECHAT(actual) = replay else { + panic!("expected the parked System Message after the entry sequence, got {replay:?}"); + }; + assert_eq!( + *actual, + codec::build_gm_system_message("Example Package is active.".to_string()) + ); + + drop(client); + server.join().unwrap(); +} + +#[test] +fn player_login_emits_sequence_then_self_create() { + // CMSG_PLAYER_LOGIN must yield the full login sequence (in order), then the self + // CREATE_OBJECT2 at the correct position/guid, then the current zone's weather — the clock + // (SMSG_LOGIN_SETTIMESPEED) before the sky, so the client has both before it renders. + let store = std::sync::Arc::new({ + let base = tester_store(7); + WorldFake { + session: SessionState { + login_entity: Some(warrior_entity()), + ..base.session + }, + weather: WeatherState { + // Elwynn Forest (the fixture's zone) is raining hard. + zone_weather: vec![( + 12, + codec::ZoneWeatherView { + weather_type: 1, + intensity: 0.8, + }, + )], + }, + ..base + } + }); + + 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()).unwrap(); + }); + + 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(); + + // The 11-message login sequence, the self CREATE_OBJECT2, then the zone's weather. + let mut tags = Vec::new(); + let mut create_guid = None; + let mut weather = None; + for message in drain_world_entry(&mut client, &mut c_dec) { + match message { + ServerOpcodeMessage::SMSG_LOGIN_VERIFY_WORLD(m) => { + tags.push("verify_world"); + assert_eq!(m.map, Map::EasternKingdoms); + assert!((m.position.x - (-8949.95)).abs() < 0.01); + } + ServerOpcodeMessage::SMSG_ACCOUNT_DATA_TIMES(_) => tags.push("account_data_times"), + ServerOpcodeMessage::SMSG_LOGIN_SETTIMESPEED(_) => tags.push("settimespeed"), + ServerOpcodeMessage::SMSG_TUTORIAL_FLAGS(_) => tags.push("tutorial_flags"), + ServerOpcodeMessage::SMSG_INITIAL_SPELLS(_) => tags.push("initial_spells"), + ServerOpcodeMessage::SMSG_SET_PROFICIENCY(m) => tags.push(match m.class.as_int() { + 2 => "weapon_proficiency", + 4 => "armor_proficiency", + other => panic!("unexpected proficiency item class {other}"), + }), + ServerOpcodeMessage::SMSG_ACTION_BUTTONS(_) => tags.push("action_buttons"), + ServerOpcodeMessage::SMSG_INITIALIZE_FACTIONS(_) => tags.push("init_factions"), + ServerOpcodeMessage::SMSG_SET_REST_START(_) => tags.push("set_rest_start"), + ServerOpcodeMessage::SMSG_BINDPOINTUPDATE(_) => tags.push("bindpoint"), + ServerOpcodeMessage::SMSG_UPDATE_OBJECT(m) => { + tags.push("update_object"); + if let Object::CreateObject2 { guid3, .. } = &m.objects[0] { + create_guid = Some(guid3.guid()); + } else { + panic!("expected CreateObject2 in self-spawn"); + } + } + ServerOpcodeMessage::SMSG_WEATHER(m) => { + tags.push("weather"); + weather = Some(*m); + } + other => panic!("unexpected message in login sequence: {other}"), + } + } + + assert_eq!( + tags, + vec![ + "verify_world", + "account_data_times", + "settimespeed", + "tutorial_flags", + "initial_spells", + "weapon_proficiency", + "armor_proficiency", + "action_buttons", + "init_factions", + "set_rest_start", + "bindpoint", + "update_object", + "weather", + ], + ); + assert_eq!(create_guid, Some(1)); + // Elwynn's stored rain, synchronized rather than faded in: an arriving client must not spend + // a transition rendering the sky it happened to have last. + let weather = weather.expect("world entry sends the zone's weather"); + assert_eq!(weather.weather_type, WeatherType::Rain); + assert_eq!(weather.grade, 0.8); + assert_eq!(weather.sound_id, 8535); + assert_eq!(weather.change, WeatherChangeType::Instant); + + drop(client); + server.join().unwrap(); +} + +#[test] +fn worldport_ack_reenters_with_fresh_subscription_and_empty_loot_state() { + // MSG_MOVE_WORLDPORT_ACK must re-run the SAME enter_world path as + // CMSG_PLAYER_LOGIN — rebuilding the entity (now on the NEW map the module's teleport_player + // durably wrote to the character row) and re-subscribing with a FRESH `created` dedup set — a + // reused dedup set from the old map would suppress the initial sweep of pre-existing entities + // through the CREATE path, leaving the new map looking empty until something moved or spawned — + // a stale created-set is exactly what would leave entities invisible on cross-map arrival. + let mut ported = warrior_entity(); + ported.map_id = 1; // Kalimdor — simulates teleport_player's durable cross-map write + ported.x = 100.0; + ported.y = 200.0; + let store = std::sync::Arc::new({ + let base = tester_store(7); + WorldFake { + session: SessionState { + // A real cross-map teleport has despawned the old-map entity before its ack arrives. + entity_in_world: false, + login_entity: Some(warrior_entity()), + worldport_entity: Some(ported), + ..base.session + }, + loot_window: LootWindowState { + corpse_money: 25, + ..base.loot_window + }, + ..base + } + }); + + 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()).unwrap(); + }); + + 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 (map 0 — not the point of this test). + drain_world_entry(&mut client, &mut c_dec); + + let _ = open_loot_window(&mut client, &mut c_enc, &mut c_dec, 60); + + MSG_MOVE_WORLDPORT_ACK {} + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + + // Re-entry skips SMSG_LOGIN_VERIFY_WORLD because resending it reloads the current map. + let mut create_guid = None; + for message in drain_world_entry(&mut client, &mut c_dec) { + match message { + ServerOpcodeMessage::SMSG_LOGIN_VERIFY_WORLD(_) => { + panic!( + "the re-entry sequence must NOT resend SMSG_LOGIN_VERIFY_WORLD — it makes the \ + client reload the map it just loaded (a second loading screen)" + ); + } + ServerOpcodeMessage::SMSG_UPDATE_OBJECT(m) => { + if let Object::CreateObject2 { guid3, .. } = &m.objects[0] { + create_guid = Some(guid3.guid()); + } + } + _ => {} + } + } + assert_eq!( + create_guid, + Some(1), + "the self entity is re-created (despawn+rebuild), not a stale row" + ); + + // subscribe_player_events must have fired TWICE: once at login (map 0), once at the world-port + // re-entry (map 1) — a fresh `created` set is built for the new map each time, never reused. + let calls = store.session.subscribed.lock().unwrap().clone(); + assert_eq!( + calls.len(), + 2, + "subscribe_player_events must run again on WORLDPORT_ACK" + ); + assert_eq!(calls[0].1, 0, "the first subscription is for the login map"); + assert_eq!( + calls[1].1, 1, + "the second subscription is for the NEW (post-teleport) map" + ); + assert!( + (calls[1].2 - 100.0).abs() < 0.01, + "the re-entry must use the NEW position (verify-world no longer carries it — the \ + subscription placement is the observable)" + ); + + // The old map's open window cannot authorize a targetless request after re-entry. + CMSG_LOOT_MONEY {} + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + + drop(client); + server.join().unwrap(); + assert!( + store.loot_window.money_looted.lock().unwrap().is_empty(), + "world-port re-entry must start with no open loot target" + ); + // The login ends the Away Status; the world-port rides the reducer that keeps it. + assert_eq!( + store.session.login_entries.lock().unwrap().as_slice(), + [codec::WorldEntry::FreshLogin, codec::WorldEntry::WorldPort] + ); +} + +#[test] +fn worldport_removes_the_source_viewer_before_routing_and_registers_a_replacement() { + let view = std::sync::Arc::new(crate::stdb::world_view::WorldView::new(true)); + let mut ported = warrior_entity(); + ported.map_id = 1; + ported.x = 100.0; + ported.y = 200.0; + let store = std::sync::Arc::new({ + let base = tester_store(7); + WorldFake { + session: SessionState { + entity_in_world: false, + login_entity: Some(warrior_entity()), + worldport_entity: Some(ported), + relay_view: Some(view.clone()), + ..base.session + }, + ..base + } + }); + + 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()).unwrap(); + }); + 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 source_session = view + .viewer_of_owner(crate::stdb::world_view::OwnerGuid(1)) + .expect("login registers the source viewer") + .session; + + MSG_MOVE_WORLDPORT_ACK {} + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + drain_world_entry(&mut client, &mut c_dec); + + assert_eq!( + store + .topology + .viewer_present_at_settle + .lock() + .unwrap() + .as_slice(), + &[false, false], + "neither initial routing nor world-port routing may see a registered source viewer; the \ + second observation is the transfer-cascade safety boundary" + ); + let destination_session = view + .viewer_of_owner(crate::stdb::world_view::OwnerGuid(1)) + .expect("destination world entry registers its viewer") + .session; + assert_ne!( + destination_session, source_session, + "world-port must replace, not reuse, the source viewer lifetime" + ); + + drop(client); + server.join().unwrap(); +} + +#[test] +fn login_initialize_factions_carries_persisted_standing_at_its_reputation_index() { + // A persisted `game_player_reputation` row must land in the login + // SMSG_INITIALIZE_FACTIONS at its STORED reputation_index slot (0..63), never faction_id — the + // guardrail that also gates the live SET_FACTION_STANDING relay (McBride ERROR). + let store = std::sync::Arc::new({ + let base = tester_store(7); + WorldFake { + session: SessionState { + login_entity: Some(warrior_entity()), + ..base.session + }, + character: CharacterState { + // Stormwind's rep-index is 19 (Faction.dbc ReputationListID), NOT its faction id (72) — + // exercising the exact index/id distinction the guardrail protects. + reputations: vec![(19, 3175, false)], + ..base.character + }, + ..base + } + }); + + 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()).unwrap(); + }); + + 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 the whole world entry so the server side doesn't see a broken pipe. + let mut factions = None; + for message in drain_world_entry(&mut client, &mut c_dec) { + if let ServerOpcodeMessage::SMSG_INITIALIZE_FACTIONS(m) = message { + factions = Some(m.factions); + } + } + let factions = factions.expect("SMSG_INITIALIZE_FACTIONS not found in login sequence"); + assert_eq!(factions.len(), 64); + assert_eq!( + factions[19].standing, 3175, + "slot 19 (the stored reputation_index) must carry the standing" + ); + // Stormwind's faction_id (72) is itself past the 64-slot array — proof that indexing by faction_id + // (the McBride bug) would panic/crash here rather than silently landing on the wrong slot. + for (i, f) in factions.iter().enumerate() { + if i != 19 { + assert_eq!(f.standing, 0, "slot {i} should remain the Neutral/0 stub"); + } + } + + drop(client); + server.join().unwrap(); +} + +#[test] +fn login_with_resident_items_and_reputation_emits_no_gain_feedback() { + let store = std::sync::Arc::new({ + let base = tester_store(7); + WorldFake { + session: SessionState { + login_entity: Some(warrior_entity()), + ..base.session + }, + cast: CastState { + player_items_fixture: vec![codec::ItemInstanceView { + guid: 0x4000_0000_0000_0001, + entry: 25, + owner_guid: 1, + slot: 23, + stack_count: 1, + durability: 20, + max_durability: 20, + container_slots: 0, + random_property_id: 0, + random_property_enchant_ids: [0; 3], + item_text_id: 0, + enchantment: 0, + }], + ..base.cast + }, + character: CharacterState { + reputations: vec![(19, 3175, false)], + ..base.character + }, + ..base + } + }); + + 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()).unwrap(); + }); + 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(); + + // World entry, which carries the resident item's CREATE. The item and standing are snapshots + // in those frames, not live insert callbacks, so neither feedback packet is lawful. + for message in drain_world_entry(&mut client, &mut c_dec) { + assert!( + !matches!( + message, + ServerOpcodeMessage::SMSG_ITEM_PUSH_RESULT(_) + | ServerOpcodeMessage::SMSG_SET_FACTION_STANDING(_) + ), + "resident login state must not look like a newly gained item or reputation change" + ); + } + client + .set_read_timeout(Some(std::time::Duration::from_millis(50))) + .unwrap(); + assert!( + ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).is_err(), + "login queued an unexpected post-snapshot feedback frame" + ); + + drop(client); + server.join().unwrap(); +} + +#[test] +fn login_fills_a_resident_suffix_items_enchantment_slots_after_the_entry_batch() { + let item_guid = 0x4000_0000_0000_0001; + let store = std::sync::Arc::new({ + let base = tester_store(7); + WorldFake { + session: SessionState { + login_entity: Some(warrior_entity()), + ..base.session + }, + cast: CastState { + player_items_fixture: vec![codec::ItemInstanceView { + guid: item_guid, + entry: 25, + owner_guid: 1, + slot: 23, + stack_count: 1, + random_property_id: 22, + random_property_enchant_ids: [73, 0, 0], + ..Default::default() + }], + ..base.cast + }, + ..base + } + }); + + 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()).unwrap(); + }); + 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(); + + // World entry, which carries the item's CREATE, then the raw update gtker's typed reader + // rejects. + drain_world_entry(&mut client, &mut c_dec); + let (opcode, body) = read_raw_frame(&mut client, &mut c_dec); + assert_eq!(opcode, 0x00A9); + let updates = lyracore_shared::values_mask::parse_values_updates(&body); + assert_eq!(updates.len(), 1); + assert_eq!(updates[0].guid, item_guid); + assert_eq!(updates[0].fields, vec![(31, 73)]); + + drop(client); + server.join().unwrap(); +} + +#[test] +fn inbound_movement_is_recorded_under_its_opcode() { + use wow_world_messages::vanilla::{ + MSG_MOVE_HEARTBEAT_Client, MovementInfo, MovementInfo_MovementFlags, Vector3d, + }; + + let store = std::sync::Arc::new({ + let base = tester_store(7); + WorldFake { + session: SessionState { + entity_in_world: true, + ..base.session + }, + ..base + } + }); + + 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()).unwrap(); + }); + + let (mut c_enc, _c_dec) = client_handshake(&mut client, "TESTER", K); + let info = MovementInfo { + flags: MovementInfo_MovementFlags::empty(), + timestamp: 12345, + position: Vector3d { + x: -8950.0, + y: -130.0, + z: 83.0, + }, + orientation: 1.5, + fall_time: 0.0, + }; + MSG_MOVE_HEARTBEAT_Client { info } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + + drop(client); // server reads the heartbeat, then EOF + server.join().unwrap(); + + let moves = store.session.moves.lock().unwrap(); + assert_eq!(moves.len(), 1); + let (opcode, x, _, _, o, t) = moves[0]; + assert_eq!( + opcode, + lyracore_shared::opcodes::movement::MSG_MOVE_HEARTBEAT + ); + assert!((x - (-8950.0)).abs() < 0.01); + assert!((o - 1.5).abs() < 0.001); + assert_eq!(t, 12345); +} + +#[test] +fn a_movement_packet_for_a_despawned_entity_never_kills_the_session() { + use wow_world_messages::vanilla::{ + MSG_MOVE_HEARTBEAT_Client, MSG_MOVE_START_FORWARD_Client, MovementInfo, + MovementInfo_MovementFlags, Vector3d, + }; + let calls: ShardCallLog = Default::default(); + let entity_presence = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let store = std::sync::Arc::new({ + let base = tester_store(7); + WorldFake { + topology: TopologyState { + calls: calls.clone(), + ..base.topology + }, + session: SessionState { + entity_presence: Some(entity_presence.clone()), + ..base.session + }, + ..base + } + }); + + 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 beat = |t: u32| MSG_MOVE_HEARTBEAT_Client { + info: MovementInfo { + flags: MovementInfo_MovementFlags::empty(), + timestamp: t, + position: Vector3d { + x: -8950.0, + y: -130.0, + z: 83.0, + }, + orientation: 1.5, + fall_time: 0.0, + }, + }; + // The first packet is a transfer tail. Once the coordinator cache sees the entity again, the + // next state transition must resume normal batched submission. + beat(1) + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + for _ in 0..100 { + if store + .session + .entity_presence_checks + .load(std::sync::atomic::Ordering::SeqCst) + != 0 + { + break; + } + std::thread::sleep(std::time::Duration::from_millis(1)); + } + assert_ne!( + store + .session + .entity_presence_checks + .load(std::sync::atomic::Ordering::SeqCst), + 0, + "the encrypted movement packet must reach the coordinator presence gate" + ); + entity_presence.store(true, std::sync::atomic::Ordering::SeqCst); + MSG_MOVE_START_FORWARD_Client { info: beat(2).info } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + drop(client); + + server.join().unwrap().expect( + "a movement packet for a despawned entity must be DROPPED, not session-fatal — \ + closing the socket here strands the client on a loading screen with no error", + ); + assert_eq!( + calls +.lock() +.unwrap() +.iter() +.filter(|(_, c)| c == "movement_update") +.count(), + 1, + "the transfer-tail packet must not enter the batch, while movement resumes when presence returns" + ); +} + +#[test] +fn a_reappearing_entity_resets_the_movement_desync_tolerance() { + use wow_world_messages::vanilla::{ + MSG_MOVE_START_FORWARD_Client, MSG_MOVE_STOP_Client, MovementInfo, + MovementInfo_MovementFlags, Vector3d, + }; + let present = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let store = std::sync::Arc::new({ + let base = tester_store(7); + WorldFake { + session: SessionState { + entity_presence: Some(present.clone()), + ..base.session + }, + ..base + } + }); + 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 info = |t| MovementInfo { + flags: MovementInfo_MovementFlags::empty(), + timestamp: t, + position: Vector3d { + x: -8950.0, + y: -130.0, + z: 83.0, + }, + orientation: 1.5, + fall_time: 0.0, + }; + let send_tail = |start: u32, end: u32, client: &mut UnixStream, enc: &mut EncrypterHalf| { + for t in start..end { + let sent = if t % 2 == 0 { + MSG_MOVE_START_FORWARD_Client { info: info(t) } + .write_encrypted_client(&mut *client, enc) + } else { + MSG_MOVE_STOP_Client { info: info(t) }.write_encrypted_client(&mut *client, enc) + }; + sent.unwrap(); + } + }; + let wait_for_checks = |n| { + for _ in 0..100 { + if store + .session + .entity_presence_checks + .load(std::sync::atomic::Ordering::SeqCst) + >= n + { + return; + } + std::thread::sleep(std::time::Duration::from_millis(1)); + } + panic!("the gateway did not check entity presence {n} times"); + }; + + send_tail(0, MOVE_DESYNC_TOLERANCE, &mut client, &mut c_enc); + wait_for_checks(MOVE_DESYNC_TOLERANCE as usize); + present.store(true, std::sync::atomic::Ordering::SeqCst); + MSG_MOVE_START_FORWARD_Client { + info: info(MOVE_DESYNC_TOLERANCE), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + wait_for_checks(MOVE_DESYNC_TOLERANCE as usize + 1); + present.store(false, std::sync::atomic::Ordering::SeqCst); + send_tail( + MOVE_DESYNC_TOLERANCE + 1, + MOVE_DESYNC_TOLERANCE * 2 + 1, + &mut client, + &mut c_enc, + ); + wait_for_checks(MOVE_DESYNC_TOLERANCE as usize * 2 + 1); + present.store(true, std::sync::atomic::Ordering::SeqCst); + MSG_MOVE_STOP_Client { + info: info(MOVE_DESYNC_TOLERANCE * 2 + 1), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + drop(client); + server + .join() + .unwrap() + .expect("a restored entity must reset the tolerance before a later transfer tail arrives"); + assert_eq!( + store.session.moves.lock().unwrap().len(), + 2, + "only movements sent while the entity was present may reach the shared batch" + ); +} + +#[test] +fn a_movement_failure_that_is_not_a_desync_is_still_session_fatal() { + use wow_world_messages::vanilla::{ + MSG_MOVE_HEARTBEAT_Client, MovementInfo, MovementInfo_MovementFlags, Vector3d, + }; + let store = std::sync::Arc::new({ + let base = tester_store(7); + WorldFake { + session: SessionState { + entity_in_world: true, + movement_error: Some("timed out after 10s".into()), + ..base.session + }, + ..base + } + }); + 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); + MSG_MOVE_HEARTBEAT_Client { + info: MovementInfo { + flags: MovementInfo_MovementFlags::empty(), + timestamp: 1, + position: Vector3d { + x: 0.0, + y: 0.0, + z: 0.0, + }, + orientation: 0.0, + fall_time: 0.0, + }, + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + drop(client); + let err = server + .join() + .unwrap() + .expect_err("a non-desync movement failure must still end the session"); + assert!(format!("{err:#}").contains("timed out"), "{err:#}"); +} + +#[test] +fn a_movement_desync_that_never_heals_still_ends_the_session() { + use wow_world_messages::vanilla::{ + MSG_MOVE_START_FORWARD_Client, MSG_MOVE_STOP_Client, MovementInfo, + MovementInfo_MovementFlags, Vector3d, + }; + let store = std::sync::Arc::new({ + let base = tester_store(7); + WorldFake { + session: SessionState { + // The entity is gone and is NEVER coming back — not a teleport tail, a real desync. + entity_in_world: false, + ..base.session + }, + ..base + } + }); + 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 info = |t: u32| MovementInfo { + flags: MovementInfo_MovementFlags::empty(), + timestamp: t, + position: Vector3d { + x: -8950.0, + y: -130.0, + z: 83.0, + }, + orientation: 1.5, + fall_time: 0.0, + }; + // State transitions, so every one forwards immediately (never coalesced). Far more than a + // cross-map port's in-flight tail, and spread over more time than any loading screen's worth of + // queued packets — a session still serving these has stopped detecting desyncs altogether. + // Writes are best-effort: the socket SHOULD close partway through, which is the whole point. + for i in 0..200u32 { + let ok = if i % 2 == 0 { + MSG_MOVE_START_FORWARD_Client { info: info(i) } + .write_encrypted_client(&mut client, &mut c_enc) + .is_ok() + } else { + MSG_MOVE_STOP_Client { info: info(i) } + .write_encrypted_client(&mut client, &mut c_enc) + .is_ok() + }; + if !ok { + break; + } + } + drop(client); + let err = server.join().unwrap().expect_err( + "a movement desync that never heals must STILL end the session: tolerating the tail of a \ + cross-map port is one thing, serving a permanently desynced client a frozen world forever \ + is the hang a stuck cross-map transfer causes, with no loading screen to blame it on", + ); + assert!(format!("{err:#}").contains("not in world"), "{err:#}"); +} + +#[test] +fn a_world_port_whose_transfer_cannot_be_driven_aborts_the_clients_loading_screen() { + let view = std::sync::Arc::new(crate::stdb::world_view::WorldView::new(true)); + let xdb = FakeShardDb::with_character( + 1, + FakeChar { + map_id: 36, + instance_id: 7, + payload: "gear+spells".into(), + }, + ); + let store = std::sync::Arc::new({ + let base = tester_store(7); + WorldFake { + session: SessionState { + // The world-port ack is only actionable after teleport_player removed the old entity. + entity_in_world: false, + login_entity: Some(warrior_entity()), + relay_view: Some(view.clone()), + ..base.session + }, + characters: vec![], + transfer: TransferState { + xdb: Some(xdb), + ..base.transfer + }, + topology: TopologyState { + settle_error: Some("instances shard unreachable".into()), + settle_ok_calls: 1, // the LOGIN routes fine; the world-port's settle is the one that fails + ..base.topology + }, + ..base + } + }); + + 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, 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); + // The client finished loading the dungeon map and acks — this is where the transfer runs. + MSG_MOVE_WORLDPORT_ACK {} + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + + let aborted = ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).expect( + "the client must receive SMSG_TRANSFER_ABORTED — silence here is the loading-screen hang this fixes", + ); + match aborted { + ServerOpcodeMessage::SMSG_TRANSFER_ABORTED(m) => { + assert_eq!( + m.map, + Map::Deadmines, + "the abort must name the map the client is loading" + ); + } + other => panic!("expected SMSG_TRANSFER_ABORTED, got {other}"), + } + drop(client); + let err = server.join().unwrap().expect_err( + "a half-driven transfer must still end the session rather than enter the world", + ); + assert!( + format!("{err:#}").contains("instances shard unreachable"), + "{err:#}" + ); + assert!( + view.viewer_of_owner(crate::stdb::world_view::OwnerGuid(1)) + .is_none(), + "a failed transfer terminates the session and must not restore its source viewer" + ); + assert!( + store + .session + .logout_called + .load(std::sync::atomic::Ordering::SeqCst), + "early viewer removal keeps InWorld intact so the existing abort teardown still logs out" + ); +} + +#[test] +fn a_world_port_whose_world_entry_fails_also_aborts_the_clients_loading_screen() { + let xdb = FakeShardDb::with_character( + 1, + FakeChar { + map_id: 36, + instance_id: 7, + payload: "gear+spells".into(), + }, + ); + let store = std::sync::Arc::new({ + let base = tester_store(7); + WorldFake { + session: SessionState { + // The world-port ack is only actionable after teleport_player removed the old entity. + entity_in_world: false, + login_entity: Some(warrior_entity()), + // Routing succeeds; the world entry on the far side is what fails. + worldport_login_error: Some("character 1 is stranded on map 36".into()), + ..base.session + }, + characters: vec![], + transfer: TransferState { + xdb: Some(xdb), + ..base.transfer + }, + ..base + } + }); + + 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, 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_WORLDPORT_ACK {} + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + + // Drain whatever the (partial) re-entry emitted and require an abort somewhere in it — the + // client must not be left loading. `read_encrypted` returns Err once the socket closes. + let mut aborted = None; + while let Ok(msg) = ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec) { + if let ServerOpcodeMessage::SMSG_TRANSFER_ABORTED(m) = msg { + aborted = Some(m); + break; + } + } + let m = aborted.expect( + "a world-port whose ENTRY fails must still abort the client's loading screen — silence \ + here is the infinite loading bar this abort exists to kill", + ); + assert_eq!( + m.map, + Map::Deadmines, + "the abort must name the map the client is loading" + ); + drop(client); + let err = server + .join() + .unwrap() + .expect_err("a half-entered world-port must still end the session"); + assert!(format!("{err:#}").contains("stranded"), "{err:#}"); +} + +#[test] +fn movement_state_changes_forward_immediately_and_in_order_never_delayed() { + use wow_world_messages::vanilla::{ + MSG_MOVE_HEARTBEAT_Client, MSG_MOVE_START_FORWARD_Client, MSG_MOVE_START_TURN_LEFT_Client, + MSG_MOVE_STOP_Client, MovementInfo, MovementInfo_MovementFlags, Vector3d, + }; + + let store = std::sync::Arc::new(quest_store()); + let (mut client, mut c_enc, _c_dec, server) = enter_world(store.clone(), 1); + + let mk = |flags: u32, orientation: f32, x: f32, timestamp: u32| MovementInfo { + flags: MovementInfo_MovementFlags::new(flags, None, None, None, None), + timestamp, + position: Vector3d { x, y: 0.0, z: 0.0 }, + orientation, + fall_time: 0.0, + }; + const RUN: u32 = 0x1; + const TURN: u32 = 0x1 | 0x10; + const STOPPED: u32 = 0x0; + + MSG_MOVE_START_FORWARD_Client { + info: mk(RUN, 0.0, 0.0, 100), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + // Same flags + heading as the run-start -> a pure heartbeat -> held pending (nowhere near the + // 150ms window in real wall-clock terms, since these all write back-to-back with no sleep). + MSG_MOVE_HEARTBEAT_Client { + info: mk(RUN, 0.0, 5.0, 200), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + // A second same-vector heartbeat SUPERSEDES the first (rule 3: the pending slot IS the drop + // mechanism) — x=5.0 must never reach the module. + MSG_MOVE_HEARTBEAT_Client { + info: mk(RUN, 0.0, 10.0, 300), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + // A turn: different flags -> a STATE CHANGE. Must flush the pending x=10.0 heartbeat FIRST, + // then forward the turn itself, both undelayed. + MSG_MOVE_START_TURN_LEFT_Client { + info: mk(TURN, 5.0, 10.0, 400), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + // A stop: another STATE CHANGE, no pending left to flush — forwards immediately alone. + MSG_MOVE_STOP_Client { + info: mk(STOPPED, 5.0, 10.0, 500), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + + drop(client); + server.join().unwrap(); + + let moves = store.session.moves.lock().unwrap(); + let opcodes: Vec = moves.iter().map(|(op, ..)| *op).collect(); + assert_eq!( + opcodes, + vec![ + lyracore_shared::opcodes::movement::MSG_MOVE_START_FORWARD, + lyracore_shared::opcodes::movement::MSG_MOVE_HEARTBEAT, // the FLUSHED x=10.0 heartbeat + lyracore_shared::opcodes::movement::MSG_MOVE_START_TURN_LEFT, + lyracore_shared::opcodes::movement::MSG_MOVE_STOP, + ], + "run-start, [flushed heartbeat], turn, stop — in that exact order; the turn and stop must \ + never be held, and the superseded x=5.0 intermediate must never appear at all" + ); + // The flushed heartbeat carries the LATEST pending position (x=10.0), not the dropped x=5.0 one. + assert!( + (moves[1].1 - 10.0).abs() < 0.01, + "flushed heartbeat must carry the superseding x=10.0, not the dropped x=5.0" + ); + assert_eq!( + moves.len(), + 4, + "exactly one heartbeat survives coalescing out of the two sent" + ); +} + +#[test] +fn non_movement_opcode_flushes_pending_heartbeat_before_being_handled() { + use wow_world_messages::vanilla::{ + MSG_MOVE_HEARTBEAT_Client, MSG_MOVE_START_FORWARD_Client, MovementInfo, + MovementInfo_MovementFlags, Vector3d, + }; + + let store = std::sync::Arc::new(quest_store()); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); + + let mk = |x: f32, timestamp: u32| MovementInfo { + flags: MovementInfo_MovementFlags::new(0x1, None, None, None, None), + timestamp, + position: Vector3d { x, y: 0.0, z: 0.0 }, + orientation: 0.0, + fall_time: 0.0, + }; + + MSG_MOVE_START_FORWARD_Client { info: mk(0.0, 100) } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + // Three same-vector heartbeats, sent back-to-back (well inside the 150ms window): each + // supersedes the last in the pending slot, so only the FINAL one (x=30.0) may ever ship. + MSG_MOVE_HEARTBEAT_Client { + info: mk(10.0, 200), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + MSG_MOVE_HEARTBEAT_Client { + info: mk(20.0, 300), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + MSG_MOVE_HEARTBEAT_Client { + info: mk(30.0, 400), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + + CMSG_QUESTGIVER_STATUS_QUERY { + guid: Guid::new(50), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_QUESTGIVER_STATUS(_) => {} // the query itself was still answered normally + other => panic!("expected SMSG_QUESTGIVER_STATUS, got {other}"), + } + // Safe to inspect now: the reply we just read could only have been produced AFTER the query's + // dispatch ran (flush, then the query handler) on the single-threaded reader/dispatch loop. + { + let moves = store.session.moves.lock().unwrap(); + assert_eq!( + moves.len(), + 2, + "baseline + exactly ONE flushed heartbeat — if coalescing weren't happening, all 3 \ + heartbeats would have forwarded individually (4 total), not 2" + ); + assert!( + (moves[1].1 - 30.0).abs() < 0.01, + "the flushed heartbeat must carry the LATEST superseding position, not an earlier dropped one" + ); + } + + drop(client); + server.join().unwrap(); +} + +#[test] +fn desync_error_classifies_entity_missing_as_fatal_but_not_transient() { + // The module's `entity_by_owner` failures (the player's entity is gone — a desync, e.g. a + // schema-change publish dropped the gateway's read subscription) must be session-FATAL so the + // handler forces a CLEAN disconnect instead of leaving a silent zombie (can't attack / can't logout). + assert!(is_desync_error(&anyhow!("attacker not in world"))); + assert!(is_desync_error(&anyhow!("caster not in world"))); + assert!(is_desync_error(&anyhow!("no live entity for guid 5"))); + assert!(is_desync_error(&anyhow!("Player NOT IN WORLD"))); // case-insensitive + // TRANSIENT per-action failures are NOT desync — they stay swallowed (the player keeps playing, + // never disconnected). A false positive here would drop players on every dead-target swing. + assert!(!is_desync_error(&anyhow!("target is dead"))); + assert!(!is_desync_error(&anyhow!( + "target out of range (35.0 yd > 30 yd)" + ))); + assert!(!is_desync_error(&anyhow!( + "not enough power: have 0, need 30" + ))); + assert!(!is_desync_error(&anyhow!( + "spell not ready (global cooldown)" + ))); + assert!(!is_desync_error(&anyhow!("cannot attack self"))); +} + +#[test] +fn the_writer_drains_the_egress_depth_back_to_zero_b2() { + let store = std::sync::Arc::new(quest_store()); + let (client, _c_enc, _c_dec, server) = enter_world(store.clone(), 1); + let depth = store + .session + .session_depth + .lock() + .unwrap() + .clone() + .expect("the session must have handed its egress handle to subscribe_player_events"); + assert_eq!( + depth.load(std::sync::atomic::Ordering::Relaxed), + 0, + "the login sequence has been fully read by the client, so the writer must have decremented \ + the egress depth for every item it wrote — a depth that only climbs makes every session \ + shed peer movement forever once it passes EGRESS_SHED_DEPTH" + ); + drop(client); + let _ = server.join(); +} + +#[test] +fn logout_while_out_of_combat_succeeds_and_clears_open_loot() { + // combat_until_ms=0 (default, never in combat) → CMSG_LOGOUT_REQUEST must reply + // Success/Instant + LOGOUT_COMPLETE and the Store must release Account ownership. + let store = std::sync::Arc::new({ + let base = tester_store(7); + WorldFake { + session: SessionState { + login_entity: Some(warrior_entity()), + ..base.session + }, + loot_window: LootWindowState { + corpse_money: 25, + ..base.loot_window + }, + ..base + } + }); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); + + let _ = open_loot_window(&mut client, &mut c_enc, &mut c_dec, 60); + + CMSG_LOGOUT_REQUEST {} + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + + // First message: SMSG_LOGOUT_RESPONSE(Success, Instant) + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_LOGOUT_RESPONSE(r) => { + use wow_world_messages::vanilla::{LogoutResult, LogoutSpeed}; + assert_eq!( + r.result, + LogoutResult::Success, + "expected Success, got {:?}", + r.result + ); + assert_eq!(r.speed, LogoutSpeed::Instant); + } + other => panic!("expected SMSG_LOGOUT_RESPONSE, got {other}"), + } + // Second message: SMSG_LOGOUT_COMPLETE + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_LOGOUT_COMPLETE => {} + other => panic!("expected SMSG_LOGOUT_COMPLETE, got {other}"), + } + + CMSG_LOOT_MONEY {} + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + + drop(client); + server.join().unwrap(); + + // Releasing Account ownership removes the live Character. + assert!( + store + .session + .logout_called + .load(std::sync::atomic::Ordering::SeqCst), + "Account ownership must be released after successful logout" + ); + assert!( + store.loot_window.money_looted.lock().unwrap().is_empty(), + "logout must discard the open loot target" + ); +} + +#[test] +fn logout_while_in_combat_is_denied() { + // combat_until_ms=u64::MAX → CMSG_LOGOUT_REQUEST must reply FailureInCombat. The session + // stays alive (verified by sending a second request and getting a second denial), meaning the + // entity was NOT removed during the handler — the player cannot escape combat by logging out. + // Note: socket teardown (drop below) still calls leave_world/logout as cleanup; that is correct + // and separate from the CMSG gate. + let store = std::sync::Arc::new({ + let base = tester_store(7); + WorldFake { + session: SessionState { + login_entity: Some(warrior_entity()), + combat_until_ms: u64::MAX, // always in combat + ..base.session + }, + ..base + } + }); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); + + // First request: must be denied. + CMSG_LOGOUT_REQUEST {} + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_LOGOUT_RESPONSE(r) => { + use wow_world_messages::vanilla::LogoutResult; + assert_eq!( + r.result, + LogoutResult::FailureInCombat, + "expected FailureInCombat on first request, got {:?}", + r.result + ); + } + other => panic!("expected SMSG_LOGOUT_RESPONSE(denial) on first request, got {other}"), + } + + // Second request: still denied (session is still alive, entity still in-world). + // If the first denial had accidentally removed the entity / transitioned to CharSelect, + // this second request would either hang or produce a different message. + CMSG_LOGOUT_REQUEST {} + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_LOGOUT_RESPONSE(r) => { + use wow_world_messages::vanilla::LogoutResult; + assert_eq!( + r.result, + LogoutResult::FailureInCombat, + "expected FailureInCombat on second request (session still alive), got {:?}", + r.result + ); + } + other => panic!("expected SMSG_LOGOUT_RESPONSE(denial) on second request, got {other}"), + } + + drop(client); + server.join().unwrap(); +} + +#[test] +fn played_time_replies_with_the_durable_total_plus_the_live_session_span() { + // CMSG_PLAYED_TIME -> SMSG_PLAYED_TIME. The character row carries a durable + // 3600s total plus a session_start_micros stamped in the recent past, so the reply must be + // strictly greater than the durable floor (the live span gets folded in) and sane (not absurdly + // large — bounds the test against a unit mixup, e.g. treating micros as millis). + let durable_secs: u32 = 3600; + let session_started_secs_ago: u64 = 5; + let now_micros = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_micros() as u64; + let session_start_micros = now_micros - session_started_secs_ago * 1_000_000; + + let store = std::sync::Arc::new({ + let base = tester_store(7); + WorldFake { + session: SessionState { + login_entity: Some(warrior_entity()), + ..base.session + }, + characters: vec![codec::CharacterView { + guid: 1, + name: "Tester".into(), + played_total_secs: durable_secs, + session_start_micros, + ..Default::default() + }], + ..base + } + }); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); + + CMSG_PLAYED_TIME {} + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_PLAYED_TIME(p) => { + assert!( + p.total_played_time >= durable_secs, + "reply {} must be at least the durable floor {durable_secs}", + p.total_played_time + ); + assert!( + p.total_played_time < durable_secs + 60, + "reply {} should only add a few seconds of live session, not run away", + p.total_played_time + ); + assert_eq!( + p.total_played_time, p.level_played_time, + "level time mirrors total (untracked per-level in this slice)" + ); + } + other => panic!("expected SMSG_PLAYED_TIME, got {other}"), + } + + drop(client); + server.join().unwrap(); +} + +#[test] +fn force_run_speed_change_ack_is_swallowed_with_no_reply_and_no_session_teardown() { + // The client's ack to our `.speed`-triggered SMSG_FORCE_RUN_SPEED_CHANGE must be + // consumed cleanly (no reply, no desync/disconnect) — proven by a sentinel opcode right after it + // still getting its normal reply on the SAME session. + use wow_world_messages::vanilla::{ + MovementInfo, MovementInfo_MovementFlags, Vector3d, CMSG_FORCE_RUN_SPEED_CHANGE_ACK, + }; + let store = std::sync::Arc::new(quest_store()); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); + CMSG_FORCE_RUN_SPEED_CHANGE_ACK { + guid: Guid::new(1), + counter: 1, + info: MovementInfo { + flags: MovementInfo_MovementFlags::empty(), + timestamp: 0, + position: Vector3d { + x: 0.0, + y: 0.0, + z: 0.0, + }, + orientation: 0.0, + fall_time: 0.0, + }, + new_speed: 21.0, + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + CMSG_QUESTGIVER_STATUS_QUERY { + guid: Guid::new(50), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_QUESTGIVER_STATUS(_) => {} // the ack produced no reply of its own + other => panic!("expected the sentinel (ack swallowed), got {other}"), + } + drop(client); + server.join().unwrap(); // the session ran to a clean close, not a desync teardown +} + +#[test] +fn reducer_transport_loss_ends_an_admitted_session_and_frees_one_queue_seat() { + let store = std::sync::Arc::new({ + let base = tester_store(7); + WorldFake { + session: SessionState { + 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()), + ..base.session + }, + combat: CombatState { + set_target_error: Some("transport disconnected".into()), + }, + ..base + } + }); + let queue = std::sync::Arc::new(LoginQueue::new(1, 0)); + let (mut client, server_end) = world_session_socket_pair(); + let server_store = store.clone(); + let server_queue = queue.clone(); + let (result_tx, result_rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let result = + run_world_session_with_queue(server_end, server_store.clone(), server_queue.as_ref()); + result_tx.send(result).unwrap(); + }); + + 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); + assert_eq!( + queue.active(), + 1, + "the admitted session holds the only seat" + ); + + CMSG_SET_SELECTION { + target: Guid::new(321), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + + let err = result_rx + .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!( + ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).is_err(), + "the world socket closes after the fatal reducer result" + ); + assert!( + store + .session + .logout_called + .load(std::sync::atomic::Ordering::SeqCst), + "teardown still attempts leave-world cleanup" + ); + assert_eq!(queue.active(), 0, "the ended session released its seat"); + assert_eq!( + queue.request(), + Admission::Admitted, + "one replacement session is admitted" + ); + assert!( + matches!(queue.request(), Admission::Queued(_)), + "only one seat was released" + ); + drop(client); +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn competing_world_sessions_close_the_old_socket_without_removing_the_winner() { + use crate::accept::BlockingTaskCapacity; + use crate::config::GatewayConfig; + use crate::durable_test_support::Standalone; + use crate::stdb::Coordinator; + + fn start( + coord: Coordinator, + runtime: tokio::runtime::Handle, + ) -> (UnixStream, std::sync::mpsc::Receiver>) { + let (client, server) = world_session_socket_pair(); + let (done, result) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _entered = runtime.enter(); + let _ = done.send(run_world_session(server, std::sync::Arc::new(coord))); + }); + (client, result) + } + + fn login(client: &mut UnixStream) -> (EncrypterHalf, DecrypterHalf) { + let (mut encrypt, mut decrypt) = client_handshake(client, "TEST", [7; 40]); + CMSG_PLAYER_LOGIN { guid: Guid::new(1) } + .write_encrypted_client(&mut *client, &mut encrypt) + .unwrap(); + for _ in 0..100 { + if matches!( + ServerOpcodeMessage::read_encrypted(&mut *client, &mut decrypt).unwrap(), + ServerOpcodeMessage::SMSG_WEATHER(_) + ) { + return (encrypt, decrypt); + } + } + panic!("World Session did not complete its entry batch"); + } + + for name in [ + "LYRACORE_SHARD_MAP", + "LYRACORE_SHARD_MAP_FILE", + "LYRACORE_REALM_CORE", + ] { + assert!( + std::env::var_os(name).is_none(), + "unset {name} for this private fixture" + ); + } + let mut fixture = Standalone::start("account-world-sessions"); + fixture.publish_module(); + fixture.assert_call("claim_operator", &[]); + fixture.assert_call("install_guid_range", &["0"]); + fixture.assert_call("gw_heartbeat", &[]); + let cfg = GatewayConfig { + logon_bind: "127.0.0.1:0".into(), + world_bind: "127.0.0.1:0".into(), + stdb_uri: fixture.server().into(), + module_name: fixture.shard_name().into(), + coordinator_token: Some(fixture.owner_token()), + gateway_id: "world-owner-a".into(), + blocking_task_capacity: BlockingTaskCapacity::new(2), + }; + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .max_blocking_threads(1) + .enable_all() + .build() + .unwrap(); + let a = runtime.block_on(Coordinator::connect(&cfg)).unwrap(); + let b = runtime + .block_on(Coordinator::connect(&GatewayConfig { + gateway_id: "world-owner-b".into(), + ..cfg + })) + .unwrap(); + let (release_pool, hold_pool) = std::sync::mpsc::channel(); + let (pool_started, pool_ready) = std::sync::mpsc::channel(); + runtime.spawn_blocking(move || { + pool_started.send(()).unwrap(); + let _ = hold_pool.recv_timeout(Duration::from_secs(40)); + }); + pool_ready.recv_timeout(Duration::from_secs(2)).unwrap(); + let account = a.account_by_username("TEST").unwrap().unwrap().id; + a.establish_session(account, &[7; 40], a.bound_identity(account).unwrap()) + .unwrap(); + let (mut first, first_done) = start(a, runtime.handle().clone()); + let _first_cipher = login(&mut first); + + let (mut refused, refused_done) = start(b.clone(), runtime.handle().clone()); + let (mut encrypt, _) = client_handshake(&mut refused, "TEST", [7; 40]); + CMSG_PLAYER_LOGIN { guid: Guid::new(1) } + .write_encrypted_client(&mut refused, &mut encrypt) + .unwrap(); + let refusal = refused_done + .recv_timeout(Duration::from_secs(10)) + .unwrap() + .unwrap_err(); + assert!( + refusal.to_string().contains("ACCOUNT_IN_USE"), + "{refusal:#}" + ); + assert!(first_done.try_recv().is_err()); + + fixture.assert_sql("UPDATE game_account_claim SET expires_micros = 0"); + let (mut winner, winner_done) = start(b, runtime.handle().clone()); + let (mut winner_encrypt, mut winner_decrypt) = login(&mut winner); + let first_deadline = fixture.query_rows("SELECT expires_micros FROM game_account_claim")[0] + ["expires_micros"] + .clone(); + first_done + .recv_timeout(Duration::from_secs(25)) + .expect("lost Account ownership must close the old World Session") + .unwrap(); + assert_eq!( + fixture + .query_rows("SELECT * FROM game_world_entity WHERE guid = 1") + .len(), + 1 + ); + assert!(winner_done.try_recv().is_err()); + assert!( + crate::durable_test_support::poll_until(Duration::from_secs(5), || fixture + .query_rows("SELECT expires_micros FROM game_account_claim")[0]["expires_micros"] + != first_deadline), + "the winning Account Claim must renew while the Tokio blocking pool is full" + ); + CMSG_LOGOUT_REQUEST {} + .write_encrypted_client(&mut winner, &mut winner_encrypt) + .unwrap(); + let mut logged_out = false; + for _ in 0..100 { + if read_raw_frame(&mut winner, &mut winner_decrypt).0 + == lyracore_shared::opcodes::world::SMSG_LOGOUT_COMPLETE + { + logged_out = true; + break; + } + } + assert!( + logged_out, + "winning World Session must still process logout" + ); + winner.shutdown(std::net::Shutdown::Write).unwrap(); + winner_done + .recv_timeout(Duration::from_secs(10)) + .unwrap() + .unwrap(); + drop(winner); + release_pool.send(()).unwrap(); + assert!(fixture + .query_rows("SELECT * FROM game_world_entity WHERE guid = 1") + .is_empty()); +} + +#[cfg(target_os = "linux")] +#[test] +fn closing_a_world_session_interrupts_a_full_socket_without_draining_its_queue() { + use std::os::fd::AsRawFd; + + let (_client, mut socket) = world_session_socket_pair(); + socket.set_nonblocking(true).unwrap(); + loop { + match socket.write(&[0; 65536]) { + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => break, + Err(error) => panic!("fill owned socket: {error}"), + } + } + socket.set_nonblocking(false).unwrap(); + let (mut tx, queued, _) = session_channel(); + tx.bind_socket(socket.try_clone().unwrap()); + tx.send(Outbound::Raw { + opcode: 0, + body: vec![], + }) + .unwrap(); + let socket_fd = socket.as_raw_fd(); + let (started, ready) = std::sync::mpsc::channel(); + let (finished, result) = std::sync::mpsc::channel(); + let writer = std::thread::spawn(move || { + let task = std::fs::read_link("/proc/thread-self") + .expect("this Linux socket fixture requires /proc/thread-self"); + started.send(task).unwrap(); + finished.send(socket.write_all(&[1; 65536])).unwrap(); + }); + let task = ready.recv_timeout(Duration::from_secs(2)).unwrap(); + let syscall_path = std::path::Path::new("/proc").join(task).join("syscall"); + assert!( + crate::durable_test_support::poll_until(Duration::from_secs(2), || { + let syscall = std::fs::read_to_string(&syscall_path) + .expect("this Linux socket fixture requires visibility of its writer's syscall"); + let mut fields = syscall.split_whitespace(); + let number = fields + .next() + .and_then(|value| value.parse::().ok()); + let fd = fields + .next() + .and_then(|value| value.strip_prefix("0x")) + .and_then(|value| i32::from_str_radix(value, 16).ok()); + matches!(number, Some(n) if n == libc::SYS_sendto || n == libc::SYS_write) + && fd == Some(socket_fd) + }), + "writer did not enter its blocked socket write" + ); + tx.close(); + assert!(result + .recv_timeout(Duration::from_secs(2)) + .unwrap() + .is_err()); + assert!(matches!(queued.try_recv().unwrap(), Outbound::Raw { .. })); + writer.join().unwrap(); +} diff --git a/gateway/src/world/shard_routing_tests.rs b/gateway/src/world/shard_routing_tests.rs index 84026a3c4..34880eb60 100644 --- a/gateway/src/world/shard_routing_tests.rs +++ b/gateway/src/world/shard_routing_tests.rs @@ -1,11 +1,11 @@ -//! Multi-shard routing tests through the shared InMemoryStore topology. +//! Multi-shard routing tests through the shared WorldFake topology. use super::*; // =========================================================================================== // Cross-database Transfer and Shard routing // Requirement: reducer calls and subscriptions never target a shard other than the player's home -// shard. The `InMemoryStore` pair below stands for two DATABASES sharing one ordered call log, so +// shard. The `WorldFake` pair below stands for two DATABASES sharing one ordered call log, so // a test can read off exactly which database served every player-scoped call of a whole live // session. // =========================================================================================== @@ -15,7 +15,7 @@ pub(super) type ShardCallLog = std::sync::Arc (std::sync::Arc, ShardCallLog) { +pub(super) fn sharded_stores() -> (std::sync::Arc, ShardCallLog) { sharded_stores_with_home_entity(true) } @@ -24,28 +24,44 @@ pub(super) fn sharded_stores() -> (std::sync::Arc, ShardCallLog) /// sessions remain in-world and use the default constructor above. fn sharded_stores_with_home_entity( home_entity_in_world: bool, -) -> (std::sync::Arc, ShardCallLog) { +) -> (std::sync::Arc, ShardCallLog) { let calls: ShardCallLog = Default::default(); // The character's post-world-port entity, for the re-entry test below. let mut ported = warrior_entity(); ported.map_id = 1; - let home = std::sync::Arc::new(InMemoryStore { - entity_in_world: home_entity_in_world, - shard: "instances".into(), - calls: calls.clone(), - login_entity: Some(warrior_entity()), - auction_interaction: Some(imported_auction_interaction()), - worldport_entity: Some(ported), + let home = std::sync::Arc::new(WorldFake { + session: SessionState { + entity_in_world: home_entity_in_world, + login_entity: Some(warrior_entity()), + worldport_entity: Some(ported), + ..Default::default() + }, + topology: TopologyState { + shard: "instances".into(), + calls: calls.clone(), + ..Default::default() + }, + auction: AuctionState { + auction_interaction: Some(imported_auction_interaction()), + }, ..Default::default() }); - let world = std::sync::Arc::new(InMemoryStore { - shard: "world".into(), - calls: calls.clone(), - username: "TESTER".into(), - session: Some(WorldSession { - account_id: 7, - session_key: K, - }), + let world = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "world".into(), + calls: calls.clone(), + home: Some(home), + ..Default::default() + }, + session: SessionState { + username: "TESTER".into(), + session: Some(WorldSession { + account_id: 7, + session_key: K, + }), + login_entity: Some(warrior_entity()), + ..Default::default() + }, characters: vec![codec::CharacterView { guid: 1, name: "Tester".into(), @@ -54,8 +70,6 @@ fn sharded_stores_with_home_entity( level: 1, ..Default::default() }], - login_entity: Some(warrior_entity()), - home: Some(home), ..Default::default() }); (world, calls) @@ -112,13 +126,13 @@ fn heartbeat(timestamp: u32) -> wow_world_messages::vanilla::MSG_MOVE_HEARTBEAT_ /// Drive a full session (char-select → login → movement → an attack → disconnect) and return the /// ordered `(shard, call)` log. pub(super) fn drive_routed_session( - store: std::sync::Arc, + store: std::sync::Arc, calls: ShardCallLog, ) -> Vec<(String, String)> { let (mut client, server_end) = world_session_socket_pair(); let server_store = store; let server = std::thread::spawn(move || { - run_world_session(server_end, server_store.as_ref()).unwrap(); + run_world_session(server_end, server_store.clone()).unwrap(); }); let (mut c_enc, mut c_dec) = client_handshake(&mut client, "TESTER", K); @@ -132,9 +146,7 @@ pub(super) fn drive_routed_session( CMSG_PLAYER_LOGIN { guid: Guid::new(1) } .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); - for _ in 0..WORLD_ENTRY_PACKETS { - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - } + drain_world_entry(&mut client, &mut c_dec); // In-world traffic: a movement heartbeat (flushed by the following non-movement opcode) and a // melee swing — one subscription-driven path and one reducer path. @@ -193,18 +205,24 @@ fn a_single_entry_shard_map_never_routes_and_keeps_every_call_on_the_one_databas // (default/unconfigured) shard map always answers — the session never swaps handles, so the // whole flow is served by the database the listener handed it, byte-identically to before. let (store, calls) = sharded_stores(); - let single = std::sync::Arc::new(InMemoryStore { - entity_in_world: true, - shard: "world".into(), - calls: calls.clone(), - username: "TESTER".into(), - session: Some(WorldSession { - account_id: 7, - session_key: K, - }), + let single = std::sync::Arc::new(WorldFake { + session: SessionState { + entity_in_world: true, + username: "TESTER".into(), + session: Some(WorldSession { + account_id: 7, + session_key: K, + }), + login_entity: Some(warrior_entity()), + ..Default::default() + }, + topology: TopologyState { + shard: "world".into(), + calls: calls.clone(), + home: None, // ← a single-entry shard map: "you are already on the right shard" + ..Default::default() + }, characters: store.characters.clone(), - login_entity: Some(warrior_entity()), - home: None, // ← a single-entry shard map: "you are already on the right shard" ..Default::default() }); let log = drive_routed_session(single, calls); @@ -241,31 +259,52 @@ fn a_routing_flip_re_routes_the_next_entrant_and_leaves_the_resident_alone() { // Nothing moves a live session. let calls: ShardCallLog = Default::default(); let resolutions: std::sync::Arc = Default::default(); - let instances = std::sync::Arc::new(InMemoryStore { - entity_in_world: true, - shard: "instances".into(), - calls: calls.clone(), - home_shard_calls: resolutions.clone(), - login_entity: Some(warrior_entity()), + let instances = std::sync::Arc::new(WorldFake { + session: SessionState { + entity_in_world: true, + login_entity: Some(warrior_entity()), + ..Default::default() + }, + topology: TopologyState { + shard: "instances".into(), + calls: calls.clone(), + home_shard_calls: resolutions.clone(), + ..Default::default() + }, ..Default::default() }); - let pool_b = std::sync::Arc::new(InMemoryStore { - entity_in_world: true, - shard: "pool-b".into(), - calls: calls.clone(), - home_shard_calls: resolutions.clone(), - login_entity: Some(warrior_entity()), + let pool_b = std::sync::Arc::new(WorldFake { + session: SessionState { + entity_in_world: true, + login_entity: Some(warrior_entity()), + ..Default::default() + }, + topology: TopologyState { + shard: "pool-b".into(), + calls: calls.clone(), + home_shard_calls: resolutions.clone(), + ..Default::default() + }, ..Default::default() }); - let world = std::sync::Arc::new(InMemoryStore { - shard: "world".into(), - calls: calls.clone(), - home_shard_calls: resolutions.clone(), - username: "TESTER".into(), - session: Some(WorldSession { - account_id: 7, - session_key: K, - }), + let world = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "world".into(), + calls: calls.clone(), + home_shard_calls: resolutions.clone(), + home: Some(instances), + home_after_flip: Some(pool_b), // the routing flips between the two sessions + ..Default::default() + }, + session: SessionState { + username: "TESTER".into(), + session: Some(WorldSession { + account_id: 7, + session_key: K, + }), + login_entity: Some(warrior_entity()), + ..Default::default() + }, characters: vec![codec::CharacterView { guid: 1, name: "Tester".into(), @@ -274,9 +313,6 @@ fn a_routing_flip_re_routes_the_next_entrant_and_leaves_the_resident_alone() { level: 1, ..Default::default() }], - login_entity: Some(warrior_entity()), - home: Some(instances), - home_after_flip: Some(pool_b), // the routing flips between the two sessions ..Default::default() }); @@ -336,21 +372,17 @@ fn a_world_port_keeps_the_pin_when_the_home_shard_still_owns_the_new_map() { 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.as_ref()).unwrap(); + run_world_session(server_end, server_store.clone()).unwrap(); }); 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(); - for _ in 0..WORLD_ENTRY_PACKETS { - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - } + drain_world_entry(&mut client, &mut c_dec); MSG_MOVE_WORLDPORT_ACK {} .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); - for _ in 0..WORLD_REENTRY_PACKETS { - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - } + drain_world_entry(&mut client, &mut c_dec); drop(client); server.join().unwrap(); @@ -375,25 +407,42 @@ fn a_world_port_keeps_the_pin_when_the_home_shard_still_owns_the_new_map() { #[test] fn a_spurious_worldport_ack_is_ignored_on_a_session_pinned_off_the_default_shard() { - // The gate reads the live entity through the handler's `store`, which `on_home_shard!` has + // The gate reads the live entity through the handler's `store`, which `RoutedStore` has // already routed home. If either stops holding, the stray ack re-runs the world entry. let calls: ShardCallLog = Default::default(); - let home = std::sync::Arc::new(InMemoryStore { - shard: "instances".into(), - calls: calls.clone(), - login_entity: Some(warrior_entity()), - // The live entity IS in the world on the home shard — the ack is spurious. - entity_in_world: true, + let home = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "instances".into(), + calls: calls.clone(), + ..Default::default() + }, + session: SessionState { + login_entity: Some(warrior_entity()), + // The live entity IS in the world on the home shard — the ack is spurious. + entity_in_world: true, + ..Default::default() + }, ..Default::default() }); - let world = std::sync::Arc::new(InMemoryStore { - shard: "world".into(), - calls: calls.clone(), - username: "TESTER".into(), - session: Some(WorldSession { - account_id: 7, - session_key: K, - }), + let world = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "world".into(), + calls: calls.clone(), + home: Some(home.clone()), + ..Default::default() + }, + session: SessionState { + username: "TESTER".into(), + session: Some(WorldSession { + account_id: 7, + session_key: K, + }), + login_entity: Some(warrior_entity()), + // The default handle has NO entity for this guid (it lives on `instances`) — a + // default-shard read here would wrongly answer "absent" and re-enter. + entity_in_world: false, + ..Default::default() + }, characters: vec![codec::CharacterView { guid: 1, name: "Tester".into(), @@ -402,26 +451,19 @@ fn a_spurious_worldport_ack_is_ignored_on_a_session_pinned_off_the_default_shard level: 1, ..Default::default() }], - login_entity: Some(warrior_entity()), - // The default handle has NO entity for this guid (it lives on `instances`) — a - // default-shard read here would wrongly answer "absent" and re-enter. - entity_in_world: false, - home: Some(home.clone()), ..Default::default() }); let (mut client, server_end) = world_session_socket_pair(); let server_store = world.clone(); let server = std::thread::spawn(move || { - run_world_session(server_end, server_store.as_ref()).unwrap(); + run_world_session(server_end, server_store.clone()).unwrap(); }); 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(); - for _ in 0..WORLD_ENTRY_PACKETS { - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - } + drain_world_entry(&mut client, &mut c_dec); // The stray ack: no teleport despawned the entity — it must be dropped, not answered. MSG_MOVE_WORLDPORT_ACK {} @@ -431,12 +473,14 @@ fn a_spurious_worldport_ack_is_ignored_on_a_session_pinned_off_the_default_shard server.join().unwrap(); assert_eq!( - home.login_calls.load(std::sync::atomic::Ordering::SeqCst), + home.session + .login_calls + .load(std::sync::atomic::Ordering::SeqCst), 1, "a spurious ack on a pinned session must be ignored — the world entry ran again" ); assert_eq!( - home.subscribed.lock().unwrap().len(), + home.session.subscribed.lock().unwrap().len(), 1, "a spurious ack must not tear down and re-register the session's subscriptions" ); @@ -446,23 +490,27 @@ fn a_spurious_worldport_ack_is_ignored_on_a_session_pinned_off_the_default_shard fn a_spurious_worldport_ack_is_ignored_on_the_default_shard() { // The single-database twin of the test above — the `entity_in_world: true` ignore path was // untested before these two. - let store = std::sync::Arc::new(InMemoryStore { - login_entity: Some(warrior_entity()), - entity_in_world: true, - ..tester_store(7) + let store = std::sync::Arc::new({ + let base = tester_store(7); + WorldFake { + session: SessionState { + login_entity: Some(warrior_entity()), + entity_in_world: true, + ..base.session + }, + ..base + } }); 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.as_ref()).unwrap(); + run_world_session(server_end, server_store.clone()).unwrap(); }); 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(); - for _ in 0..WORLD_ENTRY_PACKETS { - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - } + drain_world_entry(&mut client, &mut c_dec); MSG_MOVE_WORLDPORT_ACK {} .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); @@ -470,7 +518,10 @@ fn a_spurious_worldport_ack_is_ignored_on_the_default_shard() { server.join().unwrap(); assert_eq!( - store.login_calls.load(std::sync::atomic::Ordering::SeqCst), + store + .session + .login_calls + .load(std::sync::atomic::Ordering::SeqCst), 1, "a spurious ack with the entity live must be ignored" ); @@ -482,7 +533,7 @@ fn a_logout_to_character_select_releases_the_home_shard_pin() { 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.as_ref()).unwrap(); + run_world_session(server_end, server_store.clone()).unwrap(); }); let (mut c_enc, mut c_dec) = client_handshake(&mut client, "TESTER", K); @@ -490,9 +541,7 @@ fn a_logout_to_character_select_releases_the_home_shard_pin() { CMSG_PLAYER_LOGIN { guid: Guid::new(1) } .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); - for _ in 0..WORLD_ENTRY_PACKETS { - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - } + drain_world_entry(&mut client, &mut c_dec); CMSG_LOGOUT_REQUEST {} .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); @@ -528,7 +577,7 @@ fn a_logout_to_character_select_releases_the_home_shard_pin() { fn a_freshly_created_characters_first_login_transfers_off_the_default_shard() { // Requirement: `create_character` always writes to the DEFAULT/realm shard, even when the // start position routes to a different one under `LYRACORE_SHARD_MAP` — a deliberate decision (see - // the doc comment on `impl WorldStore for Coordinator::create_character`): create-then- + // the doc comment on `CharacterStore::create_character` for `Coordinator`): create-then- // transfer-on-first-login, not create-directly-on-the-owning-shard. That decision rides the // SAME `route_home`/`settle_home_shard` machinery every other login already uses — prove it // end to end for a guid the CREATE call ITSELF produced, not one hardcoded @@ -538,7 +587,7 @@ fn a_freshly_created_characters_first_login_transfers_off_the_default_shard() { // // So this drives the REAL 1.12 flow instead: CREATE, then re-ENUMERATE — `SMSG_CHAR_CREATE` // carries no guid, the client is expected to learn it from the next `CMSG_CHAR_ENUM` — and - // pick "Newbie"'s guid out of THAT reply before logging in with it. `InMemoryStore:: + // pick "Newbie"'s guid out of THAT reply before logging in with it. `WorldFake:: // create_character` now actually records the character (see its doc comment) so this guid is // genuinely create-produced, and `sharded_stores()`'s single connected `home` shard // (`instances`) stands in for a start map that routes off `world`. @@ -547,7 +596,7 @@ fn a_freshly_created_characters_first_login_transfers_off_the_default_shard() { 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.as_ref()).unwrap(); + run_world_session(server_end, server_store.clone()).unwrap(); }); let (mut c_enc, mut c_dec) = client_handshake(&mut client, "TESTER", K); @@ -601,9 +650,7 @@ fn a_freshly_created_characters_first_login_transfers_off_the_default_shard() { } .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); - for _ in 0..WORLD_ENTRY_PACKETS { - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - } + drain_world_entry(&mut client, &mut c_dec); drop(client); server.join().unwrap(); diff --git a/gateway/src/world/social.rs b/gateway/src/world/social.rs index daa43fce1..cc397072b 100644 --- a/gateway/src/world/social.rs +++ b/gateway/src/world/social.rs @@ -3,8 +3,9 @@ //! shape as the rest of the dispatch chain. use super::party::PartyOutcome; -use super::{party, presence, send, who, Outbound, SessionTx, WorldConn, WorldState, WorldStore}; +use super::{party, presence, send, who, Outbound, SessionTx, WorldConn, WorldState}; use crate::codec; +use crate::world::{CharacterStore, GuildActionStore, PartyStore, SessionStore, ShardRoutingStore}; use anyhow::Result; use lyracore_shared::group::{GroupRefusal, TARGET_ICON_LIST_REQUEST}; use lyracore_shared::social::ContactRefusal; @@ -13,6 +14,105 @@ use wow_world_messages::vanilla::opcodes::{ClientOpcodeMessage, ServerOpcodeMess use wow_world_messages::vanilla::{MSG_RAID_TARGET_UPDATE_Client, PartyOperation, PartyResult}; use wow_world_messages::Guid; +/// Per-Shard presence reads and contact lists, behind presence, `/who` and whispers. +pub(crate) trait SocialStore: Send + Sync { + /// This Shard's durable Character row for `guid`: identity plus the session flag. `None` if + /// this Shard holds no `game_character` row for it. + /// [`presence::of`](super::presence::of) unions it across every connected Shard. + fn character_identity(&self, guid: u64) -> Result>; + + /// This Shard's live `game_world_entity` row for `guid`, if any — the Member Stats columns, + /// plus level and zone, current unlike the durable row (`persist_entity` only refreshes it on + /// logout, cross-map teleport or Transfer). `None` if `guid` has no live entity here. + fn live_entity(&self, guid: u64) -> Option; + + /// Does this Shard show `guid` between two places: its own Character row reading online with + /// no live entity here (a map-change loading screen, or a human Transfer's frozen source + /// copy — `begin_transfer` persists with `set_offline: false`), or a Transfer Intent naming a + /// session-less bot mid-crossing. + fn character_in_transit(&self, guid: u64) -> bool; + + /// This Shard's stored Auto-Reply for `guid`. `None` when this Shard holds none. + /// [`presence::auto_reply`] asks the Shard that holds the live entity. + fn auto_reply_text(&self, guid: u64) -> Result>; + + /// Does every configured World Shard vouch that it is reachable and healthy enough to trust a + /// negative read from? [`presence::of`] asks this before answering `Whereabouts::Offline` or + /// `None` — an unreachable or stale-cached Shard could be hiding the Character. A Store + /// without a Shard topology answers `Ok(())`. + fn every_shard_vouches_for_absence(&self) -> Result<()>; + + /// Every in-world player Character on this Shard — the per-Shard input + /// [`presence::in_world_characters`](super::presence::in_world_characters) unions, and `/who`'s + /// ultimate source. A player is "in world" iff their guid appears in `game_world_entity` with + /// `entry == 0` (player entity); dead players are included (ghosts are in world). Bots are + /// included: they have no session, but they do have a live entity. + fn in_world_players(&self) -> Result>; + + /// `game_area.name` for `zone_id` — `/who`'s search-string match against a zone name. Empty + /// when the catalogue holds no row for it (unimported, or an id the client sends that the + /// imported DBC lacks). + fn zone_name(&self, zone_id: u32) -> String; + + /// `self_guid`'s friend guids and ignore guids for `CMSG_FRIEND_LIST`. `self_guid` is always + /// the CALLING World Session's own guid, never a peer's, so a Store may answer this from + /// whatever cheap per-connection state it keeps for its own connected sessions (the Coordinator + /// reads its Gateway-side `Viewer`). A caller that needs a friend's PRESENCE composes it + /// separately with `world::social::friend_views`, over `presence::of`; a caller that needs to + /// know whether an ARBITRARY (possibly unconnected) Character ignores another uses + /// [`SocialStore::ignored_guids`] instead, never this method. + fn contact_lists(&self, self_guid: u64) -> Result<(Vec, Vec)>; + + /// `owner_guid`'s ignore guids, read directly off this Shard's durable contact rows — realm-wide + /// safe for ANY owner, including one with no live World Session on this Gateway process at all + /// (a whisper sender or a guild-invite target usually is not). `whisper::ignored_anywhere` fans + /// this out across every connected Shard. Unlike `contact_lists`, this never reads a `Viewer`. + fn ignored_guids(&self, owner_guid: u64) -> Result>; + + /// Resolve a typed contact name to a character guid on THIS Shard (case-insensitive, like the + /// Module's `character_by_name`): `presence::resolve_by_name`'s per-Shard primitive. `None` + /// if this Shard has no character with that name. + fn character_guid_by_name(&self, name: &str) -> Result>; + + // Contact ops answer a [`ContactOutcome`]: a typed Refusal is a gameplay answer the + // client renders, and only a failure with an unknown durable result stays `Err`. + + /// `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; + + /// `CMSG_DEL_FRIEND`. + fn del_friend( + &self, + account_id: u64, + self_guid: u64, + 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; + + /// `CMSG_DEL_IGNORE`. + fn del_ignore( + &self, + account_id: u64, + self_guid: u64, + 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. @@ -32,7 +132,15 @@ impl From for ContactOutcome { /// opcodes. Each arm consumes its opcode (`Ok(None)`) or passes the message on (`Ok(Some(msg))`), /// like the other per-family handlers. #[allow(clippy::too_many_lines)] // One arm per social and party opcode. -pub(super) fn handle_social( +pub(super) fn handle_social< + St: CharacterStore + + GuildActionStore + + PartyStore + + SessionStore + + ShardRoutingStore + + SocialStore + + ?Sized, +>( tx: &SessionTx, store: &St, conn: &mut WorldConn, @@ -247,7 +355,9 @@ pub(super) fn handle_social( /// Run a leave or a kick by guid. Only a Refusal answers, as `SMSG_PARTY_COMMAND_RESULT(Leave, "", /// result)`; an op that ran is heard through the relay. -fn run_answering_refusal( +fn run_answering_refusal< + St: CharacterStore + PartyStore + SessionStore + ShardRoutingStore + SocialStore + ?Sized, +>( tx: &SessionTx, store: &St, conn: &WorldConn, @@ -274,7 +384,9 @@ fn run_answering_refusal( /// The leader's "Convert to Raid". cmangos answers success with /// `SMSG_PARTY_COMMAND_RESULT(Invite, "", Ok)` and every refusal with silence /// (cm:GroupHandler.cpp:473-490); the raid list reaches every member through the LIST relay. -fn raid_convert( +fn raid_convert< + St: CharacterStore + PartyStore + SessionStore + ShardRoutingStore + SocialStore + ?Sized, +>( tx: &SessionTx, store: &St, conn: &WorldConn, @@ -306,7 +418,9 @@ fn raid_convert( /// Run a party op whose outcome the client hears only through the relay. A Refusal is logged at /// debug and sends nothing. cmangos sends nothing for set leader and set Assistant, whatever the /// outcome (cm:GroupHandler.cpp:344-362, 527-545). -fn run_unanswered( +fn run_unanswered< + St: CharacterStore + PartyStore + SessionStore + ShardRoutingStore + SocialStore + ?Sized, +>( store: &St, conn: &WorldConn, op: party::Op, @@ -327,7 +441,9 @@ fn run_unanswered( /// resolves against the actor's OWN roster rather than realm-wide, so a namesake standing outside /// the Raid cannot be reached — the same avoided-homonym rule cmangos applies to Swap Subgroup /// alone (cm:GroupHandler.cpp:919-936), extended here to both opcodes. -fn change_subgroup( +fn change_subgroup< + St: CharacterStore + PartyStore + SessionStore + ShardRoutingStore + SocialStore + ?Sized, +>( store: &St, conn: &WorldConn, name: &str, @@ -353,7 +469,9 @@ fn change_subgroup( /// roster-only name resolution as [`change_subgroup`] (cm:GroupHandler.cpp:901-944, /// cm:GroupHandler.cpp:919-936), with both names resolved against the SAME roster read so the /// pair cannot straddle two different snapshots of it. -fn swap_subgroup( +fn swap_subgroup< + St: CharacterStore + PartyStore + SessionStore + ShardRoutingStore + SocialStore + ?Sized, +>( store: &St, conn: &WorldConn, name: &str, @@ -382,7 +500,9 @@ fn swap_subgroup( /// opcodes with silence, so a Refusal only logs. A transport failure only logs too: a broadcast /// changes no roster and nothing waits on it, so a lost ping or roll must not end the session. /// A throttled op sent again inside its cooldown is dropped the same way. -pub(super) fn run_group_broadcast( +pub(super) fn run_group_broadcast< + St: CharacterStore + PartyStore + SessionStore + ShardRoutingStore + SocialStore + ?Sized, +>( store: &St, conn: &mut WorldConn, op: party::Op, @@ -535,7 +655,7 @@ pub(crate) fn friend_online_fields(presence: &presence::RealmPresence) -> codec: /// degrades that one row to offline rather than failing the whole list or ending the World /// Session. Lives over the `WorldStore` seam, not behind any one Store's own `contact_lists`, so a /// Store Fake exercises the exact composition a Coordinator serves. -pub(crate) fn friend_views( +pub(crate) fn friend_views( store: &St, own_team: u32, friend_guids: &[u64], @@ -576,7 +696,7 @@ pub(crate) fn friend_views( /// the OTHER party's guid, and — ONLINE/ADDED_ONLINE only — the trailing presence fields. An /// unknown name never reaches the module (guid 0, `NotFound`/`IgnoreNotFound`); everything else /// (self/duplicate/cap/team) is the module's own typed Refusal. -fn resolve_add_contact( +fn resolve_add_contact( store: &St, account_id: u64, actor_guid: u64, @@ -646,7 +766,7 @@ fn resolve_add_contact( /// Call the module's remove reducer (`del_friend`/`del_ignore`) for `target_guid` and translate the /// outcome into the `(FriendResult, guid)` pair `SMSG_FRIEND_STATUS` needs. A remove carries no /// trailing presence fields, whatever the outcome. -fn resolve_del_contact( +fn resolve_del_contact( store: &St, account_id: u64, actor_guid: u64, diff --git a/gateway/src/world/social_tests.rs b/gateway/src/world/social_tests.rs new file mode 100644 index 000000000..948557426 --- /dev/null +++ b/gateway/src/world/social_tests.rs @@ -0,0 +1,488 @@ +//! Friends and ignore lists over an encrypted World Session. + +use super::*; + +/// Read one `SMSG_FRIEND_STATUS` frame — RAW always, now that `ONLINE`/`ADDED_ONLINE` carry +/// trailing fields gtker 0.3 cannot type (see `codec::build_friend_status_raw`). Returns the +/// result, the other party's guid, and the trailing bytes (empty unless online). +fn read_friend_status( + client: &mut UnixStream, + dec: &mut DecrypterHalf, +) -> (FriendResult, u64, Vec) { + let (opcode, body) = read_raw_frame(client, dec); + assert_eq!( + opcode, + codec::social::SMSG_FRIEND_STATUS_OPCODE, + "expected SMSG_FRIEND_STATUS" + ); + let result = FriendResult::try_from(body[0]).expect("a known FriendResult byte"); + let guid = u64::from_le_bytes(body[1..9].try_into().unwrap()); + (result, guid, body[9..].to_vec()) +} + +#[test] +fn add_friend_by_name_then_friend_list_carries_online_presence() { + // CMSG_ADD_FRIEND "Buddy" -> SMSG_FRIEND_STATUS AddedOnline (guid 2, resolved by + // name); a follow-up CMSG_FRIEND_LIST then carries them online with their level/class/zone. + let mut s = quest_store(); + s.characters = vec![codec::CharacterView { + guid: 2, + name: "Buddy".into(), + class: 1, + level: 10, + zone_id: 12, + ..Default::default() + }]; + let store = std::sync::Arc::new(s); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); + + CMSG_ADD_FRIEND { + name: "buddy".into(), + } // case-insensitive match + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + let (result, guid, trailer) = read_friend_status(&mut client, &mut c_dec); + assert_eq!(result, FriendResult::AddedOnline); + assert_eq!(guid, 2); + assert_eq!(trailer[0], 1, "status ONLINE"); + + CMSG_FRIEND_LIST {} + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_FRIEND_LIST(l) => { + assert_eq!(l.friends.len(), 1); + assert_eq!(l.friends[0].guid.guid(), 2); + assert!(matches!( + l.friends[0].status, + wow_world_messages::vanilla::Friend_FriendStatus::Online { .. } + )); + } + other => panic!("expected SMSG_FRIEND_LIST, got {other}"), + } + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_IGNORE_LIST(l) => assert!(l.ignored.is_empty()), + other => panic!("expected SMSG_IGNORE_LIST, got {other}"), + } + + // Removing it replies Removed and the friend list empties out. + CMSG_DEL_FRIEND { guid: Guid::new(2) } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + let (result, _, trailer) = read_friend_status(&mut client, &mut c_dec); + assert_eq!(result, FriendResult::Removed); + assert!(trailer.is_empty(), "a remove carries no trailing fields"); + drop(client); + server.join().unwrap(); +} + +/// A friend on ANOTHER Shard resolves. Vim lives on `instances`; Ginger (this Gateway's own +/// Shard) adds them by name and gets ADDED_ONLINE with Vim's real presence, byte-exact against +/// the raw encoder. +#[test] +fn add_friend_on_another_shard_answers_added_online_with_presence() { + let mut s = quest_store(); + s.characters = vec![codec::CharacterView { + guid: 1, + name: "Ginger".into(), + race: 1, + ..Default::default() + }]; + let store = std::sync::Arc::new(s); + let peer = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "instances".into(), + ..Default::default() + }, + characters: vec![codec::CharacterView { + guid: 2, + name: "Vim".into(), + race: 1, // same team as Ginger + class: 4, + ..Default::default() + }], + social: SocialState { + // Vim standing IN the instance: a live entity, not just a durable row, so the ADDED_ONLINE + // fields below come off `Whereabouts::InWorld`. + member_entities: std::sync::Mutex::new(vec![( + 2, + codec::MemberEntity { + level: 22, + zone_id: 33, + ..Default::default() + }, + )]), + ..Default::default() + }, + ..Default::default() + }); + *store.topology.peers.lock().unwrap() = vec![store.clone(), peer.clone()]; + *peer.topology.peers.lock().unwrap() = vec![store.clone(), peer.clone()]; + + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); + CMSG_ADD_FRIEND { name: "Vim".into() } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + let (result, guid, trailer) = read_friend_status(&mut client, &mut c_dec); + assert_eq!(result, FriendResult::AddedOnline); + assert_eq!(guid, 2); + let mut expected = vec![1u8]; // status ONLINE + expected.extend(33u32.to_le_bytes()); // area + expected.extend(22u32.to_le_bytes()); // level + expected.extend(4u32.to_le_bytes()); // class + assert_eq!(trailer, expected); + drop(client); + server.join().unwrap(); +} + +/// A friend standing in another Shard's instance shows ONLINE with real level/class/zone through +/// `CMSG_FRIEND_LIST`, and AFK/DND status follow the live entity's `PLAYER_FLAGS` — exercised +/// through the actual socket dispatch, so it runs the SAME `world::social::friend_views` +/// composition a Coordinator serves, not a Store Fake's own reimplementation of it. +#[test] +fn friend_list_shows_a_friend_in_another_shards_instance_online_afk_or_dnd() { + let mut s = quest_store(); + s.characters = vec![codec::CharacterView { + guid: 1, + name: "Ginger".into(), + race: 1, + ..Default::default() + }]; + s.social.contacts = std::sync::Mutex::new(vec![(1, 2, false)]); // Ginger already friends Vim + let store = std::sync::Arc::new(s); + let peer = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "instances".into(), + ..Default::default() + }, + characters: vec![codec::CharacterView { + guid: 2, + name: "Vim".into(), + race: 1, // same team as Ginger + class: 4, + ..Default::default() + }], + social: SocialState { + member_entities: std::sync::Mutex::new(vec![( + 2, + codec::MemberEntity { + level: 22, + zone_id: 33, + player_flags: lyracore_shared::constants::player_flags::DND, + ..Default::default() + }, + )]), + ..Default::default() + }, + ..Default::default() + }); + *store.topology.peers.lock().unwrap() = vec![store.clone(), peer.clone()]; + *peer.topology.peers.lock().unwrap() = vec![store.clone(), peer.clone()]; + + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); + CMSG_FRIEND_LIST {} + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_FRIEND_LIST(l) => { + assert_eq!(l.friends.len(), 1); + assert_eq!(l.friends[0].guid.guid(), 2); + match l.friends[0].status { + wow_world_messages::vanilla::Friend_FriendStatus::Dnd { area, class, level } => { + assert_eq!(area.as_int(), 33); + assert_eq!(level.as_int(), 22); + assert_eq!(class, wow_world_messages::vanilla::Class::Rogue); + } + other => panic!("expected Dnd, got {other:?}"), + } + } + other => panic!("expected SMSG_FRIEND_LIST, got {other}"), + } + // CMSG_FRIEND_LIST always answers with BOTH lists; leaving the SMSG_IGNORE_LIST reply unread + // and dropping the client closes the socket with a queued write still in flight, which Linux + // reports to the server as a reset rather than a clean EOF. + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_IGNORE_LIST(l) => assert!(l.ignored.is_empty()), + other => panic!("expected SMSG_IGNORE_LIST, got {other}"), + } + drop(client); + server.join().unwrap(); +} + +/// An enemy-faction friend answers FRIEND_ENEMY. The companion half — the same target is a legal +/// ignore — is a Module rule this Store Fake cannot express; it is pinned durably in +/// `module/tests/friends.rs`. +#[test] +fn add_friend_enemy_faction_answers_friend_enemy() { + let mut s = quest_store(); + s.characters = vec![ + codec::CharacterView { + guid: 1, + name: "Ginger".into(), + race: 1, // Human, Alliance + ..Default::default() + }, + codec::CharacterView { + guid: 2, + name: "Grunt".into(), + race: 2, // Orc, Horde + ..Default::default() + }, + ]; + s.trade_error = Some(ContactRefusal::Enemy.as_tag().to_string()); + let store = std::sync::Arc::new(s); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); + CMSG_ADD_FRIEND { + name: "Grunt".into(), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + let (result, guid, trailer) = read_friend_status(&mut client, &mut c_dec); + assert_eq!(result, FriendResult::Enemy); + assert_eq!(guid, 2); + assert!(trailer.is_empty()); + drop(client); + server.join().unwrap(); +} + +#[test] +fn add_friend_unknown_name_replies_not_found() { + let store = std::sync::Arc::new(quest_store()); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); + CMSG_ADD_FRIEND { + name: "Nobody".into(), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + let (result, guid, _) = read_friend_status(&mut client, &mut c_dec); + assert_eq!(result, FriendResult::NotFound); + assert_eq!(guid, 0); + drop(client); + server.join().unwrap(); +} + +/// The ignore list has its own "unknown name" code. +#[test] +fn add_ignore_unknown_name_replies_ignore_not_found() { + let store = std::sync::Arc::new(quest_store()); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); + CMSG_ADD_IGNORE { + name: "Nobody".into(), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + let (result, guid, _) = read_friend_status(&mut client, &mut c_dec); + assert_eq!(result, FriendResult::IgnoreNotFound); + assert_eq!(guid, 0); + drop(client); + server.join().unwrap(); +} + +#[test] +fn add_ignore_by_name_replies_ignore_added() { + let mut s = quest_store(); + s.characters = vec![codec::CharacterView { + guid: 3, + name: "Pest".into(), + ..Default::default() + }]; + let store = std::sync::Arc::new(s); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); + CMSG_ADD_IGNORE { + name: "Pest".into(), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + let (result, guid, _) = read_friend_status(&mut client, &mut c_dec); + assert_eq!(result, FriendResult::IgnoreAdded); + assert_eq!(guid, 3); + drop(client); + server.join().unwrap(); +} + +#[test] +fn add_friend_maps_self_already_and_full_errors() { + for (refusal, want) in [ + (ContactRefusal::AddSelf, FriendResult::SelfX), + (ContactRefusal::AlreadyOnList, FriendResult::Already), + (ContactRefusal::ListFull, FriendResult::ListFull), + ] { + let mut s = quest_store(); + s.characters = vec![codec::CharacterView { + guid: 2, + name: "Buddy".into(), + ..Default::default() + }]; + s.trade_error = Some(refusal.as_tag().to_string()); + let store = std::sync::Arc::new(s); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); + CMSG_ADD_FRIEND { + name: "Buddy".into(), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + let (result, ..) = read_friend_status(&mut client, &mut c_dec); + assert_eq!(result, want, "{refusal:?} must map to {want:?}"); + drop(client); + server.join().unwrap(); + } +} + +#[test] +fn add_ignore_maps_already_and_full_errors_to_the_ignore_variants() { + for (refusal, want) in [ + (ContactRefusal::AddSelf, FriendResult::IgnoreSelf), + (ContactRefusal::AlreadyOnList, FriendResult::IgnoreAlready), + (ContactRefusal::ListFull, FriendResult::IgnoreFull), + ] { + let mut s = quest_store(); + s.characters = vec![codec::CharacterView { + guid: 3, + name: "Pest".into(), + ..Default::default() + }]; + s.trade_error = Some(refusal.as_tag().to_string()); + let store = std::sync::Arc::new(s); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); + CMSG_ADD_IGNORE { + name: "Pest".into(), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + let (result, ..) = read_friend_status(&mut client, &mut c_dec); + assert_eq!(result, want, "{refusal:?} must map to {want:?}"); + drop(client); + server.join().unwrap(); + } +} + +/// Every contact Refusal the Module can send reaches the client as exactly one `FriendResult`, and +/// the friends and ignore lists get their own code family for the same condition. +#[test] +fn every_contact_refusal_reaches_the_client_as_one_friend_result() { + for refusal in ContactRefusal::ALL { + let (friend, ignore) = match refusal { + ContactRefusal::AddSelf => (FriendResult::SelfX, FriendResult::IgnoreSelf), + ContactRefusal::AlreadyOnList => (FriendResult::Already, FriendResult::IgnoreAlready), + ContactRefusal::ListFull => (FriendResult::ListFull, FriendResult::IgnoreFull), + ContactRefusal::NotOnList => (FriendResult::NotFound, FriendResult::IgnoreNotFound), + ContactRefusal::ActorUnavailable => (FriendResult::NotFound, FriendResult::NotFound), + ContactRefusal::Enemy => (FriendResult::Enemy, FriendResult::Enemy), + }; + for (is_ignore, want) in [(false, friend), (true, ignore)] { + let mut s = quest_store(); + s.characters = vec![codec::CharacterView { + guid: 2, + name: "Buddy".into(), + ..Default::default() + }]; + s.trade_error = Some(refusal.as_tag().to_string()); + let store = std::sync::Arc::new(s); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); + if is_ignore { + CMSG_ADD_IGNORE { + name: "Buddy".into(), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + } else { + CMSG_ADD_FRIEND { + name: "Buddy".into(), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + } + let (result, ..) = read_friend_status(&mut client, &mut c_dec); + assert_eq!(result, want, "{refusal:?} on ignore={is_ignore}"); + drop(client); + server.join().unwrap(); + } + } +} + +/// The contact half of the same rule: a timed-out add left the list in an unknown state. +#[test] +fn an_add_friend_timeout_is_not_answered_as_a_refusal() { + let mut s = quest_store(); + s.characters = vec![codec::CharacterView { + guid: 2, + name: "Buddy".into(), + ..Default::default() + }]; + s.session.login_entity = Some(warrior_entity()); + s.trade_error = Some("gw_add_friend reducer timed out after 10s".into()); + let store = std::sync::Arc::new(s); + let (mut client, server_end) = world_session_socket_pair(); + let server_store = store.clone(); + let (result_tx, result_rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + result_tx + .send(run_world_session(server_end, server_store.clone())) + .unwrap(); + }); + 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_ADD_FRIEND { + name: "Buddy".into(), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + 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")); +} + +#[test] +fn del_friend_unknown_contact_replies_not_found() { + let store = std::sync::Arc::new(quest_store()); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); + CMSG_DEL_FRIEND { + guid: Guid::new(404), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + let (result, ..) = read_friend_status(&mut client, &mut c_dec); + assert_eq!(result, FriendResult::NotFound); + drop(client); + server.join().unwrap(); +} + +#[test] +fn del_ignore_round_trips_added_then_unknown_is_ignore_not_found() { + let mut s = quest_store(); + s.characters = vec![codec::CharacterView { + guid: 3, + name: "Pest".into(), + ..Default::default() + }]; + let store = std::sync::Arc::new(s); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); + + CMSG_ADD_IGNORE { + name: "Pest".into(), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + let (result, ..) = read_friend_status(&mut client, &mut c_dec); + assert_eq!(result, FriendResult::IgnoreAdded); + + // Removing the just-added contact: IgnoreRemoved. + CMSG_DEL_IGNORE { guid: Guid::new(3) } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + let (result, ..) = read_friend_status(&mut client, &mut c_dec); + assert_eq!(result, FriendResult::IgnoreRemoved); + // Removing it again: no longer on the list -> IgnoreNotFound. + CMSG_DEL_IGNORE { guid: Guid::new(3) } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + let (result, ..) = read_friend_status(&mut client, &mut c_dec); + assert_eq!(result, FriendResult::IgnoreNotFound); + drop(client); + server.join().unwrap(); +} diff --git a/gateway/src/world/store.rs b/gateway/src/world/store.rs index c89c3f7e7..b63e17015 100644 --- a/gateway/src/world/store.rs +++ b/gateway/src/world/store.rs @@ -1,19 +1,7 @@ -//! `WorldStore`: the broad storage/coordination seam used by the world session. Deep protocol -//! families may add focused supertraits such as [`AuctionActionStore`], [`ChannelActionStore`], -//! [`ChatActionStore`], -//! [`ItemActionStore`], [`MeetingStoneActionStore`], [`MeleeActionStore`], [`QuestActionStore`], -//! [`TaxiActionStore`] and -//! [`VendorActionStore`] so their wire mapping and failure policy can be tested without -//! implementing this entire interface — a migrated family's operations live only on its own trait, -//! never here. Kept as one broad trait for the remaining -//! session operations (only two implementors); the section markers below are load-bearing -//! navigation, not a split. +//! `WorldStore`: the umbrella over every Store family a World Session reaches, plus the shard +//! routing and session families. Each other family trait sits beside the handler that calls it, so +//! a handler names only the families it needs and a family Fake implements only its own trait. -use super::handlers::{ - AuctionActionStore, CastStore, ChannelActionStore, ChatActionStore, DuelActionStore, - GuildActionStore, ItemActionStore, LootWindowStore, MeetingStoneActionStore, MeleeActionStore, - MemberStatsStore, QuestActionStore, TaxiActionStore, VendorActionStore, WeatherStore, -}; use super::*; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -23,15 +11,32 @@ pub struct WorldSessionToken { pub request_nonce: u128, } +/// Every Store family a World Session reaches. The blanket impl below supplies it, so a Store +/// implements the families and never this trait. pub trait WorldStore: - AuctionActionStore + ShardRoutingStore + + SessionStore + + CharacterStore + + TransferStore + + PartyStore + + LootWindowStore + + LootRollStore + + MailStore + + SocialStore + + NpcStore + + TrainerStore + + BankStore + + CombatStore + + DeathStore + + TradeStore + + AuctionActionStore + CastStore + ChannelActionStore + ChatActionStore + + SpeechStore + DuelActionStore + GuildActionStore + ItemActionStore - + LootWindowStore + MeetingStoneActionStore + MeleeActionStore + MemberStatsStore @@ -42,629 +47,118 @@ pub trait WorldStore: + Send + Sync { - /// Look up the shared session key K (+ account id) for an (already uppercased) account - /// name. `None` when no live session exists for that account (reject the handshake). - fn lookup_session(&self, account_name: &str) -> Result>; +} - /// Multi-shard routing: the handle for the shard that OWNS `character_guid`'s location, - /// resolved once per world entry and then used for EVERY player-scoped call and subscription of - /// that session (see `on_home_shard!`). `None` means "you are already on the right shard" — - /// which is what a single-entry shard map, and every mock, always answer, so the session keeps - /// the handle it was given and behaves byte-identically to the pre-sharding gateway. - fn home_shard(&self, _character_guid: u64) -> Option> { - None - } +impl WorldStore for T where + T: ShardRoutingStore + + SessionStore + + CharacterStore + + TransferStore + + PartyStore + + LootWindowStore + + LootRollStore + + MailStore + + SocialStore + + NpcStore + + TrainerStore + + BankStore + + CombatStore + + DeathStore + + TradeStore + + AuctionActionStore + + CastStore + + ChannelActionStore + + ChatActionStore + + SpeechStore + + DuelActionStore + + GuildActionStore + + ItemActionStore + + MeetingStoneActionStore + + MeleeActionStore + + MemberStatsStore + + QuestActionStore + + TaxiActionStore + + VendorActionStore + + WeatherStore + + Send + + Sync +{ +} +/// Shard routing: the handle that serves a Character, a location, Realm-core or every World Shard. +pub(crate) trait ShardRoutingStore: Send + Sync { /// The database this handle targets — routing identity, for logs and for the tests that assert - /// no call ever escapes the player's home shard. `""` for mocks that don't model shards. - fn shard_name(&self) -> &str { - "" - } - - /// Undelivered private System Messages addressed to this character, oldest-first. A Package - /// `on_login` hook emits its message INSIDE `player_login`, before the session is in the - /// viewer registry, so the live insert relay has nobody to address — world entry replays what - /// is still parked in the shard cache. Defaults to none for mocks. - fn pending_system_messages(&self, _self_guid: u64) -> Vec { - Vec::new() - } - - // --- Cross-database transfer. Every one defaults to the single-database posture, so a - // --- store that does not shard (and every mock that does not exercise transfers) is unchanged. + /// no call ever escapes the player's home shard. + fn shard_name(&self) -> &str; /// Put `character_guid` on the shard that owns its location, running the escrowed transfer if - /// it is somewhere else, then answer the same question [`home_shard`](Self::home_shard) does. + /// it is somewhere else, then answer with that shard's handle, or `None` when this handle + /// already serves it. /// Called at every world entry; `Err` fails the login rather than letting a half-moved /// character into the world on either side. fn settle_home_shard( &self, character_guid: u64, - ) -> Result>> { - Ok(self.home_shard(character_guid)) - } + ) -> Result>>; /// The handle for the shard the Shard Map gives `(map_id, instance_id)`, asked of the handle /// that currently HOLDS the character (so a live dungeon run stays on its pool member). - /// `None` = this handle already serves that location, which is what a single-database gateway — - /// and every mock — always answers. + /// `None` = this handle already serves that location, which is what a single-database gateway + /// always answers. /// /// [`settle_home_shard`](Self::settle_home_shard) asks the same question from the character's /// own row; this asks it about a location nobody is at yet, which is what a session-less /// crossing needs before it has anything to route from. fn shard_for_location( &self, - _map_id: u32, - _instance_id: u64, - ) -> Option> { - None - } - - /// The escrow row this shard holds for `character_guid`, if any — the transfer's identity, its - /// destination and the serialized character. `None` = not mid-transfer here. - fn escrowed_transfer(&self, _character_guid: u64) -> Option { - None - } - - /// Where this shard's durable row says the character is going (`world::teleport_player` wrote - /// the destination there before despawning the entity). `None` = this shard has no row for it. - fn character_destination(&self, _character_guid: u64) -> Option { - None - } - - /// `begin_transfer` — freeze + serialize + delete the live entity, in one transaction. - fn begin_transfer(&self, _plan: &TransferPlan) -> Result<()> { - Err(anyhow!( - "this store does not implement cross-database transfers" - )) - } - - /// 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. - fn import_character_blob( - &self, - _transfer_id: u64, - _blob: &[u8], - _source: transfer::RealmLocatorPredecessor, - _bot_arrival: Option<&transfer::BotTransferIntent>, - ) -> Result<()> { - Err(anyhow!( - "this store does not implement cross-database transfers" - )) - } - - /// `confirm_import` — attest, on the SOURCE, that the destination copy is durable. - fn confirm_import(&self, _transfer_id: u64) -> Result<()> { - Err(anyhow!( - "this store does not implement cross-database transfers" - )) - } - - /// `finish_transfer` — delete-last: destroy the source copy and clear the escrow. - fn finish_transfer(&self, _transfer_id: u64) -> Result<()> { - Err(anyhow!( - "this store does not implement cross-database transfers" - )) - } - - /// `release_transfer` — drop a migrated blank arrival fence. New human and session-less - /// arrivals refuse because their exact crossing-specific reducer owns release. - fn release_transfer(&self, _transfer_id: u64) -> Result<()> { - Ok(()) - } - - /// Release one exact human arrival fence. - fn release_player_transfer_arrival( - &self, - _transfer_id: u64, - _character_guid: u64, - _source: transfer::RealmLocatorPredecessor, - ) -> Result<()> { - Err(anyhow!("this store does not host player Transfer arrivals")) - } - - /// Exact destination fence identity used by the Realm locator recovery compare-and-set. - fn transfer_arrival(&self, _transfer_id: u64) -> Option { - None - } - - /// Reconcile the arriving Character's authoritative party mirror before its destination fence - /// drops. A single-database store and a test store without realm-wide parties have no work. - fn sync_transfer_arrival(&self, _character_guid: u64) -> Result<()> { - Ok(()) - } - - /// Publish Realm's pending partition before the source is frozen. - fn sync_transfer_pending(&self, _character_guid: u64) -> Result<()> { - Ok(()) - } - - /// `set_character_shard` on the REALM-CORE handle — publish where a settled transfer put the - /// character. Called by `transfer::run_transfer` immediately after - /// `finish_transfer` commits, so it can only ever name a destination the escrow actually - /// reached; see `crate::realm_core::publish_shard_index` for why that is the strongest form of - /// "transactional" available across two databases. - /// - /// The default is a no-op so a store that does not shard is unchanged — the same posture as - /// every other transfer method. Production overrides it in `stdb::world_store`. - fn publish_shard_index( - &self, - _character_guid: u64, - _map_id: u32, - _instance_id: u64, - ) -> Result<()> { - Ok(()) - } - - /// Record Realm-core's pending phase before the source is frozen and return its predecessor - /// locator revision. Single-database stores have no remote phase. - fn begin_shard_index_transfer( - &self, - _plan: &transfer::TransferPlan, - bot_intent: Option<(&transfer::BotTransferIntent, u64)>, - ) -> Result { - Ok(party::RealmCharacterPartition { - map_id: 0, - instance_id: 0, - revision: bot_intent.map_or(1, |(intent, _)| intent.source_locator_revision.max(1)), - transfer_pending: true, - pending_destination_map: _plan.dest_map_id, - pending_destination_instance: _plan.dest_instance_id, - bot_source_identity: bot_intent - .map_or(spacetimedb_sdk::Identity::ZERO, |(intent, _)| { - intent.source_module_identity - }), - bot_transfer_intent_id: bot_intent.map_or(0, |(intent, _)| intent.id), - bot_controller_generation: bot_intent - .map_or(0, |(intent, _)| intent.controller_generation), - }) - } - - /// Settle a player crossing against the predecessor returned by - /// [`begin_shard_index_transfer`](Self::begin_shard_index_transfer). - fn finish_player_shard_index_transfer( - &self, - plan: &transfer::TransferPlan, - _source_map: u32, - _source_instance: u64, - _source_revision: u64, - ) -> Result<()> { - self.publish_shard_index(plan.character_guid, plan.dest_map_id, plan.dest_instance_id) - } - - /// Resume a Realm pending phase from the destination Character after source finish. - fn finish_pending_shard_index_transfer( - &self, - _character_guid: u64, - _destination_map: u32, - _destination_instance: u64, - _arrival: &transfer::TransferArrival, - ) -> Result<()> { - Ok(()) - } - - /// Bind this claimed intent to the Realm locator it is about to move from. - fn bind_bot_transfer_locator( - &self, - _intent: &transfer::BotTransferIntent, - _source_revision: u64, - _claim_token: u64, - ) -> Result<()> { - Err(anyhow!("this store does not bind bot Transfer locators")) - } - - /// Compare-and-set Realm-core's locator for one exact session-less crossing. - fn publish_bot_shard_index(&self, intent: &transfer::BotTransferIntent) -> Result<()> { - self.publish_shard_index( - intent.bot_guid, - intent.destination_map, - intent.destination_instance, - ) - } - - /// The source-side lease for a named instance: its map and owning party. Portal admission - /// creates this before a cross-Shard Transfer, so it remains the authority if party membership - /// changes while the Character is in Escrow. - fn instance_partition(&self, _instance_id: u64) -> Option<(u32, u64)> { - None - } - - /// `ensure_instance` — mirror an instance id onto this shard, spawning its population once. - fn ensure_instance(&self, _instance_id: u64, _map_id: u32, _party_id: u64) -> Result<()> { - Err(anyhow!( - "this store does not implement cross-database transfers" - )) - } - - /// `evict_instance_population` — drop an instance's population here, keeping the lease row. - fn evict_instance_population(&self, _instance_id: u64) -> Result<()> { - Err(anyhow!( - "this store does not implement cross-database transfers" - )) - } + map_id: u32, + instance_id: u64, + ) -> Option>; /// Bind this shard's per-player connection identity to the account (`establish_session`), so /// `player_login` can resolve the caller here. A no-op on the realm shard, where the logon tier /// already did it. Called at world entry whenever the session's home shard is not the realm. - fn bind_shard_session(&self, _account_id: u64, _session_key: &[u8; 40]) -> Result<()> { - Ok(()) - } - - // --- Realm-wide party state (the realm-core group slice). Every one defaults to the single-database - // --- posture, so an unsharded store — and every mock that does not model a realm — is - // --- unchanged: `realm_store()` answering `None` is what routes every party op back onto the - // --- player's own shard through the pre-realm-core reducers. + fn bind_shard_session(&self, account_id: u64, session_key: &[u8; 40]) -> Result<()>; /// The **realm-core** handle: the database that owns party membership realm-wide. /// /// `None` is not "no realm-core configured" — it is "this gateway runs against ONE database", in /// which case that database already is the authority and there is nothing to route. `world::party` /// branches on exactly this, so the single-database path never reads a row it did not read before. - fn realm_store(&self) -> Option> { - None - } + fn realm_store(&self) -> Option>; /// Realm-core for deleted Character cleanup. An unavailable configured Realm-core is an /// infrastructure failure and cannot fall back to a World Shard. - fn party_cleanup_realm(&self) -> Result>> { - Ok(self.realm_store()) - } + fn party_cleanup_realm(&self) -> Result>>; /// Realm-core for companion command authority. Configured outages fail closed. - fn party_command_realm(&self) -> Result>> { - Ok(self.realm_store()) - } + fn party_command_realm(&self) -> Result>>; /// Realm-core for Transfer locator authority. Only an unsharded Realm uses the local Store; /// an unavailable configured Realm-core is an infrastructure failure. - fn transfer_realm(&self) -> Result>> { - Ok(self.realm_store()) - } + fn transfer_realm(&self) -> Result>>; /// Every connected WORLD shard's handle (realm-core excluded — it owns no gameplay reads). The /// fan-out set for the roster mirror; empty on a single-database gateway, which is what makes the /// mirror push a no-op there. - fn world_stores(&self) -> Vec> { - Vec::new() - } + fn world_stores(&self) -> Vec>; /// Every configured World Shard for command receipt and holder admission. Missing or unhealthy /// members are an infrastructure error because absence cannot be certified on a partial set. - fn party_command_worlds(&self) -> Result>> { - Ok(self.world_stores()) - } - - /// Admit and claim one Group Intent against current World Shard state. Refusals include a - /// consumed intent or suppressed action. Transport failures remain distinct. - fn claim_bot_invite_intent(&self, intent_id: u64) -> Result; - - fn claim_party_command_intent(&self, _intent_id: u64, _claim_token: u64) -> Result<()> { - Err(anyhow!( - "this store does not host companion command intents" - )) - } - - fn admit_party_command_authority( - &self, - _group_id: u64, - _leader_guid: u64, - _bot_guid: u64, - _authority_member_guid: u64, - _expected_members: Vec, - ) -> Result { - Err(anyhow!( - "this store does not host realm-wide party authority" - )) - } - - fn apply_admitted_party_command( - &self, - _command: &party::AdmittedCompanionCommand, - ) -> Result { - Err(anyhow!( - "this store does not host companion command application" - )) - } - - fn finish_party_command_intent( - &self, - _intent_id: u64, - _claim_token: u64, - _outcome: party::CompanionCommandOutcome, - ) -> Result<()> { - Err(anyhow!( - "this store does not host companion command intents" - )) - } - - fn confirm_party_command_receipt( - &self, - _source_identity: spacetimedb_sdk::Identity, - _intent_id: u64, - ) -> Result> { - Ok(None) - } - - fn confirm_party_command_holder(&self, _bot_guid: u64) -> Result { - Err(anyhow!( - "this store does not host companion command targets" - )) - } - - fn entity_partition(&self, _guid: u64) -> Option<(u32, u64)> { - None - } - - /// Persist the exact claimed intent's destination-ready witness before release. - fn mark_bot_transfer_arrival_ready( - &self, - _intent_id: u64, - _bot_guid: u64, - _controller_generation: u64, - _claim_token: u64, - ) -> Result<()> { - Err(anyhow!("this store does not host Transfer Intents")) - } - - /// Whether the current destination fence belongs to this exact source intent. - fn bot_transfer_arrival_matches( - &self, - _transfer_id: u64, - _intent: &transfer::BotTransferIntent, - ) -> bool { - false - } - - /// Release only the destination fence identified by this intent. A newer fence is untouched. - fn release_bot_transfer_arrival( - &self, - _transfer_id: u64, - _intent: &transfer::BotTransferIntent, - ) -> Result<()> { - Err(anyhow!("this store does not host bot Transfer arrivals")) - } - - /// Acknowledged World Shard admission for one automatic group action. A later controller - /// selection cannot undo admission or membership already committed on Realm-core. - fn admit_sessionless_group_action(&self, character_guid: u64) -> Result; - - /// `realm_group_op` — run one party op against the database this handle names: realm-core - /// when sharded, the only shard otherwise. The op byte and argument slots are - /// `lyracore_shared::group::realm_op`. - fn realm_group_op( - &self, - _op: u8, - _actor_guid: u64, - _target_guid: u64, - _arg_a: u8, - _arg_b: u8, - _arg_c: u64, - ) -> Result { - Err(anyhow!("this store does not host realm-wide party state")) - } - - /// [`Self::realm_group_op`] that returns only after this handle's Coordinator cache holds the - /// commit, so a roster read right after it sees the op. It waits on the Coordinator pump, so its - /// caller must run on another thread. Fakes may reuse the ordinary op. - fn realm_group_op_visible( - &self, - op: u8, - actor_guid: u64, - target_guid: u64, - arg_a: u8, - arg_b: u8, - arg_c: u64, - ) -> Result { - self.realm_group_op(op, actor_guid, target_guid, arg_a, arg_b, arg_c) - } - - /// 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 { - self.realm_group_op( - lyracore_shared::group::realm_op::LEAVE, - character_guid, - 0, - lyracore_shared::group::leave_cause::CHARACTER_DELETED, - 0, - 0, - ) - } - - /// 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. - fn group_roster(&self, _character_guid: u64) -> Result> { - Ok(None) - } - - /// Bounded party projection used by companion-command authority. A roster longer than a Raid, - /// or an otherwise unreadable projection, is an infrastructure failure, never proof of - /// membership. - fn party_command_group_roster( - &self, - character_guid: u64, - ) -> Result> { - let roster = self.group_roster(character_guid)?; - if roster - .as_ref() - .is_some_and(|roster| roster.members.len() > lyracore_shared::group::RAID_MAX_MEMBERS) - { - anyhow::bail!("party command roster exceeds the member limit"); - } - Ok(roster) - } - - /// [`group_roster`](Self::group_roster) keyed by the group — the read the mirror push needs for a - /// party the acting character has just left. - fn group_roster_by_id(&self, _group_id: u64) -> Result> { - Ok(None) - } - - /// Realm-core's durable order for a complete party roster, including a disbanded party. - fn group_roster_revision(&self, _group_id: u64) -> Result { - Ok(1) - } - - /// The Roster Revision this database holds for one party, or `None` when it holds no row. On a - /// World Shard it is the mirror's revision, kept after a disband; on Realm-core it is the - /// authoritative one. - fn held_roster_revision(&self, _group_id: u64) -> Result> { - Ok(None) - } - - /// Realm-core's ordered locator for one Character. World Shards never supply this fact. - fn realm_character_partition( - &self, - _character_guid: u64, - ) -> Result> { - Ok(None) - } - - fn party_holder_observation( - &self, - character_guid: u64, - map_id: u32, - instance_id: u64, - ) -> Result { - Ok(party::PartyHolderObservation { - serves_locator: self.shard_for_location(map_id, instance_id).is_none(), - has_escrow: self.escrowed_transfer(character_guid).is_some(), - character_partition: self - .character_destination(character_guid) - .map(|character| (character.dest_map_id, character.dest_instance_id)), - }) - } - - fn party_cleanup_group_roster_by_id( - &self, - group_id: u64, - ) -> Result> { - self.group_roster_by_id(group_id) - } - - /// Every Character with authoritative Realm-core party membership. - fn party_member_guids(&self) -> Result> { - Ok(Vec::new()) - } - - /// Every party id held by this store. Realm-core rows are authoritative; World Shard rows are - /// mirrors that reconnect reconciliation must also inspect for stale parties. - fn party_group_ids(&self) -> Result> { - Ok(Vec::new()) - } - - /// `sync_group_mirror` — replace this shard's mirror of one party with realm-core's roster. - /// An empty `roster.members` is the disband tombstone. - fn sync_group_mirror(&self, _roster: &party::GroupRoster) -> Result<()> { - Ok(()) - } - - // --- Realm-wide loot rolls. Every one defaults to the single-database posture, so an - // --- unsharded store — and every mock that does not model a realm — is unchanged: `realm_store()` - // --- answering `None` is what routes `CMSG_LOOT_ROLL` back onto the player's own shard through - // --- the original `loot_roll` reducer, and leaves the relay (`loot::relay_tick`) with nothing to do. - - // Mirrors the `realm_loot_op` REDUCER's parameter list 1:1 — this trait is the seam between them, so the shapes have to match. - #[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( - &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<()> { - Err(anyhow!("this store does not host realm-wide loot rolls")) - } - - /// Cast one player vote on realm-core. The Store returns a typed gameplay answer while keeping - /// failures with an unknown durable result as `Err`. - fn realm_loot_vote( - &self, - corpse_guid: u64, - slot: u8, - actor_guid: u64, - vote: u8, - ) -> Result { - self.realm_loot_op( - lyracore_shared::loot_roll::loot_op::VOTE, - corpse_guid, - slot, - 0, - actor_guid, - vote, - 0, - Vec::new(), - 0, - spacetimedb_sdk::Identity::ZERO, - 0, - )?; - Ok(LootActionStatus::Applied) - } - - /// Every UNRESOLVED loot roll this WORLD SHARD has created but not yet had promoted onto - /// realm-core — the relay's promotion queue. Empty by default, which is what makes the - /// relay a no-op on an unsharded store and 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 method). - fn pending_local_rolls(&self) -> Result> { - Ok(Vec::new()) - } - - /// `settle_loot_roll` — grant a resolved roll's item on THIS world shard, if it holds the - /// matching corpse row. A no-op default so an unsharded store, and every shard that does - /// not hold the corpse, are unaffected; the module's own `withheld` guard is what makes a - /// wrong-shard call harmless in production too. - fn settle_loot_roll(&self, _corpse_guid: u64, _slot: u8, _winner_guid: u64) -> Result<()> { - Ok(()) - } - - /// `clear_promoted_loot_roll` — delete a staging roll's rows on THIS world shard, once the relay - /// has promoted it onto realm-core. A no-op default, matching `sync_group_mirror`'s shape. - fn clear_promoted_loot_roll(&self, _roll_id: u64) -> Result<()> { - Ok(()) - } - - // Same shape as `Coordinator::loot_won_since` (watermark + `(corpse, slot, winner)` triples) — the trait mirrors the read it fronts. - #[allow(clippy::type_complexity)] - /// Every `ROLL_WON` `game_group_event` row realm-core has pushed with an id greater than - /// `after_id` — `(corpse_guid, slot, winner_guid)` triples, plus the new high-water mark to - /// poll from next. Called on the **realm-core** handle. `(after_id, [])` by default, so the relay - /// never advances its watermark and never settles anything on an unsharded/mock store. - fn loot_won_since(&self, after_id: u64) -> Result<(u64, Vec<(u64, u8, u64)>)> { - Ok((after_id, Vec::new())) - } - - /// The account's characters for the character-select screen. In production this - /// reads the per-player `game_character` subscription (RLS-restricted to the owner). - fn characters(&self, account_id: u64) -> Result>; + fn party_command_worlds(&self) -> 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. - fn create_character( - &self, - account_id: u64, - name: &str, - race: u8, - class: u8, - gender: u8, - appearance: codec::Appearance, - ) -> Result; +/// The World Session lifecycle: handshake lookup, Account Claim, world entry, movement and logout reads. +pub(crate) trait SessionStore: Send + Sync { + /// Look up the shared session key K (+ account id) for an (already uppercased) account + /// name. `None` when no live session exists for that account (reject the handshake). + fn lookup_session(&self, account_name: &str) -> 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`). - fn delete_character( - &self, - account_id: u64, - character_guid: u64, - ) -> Result; + /// Undelivered private System Messages addressed to this character, oldest-first. A Package + /// `on_login` hook emits its message INSIDE `player_login`, before the session is in the + /// viewer registry, so the live insert relay has nobody to address — world entry replays what + /// 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 /// returns the live entity to spawn (from the resulting `game_world_entity` row). Errors if @@ -705,50 +199,6 @@ pub trait WorldStore: tx: SessionTx, ) -> Result; - /// Look up a character by guid (any owner) to answer `CMSG_NAME_QUERY` — the queried guid is - /// usually a peer, so this is not account-scoped. - fn character_by_guid(&self, guid: u64) -> Result>; - - /// Does any World Shard hold this Character? `false` means every configured Shard was readable - /// and had no row. An incomplete, unhealthy, or changing Shard set must return `Err`. - fn character_exists_on_any_world_shard(&self, guid: u64) -> Result { - if self.character_by_guid(guid)?.is_some() { - return Ok(true); - } - for shard in self.world_stores() { - if shard.character_by_guid(guid)?.is_some() { - return Ok(true); - } - } - Ok(false) - } - - /// Look up a creature template by entry to answer `CMSG_CREATURE_QUERY` (Tier 2 / NPCs). - fn creature_template(&self, entry: u32) -> Result>; - - /// Resolve the public name of a live pet for an in-world requester. - fn pet_name( - &self, - _requester_guid: u64, - _pet_number: u32, - _pet_guid: u64, - ) -> Result> { - Ok(None) - } - - /// Look up a gameobject template by entry to answer `CMSG_GAMEOBJECT_QUERY`. - fn gameobject_template(&self, entry: u32) -> Result>; - - /// The `type_id` of a spawned GameObject by its live guid. `CMSG_GAMEOBJ_USE` sends questgivers - /// to the quest window, chests to the loot-window lifecycle, and every other type to the general - /// use path. Production overrides the default with the spawned-object read. - fn gameobject_type(&self, _go_guid: u64) -> Result> { - Ok(None) - } - - /// 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<()>; - /// 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( @@ -759,158 +209,6 @@ pub trait WorldStore: payload: String, ) -> Result<()>; - /// The character's learned skill lines as `(skill_line, current, max_rank)` — feeds the self - /// CREATE's SkillInfo block. Empty when no `game_player_skill` rows exist. - fn player_skills(&self, character_guid: u64) -> Result>; - - /// The EFFECTIVE armor for `guid` (base + worn gear armor) for the self-login CREATE's - /// `UNIT_FIELD_RESISTANCES[0]` — so the character sheet shows real worn armor on relog. Auras aren't - /// folded here (they self-correct via the on_aura relay). Mirrors the module's combat `effective_armor`. - fn effective_armor(&self, guid: u64) -> u32; - - fn effective_magic_resistances(&self, _guid: u64) -> [u32; 6] { - [0; 6] - } - - /// Standing-derived reaction gate: does this NPC refuse `player_guid` its - /// interaction WINDOW? Rep-bar factions refuse at Unfriendly-or-below standing; bar-less - /// factions fall back to the FactionTemplate hostility masks. Fail-open on missing data. - /// Gossip, trainer and banker ask here; the migrated families ask the same read through their - /// own traits (`QuestActionStore::giver_refuses_interaction`, - /// `VendorActionStore::vendor_refuses_interaction`). - fn npc_refuses_interaction(&self, npc_guid: u64, player_guid: u64) -> Result; - - /// Does this trainer serve `player_guid`'s class? Gates the window and the "train" gossip - /// option through the same predicate the module buys with. Fail-open on missing data. - fn trainer_serves(&self, player_guid: u64, trainer_guid: u64) -> Result; - - /// The spells a class trainer (`trainer_guid`) teaches, each pre-folded with the player's level + - /// known-state for the `SMSG_TRAINER_LIST` Green/Red/Gray rendering (`CMSG_TRAINER_LIST`). - fn trainer_list( - &self, - player_guid: u64, - trainer_guid: u64, - ) -> Result>; - - /// Buy/learn `spell_id` from trainer `trainer_guid` (`CMSG_TRAINER_BUY_SPELL`). The module gates it - /// (range / level / cost / not-already-known) and a Refusal comes back as an outcome; `Err` means - /// the durable result is unknown. - fn buy_trainer_spell( - &self, - account_id: u64, - self_guid: u64, - trainer_guid: u64, - spell_id: u32, - ) -> Result; - - /// The skill line a trainer offering teaches (`game_trainer_spell.learn_skill_line`), or 0 for an - /// ordinary spell offering. The buy path reads it to tell a SKILL purchase from a SPELL purchase: - /// a riding offering's id is a marker with no Spell.dbc row, so echoing it as a learned spell would - /// push the client something it cannot resolve. 0 on any missing read (fail toward the spell echo, - /// which is the pre-existing behaviour). - fn trainer_offer_skill_line(&self, trainer_guid: u64, spell_id: u32) -> u32; - - /// Return the `grant_spell_id` for `talent_id` (0 = passive, no ability granted), so the gateway - /// can push `SMSG_LEARNED_SPELL` for ability talents after a successful `learn_talent`. - fn talent_grant_spell(&self, talent_id: u32) -> u32; - - /// 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, - button: u8, - action: u32, - action_type: u8, - ) -> Result<()>; - - /// Persist the rep pane's At-War checkbox (`CMSG_SET_FACTION_ATWAR`). - /// `reputation_index` is the client's 0..63 rep-array slot, NOT a faction id. - fn set_faction_at_war( - &self, - account_id: u64, - self_guid: u64, - reputation_index: u32, - at_war: bool, - ) -> Result<()>; - - /// Talent-pane sync after a successful `learn_talent`: `(teach_spell, superseded_prev, - /// points_remaining)` — the rank-spell to relay as LEARNED/SUPERCEDED (the 1.12 TalentFrame - /// derives shown ranks from known rank-spells) and the live PLAYER_CHARACTER_POINTS1 value - /// (earned − spent). `talent_id = 0` → just the points. - fn talent_pane_sync(&self, character_guid: u64, talent_id: u32) -> (u32, u32, u32); - - /// Sum of the character's spent talent ranks — non-zero gates the login points correction. - fn talent_points_spent(&self, character_guid: u64) -> u32; - - /// The character's active spell-modifier auras as raw (family_mask, op, amount, is_pct) rows — - /// the SMSG_SET_FLAT/PCT_SPELL_MODIFIER mirror source. - fn spell_modifiers(&self, character_guid: u64) -> Vec<(u32, u8, i32, bool)>; - - /// 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<()>; - - /// Bind the caller's hearthstone home to their current position (innkeeper gossip "Make this inn - /// your home."). No args — the module resolves the caller via `ctx.sender`. - fn bind_home(&self, account_id: u64, self_guid: u64) -> Result<()>; - - /// Does the NPC at `guid` carry the innkeeper flag? Gates the "Make this inn your home." gossip - /// option + the bind select. - fn npc_is_innkeeper(&self, guid: u64) -> Result; - - /// Resolve the `title_text_id` to embed in `SMSG_GOSSIP_MESSAGE` for the NPC at `guid`. - /// Looks up `game_gossip_menu` by creature entry; falls back to 1 (generic greeting). - fn npc_gossip_text_id(&self, npc_guid: u64) -> u32; - - /// Look up the full weighted greeting (all 8 `npc_text` slots) for a `text_id`. - /// Returns `None` when no imported `game_npc_text` row exists (the gateway falls back to the - /// generic greeting string). - fn npc_text_for_id(&self, text_id: u32) -> Option; - - /// The imported gossip menu options for the NPC at `guid`, sorted by - /// `option_index`, RAW/unfiltered by condition. Empty when nothing is imported for this creature - /// (the gateway falls back to the flag-derived vendor/innkeeper synthesis). - fn gossip_options(&self, npc_guid: u64) -> Result>; - - /// Respec at `trainer_guid` (the "I wish to unlearn my talents." gossip option, gated to level - /// 10+ by `filtered_gossip_options`). Errors (out of range / not enough gold) are - /// per-action; the caller just closes the gossip window either way. - fn reset_talents(&self, account_id: u64, self_guid: u64, trainer_guid: u64) -> Result<()>; - - /// Auto-bank/auto-store-bank the item in `slot` (`CMSG_AUTOBANK_ITEM`/`CMSG_AUTOSTORE_BANK_ITEM` - /// — 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<()>; - - /// 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<()>; - - /// The player's LEARNED spells (`game_player_spell`, beyond the class kit) — chained into the - /// login SMSG_INITIAL_SPELLS so a taught ability (e.g. Auto Shot) reaches the client spellbook. - fn player_learned_spells(&self, player_guid: u64) -> Result>; - - /// The player's persisted reputation standings (`game_player_reputation`) as `(reputation_index, - /// standing)` pairs — folded into the login `SMSG_INITIALIZE_FACTIONS` so a relog shows - /// the real standing instead of the all-neutral stub. - fn player_reputations(&self, player_guid: u64) -> Result>; - - /// The player's IMPORTED action-bar rows (`game_player_action`) as `(button, - /// action, action_type)` triples — empty pre-import (the common case today), in which case the - /// login codec falls back to synthesizing the bar from the spellbook (byte-identical to before - /// this method existed). - fn player_actions(&self, player_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 - /// SMSG_LEARNED_SPELL books the granted spell, never the wrapper. - fn resolve_learn_target(&self, spell_id: u32) -> u32; - - /// The KNOWN rank `new_spell` supersedes — Some(prev) drives SMSG_SUPERCEDED_SPELL on a buy. - fn superseded_old_rank(&self, new_spell: u32, player_guid: u64) -> Option; - /// 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 /// ABSENT = a transfer is genuinely pending; PRESENT = the ack is spurious (double-send or @@ -918,602 +216,23 @@ pub trait WorldStore: /// (visible blink, gateway combat-bookkeeping reset) at zero cost to the client. fn entity_in_world(&self, guid: u64) -> bool; - /// 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<()>; - - /// Validate a `CMSG_INSPECT` request: `target_guid` must be a real in-world player, on the - /// 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<()>; - - /// 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<()>; - - /// 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<()>; - /// The live entity's max health (0 if not in world) — the fall-damage flavor line folds /// the shared curve against it. fn entity_max_health(&self, guid: u64) -> u32; - /// Speak (`CMSG_MESSAGECHAT`, social tier): broadcast a say, yell or `/e` line - /// (`lyracore_shared::chat::broadcast_chat`). A language the speaker's race does not know is a - /// Refusal. - fn send_chat( - &self, - account_id: u64, - self_guid: u64, - chat_type: u8, - language: u8, - message: String, - ) -> Result; - - /// Perform an emote (`CMSG_TEXT_EMOTE`, social tier): broadcast the "X dances." line + animation. - /// `target_guid` (0 = untargeted) is the client's selected target — the gateway resolves it to a - /// name so the chat line reads "X waves at ." - fn send_emote( - &self, - account_id: u64, - self_guid: u64, - text_emote: u32, - emote_anim: u32, - target_guid: u64, - ) -> Result<()>; - - /// 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<()>; - - /// Every mail addressed to `recipient_guid`, 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 two-plane read, which is why this is one method rather than a realm-only twin. - /// Empty by default so a mock that models no mailbox answers "no mail" instead of failing a - /// session; the real refusals are the gates in `world::mail`, which run before this. - fn mail_list(&self, _recipient_guid: u64) -> Result> { - Ok(Vec::new()) - } - - /// The mail `mail_id`, delivered or not, on the database THIS handle names: the same two-plane - /// routing as [`mail_list`](Self::mail_list), as one primary key read. `None` by default, so a - /// store that models no mailbox finds no mail. - fn mail_by_id(&self, _mail_id: u64) -> Result> { - Ok(None) - } - - /// The name of the Realm Account that owns `character_guid`, read on THIS handle only. The - /// Account Character Owner names it when this Shard retains one. Otherwise the Character's - /// local Account names it, unless that Account is a shadow Account, whose name is not a Realm - /// Account's. `None` when this handle cannot name it. `world::mail` asks every World Shard, - /// because the Delivery Delay compares the Realm Accounts of two Characters on any Shards. - fn realm_account_name(&self, _character_guid: u64) -> Result> { - Ok(None) - } - - /// Is `player_guid` in range of the gameobject `mailbox_guid` names, and is it a mailbox at all? - /// - /// Always asked of the session's OWN handle: the mailbox is a gameobject on the shard the player - /// is standing on, and realm-core holds none. A PK lookup plus a map/instance/range check — - /// never a scan of the spatial gameobject table. - fn mailbox_in_range(&self, _mailbox_guid: u64, _player_guid: u64) -> Result { - Ok(false) - } - - /// Flip `mail_id`'s read state for `recipient_guid`, 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 - /// `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<()>; - - /// Delete `mail_id` for `recipient_guid`, 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<()>; - - /// 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 - /// 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<()>; - - /// Write one sent letter on the database THIS handle names, charging the sender the postage - /// plus the attached `money` in the SAME transaction. - /// - /// **The single-database gateway only**, where the purse and the row are on one database. A - /// sharded realm cannot have that transaction and drives [`mail_fence`](Self::mail_fence) and - /// 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. - /// - /// `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. - /// `same_account` says whether the sender and the recipient belong to one Realm Account; the - /// Module turns it into the letter's Delivery Delay. - #[allow(clippy::too_many_arguments)] - 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<()>; - - /// Credit `mail_id`'s copper to `recipient_guid` 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<()>; - - /// Re-create `mail_id`'s attached item in `recipient_guid`'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<()>; - - /// Has `payee_guid` 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. Answers `Ok` by default, so a store that - /// models no bags does not block a take it has no opinion on. - fn mail_item_room(&self, _payee_guid: u64) -> Result<()> { - Ok(()) - } - - /// `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, - /// this shard's own database otherwise — the same two-plane routing `mail_take_item_fence` - /// 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<()>; - - /// `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 - /// here refuses and leaves the mail COPIED with no letter granted. The Plain Letter sells for - /// 0, so a grant lost to that race costs nothing — this is deliberately not an escrow. Also a - /// 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<()>; - - /// `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<()>; - - /// 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 - /// copied letter's text outlives the mail row that created it, so this answers even after that - /// mail is deleted. `None` by default, so a store with no opinion on item text has none. - /// - /// The mail plane holds every copied letter's text keyed by a small, sequential id, so this - /// must never be read for a caller who has not proven they may see it — see - /// [`owns_item_with_text`](Self::owns_item_with_text). - fn item_text(&self, _item_text_id: u32) -> Result> { - Ok(None) - } - - /// Does `owner_guid` hold an item carrying `item_text_id` in their own bags, on THIS handle? - /// The ownership Gate `mail::item_text` checks before it answers from `game_item_text`: a - /// client walking `item_text_id` values must not read another player's Letter Copy that way. - /// `hint_item_guid` is `CMSG_ITEM_TEXT_QUERY`'s overloaded second field — often the queried - /// item's own guid when it names an item rather than a mail — so an implementation can try a - /// cheap PK lookup before falling back to a scan of `owner_guid`'s rows. `false` by default, so - /// a store with no opinion on item ownership grants nothing. - fn owns_item_with_text( - &self, - _owner_guid: u64, - _item_text_id: u32, - _hint_item_guid: u64, - ) -> Result { - Ok(false) - } - - /// **Escrow step 1 (send)** — take the postage plus the attached coin out of `sender_guid`'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 - /// are standing on. `Err` is the atomic affordability refusal — a refused send costs nothing. - /// - /// A COD PAYMENT is fenced through here too, because it is a letter out of a purse like any - /// other: `cod_source_mail_id` names the mail whose price it pays (0 for an ordinary letter), - /// and it rides the fence so a re-drive can settle that price without re-deriving anything. - /// - /// `same_account` says whether the sender and the recipient belong to one Realm Account. The - /// fence resolves the letter's Delivery Delay from it and stores it for the commit. - #[allow(clippy::too_many_arguments)] - 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<()> { - anyhow::bail!("mail_fence: this store models no escrow") - } - - /// **Escrow step 2 (send)** — write the mail row and its receipt under `escrow_id`, on the - /// database THIS handle names (realm-core). Idempotent: a replay writes nothing. - /// - /// `cod_source_mail_id` (0 for an ordinary letter) is the mail this one PAYS FOR: its price is - /// settled in the same transaction as the payout row, which is what makes a COD take charge - /// once however the drive is interrupted. - /// - /// `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`. - #[allow(clippy::too_many_arguments)] - fn mail_commit( - &self, - _escrow_id: u64, - _sender_guid: u64, - _recipient_guid: u64, - _subject: String, - _body: String, - _money: u32, - _item: mail::AttachedItem, - _cod: u32, - _cod_source_mail_id: u64, - _delivery_delay_secs: u32, - _reward: Option, - ) -> Result<()> { - anyhow::bail!("mail_commit: this store models no escrow") - } - - /// **Escrow step 1 (take)** — take `mail_id`'s copper out of the row into a fence, on the - /// database that OWNS THE ROW. `expect_money` is the amount the caller is about to pay out; a - /// mismatch is refused rather than fenced, because the gateway carries that number across. - fn mail_take_money_fence( - &self, - _escrow_id: u64, - _payee_guid: u64, - _mail_id: u64, - _expect_money: u32, - ) -> Result<()> { - anyhow::bail!("mail_take_money_fence: this store models no escrow") - } - - /// **Escrow step 2 (take)** — credit `amount` to `payee_guid` 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<()> { - anyhow::bail!("mail_payout: this store models no escrow") - } - - /// **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 - /// mismatch is refused rather than fenced, because the gateway carries the snapshot across. - fn mail_take_item_fence( - &self, - _escrow_id: u64, - _payee_guid: u64, - _mail_id: u64, - _expect_entry: u32, - ) -> Result<()> { - anyhow::bail!("mail_take_item_fence: this store models no escrow") - } - - /// **Escrow step 2 (item take)** — re-create the fenced item in `payee_guid`'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, - _mail_id: u64, - _item: mail::AttachedItem, - ) -> Result<()> { - anyhow::bail!("mail_item_payout: this store models no escrow") - } - - /// **Escrow step 3** — attest, on the handle HOLDING the fence, that the other database - /// committed. The only thing that licenses step 4. - fn mail_confirm_delivery(&self, _escrow_id: u64) -> Result<()> { - anyhow::bail!("mail_confirm_delivery: this store models no escrow") - } - - /// **Escrow step 4** — destroy the fence, on the handle holding it. Delete-last: it refuses - /// while unattested. - fn mail_settle(&self, _escrow_id: u64) -> Result<()> { - anyhow::bail!("mail_settle: this store models no escrow") - } - - /// Every unfinished mail escrow this database holds for `sender_guid` (the payee, on a payout). - /// - /// The read that makes re-driving possible at all: a fence carries its whole letter, so a drive - /// abandoned by a dead gateway is resumable from the row. Empty by default, so a store that - /// models no escrow simply has nothing to re-drive. - fn mail_escrows_of(&self, _sender_guid: u64) -> Result> { - Ok(Vec::new()) - } - - /// 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<()>; - /// Acquire Realm-core ownership and fence every configured World Shard before returning. fn claim_session(&self, account_id: u64, character_guid: u64) -> Result; - /// Bind subsequent requests to this World Session. A Fake can retain its existing handle. + /// Bind subsequent requests to this World Session. `None` keeps the current handle. fn bind_session( &self, - _token: WorldSessionToken, - ) -> Result>> { - Ok(None) - } + token: WorldSessionToken, + ) -> Result>>; /// Close the matching Shard fences before releasing the Realm-core claim. Stale cleanup is inert. fn release_session(&self, token: WorldSessionToken) -> 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<()>; - - /// 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<()>; - - /// 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<()>; - - /// 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<()>; - - // --- Trade. 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). - - /// `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<()>; - /// `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<()>; - /// `CMSG_CANCEL_TRADE` — tear the caller's Trade Session down (`TradeCanceled` to both). - fn cancel_trade(&self, account_id: u64, self_guid: u64) -> 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<()>; - /// `CMSG_CLEAR_TRADE_ITEM`. - fn clear_trade_item(&self, account_id: u64, self_guid: u64, 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<()>; - /// `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<()>; - /// `CMSG_UNACCEPT_TRADE`, withdraw an accept; partner hears `BackToTrade`. - fn unaccept_trade(&self, account_id: u64, self_guid: u64) -> Result<()>; - /// `CMSG_BUSY_TRADE`, decline a pending proposal as busy; initiator hears `Busy`. - fn busy_trade(&self, account_id: u64, self_guid: u64) -> Result<()>; - /// `CMSG_IGNORE_TRADE`, decline via ignore; initiator hears `IgnoreYou`. - fn ignore_trade(&self, account_id: u64, self_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>; - /// Return the `combat_until_ms` timestamp for `player_guid`'s entity row (0 if the entity is not /// found). Used by the logout handler to deny `CMSG_LOGOUT_REQUEST` while the player is in combat. fn player_combat_until_ms(&self, player_guid: u64) -> u64; - - /// This Shard's durable Character row for `guid`: identity plus the session flag. `None` if - /// this Shard holds no `game_character` row for it. - /// [`presence::of`](super::presence::of) unions it across every connected Shard. - fn character_identity(&self, guid: u64) -> Result>; - - /// This Shard's live `game_world_entity` row for `guid`, if any — the Member Stats columns, - /// plus level and zone, current unlike the durable row (`persist_entity` only refreshes it on - /// logout, cross-map teleport or Transfer). `None` if `guid` has no live entity here. - fn live_entity(&self, guid: u64) -> Option; - - /// Does this Shard show `guid` between two places: its own Character row reading online with - /// no live entity here (a map-change loading screen, or a human Transfer's frozen source - /// copy — `begin_transfer` persists with `set_offline: false`), or a Transfer Intent naming a - /// session-less bot mid-crossing. - fn character_in_transit(&self, guid: u64) -> bool; - - /// This Shard's stored Auto-Reply for `guid`. `None` when this Shard holds none. - /// [`presence::auto_reply`] asks the Shard that holds the live entity. - fn auto_reply_text(&self, guid: u64) -> Result>; - - /// Does every configured World Shard vouch that it is reachable and healthy enough to trust a - /// negative read from? [`presence::of`] asks this before answering `Whereabouts::Offline` or - /// `None` — an unreachable or stale-cached Shard could be hiding the Character, so the default - /// (`Ok(())`, every Store without a Shard topology to ask) must be overridden by any Store that - /// actually has one to check. - fn every_shard_vouches_for_absence(&self) -> Result<()> { - Ok(()) - } - - /// Every in-world player Character on this Shard — the per-Shard input - /// [`presence::in_world_characters`](super::presence::in_world_characters) unions, and `/who`'s - /// ultimate source. A player is "in world" iff their guid appears in `game_world_entity` with - /// `entry == 0` (player entity); dead players are included (ghosts are in world). Bots are - /// included: they have no session, but they do have a live entity. - fn in_world_players(&self) -> Result>; - - /// `game_area.name` for `zone_id` — `/who`'s search-string match against a zone name. Empty - /// when the catalogue holds no row for it (unimported, or an id the client sends that the - /// imported DBC lacks). - fn zone_name(&self, _zone_id: u32) -> String { - String::new() - } - - /// `self_guid`'s friend guids and ignore guids for `CMSG_FRIEND_LIST`. `self_guid` is always - /// the CALLING World Session's own guid, never a peer's, so a Store may answer this from - /// whatever cheap per-connection state it keeps for its own connected sessions (the Coordinator - /// reads its Gateway-side `Viewer`). A caller that needs a friend's PRESENCE composes it - /// separately with `world::social::friend_views`, over `presence::of`; a caller that needs to - /// know whether an ARBITRARY (possibly unconnected) Character ignores another uses - /// [`WorldStore::ignored_guids`] instead, never this method. - fn contact_lists(&self, self_guid: u64) -> Result<(Vec, Vec)>; - - /// `owner_guid`'s ignore guids, read directly off this Shard's durable contact rows — realm-wide - /// safe for ANY owner, including one with no live World Session on this Gateway process at all - /// (a whisper sender or a guild-invite target usually is not). `whisper::ignored_anywhere` fans - /// this out across every connected Shard. Unlike `contact_lists`, this never reads a `Viewer`. - fn ignored_guids(&self, owner_guid: u64) -> Result>; - - /// Resolve a typed contact name to a character guid on THIS Shard (case-insensitive, like the - /// Module's `character_by_name`): `presence::resolve_by_name`'s per-Shard primitive. `None` - /// if this Shard has no character with that name. - fn character_guid_by_name(&self, name: &str) -> Result>; - - /// A character's live presence `(online, level, class, zone_id)` on THIS Shard only. `None` if - /// the guid doesn't resolve to any character here. - fn character_presence(&self, guid: u64) -> Result>; - - // Contact ops answer a [`social::ContactOutcome`]: a typed Refusal is a gameplay answer the - // client renders, and only a failure with an unknown durable result stays `Err`. - - /// `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; - /// `CMSG_DEL_FRIEND`. - fn del_friend( - &self, - account_id: u64, - self_guid: u64, - 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; - /// `CMSG_DEL_IGNORE`. - fn del_ignore( - &self, - account_id: u64, - self_guid: u64, - target_guid: u64, - ) -> 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. - - /// `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; - /// `CMSG_GROUP_ACCEPT`. - fn group_accept(&self, account_id: u64, self_guid: u64) -> Result; - /// `CMSG_GROUP_DECLINE`. - fn group_decline(&self, account_id: u64, self_guid: u64) -> Result; - /// `CMSG_GROUP_DISBAND` (the client's "Leave Party"). - fn group_leave(&self, account_id: u64, self_guid: u64) -> 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; - /// `CMSG_LOOT_METHOD` — the leader sets the party's loot method/ - /// threshold/master. `loot_setting`/`loot_threshold` are the gateway-decoded `GroupLootSetting`/ - /// `ItemQuality` wire bytes, passed straight through (the module adopted the wire ordering - /// verbatim — zero translation). - fn group_loot_method( - &self, - account_id: u64, - self_guid: u64, - loot_setting: u8, - master_guid: u64, - loot_threshold: u8, - ) -> Result; - /// `CMSG_LOOT_ROLL` — record the caller's need/greed/pass vote. - fn loot_roll( - &self, - account_id: u64, - self_guid: u64, - corpse_guid: u64, - loot_slot: u32, - vote: u8, - ) -> Result; - /// `CMSG_LOOT_MASTER_GIVE` — the master looter assigns an above- - /// threshold row to `target_guid`. - fn loot_master_give( - &self, - account_id: u64, - self_guid: u64, - corpse_guid: u64, - loot_slot: u8, - 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, - npc_guid: u64, - option_id: u32, - option_row_id: u32, - ) -> Result<()>; } diff --git a/gateway/src/world/taxi_tests.rs b/gateway/src/world/taxi_tests.rs new file mode 100644 index 000000000..b80778753 --- /dev/null +++ b/gateway/src/world/taxi_tests.rs @@ -0,0 +1,206 @@ +//! Taxi opcodes through their dispatcher, and taxi gossip over an encrypted World Session. + +use super::handlers::InMemoryTaxiActions; +use super::*; +use wow_world_messages::vanilla::ActivateTaxiReply; + +const PLAYER: TaxiActionPlayer = TaxiActionPlayer { self_guid: Some(1) }; + +/// Dispatch one taxi message and return the packets the session would send for it. An activation +/// goes through `queue_reply_then_arm`, as the session does, so the reply is queued before the +/// flight arms. +fn try_run( + actions: &InMemoryTaxiActions, + msg: impl Into, +) -> Result> { + match dispatch_taxi_action(actions, PLAYER, msg.into())? { + TaxiActionOutcome::Handled { outbound } => Ok(outbound), + TaxiActionOutcome::Activated { + outbound, + character_guid, + arm, + } => { + let (tx, rx) = SessionTx::with_depth(0); + queue_reply_then_arm(&tx, actions, outbound, character_guid, arm)?; + drop(tx); + Ok(rx.try_iter().collect()) + } + TaxiActionOutcome::PassThrough(_) => panic!("the taxi dispatcher passed the message on"), + } +} + +fn run(actions: &InMemoryTaxiActions, msg: impl Into) -> Vec { + try_run(actions, msg).unwrap() +} + +/// The one typed packet a request answered with. +fn only_message(mut sent: Vec) -> ServerOpcodeMessage { + match (sent.pop(), sent.is_empty()) { + (Some(Outbound::One(message)), true) => message, + _ => panic!("expected exactly one typed packet"), + } +} + +fn taxi_map_90() -> codec::TaxiMapView { + codec::TaxiMapView { + npc_guid: 90, + source_client_node_id: 255, + available_client_node_ids: vec![255, 256], + } +} + +fn taxi_map_actions() -> InMemoryTaxiActions { + let actions = InMemoryTaxiActions::default(); + *actions.map.lock().unwrap() = Some(taxi_map_90()); + actions +} + +fn activate_90() -> CMSG_ACTIVATETAXI { + CMSG_ACTIVATETAXI { + guid: Guid::new(90), + source_node: 255, + destination_node: 256, + } +} + +#[test] +fn taxi_status_query_returns_the_persisted_bit_without_opening() { + // A status answer, not a node map, shows the query did not open the taxi. + let actions = InMemoryTaxiActions::default(); + *actions.status.lock().unwrap() = Some(codec::TaxiNodeStatusView { + npc_guid: 90, + known: false, + }); + let query = CMSG_TAXINODE_STATUS_QUERY { + guid: Guid::new(90), + }; + match only_message(run(&actions, query)) { + ServerOpcodeMessage::SMSG_TAXINODE_STATUS(status) => { + assert_eq!(status.guid.guid(), 90); + assert!(!status.taxi_mask_node_known); + } + other => panic!("expected SMSG_TAXINODE_STATUS, got {other}"), + } +} + +#[test] +fn direct_taxi_query_opens_the_taxi_map() { + let actions = taxi_map_actions(); + let query = CMSG_TAXIQUERYAVAILABLENODES { + guid: Guid::new(90), + }; + match only_message(run(&actions, query)) { + ServerOpcodeMessage::SMSG_SHOWTAXINODES(map) => { + assert_eq!(map.guid.guid(), 90); + assert_eq!(map.nearest_node, 255); + assert_eq!(map.nodes.len(), 8); + assert_eq!(map.nodes[7], 0xC000_0000); + } + other => panic!("expected SMSG_SHOWTAXINODES, got {other}"), + } +} + +#[test] +fn taxi_gossip_opens_the_same_map_as_the_direct_query() { + // The gossip arm lives in the query handler, so this stays a socket test. It must answer + // with exactly the packet the direct query builds. + use lyracore_shared::constants::gossip_option; + let direct = only_message(run( + &taxi_map_actions(), + CMSG_TAXIQUERYAVAILABLENODES { + guid: Guid::new(90), + }, + )); + + let mut s = quest_store(); + s.npc.gossip_opts = vec![opt(0, "Show me your flight routes.", gossip_option::TAXI)]; + s.taxi.taxi_map = Some(taxi_map_90()); + 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); + CMSG_GOSSIP_SELECT_OPTION { + guid: Guid::new(90), + gossip_list_id: 0, + unknown: None, + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + let gossip = ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); + assert_eq!(gossip, direct); + + drop(client); + server.join().unwrap(); +} + +#[test] +fn activate_taxi_gameplay_refusal_replies_and_does_not_fail_the_next_query() { + // The Module accepts only 255 -> 256, so the reply also shows the node ids arrived intact. + let actions = InMemoryTaxiActions { + route: Some((255, 256)), + ..Default::default() + }; + *actions.activation.lock().unwrap() = codec::TaxiActivationResult { + result_code: lyracore_shared::constants::taxi_protocol::ACTIVATE_NOT_ENOUGH_MONEY, + }; + *actions.status.lock().unwrap() = Some(codec::TaxiNodeStatusView { + npc_guid: 90, + known: true, + }); + + match only_message(run(&actions, activate_90())) { + ServerOpcodeMessage::SMSG_ACTIVATETAXIREPLY(reply) => { + assert_eq!(reply.reply, ActivateTaxiReply::NotEnoughMoney); + } + other => panic!("expected SMSG_ACTIVATETAXIREPLY, got {other}"), + } + + // A gameplay refusal is a normal packet result, not a world-session failure. + let query = CMSG_TAXINODE_STATUS_QUERY { + guid: Guid::new(90), + }; + assert!(matches!( + only_message(run(&actions, query)), + ServerOpcodeMessage::SMSG_TAXINODE_STATUS(_) + )); +} + +#[test] +fn activate_taxi_success_replies_ok() { + let actions = InMemoryTaxiActions { + route: Some((255, 256)), + ..Default::default() + }; + *actions.activation.lock().unwrap() = codec::TaxiActivationResult { + result_code: lyracore_shared::constants::taxi_protocol::ACTIVATE_OK, + }; + + match only_message(run(&actions, activate_90())) { + ServerOpcodeMessage::SMSG_ACTIVATETAXIREPLY(reply) => { + assert_eq!(reply.reply, ActivateTaxiReply::Ok); + } + other => panic!("expected SMSG_ACTIVATETAXIREPLY, got {other}"), + } +} + +#[test] +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()); + 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); + CMSG_GOSSIP_SELECT_OPTION { + guid: Guid::new(90), + gossip_list_id: 0, + unknown: None, + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + + server + .join() + .expect_err("a broken taxi reducer transport must end the world session"); +} diff --git a/gateway/src/world/test_support/entry.rs b/gateway/src/world/test_support/entry.rs new file mode 100644 index 000000000..bb71e1581 --- /dev/null +++ b/gateway/src/world/test_support/entry.rs @@ -0,0 +1,255 @@ +//! World entry as a socket test reads it. + +use super::*; +use std::os::unix::net::UnixStream; +use wow_world_messages::vanilla::{ClientMessage, CMSG_AUTH_SESSION, CMSG_PLAYER_LOGIN}; +use wow_world_messages::Guid; + +/// More frames than any world entry batch carries. Reaching it means the batch never ended. +const WORLD_ENTRY_FRAME_LIMIT: usize = 64; + +/// Read one world entry batch: the login sequence, any item CREATEs and the self CREATE, up to and +/// including the zone's `SMSG_WEATHER`, which always closes the batch. A fresh entry and a +/// world-port re-entry end the same way. Panics when no `SMSG_WEATHER` arrives within +/// [`WORLD_ENTRY_FRAME_LIMIT`] frames. +pub(crate) fn drain_world_entry( + client: &mut S, + dec: &mut DecrypterHalf, +) -> Vec { + let mut frames = Vec::new(); + while frames.len() < WORLD_ENTRY_FRAME_LIMIT { + let message = ServerOpcodeMessage::read_encrypted(&mut *client, dec) + .unwrap_or_else(|e| panic!("world entry frame {} did not arrive: {e}", frames.len())); + let ends_batch = matches!(message, ServerOpcodeMessage::SMSG_WEATHER(_)); + frames.push(message); + if ends_batch { + return frames; + } + } + panic!("no SMSG_WEATHER within {WORLD_ENTRY_FRAME_LIMIT} frames: the world entry batch never ended"); +} + +/// The client side of every real world-session test has a bounded read. A missing server packet is +/// a test failure, never an indefinitely blocked test process. +pub(crate) const WORLD_SESSION_READ_DEADLINE: std::time::Duration = + std::time::Duration::from_secs(5); + +pub(crate) fn world_session_socket_pair() -> (UnixStream, UnixStream) { + let (client, server) = UnixStream::pair().expect("world-session socket pair must be created"); + client + .set_read_timeout(Some(WORLD_SESSION_READ_DEADLINE)) + .expect("world-session client read deadline must be configured"); + (client, server) +} + +pub(crate) const K: [u8; 40] = [ + 0x2E, 0xFE, 0xE7, 0xB0, 0xC1, 0x77, 0xEB, 0xBD, 0xFF, 0x66, 0x76, 0xC5, 0x6E, 0xFC, 0x23, 0x39, + 0xBE, 0x9C, 0xAD, 0x14, 0xBF, 0x8B, 0x54, 0xBB, 0x5A, 0x86, 0xFB, 0xF8, 0x1F, 0x6D, 0x42, 0x4A, + 0xA2, 0x3C, 0xC9, 0xA3, 0x14, 0x9F, 0xB1, 0x75, +]; + +pub(crate) fn ns(s: &str) -> NormalizedString { + NormalizedString::new(s).unwrap() +} + +/// Drive the shared prefix of every world handshake: read the plaintext `SMSG_AUTH_CHALLENGE`, +/// derive the client-side proof + cipher pair for `key` with `wow_srp`, and send +/// `CMSG_AUTH_SESSION`. Lower-level than [`client_handshake`] — it does not read whatever comes +/// back, so a call site can assert on that itself (an `AuthOk`, an `AuthWaitQueue`, a plaintext +/// rejection...). Returns the cipher pair `into_client_header_crypto` derived, split; a `key` that +/// does not match what the server holds still produces a (mismatched, useless) pair here — the +/// send happens regardless — so a rejection-path call site is free to bind them as `_`. +pub(crate) fn drive_auth( + client: &mut S, + username: &str, + key: [u8; 40], +) -> (EncrypterHalf, DecrypterHalf) { + let server_seed = match ServerOpcodeMessage::read_unencrypted(&mut *client).unwrap() { + ServerOpcodeMessage::SMSG_AUTH_CHALLENGE(c) => c.server_seed, + other => panic!("expected SMSG_AUTH_CHALLENGE, got {other}"), + }; + let client_seed = ProofSeed::new(); + let client_seed_value = client_seed.seed(); + let (client_proof, crypto) = + client_seed.into_client_header_crypto(&ns(username), key, server_seed); + let (enc, dec) = crypto.split(); + + auth_session(username, client_seed_value, client_proof) + .write_unencrypted_client(&mut *client) + .unwrap(); + + (enc, dec) +} + +/// Drive the client side of the world handshake against a server running `run_world_session` +/// (or `world_handshake`): read the plaintext challenge, send `CMSG_AUTH_SESSION` with a +/// valid proof, read the encrypted AUTH_OK. Returns the client's cipher halves for the +/// post-handshake encrypted traffic. +pub(crate) fn client_handshake( + client: &mut S, + username: &str, + key: [u8; 40], +) -> (EncrypterHalf, DecrypterHalf) { + let (enc, mut dec) = drive_auth(client, username, key); + + match ServerOpcodeMessage::read_encrypted(&mut *client, &mut dec).unwrap() { + ServerOpcodeMessage::SMSG_AUTH_RESPONSE(r) => { + assert!(matches!(*r, SMSG_AUTH_RESPONSE::AuthOk { .. })); + } + other => panic!("expected encrypted SMSG_AUTH_RESPONSE, got {other}"), + } + (enc, dec) +} + +pub(crate) fn auth_session( + username: &str, + client_seed: u32, + client_proof: [u8; 20], +) -> CMSG_AUTH_SESSION { + CMSG_AUTH_SESSION { + build: 5875, + server_id: 1, + username: username.to_string(), + client_seed, + client_proof, + addon_info: vec![], + } +} + +/// A store for a logged-in TESTER (account `account_id`), with no character/scenario state beyond +/// the session itself — the shape every handshake/login/logout/movement test overlays with its own +/// fields via `..`. `quest_store()` is the sibling for tests that also need a login entity. +pub(crate) fn tester_store(account_id: u64) -> WorldFake { + WorldFake { + session: SessionState { + entity_in_world: true, + username: "TESTER".into(), + session: Some(WorldSession { + account_id, + session_key: K, + }), + ..Default::default() + }, + ..Default::default() + } +} + +/// Human/Warrior entity matching the seed, as the gateway would read it back after +/// `player_login`. +pub(crate) fn warrior_entity() -> codec::EntityView { + codec::EntityView { + guid: 1, + map_id: 0, + instance_id: 0, // the open world + zone_id: 12, + x: -8949.95, + y: -132.493, + z: 83.5312, + orientation: 0.0, + last_move_ms: 0, + movement_flags: 0, + run_speed_mult_bp: 10_000, + type_mask: 0x19, + entry: 0, + scale_x: 1.0, + health: 60, + max_health: 60, + power: 0, + max_power: 1000, + level: 1, + faction_template: 1, + target_guid: 0, + unit_bytes_0: 1 | (1 << 8) | (1 << 24), // human(1) warrior(1) male(0) rage(1) + display_id: 49, + native_display_id: 49, + unit_flags: 0, + mount_display_id: 0, + base_attack_time_ms: 2000, + dynamic_flags: 0, + player_bytes: 0, + player_bytes_2: 0, + player_bytes_3: 0, + player_flags: 0, + xp: 0, + next_level_xp: 0, + money: 0, + unit_bytes_1: 0, + unit_bytes_2: 0, + // L1 Human Warrior base attributes (cmangos curve) — non-zero so the CREATE exercises them. + strength: 23, + agility: 20, + stamina: 22, + intellect: 20, + spirit: 21, + sheet_ap_base: 29, + sheet_ap_mods: 0, + sheet_dmg_min: 5, + sheet_dmg_max: 7, + sheet_ranged_ap: 0, + sheet_ranged_dmg_min: 0, + sheet_ranged_dmg_max: 0, + npc_flags: 0, // a player is not an NPC + owner_guid: 0, // not a summon + effective_armor: 40, // agility 20 * 2 (base; no gear in the fixture → effective == base) + magic_resistances: [0; 6], + // No hearthstone bind recorded for the test entity; fall back to login position. + home_map: 0, + home_zone: 0, + home_x: 0.0, + home_y: 0.0, + home_z: 0.0, + guild_id: 0, + guild_rank: 0, + } +} + +/// A store configured for an in-world TESTER (account 7, char guid 1) with a login entity — the +/// base fixture every socket test that needs a logged-in player builds on, quest or not. +pub(crate) fn quest_store() -> WorldFake { + let base = tester_store(7); + WorldFake { + session: SessionState { + login_entity: Some(warrior_entity()), + ..base.session + }, + ..base + } +} + +/// Spin up a world session over a socket pair, handshake as TESTER, enter the world as `guid`, and +/// drain the world entry batch, then the quest-log update when the player has quests. Draining one +/// message too few manifests as the CLIENT closing with unread bytes still queued — which the kernel +/// reports back to the SERVER thread's next read as ECONNRESET, not a clean EOF. +/// Returns the client socket + encrypted halves + the server join handle for the test to drive. +pub(crate) fn enter_world( + store: std::sync::Arc, + guid: u64, +) -> ( + UnixStream, + EncrypterHalf, + DecrypterHalf, + std::thread::JoinHandle<()>, +) { + let (mut client, server_end) = world_session_socket_pair(); + // The login sequence ends with the quest-log VALUES packet IFF the player has quests (mirrors + // `send_quest_log`'s skip-when-empty). Checked before `store` is moved into the server thread. + let has_quest_log = !store.quest.quest_log_slots.is_empty(); + let server_store = store; + let server = std::thread::spawn(move || { + run_world_session(server_end, server_store.clone()).unwrap(); + }); + 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); + if has_quest_log { + // The quest-log packet is a PARTIAL VALUES update with OBJECT_FIELD_TYPE stripped (so the real + // 5875 client doesn't crash — see the health-VALUES note). gtker's DECODER rejects that ("Missing + // object TYPE"), but the frame bytes are consumed, so drain it tolerantly rather than unwrap. + let _ = ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec); + } + (client, c_enc, c_dec, server) +} diff --git a/gateway/src/world/test_support/family.rs b/gateway/src/world/test_support/family.rs new file mode 100644 index 000000000..62d39d5e8 --- /dev/null +++ b/gateway/src/world/test_support/family.rs @@ -0,0 +1,137 @@ +//! Pieces the handler-level family tests share: a session connection to run a handler against, and +//! a drain for what the handler sent. A family test owns its Fake and its `run` helper. + +use super::*; +use std::sync::mpsc::Receiver; + +/// A connection for `account_id` in the world as `self_guid`. The session's routed Store is +/// never read here: every handler under test takes its family Fake as an argument. +pub(crate) fn in_world_conn(account_id: u64, self_guid: u64) -> WorldConn { + let username = NormalizedString::new("TESTER").unwrap(); + let server_seed = ProofSeed::new(); + let client_seed = ProofSeed::new(); + let client_seed_value = client_seed.seed(); + let (client_proof, _client_crypto) = + client_seed.into_client_header_crypto(&username, K, server_seed.seed()); + let (_encrypt, decrypt) = server_seed + .into_server_header_crypto(&username, K, client_proof, client_seed_value) + .map(|crypto| crypto.split()) + .unwrap_or_else(|_| panic!("the proof was made for the same key")); + WorldConn { + session_claim: None, + account_id, + account_name: "TESTER".into(), + decrypt, + state: WorldState::InWorld(InWorld { + self_guid, + subs: PlayerSubscriptions::empty(), + attacking_target: None, + open_loot: Default::default(), + ranged_repeat: false, + }), + move_coalesce: Default::default(), + gossip_menu: None, + store: RoutedStore::new(Arc::new(WorldFake::default())), + session_key: None, + guild_signed_on: None, + move_desync_drops: 0, + who_throttled_until: None, + group_broadcast_cooldowns: Default::default(), + chat_flood: Default::default(), + } +} + +/// Every message a handler queued on its `SessionTx`, in send order. +pub(crate) fn drain_outbound(rx: &Receiver) -> Vec { + let mut sent = Vec::new(); + while let Ok(out) = rx.try_recv() { + match out { + Outbound::One(message) => sent.push(message), + Outbound::Batch(messages) => sent.extend(messages), + Outbound::Raw { opcode, .. } => panic!("unexpected raw packet {opcode:#06x}"), + Outbound::Job(_) => panic!("unexpected deferred relay job"), + } + } + sent +} + +/// `NpcStore` for a Fake that only needs the Npc to refuse or accept an interaction. Every other +/// method panics, so a handler that starts to read more fails the test loudly. +macro_rules! npc_store_refusing_by { + ($fake:ty, $refuses:ident) => { + impl NpcStore for $fake { + fn npc_refuses_interaction(&self, _npc_guid: u64, _player_guid: u64) -> Result { + Ok(self.$refuses) + } + + fn creature_template(&self, _entry: u32) -> Result> { + unimplemented!("creature_template") + } + + fn pet_name( + &self, + _requester_guid: u64, + _pet_number: u32, + _pet_guid: u64, + ) -> Result> { + unimplemented!("pet_name") + } + + fn gameobject_template( + &self, + _entry: u32, + ) -> Result> { + unimplemented!("gameobject_template") + } + + fn gameobject_type(&self, _go_guid: u64) -> Result> { + unimplemented!("gameobject_type") + } + + fn enter_areatrigger( + &self, + _account_id: u64, + _self_guid: u64, + _trigger_id: u32, + ) -> Result<()> { + unimplemented!("enter_areatrigger") + } + + fn bind_home(&self, _account_id: u64, _self_guid: u64) -> Result<()> { + unimplemented!("bind_home") + } + + fn npc_is_innkeeper(&self, _guid: u64) -> Result { + unimplemented!("npc_is_innkeeper") + } + + fn npc_gossip_text_id(&self, _npc_guid: u64) -> u32 { + unimplemented!("npc_gossip_text_id") + } + + fn npc_text_for_id(&self, _text_id: u32) -> Option { + unimplemented!("npc_text_for_id") + } + + fn gossip_options(&self, _npc_guid: u64) -> Result> { + unimplemented!("gossip_options") + } + + fn inspect(&self, _account_id: u64, _self_guid: u64, _target_guid: u64) -> Result<()> { + unimplemented!("inspect") + } + + fn gossip_select( + &self, + _account_id: u64, + _self_guid: u64, + _npc_guid: u64, + _option_id: u32, + _option_row_id: u32, + ) -> Result<()> { + unimplemented!("gossip_select") + } + } + }; +} +pub(crate) use npc_store_refusing_by; diff --git a/gateway/src/world/test_support/mod.rs b/gateway/src/world/test_support/mod.rs new file mode 100644 index 000000000..9deb32354 --- /dev/null +++ b/gateway/src/world/test_support/mod.rs @@ -0,0 +1,27 @@ +//! Shared test support for World Session tests: [`WorldFake`], the multi-shard Fake every Store +//! family is implemented on, and its shard topology. + +mod entry; +mod family; +mod topology; +mod world_fake; + +pub(crate) use self::entry::*; +pub(crate) use self::family::*; +pub(crate) use self::topology::*; +pub(crate) use self::world_fake::*; + +use super::handlers::{ + resolve_online_character, AuctionActionStore, AuctionInteraction, CastStore, + ChannelActionStore, ChannelOutcome, ChannelRequest, ChannelRoster, ChatActionStore, + ChatOutcome, DuelActionStore, GuildActionStore, ItemActionStore, LootWindowRequestStatus, + LootWindowStore, MeetingStoneActionStore, MeetingStoneOutcome, MeleeActionStore, + MemberPresence, MemberSnapshot, MemberStatsStore, QuestActionStore, RealmChatRequest, + ResolvedTarget, SpeakerFacts, TaxiActionStore, VendorActionStore, WeatherStore, WhisperRequest, + WhisperTargetFacts, +}; +use super::party::PartyOutcome; +use super::*; +use lyracore_shared::group::{GroupKind, GroupRefusal, RaidSlot}; +use lyracore_shared::loot::LootRefusal; +use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; diff --git a/gateway/src/world/test_support/topology.rs b/gateway/src/world/test_support/topology.rs new file mode 100644 index 000000000..aff66070d --- /dev/null +++ b/gateway/src/world/test_support/topology.rs @@ -0,0 +1,182 @@ +use super::*; + +#[derive(Default)] +pub(crate) struct TopologyState { + /// Multi-shard routing: the database this handle stands for. `""` (derive-Default) is the + /// single-shard world every other test runs in, where nothing routes. + pub(crate) shard: String, + /// The handle `home_shard()` hands back — the character's home shard. `None` (default) = "you + /// are already on the right shard", i.e. the single-entry shard map / pre-sharding behavior. + pub(crate) home: Option>, + /// Home-shard reassignment: when set, every `home_shard()` resolution AFTER the first + /// answers THIS shard instead of `home` — the mock's stand-in for a routing change landing + /// between two logins (a shard-map edit, or the realm-core index re-homing a character). `None` + /// (default): every resolution answers `home`, byte-identical to before this field existed. + pub(crate) home_after_flip: Option>, + /// The one location this handle routes elsewhere, and where to: `(map_id, instance_id, shard)`. + /// `None` (derive-Default) = "this handle serves every location", the single-database answer + /// `shard_for_location` gives. Keyed by location on purpose — a caller that asks about the + /// wrong place gets `None` and the crossing silently becomes a no-op, which is what the tests + /// must be able to tell apart from a crossing that ran. + pub(crate) location_shard: Option<(u32, u64, std::sync::Arc)>, + /// How many times `home_shard()` has been asked — drives `home_after_flip`, and is itself the + /// assertion that routing is resolved ONCE PER WORLD ENTRY and never mid-session. SHARED + /// between a store and the handles it routes to (like `calls`), so a re-resolution asked of the + /// *pinned* handle — which is what a mid-session re-route would actually look like, since + /// `route_home` asks whichever handle the session currently holds — is counted too. + pub(crate) home_shard_calls: std::sync::Arc, + /// SHARED between a store and its `home` handle: `(shard, call)` for every instrumented + /// player-scoped call, in order. The routing test asserts nothing lands on the wrong database. + pub(crate) calls: std::sync::Arc>>, + /// The handle `realm_store()` hands back — the database that owns party + /// membership realm-wide. `None` (derive-Default) is the SINGLE-DATABASE gateway, which is what + /// every other test in this file is, and it is what routes every party op back onto the + /// player-facing reducers below. + pub(crate) realm: Option>, + /// The connected WORLD shards `world_stores()` fans the roster mirror out to (and the + /// cross-shard name/presence lookups walk). Empty = single database. Behind a `Mutex` only so + /// the topology can be wired up AFTER every handle exists — production reads the shared + /// `ShardSet`, which has the same shape and the same "includes this handle" membership. + pub(crate) peers: std::sync::Mutex>>, + /// When set, the configured World Shard set is incomplete or unhealthy. + pub(crate) world_shard_set_error: Option, + /// When set, deleted Character cleanup cannot reach Realm-core. + pub(crate) party_cleanup_realm_error: Option, + pub(crate) party_command_realm_error: Option, + pub(crate) transfer_realm_error: Option, + /// When set, `settle_home_shard` fails with this message (a transfer that could not be + /// driven — an unreachable destination shard, a refused import). + pub(crate) settle_error: Option, + /// How many `settle_home_shard` calls SUCCEED before `settle_error` starts firing. 0 + /// (derive-Default) = the very first one fails, i.e. the login-time failure the transfer + /// test drives. + /// 1 = the login routes fine and the WORLD-PORT's settle is the one that cannot be driven — + /// the case that hung a real client on its loading screen forever. + pub(crate) settle_ok_calls: usize, + /// How many times `settle_home_shard` has been asked (drives `settle_ok_calls`). + pub(crate) settle_calls: std::sync::atomic::AtomicUsize, + /// Accounts `bind_shard_session` was called for, per shard. + pub(crate) bound_sessions: std::sync::Mutex>, + /// Whether this character still had an addressable viewer at each transfer-resolution call. + pub(crate) viewer_present_at_settle: std::sync::Mutex>, +} + +impl ShardRoutingStore for WorldFake { + fn shard_name(&self) -> &str { + &self.topology.shard + } + + // The Fake enforces the Module's escrow guards so transfer ordering affects outcomes. + + fn settle_home_shard( + &self, + character_guid: u64, + ) -> Result>> { + if let Some(view) = &self.session.relay_view { + self.topology.viewer_present_at_settle.lock().unwrap().push( + view.viewer_of_owner(crate::stdb::world_view::OwnerGuid(character_guid)) + .is_some(), + ); + } + if let Some(e) = &self.topology.settle_error { + let nth = self + .topology + .settle_calls + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if nth >= self.topology.settle_ok_calls { + return Err(anyhow!("{e}")); + } + } + Ok(self.home_shard(character_guid)) + } + + fn bind_shard_session(&self, account_id: u64, _session_key: &[u8; 40]) -> Result<()> { + self.rec("bind_shard_session"); + self.topology + .bound_sessions + .lock() + .unwrap() + .push(account_id); + Ok(()) + } + + fn shard_for_location( + &self, + map_id: u32, + instance_id: u64, + ) -> Option> { + let (m, i, shard) = self.topology.location_shard.as_ref()?; + (*m == map_id && *i == instance_id).then(|| shard.clone() as std::sync::Arc) + } + + fn realm_store(&self) -> Option> { + self.topology + .realm + .clone() + .map(|r| r as std::sync::Arc) + } + + fn party_cleanup_realm(&self) -> Result>> { + if let Some(error) = &self.topology.party_cleanup_realm_error { + return Err(anyhow!(error.clone())); + } + Ok(self.realm_store()) + } + + fn party_command_realm(&self) -> Result>> { + if let Some(error) = &self.topology.party_command_realm_error { + return Err(anyhow!(error.clone())); + } + Ok(self.realm_store()) + } + + fn transfer_realm(&self) -> Result>> { + if let Some(error) = &self.topology.transfer_realm_error { + return Err(anyhow!(error.clone())); + } + Ok(self.realm_store()) + } + + fn world_stores(&self) -> Vec> { + self.topology + .peers + .lock() + .unwrap() + .iter() + .map(|p| p.clone() as std::sync::Arc) + .collect() + } + + fn party_command_worlds(&self) -> Result>> { + if let Some(error) = &self.topology.world_shard_set_error { + return Err(anyhow!(error.clone())); + } + Ok(self.world_stores()) + } +} + +impl WorldFake { + /// Record one player-scoped call against THIS handle's shard. + pub(crate) fn rec(&self, what: &str) { + self.topology + .calls + .lock() + .unwrap() + .push((self.topology.shard.clone(), what.to_string())); + } + + pub(crate) fn home_shard( + &self, + _character_guid: u64, + ) -> Option> { + let nth = self + .topology + .home_shard_calls + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let resolved = match (&self.topology.home_after_flip, nth) { + (Some(flipped), n) if n >= 1 => Some(flipped.clone()), + _ => self.topology.home.clone(), + }; + resolved.map(|h| h as std::sync::Arc) + } +} diff --git a/gateway/src/world/test_support/world_fake/auction.rs b/gateway/src/world/test_support/world_fake/auction.rs new file mode 100644 index 000000000..7f10736f5 --- /dev/null +++ b/gateway/src/world/test_support/world_fake/auction.rs @@ -0,0 +1,56 @@ +use super::super::*; + +#[derive(Default)] +pub(crate) struct AuctionState { + /// The auctioneer's house and faction verdict returned to the focused auction seam. + pub(crate) auction_interaction: Option, +} + +impl AuctionActionStore for WorldFake { + fn auction_interaction( + &self, + _player_guid: u64, + _auctioneer_guid: u64, + ) -> Result> { + self.rec("auction_interaction"); + Ok(self.auction.auction_interaction) + } + + fn create_auction( + &self, + _request: crate::world::handlers::CreateAuctionRequest, + ) -> Result { + Ok(crate::world::handlers::CreateAuctionOutcome::Database) + } + + fn place_bid( + &self, + _request: crate::world::handlers::PlaceBidRequest, + ) -> Result { + Ok(crate::world::handlers::PlaceBidOutcome::Database) + } + + fn cancel_auction( + &self, + _request: crate::world::handlers::CancelAuctionRequest, + ) -> Result { + Ok(crate::world::handlers::CancelAuctionOutcome::Stale) + } + + fn resume_auction_holds(&self, _actor_guid: u64) -> Result<()> { + Ok(()) + } + + fn auction_query( + &self, + _player_guid: u64, + _house_id: u32, + _query: crate::world::handlers::AuctionQuery, + ) -> Result { + Ok(crate::world::handlers::AuctionPage { + rows: Vec::new(), + total: 0, + now_micros: 0, + }) + } +} diff --git a/gateway/src/world/test_support/world_fake/bank.rs b/gateway/src/world/test_support/world_fake/bank.rs new file mode 100644 index 000000000..a184df7d8 --- /dev/null +++ b/gateway/src/world/test_support/world_fake/bank.rs @@ -0,0 +1,11 @@ +use super::super::*; + +impl BankStore for WorldFake { + fn auto_bank_item(&self, _account_id: u64, _self_guid: u64, _slot: u8) -> Result<()> { + Ok(()) + } + + fn buy_bank_slot(&self, _account_id: u64, _self_guid: u64, _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 new file mode 100644 index 000000000..c534505ab --- /dev/null +++ b/gateway/src/world/test_support/world_fake/cast.rs @@ -0,0 +1,117 @@ +use super::super::*; + +#[derive(Default)] +pub(crate) struct CastState { + /// Imported item templates the query path resolves by entry. Keeping this keyed fixture here + /// makes socket tests exercise the same durable read shape as the Coordinator cache. + pub(crate) item_templates: Vec, + /// The caller's owned items, read by world entry. + pub(crate) player_items_fixture: Vec, +} + +/// 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<()> { + Ok(()) + } + + fn cancel_aura(&self, _account_id: u64, _self_guid: u64, _spell_id: u32) -> Result<()> { + Ok(()) + } + fn cancel_cast(&self, _account_id: u64, _self_guid: u64) -> Result<()> { + Ok(()) + } + fn cast_spell( + &self, + _account_id: u64, + _self_guid: u64, + _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<()> { + Ok(()) + } + fn spell_is_ranged_auto_repeat(&self, _spell_id: u32) -> bool { + false + } + fn spell_cast_time(&self, _spell_id: u32) -> Option { + None + } + fn spell_queues_next_swing(&self, _spell_id: u32) -> bool { + false + } + fn spell_is_ground_area(&self, _spell_id: u32) -> bool { + false + } + fn enchant_route(&self, _spell_id: u32) -> Option { + None + } + fn spell_is_fishing(&self, _spell_id: u32) -> bool { + false + } + fn spell_is_open_lock(&self, _spell_id: u32) -> bool { + false + } + fn cast_spell_at( + &self, + _account_id: u64, + _self_guid: u64, + _spell_id: u32, + _target_guid: u64, + _x: f32, + _y: f32, + _z: f32, + ) -> Result<()> { + Ok(()) + } + fn item_slot_by_guid(&self, _account_id: u64, _item_guid: u64) -> Option { + None + } + fn disenchant_item(&self, _account_id: u64, _self_guid: u64, _slot: u8) -> Result<()> { + Ok(()) + } + fn enchant_item_on_slot( + &self, + _account_id: u64, + _self_guid: u64, + _slot: u8, + _enchant_id: u32, + ) -> Result<()> { + Ok(()) + } + fn fish(&self, _account_id: u64, _self_guid: u64) -> Result<()> { + Ok(()) + } + fn pick_lock(&self, _account_id: u64, _self_guid: u64, _go_guid: u64) -> Result<()> { + Ok(()) + } + + // Shared with the character, vendor and query paths, so these two keep real fixtures. + + fn player_items(&self, _owner_guid: u64) -> Result> { + Ok(self.cast.player_items_fixture.clone()) + } + fn item_template(&self, entry: u32) -> Result> { + Ok(self + .cast + .item_templates + .iter() + .find(|template| template.entry == entry) + .cloned()) + } +} diff --git a/gateway/src/world/test_support/world_fake/channel.rs b/gateway/src/world/test_support/world_fake/channel.rs new file mode 100644 index 000000000..75b0a6b9f --- /dev/null +++ b/gateway/src/world/test_support/world_fake/channel.rs @@ -0,0 +1,24 @@ +use super::super::*; + +impl ChannelActionStore for WorldFake { + fn channel_op( + &self, + _actor_guid: u64, + _op: u8, + _request: ChannelRequest, + ) -> Result { + Ok(ChannelOutcome::Done) + } + + fn channel_roster(&self, _team: u32, _channel_name: &str) -> Result> { + Ok(None) + } + + 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) + } +} diff --git a/gateway/src/world/test_support/world_fake/character.rs b/gateway/src/world/test_support/world_fake/character.rs new file mode 100644 index 000000000..b315cabe6 --- /dev/null +++ b/gateway/src/world/test_support/world_fake/character.rs @@ -0,0 +1,157 @@ +use super::super::*; + +#[derive(Default)] +pub(crate) struct CharacterState { + /// When set, `character_by_guid` cannot answer from this Shard. + pub(crate) character_read_error: Option, + /// Recorded `delete_character` calls: (account_id, character_guid). + pub(crate) deleted: std::sync::Mutex>, + /// When set, `delete_character` returns this outcome instead of `Success`. + pub(crate) delete_outcome: Option, + pub(crate) created_characters: std::sync::Mutex>, + /// The Nth guid `create_character` assigns, offset well above every hand-seeded fixture + /// guid in this file (the highest is 100, in the transfer tests) so it can never collide. + pub(crate) next_created_guid: std::sync::atomic::AtomicU64, + /// Reputation standings `player_reputations` returns — `(reputation_index, standing)` pairs folded + /// into the login SMSG_INITIALIZE_FACTIONS (restoring persisted standings instead of the + /// all-neutral stub). + pub(crate) reputations: Vec<(i32, i32, bool)>, + /// Imported action-bar rows `player_actions` returns — `(button, action, action_type)` triples. + /// Empty by default (the pre-import fallback path). + pub(crate) player_actions: Vec<(u8, u32, u8)>, + /// The character's spellbook, as `player_learned_spells` reports it. Empty by default; a + /// proficiency test seeds the passive the trainer buy is meant to have granted. + pub(crate) learned_spells: Vec, + /// How many times a caller asked for the two-snapshot durable absence check. + pub(crate) durable_absence_checks: std::sync::atomic::AtomicUsize, +} + +impl CharacterStore for WorldFake { + fn characters(&self, _account_id: u64) -> Result> { + self.rec("characters"); + let mut out = self.characters.clone(); + out.extend( + self.character + .created_characters + .lock() + .unwrap() + .iter() + .cloned(), + ); + Ok(out) + } + + fn create_character( + &self, + _account_id: u64, + name: &str, + race: u8, + class: u8, + _gender: u8, + _appearance: codec::Appearance, + ) -> Result { + if self.characters.iter().any(|c| c.name == name) + || self + .character + .created_characters + .lock() + .unwrap() + .iter() + .any(|c| c.name == name) + { + return Ok(codec::CharCreateOutcome::NameInUse); + } + let guid = 500 + + self + .character + .next_created_guid + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + self.character + .created_characters + .lock() + .unwrap() + .push(codec::CharacterView { + guid, + name: name.to_string(), + race, + class, + level: 1, + ..Default::default() + }); + Ok(codec::CharCreateOutcome::Success) + } + + fn delete_character( + &self, + account_id: u64, + character_guid: u64, + ) -> Result { + self.character + .deleted + .lock() + .unwrap() + .push((account_id, character_guid)); + Ok(self + .character + .delete_outcome + .unwrap_or(codec::CharDeleteOutcome::Success)) + } + + fn character_by_guid(&self, guid: u64) -> Result> { + if let Some(error) = &self.character.character_read_error { + return Err(anyhow!(error.clone())); + } + Ok(self.characters.iter().find(|c| c.guid == guid).cloned()) + } + + fn character_exists_on_any_world_shard(&self, guid: u64) -> Result { + self.character + .durable_absence_checks + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if let Some(error) = &self.topology.world_shard_set_error { + return Err(anyhow!(error.clone())); + } + if self.character_by_guid(guid)?.is_some() { + return Ok(true); + } + for shard in self.topology.peers.lock().unwrap().iter() { + if shard.character_by_guid(guid)?.is_some() { + return Ok(true); + } + } + Ok(false) + } + + fn player_skills(&self, _character_guid: u64) -> Result> { + Ok(Vec::new()) + } + + fn effective_armor(&self, _guid: u64) -> u32 { + // No gear/auras in the test store → effective == the login entity's armor. + self.session + .login_entity + .as_ref() + .map(|e| e.effective_armor) + .unwrap_or(0) + } + + fn effective_magic_resistances(&self, _guid: u64) -> [u32; 6] { + [0; 6] + } + + fn spell_modifiers(&self, _character_guid: u64) -> Vec<(u32, u8, i32, bool)> { + Vec::new() // no modifier packets in the harness (login stays byte-identical) + } + + fn player_learned_spells(&self, _player_guid: u64) -> Result> { + Ok(self.character.learned_spells.clone()) + } + + fn player_reputations(&self, _player_guid: u64) -> Result> { + Ok(self.character.reputations.clone()) + } + + fn player_actions(&self, _player_guid: u64) -> Result> { + Ok(self.character.player_actions.clone()) + } +} diff --git a/gateway/src/world/test_support/world_fake/chat.rs b/gateway/src/world/test_support/world_fake/chat.rs new file mode 100644 index 000000000..c606cc30d --- /dev/null +++ b/gateway/src/world/test_support/world_fake/chat.rs @@ -0,0 +1,56 @@ +use super::super::*; + +#[derive(Default)] +pub(crate) struct ChatState { + /// What `speaker_facts` answers for every speaker. `None` models a speaker with no live entity. + pub(crate) speaker_facts: Option, + /// Every Character's GM level, as `speaker_gm_level` reads it. + pub(crate) gm_level: u8, + /// Recorded `realm_chat` requests, with the speaker guid the session authenticated. + pub(crate) realm_chats: std::sync::Mutex>, + /// Recorded `realm_whisper` requests, with the speaker guid the session authenticated. + pub(crate) realm_whispers: std::sync::Mutex>, +} + +impl ChatActionStore for WorldFake { + fn speaker_facts(&self, _speaker_guid: u64) -> Result> { + Ok(self.chat.speaker_facts.clone()) + } + + fn realm_chat(&self, speaker_guid: u64, request: RealmChatRequest) -> Result { + self.chat + .realm_chats + .lock() + .unwrap() + .push((speaker_guid, request)); + Ok(ChatOutcome::Delivered) + } + + fn set_away(&self, _speaker_guid: u64, _kind: u8, _message: String) -> Result<()> { + Ok(()) + } + + fn whisper_target( + &self, + speaker_guid: u64, + typed_name: &str, + ) -> Result> { + whisper::target_facts(self, speaker_guid, typed_name) + } + + /// 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 { + self.rec("realm_whisper"); + self.chat + .realm_whispers + .lock() + .unwrap() + .push((speaker_guid, request)); + Ok(ChatOutcome::Delivered) + } + + fn speaker_gm_level(&self, _speaker_guid: u64) -> Result { + Ok(self.chat.gm_level) + } +} diff --git a/gateway/src/world/test_support/world_fake/combat.rs b/gateway/src/world/test_support/world_fake/combat.rs new file mode 100644 index 000000000..fd7798758 --- /dev/null +++ b/gateway/src/world/test_support/world_fake/combat.rs @@ -0,0 +1,31 @@ +use super::super::*; + +#[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, +} + +impl CombatStore for WorldFake { + fn set_target(&self, _account_id: u64, _self_guid: u64, _target_guid: u64) -> Result<()> { + match &self.combat.set_target_error { + Some(e) => Err(anyhow!("{e}")), + None => Ok(()), + } + } + + fn pet_command( + &self, + _account_id: u64, + _self_guid: u64, + _data: u32, + _target_guid: u64, + ) -> Result<()> { + Ok(()) + } + + fn set_sheathed(&self, _account_id: u64, _self_guid: u64, _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 new file mode 100644 index 000000000..8b1561cb8 --- /dev/null +++ b/gateway/src/world/test_support/world_fake/death.rs @@ -0,0 +1,34 @@ +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<()> { + Ok(()) + } + + fn reclaim_corpse(&self, _account_id: u64, _self_guid: u64, _corpse_guid: u64) -> Result<()> { + Ok(()) + } + + fn resurrect_response(&self, _account_id: u64, _self_guid: u64, _accept: bool) -> Result<()> { + Ok(()) + } + + fn self_resurrect(&self, _account_id: u64, _self_guid: u64) -> Result<()> { + Ok(()) + } + + fn spirit_healer_res( + &self, + _account_id: u64, + _self_guid: u64, + _healer_guid: u64, + ) -> Result<()> { + Ok(()) + } + + fn corpse_location(&self, _owner_guid: u64) -> Result> { + Ok(None) + } +} diff --git a/gateway/src/world/test_support/world_fake/duel.rs b/gateway/src/world/test_support/world_fake/duel.rs new file mode 100644 index 000000000..563d032ac --- /dev/null +++ b/gateway/src/world/test_support/world_fake/duel.rs @@ -0,0 +1,11 @@ +use super::super::*; + +impl DuelActionStore for WorldFake { + fn duel_accept(&self, _account_id: u64, _actor_guid: u64, _flag_guid: u64) -> Result<()> { + Ok(()) + } + + fn duel_cancel(&self, _account_id: u64, _actor_guid: u64, _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 new file mode 100644 index 000000000..fa8bdefb3 --- /dev/null +++ b/gateway/src/world/test_support/world_fake/guild.rs @@ -0,0 +1,223 @@ +use super::super::*; + +#[derive(Default)] +pub(crate) struct GuildState { + /// Realm-core Guilds the guild query answers from. + pub(crate) guilds: Vec, + /// Realm-core member rows. Guild ops are recorded in `calls` as `guild_op:`. + pub(crate) guild_memberships: Vec, + /// A Fee Hold left on this Home Shard. Fee steps are recorded in `calls` as `guild_fee_`. + pub(crate) guild_fee_hold: std::sync::Mutex>, + /// Realm-core Petitions. + pub(crate) guild_petitions: Vec, + /// A Character whose guild lookup fails, as when one Realm-core read errors. + pub(crate) guild_lookup_error_for: Option, +} + +impl GuildActionStore for WorldFake { + fn guild_member(&self, character_guid: u64) -> Result> { + Ok(self + .guild + .guild_memberships + .iter() + .find(|member| member.character_guid == character_guid) + .cloned()) + } + + fn guild(&self, guild_id: u32) -> Result> { + Ok(self + .guild + .guilds + .iter() + .find(|guild| guild.guild_id == guild_id) + .cloned()) + } + + fn guild_members(&self, _guild_id: u32) -> Result> { + Ok(Vec::new()) + } + + fn guild_character_facts(&self, character_guid: u64) -> Result> { + Ok(self + .characters + .iter() + .find(|c| c.guid == character_guid) + .map(|c| CharacterFacts { + guid: c.guid, + name: c.name.clone(), + race: c.race, + class: c.class, + level: c.level, + zone_id: c.zone_id, + last_logout_micros: 0, + online: !self.social.offline_guids.contains(&c.guid), + in_transit: false, + realm_account_id: 0, + })) + } + + fn guild_characters_named(&self, name: &str) -> Result> { + Ok(self + .characters + .iter() + .filter(|c| c.name.eq_ignore_ascii_case(name)) + .map(|c| c.guid) + .collect()) + } + + fn guild_gm_level(&self, _actor_guid: u64) -> Result { + Ok(0) + } + + fn guild_selected_target(&self, _actor_guid: u64) -> u64 { + 0 + } + + fn guild_ignored_by(&self, _owner_guid: u64, _other_guid: u64) -> Result { + Ok(false) + } + + fn guild_op(&self, actor_guid: u64, request: GuildRequest) -> Result { + if request == GuildRequest::ForgetDeletedCharacter { + self.rec(&format!("guild_op:ForgetDeletedCharacter:{actor_guid}")); + return Ok(GuildOutcome::Ran); + } + self.rec(match request { + GuildRequest::GmCreate { .. } => "guild_op:GmCreate", + GuildRequest::SignOn { .. } => "guild_op:SignOn", + GuildRequest::SignOff => "guild_op:SignOff", + GuildRequest::Invite { .. } => "guild_op:Invite", + GuildRequest::Accept { .. } => "guild_op:Accept", + GuildRequest::Decline { .. } => "guild_op:Decline", + GuildRequest::Leave => "guild_op:Leave", + GuildRequest::Remove { .. } => "guild_op:Remove", + GuildRequest::Promote { .. } => "guild_op:Promote", + GuildRequest::Demote { .. } => "guild_op:Demote", + GuildRequest::SetLeader { .. } => "guild_op:SetLeader", + GuildRequest::Disband => "guild_op:Disband", + GuildRequest::SetMotd { .. } => "guild_op:SetMotd", + GuildRequest::SetInfo { .. } => "guild_op:SetInfo", + GuildRequest::SetPublicNote { .. } => "guild_op:SetPublicNote", + GuildRequest::SetOfficerNote { .. } => "guild_op:SetOfficerNote", + GuildRequest::EditRank { .. } => "guild_op:EditRank", + GuildRequest::AddRank { .. } => "guild_op:AddRank", + GuildRequest::DeleteRank => "guild_op:DeleteRank", + GuildRequest::SignPetition { .. } => "guild_op:SignPetition", + GuildRequest::OfferPetition { .. } => "guild_op:OfferPetition", + GuildRequest::DeclinePetition { .. } => "guild_op:DeclinePetition", + GuildRequest::RenamePetition { .. } => "guild_op:RenamePetition", + GuildRequest::TurnInPetition { .. } => "guild_op:TurnInPetition", + GuildRequest::ClosePetition { .. } => "guild_op:ClosePetition", + GuildRequest::ForgetDeletedCharacter => unreachable!("recorded with its actor above"), + }); + Ok(GuildOutcome::Ran) + } + + fn guild_npc_refuses(&self, _npc_guid: u64, _actor_guid: u64) -> Result { + Ok(false) + } + + fn guild_petition_of_charter( + &self, + charter_item_guid: u64, + ) -> Result> { + Ok(self + .guild + .guild_petitions + .iter() + .find(|petition| petition.charter_item_guid == charter_item_guid) + .cloned()) + } + + fn guild_petition_of_owner(&self, owner_guid: u64) -> Result> { + Ok(self + .guild + .guild_petitions + .iter() + .find(|petition| petition.owner_guid == owner_guid) + .cloned()) + } + + fn guild_name_taken(&self, name: &str) -> Result { + Ok(self + .guild + .guilds + .iter() + .any(|guild| guild.name.eq_ignore_ascii_case(name))) + } + + fn guild_holds_charter(&self, _actor_guid: u64, _charter_item_guid: u64) -> Result { + Ok(false) + } + + fn guild_destroy_charter(&self, _actor_guid: u64, _charter_item_guid: u64) -> Result<()> { + Ok(()) + } + + fn guild_character_guids(&self) -> Result> { + let mut guids: Vec = self + .guild + .guild_memberships + .iter() + .map(|member| member.character_guid) + .chain(self.guild.guild_petitions.iter().flat_map(|petition| { + std::iter::once(petition.owner_guid).chain(petition.signers.iter().copied()) + })) + .collect(); + guids.sort_unstable(); + guids.dedup(); + Ok(guids) + } + + 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")); + } + Ok(self.guild_character_guids()?.contains(&character_guid)) + } + + fn guild_name_of_member(&self, character_guid: u64) -> Result> { + let Some(member) = self.guild_member(character_guid)? else { + return Ok(None); + }; + Ok(self.guild(member.guild_id)?.map(|guild| guild.name)) + } +} + +/// 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> { + Ok(self.guild.guild_fee_hold.lock().unwrap().clone()) + } + + fn guild_fee_hold( + &self, + _actor_guid: u64, + _request: crate::world::guild_fee::FeeRequest, + ) -> Result> + { + Ok(Err(lyracore_shared::guild::GuildRefusal::NotEnoughMoney)) + } + + fn guild_fee_decide( + &self, + _actor_guid: u64, + _hold: crate::world::guild_fee::FeeHold, + ) -> Result { + self.rec("guild_fee_decide"); + Ok(crate::world::guild_fee::FeeOutcome::Refused( + lyracore_shared::guild::GuildRefusal::NotLeader, + )) + } + + fn guild_fee_finish( + &self, + _actor_guid: u64, + _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 new file mode 100644 index 000000000..d66290ab4 --- /dev/null +++ b/gateway/src/world/test_support/world_fake/item.rs @@ -0,0 +1,36 @@ +use super::super::*; + +/// Item behaviour is tested through `InMemoryItemActions`; every verb here succeeds. +impl ItemActionStore for WorldFake { + fn equip_item( + &self, + _account_id: u64, + _self_guid: u64, + _from_slot: u8, + ) -> Result { + Ok(ItemActionResult::Done) + } + + fn unequip_item( + &self, + _account_id: u64, + _self_guid: u64, + _from_slot: u8, + ) -> Result { + Ok(ItemActionResult::Done) + } + + fn move_item( + &self, + _account_id: u64, + _self_guid: u64, + _from_slot: u8, + _to_slot: u8, + ) -> Result { + Ok(ItemActionResult::Done) + } + + fn use_item(&self, _account_id: u64, _self_guid: u64, _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 new file mode 100644 index 000000000..bd22c508c --- /dev/null +++ b/gateway/src/world/test_support/world_fake/loot_roll.rs @@ -0,0 +1,201 @@ +use super::super::*; + +#[derive(Default)] +pub(crate) struct LootRollState { + /// Recorded `loot_roll` calls: (corpse_guid, loot_slot, vote). + pub(crate) loot_rolls: std::sync::Mutex>, + /// Recorded `loot_master_give` calls: (corpse_guid, loot_slot, target_guid). + 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 + /// 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< + Vec<( + u8, + u64, + u8, + u32, + u64, + u8, + i64, + Vec, + u32, + spacetimedb_sdk::Identity, + u64, + )>, + >, + /// When set, `realm_loot_op` fails with this message. + pub(crate) realm_loot_op_error: 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. + /// Empty (derive-Default) = nothing to promote, byte-identical to before this field existed. + 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, + /// 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>, + /// This REALM-CORE handle's fixture `ROLL_WON` queue — `(corpse_guid, slot, winner_guid)` + /// triples, in the order they "arrived". `loot_won_since(after_id)` answers every entry whose + /// 1-based INDEX exceeds `after_id`, and the new watermark is the queue's length — the same + /// shape the real `game_group_event.id` high-water mark has, without needing a fake event table. + /// `Mutex`-wrapped for the same after-`Arc`-construction reason as `pending_rolls`. + pub(crate) won_events: std::sync::Mutex>, +} + +impl LootRollStore for WorldFake { + fn loot_roll( + &self, + _account_id: u64, + _self_guid: u64, + 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 let Some(refusal) = self.loot_roll.loot_action_refusal { + return Ok(LootActionStatus::Refused(refusal)); + } + self.loot_roll + .loot_rolls + .lock() + .unwrap() + .push((corpse_guid, loot_slot, vote)); + Ok(LootActionStatus::Applied) + } + + fn loot_master_give( + &self, + _account_id: u64, + _self_guid: u64, + 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 let Some(refusal) = self.loot_roll.loot_action_refusal { + return Ok(LootActionStatus::Refused(refusal)); + } + self.loot_roll.loot_master_gives.lock().unwrap().push(( + corpse_guid, + loot_slot, + target_guid, + )); + Ok(LootActionStatus::Applied) + } + + #[allow(clippy::too_many_arguments)] + 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<()> { + self.rec("realm_loot_op"); + self.loot_roll.realm_loot_ops.lock().unwrap().push(( + op, + corpse_guid, + slot, + item_entry, + actor_guid, + vote, + 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}")); + } + Ok(()) + } + + fn realm_loot_vote( + &self, + corpse_guid: u64, + slot: u8, + actor_guid: u64, + vote: u8, + ) -> Result { + self.realm_loot_op( + lyracore_shared::loot_roll::loot_op::VOTE, + corpse_guid, + slot, + 0, + 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())); + } + Ok(self + .loot_roll + .loot_action_refusal + .map_or(LootActionStatus::Applied, LootActionStatus::Refused)) + } + + fn pending_local_rolls(&self) -> Result> { + Ok(self.loot_roll.pending_rolls.lock().unwrap().clone()) + } + + 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}")); + } + self.loot_roll + .settled_rolls + .lock() + .unwrap() + .push((corpse_guid, slot, winner_guid)); + Ok(()) + } + + fn clear_promoted_loot_roll(&self, roll_id: u64) -> Result<()> { + self.rec("clear_promoted_loot_roll"); + self.loot_roll.cleared_rolls.lock().unwrap().push(roll_id); + self.loot_roll + .pending_rolls + .lock() + .unwrap() + .retain(|r| r.roll_id != roll_id); + Ok(()) + } + + fn loot_won_since(&self, after_id: u64) -> Result<(u64, Vec<(u64, u8, u64)>)> { + let events = self.loot_roll.won_events.lock().unwrap(); + let watermark = events.len() as u64; + let wins = events + .iter() + .enumerate() + .filter(|(i, _)| (*i as u64 + 1) > after_id) + .map(|(_, w)| *w) + .collect(); + Ok((watermark, wins)) + } +} diff --git a/gateway/src/world/test_support/world_fake/loot_window.rs b/gateway/src/world/test_support/world_fake/loot_window.rs new file mode 100644 index 000000000..0d307c69d --- /dev/null +++ b/gateway/src/world/test_support/world_fake/loot_window.rs @@ -0,0 +1,96 @@ +use super::super::*; + +#[derive(Default)] +pub(crate) struct LootWindowState { + /// The lootable copper `loot_target_money` reports for any target (default 0). + pub(crate) corpse_money: u32, + /// Recorded `loot_money` targets — CMSG_LOOT_MONEY must drive the TRACKED guid. + pub(crate) money_looted: std::sync::Mutex>, + /// Recorded `take_loot` calls as (target guid, slot): a take must act on the open target. + pub(crate) items_taken: std::sync::Mutex>, + /// Recorded `use_gameobject` target guids. + pub(crate) gameobjects_used: std::sync::Mutex>, + /// Per-VIEWER corpse loot fixture for `corpse_loot(corpse_guid, viewer_guid)`, keyed by viewer + /// guid. Empty by default, so a test that never sets it sees an empty window. + pub(crate) corpse_loot_by_viewer: std::collections::HashMap>, +} + +impl LootWindowStore for WorldFake { + fn loot_target_money(&self, _target_guid: u64) -> Result { + Ok(self.loot_window.corpse_money) + } + + fn loot_target_items( + &self, + _target_guid: u64, + viewer_guid: u64, + ) -> Result> { + Ok(self + .loot_window + .corpse_loot_by_viewer + .get(&viewer_guid) + .cloned() + .unwrap_or_default()) + } + + fn use_gameobject( + &self, + _account_id: u64, + _actor_guid: u64, + target_guid: u64, + ) -> Result { + self.loot_window + .gameobjects_used + .lock() + .unwrap() + .push(target_guid); + Ok(LootWindowRequestStatus::Applied) + } + + fn open_creature_loot( + &self, + _account_id: u64, + _actor_guid: u64, + _corpse_guid: u64, + ) -> Result { + Ok(LootWindowRequestStatus::Applied) + } + + fn skin_corpse( + &self, + _account_id: u64, + _actor_guid: u64, + _target_guid: u64, + ) -> Result { + Ok(LootWindowRequestStatus::Applied) + } + + fn loot_money( + &self, + _account_id: u64, + _actor_guid: u64, + target_guid: u64, + ) -> Result { + self.loot_window + .money_looted + .lock() + .unwrap() + .push(target_guid); + Ok(LootWindowRequestStatus::Applied) + } + + fn take_loot( + &self, + _account_id: u64, + _actor_guid: u64, + target_guid: u64, + loot_slot: u8, + ) -> Result { + self.loot_window + .items_taken + .lock() + .unwrap() + .push((target_guid, loot_slot)); + Ok(LootWindowRequestStatus::Applied) + } +} diff --git a/gateway/src/world/test_support/world_fake/mail.rs b/gateway/src/world/test_support/world_fake/mail.rs new file mode 100644 index 000000000..a11d75598 --- /dev/null +++ b/gateway/src/world/test_support/world_fake/mail.rs @@ -0,0 +1,1027 @@ +use super::super::*; + +/// 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 { + if has_item && !same_account { + 3_600 + } else { + 0 + } +} + +/// A mail's `deliver_secs` when it arrives `delay_secs` from now. 0 means from creation. +pub(crate) fn delivered_after(delay_secs: u32) -> i64 { + if delay_secs == 0 { + 0 + } else { + crate::world::mail::now_secs() + i64::from(delay_secs) + } +} + +#[derive(Default)] +pub(crate) struct MailState { + /// The mail rows on THIS database, as `(recipient_guid, row)`. The realm handle owns them on a + /// sharded gateway and a world shard's staying empty is how a test tells "the mailbox read went + /// to the authority" from "it quietly went back to being shard-local"; on a single-database + /// gateway the one handle owns them instead. Same fixture either way — that is the point. + pub(crate) mails: std::sync::Mutex>, + /// Gameobject guids that ARE a mailbox within reach on this shard. Empty (derive-Default) + /// refuses every mailbox, which is the wrong-map / out-of-range / not-a-mailbox arm. + pub(crate) mailboxes: Vec, + /// `game_world_entity.money` per guid, on THIS database — the purse the postage comes out of. + /// A guid with no row here cannot pay, which is also the module's answer for a character with no + /// live entity on the shard being asked. + pub(crate) purses: std::sync::Mutex>, + /// Letters written on THIS database: `(sender, recipient, subject, body, attached money)`. + /// Recorded separately from `mails` because WHICH call wrote the row is what tells the two + /// planes apart — `mail_send` on one database, `mail_commit` on the mail plane of a sharded one. + #[allow(clippy::type_complexity)] + pub(crate) sent_mail: std::sync::Mutex>, + /// The escrow ledger on THIS database, as `(the guid the fence is filed under, the row)`. A + /// letter's fence is filed under its SENDER and lives on their shard; a take's is filed under + /// the PAYEE and lives on the plane holding the mail row. + #[allow(clippy::type_complexity)] + pub(crate) mail_escrows: std::sync::Mutex>, + /// How many `mail_escrows_of` reads answer empty before a just-filed fence shows up. The same + /// cross-connection lag `escrow_reads_before_visible` dials in for `escrowed_transfer`, made + /// dialable for the mail escrow read `held_fence` polls. + pub(crate) mail_escrow_reads_before_visible: std::sync::atomic::AtomicUsize, + /// The Realm Account name THIS Shard holds per Character guid. A Character missing here is + /// one this Shard cannot name: absent, or on a shadow Account. + pub(crate) realm_accounts: std::sync::Mutex>, + /// The `same_account` each mail send, fence and return carried, in call order. + pub(crate) same_account_seen: std::sync::Mutex>, + /// `game_mail_escrow.delivered` per escrow id: the attestation that licenses the settle. Kept + /// beside the fence rather than in it so the fake cannot settle one it never attested. + pub(crate) attested: std::sync::Mutex>, + /// Delivery/payout receipts on THIS database, `(escrow_id, recipient or payee)` — the + /// idempotency key that makes a replayed commit or payout a no-op. + pub(crate) mail_receipts: std::sync::Mutex>, + /// `game_item_text` on THIS database: `(item_text_id, text)`. Filed by `mail_copy_text`, read + /// back by `item_text` — the mail plane's half of a Letter Copy. + pub(crate) item_texts: std::sync::Mutex>, + /// Plain Letters `mail_grant_letter` granted on THIS database: `(payee_guid, item_text_id)`. + /// Not `mail_items` — a Letter Copy mints a fresh item, it never moves one out of a mail row. + pub(crate) granted_letters: std::sync::Mutex>, + /// `game_mail.item_text_id` per mail id: the attached Plain Letter's text id. Kept beside + /// `mails` because `MailView` does not carry it. + pub(crate) mail_item_text_ids: std::sync::Mutex>, + /// The mail-escrow step to fail on THIS database — a gateway killed before that step's + /// transaction committed. `transfer`'s `kill_at` for the mail drive, and a `Mutex` because a + /// re-drive test has to bring the database back up before driving again. + pub(crate) mail_kill_at: std::sync::Mutex>, + /// `game_item_instance` on THIS database: `item_guid -> (owner_guid, snapshot)`. The mail + /// attachment path deletes from here at send and inserts at take, which is the whole "a fenced + /// item is in nobody's bags" property. + pub(crate) mail_items: std::sync::Mutex>, + /// This shard's bags have no room — the fixture behind the full-bag refusal on a take. + /// Atomic so a test can flip it AFTER the fixture is wrapped in an `Arc`, like `purses`. + pub(crate) bags_full: std::sync::atomic::AtomicBool, +} + +impl MailStore for WorldFake { + fn mail_list(&self, recipient_guid: u64) -> Result> { + self.rec("mail_list"); + Ok(self + .mail + .mails + .lock() + .unwrap() + .iter() + .filter(|(to, _)| *to == recipient_guid) + .map(|(_, m)| m.clone()) + .collect()) + } + + fn mail_by_id(&self, mail_id: u64) -> Result> { + Ok(self + .mail + .mails + .lock() + .unwrap() + .iter() + .find(|(_, m)| m.id == mail_id) + .map(|(_, m)| m.clone())) + } + + fn realm_account_name(&self, character_guid: u64) -> Result> { + Ok(self + .mail + .realm_accounts + .lock() + .unwrap() + .iter() + .find(|(guid, _)| *guid == character_guid) + .map(|(_, name)| name.clone())) + } + + fn mailbox_in_range(&self, mailbox_guid: u64, _player_guid: u64) -> Result { + self.rec("mailbox_in_range"); + Ok(self.mail.mailboxes.contains(&mailbox_guid)) + } + + /// 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<()> { + self.rec("mail_mark_read"); + let mut mails = self.mail.mails.lock().unwrap(); + let now = crate::world::mail::now_secs(); + match mails + .iter_mut() + .find(|(to, m)| *to == recipient_guid && m.id == mail_id && m.is_delivered(now)) + { + Some((_, m)) => { + m.was_read = true; + Ok(()) + } + None => Err(anyhow!(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<()> { + self.rec("mail_delete"); + let mut mails = self.mail.mails.lock().unwrap(); + let now = crate::world::mail::now_secs(); + let Some(at) = mails + .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)); + }; + if mails[at].1.cod > 0 { + return Err(anyhow!(lyracore_shared::mail::COD_MAIL_UNDELETABLE)); + } + mails.remove(at); + Ok(()) + } + + /// Models the module's `apply_return`: the SAME row, re-addressed to whoever sent it, with + /// whatever it still carries (or nothing) travelling unchanged — except the cash-on-delivery + /// 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<()> { + self.rec("mail_return"); + self.saw_same_account("mail_return", same_account); + let mut mails = self.mail.mails.lock().unwrap(); + let now = crate::world::mail::now_secs(); + let Some((to, m)) = mails + .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)); + }; + let lyracore_shared::mail::MailSender::Character(sender @ 1..) = m.sender() else { + return Err(anyhow!(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)); + } + m.sender_guid = recipient_guid; + m.was_read = false; + m.cod = 0; + m.check_flags = lyracore_shared::mail::CHECK_MASK_RETURNED; + m.deliver_secs = now + i64::from(delivery_delay_secs(m.item_entry != 0, same_account)); + *to = sender; + Ok(()) + } + + /// Models the module's `apply_send`: the postage plus the attached coin leave the purse and the + /// row is written, in ONE call — the single-database plane's one transaction. The id is + /// per-database, as the module's `auto_inc` is. + #[allow(clippy::too_many_arguments)] + fn mail_send( + &self, + sender_guid: u64, + recipient_guid: u64, + subject: String, + body: String, + money: u32, + cod: u32, + item_guid: u64, + same_account: bool, + ) -> Result<()> { + self.rec("mail_send"); + self.saw_same_account("mail_send", same_account); + let item = self.detach(sender_guid, item_guid)?; + self.debit(sender_guid, lyracore_shared::mail::total_cost(money))?; + self.mail.sent_mail.lock().unwrap().push(( + sender_guid, + recipient_guid, + subject.clone(), + body.clone(), + money, + )); + self.write_mail( + sender_guid, + recipient_guid, + subject, + body, + money, + cod, + &item, + false, + delivery_delay_secs(!item.is_empty(), same_account), + ); + Ok(()) + } + + /// 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<()> { + let (item, settlement) = { + let mails = self.mail.mails.lock().unwrap(); + let now = crate::world::mail::now_secs(); + let Some((_, m)) = mails + .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)); + }; + if m.item_entry == 0 { + return Err(anyhow!(lyracore_shared::mail::NOTHING_TO_TAKE)); + } + ( + crate::world::mail::AttachedItem { + entry: m.item_entry, + stack_count: m.item_stack_count, + durability: m.item_durability, + enchant_id: m.item_enchant_id, + soulbound: m.item_soulbound, + random_property_id: m.random_property_id, + item_text_id: self.attached_text_id(mail_id), + }, + lyracore_shared::mail::cod_settlement( + m.cod, + m.sender_guid, + &m.subject, + recipient_guid, + ), + ) + }; + 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))?; + } + 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 + // this from the transaction, and asserting on it is the point of the full-bag test. + if let Some(s) = &settlement { + self.credit(s.payer_guid, s.copper); + } + return Err(e); + } + let mut mails = self.mail.mails.lock().unwrap(); + if let Some((_, m)) = mails + .iter_mut() + .find(|(to, m)| *to == recipient_guid && m.id == mail_id) + { + m.item_entry = 0; + m.item_stack_count = 0; + m.item_durability = 0; + m.item_enchant_id = 0; + m.item_soulbound = false; + m.cod = 0; + } + drop(mails); + self.take_attached_text_id(mail_id); + if let Some(s) = settlement { + self.write_mail( + s.payer_guid, + s.payee_guid, + s.subject, + String::new(), + s.copper, + 0, + &crate::world::mail::AttachedItem::default(), + true, + 0, + ); + } + Ok(()) + } + + fn mail_item_room(&self, _payee_guid: u64) -> 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)); + } + Ok(()) + } + + /// Models `mail_text::apply_copy_text`: sets COPIED and files the body as item text, on the + /// database that owns the mail row. Refused for a mail that is not the caller's, is not + /// 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<()> { + self.rec("mail_copy_text"); + let mut mails = self.mail.mails.lock().unwrap(); + let now = crate::world::mail::now_secs(); + let Some((_, m)) = mails + .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)); + }; + if m.body.is_empty() { + return Err(anyhow!("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")); + } + m.check_flags |= lyracore_shared::mail::CHECK_MASK_COPIED; + let text_id = lyracore_shared::mail::item_text_id_for(m.id, &m.body); + let text = m.body.clone(); + drop(mails); + let mut texts = self.mail.item_texts.lock().unwrap(); + if !texts.iter().any(|(id, _)| *id == text_id) { + texts.push((text_id, text)); + } + Ok(()) + } + + /// Models `items::grant_letter_item`: one Plain Letter, refused by the same full-bag fixture + /// every other grant uses. A no-op when the payee already holds an item carrying `item_text_id` + /// — 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<()> { + self.rec("mail_grant_letter"); + if self + .mail + .granted_letters + .lock() + .unwrap() + .iter() + .any(|&(guid, id)| guid == payee_guid && id == item_text_id) + { + return Ok(()); + } + if self + .mail + .bags_full + .load(std::sync::atomic::Ordering::Relaxed) + { + return Err(anyhow!(lyracore_shared::mail::INVENTORY_FULL)); + } + self.mail + .granted_letters + .lock() + .unwrap() + .push((payee_guid, item_text_id)); + Ok(()) + } + + /// 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<()> { + 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)); + }; + m.check_flags |= lyracore_shared::mail::CHECK_FLAG_LETTER_GRANTED; + Ok(()) + } + + /// Models the Coordinator's `item_text`: a PK read of `game_item_text` on THIS database. + fn item_text(&self, item_text_id: u32) -> Result> { + self.rec("item_text"); + Ok(self + .mail + .item_texts + .lock() + .unwrap() + .iter() + .find(|(id, _)| *id == item_text_id) + .map(|(_, text)| text.clone())) + } + + /// Models the Coordinator's `owns_item_with_text`: does `owner_guid` hold a granted letter + /// carrying `item_text_id`? The fake has no item-guid fixture to answer `hint_item_guid`'s fast + /// path distinctly, so both routes resolve through the same `granted_letters` lookup. + fn owns_item_with_text( + &self, + owner_guid: u64, + item_text_id: u32, + _hint_item_guid: u64, + ) -> Result { + self.rec("owns_item_with_text"); + Ok(item_text_id != 0 + && self + .mail + .granted_letters + .lock() + .unwrap() + .iter() + .any(|&(guid, id)| guid == owner_guid && id == item_text_id)) + } + + /// 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<()> { + self.rec("mail_take_money"); + let money = { + let mut mails = self.mail.mails.lock().unwrap(); + let now = crate::world::mail::now_secs(); + let Some((_, m)) = mails + .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)); + }; + if m.money == 0 { + return Err(anyhow!(lyracore_shared::mail::NOTHING_TO_TAKE)); + } + std::mem::take(&mut m.money) + }; + self.credit(recipient_guid, money); + Ok(()) + } + + /// Models `mail_escrow::apply_fence`: the whole cost leaves the purse into a fence row here. + fn mail_fence( + &self, + escrow_id: u64, + sender_guid: u64, + recipient_guid: u64, + subject: String, + body: String, + money: u32, + postage: u32, + item_guid: u64, + cod: u32, + cod_source_mail_id: u64, + same_account: bool, + ) -> Result<()> { + self.rec("mail_fence"); + self.saw_same_account("mail_fence", same_account); + self.mail_kill("mail_fence")?; + let escrows = self.mail.mail_escrows.lock().unwrap(); + if escrows.iter().any(|(_, e)| e.escrow_id == escrow_id) { + return Ok(()); // replay — the purse must not be debited twice for one letter + } + drop(escrows); + // The attachment before the debit: it is the refusal that can still fire, and nothing has + // been written when it does. + let item = self.detach(sender_guid, item_guid)?; + self.debit(sender_guid, money.saturating_add(postage))?; + let delivery_delay_secs = delivery_delay_secs(!item.is_empty(), same_account); + self.mail.mail_escrows.lock().unwrap().push(( + sender_guid, + crate::world::mail::HeldEscrow { + escrow_id, + recipient_guid, + subject, + body, + money, + postage, + payout: false, + mail_id: cod_source_mail_id, + item, + cod, + delivery_delay_secs, + reward: None, + }, + )); + self.mail.attested.lock().unwrap().push((escrow_id, false)); + Ok(()) + } + + /// Models `mail_escrow::apply_commit`: the row plus a receipt, idempotent on the escrow id. + fn mail_commit( + &self, + escrow_id: u64, + sender_guid: u64, + recipient_guid: u64, + subject: String, + body: String, + money: u32, + item: crate::world::mail::AttachedItem, + cod: u32, + cod_source_mail_id: u64, + delivery_delay_secs: u32, + reward: Option, + ) -> Result<()> { + self.rec("mail_commit"); + self.mail_kill("mail_commit")?; + let mut receipts = self.mail.mail_receipts.lock().unwrap(); + if receipts.iter().any(|(id, _)| *id == escrow_id) { + return Ok(()); + } + // A COD payment pays only a price its payer still owes on a delivered mail. + if cod_source_mail_id != 0 { + let now = crate::world::mail::now_secs(); + let owed = self.mail.mails.lock().unwrap().iter().any(|(to, m)| { + m.id == cod_source_mail_id && *to == sender_guid && m.cod > 0 && m.is_delivered(now) + }); + if !owed { + return Err(anyhow!( + "mail {cod_source_mail_id} owes {sender_guid} no delivered price" + )); + } + } + receipts.push((escrow_id, recipient_guid)); + drop(receipts); + self.mail.sent_mail.lock().unwrap().push(( + sender_guid, + recipient_guid, + subject.clone(), + body.clone(), + money, + )); + self.write_mail( + sender_guid, + recipient_guid, + subject, + body, + money, + cod, + &item, + cod_source_mail_id != 0, + // A payment arrives at once whatever the commit carries. + if cod_source_mail_id != 0 { + 0 + } else { + delivery_delay_secs + }, + ); + if let Some(header) = reward { + // Models `crate::world::mail::Letter::reward`: from the quest giver, naming its Mail Template. + let (sender_kind, sender_guid, sender_entry) = header.giver.sender().columns(); + let mut mails = self.mail.mails.lock().unwrap(); + if let Some((_, m)) = mails.iter_mut().max_by_key(|(_, m)| m.id) { + m.sender_guid = sender_guid; + m.sender_kind = sender_kind; + m.sender_entry = sender_entry; + m.mail_template_id = header.mail_template_id; + m.check_flags = lyracore_shared::mail::CHECK_MASK_HAS_BODY; + } + } + // The price stops being owed in the SAME call that delivers the payment for it — the + // module clears it inside the commit's transaction, which is what makes a COD take charge + // once however the drive is interrupted. + if cod_source_mail_id != 0 { + if let Some((_, m)) = self + .mail + .mails + .lock() + .unwrap() + .iter_mut() + .find(|(_, m)| m.id == cod_source_mail_id) + { + m.cod = 0; + } + } + Ok(()) + } + + /// Models `mail_escrow::apply_take_fence`: the copper leaves the ROW into a fence here. + fn mail_take_money_fence( + &self, + escrow_id: u64, + payee_guid: u64, + mail_id: u64, + expect_money: u32, + ) -> Result<()> { + self.rec("mail_take_money_fence"); + self.mail_kill("mail_take_money_fence")?; + if self + .mail + .mail_escrows + .lock() + .unwrap() + .iter() + .any(|(_, e)| e.escrow_id == escrow_id) + { + return Ok(()); + } + let money = { + let mut mails = self.mail.mails.lock().unwrap(); + let now = crate::world::mail::now_secs(); + let Some((_, m)) = mails + .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)); + }; + if m.money == 0 { + return Err(anyhow!(lyracore_shared::mail::NOTHING_TO_TAKE)); + } + if m.money != expect_money { + return Err(anyhow!( + "refusing to fence an amount the payout would not match" + )); + } + std::mem::take(&mut m.money) + }; + self.mail.mail_escrows.lock().unwrap().push(( + payee_guid, + crate::world::mail::HeldEscrow { + escrow_id, + recipient_guid: payee_guid, + subject: String::new(), + body: String::new(), + money, + postage: 0, + payout: true, + mail_id, + item: crate::world::mail::AttachedItem::default(), + cod: 0, + delivery_delay_secs: 0, + reward: None, + }, + )); + self.mail.attested.lock().unwrap().push((escrow_id, false)); + Ok(()) + } + + /// Models `mail_escrow::apply_take_item_fence`: the ATTACHMENT leaves the row into a fence. + fn mail_take_item_fence( + &self, + escrow_id: u64, + payee_guid: u64, + mail_id: u64, + expect_entry: u32, + ) -> Result<()> { + self.rec("mail_take_item_fence"); + self.mail_kill("mail_take_item_fence")?; + if self + .mail + .mail_escrows + .lock() + .unwrap() + .iter() + .any(|(_, e)| e.escrow_id == escrow_id) + { + return Ok(()); + } + let item = { + let mut mails = self.mail.mails.lock().unwrap(); + let now = crate::world::mail::now_secs(); + let Some((_, m)) = mails + .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)); + }; + if m.item_entry == 0 { + return Err(anyhow!(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" + )); + } + let item = crate::world::mail::AttachedItem { + entry: m.item_entry, + stack_count: m.item_stack_count, + durability: m.item_durability, + enchant_id: m.item_enchant_id, + soulbound: m.item_soulbound, + random_property_id: m.random_property_id, + item_text_id: self.take_attached_text_id(mail_id), + }; + m.item_entry = 0; + m.item_stack_count = 0; + m.item_durability = 0; + m.item_enchant_id = 0; + m.item_soulbound = false; + item + }; + self.mail.mail_escrows.lock().unwrap().push(( + payee_guid, + crate::world::mail::HeldEscrow { + escrow_id, + recipient_guid: payee_guid, + subject: String::new(), + body: String::new(), + money: 0, + postage: 0, + payout: true, + mail_id, + item, + cod: 0, + delivery_delay_secs: 0, + reward: None, + }, + )); + self.mail.attested.lock().unwrap().push((escrow_id, false)); + Ok(()) + } + + /// Models `mail_escrow::apply_item_payout`: the grant plus a receipt, idempotent on the escrow + /// id, and refused by a full bag — which leaves the fence holding the item. + fn mail_item_payout( + &self, + escrow_id: u64, + payee_guid: u64, + _mail_id: u64, + item: crate::world::mail::AttachedItem, + ) -> Result<()> { + self.rec("mail_item_payout"); + self.mail_kill("mail_item_payout")?; + let receipts = self.mail.mail_receipts.lock().unwrap(); + if receipts.iter().any(|(id, _)| *id == escrow_id) { + return Ok(()); + } + drop(receipts); + self.store_snapshot(payee_guid, &item)?; + self.mail + .mail_receipts + .lock() + .unwrap() + .push((escrow_id, payee_guid)); + Ok(()) + } + + /// 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<()> { + self.rec("mail_payout"); + self.mail_kill("mail_payout")?; + let mut receipts = self.mail.mail_receipts.lock().unwrap(); + if receipts.iter().any(|(id, _)| *id == escrow_id) { + return Ok(()); + } + if !self + .mail + .purses + .lock() + .unwrap() + .iter() + .any(|(g, _)| *g == payee_guid) + { + return Err(anyhow!(lyracore_shared::mail::NOT_IN_WORLD)); + } + receipts.push((escrow_id, payee_guid)); + drop(receipts); + self.credit(payee_guid, amount); + Ok(()) + } + + fn mail_confirm_delivery(&self, escrow_id: u64) -> Result<()> { + self.rec("mail_confirm_delivery"); + self.mail_kill("mail_confirm_delivery")?; + let mut attested = self.mail.attested.lock().unwrap(); + match attested.iter_mut().find(|(id, _)| *id == escrow_id) { + Some((_, done)) => { + *done = true; + Ok(()) + } + None => Err(anyhow!("mail escrow {escrow_id}: nothing fenced here")), + } + } + + /// Models `mail_escrow::apply_settle`, delete-last included: it REFUSES while unattested. + fn mail_settle(&self, escrow_id: u64) -> Result<()> { + self.rec("mail_settle"); + self.mail_kill("mail_settle")?; + let attested = self + .mail + .attested + .lock() + .unwrap() + .iter() + .find(|(id, _)| *id == escrow_id) + .map(|(_, done)| *done); + match attested { + None => Ok(()), // already settled, or this call reached the wrong database + Some(false) => Err(anyhow!( + "mail escrow {escrow_id}: delivery not attested — refusing to destroy the fence" + )), + Some(true) => { + self.mail + .mail_escrows + .lock() + .unwrap() + .retain(|(_, e)| e.escrow_id != escrow_id); + self.mail + .attested + .lock() + .unwrap() + .retain(|(id, _)| *id != escrow_id); + Ok(()) + } + } + } + + fn mail_escrows_of(&self, sender_guid: u64) -> Result> { + self.rec("mail_escrows_of"); + if self + .mail + .mail_escrow_reads_before_visible + .fetch_update( + std::sync::atomic::Ordering::SeqCst, + std::sync::atomic::Ordering::SeqCst, + |left| left.checked_sub(1), + ) + .is_ok() + { + return Ok(Vec::new()); // the coordinator cache has not caught up yet + } + Ok(self + .mail + .mail_escrows + .lock() + .unwrap() + .iter() + .filter(|(owner, _)| *owner == sender_guid) + .map(|(_, e)| e.clone()) + .collect()) + } +} + +impl WorldFake { + /// One mail-escrow step boundary. `Err` is the gateway dying before this step committed: the + /// 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!( + "injected: the gateway died before {step} on {}", + self.topology.shard + ); + } + Ok(()) + } + + /// Take `copper` out of a purse on THIS database, or refuse and take nothing — the module's + /// `charge_postage`, including its "no live entity here" arm for a guid with no purse row. + pub(crate) fn debit(&self, guid: u64, copper: u32) -> Result<()> { + let mut purses = self.mail.purses.lock().unwrap(); + match purses.iter_mut().find(|(g, _)| *g == guid) { + Some((_, money)) if *money >= copper => { + *money -= copper; + Ok(()) + } + _ => Err(anyhow!(lyracore_shared::mail::NOT_ENOUGH_MONEY)), + } + } + + pub(crate) fn credit(&self, guid: u64, copper: u32) { + let mut purses = self.mail.purses.lock().unwrap(); + if let Some((_, money)) = purses.iter_mut().find(|(g, _)| *g == guid) { + *money = money.saturating_add(copper); + } + } + + /// The module's `detach_item`: the mailable verdict, then the DELETE. `item_guid` 0 is a letter + /// with no attachment. + pub(crate) fn detach( + &self, + sender_guid: u64, + item_guid: u64, + ) -> Result { + if item_guid == 0 { + return Ok(crate::world::mail::AttachedItem::default()); + } + let mut items = self.mail.mail_items.lock().unwrap(); + match items + .iter() + .position(|(g, owner, _)| *g == item_guid && *owner == sender_guid) + { + None => Err(anyhow!(lyracore_shared::mail::NOT_YOUR_ITEM)), + Some(i) if items[i].2.soulbound => { + Err(anyhow!(lyracore_shared::mail::ITEM_IS_SOULBOUND)) + } + Some(i) => Ok(items.remove(i).2), + } + } + + /// The module's `store_instance_state`: one new row carrying the recorded state, or the item + /// module's own full-bag refusal. + pub(crate) fn store_snapshot( + &self, + owner_guid: u64, + item: &crate::world::mail::AttachedItem, + ) -> Result<()> { + if self + .mail + .bags_full + .load(std::sync::atomic::Ordering::Relaxed) + { + return Err(anyhow!(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; + items.push((guid, owner_guid, item.clone())); + Ok(()) + } + + /// Every item `owner` holds on this database — the assertion surface for "it left the bags", + /// "it arrived unchanged" and "it never arrived twice". + pub(crate) fn bags_of(&self, owner: u64) -> Vec { + self.mail + .mail_items + .lock() + .unwrap() + .iter() + .filter(|(_, o, _)| *o == owner) + .map(|(_, _, i)| i.clone()) + .collect() + } + + /// The module's `insert_letter` for a Character's letter: the row both write paths reach, so a + /// letter written by the single-database send and one written by the escrow's commit cannot + /// differ. It has HAS_BODY with a body and COPIED without one, like `Letter::from_character`, + /// and only COD_PAYMENT when it pays a price. It arrives `delay_secs` from now. + #[allow(clippy::too_many_arguments)] + pub(crate) fn write_mail( + &self, + sender_guid: u64, + recipient_guid: u64, + subject: String, + body: String, + money: u32, + cod: u32, + item: &crate::world::mail::AttachedItem, + cod_payment: bool, + delay_secs: u32, + ) { + let check_flags = if cod_payment { + lyracore_shared::mail::CHECK_MASK_COD_PAYMENT + } else if body.is_empty() { + lyracore_shared::mail::CHECK_MASK_COPIED + } else { + lyracore_shared::mail::CHECK_MASK_HAS_BODY + }; + let mut mails = self.mail.mails.lock().unwrap(); + let id = mails.iter().map(|(_, m)| m.id).max().unwrap_or(0) + 1; + mails.push(( + recipient_guid, + codec::MailView { + id, + sender_guid, + subject, + body, + money, + cod, + item_entry: item.entry, + item_stack_count: item.stack_count, + item_durability: item.durability, + item_enchant_id: item.enchant_id, + item_soulbound: item.soulbound, + random_property_id: item.random_property_id, + created_at_secs: 1_000, + check_flags, + deliver_secs: delivered_after(delay_secs), + ..Default::default() + }, + )); + drop(mails); + if item.item_text_id != 0 { + self.mail + .mail_item_text_ids + .lock() + .unwrap() + .push((id, item.item_text_id)); + } + } + + /// The text id of the letter attached to mail `mail_id`, 0 for any other attachment. + pub(crate) fn attached_text_id(&self, mail_id: u64) -> u32 { + self.mail + .mail_item_text_ids + .lock() + .unwrap() + .iter() + .find(|(id, _)| *id == mail_id) + .map_or(0, |(_, text_id)| *text_id) + } + + /// Take the attached letter's text id off mail `mail_id`, as the take clears the attachment. + pub(crate) fn take_attached_text_id(&self, mail_id: u64) -> u32 { + let mut ids = self.mail.mail_item_text_ids.lock().unwrap(); + let Some(at) = ids.iter().position(|(id, _)| *id == mail_id) else { + return 0; + }; + ids.remove(at).1 + } + + pub(crate) fn saw_same_account(&self, call: &'static str, same_account: bool) { + self.mail + .same_account_seen + .lock() + .unwrap() + .push((call, same_account)); + } +} diff --git a/gateway/src/world/test_support/world_fake/meeting_stone.rs b/gateway/src/world/test_support/world_fake/meeting_stone.rs new file mode 100644 index 000000000..b5c53eee1 --- /dev/null +++ b/gateway/src/world/test_support/world_fake/meeting_stone.rs @@ -0,0 +1,37 @@ +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 { + Ok(MeetingStoneOutcome::Refused( + lyracore_shared::meeting_stone::MeetingStoneRefusal::NotAMeetingStone, + )) + } + + fn meeting_stone_area(&self, _go_guid: u64) -> Result> { + Ok(None) + } + + fn party_members(&self, _actor_guid: u64) -> Result>> { + Ok(None) + } + + fn seeker_facts(&self, _character_guid: u64) -> Result> { + Ok(None) + } + + fn meeting_stone_op( + &self, + _actor_guid: u64, + _op: u8, + _area_id: u32, + _seekers: Vec, + ) -> Result { + Ok(MeetingStoneOutcome::Ran) + } + + fn queued_area(&self, _character_guid: u64) -> Result> { + Ok(None) + } +} diff --git a/gateway/src/world/test_support/world_fake/melee.rs b/gateway/src/world/test_support/world_fake/melee.rs new file mode 100644 index 000000000..72d8dc1db --- /dev/null +++ b/gateway/src/world/test_support/world_fake/melee.rs @@ -0,0 +1,13 @@ +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<()> { + self.rec("start_attack"); + Ok(()) + } + fn stop_attack(&self, _account_id: u64, _self_guid: u64) -> Result<()> { + Ok(()) + } +} diff --git a/gateway/src/world/test_support/world_fake/member_stats.rs b/gateway/src/world/test_support/world_fake/member_stats.rs new file mode 100644 index 000000000..204f4c280 --- /dev/null +++ b/gateway/src/world/test_support/world_fake/member_stats.rs @@ -0,0 +1,57 @@ +use super::super::*; + +#[derive(Default)] +pub(crate) struct MemberStatsState { + /// How many `member_presence` reads reached this handle. + pub(crate) member_presence_reads: std::sync::atomic::AtomicUsize, +} + +/// Member Stats over the same party state the routing tests use: Realm-core's `party` when this +/// handle has a realm, its own `mirror` on a single database. Presence reads this shard and its +/// peers, every World Shard, like `Coordinator::member_presence`. +impl MemberStatsStore for WorldFake { + fn group_mates(&self, self_guid: u64) -> Result> { + let roster = match &self.topology.realm { + Some(realm) => { + let party = realm.party.party.lock().unwrap(); + party + .group_of(self_guid) + .and_then(|group| party.roster(group)) + } + None => self + .party + .mirror + .lock() + .unwrap() + .iter() + .find(|roster| roster.has_member(self_guid)) + .cloned(), + }; + Ok(roster + .map(|roster| roster.member_guids()) + .unwrap_or_default() + .into_iter() + .filter(|member| *member != self_guid) + .collect()) + } + + fn member_presence(&self, guid: u64) -> Result { + self.member_stats + .member_presence_reads + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + // Aura slots and a live pet are Member Stats' own overlay in production + // (`Coordinator::with_member_shard_stats`, keyed by `ShardId`) — this Fake has no + // `AuraIndex` to key into, so `entity` carries whatever `member_entities`/`live_entity`'s + // fallback already gave it (empty auras, no pet, unless a test seeded `member_entities` + // with its own). + Ok( + match presence::of(self, guid)?.map(|presence| presence.whereabouts) { + Some(presence::Whereabouts::InWorld { entity, .. }) => { + MemberPresence::Live(Box::new(codec::MemberStats::from_entity(&entity))) + } + Some(presence::Whereabouts::InTransit) => MemberPresence::InTransit, + Some(presence::Whereabouts::Offline) | None => MemberPresence::Offline, + }, + ) + } +} diff --git a/gateway/src/world/test_support/world_fake/mod.rs b/gateway/src/world/test_support/world_fake/mod.rs new file mode 100644 index 000000000..033a9c170 --- /dev/null +++ b/gateway/src/world/test_support/world_fake/mod.rs @@ -0,0 +1,100 @@ +//! [`WorldFake`]: one sub-state per Store family, each in its family's file with its impl. + +mod auction; +mod bank; +mod cast; +mod channel; +mod character; +mod chat; +mod combat; +mod death; +mod duel; +mod guild; +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 social; +mod speech; +mod taxi; +mod trade; +mod trainer; +mod transfer; +mod vendor; +mod weather; + +pub(crate) use self::auction::*; +pub(crate) use self::cast::*; +pub(crate) use self::character::*; +pub(crate) use self::chat::*; +pub(crate) use self::combat::*; +pub(crate) use self::guild::*; +pub(crate) use self::loot_roll::*; +pub(crate) use self::loot_window::*; +pub(crate) use self::mail::*; +pub(crate) use self::member_stats::*; +pub(crate) use self::npc::*; +pub(crate) use self::party::*; +pub(crate) use self::quest::*; +pub(crate) use self::session::*; +pub(crate) use self::social::*; +pub(crate) use self::speech::*; +pub(crate) use self::taxi::*; +pub(crate) use self::trainer::*; +pub(crate) use self::transfer::*; +pub(crate) use self::vendor::*; +pub(crate) use self::weather::*; + +use super::TopologyState; +use crate::world::codec; + +/// The Fake behind World Session socket tests. It implements every Store family, so one handle +/// serves a whole session, and several handles wired through `topology` model a sharded Realm. +/// Each family keeps its state in its own field, defined beside that family's impl. +#[derive(Default)] +pub(crate) struct WorldFake { + /// The Characters this Shard holds. Several families read it. + pub(crate) characters: Vec, + /// 195: `npc_refuses_interaction` return — false (derive-Default) keeps every fixture NPC open. + pub(crate) npc_refuses: bool, + /// When set, Durable Requests in several families fail with this error: a Refusal tag, or any + /// other text for a failure with an unknown durable result. + pub(crate) trade_error: Option, + pub(crate) topology: TopologyState, + pub(crate) session: SessionState, + pub(crate) character: CharacterState, + pub(crate) transfer: TransferState, + pub(crate) party: PartyState, + pub(crate) mail: MailState, + pub(crate) social: SocialState, + pub(crate) npc: NpcState, + pub(crate) trainer: TrainerState, + pub(crate) combat: CombatState, + pub(crate) cast: CastState, + pub(crate) taxi: TaxiState, + pub(crate) chat: ChatState, + pub(crate) speech: SpeechState, + pub(crate) guild: GuildState, + pub(crate) auction: AuctionState, + pub(crate) quest: QuestState, + pub(crate) vendor: VendorState, + pub(crate) weather: WeatherState, + pub(crate) member_stats: MemberStatsState, + pub(crate) loot_window: LootWindowState, + pub(crate) loot_roll: LootRollState, +} + +pub(crate) fn lk(m: &std::sync::Mutex) -> std::sync::MutexGuard<'_, T> { + m.try_lock().expect( + "re-entrant lock on FakeShardDb: a method is already holding this mutex further up the \ + stack. With `lock()` this would be a DEADLOCK and the suite would HANG instead of failing \ + — see the fn doc on `lk`.", + ) +} diff --git a/gateway/src/world/test_support/world_fake/npc.rs b/gateway/src/world/test_support/world_fake/npc.rs new file mode 100644 index 000000000..a3fdd8b16 --- /dev/null +++ b/gateway/src/world/test_support/world_fake/npc.rs @@ -0,0 +1,103 @@ +use super::super::*; + +#[derive(Default)] +pub(crate) struct NpcState { + /// `npc_is_innkeeper` flag for the gossip bind-home routing. + pub(crate) innkeeper: bool, + /// Whether `bind_home` ran (the innkeeper gossip select). + pub(crate) home_bound: std::sync::atomic::AtomicBool, + /// Imported gossip menu options `gossip_options` returns for ANY npc_guid — empty + /// by default (the pre-import fallback path). + pub(crate) gossip_opts: Vec, + /// Recorded `gossip_select` notifications as `(option_id, option_row_id)` — the clicked POSITION + /// and the stable row identity the module is told about. + pub(crate) gossip_selects: std::sync::Mutex>, + /// The `npc_text_for_id` view `npc_text_for_id` returns for ANY text_id — `None` by default (the + /// generic-greeting fallback), settable per-test for the 8-slot pin coverage. + pub(crate) npc_text_view: Option, + /// Spawned GameObject type returned for the `CMSG_GAMEOBJ_USE` questgiver classification. + pub(crate) gameobject_type: Option, +} + +impl NpcStore for WorldFake { + fn creature_template(&self, _entry: u32) -> Result> { + Ok(None) + } + + fn pet_name( + &self, + _requester_guid: u64, + _pet_number: u32, + _pet_guid: u64, + ) -> Result> { + Ok(None) + } + + fn gameobject_template(&self, _entry: u32) -> Result> { + Ok(None) + } + + fn gameobject_type(&self, _go_guid: u64) -> Result> { + Ok(self.npc.gameobject_type) + } + + fn enter_areatrigger(&self, _account_id: u64, _self_guid: u64, _trigger_id: u32) -> Result<()> { + Ok(()) + } + + fn npc_refuses_interaction(&self, _npc_guid: u64, _player_guid: u64) -> Result { + Ok(self.npc_refuses) // default false — every existing fixture NPC keeps interacting + } + + fn bind_home(&self, _account_id: u64, _self_guid: u64) -> Result<()> { + self.npc + .home_bound + .store(true, std::sync::atomic::Ordering::SeqCst); + Ok(()) + } + + fn npc_is_innkeeper(&self, _guid: u64) -> Result { + Ok(self.npc.innkeeper) + } + + fn npc_gossip_text_id(&self, _npc_guid: u64) -> u32 { + 1 // generic fallback for tests + } + + fn npc_text_for_id(&self, _text_id: u32) -> Option { + self.npc.npc_text_view.clone() + } + + fn gossip_options(&self, _npc_guid: u64) -> Result> { + 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}")); + } + // 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")); + } + Ok(()) + } + + fn gossip_select( + &self, + _account_id: u64, + _self_guid: u64, + _npc_guid: u64, + option_id: u32, + option_row_id: u32, + ) -> Result<()> { + self.npc + .gossip_selects + .lock() + .unwrap() + .push((option_id, option_row_id)); + Ok(()) + } +} diff --git a/gateway/src/world/test_support/world_fake/party.rs b/gateway/src/world/test_support/world_fake/party.rs new file mode 100644 index 000000000..1863942c8 --- /dev/null +++ b/gateway/src/world/test_support/world_fake/party.rs @@ -0,0 +1,1075 @@ +use super::super::*; + +#[derive(Default)] +pub(crate) struct PartyState { + pub(crate) group_invites: std::sync::Mutex>, + /// Recorded `group_loot_method` calls: (loot_setting, master_guid, loot_threshold). + pub(crate) group_loot_methods: std::sync::Mutex>, + /// Unclaimed bot invite intent ids on this World Shard. Two concurrent consumers share this + /// collection, matching the Module table both Gateways call into. + pub(crate) bot_invite_intents: std::sync::Mutex>, + /// Current consent returned by the owned admission seam, independent of presence reads. + pub(crate) sessionless_admission: + std::sync::Mutex>, + pub(crate) sessionless_admission_unavailable: std::sync::Mutex>, + pub(crate) suppress_after_admission: std::sync::Mutex>, + pub(crate) bot_intent_claim_refusals: + std::sync::Mutex>, + /// The AUTHORITATIVE party state, when this handle is the realm-core one. Shared with + /// nobody — a realm handle owns exactly one of these, and every shard reads its own `mirror`. + pub(crate) party: std::sync::Arc>, + /// Realm-core's Coordinator cache lags a call-pipe commit: after `realm_group_op`, roster reads + /// answer from `stale_party` until a `realm_group_op_visible` returns. The production shape of + /// a call pipe, which subscribes no group table. + pub(crate) cache_lags: std::sync::atomic::AtomicBool, + /// The party as the lagging cache still shows it. + pub(crate) stale_party: std::sync::Mutex>, + /// True when this handle is realm-core, so `group_roster` answers from `party` (the + /// authority) instead of `mirror` (this shard's cache of it). + pub(crate) is_realm: bool, + /// What `sync_group_mirror` wrote onto THIS shard, latest per group. The invalidation + /// story, made observable. + pub(crate) mirror: std::sync::Mutex>, + /// The Roster Revision each `sync_group_mirror` accepted on THIS shard, kept after the disband + /// tombstone, as the Module's mirror keeps its `game_group_roster_revision` row. + pub(crate) mirror_revisions: std::sync::Mutex>, + /// When set, `sync_group_mirror` fails with this message — a world shard that cannot be + /// mirrored (an unreachable database), which must not fail a party op realm-core already took. + pub(crate) mirror_error: Option, + /// How many mirror writes fail before this Shard accepts one. + pub(crate) mirror_failures: std::sync::atomic::AtomicUsize, + /// When set, `realm_group_op(ACCEPT, …)` fails with this message. INJECTED because a real + /// one cannot be staged synchronously: every accept-time refusal the module has (already grouped, + /// party full, the inviter no longer leads) needs the party to change BETWEEN the invite and the + /// accept, and the gateway's bot answer runs in the same call as the invite. The failure is still + /// reachable in production — a concurrent op on another socket — and what it must not do is leave + /// the invite dialog hanging. + pub(crate) party_accept_error: Option, + /// How many Realm-core LEAVE calls fail before one reaches the party state. + pub(crate) party_leave_failures: std::sync::atomic::AtomicUsize, + /// When set, every Group Broadcast op fails as a lost connection would. + pub(crate) group_broadcast_error: bool, + /// Return a connection failure after the next Realm-core LEAVE commits. + pub(crate) party_leave_commit_then_error: std::sync::atomic::AtomicBool, + /// Fail one `group_roster` read by its one-based call number. + pub(crate) group_roster_error_on_read: std::sync::Mutex>, + /// Number of `group_roster` reads on this handle. + pub(crate) group_roster_reads: std::sync::atomic::AtomicUsize, + /// When set, this handle cannot enumerate its mirrored party ids. + pub(crate) party_group_ids_error: std::sync::Mutex>, + pub(crate) party_command_claims: std::sync::Mutex>, + pub(crate) party_command_finishes: + std::sync::Mutex>, + pub(crate) admitted_party_commands: + std::sync::Mutex>, + pub(crate) party_command_apply_outcome: Option, + pub(crate) party_command_receipt_error: std::sync::Mutex>, + pub(crate) party_command_authority_members: std::sync::Mutex>>, + pub(crate) party_command_in_transit: std::sync::Mutex>, + pub(crate) party_command_receipts: std::sync::Mutex< + Vec<( + spacetimedb_sdk::Identity, + u64, + crate::world::party::CompanionCommandOutcome, + )>, + >, + pub(crate) entity_partitions: std::sync::Mutex>, + /// Characters Realm-core reports in a pending Transfer. Read on the realm handle only. + pub(crate) members_in_transit: std::sync::Mutex>, +} + +/// 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 { + match lyracore_shared::group::GroupRefusal::parse_tag(error) { + Some(refusal) => Ok(refusal.into()), + None => Err(anyhow!("{error}")), + } +} + +impl PartyStore for WorldFake { + fn realm_character_partition( + &self, + character_guid: u64, + ) -> Result> { + // `members_in_transit` is Member Stats' own per-guid fixture (`presence::of`'s + // `realm_transfer_pending` reads this trait method on the realm handle, same as + // `Coordinator::member_presence` did before the two merged). Checked first so it can name + // one guid as pending without disturbing `realm_partition`, which every other test that + // exercises a real Transfer already drives. + if self + .party + .members_in_transit + .lock() + .unwrap() + .contains(&character_guid) + { + return Ok(Some(crate::world::party::RealmCharacterPartition { + map_id: 0, + instance_id: 0, + revision: 1, + transfer_pending: true, + pending_destination_map: 0, + pending_destination_instance: 0, + bot_source_identity: spacetimedb_sdk::Identity::ZERO, + bot_transfer_intent_id: 0, + bot_controller_generation: 0, + })); + } + Ok(*self.transfer.realm_partition.lock().unwrap()) + } + + fn party_holder_observation( + &self, + character_guid: u64, + map_id: u32, + instance_id: u64, + ) -> Result { + Ok(crate::world::party::PartyHolderObservation { + serves_locator: self.shard_for_location(map_id, instance_id).is_none(), + has_escrow: self.escrowed_transfer(character_guid).is_some(), + character_partition: self + .character_destination(character_guid) + .map(|character| (character.dest_map_id, character.dest_instance_id)), + }) + } + + // Group: a minimal in-memory party — enough for the dispatch tests to drive + // invite-result mapping and the GROUP_LIST build without a live module. + // + // 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 { + self.rec("group_invite"); + if let Some(e) = &self.trade_error { + return faked_party(e); + } + self.party.group_invites.lock().unwrap().push(target_guid); + Ok(PartyOutcome::Ran) + } + + fn group_accept(&self, _account_id: u64, _self_guid: u64) -> Result { + self.rec("group_accept"); + Ok(PartyOutcome::Ran) + } + + fn group_decline(&self, _account_id: u64, _self_guid: u64) -> Result { + self.rec("group_decline"); + Ok(PartyOutcome::Ran) + } + + fn group_leave(&self, _account_id: u64, _self_guid: u64) -> Result { + self.rec("group_leave"); + Ok(PartyOutcome::Ran) + } + + fn group_loot_method( + &self, + _account_id: u64, + _self_guid: u64, + 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); + } + self.party.group_loot_methods.lock().unwrap().push(( + loot_setting, + master_guid, + loot_threshold, + )); + Ok(PartyOutcome::Ran) + } + + fn claim_party_command_intent(&self, intent_id: u64, claim_token: u64) -> Result<()> { + self.party + .party_command_claims + .lock() + .unwrap() + .push((intent_id, claim_token)); + Ok(()) + } + + fn admit_party_command_authority( + &self, + group_id: u64, + leader_guid: u64, + bot_guid: u64, + authority_member_guid: u64, + mut expected_members: Vec, + ) -> Result { + let roster = self.group_roster(leader_guid)?; + let Some(roster) = roster else { + return Ok(crate::world::party::CompanionCommandOutcome::NotMember); + }; + if roster.group_id != group_id || roster.leader_guid != leader_guid { + return Ok(crate::world::party::CompanionCommandOutcome::NotLeader); + } + expected_members.sort_unstable(); + let mut current_members = self + .party + .party_command_authority_members + .lock() + .unwrap() + .clone() + .unwrap_or_else(|| roster.member_guids()); + current_members.sort_unstable(); + if expected_members != current_members { + return Ok(crate::world::party::CompanionCommandOutcome::StalePartyMirror); + } + if !roster.has_member(bot_guid) + || (authority_member_guid != 0 && !roster.has_member(authority_member_guid)) + { + return Ok(crate::world::party::CompanionCommandOutcome::NotMember); + } + Ok(crate::world::party::CompanionCommandOutcome::Applied) + } + + fn apply_admitted_party_command( + &self, + command: &crate::world::party::AdmittedCompanionCommand, + ) -> Result { + self.party + .admitted_party_commands + .lock() + .unwrap() + .push(command.clone()); + let outcome = self + .party + .party_command_apply_outcome + .unwrap_or(crate::world::party::CompanionCommandOutcome::Applied); + if outcome != crate::world::party::CompanionCommandOutcome::WaitingForCapacity { + self.party.party_command_receipts.lock().unwrap().push(( + command.source_identity, + command.intent_id, + outcome, + )); + } + Ok(outcome) + } + + fn finish_party_command_intent( + &self, + intent_id: u64, + claim_token: u64, + outcome: crate::world::party::CompanionCommandOutcome, + ) -> Result<()> { + self.party + .party_command_finishes + .lock() + .unwrap() + .push((intent_id, claim_token, outcome)); + Ok(()) + } + + fn confirm_party_command_receipt( + &self, + source_identity: spacetimedb_sdk::Identity, + intent_id: u64, + ) -> Result> { + if let Some(error) = self + .party + .party_command_receipt_error + .lock() + .unwrap() + .as_ref() + { + return Err(anyhow!(error.clone())); + } + Ok(self + .party + .party_command_receipts + .lock() + .unwrap() + .iter() + .find(|(source, id, _)| *source == source_identity && *id == intent_id) + .map(|(_, _, outcome)| *outcome)) + } + + fn confirm_party_command_holder( + &self, + guid: u64, + ) -> Result { + Ok( + if self + .party + .party_command_in_transit + .lock() + .unwrap() + .contains(&guid) + { + crate::world::party::PartyCommandHolder::InTransit + } else if self + .characters + .iter() + .any(|character| character.guid == guid) + { + crate::world::party::PartyCommandHolder::Present + } else { + crate::world::party::PartyCommandHolder::Missing + }, + ) + } + + fn entity_partition(&self, guid: u64) -> Option<(u32, u64)> { + self.party + .entity_partitions + .lock() + .unwrap() + .iter() + .find(|(candidate, _, _)| *candidate == guid) + .map(|(_, map, instance)| (*map, *instance)) + } + + fn claim_bot_invite_intent(&self, intent_id: u64) -> Result { + if let Some(refusal) = self + .party + .bot_intent_claim_refusals + .lock() + .unwrap() + .get(&intent_id) + { + return Ok(PartyOutcome::Refused(*refusal)); + } + let mut intents = self.party.bot_invite_intents.lock().unwrap(); + let Some(index) = intents.iter().position(|id| *id == intent_id) else { + return Ok(PartyOutcome::Refused(GroupRefusal::IntentAlreadyClaimed)); + }; + intents.swap_remove(index); + Ok(PartyOutcome::Ran) + } + + fn admit_sessionless_group_action(&self, character_guid: u64) -> Result { + self.rec("admit_sessionless_group_action"); + if self + .party + .sessionless_admission_unavailable + .lock() + .unwrap() + .contains(&character_guid) + { + anyhow::bail!("World Shard admission unavailable"); + } + if let Some(refusal) = self + .party + .sessionless_admission + .lock() + .unwrap() + .get(&character_guid) + { + return Ok(PartyOutcome::Refused(*refusal)); + } + if !self.entity_in_world(character_guid) + || !matches!( + self.character_presence(character_guid), + Ok(Some((false, ..))) + ) + { + return Ok(PartyOutcome::Refused(GroupRefusal::ActorUnavailable)); + } + if self + .party + .suppress_after_admission + .lock() + .unwrap() + .contains(&character_guid) + { + self.party + .sessionless_admission + .lock() + .unwrap() + .insert(character_guid, GroupRefusal::ActionSuppressed); + } + Ok(PartyOutcome::Ran) + } + + /// The module's `realm_group_op`, modelled: the rules the ROUTING depends on, applied to the + /// authority this handle owns. + #[allow(clippy::too_many_lines)] // One arm per realm group op, as the Module's reducer has. + fn realm_group_op( + &self, + op: u8, + actor_guid: u64, + target_guid: u64, + arg_a: u8, + arg_b: u8, + arg_c: u64, + ) -> Result { + use lyracore_shared::group::{event_kind as kind, realm_op, GroupRefusal}; + self.rec("realm_group_op"); + let mut p = self.party.party.lock().unwrap(); + if self + .party + .cache_lags + .load(std::sync::atomic::Ordering::SeqCst) + { + let mut stale = self.party.stale_party.lock().unwrap(); + if stale.is_none() { + *stale = Some(p.clone()); + } + } + p.ops + .push((op, actor_guid, target_guid, arg_a, arg_b, arg_c)); + let full = |p: &FakeParty, group_id: u64| { + p.member_guids(group_id).len() >= p.kind_of(group_id).member_cap() + }; + match op { + realm_op::INVITE => { + if p.group_of(target_guid).is_some() { + return Ok(GroupRefusal::AlreadyInGroup.into()); + } + if p.group_of(actor_guid) + .is_some_and(|group_id| full(&p, group_id)) + { + return Ok(GroupRefusal::GroupFull.into()); + } + p.invites.retain(|(t, _)| *t != target_guid); + p.invites.push((target_guid, actor_guid)); + p.events.push((target_guid, kind::INVITE)); + } + realm_op::ACCEPT => { + if let Some(e) = &self.party.party_accept_error { + return faked_party(e); + } + let Some(inviter) = p + .invites + .iter() + .find(|(t, _)| *t == actor_guid) + .map(|(_, i)| *i) + else { + return Ok(GroupRefusal::NoPendingInvite.into()); + }; + p.invites.retain(|(t, _)| *t != actor_guid); + let group_id = match p.group_of(inviter) { + Some(g) if full(&p, g) => return Ok(GroupRefusal::GroupFull.into()), + Some(g) => g, + None => { + p.next_group_id += 1; + let g = p.next_group_id; + // The vanilla defaults a freshly-formed party gets: GROUP loot, Uncommon. + p.groups.push((g, inviter, 3, 2, 0)); + p.members.push((g, inviter)); + g + } + }; + let slot = p.joining_slot(group_id); + if slot != RaidSlot::default() { + p.slots.insert(actor_guid, slot); + } + p.members.push((group_id, actor_guid)); + p.push_list(group_id); + } + realm_op::RAID_CONVERT => { + let Some(group_id) = p.group_of(actor_guid) else { + return Ok(GroupRefusal::NotInGroup.into()); + }; + if p.groups.iter().find(|(g, ..)| *g == group_id).map(|e| e.1) != Some(actor_guid) { + return Ok(GroupRefusal::NotLeader.into()); + } + if p.kind_of(group_id) == GroupKind::Party { + p.raids.push(group_id); + p.push_list(group_id); + } + } + realm_op::DECLINE => { + let Some(inviter) = p + .invites + .iter() + .find(|(t, _)| *t == actor_guid) + .map(|(_, i)| *i) + else { + return Ok(GroupRefusal::NoPendingInvite.into()); + }; + p.invites.retain(|(t, _)| *t != actor_guid); + p.events.push((inviter, kind::DECLINE)); + } + realm_op::LEAVE => { + if self + .party + .party_leave_failures + .fetch_update( + std::sync::atomic::Ordering::SeqCst, + std::sync::atomic::Ordering::SeqCst, + |remaining| remaining.checked_sub(1), + ) + .is_ok() + { + return Err(anyhow!("Realm-core LEAVE connection interrupted")); + } + if p.group_of(actor_guid).is_none() { + return Ok(GroupRefusal::NotInGroup.into()); + } + if self + .party + .party_leave_commit_then_error + .swap(false, std::sync::atomic::Ordering::SeqCst) + { + p.remove_member(actor_guid); + return Err(anyhow!("Realm-core LEAVE reply was lost after commit")); + } + p.remove_member(actor_guid); + } + realm_op::UNINVITE => { + let Some(group_id) = p.group_of(actor_guid) else { + return Ok(GroupRefusal::NotInGroup.into()); + }; + if !p.manages(group_id, actor_guid) { + return Ok(GroupRefusal::NotLeader.into()); + } + if p.group_of(target_guid) != Some(group_id) { + return Ok(GroupRefusal::TargetNotInGroup.into()); + } + if target_guid == actor_guid { + return Ok(GroupRefusal::KickSelf.into()); + } + if target_guid == p.leader_of(group_id) { + return Ok(GroupRefusal::NotLeader.into()); + } + p.remove_member(target_guid); + } + realm_op::LOOT_METHOD => { + let Some(group_id) = p.group_of(actor_guid) else { + return Ok(GroupRefusal::NotInGroup.into()); + }; + if let Some(entry) = p.groups.iter_mut().find(|(g, ..)| *g == group_id) { + if entry.1 != actor_guid { + return Ok(GroupRefusal::NotLeader.into()); + } + entry.2 = arg_a; + entry.3 = arg_b; + entry.4 = target_guid; + } + p.push_list(group_id); + } + realm_op::SET_LEADER => { + let Some(group_id) = p.group_of(actor_guid) else { + return Ok(GroupRefusal::NotInGroup.into()); + }; + if p.leader_of(group_id) != actor_guid { + return Ok(GroupRefusal::NotLeader.into()); + } + if target_guid == actor_guid { + return Ok(GroupRefusal::TargetIsSelf.into()); + } + if p.group_of(target_guid) != Some(group_id) { + return Ok(GroupRefusal::TargetNotInGroup.into()); + } + p.set_leader(group_id, target_guid); + p.push_list(group_id); + } + realm_op::SET_ASSISTANT => { + let Some(group_id) = p.group_of(actor_guid) else { + return Ok(GroupRefusal::NotInGroup.into()); + }; + if p.leader_of(group_id) != actor_guid { + return Ok(GroupRefusal::NotLeader.into()); + } + if p.kind_of(group_id) != GroupKind::Raid { + return Ok(GroupRefusal::NotRaid.into()); + } + if target_guid == actor_guid { + return Ok(GroupRefusal::TargetIsSelf.into()); + } + if p.group_of(target_guid) != Some(group_id) { + return Ok(GroupRefusal::TargetNotInGroup.into()); + } + let slot = p.slots.get(&target_guid).copied().unwrap_or_default(); + let assigned = slot.with_assistant(arg_a != 0); + if assigned != slot { + p.slots.insert(target_guid, assigned); + p.push_list(group_id); + } + } + realm_op::CHANGE_SUBGROUP => { + return Ok(p.change_subgroup(actor_guid, target_guid, arg_a)) + } + realm_op::SWAP_SUBGROUP => return Ok(p.swap_subgroup(actor_guid, target_guid, arg_c)), + // Group Broadcasts. Who hears each one is the Module's rule; the routing needs only + // the op on the authority, and a Refusal for a member of no group. + realm_op::READY_CHECK_START..=realm_op::RANDOM_ROLL + if self.party.group_broadcast_error => + { + return Err(anyhow!("Realm-core call pipe timed out")); + } + realm_op::READY_CHECK_START + | realm_op::READY_CHECK_ANSWER + | realm_op::TARGET_ICON + | realm_op::MINIMAP_PING => { + if p.group_of(actor_guid).is_none() { + return Ok(GroupRefusal::NotInGroup.into()); + } + } + realm_op::RANDOM_ROLL => {} + other => return Err(anyhow!("unknown realm group op {other}")), + } + Ok(PartyOutcome::Ran) + } + + /// The visibility receipt: the op commits, then the Coordinator cache holds it. + 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 outcome = self.realm_group_op(op, actor_guid, 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, + ) -> Result { + self.realm_group_op( + lyracore_shared::group::realm_op::LEAVE, + character_guid, + 0, + lyracore_shared::group::leave_cause::CHARACTER_DELETED, + 0, + 0, + ) + } + + fn group_roster( + &self, + character_guid: u64, + ) -> Result> { + let read = self + .party + .group_roster_reads + .fetch_add(1, std::sync::atomic::Ordering::SeqCst) + + 1; + if let Some((error_read, error)) = &*self.party.group_roster_error_on_read.lock().unwrap() { + if read == *error_read { + return Err(anyhow!(error.clone())); + } + } + if self.party.is_realm { + return Ok(self.realm_cache(|p| p.group_of(character_guid).and_then(|g| p.roster(g)))); + } + Ok(self + .party + .mirror + .lock() + .unwrap() + .iter() + .find(|r| r.has_member(character_guid)) + .cloned()) + } + + fn party_command_group_roster( + &self, + character_guid: u64, + ) -> Result> { + let roster = self.group_roster(character_guid)?; + if roster + .as_ref() + .is_some_and(|roster| roster.members.len() > lyracore_shared::group::RAID_MAX_MEMBERS) + { + anyhow::bail!("party command roster exceeds the member limit"); + } + Ok(roster) + } + + fn group_roster_by_id( + &self, + group_id: u64, + ) -> Result> { + if self.party.is_realm { + return Ok(self.realm_cache(|p| p.roster(group_id))); + } + Ok(self + .party + .mirror + .lock() + .unwrap() + .iter() + .find(|r| r.group_id == group_id) + .cloned()) + } + + fn party_cleanup_group_roster_by_id( + &self, + group_id: u64, + ) -> Result> { + self.group_roster_by_id(group_id) + } + + fn group_roster_revision(&self, group_id: u64) -> Result { + Ok(self.held_roster_revision(group_id)?.unwrap_or(1)) + } + + fn held_roster_revision(&self, group_id: u64) -> Result> { + if self.party.is_realm { + return Ok(self.realm_cache(|p| p.revisions.get(&group_id).copied())); + } + Ok(self + .party + .mirror_revisions + .lock() + .unwrap() + .get(&group_id) + .copied()) + } + + fn party_member_guids(&self) -> Result> { + if !self.party.is_realm { + return Ok(Vec::new()); + } + Ok(self + .party + .party + .lock() + .unwrap() + .members + .iter() + .map(|(_, guid)| *guid) + .collect()) + } + + fn party_group_ids(&self) -> Result> { + if let Some(error) = &*self.party.party_group_ids_error.lock().unwrap() { + return Err(anyhow!(error.clone())); + } + if self.party.is_realm { + return Ok(self + .party + .party + .lock() + .unwrap() + .groups + .iter() + .map(|(group_id, ..)| *group_id) + .collect()); + } + Ok(self + .party + .mirror + .lock() + .unwrap() + .iter() + .map(|roster| roster.group_id) + .collect()) + } + + fn sync_group_mirror(&self, roster: &crate::world::party::GroupRoster) -> Result<()> { + self.rec("sync_group_mirror"); + if self + .party + .mirror_failures + .fetch_update( + std::sync::atomic::Ordering::SeqCst, + std::sync::atomic::Ordering::SeqCst, + |remaining| remaining.checked_sub(1), + ) + .is_ok() + { + return Err(anyhow!("World Shard mirror connection interrupted")); + } + if let Some(e) = &self.party.mirror_error { + return Err(anyhow!("{e}")); + } + self.party + .mirror_revisions + .lock() + .unwrap() + .insert(roster.group_id, roster.roster_revision); + let mut mirror = self.party.mirror.lock().unwrap(); + mirror.retain(|r| r.group_id != roster.group_id); + // An empty roster is the disband tombstone — the shard forgets the party rather than + // keeping an empty one, which is what the module's own `sync_group_mirror` does. + if !roster.members.is_empty() { + mirror.push(roster.clone()); + } + Ok(()) + } + + fn group_uninvite( + &self, + _account_id: u64, + _self_guid: u64, + _target_guid: u64, + ) -> Result { + self.rec("group_uninvite"); + Ok(PartyOutcome::Ran) + } +} + +/// Realm-core's authoritative party state, as far as the gateway's routing can see it — +/// the module's `realm_group_op` rules, modelled at the granularity the ROUTING depends on: +/// which database the op lands on, who ends up in which group, and who gets notified. +/// +/// Same instrument, and same limits, as [`FakeShardDb`]: the module's own reducer bodies cannot run +/// in a gateway test (no `ReducerContext`), so what executes here is the gateway's production +/// routing (`world::party`) against a faithful stand-in for the authority. The rules themselves are +/// the module's to test. +#[derive(Default, Clone)] +pub(crate) struct FakeParty { + pub(crate) next_group_id: u64, + /// group_id → (leader, loot_method, loot_threshold, master_looter). + pub(crate) groups: Vec<(u64, u64, u8, u8, u64)>, + /// (group_id, character_guid), in join order — the order realm-core's roster read returns. + pub(crate) members: Vec<(u64, u64)>, + /// (target_guid, inviter_guid) — at most one pending per target, newest wins. + pub(crate) invites: Vec<(u64, u64)>, + /// Groups their leader converted to a Raid. + pub(crate) raids: Vec, + /// Each Raid member's Raid Slot; a member absent here holds the Party default. + pub(crate) slots: std::collections::HashMap, + /// group_id → how many times [`Self::push_list`] fired for it — this Fake's stand-in for the + /// Roster Revision. A group absent here has never had a list pushed. Bumped exactly where a + /// real accepted change would advance the revision, so [`crate::world::party::roster_unchanged`]'s + /// no-mirror-push optimization is exercised against a real signal rather than a constant. + pub(crate) revisions: std::collections::HashMap, + /// Every op that reached the AUTHORITY: `(op, actor, target, arg_a, arg_b, arg_c)`. The + /// assertion that a party op ran on realm-core rather than on the player's shard. + pub(crate) ops: Vec<(u8, u64, u64, u8, u8, u64)>, + /// Every notification the authority pushed: `(recipient_guid, kind)` — the relay's input. + pub(crate) events: Vec<(u64, u8)>, +} + +impl FakeParty { + pub(crate) fn group_of(&self, guid: u64) -> Option { + self.members + .iter() + .find(|(_, g)| *g == guid) + .map(|(gid, _)| *gid) + } + + pub(crate) fn kind_of(&self, group_id: u64) -> GroupKind { + if self.raids.contains(&group_id) { + GroupKind::Raid + } else { + GroupKind::Party + } + } + + pub(crate) fn member_guids(&self, group_id: u64) -> Vec { + self.members + .iter() + .filter(|(g, _)| *g == group_id) + .map(|(_, guid)| *guid) + .collect() + } + + /// The Module's own placement rule, `RaidSlot::for_raid_joiner`, over this Fake's slots. + pub(crate) fn joining_slot(&self, group_id: u64) -> RaidSlot { + if self.kind_of(group_id) == GroupKind::Party { + return RaidSlot::default(); + } + let current = self + .member_guids(group_id) + .into_iter() + .map(|guid| self.slots.get(&guid).copied().unwrap_or_default()); + RaidSlot::for_raid_joiner(current).expect("the cap check leaves room") + } + + pub(crate) fn roster(&self, group_id: u64) -> Option { + let (gid, leader, method, threshold, master) = + *self.groups.iter().find(|(g, ..)| *g == group_id)?; + Some(crate::world::party::GroupRoster { + group_id: gid, + roster_revision: self.revisions.get(&group_id).copied().unwrap_or(0), + leader_guid: leader, + loot_method: method, + loot_threshold: threshold, + master_looter_guid: master, + kind: self.kind_of(group_id), + members: self + .member_guids(group_id) + .into_iter() + .map(|guid| crate::world::party::GroupRosterMember { + guid, + slot: self.slots.get(&guid).copied().unwrap_or_default(), + }) + .collect(), + partitions: Vec::new(), + }) + } + + pub(crate) fn leader_of(&self, group_id: u64) -> u64 { + self.groups + .iter() + .find(|(g, ..)| *g == group_id) + .map_or(0, |group| group.1) + } + + /// The leader, or an Assistant. + pub(crate) fn manages(&self, group_id: u64, guid: u64) -> bool { + self.leader_of(group_id) == guid + || self + .slots + .get(&guid) + .is_some_and(|slot| slot.is_assistant()) + } + + /// The Module's `change_subgroup_on`, modelled: leader-or-Assistant, Raid-only, capacity + /// decided by the shared [`RaidSlot::moved_to_subgroup`] rule. + pub(crate) fn change_subgroup( + &mut self, + actor_guid: u64, + target_guid: u64, + subgroup: u8, + ) -> PartyOutcome { + use lyracore_shared::group::GroupRefusal; + let Some(group_id) = self.group_of(actor_guid) else { + return GroupRefusal::NotInGroup.into(); + }; + if self.kind_of(group_id) != GroupKind::Raid { + return GroupRefusal::NotRaid.into(); + } + if !self.manages(group_id, actor_guid) { + return GroupRefusal::NotLeader.into(); + } + if self.group_of(target_guid) != Some(group_id) { + return GroupRefusal::TargetNotInGroup.into(); + } + let current = self.slots.get(&target_guid).copied().unwrap_or_default(); + let destination_size = self + .member_guids(group_id) + .into_iter() + .filter(|&guid| { + guid != target_guid + && self + .slots + .get(&guid) + .copied() + .unwrap_or_default() + .subgroup() + == subgroup + }) + .count(); + match current.moved_to_subgroup(subgroup, destination_size) { + Err(refusal) => refusal.into(), + Ok(None) => PartyOutcome::Ran, + Ok(Some(new_slot)) => { + self.slots.insert(target_guid, new_slot); + self.push_list(group_id); + PartyOutcome::Ran + } + } + } + + /// The Module's `swap_subgroup_on`, modelled: same gates as [`Self::change_subgroup`], the + /// shared [`RaidSlot::swapped_with`] rule, and no capacity Gate. + pub(crate) fn swap_subgroup( + &mut self, + actor_guid: u64, + first_guid: u64, + second_guid: u64, + ) -> PartyOutcome { + use lyracore_shared::group::GroupRefusal; + let Some(group_id) = self.group_of(actor_guid) else { + return GroupRefusal::NotInGroup.into(); + }; + if self.kind_of(group_id) != GroupKind::Raid { + return GroupRefusal::NotRaid.into(); + } + if !self.manages(group_id, actor_guid) { + return GroupRefusal::NotLeader.into(); + } + if self.group_of(first_guid) != Some(group_id) + || self.group_of(second_guid) != Some(group_id) + { + return GroupRefusal::TargetNotInGroup.into(); + } + let first_slot = self.slots.get(&first_guid).copied().unwrap_or_default(); + let second_slot = self.slots.get(&second_guid).copied().unwrap_or_default(); + if let Some((new_first, new_second)) = first_slot.swapped_with(second_slot) { + self.slots.insert(first_guid, new_first); + self.slots.insert(second_guid, new_second); + self.push_list(group_id); + } + PartyOutcome::Ran + } + + /// Hand the lead to `leader` and announce it to every member, as the Module does, before the + /// list. + pub(crate) fn set_leader(&mut self, group_id: u64, leader: u64) { + if let Some(entry) = self.groups.iter_mut().find(|(g, ..)| *g == group_id) { + entry.1 = leader; + } + for member in self.member_guids(group_id) { + self.events + .push((member, lyracore_shared::group::event_kind::SET_LEADER)); + } + } + + pub(crate) fn push_list(&mut self, group_id: u64) { + *self.revisions.entry(group_id).or_insert(0) += 1; + let recipients: Vec = self + .members + .iter() + .filter(|(g, _)| *g == group_id) + .map(|(_, guid)| *guid) + .collect(); + for r in recipients { + self.events + .push((r, lyracore_shared::group::event_kind::LIST)); + } + } + + /// Drop `guid` from its party, applying the module's own disband rule (a party of one is no + /// party) — the half `sync_mirrors` has to observe to push the right tombstone. + pub(crate) fn remove_member(&mut self, guid: u64) { + use lyracore_shared::group::event_kind as kind; + let Some(group_id) = self.group_of(guid) else { + return; + }; + self.members + .retain(|(g, m)| !(*g == group_id && *m == guid)); + self.slots.remove(&guid); + self.events.push((guid, kind::DESTROYED)); + let remaining: Vec = self + .members + .iter() + .filter(|(g, _)| *g == group_id) + .map(|(_, m)| *m) + .collect(); + if remaining.len() < 2 { + for r in remaining { + self.events.push((r, kind::DESTROYED)); + self.slots.remove(&r); + } + self.members.retain(|(g, _)| *g != group_id); + self.groups.retain(|(g, ..)| *g != group_id); + self.raids.retain(|g| *g != group_id); + } else { + if self.leader_of(group_id) == guid { + // The Module's succession: a Raid's first Assistant in join order, else the + // longest-standing member. + let raid = self.kind_of(group_id) == GroupKind::Raid; + let heir = remaining + .iter() + .copied() + .find(|member| raid && self.manages(group_id, *member)) + .unwrap_or(remaining[0]); + self.set_leader(group_id, heir); + } + self.push_list(group_id); + } + } +} + +impl WorldFake { + /// Read realm-core's party the way its Coordinator cache shows it: stale after a lagging + /// call-pipe commit, current otherwise. + pub(crate) fn realm_cache(&self, read: impl FnOnce(&FakeParty) -> R) -> R { + if let Some(stale) = &*self.party.stale_party.lock().unwrap() { + return read(stale); + } + read(&self.party.party.lock().unwrap()) + } +} diff --git a/gateway/src/world/test_support/world_fake/quest.rs b/gateway/src/world/test_support/world_fake/quest.rs new file mode 100644 index 000000000..5704b252e --- /dev/null +++ b/gateway/src/world/test_support/world_fake/quest.rs @@ -0,0 +1,148 @@ +use super::super::*; + +#[derive(Default)] +pub(crate) struct QuestState { + /// Quest-giver evals returned by `giver_quest_evals` (the menu/status input). + pub(crate) quest_evals: Vec, + /// Quest details `quest_detail_view(id)` resolves from (matched by `quest_id`). + 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 + /// 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 + /// `gw_turn_in_quest` does in its own transaction. + pub(crate) turn_in_reward_letter: Option, + /// The caller's quest log for `quest_status`, as `(quest_id, rewarded)` pairs — a quest id present + /// here is "taken"; `rewarded` distinguishes active vs. turned-in. Absent = never seen. + /// Behind a `Mutex` so a test can change the log WHILE a gossip window is open — the + /// HELLO→SELECT race the menu snapshot exists to close. + pub(crate) quest_log: std::sync::Mutex>, +} + +impl QuestActionStore for WorldFake { + fn giver_quest_evals( + &self, + _giver_guid: u64, + _player_guid: u64, + ) -> Result> { + Ok(self.quest.quest_evals.clone()) + } + + fn quest_detail_view(&self, quest_id: u32) -> Result> { + Ok(self + .quest + .quest_details + .iter() + .find(|d| d.quest_id == quest_id) + .cloned()) + } + + fn giver_refuses_interaction(&self, _giver_guid: u64, _player_guid: u64) -> Result { + Ok(self.npc_refuses) + } + + /// Accept, abandon and share are driven only at the quest seam, where + /// `InMemoryQuestActions` records the request — a socket test would prove nothing more, so + /// this store just answers success. + fn accept_quest( + &self, + _account_id: u64, + _self_guid: u64, + _giver_guid: u64, + _quest_id: u32, + ) -> Result<()> { + Ok(()) + } + + /// No socket test drives an item-started quest — the route is proved at the quest seam. + fn item_start_quest(&self, _owner_guid: u64, _slot: u8) -> Option<(u64, u32)> { + None + } + + fn player_quest_log(&self, _player_guid: u64) -> Result> { + Ok(self.quest.quest_log_slots.clone()) + } + + fn abandon_quest(&self, _account_id: u64, _self_guid: u64, _quest_id: u32) -> Result<()> { + Ok(()) + } + + fn push_quest(&self, _account_id: u64, _self_guid: u64, _quest_id: u32) -> Result<()> { + Ok(()) + } + + fn quest_status(&self, _player_guid: u64, quest_id: u32) -> (bool, bool) { + match self + .quest + .quest_log + .lock() + .unwrap() + .iter() + .find(|(id, _)| *id == quest_id) + { + Some((_, rewarded)) => (true, *rewarded), + None => (false, false), + } + } + + fn turn_in_quest( + &self, + account_id: u64, + _self_guid: u64, + giver_guid: u64, + quest_id: u32, + reward_index: u32, + ) -> Result<()> { + if let Some(e) = &self.trade_error { + return Err(anyhow!("{e}")); + } + self.quest + .turned_in + .lock() + .unwrap() + .push((account_id, giver_guid, quest_id, reward_index)); + if let Some(letter) = self.quest.turn_in_reward_letter.clone() { + self.mail + .attested + .lock() + .unwrap() + .push((letter.escrow_id, false)); + self.mail + .mail_escrows + .lock() + .unwrap() + .push((letter.recipient_guid, letter)); + } + if let (Some(item), Some(tx)) = ( + self.session.turn_in_reward_item.clone(), + self.session.turn_in_tx.lock().unwrap().take(), + ) { + let mut relay = vec![Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( + Box::new(codec::build_item_create_object(&item)), + ))]; + if let Some(pointer) = + codec::build_inv_slot_values(item.owner_guid, item.slot, item.guid) + { + relay.push(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( + Box::new(pointer), + ))); + } + relay.push(Outbound::One(ServerOpcodeMessage::SMSG_ITEM_PUSH_RESULT( + Box::new(codec::build_item_push_result( + item.owner_guid, + 255, + item.slot as u32, + item.entry, + item.stack_count, + false, + 0, + )), + ))); + tx.send(Outbound::Job(Box::new(move || relay))) + .map_err(|_| anyhow!("reward item relay writer is gone"))?; + } + Ok(()) + } +} diff --git a/gateway/src/world/test_support/world_fake/session.rs b/gateway/src/world/test_support/world_fake/session.rs new file mode 100644 index 000000000..547602938 --- /dev/null +++ b/gateway/src/world/test_support/world_fake/session.rs @@ -0,0 +1,229 @@ +use super::super::*; + +/// One recorded `movement_update`: (opcode, x, y, z, orientation, timestamp). +pub(crate) type MoveRecord = (u32, f32, f32, f32, f32, u32); + +#[derive(Default)] +pub(crate) struct SessionState { + /// WORLDPORT_ACK gate: true = entity present -> a spurious ack is ignored; + /// false (derive-Default) = absent -> a genuine transfer is pending. + pub(crate) entity_in_world: bool, + /// An in-session controllable cache answer for movement desync regression tests. + pub(crate) entity_presence: Option>, + pub(crate) entity_presence_checks: std::sync::atomic::AtomicUsize, + pub(crate) username: String, + pub(crate) session: Option, + pub(crate) login_entity: Option, + pub(crate) moves: std::sync::Mutex>, + pub(crate) client_commands: std::sync::Mutex>, + /// 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. + pub(crate) turn_in_reward_item: Option, + pub(crate) turn_in_tx: std::sync::Mutex>, + /// Override for `player_combat_until_ms`: 0 = out of combat (default), non-zero = in combat until + /// this ms-epoch deadline (use u64::MAX for "always in combat" in tests). + 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. + /// The world session must still close and relinquish its admission seat. + pub(crate) logout_error: Option, + /// 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). + pub(crate) pending_system_messages: Vec, + /// Recorded `player_login` call count — the WORLDPORT_ACK test distinguishes the + /// initial `CMSG_PLAYER_LOGIN` call from a world-port RE-entry call, since both dispatch through + /// this one trait method (`enter_world` is shared by both call sites). + pub(crate) login_calls: std::sync::atomic::AtomicU32, + /// When set, every `player_login` call AFTER the first returns THIS entity instead of + /// `login_entity` — simulates the character row having moved to a new map (`teleport_player`'s + /// durable write) between the initial login and the `MSG_MOVE_WORLDPORT_ACK`. `None` (default): + /// every call keeps returning `login_entity`, byte-identical to before this field existed. + pub(crate) worldport_entity: Option, + /// When set, every `player_login` AFTER the first FAILS with this message — the world entry + /// behind a world-port that routed fine (the stranding guard, a refused re-login on the + /// destination shard). The client is mid-loading-screen for it either way. + pub(crate) worldport_login_error: Option, + /// Recorded `subscribe_player_events` calls: (self_guid, login_map, login_x, login_y) — the + /// WORLDPORT_ACK test asserts this fires AGAIN (a fresh `created` dedup set) at the new + /// map/position rather than reusing the old subscription. + pub(crate) subscribed: std::sync::Mutex>, + /// The egress DEPTH counter of the live session `subscribe_player_events` was handed — so a test + /// can read the real queue depth of a real `run_world_session` (the writer thread's decrement has + /// no other reachable seam: it lives inside the spawned writer loop). Deliberately the depth + /// `Arc` and NOT the `SessionTx` itself: holding a sender clone here would keep the writer's + /// `rx.recv()` alive forever and hang every `enter_world` test's `server.join()`. + pub(crate) session_depth: + std::sync::Mutex>>, + /// Guids with a LIVE entity on this shard — the per-guid `entity_in_world` answer a + /// 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, + /// 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 + /// the world-entry party frame. + pub(crate) member_stats_before_party_frame: Option, +} + +impl SessionStore for WorldFake { + fn pending_system_messages(&self, _self_guid: u64) -> Vec { + self.session.pending_system_messages.clone() + } + + fn lookup_session(&self, account_name: &str) -> Result> { + Ok((account_name == self.session.username) + .then(|| self.session.session.clone()) + .flatten()) + } + + fn player_login( + &self, + _account_id: u64, + _character_guid: u64, + entry: codec::WorldEntry, + ) -> Result { + self.rec("player_login"); + self.session.login_entries.lock().unwrap().push(entry); + let call = self + .session + .login_calls + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if call > 0 { + if let Some(e) = &self.session.worldport_login_error { + return Err(anyhow!("{e}")); + } + if let Some(e) = self.session.worldport_entity.clone() { + return Ok(e); + } + } + self.session + .login_entity + .clone() + .ok_or_else(|| anyhow!("no login entity configured")) + } + + fn movement_update( + &self, + _account_id: u64, + _self_guid: u64, + 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}")); + } + self.session.moves.lock().unwrap().push(( + opcode, + info.position.x, + info.position.y, + info.position.z, + info.orientation, + info.timestamp, + )); + Ok(()) + } + + fn subscribe_player_events( + &self, + _account_id: u64, + self_guid: u64, + arrival: &codec::EntityView, + tx: SessionTx, + ) -> Result { + self.rec("subscribe_player_events"); + self.session.subscribed.lock().unwrap().push(( + self_guid, + arrival.map_id, + arrival.x, + arrival.y, + )); + *self.session.session_depth.lock().unwrap() = Some(tx.depth_handle()); + if self.session.turn_in_reward_item.is_some() { + *self.session.turn_in_tx.lock().unwrap() = Some(tx.clone()); + } + let Some(view) = &self.session.relay_view else { + return Ok(PlayerSubscriptions::empty()); + }; + let subs = PlayerSubscriptions::registered_for_test(view.clone(), self_guid, arrival, tx); + if let (Some(mate), Some(record)) = ( + self.session.member_stats_before_party_frame, + subs.member_stats_record(), + ) { + let delivered = codec::MemberStats::default(); + record + .lock() + .insert(mate, MemberSnapshot::Live(Box::new(delivered))); + } + Ok(subs) + } + + fn client_command( + &self, + account_id: u64, + self_guid: u64, + cmd: String, + payload: String, + ) -> Result<()> { + self.session + .client_commands + .lock() + .unwrap() + .push((account_id, self_guid, cmd, payload)); + Ok(()) + } + + fn entity_in_world(&self, guid: u64) -> bool { + self.session + .entity_presence_checks + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if let Some(present) = &self.session.entity_presence { + return present.load(std::sync::atomic::Ordering::SeqCst); + } + // `live_guids` is the per-guid answer the realm-wide party frame needs ("is this member + // live on THIS shard"). Empty by default, so the single flag above is still the answer + // every test written before realm-wide party routing set. + self.session.entity_in_world || self.session.live_guids.contains(&guid) + } + + fn entity_max_health(&self, _guid: u64) -> u32 { + 100 + } + + fn claim_session(&self, account_id: u64, _character_guid: u64) -> Result { + Ok(WorldSessionToken { + account_id, + generation: 1, + request_nonce: 1, + }) + } + + fn bind_session( + &self, + _token: WorldSessionToken, + ) -> Result>> { + Ok(None) + } + + fn release_session(&self, _token: WorldSessionToken) -> Result<()> { + self.rec("logout"); + self.session + .logout_called + .store(true, std::sync::atomic::Ordering::SeqCst); + match &self.session.logout_error { + Some(e) => Err(anyhow!("{e}")), + None => Ok(()), + } + } + + fn player_combat_until_ms(&self, _player_guid: u64) -> u64 { + self.session.combat_until_ms + } +} diff --git a/gateway/src/world/test_support/world_fake/social.rs b/gateway/src/world/test_support/world_fake/social.rs new file mode 100644 index 000000000..d034c8bc4 --- /dev/null +++ b/gateway/src/world/test_support/world_fake/social.rs @@ -0,0 +1,283 @@ +use super::super::*; + +#[derive(Default)] +pub(crate) struct SocialState { + /// Friend/ignore rows: `(owner_guid, target_guid, is_ignore)`. `add_friend`/ + /// `add_ignore`/`del_friend`/`del_ignore` mutate it; `contact_lists` reads it scoped to the caller. + pub(crate) contacts: std::sync::Mutex>, + /// This Shard's stored Auto-Replies, by Character guid. + pub(crate) auto_replies: std::sync::Mutex>, + /// Seeded characters that are nevertheless OFFLINE, so the invite gate's "player not + /// online" arm can be driven. Empty = every seeded character is online, as before. + pub(crate) offline_guids: Vec, + /// Raw `PLAYER_FLAGS` per guid, for `presence_row`'s Away Status. Empty = every guid reads + /// `AwayStatus::None`, as a Character with no live entity does in production. + pub(crate) away_flags: std::collections::HashMap, + /// `game_area.name` per zone id, for `/who`'s search-string match. Empty = every zone name + /// reads "", the "unimported catalogue" case. + pub(crate) zone_names: std::collections::HashMap, + /// When set, `contact_lists` fails with this message on THIS shard — the + /// unreachable-database arm of the realm-wide ignore-list union. + pub(crate) contact_lists_error: Option, + /// Live `game_world_entity` rows on THIS shard, as the columns Member Stats read. + pub(crate) member_entities: std::sync::Mutex>, + /// Characters THIS shard shows between two places: an online Session with no entity, or a + /// bot named by a Transfer Intent. + pub(crate) members_between_places: std::sync::Mutex>, +} + +/// [`faked_party`] for the friends and ignore lists. +pub(crate) fn faked_contact(error: &str) -> Result { + match lyracore_shared::social::ContactRefusal::parse_tag(error) { + Some(refusal) => Ok(refusal.into()), + None => Err(anyhow!("{error}")), + } +} + +impl SocialStore for WorldFake { + fn character_identity(&self, guid: u64) -> Result> { + Ok(self.characters.iter().find(|c| c.guid == guid).map(|c| { + presence::CharacterIdentity { + guid: c.guid, + name: c.name.clone(), + race: c.race, + class: c.class, + level: c.level, + zone_id: c.zone_id, + // `offline_guids` drives the invite gate's "player not online" arm; a seeded + // character is session-online unless listed there, mirroring `character_presence`. + session_online: !self.social.offline_guids.contains(&guid), + } + })) + } + + fn live_entity(&self, guid: u64) -> Option { + // `member_entities` alone: Member Stats' own tests despawn a guid here while it stays in + // `live_guids` (a party-eligibility signal, not a Member Stats one) to pin the case where a + // group mate's entity is gone but the party frame's own bookkeeping has not caught up — + // falling back to `live_guids` or the blanket `entity_in_world` flag would read that guid + // live again and silently defeat the pin. `in_world_players`'s bulk /who scan has its own, + // separate fallback for a guid this fixture never gave a precise entity to. + self.social + .member_entities + .lock() + .unwrap() + .iter() + .find(|(g, _)| *g == guid) + .map(|(_, e)| e.clone()) + } + + fn character_in_transit(&self, guid: u64) -> bool { + self.social + .members_between_places + .lock() + .unwrap() + .contains(&guid) + } + + fn auto_reply_text(&self, guid: u64) -> Result> { + Ok(self.social.auto_replies.lock().unwrap().get(&guid).cloned()) + } + + fn every_shard_vouches_for_absence(&self) -> Result<()> { + // A Realm Presence "gone" claim spans every configured Shard, not just this handle — each + // Fake instance models one Shard's own connection, so the peer set is checked too, the + // same reach `Coordinator::world_shards_for_absence` has from any one of its own handles. + for peer in self.topology.peers.lock().unwrap().iter() { + if let Some(error) = &peer.topology.world_shard_set_error { + return Err(anyhow!(error.clone())); + } + } + if let Some(error) = &self.topology.world_shard_set_error { + return Err(anyhow!(error.clone())); + } + Ok(()) + } + + fn in_world_players(&self) -> Result> { + // Test store: every seeded character the fake considers in-world (`entity_in_world`, + // the BLANKET flag included) is listed, so CMSG_WHO tests can assert a response without + // wiring `live_guids` by hand — unlike `live_entity`, which `presence::of` uses for one + // guid at a time and which deliberately does not trust that blanket flag. + // `live_entity` supplies level/zone from `member_entities`/`live_guids` when a test seeded + // one for this guid, else the durable row stands in. + Ok(self + .characters + .iter() + .filter(|c| self.entity_in_world(c.guid)) + .map(|c| { + let entity = self.live_entity(c.guid).unwrap_or(codec::MemberEntity { + level: u32::from(c.level), + zone_id: c.zone_id, + player_flags: self.social.away_flags.get(&c.guid).copied().unwrap_or(0), + ..Default::default() + }); + presence::RealmPresence { + guid: c.guid, + name: c.name.clone(), + race: c.race, + class: c.class, + level: u8::try_from(entity.level).unwrap_or(u8::MAX), + zone_id: entity.zone_id, + session_online: !self.social.offline_guids.contains(&c.guid), + whereabouts: presence::Whereabouts::InWorld { + away: self.away(c.guid), + entity, + shard_name: self.topology.shard.clone(), + }, + } + }) + .collect()) + } + + fn zone_name(&self, zone_id: u32) -> String { + self.social + .zone_names + .get(&zone_id) + .cloned() + .unwrap_or_default() + } + + fn contact_lists(&self, self_guid: u64) -> Result<(Vec, Vec)> { + if let Some(e) = &self.social.contact_lists_error { + return Err(anyhow!("{e}")); + } + let contacts = self.social.contacts.lock().unwrap(); + let mut friends = Vec::new(); + let mut ignored = Vec::new(); + for &(owner, target, is_ignore) in contacts.iter() { + if owner != self_guid { + continue; + } + if is_ignore { + ignored.push(target); + } else { + friends.push(target); + } + } + Ok((friends, ignored)) + } + + fn ignored_guids(&self, owner_guid: u64) -> Result> { + if let Some(e) = &self.social.contact_lists_error { + return Err(anyhow!("{e}")); + } + Ok(self + .social + .contacts + .lock() + .unwrap() + .iter() + .filter(|&&(owner, _, is_ignore)| owner == owner_guid && is_ignore) + .map(|&(_, target, _)| target) + .collect()) + } + + fn character_guid_by_name(&self, name: &str) -> Result> { + Ok(self + .characters + .iter() + .find(|c| c.name.eq_ignore_ascii_case(name)) + .map(|c| c.guid)) + } + + fn add_friend( + &self, + _account_id: u64, + _self_guid: u64, + target_guid: u64, + _target_race: u8, + ) -> Result { + if let Some(e) = &self.trade_error { + return faked_contact(e); + } + let owner = self + .session + .login_entity + .as_ref() + .map(|e| e.guid) + .unwrap_or(0); + self.social + .contacts + .lock() + .unwrap() + .push((owner, target_guid, false)); + Ok(ContactOutcome::Done) + } + + fn del_friend( + &self, + _account_id: u64, + _self_guid: u64, + target_guid: u64, + ) -> Result { + self.remove_contact(target_guid, false) + } + + fn add_ignore( + &self, + _account_id: u64, + _self_guid: u64, + target_guid: u64, + ) -> Result { + if let Some(e) = &self.trade_error { + return faked_contact(e); + } + let owner = self + .session + .login_entity + .as_ref() + .map(|e| e.guid) + .unwrap_or(0); + self.social + .contacts + .lock() + .unwrap() + .push((owner, target_guid, true)); + Ok(ContactOutcome::Done) + } + + fn del_ignore( + &self, + _account_id: u64, + _self_guid: u64, + target_guid: u64, + ) -> Result { + self.remove_contact(target_guid, true) + } +} + +impl WorldFake { + /// Drop one contact row, or refuse when the owner does not hold it. + pub(crate) fn remove_contact( + &self, + target_guid: u64, + is_ignore: bool, + ) -> Result { + let owner = self + .session + .login_entity + .as_ref() + .map(|e| e.guid) + .unwrap_or(0); + let mut contacts = self.social.contacts.lock().unwrap(); + let before = contacts.len(); + contacts.retain(|&(o, t, ig)| !(o == owner && t == target_guid && ig == is_ignore)); + if contacts.len() == before { + return Ok(lyracore_shared::social::ContactRefusal::NotOnList.into()); + } + Ok(ContactOutcome::Done) + } + + /// `guid`'s Away Status from `away_flags` (raw `PLAYER_FLAGS`), `AwayStatus::None` when unset — + /// mirroring "a Character with no live entity has `AwayStatus::None`" for every guid a test + /// never seeds. + pub(crate) fn away(&self, guid: u64) -> presence::AwayStatus { + self.social + .away_flags + .get(&guid) + .copied() + .map(presence::away_from_player_flags) + .unwrap_or(presence::AwayStatus::None) + } +} diff --git a/gateway/src/world/test_support/world_fake/speech.rs b/gateway/src/world/test_support/world_fake/speech.rs new file mode 100644 index 000000000..7d0444a67 --- /dev/null +++ b/gateway/src/world/test_support/world_fake/speech.rs @@ -0,0 +1,92 @@ +use super::super::*; + +#[derive(Default)] +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, + /// 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. + pub(crate) gm_commands: std::sync::Mutex>, + /// Current Realm-core Alpha Test Tools answer for the next command. `None` leaves the older + /// fixed-outcome fixture in place; tests that set it model the production Store's fresh read. + pub(crate) gm_alpha_test_tools: Option>, + /// The authority conveyed with each command when `gm_alpha_test_tools` is in use. + pub(crate) gm_authority_results: std::sync::Mutex>, + /// Home Shard gameplay accepted by the focused Alpha Test Tools Fake. The command parser and + /// effects belong to Module tests, so this only records the visible Store outcome. + pub(crate) gm_gameplay_changes: std::sync::Mutex>, + /// Recorded `send_chat` lines: (chat_type, language, message). + pub(crate) chats: std::sync::Mutex>, +} + +impl SpeechStore for WorldFake { + fn send_chat( + &self, + _account_id: u64, + _self_guid: u64, + chat_type: u8, + language: u8, + message: String, + ) -> Result { + // 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"); + self.speech + .chats + .lock() + .unwrap() + .push((chat_type, language, message)); + Ok(self + .speech + .send_chat_outcome + .unwrap_or(ChatOutcome::Delivered)) + } + + fn send_emote( + &self, + _account_id: u64, + _self_guid: u64, + _text_emote: u32, + _emote_anim: u32, + _target_guid: u64, + ) -> Result<()> { + self.rec("send_emote"); + Ok(()) + } + + fn gm_command(&self, account_name: &str, _self_guid: u64, 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 + .gm_commands + .lock() + .unwrap() + .push((account_name.to_string(), text.clone())); + self.speech + .gm_authority_results + .lock() + .unwrap() + .push(authorized); + if authorized && (text.starts_with(".speed") || text.starts_with(".tele")) { + self.speech.gm_gameplay_changes.lock().unwrap().push(text); + return Ok(()); + } + return Err(anyhow!("permission denied")); + } + match &self.speech.gm_command_error { + Some(e) => Err(anyhow!("{e}")), + None => { + self.speech + .gm_commands + .lock() + .unwrap() + .push((account_name.to_string(), text)); + Ok(()) + } + } + } +} diff --git a/gateway/src/world/test_support/world_fake/taxi.rs b/gateway/src/world/test_support/world_fake/taxi.rs new file mode 100644 index 000000000..d86038f08 --- /dev/null +++ b/gateway/src/world/test_support/world_fake/taxi.rs @@ -0,0 +1,45 @@ +use super::super::*; + +#[derive(Default)] +pub(crate) struct TaxiState { + /// The taxi map the open operation returns, for the TAXI gossip socket tests. + pub(crate) taxi_map: Option, + pub(crate) taxi_error: Option, +} + +/// Taxi behavior is tested through `InMemoryTaxiActions`. This adapter serves only the TAXI gossip +/// socket tests: an open that returns the configured map, or the configured failure. +impl TaxiActionStore for WorldFake { + fn taxi_node_status( + &self, + _character_guid: u64, + _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}")); + } + Ok(self.taxi.taxi_map.clone()) + } + + fn activate_taxi( + &self, + _character_guid: u64, + _npc_guid: u64, + _source_client_node_id: u32, + _destination_client_node_id: u32, + ) -> Result { + Ok(codec::TaxiActivationResult::default()) + } + + fn arm_taxi_flight(&self, _character_guid: u64) -> Result<()> { + Ok(()) + } +} diff --git a/gateway/src/world/test_support/world_fake/trade.rs b/gateway/src/world/test_support/world_fake/trade.rs new file mode 100644 index 000000000..5424bc18c --- /dev/null +++ b/gateway/src/world/test_support/world_fake/trade.rs @@ -0,0 +1,49 @@ +use super::super::*; + +impl TradeStore for WorldFake { + fn initiate_trade(&self, _account_id: u64, _self_guid: u64, _target_guid: u64) -> Result<()> { + Ok(()) + } + + fn begin_trade(&self, _account_id: u64, _self_guid: u64) -> Result<()> { + Ok(()) + } + + fn cancel_trade(&self, _account_id: u64, _self_guid: u64) -> Result<()> { + Ok(()) + } + + fn set_trade_item( + &self, + _account_id: u64, + _self_guid: u64, + _trade_slot: u8, + _inv_slot: u8, + ) -> Result<()> { + Ok(()) + } + + fn clear_trade_item(&self, _account_id: u64, _self_guid: u64, _trade_slot: u8) -> Result<()> { + Ok(()) + } + + fn set_trade_gold(&self, _account_id: u64, _self_guid: u64, _copper: u32) -> Result<()> { + Ok(()) + } + + fn accept_trade(&self, _account_id: u64, _self_guid: u64) -> Result<()> { + Ok(()) + } + + fn unaccept_trade(&self, _account_id: u64, _self_guid: u64) -> Result<()> { + Ok(()) + } + + fn busy_trade(&self, _account_id: u64, _self_guid: u64) -> Result<()> { + Ok(()) + } + + fn ignore_trade(&self, _account_id: u64, _self_guid: u64) -> Result<()> { + Ok(()) + } +} diff --git a/gateway/src/world/test_support/world_fake/trainer.rs b/gateway/src/world/test_support/world_fake/trainer.rs new file mode 100644 index 000000000..ad6992ba1 --- /dev/null +++ b/gateway/src/world/test_support/world_fake/trainer.rs @@ -0,0 +1,110 @@ +use super::super::*; + +#[derive(Default)] +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 + /// gossip select. + pub(crate) reset_talents_calls: std::sync::Mutex>, +} + +impl TrainerStore for WorldFake { + fn trainer_serves(&self, _player_guid: u64, _trainer_guid: u64) -> Result { + Ok(!self.trainer.trainer_refuses_class) // default true — every existing fixture trainer serves + } + + fn trainer_list( + &self, + _player_guid: u64, + _trainer_guid: u64, + ) -> Result> { + Ok(Vec::new()) + } + + fn buy_trainer_spell( + &self, + _account_id: u64, + _self_guid: u64, + _trainer_guid: u64, + _spell_id: u32, + ) -> Result { + Ok(crate::world::TrainerBuyOutcome::Learned) + } + + fn talent_grant_spell(&self, _talent_id: u32) -> u32 { + 0 + } + + fn set_faction_at_war( + &self, + _account_id: u64, + _self_guid: u64, + _reputation_index: u32, + _at_war: bool, + ) -> Result<()> { + Ok(()) + } + + fn set_action_button( + &self, + _account_id: u64, + _self_guid: u64, + _button: u8, + _action: u32, + _action_type: u8, + ) -> Result<()> { + Ok(()) + } + + fn talent_pane_sync(&self, _character_guid: u64, _talent_id: u32) -> (u32, u32, u32) { + (0, 0, 0) + } + + fn talent_points_spent(&self, _character_guid: u64) -> u32 { + 0 // login stays byte-identical in every existing harness test + } + + fn learn_talent(&self, _account_id: u64, _self_guid: u64, _talent_id: u32) -> Result<()> { + Ok(()) + } + + fn reset_talents(&self, account_id: u64, self_guid: u64, trainer_guid: u64) -> Result<()> { + self.trainer.reset_talents_calls.lock().unwrap().push(( + account_id, + self_guid, + trainer_guid, + )); + Ok(()) + } + + fn resolve_learn_target(&self, spell_id: u32) -> u32 { + spell_id // self-contained ranks (no wrapper table in the Fake) + } + + fn trainer_offer_skill_line(&self, _trainer_guid: u64, _spell_id: u32) -> u32 { + 0 // every offering reads as an ordinary spell purchase + } + + fn superseded_old_rank(&self, _new_spell: u32, _player_guid: u64) -> Option { + None + } + + fn character_presence(&self, guid: u64) -> Result> { + Ok(self + .characters + .iter() + .find(|c| c.guid == guid) + // `offline_guids` drives the invite gate's "player not online" arm. Empty by + // default, so a seeded character is online exactly as it always was. + .map(|c| { + ( + !self.social.offline_guids.contains(&guid), + c.level, + c.class, + c.zone_id, + ) + })) + } +} diff --git a/gateway/src/world/test_support/world_fake/transfer.rs b/gateway/src/world/test_support/world_fake/transfer.rs new file mode 100644 index 000000000..311146fdf --- /dev/null +++ b/gateway/src/world/test_support/world_fake/transfer.rs @@ -0,0 +1,818 @@ +use super::super::*; + +#[derive(Default)] +pub(crate) struct TransferState { + /// The fake DATABASE this handle talks to, for the cross-database transfer tests. `None` + /// (the default) leaves every transfer trait method at its "this store does not shard" default, + /// so every other test in this file is untouched. + pub(crate) xdb: Option>, + /// How many `escrowed_transfer` reads answer `None` before the row shows up — the + /// cross-connection lag between a reducer's reply and the coordinator cache, made dialable. + pub(crate) escrow_reads_before_visible: std::sync::atomic::AtomicUsize, + /// The transfer step to fail at, simulating a gateway killed before that step's + /// transaction committed. `None` = nothing fails. + pub(crate) kill_at: Option, + /// The REALM-CORE character→shard index this handle's `publish_shard_index` writes. In + /// production that write goes to a third database (`realm_core()`); here it is just a map, so a + /// test can assert the drive published the destination it settled on. + pub(crate) realm_index: std::sync::Mutex>, + /// Realm-core's ordered transfer phase for cross-Shard caller tests. + pub(crate) realm_partition: + std::sync::Mutex>, + /// When set, `publish_shard_index` fails with this message — an unreachable realm-core. + pub(crate) publish_error: Option, +} + +impl TransferStore for WorldFake { + fn escrowed_transfer( + &self, + character_guid: u64, + ) -> Option { + let db = self.transfer.xdb.as_ref()?; + if self + .transfer + .escrow_reads_before_visible + .fetch_update( + std::sync::atomic::Ordering::SeqCst, + std::sync::atomic::Ordering::SeqCst, + |left| left.checked_sub(1), + ) + .is_ok() + { + return None; + } + let out = lk(&db.out_rows); + out.values() + .find(|e| e.character_guid == character_guid) + .map(|e| crate::world::transfer::EscrowedTransfer { + transfer_id: e.transfer_id, + character_guid: e.character_guid, + dest_map_id: e.dest_map_id, + dest_instance_id: e.dest_instance_id, + blob: e.blob.clone(), + }) + } + + fn character_destination( + &self, + character_guid: u64, + ) -> Option { + let c = self.transfer.xdb.as_ref()?.get(character_guid)?; + Some(crate::world::transfer::TransferPlan { + transfer_id: crate::world::transfer::transfer_id_for(character_guid), + character_guid, + dest_map_id: c.map_id, + dest_instance_id: c.instance_id, + dest_x: 0.0, + dest_y: 0.0, + dest_z: 0.0, + dest_o: 0.0, + }) + } + + /// `plan_begin`: replay on a matching escrow, refuse without a source copy, refuse a second + /// escrow for the same character, otherwise freeze + serialize. + /// + /// The `escrowed_guid` lookup is the OUT-row with the IN-row as a fallback, exactly as + /// `module/src/transfer/mod.rs`'s `begin_transfer` computes it — and that fallback is load-bearing + /// 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<()> { + let db = self.xstep("begin_transfer")?; + let mut out = lk(&db.out_rows); + let escrowed_guid = out + .get(&plan.transfer_id) + .map(|e| e.character_guid) + .or_else(|| lk(&db.in_rows).get(&plan.transfer_id).copied()); + if let Some(existing) = escrowed_guid { + if existing != plan.character_guid { + return Err(anyhow!("transfer id collision")); + } + return Ok(()); // BeginPlan::Replay + } + let Some(c) = db.get(plan.character_guid) else { + return Err(anyhow!("no such character: {}", plan.character_guid)); + }; + if out + .values() + .any(|e| e.character_guid == plan.character_guid) + { + return Err(anyhow!( + "character {} is already in transit", + plan.character_guid + )); + } + out.insert( + plan.transfer_id, + FakeEscrow { + transfer_id: plan.transfer_id, + character_guid: plan.character_guid, + dest_map_id: plan.dest_map_id, + dest_instance_id: plan.dest_instance_id, + blob: fake_blob( + plan.character_guid, + plan.dest_map_id, + plan.dest_instance_id, + &c.payload, + ), + }, + ); + Ok(()) + } + + /// `import_character_blob`: replay on the in-row PK, refuse to land on a LIVE character, + /// otherwise materialise the row + its payload at the escrow's destination. + fn import_character_blob( + &self, + transfer_id: u64, + blob: &[u8], + source: crate::world::transfer::RealmLocatorPredecessor, + bot_arrival: Option<&crate::world::transfer::BotTransferIntent>, + ) -> Result<()> { + let db = self.xstep("import_character_blob")?; + let (guid, arriving) = parse_blob(blob); + if let Some(intent) = bot_arrival { + if intent.source_module_identity == spacetimedb_sdk::Identity::ZERO + || intent.id == 0 + || intent.created_micros <= 0 + || source.revision == 0 + || transfer_id != guid + { + return Err(anyhow!("bot Transfer arrival identity is invalid")); + } + } + // Drop each `in_rows` guard before `db.live()`, which locks the same non-reentrant Mutex. + let replayed = lk(&db.in_rows).get(&transfer_id).copied(); + if let Some(existing) = replayed { + if existing != guid { + return Err(anyhow!( + "transfer id already imported for another character" + )); + } + if let Some(intent) = bot_arrival { + let expected = ( + intent.source_module_identity, + intent.id, + intent.controller_generation, + intent.created_micros, + ); + if lk(&db.bot_arrivals).get(&transfer_id) != Some(&expected) { + return Err(anyhow!("destination arrival belongs to another crossing")); + } + } + if lk(&db.arrival_sources).get(&transfer_id) + != Some(&(source.map_id, source.instance_id, source.revision)) + { + return Err(anyhow!( + "destination arrival belongs to another Realm crossing" + )); + } + return Ok(()); + } + if db.live(guid) { + return Err(anyhow!("character {guid} is already live on this shard")); + } + // The destination rides IN the blob, exactly as the real `ExportBlob`'s `dest_*` fields do: + // cross-database the blob is the only thing that reaches this side. + lk(&db.characters).insert(guid, arriving); + lk(&db.in_rows).insert(transfer_id, guid); + lk(&db.arrival_sources).insert( + transfer_id, + (source.map_id, source.instance_id, source.revision), + ); + if let Some(intent) = bot_arrival { + lk(&db.bot_arrivals).insert( + transfer_id, + ( + intent.source_module_identity, + intent.id, + intent.controller_generation, + intent.created_micros, + ), + ); + } + Ok(()) + } + + /// `confirm_import`: the SOURCE-side attestation. Refuses without a local escrow. + fn confirm_import(&self, transfer_id: u64) -> Result<()> { + let db = self.xstep("confirm_import")?; + let out = lk(&db.out_rows); + let Some(escrow) = out.get(&transfer_id) else { + return Err(anyhow!( + "transfer {transfer_id}: nothing escrowed here to confirm" + )); + }; + lk(&db.in_rows).insert(transfer_id, escrow.character_guid); + Ok(()) + } + + /// `plan_finish`: refuses while the in-row (the attestation) is absent — the guard that makes + /// "zero durable copies" unreachable — then cascade-deletes the source copy, escrow last. + fn finish_transfer(&self, transfer_id: u64) -> Result<()> { + let db = self.xstep("finish_transfer")?; + let mut out = lk(&db.out_rows); + let Some(escrow) = out.get(&transfer_id).cloned() else { + return Ok(()); // FinishPlan::AlreadyDone + }; + if !lk(&db.in_rows).contains_key(&transfer_id) { + return Err(anyhow!( + "transfer {transfer_id}: not imported — refusing to release" + )); + } + lk(&db.characters).remove(&escrow.character_guid); + lk(&db.in_rows).remove(&transfer_id); + out.remove(&transfer_id); + Ok(()) + } + + /// `release_transfer`: refuses on a shard that is the SOURCE; replay-safe otherwise. + fn release_transfer(&self, transfer_id: u64) -> Result<()> { + let Some(_) = self.transfer.xdb.as_ref() else { + return Ok(()); + }; + let db = self.xstep("release_transfer")?; + if lk(&db.out_rows).contains_key(&transfer_id) { + return Err(anyhow!( + "transfer {transfer_id}: this database holds the SOURCE out-row" + )); + } + if lk(&db.bot_arrivals).contains_key(&transfer_id) { + return Err(anyhow!( + "transfer {transfer_id}: session-less arrival is owned by its Transfer Intent" + )); + } + lk(&db.in_rows).remove(&transfer_id); + lk(&db.arrival_sources).remove(&transfer_id); + Ok(()) + } + + fn release_player_transfer_arrival( + &self, + transfer_id: u64, + character_guid: u64, + source: crate::world::transfer::RealmLocatorPredecessor, + ) -> Result<()> { + let Some(db) = self.transfer.xdb.as_ref() else { + return Ok(()); + }; + self.xstep("release_transfer")?; + if transfer_id != character_guid || source.revision == 0 { + return Err(anyhow!("player Transfer arrival identity is invalid")); + } + if lk(&db.in_rows).get(&transfer_id) != Some(&character_guid) { + return Ok(()); + } + if lk(&db.bot_arrivals).contains_key(&transfer_id) + || lk(&db.arrival_sources).get(&transfer_id) + != Some(&(source.map_id, source.instance_id, source.revision)) + { + return Ok(()); + } + lk(&db.in_rows).remove(&transfer_id); + lk(&db.arrival_sources).remove(&transfer_id); + Ok(()) + } + + fn transfer_arrival( + &self, + transfer_id: u64, + ) -> Option { + let db = self.transfer.xdb.as_ref()?; + let character_guid = *lk(&db.in_rows).get(&transfer_id)?; + let (source, intent_id, generation, _) = lk(&db.bot_arrivals) + .get(&transfer_id) + .copied() + .unwrap_or((spacetimedb_sdk::Identity::ZERO, 0, 0, 0)); + let (source_map, source_instance, source_locator_revision) = lk(&db.arrival_sources) + .get(&transfer_id) + .copied() + .unwrap_or((0, 0, 0)); + Some(crate::world::transfer::TransferArrival { + character_guid, + source_map, + source_instance, + source_locator_revision, + bot_source_identity: source, + bot_transfer_intent_id: intent_id, + bot_controller_generation: generation, + }) + } + + fn begin_shard_index_transfer( + &self, + plan: &crate::world::transfer::TransferPlan, + bot_intent: Option<(&crate::world::transfer::BotTransferIntent, u64)>, + ) -> Result { + let mut phase = self.transfer.realm_partition.lock().unwrap(); + let Some(current) = *phase else { + return Ok(crate::world::party::RealmCharacterPartition { + map_id: 0, + instance_id: 0, + revision: bot_intent.map_or(1, |(intent, _)| intent.source_locator_revision.max(1)), + transfer_pending: true, + pending_destination_map: plan.dest_map_id, + pending_destination_instance: plan.dest_instance_id, + bot_source_identity: bot_intent + .map_or(spacetimedb_sdk::Identity::ZERO, |(intent, _)| { + intent.source_module_identity + }), + bot_transfer_intent_id: bot_intent.map_or(0, |(intent, _)| intent.id), + bot_controller_generation: bot_intent + .map_or(0, |(intent, _)| intent.controller_generation), + }); + }; + let (source_revision, crossing) = bot_intent.map_or( + (current.revision, (spacetimedb_sdk::Identity::ZERO, 0, 0)), + |(intent, _)| { + ( + intent.source_locator_revision, + ( + intent.source_module_identity, + intent.id, + intent.controller_generation, + ), + ) + }, + ); + if !current.transfer_pending + && (current.map_id, current.instance_id, current.revision) + == ( + plan.dest_map_id, + plan.dest_instance_id, + source_revision.saturating_add(1), + ) + && ( + current.bot_source_identity, + current.bot_transfer_intent_id, + current.bot_controller_generation, + ) == crossing + { + return Ok(current); + } + if current.transfer_pending + && current.revision == source_revision + && bot_intent.is_none_or(|(intent, _)| { + (current.map_id, current.instance_id) == (intent.source_map, intent.source_instance) + }) + && ( + current.pending_destination_map, + current.pending_destination_instance, + ) == (plan.dest_map_id, plan.dest_instance_id) + && ( + current.bot_source_identity, + current.bot_transfer_intent_id, + current.bot_controller_generation, + ) == crossing + { + return Ok(current); + } + if current.transfer_pending || current.revision != source_revision { + return Err(anyhow!("Transfer Realm locator changed")); + } + let pending = crate::world::party::RealmCharacterPartition { + transfer_pending: true, + pending_destination_map: plan.dest_map_id, + pending_destination_instance: plan.dest_instance_id, + bot_source_identity: crossing.0, + bot_transfer_intent_id: crossing.1, + bot_controller_generation: crossing.2, + ..current + }; + *phase = Some(pending); + Ok(pending) + } + + fn finish_player_shard_index_transfer( + &self, + plan: &crate::world::transfer::TransferPlan, + _source_map: u32, + _source_instance: u64, + source_revision: u64, + ) -> Result<()> { + let mut phase = self.transfer.realm_partition.lock().unwrap(); + let Some(current) = *phase else { + drop(phase); + return self.publish_shard_index( + plan.character_guid, + plan.dest_map_id, + plan.dest_instance_id, + ); + }; + if !current.transfer_pending || current.revision != source_revision { + return Err(anyhow!("Transfer Realm locator phase changed")); + } + *phase = Some(crate::world::party::RealmCharacterPartition { + map_id: plan.dest_map_id, + instance_id: plan.dest_instance_id, + revision: source_revision + 1, + transfer_pending: false, + pending_destination_map: 0, + pending_destination_instance: 0, + ..current + }); + Ok(()) + } + + fn bind_bot_transfer_locator( + &self, + _intent: &crate::world::transfer::BotTransferIntent, + source_revision: u64, + _claim_token: u64, + ) -> Result<()> { + self.rec("bind_bot_transfer_locator"); + let Some(current) = *self.transfer.realm_partition.lock().unwrap() else { + return Ok(()); + }; + if current.transfer_pending || current.revision != source_revision { + return Err(anyhow!("Transfer Intent Realm locator changed")); + } + Ok(()) + } + + fn publish_bot_shard_index( + &self, + intent: &crate::world::transfer::BotTransferIntent, + ) -> Result<()> { + self.rec("publish_bot_shard_index"); + let mut phase = self.transfer.realm_partition.lock().unwrap(); + let Some(current) = *phase else { + drop(phase); + return self.publish_shard_index( + intent.bot_guid, + intent.destination_map, + intent.destination_instance, + ); + }; + let settled_revision = intent + .source_locator_revision + .checked_add(1) + .ok_or_else(|| anyhow!("Transfer Realm locator revision exhausted"))?; + let crossing = ( + intent.source_module_identity, + intent.id, + intent.controller_generation, + ); + if !current.transfer_pending + && (current.map_id, current.instance_id, current.revision) + == ( + intent.destination_map, + intent.destination_instance, + settled_revision, + ) + && ( + current.bot_source_identity, + current.bot_transfer_intent_id, + current.bot_controller_generation, + ) == crossing + { + return Ok(()); + } + if !current.transfer_pending + || current.revision != intent.source_locator_revision + || ( + current.pending_destination_map, + current.pending_destination_instance, + ) != (intent.destination_map, intent.destination_instance) + || ( + current.bot_source_identity, + current.bot_transfer_intent_id, + current.bot_controller_generation, + ) != crossing + { + return Err(anyhow!("Transfer Realm locator phase changed")); + } + *phase = Some(crate::world::party::RealmCharacterPartition { + map_id: intent.destination_map, + instance_id: intent.destination_instance, + revision: settled_revision, + transfer_pending: false, + pending_destination_map: 0, + pending_destination_instance: 0, + ..current + }); + Ok(()) + } + + fn finish_pending_shard_index_transfer( + &self, + character_guid: u64, + destination_map: u32, + destination_instance: u64, + arrival: &crate::world::transfer::TransferArrival, + ) -> Result<()> { + if arrival.character_guid != character_guid { + return Err(anyhow!("arrival fence names another Character")); + } + let mut phase = self.transfer.realm_partition.lock().unwrap(); + let Some(current) = *phase else { + return Ok(()); + }; + if !current.transfer_pending + || (current.map_id, current.instance_id, current.revision) + != ( + arrival.source_map, + arrival.source_instance, + arrival.source_locator_revision, + ) + || ( + current.pending_destination_map, + current.pending_destination_instance, + ) != (destination_map, destination_instance) + || ( + current.bot_source_identity, + current.bot_transfer_intent_id, + current.bot_controller_generation, + ) != ( + arrival.bot_source_identity, + arrival.bot_transfer_intent_id, + arrival.bot_controller_generation, + ) + { + return Err(anyhow!("pending Realm Transfer phase changed")); + } + *phase = Some(crate::world::party::RealmCharacterPartition { + map_id: destination_map, + instance_id: destination_instance, + revision: arrival.source_locator_revision + 1, + transfer_pending: false, + pending_destination_map: 0, + pending_destination_instance: 0, + ..current + }); + Ok(()) + } + + fn sync_transfer_arrival(&self, character_guid: u64) -> Result<()> { + crate::world::party::sync_transfer_arrival_mirror(self, character_guid)?; + self.xstep("sync_transfer_arrival")?; + Ok(()) + } + + fn sync_transfer_pending(&self, character_guid: u64) -> Result<()> { + crate::world::party::sync_transfer_arrival_mirror(self, character_guid)?; + self.xstep("sync_transfer_pending")?; + Ok(()) + } + + fn mark_bot_transfer_arrival_ready( + &self, + _intent_id: u64, + _bot_guid: u64, + _controller_generation: u64, + _claim_token: u64, + ) -> Result<()> { + self.xstep("mark_bot_transfer_arrival_ready")?; + Ok(()) + } + + fn bot_transfer_arrival_matches( + &self, + transfer_id: u64, + intent: &crate::world::transfer::BotTransferIntent, + ) -> bool { + let Some(db) = self.transfer.xdb.as_ref() else { + return false; + }; + lk(&db.in_rows).get(&transfer_id) == Some(&intent.bot_guid) + && lk(&db.bot_arrivals).get(&transfer_id) + == Some(&( + intent.source_module_identity, + intent.id, + intent.controller_generation, + intent.created_micros, + )) + && lk(&db.arrival_sources).get(&transfer_id) + == Some(&( + intent.source_map, + intent.source_instance, + intent.source_locator_revision, + )) + } + + fn release_bot_transfer_arrival( + &self, + transfer_id: u64, + intent: &crate::world::transfer::BotTransferIntent, + ) -> Result<()> { + self.xstep("release_bot_transfer_arrival")?; + let Some(db) = self.transfer.xdb.as_ref() else { + return Ok(()); + }; + if transfer_id != intent.bot_guid + || intent.source_module_identity == spacetimedb_sdk::Identity::ZERO + || intent.id == 0 + || intent.created_micros <= 0 + { + return Err(anyhow!("bot Transfer arrival identity is invalid")); + } + let expected = ( + intent.source_module_identity, + intent.id, + intent.controller_generation, + intent.created_micros, + ); + if lk(&db.bot_arrivals).get(&transfer_id) == Some(&expected) { + if lk(&db.arrival_sources).get(&transfer_id) + != Some(&( + intent.source_map, + intent.source_instance, + intent.source_locator_revision, + )) + { + return Ok(()); + } + lk(&db.in_rows).remove(&transfer_id); + lk(&db.bot_arrivals).remove(&transfer_id); + lk(&db.arrival_sources).remove(&transfer_id); + } + Ok(()) + } + + fn instance_partition(&self, instance_id: u64) -> Option<(u32, u64)> { + self.transfer + .xdb + .as_ref() + .and_then(|db| lk(&db.instance_partitions).get(&instance_id).copied()) + } + + fn ensure_instance(&self, instance_id: u64, map_id: u32, party_id: u64) -> Result<()> { + let db = self.xstep("ensure_instance")?; + if instance_id == 0 { + return Err(anyhow!("instance 0 is the open world")); + } + let existing = lk(&db.instance_partitions).get(&instance_id).copied(); + match existing { + Some((existing_map, _)) if existing_map != map_id => { + return Err(anyhow!( + "instance {instance_id} belongs to map {existing_map}, not map {map_id}" + )); + } + Some((_, existing_party)) + if existing_party != party_id && !(existing_party == 0 && party_id != 0) => + { + return Err(anyhow!( + "instance {instance_id} belongs to party {existing_party}, not party {party_id}" + )); + } + _ => {} + } + lk(&db.instance_partitions).insert(instance_id, (map_id, party_id)); + // The module's own shape: a mirror of an instance that is ALREADY here joins it (early + // return) instead of spawning a second population. `HashSet::insert` reports that for free, + // and the count is what the second-party-member test asserts against. + if lk(&db.instances).insert(instance_id) { + lk(&db.populated).push(instance_id); + } + Ok(()) + } + + fn evict_instance_population(&self, instance_id: u64) -> Result<()> { + let db = self.xstep("evict_instance_population")?; + if instance_id == 0 { + return Err(anyhow!("instance 0 is the open world")); + } + lk(&db.evicted).push(instance_id); + Ok(()) + } +} + +// The cross-database transfer fixtures. `TransferStore` above runs on them through `xdb`, and +// `transfer_tests.rs` and the world-port tests build them directly. + +/// One character's durable state, reduced to what a transfer has to preserve: where it is, and a +/// PAYLOAD marker standing for the character-owned rows (gear/spells/skills/quest log). If the +/// payload does not arrive, the character arrived NAKED — the failure a manifest-only blob has. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct FakeChar { + pub(crate) map_id: u32, + pub(crate) instance_id: u64, + pub(crate) payload: String, +} + +#[derive(Clone, Debug)] +pub(crate) struct FakeEscrow { + pub(crate) transfer_id: u64, + pub(crate) character_guid: u64, + pub(crate) dest_map_id: u32, + pub(crate) dest_instance_id: u64, + pub(crate) blob: Vec, +} + +/// One SpacetimeDB database, as far as the transfer protocol can see it. +#[derive(Default)] +pub(crate) struct FakeShardDb { + pub(crate) characters: std::sync::Mutex>, + /// transfer_id → the source escrow (`game_transfer_out`). + pub(crate) out_rows: std::sync::Mutex>, + /// transfer_id → character guid (`game_transfer_in`): on the DESTINATION the arrival copy's + /// fence, on the SOURCE the gateway's `confirm_import` attestation. + pub(crate) in_rows: std::sync::Mutex>, + /// Exact bot Transfer identity attached to a destination fence. + pub(crate) bot_arrivals: std::sync::Mutex< + std::collections::HashMap, + >, + /// Realm locator predecessor attached to each destination fence. + pub(crate) arrival_sources: std::sync::Mutex>, + pub(crate) instances: std::sync::Mutex>, + pub(crate) instance_partitions: std::sync::Mutex>, + /// Every instance id this database actually SPAWNED a population for — one entry per + /// spawn, so "the second party member re-created the dungeon" is visible as a duplicate. + pub(crate) populated: std::sync::Mutex>, + pub(crate) evicted: std::sync::Mutex>, +} + +impl FakeShardDb { + pub(crate) fn with_character(guid: u64, c: FakeChar) -> std::sync::Arc { + let db = Self::default(); + if c.instance_id != 0 { + lk(&db.instances).insert(c.instance_id); + lk(&db.instance_partitions).insert(c.instance_id, (c.map_id, 0)); + } + lk(&db.characters).insert(guid, c); + std::sync::Arc::new(db) + } + pub(crate) fn empty() -> std::sync::Arc { + std::sync::Arc::new(Self::default()) + } + pub(crate) fn has(&self, guid: u64) -> bool { + lk(&self.characters).contains_key(&guid) + } + pub(crate) fn get(&self, guid: u64) -> Option { + lk(&self.characters).get(&guid).cloned() + } + /// A character is LIVE here iff it is durable AND neither escrow row fences it — the module's + /// `login_allowed` predicate, which is what `player_login` actually gates on. + pub(crate) fn live(&self, guid: u64) -> bool { + self.has(guid) + && !lk(&self.out_rows) + .values() + .any(|e| e.character_guid == guid) + && !lk(&self.in_rows).values().any(|g| *g == guid) + } + pub(crate) fn settled(&self) -> bool { + lk(&self.out_rows).is_empty() && lk(&self.in_rows).is_empty() + } +} + +/// The blob a fake `begin_transfer` produces. Carries the destination and the payload, exactly as +/// the real `ExportBlob` carries `dest_*` + `character_row` + `payload`: cross-database the blob is +/// the ONLY thing that reaches the far side. +pub(crate) fn fake_blob(guid: u64, dest_map: u32, dest_instance: u64, payload: &str) -> Vec { + format!("{guid}|{dest_map}|{dest_instance}|{payload}").into_bytes() +} + +pub(crate) fn parse_blob(blob: &[u8]) -> (u64, FakeChar) { + let s = String::from_utf8(blob.to_vec()).expect("fake blob is utf8"); + let parts: Vec<&str> = s.splitn(4, '|').collect(); + ( + parts[0].parse().expect("guid"), + FakeChar { + map_id: parts[1].parse().expect("map"), + instance_id: parts[2].parse().expect("instance"), + payload: parts[3].to_string(), + }, + ) +} + +impl WorldFake { + /// Record a transfer step and honour an injected kill. `Err` means "the gateway died + /// before this step's transaction committed", which is exactly a truncated drive. + pub(crate) fn xstep(&self, what: &str) -> Result<&std::sync::Arc> { + let db = self + .transfer + .xdb + .as_ref() + .ok_or_else(|| anyhow!("this store does not implement cross-database transfers"))?; + if self.transfer.kill_at.as_deref() == Some(what) { + return Err(anyhow!("gateway killed at {what}")); + } + self.rec(what); + Ok(db) + } + + /// The realm-core index publish. Recorded in the shared call log so its POSITION in the + /// drive is assertable, not just its effect. + pub(crate) fn publish_shard_index( + &self, + character_guid: u64, + map_id: u32, + instance_id: u64, + ) -> Result<()> { + if let Some(e) = &self.transfer.publish_error { + return Err(anyhow!("{e}")); + } + // Through `xstep`, like every other step of the drive — NOT a bare `rec`. Every other + // transfer method routes its "gateway killed here" injection through it, and this one + // originally did not, so `kill_at = "publish_shard_index"` was silently inert and the + // crash matrix reported a PASS for a boundary it never killed at. + self.xstep("publish_shard_index")?; + self.transfer + .realm_index + .lock() + .unwrap() + .push((character_guid, map_id, instance_id)); + Ok(()) + } +} diff --git a/gateway/src/world/test_support/world_fake/vendor.rs b/gateway/src/world/test_support/world_fake/vendor.rs new file mode 100644 index 000000000..11135f580 --- /dev/null +++ b/gateway/src/world/test_support/world_fake/vendor.rs @@ -0,0 +1,75 @@ +use super::super::*; + +#[derive(Default)] +pub(crate) struct VendorState { + /// Vendor stock the seam's `vendor_stock` read returns (empty by default). + pub(crate) vendor_stock: Vec, + /// The player's buyback ring as `(item_entry, stack_count, price)`; empty by default, so a + /// fixture login replays no buyback tab. + pub(crate) buyback_ring: Vec<(u32, u32, u32, u32)>, +} + +/// Vendor behaviour is tested through `InMemoryVendorActions`; here only the reads the session and +/// the gossip menu make are live. +impl VendorActionStore for WorldFake { + fn vendor_stock(&self, _vendor_guid: u64) -> Result> { + Ok(self.vendor.vendor_stock.clone()) + } + + fn vendor_refuses_interaction(&self, _vendor_guid: u64, _player_guid: u64) -> Result { + Ok(self.npc_refuses) + } + + fn vendor_buy( + &self, + _account_id: u64, + _self_guid: u64, + _vendor_guid: u64, + _item_entry: u32, + _count: u32, + ) -> Result<()> { + Ok(()) + } + + fn buyback_slots(&self, _player_guid: u64) -> Vec<(u32, u32, u32, u32)> { + self.vendor.buyback_ring.clone() + } + + fn random_property_enchant_ids(&self, _random_property_id: u32) -> [u32; 3] { + [0; 3] + } + + fn vendor_item_slot(&self, _item_guid: u64) -> Option { + None + } + + fn vendor_repair( + &self, + _account_id: u64, + _self_guid: u64, + _npc_guid: u64, + _slot: u8, + ) -> Result<()> { + Ok(()) + } + + fn vendor_sell( + &self, + _account_id: u64, + _self_guid: u64, + _vendor_guid: u64, + _slot: u8, + ) -> Result<()> { + Ok(()) + } + + fn vendor_buyback( + &self, + _account_id: u64, + _self_guid: u64, + _vendor_guid: u64, + _slot: u8, + ) -> Result<()> { + Ok(()) + } +} diff --git a/gateway/src/world/test_support/world_fake/weather.rs b/gateway/src/world/test_support/world_fake/weather.rs new file mode 100644 index 000000000..f59887164 --- /dev/null +++ b/gateway/src/world/test_support/world_fake/weather.rs @@ -0,0 +1,20 @@ +use super::super::*; + +#[derive(Default)] +pub(crate) struct WeatherState { + /// Canned `game_zone_weather` rows, keyed by zone. A zone absent here has no row, which the + /// Module defines as fine weather — the default, so a store that says nothing about weather + /// behaves exactly like today's weatherless world. + pub(crate) zone_weather: Vec<(u32, codec::ZoneWeatherView)>, +} + +impl WeatherStore for WorldFake { + fn zone_weather(&self, zone_id: u32) -> Result> { + Ok(self + .weather + .zone_weather + .iter() + .find(|(zone, _)| *zone == zone_id) + .map(|(_, view)| *view)) + } +} diff --git a/gateway/src/world/tests.rs b/gateway/src/world/tests.rs index 08f127c17..33034fcff 100644 --- a/gateway/src/world/tests.rs +++ b/gateway/src/world/tests.rs @@ -1,16 +1,15 @@ +//! World Session socket tests. Each `_tests.rs` file drives a real encrypted World Session +//! against [`WorldFake`] for one Store family, and `session_tests.rs` covers the session itself. +//! This module holds the imports those files share and the helpers more than one of them uses. + use super::handlers::{ - resolve_online_character, AuctionActionStore, AuctionInteraction, CastStore, - ChannelActionStore, ChannelOutcome, ChannelRequest, ChannelRoster, ChatActionStore, - ChatOutcome, DuelActionStore, GuildActionStore, ItemActionStore, LootWindowRefusal, - LootWindowRequestStatus, LootWindowStore, MeetingStoneActionStore, MeetingStoneOutcome, - MeleeActionStore, MemberPresence, MemberSnapshot, MemberStatsStore, QuestActionStore, - RealmChatRequest, ResolvedTarget, SpeakerFacts, TaxiActionStore, VendorActionStore, - WeatherStore, WhisperRequest, WhisperTargetFacts, + AuctionInteraction, ChatOutcome, SpeakerFacts, WhisperRequest, WhisperTargetFacts, }; use super::party::PartyOutcome; +use super::test_support::*; use super::*; use crate::read_deadline::{DeadlineClock, PreAuthDeadline}; -use lyracore_shared::group::{GroupKind, GroupRefusal, RaidSlot}; +use lyracore_shared::group::{GroupKind, RaidSlot}; use lyracore_shared::item::ItemRefusal; use lyracore_shared::loot::LootRefusal; use lyracore_shared::social::ContactRefusal; @@ -18,161 +17,6 @@ use std::cell::Cell; use std::io::Cursor; use std::os::unix::net::UnixStream; use std::rc::Rc; - -#[derive(Clone)] -struct ManualClock(Rc>); - -impl DeadlineClock for ManualClock { - fn now(&self) -> Instant { - self.0.get() - } -} - -struct AdvancingStream { - input: Cursor>, - output: Vec, - clock: ManualClock, - per_read: Duration, - per_write: Duration, - write_timeout_calls: Rc>, -} - -impl Read for AdvancingStream { - fn read(&mut self, buf: &mut [u8]) -> std::io::Result { - let one_byte = buf.len().min(1); - let read = self.input.read(&mut buf[..one_byte])?; - self.clock.0.set(self.clock.0.get() + self.per_read); - Ok(read) - } -} - -impl Write for AdvancingStream { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - self.output.extend_from_slice(buf); - self.clock.0.set(self.clock.0.get() + self.per_write); - Ok(buf.len()) - } - - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } -} - -impl IoDeadline for AdvancingStream { - fn set_read_timeout(&self, _timeout: Option) -> std::io::Result<()> { - Ok(()) - } - - fn set_write_timeout(&self, _timeout: Option) -> std::io::Result<()> { - self.write_timeout_calls - .set(self.write_timeout_calls.get() + 1); - Ok(()) - } -} - -/// The client side of every real world-session test has a bounded read. A missing server packet is -/// a test failure, never an indefinitely blocked test process. -const WORLD_SESSION_READ_DEADLINE: std::time::Duration = std::time::Duration::from_secs(5); - -fn world_session_socket_pair() -> (UnixStream, UnixStream) { - let (client, server) = UnixStream::pair().expect("world-session socket pair must be created"); - client - .set_read_timeout(Some(WORLD_SESSION_READ_DEADLINE)) - .expect("world-session client read deadline must be configured"); - (client, server) -} - -#[test] -fn world_session_socket_pair_times_out_when_the_server_writes_nothing() { - let (mut client, _server) = world_session_socket_pair(); - let mut byte = [0]; - let error = client - .read(&mut byte) - .expect_err("a silent server must hit the world-session read deadline"); - assert!( - matches!( - error.kind(), - std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock - ), - "a silent server must time out, got {error}" - ); -} - -/// The realm-wide party routing tests. A child module so they can reach -/// `InMemoryStore` and its fake realm-core topology without widening anything, kept in their own -/// file because this one is already the largest in the tree. -#[path = "party_tests.rs"] -mod party_tests; - -/// The Roster Revision Relay against the party topology. A sibling of `party_tests` so it reaches -/// `InMemoryStore` and that topology without widening anything. -#[path = "party_mirror_tests.rs"] -mod party_mirror_tests; - -/// Member Stats: the Relay tick and the stats request against the party topology. A sibling of -/// `party_tests` so it reaches `InMemoryStore` and that topology without widening anything. -#[path = "member_stats_tests.rs"] -mod member_stats_tests; - -/// The realm-wide whisper routing tests. A sibling of `party_tests` for the -/// same reason — it reaches `InMemoryStore` (and `party_tests`' live topology) without widening -/// anything. -#[path = "whisper_tests.rs"] -mod whisper_tests; - -/// Realm Presence's multi-shard union (`presence::of`, `presence::in_world_characters`) — reads -/// that moved out of `party.rs`. A sibling of `party_tests`/`whisper_tests` for the same reason: it -/// reaches `InMemoryStore` and `party_tests`' fixture characters without widening anything. -#[path = "presence_tests.rs"] -mod presence_tests; - -/// `/who`'s Store-Fake tests: the multi-shard listing and the 49-cap/oversized-request rules that -/// need real presence rows rather than the hand-written filter inputs `who.rs`'s own unit tests -/// use. A sibling of `presence_tests` for the same reason. -#[path = "who_tests.rs"] -mod who_tests; - -/// The realm-wide loot-roll routing/relay tests. A sibling of `party_tests`/`whisper_tests` for -/// the same reason — it reaches `InMemoryStore` without widening anything. -#[path = "loot_tests.rs"] -mod loot_tests; - -/// The mailbox read-path routing tests. A sibling of the modules above for the same reason — it -/// reaches `InMemoryStore` (and `party_tests`' fixture characters) without widening anything. -#[path = "mail_tests.rs"] -mod mail_tests; - -/// The inbound FRAMING boundary — malformed, truncated, oversized and unsupported packets -/// driven as raw bytes over a real cipher. A sibling of the modules above for the same reason (it -/// reaches `InMemoryStore` and `client_handshake`), kept separate because it is the only file here -/// that writes headers no typed builder can produce. -#[path = "framing_tests.rs"] -mod framing_tests; - -/// Account-owned Alpha Test Tools at the Headless Client seam. This stays separate from the Module -/// command Gate tests: it proves dot-Say dispatch, current authority, and client-visible results. -#[path = "alpha_test_tools_tests.rs"] -mod alpha_test_tools_tests; - -/// Multi-shard routing — reducer calls and subscriptions never target a shard other than the -/// player's home shard. A sibling of the modules above for the same reason. `ShardCallLog` is -/// `pub(super)` here and re-exported below because this file's own pre-shard-routing tests (and -/// `transfer_tests`) still share the one call-log type. -#[path = "shard_routing_tests.rs"] -mod shard_routing_tests; -use shard_routing_tests::ShardCallLog; - -/// Cross-database Transfer tests share the Character and Escrow Fakes defined here. -#[path = "transfer_tests.rs"] -mod transfer_tests; - -#[path = "trade_tests.rs"] -mod trade_tests; -/// `SMSG_COMPRESSED_MOVES` corruption regressions, driven through the real `spawn_writer` + -/// `wow_srp` cipher pair. A sibling of the modules above for the same reason. -#[path = "wire_corruption_tests.rs"] -mod wire_corruption_tests; - use wow_world_base::shared::friend_result_vanilla_tbc::FriendResult; use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; use wow_world_messages::vanilla::{ @@ -207,11 +51,9 @@ use wow_world_messages::vanilla::{ CMSG_AUCTION_LIST_BIDDER_ITEMS, CMSG_AUCTION_LIST_ITEMS, CMSG_AUCTION_LIST_OWNER_ITEMS, - CMSG_AUTH_SESSION, CMSG_AUTOBANK_ITEM, CMSG_AUTOEQUIP_ITEM, CMSG_AUTOSTORE_BANK_ITEM, - CMSG_AUTOSTORE_LOOT_ITEM, CMSG_BANKER_ACTIVATE, CMSG_BUYBACK_ITEM, CMSG_BUY_BANK_SLOT, @@ -229,15 +71,6 @@ use wow_world_messages::vanilla::{ CMSG_GAMEOBJ_USE, CMSG_GOSSIP_HELLO, CMSG_GOSSIP_SELECT_OPTION, - CMSG_GUILD_ACCEPT, - CMSG_GUILD_DECLINE, - CMSG_GUILD_DEMOTE, - CMSG_GUILD_DISBAND, - CMSG_GUILD_INVITE, - CMSG_GUILD_LEADER, - CMSG_GUILD_LEAVE, - CMSG_GUILD_PROMOTE, - CMSG_GUILD_REMOVE, CMSG_INSPECT, CMSG_ITEM_QUERY_SINGLE, CMSG_LEARN_TALENT, @@ -247,7 +80,6 @@ use wow_world_messages::vanilla::{ CMSG_LOOT_MASTER_GIVE, CMSG_LOOT_METHOD, CMSG_LOOT_MONEY, - CMSG_LOOT_RELEASE, CMSG_LOOT_ROLL, CMSG_NPC_TEXT_QUERY, CMSG_PLAYED_TIME, @@ -258,9 +90,7 @@ use wow_world_messages::vanilla::{ CMSG_QUEST_QUERY, // Item guid → slot resolution (vendor sell / armorer repair). CMSG_RECLAIM_CORPSE, - CMSG_REPOP_REQUEST, CMSG_RESURRECT_RESPONSE, - CMSG_SELF_RES, CMSG_SETSHEATHED, CMSG_SET_SELECTION, CMSG_SPIRIT_HEALER_ACTIVATE, @@ -275,13170 +105,218 @@ use wow_world_messages::vanilla::{ }; use wow_world_messages::Guid; -const K: [u8; 40] = [ - 0x2E, 0xFE, 0xE7, 0xB0, 0xC1, 0x77, 0xEB, 0xBD, 0xFF, 0x66, 0x76, 0xC5, 0x6E, 0xFC, 0x23, 0x39, - 0xBE, 0x9C, 0xAD, 0x14, 0xBF, 0x8B, 0x54, 0xBB, 0x5A, 0x86, 0xFB, 0xF8, 0x1F, 0x6D, 0x42, 0x4A, - 0xA2, 0x3C, 0xC9, 0xA3, 0x14, 0x9F, 0xB1, 0x75, -]; - -/// How many messages one world entry puts on the wire before anything else can: the eleven -/// login-sequence packets, the self CREATE_OBJECT2, and the zone's SMSG_WEATHER. Tests that are -/// about what comes AFTER world entry drain exactly this many. -/// -/// `player_login_emits_sequence_then_self_create` is the one test that reads the sequence itself, -/// and its tag vector is the authority on the order. -pub(super) const WORLD_ENTRY_PACKETS: usize = 13; - -/// A world-port re-entry skips SMSG_LOGIN_VERIFY_WORLD because the client has loaded the map. -pub(super) const WORLD_REENTRY_PACKETS: usize = WORLD_ENTRY_PACKETS - 1; - -/// In-memory `WorldStore` returning a fixed session key + character list for one account. -/// One recorded `movement_update`: (opcode, x, y, z, orientation, timestamp). -type MoveRecord = (u32, f32, f32, f32, f32, u32); +#[path = "alpha_test_tools_tests.rs"] +mod alpha_test_tools_tests; +#[path = "auction_tests.rs"] +mod auction_tests; +#[path = "bank_tests.rs"] +mod bank_tests; +#[path = "cast_tests.rs"] +mod cast_tests; +#[path = "character_tests.rs"] +mod character_tests; +#[path = "chat_tests.rs"] +mod chat_tests; +#[path = "combat_tests.rs"] +mod combat_tests; +#[path = "death_tests.rs"] +mod death_tests; +#[path = "framing_tests.rs"] +mod framing_tests; +#[path = "guild_tests.rs"] +mod guild_tests; +#[path = "item_tests.rs"] +mod item_tests; +#[path = "loot_tests.rs"] +mod loot_tests; +#[path = "loot_window_tests.rs"] +mod loot_window_tests; +#[path = "mail_tests.rs"] +mod mail_tests; +#[path = "melee_tests.rs"] +mod melee_tests; +#[path = "member_stats_tests.rs"] +mod member_stats_tests; +#[path = "npc_tests.rs"] +mod npc_tests; +#[path = "party_mirror_tests.rs"] +mod party_mirror_tests; +#[path = "party_tests.rs"] +mod party_tests; +#[path = "presence_tests.rs"] +mod presence_tests; +#[path = "quest_tests.rs"] +mod quest_tests; +#[path = "session_tests.rs"] +mod session_tests; +#[path = "shard_routing_tests.rs"] +mod shard_routing_tests; +#[path = "social_tests.rs"] +mod social_tests; +#[path = "taxi_tests.rs"] +mod taxi_tests; +#[path = "trade_tests.rs"] +mod trade_tests; +#[path = "trainer_tests.rs"] +mod trainer_tests; +#[path = "transfer_tests.rs"] +mod transfer_tests; +#[path = "vendor_tests.rs"] +mod vendor_tests; +#[path = "weather_tests.rs"] +mod weather_tests; +#[path = "whisper_tests.rs"] +mod whisper_tests; +#[path = "who_tests.rs"] +mod who_tests; +#[path = "wire_corruption_tests.rs"] +mod wire_corruption_tests; -/// 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. -fn delivery_delay_secs(has_item: bool, same_account: bool) -> u32 { - if has_item && !same_account { - 3_600 - } else { - 0 - } -} +use shard_routing_tests::ShardCallLog; -/// A mail's `deliver_secs` when it arrives `delay_secs` from now. 0 means from creation. -fn delivered_after(delay_secs: u32) -> i64 { - if delay_secs == 0 { - 0 - } else { - mail::now_secs() + i64::from(delay_secs) +/// Write one request that answers its actor nothing, then a sentinel request with a guaranteed +/// reply, and block for that reply. This is more than pacing: `enter_world` drains the world entry +/// batch, which ends before the Guild MOTD event `guild_world_entry` sends a fresh-login +/// Guild member (see its own doc comment), so one packet can still be unread in the client's +/// kernel buffer. Reading for a sentinel discards it along the way. Dropping the client with it +/// still queued would close with unread bytes, which the kernel reports to the server as a reset, +/// not a clean EOF (`enter_world`'s doc comment names this exact failure shape). The sentinel is +/// CMSG_PLAYED_TIME, which replies only when the store has a Character row for the caller. +fn sync( + client: &mut UnixStream, + enc: &mut EncrypterHalf, + dec: &mut DecrypterHalf, + write: impl FnOnce(&mut UnixStream, &mut EncrypterHalf), +) { + write(&mut *client, &mut *enc); + CMSG_PLAYED_TIME {} + .write_encrypted_client(&mut *client, &mut *enc) + .unwrap(); + loop { + if let ServerOpcodeMessage::SMSG_PLAYED_TIME(_) = + ServerOpcodeMessage::read_encrypted(&mut *client, &mut *dec).unwrap() + { + break; + } } } -#[derive(Default)] -struct InMemoryStore { - /// Realm-core Guilds the guild query answers from. - guilds: Vec, - /// Realm-core member rows. Guild ops are recorded in `calls` as `guild_op:`. - guild_memberships: Vec, - /// A Fee Hold left on this Home Shard. Fee steps are recorded in `calls` as `guild_fee_`. - guild_fee_hold: std::sync::Mutex>, - /// Realm-core Petitions. - guild_petitions: Vec, - /// Guild Charters the logged-in Character holds on this Home Shard. - held_charters: Vec, - /// WORLDPORT_ACK gate: true = entity present -> a spurious ack is ignored; - /// false (derive-Default) = absent -> a genuine transfer is pending. - entity_in_world: bool, - /// An in-session controllable cache answer for movement desync regression tests. - entity_presence: Option>, - entity_presence_checks: std::sync::atomic::AtomicUsize, - username: String, - session: Option, - characters: Vec, - /// When set, `character_by_guid` cannot answer from this Shard. - character_read_error: Option, - login_entity: Option, - /// The auctioneer's house and faction verdict returned to the focused auction seam. - auction_interaction: Option, - moves: std::sync::Mutex>, - client_commands: std::sync::Mutex>, - /// Vendor stock the seam's `vendor_stock` read returns (empty by default). - vendor_stock: Vec, - /// Imported item templates the query path resolves by entry. Keeping this keyed fixture here - /// makes socket tests exercise the same durable read shape as the Coordinator cache. - item_templates: Vec, - /// The player's buyback ring as `(item_entry, stack_count, price)`; empty by default, so a - /// fixture login replays no buyback tab. - buyback_ring: Vec<(u32, u32, u32, u32)>, - /// 195: `npc_refuses_interaction` return — false (derive-Default) keeps every fixture NPC open. - npc_refuses: bool, - /// Spelled as a refusal so derive-Default (false) keeps every fixture trainer serving; the - /// trait method reads the negation. - trainer_refuses_class: bool, - /// When set, buy/sell return this error (a gameplay failure) instead of `Ok`. - trade_error: Option, - /// When set, the trainer buy answers this Refusal instead of learning the spell. - trainer_buy_refusal: Option, - /// Quest-giver evals returned by `giver_quest_evals` (the menu/status input). - quest_evals: Vec, - /// Quest details `quest_detail_view(id)` resolves from (matched by `quest_id`). - quest_details: Vec, - /// The player's quest-log slots `player_quest_log` returns (drives the login descriptor block). - quest_log_slots: Vec, - /// Recorded `turn_in_quest` dispatches: (account, giver, quest, reward_index) — so the - /// choose-reward socket test asserts the player's pick reached the store unchanged. - turned_in: std::sync::Mutex>, - /// 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. - turn_in_reward_item: Option, - turn_in_tx: std::sync::Mutex>, - /// A Reward Letter a successful `turn_in_quest` files as Escrow for the Character, as - /// `gw_turn_in_quest` does in its own transaction. - turn_in_reward_letter: Option, - /// Override for `player_combat_until_ms`: 0 = out of combat (default), non-zero = in combat until - /// this ms-epoch deadline (use u64::MAX for "always in combat" in tests). - combat_until_ms: u64, - /// Tracks whether `logout` was called (entity removal path taken). - logout_called: std::sync::atomic::AtomicBool, - /// When set, teardown cannot reach the database after a session-fatal transport loss. - /// The world session must still close and relinquish its admission seat. - logout_error: Option, - /// Recorded `delete_character` calls: (account_id, character_guid). - deleted: std::sync::Mutex>, - /// When set, `delete_character` returns this outcome instead of `Success`. - delete_outcome: Option, - - created_characters: std::sync::Mutex>, - /// The Nth guid `create_character` assigns, offset well above every hand-seeded fixture - /// guid in this file (the highest is 100, in the transfer tests) so it can never collide. - next_created_guid: std::sync::atomic::AtomicU64, - /// Reputation standings `player_reputations` returns — `(reputation_index, standing)` pairs folded - /// into the login SMSG_INITIALIZE_FACTIONS (restoring persisted standings instead of the - /// all-neutral stub). - reputations: Vec<(i32, i32, bool)>, - /// Imported action-bar rows `player_actions` returns — `(button, action, action_type)` triples. - /// Empty by default (the pre-import fallback path). - player_actions: Vec<(u8, u32, u8)>, - /// Friend/ignore rows: `(owner_guid, target_guid, is_ignore)`. `add_friend`/ - /// `add_ignore`/`del_friend`/`del_ignore` mutate it; `contact_lists` reads it scoped to the caller. - contacts: std::sync::Mutex>, - group_invites: std::sync::Mutex>, - /// When set, `start_attack` returns this error. Only the session-fatal desync case is driven - /// from here now; the refusal mapping is tested on the melee seam itself. - start_attack_error: Option, - /// What `speaker_facts` answers for every speaker. `None` models a speaker with no live entity. - speaker_facts: Option, - /// What `realm_chat` answers. `None` delivers. - realm_chat_outcome: Option, - /// What `send_chat` answers; `None` delivers. - send_chat_outcome: Option, - /// Every Character's GM level, as `speaker_gm_level` reads it. - gm_level: u8, - /// Recorded `realm_chat` requests, with the speaker guid the session authenticated. - realm_chats: std::sync::Mutex>, - /// The `WorldEntry` of every `player_login`, in order. - login_entries: std::sync::Mutex>, - /// Recorded `set_away` requests: `(speaker_guid, kind, message)`. - away_requests: std::sync::Mutex>, - /// This Shard's stored Auto-Replies, by Character guid. - auto_replies: std::sync::Mutex>, - /// What `realm_whisper` answers. `None` delivers. - realm_whisper_outcome: Option, - /// When set, `realm_whisper` fails with this message. - realm_whisper_error: Option, - /// Recorded `realm_whisper` requests, with the speaker guid the session authenticated. - realm_whispers: std::sync::Mutex>, - /// When set, `gm_command` returns this error — e.g. `"permission denied"` to - /// drive the Say-handler's `Err` → self-only `SMSG_MESSAGECHAT` System relay. - gm_command_error: Option, - /// 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. - gm_commands: std::sync::Mutex>, - /// Current Realm-core Alpha Test Tools answer for the next command. `None` leaves the older - /// fixed-outcome fixture in place; tests that set it model the production Store's fresh read. - gm_alpha_test_tools: Option>, - /// The authority conveyed with each command when `gm_alpha_test_tools` is in use. - gm_authority_results: std::sync::Mutex>, - /// Home Shard gameplay accepted by the focused Alpha Test Tools Fake. The command parser and - /// effects belong to Module tests, so this only records the visible Store outcome. - gm_gameplay_changes: std::sync::Mutex>, - /// Recorded `cast_spell` dispatches: (spell_id, target_guid) — pins target threading. - casts: std::sync::Mutex>, - /// Recorded `start_ranged_attack` dispatches: (target_guid, spell_id) — the Auto Shot intercept. - ranged_attacks: std::sync::Mutex>, - /// Recorded `stop_attack` dispatches: the actor guid. The Auto Shot teardowns reach this - /// through `WorldStore`'s `MeleeActionStore` supertrait, so it pins that resolution. - stop_attacks: std::sync::Mutex>, - /// The caller's owned items, for the Auto Shot ammo block on the activation START. - player_items_fixture: Vec, - /// Item-instance guid → bag slot, for the vendor repair target. - item_slots: Vec<(u64, u8)>, - /// Recorded `set_sheathed` dispatches: (self_guid, state), the `CMSG_SETSHEATHED` route. - sheathed: std::sync::Mutex>, - /// What `channel_op` answers. `None` succeeds. - channel_outcome: Option, - /// Recorded `channel_op` calls: `(actor_guid, op, request)`. - channel_ops: std::sync::Mutex>, - /// The lootable copper `loot_target_money` reports for any target (default 0). - corpse_money: u32, - /// Parked private System Messages world entry replays (a Package `on_login` hook's output). - pending_system_messages: Vec, - /// Recorded `loot_money` targets — CMSG_LOOT_MONEY must drive the TRACKED guid. - money_looted: std::sync::Mutex>, - /// Recorded target and slot for `CMSG_AUTOSTORE_LOOT_ITEM`. - items_looted: std::sync::Mutex>, - /// Recorded `skin_corpse` targets (the empty-loot-window skinning fallback). - skinned: std::sync::Mutex>, - /// Typed legacy skinning refusal returned by the empty-loot fallback. - skinning_refusal: Option, - /// Infrastructure failure returned by the empty-loot skinning fallback. - skinning_failure: Option, - /// Recorded `vendor_buyback` calls: (vendor_guid, slot) — pins the 69→0 slot mapping. - bought_back: std::sync::Mutex>, - /// What `talent_grant_spell` returns (0 = passive talent → no SMSG_LEARNED_SPELL push). - talent_grant: u32, - /// What `talent_pane_sync` returns: (teach rank-spell, superseded prev, points remaining). - talent_pane: (u32, u32, u32), - /// What `superseded_old_rank` returns for a trainer buy — the known previous rank a - /// non-stacking chain's new rank replaces. `None` (derive-Default) mirrors "no known prior - /// rank" -> a trainer buy pushes plain SMSG_LEARNED_SPELL. - trainer_superseded: Option, - /// The character's spellbook, as `player_learned_spells` reports it. Empty by default; a - /// proficiency test seeds the passive the trainer buy is meant to have granted. - learned_spells: Vec, - /// `npc_is_innkeeper` flag for the gossip bind-home routing. - innkeeper: bool, - /// Whether `bind_home` ran (the innkeeper gossip select). - home_bound: std::sync::atomic::AtomicBool, - /// Recorded `reset_talents` dispatches: (account_id, self_guid, trainer_guid) — the unlearn-talents - /// gossip select. - reset_talents_calls: std::sync::Mutex>, - /// When set, `reset_talents` returns this error instead of recording the call. - reset_talents_error: Option, - /// Recorded `send_chat` lines: (chat_type, language, message). - chats: std::sync::Mutex>, - /// Imported gossip menu options `gossip_options` returns for ANY npc_guid — empty - /// by default (the pre-import fallback path). - gossip_opts: Vec, - /// Recorded `gossip_select` notifications as `(option_id, option_row_id)` — the clicked POSITION - /// and the stable row identity the module is told about. - gossip_selects: std::sync::Mutex>, - /// Taxi seam fixtures and operation log. Both direct query opcodes and TAXI gossip must append - /// the same `open` operation here. - taxi_status: Option, - taxi_map: Option, - taxi_activation: codec::TaxiActivationResult, - taxi_activation_inputs: std::sync::Mutex>, - taxi_error: Option, - taxi_calls: std::sync::Mutex>, - /// The caller's quest log for `quest_status`, as `(quest_id, rewarded)` pairs — a quest id present - /// here is "taken"; `rewarded` distinguishes active vs. turned-in. Absent = never seen. - /// Behind a `Mutex` so a test can change the log WHILE a gossip window is open — the - /// HELLO→SELECT race the menu snapshot exists to close. - quest_log: std::sync::Mutex>, - /// The `npc_text_for_id` view `npc_text_for_id` returns for ANY text_id — `None` by default (the - /// generic-greeting fallback), settable per-test for the 8-slot pin coverage. - npc_text_view: Option, - /// Per-VIEWER corpse loot fixture for `corpse_loot(corpse_guid, viewer_guid)` — different viewers - /// of the SAME corpse can see different windows (`quest_only` rows are per-looter) — keyed by - /// viewer guid, standing in for whatever the real per-viewer read - /// (`gateway/src/stdb/reads.rs::corpse_loot`) would return for that viewer; its own filtering - /// decision is unit-tested directly in `reads.rs`, not reproduced here. Empty by default — every - /// test that never sets this keeps seeing an empty window, byte-identical to before. - corpse_loot_by_viewer: std::collections::HashMap>, - /// Spawned GameObject type returned for the `CMSG_GAMEOBJ_USE` questgiver classification. - gameobject_type: Option, - /// Recorded non-questgiver GameObject uses owned by the loot-window seam. - gameobjects_used: std::sync::Mutex>, - - corpse_loot_reads: std::sync::Mutex>, - /// Recorded `group_loot_method` calls: (loot_setting, master_guid, loot_threshold). - group_loot_methods: std::sync::Mutex>, - /// Recorded `loot_roll` calls: (corpse_guid, loot_slot, vote). - loot_rolls: std::sync::Mutex>, - /// Recorded `loot_master_give` calls: (corpse_guid, loot_slot, target_guid). - loot_master_gives: std::sync::Mutex>, - /// Typed gameplay Refusal returned by loot-roll and master-loot action tests. - loot_action_refusal: Option, - /// Infrastructure failure returned by loot-roll and master-loot action tests. - loot_action_failure: Option, - /// Recorded `use_item` slots. - used_items: std::sync::Mutex>, - /// Recorded `player_login` call count — the WORLDPORT_ACK test distinguishes the - /// initial `CMSG_PLAYER_LOGIN` call from a world-port RE-entry call, since both dispatch through - /// this one trait method (`enter_world` is shared by both call sites). - login_calls: std::sync::atomic::AtomicU32, - /// When set, every `player_login` call AFTER the first returns THIS entity instead of - /// `login_entity` — simulates the character row having moved to a new map (`teleport_player`'s - /// durable write) between the initial login and the `MSG_MOVE_WORLDPORT_ACK`. `None` (default): - /// every call keeps returning `login_entity`, byte-identical to before this field existed. - worldport_entity: Option, - /// When set, every `player_login` AFTER the first FAILS with this message — the world entry - /// behind a world-port that routed fine (the stranding guard, a refused re-login on the - /// destination shard). The client is mid-loading-screen for it either way. - worldport_login_error: Option, - /// Recorded `subscribe_player_events` calls: (self_guid, login_map, login_x, login_y) — the - /// WORLDPORT_ACK test asserts this fires AGAIN (a fresh `created` dedup set) at the new - /// map/position rather than reusing the old subscription. - subscribed: std::sync::Mutex>, - /// The egress DEPTH counter of the live session `subscribe_player_events` was handed — so a test - /// can read the real queue depth of a real `run_world_session` (the writer thread's decrement has - /// no other reachable seam: it lives inside the spawned writer loop). Deliberately the depth - /// `Arc` and NOT the `SessionTx` itself: holding a sender clone here would keep the writer's - /// `rx.recv()` alive forever and hang every `enter_world` test's `server.join()`. - session_depth: std::sync::Mutex>>, - /// Canned `game_zone_weather` rows, keyed by zone. A zone absent here has no row, which the - /// Module defines as fine weather — the default, so a store that says nothing about weather - /// behaves exactly like today's weatherless world. - zone_weather: Vec<(u32, codec::ZoneWeatherView)>, - /// When set, every weather read fails with this message — the "the Store could not answer" - /// case, which must still leave the player with a sky rather than a failed login. - weather_error: Option, - /// Multi-shard routing: the database this handle stands for. `""` (derive-Default) is the - /// single-shard world every other test runs in, where nothing routes. - shard: String, - /// The handle `home_shard()` hands back — the character's home shard. `None` (default) = "you - /// are already on the right shard", i.e. the single-entry shard map / pre-sharding behavior. - home: Option>, - /// Home-shard reassignment: when set, every `home_shard()` resolution AFTER the first - /// answers THIS shard instead of `home` — the mock's stand-in for a routing change landing - /// between two logins (a shard-map edit, or the realm-core index re-homing a character). `None` - /// (default): every resolution answers `home`, byte-identical to before this field existed. - home_after_flip: Option>, - /// The one location this handle routes elsewhere, and where to: `(map_id, instance_id, shard)`. - /// `None` (derive-Default) = "this handle serves every location", the single-database answer - /// `shard_for_location` gives. Keyed by location on purpose — a caller that asks about the - /// wrong place gets `None` and the crossing silently becomes a no-op, which is what the tests - /// must be able to tell apart from a crossing that ran. - location_shard: Option<(u32, u64, std::sync::Arc)>, - /// How many times `home_shard()` has been asked — drives `home_after_flip`, and is itself the - /// assertion that routing is resolved ONCE PER WORLD ENTRY and never mid-session. SHARED - /// between a store and the handles it routes to (like `calls`), so a re-resolution asked of the - /// *pinned* handle — which is what a mid-session re-route would actually look like, since - /// `route_home` asks whichever handle the session currently holds — is counted too. - home_shard_calls: std::sync::Arc, - /// SHARED between a store and its `home` handle: `(shard, call)` for every instrumented - /// player-scoped call, in order. The routing test asserts nothing lands on the wrong database. - calls: std::sync::Arc>>, - /// The fake DATABASE this handle talks to, for the cross-database transfer tests. `None` - /// (the default) leaves every transfer trait method at its "this store does not shard" default, - /// so every other test in this file is untouched. - xdb: Option>, - /// How many `escrowed_transfer` reads answer `None` before the row shows up — the - /// cross-connection lag between a reducer's reply and the coordinator cache, made dialable. - escrow_reads_before_visible: std::sync::atomic::AtomicUsize, - /// The handle `realm_store()` hands back — the database that owns party - /// membership realm-wide. `None` (derive-Default) is the SINGLE-DATABASE gateway, which is what - /// every other test in this file is, and it is what routes every party op back onto the - /// player-facing reducers below. - realm: Option>, - /// The connected WORLD shards `world_stores()` fans the roster mirror out to (and the - /// cross-shard name/presence lookups walk). Empty = single database. Behind a `Mutex` only so - /// the topology can be wired up AFTER every handle exists — production reads the shared - /// `ShardSet`, which has the same shape and the same "includes this handle" membership. - peers: std::sync::Mutex>>, - /// When set, the configured World Shard set is incomplete or unhealthy. - world_shard_set_error: Option, - /// How many times a caller asked for the two-snapshot durable absence check. - durable_absence_checks: std::sync::atomic::AtomicUsize, - /// A Character whose guild lookup fails, as when one Realm-core read errors. - guild_lookup_error_for: Option, - /// Unclaimed bot invite intent ids on this World Shard. Two concurrent consumers share this - /// collection, matching the Module table both Gateways call into. - bot_invite_intents: std::sync::Mutex>, - /// Current consent returned by the owned admission seam, independent of presence reads. - sessionless_admission: std::sync::Mutex>, - sessionless_admission_unavailable: std::sync::Mutex>, - suppress_after_admission: std::sync::Mutex>, - bot_intent_claim_refusals: std::sync::Mutex>, - /// The AUTHORITATIVE party state, when this handle is the realm-core one. Shared with - /// nobody — a realm handle owns exactly one of these, and every shard reads its own `mirror`. - party: std::sync::Arc>, - /// Realm-core's Coordinator cache lags a call-pipe commit: after `realm_group_op`, roster reads - /// answer from `stale_party` until a `realm_group_op_visible` returns. The production shape of - /// a call pipe, which subscribes no group table. - cache_lags: std::sync::atomic::AtomicBool, - /// The party as the lagging cache still shows it. - stale_party: std::sync::Mutex>, - /// True when this handle is realm-core, so `group_roster` answers from `party` (the - /// authority) instead of `mirror` (this shard's cache of it). - is_realm: bool, - /// What `sync_group_mirror` wrote onto THIS shard, latest per group. The invalidation - /// story, made observable. - mirror: std::sync::Mutex>, - /// The Roster Revision each `sync_group_mirror` accepted on THIS shard, kept after the disband - /// tombstone, as the Module's mirror keeps its `game_group_roster_revision` row. - mirror_revisions: std::sync::Mutex>, - /// Guids with a LIVE entity on this shard — the per-guid `entity_in_world` answer a - /// realm-wide party frame's online flags are built from. Empty = the single `entity_in_world` - /// flag above decides, as it did before. - live_guids: Vec, - /// Seeded characters that are nevertheless OFFLINE, so the invite gate's "player not - /// online" arm can be driven. Empty = every seeded character is online, as before. - offline_guids: Vec, - /// Raw `PLAYER_FLAGS` per guid, for `presence_row`'s Away Status. Empty = every guid reads - /// `AwayStatus::None`, as a Character with no live entity does in production. - away_flags: std::collections::HashMap, - /// `game_area.name` per zone id, for `/who`'s search-string match. Empty = every zone name - /// reads "", the "unimported catalogue" case. - zone_names: std::collections::HashMap, - /// When set, `sync_group_mirror` fails with this message — a world shard that cannot be - /// mirrored (an unreachable database), which must not fail a party op realm-core already took. - mirror_error: Option, - /// How many mirror writes fail before this Shard accepts one. - mirror_failures: std::sync::atomic::AtomicUsize, - /// When set, `contact_lists` fails with this message on THIS shard — the - /// unreachable-database arm of the realm-wide ignore-list union. - contact_lists_error: Option, - /// The mail rows on THIS database, as `(recipient_guid, row)`. The realm handle owns them on a - /// sharded gateway and a world shard's staying empty is how a test tells "the mailbox read went - /// to the authority" from "it quietly went back to being shard-local"; on a single-database - /// gateway the one handle owns them instead. Same fixture either way — that is the point. - mails: std::sync::Mutex>, - /// Gameobject guids that ARE a mailbox within reach on this shard. Empty (derive-Default) - /// refuses every mailbox, which is the wrong-map / out-of-range / not-a-mailbox arm. - mailboxes: Vec, - /// `game_world_entity.money` per guid, on THIS database — the purse the postage comes out of. - /// A guid with no row here cannot pay, which is also the module's answer for a character with no - /// live entity on the shard being asked. - purses: std::sync::Mutex>, - /// Letters written on THIS database: `(sender, recipient, subject, body, attached money)`. - /// Recorded separately from `mails` because WHICH call wrote the row is what tells the two - /// planes apart — `mail_send` on one database, `mail_commit` on the mail plane of a sharded one. - #[allow(clippy::type_complexity)] - sent_mail: std::sync::Mutex>, - /// The escrow ledger on THIS database, as `(the guid the fence is filed under, the row)`. A - /// letter's fence is filed under its SENDER and lives on their shard; a take's is filed under - /// the PAYEE and lives on the plane holding the mail row. - #[allow(clippy::type_complexity)] - mail_escrows: std::sync::Mutex>, - /// How many `mail_escrows_of` reads answer empty before a just-filed fence shows up. The same - /// cross-connection lag `escrow_reads_before_visible` dials in for `escrowed_transfer`, made - /// dialable for the mail escrow read `held_fence` polls. - mail_escrow_reads_before_visible: std::sync::atomic::AtomicUsize, - /// The Realm Account name THIS Shard holds per Character guid. A Character missing here is - /// one this Shard cannot name: absent, or on a shadow Account. - realm_accounts: std::sync::Mutex>, - /// The `same_account` each mail send, fence and return carried, in call order. - same_account_seen: std::sync::Mutex>, - /// `game_mail_escrow.delivered` per escrow id: the attestation that licenses the settle. Kept - /// beside the fence rather than in it so the fake cannot settle one it never attested. - attested: std::sync::Mutex>, - /// Delivery/payout receipts on THIS database, `(escrow_id, recipient or payee)` — the - /// idempotency key that makes a replayed commit or payout a no-op. - mail_receipts: std::sync::Mutex>, - /// `game_item_text` on THIS database: `(item_text_id, text)`. Filed by `mail_copy_text`, read - /// back by `item_text` — the mail plane's half of a Letter Copy. - item_texts: std::sync::Mutex>, - /// Plain Letters `mail_grant_letter` granted on THIS database: `(payee_guid, item_text_id)`. - /// Not `mail_items` — a Letter Copy mints a fresh item, it never moves one out of a mail row. - granted_letters: std::sync::Mutex>, - /// `game_mail.item_text_id` per mail id: the attached Plain Letter's text id. Kept beside - /// `mails` because `MailView` does not carry it. - mail_item_text_ids: std::sync::Mutex>, - /// The mail-escrow step to fail on THIS database — a gateway killed before that step's - /// transaction committed. `transfer`'s `kill_at` for the mail drive, and a `Mutex` because a - /// re-drive test has to bring the database back up before driving again. - mail_kill_at: std::sync::Mutex>, - /// When set, `realm_group_op(ACCEPT, …)` fails with this message. INJECTED because a real - /// one cannot be staged synchronously: every accept-time refusal the module has (already grouped, - /// party full, the inviter no longer leads) needs the party to change BETWEEN the invite and the - /// accept, and the gateway's bot answer runs in the same call as the invite. The failure is still - /// reachable in production — a concurrent op on another socket — and what it must not do is leave - /// the invite dialog hanging. - party_accept_error: Option, - /// How many Realm-core LEAVE calls fail before one reaches the party state. - party_leave_failures: std::sync::atomic::AtomicUsize, - /// When set, every Group Broadcast op fails as a lost connection would. - group_broadcast_error: bool, - /// Return a connection failure after the next Realm-core LEAVE commits. - party_leave_commit_then_error: std::sync::atomic::AtomicBool, - /// Fail one `group_roster` read by its one-based call number. - group_roster_error_on_read: std::sync::Mutex>, - /// Number of `group_roster` reads on this handle. - group_roster_reads: std::sync::atomic::AtomicUsize, - /// When set, this handle cannot enumerate its mirrored party ids. - party_group_ids_error: std::sync::Mutex>, - /// When set, deleted Character cleanup cannot reach Realm-core. - party_cleanup_realm_error: Option, - party_command_realm_error: Option, - transfer_realm_error: Option, - party_command_claims: std::sync::Mutex>, - party_command_finishes: - std::sync::Mutex>, - admitted_party_commands: std::sync::Mutex>, - party_command_apply_outcome: Option, - party_command_receipt_error: std::sync::Mutex>, - party_command_authority_members: std::sync::Mutex>>, - party_command_in_transit: std::sync::Mutex>, - party_command_receipts: std::sync::Mutex< - Vec<( - spacetimedb_sdk::Identity, - u64, - super::party::CompanionCommandOutcome, - )>, - >, - entity_partitions: std::sync::Mutex>, - /// The transfer step to fail at, simulating a gateway killed before that step's - /// transaction committed. `None` = nothing fails. - kill_at: Option, - /// When set, `settle_home_shard` fails with this message (a transfer that could not be - /// driven — an unreachable destination shard, a refused import). - settle_error: Option, - /// How many `settle_home_shard` calls SUCCEED before `settle_error` starts firing. 0 - /// (derive-Default) = the very first one fails, i.e. the login-time failure the transfer - /// test drives. - /// 1 = the login routes fine and the WORLD-PORT's settle is the one that cannot be driven — - /// the case that hung a real client on its loading screen forever. - settle_ok_calls: usize, - /// How many times `settle_home_shard` has been asked (drives `settle_ok_calls`). - settle_calls: std::sync::atomic::AtomicUsize, - /// Accounts `bind_shard_session` was called for, per shard. - bound_sessions: std::sync::Mutex>, - /// The REALM-CORE character→shard index this handle's `publish_shard_index` writes. In - /// production that write goes to a third database (`realm_core()`); here it is just a map, so a - /// test can assert the drive published the destination it settled on. - realm_index: std::sync::Mutex>, - /// Realm-core's ordered transfer phase for cross-Shard caller tests. - realm_partition: std::sync::Mutex>, - /// When set, `publish_shard_index` fails with this message — an unreachable realm-core. - publish_error: Option, - /// 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. - movement_error: Option, - /// Records every `realm_loot_op` 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)] - realm_loot_ops: std::sync::Mutex< - Vec<( - u8, - u64, - u8, - u32, - u64, - u8, - i64, - Vec, - u32, - spacetimedb_sdk::Identity, - u64, - )>, - >, - /// When set, `realm_loot_op` fails with this message. - realm_loot_op_error: 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. - /// Empty (derive-Default) = nothing to promote, byte-identical to before this field existed. - pending_rolls: std::sync::Mutex>, - /// Recorded `settle_loot_roll` calls on THIS shard — `(corpse_guid, slot, winner_guid)`. - settled_rolls: std::sync::Mutex>, - /// When set, `settle_loot_roll` fails with this message. - settle_loot_roll_error: Option, - /// 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. - cleared_rolls: std::sync::Mutex>, - /// This REALM-CORE handle's fixture `ROLL_WON` queue — `(corpse_guid, slot, winner_guid)` - /// triples, in the order they "arrived". `loot_won_since(after_id)` answers every entry whose - /// 1-based INDEX exceeds `after_id`, and the new watermark is the queue's length — the same - /// shape the real `game_group_event.id` high-water mark has, without needing a fake event table. - /// `Mutex`-wrapped for the same after-`Arc`-construction reason as `pending_rolls`. - won_events: std::sync::Mutex>, - /// Recorded `auto_bank_item` slots — backs both CMSG_AUTOBANK_ITEM (deposit) and - /// CMSG_AUTOSTORE_BANK_ITEM (withdraw), which both route onto this one store method. - auto_banked_items: std::sync::Mutex>, - /// Recorded `buy_bank_slot` calls — the banker guid named on each `CMSG_BUY_BANK_SLOT`. - bought_bank_slots: std::sync::Mutex>, - /// Trainer rows `trainer_list` returns for ANY (player, trainer) pair — the - /// CMSG_TRAINER_LIST fixture. Empty by default (an empty trainer window). - trainer_spells: Vec, - /// `learn_skill_line` per offering id — the skill-teaching offerings the fixture trainer carries. - /// Empty by default, so every offering reads as an ordinary spell purchase. - trainer_offer_skill_lines: std::collections::HashMap, - /// Recorded `repop` calls — the caller's self_guid, one entry per CMSG_REPOP_REQUEST. - repopped: std::sync::Mutex>, - /// Recorded `reclaim_corpse` calls — `(self_guid, corpse_guid)` off CMSG_RECLAIM_CORPSE. - reclaimed_corpses: std::sync::Mutex>, - /// Recorded `resurrect_response` calls — `(self_guid, accept)`, pinning the wire's - /// `status != 0` → bool mapping. - resurrect_responses: std::sync::Mutex>, - /// Recorded `self_resurrect` calls: the caller's self_guid off CMSG_SELF_RES. - self_resurrects: std::sync::Mutex>, - /// When set, `self_resurrect` returns this Refusal after recording the call. - self_resurrect_error: Option, - /// Recorded `spirit_healer_res` calls — `(self_guid, healer_guid)` off - /// CMSG_SPIRIT_HEALER_ACTIVATE. - spirit_healer_calls: std::sync::Mutex>, - /// Recorded `set_target` target guids — CMSG_SET_SELECTION. (`rec("set_target")` already - /// pins the per-shard call NAME; this pins the ARGUMENT actually threaded through.) - selected_targets: std::sync::Mutex>, - /// 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. - set_target_error: Option, - /// Optional real shared view used by world-port ordering tests. - relay_view: Option>, - /// Whether this character still had an addressable viewer at each transfer-resolution call. - viewer_present_at_settle: std::sync::Mutex>, - /// Recorded `cancel_aura` spell ids — CMSG_CANCEL_AURA. - cancelled_auras: std::sync::Mutex>, - /// Recorded `cancel_cast` self_guids — CMSG_CANCEL_CAST. - cancelled_casts: std::sync::Mutex>, - /// `game_item_instance` on THIS database: `item_guid -> (owner_guid, snapshot)`. The mail - /// attachment path deletes from here at send and inserts at take, which is the whole "a fenced - /// item is in nobody's bags" property. - mail_items: std::sync::Mutex>, - /// This shard's bags have no room — the fixture behind the full-bag refusal on a take. - /// Atomic so a test can flip it AFTER the fixture is wrapped in an `Arc`, like `purses`. - bags_full: std::sync::atomic::AtomicBool, - /// Recorded `initiate_trade` calls, `(self_guid, target_guid)` off CMSG_INITIATE_TRADE. - initiated_trades: std::sync::Mutex>, - /// Recorded `begin_trade` self_guids, CMSG_BEGIN_TRADE. - begun_trades: std::sync::Mutex>, - /// Recorded `cancel_trade` self_guids, CMSG_CANCEL_TRADE. - cancelled_trades: std::sync::Mutex>, - /// Recorded `set_trade_item` calls — `(self_guid, trade_slot, inv_slot)` AFTER the gateway's - /// (bag, slot) → absolute-slot mapping. - set_trade_items: std::sync::Mutex>, - /// Recorded `clear_trade_item` calls, `(self_guid, trade_slot)`. - cleared_trade_items: std::sync::Mutex>, - /// Recorded `set_trade_gold` calls, `(self_guid, copper)` after the wire's Gold decode. - set_trade_golds: std::sync::Mutex>, - /// Recorded `accept_trade` self_guids, CMSG_ACCEPT_TRADE. - accepted_trades: std::sync::Mutex>, - /// Recorded `unaccept_trade` self_guids, CMSG_UNACCEPT_TRADE. - unaccepted_trades: std::sync::Mutex>, - /// Recorded `busy_trade` self_guids, CMSG_BUSY_TRADE. - busy_trades: std::sync::Mutex>, - /// Recorded `ignore_trade` self_guids, CMSG_IGNORE_TRADE. - ignore_trades: std::sync::Mutex>, - /// Live `game_world_entity` rows on THIS shard, as the columns Member Stats read. - member_entities: std::sync::Mutex>, - /// Characters Realm-core reports in a pending Transfer. Read on the realm handle only. - members_in_transit: std::sync::Mutex>, - /// Characters THIS shard shows between two places: an online Session with no entity, or a - /// bot named by a Transfer Intent. - members_between_places: std::sync::Mutex>, - /// With `relay_view`, the Member Stats a Relay tick delivered between viewer registration and - /// the world-entry party frame. - member_stats_before_party_frame: Option, - /// How many `member_presence` reads reached this handle. - member_presence_reads: std::sync::atomic::AtomicUsize, +fn position(calls: &[String], what: &str) -> usize { + calls + .iter() + .position(|call| call == what) + .unwrap_or_else(|| panic!("`{what}` never ran: {calls:?}")) } -/// 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. -fn faked_party(error: &str) -> Result { - match lyracore_shared::group::GroupRefusal::parse_tag(error) { - Some(refusal) => Ok(refusal.into()), - None => Err(anyhow!("{error}")), +/// A minimal quest detail (everything zero/empty but the id + title) — enough for the build_* codecs. +fn detail_view(quest_id: u32, title: &str) -> codec::QuestDetailView { + codec::QuestDetailView { + quest_id, + quest_level: 1, + zone_or_sort: 12, + title: title.into(), + details: String::new(), + objectives_text: String::new(), + offer_reward_text: String::new(), + request_items_text: String::new(), + money_reward: 0, + reward_xp: 0, + next_quest_id: 0, + max_level_money_reward: 0, + rewards: Vec::new(), + choice_rewards: Vec::new(), + objectives: Vec::new(), } } -/// [`faked_party`] for the friends and ignore lists. -fn faked_contact(error: &str) -> Result { - match lyracore_shared::social::ContactRefusal::parse_tag(error) { - Some(refusal) => Ok(refusal.into()), - None => Err(anyhow!("{error}")), +/// One giver↔quest eval with the four booleans the menu/status + complete-branch read. +fn eval(quest_id: u32, role: u8, active: bool, complete: bool) -> codec::GiverQuestEval { + codec::GiverQuestEval { + quest_id, + title: "Q".into(), + level: 1, + role, + startable: role == codec::ROLE_START, + active, + complete, } } -impl InMemoryStore { - /// Drop one contact row, or refuse when the owner does not hold it. - fn remove_contact(&self, target_guid: u64, is_ignore: bool) -> Result { - let owner = self.login_entity.as_ref().map(|e| e.guid).unwrap_or(0); - let mut contacts = self.contacts.lock().unwrap(); - let before = contacts.len(); - contacts.retain(|&(o, t, ig)| !(o == owner && t == target_guid && ig == is_ignore)); - if contacts.len() == before { - return Ok(lyracore_shared::social::ContactRefusal::NotOnList.into()); - } - Ok(ContactOutcome::Done) - } - - /// Read realm-core's party the way its Coordinator cache shows it: stale after a lagging - /// call-pipe commit, current otherwise. - fn realm_cache(&self, read: impl FnOnce(&FakeParty) -> R) -> R { - if let Some(stale) = &*self.stale_party.lock().unwrap() { - return read(stale); - } - read(&self.party.lock().unwrap()) - } - - /// Record one player-scoped call against THIS handle's shard. - fn rec(&self, what: &str) { - self.calls - .lock() - .unwrap() - .push((self.shard.clone(), what.to_string())); - } - - /// `guid`'s Away Status from `away_flags` (raw `PLAYER_FLAGS`), `AwayStatus::None` when unset — - /// mirroring "a Character with no live entity has `AwayStatus::None`" for every guid a test - /// never seeds. - fn away(&self, guid: u64) -> presence::AwayStatus { - self.away_flags - .get(&guid) - .copied() - .map(presence::away_from_player_flags) - .unwrap_or(presence::AwayStatus::None) - } - - /// One mail-escrow step boundary. `Err` is the gateway dying before this step committed: the - /// call never lands, and nothing after it in the drive runs either. - fn mail_kill(&self, step: &str) -> Result<()> { - if self.mail_kill_at.lock().unwrap().as_deref() == Some(step) { - anyhow::bail!("injected: the gateway died before {step} on {}", self.shard); - } - Ok(()) - } - - /// Take `copper` out of a purse on THIS database, or refuse and take nothing — the module's - /// `charge_postage`, including its "no live entity here" arm for a guid with no purse row. - fn debit(&self, guid: u64, copper: u32) -> Result<()> { - let mut purses = self.purses.lock().unwrap(); - match purses.iter_mut().find(|(g, _)| *g == guid) { - Some((_, money)) if *money >= copper => { - *money -= copper; - Ok(()) - } - _ => Err(anyhow!(lyracore_shared::mail::NOT_ENOUGH_MONEY)), - } - } - - fn credit(&self, guid: u64, copper: u32) { - let mut purses = self.purses.lock().unwrap(); - if let Some((_, money)) = purses.iter_mut().find(|(g, _)| *g == guid) { - *money = money.saturating_add(copper); - } - } - - /// The module's `detach_item`: the mailable verdict, then the DELETE. `item_guid` 0 is a letter - /// with no attachment. - fn detach(&self, sender_guid: u64, item_guid: u64) -> Result { - if item_guid == 0 { - return Ok(mail::AttachedItem::default()); - } - let mut items = self.mail_items.lock().unwrap(); - match items - .iter() - .position(|(g, owner, _)| *g == item_guid && *owner == sender_guid) - { - None => Err(anyhow!(lyracore_shared::mail::NOT_YOUR_ITEM)), - Some(i) if items[i].2.soulbound => { - Err(anyhow!(lyracore_shared::mail::ITEM_IS_SOULBOUND)) - } - Some(i) => Ok(items.remove(i).2), - } - } - - /// The module's `store_instance_state`: one new row carrying the recorded state, or the item - /// module's own full-bag refusal. - fn store_snapshot(&self, owner_guid: u64, item: &mail::AttachedItem) -> Result<()> { - if self.bags_full.load(std::sync::atomic::Ordering::Relaxed) { - return Err(anyhow!(lyracore_shared::mail::INVENTORY_FULL)); - } - let mut items = self.mail_items.lock().unwrap(); - let guid = items.iter().map(|(g, _, _)| *g).max().unwrap_or(0) + 1; - items.push((guid, owner_guid, item.clone())); - Ok(()) - } - - /// Every item `owner` holds on this database — the assertion surface for "it left the bags", - /// "it arrived unchanged" and "it never arrived twice". - pub(crate) fn bags_of(&self, owner: u64) -> Vec { - self.mail_items - .lock() - .unwrap() - .iter() - .filter(|(_, o, _)| *o == owner) - .map(|(_, _, i)| i.clone()) - .collect() - } - - /// The module's `insert_letter` for a Character's letter: the row both write paths reach, so a - /// letter written by the single-database send and one written by the escrow's commit cannot - /// differ. It has HAS_BODY with a body and COPIED without one, like `Letter::from_character`, - /// and only COD_PAYMENT when it pays a price. It arrives `delay_secs` from now. - #[allow(clippy::too_many_arguments)] - fn write_mail( - &self, - sender_guid: u64, - recipient_guid: u64, - subject: String, - body: String, - money: u32, - cod: u32, - item: &mail::AttachedItem, - cod_payment: bool, - delay_secs: u32, - ) { - let check_flags = if cod_payment { - lyracore_shared::mail::CHECK_MASK_COD_PAYMENT - } else if body.is_empty() { - lyracore_shared::mail::CHECK_MASK_COPIED - } else { - lyracore_shared::mail::CHECK_MASK_HAS_BODY - }; - let mut mails = self.mails.lock().unwrap(); - let id = mails.iter().map(|(_, m)| m.id).max().unwrap_or(0) + 1; - mails.push(( - recipient_guid, - codec::MailView { - id, - sender_guid, - subject, - body, - money, - cod, - item_entry: item.entry, - item_stack_count: item.stack_count, - item_durability: item.durability, - item_enchant_id: item.enchant_id, - item_soulbound: item.soulbound, - random_property_id: item.random_property_id, - created_at_secs: 1_000, - check_flags, - deliver_secs: delivered_after(delay_secs), - ..Default::default() - }, - )); - drop(mails); - if item.item_text_id != 0 { - self.mail_item_text_ids - .lock() - .unwrap() - .push((id, item.item_text_id)); - } - } - - /// The text id of the letter attached to mail `mail_id`, 0 for any other attachment. - fn attached_text_id(&self, mail_id: u64) -> u32 { - self.mail_item_text_ids - .lock() - .unwrap() - .iter() - .find(|(id, _)| *id == mail_id) - .map_or(0, |(_, text_id)| *text_id) +fn imported_auction_interaction() -> AuctionInteraction { + AuctionInteraction { + house: super::handlers::AuctionHousePolicy { + id: 1, + deposit_rate: 5, + consignment_rate: 5, + }, + refuses_interaction: false, } +} - /// Take the attached letter's text id off mail `mail_id`, as the take clears the attachment. - fn take_attached_text_id(&self, mail_id: u64) -> u32 { - let mut ids = self.mail_item_text_ids.lock().unwrap(); - let Some(at) = ids.iter().position(|(id, _)| *id == mail_id) else { - return 0; - }; - ids.remove(at).1 - } +/// `SMSG_LOOT_RESPONSE`, read raw because the loot window is an `Outbound::Raw` packet. +const OP_LOOT_RESPONSE: u16 = 0x0160; - fn saw_same_account(&self, call: &'static str, same_account: bool) { - self.same_account_seen - .lock() - .unwrap() - .push((call, same_account)); - } +/// Read one encrypted server frame RAW: decrypt the 4-byte header via the client's `DecrypterHalf`, +/// return `(opcode, body)`. Needed where the wire contract is an `Outbound::Raw` packet (the 5-byte +/// CAST_RESULT ack, the loot/vendor windows) or where the exact opcode ORDER across raw+typed sends +/// is the assertion — gtker's typed reader rejects some of the hand-rolled bodies (it would consume +/// the frame but error), so the bytes are read and pinned directly. +fn read_raw_frame(client: &mut S, dec: &mut DecrypterHalf) -> (u16, Vec) { + let h = dec.read_and_decrypt_server_header(&mut *client).unwrap(); + let mut body = vec![0u8; (h.size as usize).saturating_sub(2)]; + client.read_exact(&mut body).unwrap(); + (h.opcode, body) +} - /// Record a transfer step and honour an injected kill. `Err` means "the gateway died - /// before this step's transaction committed", which is exactly a truncated drive. - fn xstep(&self, what: &str) -> Result<&std::sync::Arc> { - let db = self - .xdb - .as_ref() - .ok_or_else(|| anyhow!("this store does not implement cross-database transfers"))?; - if self.kill_at.as_deref() == Some(what) { - return Err(anyhow!("gateway killed at {what}")); - } - self.rec(what); - Ok(db) +fn open_loot_window( + client: &mut UnixStream, + enc: &mut EncrypterHalf, + dec: &mut DecrypterHalf, + target_guid: u64, +) -> Vec { + CMSG_LOOT { + guid: Guid::new(target_guid), } + .write_encrypted_client(&mut *client, enc) + .unwrap(); + let (opcode, body) = read_raw_frame(client, dec); + assert_eq!(opcode, OP_LOOT_RESPONSE); + body } -impl WorldStore for InMemoryStore { - fn home_shard(&self, _character_guid: u64) -> Option> { - let nth = self - .home_shard_calls - .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - let resolved = match (&self.home_after_flip, nth) { - (Some(flipped), n) if n >= 1 => Some(flipped.clone()), - _ => self.home.clone(), - }; - resolved.map(|h| h as std::sync::Arc) - } - fn pending_system_messages(&self, _self_guid: u64) -> Vec { - self.pending_system_messages.clone() +/// Open `npc`'s gossip window and drain the menu, so a following `CMSG_GOSSIP_SELECT_OPTION` has the +/// snapshot it resolves against — a click with nothing open selects nothing. +fn gossip_hello( + client: &mut UnixStream, + enc: &mut EncrypterHalf, + dec: &mut DecrypterHalf, + npc: u64, +) -> wow_world_messages::vanilla::SMSG_GOSSIP_MESSAGE { + CMSG_GOSSIP_HELLO { + guid: Guid::new(npc), } - fn shard_name(&self) -> &str { - &self.shard + .write_encrypted_client(&mut *client, enc) + .unwrap(); + match ServerOpcodeMessage::read_encrypted(&mut *client, dec).unwrap() { + ServerOpcodeMessage::SMSG_GOSSIP_MESSAGE(m) => *m, + other => panic!("expected SMSG_GOSSIP_MESSAGE, got {other}"), } +} - // The Fake enforces the Module's escrow guards so transfer ordering affects outcomes. - - fn settle_home_shard( - &self, - character_guid: u64, - ) -> Result>> { - if let Some(view) = &self.relay_view { - self.viewer_present_at_settle.lock().unwrap().push( - view.viewer_of_owner(crate::stdb::world_view::OwnerGuid(character_guid)) - .is_some(), - ); - } - if let Some(e) = &self.settle_error { - let nth = self - .settle_calls - .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - if nth >= self.settle_ok_calls { - return Err(anyhow!("{e}")); - } - } - Ok(self.home_shard(character_guid)) +/// A shorthand imported option builder for the gossip tests. +fn opt(icon: u32, text: &str, action: u32) -> codec::GossipOptionView { + codec::GossipOptionView { + icon, + text: text.to_string(), + action, + ..Default::default() } +} - fn bind_shard_session(&self, account_id: u64, _session_key: &[u8; 40]) -> Result<()> { - self.rec("bind_shard_session"); - self.bound_sessions.lock().unwrap().push(account_id); - Ok(()) - } - - fn shard_for_location( - &self, - map_id: u32, - instance_id: u64, - ) -> Option> { - let (m, i, shard) = self.location_shard.as_ref()?; - (*m == map_id && *i == instance_id).then(|| shard.clone() as std::sync::Arc) - } - - fn escrowed_transfer(&self, character_guid: u64) -> Option { - let db = self.xdb.as_ref()?; - if self - .escrow_reads_before_visible - .fetch_update( - std::sync::atomic::Ordering::SeqCst, - std::sync::atomic::Ordering::SeqCst, - |left| left.checked_sub(1), - ) - .is_ok() - { - return None; - } - let out = lk(&db.out_rows); - out.values() - .find(|e| e.character_guid == character_guid) - .map(|e| super::transfer::EscrowedTransfer { - transfer_id: e.transfer_id, - character_guid: e.character_guid, - dest_map_id: e.dest_map_id, - dest_instance_id: e.dest_instance_id, - blob: e.blob.clone(), - }) - } - - fn character_destination(&self, character_guid: u64) -> Option { - let c = self.xdb.as_ref()?.get(character_guid)?; - Some(super::transfer::TransferPlan { - transfer_id: super::transfer::transfer_id_for(character_guid), - character_guid, - dest_map_id: c.map_id, - dest_instance_id: c.instance_id, - dest_x: 0.0, - dest_y: 0.0, - dest_z: 0.0, - dest_o: 0.0, - }) - } - - /// `plan_begin`: replay on a matching escrow, refuse without a source copy, refuse a second - /// escrow for the same character, otherwise freeze + serialize. - /// - /// The `escrowed_guid` lookup is the OUT-row with the IN-row as a fallback, exactly as - /// `module/src/transfer/mod.rs`'s `begin_transfer` computes it — and that fallback is load-bearing - /// 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: &super::transfer::TransferPlan) -> Result<()> { - let db = self.xstep("begin_transfer")?; - let mut out = lk(&db.out_rows); - let escrowed_guid = out - .get(&plan.transfer_id) - .map(|e| e.character_guid) - .or_else(|| lk(&db.in_rows).get(&plan.transfer_id).copied()); - if let Some(existing) = escrowed_guid { - if existing != plan.character_guid { - return Err(anyhow!("transfer id collision")); - } - return Ok(()); // BeginPlan::Replay - } - let Some(c) = db.get(plan.character_guid) else { - return Err(anyhow!("no such character: {}", plan.character_guid)); - }; - if out - .values() - .any(|e| e.character_guid == plan.character_guid) - { - return Err(anyhow!( - "character {} is already in transit", - plan.character_guid - )); - } - out.insert( - plan.transfer_id, - FakeEscrow { - transfer_id: plan.transfer_id, - character_guid: plan.character_guid, - dest_map_id: plan.dest_map_id, - dest_instance_id: plan.dest_instance_id, - blob: fake_blob( - plan.character_guid, - plan.dest_map_id, - plan.dest_instance_id, - &c.payload, - ), - }, - ); - Ok(()) - } - - /// `import_character_blob`: replay on the in-row PK, refuse to land on a LIVE character, - /// otherwise materialise the row + its payload at the escrow's destination. - fn import_character_blob( - &self, - transfer_id: u64, - blob: &[u8], - source: super::transfer::RealmLocatorPredecessor, - bot_arrival: Option<&super::transfer::BotTransferIntent>, - ) -> Result<()> { - let db = self.xstep("import_character_blob")?; - let (guid, arriving) = parse_blob(blob); - if let Some(intent) = bot_arrival { - if intent.source_module_identity == spacetimedb_sdk::Identity::ZERO - || intent.id == 0 - || intent.created_micros <= 0 - || source.revision == 0 - || transfer_id != guid - { - return Err(anyhow!("bot Transfer arrival identity is invalid")); - } - } - // Drop each `in_rows` guard before `db.live()`, which locks the same non-reentrant Mutex. - let replayed = lk(&db.in_rows).get(&transfer_id).copied(); - if let Some(existing) = replayed { - if existing != guid { - return Err(anyhow!( - "transfer id already imported for another character" - )); - } - if let Some(intent) = bot_arrival { - let expected = ( - intent.source_module_identity, - intent.id, - intent.controller_generation, - intent.created_micros, - ); - if lk(&db.bot_arrivals).get(&transfer_id) != Some(&expected) { - return Err(anyhow!("destination arrival belongs to another crossing")); - } - } - if lk(&db.arrival_sources).get(&transfer_id) - != Some(&(source.map_id, source.instance_id, source.revision)) - { - return Err(anyhow!( - "destination arrival belongs to another Realm crossing" - )); - } - return Ok(()); - } - if db.live(guid) { - return Err(anyhow!("character {guid} is already live on this shard")); - } - // The destination rides IN the blob, exactly as the real `ExportBlob`'s `dest_*` fields do: - // cross-database the blob is the only thing that reaches this side. - lk(&db.characters).insert(guid, arriving); - lk(&db.in_rows).insert(transfer_id, guid); - lk(&db.arrival_sources).insert( - transfer_id, - (source.map_id, source.instance_id, source.revision), - ); - if let Some(intent) = bot_arrival { - lk(&db.bot_arrivals).insert( - transfer_id, - ( - intent.source_module_identity, - intent.id, - intent.controller_generation, - intent.created_micros, - ), - ); - } - Ok(()) - } - - /// `confirm_import`: the SOURCE-side attestation. Refuses without a local escrow. - fn confirm_import(&self, transfer_id: u64) -> Result<()> { - let db = self.xstep("confirm_import")?; - let out = lk(&db.out_rows); - let Some(escrow) = out.get(&transfer_id) else { - return Err(anyhow!( - "transfer {transfer_id}: nothing escrowed here to confirm" - )); - }; - lk(&db.in_rows).insert(transfer_id, escrow.character_guid); - Ok(()) - } - - /// `plan_finish`: refuses while the in-row (the attestation) is absent — the guard that makes - /// "zero durable copies" unreachable — then cascade-deletes the source copy, escrow last. - fn finish_transfer(&self, transfer_id: u64) -> Result<()> { - let db = self.xstep("finish_transfer")?; - let mut out = lk(&db.out_rows); - let Some(escrow) = out.get(&transfer_id).cloned() else { - return Ok(()); // FinishPlan::AlreadyDone - }; - if !lk(&db.in_rows).contains_key(&transfer_id) { - return Err(anyhow!( - "transfer {transfer_id}: not imported — refusing to release" - )); - } - lk(&db.characters).remove(&escrow.character_guid); - lk(&db.in_rows).remove(&transfer_id); - out.remove(&transfer_id); - Ok(()) - } - - /// `release_transfer`: refuses on a shard that is the SOURCE; replay-safe otherwise. - fn release_transfer(&self, transfer_id: u64) -> Result<()> { - let Some(_) = self.xdb.as_ref() else { - return Ok(()); - }; - let db = self.xstep("release_transfer")?; - if lk(&db.out_rows).contains_key(&transfer_id) { - return Err(anyhow!( - "transfer {transfer_id}: this database holds the SOURCE out-row" - )); - } - if lk(&db.bot_arrivals).contains_key(&transfer_id) { - return Err(anyhow!( - "transfer {transfer_id}: session-less arrival is owned by its Transfer Intent" - )); - } - lk(&db.in_rows).remove(&transfer_id); - lk(&db.arrival_sources).remove(&transfer_id); - Ok(()) - } - - fn release_player_transfer_arrival( - &self, - transfer_id: u64, - character_guid: u64, - source: super::transfer::RealmLocatorPredecessor, - ) -> Result<()> { - let Some(db) = self.xdb.as_ref() else { - return Ok(()); - }; - self.xstep("release_transfer")?; - if transfer_id != character_guid || source.revision == 0 { - return Err(anyhow!("player Transfer arrival identity is invalid")); - } - if lk(&db.in_rows).get(&transfer_id) != Some(&character_guid) { - return Ok(()); - } - if lk(&db.bot_arrivals).contains_key(&transfer_id) - || lk(&db.arrival_sources).get(&transfer_id) - != Some(&(source.map_id, source.instance_id, source.revision)) - { - return Ok(()); - } - lk(&db.in_rows).remove(&transfer_id); - lk(&db.arrival_sources).remove(&transfer_id); - Ok(()) - } - - fn transfer_arrival(&self, transfer_id: u64) -> Option { - let db = self.xdb.as_ref()?; - let character_guid = *lk(&db.in_rows).get(&transfer_id)?; - let (source, intent_id, generation, _) = lk(&db.bot_arrivals) - .get(&transfer_id) - .copied() - .unwrap_or((spacetimedb_sdk::Identity::ZERO, 0, 0, 0)); - let (source_map, source_instance, source_locator_revision) = lk(&db.arrival_sources) - .get(&transfer_id) - .copied() - .unwrap_or((0, 0, 0)); - Some(super::transfer::TransferArrival { - character_guid, - source_map, - source_instance, - source_locator_revision, - bot_source_identity: source, - bot_transfer_intent_id: intent_id, - bot_controller_generation: generation, - }) - } - - fn realm_character_partition( - &self, - character_guid: u64, - ) -> Result> { - // `members_in_transit` is Member Stats' own per-guid fixture (`presence::of`'s - // `realm_transfer_pending` reads this trait method on the realm handle, same as - // `Coordinator::member_presence` did before the two merged). Checked first so it can name - // one guid as pending without disturbing `realm_partition`, which every other test that - // exercises a real Transfer already drives. - if self - .members_in_transit - .lock() - .unwrap() - .contains(&character_guid) - { - return Ok(Some(super::party::RealmCharacterPartition { - map_id: 0, - instance_id: 0, - revision: 1, - transfer_pending: true, - pending_destination_map: 0, - pending_destination_instance: 0, - bot_source_identity: spacetimedb_sdk::Identity::ZERO, - bot_transfer_intent_id: 0, - bot_controller_generation: 0, - })); - } - Ok(*self.realm_partition.lock().unwrap()) - } - - fn begin_shard_index_transfer( - &self, - plan: &super::transfer::TransferPlan, - bot_intent: Option<(&super::transfer::BotTransferIntent, u64)>, - ) -> Result { - let mut phase = self.realm_partition.lock().unwrap(); - let Some(current) = *phase else { - return Ok(super::party::RealmCharacterPartition { - map_id: 0, - instance_id: 0, - revision: bot_intent.map_or(1, |(intent, _)| intent.source_locator_revision.max(1)), - transfer_pending: true, - pending_destination_map: plan.dest_map_id, - pending_destination_instance: plan.dest_instance_id, - bot_source_identity: bot_intent - .map_or(spacetimedb_sdk::Identity::ZERO, |(intent, _)| { - intent.source_module_identity - }), - bot_transfer_intent_id: bot_intent.map_or(0, |(intent, _)| intent.id), - bot_controller_generation: bot_intent - .map_or(0, |(intent, _)| intent.controller_generation), - }); - }; - let (source_revision, crossing) = bot_intent.map_or( - (current.revision, (spacetimedb_sdk::Identity::ZERO, 0, 0)), - |(intent, _)| { - ( - intent.source_locator_revision, - ( - intent.source_module_identity, - intent.id, - intent.controller_generation, - ), - ) - }, - ); - if !current.transfer_pending - && (current.map_id, current.instance_id, current.revision) - == ( - plan.dest_map_id, - plan.dest_instance_id, - source_revision.saturating_add(1), - ) - && ( - current.bot_source_identity, - current.bot_transfer_intent_id, - current.bot_controller_generation, - ) == crossing - { - return Ok(current); - } - if current.transfer_pending - && current.revision == source_revision - && bot_intent.is_none_or(|(intent, _)| { - (current.map_id, current.instance_id) == (intent.source_map, intent.source_instance) - }) - && ( - current.pending_destination_map, - current.pending_destination_instance, - ) == (plan.dest_map_id, plan.dest_instance_id) - && ( - current.bot_source_identity, - current.bot_transfer_intent_id, - current.bot_controller_generation, - ) == crossing - { - return Ok(current); - } - if current.transfer_pending || current.revision != source_revision { - return Err(anyhow!("Transfer Realm locator changed")); - } - let pending = super::party::RealmCharacterPartition { - transfer_pending: true, - pending_destination_map: plan.dest_map_id, - pending_destination_instance: plan.dest_instance_id, - bot_source_identity: crossing.0, - bot_transfer_intent_id: crossing.1, - bot_controller_generation: crossing.2, - ..current - }; - *phase = Some(pending); - Ok(pending) - } - - fn finish_player_shard_index_transfer( - &self, - plan: &super::transfer::TransferPlan, - _source_map: u32, - _source_instance: u64, - source_revision: u64, - ) -> Result<()> { - let mut phase = self.realm_partition.lock().unwrap(); - let Some(current) = *phase else { - drop(phase); - return self.publish_shard_index( - plan.character_guid, - plan.dest_map_id, - plan.dest_instance_id, - ); - }; - if !current.transfer_pending || current.revision != source_revision { - return Err(anyhow!("Transfer Realm locator phase changed")); - } - *phase = Some(super::party::RealmCharacterPartition { - map_id: plan.dest_map_id, - instance_id: plan.dest_instance_id, - revision: source_revision + 1, - transfer_pending: false, - pending_destination_map: 0, - pending_destination_instance: 0, - ..current - }); - Ok(()) - } - - fn bind_bot_transfer_locator( - &self, - _intent: &super::transfer::BotTransferIntent, - source_revision: u64, - _claim_token: u64, - ) -> Result<()> { - self.rec("bind_bot_transfer_locator"); - let Some(current) = *self.realm_partition.lock().unwrap() else { - return Ok(()); - }; - if current.transfer_pending || current.revision != source_revision { - return Err(anyhow!("Transfer Intent Realm locator changed")); - } - Ok(()) - } - - fn publish_bot_shard_index(&self, intent: &super::transfer::BotTransferIntent) -> Result<()> { - self.rec("publish_bot_shard_index"); - let mut phase = self.realm_partition.lock().unwrap(); - let Some(current) = *phase else { - drop(phase); - return self.publish_shard_index( - intent.bot_guid, - intent.destination_map, - intent.destination_instance, - ); - }; - let settled_revision = intent - .source_locator_revision - .checked_add(1) - .ok_or_else(|| anyhow!("Transfer Realm locator revision exhausted"))?; - let crossing = ( - intent.source_module_identity, - intent.id, - intent.controller_generation, - ); - if !current.transfer_pending - && (current.map_id, current.instance_id, current.revision) - == ( - intent.destination_map, - intent.destination_instance, - settled_revision, - ) - && ( - current.bot_source_identity, - current.bot_transfer_intent_id, - current.bot_controller_generation, - ) == crossing - { - return Ok(()); - } - if !current.transfer_pending - || current.revision != intent.source_locator_revision - || ( - current.pending_destination_map, - current.pending_destination_instance, - ) != (intent.destination_map, intent.destination_instance) - || ( - current.bot_source_identity, - current.bot_transfer_intent_id, - current.bot_controller_generation, - ) != crossing - { - return Err(anyhow!("Transfer Realm locator phase changed")); - } - *phase = Some(super::party::RealmCharacterPartition { - map_id: intent.destination_map, - instance_id: intent.destination_instance, - revision: settled_revision, - transfer_pending: false, - pending_destination_map: 0, - pending_destination_instance: 0, - ..current - }); - Ok(()) - } - - fn finish_pending_shard_index_transfer( - &self, - character_guid: u64, - destination_map: u32, - destination_instance: u64, - arrival: &super::transfer::TransferArrival, - ) -> Result<()> { - if arrival.character_guid != character_guid { - return Err(anyhow!("arrival fence names another Character")); - } - let mut phase = self.realm_partition.lock().unwrap(); - let Some(current) = *phase else { - return Ok(()); - }; - if !current.transfer_pending - || (current.map_id, current.instance_id, current.revision) - != ( - arrival.source_map, - arrival.source_instance, - arrival.source_locator_revision, - ) - || ( - current.pending_destination_map, - current.pending_destination_instance, - ) != (destination_map, destination_instance) - || ( - current.bot_source_identity, - current.bot_transfer_intent_id, - current.bot_controller_generation, - ) != ( - arrival.bot_source_identity, - arrival.bot_transfer_intent_id, - arrival.bot_controller_generation, - ) - { - return Err(anyhow!("pending Realm Transfer phase changed")); - } - *phase = Some(super::party::RealmCharacterPartition { - map_id: destination_map, - instance_id: destination_instance, - revision: arrival.source_locator_revision + 1, - transfer_pending: false, - pending_destination_map: 0, - pending_destination_instance: 0, - ..current - }); - Ok(()) - } - - fn sync_transfer_arrival(&self, character_guid: u64) -> Result<()> { - super::party::sync_transfer_arrival_mirror(self, character_guid)?; - self.xstep("sync_transfer_arrival")?; - Ok(()) - } - - fn sync_transfer_pending(&self, character_guid: u64) -> Result<()> { - super::party::sync_transfer_arrival_mirror(self, character_guid)?; - self.xstep("sync_transfer_pending")?; - Ok(()) - } - - fn mark_bot_transfer_arrival_ready( - &self, - _intent_id: u64, - _bot_guid: u64, - _controller_generation: u64, - _claim_token: u64, - ) -> Result<()> { - self.xstep("mark_bot_transfer_arrival_ready")?; - Ok(()) - } - - fn bot_transfer_arrival_matches( - &self, - transfer_id: u64, - intent: &super::transfer::BotTransferIntent, - ) -> bool { - let Some(db) = self.xdb.as_ref() else { - return false; - }; - lk(&db.in_rows).get(&transfer_id) == Some(&intent.bot_guid) - && lk(&db.bot_arrivals).get(&transfer_id) - == Some(&( - intent.source_module_identity, - intent.id, - intent.controller_generation, - intent.created_micros, - )) - && lk(&db.arrival_sources).get(&transfer_id) - == Some(&( - intent.source_map, - intent.source_instance, - intent.source_locator_revision, - )) - } - - fn release_bot_transfer_arrival( - &self, - transfer_id: u64, - intent: &super::transfer::BotTransferIntent, - ) -> Result<()> { - self.xstep("release_bot_transfer_arrival")?; - let Some(db) = self.xdb.as_ref() else { - return Ok(()); - }; - if transfer_id != intent.bot_guid - || intent.source_module_identity == spacetimedb_sdk::Identity::ZERO - || intent.id == 0 - || intent.created_micros <= 0 - { - return Err(anyhow!("bot Transfer arrival identity is invalid")); - } - let expected = ( - intent.source_module_identity, - intent.id, - intent.controller_generation, - intent.created_micros, - ); - if lk(&db.bot_arrivals).get(&transfer_id) == Some(&expected) { - if lk(&db.arrival_sources).get(&transfer_id) - != Some(&( - intent.source_map, - intent.source_instance, - intent.source_locator_revision, - )) - { - return Ok(()); - } - lk(&db.in_rows).remove(&transfer_id); - lk(&db.bot_arrivals).remove(&transfer_id); - lk(&db.arrival_sources).remove(&transfer_id); - } - Ok(()) - } - - /// The realm-core index publish. Recorded in the shared call log so its POSITION in the - /// drive is assertable, not just its effect. - fn publish_shard_index( - &self, - character_guid: u64, - map_id: u32, - instance_id: u64, - ) -> Result<()> { - if let Some(e) = &self.publish_error { - return Err(anyhow!("{e}")); - } - // Through `xstep`, like every other step of the drive — NOT a bare `rec`. Every other - // transfer method routes its "gateway killed here" injection through it, and this one - // originally did not, so `kill_at = "publish_shard_index"` was silently inert and the - // crash matrix reported a PASS for a boundary it never killed at. - self.xstep("publish_shard_index")?; - self.realm_index - .lock() - .unwrap() - .push((character_guid, map_id, instance_id)); - Ok(()) - } - - fn instance_partition(&self, instance_id: u64) -> Option<(u32, u64)> { - self.xdb - .as_ref() - .and_then(|db| lk(&db.instance_partitions).get(&instance_id).copied()) - } - - fn ensure_instance(&self, instance_id: u64, map_id: u32, party_id: u64) -> Result<()> { - let db = self.xstep("ensure_instance")?; - if instance_id == 0 { - return Err(anyhow!("instance 0 is the open world")); - } - let existing = lk(&db.instance_partitions).get(&instance_id).copied(); - match existing { - Some((existing_map, _)) if existing_map != map_id => { - return Err(anyhow!( - "instance {instance_id} belongs to map {existing_map}, not map {map_id}" - )); - } - Some((_, existing_party)) - if existing_party != party_id && !(existing_party == 0 && party_id != 0) => - { - return Err(anyhow!( - "instance {instance_id} belongs to party {existing_party}, not party {party_id}" - )); - } - _ => {} - } - lk(&db.instance_partitions).insert(instance_id, (map_id, party_id)); - // The module's own shape: a mirror of an instance that is ALREADY here joins it (early - // return) instead of spawning a second population. `HashSet::insert` reports that for free, - // and the count is what the second-party-member test asserts against. - if lk(&db.instances).insert(instance_id) { - lk(&db.populated).push(instance_id); - } - Ok(()) - } - - fn evict_instance_population(&self, instance_id: u64) -> Result<()> { - let db = self.xstep("evict_instance_population")?; - if instance_id == 0 { - return Err(anyhow!("instance 0 is the open world")); - } - lk(&db.evicted).push(instance_id); - Ok(()) - } - - fn lookup_session(&self, account_name: &str) -> Result> { - Ok((account_name == self.username) - .then(|| self.session.clone()) - .flatten()) - } - fn characters(&self, _account_id: u64) -> Result> { - self.rec("characters"); - let mut out = self.characters.clone(); - out.extend(self.created_characters.lock().unwrap().iter().cloned()); - Ok(out) - } - fn create_character( - &self, - _account_id: u64, - name: &str, - race: u8, - class: u8, - _gender: u8, - _appearance: codec::Appearance, - ) -> Result { - if self.characters.iter().any(|c| c.name == name) - || self - .created_characters - .lock() - .unwrap() - .iter() - .any(|c| c.name == name) - { - return Ok(codec::CharCreateOutcome::NameInUse); - } - let guid = 500 - + self - .next_created_guid - .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - self.created_characters - .lock() - .unwrap() - .push(codec::CharacterView { - guid, - name: name.to_string(), - race, - class, - level: 1, - ..Default::default() - }); - Ok(codec::CharCreateOutcome::Success) - } - fn delete_character( - &self, - account_id: u64, - character_guid: u64, - ) -> Result { - self.deleted - .lock() - .unwrap() - .push((account_id, character_guid)); - Ok(self - .delete_outcome - .unwrap_or(codec::CharDeleteOutcome::Success)) - } - fn player_login( - &self, - _account_id: u64, - _character_guid: u64, - entry: codec::WorldEntry, - ) -> Result { - self.rec("player_login"); - self.login_entries.lock().unwrap().push(entry); - let call = self - .login_calls - .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - if call > 0 { - if let Some(e) = &self.worldport_login_error { - return Err(anyhow!("{e}")); - } - if let Some(e) = self.worldport_entity.clone() { - return Ok(e); - } - } - self.login_entity - .clone() - .ok_or_else(|| anyhow!("no login entity configured")) - } - fn movement_update( - &self, - _account_id: u64, - _self_guid: u64, - opcode: u32, - info: &MovementInfo, - ) -> Result<()> { - self.rec("movement_update"); - if let Some(e) = &self.movement_error { - return Err(anyhow!("movement_update reducer failed: {e}")); - } - self.moves.lock().unwrap().push(( - opcode, - info.position.x, - info.position.y, - info.position.z, - info.orientation, - info.timestamp, - )); - Ok(()) - } - fn subscribe_player_events( - &self, - _account_id: u64, - self_guid: u64, - arrival: &codec::EntityView, - tx: SessionTx, - ) -> Result { - self.rec("subscribe_player_events"); - self.subscribed - .lock() - .unwrap() - .push((self_guid, arrival.map_id, arrival.x, arrival.y)); - *self.session_depth.lock().unwrap() = Some(tx.depth_handle()); - if self.turn_in_reward_item.is_some() { - *self.turn_in_tx.lock().unwrap() = Some(tx.clone()); - } - let Some(view) = &self.relay_view else { - return Ok(PlayerSubscriptions::empty()); - }; - let subs = PlayerSubscriptions::registered_for_test(view.clone(), self_guid, arrival, tx); - if let (Some(mate), Some(record)) = ( - self.member_stats_before_party_frame, - subs.member_stats_record(), - ) { - let delivered = codec::MemberStats::default(); - record - .lock() - .insert(mate, MemberSnapshot::Live(Box::new(delivered))); - } - Ok(subs) - } - fn character_by_guid(&self, guid: u64) -> Result> { - if let Some(error) = &self.character_read_error { - return Err(anyhow!(error.clone())); - } - Ok(self.characters.iter().find(|c| c.guid == guid).cloned()) - } - - fn character_exists_on_any_world_shard(&self, guid: u64) -> Result { - self.durable_absence_checks - .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - if let Some(error) = &self.world_shard_set_error { - return Err(anyhow!(error.clone())); - } - if self.character_by_guid(guid)?.is_some() { - return Ok(true); - } - for shard in self.peers.lock().unwrap().iter() { - if shard.character_by_guid(guid)?.is_some() { - return Ok(true); - } - } - Ok(false) - } - fn creature_template(&self, _entry: u32) -> Result> { - Ok(None) - } - fn gameobject_template(&self, _entry: u32) -> Result> { - Ok(None) - } - fn gameobject_type(&self, _go_guid: u64) -> Result> { - Ok(self.gameobject_type) - } - fn client_command( - &self, - account_id: u64, - self_guid: u64, - cmd: String, - payload: String, - ) -> Result<()> { - self.client_commands - .lock() - .unwrap() - .push((account_id, self_guid, cmd, payload)); - Ok(()) - } - - fn enter_areatrigger(&self, _account_id: u64, _self_guid: u64, _trigger_id: u32) -> Result<()> { - Ok(()) - } - fn player_skills(&self, _character_guid: u64) -> Result> { - Ok(Vec::new()) - } - fn effective_armor(&self, _guid: u64) -> u32 { - // No gear/auras in the test store → effective == the login entity's armor. - self.login_entity - .as_ref() - .map(|e| e.effective_armor) - .unwrap_or(0) - } - fn npc_refuses_interaction(&self, _npc_guid: u64, _player_guid: u64) -> Result { - Ok(self.npc_refuses) // default false — every existing fixture NPC keeps interacting - } - fn mail_list(&self, recipient_guid: u64) -> Result> { - self.rec("mail_list"); - Ok(self - .mails - .lock() - .unwrap() - .iter() - .filter(|(to, _)| *to == recipient_guid) - .map(|(_, m)| m.clone()) - .collect()) - } - fn mail_by_id(&self, mail_id: u64) -> Result> { - Ok(self - .mails - .lock() - .unwrap() - .iter() - .find(|(_, m)| m.id == mail_id) - .map(|(_, m)| m.clone())) - } - fn realm_account_name(&self, character_guid: u64) -> Result> { - Ok(self - .realm_accounts - .lock() - .unwrap() - .iter() - .find(|(guid, _)| *guid == character_guid) - .map(|(_, name)| name.clone())) - } - fn mailbox_in_range(&self, mailbox_guid: u64, _player_guid: u64) -> Result { - self.rec("mailbox_in_range"); - Ok(self.mailboxes.contains(&mailbox_guid)) - } - /// 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<()> { - self.rec("mail_mark_read"); - let mut mails = self.mails.lock().unwrap(); - let now = mail::now_secs(); - match mails - .iter_mut() - .find(|(to, m)| *to == recipient_guid && m.id == mail_id && m.is_delivered(now)) - { - Some((_, m)) => { - m.was_read = true; - Ok(()) - } - None => Err(anyhow!(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<()> { - self.rec("mail_delete"); - let mut mails = self.mails.lock().unwrap(); - let now = mail::now_secs(); - let Some(at) = mails - .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)); - }; - if mails[at].1.cod > 0 { - return Err(anyhow!(lyracore_shared::mail::COD_MAIL_UNDELETABLE)); - } - mails.remove(at); - Ok(()) - } - /// Models the module's `apply_return`: the SAME row, re-addressed to whoever sent it, with - /// whatever it still carries (or nothing) travelling unchanged — except the cash-on-delivery - /// 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<()> { - self.rec("mail_return"); - self.saw_same_account("mail_return", same_account); - let mut mails = self.mails.lock().unwrap(); - let now = mail::now_secs(); - let Some((to, m)) = mails - .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)); - }; - let lyracore_shared::mail::MailSender::Character(sender @ 1..) = m.sender() else { - return Err(anyhow!(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)); - } - m.sender_guid = recipient_guid; - m.was_read = false; - m.cod = 0; - m.check_flags = lyracore_shared::mail::CHECK_MASK_RETURNED; - m.deliver_secs = now + i64::from(delivery_delay_secs(m.item_entry != 0, same_account)); - *to = sender; - Ok(()) - } - /// Models the module's `apply_send`: the postage plus the attached coin leave the purse and the - /// row is written, in ONE call — the single-database plane's one transaction. The id is - /// per-database, as the module's `auto_inc` is. - #[allow(clippy::too_many_arguments)] - fn mail_send( - &self, - sender_guid: u64, - recipient_guid: u64, - subject: String, - body: String, - money: u32, - cod: u32, - item_guid: u64, - same_account: bool, - ) -> Result<()> { - self.rec("mail_send"); - self.saw_same_account("mail_send", same_account); - let item = self.detach(sender_guid, item_guid)?; - self.debit(sender_guid, lyracore_shared::mail::total_cost(money))?; - self.sent_mail.lock().unwrap().push(( - sender_guid, - recipient_guid, - subject.clone(), - body.clone(), - money, - )); - self.write_mail( - sender_guid, - recipient_guid, - subject, - body, - money, - cod, - &item, - false, - delivery_delay_secs(!item.is_empty(), same_account), - ); - Ok(()) - } - - /// 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<()> { - let (item, settlement) = { - let mails = self.mails.lock().unwrap(); - let now = mail::now_secs(); - let Some((_, m)) = mails - .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)); - }; - if m.item_entry == 0 { - return Err(anyhow!(lyracore_shared::mail::NOTHING_TO_TAKE)); - } - ( - mail::AttachedItem { - entry: m.item_entry, - stack_count: m.item_stack_count, - durability: m.item_durability, - enchant_id: m.item_enchant_id, - soulbound: m.item_soulbound, - random_property_id: m.random_property_id, - item_text_id: self.attached_text_id(mail_id), - }, - lyracore_shared::mail::cod_settlement( - m.cod, - m.sender_guid, - &m.subject, - recipient_guid, - ), - ) - }; - 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))?; - } - 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 - // this from the transaction, and asserting on it is the point of the full-bag test. - if let Some(s) = &settlement { - self.credit(s.payer_guid, s.copper); - } - return Err(e); - } - let mut mails = self.mails.lock().unwrap(); - if let Some((_, m)) = mails - .iter_mut() - .find(|(to, m)| *to == recipient_guid && m.id == mail_id) - { - m.item_entry = 0; - m.item_stack_count = 0; - m.item_durability = 0; - m.item_enchant_id = 0; - m.item_soulbound = false; - m.cod = 0; - } - drop(mails); - self.take_attached_text_id(mail_id); - if let Some(s) = settlement { - self.write_mail( - s.payer_guid, - s.payee_guid, - s.subject, - String::new(), - s.copper, - 0, - &mail::AttachedItem::default(), - true, - 0, - ); - } - Ok(()) - } - - fn mail_item_room(&self, _payee_guid: u64) -> Result<()> { - self.rec("mail_item_room"); - if self.bags_full.load(std::sync::atomic::Ordering::Relaxed) { - return Err(anyhow!(lyracore_shared::mail::INVENTORY_FULL)); - } - Ok(()) - } - /// Models `mail_text::apply_copy_text`: sets COPIED and files the body as item text, on the - /// database that owns the mail row. Refused for a mail that is not the caller's, is not - /// 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<()> { - self.rec("mail_copy_text"); - let mut mails = self.mails.lock().unwrap(); - let now = mail::now_secs(); - let Some((_, m)) = mails - .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)); - }; - if m.body.is_empty() { - return Err(anyhow!("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")); - } - m.check_flags |= lyracore_shared::mail::CHECK_MASK_COPIED; - let text_id = lyracore_shared::mail::item_text_id_for(m.id, &m.body); - let text = m.body.clone(); - drop(mails); - let mut texts = self.item_texts.lock().unwrap(); - if !texts.iter().any(|(id, _)| *id == text_id) { - texts.push((text_id, text)); - } - Ok(()) - } - /// Models `items::grant_letter_item`: one Plain Letter, refused by the same full-bag fixture - /// every other grant uses. A no-op when the payee already holds an item carrying `item_text_id` - /// — 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<()> { - self.rec("mail_grant_letter"); - if self - .granted_letters - .lock() - .unwrap() - .iter() - .any(|&(guid, id)| guid == payee_guid && id == item_text_id) - { - return Ok(()); - } - if self.bags_full.load(std::sync::atomic::Ordering::Relaxed) { - return Err(anyhow!(lyracore_shared::mail::INVENTORY_FULL)); - } - self.granted_letters - .lock() - .unwrap() - .push((payee_guid, item_text_id)); - Ok(()) - } - /// 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<()> { - self.rec("mail_mark_letter_granted"); - let mut mails = self.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)); - }; - m.check_flags |= lyracore_shared::mail::CHECK_FLAG_LETTER_GRANTED; - Ok(()) - } - /// Models the Coordinator's `item_text`: a PK read of `game_item_text` on THIS database. - fn item_text(&self, item_text_id: u32) -> Result> { - self.rec("item_text"); - Ok(self - .item_texts - .lock() - .unwrap() - .iter() - .find(|(id, _)| *id == item_text_id) - .map(|(_, text)| text.clone())) - } - /// Models the Coordinator's `owns_item_with_text`: does `owner_guid` hold a granted letter - /// carrying `item_text_id`? The fake has no item-guid fixture to answer `hint_item_guid`'s fast - /// path distinctly, so both routes resolve through the same `granted_letters` lookup. - fn owns_item_with_text( - &self, - owner_guid: u64, - item_text_id: u32, - _hint_item_guid: u64, - ) -> Result { - self.rec("owns_item_with_text"); - Ok(item_text_id != 0 - && self - .granted_letters - .lock() - .unwrap() - .iter() - .any(|&(guid, id)| guid == owner_guid && id == item_text_id)) - } - /// 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<()> { - self.rec("mail_take_money"); - let money = { - let mut mails = self.mails.lock().unwrap(); - let now = mail::now_secs(); - let Some((_, m)) = mails - .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)); - }; - if m.money == 0 { - return Err(anyhow!(lyracore_shared::mail::NOTHING_TO_TAKE)); - } - std::mem::take(&mut m.money) - }; - self.credit(recipient_guid, money); - Ok(()) - } - /// Models `mail_escrow::apply_fence`: the whole cost leaves the purse into a fence row here. - fn mail_fence( - &self, - escrow_id: u64, - sender_guid: u64, - recipient_guid: u64, - subject: String, - body: String, - money: u32, - postage: u32, - item_guid: u64, - cod: u32, - cod_source_mail_id: u64, - same_account: bool, - ) -> Result<()> { - self.rec("mail_fence"); - self.saw_same_account("mail_fence", same_account); - self.mail_kill("mail_fence")?; - let escrows = self.mail_escrows.lock().unwrap(); - if escrows.iter().any(|(_, e)| e.escrow_id == escrow_id) { - return Ok(()); // replay — the purse must not be debited twice for one letter - } - drop(escrows); - // The attachment before the debit: it is the refusal that can still fire, and nothing has - // been written when it does. - let item = self.detach(sender_guid, item_guid)?; - self.debit(sender_guid, money.saturating_add(postage))?; - let delivery_delay_secs = delivery_delay_secs(!item.is_empty(), same_account); - self.mail_escrows.lock().unwrap().push(( - sender_guid, - mail::HeldEscrow { - escrow_id, - recipient_guid, - subject, - body, - money, - postage, - payout: false, - mail_id: cod_source_mail_id, - item, - cod, - delivery_delay_secs, - reward: None, - }, - )); - self.attested.lock().unwrap().push((escrow_id, false)); - Ok(()) - } - /// Models `mail_escrow::apply_commit`: the row plus a receipt, idempotent on the escrow id. - fn mail_commit( - &self, - escrow_id: u64, - sender_guid: u64, - recipient_guid: u64, - subject: String, - body: String, - money: u32, - item: mail::AttachedItem, - cod: u32, - cod_source_mail_id: u64, - delivery_delay_secs: u32, - reward: Option, - ) -> Result<()> { - self.rec("mail_commit"); - self.mail_kill("mail_commit")?; - let mut receipts = self.mail_receipts.lock().unwrap(); - if receipts.iter().any(|(id, _)| *id == escrow_id) { - return Ok(()); - } - // A COD payment pays only a price its payer still owes on a delivered mail. - if cod_source_mail_id != 0 { - let now = mail::now_secs(); - let owed = self.mails.lock().unwrap().iter().any(|(to, m)| { - m.id == cod_source_mail_id && *to == sender_guid && m.cod > 0 && m.is_delivered(now) - }); - if !owed { - return Err(anyhow!( - "mail {cod_source_mail_id} owes {sender_guid} no delivered price" - )); - } - } - receipts.push((escrow_id, recipient_guid)); - drop(receipts); - self.sent_mail.lock().unwrap().push(( - sender_guid, - recipient_guid, - subject.clone(), - body.clone(), - money, - )); - self.write_mail( - sender_guid, - recipient_guid, - subject, - body, - money, - cod, - &item, - cod_source_mail_id != 0, - // A payment arrives at once whatever the commit carries. - if cod_source_mail_id != 0 { - 0 - } else { - delivery_delay_secs - }, - ); - if let Some(header) = reward { - // Models `mail::Letter::reward`: from the quest giver, naming its Mail Template. - let (sender_kind, sender_guid, sender_entry) = header.giver.sender().columns(); - let mut mails = self.mails.lock().unwrap(); - if let Some((_, m)) = mails.iter_mut().max_by_key(|(_, m)| m.id) { - m.sender_guid = sender_guid; - m.sender_kind = sender_kind; - m.sender_entry = sender_entry; - m.mail_template_id = header.mail_template_id; - m.check_flags = lyracore_shared::mail::CHECK_MASK_HAS_BODY; - } - } - // The price stops being owed in the SAME call that delivers the payment for it — the - // module clears it inside the commit's transaction, which is what makes a COD take charge - // once however the drive is interrupted. - if cod_source_mail_id != 0 { - if let Some((_, m)) = self - .mails - .lock() - .unwrap() - .iter_mut() - .find(|(_, m)| m.id == cod_source_mail_id) - { - m.cod = 0; - } - } - Ok(()) - } - /// Models `mail_escrow::apply_take_fence`: the copper leaves the ROW into a fence here. - fn mail_take_money_fence( - &self, - escrow_id: u64, - payee_guid: u64, - mail_id: u64, - expect_money: u32, - ) -> Result<()> { - self.rec("mail_take_money_fence"); - self.mail_kill("mail_take_money_fence")?; - if self - .mail_escrows - .lock() - .unwrap() - .iter() - .any(|(_, e)| e.escrow_id == escrow_id) - { - return Ok(()); - } - let money = { - let mut mails = self.mails.lock().unwrap(); - let now = mail::now_secs(); - let Some((_, m)) = mails - .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)); - }; - if m.money == 0 { - return Err(anyhow!(lyracore_shared::mail::NOTHING_TO_TAKE)); - } - if m.money != expect_money { - return Err(anyhow!( - "refusing to fence an amount the payout would not match" - )); - } - std::mem::take(&mut m.money) - }; - self.mail_escrows.lock().unwrap().push(( - payee_guid, - mail::HeldEscrow { - escrow_id, - recipient_guid: payee_guid, - subject: String::new(), - body: String::new(), - money, - postage: 0, - payout: true, - mail_id, - item: mail::AttachedItem::default(), - cod: 0, - delivery_delay_secs: 0, - reward: None, - }, - )); - self.attested.lock().unwrap().push((escrow_id, false)); - Ok(()) - } - /// Models `mail_escrow::apply_take_item_fence`: the ATTACHMENT leaves the row into a fence. - fn mail_take_item_fence( - &self, - escrow_id: u64, - payee_guid: u64, - mail_id: u64, - expect_entry: u32, - ) -> Result<()> { - self.rec("mail_take_item_fence"); - self.mail_kill("mail_take_item_fence")?; - if self - .mail_escrows - .lock() - .unwrap() - .iter() - .any(|(_, e)| e.escrow_id == escrow_id) - { - return Ok(()); - } - let item = { - let mut mails = self.mails.lock().unwrap(); - let now = mail::now_secs(); - let Some((_, m)) = mails - .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)); - }; - if m.item_entry == 0 { - return Err(anyhow!(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" - )); - } - let item = mail::AttachedItem { - entry: m.item_entry, - stack_count: m.item_stack_count, - durability: m.item_durability, - enchant_id: m.item_enchant_id, - soulbound: m.item_soulbound, - random_property_id: m.random_property_id, - item_text_id: self.take_attached_text_id(mail_id), - }; - m.item_entry = 0; - m.item_stack_count = 0; - m.item_durability = 0; - m.item_enchant_id = 0; - m.item_soulbound = false; - item - }; - self.mail_escrows.lock().unwrap().push(( - payee_guid, - mail::HeldEscrow { - escrow_id, - recipient_guid: payee_guid, - subject: String::new(), - body: String::new(), - money: 0, - postage: 0, - payout: true, - mail_id, - item, - cod: 0, - delivery_delay_secs: 0, - reward: None, - }, - )); - self.attested.lock().unwrap().push((escrow_id, false)); - Ok(()) - } - /// Models `mail_escrow::apply_item_payout`: the grant plus a receipt, idempotent on the escrow - /// id, and refused by a full bag — which leaves the fence holding the item. - fn mail_item_payout( - &self, - escrow_id: u64, - payee_guid: u64, - _mail_id: u64, - item: mail::AttachedItem, - ) -> Result<()> { - self.rec("mail_item_payout"); - self.mail_kill("mail_item_payout")?; - let receipts = self.mail_receipts.lock().unwrap(); - if receipts.iter().any(|(id, _)| *id == escrow_id) { - return Ok(()); - } - drop(receipts); - self.store_snapshot(payee_guid, &item)?; - self.mail_receipts - .lock() - .unwrap() - .push((escrow_id, payee_guid)); - Ok(()) - } - /// 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<()> { - self.rec("mail_payout"); - self.mail_kill("mail_payout")?; - let mut receipts = self.mail_receipts.lock().unwrap(); - if receipts.iter().any(|(id, _)| *id == escrow_id) { - return Ok(()); - } - if !self - .purses - .lock() - .unwrap() - .iter() - .any(|(g, _)| *g == payee_guid) - { - return Err(anyhow!(lyracore_shared::mail::NOT_IN_WORLD)); - } - receipts.push((escrow_id, payee_guid)); - drop(receipts); - self.credit(payee_guid, amount); - Ok(()) - } - fn mail_confirm_delivery(&self, escrow_id: u64) -> Result<()> { - self.rec("mail_confirm_delivery"); - self.mail_kill("mail_confirm_delivery")?; - let mut attested = self.attested.lock().unwrap(); - match attested.iter_mut().find(|(id, _)| *id == escrow_id) { - Some((_, done)) => { - *done = true; - Ok(()) - } - None => Err(anyhow!("mail escrow {escrow_id}: nothing fenced here")), - } - } - /// Models `mail_escrow::apply_settle`, delete-last included: it REFUSES while unattested. - fn mail_settle(&self, escrow_id: u64) -> Result<()> { - self.rec("mail_settle"); - self.mail_kill("mail_settle")?; - let attested = self - .attested - .lock() - .unwrap() - .iter() - .find(|(id, _)| *id == escrow_id) - .map(|(_, done)| *done); - match attested { - None => Ok(()), // already settled, or this call reached the wrong database - Some(false) => Err(anyhow!( - "mail escrow {escrow_id}: delivery not attested — refusing to destroy the fence" - )), - Some(true) => { - self.mail_escrows - .lock() - .unwrap() - .retain(|(_, e)| e.escrow_id != escrow_id); - self.attested - .lock() - .unwrap() - .retain(|(id, _)| *id != escrow_id); - Ok(()) - } - } - } - fn mail_escrows_of(&self, sender_guid: u64) -> Result> { - self.rec("mail_escrows_of"); - if self - .mail_escrow_reads_before_visible - .fetch_update( - std::sync::atomic::Ordering::SeqCst, - std::sync::atomic::Ordering::SeqCst, - |left| left.checked_sub(1), - ) - .is_ok() - { - return Ok(Vec::new()); // the coordinator cache has not caught up yet - } - Ok(self - .mail_escrows - .lock() - .unwrap() - .iter() - .filter(|(owner, _)| *owner == sender_guid) - .map(|(_, e)| e.clone()) - .collect()) - } - fn trainer_serves(&self, _player_guid: u64, _trainer_guid: u64) -> Result { - Ok(!self.trainer_refuses_class) // default true — every existing fixture trainer serves - } - fn trainer_list( - &self, - _player_guid: u64, - _trainer_guid: u64, - ) -> Result> { - Ok(self.trainer_spells.clone()) - } - fn buy_trainer_spell( - &self, - _account_id: u64, - _self_guid: u64, - _trainer_guid: u64, - _spell_id: u32, - ) -> Result { - if let Some(refusal) = self.trainer_buy_refusal { - return Ok(refusal.into()); - } - match &self.trade_error { - Some(e) => Err(anyhow!("{e}")), - None => Ok(crate::world::TrainerBuyOutcome::Learned), - } - } - fn talent_grant_spell(&self, _talent_id: u32) -> u32 { - self.talent_grant - } - fn set_faction_at_war( - &self, - _account_id: u64, - _self_guid: u64, - _reputation_index: u32, - _at_war: bool, - ) -> Result<()> { - Ok(()) - } - fn set_action_button( - &self, - _account_id: u64, - _self_guid: u64, - _button: u8, - _action: u32, - _action_type: u8, - ) -> Result<()> { - Ok(()) - } - fn talent_pane_sync(&self, _character_guid: u64, _talent_id: u32) -> (u32, u32, u32) { - self.talent_pane - } - fn talent_points_spent(&self, _character_guid: u64) -> u32 { - 0 // login stays byte-identical in every existing harness test - } - fn spell_modifiers(&self, _character_guid: u64) -> Vec<(u32, u8, i32, bool)> { - Vec::new() // no modifier packets in the harness (login stays byte-identical) - } - fn learn_talent(&self, _account_id: u64, _self_guid: u64, _talent_id: u32) -> Result<()> { - match &self.trade_error { - Some(e) => Err(anyhow!("{e}")), - None => Ok(()), - } - } - fn bind_home(&self, _account_id: u64, _self_guid: u64) -> Result<()> { - self.home_bound - .store(true, std::sync::atomic::Ordering::SeqCst); - Ok(()) - } - fn npc_is_innkeeper(&self, _guid: u64) -> Result { - Ok(self.innkeeper) - } - fn npc_gossip_text_id(&self, _npc_guid: u64) -> u32 { - 1 // generic fallback for tests - } - fn npc_text_for_id(&self, _text_id: u32) -> Option { - self.npc_text_view.clone() - } - fn gossip_options(&self, _npc_guid: u64) -> Result> { - Ok(self.gossip_opts.clone()) - } - fn reset_talents(&self, account_id: u64, self_guid: u64, trainer_guid: u64) -> Result<()> { - if let Some(e) = &self.reset_talents_error { - return Err(anyhow!("{e}")); - } - self.reset_talents_calls - .lock() - .unwrap() - .push((account_id, self_guid, trainer_guid)); - Ok(()) - } - fn auto_bank_item(&self, _account_id: u64, _self_guid: u64, slot: u8) -> Result<()> { - if let Some(e) = &self.trade_error { - return Err(anyhow!("{e}")); - } - self.auto_banked_items.lock().unwrap().push(slot); - Ok(()) - } - fn buy_bank_slot(&self, _account_id: u64, _self_guid: u64, banker_guid: u64) -> Result<()> { - if let Some(e) = &self.trade_error { - return Err(anyhow!("{e}")); - } - self.bought_bank_slots.lock().unwrap().push(banker_guid); - Ok(()) - } - fn player_learned_spells(&self, _player_guid: u64) -> Result> { - Ok(self.learned_spells.clone()) - } - fn player_reputations(&self, _player_guid: u64) -> Result> { - Ok(self.reputations.clone()) - } - fn player_actions(&self, _player_guid: u64) -> Result> { - Ok(self.player_actions.clone()) - } - fn resolve_learn_target(&self, spell_id: u32) -> u32 { - spell_id // mock: self-contained ranks (no wrapper table in the mock store) - } - fn trainer_offer_skill_line(&self, _trainer_guid: u64, spell_id: u32) -> u32 { - // The mock's offerings are spell rows unless a test stages a skill-teaching one. - self.trainer_offer_skill_lines - .get(&spell_id) - .copied() - .unwrap_or(0) - } - fn entity_in_world(&self, guid: u64) -> bool { - self.entity_presence_checks - .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - if let Some(present) = &self.entity_presence { - return present.load(std::sync::atomic::Ordering::SeqCst); - } - // `live_guids` is the per-guid answer the realm-wide party frame needs ("is this member - // live on THIS shard"). Empty by default, so the single flag above is still the answer - // every test written before realm-wide party routing set. - self.entity_in_world || self.live_guids.contains(&guid) - } - fn set_target(&self, _account_id: u64, _self_guid: u64, target_guid: u64) -> Result<()> { - self.rec("set_target"); - if let Some(e) = &self.set_target_error { - return Err(anyhow!("{e}")); - } - self.selected_targets.lock().unwrap().push(target_guid); - Ok(()) - } - fn inspect(&self, _account_id: u64, _self_guid: u64, target_guid: u64) -> Result<()> { - if let Some(e) = &self.trade_error { - return Err(anyhow!("{e}")); - } - // 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")); - } - Ok(()) - } - fn pet_command( - &self, - _account_id: u64, - _self_guid: u64, - _data: u32, - _target_guid: u64, - ) -> Result<()> { - Ok(()) - } - fn set_sheathed(&self, _account_id: u64, self_guid: u64, state: u8) -> Result<()> { - self.sheathed.lock().unwrap().push((self_guid, state)); - Ok(()) - } - fn entity_max_health(&self, _guid: u64) -> u32 { - 100 - } - fn superseded_old_rank(&self, _new_spell: u32, _player_guid: u64) -> Option { - self.trainer_superseded - } - fn send_chat( - &self, - _account_id: u64, - _self_guid: u64, - chat_type: u8, - language: u8, - message: String, - ) -> Result { - // 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"); - self.chats - .lock() - .unwrap() - .push((chat_type, language, message)); - Ok(self.send_chat_outcome.unwrap_or(ChatOutcome::Delivered)) - } - fn send_emote( - &self, - _account_id: u64, - _self_guid: u64, - _text_emote: u32, - _emote_anim: u32, - _target_guid: u64, - ) -> Result<()> { - self.rec("send_emote"); - Ok(()) - } - fn gm_command(&self, account_name: &str, _self_guid: u64, text: String) -> Result<()> { - if let Some(alpha_test_tools) = &self.gm_alpha_test_tools { - let authorized = alpha_test_tools.load(std::sync::atomic::Ordering::SeqCst); - self.gm_commands - .lock() - .unwrap() - .push((account_name.to_string(), text.clone())); - self.gm_authority_results.lock().unwrap().push(authorized); - if authorized && (text.starts_with(".speed") || text.starts_with(".tele")) { - self.gm_gameplay_changes.lock().unwrap().push(text); - return Ok(()); - } - return Err(anyhow!("permission denied")); - } - match &self.gm_command_error { - Some(e) => Err(anyhow!("{e}")), - None => { - self.gm_commands - .lock() - .unwrap() - .push((account_name.to_string(), text)); - Ok(()) - } - } - } - fn repop(&self, _account_id: u64, self_guid: u64) -> Result<()> { - self.repopped.lock().unwrap().push(self_guid); - Ok(()) - } - fn claim_session(&self, account_id: u64, _character_guid: u64) -> Result { - Ok(WorldSessionToken { - account_id, - generation: 1, - request_nonce: 1, - }) - } - fn release_session(&self, _token: WorldSessionToken) -> Result<()> { - self.rec("logout"); - self.logout_called - .store(true, std::sync::atomic::Ordering::SeqCst); - match &self.logout_error { - Some(e) => Err(anyhow!("{e}")), - None => Ok(()), - } - } - fn reclaim_corpse(&self, _account_id: u64, self_guid: u64, corpse_guid: u64) -> Result<()> { - self.reclaimed_corpses - .lock() - .unwrap() - .push((self_guid, corpse_guid)); - Ok(()) - } - fn resurrect_response(&self, _account_id: u64, self_guid: u64, accept: bool) -> Result<()> { - self.resurrect_responses - .lock() - .unwrap() - .push((self_guid, accept)); - Ok(()) - } - fn self_resurrect(&self, _account_id: u64, self_guid: u64) -> Result<()> { - self.self_resurrects.lock().unwrap().push(self_guid); - match &self.self_resurrect_error { - Some(e) => Err(anyhow!("{e}")), - None => Ok(()), - } - } - fn spirit_healer_res(&self, _account_id: u64, self_guid: u64, healer_guid: u64) -> Result<()> { - self.spirit_healer_calls - .lock() - .unwrap() - .push((self_guid, healer_guid)); - Ok(()) - } - fn corpse_location(&self, _owner_guid: u64) -> Result> { - Ok(None) - } - fn player_combat_until_ms(&self, _player_guid: u64) -> u64 { - self.combat_until_ms - } - fn character_identity(&self, guid: u64) -> Result> { - Ok(self.characters.iter().find(|c| c.guid == guid).map(|c| { - presence::CharacterIdentity { - guid: c.guid, - name: c.name.clone(), - race: c.race, - class: c.class, - level: c.level, - zone_id: c.zone_id, - // `offline_guids` drives the invite gate's "player not online" arm; a seeded - // character is session-online unless listed there, mirroring `character_presence`. - session_online: !self.offline_guids.contains(&guid), - } - })) - } - fn live_entity(&self, guid: u64) -> Option { - // `member_entities` alone: Member Stats' own tests despawn a guid here while it stays in - // `live_guids` (a party-eligibility signal, not a Member Stats one) to pin the case where a - // group mate's entity is gone but the party frame's own bookkeeping has not caught up — - // falling back to `live_guids` or the blanket `entity_in_world` flag would read that guid - // live again and silently defeat the pin. `in_world_players`'s bulk /who scan has its own, - // separate fallback for a guid this fixture never gave a precise entity to. - self.member_entities - .lock() - .unwrap() - .iter() - .find(|(g, _)| *g == guid) - .map(|(_, e)| e.clone()) - } - fn character_in_transit(&self, guid: u64) -> bool { - self.members_between_places.lock().unwrap().contains(&guid) - } - fn auto_reply_text(&self, guid: u64) -> Result> { - Ok(self.auto_replies.lock().unwrap().get(&guid).cloned()) - } - fn every_shard_vouches_for_absence(&self) -> Result<()> { - // A Realm Presence "gone" claim spans every configured Shard, not just this handle — each - // Fake instance models one Shard's own connection, so the peer set is checked too, the - // same reach `Coordinator::world_shards_for_absence` has from any one of its own handles. - for peer in self.peers.lock().unwrap().iter() { - if let Some(error) = &peer.world_shard_set_error { - return Err(anyhow!(error.clone())); - } - } - if let Some(error) = &self.world_shard_set_error { - return Err(anyhow!(error.clone())); - } - Ok(()) - } - fn in_world_players(&self) -> Result> { - // Test store: every seeded character the fake considers in-world (`entity_in_world`, - // the BLANKET flag included) is listed, so CMSG_WHO tests can assert a response without - // wiring `live_guids` by hand — unlike `live_entity`, which `presence::of` uses for one - // guid at a time and which deliberately does not trust that blanket flag. - // `live_entity` supplies level/zone from `member_entities`/`live_guids` when a test seeded - // one for this guid, else the durable row stands in. - Ok(self - .characters - .iter() - .filter(|c| self.entity_in_world(c.guid)) - .map(|c| { - let entity = self.live_entity(c.guid).unwrap_or(codec::MemberEntity { - level: u32::from(c.level), - zone_id: c.zone_id, - player_flags: self.away_flags.get(&c.guid).copied().unwrap_or(0), - ..Default::default() - }); - presence::RealmPresence { - guid: c.guid, - name: c.name.clone(), - race: c.race, - class: c.class, - level: u8::try_from(entity.level).unwrap_or(u8::MAX), - zone_id: entity.zone_id, - session_online: !self.offline_guids.contains(&c.guid), - whereabouts: presence::Whereabouts::InWorld { - away: self.away(c.guid), - entity, - shard_name: self.shard.clone(), - }, - } - }) - .collect()) - } - fn zone_name(&self, zone_id: u32) -> String { - self.zone_names.get(&zone_id).cloned().unwrap_or_default() - } - fn contact_lists(&self, self_guid: u64) -> Result<(Vec, Vec)> { - if let Some(e) = &self.contact_lists_error { - return Err(anyhow!("{e}")); - } - let contacts = self.contacts.lock().unwrap(); - let mut friends = Vec::new(); - let mut ignored = Vec::new(); - for &(owner, target, is_ignore) in contacts.iter() { - if owner != self_guid { - continue; - } - if is_ignore { - ignored.push(target); - } else { - friends.push(target); - } - } - Ok((friends, ignored)) - } - fn ignored_guids(&self, owner_guid: u64) -> Result> { - if let Some(e) = &self.contact_lists_error { - return Err(anyhow!("{e}")); - } - Ok(self - .contacts - .lock() - .unwrap() - .iter() - .filter(|&&(owner, _, is_ignore)| owner == owner_guid && is_ignore) - .map(|&(_, target, _)| target) - .collect()) - } - fn character_guid_by_name(&self, name: &str) -> Result> { - Ok(self - .characters - .iter() - .find(|c| c.name.eq_ignore_ascii_case(name)) - .map(|c| c.guid)) - } - fn character_presence(&self, guid: u64) -> Result> { - Ok(self - .characters - .iter() - .find(|c| c.guid == guid) - // `offline_guids` drives the invite gate's "player not online" arm. Empty by - // default, so a seeded character is online exactly as it always was. - .map(|c| { - ( - !self.offline_guids.contains(&guid), - c.level, - c.class, - c.zone_id, - ) - })) - } - // Group: a minimal in-memory party — enough for the dispatch tests to drive - // invite-result mapping and the GROUP_LIST build without a live module. - // - // 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 { - self.rec("group_invite"); - if let Some(e) = &self.trade_error { - return faked_party(e); - } - self.group_invites.lock().unwrap().push(target_guid); - Ok(PartyOutcome::Ran) - } - fn group_accept(&self, _account_id: u64, _self_guid: u64) -> Result { - self.rec("group_accept"); - Ok(PartyOutcome::Ran) - } - // Trade: pure recorders, the module owns every gate, so the fake just proves which - // verb the dispatch chose and which args survived the wire. - fn initiate_trade(&self, _account_id: u64, self_guid: u64, target_guid: u64) -> Result<()> { - self.rec("initiate_trade"); - self.initiated_trades - .lock() - .unwrap() - .push((self_guid, target_guid)); - Ok(()) - } - fn begin_trade(&self, _account_id: u64, self_guid: u64) -> Result<()> { - self.rec("begin_trade"); - self.begun_trades.lock().unwrap().push(self_guid); - Ok(()) - } - fn cancel_trade(&self, _account_id: u64, self_guid: u64) -> Result<()> { - self.rec("cancel_trade"); - self.cancelled_trades.lock().unwrap().push(self_guid); - Ok(()) - } - fn set_trade_item( - &self, - _account_id: u64, - self_guid: u64, - trade_slot: u8, - inv_slot: u8, - ) -> Result<()> { - self.rec("set_trade_item"); - self.set_trade_items - .lock() - .unwrap() - .push((self_guid, trade_slot, inv_slot)); - Ok(()) - } - fn clear_trade_item(&self, _account_id: u64, self_guid: u64, trade_slot: u8) -> Result<()> { - self.rec("clear_trade_item"); - self.cleared_trade_items - .lock() - .unwrap() - .push((self_guid, trade_slot)); - Ok(()) - } - fn set_trade_gold(&self, _account_id: u64, self_guid: u64, copper: u32) -> Result<()> { - self.rec("set_trade_gold"); - self.set_trade_golds - .lock() - .unwrap() - .push((self_guid, copper)); - Ok(()) - } - fn accept_trade(&self, _account_id: u64, self_guid: u64) -> Result<()> { - self.rec("accept_trade"); - self.accepted_trades.lock().unwrap().push(self_guid); - Ok(()) - } - fn unaccept_trade(&self, _account_id: u64, self_guid: u64) -> Result<()> { - self.rec("unaccept_trade"); - self.unaccepted_trades.lock().unwrap().push(self_guid); - Ok(()) - } - fn busy_trade(&self, _account_id: u64, self_guid: u64) -> Result<()> { - self.rec("busy_trade"); - self.busy_trades.lock().unwrap().push(self_guid); - Ok(()) - } - fn ignore_trade(&self, _account_id: u64, self_guid: u64) -> Result<()> { - self.rec("ignore_trade"); - self.ignore_trades.lock().unwrap().push(self_guid); - Ok(()) - } - fn group_decline(&self, _account_id: u64, _self_guid: u64) -> Result { - self.rec("group_decline"); - Ok(PartyOutcome::Ran) - } - fn group_leave(&self, _account_id: u64, _self_guid: u64) -> Result { - self.rec("group_leave"); - Ok(PartyOutcome::Ran) - } - fn group_loot_method( - &self, - _account_id: u64, - _self_guid: u64, - 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); - } - self.group_loot_methods - .lock() - .unwrap() - .push((loot_setting, master_guid, loot_threshold)); - Ok(PartyOutcome::Ran) - } - - // --- The realm-core plane (party/group routing) --- - - fn realm_store(&self) -> Option> { - self.realm - .clone() - .map(|r| r as std::sync::Arc) - } - - fn party_cleanup_realm(&self) -> Result>> { - if let Some(error) = &self.party_cleanup_realm_error { - return Err(anyhow!(error.clone())); - } - Ok(self.realm_store()) - } - - fn party_command_realm(&self) -> Result>> { - if let Some(error) = &self.party_command_realm_error { - return Err(anyhow!(error.clone())); - } - Ok(self.realm_store()) - } - - fn transfer_realm(&self) -> Result>> { - if let Some(error) = &self.transfer_realm_error { - return Err(anyhow!(error.clone())); - } - Ok(self.realm_store()) - } - - fn claim_party_command_intent(&self, intent_id: u64, claim_token: u64) -> Result<()> { - self.party_command_claims - .lock() - .unwrap() - .push((intent_id, claim_token)); - Ok(()) - } - - fn admit_party_command_authority( - &self, - group_id: u64, - leader_guid: u64, - bot_guid: u64, - authority_member_guid: u64, - mut expected_members: Vec, - ) -> Result { - let roster = self.group_roster(leader_guid)?; - let Some(roster) = roster else { - return Ok(super::party::CompanionCommandOutcome::NotMember); - }; - if roster.group_id != group_id || roster.leader_guid != leader_guid { - return Ok(super::party::CompanionCommandOutcome::NotLeader); - } - expected_members.sort_unstable(); - let mut current_members = self - .party_command_authority_members - .lock() - .unwrap() - .clone() - .unwrap_or_else(|| roster.member_guids()); - current_members.sort_unstable(); - if expected_members != current_members { - return Ok(super::party::CompanionCommandOutcome::StalePartyMirror); - } - if !roster.has_member(bot_guid) - || (authority_member_guid != 0 && !roster.has_member(authority_member_guid)) - { - return Ok(super::party::CompanionCommandOutcome::NotMember); - } - Ok(super::party::CompanionCommandOutcome::Applied) - } - - fn apply_admitted_party_command( - &self, - command: &super::party::AdmittedCompanionCommand, - ) -> Result { - self.admitted_party_commands - .lock() - .unwrap() - .push(command.clone()); - let outcome = self - .party_command_apply_outcome - .unwrap_or(super::party::CompanionCommandOutcome::Applied); - if outcome != super::party::CompanionCommandOutcome::WaitingForCapacity { - self.party_command_receipts.lock().unwrap().push(( - command.source_identity, - command.intent_id, - outcome, - )); - } - Ok(outcome) - } - - fn finish_party_command_intent( - &self, - intent_id: u64, - claim_token: u64, - outcome: super::party::CompanionCommandOutcome, - ) -> Result<()> { - self.party_command_finishes - .lock() - .unwrap() - .push((intent_id, claim_token, outcome)); - Ok(()) - } - - fn confirm_party_command_receipt( - &self, - source_identity: spacetimedb_sdk::Identity, - intent_id: u64, - ) -> Result> { - if let Some(error) = self.party_command_receipt_error.lock().unwrap().as_ref() { - return Err(anyhow!(error.clone())); - } - Ok(self - .party_command_receipts - .lock() - .unwrap() - .iter() - .find(|(source, id, _)| *source == source_identity && *id == intent_id) - .map(|(_, _, outcome)| *outcome)) - } - - fn confirm_party_command_holder(&self, guid: u64) -> Result { - Ok( - if self - .party_command_in_transit - .lock() - .unwrap() - .contains(&guid) - { - super::party::PartyCommandHolder::InTransit - } else if self - .characters - .iter() - .any(|character| character.guid == guid) - { - super::party::PartyCommandHolder::Present - } else { - super::party::PartyCommandHolder::Missing - }, - ) - } - - fn entity_partition(&self, guid: u64) -> Option<(u32, u64)> { - self.entity_partitions - .lock() - .unwrap() - .iter() - .find(|(candidate, _, _)| *candidate == guid) - .map(|(_, map, instance)| (*map, *instance)) - } - - fn world_stores(&self) -> Vec> { - self.peers - .lock() - .unwrap() - .iter() - .map(|p| p.clone() as std::sync::Arc) - .collect() - } - - fn party_command_worlds(&self) -> Result>> { - if let Some(error) = &self.world_shard_set_error { - return Err(anyhow!(error.clone())); - } - Ok(self.world_stores()) - } - - fn claim_bot_invite_intent(&self, intent_id: u64) -> Result { - if let Some(refusal) = self - .bot_intent_claim_refusals - .lock() - .unwrap() - .get(&intent_id) - { - return Ok(PartyOutcome::Refused(*refusal)); - } - let mut intents = self.bot_invite_intents.lock().unwrap(); - let Some(index) = intents.iter().position(|id| *id == intent_id) else { - return Ok(PartyOutcome::Refused(GroupRefusal::IntentAlreadyClaimed)); - }; - intents.swap_remove(index); - Ok(PartyOutcome::Ran) - } - - fn admit_sessionless_group_action(&self, character_guid: u64) -> Result { - self.rec("admit_sessionless_group_action"); - if self - .sessionless_admission_unavailable - .lock() - .unwrap() - .contains(&character_guid) - { - anyhow::bail!("World Shard admission unavailable"); - } - if let Some(refusal) = self - .sessionless_admission - .lock() - .unwrap() - .get(&character_guid) - { - return Ok(PartyOutcome::Refused(*refusal)); - } - if !self.entity_in_world(character_guid) - || !matches!( - self.character_presence(character_guid), - Ok(Some((false, ..))) - ) - { - return Ok(PartyOutcome::Refused(GroupRefusal::ActorUnavailable)); - } - if self - .suppress_after_admission - .lock() - .unwrap() - .contains(&character_guid) - { - self.sessionless_admission - .lock() - .unwrap() - .insert(character_guid, GroupRefusal::ActionSuppressed); - } - Ok(PartyOutcome::Ran) - } - - /// The module's `realm_group_op`, modelled: the rules the ROUTING depends on, applied to the - /// authority this handle owns. - fn realm_group_op( - &self, - op: u8, - actor_guid: u64, - target_guid: u64, - arg_a: u8, - arg_b: u8, - arg_c: u64, - ) -> Result { - use lyracore_shared::group::{event_kind as kind, realm_op, GroupRefusal}; - self.rec("realm_group_op"); - let mut p = self.party.lock().unwrap(); - if self.cache_lags.load(std::sync::atomic::Ordering::SeqCst) { - let mut stale = self.stale_party.lock().unwrap(); - if stale.is_none() { - *stale = Some(p.clone()); - } - } - p.ops - .push((op, actor_guid, target_guid, arg_a, arg_b, arg_c)); - let full = |p: &FakeParty, group_id: u64| { - p.member_guids(group_id).len() >= p.kind_of(group_id).member_cap() - }; - match op { - realm_op::INVITE => { - if p.group_of(target_guid).is_some() { - return Ok(GroupRefusal::AlreadyInGroup.into()); - } - if p.group_of(actor_guid) - .is_some_and(|group_id| full(&p, group_id)) - { - return Ok(GroupRefusal::GroupFull.into()); - } - p.invites.retain(|(t, _)| *t != target_guid); - p.invites.push((target_guid, actor_guid)); - p.events.push((target_guid, kind::INVITE)); - } - realm_op::ACCEPT => { - if let Some(e) = &self.party_accept_error { - return faked_party(e); - } - let Some(inviter) = p - .invites - .iter() - .find(|(t, _)| *t == actor_guid) - .map(|(_, i)| *i) - else { - return Ok(GroupRefusal::NoPendingInvite.into()); - }; - p.invites.retain(|(t, _)| *t != actor_guid); - let group_id = match p.group_of(inviter) { - Some(g) if full(&p, g) => return Ok(GroupRefusal::GroupFull.into()), - Some(g) => g, - None => { - p.next_group_id += 1; - let g = p.next_group_id; - // The vanilla defaults a freshly-formed party gets: GROUP loot, Uncommon. - p.groups.push((g, inviter, 3, 2, 0)); - p.members.push((g, inviter)); - g - } - }; - let slot = p.joining_slot(group_id); - if slot != RaidSlot::default() { - p.slots.insert(actor_guid, slot); - } - p.members.push((group_id, actor_guid)); - p.push_list(group_id); - } - realm_op::RAID_CONVERT => { - let Some(group_id) = p.group_of(actor_guid) else { - return Ok(GroupRefusal::NotInGroup.into()); - }; - if p.groups.iter().find(|(g, ..)| *g == group_id).map(|e| e.1) != Some(actor_guid) { - return Ok(GroupRefusal::NotLeader.into()); - } - if p.kind_of(group_id) == GroupKind::Party { - p.raids.push(group_id); - p.push_list(group_id); - } - } - realm_op::DECLINE => { - let Some(inviter) = p - .invites - .iter() - .find(|(t, _)| *t == actor_guid) - .map(|(_, i)| *i) - else { - return Ok(GroupRefusal::NoPendingInvite.into()); - }; - p.invites.retain(|(t, _)| *t != actor_guid); - p.events.push((inviter, kind::DECLINE)); - } - realm_op::LEAVE => { - if self - .party_leave_failures - .fetch_update( - std::sync::atomic::Ordering::SeqCst, - std::sync::atomic::Ordering::SeqCst, - |remaining| remaining.checked_sub(1), - ) - .is_ok() - { - return Err(anyhow!("Realm-core LEAVE connection interrupted")); - } - if p.group_of(actor_guid).is_none() { - return Ok(GroupRefusal::NotInGroup.into()); - } - if self - .party_leave_commit_then_error - .swap(false, std::sync::atomic::Ordering::SeqCst) - { - p.remove_member(actor_guid); - return Err(anyhow!("Realm-core LEAVE reply was lost after commit")); - } - p.remove_member(actor_guid); - } - realm_op::UNINVITE => { - let Some(group_id) = p.group_of(actor_guid) else { - return Ok(GroupRefusal::NotInGroup.into()); - }; - if !p.manages(group_id, actor_guid) { - return Ok(GroupRefusal::NotLeader.into()); - } - if p.group_of(target_guid) != Some(group_id) { - return Ok(GroupRefusal::TargetNotInGroup.into()); - } - if target_guid == actor_guid { - return Ok(GroupRefusal::KickSelf.into()); - } - if target_guid == p.leader_of(group_id) { - return Ok(GroupRefusal::NotLeader.into()); - } - p.remove_member(target_guid); - } - realm_op::LOOT_METHOD => { - let Some(group_id) = p.group_of(actor_guid) else { - return Ok(GroupRefusal::NotInGroup.into()); - }; - if let Some(entry) = p.groups.iter_mut().find(|(g, ..)| *g == group_id) { - if entry.1 != actor_guid { - return Ok(GroupRefusal::NotLeader.into()); - } - entry.2 = arg_a; - entry.3 = arg_b; - entry.4 = target_guid; - } - p.push_list(group_id); - } - realm_op::SET_LEADER => { - let Some(group_id) = p.group_of(actor_guid) else { - return Ok(GroupRefusal::NotInGroup.into()); - }; - if p.leader_of(group_id) != actor_guid { - return Ok(GroupRefusal::NotLeader.into()); - } - if target_guid == actor_guid { - return Ok(GroupRefusal::TargetIsSelf.into()); - } - if p.group_of(target_guid) != Some(group_id) { - return Ok(GroupRefusal::TargetNotInGroup.into()); - } - p.set_leader(group_id, target_guid); - p.push_list(group_id); - } - realm_op::SET_ASSISTANT => { - let Some(group_id) = p.group_of(actor_guid) else { - return Ok(GroupRefusal::NotInGroup.into()); - }; - if p.leader_of(group_id) != actor_guid { - return Ok(GroupRefusal::NotLeader.into()); - } - if p.kind_of(group_id) != GroupKind::Raid { - return Ok(GroupRefusal::NotRaid.into()); - } - if target_guid == actor_guid { - return Ok(GroupRefusal::TargetIsSelf.into()); - } - if p.group_of(target_guid) != Some(group_id) { - return Ok(GroupRefusal::TargetNotInGroup.into()); - } - let slot = p.slots.get(&target_guid).copied().unwrap_or_default(); - let assigned = slot.with_assistant(arg_a != 0); - if assigned != slot { - p.slots.insert(target_guid, assigned); - p.push_list(group_id); - } - } - realm_op::CHANGE_SUBGROUP => { - return Ok(p.change_subgroup(actor_guid, target_guid, arg_a)) - } - realm_op::SWAP_SUBGROUP => return Ok(p.swap_subgroup(actor_guid, target_guid, arg_c)), - // Group Broadcasts. Who hears each one is the Module's rule; the routing needs only - // the op on the authority, and a Refusal for a member of no group. - realm_op::READY_CHECK_START..=realm_op::RANDOM_ROLL if self.group_broadcast_error => { - return Err(anyhow!("Realm-core call pipe timed out")); - } - realm_op::READY_CHECK_START - | realm_op::READY_CHECK_ANSWER - | realm_op::TARGET_ICON - | realm_op::MINIMAP_PING => { - if p.group_of(actor_guid).is_none() { - return Ok(GroupRefusal::NotInGroup.into()); - } - } - realm_op::RANDOM_ROLL => {} - other => return Err(anyhow!("unknown realm group op {other}")), - } - Ok(PartyOutcome::Ran) - } - - /// The visibility receipt: the op commits, then the Coordinator cache holds it. - 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 outcome = self.realm_group_op(op, actor_guid, target_guid, arg_a, arg_b, arg_c); - *self.stale_party.lock().unwrap() = None; - outcome - } - - fn group_roster(&self, character_guid: u64) -> Result> { - let read = self - .group_roster_reads - .fetch_add(1, std::sync::atomic::Ordering::SeqCst) - + 1; - if let Some((error_read, error)) = &*self.group_roster_error_on_read.lock().unwrap() { - if read == *error_read { - return Err(anyhow!(error.clone())); - } - } - if self.is_realm { - return Ok(self.realm_cache(|p| p.group_of(character_guid).and_then(|g| p.roster(g)))); - } - Ok(self - .mirror - .lock() - .unwrap() - .iter() - .find(|r| r.has_member(character_guid)) - .cloned()) - } - - fn group_roster_by_id(&self, group_id: u64) -> Result> { - if self.is_realm { - return Ok(self.realm_cache(|p| p.roster(group_id))); - } - Ok(self - .mirror - .lock() - .unwrap() - .iter() - .find(|r| r.group_id == group_id) - .cloned()) - } - - fn group_roster_revision(&self, group_id: u64) -> Result { - Ok(self.held_roster_revision(group_id)?.unwrap_or(1)) - } - - fn held_roster_revision(&self, group_id: u64) -> Result> { - if self.is_realm { - return Ok(self.realm_cache(|p| p.revisions.get(&group_id).copied())); - } - Ok(self - .mirror_revisions - .lock() - .unwrap() - .get(&group_id) - .copied()) - } - - fn party_member_guids(&self) -> Result> { - if !self.is_realm { - return Ok(Vec::new()); - } - Ok(self - .party - .lock() - .unwrap() - .members - .iter() - .map(|(_, guid)| *guid) - .collect()) - } - - fn party_group_ids(&self) -> Result> { - if let Some(error) = &*self.party_group_ids_error.lock().unwrap() { - return Err(anyhow!(error.clone())); - } - if self.is_realm { - return Ok(self - .party - .lock() - .unwrap() - .groups - .iter() - .map(|(group_id, ..)| *group_id) - .collect()); - } - Ok(self - .mirror - .lock() - .unwrap() - .iter() - .map(|roster| roster.group_id) - .collect()) - } - - fn sync_group_mirror(&self, roster: &super::party::GroupRoster) -> Result<()> { - self.rec("sync_group_mirror"); - if self - .mirror_failures - .fetch_update( - std::sync::atomic::Ordering::SeqCst, - std::sync::atomic::Ordering::SeqCst, - |remaining| remaining.checked_sub(1), - ) - .is_ok() - { - return Err(anyhow!("World Shard mirror connection interrupted")); - } - if let Some(e) = &self.mirror_error { - return Err(anyhow!("{e}")); - } - self.mirror_revisions - .lock() - .unwrap() - .insert(roster.group_id, roster.roster_revision); - let mut mirror = self.mirror.lock().unwrap(); - mirror.retain(|r| r.group_id != roster.group_id); - // An empty roster is the disband tombstone — the shard forgets the party rather than - // keeping an empty one, which is what the module's own `sync_group_mirror` does. - if !roster.members.is_empty() { - mirror.push(roster.clone()); - } - Ok(()) - } - - fn loot_roll( - &self, - _account_id: u64, - _self_guid: u64, - corpse_guid: u64, - loot_slot: u32, - vote: u8, - ) -> Result { - if let Some(failure) = &self.loot_action_failure { - return Err(anyhow!(failure.clone())); - } - if let Some(refusal) = self.loot_action_refusal { - return Ok(LootActionStatus::Refused(refusal)); - } - self.loot_rolls - .lock() - .unwrap() - .push((corpse_guid, loot_slot, vote)); - Ok(LootActionStatus::Applied) - } - fn loot_master_give( - &self, - _account_id: u64, - _self_guid: u64, - corpse_guid: u64, - loot_slot: u8, - target_guid: u64, - ) -> Result { - if let Some(failure) = &self.loot_action_failure { - return Err(anyhow!(failure.clone())); - } - if let Some(refusal) = self.loot_action_refusal { - return Ok(LootActionStatus::Refused(refusal)); - } - self.loot_master_gives - .lock() - .unwrap() - .push((corpse_guid, loot_slot, target_guid)); - Ok(LootActionStatus::Applied) - } - - // --- Realm-wide loot rolls --- - - #[allow(clippy::too_many_arguments)] - 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<()> { - self.rec("realm_loot_op"); - self.realm_loot_ops.lock().unwrap().push(( - op, - corpse_guid, - slot, - item_entry, - actor_guid, - vote, - deadline_micros, - recipients, - random_property_id, - promotion_source, - source_roll_id, - )); - if let Some(e) = &self.realm_loot_op_error { - return Err(anyhow!("{e}")); - } - Ok(()) - } - - fn realm_loot_vote( - &self, - corpse_guid: u64, - slot: u8, - actor_guid: u64, - vote: u8, - ) -> Result { - self.realm_loot_op( - lyracore_shared::loot_roll::loot_op::VOTE, - corpse_guid, - slot, - 0, - actor_guid, - vote, - 0, - Vec::new(), - 0, - spacetimedb_sdk::Identity::ZERO, - 0, - )?; - if let Some(failure) = &self.loot_action_failure { - return Err(anyhow!(failure.clone())); - } - Ok(self - .loot_action_refusal - .map_or(LootActionStatus::Applied, LootActionStatus::Refused)) - } - - fn pending_local_rolls(&self) -> Result> { - Ok(self.pending_rolls.lock().unwrap().clone()) - } - - fn settle_loot_roll(&self, corpse_guid: u64, slot: u8, winner_guid: u64) -> Result<()> { - self.rec("settle_loot_roll"); - if let Some(e) = &self.settle_loot_roll_error { - return Err(anyhow!("{e}")); - } - self.settled_rolls - .lock() - .unwrap() - .push((corpse_guid, slot, winner_guid)); - Ok(()) - } - - fn clear_promoted_loot_roll(&self, roll_id: u64) -> Result<()> { - self.rec("clear_promoted_loot_roll"); - self.cleared_rolls.lock().unwrap().push(roll_id); - self.pending_rolls - .lock() - .unwrap() - .retain(|r| r.roll_id != roll_id); - Ok(()) - } - - fn loot_won_since(&self, after_id: u64) -> Result<(u64, Vec<(u64, u8, u64)>)> { - let events = self.won_events.lock().unwrap(); - let watermark = events.len() as u64; - let wins = events - .iter() - .enumerate() - .filter(|(i, _)| (*i as u64 + 1) > after_id) - .map(|(_, w)| *w) - .collect(); - Ok((watermark, wins)) - } - - fn group_uninvite( - &self, - _account_id: u64, - _self_guid: u64, - _target_guid: u64, - ) -> Result { - self.rec("group_uninvite"); - Ok(PartyOutcome::Ran) - } - fn gossip_select( - &self, - _account_id: u64, - _self_guid: u64, - _npc_guid: u64, - option_id: u32, - option_row_id: u32, - ) -> Result<()> { - self.gossip_selects - .lock() - .unwrap() - .push((option_id, option_row_id)); - Ok(()) - } - fn add_friend( - &self, - _account_id: u64, - _self_guid: u64, - target_guid: u64, - _target_race: u8, - ) -> Result { - if let Some(e) = &self.trade_error { - return faked_contact(e); - } - let owner = self.login_entity.as_ref().map(|e| e.guid).unwrap_or(0); - self.contacts - .lock() - .unwrap() - .push((owner, target_guid, false)); - Ok(ContactOutcome::Done) - } - fn del_friend( - &self, - _account_id: u64, - _self_guid: u64, - target_guid: u64, - ) -> Result { - self.remove_contact(target_guid, false) - } - fn add_ignore( - &self, - _account_id: u64, - _self_guid: u64, - target_guid: u64, - ) -> Result { - if let Some(e) = &self.trade_error { - return faked_contact(e); - } - let owner = self.login_entity.as_ref().map(|e| e.guid).unwrap_or(0); - self.contacts - .lock() - .unwrap() - .push((owner, target_guid, true)); - Ok(ContactOutcome::Done) - } - fn del_ignore( - &self, - _account_id: u64, - _self_guid: u64, - target_guid: u64, - ) -> Result { - self.remove_contact(target_guid, true) - } -} - -/// Cast operations for the shared adapter. It configures no cast state: the surviving encrypted -/// tests need one instant ordinary cast, the two auto-repeat spells, and a record of what each -/// cancellation asked for. Route variation — cast time, next-swing, ground area, enchant, -/// disenchant, fishing and lock opening — belongs to the cast seam's own focused adapter. -impl CastStore for InMemoryStore { - fn cast_item_target( - &self, - _account_id: u64, - _self_guid: u64, - _spell_id: u32, - _slot: u8, - ) -> Result<()> { - Ok(()) - } - - fn cancel_aura(&self, _account_id: u64, _self_guid: u64, spell_id: u32) -> Result<()> { - self.cancelled_auras.lock().unwrap().push(spell_id); - Ok(()) - } - fn cancel_cast(&self, _account_id: u64, self_guid: u64) -> Result<()> { - self.cancelled_casts.lock().unwrap().push(self_guid); - Ok(()) - } - fn cast_spell( - &self, - _account_id: u64, - _self_guid: u64, - spell_id: u32, - target_guid: u64, - ) -> Result<()> { - self.casts.lock().unwrap().push((spell_id, target_guid)); - Ok(()) - } - fn start_ranged_attack( - &self, - _account_id: u64, - _self_guid: u64, - target_guid: u64, - spell_id: u32, - ) -> Result<()> { - self.ranged_attacks - .lock() - .unwrap() - .push((target_guid, spell_id)); - Ok(()) - } - fn spell_is_ranged_auto_repeat(&self, spell_id: u32) -> bool { - // Mirrors the real RANGED_AUTO_REPEAT cast_flags bit for the two vanilla auto-repeat abilities. - matches!(spell_id, 75 | 5019) - } - /// Every other spell is an instant ordinary cast — the one route these tests send over a socket. - fn spell_cast_time(&self, _spell_id: u32) -> Option { - Some(0) - } - fn spell_queues_next_swing(&self, _spell_id: u32) -> bool { - false - } - fn spell_is_ground_area(&self, _spell_id: u32) -> bool { - false - } - fn enchant_route(&self, _spell_id: u32) -> Option { - None - } - fn spell_is_fishing(&self, _spell_id: u32) -> bool { - false - } - fn spell_is_open_lock(&self, _spell_id: u32) -> bool { - false - } - fn cast_spell_at( - &self, - _account_id: u64, - _self_guid: u64, - _spell_id: u32, - _target_guid: u64, - _x: f32, - _y: f32, - _z: f32, - ) -> Result<()> { - Ok(()) - } - fn item_slot_by_guid(&self, _account_id: u64, _item_guid: u64) -> Option { - None - } - fn disenchant_item(&self, _account_id: u64, _self_guid: u64, _slot: u8) -> Result<()> { - Ok(()) - } - fn enchant_item_on_slot( - &self, - _account_id: u64, - _self_guid: u64, - _slot: u8, - _enchant_id: u32, - ) -> Result<()> { - Ok(()) - } - fn fish(&self, _account_id: u64, _self_guid: u64) -> Result<()> { - Ok(()) - } - fn pick_lock(&self, _account_id: u64, _self_guid: u64, _go_guid: u64) -> Result<()> { - Ok(()) - } - - // Shared with the character, vendor and query paths, so these two keep real fixtures. - - fn player_items(&self, _owner_guid: u64) -> Result> { - Ok(self.player_items_fixture.clone()) - } - fn item_template(&self, entry: u32) -> Result> { - Ok(self - .item_templates - .iter() - .find(|template| template.entry == entry) - .cloned()) - } -} - -/// Taxi behavior has focused handler tests. The broad encrypted-session store opts out unless a -/// socket test explicitly needs a taxi reply. -impl TaxiActionStore for InMemoryStore { - fn taxi_node_status( - &self, - character_guid: u64, - npc_guid: u64, - ) -> Result> { - self.taxi_calls - .lock() - .unwrap() - .push(("status", character_guid, npc_guid)); - if let Some(error) = &self.taxi_error { - return Err(anyhow!("{error}")); - } - Ok(self.taxi_status) - } - - fn open_taxi(&self, character_guid: u64, npc_guid: u64) -> Result> { - self.taxi_calls - .lock() - .unwrap() - .push(("open", character_guid, npc_guid)); - if let Some(error) = &self.taxi_error { - return Err(anyhow!("{error}")); - } - Ok(self.taxi_map.clone()) - } - - fn activate_taxi( - &self, - character_guid: u64, - npc_guid: u64, - source_client_node_id: u32, - destination_client_node_id: u32, - ) -> Result { - self.taxi_calls - .lock() - .unwrap() - .push(("activate", character_guid, npc_guid)); - self.taxi_activation_inputs.lock().unwrap().push(( - character_guid, - npc_guid, - source_client_node_id, - destination_client_node_id, - )); - if let Some(error) = &self.taxi_error { - return Err(anyhow!("{error}")); - } - Ok(self.taxi_activation) - } - - fn arm_taxi_flight(&self, character_guid: u64) -> Result<()> { - self.taxi_calls - .lock() - .unwrap() - .push(("arm", character_guid, 0)); - Ok(()) - } -} - -impl MeleeActionStore for InMemoryStore { - fn start_attack(&self, _account_id: u64, _self_guid: u64, _target_guid: u64) -> Result<()> { - self.rec("start_attack"); - match &self.start_attack_error { - Some(e) => Err(anyhow!("{e}")), - None => Ok(()), - } - } - fn stop_attack(&self, _account_id: u64, self_guid: u64) -> Result<()> { - self.stop_attacks.lock().unwrap().push(self_guid); - Ok(()) - } -} - -impl ChatActionStore for InMemoryStore { - fn speaker_facts(&self, _speaker_guid: u64) -> Result> { - Ok(self.speaker_facts.clone()) - } - - fn realm_chat(&self, speaker_guid: u64, request: RealmChatRequest) -> Result { - self.realm_chats - .lock() - .unwrap() - .push((speaker_guid, request)); - Ok(self.realm_chat_outcome.unwrap_or(ChatOutcome::Delivered)) - } - - fn set_away(&self, speaker_guid: u64, kind: u8, message: String) -> Result<()> { - self.away_requests - .lock() - .unwrap() - .push((speaker_guid, kind, message)); - Ok(()) - } - - fn whisper_target( - &self, - speaker_guid: u64, - typed_name: &str, - ) -> Result> { - whisper::target_facts(self, speaker_guid, typed_name) - } - - /// 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 { - self.rec("realm_whisper"); - self.realm_whispers - .lock() - .unwrap() - .push((speaker_guid, request)); - if let Some(e) = &self.realm_whisper_error { - return Err(anyhow!("{e}")); - } - Ok(self.realm_whisper_outcome.unwrap_or(ChatOutcome::Delivered)) - } - - fn speaker_gm_level(&self, _speaker_guid: u64) -> Result { - Ok(self.gm_level) - } -} - -impl ChannelActionStore for InMemoryStore { - fn channel_op( - &self, - actor_guid: u64, - op: u8, - request: ChannelRequest, - ) -> Result { - self.channel_ops - .lock() - .unwrap() - .push((actor_guid, op, request)); - Ok(self.channel_outcome.unwrap_or(ChannelOutcome::Done)) - } - - fn channel_roster(&self, _team: u32, _channel_name: &str) -> Result> { - Ok(None) - } - - 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 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 InMemoryStore { - fn admit_meeting_stone(&self, _actor_guid: u64, _go_guid: u64) -> Result { - Ok(MeetingStoneOutcome::Refused( - lyracore_shared::meeting_stone::MeetingStoneRefusal::NotAMeetingStone, - )) - } - - fn meeting_stone_area(&self, _go_guid: u64) -> Result> { - Ok(None) - } - - fn party_members(&self, _actor_guid: u64) -> Result>> { - Ok(None) - } - - fn seeker_facts(&self, _character_guid: u64) -> Result> { - Ok(None) - } - - fn meeting_stone_op( - &self, - _actor_guid: u64, - _op: u8, - _area_id: u32, - _seekers: Vec, - ) -> Result { - Ok(MeetingStoneOutcome::Ran) - } - - fn queued_area(&self, _character_guid: u64) -> Result> { - Ok(None) - } -} - -impl DuelActionStore for InMemoryStore { - fn duel_accept(&self, _account_id: u64, _actor_guid: u64, _flag_guid: u64) -> Result<()> { - Ok(()) - } - - fn duel_cancel(&self, _account_id: u64, _actor_guid: u64, _flag_guid: u64) -> Result<()> { - Ok(()) - } -} - -impl GuildActionStore for InMemoryStore { - fn guild_member(&self, character_guid: u64) -> Result> { - Ok(self - .guild_memberships - .iter() - .find(|member| member.character_guid == character_guid) - .cloned()) - } - - fn guild(&self, guild_id: u32) -> Result> { - Ok(self - .guilds - .iter() - .find(|guild| guild.guild_id == guild_id) - .cloned()) - } - - fn guild_members(&self, _guild_id: u32) -> Result> { - Ok(Vec::new()) - } - - fn guild_character_facts(&self, character_guid: u64) -> Result> { - Ok(self - .characters - .iter() - .find(|c| c.guid == character_guid) - .map(|c| CharacterFacts { - guid: c.guid, - name: c.name.clone(), - race: c.race, - class: c.class, - level: c.level, - zone_id: c.zone_id, - last_logout_micros: 0, - online: !self.offline_guids.contains(&c.guid), - in_transit: false, - realm_account_id: 0, - })) - } - - fn guild_characters_named(&self, name: &str) -> Result> { - Ok(self - .characters - .iter() - .filter(|c| c.name.eq_ignore_ascii_case(name)) - .map(|c| c.guid) - .collect()) - } - - fn guild_gm_level(&self, _actor_guid: u64) -> Result { - Ok(0) - } - - fn guild_selected_target(&self, _actor_guid: u64) -> u64 { - 0 - } - - fn guild_ignored_by(&self, _owner_guid: u64, _other_guid: u64) -> Result { - Ok(false) - } - - fn guild_op(&self, actor_guid: u64, request: GuildRequest) -> Result { - if request == GuildRequest::ForgetDeletedCharacter { - self.rec(&format!("guild_op:ForgetDeletedCharacter:{actor_guid}")); - return Ok(GuildOutcome::Ran); - } - self.rec(match request { - GuildRequest::GmCreate { .. } => "guild_op:GmCreate", - GuildRequest::SignOn { .. } => "guild_op:SignOn", - GuildRequest::SignOff => "guild_op:SignOff", - GuildRequest::Invite { .. } => "guild_op:Invite", - GuildRequest::Accept { .. } => "guild_op:Accept", - GuildRequest::Decline { .. } => "guild_op:Decline", - GuildRequest::Leave => "guild_op:Leave", - GuildRequest::Remove { .. } => "guild_op:Remove", - GuildRequest::Promote { .. } => "guild_op:Promote", - GuildRequest::Demote { .. } => "guild_op:Demote", - GuildRequest::SetLeader { .. } => "guild_op:SetLeader", - GuildRequest::Disband => "guild_op:Disband", - GuildRequest::SetMotd { .. } => "guild_op:SetMotd", - GuildRequest::SetInfo { .. } => "guild_op:SetInfo", - GuildRequest::SetPublicNote { .. } => "guild_op:SetPublicNote", - GuildRequest::SetOfficerNote { .. } => "guild_op:SetOfficerNote", - GuildRequest::EditRank { .. } => "guild_op:EditRank", - GuildRequest::AddRank { .. } => "guild_op:AddRank", - GuildRequest::DeleteRank => "guild_op:DeleteRank", - GuildRequest::SignPetition { .. } => "guild_op:SignPetition", - GuildRequest::OfferPetition { .. } => "guild_op:OfferPetition", - GuildRequest::DeclinePetition { .. } => "guild_op:DeclinePetition", - GuildRequest::RenamePetition { .. } => "guild_op:RenamePetition", - GuildRequest::TurnInPetition { .. } => "guild_op:TurnInPetition", - GuildRequest::ClosePetition { .. } => "guild_op:ClosePetition", - GuildRequest::ForgetDeletedCharacter => unreachable!("recorded with its actor above"), - }); - Ok(GuildOutcome::Ran) - } - - fn guild_npc_refuses(&self, _npc_guid: u64, _actor_guid: u64) -> Result { - Ok(false) - } - - fn guild_petition_of_charter( - &self, - charter_item_guid: u64, - ) -> Result> { - Ok(self - .guild_petitions - .iter() - .find(|petition| petition.charter_item_guid == charter_item_guid) - .cloned()) - } - - fn guild_petition_of_owner(&self, owner_guid: u64) -> Result> { - Ok(self - .guild_petitions - .iter() - .find(|petition| petition.owner_guid == owner_guid) - .cloned()) - } - - fn guild_name_taken(&self, name: &str) -> Result { - Ok(self - .guilds - .iter() - .any(|guild| guild.name.eq_ignore_ascii_case(name))) - } - - fn guild_holds_charter(&self, _actor_guid: u64, charter_item_guid: u64) -> Result { - Ok(self.held_charters.contains(&charter_item_guid)) - } - - fn guild_destroy_charter(&self, _actor_guid: u64, _charter_item_guid: u64) -> Result<()> { - self.rec("guild_destroy_charter"); - Ok(()) - } - - fn guild_character_guids(&self) -> Result> { - let mut guids: Vec = self - .guild_memberships - .iter() - .map(|member| member.character_guid) - .chain(self.guild_petitions.iter().flat_map(|petition| { - std::iter::once(petition.owner_guid).chain(petition.signers.iter().copied()) - })) - .collect(); - guids.sort_unstable(); - guids.dedup(); - Ok(guids) - } - - fn guild_names_character(&self, character_guid: u64) -> Result { - if self.guild_lookup_error_for == Some(character_guid) { - return Err(anyhow!("guild lookup for {character_guid} failed")); - } - Ok(self.guild_character_guids()?.contains(&character_guid)) - } - - fn guild_name_of_member(&self, character_guid: u64) -> Result> { - let Some(member) = self.guild_member(character_guid)? else { - return Ok(None); - }; - Ok(self.guild(member.guild_id)?.map(|guild| guild.name)) - } -} - -/// Realm-core refuses every fee: the socket tests only watch the steps run. -impl crate::world::guild_fee::GuildFeeStore for InMemoryStore { - fn guild_fee_held(&self, _actor_guid: u64) -> Result> { - Ok(self.guild_fee_hold.lock().unwrap().clone()) - } - - fn guild_fee_hold( - &self, - _actor_guid: u64, - _request: crate::world::guild_fee::FeeRequest, - ) -> Result> - { - self.rec("guild_fee_hold"); - Ok(Err(lyracore_shared::guild::GuildRefusal::NotEnoughMoney)) - } - - fn guild_fee_decide( - &self, - _actor_guid: u64, - _hold: crate::world::guild_fee::FeeHold, - ) -> Result { - self.rec("guild_fee_decide"); - Ok(crate::world::guild_fee::FeeOutcome::Refused( - lyracore_shared::guild::GuildRefusal::NotLeader, - )) - } - - fn guild_fee_finish( - &self, - _actor_guid: u64, - _operation_id: u64, - _accepted: bool, - ) -> Result<()> { - self.rec("guild_fee_finish"); - *self.guild_fee_hold.lock().unwrap() = None; - Ok(()) - } -} - -impl AuctionActionStore for InMemoryStore { - fn auction_interaction( - &self, - _player_guid: u64, - _auctioneer_guid: u64, - ) -> Result> { - self.rec("auction_interaction"); - Ok(self.auction_interaction) - } - - fn create_auction( - &self, - _request: super::handlers::CreateAuctionRequest, - ) -> Result { - Ok(super::handlers::CreateAuctionOutcome::Database) - } - - fn place_bid( - &self, - _request: super::handlers::PlaceBidRequest, - ) -> Result { - Ok(super::handlers::PlaceBidOutcome::Database) - } - - fn cancel_auction( - &self, - _request: super::handlers::CancelAuctionRequest, - ) -> Result { - Ok(super::handlers::CancelAuctionOutcome::Stale) - } - - fn resume_auction_holds(&self, _actor_guid: u64) -> Result<()> { - Ok(()) - } - - fn auction_query( - &self, - _player_guid: u64, - _house_id: u32, - _query: super::handlers::AuctionQuery, - ) -> Result { - Ok(super::handlers::AuctionPage { - rows: Vec::new(), - total: 0, - now_micros: 0, - }) - } -} - -impl QuestActionStore for InMemoryStore { - fn giver_quest_evals( - &self, - _giver_guid: u64, - _player_guid: u64, - ) -> Result> { - Ok(self.quest_evals.clone()) - } - - fn quest_detail_view(&self, quest_id: u32) -> Result> { - Ok(self - .quest_details - .iter() - .find(|d| d.quest_id == quest_id) - .cloned()) - } - - fn giver_refuses_interaction(&self, _giver_guid: u64, _player_guid: u64) -> Result { - Ok(self.npc_refuses) - } - - /// Accept, abandon and share are driven only at the quest seam, where - /// `InMemoryQuestActions` records the request — a socket test would prove nothing more, so - /// this store just answers success. - fn accept_quest( - &self, - _account_id: u64, - _self_guid: u64, - _giver_guid: u64, - _quest_id: u32, - ) -> Result<()> { - Ok(()) - } - - /// No socket test drives an item-started quest — the route is proved at the quest seam. - fn item_start_quest(&self, _owner_guid: u64, _slot: u8) -> Option<(u64, u32)> { - None - } - - fn player_quest_log(&self, _player_guid: u64) -> Result> { - Ok(self.quest_log_slots.clone()) - } - - fn abandon_quest(&self, _account_id: u64, _self_guid: u64, _quest_id: u32) -> Result<()> { - Ok(()) - } - - fn push_quest(&self, _account_id: u64, _self_guid: u64, _quest_id: u32) -> Result<()> { - Ok(()) - } - - fn quest_status(&self, _player_guid: u64, quest_id: u32) -> (bool, bool) { - match self - .quest_log - .lock() - .unwrap() - .iter() - .find(|(id, _)| *id == quest_id) - { - Some((_, rewarded)) => (true, *rewarded), - None => (false, false), - } - } - - fn turn_in_quest( - &self, - account_id: u64, - _self_guid: u64, - giver_guid: u64, - quest_id: u32, - reward_index: u32, - ) -> Result<()> { - if let Some(e) = &self.trade_error { - return Err(anyhow!("{e}")); - } - self.turned_in - .lock() - .unwrap() - .push((account_id, giver_guid, quest_id, reward_index)); - if let Some(letter) = self.turn_in_reward_letter.clone() { - self.attested - .lock() - .unwrap() - .push((letter.escrow_id, false)); - self.mail_escrows - .lock() - .unwrap() - .push((letter.recipient_guid, letter)); - } - if let (Some(item), Some(tx)) = ( - self.turn_in_reward_item.clone(), - self.turn_in_tx.lock().unwrap().take(), - ) { - let mut relay = vec![Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( - Box::new(codec::build_item_create_object(&item)), - ))]; - if let Some(pointer) = - codec::build_inv_slot_values(item.owner_guid, item.slot, item.guid) - { - relay.push(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( - Box::new(pointer), - ))); - } - relay.push(Outbound::One(ServerOpcodeMessage::SMSG_ITEM_PUSH_RESULT( - Box::new(codec::build_item_push_result( - item.owner_guid, - 255, - item.slot as u32, - item.entry, - item.stack_count, - false, - 0, - )), - ))); - tx.send(Outbound::Job(Box::new(move || relay))) - .map_err(|_| anyhow!("reward item relay writer is gone"))?; - } - Ok(()) - } -} - -impl VendorActionStore for InMemoryStore { - fn vendor_stock(&self, _vendor_guid: u64) -> Result> { - Ok(self.vendor_stock.clone()) - } - - fn vendor_refuses_interaction(&self, _vendor_guid: u64, _player_guid: u64) -> Result { - Ok(self.npc_refuses) - } - - fn vendor_buy( - &self, - _account_id: u64, - _self_guid: u64, - _vendor_guid: u64, - _item_entry: u32, - _count: u32, - ) -> Result<()> { - match &self.trade_error { - Some(e) => Err(anyhow!("{e}")), - None => Ok(()), - } - } - - fn buyback_slots(&self, _player_guid: u64) -> Vec<(u32, u32, u32, u32)> { - self.buyback_ring.clone() - } - - fn random_property_enchant_ids(&self, _random_property_id: u32) -> [u32; 3] { - [0; 3] - } - - fn vendor_item_slot(&self, item_guid: u64) -> Option { - self.item_slots - .iter() - .find(|(g, _)| *g == item_guid) - .map(|&(_, s)| s) - } - - fn vendor_repair( - &self, - _account_id: u64, - _self_guid: u64, - _npc_guid: u64, - _slot: u8, - ) -> Result<()> { - match &self.trade_error { - Some(e) => Err(anyhow!("{e}")), - None => Ok(()), - } - } - - fn vendor_sell( - &self, - _account_id: u64, - _self_guid: u64, - _vendor_guid: u64, - _slot: u8, - ) -> Result<()> { - match &self.trade_error { - Some(e) => Err(anyhow!("{e}")), - None => Ok(()), - } - } - - fn vendor_buyback( - &self, - _account_id: u64, - _self_guid: u64, - vendor_guid: u64, - slot: u8, - ) -> Result<()> { - if let Some(e) = &self.trade_error { - return Err(anyhow!("{e}")); - } - self.bought_back.lock().unwrap().push((vendor_guid, slot)); - Ok(()) - } -} - -impl InMemoryStore { - /// The Coordinator answers a Refusal tag as an outcome and anything else as a failure with an - /// unknown durable result, so `trade_error` reaches the item family the same way. - fn canned_item_action(&self) -> Result { - match &self.trade_error { - None => Ok(ItemActionResult::Done), - Some(e) => ItemRefusal::parse_tag(e) - .map(ItemActionResult::from) - .ok_or_else(|| anyhow!("{e}")), - } - } -} - -impl ItemActionStore for InMemoryStore { - fn equip_item( - &self, - _account_id: u64, - _self_guid: u64, - _from_slot: u8, - ) -> Result { - self.canned_item_action() - } - - fn unequip_item( - &self, - _account_id: u64, - _self_guid: u64, - _from_slot: u8, - ) -> Result { - self.canned_item_action() - } - - fn move_item( - &self, - _account_id: u64, - _self_guid: u64, - _from_slot: u8, - _to_slot: u8, - ) -> Result { - self.canned_item_action() - } - - fn use_item(&self, _account_id: u64, _self_guid: u64, slot: u8) -> Result { - self.used_items.lock().unwrap().push(slot); - self.canned_item_action() - } -} - -impl WeatherStore for InMemoryStore { - fn zone_weather(&self, zone_id: u32) -> Result> { - if let Some(e) = &self.weather_error { - return Err(anyhow!("{e}")); - } - Ok(self - .zone_weather - .iter() - .find(|(zone, _)| *zone == zone_id) - .map(|(_, view)| *view)) - } -} - -/// Member Stats over the same party state the routing tests use: Realm-core's `party` when this -/// handle has a realm, its own `mirror` on a single database. Presence reads this shard and its -/// peers, every World Shard, like `Coordinator::member_presence`. -impl MemberStatsStore for InMemoryStore { - fn group_mates(&self, self_guid: u64) -> Result> { - let roster = match &self.realm { - Some(realm) => { - let party = realm.party.lock().unwrap(); - party - .group_of(self_guid) - .and_then(|group| party.roster(group)) - } - None => self - .mirror - .lock() - .unwrap() - .iter() - .find(|roster| roster.has_member(self_guid)) - .cloned(), - }; - Ok(roster - .map(|roster| roster.member_guids()) - .unwrap_or_default() - .into_iter() - .filter(|member| *member != self_guid) - .collect()) - } - - fn member_presence(&self, guid: u64) -> Result { - self.member_presence_reads - .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - // Aura slots and a live pet are Member Stats' own overlay in production - // (`Coordinator::with_member_shard_stats`, keyed by `ShardId`) — this Fake has no - // `AuraIndex` to key into, so `entity` carries whatever `member_entities`/`live_entity`'s - // fallback already gave it (empty auras, no pet, unless a test seeded `member_entities` - // with its own). - Ok( - match presence::of(self, guid)?.map(|presence| presence.whereabouts) { - Some(presence::Whereabouts::InWorld { entity, .. }) => { - MemberPresence::Live(Box::new(codec::MemberStats::from_entity(&entity))) - } - Some(presence::Whereabouts::InTransit) => MemberPresence::InTransit, - Some(presence::Whereabouts::Offline) | None => MemberPresence::Offline, - }, - ) - } -} - -impl LootWindowStore for InMemoryStore { - fn loot_target_money(&self, _target_guid: u64) -> Result { - Ok(self.corpse_money) - } - - fn loot_target_items( - &self, - _target_guid: u64, - viewer_guid: u64, - ) -> Result> { - self.corpse_loot_reads - .lock() - .unwrap() - .push((_target_guid, viewer_guid)); - Ok(self - .corpse_loot_by_viewer - .get(&viewer_guid) - .cloned() - .unwrap_or_default()) - } - - fn use_gameobject( - &self, - _account_id: u64, - _actor_guid: u64, - target_guid: u64, - ) -> Result { - self.gameobjects_used.lock().unwrap().push(target_guid); - Ok(LootWindowRequestStatus::Applied) - } - - fn open_creature_loot( - &self, - _account_id: u64, - _actor_guid: u64, - _corpse_guid: u64, - ) -> Result { - Ok(LootWindowRequestStatus::Applied) - } - - fn skin_corpse( - &self, - _account_id: u64, - _actor_guid: u64, - target_guid: u64, - ) -> Result { - if let Some(error) = &self.skinning_failure { - return Err(anyhow!(error.clone())); - } - if let Some(refusal) = self.skinning_refusal { - return Ok(LootWindowRequestStatus::Refused(refusal)); - } - self.skinned.lock().unwrap().push(target_guid); - Ok(LootWindowRequestStatus::Applied) - } - - fn loot_money( - &self, - _account_id: u64, - _actor_guid: u64, - target_guid: u64, - ) -> Result { - self.money_looted.lock().unwrap().push(target_guid); - Ok(LootWindowRequestStatus::Applied) - } - - fn take_loot( - &self, - _account_id: u64, - _actor_guid: u64, - target_guid: u64, - loot_slot: u8, - ) -> Result { - self.items_looted - .lock() - .unwrap() - .push((target_guid, loot_slot)); - Ok(LootWindowRequestStatus::Applied) - } -} - -fn ns(s: &str) -> NormalizedString { - NormalizedString::new(s).unwrap() -} - -/// Drive the shared prefix of every world handshake: read the plaintext `SMSG_AUTH_CHALLENGE`, -/// derive the client-side proof + cipher pair for `key` with `wow_srp`, and send -/// `CMSG_AUTH_SESSION`. Lower-level than [`client_handshake`] — it does not read whatever comes -/// back, so a call site can assert on that itself (an `AuthOk`, an `AuthWaitQueue`, a plaintext -/// rejection...). Returns the cipher pair `into_client_header_crypto` derived, split; a `key` that -/// does not match what the server holds still produces a (mismatched, useless) pair here — the -/// send happens regardless — so a rejection-path call site is free to bind them as `_`. -fn drive_auth( - client: &mut S, - username: &str, - key: [u8; 40], -) -> (EncrypterHalf, DecrypterHalf) { - let server_seed = match ServerOpcodeMessage::read_unencrypted(&mut *client).unwrap() { - ServerOpcodeMessage::SMSG_AUTH_CHALLENGE(c) => c.server_seed, - other => panic!("expected SMSG_AUTH_CHALLENGE, got {other}"), - }; - let client_seed = ProofSeed::new(); - let client_seed_value = client_seed.seed(); - let (client_proof, crypto) = - client_seed.into_client_header_crypto(&ns(username), key, server_seed); - let (enc, dec) = crypto.split(); - - auth_session(username, client_seed_value, client_proof) - .write_unencrypted_client(&mut *client) - .unwrap(); - - (enc, dec) -} - -/// Drive the client side of the world handshake against a server running `run_world_session` -/// (or `world_handshake`): read the plaintext challenge, send `CMSG_AUTH_SESSION` with a -/// valid proof, read the encrypted AUTH_OK. Returns the client's cipher halves for the -/// post-handshake encrypted traffic. -fn client_handshake( - client: &mut S, - username: &str, - key: [u8; 40], -) -> (EncrypterHalf, DecrypterHalf) { - let (enc, mut dec) = drive_auth(client, username, key); - - match ServerOpcodeMessage::read_encrypted(&mut *client, &mut dec).unwrap() { - ServerOpcodeMessage::SMSG_AUTH_RESPONSE(r) => { - assert!(matches!(*r, SMSG_AUTH_RESPONSE::AuthOk { .. })); - } - other => panic!("expected encrypted SMSG_AUTH_RESPONSE, got {other}"), - } - (enc, dec) -} - -fn auth_session(username: &str, client_seed: u32, client_proof: [u8; 20]) -> CMSG_AUTH_SESSION { - CMSG_AUTH_SESSION { - build: 5875, - server_id: 1, - username: username.to_string(), - client_seed, - client_proof, - addon_info: vec![], - } -} - -/// A store for a logged-in TESTER (account `account_id`), with no character/scenario state beyond -/// the session itself — the shape every handshake/login/logout/movement test overlays with its own -/// fields via `..`. `quest_store()` is the sibling for tests that also need a login entity. -fn tester_store(account_id: u64) -> InMemoryStore { - InMemoryStore { - entity_in_world: true, - username: "TESTER".into(), - session: Some(WorldSession { - account_id, - session_key: K, - }), - ..Default::default() - } -} - -#[test] -fn handshake_succeeds_and_traffic_is_encrypted_both_ways() { - let store = std::sync::Arc::new(tester_store(42)); - - let (mut client, server_end) = world_session_socket_pair(); - let server_store = store.clone(); - let server = std::thread::spawn(move || { - let mut s = server_end; - let (mut conn, _encrypt) = world_handshake(&mut s, server_store.as_ref()) - .unwrap() - .expect("handshake should succeed"); - assert_eq!(conn.account_id, 42); - assert_eq!(conn.account_name, "TESTER"); - // Prove the inbound cipher works: read one encrypted client message. - match ClientOpcodeMessage::read_encrypted(&mut s, &mut conn.decrypt).unwrap() { - ClientOpcodeMessage::CMSG_CHAR_ENUM => {} - other => panic!("expected encrypted CMSG_CHAR_ENUM, got {other}"), - } - }); - - // --- client: drive the shared challenge→proof→AUTH_SESSION prefix (`drive_auth`) --- - let (mut c_enc, mut c_dec) = drive_auth(&mut client, "TESTER", K); - - // --- client: the AUTH_OK response is the first ENCRYPTED packet --- - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_AUTH_RESPONSE(r) => { - assert!(matches!(*r, SMSG_AUTH_RESPONSE::AuthOk { .. })); - } - other => panic!("expected encrypted SMSG_AUTH_RESPONSE, got {other}"), - } - - // --- client: send an encrypted CMSG the server decrypts --- - CMSG_CHAR_ENUM {} - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - - drop(client); - server.join().unwrap(); -} - -/// The typed decoder would size a buffer from the addon field and unwrap the zlib. The gateway -/// never reads the addon list, so a 4 GiB claim over garbage bytes changes nothing: the proof is -/// checked and AUTH_OK goes out. -#[test] -fn an_auth_session_with_an_absurd_addon_size_still_completes_the_handshake() { - let store = std::sync::Arc::new(tester_store(42)); - 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.as_ref())); - - let server_seed = match ServerOpcodeMessage::read_unencrypted(&mut client).unwrap() { - ServerOpcodeMessage::SMSG_AUTH_CHALLENGE(c) => c.server_seed, - other => panic!("expected SMSG_AUTH_CHALLENGE, got {other}"), - }; - let client_seed = ProofSeed::new(); - let client_seed_value = client_seed.seed(); - let (client_proof, crypto) = - client_seed.into_client_header_crypto(&ns("TESTER"), K, server_seed); - let (_c_enc, mut c_dec) = crypto.split(); - - // Hand-built frame: the fixed fields, then a 4 GiB decompressed-size claim over garbage. - let mut body = Vec::new(); - body.extend_from_slice(&5875u32.to_le_bytes()); - body.extend_from_slice(&1u32.to_le_bytes()); - body.extend_from_slice(b"TESTER\0"); - body.extend_from_slice(&client_seed_value.to_le_bytes()); - body.extend_from_slice(&client_proof); - body.extend_from_slice(&u32::MAX.to_le_bytes()); - body.extend_from_slice(&[0xFF; 32]); - let mut frame = Vec::new(); - frame.extend_from_slice(&((body.len() + 4) as u16).to_be_bytes()); - frame.extend_from_slice(&CMSG_AUTH_SESSION_OPCODE.to_le_bytes()); - frame.extend_from_slice(&body); - client.write_all(&frame).unwrap(); - - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_AUTH_RESPONSE(r) => { - assert!(matches!(*r, SMSG_AUTH_RESPONSE::AuthOk { .. })); - } - other => panic!("expected encrypted AUTH_OK, got {other}"), - } - drop(client); - server.join().unwrap().unwrap(); -} - -/// A peer that reads the challenge and never answers must give its blocking thread back at the -/// total pre-auth deadline. -#[test] -fn a_silent_world_connection_is_closed_at_the_pre_auth_deadline() { - let store = tester_store(42); - let (mut client, server_end) = world_session_socket_pair(); - let mut deadline = PreAuthDeadline::after(Duration::from_millis(200)); - let server = std::thread::spawn(move || { - run_world_session_with_queue_and_deadline( - server_end, - &store, - &LoginQueue::unlimited(), - &mut deadline, - ) - }); - - ServerOpcodeMessage::read_unencrypted(&mut client).unwrap(); - let err = server - .join() - .unwrap() - .expect_err("a silent peer must be cut at the deadline"); - assert!( - err.to_string().contains("total pre-auth deadline"), - "{err:#}" - ); - let mut byte = [0u8]; - assert_eq!( - client.read(&mut byte).unwrap(), - 0, - "the socket must be closed" - ); -} - -#[test] -fn slow_auth_session_bytes_cannot_extend_the_pre_auth_deadline() { - let store = tester_store(42); - let mut input = Vec::new(); - auth_session("TESTER", 1, [0; 20]) - .write_unencrypted_client(&mut input) - .unwrap(); - let start = Instant::now(); - let clock = ManualClock(Rc::new(Cell::new(start))); - let mut deadline = PreAuthDeadline::with_clock(start + Duration::from_millis(8), clock.clone()); - let mut stream = AdvancingStream { - input: Cursor::new(input), - output: Vec::new(), - clock, - per_read: Duration::from_millis(1), - per_write: Duration::ZERO, - write_timeout_calls: Rc::new(Cell::new(0)), - }; - - let result = world_handshake_with_queue_and_deadline( - &mut stream, - &store, - &LoginQueue::unlimited(), - &mut deadline, - ); - let error = match result { - Err(error) => error, - Ok(_) => panic!("a slow auth frame must share one total deadline"), - }; - - assert!( - error.to_string().contains("total pre-auth deadline"), - "{error:#}" - ); - assert!( - !stream.output.is_empty(), - "the server challenge must be written before the slow client frame times out" - ); -} - -#[test] -fn world_challenge_write_uses_the_absolute_pre_auth_deadline() { - let store = tester_store(42); - let start = Instant::now(); - let clock = ManualClock(Rc::new(Cell::new(start))); - let write_timeout_calls = Rc::new(Cell::new(0)); - let mut deadline = - PreAuthDeadline::with_clock(start + Duration::from_millis(10), clock.clone()); - let mut stream = AdvancingStream { - input: Cursor::new(Vec::new()), - output: Vec::new(), - clock, - per_read: Duration::ZERO, - per_write: Duration::from_millis(20), - write_timeout_calls: write_timeout_calls.clone(), - }; - - let result = world_handshake_with_queue_and_deadline( - &mut stream, - &store, - &LoginQueue::unlimited(), - &mut deadline, - ); - let error = match result { - Err(error) => error, - Ok(_) => panic!("the world challenge must not write past the total deadline"), - }; - - assert_eq!( - write_timeout_calls.get(), - 1, - "the world challenge must write through the pre-auth I/O wrapper" - ); - assert!( - error.to_string().contains("pre-auth I/O deadline"), - "{error:#}" - ); - assert!( - !stream.output.is_empty(), - "the fake advances time after accepting the world challenge bytes" - ); -} - -/// Once client proof succeeds, authenticated World Session traffic has no pre-auth deadline. -#[test] -fn post_auth_world_traffic_has_no_pre_auth_deadline() { - let store = std::sync::Arc::new(tester_store(42)); - let (mut client, server_end) = world_session_socket_pair(); - let mut deadline = PreAuthDeadline::after(Duration::from_millis(200)); - let server_store = store.clone(); - let server = std::thread::spawn(move || { - run_world_session_with_queue_and_deadline( - server_end, - server_store.as_ref(), - &LoginQueue::unlimited(), - &mut deadline, - ) - }); - - let (mut c_enc, mut c_dec) = client_handshake(&mut client, "TESTER", K); - std::thread::sleep(Duration::from_millis(500)); - CMSG_CHAR_ENUM {} - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_CHAR_ENUM(_) => {} - other => panic!("expected SMSG_CHAR_ENUM, got {other}"), - } - drop(client); - server.join().unwrap().unwrap(); -} - -#[test] -fn queued_handshake_sends_wait_queue_then_admits_once_a_seat_frees() { - let store = std::sync::Arc::new(tester_store(42)); - let queue = std::sync::Arc::new(LoginQueue::new(1, 0)); - // Occupy the only seat directly — exactly what an already-connected world session holds. - assert_eq!(queue.request(), Admission::Admitted); - - let (mut client, server_end) = world_session_socket_pair(); - let server_store = store.clone(); - let server_queue = queue.clone(); - let server = std::thread::spawn(move || { - let mut s = server_end; - // Blocks in the queue's wait loop until `queue.depart()` (below) frees the one seat. - let (conn, _encrypt) = - world_handshake_with_queue(&mut s, server_store.as_ref(), &server_queue) - .unwrap() - .expect("handshake should eventually succeed once admitted"); - assert_eq!(conn.account_id, 42); - }); - - // --- client: drive the shared challenge→proof→AUTH_SESSION prefix (`drive_auth`) --- - let (_c_enc, mut c_dec) = drive_auth(&mut client, "TESTER", K); - - // --- client: the FIRST encrypted response must be AuthWaitQueue at position 1, not AuthOk — - // proving the handshake actually queued instead of bypassing the gate --- - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_AUTH_RESPONSE(r) => { - assert!( - matches!(*r, SMSG_AUTH_RESPONSE::AuthWaitQueue { queue_position: 1 }), - "expected AuthWaitQueue at position 1, got {r:?}" - ); - } - other => panic!("expected encrypted SMSG_AUTH_RESPONSE, got {other}"), - } - - // Free the one seat — mirrors what `run_world_session_with_queue`'s teardown does on a real - // disconnect. The queued handshake must notice on its next poll and proceed to admission. - queue.depart(); - - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_AUTH_RESPONSE(r) => { - assert!( - matches!(*r, SMSG_AUTH_RESPONSE::AuthOk { .. }), - "expected AuthOk, got {r:?}" - ); - } - other => panic!("expected encrypted SMSG_AUTH_RESPONSE, got {other}"), - } - - drop(client); - server.join().unwrap(); -} - -#[test] -fn disconnecting_while_queued_leaves_the_line_without_taking_a_seat() { - let store = std::sync::Arc::new(tester_store(7)); - let queue = std::sync::Arc::new(LoginQueue::new(1, 0)); - assert_eq!(queue.request(), Admission::Admitted); // occupy the only seat - - let (mut client, server_end) = world_session_socket_pair(); - let server_store = store.clone(); - let server_queue = queue.clone(); - let server = std::thread::spawn(move || { - let mut s = server_end; - let result = - world_handshake_with_queue(&mut s, server_store.as_ref(), &server_queue).unwrap(); - assert!( - result.is_none(), - "a hangup while queued must end the session cleanly, not error" - ); - }); - - let (_c_enc, mut c_dec) = drive_auth(&mut client, "TESTER", K); - - // Read (and discard) the first AuthWaitQueue so we know the server has actually queued us - // before hanging up — a hangup racing the very first send would prove nothing about `cancel`. - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_AUTH_RESPONSE(r) => { - assert!(matches!(*r, SMSG_AUTH_RESPONSE::AuthWaitQueue { .. })); - } - other => panic!("expected AuthWaitQueue, got {other}"), - } - - drop(client); // hang up while still queued - server.join().unwrap(); - - // The seat is still held by the ORIGINAL occupant (never touched); the queued-then-cancelled - // connection must have left NO trace in the line. - assert_eq!( - queue.depth(), - 0, - "the cancelled ticket must not linger in the queue" - ); - assert_eq!( - queue.active(), - 1, - "cancelling a waiter must never grant or free a seat" - ); -} - -#[test] -fn a_restarted_gateway_completes_the_handshake_from_realm_state_alone() { - // The stateless-gateway invariant, now realm-scoped. The session key K lives in - // `game_session` — on realm-core when it is configured, on the world DB when it is not — and - // the gateway keeps NOTHING about it. So killing the gateway mid-session and starting a fresh - // one must let the same account re-handshake with no re-logon, against a brand-new process - // that has never seen this client. - // - // Modelled here as two handshakes against two INDEPENDENT store instances that share only the - // realm-held session row: `store_before` is the gateway that gets killed, `store_after` is the - // replacement. Each handshake mints its own server seed and its own client seed, so nothing - // from the first run can be smuggled into the second — if any handshake input were - // gateway-local rather than realm state, the second run could not succeed. - let realm_session = || WorldSession { - account_id: 42, - session_key: K, - }; - let handshake_once = |store: InMemoryStore| { - let (mut client, server_end) = world_session_socket_pair(); - let server = std::thread::spawn(move || { - let mut s = server_end; - let established = world_handshake(&mut s, &store) - .unwrap() - .expect("handshake should succeed"); - established.0.account_id - }); - client_handshake(&mut client, "TESTER", K); - drop(client); - server.join().unwrap() - }; - - let store_before = InMemoryStore { - username: "TESTER".into(), - session: Some(realm_session()), - ..Default::default() - }; - assert_eq!(handshake_once(store_before), 42); - - // ---- the gateway is killed here; every byte of its in-process state is gone ---- - - let store_after = InMemoryStore { - username: "TESTER".into(), - session: Some(realm_session()), // re-READ from the realm, not carried over - ..Default::default() - }; - assert_eq!( - handshake_once(store_after), - 42, - "a fresh gateway must re-establish the session from realm-held state alone" - ); -} - -#[test] -fn a_gateway_that_cannot_reach_the_session_store_rejects_rather_than_guessing() { - // The other half of the stateless-gateway invariant: "resume from realm state" must not - // degrade into "resume from anything". A store that cannot answer (realm-core unreachable → - // `lookup_session` yields no session) rejects the handshake plaintext instead of establishing - // a session on an unverified key. `CoordinatorStore` reaches this state by way of - // `Coordinator::realm_core()`'s Err. - let store = InMemoryStore { - username: "TESTER".into(), - session: None, - ..Default::default() - }; - let (mut client, server_end) = world_session_socket_pair(); - let server = std::thread::spawn(move || { - let mut s = server_end; - assert!( - world_handshake(&mut s, &store).unwrap().is_none(), - "no session material ⇒ no session, never a best-effort one" - ); - }); - - let (_enc, _dec) = drive_auth(&mut client, "TESTER", K); - match ServerOpcodeMessage::read_unencrypted(&mut client).unwrap() { - ServerOpcodeMessage::SMSG_AUTH_RESPONSE(r) => { - assert!(matches!(*r, SMSG_AUTH_RESPONSE::AuthUnknownAccount)); - } - other => panic!("expected a plaintext rejection, got {other}"), - } - drop(client); - server.join().unwrap(); -} - -#[test] -fn bad_proof_is_rejected() { - // The store hands out a session key, but the client computes its proof against a - // DIFFERENT key, so the server's digest check must fail. - let store = InMemoryStore { - username: "TESTER".into(), - session: Some(WorldSession { - account_id: 1, - session_key: K, - }), - ..Default::default() - }; - - let (mut client, server_end) = world_session_socket_pair(); - let server = std::thread::spawn(move || { - let mut s = server_end; - let conn = world_handshake(&mut s, &store).unwrap(); - assert!(conn.is_none(), "bad proof must not establish a connection"); - }); - - let server_seed = match ServerOpcodeMessage::read_unencrypted(&mut client).unwrap() { - ServerOpcodeMessage::SMSG_AUTH_CHALLENGE(c) => c.server_seed, - other => panic!("expected SMSG_AUTH_CHALLENGE, got {other}"), - }; - - let wrong_key = [0x11u8; 40]; - let client_seed = ProofSeed::new(); - let client_seed_value = client_seed.seed(); - let (client_proof, _crypto) = - client_seed.into_client_header_crypto(&ns("TESTER"), wrong_key, server_seed); - - auth_session("TESTER", client_seed_value, client_proof) - .write_unencrypted_client(&mut client) - .unwrap(); - - // The failure is sent plaintext (no cipher was established). - match ServerOpcodeMessage::read_unencrypted(&mut client).unwrap() { - ServerOpcodeMessage::SMSG_AUTH_RESPONSE(r) => { - assert!(matches!(*r, SMSG_AUTH_RESPONSE::AuthFailed)); - } - other => panic!("expected plaintext SMSG_AUTH_RESPONSE failure, got {other}"), - } - - drop(client); - server.join().unwrap(); -} - -#[test] -fn unknown_account_is_rejected_cleanly() { - let store = InMemoryStore { - username: "TESTER".into(), - session: None, // account exists nowhere / no session - ..Default::default() - }; - - let (mut client, server_end) = world_session_socket_pair(); - let server = std::thread::spawn(move || { - let mut s = server_end; - assert!(world_handshake(&mut s, &store).unwrap().is_none()); - }); - - let (_enc, _dec) = drive_auth(&mut client, "NOBODY", K); - - match ServerOpcodeMessage::read_unencrypted(&mut client).unwrap() { - ServerOpcodeMessage::SMSG_AUTH_RESPONSE(r) => { - assert!(matches!(*r, SMSG_AUTH_RESPONSE::AuthUnknownAccount)); - } - other => panic!("expected SMSG_AUTH_RESPONSE unknown-account, got {other}"), - } - - drop(client); - server.join().unwrap(); -} - -#[test] -fn char_enum_returns_the_seeded_character() { - // The pre-seeded Human Warrior "Tester" must appear on the character-select screen. - let tester = codec::CharacterView { - guid: 1, - name: "Tester".into(), - race: 1, // Human - class: 1, // Warrior - gender: 0, // Male - level: 1, - map_id: 0, // Eastern Kingdoms - zone_id: 12, // Elwynn Forest - x: -8949.95, - y: -132.493, - z: 83.5312, - first_login: true, - ..Default::default() - }; - let store = std::sync::Arc::new(InMemoryStore { - characters: vec![tester], - ..tester_store(7) - }); - - 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.as_ref()).unwrap(); - }); - - // Full handshake, then request the character list over the encrypted channel. - let (mut c_enc, mut c_dec) = client_handshake(&mut client, "TESTER", K); - CMSG_CHAR_ENUM {} - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_CHAR_ENUM(e) => { - assert_eq!(e.characters.len(), 1); - let ch = &e.characters[0]; - assert_eq!(ch.name, "Tester"); - assert_eq!(ch.guid.guid(), 1); - assert_eq!(ch.race, Race::Human); - assert_eq!(ch.class, Class::Warrior); - assert_eq!(ch.level, Level::new(1)); - assert_eq!(ch.map, Map::EasternKingdoms); - } - other => panic!("expected SMSG_CHAR_ENUM, got {other}"), - } - - drop(client); - server.join().unwrap(); -} - -#[test] -fn guild_query_answers_at_character_select() { - let store = std::sync::Arc::new(InMemoryStore { - guilds: vec![codec::GuildView { - guild_id: 7, - name: "Tracer Guild".into(), - ranks: vec![codec::GuildRankView { - rank_id: 0, - name: "Guild Master".into(), - rights: lyracore_shared::guild::rights::ALL, - }], - ..Default::default() - }], - ..tester_store(7) - }); - - 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.as_ref()).unwrap(); - }); - - let (mut c_enc, mut c_dec) = client_handshake(&mut client, "TESTER", K); - wow_world_messages::vanilla::CMSG_GUILD_QUERY { guild_id: 7 } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_GUILD_QUERY_RESPONSE(response) => { - assert_eq!(response.id, 7); - assert_eq!(response.name, "Tracer Guild"); - assert_eq!(response.rank_names[0], "Guild Master"); - assert_eq!(response.rank_names[1], ""); - } - other => panic!("expected SMSG_GUILD_QUERY_RESPONSE, got {other}"), - } - - drop(client); - server.join().unwrap(); -} - -/// Write one request that answers its actor nothing, then a sentinel request with a guaranteed -/// reply, and block for that reply. This is more than pacing: `enter_world` drains a FIXED packet -/// count that knows nothing about the Guild MOTD event `guild_world_entry` sends a fresh-login -/// Guild member (see its own doc comment), so one packet can still be unread in the client's -/// kernel buffer. Reading for a sentinel discards it along the way. Dropping the client with it -/// still queued would close with unread bytes, which the kernel reports to the server as a reset, -/// not a clean EOF (`enter_world`'s doc comment names this exact failure shape). The sentinel is -/// CMSG_PLAYED_TIME, which replies only when the store has a Character row for the caller. -fn sync( - client: &mut UnixStream, - enc: &mut EncrypterHalf, - dec: &mut DecrypterHalf, - write: impl FnOnce(&mut UnixStream, &mut EncrypterHalf), -) { - write(&mut *client, &mut *enc); - CMSG_PLAYED_TIME {} - .write_encrypted_client(&mut *client, &mut *enc) - .unwrap(); - loop { - if let ServerOpcodeMessage::SMSG_PLAYED_TIME(_) = - ServerOpcodeMessage::read_encrypted(&mut *client, &mut *dec).unwrap() - { - break; - } - } -} - -/// Character 1 as a member of Guild 7 at rank 3. -fn guild_member_store() -> InMemoryStore { - InMemoryStore { - login_entity: Some(warrior_entity()), - guild_memberships: vec![codec::GuildMemberView { - character_guid: 1, - guild_id: 7, - rank_id: 3, - name: "Warrior".into(), - ..Default::default() - }], - ..tester_store(7) - } -} - -/// The recorded operations, in call order, without their Shard. -fn recorded(store: &InMemoryStore) -> Vec { - store - .calls - .lock() - .unwrap() - .iter() - .map(|(_, what)| what.clone()) - .collect() -} - -fn position(calls: &[String], what: &str) -> usize { - calls - .iter() - .position(|call| call == what) - .unwrap_or_else(|| panic!("`{what}` never ran: {calls:?}")) -} - -#[test] -fn a_member_enters_the_world_with_its_guild_on_the_self_create_and_signs_on() { - let store = std::sync::Arc::new(guild_member_store()); - 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.as_ref()).unwrap(); - }); - 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(); - let mut guild = None; - for _ in 0..WORLD_ENTRY_PACKETS { - if let ServerOpcodeMessage::SMSG_UPDATE_OBJECT(update) = - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() - { - if let [Object::CreateObject2 { - mask2: wow_world_messages::vanilla::UpdateMask::Player(player), - .. - }] = update.objects.as_slice() - { - guild = Some((player.player_guildid(), player.player_guildrank())); - } - } - } - assert_eq!(guild, Some((Some(7), Some(3)))); - drop(client); - server.join().unwrap(); - assert!(recorded(&store).contains(&"guild_op:SignOn".to_string())); -} - -#[test] -fn every_membership_opcode_reaches_its_durable_request() { - let store = std::sync::Arc::new(InMemoryStore { - login_entity: Some(warrior_entity()), - guild_memberships: vec![codec::GuildMemberView { - character_guid: 1, - guild_id: 7, - rank_id: 3, - name: "Warrior".into(), - ..Default::default() - }], - guilds: vec![codec::GuildView { - guild_id: 7, - name: "Tracer Guild".into(), - leader_guid: 1, - ..Default::default() - }], - characters: vec![ - codec::CharacterView { - guid: 1, - name: "Warrior".into(), - ..Default::default() - }, - codec::CharacterView { - guid: 2, - name: "Target".into(), - ..Default::default() - }, - ], - ..tester_store(7) - }); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - - sync(&mut client, &mut c_enc, &mut c_dec, |c, e| { - CMSG_GUILD_INVITE { - invited_player: "Target".into(), - } - .write_encrypted_client(c, e) - .unwrap(); - }); - sync(&mut client, &mut c_enc, &mut c_dec, |c, e| { - CMSG_GUILD_ACCEPT {}.write_encrypted_client(c, e).unwrap(); - }); - sync(&mut client, &mut c_enc, &mut c_dec, |c, e| { - CMSG_GUILD_DECLINE {}.write_encrypted_client(c, e).unwrap(); - }); - sync(&mut client, &mut c_enc, &mut c_dec, |c, e| { - CMSG_GUILD_REMOVE { - player_name: "Target".into(), - } - .write_encrypted_client(c, e) - .unwrap(); - }); - sync(&mut client, &mut c_enc, &mut c_dec, |c, e| { - CMSG_GUILD_PROMOTE { - player_name: "Target".into(), - } - .write_encrypted_client(c, e) - .unwrap(); - }); - sync(&mut client, &mut c_enc, &mut c_dec, |c, e| { - CMSG_GUILD_DEMOTE { - player_name: "Target".into(), - } - .write_encrypted_client(c, e) - .unwrap(); - }); - sync(&mut client, &mut c_enc, &mut c_dec, |c, e| { - CMSG_GUILD_LEADER { - new_guild_leader_name: "Target".into(), - } - .write_encrypted_client(c, e) - .unwrap(); - }); - sync(&mut client, &mut c_enc, &mut c_dec, |c, e| { - CMSG_GUILD_LEAVE {}.write_encrypted_client(c, e).unwrap(); - }); - sync(&mut client, &mut c_enc, &mut c_dec, |c, e| { - CMSG_GUILD_DISBAND {}.write_encrypted_client(c, e).unwrap(); - }); - - drop(client); - server.join().unwrap(); - let calls = recorded(&store); - for op in [ - "guild_op:Invite", - "guild_op:Accept", - "guild_op:Decline", - "guild_op:Remove", - "guild_op:Promote", - "guild_op:Demote", - "guild_op:SetLeader", - "guild_op:Leave", - "guild_op:Disband", - ] { - assert!( - calls.contains(&op.to_string()), - "{op} never reached the Durable Request: {calls:?}" - ); - } -} - -#[test] -fn logout_signs_the_member_off_before_the_account_claim_is_released() { - let store = std::sync::Arc::new(guild_member_store()); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - CMSG_LOGOUT_REQUEST {} - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - for _ in 0..2 { - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - } - drop(client); - server.join().unwrap(); - let calls = recorded(&store); - assert!( - position(&calls, "guild_op:SignOff") < position(&calls, "logout"), - "Realm-core refuses a sign-off after the Account Claim closes: {calls:?}" - ); -} - -#[test] -fn world_entry_finishes_a_leftover_fee_hold_on_the_home_shard() { - let store = std::sync::Arc::new(InMemoryStore { - guild_fee_hold: std::sync::Mutex::new(Some(crate::world::guild_fee::FeeHold { - operation_id: 5_090_401, - terms: crate::world::guild_fee::FeeTerms::Emblem(Default::default()), - })), - ..guild_member_store() - }); - let (client, _c_enc, _c_dec, server) = enter_world(store.clone(), 1); - drop(client); - server.join().unwrap(); - let calls = recorded(&store); - assert!( - position(&calls, "player_login") < position(&calls, "guild_fee_decide") - && position(&calls, "guild_fee_decide") < position(&calls, "guild_fee_finish"), - "{calls:?}" - ); - assert_eq!(*store.guild_fee_hold.lock().unwrap(), None); -} - -#[test] -fn the_tabard_designer_window_opens_over_the_socket() { - let store = std::sync::Arc::new(guild_member_store()); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - wow_world_messages::vanilla::MSG_TABARDVENDOR_ACTIVATE { - guid: Guid::new(5_090_010), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::MSG_TABARDVENDOR_ACTIVATE(window) => { - assert_eq!(window.guid, Guid::new(5_090_010)); - } - other => panic!("expected MSG_TABARDVENDOR_ACTIVATE, got {other}"), - } - drop(client); - server.join().unwrap(); -} - -#[test] -fn a_member_who_is_not_the_leader_saves_no_emblem_over_the_socket() { - let store = std::sync::Arc::new(guild_member_store()); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - wow_world_messages::vanilla::MSG_SAVE_GUILD_EMBLEM_Client { - vendor: Guid::new(5_090_010), - emblem_style: 1, - emblem_color: 2, - border_style: 3, - border_color: 4, - background_color: 5, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::MSG_SAVE_GUILD_EMBLEM(saved) => assert_eq!( - saved.result, - wow_world_messages::vanilla::GuildEmblemResult::NotGuildMaster - ), - other => panic!("expected MSG_SAVE_GUILD_EMBLEM, got {other}"), - } - drop(client); - server.join().unwrap(); - assert!(!recorded(&store).contains(&"guild_fee_hold".to_string())); -} - -#[test] -fn the_settings_opcodes_reach_their_dispatch_entries_over_the_socket() { - let store = std::sync::Arc::new(InMemoryStore { - guilds: vec![codec::GuildView { - guild_id: 7, - name: "Tracer Guild".into(), - ranks: vec![codec::GuildRankView { - rank_id: 0, - name: "Guild Master".into(), - rights: lyracore_shared::guild::rights::ALL, - }], - ..Default::default() - }], - // The sentinel below is CMSG_PLAYED_TIME, which only replies once `character_by_guid` - // resolves the caller's row (`char.rs`'s own doc comment); without one here, the reply - // never comes and the sentinel read blocks until the test's socket timeout fires. - characters: vec![codec::CharacterView { - guid: 1, - name: "Warrior".into(), - ..Default::default() - }], - ..guild_member_store() - }); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - - sync(&mut client, &mut c_enc, &mut c_dec, |c, e| { - wow_world_messages::vanilla::CMSG_GUILD_MOTD { - message_of_the_day: "Assemble!".into(), - } - .write_encrypted_client(c, e) - .unwrap(); - }); - sync(&mut client, &mut c_enc, &mut c_dec, |c, e| { - wow_world_messages::vanilla::CMSG_GUILD_INFO_TEXT { - guild_info: "About us".into(), - } - .write_encrypted_client(c, e) - .unwrap(); - }); - sync(&mut client, &mut c_enc, &mut c_dec, |c, e| { - wow_world_messages::vanilla::CMSG_GUILD_SET_PUBLIC_NOTE { - player_name: "Dave".into(), - note: "reliable".into(), - } - .write_encrypted_client(c, e) - .unwrap(); - }); - sync(&mut client, &mut c_enc, &mut c_dec, |c, e| { - wow_world_messages::vanilla::CMSG_GUILD_SET_OFFICER_NOTE { - player_name: "Dave".into(), - note: "watch closely".into(), - } - .write_encrypted_client(c, e) - .unwrap(); - }); - sync(&mut client, &mut c_enc, &mut c_dec, |c, e| { - wow_world_messages::vanilla::CMSG_GUILD_RANK { - rank_id: 2, - rights: 0x43, - rank_name: "Veteran+".into(), - } - .write_encrypted_client(c, e) - .unwrap(); - }); - sync(&mut client, &mut c_enc, &mut c_dec, |c, e| { - wow_world_messages::vanilla::CMSG_GUILD_ADD_RANK { - rank_name: "Recruit".into(), - } - .write_encrypted_client(c, e) - .unwrap(); - }); - sync(&mut client, &mut c_enc, &mut c_dec, |c, e| { - wow_world_messages::vanilla::CMSG_GUILD_DEL_RANK {} - .write_encrypted_client(c, e) - .unwrap(); - }); - - drop(client); - server.join().unwrap(); - let calls = recorded(&store); - for op in [ - "guild_op:SetMotd", - "guild_op:SetInfo", - "guild_op:SetPublicNote", - "guild_op:SetOfficerNote", - "guild_op:EditRank", - "guild_op:AddRank", - "guild_op:DeleteRank", - ] { - assert!( - calls.contains(&op.to_string()), - "{op} never reached guild_op: {calls:?}" - ); - } -} - -#[test] -fn a_world_port_that_fails_after_sign_on_still_signs_off() { - let store = std::sync::Arc::new(InMemoryStore { - // The world-port ack is only actionable after teleport_player removed the old entity. - entity_in_world: false, - worldport_login_error: Some("character 1 is stranded on map 36".into()), - ..guild_member_store() - }); - 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.as_ref())); - 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(); - for _ in 0..WORLD_ENTRY_PACKETS { - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - } - MSG_MOVE_WORLDPORT_ACK {} - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - while ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).is_ok() {} - drop(client); - assert!( - server.join().unwrap().is_err(), - "the failed entry ends the session" - ); - let calls = recorded(&store); - assert!( - position(&calls, "guild_op:SignOn") < position(&calls, "guild_op:SignOff"), - "{calls:?}" - ); - assert!(position(&calls, "guild_op:SignOff") < position(&calls, "logout")); -} - -#[test] -fn char_create_replies_success_then_name_in_use() { - // The fake store reports a name already among its characters as in-use, else success. - let tester = codec::CharacterView { - guid: 1, - name: "Tester".into(), - race: 1, - class: 1, - level: 1, - ..Default::default() - }; - let store = std::sync::Arc::new(InMemoryStore { - characters: vec![tester], - ..tester_store(7) - }); - 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.as_ref()).unwrap(); - }); - - let (mut c_enc, mut c_dec) = client_handshake(&mut client, "TESTER", K); - let mk = |name: &str| CMSG_CHAR_CREATE { - name: name.to_string(), - race: Race::Human, - class: Class::Warrior, - gender: Gender::Male, - skin_color: 0, - face: 0, - hair_style: 0, - hair_color: 0, - facial_hair: 0, - }; - - // A fresh name → CharCreateSuccess. - mk("Newbie") - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_CHAR_CREATE(m) => { - assert_eq!(m.result, WorldResult::CharCreateSuccess) - } - other => panic!("expected SMSG_CHAR_CREATE, got {other}"), - } - - // An existing name → CharCreateNameInUse, and the session stays alive. - mk("Tester") - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_CHAR_CREATE(m) => { - assert_eq!(m.result, WorldResult::CharCreateNameInUse) - } - other => panic!("expected SMSG_CHAR_CREATE, got {other}"), - } - - drop(client); - server.join().unwrap(); -} - -#[test] -fn char_delete_replies_success_and_dispatches_owned_guid() { - let tester = codec::CharacterView { - guid: 5, - name: "Tester".into(), - race: 1, - class: 1, - level: 1, - ..Default::default() - }; - let store = std::sync::Arc::new(InMemoryStore { - characters: vec![tester], - ..tester_store(7) - }); - 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.as_ref()).unwrap(); - }); - - let (mut c_enc, mut c_dec) = client_handshake(&mut client, "TESTER", K); - CMSG_CHAR_DELETE { guid: Guid::new(5) } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_CHAR_DELETE(m) => { - assert_eq!(m.result, WorldResult::CharDeleteSuccess) - } - other => panic!("expected SMSG_CHAR_DELETE, got {other}"), - } - assert_eq!(*store.deleted.lock().unwrap(), vec![(7, 5)]); - - drop(client); - server.join().unwrap(); -} - -#[test] -fn char_delete_failure_replies_failed_and_keeps_session_alive() { - let store = std::sync::Arc::new(InMemoryStore { - delete_outcome: Some(codec::CharDeleteOutcome::Failed), - ..tester_store(7) - }); - 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.as_ref()).unwrap(); - }); - - let (mut c_enc, mut c_dec) = client_handshake(&mut client, "TESTER", K); - CMSG_CHAR_DELETE { - guid: Guid::new(999), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_CHAR_DELETE(m) => { - assert_eq!(m.result, WorldResult::CharDeleteFailed) - } - other => panic!("expected SMSG_CHAR_DELETE, got {other}"), - } - - // Session should still be alive: CMSG_CHAR_ENUM gets a normal reply, not a dropped socket. - CMSG_CHAR_ENUM {} - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_CHAR_ENUM(_) => {} - other => panic!("expected SMSG_CHAR_ENUM, got {other}"), - } - - drop(client); - server.join().unwrap(); -} - -/// Guild state on Realm-core for the deleted-Character reconciliation: Leader 5 and member 6 in -/// one Guild, and a Petition owned by 7 with a Signature by 8. Characters 5 and 7 still exist on -/// the `instances` peer; 6 and 8 exist on no World Shard. -fn guild_cleanup_topology() -> std::sync::Arc { - guild_cleanup_topology_failing_lookup_for(None) -} - -fn guild_cleanup_topology_failing_lookup_for( - guild_lookup_error_for: Option, -) -> std::sync::Arc { - let instances = std::sync::Arc::new(InMemoryStore { - shard: "instances".into(), - characters: [5, 7] - .into_iter() - .map(|guid| codec::CharacterView { - guid, - name: format!("Kept{guid}"), - ..Default::default() - }) - .collect(), - ..Default::default() - }); - let world = std::sync::Arc::new(InMemoryStore { - shard: "world".into(), - guild_memberships: [(5, 0), (6, 4)] - .into_iter() - .map(|(character_guid, rank_id)| codec::GuildMemberView { - character_guid, - guild_id: 7, - rank_id, - ..Default::default() - }) - .collect(), - guild_petitions: vec![codec::PetitionView { - petition_id: 3, - charter_item_guid: 90, - owner_guid: 7, - name: "Boundary Test".into(), - signers: vec![8], - }], - guild_lookup_error_for, - ..Default::default() - }); - *world.peers.lock().unwrap() = vec![world.clone(), instances]; - world -} - -fn forgotten(store: &InMemoryStore) -> Vec { - store - .calls - .lock() - .unwrap() - .iter() - .map(|(_, call)| call.clone()) - .filter(|call| call.starts_with("guild_op:ForgetDeletedCharacter")) - .collect() -} - -fn sweep() -> crate::world::GuildCleanup { - crate::world::GuildCleanup { - sweep: true, - ..Default::default() - } -} - -fn deleted(guids: &[u64]) -> crate::world::GuildCleanup { - crate::world::GuildCleanup { - sweep: false, - deleted: guids.iter().copied().collect(), - } -} - -fn durable_absence_checks(store: &InMemoryStore) -> usize { - store - .durable_absence_checks - .load(std::sync::atomic::Ordering::SeqCst) -} - -#[test] -fn only_characters_absent_from_every_world_shard_are_forgotten() { - let world = guild_cleanup_topology(); - crate::world::reconcile_deleted_guild_characters(world.as_ref(), &sweep()).unwrap(); - assert_eq!( - forgotten(&world), - [ - "guild_op:ForgetDeletedCharacter:6", - "guild_op:ForgetDeletedCharacter:8" - ] - ); -} - -/// A Character row in any Shard's cache proves the Character exists, so the pass takes no durable -/// snapshot for it. Only 6 and 8, gone from every cache, pay for the absence check. -#[test] -fn a_character_still_in_a_shard_cache_takes_no_durable_snapshot() { - let world = guild_cleanup_topology(); - crate::world::reconcile_deleted_guild_characters(world.as_ref(), &sweep()).unwrap(); - assert_eq!(durable_absence_checks(&world), 2); - - let kept = guild_cleanup_topology(); - crate::world::reconcile_deleted_guild_characters(kept.as_ref(), &deleted(&[5, 7])).unwrap(); - assert_eq!(durable_absence_checks(&kept), 0); - assert!(forgotten(&kept).is_empty()); -} - -/// A `game_character` delete owes cleanup for that Character only, and none at all for a -/// Character that no Guild, Petition or Signature names. -#[test] -fn a_single_delete_checks_only_its_own_character() { - let world = guild_cleanup_topology(); - crate::world::reconcile_deleted_guild_characters(world.as_ref(), &deleted(&[8, 40])).unwrap(); - assert_eq!(forgotten(&world), ["guild_op:ForgetDeletedCharacter:8"]); - assert_eq!(durable_absence_checks(&world), 1); -} - -/// A lookup that fails for one deleted Character does not hold up the others. The pass still -/// fails, so the worker keeps the work and retries it. -#[test] -fn a_failed_lookup_still_cleans_the_other_deleted_characters() { - let world = guild_cleanup_topology_failing_lookup_for(Some(6)); - let error = crate::world::reconcile_deleted_guild_characters(world.as_ref(), &deleted(&[6, 8])) - .expect_err("the failed lookup is owed a retry"); - assert!(error.to_string().contains("lookup for 6")); - assert_eq!(forgotten(&world), ["guild_op:ForgetDeletedCharacter:8"]); -} - -#[test] -fn an_unavailable_world_shard_defers_every_guild_cleanup() { - let world = guild_cleanup_topology(); - let incomplete = InMemoryStore { - shard: "world".into(), - calls: world.calls.clone(), - guild_memberships: world.guild_memberships.clone(), - guild_petitions: world.guild_petitions.clone(), - world_shard_set_error: Some("instances has no healthy Coordinator subscription".into()), - ..Default::default() - }; - let error = crate::world::reconcile_deleted_guild_characters(&incomplete, &sweep()) - .expect_err("an incomplete Shard set cannot prove a deletion"); - assert!(error.to_string().contains("no healthy Coordinator")); - assert!(forgotten(&incomplete).is_empty()); -} - -/// A Guild Leader's delete answers 0x3A, FAILED_GUILD_LEADER in mangos, and never reaches the -/// Home Shard (`cm:CharacterHandler.cpp:540-546`). -#[test] -fn char_delete_of_a_guild_leader_replies_failed_and_deletes_nothing() { - let store = std::sync::Arc::new(InMemoryStore { - guild_memberships: vec![codec::GuildMemberView { - character_guid: 5, - guild_id: 7, - rank_id: 0, - name: "Tester".into(), - ..Default::default() - }], - guilds: vec![codec::GuildView { - guild_id: 7, - name: "Boundary Test".into(), - leader_guid: 5, - ..Default::default() - }], - ..tester_store(7) - }); - 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.as_ref()).unwrap(); - }); - - let (mut c_enc, mut c_dec) = client_handshake(&mut client, "TESTER", K); - CMSG_CHAR_DELETE { guid: Guid::new(5) } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_CHAR_DELETE(m) => { - assert_eq!(m.result.as_int(), 0x3A) - } - other => panic!("expected SMSG_CHAR_DELETE, got {other}"), - } - assert!(store.deleted.lock().unwrap().is_empty()); - - drop(client); - server.join().unwrap(); -} - -/// Human/Warrior entity matching the seed, as the gateway would read it back after -/// `player_login`. -fn warrior_entity() -> codec::EntityView { - codec::EntityView { - guid: 1, - map_id: 0, - instance_id: 0, // the open world - zone_id: 12, - x: -8949.95, - y: -132.493, - z: 83.5312, - orientation: 0.0, - last_move_ms: 0, - movement_flags: 0, - run_speed_mult_bp: 10_000, - type_mask: 0x19, - entry: 0, - scale_x: 1.0, - health: 60, - max_health: 60, - power: 0, - max_power: 1000, - level: 1, - faction_template: 1, - target_guid: 0, - unit_bytes_0: 1 | (1 << 8) | (1 << 24), // human(1) warrior(1) male(0) rage(1) - display_id: 49, - native_display_id: 49, - unit_flags: 0, - mount_display_id: 0, - base_attack_time_ms: 2000, - dynamic_flags: 0, - player_bytes: 0, - player_bytes_2: 0, - player_bytes_3: 0, - player_flags: 0, - xp: 0, - next_level_xp: 0, - money: 0, - unit_bytes_1: 0, - unit_bytes_2: 0, - // L1 Human Warrior base attributes (cmangos curve) — non-zero so the CREATE exercises them. - strength: 23, - agility: 20, - stamina: 22, - intellect: 20, - spirit: 21, - sheet_ap_base: 29, - sheet_ap_mods: 0, - sheet_dmg_min: 5, - sheet_dmg_max: 7, - sheet_ranged_ap: 0, - sheet_ranged_dmg_min: 0, - sheet_ranged_dmg_max: 0, - npc_flags: 0, // a player is not an NPC - owner_guid: 0, // not a summon - effective_armor: 40, // agility 20 * 2 (base; no gear in the fixture → effective == base) - magic_resistances: [0; 6], - // No hearthstone bind recorded for the test entity; fall back to login position. - home_map: 0, - home_zone: 0, - home_x: 0.0, - home_y: 0.0, - home_z: 0.0, - guild_id: 0, - guild_rank: 0, - } -} - -#[test] -fn login_replays_a_pending_package_system_message() { - // A Package `on_login` hook emits its System Message INSIDE `player_login` — before the - // session is in the viewer registry, so the live insert relay has nobody to address. World - // entry must replay the parked row right after the entry sequence. - let store = std::sync::Arc::new(InMemoryStore { - login_entity: Some(warrior_entity()), - pending_system_messages: vec!["Example Package is active.".to_string()], - ..tester_store(7) - }); - - 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.as_ref()).unwrap(); - }); - - 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(); - for _ in 0..WORLD_ENTRY_PACKETS { - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - } - let replay = ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - let ServerOpcodeMessage::SMSG_MESSAGECHAT(actual) = replay else { - panic!("expected the parked System Message after the entry sequence, got {replay:?}"); - }; - assert_eq!( - *actual, - codec::build_gm_system_message("Example Package is active.".to_string()) - ); - - drop(client); - server.join().unwrap(); -} - -#[test] -fn player_login_emits_sequence_then_self_create() { - // CMSG_PLAYER_LOGIN must yield the full login sequence (in order), then the self - // CREATE_OBJECT2 at the correct position/guid, then the current zone's weather — the clock - // (SMSG_LOGIN_SETTIMESPEED) before the sky, so the client has both before it renders. - let store = std::sync::Arc::new(InMemoryStore { - login_entity: Some(warrior_entity()), - // Elwynn Forest (the fixture's zone) is raining hard. - zone_weather: vec![( - 12, - codec::ZoneWeatherView { - weather_type: 1, - intensity: 0.8, - }, - )], - ..tester_store(7) - }); - - 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.as_ref()).unwrap(); - }); - - 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(); - - // The 11-message login sequence, the self CREATE_OBJECT2, then the zone's weather. - let mut tags = Vec::new(); - let mut create_guid = None; - let mut weather = None; - for _ in 0..WORLD_ENTRY_PACKETS { - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_LOGIN_VERIFY_WORLD(m) => { - tags.push("verify_world"); - assert_eq!(m.map, Map::EasternKingdoms); - assert!((m.position.x - (-8949.95)).abs() < 0.01); - } - ServerOpcodeMessage::SMSG_ACCOUNT_DATA_TIMES(_) => tags.push("account_data_times"), - ServerOpcodeMessage::SMSG_LOGIN_SETTIMESPEED(_) => tags.push("settimespeed"), - ServerOpcodeMessage::SMSG_TUTORIAL_FLAGS(_) => tags.push("tutorial_flags"), - ServerOpcodeMessage::SMSG_INITIAL_SPELLS(_) => tags.push("initial_spells"), - ServerOpcodeMessage::SMSG_SET_PROFICIENCY(m) => tags.push(match m.class.as_int() { - 2 => "weapon_proficiency", - 4 => "armor_proficiency", - other => panic!("unexpected proficiency item class {other}"), - }), - ServerOpcodeMessage::SMSG_ACTION_BUTTONS(_) => tags.push("action_buttons"), - ServerOpcodeMessage::SMSG_INITIALIZE_FACTIONS(_) => tags.push("init_factions"), - ServerOpcodeMessage::SMSG_SET_REST_START(_) => tags.push("set_rest_start"), - ServerOpcodeMessage::SMSG_BINDPOINTUPDATE(_) => tags.push("bindpoint"), - ServerOpcodeMessage::SMSG_UPDATE_OBJECT(m) => { - tags.push("update_object"); - if let Object::CreateObject2 { guid3, .. } = &m.objects[0] { - create_guid = Some(guid3.guid()); - } else { - panic!("expected CreateObject2 in self-spawn"); - } - } - ServerOpcodeMessage::SMSG_WEATHER(m) => { - tags.push("weather"); - weather = Some(*m); - } - other => panic!("unexpected message in login sequence: {other}"), - } - } - - assert_eq!( - tags, - vec![ - "verify_world", - "account_data_times", - "settimespeed", - "tutorial_flags", - "initial_spells", - "weapon_proficiency", - "armor_proficiency", - "action_buttons", - "init_factions", - "set_rest_start", - "bindpoint", - "update_object", - "weather", - ], - ); - assert_eq!(create_guid, Some(1)); - // Elwynn's stored rain, synchronized rather than faded in: an arriving client must not spend - // a transition rendering the sky it happened to have last. - let weather = weather.expect("world entry sends the zone's weather"); - assert_eq!(weather.weather_type, WeatherType::Rain); - assert_eq!(weather.grade, 0.8); - assert_eq!(weather.sound_id, 8535); - assert_eq!(weather.change, WeatherChangeType::Instant); - - drop(client); - server.join().unwrap(); -} - -/// Drive one login to completion and hand back the world-entry weather packet. -fn world_entry_weather(store: std::sync::Arc) -> SMSG_WEATHER { - 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.as_ref()).unwrap(); - }); - 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(); - let mut weather = None; - for _ in 0..WORLD_ENTRY_PACKETS { - if let ServerOpcodeMessage::SMSG_WEATHER(m) = - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() - { - weather = Some(*m); - } - } - drop(client); - server.join().unwrap(); - weather.expect("every world entry sends the zone's weather") -} - -/// Story 10: a zone the Module has no weather row for is fine weather. World entry still sends the -/// packet — a client told nothing keeps rendering whatever sky it arrived with. -#[test] -fn world_entry_into_a_zone_with_no_weather_row_sends_fine_weather() { - let weather = world_entry_weather(std::sync::Arc::new(InMemoryStore { - login_entity: Some(warrior_entity()), - ..tester_store(7) - })); - assert_eq!(weather.weather_type, WeatherType::Fine); - assert_eq!(weather.grade, 0.0); - assert_eq!(weather.sound_id, 0); - assert_eq!(weather.change, WeatherChangeType::Instant); -} - -/// A Store that cannot answer the weather question is a degraded sky, never a failed login: the -/// session completes world entry and the player lands under clear skies. -#[test] -fn a_weather_read_failure_still_completes_world_entry() { - let weather = world_entry_weather(std::sync::Arc::new(InMemoryStore { - login_entity: Some(warrior_entity()), - weather_error: Some("shard cache unavailable".into()), - ..tester_store(7) - })); - assert_eq!(weather.weather_type, WeatherType::Fine); - assert_eq!(weather.grade, 0.0); -} - -/// Drive one World Session through the handshake and world entry, and hand back the client end -/// parked on the first packet that comes AFTER world entry. -fn world_session_in_world( - store: std::sync::Arc, - character_guid: u64, -) -> (UnixStream, 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.as_ref()).unwrap(); - }); - let (mut c_enc, mut c_dec) = client_handshake(&mut client, "TESTER", K); - CMSG_PLAYER_LOGIN { - guid: Guid::new(character_guid), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - for _ in 0..WORLD_ENTRY_PACKETS { - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - } - (client, c_dec, server) -} - -/// One `game_zone_weather` row, as the Module writes it and the relay reads it. -fn zone_weather_row( - zone_id: u32, - weather_type: u8, - intensity: f32, -) -> crate::stdb::bindings::ZoneWeather { - crate::stdb::bindings::ZoneWeather { - zone_id, - weather_type, - intensity, - changed_at_micros: 0, - } -} - -/// Stories 5 and 6 end to end, over the real cipher: two World Sessions on real socket pairs share -/// one shard's relay, one standing in Elwynn Forest and one in Westfall. Forced Elwynn rain must -/// decode as valid rain on the Elwynn client and must not reach the Westfall client at all. -/// -/// Both characters stand on the SAME position, so nothing but the zone can separate them — the -/// spatial index has no say in weather. -/// -/// Westfall's own snow is a sentinel rather than a bare timeout. A session's writer is FIFO, so an -/// Elwynn row that leaked into Westfall's routing would arrive on that socket BEFORE the snow; the -/// assertion therefore fails deterministically if the zone filter goes away, instead of resting on -/// a socket that happened to stay quiet. The bounded silence afterwards closes the mirror case: -/// Westfall's snow must not reach Elwynn either. -#[test] -fn forced_elwynn_rain_reaches_only_the_client_standing_in_elwynn() { - const ELWYNN: u32 = 12; - const WESTFALL: u32 = 40; - let view = std::sync::Arc::new(crate::stdb::world_view::WorldView::new(true)); - - let mut westfall_entity = warrior_entity(); - westfall_entity.guid = 2; - westfall_entity.zone_id = WESTFALL; - let session_store = |entity: codec::EntityView| { - std::sync::Arc::new(InMemoryStore { - login_entity: Some(entity), - relay_view: Some(view.clone()), - ..tester_store(7) - }) - }; - - let (mut elwynn_client, mut elwynn_dec, elwynn_server) = - world_session_in_world(session_store(warrior_entity()), 1); - let (mut westfall_client, mut westfall_dec, westfall_server) = - world_session_in_world(session_store(westfall_entity), 2); - - // Elwynn is forced to heavy rain; Westfall drifts into light snow of its own. - crate::stdb::world_view::relay_zone_weather(&view, 0, &zone_weather_row(ELWYNN, 1, 0.75)); - crate::stdb::world_view::relay_zone_weather(&view, 0, &zone_weather_row(WESTFALL, 2, 0.30)); - - match ServerOpcodeMessage::read_encrypted(&mut elwynn_client, &mut elwynn_dec).unwrap() { - ServerOpcodeMessage::SMSG_WEATHER(m) => { - assert_eq!(m.weather_type, WeatherType::Rain); - assert_eq!(m.grade, 0.75); - assert_eq!(m.sound_id, 8535, "0.75 is the heavy rain band"); - assert_eq!(m.change, WeatherChangeType::Smooth); - } - other => panic!("the Elwynn client must receive Elwynn's rain, got {other}"), - } - match ServerOpcodeMessage::read_encrypted(&mut westfall_client, &mut westfall_dec).unwrap() { - ServerOpcodeMessage::SMSG_WEATHER(m) => { - assert_eq!( - m.weather_type, - WeatherType::Snow, - "the first weather the Westfall client sees must be Westfall's own — rain here \ - means Elwynn's row leaked across the zone boundary" - ); - assert_eq!(m.grade, 0.30); - assert_eq!(m.sound_id, 8536, "0.30 is the light snow band"); - } - other => panic!("the Westfall client must receive Westfall's snow, got {other}"), - } - - // Neither zone's sky is echoed to the other. A short deadline is enough: both sockets have - // already delivered a packet enqueued after the one being tested for. - let quiet = std::time::Duration::from_millis(250); - for (name, client, dec) in [ - ("Elwynn", &mut elwynn_client, &mut elwynn_dec), - ("Westfall", &mut westfall_client, &mut westfall_dec), - ] { - client.set_read_timeout(Some(quiet)).unwrap(); - assert!( - ServerOpcodeMessage::read_encrypted(client, dec).is_err(), - "the {name} client must receive exactly one weather packet, its own zone's" - ); - } - - drop(elwynn_client); - drop(westfall_client); - elwynn_server.join().unwrap(); - westfall_server.join().unwrap(); -} - -#[test] -fn worldport_ack_reenters_with_fresh_subscription_and_empty_loot_state() { - // MSG_MOVE_WORLDPORT_ACK must re-run the SAME enter_world path as - // CMSG_PLAYER_LOGIN — rebuilding the entity (now on the NEW map the module's teleport_player - // durably wrote to the character row) and re-subscribing with a FRESH `created` dedup set — a - // reused dedup set from the old map would suppress the initial sweep of pre-existing entities - // through the CREATE path, leaving the new map looking empty until something moved or spawned — - // a stale created-set is exactly what would leave entities invisible on cross-map arrival. - let mut ported = warrior_entity(); - ported.map_id = 1; // Kalimdor — simulates teleport_player's durable cross-map write - ported.x = 100.0; - ported.y = 200.0; - let store = std::sync::Arc::new(InMemoryStore { - // A real cross-map teleport has despawned the old-map entity before its ack arrives. - entity_in_world: false, - login_entity: Some(warrior_entity()), - worldport_entity: Some(ported), - corpse_money: 25, - ..tester_store(7) - }); - - 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.as_ref()).unwrap(); - }); - - 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 (map 0 — not the point of this test). - for _ in 0..WORLD_ENTRY_PACKETS { - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - } - - let _ = open_loot_window(&mut client, &mut c_enc, &mut c_dec, 60); - - MSG_MOVE_WORLDPORT_ACK {} - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - - // Re-entry skips SMSG_LOGIN_VERIFY_WORLD because resending it reloads the current map. - let mut create_guid = None; - for _ in 0..WORLD_REENTRY_PACKETS { - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_LOGIN_VERIFY_WORLD(_) => { - panic!( - "the re-entry sequence must NOT resend SMSG_LOGIN_VERIFY_WORLD — it makes the \ - client reload the map it just loaded (a second loading screen)" - ); - } - ServerOpcodeMessage::SMSG_UPDATE_OBJECT(m) => { - if let Object::CreateObject2 { guid3, .. } = &m.objects[0] { - create_guid = Some(guid3.guid()); - } - } - _ => {} - } - } - assert_eq!( - create_guid, - Some(1), - "the self entity is re-created (despawn+rebuild), not a stale row" - ); - - // subscribe_player_events must have fired TWICE: once at login (map 0), once at the world-port - // re-entry (map 1) — a fresh `created` set is built for the new map each time, never reused. - let calls = store.subscribed.lock().unwrap().clone(); - assert_eq!( - calls.len(), - 2, - "subscribe_player_events must run again on WORLDPORT_ACK" - ); - assert_eq!(calls[0].1, 0, "the first subscription is for the login map"); - assert_eq!( - calls[1].1, 1, - "the second subscription is for the NEW (post-teleport) map" - ); - assert!( - (calls[1].2 - 100.0).abs() < 0.01, - "the re-entry must use the NEW position (verify-world no longer carries it — the \ - subscription placement is the observable)" - ); - - // The old map's open window cannot authorize a targetless request after re-entry. - CMSG_LOOT_MONEY {} - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - - drop(client); - server.join().unwrap(); - assert!( - store.money_looted.lock().unwrap().is_empty(), - "world-port re-entry must start with no open loot target" - ); - // The login ends the Away Status; the world-port rides the reducer that keeps it. - assert_eq!( - store.login_entries.lock().unwrap().as_slice(), - [codec::WorldEntry::FreshLogin, codec::WorldEntry::WorldPort] - ); -} - -#[test] -fn worldport_removes_the_source_viewer_before_routing_and_registers_a_replacement() { - let view = std::sync::Arc::new(crate::stdb::world_view::WorldView::new(true)); - let mut ported = warrior_entity(); - ported.map_id = 1; - ported.x = 100.0; - ported.y = 200.0; - let store = std::sync::Arc::new(InMemoryStore { - entity_in_world: false, - login_entity: Some(warrior_entity()), - worldport_entity: Some(ported), - relay_view: Some(view.clone()), - ..tester_store(7) - }); - - 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.as_ref()).unwrap(); - }); - 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(); - for _ in 0..WORLD_ENTRY_PACKETS { - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - } - let source_session = view - .viewer_of_owner(crate::stdb::world_view::OwnerGuid(1)) - .expect("login registers the source viewer") - .session; - - MSG_MOVE_WORLDPORT_ACK {} - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - for _ in 0..WORLD_REENTRY_PACKETS { - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - } - - assert_eq!( - store.viewer_present_at_settle.lock().unwrap().as_slice(), - &[false, false], - "neither initial routing nor world-port routing may see a registered source viewer; the \ - second observation is the transfer-cascade safety boundary" - ); - let destination_session = view - .viewer_of_owner(crate::stdb::world_view::OwnerGuid(1)) - .expect("destination world entry registers its viewer") - .session; - assert_ne!( - destination_session, source_session, - "world-port must replace, not reuse, the source viewer lifetime" - ); - - drop(client); - server.join().unwrap(); -} - -#[test] -fn login_initialize_factions_carries_persisted_standing_at_its_reputation_index() { - // A persisted `game_player_reputation` row must land in the login - // SMSG_INITIALIZE_FACTIONS at its STORED reputation_index slot (0..63), never faction_id — the - // guardrail that also gates the live SET_FACTION_STANDING relay (McBride ERROR). - let store = std::sync::Arc::new(InMemoryStore { - login_entity: Some(warrior_entity()), - // Stormwind's rep-index is 19 (Faction.dbc ReputationListID), NOT its faction id (72) — - // exercising the exact index/id distinction the guardrail protects. - reputations: vec![(19, 3175, false)], - ..tester_store(7) - }); - - 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.as_ref()).unwrap(); - }); - - 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 the full login sequence + self CREATE_OBJECT2 (mirrors the message count in - // player_login_emits_sequence_then_self_create) so the server side doesn't see a broken pipe. - let mut factions = None; - for _ in 0..WORLD_ENTRY_PACKETS { - if let ServerOpcodeMessage::SMSG_INITIALIZE_FACTIONS(m) = - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() - { - factions = Some(m.factions); - } - } - let factions = factions.expect("SMSG_INITIALIZE_FACTIONS not found in login sequence"); - assert_eq!(factions.len(), 64); - assert_eq!( - factions[19].standing, 3175, - "slot 19 (the stored reputation_index) must carry the standing" - ); - // Stormwind's faction_id (72) is itself past the 64-slot array — proof that indexing by faction_id - // (the McBride bug) would panic/crash here rather than silently landing on the wrong slot. - for (i, f) in factions.iter().enumerate() { - if i != 19 { - assert_eq!(f.standing, 0, "slot {i} should remain the Neutral/0 stub"); - } - } - - drop(client); - server.join().unwrap(); -} - -#[test] -fn login_with_resident_items_and_reputation_emits_no_gain_feedback() { - let store = std::sync::Arc::new(InMemoryStore { - login_entity: Some(warrior_entity()), - player_items_fixture: vec![codec::ItemInstanceView { - guid: 0x4000_0000_0000_0001, - entry: 25, - owner_guid: 1, - slot: 23, - stack_count: 1, - durability: 20, - max_durability: 20, - container_slots: 0, - random_property_id: 0, - random_property_enchant_ids: [0; 3], - item_text_id: 0, - enchantment: 0, - }], - reputations: vec![(19, 3175, false)], - ..tester_store(7) - }); - - 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.as_ref()).unwrap(); - }); - 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(); - - // World entry plus the resident item's CREATE. The item and standing are snapshots in those - // frames, not live insert callbacks, so neither feedback packet is lawful. - for _ in 0..WORLD_ENTRY_PACKETS + 1 { - let message = ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - assert!( - !matches!( - message, - ServerOpcodeMessage::SMSG_ITEM_PUSH_RESULT(_) - | ServerOpcodeMessage::SMSG_SET_FACTION_STANDING(_) - ), - "resident login state must not look like a newly gained item or reputation change" - ); - } - client - .set_read_timeout(Some(std::time::Duration::from_millis(50))) - .unwrap(); - assert!( - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).is_err(), - "login queued an unexpected post-snapshot feedback frame" - ); - - drop(client); - server.join().unwrap(); -} - -#[test] -fn login_fills_a_resident_suffix_items_enchantment_slots_after_the_entry_batch() { - let item_guid = 0x4000_0000_0000_0001; - let store = std::sync::Arc::new(InMemoryStore { - login_entity: Some(warrior_entity()), - player_items_fixture: vec![codec::ItemInstanceView { - guid: item_guid, - entry: 25, - owner_guid: 1, - slot: 23, - stack_count: 1, - random_property_id: 22, - random_property_enchant_ids: [73, 0, 0], - ..Default::default() - }], - ..tester_store(7) - }); - - 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.as_ref()).unwrap(); - }); - 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(); - - // World entry plus the item's CREATE, then the raw update gtker's typed reader rejects. - for _ in 0..WORLD_ENTRY_PACKETS + 1 { - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - } - let (opcode, body) = read_raw_frame(&mut client, &mut c_dec); - assert_eq!(opcode, 0x00A9); - let updates = lyracore_shared::values_mask::parse_values_updates(&body); - assert_eq!(updates.len(), 1); - assert_eq!(updates[0].guid, item_guid); - assert_eq!(updates[0].fields, vec![(31, 73)]); - - drop(client); - server.join().unwrap(); -} - -#[test] -fn inbound_movement_is_recorded_under_its_opcode() { - use wow_world_messages::vanilla::{ - MSG_MOVE_HEARTBEAT_Client, MovementInfo, MovementInfo_MovementFlags, Vector3d, - }; - - let store = std::sync::Arc::new(InMemoryStore { - entity_in_world: true, - ..tester_store(7) - }); - - 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.as_ref()).unwrap(); - }); - - let (mut c_enc, _c_dec) = client_handshake(&mut client, "TESTER", K); - let info = MovementInfo { - flags: MovementInfo_MovementFlags::empty(), - timestamp: 12345, - position: Vector3d { - x: -8950.0, - y: -130.0, - z: 83.0, - }, - orientation: 1.5, - fall_time: 0.0, - }; - MSG_MOVE_HEARTBEAT_Client { info } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - - drop(client); // server reads the heartbeat, then EOF - server.join().unwrap(); - - let moves = store.moves.lock().unwrap(); - assert_eq!(moves.len(), 1); - let (opcode, x, _, _, o, t) = moves[0]; - assert_eq!( - opcode, - lyracore_shared::opcodes::movement::MSG_MOVE_HEARTBEAT - ); - assert!((x - (-8950.0)).abs() < 0.01); - assert!((o - 1.5).abs() < 0.001); - assert_eq!(t, 12345); -} - -#[test] -fn a_movement_packet_for_a_despawned_entity_never_kills_the_session() { - use wow_world_messages::vanilla::{ - MSG_MOVE_HEARTBEAT_Client, MSG_MOVE_START_FORWARD_Client, MovementInfo, - MovementInfo_MovementFlags, Vector3d, - }; - let calls: ShardCallLog = Default::default(); - let entity_presence = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - let store = std::sync::Arc::new(InMemoryStore { - calls: calls.clone(), - entity_presence: Some(entity_presence.clone()), - ..tester_store(7) - }); - - 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.as_ref())); - - let (mut c_enc, _c_dec) = client_handshake(&mut client, "TESTER", K); - let beat = |t: u32| MSG_MOVE_HEARTBEAT_Client { - info: MovementInfo { - flags: MovementInfo_MovementFlags::empty(), - timestamp: t, - position: Vector3d { - x: -8950.0, - y: -130.0, - z: 83.0, - }, - orientation: 1.5, - fall_time: 0.0, - }, - }; - // The first packet is a transfer tail. Once the coordinator cache sees the entity again, the - // next state transition must resume normal batched submission. - beat(1) - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - for _ in 0..100 { - if store - .entity_presence_checks - .load(std::sync::atomic::Ordering::SeqCst) - != 0 - { - break; - } - std::thread::sleep(std::time::Duration::from_millis(1)); - } - assert_ne!( - store - .entity_presence_checks - .load(std::sync::atomic::Ordering::SeqCst), - 0, - "the encrypted movement packet must reach the coordinator presence gate" - ); - entity_presence.store(true, std::sync::atomic::Ordering::SeqCst); - MSG_MOVE_START_FORWARD_Client { info: beat(2).info } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - drop(client); - - server.join().unwrap().expect( - "a movement packet for a despawned entity must be DROPPED, not session-fatal — \ - closing the socket here strands the client on a loading screen with no error", - ); - assert_eq!( - calls -.lock() -.unwrap() -.iter() -.filter(|(_, c)| c == "movement_update") -.count(), - 1, - "the transfer-tail packet must not enter the batch, while movement resumes when presence returns" - ); -} - -#[test] -fn a_reappearing_entity_resets_the_movement_desync_tolerance() { - use wow_world_messages::vanilla::{ - MSG_MOVE_START_FORWARD_Client, MSG_MOVE_STOP_Client, MovementInfo, - MovementInfo_MovementFlags, Vector3d, - }; - let present = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - let store = std::sync::Arc::new(InMemoryStore { - entity_presence: Some(present.clone()), - ..tester_store(7) - }); - 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.as_ref())); - let (mut c_enc, _c_dec) = client_handshake(&mut client, "TESTER", K); - let info = |t| MovementInfo { - flags: MovementInfo_MovementFlags::empty(), - timestamp: t, - position: Vector3d { - x: -8950.0, - y: -130.0, - z: 83.0, - }, - orientation: 1.5, - fall_time: 0.0, - }; - let send_tail = |start: u32, end: u32, client: &mut UnixStream, enc: &mut EncrypterHalf| { - for t in start..end { - let sent = if t % 2 == 0 { - MSG_MOVE_START_FORWARD_Client { info: info(t) } - .write_encrypted_client(&mut *client, enc) - } else { - MSG_MOVE_STOP_Client { info: info(t) }.write_encrypted_client(&mut *client, enc) - }; - sent.unwrap(); - } - }; - let wait_for_checks = |n| { - for _ in 0..100 { - if store - .entity_presence_checks - .load(std::sync::atomic::Ordering::SeqCst) - >= n - { - return; - } - std::thread::sleep(std::time::Duration::from_millis(1)); - } - panic!("the gateway did not check entity presence {n} times"); - }; - - send_tail(0, MOVE_DESYNC_TOLERANCE, &mut client, &mut c_enc); - wait_for_checks(MOVE_DESYNC_TOLERANCE as usize); - present.store(true, std::sync::atomic::Ordering::SeqCst); - MSG_MOVE_START_FORWARD_Client { - info: info(MOVE_DESYNC_TOLERANCE), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - wait_for_checks(MOVE_DESYNC_TOLERANCE as usize + 1); - present.store(false, std::sync::atomic::Ordering::SeqCst); - send_tail( - MOVE_DESYNC_TOLERANCE + 1, - MOVE_DESYNC_TOLERANCE * 2 + 1, - &mut client, - &mut c_enc, - ); - wait_for_checks(MOVE_DESYNC_TOLERANCE as usize * 2 + 1); - present.store(true, std::sync::atomic::Ordering::SeqCst); - MSG_MOVE_STOP_Client { - info: info(MOVE_DESYNC_TOLERANCE * 2 + 1), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - drop(client); - server - .join() - .unwrap() - .expect("a restored entity must reset the tolerance before a later transfer tail arrives"); - assert_eq!( - store.moves.lock().unwrap().len(), - 2, - "only movements sent while the entity was present may reach the shared batch" - ); -} - -#[test] -fn a_movement_failure_that_is_not_a_desync_is_still_session_fatal() { - use wow_world_messages::vanilla::{ - MSG_MOVE_HEARTBEAT_Client, MovementInfo, MovementInfo_MovementFlags, Vector3d, - }; - let store = std::sync::Arc::new(InMemoryStore { - entity_in_world: true, - movement_error: Some("timed out after 10s".into()), - ..tester_store(7) - }); - 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.as_ref())); - let (mut c_enc, _c_dec) = client_handshake(&mut client, "TESTER", K); - MSG_MOVE_HEARTBEAT_Client { - info: MovementInfo { - flags: MovementInfo_MovementFlags::empty(), - timestamp: 1, - position: Vector3d { - x: 0.0, - y: 0.0, - z: 0.0, - }, - orientation: 0.0, - fall_time: 0.0, - }, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - drop(client); - let err = server - .join() - .unwrap() - .expect_err("a non-desync movement failure must still end the session"); - assert!(format!("{err:#}").contains("timed out"), "{err:#}"); -} - -#[test] -fn a_movement_desync_that_never_heals_still_ends_the_session() { - use wow_world_messages::vanilla::{ - MSG_MOVE_START_FORWARD_Client, MSG_MOVE_STOP_Client, MovementInfo, - MovementInfo_MovementFlags, Vector3d, - }; - let store = std::sync::Arc::new(InMemoryStore { - // The entity is gone and is NEVER coming back — not a teleport tail, a real desync. - entity_in_world: false, - ..tester_store(7) - }); - 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.as_ref())); - let (mut c_enc, _c_dec) = client_handshake(&mut client, "TESTER", K); - let info = |t: u32| MovementInfo { - flags: MovementInfo_MovementFlags::empty(), - timestamp: t, - position: Vector3d { - x: -8950.0, - y: -130.0, - z: 83.0, - }, - orientation: 1.5, - fall_time: 0.0, - }; - // State transitions, so every one forwards immediately (never coalesced). Far more than a - // cross-map port's in-flight tail, and spread over more time than any loading screen's worth of - // queued packets — a session still serving these has stopped detecting desyncs altogether. - // Writes are best-effort: the socket SHOULD close partway through, which is the whole point. - for i in 0..200u32 { - let ok = if i % 2 == 0 { - MSG_MOVE_START_FORWARD_Client { info: info(i) } - .write_encrypted_client(&mut client, &mut c_enc) - .is_ok() - } else { - MSG_MOVE_STOP_Client { info: info(i) } - .write_encrypted_client(&mut client, &mut c_enc) - .is_ok() - }; - if !ok { - break; - } - } - drop(client); - let err = server.join().unwrap().expect_err( - "a movement desync that never heals must STILL end the session: tolerating the tail of a \ - cross-map port is one thing, serving a permanently desynced client a frozen world forever \ - is the hang a stuck cross-map transfer causes, with no loading screen to blame it on", - ); - assert!(format!("{err:#}").contains("not in world"), "{err:#}"); -} - -#[test] -fn a_world_port_whose_transfer_cannot_be_driven_aborts_the_clients_loading_screen() { - let view = std::sync::Arc::new(crate::stdb::world_view::WorldView::new(true)); - let xdb = FakeShardDb::with_character( - 1, - FakeChar { - map_id: 36, - instance_id: 7, - payload: "gear+spells".into(), - }, - ); - let store = std::sync::Arc::new(InMemoryStore { - // The world-port ack is only actionable after teleport_player removed the old entity. - entity_in_world: false, - characters: vec![], - login_entity: Some(warrior_entity()), - xdb: Some(xdb), - settle_error: Some("instances shard unreachable".into()), - settle_ok_calls: 1, // the LOGIN routes fine; the world-port's settle is the one that fails - relay_view: Some(view.clone()), - ..tester_store(7) - }); - - 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.as_ref())); - 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(); - for _ in 0..WORLD_ENTRY_PACKETS { - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - } - // The client finished loading the dungeon map and acks — this is where the transfer runs. - MSG_MOVE_WORLDPORT_ACK {} - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - - let aborted = ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).expect( - "the client must receive SMSG_TRANSFER_ABORTED — silence here is the loading-screen hang this fixes", - ); - match aborted { - ServerOpcodeMessage::SMSG_TRANSFER_ABORTED(m) => { - assert_eq!( - m.map, - Map::Deadmines, - "the abort must name the map the client is loading" - ); - } - other => panic!("expected SMSG_TRANSFER_ABORTED, got {other}"), - } - drop(client); - let err = server.join().unwrap().expect_err( - "a half-driven transfer must still end the session rather than enter the world", - ); - assert!( - format!("{err:#}").contains("instances shard unreachable"), - "{err:#}" - ); - assert!( - view.viewer_of_owner(crate::stdb::world_view::OwnerGuid(1)) - .is_none(), - "a failed transfer terminates the session and must not restore its source viewer" - ); - assert!( - store - .logout_called - .load(std::sync::atomic::Ordering::SeqCst), - "early viewer removal keeps InWorld intact so the existing abort teardown still logs out" - ); -} - -#[test] -fn a_world_port_whose_world_entry_fails_also_aborts_the_clients_loading_screen() { - let xdb = FakeShardDb::with_character( - 1, - FakeChar { - map_id: 36, - instance_id: 7, - payload: "gear+spells".into(), - }, - ); - let store = std::sync::Arc::new(InMemoryStore { - // The world-port ack is only actionable after teleport_player removed the old entity. - entity_in_world: false, - characters: vec![], - login_entity: Some(warrior_entity()), - xdb: Some(xdb), - // Routing succeeds; the world entry on the far side is what fails. - worldport_login_error: Some("character 1 is stranded on map 36".into()), - ..tester_store(7) - }); - - 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.as_ref())); - 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(); - for _ in 0..WORLD_ENTRY_PACKETS { - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - } - MSG_MOVE_WORLDPORT_ACK {} - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - - // Drain whatever the (partial) re-entry emitted and require an abort somewhere in it — the - // client must not be left loading. `read_encrypted` returns Err once the socket closes. - let mut aborted = None; - while let Ok(msg) = ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec) { - if let ServerOpcodeMessage::SMSG_TRANSFER_ABORTED(m) = msg { - aborted = Some(m); - break; - } - } - let m = aborted.expect( - "a world-port whose ENTRY fails must still abort the client's loading screen — silence \ - here is the infinite loading bar this abort exists to kill", - ); - assert_eq!( - m.map, - Map::Deadmines, - "the abort must name the map the client is loading" - ); - drop(client); - let err = server - .join() - .unwrap() - .expect_err("a half-entered world-port must still end the session"); - assert!(format!("{err:#}").contains("stranded"), "{err:#}"); -} - -#[test] -fn movement_state_changes_forward_immediately_and_in_order_never_delayed() { - use wow_world_messages::vanilla::{ - MSG_MOVE_HEARTBEAT_Client, MSG_MOVE_START_FORWARD_Client, MSG_MOVE_START_TURN_LEFT_Client, - MSG_MOVE_STOP_Client, MovementInfo, MovementInfo_MovementFlags, Vector3d, - }; - - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, _c_dec, server) = enter_world(store.clone(), 1); - - let mk = |flags: u32, orientation: f32, x: f32, timestamp: u32| MovementInfo { - flags: MovementInfo_MovementFlags::new(flags, None, None, None, None), - timestamp, - position: Vector3d { x, y: 0.0, z: 0.0 }, - orientation, - fall_time: 0.0, - }; - const RUN: u32 = 0x1; - const TURN: u32 = 0x1 | 0x10; - const STOPPED: u32 = 0x0; - - MSG_MOVE_START_FORWARD_Client { - info: mk(RUN, 0.0, 0.0, 100), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - // Same flags + heading as the run-start -> a pure heartbeat -> held pending (nowhere near the - // 150ms window in real wall-clock terms, since these all write back-to-back with no sleep). - MSG_MOVE_HEARTBEAT_Client { - info: mk(RUN, 0.0, 5.0, 200), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - // A second same-vector heartbeat SUPERSEDES the first (rule 3: the pending slot IS the drop - // mechanism) — x=5.0 must never reach the module. - MSG_MOVE_HEARTBEAT_Client { - info: mk(RUN, 0.0, 10.0, 300), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - // A turn: different flags -> a STATE CHANGE. Must flush the pending x=10.0 heartbeat FIRST, - // then forward the turn itself, both undelayed. - MSG_MOVE_START_TURN_LEFT_Client { - info: mk(TURN, 5.0, 10.0, 400), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - // A stop: another STATE CHANGE, no pending left to flush — forwards immediately alone. - MSG_MOVE_STOP_Client { - info: mk(STOPPED, 5.0, 10.0, 500), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - - drop(client); - server.join().unwrap(); - - let moves = store.moves.lock().unwrap(); - let opcodes: Vec = moves.iter().map(|(op, ..)| *op).collect(); - assert_eq!( - opcodes, - vec![ - lyracore_shared::opcodes::movement::MSG_MOVE_START_FORWARD, - lyracore_shared::opcodes::movement::MSG_MOVE_HEARTBEAT, // the FLUSHED x=10.0 heartbeat - lyracore_shared::opcodes::movement::MSG_MOVE_START_TURN_LEFT, - lyracore_shared::opcodes::movement::MSG_MOVE_STOP, - ], - "run-start, [flushed heartbeat], turn, stop — in that exact order; the turn and stop must \ - never be held, and the superseded x=5.0 intermediate must never appear at all" - ); - // The flushed heartbeat carries the LATEST pending position (x=10.0), not the dropped x=5.0 one. - assert!( - (moves[1].1 - 10.0).abs() < 0.01, - "flushed heartbeat must carry the superseding x=10.0, not the dropped x=5.0" - ); - assert_eq!( - moves.len(), - 4, - "exactly one heartbeat survives coalescing out of the two sent" - ); -} - -#[test] -fn non_movement_opcode_flushes_pending_heartbeat_before_being_handled() { - use wow_world_messages::vanilla::{ - MSG_MOVE_HEARTBEAT_Client, MSG_MOVE_START_FORWARD_Client, MovementInfo, - MovementInfo_MovementFlags, Vector3d, - }; - - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - - let mk = |x: f32, timestamp: u32| MovementInfo { - flags: MovementInfo_MovementFlags::new(0x1, None, None, None, None), - timestamp, - position: Vector3d { x, y: 0.0, z: 0.0 }, - orientation: 0.0, - fall_time: 0.0, - }; - - MSG_MOVE_START_FORWARD_Client { info: mk(0.0, 100) } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - // Three same-vector heartbeats, sent back-to-back (well inside the 150ms window): each - // supersedes the last in the pending slot, so only the FINAL one (x=30.0) may ever ship. - MSG_MOVE_HEARTBEAT_Client { - info: mk(10.0, 200), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - MSG_MOVE_HEARTBEAT_Client { - info: mk(20.0, 300), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - MSG_MOVE_HEARTBEAT_Client { - info: mk(30.0, 400), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - - CMSG_QUESTGIVER_STATUS_QUERY { - guid: Guid::new(50), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_QUESTGIVER_STATUS(_) => {} // the query itself was still answered normally - other => panic!("expected SMSG_QUESTGIVER_STATUS, got {other}"), - } - // Safe to inspect now: the reply we just read could only have been produced AFTER the query's - // dispatch ran (flush, then the query handler) on the single-threaded reader/dispatch loop. - { - let moves = store.moves.lock().unwrap(); - assert_eq!( - moves.len(), - 2, - "baseline + exactly ONE flushed heartbeat — if coalescing weren't happening, all 3 \ - heartbeats would have forwarded individually (4 total), not 2" - ); - assert!( - (moves[1].1 - 30.0).abs() < 0.01, - "the flushed heartbeat must carry the LATEST superseding position, not an earlier dropped one" - ); - } - - drop(client); - server.join().unwrap(); -} - -#[test] -fn desync_error_classifies_entity_missing_as_fatal_but_not_transient() { - // The module's `entity_by_owner` failures (the player's entity is gone — a desync, e.g. a - // schema-change publish dropped the gateway's read subscription) must be session-FATAL so the - // handler forces a CLEAN disconnect instead of leaving a silent zombie (can't attack / can't logout). - assert!(is_desync_error(&anyhow!("attacker not in world"))); - assert!(is_desync_error(&anyhow!("caster not in world"))); - assert!(is_desync_error(&anyhow!("no live entity for guid 5"))); - assert!(is_desync_error(&anyhow!("Player NOT IN WORLD"))); // case-insensitive - // TRANSIENT per-action failures are NOT desync — they stay swallowed (the player keeps playing, - // never disconnected). A false positive here would drop players on every dead-target swing. - assert!(!is_desync_error(&anyhow!("target is dead"))); - assert!(!is_desync_error(&anyhow!( - "target out of range (35.0 yd > 30 yd)" - ))); - assert!(!is_desync_error(&anyhow!( - "not enough power: have 0, need 30" - ))); - assert!(!is_desync_error(&anyhow!( - "spell not ready (global cooldown)" - ))); - assert!(!is_desync_error(&anyhow!("cannot attack self"))); -} - -// =========================================================================================== -// Quest traffic over the world session. Which screen a quest opens, and which durable request it -// makes, is decided and proved at the `dispatch_quest_action` seam (`handlers/quest.rs`). What is -// left here is only what the seam cannot see: that a quest opcode reaches the seam through the -// full handshake + login + cipher, and that the bodies it answers with survive the encrypted -// frame. Five tests carry that contract, each naming it in its own comment. -// =========================================================================================== - -/// A store configured for an in-world TESTER (account 7, char guid 1) with a login entity — the -/// base fixture every socket test that needs a logged-in player builds on, quest or not. -fn quest_store() -> InMemoryStore { - InMemoryStore { - login_entity: Some(warrior_entity()), - ..tester_store(7) - } -} - -/// A minimal quest detail (everything zero/empty but the id + title) — enough for the build_* codecs. -fn detail_view(quest_id: u32, title: &str) -> codec::QuestDetailView { - codec::QuestDetailView { - quest_id, - quest_level: 1, - zone_or_sort: 12, - title: title.into(), - details: String::new(), - objectives_text: String::new(), - offer_reward_text: String::new(), - request_items_text: String::new(), - money_reward: 0, - reward_xp: 0, - next_quest_id: 0, - max_level_money_reward: 0, - rewards: Vec::new(), - choice_rewards: Vec::new(), - objectives: Vec::new(), - } -} - -/// One giver↔quest eval with the four booleans the menu/status + complete-branch read. -fn eval(quest_id: u32, role: u8, active: bool, complete: bool) -> codec::GiverQuestEval { - codec::GiverQuestEval { - quest_id, - title: "Q".into(), - level: 1, - role, - startable: role == codec::ROLE_START, - active, - complete, - } -} - -/// Spin up a world session over a socket pair, handshake as TESTER, enter the world as `guid`, and -/// drain the login sequence — 10 fixed messages (LYRACORE_QUEST_LOG off in tests → no quest-log update -/// appended) plus one SMSG_UPDATE_OBJECT CREATE per `player_items()` row (`enter_world` inserts -/// one per owned item BEFORE the self-spawn CREATE — see its doc comment in `mod.rs`). Draining one -/// message too few manifests as the CLIENT closing with unread bytes still queued — which the kernel -/// reports back to the SERVER thread's next read as ECONNRESET, not a clean EOF. -/// Returns the client socket + encrypted halves + the server join handle for the test to drive. -fn enter_world( - store: std::sync::Arc, - guid: u64, -) -> ( - UnixStream, - EncrypterHalf, - DecrypterHalf, - std::thread::JoinHandle<()>, -) { - let (mut client, server_end) = world_session_socket_pair(); - // The login sequence ends with the quest-log VALUES packet IFF the player has quests (mirrors - // `send_quest_log`'s skip-when-empty). Checked before `store` is moved into the server thread. - let has_quest_log = !store.quest_log_slots.is_empty(); - let item_creates = store.player_items(guid).map(|v| v.len()).unwrap_or(0); - let server_store = store; - let server = std::thread::spawn(move || { - run_world_session(server_end, server_store.as_ref()).unwrap(); - }); - 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(); - // 10 login-sequence packets, plus the weapon and armor SMSG_SET_PROFICIENCY pair. - for _ in 0..WORLD_ENTRY_PACKETS + item_creates { - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - } - if has_quest_log { - // The quest-log packet is a PARTIAL VALUES update with OBJECT_FIELD_TYPE stripped (so the real - // 5875 client doesn't crash — see the health-VALUES note). gtker's DECODER rejects that ("Missing - // object TYPE"), but the frame bytes are consumed, so drain it tolerantly rather than unwrap. - let _ = ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec); - } - (client, c_enc, c_dec, server) -} - -/// A restrictive imported equipment template. The query response is cached by the client and is -/// shared by inventory, vendor, and quest-reward screens, so one socket test pins that definition. -fn human_warrior_sword_template() -> codec::ItemTemplateView { - codec::ItemTemplateView { - entry: 910_261, - class: 2, - subclass: 7, - name: "Quartermaster's Practice Sword".into(), - display_id: 1542, - quality: 2, - inventory_type: 13, - item_level: 20, - required_level: 15, - max_durability: 40, - buy_price: 4_000, - sell_price: 800, - max_stack: 1, - damage_min: 9.0, - damage_max: 17.0, - delay_ms: 2_400, - required_skill: 43, // Swords - required_skill_rank: 150, - required_reputation_faction: 72, - required_reputation_rank: 5, - allowed_class: 0x01, // Warrior - allowed_race: 0x01, // Human - ..Default::default() - } -} - -#[test] -fn item_query_preserves_imported_eligibility_through_the_encrypted_world_session() { - let template = human_warrior_sword_template(); - let mut fixture = quest_store(); - fixture.item_templates = vec![template.clone()]; - let store = std::sync::Arc::new(fixture); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - - CMSG_ITEM_QUERY_SINGLE { - item: template.entry, - guid: Guid::new(0), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - - let found = match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_ITEM_QUERY_SINGLE_RESPONSE(reply) => { - assert_eq!(reply.item, template.entry); - reply - .found - .expect("the imported template must reach the item query reply") - } - other => panic!("expected SMSG_ITEM_QUERY_SINGLE_RESPONSE, got {other}"), - }; - - assert_eq!(found.allowed_class.as_int(), template.allowed_class); - assert_eq!(found.allowed_race.as_int(), template.allowed_race); - assert_eq!( - u32::from(found.required_skill.as_int()), - template.required_skill - ); - assert_eq!(found.required_skill_rank, template.required_skill_rank); - assert_eq!( - u32::from(found.required_faction.as_int()), - template.required_reputation_faction - ); - assert_eq!( - found.required_faction_rank, - template.required_reputation_rank - ); - - // These are client-local eligibility terms, deliberately limited to the fields this cached - // definition contains. They prove the fixture is restrictive rather than an all-bits mask. - let client_can_use = |class: u32, race: u32, skill: Option<(u32, u32)>| { - found.allowed_class.as_int() & class != 0 - && found.allowed_race.as_int() & race != 0 - && skill.is_some_and(|(id, rank)| { - id == u32::from(found.required_skill.as_int()) && rank >= found.required_skill_rank - }) - }; - assert!(client_can_use(0x01, 0x01, Some((43, 150)))); // Human Warrior with Swords 150 - assert!(!client_can_use(0x80, 0x01, Some((43, 150)))); // excluded Mage - assert!(!client_can_use(0x01, 0x02, Some((43, 150)))); // excluded Orc - assert!(!client_can_use(0x01, 0x01, None)); // missing sword proficiency - - drop(client); - server.join().unwrap(); -} - -#[test] -fn the_writer_drains_the_egress_depth_back_to_zero_b2() { - let store = std::sync::Arc::new(quest_store()); - let (client, _c_enc, _c_dec, server) = enter_world(store.clone(), 1); - let depth = store - .session_depth - .lock() - .unwrap() - .clone() - .expect("the session must have handed its egress handle to subscribe_player_events"); - assert_eq!( - depth.load(std::sync::atomic::Ordering::Relaxed), - 0, - "the login sequence has been fully read by the client, so the writer must have decremented \ - the egress depth for every item it wrote — a depth that only climbs makes every session \ - shed peer movement forever once it passes EGRESS_SHED_DEPTH" - ); - drop(client); - let _ = server.join(); -} - -#[test] -fn group_invite_by_name_replies_party_command_result_success() { - // CMSG_GROUP_INVITE "Buddy" resolves the name, calls the store, and echoes - // SMSG_PARTY_COMMAND_RESULT(Invite, "Buddy", Success); the store recorded the resolved guid. - let mut s = quest_store(); - s.characters = vec![codec::CharacterView { - guid: 2, - name: "Buddy".into(), - class: 1, - level: 10, - zone_id: 12, - ..Default::default() - }]; - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - - wow_world_messages::vanilla::CMSG_GROUP_INVITE { - name: "buddy".into(), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_PARTY_COMMAND_RESULT(r) => { - assert_eq!(r.result, wow_world_messages::vanilla::PartyResult::Success); - assert_eq!( - r.operation, - wow_world_messages::vanilla::PartyOperation::Invite - ); - assert_eq!(r.member, "buddy"); - } - other => panic!("expected SMSG_PARTY_COMMAND_RESULT, got {other}"), - } - assert_eq!(store.group_invites.lock().unwrap().as_slice(), &[2]); - drop(client); - let _ = server.join(); -} - -/// Every party Refusal the Module can send reaches the client as exactly one `PartyResult`, through -/// the real store seam rather than the mapping function alone. -#[test] -fn every_group_refusal_reaches_the_client_as_one_party_result() { - use wow_world_messages::vanilla::PartyResult; - for refusal in GroupRefusal::ALL { - let want = match refusal { - GroupRefusal::AlreadyInGroup => PartyResult::AlreadyInGroup, - GroupRefusal::GroupFull => PartyResult::GroupFull, - GroupRefusal::NotLeader => PartyResult::NotLeader, - GroupRefusal::NotInGroup => PartyResult::NotInGroup, - GroupRefusal::TargetNotInGroup => PartyResult::TargetNotInGroup, - GroupRefusal::WrongFaction => PartyResult::PlayerWrongFaction, - _ => PartyResult::BadPlayerName, - }; - let mut s = quest_store(); - s.characters = vec![codec::CharacterView { - guid: 2, - name: "Buddy".into(), - ..Default::default() - }]; - s.trade_error = Some(refusal.as_tag().to_string()); - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - wow_world_messages::vanilla::CMSG_GROUP_INVITE { - name: "Buddy".into(), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_PARTY_COMMAND_RESULT(r) => { - assert_eq!(r.result, want, "{refusal:?} must map to {want:?}") - } - other => panic!("expected SMSG_PARTY_COMMAND_RESULT, got {other}"), - } - drop(client); - let _ = server.join(); - } -} - -/// A reducer that timed out left the party in an unknown state, so it must end the session rather -/// than pose as a gameplay answer the client renders. -#[test] -fn a_group_invite_timeout_is_not_answered_as_a_refusal() { - let mut s = quest_store(); - s.characters = vec![codec::CharacterView { - guid: 2, - name: "Buddy".into(), - ..Default::default() - }]; - s.login_entity = Some(warrior_entity()); - s.trade_error = Some("gw_group_invite reducer timed out after 10s".into()); - let store = std::sync::Arc::new(s); - let (mut client, server_end) = world_session_socket_pair(); - let server_store = store.clone(); - let (result_tx, result_rx) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - result_tx - .send(run_world_session(server_end, server_store.as_ref())) - .unwrap(); - }); - 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(); - for _ in 0..WORLD_ENTRY_PACKETS { - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - } - wow_world_messages::vanilla::CMSG_GROUP_INVITE { - name: "Buddy".into(), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - let error = result_rx - .recv_timeout(std::time::Duration::from_secs(1)) - .expect("an unknown party outcome must end the session promptly") - .expect_err("a timed-out party reducer must be session-fatal"); - assert!(format!("{error:#}").contains("timed out")); -} - -#[test] -fn group_invite_unknown_name_replies_bad_player_name() { - // An unresolvable name never reaches the store — the reply is BadPlayerName ("player not found"). - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - - wow_world_messages::vanilla::CMSG_GROUP_INVITE { - name: "Nobody".into(), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_PARTY_COMMAND_RESULT(r) => { - assert_eq!( - r.result, - wow_world_messages::vanilla::PartyResult::BadPlayerName - ); - } - other => panic!("expected SMSG_PARTY_COMMAND_RESULT, got {other}"), - } - assert!(store.group_invites.lock().unwrap().is_empty()); - drop(client); - let _ = server.join(); -} - -/// Read one `SMSG_FRIEND_STATUS` frame — RAW always, now that `ONLINE`/`ADDED_ONLINE` carry -/// trailing fields gtker 0.3 cannot type (see `codec::build_friend_status_raw`). Returns the -/// result, the other party's guid, and the trailing bytes (empty unless online). -fn read_friend_status( - client: &mut UnixStream, - dec: &mut DecrypterHalf, -) -> (FriendResult, u64, Vec) { - let (opcode, body) = read_raw_frame(client, dec); - assert_eq!( - opcode, - codec::social::SMSG_FRIEND_STATUS_OPCODE, - "expected SMSG_FRIEND_STATUS" - ); - let result = FriendResult::try_from(body[0]).expect("a known FriendResult byte"); - let guid = u64::from_le_bytes(body[1..9].try_into().unwrap()); - (result, guid, body[9..].to_vec()) -} - -#[test] -fn add_friend_by_name_then_friend_list_carries_online_presence() { - // CMSG_ADD_FRIEND "Buddy" -> SMSG_FRIEND_STATUS AddedOnline (guid 2, resolved by - // name); a follow-up CMSG_FRIEND_LIST then carries them online with their level/class/zone. - let mut s = quest_store(); - s.characters = vec![codec::CharacterView { - guid: 2, - name: "Buddy".into(), - class: 1, - level: 10, - zone_id: 12, - ..Default::default() - }]; - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - - CMSG_ADD_FRIEND { - name: "buddy".into(), - } // case-insensitive match - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - let (result, guid, trailer) = read_friend_status(&mut client, &mut c_dec); - assert_eq!(result, FriendResult::AddedOnline); - assert_eq!(guid, 2); - assert_eq!(trailer[0], 1, "status ONLINE"); - - CMSG_FRIEND_LIST {} - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_FRIEND_LIST(l) => { - assert_eq!(l.friends.len(), 1); - assert_eq!(l.friends[0].guid.guid(), 2); - assert!(matches!( - l.friends[0].status, - wow_world_messages::vanilla::Friend_FriendStatus::Online { .. } - )); - } - other => panic!("expected SMSG_FRIEND_LIST, got {other}"), - } - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_IGNORE_LIST(l) => assert!(l.ignored.is_empty()), - other => panic!("expected SMSG_IGNORE_LIST, got {other}"), - } - - // Removing it replies Removed and the friend list empties out. - CMSG_DEL_FRIEND { guid: Guid::new(2) } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - let (result, _, trailer) = read_friend_status(&mut client, &mut c_dec); - assert_eq!(result, FriendResult::Removed); - assert!(trailer.is_empty(), "a remove carries no trailing fields"); - drop(client); - server.join().unwrap(); -} - -/// A friend on ANOTHER Shard resolves. Vim lives on `instances`; Ginger (this Gateway's own -/// Shard) adds them by name and gets ADDED_ONLINE with Vim's real presence, byte-exact against -/// the raw encoder. -#[test] -fn add_friend_on_another_shard_answers_added_online_with_presence() { - let mut s = quest_store(); - s.characters = vec![codec::CharacterView { - guid: 1, - name: "Ginger".into(), - race: 1, - ..Default::default() - }]; - let store = std::sync::Arc::new(s); - let peer = std::sync::Arc::new(InMemoryStore { - shard: "instances".into(), - characters: vec![codec::CharacterView { - guid: 2, - name: "Vim".into(), - race: 1, // same team as Ginger - class: 4, - ..Default::default() - }], - // Vim standing IN the instance: a live entity, not just a durable row, so the ADDED_ONLINE - // fields below come off `Whereabouts::InWorld`. - member_entities: std::sync::Mutex::new(vec![( - 2, - codec::MemberEntity { - level: 22, - zone_id: 33, - ..Default::default() - }, - )]), - ..Default::default() - }); - *store.peers.lock().unwrap() = vec![store.clone(), peer.clone()]; - *peer.peers.lock().unwrap() = vec![store.clone(), peer.clone()]; - - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - CMSG_ADD_FRIEND { name: "Vim".into() } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - let (result, guid, trailer) = read_friend_status(&mut client, &mut c_dec); - assert_eq!(result, FriendResult::AddedOnline); - assert_eq!(guid, 2); - let mut expected = vec![1u8]; // status ONLINE - expected.extend(33u32.to_le_bytes()); // area - expected.extend(22u32.to_le_bytes()); // level - expected.extend(4u32.to_le_bytes()); // class - assert_eq!(trailer, expected); - drop(client); - server.join().unwrap(); -} - -/// A friend standing in another Shard's instance shows ONLINE with real level/class/zone through -/// `CMSG_FRIEND_LIST`, and AFK/DND status follow the live entity's `PLAYER_FLAGS` — exercised -/// through the actual socket dispatch, so it runs the SAME `world::social::friend_views` -/// composition a Coordinator serves, not a Store Fake's own reimplementation of it. -#[test] -fn friend_list_shows_a_friend_in_another_shards_instance_online_afk_or_dnd() { - let mut s = quest_store(); - s.characters = vec![codec::CharacterView { - guid: 1, - name: "Ginger".into(), - race: 1, - ..Default::default() - }]; - s.contacts = std::sync::Mutex::new(vec![(1, 2, false)]); // Ginger already friends Vim - let store = std::sync::Arc::new(s); - let peer = std::sync::Arc::new(InMemoryStore { - shard: "instances".into(), - characters: vec![codec::CharacterView { - guid: 2, - name: "Vim".into(), - race: 1, // same team as Ginger - class: 4, - ..Default::default() - }], - member_entities: std::sync::Mutex::new(vec![( - 2, - codec::MemberEntity { - level: 22, - zone_id: 33, - player_flags: lyracore_shared::constants::player_flags::DND, - ..Default::default() - }, - )]), - ..Default::default() - }); - *store.peers.lock().unwrap() = vec![store.clone(), peer.clone()]; - *peer.peers.lock().unwrap() = vec![store.clone(), peer.clone()]; - - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - CMSG_FRIEND_LIST {} - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_FRIEND_LIST(l) => { - assert_eq!(l.friends.len(), 1); - assert_eq!(l.friends[0].guid.guid(), 2); - match l.friends[0].status { - wow_world_messages::vanilla::Friend_FriendStatus::Dnd { area, class, level } => { - assert_eq!(area.as_int(), 33); - assert_eq!(level.as_int(), 22); - assert_eq!(class, wow_world_messages::vanilla::Class::Rogue); - } - other => panic!("expected Dnd, got {other:?}"), - } - } - other => panic!("expected SMSG_FRIEND_LIST, got {other}"), - } - // CMSG_FRIEND_LIST always answers with BOTH lists; leaving the SMSG_IGNORE_LIST reply unread - // and dropping the client closes the socket with a queued write still in flight, which Linux - // reports to the server as a reset rather than a clean EOF. - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_IGNORE_LIST(l) => assert!(l.ignored.is_empty()), - other => panic!("expected SMSG_IGNORE_LIST, got {other}"), - } - drop(client); - server.join().unwrap(); -} - -/// An enemy-faction friend answers FRIEND_ENEMY. The companion half — the same target is a legal -/// ignore — is a Module rule this Store Fake cannot express; it is pinned durably in -/// `module/tests/friends.rs`. -#[test] -fn add_friend_enemy_faction_answers_friend_enemy() { - let mut s = quest_store(); - s.characters = vec![ - codec::CharacterView { - guid: 1, - name: "Ginger".into(), - race: 1, // Human, Alliance - ..Default::default() - }, - codec::CharacterView { - guid: 2, - name: "Grunt".into(), - race: 2, // Orc, Horde - ..Default::default() - }, - ]; - s.trade_error = Some(ContactRefusal::Enemy.as_tag().to_string()); - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - CMSG_ADD_FRIEND { - name: "Grunt".into(), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - let (result, guid, trailer) = read_friend_status(&mut client, &mut c_dec); - assert_eq!(result, FriendResult::Enemy); - assert_eq!(guid, 2); - assert!(trailer.is_empty()); - drop(client); - server.join().unwrap(); -} - -#[test] -fn add_friend_unknown_name_replies_not_found() { - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - CMSG_ADD_FRIEND { - name: "Nobody".into(), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - let (result, guid, _) = read_friend_status(&mut client, &mut c_dec); - assert_eq!(result, FriendResult::NotFound); - assert_eq!(guid, 0); - drop(client); - server.join().unwrap(); -} - -/// The ignore list has its own "unknown name" code. -#[test] -fn add_ignore_unknown_name_replies_ignore_not_found() { - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - CMSG_ADD_IGNORE { - name: "Nobody".into(), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - let (result, guid, _) = read_friend_status(&mut client, &mut c_dec); - assert_eq!(result, FriendResult::IgnoreNotFound); - assert_eq!(guid, 0); - drop(client); - server.join().unwrap(); -} - -#[test] -fn add_ignore_by_name_replies_ignore_added() { - let mut s = quest_store(); - s.characters = vec![codec::CharacterView { - guid: 3, - name: "Pest".into(), - ..Default::default() - }]; - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - CMSG_ADD_IGNORE { - name: "Pest".into(), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - let (result, guid, _) = read_friend_status(&mut client, &mut c_dec); - assert_eq!(result, FriendResult::IgnoreAdded); - assert_eq!(guid, 3); - drop(client); - server.join().unwrap(); -} - -#[test] -fn add_friend_maps_self_already_and_full_errors() { - for (refusal, want) in [ - (ContactRefusal::AddSelf, FriendResult::SelfX), - (ContactRefusal::AlreadyOnList, FriendResult::Already), - (ContactRefusal::ListFull, FriendResult::ListFull), - ] { - let mut s = quest_store(); - s.characters = vec![codec::CharacterView { - guid: 2, - name: "Buddy".into(), - ..Default::default() - }]; - s.trade_error = Some(refusal.as_tag().to_string()); - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - CMSG_ADD_FRIEND { - name: "Buddy".into(), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - let (result, ..) = read_friend_status(&mut client, &mut c_dec); - assert_eq!(result, want, "{refusal:?} must map to {want:?}"); - drop(client); - server.join().unwrap(); - } -} - -#[test] -fn add_ignore_maps_already_and_full_errors_to_the_ignore_variants() { - for (refusal, want) in [ - (ContactRefusal::AddSelf, FriendResult::IgnoreSelf), - (ContactRefusal::AlreadyOnList, FriendResult::IgnoreAlready), - (ContactRefusal::ListFull, FriendResult::IgnoreFull), - ] { - let mut s = quest_store(); - s.characters = vec![codec::CharacterView { - guid: 3, - name: "Pest".into(), - ..Default::default() - }]; - s.trade_error = Some(refusal.as_tag().to_string()); - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - CMSG_ADD_IGNORE { - name: "Pest".into(), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - let (result, ..) = read_friend_status(&mut client, &mut c_dec); - assert_eq!(result, want, "{refusal:?} must map to {want:?}"); - drop(client); - server.join().unwrap(); - } -} - -/// Every contact Refusal the Module can send reaches the client as exactly one `FriendResult`, and -/// the friends and ignore lists get their own code family for the same condition. -#[test] -fn every_contact_refusal_reaches_the_client_as_one_friend_result() { - for refusal in ContactRefusal::ALL { - let (friend, ignore) = match refusal { - ContactRefusal::AddSelf => (FriendResult::SelfX, FriendResult::IgnoreSelf), - ContactRefusal::AlreadyOnList => (FriendResult::Already, FriendResult::IgnoreAlready), - ContactRefusal::ListFull => (FriendResult::ListFull, FriendResult::IgnoreFull), - ContactRefusal::NotOnList => (FriendResult::NotFound, FriendResult::IgnoreNotFound), - ContactRefusal::ActorUnavailable => (FriendResult::NotFound, FriendResult::NotFound), - ContactRefusal::Enemy => (FriendResult::Enemy, FriendResult::Enemy), - }; - for (is_ignore, want) in [(false, friend), (true, ignore)] { - let mut s = quest_store(); - s.characters = vec![codec::CharacterView { - guid: 2, - name: "Buddy".into(), - ..Default::default() - }]; - s.trade_error = Some(refusal.as_tag().to_string()); - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - if is_ignore { - CMSG_ADD_IGNORE { - name: "Buddy".into(), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - } else { - CMSG_ADD_FRIEND { - name: "Buddy".into(), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - } - let (result, ..) = read_friend_status(&mut client, &mut c_dec); - assert_eq!(result, want, "{refusal:?} on ignore={is_ignore}"); - drop(client); - server.join().unwrap(); - } - } -} - -/// The contact half of the same rule: a timed-out add left the list in an unknown state. -#[test] -fn an_add_friend_timeout_is_not_answered_as_a_refusal() { - let mut s = quest_store(); - s.characters = vec![codec::CharacterView { - guid: 2, - name: "Buddy".into(), - ..Default::default() - }]; - s.login_entity = Some(warrior_entity()); - s.trade_error = Some("gw_add_friend reducer timed out after 10s".into()); - let store = std::sync::Arc::new(s); - let (mut client, server_end) = world_session_socket_pair(); - let server_store = store.clone(); - let (result_tx, result_rx) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - result_tx - .send(run_world_session(server_end, server_store.as_ref())) - .unwrap(); - }); - 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(); - for _ in 0..WORLD_ENTRY_PACKETS { - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - } - CMSG_ADD_FRIEND { - name: "Buddy".into(), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - 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")); -} - -#[test] -fn del_friend_unknown_contact_replies_not_found() { - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - CMSG_DEL_FRIEND { - guid: Guid::new(404), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - let (result, ..) = read_friend_status(&mut client, &mut c_dec); - assert_eq!(result, FriendResult::NotFound); - drop(client); - server.join().unwrap(); -} - -#[test] -fn del_ignore_round_trips_added_then_unknown_is_ignore_not_found() { - let mut s = quest_store(); - s.characters = vec![codec::CharacterView { - guid: 3, - name: "Pest".into(), - ..Default::default() - }]; - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - - CMSG_ADD_IGNORE { - name: "Pest".into(), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - let (result, ..) = read_friend_status(&mut client, &mut c_dec); - assert_eq!(result, FriendResult::IgnoreAdded); - - // Removing the just-added contact: IgnoreRemoved. - CMSG_DEL_IGNORE { guid: Guid::new(3) } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - let (result, ..) = read_friend_status(&mut client, &mut c_dec); - assert_eq!(result, FriendResult::IgnoreRemoved); - // Removing it again: no longer on the list -> IgnoreNotFound. - CMSG_DEL_IGNORE { guid: Guid::new(3) } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - let (result, ..) = read_friend_status(&mut client, &mut c_dec); - assert_eq!(result, FriendResult::IgnoreNotFound); - drop(client); - server.join().unwrap(); -} - -#[test] -fn quest_choose_reward_relays_inventory_before_completion_over_the_cipher() { - // The socket-level contract: the subscribed reducer callback has already queued the complete - // item insertion when turn_in_quest returns, so the session's completion presentation must sit - // behind CREATE, its inventory pointer and gain feedback on the one writer queue. - let mut s = quest_store(); - let detail = detail_view(1234, "A Threat Within"); - s.quest_details = vec![detail.clone()]; - let reward_item = codec::ItemInstanceView { - guid: 0x4000_0000_0000_0042, - entry: 25, - owner_guid: 1, - slot: 23, - stack_count: 2, - durability: 20, - max_durability: 20, - container_slots: 0, - random_property_id: 0, - random_property_enchant_ids: [0; 3], - item_text_id: 0, - enchantment: 0, - }; - s.turn_in_reward_item = Some(reward_item.clone()); - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - CMSG_QUESTGIVER_CHOOSE_REWARD { - guid: Guid::new(50), - quest_id: 1234, - reward: 2, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - let framed = |message: ServerOpcodeMessage| { - let mut bytes = Vec::new(); - message.write_unencrypted_server(&mut bytes).unwrap(); - ( - u16::from_le_bytes([bytes[2], bytes[3]]), - bytes[4..].to_vec(), - ) - }; - let expected = [ - framed(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( - codec::build_item_create_object(&reward_item), - ))), - framed(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( - codec::build_inv_slot_values( - reward_item.owner_guid, - reward_item.slot, - reward_item.guid, - ) - .unwrap(), - ))), - framed(ServerOpcodeMessage::SMSG_ITEM_PUSH_RESULT(Box::new( - codec::build_item_push_result( - reward_item.owner_guid, - 255, - reward_item.slot as u32, - reward_item.entry, - reward_item.stack_count, - false, - 0, - ), - ))), - framed(ServerOpcodeMessage::SMSG_QUESTGIVER_QUEST_COMPLETE( - Box::new(codec::build_quest_complete(&detail)), - )), - ]; - let actual = std::array::from_fn(|_| read_raw_frame(&mut client, &mut c_dec)); - assert_eq!( - actual, expected, - "inventory visibility must precede completion" - ); - drop(client); - server.join().unwrap(); - assert_eq!( - store.turned_in.lock().unwrap().as_slice(), - &[(7, 50, 1234, 2)] - ); -} - -#[test] -fn login_sends_the_quest_log_descriptor_raw_update_after_the_create_packet() { - let slots = vec![codec::update_mask::QuestLogSlot { - slot: 3, - quest_id: 777, - counts: Vec::new(), - state: 0, - timer: 0, - }]; - let mut s = quest_store(); - s.quest_log_slots = slots.clone(); - let store = std::sync::Arc::new(s); - - 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.as_ref()).unwrap(); - }); - 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(); - - // World entry — discarded, this test is about what comes right after. - for _ in 0..WORLD_ENTRY_PACKETS { - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - } - // gtker's typed reader rejects this raw partial VALUES body (no OBJECT_FIELD_TYPE), so read it - // RAW and compare it against the same builder the seam's `quest_log_update` calls. - let (opcode, body) = read_raw_frame(&mut client, &mut c_dec); - let mask = codec::update_mask::full_quest_log_mask(&slots); - assert_eq!((opcode, body), codec::build_values_update_raw(1, &mask)); - - drop(client); - server.join().unwrap(); -} - -// ── Inspect ─────────────────────────────────────────────────────────────────────────────────────── - -#[test] -fn inspect_in_range_friendly_target_replies_smsg_inspect_with_the_target_guid() { - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - CMSG_INSPECT { - guid: Guid::new(55), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_INSPECT(r) => assert_eq!(r.guid.guid(), 55), - other => panic!("expected SMSG_INSPECT, got {other}"), - } - drop(client); - server.join().unwrap(); -} - -#[test] -fn inspect_refused_target_sends_no_reply() { - // CMSG_PLAYED_TIME (below) always replies as long as `character_by_guid` resolves the caller's - // own guid, so give the store a character row for guid 1 (quest_store() has none). - let store = std::sync::Arc::new(InMemoryStore { - characters: vec![codec::CharacterView { - guid: 1, - ..Default::default() - }], - ..quest_store() - }); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - // guid 0 is the mock store's stand-in for "out of range / no such target" — the gate rejects it - // and the handler drops the request silently (mirrors CMSG_GAMEOBJ_USE/CMSG_AREATRIGGER). - CMSG_INSPECT { guid: Guid::new(0) } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - // Sentinel: a follow-up request with a guaranteed reply. If the refused CMSG_INSPECT had - // wrongly produced an SMSG_INSPECT, it would arrive first and this match would fail. - CMSG_PLAYED_TIME {} - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_PLAYED_TIME(_) => {} // no SMSG_INSPECT was sent for the refused target - other => { - panic!("expected SMSG_PLAYED_TIME (no SMSG_INSPECT for refused target), got {other}") - } - } - drop(client); - server.join().unwrap(); -} - -// ── Vendor / buy-failed ───────────────────────────────────────────────────────── - -#[test] -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 store = std::sync::Arc::new(InMemoryStore { - login_entity: Some(warrior_entity()), - trade_error: Some("not enough money to buy that item".into()), - ..tester_store(7) - }); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - CMSG_BUY_ITEM { - vendor: Guid::new(99), - item: 1234, - amount: 1, - unknown1: 1, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_BUY_FAILED(p) => { - assert_eq!(p.guid.guid(), 99, "vendor guid echoed back"); - assert_eq!(p.item, 1234, "item entry echoed back"); - assert!( - matches!(p.result, BuyResult::NotEnoughMoney), - "BuyResult maps to NotEnoughMoney" - ); - } - other => panic!("expected SMSG_BUY_FAILED, got {other}"), - } - drop(client); - server.join().unwrap(); -} - -// ── Bank ────────────────────────────────────────────────────────────────────────── - -#[test] -fn banker_activate_sends_smsg_show_bank_with_the_banker_guid() { - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - CMSG_BANKER_ACTIVATE { - guid: Guid::new(77), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_SHOW_BANK(p) => assert_eq!(p.guid.guid(), 77), - other => panic!("expected SMSG_SHOW_BANK, got {other}"), - } - drop(client); - server.join().unwrap(); -} - -#[test] -fn banker_activate_on_a_standing_refusing_banker_sends_no_reply() { - // CMSG_PLAYED_TIME (the sentinel below) only replies once `character_by_guid` resolves the - // caller's own guid, so give the store a character row for guid 1 (quest_store() has none) — - // same setup as `inspect_refused_target_sends_no_reply`. - let store = std::sync::Arc::new(InMemoryStore { - npc_refuses: true, - characters: vec![codec::CharacterView { - guid: 1, - ..Default::default() - }], - ..quest_store() - }); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - CMSG_BANKER_ACTIVATE { - guid: Guid::new(77), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - // Sentinel: a follow-up request with a guaranteed reply. If the refused activate had wrongly - // produced an SMSG_SHOW_BANK, it would arrive first and this match would fail. - CMSG_PLAYED_TIME {} - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_PLAYED_TIME(_) => {} // no SMSG_SHOW_BANK for the refused banker - other => { - panic!("expected SMSG_PLAYED_TIME (no SMSG_SHOW_BANK for refused banker), got {other}") - } - } - drop(client); - server.join().unwrap(); -} - -#[test] -fn gossip_select_on_an_imported_banker_option_opens_the_bank_window() { - use lyracore_shared::constants::gossip_option; - let mut s = quest_store(); - s.gossip_opts = vec![opt( - 0, - "I would like to check my deposit box.", - gossip_option::BANKER, - )]; - 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); - CMSG_GOSSIP_SELECT_OPTION { - guid: Guid::new(90), - gossip_list_id: 0, - unknown: None, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_SHOW_BANK(p) => assert_eq!(p.guid.guid(), 90), - other => panic!("expected SMSG_SHOW_BANK, got {other}"), - } - drop(client); - server.join().unwrap(); -} - -#[test] -fn gossip_select_on_a_petitioner_option_opens_the_charter_list() { - use lyracore_shared::constants::gossip_option; - let mut s = quest_store(); - s.gossip_opts = vec![opt(0, "How do I form a guild?", gossip_option::PETITIONER)]; - 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); - CMSG_GOSSIP_SELECT_OPTION { - guid: Guid::new(90), - gossip_list_id: 0, - unknown: None, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE => {} - other => panic!("expected SMSG_GOSSIP_COMPLETE, got {other}"), - } - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_PETITION_SHOWLIST(list) => { - assert_eq!(list.npc.guid(), 90); - assert_eq!(list.petitions[0].charter_entry, 5863); - assert_eq!(list.petitions[0].guild_charter_cost, 1000); - } - other => panic!("expected SMSG_PETITION_SHOWLIST, got {other}"), - } - drop(client); - server.join().unwrap(); -} - -#[test] -fn autobank_item_from_the_main_bag_dispatches_auto_bank_item() { - // Right-click a bag item with the bank open (CMSG_AUTOBANK_ITEM) → the gateway names the source - // slot and lets the module resolve the free bank slot; deposit and withdraw share one store method. - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, _c_dec, server) = enter_world(store.clone(), 1); - CMSG_AUTOBANK_ITEM { - bag_index: 255, - slot_index: 23, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - drop(client); - server.join().unwrap(); - assert_eq!(store.auto_banked_items.lock().unwrap().as_slice(), &[23]); -} - -#[test] -fn autostore_bank_item_from_the_main_bag_dispatches_auto_bank_item() { - // Right-click a banked item (CMSG_AUTOSTORE_BANK_ITEM) → withdraw, same store method as deposit. - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, _c_dec, server) = enter_world(store.clone(), 1); - CMSG_AUTOSTORE_BANK_ITEM { - bag_index: 255, - slot_index: 39, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - drop(client); - server.join().unwrap(); - assert_eq!(store.auto_banked_items.lock().unwrap().as_slice(), &[39]); -} - -#[test] -fn autobank_item_err_sends_smsg_inventory_change_failure() { - // A full destination (bank full, or carry space full) is a per-action error relayed as the - // existing inventory-change-failure reply, never session-fatal. - let store = std::sync::Arc::new(InMemoryStore { - trade_error: Some("bank full".into()), - ..quest_store() - }); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - CMSG_AUTOBANK_ITEM { - bag_index: 255, - slot_index: 23, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_INVENTORY_CHANGE_FAILURE(_) => {} // correct feedback packet - other => panic!("expected SMSG_INVENTORY_CHANGE_FAILURE, got {other}"), - } - drop(client); - server.join().unwrap(); -} - -#[test] -fn autobank_item_from_a_sub_bag_is_unsupported_and_does_not_dispatch() { - // Only the main pseudo-bag (255) is addressed, matching the item handler's restriction — a - // sub-bag index is logged and ignored, never fatal. - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, _c_dec, server) = enter_world(store.clone(), 1); - CMSG_AUTOBANK_ITEM { - bag_index: 19, - slot_index: 0, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - drop(client); - server.join().unwrap(); - assert!( - store.auto_banked_items.lock().unwrap().is_empty(), - "a sub-bag source must not be routed through auto_bank_item" - ); -} - -#[test] -fn autostore_bank_item_from_a_sub_bag_is_unsupported_and_does_not_dispatch() { - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, _c_dec, server) = enter_world(store.clone(), 1); - CMSG_AUTOSTORE_BANK_ITEM { - bag_index: 19, - slot_index: 0, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - drop(client); - server.join().unwrap(); - assert!( - store.auto_banked_items.lock().unwrap().is_empty(), - "a sub-bag source must not be routed through auto_bank_item" - ); -} - -#[test] -fn buy_bank_slot_success_sends_ok_and_reaches_the_named_banker() { - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - CMSG_BUY_BANK_SLOT { - guid: Guid::new(88), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_BUY_BANK_SLOT_RESULT(p) => { - assert_eq!(p.result, BuyBankSlotResult::Ok); - } - other => panic!("expected SMSG_BUY_BANK_SLOT_RESULT, got {other}"), - } - drop(client); - server.join().unwrap(); - assert_eq!(store.bought_bank_slots.lock().unwrap().as_slice(), &[88]); -} - -#[test] -fn buy_bank_slot_failure_maps_the_bracketed_code_to_the_matching_result() { - // The module tags a refusal with its `SMSG_BUY_BANK_SLOT_RESULT` code in brackets — parsed by - // code, not by matching the prose. - for (err, want) in [ - ( - "[0] no bank bag slots left to buy", - BuyBankSlotResult::FailedTooMany, - ), - ( - "[1] not enough money (need 1000)", - BuyBankSlotResult::InsufficientFunds, - ), - ("[2] target is not a banker", BuyBankSlotResult::NotBanker), - ] { - let mut s = quest_store(); - s.trade_error = Some(err.into()); - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - CMSG_BUY_BANK_SLOT { - guid: Guid::new(88), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_BUY_BANK_SLOT_RESULT(p) => { - assert_eq!(p.result, want, "store error {err:?} must map to {want:?}"); - } - other => panic!("expected SMSG_BUY_BANK_SLOT_RESULT, got {other}"), - } - drop(client); - server.join().unwrap(); - } -} - -// ── Inventory change failure ───────────────────────────────────────────────────── - -#[test] -fn equip_item_err_sends_smsg_inventory_change_failure() { - // Socket contract: a gameplay refusal reaches the client as an encrypted - // SMSG_INVENTORY_CHANGE_FAILURE frame and the session keeps serving the next action. - let store = std::sync::Arc::new(InMemoryStore { - login_entity: Some(warrior_entity()), - trade_error: Some(ItemRefusal::CannotEquip.as_tag().into()), - ..tester_store(7) - }); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - // Slot 24 is a backpack slot (>= 23); bag 255 = INVENTORY_SLOT_BAG_0 (main bag). - CMSG_AUTOEQUIP_ITEM { - source_bag: 255, - source_slot: 24, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_INVENTORY_CHANGE_FAILURE(_) => {} // correct feedback packet - other => panic!("expected SMSG_INVENTORY_CHANGE_FAILURE, got {other}"), - } - CMSG_AUTOEQUIP_ITEM { - source_bag: 255, - source_slot: 25, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_INVENTORY_CHANGE_FAILURE(_) => {} - other => panic!("expected a second SMSG_INVENTORY_CHANGE_FAILURE, got {other}"), - } - drop(client); - server.join().unwrap(); -} - -#[test] -fn item_action_before_player_login_is_handled_without_panicking() { - // Socket contract: an item frame arriving after the handshake but before CMSG_PLAYER_LOGIN - // (no selected player) must not panic or error the session thread. - let store = std::sync::Arc::new(tester_store(7)); - 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.as_ref())); - let (mut c_enc, _c_dec) = client_handshake(&mut client, "TESTER", K); - - CMSG_AUTOEQUIP_ITEM { - source_bag: 255, - source_slot: 24, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - drop(client); - - server - .join() - .expect("an item action without a selected player must not panic") - .expect("the legacy zero-actor fallback remains a handled gameplay context"); -} - -#[test] -fn item_reducer_transport_loss_ends_the_world_session() { - // Socket contract: reducer transport loss ends the session with an error and closes the - // socket instead of being translated into gameplay feedback. - let store = std::sync::Arc::new(InMemoryStore { - login_entity: Some(warrior_entity()), - trade_error: Some("equip_item reducer transport disconnected: channel closed".into()), - ..tester_store(7) - }); - let (mut client, server_end) = world_session_socket_pair(); - let server_store = store.clone(); - let (result_tx, result_rx) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - result_tx - .send(run_world_session(server_end, server_store.as_ref())) - .unwrap(); - }); - 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(); - for _ in 0..WORLD_ENTRY_PACKETS { - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - } - - CMSG_AUTOEQUIP_ITEM { - source_bag: 255, - source_slot: 24, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - - let error = result_rx - .recv_timeout(std::time::Duration::from_secs(1)) - .expect("transport loss must end the session promptly") - .expect_err("a disconnected item reducer transport must be session-fatal"); - assert!(format!("{error:#}").contains("reducer transport disconnected")); - assert!( - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).is_err(), - "the socket closes instead of translating transport loss into gameplay feedback" - ); -} - -// =========================================================================== -// Logout gate tests (blocking logout while in combat) -// =========================================================================== - -#[test] -fn logout_while_out_of_combat_succeeds_and_clears_open_loot() { - // combat_until_ms=0 (default, never in combat) → CMSG_LOGOUT_REQUEST must reply - // Success/Instant + LOGOUT_COMPLETE and the Store must release Account ownership. - let store = std::sync::Arc::new(InMemoryStore { - login_entity: Some(warrior_entity()), - corpse_money: 25, - ..tester_store(7) - }); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - - let _ = open_loot_window(&mut client, &mut c_enc, &mut c_dec, 60); - - CMSG_LOGOUT_REQUEST {} - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - - // First message: SMSG_LOGOUT_RESPONSE(Success, Instant) - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_LOGOUT_RESPONSE(r) => { - use wow_world_messages::vanilla::{LogoutResult, LogoutSpeed}; - assert_eq!( - r.result, - LogoutResult::Success, - "expected Success, got {:?}", - r.result - ); - assert_eq!(r.speed, LogoutSpeed::Instant); - } - other => panic!("expected SMSG_LOGOUT_RESPONSE, got {other}"), - } - // Second message: SMSG_LOGOUT_COMPLETE - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_LOGOUT_COMPLETE => {} - other => panic!("expected SMSG_LOGOUT_COMPLETE, got {other}"), - } - - CMSG_LOOT_MONEY {} - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - - drop(client); - server.join().unwrap(); - - // Releasing Account ownership removes the live Character. - assert!( - store - .logout_called - .load(std::sync::atomic::Ordering::SeqCst), - "Account ownership must be released after successful logout" - ); - assert!( - store.money_looted.lock().unwrap().is_empty(), - "logout must discard the open loot target" - ); -} - -#[test] -fn logout_while_in_combat_is_denied() { - // combat_until_ms=u64::MAX → CMSG_LOGOUT_REQUEST must reply FailureInCombat. The session - // stays alive (verified by sending a second request and getting a second denial), meaning the - // entity was NOT removed during the handler — the player cannot escape combat by logging out. - // Note: socket teardown (drop below) still calls leave_world/logout as cleanup; that is correct - // and separate from the CMSG gate. - let store = std::sync::Arc::new(InMemoryStore { - login_entity: Some(warrior_entity()), - combat_until_ms: u64::MAX, // always in combat - ..tester_store(7) - }); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - - // First request: must be denied. - CMSG_LOGOUT_REQUEST {} - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_LOGOUT_RESPONSE(r) => { - use wow_world_messages::vanilla::LogoutResult; - assert_eq!( - r.result, - LogoutResult::FailureInCombat, - "expected FailureInCombat on first request, got {:?}", - r.result - ); - } - other => panic!("expected SMSG_LOGOUT_RESPONSE(denial) on first request, got {other}"), - } - - // Second request: still denied (session is still alive, entity still in-world). - // If the first denial had accidentally removed the entity / transitioned to CharSelect, - // this second request would either hang or produce a different message. - CMSG_LOGOUT_REQUEST {} - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_LOGOUT_RESPONSE(r) => { - use wow_world_messages::vanilla::LogoutResult; - assert_eq!( - r.result, - LogoutResult::FailureInCombat, - "expected FailureInCombat on second request (session still alive), got {:?}", - r.result - ); - } - other => panic!("expected SMSG_LOGOUT_RESPONSE(denial) on second request, got {other}"), - } - - drop(client); - server.join().unwrap(); -} - -#[test] -fn played_time_replies_with_the_durable_total_plus_the_live_session_span() { - // CMSG_PLAYED_TIME -> SMSG_PLAYED_TIME. The character row carries a durable - // 3600s total plus a session_start_micros stamped in the recent past, so the reply must be - // strictly greater than the durable floor (the live span gets folded in) and sane (not absurdly - // large — bounds the test against a unit mixup, e.g. treating micros as millis). - let durable_secs: u32 = 3600; - let session_started_secs_ago: u64 = 5; - let now_micros = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_micros() as u64; - let session_start_micros = now_micros - session_started_secs_ago * 1_000_000; - - let store = std::sync::Arc::new(InMemoryStore { - login_entity: Some(warrior_entity()), - characters: vec![codec::CharacterView { - guid: 1, - name: "Tester".into(), - played_total_secs: durable_secs, - session_start_micros, - ..Default::default() - }], - ..tester_store(7) - }); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - - CMSG_PLAYED_TIME {} - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_PLAYED_TIME(p) => { - assert!( - p.total_played_time >= durable_secs, - "reply {} must be at least the durable floor {durable_secs}", - p.total_played_time - ); - assert!( - p.total_played_time < durable_secs + 60, - "reply {} should only add a few seconds of live session, not run away", - p.total_played_time - ); - assert_eq!( - p.total_played_time, p.level_played_time, - "level time mirrors total (untracked per-level in this slice)" - ); - } - other => panic!("expected SMSG_PLAYED_TIME, got {other}"), - } - - drop(client); - server.join().unwrap(); -} - -fn imported_auction_interaction() -> AuctionInteraction { - AuctionInteraction { - house: super::handlers::AuctionHousePolicy { - id: 1, - deposit_rate: 5, - consignment_rate: 5, - }, - refuses_interaction: false, - } -} - -#[test] -fn auction_house_round_trip_stays_typed_and_ordered_over_an_encrypted_session() { - let store = std::sync::Arc::new(InMemoryStore { - auction_interaction: Some(imported_auction_interaction()), - ..quest_store() - }); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - let auctioneer = Guid::new(42); - - MSG_AUCTION_HELLO_Client { auctioneer } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::MSG_AUCTION_HELLO(message) => { - assert_eq!(message.auctioneer, auctioneer); - assert_eq!(message.auction_house.as_int(), 1); - } - other => panic!("expected auction hello first, got {other}"), - } - - CMSG_AUCTION_LIST_ITEMS { - auctioneer, - ..Default::default() - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_AUCTION_LIST_RESULT(result) => { - assert!(result.auctions.is_empty()); - assert_eq!(result.total_amount_of_auctions, 0); - } - other => panic!("expected empty browse view second, got {other}"), - } - - CMSG_AUCTION_LIST_OWNER_ITEMS { - auctioneer, - ..Default::default() - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_AUCTION_OWNER_LIST_RESULT(result) => { - assert!(result.auctions.is_empty()); - assert_eq!(result.total_amount_of_auctions, 0); - } - other => panic!("expected empty owner view third, got {other}"), - } - - CMSG_AUCTION_LIST_BIDDER_ITEMS { - auctioneer, - ..Default::default() - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_AUCTION_BIDDER_LIST_RESULT(result) => { - assert!(result.auctions.is_empty()); - assert_eq!(result.total_amount_of_auctions, 0); - } - other => panic!("expected empty bidder view fourth, got {other}"), - } - - drop(client); - server.join().unwrap(); -} - -#[test] -fn refused_auctioneer_interaction_keeps_the_encrypted_world_session_alive() { - // A resolvable requester with no seeded in-world Characters (`entity_in_world: false` - // overrides `quest_store`'s blanket flag), so the WHO answer this test cares about is the - // empty-but-present reply, not "no answer for an unknown requester" (a different rule, pinned - // in `social.rs`'s own WHO tests). - let store = std::sync::Arc::new(InMemoryStore { - characters: vec![codec::CharacterView { - guid: 1, - name: "Tester".into(), - race: 1, - ..Default::default() - }], - entity_in_world: false, - ..quest_store() - }); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - - MSG_AUCTION_HELLO_Client { - auctioneer: Guid::new(999), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - CMSG_WHO::default() - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - - // RAW-encoded (codec::build_who_response_raw); the auction refusal must be silent and leave - // WHO's own empty-roster reply next. - let (opcode, body) = read_raw_frame(&mut client, &mut c_dec); - assert_eq!(opcode, codec::social::SMSG_WHO_OPCODE); - assert_eq!( - &body[0..8], - &[0u8; 8], - "no in-world Characters: listed and online both 0" - ); - - drop(client); - server.join().unwrap(); -} - -// =========================================================================================== -// The handler-level tests — CMSG_CAST_SPELL routing, quest instant -// routing, the loot window state machine, melee attack over the socket, and the smaller mappings. -// Each drives the full encrypted session (enter_world) and pins wire replies + store dispatches. -// =========================================================================================== - -/// Vanilla opcodes for the frames the cast path emits (raw + typed) — pinned as numbers because the -/// synchronous ORDER across `Outbound::Raw` and typed sends is the contract under test. -const OP_CAST_RESULT: u16 = 0x0130; -const OP_SPELL_START: u16 = 0x0131; -const OP_SPELL_GO: u16 = 0x0132; -const OP_LOOT_RESPONSE: u16 = 0x0160; - -/// Read one encrypted server frame RAW: decrypt the 4-byte header via the client's `DecrypterHalf`, -/// return `(opcode, body)`. Needed where the wire contract is an `Outbound::Raw` packet (the 5-byte -/// CAST_RESULT ack, the loot/vendor windows) or where the exact opcode ORDER across raw+typed sends -/// is the assertion — gtker's typed reader rejects some of the hand-rolled bodies (it would consume -/// the frame but error), so the bytes are read and pinned directly. -fn read_raw_frame(client: &mut S, dec: &mut DecrypterHalf) -> (u16, Vec) { - let h = dec.read_and_decrypt_server_header(&mut *client).unwrap(); - let mut body = vec![0u8; (h.size as usize).saturating_sub(2)]; - client.read_exact(&mut body).unwrap(); - (h.opcode, body) -} - -fn open_loot_window( - client: &mut UnixStream, - enc: &mut EncrypterHalf, - dec: &mut DecrypterHalf, - target_guid: u64, -) -> Vec { - CMSG_LOOT { - guid: Guid::new(target_guid), - } - .write_encrypted_client(&mut *client, enc) - .unwrap(); - let (opcode, body) = read_raw_frame(client, dec); - assert_eq!(opcode, OP_LOOT_RESPONSE); - body -} - -/// `SpellCastTargets` carrying a UNIT target (the client's selected mob). -fn unit_targets(guid: u64) -> SpellCastTargets { - SpellCastTargets { - target_flags: SpellCastTargets_SpellCastTargetFlags::new_unit( - SpellCastTargets_SpellCastTargetFlags_Unit { - unit_target: Guid::new(guid), - }, - ), - } -} - -// ── Cast dispatch through the encrypted session ──────────────────────────────────────────────── -// -// Route selection, target decoding, durable dispatch and message ORDER are covered per route by the -// focused seam tests in `handlers/cast`. What only a socket can show survives here: that a cast -// opcode reaches the seam through the real dispatch chain, that the ordered batch leaves the writer -// as those frames in that order, and that the session supplies the caller's identity. - -#[test] -fn instant_cast_sends_start_then_raw_cast_result_ok_then_go_and_threads_the_target() { - // Root-cause client-wedge fix: an INSTANT cast must emit START(0) → raw CAST_RESULT(OK, - // opcode 0x0130, 5-byte body) → GO synchronously, IN THAT ORDER, and the cast must reach the - // store with the client's unit target. Raw and typed sends interleave here, so the frame order - // is a property of the writer, not just of the batch the seam returns. - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - - CMSG_CAST_SPELL { - spell: 100, - targets: unit_targets(77), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - - let (op1, _) = read_raw_frame(&mut client, &mut c_dec); - assert_eq!(op1, OP_SPELL_START, "first frame must be SMSG_SPELL_START"); - let (op2, body2) = read_raw_frame(&mut client, &mut c_dec); - assert_eq!( - op2, OP_CAST_RESULT, - "second frame must be the raw CAST_RESULT ack" - ); - let mut want = 100u32.to_le_bytes().to_vec(); - want.push(0x00); // SPELL_RESULT_STATUS_OKAY — 5 bytes, NO trailing reason byte - assert_eq!( - body2, want, - "CAST_RESULT body is spell_id(u32 LE) + OKAY(0x00)" - ); - let (op3, _) = read_raw_frame(&mut client, &mut c_dec); - assert_eq!(op3, OP_SPELL_GO, "third frame must be SMSG_SPELL_GO"); - - drop(client); - server.join().unwrap(); - // The unit target rode CMSG → handler → store unchanged (target-keyed effects need it). - assert_eq!(store.casts.lock().unwrap().as_slice(), &[(100, 77)]); -} - -#[test] -fn set_sheathed_routes_the_clients_z_press_to_the_store() { - for (sent, expect) in [ - (SheathState::Unarmed, 0u8), - (SheathState::Melee, 1), - (SheathState::Ranged, 2), - ] { - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, _c_dec, server) = enter_world(store.clone(), 1); - CMSG_SETSHEATHED { sheathed: sent } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - drop(client); - server.join().unwrap(); - assert_eq!( - store.sheathed.lock().unwrap().as_slice(), - &[(1, expect)], - "{sent:?} must reach set_sheathed as byte {expect}" - ); - } -} - -#[test] -fn auto_shot_intercept_starts_the_ranged_attack_instead_of_casting() { - // Vanilla shape: Auto Shot (75) and wand Shoot (5019) are auto-repeat ranged attacks — - // the handler arms start_ranged_attack with the cast's unit target, then the activation ack is - // SMSG_SPELL_START ALONE with timer 0 (no CAST_RESULT, no GO — the cast parks in the client's - // AUTOREPEAT slot and never resolves; each shot's GO comes from the swing-tick relay). - // cast_spell must NOT run. - for spell in [75u32, 5019] { - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - CMSG_CAST_SPELL { - spell, - targets: unit_targets(88), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - let (op, body) = read_raw_frame(&mut client, &mut c_dec); - assert_eq!( - op, OP_SPELL_START, - "spell {spell}: activation ack is SPELL_START alone" - ); - // timer (u32 LE) is the LAST 4 body bytes before... layout: cast_item(packed) caster(packed) - // spell(4) flags(2) timer(4) targets(..). Cheap pin: the timer bytes right after the u16 - // flags must be 0 — locate spell id then skip flags. spell sits at a packed-guid-dependent - // offset; both packed self-guids here are 2 bytes (guid 1 -> [0x01, 0x01]). - let spell_pos = 4; // two 2-byte packed guids - assert_eq!( - &body[spell_pos..spell_pos + 4], - &spell.to_le_bytes(), - "spell id in START" - ); - assert_eq!( - &body[spell_pos + 6..spell_pos + 10], - &0u32.to_le_bytes(), - "spell {spell}: START timer must be 0 (the 0.5s wind-up is an attack-timer, not a cast bar)" - ); - // Nothing else may follow on the activation path (the old phantom GO fired the shoot - // animation instantly). - let mut probe = [0u8; 1]; - assert!( - client.read(&mut probe).map(|n| n == 0).unwrap_or(true), - "spell {spell}: no packet may follow the activation START" - ); - drop(client); - server.join().unwrap(); - assert_eq!( - store.ranged_attacks.lock().unwrap().as_slice(), - &[(88, spell)] - ); - assert!( - store.casts.lock().unwrap().is_empty(), - "spell {spell} must not reach cast_spell" - ); - } -} - -#[test] -fn a_cast_before_entering_the_world_answers_nothing_and_keeps_the_session_alive() { - // 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 session must serve the - // next opcode instead of panicking, which the CHAR_ENUM sentinel proves. - let store = std::sync::Arc::new(quest_store()); - 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.as_ref()).unwrap(); - }); - let (mut c_enc, mut c_dec) = client_handshake(&mut client, "TESTER", K); - - CMSG_CAST_SPELL { - spell: 100, - targets: unit_targets(77), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - CMSG_CHAR_ENUM {} - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_CHAR_ENUM(_) => {} - other => panic!("expected SMSG_CHAR_ENUM (the cast sent no frames), got {other}"), - } - drop(client); - server.join().unwrap(); - assert_eq!(store.casts.lock().unwrap().as_slice(), &[(100, 77)]); -} - -#[test] -fn cancelling_auto_repeat_still_tears_the_ranged_loop_down_through_stop_attack() { - // The cancel tears the loop down only when one is armed (the `was_repeat` gate). Arm Auto Shot, - // then cancel, and pin that the teardown reaches the durable disengage for this attacker. - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - CMSG_CAST_SPELL { - spell: 75, - targets: unit_targets(88), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - let (op, _) = read_raw_frame(&mut client, &mut c_dec); - assert_eq!(op, OP_SPELL_START, "the activation ack arms the loop"); - CMSG_CANCEL_AUTO_REPEAT_SPELL {} - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - // The cancel sends no ack of its own (the on_delete relay does), so a sentinel proves the - // server got that far before we assert. - CMSG_QUESTGIVER_STATUS_QUERY { - guid: Guid::new(50), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_QUESTGIVER_STATUS(_) => {} - other => panic!("expected the sentinel (the cancel acks nothing), got {other}"), - } - drop(client); - server.join().unwrap(); - assert_eq!(store.stop_attacks.lock().unwrap().as_slice(), &[1]); -} - -// ── Quest giver routing (CMSG_QUESTGIVER_HELLO) ────────────────────────────────────────────────── - -#[test] -fn quest_hello_reaches_the_quest_module_and_its_raw_details_body_survives_the_cipher() { - // The socket-level contract: dispatch routes HELLO to the quest module, and the raw-encoded - // DETAILS screen it returns crosses the encrypted frame intact. Which screen a giver opens is - // decided at the `dispatch_quest_action` seam and proved there. - const OP_QUEST_DETAILS: u16 = 0x0188; - let mut s = quest_store(); - s.quest_evals = vec![eval(1234, codec::ROLE_START, false, false)]; - s.quest_details = vec![detail_view(1234, "A Threat Within")]; - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - CMSG_QUESTGIVER_HELLO { - guid: Guid::new(50), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - let (op, body) = read_raw_frame(&mut client, &mut c_dec); - assert_eq!(op, OP_QUEST_DETAILS); - assert_eq!( - &body[..12], - &codec::build_quest_details_raw(50, &detail_view(1234, "A Threat Within")).1[..12], - "giver guid + quest id reached the wire unchanged" - ); - drop(client); - server.join().unwrap(); -} - -#[test] -fn quest_query_answers_the_raw_definition_body_through_the_cipher() { - // The socket-level contract for the client's cold-cache definition query: the hand-rolled 5875 - // body crosses the encrypted frame intact. Which quests answer at all is proved at the seam. - const OP_QUEST_QUERY_RESPONSE: u16 = 0x005D; - let mut s = quest_store(); - s.quest_details = vec![detail_view(1234, "A Threat Within")]; - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - CMSG_QUEST_QUERY { quest_id: 1234 } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - let (op, body) = read_raw_frame(&mut client, &mut c_dec); - assert_eq!(op, OP_QUEST_QUERY_RESPONSE); - assert_eq!( - body, - codec::build_quest_query_response_raw(&detail_view(1234, "A Threat Within")).1 - ); - drop(client); - server.join().unwrap(); -} - -#[test] -fn questgiver_gameobject_bypasses_the_chest_lifecycle() { - let mut s = quest_store(); - s.gameobject_type = Some(lyracore_shared::constants::go_type::QUESTGIVER); - s.quest_evals = vec![eval(1234, codec::ROLE_START, false, false)]; - s.quest_details = vec![detail_view(1234, "A Threat Within")]; - s.corpse_loot_by_viewer - .insert(1, vec![(0, 2589, 1, 200, 0)]); - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - - CMSG_GAMEOBJ_USE { - guid: Guid::new(68), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_QUESTGIVER_QUEST_DETAILS(details) => { - assert_eq!(details.quest_id, 1234) - } - other => panic!("expected quest details from a questgiver GameObject, got {other}"), - } - drop(client); - server.join().unwrap(); - assert!(store.gameobjects_used.lock().unwrap().is_empty()); - assert!(store.corpse_loot_reads.lock().unwrap().is_empty()); -} - -#[test] -fn non_chest_gameobject_preserves_the_general_use_path() { - let mut s = quest_store(); - s.gameobject_type = Some(0); - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, _c_dec, server) = enter_world(store.clone(), 1); - - CMSG_GAMEOBJ_USE { - guid: Guid::new(91), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - - drop(client); - server.join().unwrap(); - assert_eq!(store.gameobjects_used.lock().unwrap().as_slice(), &[91]); - assert_eq!( - store.corpse_loot_reads.lock().unwrap().as_slice(), - &[(91, 1)] - ); -} - -// ── Loot window state machine ──────────────────────────────────────────────────────────────────── - -#[test] -fn chest_dispatch_opens_the_shared_window_and_tracks_its_target() { - let mut s = quest_store(); - s.gameobject_type = Some(lyracore_shared::constants::go_type::CHEST); - s.corpse_loot_by_viewer.insert(1, vec![(4, 117, 2, 321, 0)]); - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - - CMSG_GAMEOBJ_USE { - guid: Guid::new(90), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - - let (opcode, body) = read_raw_frame(&mut client, &mut c_dec); - assert_eq!(opcode, OP_LOOT_RESPONSE); - assert_eq!(&body[0..8], &90u64.to_le_bytes()); - assert_eq!(loot_item_bytes(&body, 0), (4, 117, 2, 321)); - - CMSG_AUTOSTORE_LOOT_ITEM { item_slot: 4 } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_LOOT_REMOVED(removed) => assert_eq!(removed.slot, 4), - other => panic!("expected SMSG_LOOT_REMOVED, got {other}"), - } - - drop(client); - server.join().unwrap(); - assert_eq!(store.gameobjects_used.lock().unwrap().as_slice(), &[90]); - assert_eq!( - store.corpse_loot_reads.lock().unwrap().as_slice(), - &[(90, 1)] - ); - assert_eq!(store.items_looted.lock().unwrap().as_slice(), &[(90, 4)]); -} - -#[test] -fn loot_before_player_login_is_handled_without_panicking() { - let store = std::sync::Arc::new(tester_store(7)); - 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.as_ref())); - let (mut c_enc, _c_dec) = client_handshake(&mut client, "TESTER", K); - - CMSG_LOOT { - guid: Guid::new(60), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - drop(client); - - server - .join() - .expect("loot without a selected player must not panic") - .expect("loot without a selected player remains a handled no-op"); - assert!(store.corpse_loot_reads.lock().unwrap().is_empty()); - assert!(store.skinned.lock().unwrap().is_empty()); -} - -#[test] -fn loot_with_a_zero_player_guid_is_handled_without_panicking() { - let mut entity = warrior_entity(); - entity.guid = 0; - let store = std::sync::Arc::new(InMemoryStore { - login_entity: Some(entity), - ..tester_store(7) - }); - let (mut client, mut c_enc, _c_dec, server) = enter_world(store.clone(), 0); - - CMSG_LOOT { - guid: Guid::new(60), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - drop(client); - - server - .join() - .expect("loot with a zero player guid must not panic"); - assert!(store.corpse_loot_reads.lock().unwrap().is_empty()); - assert!(store.skinned.lock().unwrap().is_empty()); -} - -#[test] -fn skinning_refusal_keeps_the_world_session_alive() { - let store = std::sync::Arc::new(InMemoryStore { - skinning_refusal: Some(LootWindowRefusal::Unanswered), - ..quest_store() - }); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - - for target_guid in [60, 61] { - CMSG_LOOT { - guid: Guid::new(target_guid), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - let (opcode, body) = read_raw_frame(&mut client, &mut c_dec); - assert_eq!(opcode, OP_LOOT_RESPONSE); - assert_eq!(&body[0..8], &target_guid.to_le_bytes()); - } - - drop(client); - server.join().unwrap(); - assert!(store.skinned.lock().unwrap().is_empty()); -} - -#[test] -fn skinning_infrastructure_failure_ends_the_world_session() { - let store = std::sync::Arc::new(InMemoryStore { - skinning_failure: Some("gw_skin reducer timed out after 10s".to_string()), - ..quest_store() - }); - let (mut client, mut c_enc, _c_dec, server) = enter_world(store, 1); - - CMSG_LOOT { - guid: Guid::new(60), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - drop(client); - - assert!( - server.join().is_err(), - "a skinning timeout must end the World Session" - ); -} - -#[test] -fn loot_opens_the_window_and_loot_money_drives_the_tracked_guid() { - // CMSG_LOOT arms the open-loot state and replies the RAW loot window (guid + money in the body); - // CMSG_LOOT_MONEY (which carries NO guid) must then hit the TRACKED corpse. A - // SOLO money loot sends ONLY SMSG_LOOT_CLEAR_MONEY — the unconditional SMSG_LOOT_MONEY_NOTIFY - // is gone (vanilla never sends it to a solo looter; the client prints its own local "You loot X - // copper" line). A corpse with money is NOT skinned. - let mut s = quest_store(); - s.corpse_money = 25; - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - - CMSG_LOOT { - guid: Guid::new(60), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - let (op, body) = read_raw_frame(&mut client, &mut c_dec); - assert_eq!(op, OP_LOOT_RESPONSE); - assert_eq!( - &body[0..8], - &60u64.to_le_bytes(), - "loot window names the corpse guid" - ); - assert_eq!( - &body[9..13], - &25u32.to_le_bytes(), - "loot window shows the corpse's copper" - ); - - CMSG_LOOT_MONEY {} - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_LOOT_CLEAR_MONEY => {} - other => panic!( - "expected SMSG_LOOT_CLEAR_MONEY directly (no notify for a solo looter), got {other}" - ), - } - drop(client); - server.join().unwrap(); - assert_eq!( - store.money_looted.lock().unwrap().as_slice(), - &[60], - "the TRACKED guid was looted" - ); - assert!( - store.skinned.lock().unwrap().is_empty(), - "a corpse with money is not skinned" - ); -} - -#[test] -fn loot_money_with_zero_copper_still_clears_with_no_notify() { - // amount == 0: the same no-notify contract as any solo loot — CLEAR_MONEY still - // goes out so the client's loot window drops its money row. - let store = std::sync::Arc::new(quest_store()); // corpse_money = 0 - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - CMSG_LOOT { - guid: Guid::new(60), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - let _ = read_raw_frame(&mut client, &mut c_dec); // the loot window - CMSG_LOOT_MONEY {} - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_LOOT_CLEAR_MONEY => {} // and NOT a NOTIFY first - other => { - panic!("expected SMSG_LOOT_CLEAR_MONEY directly (no notify for 0 copper), got {other}") - } - } - drop(client); - server.join().unwrap(); - assert_eq!(store.money_looted.lock().unwrap().as_slice(), &[60]); -} - -#[test] -fn loot_release_clears_the_tracked_target_so_take_requests_are_noops() { - let mut s = quest_store(); - s.corpse_money = 25; // non-empty so the skin fallback stays out of the picture - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - let _ = open_loot_window(&mut client, &mut c_enc, &mut c_dec, 60); - CMSG_LOOT_RELEASE { - guid: Guid::new(60), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_LOOT_RELEASE_RESPONSE(r) => assert_eq!(r.guid.guid(), 60), - other => panic!("expected SMSG_LOOT_RELEASE_RESPONSE, got {other}"), - } - // The window is closed — stray targetless take requests must not reach the store. - CMSG_LOOT_MONEY {} - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - CMSG_AUTOSTORE_LOOT_ITEM { item_slot: 3 } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - drop(client); - server.join().unwrap(); - assert!( - store.money_looted.lock().unwrap().is_empty(), - "release cleared the tracked target" - ); - assert!( - store.items_looted.lock().unwrap().is_empty(), - "release cleared the tracked target before an item take" - ); -} - -// ── Group loot methods ─────────────────────────────────────────────────────────────────────────── - -#[test] -fn loot_method_dispatches_the_decoded_setting_threshold_and_master() { - // CMSG_LOOT_METHOD (leader sets MASTER LOOT, Epic threshold, master guid 7) must reach the - // store with the gateway-decoded wire bytes — a direct pass-through (module adopted the wire - // ordering verbatim), no separate ack packet (vanilla sends none for this opcode either). - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, _c_dec, server) = enter_world(store.clone(), 1); - CMSG_LOOT_METHOD { - loot_setting: GroupLootSetting::MasterLoot, - loot_master: Guid::new(7), - loot_threshold: ItemQuality::Epic, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - // No reply packet for this opcode — send a harmless follow-up (CMSG_LOOT_MONEY, a no-op here - // with no tracked target) and confirm it's the NEXT thing the server processes, proving the - // method call didn't hang the dispatch loop waiting to send something. - CMSG_LOOT_MONEY {} - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - drop(client); - server.join().unwrap(); - assert_eq!( - store.group_loot_methods.lock().unwrap().as_slice(), - &[( - GroupLootSetting::MasterLoot.as_int(), - 7, - ItemQuality::Epic.as_int() - )] - ); -} - -#[test] -fn loot_roll_dispatches_the_corpse_slot_and_vote() { - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, _c_dec, server) = enter_world(store.clone(), 1); - CMSG_LOOT_ROLL { - item: Guid::new(60), - item_slot: 2, - vote: RollVote::Need, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - CMSG_LOOT_MONEY {} - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - drop(client); - server.join().unwrap(); - assert_eq!( - store.loot_rolls.lock().unwrap().as_slice(), - &[(60, 2, RollVote::Need.as_int())] - ); -} - -#[test] -fn loot_master_give_dispatches_the_corpse_slot_and_target() { - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, _c_dec, server) = enter_world(store.clone(), 1); - CMSG_LOOT_MASTER_GIVE { - loot: Guid::new(60), - slot_id: 3, - player: Guid::new(9), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - CMSG_LOOT_MONEY {} - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - drop(client); - server.join().unwrap(); - assert_eq!( - store.loot_master_gives.lock().unwrap().as_slice(), - &[(60, 3, 9)] - ); -} - -#[test] -fn loot_roll_rejection_is_logged_and_ignored_not_session_fatal() { - // A rejection (no roll open / already voted / not eligible) must not tear the connection down — - // the SAME session keeps working afterward (mirrors take_loot's per-action ignore discipline). - let mut s = quest_store(); - s.loot_action_refusal = Some(LootRefusal::RollUnavailable); - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - CMSG_LOOT_ROLL { - item: Guid::new(60), - item_slot: 2, - vote: RollVote::Greed, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - // The session survives: a subsequent CMSG_LOOT still gets a normal reply. - CMSG_LOOT { - guid: Guid::new(61), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - let (op, _) = read_raw_frame(&mut client, &mut c_dec); - assert_eq!( - op, OP_LOOT_RESPONSE, - "the session must survive a rejected loot_roll" - ); - drop(client); - server.join().unwrap(); -} - -#[test] -fn loot_master_give_refusals_keep_the_world_session_alive() { - for refusal in [ - LootRefusal::NotMasterLooter, - LootRefusal::RecipientUnavailable, - LootRefusal::RecipientInventoryFull, - ] { - let mut s = quest_store(); - s.loot_action_refusal = Some(refusal); - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - CMSG_LOOT_MASTER_GIVE { - loot: Guid::new(60), - slot_id: 3, - player: Guid::new(9), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - CMSG_LOOT { - guid: Guid::new(61), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - - let (op, _) = read_raw_frame(&mut client, &mut c_dec); - assert_eq!(op, OP_LOOT_RESPONSE, "{refusal:?}"); - drop(client); - server.join().unwrap(); - } -} - -#[test] -fn loot_roll_timeout_ends_the_world_session() { - let mut s = quest_store(); - s.loot_action_failure = Some("gw_loot_roll reducer timed out after 10s".to_string()); - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, _c_dec, server) = enter_world(store, 1); - CMSG_LOOT_ROLL { - item: Guid::new(60), - item_slot: 2, - vote: RollVote::Greed, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - drop(client); - - assert!( - server.join().is_err(), - "an unknown vote result must end the World Session" - ); -} - -#[test] -fn loot_master_give_transport_failure_ends_the_world_session() { - let mut s = quest_store(); - s.loot_action_failure = Some("gw_loot_master_give reducer transport disconnected".to_string()); - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, _c_dec, server) = enter_world(store, 1); - CMSG_LOOT_MASTER_GIVE { - loot: Guid::new(60), - slot_id: 3, - player: Guid::new(9), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - drop(client); - - assert!( - server.join().is_err(), - "an unknown master-loot result must end the World Session" - ); -} - -fn loot_item_bytes(body: &[u8], index: usize) -> (u8, u32, u32, u32) { - // Item N starts at byte 14 (8 guid + 1 method + 4 money + 1 count), 22 bytes each. - let base = 14 + index * 22; - let slot = body[base]; - let item_id = u32::from_le_bytes(body[base + 1..base + 5].try_into().unwrap()); - let count = u32::from_le_bytes(body[base + 5..base + 9].try_into().unwrap()); - let display_id = u32::from_le_bytes(body[base + 9..base + 13].try_into().unwrap()); - (slot, item_id, count, display_id) -} - -// ── CMSG_ATTACKSWING error split + happy path (combat C1) ─────────────────────────────────────── - -#[test] -fn attackswing_ok_replies_attackstart_and_stop_echoes_then_clears() { - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - CMSG_ATTACKSWING { - guid: Guid::new(90), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_ATTACKSTART(a) => { - assert_eq!(a.attacker.guid(), 1, "self guid"); - assert_eq!(a.victim.guid(), 90); - } - other => panic!("expected SMSG_ATTACKSTART, got {other}"), - } - // Stop echoes the armed target and clears it. - CMSG_ATTACKSTOP {} - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_ATTACKSTOP(a) => { - assert_eq!(a.player.guid(), 1); - assert_eq!(a.enemy.guid(), 90); - } - other => panic!("expected SMSG_ATTACKSTOP, got {other}"), - } - // A second stop finds no armed target → NO second echo (sentinel replies first). - CMSG_ATTACKSTOP {} - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - CMSG_QUESTGIVER_STATUS_QUERY { - guid: Guid::new(50), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_QUESTGIVER_STATUS(_) => {} - other => panic!("expected the sentinel (no ATTACKSTOP echo once cleared), got {other}"), - } - drop(client); - server.join().unwrap(); -} - -#[test] -fn melee_opcodes_at_character_select_answer_nothing_and_keep_the_session_alive() { - // 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 and the client gets no attack - // message. The sentinel can only arrive if neither opcode replied or panicked. - let store = std::sync::Arc::new(quest_store()); - 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.as_ref()).unwrap(); - }); - let (mut c_enc, mut c_dec) = client_handshake(&mut client, "TESTER", K); - CMSG_ATTACKSWING { - guid: Guid::new(90), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - CMSG_ATTACKSTOP {} - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - CMSG_CHAR_ENUM {} - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_CHAR_ENUM(_) => {} - other => panic!("expected the sentinel (no melee reply at character select), got {other}"), - } - drop(client); - server.join().unwrap(); - assert_eq!( - store.stop_attacks.lock().unwrap().as_slice(), - &[0], - "the stop still reaches the durable seam under the legacy zero actor" - ); -} - -#[test] -fn attackswing_desync_error_is_session_fatal() { - // A desync-classified start_attack failure (the player's OWN entity is gone) must PROPAGATE - // as session-fatal: run_world_session returns Err and the socket tears down for a clean relog. - // Only the socket half is proved here; which failures are fatal, and which answer a refusal - // instead, belongs to the melee seam's own tests. - let mut s = quest_store(); - s.start_attack_error = Some("no live entity for guid 1".into()); - let store = std::sync::Arc::new(s); - let (mut client, server_end) = world_session_socket_pair(); - let server_store = store.clone(); - // Roll enter_world by hand: the server thread must RETURN the session result (not unwrap it). - let server = std::thread::spawn(move || run_world_session(server_end, server_store.as_ref())); - 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(); - for _ in 0..WORLD_ENTRY_PACKETS { - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - } - CMSG_ATTACKSWING { - guid: Guid::new(90), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - let result = server.join().unwrap(); - let err = result.expect_err("a desync on attackswing must end the session with an error"); - assert!( - format!("{err:#}").contains("desync"), - "the error should carry the desync context, got: {err:#}" - ); - drop(client); -} - -// ── Smaller mappings: WHO, buyback slots, trainer buy, talents, gossip select, chat ───── - -#[test] -fn who_reply_lists_every_online_player_with_guild_level_and_zone() { - let mut s = quest_store(); - s.guild_memberships = vec![codec::GuildMemberView { - character_guid: 2, - guild_id: 7, - name: "Alpha".into(), - ..Default::default() - }]; - s.guilds = vec![codec::GuildView { - guild_id: 7, - name: "Boundary Test".into(), - ..Default::default() - }]; - s.characters = vec![ - // The requester. Human like Alpha/Bravo (so the team gate passes them), but a class - // outside the request's `class_mask` — the requester is not exempt from its own filters, - // so this keeps the assertions below at exactly the two matches. - codec::CharacterView { - guid: 1, - name: "Tester".into(), - race: 1, - class: 4, - level: 10, - zone_id: 12, - ..Default::default() - }, - codec::CharacterView { - guid: 2, - name: "Alpha".into(), - race: 1, - class: 1, - level: 5, - zone_id: 12, - ..Default::default() - }, - codec::CharacterView { - guid: 3, - name: "Bravo".into(), - race: 1, - class: 1, - level: 60, - zone_id: 12, - ..Default::default() - }, - ]; - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - CMSG_WHO { - minimum_level: Level::new(1), - maximum_level: Level::new(60), - player_name: String::new(), - guild_name: String::new(), - race_mask: 1 << 1, // Human - class_mask: 1 << 1, // Warrior - zones: Vec::new(), - search_strings: Vec::new(), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - // RAW-encoded (codec::build_who_response_raw): gtker's typed reader assumes the wrong 5875 - // layout (see that builder's doc comment), so this reads the cmangos body by hand. - let (opcode, body) = read_raw_frame(&mut client, &mut c_dec); - assert_eq!(opcode, codec::social::SMSG_WHO_OPCODE); - let online_players = u32::from_le_bytes(body[4..8].try_into().unwrap()); - assert_eq!(online_players, 2); - let mut rest = &body[8..]; - for (name, guild, level) in [("Alpha", "Boundary Test", 5u32), ("Bravo", "", 60)] { - let name_end = rest.iter().position(|&b| b == 0).unwrap(); - assert_eq!(std::str::from_utf8(&rest[..name_end]).unwrap(), name); - rest = &rest[name_end + 1..]; - let guild_end = rest.iter().position(|&b| b == 0).unwrap(); - assert_eq!(std::str::from_utf8(&rest[..guild_end]).unwrap(), guild); - rest = &rest[guild_end + 1..]; - assert_eq!(u32::from_le_bytes(rest[0..4].try_into().unwrap()), level); - rest = &rest[16..]; // level, class, race, zone: u32 each - } - assert!(rest.is_empty(), "exactly two listed rows"); - drop(client); - server.join().unwrap(); -} - -#[test] -fn login_replays_a_persisted_buyback_ring_after_the_login_sequence() { - // The ring survives logout, so world entry rebuilds the tab: one fabricated item CREATE per - // entry, then the raw descriptor update. (An EMPTY ring emits nothing — every other login test - // reads the login sequence and then EOF, which is that case.) - let store = std::sync::Arc::new(InMemoryStore { - buyback_ring: vec![(2589, 5, 120, 0), (4540, 1, 30, 0)], - ..quest_store() - }); - let (mut client, _c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - for _ in 0..2 { - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_UPDATE_OBJECT(_) => {} - other => panic!("expected a fabricated buyback item CREATE, got {other}"), - } - } - // The descriptor update is a hand-rolled partial VALUES mask gtker cannot decode; the frame is - // consumed either way, and EOF after it proves nothing else was sent. - let _ = ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec); - assert!(ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).is_err()); - drop(client); - server.join().unwrap(); -} - -#[test] -fn buyback_maps_the_wire_slot_enum_to_zero_based_ring_slots() { - // BuybackSlot rides as 69..=81 on the wire; the store reducer takes 0-based ring slots — - // Slot1 (69) → 0, Slot13 (81) → 12. - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - CMSG_BUYBACK_ITEM { - guid: Guid::new(99), - slot: BuybackSlot::Slot1, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - CMSG_BUYBACK_ITEM { - guid: Guid::new(99), - slot: BuybackSlot::Slot13, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - // 248: a successful buyback now pushes the refreshed tab view (one raw VALUES per call — - // the mock ring is empty, so no item CREATEs). Consume both frames before EOF; gtker cannot - // DECODE a hand-rolled partial VALUES mask (no OBJECT_FIELD_TYPE — the raw path's whole - // reason to exist), so tolerate the parse error: the frame bytes are consumed either way. - let _ = ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec); - let _ = ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec); - drop(client); - server.join().unwrap(); - assert_eq!( - store.bought_back.lock().unwrap().as_slice(), - &[(99, 0), (99, 12)] - ); -} - -#[test] -fn trainer_buy_success_replies_succeeded_then_pushes_the_learned_spell() { - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - CMSG_TRAINER_BUY_SPELL { - guid: Guid::new(70), - id: 1234, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_TRAINER_BUY_SUCCEEDED(m) => { - assert_eq!(m.guid.guid(), 70); - assert_eq!(m.id, 1234); - } - other => panic!("expected SMSG_TRAINER_BUY_SUCCEEDED, got {other}"), - } - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_LEARNED_SPELL(m) => assert_eq!(m.id, 1234), - other => panic!("expected SMSG_LEARNED_SPELL, got {other}"), - } - drop(client); - server.join().unwrap(); -} - -/// A RIDING purchase teaches a skill, not a spell. Its trainer-list id is a marker with no Spell.dbc row, -/// so the buy confirms and stops — pushing it as a learned spell would hand the client an id it cannot -/// resolve. The skill pane still moves, from the `game_player_skill` relay. -#[test] -fn a_riding_buy_confirms_without_echoing_the_offering_as_a_learned_spell() { - let mut s = quest_store(); - s.trainer_offer_skill_lines - .insert(50132, lyracore_shared::trainer::RIDING_SKILL_LINE); - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - CMSG_TRAINER_BUY_SPELL { - guid: Guid::new(70), - id: 50132, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_TRAINER_BUY_SUCCEEDED(m) => assert_eq!(m.id, 50132), - other => panic!("expected SMSG_TRAINER_BUY_SUCCEEDED, got {other}"), - } - // The follow-up whose reply we DO expect, proving the buy emitted nothing further. - CMSG_GOSSIP_HELLO { - guid: Guid::new(70), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_GOSSIP_MESSAGE(_) => {} - ServerOpcodeMessage::SMSG_LEARNED_SPELL(m) => { - panic!( - "a riding buy must not echo marker {} as a learned spell", - m.id - ) - } - other => panic!("expected SMSG_GOSSIP_MESSAGE, got {other}"), - } - drop(client); - server.join().unwrap(); -} - -/// A Warrior buying Plate Mail at 40. The book update alone leaves the client tinting every plate -/// piece red, so the buy also refreshes the ARMOR mask from the post-buy spellbook. -#[test] -fn a_proficiency_buy_pushes_the_refreshed_armor_mask_after_the_learned_spell() { - let mut s = quest_store(); - // The passive the buy granted, and the class the mask is derived for. - s.learned_spells = vec![lyracore_shared::constants::armor_proficiency::PLATE_PASSIVE_SPELL_ID]; - s.characters = vec![codec::CharacterView { - guid: 1, - class: 1, - level: 40, - ..Default::default() - }]; - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - CMSG_TRAINER_BUY_SPELL { - guid: Guid::new(70), - id: lyracore_shared::constants::armor_proficiency::PLATE_TRAINER_SPELL_ID, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_TRAINER_BUY_SUCCEEDED(m) => assert_eq!(m.id, 7109), - other => panic!("expected SMSG_TRAINER_BUY_SUCCEEDED, got {other}"), - } - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_LEARNED_SPELL(m) => assert_eq!(m.id, 7109), - other => panic!("expected SMSG_LEARNED_SPELL, got {other}"), - } - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_SET_PROFICIENCY(m) => { - assert_eq!( - m.class.as_int(), - 4, - "a proficiency buy refreshes ARMOR only" - ); - assert!( - m.item_sub_class_mask & (1 << lyracore_shared::item::armor_subclass::PLATE) != 0, - "the trained plate bit must be set: {:#x}", - m.item_sub_class_mask - ); - } - other => panic!("expected SMSG_SET_PROFICIENCY, got {other}"), - } - drop(client); - server.join().unwrap(); -} - -/// An ordinary ability purchase changes nothing about what the Character may wear, so it must not -/// resend a proficiency mask. -#[test] -fn an_ordinary_trainer_buy_pushes_no_proficiency_mask() { - let mut s = quest_store(); - s.characters = vec![codec::CharacterView { - guid: 1, - class: 1, - level: 40, - ..Default::default() - }]; - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - CMSG_TRAINER_BUY_SPELL { - guid: Guid::new(70), - id: 1234, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_TRAINER_BUY_SUCCEEDED(_) => {} - other => panic!("expected SMSG_TRAINER_BUY_SUCCEEDED, got {other}"), - } - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_LEARNED_SPELL(m) => assert_eq!(m.id, 1234), - other => panic!("expected SMSG_LEARNED_SPELL, got {other}"), - } - // A follow-up whose reply we DO expect, proving the buy emitted nothing further. - CMSG_GOSSIP_HELLO { - guid: Guid::new(70), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_GOSSIP_MESSAGE(_) => {} - other => panic!("expected SMSG_GOSSIP_MESSAGE, got {other}"), - } - drop(client); - server.join().unwrap(); -} - -#[test] -fn trainer_buy_rank_upgrade_supersedes_the_previous_rank_spell() { - // A trainer buy whose chain prev is already known sends SMSG_SUPERCEDED_SPELL, not - // SMSG_LEARNED_SPELL — the client REPLACES the old rank's book entry (vanilla), mirroring the - // talent rank-upgrade path's cmangos wire order (OLD rides the first u16 slot). - let mut s = quest_store(); - s.trainer_superseded = Some(1233); // rank 1 known; buying 1234 (rank 2) supersedes it - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - CMSG_TRAINER_BUY_SPELL { - guid: Guid::new(70), - id: 1234, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_TRAINER_BUY_SUCCEEDED(m) => { - assert_eq!(m.guid.guid(), 70); - assert_eq!(m.id, 1234); - } - other => panic!("expected SMSG_TRAINER_BUY_SUCCEEDED, got {other}"), - } - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_SUPERCEDED_SPELL(m) => { - assert_eq!( - m.new_spell_id, 1233, - "first wire slot carries the OLD rank (cmangos order)" - ); - assert_eq!( - m.old_spell_id, 1234, - "second wire slot carries the NEW rank" - ); - } - other => panic!("expected SMSG_SUPERCEDED_SPELL, got {other}"), - } - drop(client); - server.join().unwrap(); -} - -#[test] -fn every_module_refusal_has_one_client_failure_reason() { - use lyracore_shared::trainer::TrainerRefusal; - let expected = |refusal| match refusal { - TrainerRefusal::NotEnoughMoney => TrainingFailureReason::NotEnoughMoney, - TrainerRefusal::LevelTooLow | TrainerRefusal::PreviousRankMissing => { - TrainingFailureReason::NotEnoughSkill - } - TrainerRefusal::Unavailable | TrainerRefusal::NotOffered | TrainerRefusal::AlreadyKnown => { - TrainingFailureReason::Unavailable - } - }; - for refusal in TrainerRefusal::ALL { - let mut s = quest_store(); - s.trainer_buy_refusal = Some(refusal); - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - CMSG_TRAINER_BUY_SPELL { - guid: Guid::new(70), - id: 1234, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_TRAINER_BUY_FAILED(m) => { - assert_eq!(m.error, expected(refusal), "{refusal:?}"); - assert_eq!(m.id, 1234); - } - other => panic!("expected SMSG_TRAINER_BUY_FAILED, got {other}"), - } - drop(client); - server.join().unwrap(); - } -} - -/// A reducer timeout leaves the durable result unknown, so it must not reach the client as a -/// gameplay Refusal that says the purchase did not happen. -#[test] -fn a_trainer_reducer_timeout_is_not_answered_as_a_refusal() { - let store = std::sync::Arc::new(InMemoryStore { - login_entity: Some(warrior_entity()), - trade_error: Some("gw_trainer_buy reducer timed out after 10s".into()), - ..tester_store(7) - }); - let (mut client, server_end) = world_session_socket_pair(); - let server_store = store.clone(); - let (result_tx, result_rx) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - result_tx - .send(run_world_session(server_end, server_store.as_ref())) - .unwrap(); - }); - 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(); - for _ in 0..WORLD_ENTRY_PACKETS { - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - } - - CMSG_TRAINER_BUY_SPELL { - guid: Guid::new(70), - id: 1234, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - - let error = result_rx - .recv_timeout(std::time::Duration::from_secs(1)) - .expect("an unknown buy outcome must end the session promptly") - .expect_err("a timed-out trainer reducer must be session-fatal"); - assert!(format!("{error:#}").contains("timed out")); - assert!( - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).is_err(), - "the socket closes instead of claiming the purchase failed" - ); -} - -#[test] -fn learn_talent_with_a_grant_spell_pushes_learned_spell() { - // An ability talent (grant_spell_id != 0) + a successful learn → SMSG_LEARNED_SPELL(grant) so - // the new button is usable without a relog. - let mut s = quest_store(); - s.talent_grant = 2098; - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - CMSG_LEARN_TALENT { - talent: Talent::BurningSoul, - requested_rank: 0, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_LEARNED_SPELL(m) => assert_eq!(m.id, 2098), - other => panic!("expected SMSG_LEARNED_SPELL, got {other}"), - } - // The CHARACTER_POINTS1 VALUES push follows (raw read — the dirty_reset partial deliberately - // omits OBJECT_FIELD_TYPE, which gtker's typed reader refuses). - assert_eq!(read_raw_frame(&mut client, &mut c_dec).0, 0x00A9); - drop(client); - server.join().unwrap(); -} - -#[test] -fn learn_talent_passive_pushes_rank_spell_and_points() { - // A PASSIVE pick must still refresh the 1.12 TalentFrame live: SMSG_LEARNED_SPELL for the - // RANK-SPELL the module taught (the pane derives shown ranks from known rank-spells — - // SPELLS_CHANGED) followed by the PLAYER_CHARACTER_POINTS1 partial VALUES - // (CHARACTER_POINTS_CHANGED). The old behavior sent NOTHING → pane frozen until relog. - let mut s = quest_store(); // talent_grant = 0 - s.talent_pane = (7777, 0, 2); // rank-spell 7777 taught, no superseded prev, 2 points left - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - CMSG_LEARN_TALENT { - talent: Talent::BurningSoul, - requested_rank: 0, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_LEARNED_SPELL(m) => assert_eq!(m.id, 7777), - other => panic!("expected SMSG_LEARNED_SPELL(rank-spell), got {other}"), - } - // Raw read: the dirty_reset partial VALUES omits OBJECT_FIELD_TYPE (gtker's typed reader refuses). - assert_eq!( - read_raw_frame(&mut client, &mut c_dec).0, - 0x00A9, - "the CHARACTER_POINTS1 VALUES push" - ); - drop(client); - server.join().unwrap(); -} - -#[test] -fn learn_talent_rank_upgrade_supersedes_the_previous_rank_spell() { - // Rank N>1: the previous rank's spell is REPLACED in the book — SMSG_SUPERCEDED_SPELL with the - // cmangos wire order (OLD rides the first u16 slot), mirroring the trainer rank-upgrade path. - let mut s = quest_store(); - s.talent_pane = (7778, 7777, 1); // new rank-spell 7778 supersedes 7777, 1 point left - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - CMSG_LEARN_TALENT { - talent: Talent::BurningSoul, - requested_rank: 0, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_SUPERCEDED_SPELL(m) => { - assert_eq!( - m.new_spell_id, 7777, - "first wire slot carries the OLD rank (cmangos order)" - ); - assert_eq!( - m.old_spell_id, 7778, - "second wire slot carries the NEW rank" - ); - } - other => panic!("expected SMSG_SUPERCEDED_SPELL, got {other}"), - } - assert_eq!( - read_raw_frame(&mut client, &mut c_dec).0, - 0x00A9, - "the CHARACTER_POINTS1 VALUES push" - ); - drop(client); - server.join().unwrap(); -} - -#[test] -fn list_inventory_opens_the_vendor_window_over_the_socket() { - let mut s = quest_store(); - s.vendor_stock = vec![codec::VendorItemView { - item_entry: 4540, - display_id: 6353, - buy_price: 25, - ..Default::default() - }]; - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - CMSG_LIST_INVENTORY { - guid: Guid::new(80), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - let (op, body) = read_raw_frame(&mut client, &mut c_dec); - assert_eq!(op, codec::SMSG_LIST_INVENTORY_OPCODE); - assert_eq!(&body[0..8], &80u64.to_le_bytes()); - assert_eq!(body[8], 1, "one stocked item"); - drop(client); - server.join().unwrap(); -} - -#[test] -fn gossip_select_on_a_vendor_opens_the_inventory_window() { - // Option 0 on a stocked NPC is "browse goods" → the RAW SMSG_LIST_INVENTORY, same as the - // direct CMSG_LIST_INVENTORY path. - let mut s = quest_store(); - s.vendor_stock = vec![codec::VendorItemView { - item_entry: 4540, - display_id: 6353, - buy_price: 25, - ..Default::default() - }]; - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - let menu = gossip_hello(&mut client, &mut c_enc, &mut c_dec, 80); - assert_eq!(menu.gossips[0].message, "I'd like to browse your goods."); - CMSG_GOSSIP_SELECT_OPTION { - guid: Guid::new(80), - gossip_list_id: 0, - unknown: None, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - let (op, body) = read_raw_frame(&mut client, &mut c_dec); - assert_eq!(op, codec::SMSG_LIST_INVENTORY_OPCODE); - assert_eq!( - &body[0..8], - &80u64.to_le_bytes(), - "the vendor window names the NPC" - ); - drop(client); - server.join().unwrap(); - assert!(!store.home_bound.load(std::sync::atomic::Ordering::SeqCst)); -} - -#[test] -fn gossip_select_on_an_innkeeper_binds_home_and_completes() { - // A non-vendor innkeeper's "Make this inn your home." is option 0 → bind_home + GOSSIP_COMPLETE. - let mut s = quest_store(); - s.innkeeper = true; - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - let menu = gossip_hello(&mut client, &mut c_enc, &mut c_dec, 81); - assert_eq!(menu.gossips[0].message, "Make this inn your home."); - CMSG_GOSSIP_SELECT_OPTION { - guid: Guid::new(81), - gossip_list_id: 0, - unknown: None, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE => {} - other => panic!("expected SMSG_GOSSIP_COMPLETE, got {other}"), - } - drop(client); - server.join().unwrap(); - assert!( - store.home_bound.load(std::sync::atomic::Ordering::SeqCst), - "bind_home must have run" - ); -} - -#[test] -fn gossip_select_of_any_other_option_completes_without_binding() { - // Farewell (option 1 on an innkeeper NPC) → GOSSIP_COMPLETE only; no bind, no vendor window. - let mut s = quest_store(); - s.innkeeper = true; - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - gossip_hello(&mut client, &mut c_enc, &mut c_dec, 81); - CMSG_GOSSIP_SELECT_OPTION { - guid: Guid::new(81), - gossip_list_id: 1, - unknown: None, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE => {} - other => panic!("expected SMSG_GOSSIP_COMPLETE, got {other}"), - } - drop(client); - server.join().unwrap(); - assert!(!store.home_bound.load(std::sync::atomic::Ordering::SeqCst)); -} - -// --- Imported gossip menu options + multi-slot npc_text ------------------------------------------- - -/// Open `npc`'s gossip window and drain the menu, so a following `CMSG_GOSSIP_SELECT_OPTION` has the -/// snapshot it resolves against — a click with nothing open selects nothing. -fn gossip_hello( - client: &mut UnixStream, - enc: &mut EncrypterHalf, - dec: &mut DecrypterHalf, - npc: u64, -) -> wow_world_messages::vanilla::SMSG_GOSSIP_MESSAGE { - CMSG_GOSSIP_HELLO { - guid: Guid::new(npc), - } - .write_encrypted_client(&mut *client, enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut *client, dec).unwrap() { - ServerOpcodeMessage::SMSG_GOSSIP_MESSAGE(m) => *m, - other => panic!("expected SMSG_GOSSIP_MESSAGE, got {other}"), - } -} - -/// A shorthand imported option builder for the gossip mock tests. -fn opt(icon: u32, text: &str, action: u32) -> codec::GossipOptionView { - codec::GossipOptionView { - icon, - text: text.to_string(), - action, - ..Default::default() - } -} - -#[test] -fn taxi_status_query_returns_the_persisted_bit_without_opening() { - let mut s = quest_store(); - s.taxi_status = Some(codec::TaxiNodeStatusView { - npc_guid: 90, - known: false, - }); - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - CMSG_TAXINODE_STATUS_QUERY { - guid: Guid::new(90), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_TAXINODE_STATUS(status) => { - assert_eq!(status.guid.guid(), 90); - assert!(!status.taxi_mask_node_known); - } - other => panic!("expected SMSG_TAXINODE_STATUS, got {other}"), - } - assert_eq!(*store.taxi_calls.lock().unwrap(), vec![("status", 1, 90)]); - drop(client); - server.join().unwrap(); -} - -#[test] -fn direct_taxi_query_and_taxi_gossip_share_one_open_operation() { - use lyracore_shared::constants::gossip_option; - let mut s = quest_store(); - s.gossip_opts = vec![opt(0, "Show me your flight routes.", gossip_option::TAXI)]; - s.taxi_map = Some(codec::TaxiMapView { - npc_guid: 90, - source_client_node_id: 255, - available_client_node_ids: vec![255, 256], - }); - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - - CMSG_TAXIQUERYAVAILABLENODES { - guid: Guid::new(90), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_SHOWTAXINODES(map) => { - assert_eq!(map.guid.guid(), 90); - assert_eq!(map.nearest_node, 255); - assert_eq!(map.nodes.len(), 8); - assert_eq!(map.nodes[7], 0xC000_0000); - } - other => panic!("expected SMSG_SHOWTAXINODES, got {other}"), - } - - gossip_hello(&mut client, &mut c_enc, &mut c_dec, 90); - CMSG_GOSSIP_SELECT_OPTION { - guid: Guid::new(90), - gossip_list_id: 0, - unknown: None, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_SHOWTAXINODES(map) => { - assert_eq!(map.nearest_node, 255); - assert_eq!(map.nodes[7], 0xC000_0000); - } - other => panic!("expected SMSG_SHOWTAXINODES, got {other}"), - } - - assert_eq!( - *store.taxi_calls.lock().unwrap(), - vec![("open", 1, 90), ("open", 1, 90)] - ); - drop(client); - server.join().unwrap(); -} - -#[test] -fn activate_taxi_gameplay_refusal_replies_and_keeps_the_socket_alive() { - let mut s = quest_store(); - s.taxi_activation = codec::TaxiActivationResult { - result_code: lyracore_shared::constants::taxi_protocol::ACTIVATE_NOT_ENOUGH_MONEY, - }; - s.taxi_status = Some(codec::TaxiNodeStatusView { - npc_guid: 90, - known: true, - }); - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - - CMSG_ACTIVATETAXI { - guid: Guid::new(90), - source_node: 255, - destination_node: 256, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_ACTIVATETAXIREPLY(reply) => assert_eq!( - reply.reply, - wow_world_messages::vanilla::ActivateTaxiReply::NotEnoughMoney - ), - other => panic!("expected SMSG_ACTIVATETAXIREPLY, got {other}"), - } - assert_eq!( - *store.taxi_activation_inputs.lock().unwrap(), - vec![(1, 90, 255, 256)] - ); - - // A gameplay refusal is a normal packet result, not a world-session failure. - CMSG_TAXINODE_STATUS_QUERY { - guid: Guid::new(90), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - assert!(matches!( - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(), - ServerOpcodeMessage::SMSG_TAXINODE_STATUS(_) - )); - drop(client); - server.join().unwrap(); -} - -#[test] -fn activate_taxi_success_round_trips_over_the_encrypted_socket() { - let mut s = quest_store(); - s.taxi_activation = codec::TaxiActivationResult { - result_code: lyracore_shared::constants::taxi_protocol::ACTIVATE_OK, - }; - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - - CMSG_ACTIVATETAXI { - guid: Guid::new(90), - source_node: 255, - destination_node: 256, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_ACTIVATETAXIREPLY(reply) => assert_eq!( - reply.reply, - wow_world_messages::vanilla::ActivateTaxiReply::Ok - ), - other => panic!("expected SMSG_ACTIVATETAXIREPLY, got {other}"), - } - assert_eq!( - *store.taxi_activation_inputs.lock().unwrap(), - vec![(1, 90, 255, 256)] - ); - - drop(client); - server.join().unwrap(); -} - -#[test] -fn taxi_gossip_transport_failure_ends_the_world_session() { - use lyracore_shared::constants::gossip_option; - let mut s = quest_store(); - s.gossip_opts = vec![opt(0, "Show me your flight routes.", gossip_option::TAXI)]; - s.taxi_error = Some("taxi reducer transport disconnected: channel closed".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); - CMSG_GOSSIP_SELECT_OPTION { - guid: Guid::new(90), - gossip_list_id: 0, - unknown: None, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - - server - .join() - .expect_err("a broken taxi reducer transport must end the world session"); -} - -#[test] -fn gossip_hello_renders_imported_options_verbatim_with_a_trailing_farewell() { - // The 217 acceptance criterion: an Elwynn-innkeeper-shaped NPC with 3 imported options (chat, - // browse goods, make-home) renders them VERBATIM (real dump text, not the hardcoded fallback - // strings) — the vendor/innkeeper flags are ignored entirely once options are imported. - use lyracore_shared::constants::gossip_option; - let mut s = quest_store(); - s.gossip_opts = vec![ - opt(0, "Well met, traveler.", gossip_option::GOSSIP), - opt(1, "I'd like to browse your goods.", gossip_option::VENDOR), - opt( - 0, - "I'd like to stay here a while.", - gossip_option::INNKEEPER, - ), - ]; - // Fallback signals present too — must be ignored while options are imported. - s.vendor_stock = vec![codec::VendorItemView { - item_entry: 1, - ..Default::default() - }]; - s.innkeeper = true; - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - CMSG_GOSSIP_HELLO { - guid: Guid::new(90), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_GOSSIP_MESSAGE(m) => { - assert_eq!( - m.gossips.len(), - 4, - "3 imported + a trailing Farewell: {:?}", - m.gossips - ); - assert_eq!(m.gossips[0].message, "Well met, traveler."); - assert_eq!(m.gossips[1].message, "I'd like to browse your goods."); - assert_eq!(m.gossips[2].message, "I'd like to stay here a while."); - assert_eq!(m.gossips[3].message, "Farewell."); - } - other => panic!("expected SMSG_GOSSIP_MESSAGE, got {other}"), - } - drop(client); - server.join().unwrap(); -} - -#[test] -fn gossip_select_on_an_imported_vendor_option_opens_the_inventory_window() { - use lyracore_shared::constants::gossip_option; - let mut s = quest_store(); - s.gossip_opts = vec![opt(1, "Browse.", gossip_option::VENDOR)]; - s.vendor_stock = vec![codec::VendorItemView { - item_entry: 4540, - display_id: 6353, - buy_price: 25, - ..Default::default() - }]; - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - gossip_hello(&mut client, &mut c_enc, &mut c_dec, 90); - CMSG_GOSSIP_SELECT_OPTION { - guid: Guid::new(90), - gossip_list_id: 0, - unknown: None, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - let (op, body) = read_raw_frame(&mut client, &mut c_dec); - assert_eq!(op, codec::SMSG_LIST_INVENTORY_OPCODE); - assert_eq!( - &body[0..8], - &90u64.to_le_bytes(), - "the vendor window names the NPC" - ); - drop(client); - server.join().unwrap(); - assert!(!store.home_bound.load(std::sync::atomic::Ordering::SeqCst)); -} - -#[test] -fn gossip_select_on_an_imported_innkeeper_option_binds_home() { - use lyracore_shared::constants::gossip_option; - let mut s = quest_store(); - s.gossip_opts = vec![ - opt(0, "Chat.", gossip_option::GOSSIP), - opt(0, "Stay here.", gossip_option::INNKEEPER), - ]; - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - gossip_hello(&mut client, &mut c_enc, &mut c_dec, 90); - CMSG_GOSSIP_SELECT_OPTION { - guid: Guid::new(90), - gossip_list_id: 1, - unknown: None, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE => {} - other => panic!("expected SMSG_GOSSIP_COMPLETE, got {other}"), - } - drop(client); - server.join().unwrap(); - assert!( - store.home_bound.load(std::sync::atomic::Ordering::SeqCst), - "bind_home must have run" - ); -} - -#[test] -fn the_same_option_row_reaches_the_module_by_row_id_from_either_viewer() { - use lyracore_shared::constants::{gossip_condition, gossip_option}; - let menu = || { - let mut gated = opt(0, "About that favor...", gossip_option::GOSSIP); - gated.row_id = 4001; - gated.cond_type = gossip_condition::QUEST_TAKEN; - gated.cond_value1 = 60; - let mut always = opt(0, "Stay here.", gossip_option::INNKEEPER); - always.row_id = 4002; - vec![gated, always] - }; - // Viewer A has not taken quest 60 → the gated row is hidden, so "Stay here." renders at 0. - let mut a = quest_store(); - a.gossip_opts = menu(); - let a = std::sync::Arc::new(a); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(a.clone(), 1); - let rendered = gossip_hello(&mut client, &mut c_enc, &mut c_dec, 90); - assert_eq!(rendered.gossips[0].message, "Stay here."); - CMSG_GOSSIP_SELECT_OPTION { - guid: Guid::new(90), - gossip_list_id: 0, - unknown: None, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - let _ = ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - drop(client); - server.join().unwrap(); - - // Viewer B HAS taken it → the gated row renders first and pushes "Stay here." to position 1. - let mut b = quest_store(); - b.gossip_opts = menu(); - b.quest_log = vec![(60, false)].into(); - let b = std::sync::Arc::new(b); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(b.clone(), 1); - let rendered = gossip_hello(&mut client, &mut c_enc, &mut c_dec, 90); - assert_eq!(rendered.gossips[1].message, "Stay here."); - CMSG_GOSSIP_SELECT_OPTION { - guid: Guid::new(90), - gossip_list_id: 1, - unknown: None, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - let _ = ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - drop(client); - server.join().unwrap(); - - let (a_pos, a_row) = a.gossip_selects.lock().unwrap()[0]; - let (b_pos, b_row) = b.gossip_selects.lock().unwrap()[0]; - assert_ne!(a_pos, b_pos, "the POSITION differs between the two viewers"); - assert_eq!( - (a_row, b_row), - (4002, 4002), - "the row_id is the same option for both" - ); -} - -#[test] -fn a_quest_taken_while_the_window_is_open_does_not_shift_the_click() { - use lyracore_shared::constants::{gossip_condition, gossip_option}; - let mut s = quest_store(); - let mut gated = opt(0, "About that favor...", gossip_option::GOSSIP); - gated.row_id = 4001; - gated.cond_type = gossip_condition::QUEST_TAKEN; - gated.cond_value1 = 60; - let mut inn = opt(0, "Stay here.", gossip_option::INNKEEPER); - inn.row_id = 4002; - s.gossip_opts = vec![gated, inn]; - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - // Rendered while the quest is untaken: the gated line is hidden, "Stay here." is position 0. - let rendered = gossip_hello(&mut client, &mut c_enc, &mut c_dec, 90); - assert_eq!(rendered.gossips[0].message, "Stay here."); - // The player accepts quest 60 elsewhere (another window, a party member's turn-in) — a fresh - // filter would now put the gated line at 0 and push "Stay here." to 1. - store.quest_log.lock().unwrap().push((60, false)); - CMSG_GOSSIP_SELECT_OPTION { - guid: Guid::new(90), - gossip_list_id: 0, - unknown: None, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE => {} - other => panic!("expected SMSG_GOSSIP_COMPLETE, got {other}"), - } - drop(client); - server.join().unwrap(); - assert!( - store.home_bound.load(std::sync::atomic::Ordering::SeqCst), - "the click must still select the innkeeper line the player was shown" - ); - assert_eq!( - store.gossip_selects.lock().unwrap()[0], - (0, 4002), - "and the module hears the row the player saw, not the one that moved into that slot" - ); -} - -#[test] -fn a_select_with_no_open_menu_just_closes_the_window() { - use lyracore_shared::constants::gossip_option; - let mut s = quest_store(); - s.gossip_opts = vec![opt(0, "Stay here.", gossip_option::INNKEEPER)]; - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - gossip_hello(&mut client, &mut c_enc, &mut c_dec, 90); - // Same position, a DIFFERENT npc — the open menu is 90's, so this selects nothing. - CMSG_GOSSIP_SELECT_OPTION { - guid: Guid::new(91), - gossip_list_id: 0, - unknown: None, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE => {} - other => panic!("expected SMSG_GOSSIP_COMPLETE, got {other}"), - } - drop(client); - server.join().unwrap(); - assert!(!store.home_bound.load(std::sync::atomic::Ordering::SeqCst)); - assert_eq!( - store.gossip_selects.lock().unwrap()[0].1, - codec::SYNTHESIZED_ROW_ID, - "no imported row was selected" - ); -} - -#[test] -fn an_imported_menu_missing_its_vendor_row_still_reaches_the_stock() { - use lyracore_shared::constants::gossip_option; - let mut s = quest_store(); - s.gossip_opts = vec![opt(0, "What is Children's Week?", gossip_option::GOSSIP)]; - s.vendor_stock = vec![codec::VendorItemView { - item_entry: 4540, - display_id: 6353, - buy_price: 25, - ..Default::default() - }]; - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - let menu = gossip_hello(&mut client, &mut c_enc, &mut c_dec, 90); - assert_eq!(menu.gossips.len(), 3, "chat + browse goods + Farewell"); - assert_eq!(menu.gossips[0].message, "What is Children's Week?"); - assert_eq!(menu.gossips[1].message, "I'd like to browse your goods."); - CMSG_GOSSIP_SELECT_OPTION { - guid: Guid::new(90), - gossip_list_id: 1, - unknown: None, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - let (op, body) = read_raw_frame(&mut client, &mut c_dec); - assert_eq!(op, codec::SMSG_LIST_INVENTORY_OPCODE); - assert_eq!(&body[0..8], &90u64.to_le_bytes()); - drop(client); - server.join().unwrap(); -} - -#[test] -fn an_imported_menu_missing_its_bind_row_still_offers_the_hearth() { - use lyracore_shared::constants::gossip_option; - let mut s = quest_store(); - s.gossip_opts = vec![opt(0, "Tell me about the inn.", gossip_option::GOSSIP)]; - s.innkeeper = true; - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - let menu = gossip_hello(&mut client, &mut c_enc, &mut c_dec, 90); - assert_eq!(menu.gossips[1].message, "Make this inn your home."); - CMSG_GOSSIP_SELECT_OPTION { - guid: Guid::new(90), - gossip_list_id: 1, - unknown: None, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE => {} - other => panic!("expected SMSG_GOSSIP_COMPLETE, got {other}"), - } - drop(client); - server.join().unwrap(); - assert!(store.home_bound.load(std::sync::atomic::Ordering::SeqCst)); -} - -/// A `quest_store()` fixture whose logged-in character (guid 1) reports `level`, so -/// `filtered_gossip_options`' level gate has something to read (`quest_store()` itself leaves -/// `characters` empty, which reads as level 0 — every below-10 test can lean on that default). -fn quest_store_at_level(level: u8) -> InMemoryStore { - InMemoryStore { - characters: vec![codec::CharacterView { - guid: 1, - level, - ..Default::default() - }], - ..quest_store() - } -} - -#[test] -fn gossip_hello_hides_unlearn_talents_below_level_10() { - // The imported "I wish to unlearn my talents." row (reclassified by the importer to - // `UNLEARNTALENTS`, since the raw dump column never carries it) must not render for a character - // who cannot yet have a talent point. - use lyracore_shared::constants::gossip_option; - let mut s = quest_store_at_level(5); - s.gossip_opts = vec![ - opt(0, "I require warrior training.", gossip_option::TRAINER), - opt( - 0, - "I wish to unlearn my talents.", - gossip_option::UNLEARNTALENTS, - ), - ]; - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - CMSG_GOSSIP_HELLO { - guid: Guid::new(90), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_GOSSIP_MESSAGE(m) => { - assert_eq!( - m.gossips.len(), - 2, - "training + trailing Farewell only, no unlearn option: {:?}", - m.gossips - ); - assert_eq!(m.gossips[0].message, "I require warrior training."); - assert_eq!(m.gossips[1].message, "Farewell."); - } - other => panic!("expected SMSG_GOSSIP_MESSAGE, got {other}"), - } - drop(client); - server.join().unwrap(); -} - -#[test] -fn gossip_hello_shows_unlearn_talents_at_level_10_and_select_routes_to_reset_talents() { - use lyracore_shared::constants::gossip_option; - let mut s = quest_store_at_level(10); - s.gossip_opts = vec![ - opt(0, "I require warrior training.", gossip_option::TRAINER), - opt( - 0, - "I wish to unlearn my talents.", - gossip_option::UNLEARNTALENTS, - ), - ]; - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - CMSG_GOSSIP_HELLO { - guid: Guid::new(90), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_GOSSIP_MESSAGE(m) => { - assert_eq!( - m.gossips.len(), - 3, - "training + unlearn + trailing Farewell: {:?}", - m.gossips - ); - assert_eq!(m.gossips[1].message, "I wish to unlearn my talents."); - } - other => panic!("expected SMSG_GOSSIP_MESSAGE, got {other}"), - } - // Click it (index 1, same list HELLO just rendered) — must route to reset_talents, not just - // close the window inert. - CMSG_GOSSIP_SELECT_OPTION { - guid: Guid::new(90), - gossip_list_id: 1, - unknown: None, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE => {} - other => panic!("expected SMSG_GOSSIP_COMPLETE, got {other}"), - } - drop(client); - server.join().unwrap(); - let calls = store.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)" - ); -} - -#[test] -fn gossip_hello_shows_a_quest_gated_option_once_the_quest_is_taken() { - // The socket-level contract: the player's quest state reaches the gossip filter through the - // quest seam's `quest_gate_state`, and the gated row is assembled into the gossip message the - // client actually receives. The hidden case is covered by the position-alignment test below. - use lyracore_shared::constants::{gossip_condition, gossip_option}; - let mut s = quest_store(); - s.gossip_opts = vec![opt(0, "About that favor...", gossip_option::GOSSIP)]; - s.gossip_opts[0].cond_type = gossip_condition::QUEST_TAKEN; - s.gossip_opts[0].cond_value1 = 60; - s.quest_log = vec![(60, false)].into(); // taken, not yet turned in - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - CMSG_GOSSIP_HELLO { - guid: Guid::new(90), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_GOSSIP_MESSAGE(m) => { - assert_eq!(m.gossips.len(), 2, "{:?}", m.gossips); - assert_eq!(m.gossips[0].message, "About that favor..."); - } - other => panic!("expected SMSG_GOSSIP_MESSAGE, got {other}"), - } - drop(client); - server.join().unwrap(); -} - -#[test] -fn gossip_hello_and_select_option_stay_position_aligned_under_a_hidden_option() { - // The sharpest trap (danger-zones-adjacent): 3 imported options where the MIDDLE one is - // quest-gated and hidden. HELLO sends only 2 lines (positions 0,1); a SELECT of position 1 MUST - // route to the THIRD raw option (innkeeper), not the hidden middle one — proving - // `filtered_gossip_options` re-derives the IDENTICAL list rather than indexing the raw rows. - use lyracore_shared::constants::{gossip_condition, gossip_option}; - let mut s = quest_store(); - s.gossip_opts = vec![ - opt(0, "Chat.", gossip_option::GOSSIP), // raw index 0 -> rendered index 0 - opt(0, "Hidden favor.", gossip_option::GOSSIP), // raw index 1 -> HIDDEN (quest-gated) - opt(0, "Stay here.", gossip_option::INNKEEPER), // raw index 2 -> rendered index 1 - ]; - s.gossip_opts[1].cond_type = gossip_condition::QUEST_TAKEN; - s.gossip_opts[1].cond_value1 = 60; // never taken in this store - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - CMSG_GOSSIP_HELLO { - guid: Guid::new(90), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_GOSSIP_MESSAGE(m) => { - assert_eq!(m.gossips.len(), 3, "2 visible + Farewell: {:?}", m.gossips); // hidden option excluded - assert_eq!(m.gossips[0].message, "Chat."); - assert_eq!(m.gossips[1].message, "Stay here."); - } - other => panic!("expected SMSG_GOSSIP_MESSAGE, got {other}"), - } - // Click rendered position 1 ("Stay here.") — must bind home, NOT be swallowed by the hidden - // middle option that was never actually sent to the client. - CMSG_GOSSIP_SELECT_OPTION { - guid: Guid::new(90), - gossip_list_id: 1, - unknown: None, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE => {} - other => panic!("expected SMSG_GOSSIP_COMPLETE, got {other}"), - } - drop(client); - server.join().unwrap(); - assert!( - store.home_bound.load(std::sync::atomic::Ordering::SeqCst), - "position 1 must resolve to the innkeeper option, not the hidden one" - ); -} - -#[test] -fn npc_text_query_ships_the_imported_8_slot_view() { - let mut view = codec::NpcTextView::default(); - view.slots[0] = ( - "Well met.".to_string(), - "Well met, traveler.".to_string(), - 0.6, - ); - view.slots[3] = ( - "Watch yourself.".to_string(), - "Watch yourself.".to_string(), - 0.4, - ); - let mut s = quest_store(); - s.npc_text_view = Some(view); - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - CMSG_NPC_TEXT_QUERY { - text_id: 77, - guid: Guid::new(90), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_NPC_TEXT_UPDATE(u) => { - assert_eq!(u.text_id, 77); - assert_eq!(u.texts[0].texts[0], "Well met."); - assert_eq!(u.texts[0].probability, 0.6); - assert_eq!(u.texts[3].texts[0], "Watch yourself."); - assert_eq!(u.texts[3].probability, 0.4); - assert_eq!(u.texts[1].probability, 0.0); // untouched slot stays silent - } - other => panic!("expected SMSG_NPC_TEXT_UPDATE, got {other}"), - } - drop(client); - server.join().unwrap(); -} - -#[test] -fn messagechat_say_and_yell_route_to_chat_types_0_and_1() { - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, _c_dec, server) = enter_world(store.clone(), 1); - CMSG_MESSAGECHAT { - chat_type: CMSG_MESSAGECHAT_ChatType::Say, - language: Language::Universal, - message: "hi".into(), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - CMSG_MESSAGECHAT { - chat_type: CMSG_MESSAGECHAT_ChatType::Yell, - language: Language::Universal, - message: "HEY".into(), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - drop(client); // no reply on success — the speaker sees their line via the broadcast relay - server.join().unwrap(); - assert_eq!( - store.chats.lock().unwrap().as_slice(), - &[(0, 0, "hi".to_string()), (1, 0, "HEY".to_string())], - "Say → type 0, Yell → type 1, language threaded" - ); -} - -#[test] -fn messagechat_emote_routes_to_chat_type_3() { - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, _c_dec, server) = enter_world(store.clone(), 1); - CMSG_MESSAGECHAT { - chat_type: CMSG_MESSAGECHAT_ChatType::Emote, - language: Language::Common, - message: "waves wildly.".into(), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - drop(client); // no reply on success — the speaker sees their line via the broadcast relay - server.join().unwrap(); - assert_eq!( - store.chats.lock().unwrap().as_slice(), - &[(3, 7, "waves wildly.".to_string())], - "/e → type 3, language threaded to the Module (which stores it as Universal)" - ); -} - -#[test] -fn messagechat_dot_say_diverts_to_gm_command_never_touching_chat() { - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - CMSG_MESSAGECHAT { - chat_type: CMSG_MESSAGECHAT_ChatType::Say, - language: Language::Universal, - message: ".heal".into(), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - CMSG_QUESTGIVER_STATUS_QUERY { - guid: Guid::new(50), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_QUESTGIVER_STATUS(_) => {} // nothing was sent for a successful dot-command - other => panic!("expected the sentinel (no reply on gm_command success), got {other}"), - } - drop(client); - server.join().unwrap(); - assert_eq!( - store.gm_commands.lock().unwrap().as_slice(), - &[("TESTER".to_string(), ".heal".to_string())], - "the proof-validated Account name and raw dot-command must reach the Store together" - ); - assert!( - store.chats.lock().unwrap().is_empty(), - "a dot-command must NEVER reach send_chat" - ); -} - -#[test] -fn messagechat_non_dot_say_is_byte_identical_to_before_223() { - // The 223 divert must be a no-op for ordinary chat: a Say line NOT starting with '.' still routes - // to send_chat exactly as before, and never touches gm_command. - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, _c_dec, server) = enter_world(store.clone(), 1); - CMSG_MESSAGECHAT { - chat_type: CMSG_MESSAGECHAT_ChatType::Say, - language: Language::Universal, - message: "hi".into(), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - drop(client); - server.join().unwrap(); - assert_eq!( - store.chats.lock().unwrap().as_slice(), - &[(0u8, 0u8, "hi".to_string())] - ); - assert!( - store.gm_commands.lock().unwrap().is_empty(), - "a plain Say line must never reach gm_command" - ); -} - -#[test] -fn messagechat_dot_say_error_relays_a_system_chat_line_to_the_sender_only() { - // A rejected dot-command (bad gm_level, unknown command, bad args) is relayed back - // 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.gm_command_error = Some("permission denied".to_string()); - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - CMSG_MESSAGECHAT { - chat_type: CMSG_MESSAGECHAT_ChatType::Say, - language: Language::Universal, - message: ".god".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, "permission denied"); - assert!( - matches!( - m.chat_type, - wow_world_messages::vanilla::SMSG_MESSAGECHAT_ChatType::System { .. } - ), - "expected a System chat line, got {:?}", - m.chat_type - ); - } - other => panic!("expected SMSG_MESSAGECHAT System, got {other}"), - } - drop(client); - server.join().unwrap(); - assert!(store.chats.lock().unwrap().is_empty()); -} - -#[test] -fn force_run_speed_change_ack_is_swallowed_with_no_reply_and_no_session_teardown() { - // The client's ack to our `.speed`-triggered SMSG_FORCE_RUN_SPEED_CHANGE must be - // consumed cleanly (no reply, no desync/disconnect) — proven by a sentinel opcode right after it - // still getting its normal reply on the SAME session. - use wow_world_messages::vanilla::{ - MovementInfo, MovementInfo_MovementFlags, Vector3d, CMSG_FORCE_RUN_SPEED_CHANGE_ACK, - }; - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - CMSG_FORCE_RUN_SPEED_CHANGE_ACK { - guid: Guid::new(1), - counter: 1, - info: MovementInfo { - flags: MovementInfo_MovementFlags::empty(), - timestamp: 0, - position: Vector3d { - x: 0.0, - y: 0.0, - z: 0.0, - }, - orientation: 0.0, - fall_time: 0.0, - }, - new_speed: 21.0, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - CMSG_QUESTGIVER_STATUS_QUERY { - guid: Guid::new(50), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_QUESTGIVER_STATUS(_) => {} // the ack produced no reply of its own - other => panic!("expected the sentinel (ack swallowed), got {other}"), - } - drop(client); - server.join().unwrap(); // the session ran to a clean close, not a desync teardown -} - -#[test] -fn messagechat_whisper_to_an_unknown_player_replies_player_not_found() { - let mut s = quest_store(); - s.speaker_facts = Some(human_speaker()); - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - CMSG_MESSAGECHAT { - chat_type: CMSG_MESSAGECHAT_ChatType::Whisper { - target_player: "Ghost".into(), - }, - language: Language::Universal, - message: "hello?".into(), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec) - .expect("a whisper to nobody must answer SMSG_CHAT_PLAYER_NOT_FOUND, and nothing arrived") - { - ServerOpcodeMessage::SMSG_CHAT_PLAYER_NOT_FOUND(m) => assert_eq!(m.name, "Ghost"), - other => panic!("expected SMSG_CHAT_PLAYER_NOT_FOUND, got {other}"), - } - drop(client); - server.join().unwrap(); - assert!(store.realm_whispers.lock().unwrap().is_empty()); -} - -/// `quest_store` plus the session's own Character row, which the `sync` sentinel needs. -fn chat_session_store() -> InMemoryStore { - InMemoryStore { - characters: vec![codec::CharacterView { - guid: 1, - name: "Warrior".into(), - ..Default::default() - }], - ..quest_store() - } -} - -/// `/afk Brb` becomes one `set_away` request for the session's own Character, with no reply: the -/// client prints its own notice and observers see PLAYER_FLAGS on the entity Relay. -#[test] -fn messagechat_afk_and_dnd_become_set_away_requests_from_the_sessions_character() { - let store = std::sync::Arc::new(chat_session_store()); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - sync(&mut client, &mut c_enc, &mut c_dec, |client, enc| { - for (chat_type, message) in [ - (CMSG_MESSAGECHAT_ChatType::Afk, "Brb"), - (CMSG_MESSAGECHAT_ChatType::Dnd, ""), - ] { - CMSG_MESSAGECHAT { - chat_type, - language: Language::Universal, - message: message.into(), - } - .write_encrypted_client(&mut *client, &mut *enc) - .unwrap(); - } - }); - drop(client); - server.join().unwrap(); - assert_eq!( - store.away_requests.lock().unwrap().clone(), - vec![(1, 0x14, "Brb".to_string()), (1, 0x15, String::new())] - ); -} - -/// cm:ChatHandler.cpp:801-815: `CMSG_CHAT_IGNORED` sends IGNORED to the Character whose line the -/// client dropped, carrying the ignorer's own name. -#[test] -fn chat_ignored_becomes_an_ignored_line_to_the_dropped_speaker() { - let mut s = chat_session_store(); - s.speaker_facts = Some(SpeakerFacts { - race: 1, - chat_tag: 1, - name: "Warrior".to_string(), - }); - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - sync(&mut client, &mut c_enc, &mut c_dec, |client, enc| { - wow_world_messages::vanilla::CMSG_CHAT_IGNORED { - guid: Guid::new(42), - } - .write_encrypted_client(client, enc) - .unwrap(); - }); - drop(client); - server.join().unwrap(); - let requests = store.realm_chats.lock().unwrap(); - assert_eq!( - requests.as_slice(), - &[( - 1, - RealmChatRequest { - kind: 0x16, - language: 0, - channel_name: String::new(), - target_guid: 42, - message: "Warrior".to_string(), - speaker: SpeakerFacts { - race: 1, - chat_tag: 1, - name: "Warrior".to_string(), - }, - } - )] - ); -} - -#[test] -fn messagechat_guild_becomes_one_realm_chat_request_from_the_sessions_character() { - // `/g` reaches the Realm Chat path with the guid it entered the world with, the same shape as - // `/p`. No reply on success: the speaker hears the line through the Relay like every member. - let mut s = quest_store(); - s.speaker_facts = Some(human_speaker()); - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - CMSG_MESSAGECHAT { - chat_type: CMSG_MESSAGECHAT_ChatType::Guild, - language: Language::Common, - message: "hello guild".into(), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - CMSG_QUESTGIVER_STATUS_QUERY { - guid: Guid::new(50), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_QUESTGIVER_STATUS(_) => {} // nothing was sent for a successful /g - other => panic!("expected the sentinel (no reply on /g success), got {other}"), - } - drop(client); - server.join().unwrap(); - let requests = store.realm_chats.lock().unwrap(); - assert_eq!(requests.len(), 1); - let (speaker_guid, request) = &requests[0]; - assert_eq!(*speaker_guid, 1); - assert_eq!(request.kind, lyracore_shared::chat::chat_kind::GUILD); - assert_eq!(request.message, "hello guild"); - assert!( - store.chats.lock().unwrap().is_empty(), - "a guild line never becomes a say line" - ); -} - -#[test] -fn messagechat_officer_becomes_one_realm_chat_request_from_the_sessions_character() { - // `/o` follows the same path as `/g` with its own Chat Kind. - let mut s = quest_store(); - s.speaker_facts = Some(human_speaker()); - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - CMSG_MESSAGECHAT { - chat_type: CMSG_MESSAGECHAT_ChatType::Officer, - language: Language::Common, - message: "officers only".into(), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - CMSG_QUESTGIVER_STATUS_QUERY { - guid: Guid::new(50), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_QUESTGIVER_STATUS(_) => {} // nothing was sent for a successful /o - other => panic!("expected the sentinel (no reply on /o success), got {other}"), - } - drop(client); - server.join().unwrap(); - let requests = store.realm_chats.lock().unwrap(); - assert_eq!(requests.len(), 1); - let (speaker_guid, request) = &requests[0]; - assert_eq!(*speaker_guid, 1); - assert_eq!(request.kind, lyracore_shared::chat::chat_kind::OFFICER); - assert_eq!(request.message, "officers only"); -} - -fn human_speaker() -> SpeakerFacts { - SpeakerFacts { - race: 1, - chat_tag: 0, - name: String::new(), - } -} - -#[test] -fn messagechat_party_becomes_one_realm_chat_request_from_the_sessions_character() { - // The session's `/p` reaches the Realm Chat path with the guid it entered the world with. No - // reply on success: the speaker hears the line through the Relay like every other member. - let mut s = quest_store(); - s.speaker_facts = Some(human_speaker()); - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - CMSG_MESSAGECHAT { - chat_type: CMSG_MESSAGECHAT_ChatType::Party, - language: Language::Common, - message: "form up".into(), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - CMSG_QUESTGIVER_STATUS_QUERY { - guid: Guid::new(50), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_QUESTGIVER_STATUS(_) => {} // nothing was sent for a successful /p - other => panic!("expected the sentinel (no reply on /p success), got {other}"), - } - drop(client); - server.join().unwrap(); - let requests = store.realm_chats.lock().unwrap(); - assert_eq!(requests.len(), 1); - let (speaker_guid, request) = &requests[0]; - assert_eq!(*speaker_guid, 1); - assert_eq!(request.kind, lyracore_shared::chat::chat_kind::PARTY); - assert_eq!(request.language, 7); - assert_eq!(request.message, "form up"); - assert!( - store.chats.lock().unwrap().is_empty(), - "a party line never becomes a say line" - ); -} - -#[test] -fn messagechat_party_from_an_ungrouped_caller_replies_not_in_group() { - let mut s = quest_store(); - s.speaker_facts = Some(human_speaker()); - s.realm_chat_outcome = Some(ChatOutcome::Refused( - lyracore_shared::chat::ChatRefusal::NotInGroup, - )); - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - CMSG_MESSAGECHAT { - chat_type: CMSG_MESSAGECHAT_ChatType::Party, - language: Language::Universal, - message: "hello?".into(), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_PARTY_COMMAND_RESULT(r) => { - assert_eq!( - r.result, - wow_world_messages::vanilla::PartyResult::NotInGroup - ); - } - other => panic!("expected SMSG_PARTY_COMMAND_RESULT(NotInGroup), got {other}"), - } - drop(client); - server.join().unwrap(); -} - -/// Character 1 in the world, with a Character row so `sync`'s sentinel is answered. -fn chat_store() -> InMemoryStore { - InMemoryStore { - speaker_facts: Some(human_speaker()), - characters: vec![codec::CharacterView { - guid: 1, - name: "Tester".into(), - ..Default::default() - }], - ..quest_store() - } -} - -fn say_line(message: &str) -> CMSG_MESSAGECHAT { - CMSG_MESSAGECHAT { - chat_type: CMSG_MESSAGECHAT_ChatType::Say, - language: Language::Common, - message: message.into(), - } -} - -fn notification(message: ServerOpcodeMessage) -> String { - match message { - ServerOpcodeMessage::SMSG_NOTIFICATION(notice) => notice.notification, - other => panic!("expected SMSG_NOTIFICATION, got {other}"), - } -} - -#[test] -fn a_flooding_session_is_muted_before_any_chat_durable_request() { - // cm:Player.cpp:16344-16377: eleven fast lines mute the speaker for ten seconds. The twelfth - // line and the party line after it answer the cmangos notice and reach no reducer. - let store = std::sync::Arc::new(chat_store()); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - for n in 0..12 { - say_line(&format!("line {n}")) - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - } - CMSG_MESSAGECHAT { - chat_type: CMSG_MESSAGECHAT_ChatType::Party, - language: Language::Common, - message: "form up".into(), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - for _ in 0..2 { - assert_eq!( - notification(ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap()), - "You must wait 10 Second(s). before speaking again." - ); - } - sync(&mut client, &mut c_enc, &mut c_dec, |_, _| {}); - drop(client); - server.join().unwrap(); - assert_eq!(store.chats.lock().unwrap().len(), 11); - assert!(store.realm_chats.lock().unwrap().is_empty()); -} - -#[test] -fn a_muted_session_still_sends_addon_lines() { - // cm:ChatHandler.cpp:113-119: the addon language is never flood-controlled. - let store = std::sync::Arc::new(chat_store()); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - for n in 0..12 { - say_line(&format!("line {n}")) - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - } - CMSG_MESSAGECHAT { - chat_type: CMSG_MESSAGECHAT_ChatType::Guild, - language: Language::Addon, - message: "LCTEST\tping".into(), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - notification(ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap()); - sync(&mut client, &mut c_enc, &mut c_dec, |_, _| {}); - drop(client); - server.join().unwrap(); - let requests = store.realm_chats.lock().unwrap(); - assert_eq!(requests.len(), 1); - assert_eq!(requests[0].1.language, 0xFFFF_FFFF); -} - -#[test] -fn a_game_master_is_never_muted_for_flooding() { - // cm:Player.cpp:16346-16348 skips the flood count for any account above SEC_PLAYER. - let mut s = chat_store(); - s.gm_level = 1; - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - sync(&mut client, &mut c_enc, &mut c_dec, |c, e| { - for n in 0..15 { - say_line(&format!("line {n}")) - .write_encrypted_client(&mut *c, &mut *e) - .unwrap(); - } - }); - drop(client); - server.join().unwrap(); - assert_eq!(store.chats.lock().unwrap().len(), 15); -} - -#[test] -fn a_say_line_in_a_language_the_speaker_does_not_know_answers_the_vanilla_notice() { - // cm:ChatHandler.cpp:107-110 with cm mangos.sql:4044. - let mut s = chat_store(); - s.send_chat_outcome = Some(ChatOutcome::Refused( - lyracore_shared::chat::ChatRefusal::UnknownLanguage, - )); - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - CMSG_MESSAGECHAT { - chat_type: CMSG_MESSAGECHAT_ChatType::Say, - language: Language::Orcish, - message: "zug zug".into(), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - assert_eq!( - notification(ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap()), - "You don't know that language" - ); - sync(&mut client, &mut c_enc, &mut c_dec, |_, _| {}); - drop(client); - server.join().unwrap(); -} - -#[test] -fn join_channel_runs_the_channel_op_as_the_sessions_character() { - let mut s = quest_store(); - s.speaker_facts = Some(human_speaker()); - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - wow_world_messages::vanilla::CMSG_JOIN_CHANNEL { - channel_name: "Trade - City".into(), - channel_password: String::new(), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - CMSG_QUESTGIVER_STATUS_QUERY { - guid: Guid::new(50), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_QUESTGIVER_STATUS(_) => {} // YOU_JOINED returns on the Relay - other => panic!("expected the sentinel (no reply on a successful join), got {other}"), - } - drop(client); - server.join().unwrap(); - let ops = store.channel_ops.lock().unwrap(); - assert_eq!(ops.len(), 1); - let (actor_guid, op, request) = &ops[0]; - assert_eq!((*actor_guid, *op), (1, 0)); - assert_eq!(request.channel_name, "Trade - City"); -} - -/// A refused join answers WRONG_PASSWORD 0x04 on the session's own socket. -#[test] -fn a_refused_join_answers_the_notice_on_the_session() { - let mut s = quest_store(); - s.speaker_facts = Some(human_speaker()); - s.channel_outcome = Some(ChannelOutcome::Refused( - lyracore_shared::channel::ChannelRefusal::WrongPassword, - )); - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - wow_world_messages::vanilla::CMSG_JOIN_CHANNEL { - channel_name: "Rx".into(), - channel_password: "guess".into(), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_CHANNEL_NOTIFY(notify) => { - assert_eq!( - notify.notify_type, - wow_world_messages::vanilla::ChatNotify::WrongPasswordNotice - ); - assert_eq!(notify.channel_name, "Rx"); - } - other => panic!("expected SMSG_CHANNEL_NOTIFY, got {other}"), - } - drop(client); - server.join().unwrap(); -} - -// The cross-database transfer TESTS live in `transfer_tests.rs`, but the fixture types -// below stay here: `InMemoryStore`'s own `Store` impl (the `xdb`/`xstep` glue a few hundred lines up) -// and two world-port-abort regression tests earlier in this file construct `FakeShardDb`/`FakeChar` -// directly, so these are a shared fixture rather than section-local. `transfer_tests` reaches them -// the ordinary way private items in this file reach any child module — no `pub(super)` needed. - -/// One character's durable state, reduced to what a transfer has to preserve: where it is, and a -/// PAYLOAD marker standing for the character-owned rows (gear/spells/skills/quest log). If the -/// payload does not arrive, the character arrived NAKED — the failure a manifest-only blob has. -#[derive(Clone, Debug, PartialEq)] -struct FakeChar { - map_id: u32, - instance_id: u64, - payload: String, -} - -#[derive(Clone, Debug)] -struct FakeEscrow { - transfer_id: u64, - character_guid: u64, - dest_map_id: u32, - dest_instance_id: u64, - blob: Vec, -} - -fn lk(m: &std::sync::Mutex) -> std::sync::MutexGuard<'_, T> { - m.try_lock().expect( - "re-entrant lock on FakeShardDb: a method is already holding this mutex further up the \ - stack. With `lock()` this would be a DEADLOCK and the suite would HANG instead of failing \ - — see the fn doc on `lk`.", - ) -} - -/// Realm-core's authoritative party state, as far as the gateway's routing can see it — -/// the module's `realm_group_op` rules, modelled at the granularity the ROUTING depends on: -/// which database the op lands on, who ends up in which group, and who gets notified. -/// -/// Same instrument, and same limits, as [`FakeShardDb`]: the module's own reducer bodies cannot run -/// in a gateway test (no `ReducerContext`), so what executes here is the gateway's production -/// routing (`world::party`) against a faithful stand-in for the authority. The rules themselves are -/// the module's to test. -#[derive(Default, Clone)] -struct FakeParty { - next_group_id: u64, - /// group_id → (leader, loot_method, loot_threshold, master_looter). - groups: Vec<(u64, u64, u8, u8, u64)>, - /// (group_id, character_guid), in join order — the order realm-core's roster read returns. - members: Vec<(u64, u64)>, - /// (target_guid, inviter_guid) — at most one pending per target, newest wins. - invites: Vec<(u64, u64)>, - /// Groups their leader converted to a Raid. - raids: Vec, - /// Each Raid member's Raid Slot; a member absent here holds the Party default. - slots: std::collections::HashMap, - /// group_id → how many times [`Self::push_list`] fired for it — this Fake's stand-in for the - /// Roster Revision. A group absent here has never had a list pushed. Bumped exactly where a - /// real accepted change would advance the revision, so [`super::party::roster_unchanged`]'s - /// no-mirror-push optimization is exercised against a real signal rather than a constant. - revisions: std::collections::HashMap, - /// Every op that reached the AUTHORITY: `(op, actor, target, arg_a, arg_b, arg_c)`. The - /// assertion that a party op ran on realm-core rather than on the player's shard. - ops: Vec<(u8, u64, u64, u8, u8, u64)>, - /// Every notification the authority pushed: `(recipient_guid, kind)` — the relay's input. - events: Vec<(u64, u8)>, -} - -impl FakeParty { - fn group_of(&self, guid: u64) -> Option { - self.members - .iter() - .find(|(_, g)| *g == guid) - .map(|(gid, _)| *gid) - } - - fn kind_of(&self, group_id: u64) -> GroupKind { - if self.raids.contains(&group_id) { - GroupKind::Raid - } else { - GroupKind::Party - } - } - - fn member_guids(&self, group_id: u64) -> Vec { - self.members - .iter() - .filter(|(g, _)| *g == group_id) - .map(|(_, guid)| *guid) - .collect() - } - - /// The Module's own placement rule, `RaidSlot::for_raid_joiner`, over this Fake's slots. - fn joining_slot(&self, group_id: u64) -> RaidSlot { - if self.kind_of(group_id) == GroupKind::Party { - return RaidSlot::default(); - } - let current = self - .member_guids(group_id) - .into_iter() - .map(|guid| self.slots.get(&guid).copied().unwrap_or_default()); - RaidSlot::for_raid_joiner(current).expect("the cap check leaves room") - } - - fn roster(&self, group_id: u64) -> Option { - let (gid, leader, method, threshold, master) = - *self.groups.iter().find(|(g, ..)| *g == group_id)?; - Some(super::party::GroupRoster { - group_id: gid, - roster_revision: self.revisions.get(&group_id).copied().unwrap_or(0), - leader_guid: leader, - loot_method: method, - loot_threshold: threshold, - master_looter_guid: master, - kind: self.kind_of(group_id), - members: self - .member_guids(group_id) - .into_iter() - .map(|guid| super::party::GroupRosterMember { - guid, - slot: self.slots.get(&guid).copied().unwrap_or_default(), - }) - .collect(), - partitions: Vec::new(), - }) - } - - fn leader_of(&self, group_id: u64) -> u64 { - self.groups - .iter() - .find(|(g, ..)| *g == group_id) - .map_or(0, |group| group.1) - } - - /// The leader, or an Assistant. - fn manages(&self, group_id: u64, guid: u64) -> bool { - self.leader_of(group_id) == guid - || self - .slots - .get(&guid) - .is_some_and(|slot| slot.is_assistant()) - } - - /// The Module's `change_subgroup_on`, modelled: leader-or-Assistant, Raid-only, capacity - /// decided by the shared [`RaidSlot::moved_to_subgroup`] rule. - fn change_subgroup(&mut self, actor_guid: u64, target_guid: u64, subgroup: u8) -> PartyOutcome { - use lyracore_shared::group::GroupRefusal; - let Some(group_id) = self.group_of(actor_guid) else { - return GroupRefusal::NotInGroup.into(); - }; - if self.kind_of(group_id) != GroupKind::Raid { - return GroupRefusal::NotRaid.into(); - } - if !self.manages(group_id, actor_guid) { - return GroupRefusal::NotLeader.into(); - } - if self.group_of(target_guid) != Some(group_id) { - return GroupRefusal::TargetNotInGroup.into(); - } - let current = self.slots.get(&target_guid).copied().unwrap_or_default(); - let destination_size = self - .member_guids(group_id) - .into_iter() - .filter(|&guid| { - guid != target_guid - && self - .slots - .get(&guid) - .copied() - .unwrap_or_default() - .subgroup() - == subgroup - }) - .count(); - match current.moved_to_subgroup(subgroup, destination_size) { - Err(refusal) => refusal.into(), - Ok(None) => PartyOutcome::Ran, - Ok(Some(new_slot)) => { - self.slots.insert(target_guid, new_slot); - self.push_list(group_id); - PartyOutcome::Ran - } - } - } - - /// The Module's `swap_subgroup_on`, modelled: same gates as [`Self::change_subgroup`], the - /// shared [`RaidSlot::swapped_with`] rule, and no capacity Gate. - fn swap_subgroup( - &mut self, - actor_guid: u64, - first_guid: u64, - second_guid: u64, - ) -> PartyOutcome { - use lyracore_shared::group::GroupRefusal; - let Some(group_id) = self.group_of(actor_guid) else { - return GroupRefusal::NotInGroup.into(); - }; - if self.kind_of(group_id) != GroupKind::Raid { - return GroupRefusal::NotRaid.into(); - } - if !self.manages(group_id, actor_guid) { - return GroupRefusal::NotLeader.into(); - } - if self.group_of(first_guid) != Some(group_id) - || self.group_of(second_guid) != Some(group_id) - { - return GroupRefusal::TargetNotInGroup.into(); - } - let first_slot = self.slots.get(&first_guid).copied().unwrap_or_default(); - let second_slot = self.slots.get(&second_guid).copied().unwrap_or_default(); - if let Some((new_first, new_second)) = first_slot.swapped_with(second_slot) { - self.slots.insert(first_guid, new_first); - self.slots.insert(second_guid, new_second); - self.push_list(group_id); - } - PartyOutcome::Ran - } - - /// Hand the lead to `leader` and announce it to every member, as the Module does, before the - /// list. - fn set_leader(&mut self, group_id: u64, leader: u64) { - if let Some(entry) = self.groups.iter_mut().find(|(g, ..)| *g == group_id) { - entry.1 = leader; - } - for member in self.member_guids(group_id) { - self.events - .push((member, lyracore_shared::group::event_kind::SET_LEADER)); - } - } - - fn push_list(&mut self, group_id: u64) { - *self.revisions.entry(group_id).or_insert(0) += 1; - let recipients: Vec = self - .members - .iter() - .filter(|(g, _)| *g == group_id) - .map(|(_, guid)| *guid) - .collect(); - for r in recipients { - self.events - .push((r, lyracore_shared::group::event_kind::LIST)); - } - } - - /// Drop `guid` from its party, applying the module's own disband rule (a party of one is no - /// party) — the half `sync_mirrors` has to observe to push the right tombstone. - fn remove_member(&mut self, guid: u64) { - use lyracore_shared::group::event_kind as kind; - let Some(group_id) = self.group_of(guid) else { - return; - }; - self.members - .retain(|(g, m)| !(*g == group_id && *m == guid)); - self.slots.remove(&guid); - self.events.push((guid, kind::DESTROYED)); - let remaining: Vec = self - .members - .iter() - .filter(|(g, _)| *g == group_id) - .map(|(_, m)| *m) - .collect(); - if remaining.len() < 2 { - for r in remaining { - self.events.push((r, kind::DESTROYED)); - self.slots.remove(&r); - } - self.members.retain(|(g, _)| *g != group_id); - self.groups.retain(|(g, ..)| *g != group_id); - self.raids.retain(|g| *g != group_id); - } else { - if self.leader_of(group_id) == guid { - // The Module's succession: a Raid's first Assistant in join order, else the - // longest-standing member. - let raid = self.kind_of(group_id) == GroupKind::Raid; - let heir = remaining - .iter() - .copied() - .find(|member| raid && self.manages(group_id, *member)) - .unwrap_or(remaining[0]); - self.set_leader(group_id, heir); - } - self.push_list(group_id); - } - } -} - -/// One SpacetimeDB database, as far as the transfer protocol can see it. -#[derive(Default)] -struct FakeShardDb { - characters: std::sync::Mutex>, - /// transfer_id → the source escrow (`game_transfer_out`). - out_rows: std::sync::Mutex>, - /// transfer_id → character guid (`game_transfer_in`): on the DESTINATION the arrival copy's - /// fence, on the SOURCE the gateway's `confirm_import` attestation. - in_rows: std::sync::Mutex>, - /// Exact bot Transfer identity attached to a destination fence. - bot_arrivals: std::sync::Mutex< - std::collections::HashMap, - >, - /// Realm locator predecessor attached to each destination fence. - arrival_sources: std::sync::Mutex>, - instances: std::sync::Mutex>, - instance_partitions: std::sync::Mutex>, - /// Every instance id this database actually SPAWNED a population for — one entry per - /// spawn, so "the second party member re-created the dungeon" is visible as a duplicate. - populated: std::sync::Mutex>, - evicted: std::sync::Mutex>, -} - -impl FakeShardDb { - fn with_character(guid: u64, c: FakeChar) -> std::sync::Arc { - let db = Self::default(); - if c.instance_id != 0 { - lk(&db.instances).insert(c.instance_id); - lk(&db.instance_partitions).insert(c.instance_id, (c.map_id, 0)); - } - lk(&db.characters).insert(guid, c); - std::sync::Arc::new(db) - } - fn empty() -> std::sync::Arc { - std::sync::Arc::new(Self::default()) - } - fn has(&self, guid: u64) -> bool { - lk(&self.characters).contains_key(&guid) - } - fn get(&self, guid: u64) -> Option { - lk(&self.characters).get(&guid).cloned() - } - /// A character is LIVE here iff it is durable AND neither escrow row fences it — the module's - /// `login_allowed` predicate, which is what `player_login` actually gates on. - fn live(&self, guid: u64) -> bool { - self.has(guid) - && !lk(&self.out_rows) - .values() - .any(|e| e.character_guid == guid) - && !lk(&self.in_rows).values().any(|g| *g == guid) - } - fn settled(&self) -> bool { - lk(&self.out_rows).is_empty() && lk(&self.in_rows).is_empty() - } -} - -/// The blob a fake `begin_transfer` produces. Carries the destination and the payload, exactly as -/// the real `ExportBlob` carries `dest_*` + `character_row` + `payload`: cross-database the blob is -/// the ONLY thing that reaches the far side. -fn fake_blob(guid: u64, dest_map: u32, dest_instance: u64, payload: &str) -> Vec { - format!("{guid}|{dest_map}|{dest_instance}|{payload}").into_bytes() -} -fn parse_blob(blob: &[u8]) -> (u64, FakeChar) { - let s = String::from_utf8(blob.to_vec()).expect("fake blob is utf8"); - let parts: Vec<&str> = s.splitn(4, '|').collect(); - ( - parts[0].parse().expect("guid"), - FakeChar { - map_id: parts[1].parse().expect("map"), - instance_id: parts[2].parse().expect("instance"), - payload: parts[3].to_string(), - }, - ) -} - -// ── Trainer dispatch (CMSG_TRAINER_LIST) ───────────────────────────────────────────────────────── - -#[test] -fn trainer_list_replies_smsg_trainer_list_with_the_fixture_spells() { - let mut s = quest_store(); - s.trainer_spells = vec![codec::TrainerSpellView { - spell_id: 100, - cost: 10, - required_level: 1, - player_level: 1, - known: false, - profession: false, - }]; - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - CMSG_TRAINER_LIST { - guid: Guid::new(70), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_TRAINER_LIST(list) => { - assert_eq!(list.guid, Guid::new(70)); - assert_eq!(list.spells.len(), 1, "the fixture's one spell row"); - assert_eq!(list.spells[0].spell, 100); - } - other => panic!("expected SMSG_TRAINER_LIST, got {other}"), - } - drop(client); - server.join().unwrap(); -} - -#[test] -fn trainer_list_is_silently_dropped_for_a_player_the_trainer_does_not_serve() { - let mut s = quest_store(); - s.trainer_refuses_class = true; - s.trainer_spells = vec![codec::TrainerSpellView { - spell_id: 100, - cost: 10, - required_level: 1, - player_level: 1, - known: false, - profession: false, - }]; - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - CMSG_TRAINER_LIST { - guid: Guid::new(70), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - // The follow-up whose reply we DO expect. Gossip always answers, so reading it back proves the - // trainer request emitted nothing — and it doubles as the "the NPC still talks to you" check: - // the class gate removes the training service, not the creature. - CMSG_GOSSIP_HELLO { - guid: Guid::new(70), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_GOSSIP_MESSAGE(_) => {} - ServerOpcodeMessage::SMSG_TRAINER_LIST(_) => { - panic!("a trainer that does not serve this class must send NO window") - } - other => panic!("expected only the gossip reply, got {other}"), - } - drop(client); - server.join().unwrap(); -} - -#[test] -fn gossip_hides_the_train_and_unlearn_options_for_a_class_the_trainer_does_not_serve() { - use lyracore_shared::constants::gossip_option; - // Level 20 matters: the respec option is independently hidden below level 10, so at the default - // fixture level this would pass without the class gate doing any work. - let mut s = quest_store_at_level(20); - s.gossip_opts = vec![ - opt(0, "Well met, traveler.", gossip_option::GOSSIP), - opt(1, "I would like to train.", gossip_option::TRAINER), - opt( - 0, - "I wish to unlearn my talents.", - gossip_option::UNLEARNTALENTS, - ), - opt(1, "I'd like to browse your goods.", gossip_option::VENDOR), - ]; - s.trainer_refuses_class = true; - let store = std::sync::Arc::new(s); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - CMSG_GOSSIP_HELLO { - guid: Guid::new(90), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_GOSSIP_MESSAGE(m) => { - let lines: Vec<&str> = m.gossips.iter().map(|g| g.message.as_str()).collect(); - assert!( - !lines.contains(&"I would like to train."), - "the train option must be hidden: {lines:?}" - ); - assert!( - !lines.contains(&"I wish to unlearn my talents."), - "the respec option must be hidden too: {lines:?}" - ); - // The NPC is not silenced — it still talks, and still sells. - assert!( - lines.contains(&"Well met, traveler."), - "plain gossip lines survive: {lines:?}" - ); - assert!( - lines.contains(&"I'd like to browse your goods."), - "the vendor line on the same NPC survives: {lines:?}" - ); - } - other => panic!("expected SMSG_GOSSIP_MESSAGE, got {other}"), - } - drop(client); - server.join().unwrap(); -} - -#[test] -fn gossip_keeps_the_train_and_unlearn_options_for_a_class_the_trainer_serves() { - use lyracore_shared::constants::gossip_option; - // Same level as its counterpart, so the only difference between the two tests is the gate. - let mut s = quest_store_at_level(20); - s.gossip_opts = vec![ - opt(0, "Well met, traveler.", gossip_option::GOSSIP), - opt(1, "I would like to train.", gossip_option::TRAINER), - opt( - 0, - "I wish to unlearn my talents.", - gossip_option::UNLEARNTALENTS, - ), - opt(1, "I'd like to browse your goods.", gossip_option::VENDOR), - ]; - let store = std::sync::Arc::new(s); // trainer_refuses_class stays false (derive-Default) - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - CMSG_GOSSIP_HELLO { - guid: Guid::new(90), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_GOSSIP_MESSAGE(m) => { - let lines: Vec<&str> = m.gossips.iter().map(|g| g.message.as_str()).collect(); - assert!( - lines.contains(&"I would like to train."), - "a served class still gets the train option: {lines:?}" - ); - assert!( - lines.contains(&"I wish to unlearn my talents."), - "and the respec option: {lines:?}" - ); - } - other => panic!("expected SMSG_GOSSIP_MESSAGE, got {other}"), - } - drop(client); - server.join().unwrap(); -} - -// ── Death/resurrection dispatch (CMSG_REPOP_REQUEST / CMSG_RECLAIM_CORPSE / -// CMSG_RESURRECT_RESPONSE / CMSG_SELF_RES / CMSG_SPIRIT_HEALER_ACTIVATE) ─────────────────────────────────────────── - -#[test] -fn repop_request_dispatches_repop_for_the_caller() { - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, _c_dec, server) = enter_world(store.clone(), 1); - CMSG_REPOP_REQUEST {} - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - drop(client); // repop's revive replicates via the entity VALUES relay, not a direct SMSG here - server.join().unwrap(); - assert_eq!(store.repopped.lock().unwrap().as_slice(), &[1]); -} - -#[test] -fn reclaim_corpse_dispatches_with_the_wire_corpse_guid() { - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, _c_dec, server) = enter_world(store.clone(), 1); - CMSG_RECLAIM_CORPSE { - guid: Guid::new(777), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - drop(client); - server.join().unwrap(); - assert_eq!( - store.reclaimed_corpses.lock().unwrap().as_slice(), - &[(1, 777)] - ); -} - -#[test] -fn resurrect_response_accept_maps_status_byte_to_true() { - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, _c_dec, server) = enter_world(store.clone(), 1); - CMSG_RESURRECT_RESPONSE { - guid: Guid::new(42), - status: 1, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - drop(client); - server.join().unwrap(); - assert_eq!( - store.resurrect_responses.lock().unwrap().as_slice(), - &[(1, true)] - ); -} - -#[test] -fn resurrect_response_decline_maps_status_byte_to_false() { - // Proves the `status != 0` mapping actually distinguishes decline from accept, not just that - // SOME boolean reaches the store. - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, _c_dec, server) = enter_world(store.clone(), 1); - CMSG_RESURRECT_RESPONSE { - guid: Guid::new(42), - status: 0, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - drop(client); - server.join().unwrap(); - assert_eq!( - store.resurrect_responses.lock().unwrap().as_slice(), - &[(1, false)] - ); -} - -#[test] -fn self_res_dispatches_self_resurrect_for_the_caller() { - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, _c_dec, server) = enter_world(store.clone(), 1); - CMSG_SELF_RES {} - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - drop(client); // the revive replicates via the entity VALUES relay, not a direct SMSG here - server.join().unwrap(); - assert_eq!(store.self_resurrects.lock().unwrap().as_slice(), &[1]); -} - -#[test] -fn a_refused_self_res_sends_nothing_and_keeps_the_session() { - let store = std::sync::Arc::new(InMemoryStore { - self_resurrect_error: Some("no Self-Resurrection Option".into()), - ..quest_store() - }); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - CMSG_SELF_RES {} - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - CMSG_QUESTGIVER_STATUS_QUERY { - guid: Guid::new(50), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_QUESTGIVER_STATUS(_) => {} - other => panic!("expected the sentinel (the Refusal sends nothing), got {other}"), - } - drop(client); - server.join().unwrap(); - assert_eq!(store.self_resurrects.lock().unwrap().as_slice(), &[1]); -} - -#[test] -fn spirit_healer_activate_dispatches_and_confirms_the_healer_guid() { - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - CMSG_SPIRIT_HEALER_ACTIVATE { - guid: Guid::new(888), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_SPIRIT_HEALER_CONFIRM(p) => { - assert_eq!(p.guid, Guid::new(888), "echoes the healer's own guid"); - } - other => panic!("expected SMSG_SPIRIT_HEALER_CONFIRM, got {other}"), - } - drop(client); - server.join().unwrap(); - // The SMSG above echoes the WIRE guid verbatim, so it alone can't catch a swapped-argument bug — - // this pins that the STORE call also got (self_guid, healer_guid) in the right order. - assert_eq!( - store.spirit_healer_calls.lock().unwrap().as_slice(), - &[(1, 888)] - ); -} - -// ── Targeting/aura dispatch (CMSG_SET_SELECTION / CMSG_CANCEL_AURA / CMSG_CANCEL_CAST) ───────────── - -#[test] -fn reducer_transport_loss_ends_an_admitted_session_and_frees_one_queue_seat() { - let store = std::sync::Arc::new(InMemoryStore { - login_entity: Some(warrior_entity()), - set_target_error: Some("transport disconnected".into()), - // 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()), - ..tester_store(7) - }); - let queue = std::sync::Arc::new(LoginQueue::new(1, 0)); - let (mut client, server_end) = world_session_socket_pair(); - let server_store = store.clone(); - let server_queue = queue.clone(); - let (result_tx, result_rx) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - let result = - run_world_session_with_queue(server_end, server_store.as_ref(), server_queue.as_ref()); - result_tx.send(result).unwrap(); - }); - - 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(); - for _ in 0..WORLD_ENTRY_PACKETS { - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - } - assert_eq!( - queue.active(), - 1, - "the admitted session holds the only seat" - ); - - CMSG_SET_SELECTION { - target: Guid::new(321), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - - let err = result_rx - .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!( - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).is_err(), - "the world socket closes after the fatal reducer result" - ); - assert!( - store - .logout_called - .load(std::sync::atomic::Ordering::SeqCst), - "teardown still attempts leave-world cleanup" - ); - assert_eq!(queue.active(), 0, "the ended session released its seat"); - assert_eq!( - queue.request(), - Admission::Admitted, - "one replacement session is admitted" - ); - assert!( - matches!(queue.request(), Admission::Queued(_)), - "only one seat was released" - ); - drop(client); -} - -#[test] -fn set_selection_dispatches_set_target_with_the_wire_guid() { - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, _c_dec, server) = enter_world(store.clone(), 1); - CMSG_SET_SELECTION { - target: Guid::new(321), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - drop(client); - server.join().unwrap(); - assert_eq!(store.selected_targets.lock().unwrap().as_slice(), &[321]); -} - -#[test] -fn cancel_aura_dispatches_with_the_wire_spell_id() { - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, _c_dec, server) = enter_world(store.clone(), 1); - CMSG_CANCEL_AURA { id: 5555 } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - drop(client); - server.join().unwrap(); - assert_eq!(store.cancelled_auras.lock().unwrap().as_slice(), &[5555]); -} - -#[test] -fn cancel_cast_dispatches_for_the_caller() { - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, _c_dec, server) = enter_world(store.clone(), 1); - CMSG_CANCEL_CAST { id: 133 } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - drop(client); - server.join().unwrap(); - assert_eq!(store.cancelled_casts.lock().unwrap().as_slice(), &[1]); -} - -#[test] -#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] -fn competing_world_sessions_close_the_old_socket_without_removing_the_winner() { - use crate::accept::BlockingTaskCapacity; - use crate::config::GatewayConfig; - use crate::durable_test_support::Standalone; - use crate::stdb::Coordinator; - - fn start( - coord: Coordinator, - runtime: tokio::runtime::Handle, - ) -> (UnixStream, std::sync::mpsc::Receiver>) { - let (client, server) = world_session_socket_pair(); - let (done, result) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - let _entered = runtime.enter(); - let _ = done.send(run_world_session(server, &coord)); - }); - (client, result) - } - - fn login(client: &mut UnixStream) -> (EncrypterHalf, DecrypterHalf) { - let (mut encrypt, mut decrypt) = client_handshake(client, "TEST", [7; 40]); - CMSG_PLAYER_LOGIN { guid: Guid::new(1) } - .write_encrypted_client(&mut *client, &mut encrypt) - .unwrap(); - for _ in 0..100 { - if matches!( - ServerOpcodeMessage::read_encrypted(&mut *client, &mut decrypt).unwrap(), - ServerOpcodeMessage::SMSG_WEATHER(_) - ) { - return (encrypt, decrypt); - } - } - panic!("World Session did not complete its entry batch"); - } - - for name in [ - "LYRACORE_SHARD_MAP", - "LYRACORE_SHARD_MAP_FILE", - "LYRACORE_REALM_CORE", - ] { - assert!( - std::env::var_os(name).is_none(), - "unset {name} for this private fixture" - ); - } - let mut fixture = Standalone::start("account-world-sessions"); - fixture.publish_module(); - fixture.assert_call("claim_operator", &[]); - fixture.assert_call("install_guid_range", &["0"]); - fixture.assert_call("gw_heartbeat", &[]); - let cfg = GatewayConfig { - logon_bind: "127.0.0.1:0".into(), - world_bind: "127.0.0.1:0".into(), - stdb_uri: fixture.server().into(), - module_name: fixture.shard_name().into(), - coordinator_token: Some(fixture.owner_token()), - gateway_id: "world-owner-a".into(), - blocking_task_capacity: BlockingTaskCapacity::new(2), - }; - let runtime = tokio::runtime::Builder::new_multi_thread() - .worker_threads(2) - .max_blocking_threads(1) - .enable_all() - .build() - .unwrap(); - let a = runtime.block_on(Coordinator::connect(&cfg)).unwrap(); - let b = runtime - .block_on(Coordinator::connect(&GatewayConfig { - gateway_id: "world-owner-b".into(), - ..cfg - })) - .unwrap(); - let (release_pool, hold_pool) = std::sync::mpsc::channel(); - let (pool_started, pool_ready) = std::sync::mpsc::channel(); - runtime.spawn_blocking(move || { - pool_started.send(()).unwrap(); - let _ = hold_pool.recv_timeout(Duration::from_secs(40)); - }); - pool_ready.recv_timeout(Duration::from_secs(2)).unwrap(); - let account = a.account_by_username("TEST").unwrap().unwrap().id; - a.establish_session(account, &[7; 40], a.bound_identity(account).unwrap()) - .unwrap(); - let (mut first, first_done) = start(a, runtime.handle().clone()); - let _first_cipher = login(&mut first); - - let (mut refused, refused_done) = start(b.clone(), runtime.handle().clone()); - let (mut encrypt, _) = client_handshake(&mut refused, "TEST", [7; 40]); - CMSG_PLAYER_LOGIN { guid: Guid::new(1) } - .write_encrypted_client(&mut refused, &mut encrypt) - .unwrap(); - let refusal = refused_done - .recv_timeout(Duration::from_secs(10)) - .unwrap() - .unwrap_err(); - assert!( - refusal.to_string().contains("ACCOUNT_IN_USE"), - "{refusal:#}" - ); - assert!(first_done.try_recv().is_err()); - - fixture.assert_sql("UPDATE game_account_claim SET expires_micros = 0"); - let (mut winner, winner_done) = start(b, runtime.handle().clone()); - let (mut winner_encrypt, mut winner_decrypt) = login(&mut winner); - let first_deadline = fixture.query_rows("SELECT expires_micros FROM game_account_claim")[0] - ["expires_micros"] - .clone(); - first_done - .recv_timeout(Duration::from_secs(25)) - .expect("lost Account ownership must close the old World Session") - .unwrap(); - assert_eq!( - fixture - .query_rows("SELECT * FROM game_world_entity WHERE guid = 1") - .len(), - 1 - ); - assert!(winner_done.try_recv().is_err()); - assert!( - crate::durable_test_support::poll_until(Duration::from_secs(5), || fixture - .query_rows("SELECT expires_micros FROM game_account_claim")[0]["expires_micros"] - != first_deadline), - "the winning Account Claim must renew while the Tokio blocking pool is full" - ); - CMSG_LOGOUT_REQUEST {} - .write_encrypted_client(&mut winner, &mut winner_encrypt) - .unwrap(); - let mut logged_out = false; - for _ in 0..100 { - if read_raw_frame(&mut winner, &mut winner_decrypt).0 - == lyracore_shared::opcodes::world::SMSG_LOGOUT_COMPLETE - { - logged_out = true; - break; - } - } - assert!( - logged_out, - "winning World Session must still process logout" - ); - winner.shutdown(std::net::Shutdown::Write).unwrap(); - winner_done - .recv_timeout(Duration::from_secs(10)) - .unwrap() - .unwrap(); - drop(winner); - release_pool.send(()).unwrap(); - assert!(fixture - .query_rows("SELECT * FROM game_world_entity WHERE guid = 1") - .is_empty()); -} - -#[cfg(target_os = "linux")] -#[test] -fn closing_a_world_session_interrupts_a_full_socket_without_draining_its_queue() { - use std::os::fd::AsRawFd; - - let (_client, mut socket) = world_session_socket_pair(); - socket.set_nonblocking(true).unwrap(); - loop { - match socket.write(&[0; 65536]) { - Ok(_) => {} - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => break, - Err(error) => panic!("fill owned socket: {error}"), - } - } - socket.set_nonblocking(false).unwrap(); - let (mut tx, queued, _) = session_channel(); - tx.bind_socket(socket.try_clone().unwrap()); - tx.send(Outbound::Raw { - opcode: 0, - body: vec![], - }) - .unwrap(); - let socket_fd = socket.as_raw_fd(); - let (started, ready) = std::sync::mpsc::channel(); - let (finished, result) = std::sync::mpsc::channel(); - let writer = std::thread::spawn(move || { - let task = std::fs::read_link("/proc/thread-self") - .expect("this Linux socket fixture requires /proc/thread-self"); - started.send(task).unwrap(); - finished.send(socket.write_all(&[1; 65536])).unwrap(); - }); - let task = ready.recv_timeout(Duration::from_secs(2)).unwrap(); - let syscall_path = std::path::Path::new("/proc").join(task).join("syscall"); - assert!( - crate::durable_test_support::poll_until(Duration::from_secs(2), || { - let syscall = std::fs::read_to_string(&syscall_path) - .expect("this Linux socket fixture requires visibility of its writer's syscall"); - let mut fields = syscall.split_whitespace(); - let number = fields - .next() - .and_then(|value| value.parse::().ok()); - let fd = fields - .next() - .and_then(|value| value.strip_prefix("0x")) - .and_then(|value| i32::from_str_radix(value, 16).ok()); - matches!(number, Some(n) if n == libc::SYS_sendto || n == libc::SYS_write) - && fd == Some(socket_fd) - }), - "writer did not enter its blocked socket write" - ); - tx.close(); - assert!(result - .recv_timeout(Duration::from_secs(2)) - .unwrap() - .is_err()); - assert!(matches!(queued.try_recv().unwrap(), Outbound::Raw { .. })); - writer.join().unwrap(); -} - -/// Every petition opcode reaches its dispatch entry over a real encrypted socket. Character 1, -/// outside any Guild, owns Petition 42 and holds its Guild Charter; Character 2 is live. -#[test] -fn every_petition_opcode_reaches_its_dispatch_entry_over_the_socket() { - use wow_world_messages::vanilla::{ - CMSG_OFFER_PETITION, CMSG_PETITION_BUY, CMSG_PETITION_QUERY, CMSG_PETITION_SHOWLIST, - CMSG_PETITION_SHOW_SIGNATURES, CMSG_PETITION_SIGN, CMSG_TURN_IN_PETITION, - MSG_PETITION_DECLINE, MSG_PETITION_RENAME, - }; - const CHARTER: u64 = 0x4000_0000_0000_0101; - let store = std::sync::Arc::new(InMemoryStore { - login_entity: Some(warrior_entity()), - characters: vec![ - codec::CharacterView { - guid: 1, - name: "Warrior".into(), - ..Default::default() - }, - codec::CharacterView { - guid: 2, - name: "Target".into(), - ..Default::default() - }, - ], - guild_petitions: vec![codec::PetitionView { - petition_id: 42, - charter_item_guid: CHARTER, - owner_guid: 1, - name: "Night Watch".into(), - signers: vec![2], - }], - held_charters: vec![CHARTER], - ..tester_store(7) - }); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - let mut next = - |client: &mut UnixStream| ServerOpcodeMessage::read_encrypted(client, &mut c_dec).unwrap(); - - CMSG_PETITION_SHOWLIST { - guid: Guid::new(90), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match next(&mut client) { - ServerOpcodeMessage::SMSG_PETITION_SHOWLIST(list) => assert_eq!(list.npc.guid(), 90), - other => panic!("expected SMSG_PETITION_SHOWLIST, got {other}"), - } - CMSG_PETITION_QUERY { - guild_id: 42, - petition: Guid::new(CHARTER), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match next(&mut client) { - ServerOpcodeMessage::SMSG_PETITION_QUERY_RESPONSE(query) => { - assert_eq!( - (query.petition_id, query.guild_name.as_str()), - (42, "Night Watch") - ); - } - other => panic!("expected SMSG_PETITION_QUERY_RESPONSE, got {other}"), - } - CMSG_PETITION_SHOW_SIGNATURES { - item: Guid::new(CHARTER), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match next(&mut client) { - ServerOpcodeMessage::SMSG_PETITION_SHOW_SIGNATURES(window) => { - assert_eq!(window.signatures.len(), 1); - assert_eq!(window.signatures[0].signer, Guid::new(2)); - } - other => panic!("expected SMSG_PETITION_SHOW_SIGNATURES, got {other}"), - } - MSG_PETITION_RENAME { - petition: Guid::new(CHARTER), - new_name: "Day Watch".into(), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match next(&mut client) { - ServerOpcodeMessage::MSG_PETITION_RENAME(rename) => { - assert_eq!(rename.new_name, "Day Watch") - } - other => panic!("expected MSG_PETITION_RENAME, got {other}"), - } - - sync(&mut client, &mut c_enc, &mut c_dec, |c, e| { - CMSG_OFFER_PETITION { - petition: Guid::new(CHARTER), - target: Guid::new(2), - } - .write_encrypted_client(c, e) - .unwrap(); - }); - sync(&mut client, &mut c_enc, &mut c_dec, |c, e| { - CMSG_PETITION_SIGN { - petition: Guid::new(CHARTER), - unknown1: 0, - } - .write_encrypted_client(c, e) - .unwrap(); - }); - sync(&mut client, &mut c_enc, &mut c_dec, |c, e| { - MSG_PETITION_DECLINE { - petition: Guid::new(CHARTER), - } - .write_encrypted_client(c, e) - .unwrap(); - }); - // The buyer still holds the Charter of its open Petition. mangos is silent; no copper moves. - sync(&mut client, &mut c_enc, &mut c_dec, |c, e| { - CMSG_PETITION_BUY { - npc: Guid::new(90), - name: "Night Watch".into(), - ..Default::default() - } - .write_encrypted_client(c, e) - .unwrap(); - }); - - CMSG_TURN_IN_PETITION { - petition: Guid::new(CHARTER), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_GUILD_COMMAND_RESULT(result) => { - assert_eq!(result.string, "Night Watch"); - } - other => panic!("expected SMSG_GUILD_COMMAND_RESULT, got {other}"), - } - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_TURN_IN_PETITION_RESULTS(result) => assert_eq!( - result.result, - wow_world_messages::vanilla::PetitionResult::Ok - ), - other => panic!("expected SMSG_TURN_IN_PETITION_RESULTS, got {other}"), - } - drop(client); - server.join().unwrap(); - - let calls = recorded(&store); - for op in [ - "guild_op:RenamePetition", - "guild_op:OfferPetition", - "guild_op:SignPetition", - "guild_op:DeclinePetition", - "guild_op:TurnInPetition", - ] { - assert!(calls.contains(&op.to_string()), "{op} never ran: {calls:?}"); +fn human_speaker() -> SpeakerFacts { + SpeakerFacts { + race: 1, + chat_tag: 0, + name: String::new(), } - assert!( - position(&calls, "guild_op:TurnInPetition") < position(&calls, "guild_destroy_charter"), - "the Guild is founded before the Charter is destroyed" - ); - assert!(!calls.contains(&"guild_fee_hold".to_string())); } diff --git a/gateway/src/world/trade_tests.rs b/gateway/src/world/trade_tests.rs index d5b0f3d77..d923639a6 100644 --- a/gateway/src/world/trade_tests.rs +++ b/gateway/src/world/trade_tests.rs @@ -1,241 +1,366 @@ -//! Trade dispatch through a Headless Client and the existing Store Fake. +//! Trade opcodes, run through `handle_trade` against a Fake that holds only the Trade Store the +//! handler is bounded on. Every trade status reaches the client through the `game_trade_event` +//! relay, so the handler sends nothing and a test reads the Fake's Trade Session instead. +use super::handlers::{handle_trade, TradeStore}; use super::*; +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Mutex; use wow_world_messages::vanilla::{ CMSG_ACCEPT_TRADE, CMSG_BEGIN_TRADE, CMSG_BUSY_TRADE, CMSG_CANCEL_TRADE, CMSG_CLEAR_TRADE_ITEM, CMSG_IGNORE_TRADE, CMSG_INITIATE_TRADE, CMSG_SET_TRADE_GOLD, CMSG_SET_TRADE_ITEM, CMSG_UNACCEPT_TRADE, }; -/// **AC: initiating a trade with a targeted player reaches the store with the wire's target.** -#[test] -fn initiate_trade_dispatches_with_the_wire_target_guid() { - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, _c_dec, server) = enter_world(store.clone(), 1); - CMSG_INITIATE_TRADE { guid: Guid::new(2) } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - drop(client); // every status rides the game_trade_event relay, no direct SMSG here - server.join().unwrap(); - assert_eq!(store.initiated_trades.lock().unwrap().as_slice(), &[(1, 2)]); -} - -/// **AC: tested from both sides** — the SAME wire flow works with the seats swapped: player 2 -/// initiates against player 1. -#[test] -fn initiate_trade_dispatches_from_the_other_side_too() { - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, _c_dec, server) = enter_world(store.clone(), 2); - CMSG_INITIATE_TRADE { guid: Guid::new(1) } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - drop(client); - server.join().unwrap(); - assert_eq!(store.initiated_trades.lock().unwrap().as_slice(), &[(2, 1)]); -} - -/// **AC: the full handshake round trip, one store, both parties** — A proposes, B's client -/// answers `CMSG_BEGIN_TRADE` (the vanilla auto-reply to `BeginTrade`), then B cancels. The -/// per-shard call log pins the dispatch ORDER, the recorders pin who each verb acted as. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum Phase { + #[default] + Idle, + Proposed { + initiator: u64, + target: u64, + }, + Open { + initiator: u64, + target: u64, + }, + Cancelled { + by: u64, + }, + Declined { + by: u64, + reason: Decline, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Decline { + Busy, + IgnoresInitiator, +} + +/// One Trade Session. The Module owns every gate and the accept reset; this keeps just enough to show +/// which seat did what, with which arguments. +#[derive(Default)] +struct Session { + phase: Phase, + /// `(seat, trade slot)` to the absolute inventory slot offered. + items: BTreeMap<(u64, u8), u8>, + /// Offered copper per seat. + gold: BTreeMap, + accepted: BTreeSet, +} + +#[derive(Default)] +struct TradeFake { + session: Mutex, +} + +impl TradeFake { + fn phase(&self) -> Phase { + self.session.lock().unwrap().phase + } + + fn items(&self) -> Vec<((u64, u8), u8)> { + let session = self.session.lock().unwrap(); + session.items.iter().map(|(k, v)| (*k, *v)).collect() + } + + fn gold(&self) -> Vec<(u64, u32)> { + let session = self.session.lock().unwrap(); + session.gold.iter().map(|(k, v)| (*k, *v)).collect() + } + + fn accepted(&self) -> Vec { + self.session + .lock() + .unwrap() + .accepted + .iter() + .copied() + .collect() + } + + fn decline_if_proposed(&self, by: u64, reason: Decline) { + let mut session = self.session.lock().unwrap(); + if matches!(session.phase, Phase::Proposed { .. }) { + session.phase = Phase::Declined { by, reason }; + } + } +} + +impl TradeStore for TradeFake { + fn initiate_trade(&self, _account_id: u64, self_guid: u64, target_guid: u64) -> Result<()> { + *self.session.lock().unwrap() = Session { + phase: Phase::Proposed { + initiator: self_guid, + target: target_guid, + }, + ..Default::default() + }; + Ok(()) + } + + fn begin_trade(&self, _account_id: u64, self_guid: u64) -> Result<()> { + let mut session = self.session.lock().unwrap(); + if let Phase::Proposed { initiator, target } = session.phase { + if target == self_guid { + session.phase = Phase::Open { initiator, target }; + } + } + Ok(()) + } + + fn cancel_trade(&self, _account_id: u64, self_guid: u64) -> Result<()> { + 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<()> { + 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<()> { + self.session + .lock() + .unwrap() + .items + .remove(&(self_guid, trade_slot)); + Ok(()) + } + + fn set_trade_gold(&self, _account_id: u64, self_guid: u64, copper: u32) -> Result<()> { + self.session.lock().unwrap().gold.insert(self_guid, copper); + Ok(()) + } + + fn accept_trade(&self, _account_id: u64, self_guid: u64) -> Result<()> { + self.session.lock().unwrap().accepted.insert(self_guid); + Ok(()) + } + + fn unaccept_trade(&self, _account_id: u64, self_guid: u64) -> Result<()> { + self.session.lock().unwrap().accepted.remove(&self_guid); + Ok(()) + } + + fn busy_trade(&self, _account_id: u64, self_guid: u64) -> Result<()> { + self.decline_if_proposed(self_guid, Decline::Busy); + Ok(()) + } + + fn ignore_trade(&self, _account_id: u64, self_guid: u64) -> Result<()> { + self.decline_if_proposed(self_guid, Decline::IgnoresInitiator); + Ok(()) + } +} + +/// Send `msg` as `seat`, a Character of account 7. Phase 5 retargets only this helper. +fn run(store: &TradeFake, seat: u64, msg: impl Into) { + let mut conn = in_world_conn(7, seat); + let passed_on = + handle_trade(&SessionTx::with_depth(0).0, store, &mut conn, msg.into()).unwrap(); + assert!(passed_on.is_none(), "the trade family owns this opcode"); +} + +/// Initiating a trade with a targeted player names the wire's target. #[test] -fn the_handshake_flow_dispatches_initiate_then_begin_then_cancel() { - let store = std::sync::Arc::new(quest_store()); - - let (mut a, mut a_enc, _a_dec, a_server) = enter_world(store.clone(), 1); - CMSG_INITIATE_TRADE { guid: Guid::new(2) } - .write_encrypted_client(&mut a, &mut a_enc) - .unwrap(); - drop(a); - a_server.join().unwrap(); - - let (mut b, mut b_enc, _b_dec, b_server) = enter_world(store.clone(), 2); - CMSG_BEGIN_TRADE {} - .write_encrypted_client(&mut b, &mut b_enc) - .unwrap(); - CMSG_CANCEL_TRADE {} - .write_encrypted_client(&mut b, &mut b_enc) - .unwrap(); - drop(b); - b_server.join().unwrap(); - - assert_eq!(store.initiated_trades.lock().unwrap().as_slice(), &[(1, 2)]); - assert_eq!(store.begun_trades.lock().unwrap().as_slice(), &[2]); - assert_eq!(store.cancelled_trades.lock().unwrap().as_slice(), &[2]); - let calls: Vec = store - .calls - .lock() - .unwrap() - .iter() - .filter(|(_, what)| what.contains("trade")) - .map(|(_, what)| what.clone()) - .collect(); - assert_eq!(calls, ["initiate_trade", "begin_trade", "cancel_trade"]); -} - -/// **AC: offer mutations dispatch with the wire's arguments**, set item (main bag → -/// absolute slot passthrough), clear item, and gold (the `Gold` wire type decoded back to -/// copper). One client, three opcodes, three recorders. +fn initiate_trade_proposes_to_the_wire_target_guid() { + let store = TradeFake::default(); + run(&store, 1, CMSG_INITIATE_TRADE { guid: Guid::new(2) }); + assert_eq!( + store.phase(), + Phase::Proposed { + initiator: 1, + target: 2 + } + ); +} + +/// The same flow works with the seats swapped: Character 2 initiates against Character 1. #[test] -fn offer_mutations_dispatch_with_wire_arguments() { - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, _c_dec, server) = enter_world(store.clone(), 1); - CMSG_SET_TRADE_ITEM { - trade_slot: 2, - bag: 255, - slot: 23, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - CMSG_CLEAR_TRADE_ITEM { trade_slot: 2 } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - CMSG_SET_TRADE_GOLD { - gold: wow_world_messages::vanilla::Gold::new(1_2345), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - drop(client); - server.join().unwrap(); +fn initiate_trade_proposes_from_the_other_side_too() { + let store = TradeFake::default(); + run(&store, 2, CMSG_INITIATE_TRADE { guid: Guid::new(1) }); assert_eq!( - store.set_trade_items.lock().unwrap().as_slice(), - &[(1, 2, 23)] + store.phase(), + Phase::Proposed { + initiator: 2, + target: 1 + } ); +} + +/// The handshake round trip on one Trade Session: A proposes, B's client answers `CMSG_BEGIN_TRADE` +/// (the vanilla auto-reply to `BeginTrade`), then B cancels. Each step reads the phase the Fake +/// reached, so the steps also pin their order: a begin before the proposal opens nothing. +#[test] +fn the_handshake_flow_proposes_then_opens_then_cancels() { + let store = TradeFake::default(); + + run(&store, 1, CMSG_INITIATE_TRADE { guid: Guid::new(2) }); assert_eq!( - store.cleared_trade_items.lock().unwrap().as_slice(), - &[(1, 2)] + store.phase(), + Phase::Proposed { + initiator: 1, + target: 2 + } ); + + run(&store, 2, CMSG_BEGIN_TRADE {}); assert_eq!( - store.set_trade_golds.lock().unwrap().as_slice(), - &[(1, 1_2345)] + store.phase(), + Phase::Open { + initiator: 1, + target: 2 + } + ); + + run(&store, 2, CMSG_CANCEL_TRADE {}); + assert_eq!(store.phase(), Phase::Cancelled { by: 2 }); +} + +/// Offer mutations carry the wire's arguments: set item (main bag to absolute slot), clear item, +/// and gold (the `Gold` wire type decoded back to copper). +#[test] +fn offer_mutations_carry_the_wire_arguments() { + let store = TradeFake::default(); + run( + &store, + 1, + CMSG_SET_TRADE_ITEM { + trade_slot: 2, + bag: 255, + slot: 23, + }, ); + assert_eq!(store.items(), [((1, 2), 23)]); + + run(&store, 1, CMSG_CLEAR_TRADE_ITEM { trade_slot: 2 }); + assert!(store.items().is_empty()); + + run( + &store, + 1, + CMSG_SET_TRADE_GOLD { + gold: wow_world_messages::vanilla::Gold::new(1_2345), + }, + ); + assert_eq!(store.gold(), [(1, 1_2345)]); } -/// **AC: sub-bag items are out of scope, not mis-addressed**, a `CMSG_SET_TRADE_ITEM` -/// from an equipped sub-bag is logged and IGNORED (the item-action dispatcher posture), never -/// forwarded -/// with a bag-local slot number that would alias a main-bag slot. +/// Items inside an equipped sub-bag are out of scope: logged and ignored, never forwarded with a +/// bag-local slot number that would alias a main-bag slot. #[test] fn set_trade_item_from_a_sub_bag_is_ignored_not_misaddressed() { - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, _c_dec, server) = enter_world(store.clone(), 1); - CMSG_SET_TRADE_ITEM { - trade_slot: 0, - bag: 19, - slot: 2, - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - drop(client); - server.join().unwrap(); - assert!(store.set_trade_items.lock().unwrap().is_empty()); -} - -/// **AC: the full loop's wire half**, A initiates and offers an item, B answers, offers -/// gold, and both accept; every opcode dispatches its verb as the right seat, in order. (The -/// swap itself — items, gold, atomicity — is the module's pure-tested commit core; the fake -/// store records the dispatch, per the settled seam.) + let store = TradeFake::default(); + run( + &store, + 1, + CMSG_SET_TRADE_ITEM { + trade_slot: 0, + bag: 19, + slot: 2, + }, + ); + assert!(store.items().is_empty()); +} + +/// The wire half of the full loop: A initiates, offers an item and accepts; B answers, offers gold +/// and accepts. Each seat's offer lands under its own seat. The swap itself (items, gold, +/// atomicity) is the Module's Trade Commit, tested there. #[test] -fn the_full_loop_dispatches_offer_and_dual_accept_in_order() { - let store = std::sync::Arc::new(quest_store()); - - let (mut a, mut a_enc, _a_dec, a_server) = enter_world(store.clone(), 1); - CMSG_INITIATE_TRADE { guid: Guid::new(2) } - .write_encrypted_client(&mut a, &mut a_enc) - .unwrap(); - CMSG_SET_TRADE_ITEM { - trade_slot: 0, - bag: 255, - slot: 23, - } - .write_encrypted_client(&mut a, &mut a_enc) - .unwrap(); - CMSG_ACCEPT_TRADE { unknown1: 0 } - .write_encrypted_client(&mut a, &mut a_enc) - .unwrap(); - drop(a); - a_server.join().unwrap(); - - let (mut b, mut b_enc, _b_dec, b_server) = enter_world(store.clone(), 2); - CMSG_BEGIN_TRADE {} - .write_encrypted_client(&mut b, &mut b_enc) - .unwrap(); - CMSG_SET_TRADE_GOLD { - gold: wow_world_messages::vanilla::Gold::new(500), - } - .write_encrypted_client(&mut b, &mut b_enc) - .unwrap(); - CMSG_ACCEPT_TRADE { unknown1: 0 } - .write_encrypted_client(&mut b, &mut b_enc) - .unwrap(); - drop(b); - b_server.join().unwrap(); - - assert_eq!(store.initiated_trades.lock().unwrap().as_slice(), &[(1, 2)]); - assert_eq!( - store.set_trade_items.lock().unwrap().as_slice(), - &[(1, 0, 23)] +fn the_full_loop_records_each_seats_offer_and_both_accepts() { + let store = TradeFake::default(); + + run(&store, 1, CMSG_INITIATE_TRADE { guid: Guid::new(2) }); + run( + &store, + 1, + CMSG_SET_TRADE_ITEM { + trade_slot: 0, + bag: 255, + slot: 23, + }, + ); + run(&store, 1, CMSG_ACCEPT_TRADE { unknown1: 0 }); + + run(&store, 2, CMSG_BEGIN_TRADE {}); + run( + &store, + 2, + CMSG_SET_TRADE_GOLD { + gold: wow_world_messages::vanilla::Gold::new(500), + }, ); + run(&store, 2, CMSG_ACCEPT_TRADE { unknown1: 0 }); + assert_eq!( - store.set_trade_golds.lock().unwrap().as_slice(), - &[(2, 500)] + store.phase(), + Phase::Open { + initiator: 1, + target: 2 + } ); - assert_eq!(store.accepted_trades.lock().unwrap().as_slice(), &[1, 2]); + assert_eq!(store.items(), [((1, 0), 23)]); + assert_eq!(store.gold(), [(2, 500)]); + assert_eq!(store.accepted(), [1, 2]); } -/// **AC: the accept-reset wire half**, after an accept, a further offer mutation and an -/// explicit unaccept both dispatch; the reset itself (both flags cleared, BackToTrade to both) -/// is the module's pure-tested rule. +/// After an accept, a further offer mutation and an explicit unaccept both reach the Store for the +/// acting seat. The reset itself (both flags cleared, `BackToTrade` to both) is the Module's rule. #[test] -fn unaccept_and_post_accept_mutations_dispatch_for_the_acting_seat() { - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, _c_dec, server) = enter_world(store.clone(), 1); - CMSG_ACCEPT_TRADE { unknown1: 0 } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - CMSG_SET_TRADE_GOLD { - gold: wow_world_messages::vanilla::Gold::new(9), - } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - CMSG_UNACCEPT_TRADE {} - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - drop(client); - server.join().unwrap(); - assert_eq!(store.accepted_trades.lock().unwrap().as_slice(), &[1]); - assert_eq!(store.set_trade_golds.lock().unwrap().as_slice(), &[(1, 9)]); - assert_eq!(store.unaccepted_trades.lock().unwrap().as_slice(), &[1]); -} - -/// **AC: the decline flow**, the proposed target's client answers a `BeginTrade` it -/// can't take with `CMSG_BUSY_TRADE` (already in a dialog) or `CMSG_IGNORE_TRADE` (initiator -/// ignored); each dispatches its own decline verb as the declining side. +fn unaccept_and_post_accept_mutations_reach_the_acting_seat() { + let store = TradeFake::default(); + run(&store, 1, CMSG_ACCEPT_TRADE { unknown1: 0 }); + assert_eq!(store.accepted(), [1]); + + run( + &store, + 1, + CMSG_SET_TRADE_GOLD { + gold: wow_world_messages::vanilla::Gold::new(9), + }, + ); + assert_eq!(store.gold(), [(1, 9)]); + + run(&store, 1, CMSG_UNACCEPT_TRADE {}); + assert!(store.accepted().is_empty()); +} + +/// The proposed target's client answers a `BeginTrade` it cannot take with `CMSG_BUSY_TRADE` +/// (already in a dialog) or `CMSG_IGNORE_TRADE` (initiator ignored). Each declines as the +/// declining side, with its own reason. #[test] -fn decline_opcodes_dispatch_their_own_verbs_for_the_declining_side() { - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, _c_dec, server) = enter_world(store.clone(), 2); - CMSG_BUSY_TRADE {} - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - CMSG_IGNORE_TRADE {} - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - drop(client); - server.join().unwrap(); - assert_eq!(store.busy_trades.lock().unwrap().as_slice(), &[2]); - assert_eq!(store.ignore_trades.lock().unwrap().as_slice(), &[2]); -} - -/// **AC: either side can cancel** — the initiator's own `CMSG_CANCEL_TRADE` dispatches as -/// themselves (the counterpart of the flow test's B-side cancel). +fn decline_opcodes_decline_with_their_own_reason_for_the_declining_side() { + for (reason, decline) in [ + (Decline::Busy, ClientOpcodeMessage::from(CMSG_BUSY_TRADE {})), + ( + Decline::IgnoresInitiator, + ClientOpcodeMessage::from(CMSG_IGNORE_TRADE {}), + ), + ] { + let store = TradeFake::default(); + run(&store, 1, CMSG_INITIATE_TRADE { guid: Guid::new(2) }); + run(&store, 2, decline); + assert_eq!(store.phase(), Phase::Declined { by: 2, reason }); + } +} + +/// Either side can cancel: the initiator's own `CMSG_CANCEL_TRADE` cancels as themselves, the +/// counterpart of the handshake test's cancel by the target. #[test] -fn cancel_trade_dispatches_for_the_initiating_side_too() { - let store = std::sync::Arc::new(quest_store()); - let (mut client, mut c_enc, _c_dec, server) = enter_world(store.clone(), 1); - CMSG_CANCEL_TRADE {} - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - drop(client); - server.join().unwrap(); - assert_eq!(store.cancelled_trades.lock().unwrap().as_slice(), &[1]); +fn cancel_trade_cancels_for_the_initiating_side_too() { + let store = TradeFake::default(); + run(&store, 1, CMSG_INITIATE_TRADE { guid: Guid::new(2) }); + run(&store, 1, CMSG_CANCEL_TRADE {}); + assert_eq!(store.phase(), Phase::Cancelled { by: 1 }); } diff --git a/gateway/src/world/trainer_tests.rs b/gateway/src/world/trainer_tests.rs new file mode 100644 index 000000000..653ec22a5 --- /dev/null +++ b/gateway/src/world/trainer_tests.rs @@ -0,0 +1,512 @@ +//! Trainer and talent opcodes, run through `handle_trainer` against a Fake that holds only the +//! Character, Npc and Trainer Stores the handler is bounded on. + +use super::handlers::{handle_trainer, CharacterStore, TrainerBuyOutcome, TrainerStore}; +use super::*; +use lyracore_shared::constants::armor_proficiency::{ + PLATE_PASSIVE_SPELL_ID, PLATE_TRAINER_SPELL_ID, +}; +use lyracore_shared::trainer::TrainerRefusal; + +/// What the Module would answer a trainer window, a buy and a talent pick, set per test. +#[derive(Default)] +struct TrainerFake { + npc_refuses: bool, + refuses_class: bool, + spells: Vec, + /// `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, + /// The known previous rank a buy of a non-stacking chain replaces. + superseded: Option, + /// The spellbook after the buy. + learned_spells: Vec, + /// `(level, class)` of the buying Character. + level_and_class: Option<(u8, u8)>, + talent_grant: u32, + /// `(rank spell taught, rank spell it replaces, points left)`. + talent_pane: (u32, u32, u32), +} + +impl TrainerFake { + fn buying_as(level: u8, class: u8) -> Self { + Self { + level_and_class: Some((level, class)), + ..Default::default() + } + } +} + +npc_store_refusing_by!(TrainerFake, npc_refuses); + +impl CharacterStore for TrainerFake { + fn player_learned_spells(&self, _player_guid: u64) -> Result> { + Ok(self.learned_spells.clone()) + } + + fn spell_modifiers(&self, _character_guid: u64) -> Vec<(u32, u8, i32, bool)> { + Vec::new() + } + + fn characters(&self, _account_id: u64) -> Result> { + unimplemented!("characters") + } + + fn create_character( + &self, + _account_id: u64, + _name: &str, + _race: u8, + _class: u8, + _gender: u8, + _appearance: codec::Appearance, + ) -> Result { + unimplemented!("create_character") + } + + fn delete_character( + &self, + _account_id: u64, + _character_guid: u64, + ) -> Result { + unimplemented!("delete_character") + } + + fn character_by_guid(&self, _guid: u64) -> Result> { + unimplemented!("character_by_guid") + } + + fn character_exists_on_any_world_shard(&self, _guid: u64) -> Result { + unimplemented!("character_exists_on_any_world_shard") + } + + fn player_skills(&self, _character_guid: u64) -> Result> { + unimplemented!("player_skills") + } + + fn effective_armor(&self, _guid: u64) -> u32 { + unimplemented!("effective_armor") + } + + fn effective_magic_resistances(&self, _guid: u64) -> [u32; 6] { + unimplemented!("effective_magic_resistances") + } + + fn player_reputations(&self, _player_guid: u64) -> Result> { + unimplemented!("player_reputations") + } + + fn player_actions(&self, _player_guid: u64) -> Result> { + unimplemented!("player_actions") + } +} + +impl TrainerStore for TrainerFake { + fn trainer_serves(&self, _player_guid: u64, _trainer_guid: u64) -> Result { + Ok(!self.refuses_class) + } + + fn trainer_list( + &self, + _player_guid: u64, + _trainer_guid: u64, + ) -> Result> { + Ok(self.spells.clone()) + } + + fn buy_trainer_spell( + &self, + _account_id: u64, + _self_guid: u64, + _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}")), + None => Ok(TrainerBuyOutcome::Learned), + } + } + + fn trainer_offer_skill_line(&self, _trainer_guid: u64, spell_id: u32) -> u32 { + self.skill_lines.get(&spell_id).copied().unwrap_or(0) + } + + fn talent_grant_spell(&self, _talent_id: u32) -> u32 { + self.talent_grant + } + + fn set_action_button( + &self, + _account_id: u64, + _self_guid: u64, + _button: u8, + _action: u32, + _action_type: u8, + ) -> Result<()> { + unimplemented!("set_action_button") + } + + fn set_faction_at_war( + &self, + _account_id: u64, + _self_guid: u64, + _reputation_index: u32, + _at_war: bool, + ) -> Result<()> { + unimplemented!("set_faction_at_war") + } + + fn talent_pane_sync(&self, _character_guid: u64, _talent_id: u32) -> (u32, u32, u32) { + self.talent_pane + } + + fn talent_points_spent(&self, _character_guid: u64) -> u32 { + unimplemented!("talent_points_spent") + } + + fn learn_talent(&self, _account_id: u64, _self_guid: u64, _talent_id: u32) -> Result<()> { + Ok(()) + } + + fn reset_talents(&self, _account_id: u64, _self_guid: u64, _trainer_guid: u64) -> Result<()> { + unimplemented!("reset_talents") + } + + fn resolve_learn_target(&self, spell_id: u32) -> u32 { + spell_id + } + + fn superseded_old_rank(&self, _new_spell: u32, _player_guid: u64) -> Option { + self.superseded + } + + fn character_presence(&self, _guid: u64) -> Result> { + Ok(self + .level_and_class + .map(|(level, class)| (true, level, class, 0))) + } +} + +/// Send `msg` as Character 1 of account 7. Returns the handler's verdict and what it sent, in order. +/// Phase 5 retargets only this helper. +fn drive( + store: &TrainerFake, + msg: impl Into, +) -> (Result<()>, Vec) { + let (tx, rx) = SessionTx::with_depth(0); + let mut conn = in_world_conn(7, 1); + let verdict = handle_trainer(&tx, store, &mut conn, msg.into()) + .map(|passed_on| assert!(passed_on.is_none(), "the trainer family owns this opcode")); + (verdict, drain_outbound(&rx)) +} + +fn run(store: &TrainerFake, msg: impl Into) -> Vec { + let (verdict, sent) = drive(store, msg); + verdict.unwrap(); + sent +} + +fn kinds(sent: &[ServerOpcodeMessage]) -> String { + sent.iter() + .map(ToString::to_string) + .collect::>() + .join(", ") +} + +fn buy(id: u32) -> CMSG_TRAINER_BUY_SPELL { + CMSG_TRAINER_BUY_SPELL { + guid: Guid::new(70), + id, + } +} + +fn pick_talent() -> CMSG_LEARN_TALENT { + CMSG_LEARN_TALENT { + talent: Talent::BurningSoul, + requested_rank: 0, + } +} + +fn one_spell() -> Vec { + vec![codec::TrainerSpellView { + spell_id: 100, + cost: 10, + required_level: 1, + player_level: 1, + known: false, + profession: false, + }] +} + +#[test] +fn trainer_buy_success_replies_succeeded_then_pushes_the_learned_spell() { + let sent = run(&TrainerFake::default(), buy(1234)); + let [ServerOpcodeMessage::SMSG_TRAINER_BUY_SUCCEEDED(bought), ServerOpcodeMessage::SMSG_LEARNED_SPELL(learned)] = + sent.as_slice() + else { + panic!( + "expected a buy confirmation then the learned spell, got [{}]", + kinds(&sent) + ); + }; + assert_eq!(bought.guid.guid(), 70); + assert_eq!(bought.id, 1234); + assert_eq!(learned.id, 1234); +} + +/// A RIDING purchase teaches a skill, not a spell. Its trainer-list id is a marker with no Spell.dbc +/// row, so the buy confirms and stops: a learned-spell push would hand the client an id it cannot +/// resolve. The skill pane moves from the `game_player_skill` relay. +#[test] +fn a_riding_buy_confirms_without_echoing_the_offering_as_a_learned_spell() { + let store = TrainerFake { + skill_lines: [(50132, lyracore_shared::trainer::RIDING_SKILL_LINE)].into(), + ..Default::default() + }; + let sent = run(&store, buy(50132)); + assert!( + matches!(sent.as_slice(), [ServerOpcodeMessage::SMSG_TRAINER_BUY_SUCCEEDED(m)] if m.id == 50132), + "a riding buy confirms and sends nothing more, got [{}]", + kinds(&sent) + ); +} + +/// A Warrior buying Plate Mail at 40. The book update alone leaves the client tinting every plate +/// piece red, so the buy also refreshes the ARMOR mask from the post-buy spellbook. +#[test] +fn a_proficiency_buy_pushes_the_refreshed_armor_mask_after_the_learned_spell() { + let store = TrainerFake { + learned_spells: vec![PLATE_PASSIVE_SPELL_ID], + ..TrainerFake::buying_as(40, 1) + }; + let sent = run(&store, buy(PLATE_TRAINER_SPELL_ID)); + let [ServerOpcodeMessage::SMSG_TRAINER_BUY_SUCCEEDED(bought), ServerOpcodeMessage::SMSG_LEARNED_SPELL(learned), ServerOpcodeMessage::SMSG_SET_PROFICIENCY(mask)] = + sent.as_slice() + else { + panic!( + "expected confirmation, learned spell, proficiency mask, got [{}]", + kinds(&sent) + ); + }; + assert_eq!(bought.id, 7109); + assert_eq!(learned.id, 7109); + assert_eq!( + mask.class.as_int(), + 4, + "a proficiency buy refreshes ARMOR only" + ); + assert!( + mask.item_sub_class_mask & (1 << lyracore_shared::item::armor_subclass::PLATE) != 0, + "the trained plate bit must be set: {:#x}", + mask.item_sub_class_mask + ); +} + +/// An ordinary ability purchase changes nothing about what the Character may wear, so it must not +/// resend a proficiency mask. +#[test] +fn an_ordinary_trainer_buy_pushes_no_proficiency_mask() { + let sent = run(&TrainerFake::buying_as(40, 1), buy(1234)); + assert!( + matches!( + sent.as_slice(), + [ + ServerOpcodeMessage::SMSG_TRAINER_BUY_SUCCEEDED(_), + ServerOpcodeMessage::SMSG_LEARNED_SPELL(learned) + ] if learned.id == 1234 + ), + "expected only the confirmation and the learned spell, got [{}]", + kinds(&sent) + ); +} + +/// A buy whose previous rank is known sends SMSG_SUPERCEDED_SPELL, so the client replaces the old +/// rank's book entry. The first wire slot carries the OLD rank (cmangos order), as the talent +/// rank-upgrade path does. +#[test] +fn trainer_buy_rank_upgrade_supersedes_the_previous_rank_spell() { + let store = TrainerFake { + superseded: Some(1233), + ..Default::default() + }; + let sent = run(&store, buy(1234)); + let [ServerOpcodeMessage::SMSG_TRAINER_BUY_SUCCEEDED(bought), ServerOpcodeMessage::SMSG_SUPERCEDED_SPELL(replaced)] = + sent.as_slice() + else { + panic!( + "expected a buy confirmation then a superseded spell, got [{}]", + kinds(&sent) + ); + }; + assert_eq!(bought.guid.guid(), 70); + assert_eq!(bought.id, 1234); + assert_eq!( + replaced.new_spell_id, 1233, + "first wire slot carries the OLD rank" + ); + assert_eq!( + replaced.old_spell_id, 1234, + "second wire slot carries the NEW rank" + ); +} + +#[test] +fn every_module_refusal_has_one_client_failure_reason() { + let expected = |refusal| match refusal { + TrainerRefusal::NotEnoughMoney => TrainingFailureReason::NotEnoughMoney, + TrainerRefusal::LevelTooLow | TrainerRefusal::PreviousRankMissing => { + TrainingFailureReason::NotEnoughSkill + } + TrainerRefusal::Unavailable | TrainerRefusal::NotOffered | TrainerRefusal::AlreadyKnown => { + TrainingFailureReason::Unavailable + } + }; + for refusal in TrainerRefusal::ALL { + let store = TrainerFake { + buy_refusal: Some(refusal), + ..Default::default() + }; + let sent = run(&store, buy(1234)); + let [ServerOpcodeMessage::SMSG_TRAINER_BUY_FAILED(failed)] = sent.as_slice() else { + panic!("{refusal:?}: expected one failure, got [{}]", kinds(&sent)); + }; + assert_eq!(failed.error, expected(refusal), "{refusal:?}"); + assert_eq!(failed.id, 1234); + } +} + +/// A reducer timeout 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() { + let store = TrainerFake { + buy_error: Some("gw_trainer_buy reducer timed out after 10s".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")); + assert!( + sent.is_empty(), + "nothing claims the purchase failed: [{}]", + kinds(&sent) + ); +} + +/// An ability talent (`grant_spell_id != 0`) pushes the granted spell so the new button works +/// without a relog, then the points push. +#[test] +fn learn_talent_with_a_grant_spell_pushes_learned_spell() { + let store = TrainerFake { + talent_grant: 2098, + ..Default::default() + }; + let sent = run(&store, pick_talent()); + assert!( + matches!( + sent.as_slice(), + [ + ServerOpcodeMessage::SMSG_LEARNED_SPELL(granted), + ServerOpcodeMessage::SMSG_UPDATE_OBJECT(_) + ] if granted.id == 2098 + ), + "expected the granted spell then the CHARACTER_POINTS1 push, got [{}]", + kinds(&sent) + ); +} + +/// A passive pick still refreshes the TalentFrame: SMSG_LEARNED_SPELL for the rank spell the Module +/// taught (the pane derives shown ranks from known rank spells), then the points push. +#[test] +fn learn_talent_passive_pushes_rank_spell_and_points() { + let store = TrainerFake { + talent_pane: (7777, 0, 2), + ..Default::default() + }; + let sent = run(&store, pick_talent()); + assert!( + matches!( + sent.as_slice(), + [ + ServerOpcodeMessage::SMSG_LEARNED_SPELL(rank), + ServerOpcodeMessage::SMSG_UPDATE_OBJECT(_) + ] if rank.id == 7777 + ), + "expected the rank spell then the CHARACTER_POINTS1 push, got [{}]", + kinds(&sent) + ); +} + +/// Rank N>1 replaces the previous rank's spell in the book. SMSG_SUPERCEDED_SPELL carries the OLD +/// rank in its first wire slot (cmangos order), as the trainer rank-upgrade path does. +#[test] +fn learn_talent_rank_upgrade_supersedes_the_previous_rank_spell() { + let store = TrainerFake { + talent_pane: (7778, 7777, 1), + ..Default::default() + }; + let sent = run(&store, pick_talent()); + let [ServerOpcodeMessage::SMSG_SUPERCEDED_SPELL(replaced), ServerOpcodeMessage::SMSG_UPDATE_OBJECT(_)] = + sent.as_slice() + else { + panic!( + "expected a superseded spell then the points push, got [{}]", + kinds(&sent) + ); + }; + assert_eq!( + replaced.new_spell_id, 7777, + "first wire slot carries the OLD rank" + ); + assert_eq!( + replaced.old_spell_id, 7778, + "second wire slot carries the NEW rank" + ); +} + +#[test] +fn trainer_list_replies_smsg_trainer_list_with_the_fixture_spells() { + let store = TrainerFake { + spells: one_spell(), + ..Default::default() + }; + let sent = run( + &store, + CMSG_TRAINER_LIST { + guid: Guid::new(70), + }, + ); + let [ServerOpcodeMessage::SMSG_TRAINER_LIST(list)] = sent.as_slice() else { + panic!("expected one trainer list, got [{}]", kinds(&sent)); + }; + assert_eq!(list.guid, Guid::new(70)); + assert_eq!(list.spells.len(), 1, "the fixture's one spell row"); + assert_eq!(list.spells[0].spell, 100); +} + +/// The class gate removes the training service, not the creature: the NPC still talks, which the +/// gossip test `gossip_hides_the_train_and_unlearn_options_for_a_class_the_trainer_does_not_serve` covers. +#[test] +fn trainer_list_is_silently_dropped_for_a_player_the_trainer_does_not_serve() { + let store = TrainerFake { + refuses_class: true, + spells: one_spell(), + ..Default::default() + }; + let sent = run( + &store, + CMSG_TRAINER_LIST { + guid: Guid::new(70), + }, + ); + assert!( + sent.is_empty(), + "a trainer that does not serve this class sends no window, got [{}]", + kinds(&sent) + ); +} diff --git a/gateway/src/world/transfer.rs b/gateway/src/world/transfer.rs index 07de5fb50..b50d33165 100644 --- a/gateway/src/world/transfer.rs +++ b/gateway/src/world/transfer.rs @@ -2,7 +2,127 @@ use anyhow::{anyhow, Result}; -use super::WorldStore; +use super::{party, WorldStore}; + +/// The escrowed cross-shard Transfer steps, and the instance lease a crossing needs. +pub(crate) trait TransferStore: Send + Sync { + /// The escrow row this shard holds for `character_guid`, if any — the transfer's identity, its + /// destination and the serialized character. `None` = not mid-transfer here. + fn escrowed_transfer(&self, character_guid: u64) -> Option; + + /// Where this shard's durable row says the character is going (`world::teleport_player` wrote + /// 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<()>; + + /// 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. + fn import_character_blob( + &self, + transfer_id: u64, + blob: &[u8], + source: RealmLocatorPredecessor, + bot_arrival: Option<&BotTransferIntent>, + ) -> Result<()>; + + /// `confirm_import` — attest, on the SOURCE, that the destination copy is durable. + fn confirm_import(&self, transfer_id: u64) -> Result<()>; + + /// `finish_transfer` — delete-last: destroy the source copy and clear the escrow. + fn finish_transfer(&self, transfer_id: u64) -> Result<()>; + + /// `release_transfer` — drop a migrated blank arrival fence. New human and session-less + /// arrivals refuse because their exact crossing-specific reducer owns release. + fn release_transfer(&self, transfer_id: u64) -> Result<()>; + + /// Release one exact human arrival fence. + fn release_player_transfer_arrival( + &self, + transfer_id: u64, + character_guid: u64, + source: RealmLocatorPredecessor, + ) -> Result<()>; + + /// Exact destination fence identity used by the Realm locator recovery compare-and-set. + fn transfer_arrival(&self, transfer_id: u64) -> Option; + + /// Reconcile the arriving Character's authoritative party mirror before its destination fence + /// drops. A single-database store and a test store without realm-wide parties have no work. + fn sync_transfer_arrival(&self, character_guid: u64) -> Result<()>; + + /// Publish Realm's pending partition before the source is frozen. + fn sync_transfer_pending(&self, character_guid: u64) -> Result<()>; + + /// Record Realm-core's pending phase before the source is frozen and return its predecessor + /// locator revision. Single-database stores have no remote phase. + fn begin_shard_index_transfer( + &self, + plan: &TransferPlan, + bot_intent: Option<(&BotTransferIntent, u64)>, + ) -> Result; + + /// Settle a player crossing against the predecessor returned by + /// [`begin_shard_index_transfer`](Self::begin_shard_index_transfer). + fn finish_player_shard_index_transfer( + &self, + plan: &TransferPlan, + source_map: u32, + source_instance: u64, + source_revision: u64, + ) -> Result<()>; + + /// Resume a Realm pending phase from the destination Character after source finish. + fn finish_pending_shard_index_transfer( + &self, + character_guid: u64, + destination_map: u32, + destination_instance: u64, + arrival: &TransferArrival, + ) -> Result<()>; + + /// Bind this claimed intent to the Realm locator it is about to move from. + fn bind_bot_transfer_locator( + &self, + intent: &BotTransferIntent, + source_revision: u64, + claim_token: u64, + ) -> Result<()>; + + /// Compare-and-set Realm-core's locator for one exact session-less crossing. + fn publish_bot_shard_index(&self, intent: &BotTransferIntent) -> Result<()>; + + /// The source-side lease for a named instance: its map and owning party. Portal admission + /// creates this before a cross-Shard Transfer, so it remains the authority if party membership + /// changes while the Character is in Escrow. + fn instance_partition(&self, instance_id: u64) -> Option<(u32, u64)>; + + /// `ensure_instance` — mirror an instance id onto this shard, spawning its population once. + fn ensure_instance(&self, instance_id: u64, map_id: u32, party_id: u64) -> Result<()>; + + /// `evict_instance_population` — drop an instance's population here, keeping the lease row. + fn evict_instance_population(&self, instance_id: u64) -> Result<()>; + + /// Persist the exact claimed intent's destination-ready witness before release. + fn mark_bot_transfer_arrival_ready( + &self, + intent_id: u64, + bot_guid: u64, + controller_generation: u64, + claim_token: u64, + ) -> Result<()>; + + /// Whether the current destination fence belongs to this exact source intent. + fn bot_transfer_arrival_matches(&self, transfer_id: u64, intent: &BotTransferIntent) -> bool; + + /// Release only the destination fence identified by this intent. A newer fence is untouched. + fn release_bot_transfer_arrival( + &self, + transfer_id: u64, + intent: &BotTransferIntent, + ) -> Result<()>; +} /// Where a character is going. Derived from the character's own durable row on the source shard — /// `world::teleport_player` writes the DESTINATION map/instance/position there before it despawns diff --git a/gateway/src/world/transfer_tests.rs b/gateway/src/world/transfer_tests.rs index b51bcd5a9..1f63fbdab 100644 --- a/gateway/src/world/transfer_tests.rs +++ b/gateway/src/world/transfer_tests.rs @@ -1,4 +1,4 @@ -//! Cross-database Transfer tests through the shared InMemoryStore topology. +//! Cross-database Transfer tests through the shared WorldFake topology. use super::shard_routing_tests::{drive_routed_session, sharded_stores, ShardCallLog}; use super::*; @@ -42,12 +42,18 @@ fn xstore( db: std::sync::Arc, calls: ShardCallLog, kill_at: Option<&str>, -) -> std::sync::Arc { - std::sync::Arc::new(InMemoryStore { - shard: shard.into(), - calls, - xdb: Some(db), - kill_at: kill_at.map(|s| s.to_string()), +) -> std::sync::Arc { + std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: shard.into(), + calls, + ..Default::default() + }, + transfer: TransferState { + xdb: Some(db), + kill_at: kill_at.map(|s| s.to_string()), + ..Default::default() + }, ..Default::default() }) } @@ -61,8 +67,8 @@ const XGUID: u64 = 1; fn xdb_pair( kill_at: Option<&str>, ) -> ( - std::sync::Arc, - std::sync::Arc, + std::sync::Arc, + std::sync::Arc, std::sync::Arc, std::sync::Arc, ShardCallLog, @@ -194,7 +200,8 @@ fn a_character_moves_whole_between_two_databases_with_its_rows() { fn an_escrow_row_that_lags_the_reducer_reply_is_waited_for_not_refused() { no_hang(30, || { let (src, dst, src_db, dst_db, _) = xdb_pair(None); - src.escrow_reads_before_visible + src.transfer + .escrow_reads_before_visible .store(3, std::sync::atomic::Ordering::SeqCst); super::transfer::settle_transfer(src.as_ref(), dst.as_ref(), XGUID) .expect("a late escrow row is lag, not a missing escrow"); @@ -203,7 +210,8 @@ fn an_escrow_row_that_lags_the_reducer_reply_is_waited_for_not_refused() { "the character must arrive whole once the row shows up" ); assert_eq!( - src.escrow_reads_before_visible + src.transfer + .escrow_reads_before_visible .load(std::sync::atomic::Ordering::SeqCst), 0, "the driver must have read past every lagging answer" @@ -216,7 +224,7 @@ fn an_escrow_row_that_lags_the_reducer_reply_is_waited_for_not_refused() { /// /// Before this, `set_character_shard` had exactly one caller in the whole gateway — the login /// self-heal — so a completed cross-database transfer updated the SOURCE database's copy of the -/// index (transactionally, inside `finish_transfer`) and nothing else. The copy `home_shard` +/// index (transactionally, inside `finish_transfer`) and nothing else. The copy world entry /// actually reads is realm-core's, and it learned about the move at the character's next login, by /// probing every shard. The requirement that realm-core's index be correct without relying on that /// probe was unmet, and looked correct only because the probe masked it. @@ -227,7 +235,7 @@ fn a_completed_transfer_publishes_the_destination_to_the_realm_core_index() { super::transfer::settle_transfer(src.as_ref(), dst.as_ref(), XGUID) .expect("transfer completes"); assert_eq!( - *src.realm_index.lock().unwrap(), + *src.transfer.realm_index.lock().unwrap(), vec![(XGUID, 36, 7)], "the drive settled the character on map 36 / instance 7 and told realm-core nothing. \ Without this write the index is only ever corrected by the login self-heal, so every \ @@ -258,7 +266,7 @@ fn a_resumed_transfer_publishes_the_escrow_destination_not_the_callers_plan() { super::transfer::run_transfer_injected(src.as_ref(), dst.as_ref(), &stale, None) .expect("the drive completes against the escrow on disk"); assert_eq!( - *src.realm_index.lock().unwrap(), + *src.transfer.realm_index.lock().unwrap(), vec![(XGUID, 36, 7)], "the index was published from the DRIVER'S PLAN instead of the escrow out-row. The plan \ is whatever the caller happened to hand in; the escrow is what `finish_transfer` just \ @@ -285,11 +293,17 @@ fn a_transfer_whose_index_publish_fails_does_not_report_success() { }, ); let dst_db = FakeShardDb::empty(); - let src = std::sync::Arc::new(InMemoryStore { - shard: "world".into(), - calls: calls.clone(), - xdb: Some(src_db.clone()), - publish_error: Some("realm-core database lyracore-realm is not connected".into()), + let src = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "world".into(), + calls: calls.clone(), + ..Default::default() + }, + transfer: TransferState { + xdb: Some(src_db.clone()), + publish_error: Some("realm-core database lyracore-realm is not connected".into()), + ..Default::default() + }, ..Default::default() }); let dst = xstore("instances", dst_db.clone(), calls.clone(), None); @@ -825,20 +839,26 @@ fn a_failed_transfer_fails_the_login_instead_of_entering_the_world_anyway() { // only refusing is honest — and entering anyway is how a character ends up live on the shard // that is about to have its copy destroyed. let (store, _) = sharded_stores(); - let failing = std::sync::Arc::new(InMemoryStore { - shard: "world".into(), - username: "TESTER".into(), - session: Some(WorldSession { - account_id: 7, - session_key: K, - }), + let failing = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "world".into(), + settle_error: Some("instances shard unreachable".into()), + ..Default::default() + }, + session: SessionState { + username: "TESTER".into(), + session: Some(WorldSession { + account_id: 7, + session_key: K, + }), + login_entity: Some(warrior_entity()), + ..Default::default() + }, characters: store.characters.clone(), - login_entity: Some(warrior_entity()), - settle_error: Some("instances shard unreachable".into()), ..Default::default() }); let (mut client, server_end) = world_session_socket_pair(); - let server = std::thread::spawn(move || run_world_session(server_end, failing.as_ref())); + let server = std::thread::spawn(move || run_world_session(server_end, failing.clone())); 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) @@ -862,12 +882,13 @@ fn entering_the_world_binds_this_accounts_identity_on_the_shard_it_landed_on() { // database the logon tier never touched. let (store, calls) = sharded_stores(); let home = store + .topology .home .clone() .expect("the fixture routes to a home shard"); let _ = drive_routed_session(store, calls.clone()); assert_eq!( - *home.bound_sessions.lock().unwrap(), + *home.topology.bound_sessions.lock().unwrap(), vec![7], "the home shard must have this account's identity bound before player_login runs" ); @@ -965,7 +986,7 @@ fn bot_pair( dest_map: u32, dest_instance: u64, ) -> ( - std::sync::Arc, + std::sync::Arc, std::sync::Arc, std::sync::Arc, ShardCallLog, @@ -982,22 +1003,28 @@ fn bot_pair( let dst_db = FakeShardDb::empty(); let dst = xstore("instances", dst_db.clone(), calls.clone(), None); let source_partition = if dest_map == 0 { (36, 7) } else { (0, 0) }; - let src = std::sync::Arc::new(InMemoryStore { - shard: "world".into(), - calls: calls.clone(), - xdb: Some(src_db.clone()), - location_shard: Some((dest_map, dest_instance, dst)), - realm_partition: std::sync::Mutex::new(Some(super::party::RealmCharacterPartition { - map_id: source_partition.0, - instance_id: source_partition.1, - revision: 3, - transfer_pending: false, - pending_destination_map: 0, - pending_destination_instance: 0, - bot_source_identity: spacetimedb_sdk::Identity::ZERO, - bot_transfer_intent_id: 0, - bot_controller_generation: 0, - })), + let src = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "world".into(), + calls: calls.clone(), + location_shard: Some((dest_map, dest_instance, dst)), + ..Default::default() + }, + transfer: TransferState { + xdb: Some(src_db.clone()), + realm_partition: std::sync::Mutex::new(Some(super::party::RealmCharacterPartition { + map_id: source_partition.0, + instance_id: source_partition.1, + revision: 3, + transfer_pending: false, + pending_destination_map: 0, + pending_destination_instance: 0, + bot_source_identity: spacetimedb_sdk::Identity::ZERO, + bot_transfer_intent_id: 0, + bot_controller_generation: 0, + })), + ..Default::default() + }, ..Default::default() }); (src, src_db, dst_db, calls) @@ -1036,7 +1063,7 @@ fn a_durable_intent_resumes_from_destination_witnesses_after_source_finish() { let first = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { super::transfer::run_transfer_injected_for_intent( src.as_ref(), - src.location_shard.as_ref().unwrap().2.as_ref(), + src.topology.location_shard.as_ref().unwrap().2.as_ref(), &plan, Some("finish_transfer"), Some((&intent, 701)), @@ -1135,7 +1162,7 @@ fn a_bot_abort_at_every_transfer_step_recovers_from_durable_witnesses() { fn the_source_instance_lease_survives_a_leave_and_rejoin_during_escrow() { let (src, src_db, dst_db, _calls) = bot_pair(36, 7); lk(&src_db.instance_partitions).insert(7, (36, 77)); - lk(&src.mirror).push(super::party::GroupRoster { + lk(&src.party.mirror).push(super::party::GroupRoster { group_id: 88, roster_revision: 3, leader_guid: BOT_GUID, @@ -1172,21 +1199,27 @@ fn a_same_shard_intent_resumes_after_the_realm_locator_settled() { payload: "gear+spells".into(), }, ); - let holder = InMemoryStore { - shard: "instances".into(), - calls: calls.clone(), - xdb: Some(db), - realm_partition: std::sync::Mutex::new(Some(super::party::RealmCharacterPartition { - map_id: 0, - instance_id: 0, - revision: 3, - transfer_pending: false, - pending_destination_map: 0, - pending_destination_instance: 0, - bot_source_identity: spacetimedb_sdk::Identity::ZERO, - bot_transfer_intent_id: 0, - bot_controller_generation: 0, - })), + let holder = WorldFake { + topology: TopologyState { + shard: "instances".into(), + calls: calls.clone(), + ..Default::default() + }, + transfer: TransferState { + xdb: Some(db), + realm_partition: std::sync::Mutex::new(Some(super::party::RealmCharacterPartition { + map_id: 0, + instance_id: 0, + revision: 3, + transfer_pending: false, + pending_destination_map: 0, + pending_destination_instance: 0, + bot_source_identity: spacetimedb_sdk::Identity::ZERO, + bot_transfer_intent_id: 0, + bot_controller_generation: 0, + })), + ..Default::default() + }, ..Default::default() }; let intent = bot_intent(); @@ -1237,11 +1270,17 @@ fn an_old_bound_worker_cannot_mark_a_newer_realm_locator_pending() { bot_transfer_intent_id: 93, bot_controller_generation: 6, }; - let holder = InMemoryStore { - shard: "world".into(), - calls: calls.clone(), - xdb: Some(db), - realm_partition: std::sync::Mutex::new(Some(newer)), + let holder = WorldFake { + topology: TopologyState { + shard: "world".into(), + calls: calls.clone(), + ..Default::default() + }, + transfer: TransferState { + xdb: Some(db), + realm_partition: std::sync::Mutex::new(Some(newer)), + ..Default::default() + }, ..Default::default() }; @@ -1251,7 +1290,10 @@ fn an_old_bound_worker_cannot_mark_a_newer_realm_locator_pending() { refusal.to_string().contains("locator changed"), "{refusal:#}" ); - assert_eq!(*holder.realm_partition.lock().unwrap(), Some(newer)); + assert_eq!( + *holder.transfer.realm_partition.lock().unwrap(), + Some(newer) + ); assert!( !calls .lock() @@ -1286,11 +1328,17 @@ fn a_human_arrival_cannot_settle_a_later_same_destination_crossing() { bot_transfer_intent_id: 0, bot_controller_generation: 0, }; - let destination = InMemoryStore { - shard: "instances".into(), - calls, - xdb: Some(db.clone()), - realm_partition: std::sync::Mutex::new(Some(later)), + let destination = WorldFake { + topology: TopologyState { + shard: "instances".into(), + calls, + ..Default::default() + }, + transfer: TransferState { + xdb: Some(db.clone()), + realm_partition: std::sync::Mutex::new(Some(later)), + ..Default::default() + }, ..Default::default() }; @@ -1302,7 +1350,10 @@ fn a_human_arrival_cannot_settle_a_later_same_destination_crossing() { .contains("pending Realm Transfer phase changed"), "{refusal:#}" ); - assert_eq!(*destination.realm_partition.lock().unwrap(), Some(later)); + assert_eq!( + *destination.transfer.realm_partition.lock().unwrap(), + Some(later) + ); assert_eq!(lk(&db.in_rows).get(&BOT_GUID), Some(&BOT_GUID)); lk(&db.arrival_sources).insert(BOT_GUID, (0, 0, 3)); @@ -1310,6 +1361,7 @@ fn a_human_arrival_cannot_settle_a_later_same_destination_crossing() { .expect("the arrival carrying the exact later predecessor settles and releases"); assert_eq!( destination + .transfer .realm_partition .lock() .unwrap() @@ -1334,10 +1386,16 @@ fn an_old_human_worker_cannot_release_a_newer_arrival_fence() { ); lk(&db.in_rows).insert(BOT_GUID, BOT_GUID); lk(&db.arrival_sources).insert(BOT_GUID, (0, 0, 3)); - let destination = InMemoryStore { - shard: "instances".into(), - calls, - xdb: Some(db.clone()), + let destination = WorldFake { + topology: TopologyState { + shard: "instances".into(), + calls, + ..Default::default() + }, + transfer: TransferState { + xdb: Some(db.clone()), + ..Default::default() + }, ..Default::default() }; @@ -1403,9 +1461,15 @@ fn a_ready_local_intent_releases_its_exact_arrival_fence() { intent.created_micros, ), ); - let holder = InMemoryStore { - shard: "instances".into(), - xdb: Some(db.clone()), + let holder = WorldFake { + topology: TopologyState { + shard: "instances".into(), + ..Default::default() + }, + transfer: TransferState { + xdb: Some(db.clone()), + ..Default::default() + }, ..Default::default() }; @@ -1427,9 +1491,15 @@ fn a_ready_intent_keeps_retrying_when_the_local_character_is_elsewhere() { payload: "gear+spells".into(), }, ); - let holder = InMemoryStore { - shard: "world".into(), - xdb: Some(db.clone()), + let holder = WorldFake { + topology: TopologyState { + shard: "world".into(), + ..Default::default() + }, + transfer: TransferState { + xdb: Some(db.clone()), + ..Default::default() + }, ..Default::default() }; let ready = super::transfer::BotTransferIntent { @@ -1441,7 +1511,7 @@ fn a_ready_intent_keeps_retrying_when_the_local_character_is_elsewhere() { .expect_err("an unrelated local Character cannot prove the destination released"); assert!(error.to_string().contains("cannot resolve its destination")); - assert!(holder.calls.lock().unwrap().is_empty()); + assert!(holder.topology.calls.lock().unwrap().is_empty()); assert_eq!(db.get(BOT_GUID).unwrap().map_id, 0); } @@ -1449,7 +1519,7 @@ fn a_ready_intent_keeps_retrying_when_the_local_character_is_elsewhere() { fn an_unbound_intent_waits_for_configured_realm_core() { let (src, src_db, dst_db, calls) = bot_pair(36, 7); let mut holder = std::sync::Arc::try_unwrap(src).ok().unwrap(); - holder.transfer_realm_error = Some("configured Realm-core is unavailable".into()); + holder.topology.transfer_realm_error = Some("configured Realm-core is unavailable".into()); let intent = super::transfer::BotTransferIntent { source_locator_revision: 0, ..bot_intent() @@ -1687,10 +1757,16 @@ fn a_destination_this_shard_already_serves_is_a_completed_crossing() { fn the_bots_arrival_fence_survives_a_party_mirror_failure_and_retry() { use super::party_tests::{character, GINGER}; let calls: ShardCallLog = Default::default(); - let realm = std::sync::Arc::new(InMemoryStore { - shard: "lyracore-realm".into(), - calls: calls.clone(), - is_realm: true, + let realm = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "lyracore-realm".into(), + calls: calls.clone(), + ..Default::default() + }, + party: PartyState { + is_realm: true, + ..Default::default() + }, ..Default::default() }); let src_db = FakeShardDb::with_character( @@ -1702,38 +1778,56 @@ fn the_bots_arrival_fence_survives_a_party_mirror_failure_and_retry() { }, ); let dst_db = FakeShardDb::empty(); - let instances = std::sync::Arc::new(InMemoryStore { - shard: "instances".into(), - calls: calls.clone(), - xdb: Some(dst_db.clone()), - realm: Some(realm.clone()), + let instances = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "instances".into(), + calls: calls.clone(), + realm: Some(realm.clone()), + ..Default::default() + }, + transfer: TransferState { + xdb: Some(dst_db.clone()), + ..Default::default() + }, ..Default::default() }); - let world = std::sync::Arc::new(InMemoryStore { - shard: "world".into(), - calls: calls.clone(), - xdb: Some(src_db.clone()), - realm: Some(realm.clone()), - location_shard: Some((36, 7, instances.clone())), - realm_partition: std::sync::Mutex::new(Some(super::party::RealmCharacterPartition { - map_id: 0, - instance_id: 0, - revision: 3, - transfer_pending: false, - pending_destination_map: 0, - pending_destination_instance: 0, - bot_source_identity: spacetimedb_sdk::Identity::ZERO, - bot_transfer_intent_id: 0, - bot_controller_generation: 0, - })), + let world = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "world".into(), + calls: calls.clone(), + realm: Some(realm.clone()), + location_shard: Some((36, 7, instances.clone())), + ..Default::default() + }, + transfer: TransferState { + xdb: Some(src_db.clone()), + realm_partition: std::sync::Mutex::new(Some(super::party::RealmCharacterPartition { + map_id: 0, + instance_id: 0, + revision: 3, + transfer_pending: false, + pending_destination_map: 0, + pending_destination_instance: 0, + bot_source_identity: spacetimedb_sdk::Identity::ZERO, + bot_transfer_intent_id: 0, + bot_controller_generation: 0, + })), + ..Default::default() + }, characters: vec![character(GINGER, "Ginger"), character(BOT_GUID, "Botty")], - // The production shape of a playerbot: a live entity that never logged in. - live_guids: vec![GINGER, BOT_GUID], - offline_guids: vec![BOT_GUID], + session: SessionState { + // The production shape of a playerbot: a live entity that never logged in. + live_guids: vec![GINGER, BOT_GUID], + ..Default::default() + }, + social: SocialState { + offline_guids: vec![BOT_GUID], + ..Default::default() + }, ..Default::default() }); for shard in [&world, &instances] { - *shard.peers.lock().unwrap() = vec![world.clone(), instances.clone()]; + *shard.topology.peers.lock().unwrap() = vec![world.clone(), instances.clone()]; } super::party::run( world.as_ref(), @@ -1774,6 +1868,7 @@ fn the_bots_arrival_fence_survives_a_party_mirror_failure_and_retry() { "the source is finished while the destination arrival remains fenced" ); instances + .party .mirror_failures .store(1, std::sync::atomic::Ordering::SeqCst); let first = super::transfer::run_bot_transfer_intent(world.as_ref(), &intent, 702) diff --git a/gateway/src/world/vendor_tests.rs b/gateway/src/world/vendor_tests.rs new file mode 100644 index 000000000..a83f741e9 --- /dev/null +++ b/gateway/src/world/vendor_tests.rs @@ -0,0 +1,129 @@ +//! Vendor opcodes through their dispatcher, and the buyback ring at login. + +use super::handlers::InMemoryVendorActions; +use super::*; + +const PLAYER: VendorActionPlayer = VendorActionPlayer { + account_id: 7, + self_guid: Some(1), +}; + +/// Dispatch one vendor message and return the packets the session would send for it. +fn run(actions: &InMemoryVendorActions, msg: impl Into) -> Vec { + match dispatch_vendor_action(actions, PLAYER, msg.into()).unwrap() { + VendorActionOutcome::Handled { outbound } => outbound, + VendorActionOutcome::PassThrough(_) => { + panic!("the vendor dispatcher passed the message on") + } + } +} + +#[test] +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()), + ..Default::default() + }; + let sent = run( + &actions, + CMSG_BUY_ITEM { + vendor: Guid::new(99), + item: 1234, + amount: 1, + unknown1: 1, + }, + ); + match sent.as_slice() { + [Outbound::One(ServerOpcodeMessage::SMSG_BUY_FAILED(p))] => { + assert_eq!(p.guid.guid(), 99, "vendor guid echoed back"); + assert_eq!(p.item, 1234, "item entry echoed back"); + assert!( + matches!(p.result, BuyResult::NotEnoughMoney), + "BuyResult maps to NotEnoughMoney" + ); + } + other => panic!("expected one SMSG_BUY_FAILED, got {} packets", other.len()), + } +} + +#[test] +fn login_replays_a_persisted_buyback_ring_after_the_login_sequence() { + // The ring survives logout, so world entry rebuilds the tab: one fabricated item CREATE per + // entry, then the raw descriptor update. (An EMPTY ring emits nothing — every other login test + // reads the login sequence and then EOF, which is that case.) This stays a socket test because + // the replay's place in the world entry sequence is the fact under test. + let store = std::sync::Arc::new({ + let base = quest_store(); + WorldFake { + vendor: VendorState { + buyback_ring: vec![(2589, 5, 120, 0), (4540, 1, 30, 0)], + ..base.vendor + }, + ..base + } + }); + let (mut client, _c_enc, mut c_dec, server) = enter_world(store.clone(), 1); + for _ in 0..2 { + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + ServerOpcodeMessage::SMSG_UPDATE_OBJECT(_) => {} + other => panic!("expected a fabricated buyback item CREATE, got {other}"), + } + } + // The descriptor update is a hand-rolled partial VALUES mask gtker cannot decode; the frame is + // consumed either way, and EOF after it proves nothing else was sent. + let _ = ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec); + assert!(ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).is_err()); + drop(client); + server.join().unwrap(); +} + +#[test] +fn buyback_maps_the_wire_slot_enum_to_zero_based_ring_slots() { + // BuybackSlot rides as 69..=81 on the wire; the ring is 0-based, so Slot1 (69) takes entry 0 + // and Slot13 (81) takes entry 12. The ring holds 13 entries so both ends name a real one, and + // the later slot goes first so the earlier take does not shift it. + let ring: Vec<(u32, u32, u32, u32)> = (0..13).map(|i| (100 + i, 1, 10, 0)).collect(); + let actions = InMemoryVendorActions { + ring: std::sync::Mutex::new(ring.clone()), + ..Default::default() + }; + for slot in [BuybackSlot::Slot13, BuybackSlot::Slot1] { + run( + &actions, + CMSG_BUYBACK_ITEM { + guid: Guid::new(99), + slot, + }, + ); + } + assert_eq!(actions.ring.lock().unwrap().as_slice(), &ring[1..12]); +} + +#[test] +fn list_inventory_opens_the_vendor_window() { + let actions = InMemoryVendorActions { + stock: vec![codec::VendorItemView { + item_entry: 4540, + display_id: 6353, + buy_price: 25, + ..Default::default() + }], + ..Default::default() + }; + let sent = run( + &actions, + CMSG_LIST_INVENTORY { + guid: Guid::new(80), + }, + ); + match sent.as_slice() { + [Outbound::Raw { opcode, body }] => { + assert_eq!(*opcode, codec::SMSG_LIST_INVENTORY_OPCODE); + assert_eq!(&body[0..8], &80u64.to_le_bytes()); + assert_eq!(body[8], 1, "one stocked item"); + } + _ => panic!("expected one raw vendor window, got {} packets", sent.len()), + } +} diff --git a/gateway/src/world/weather_tests.rs b/gateway/src/world/weather_tests.rs new file mode 100644 index 000000000..d2b9c1c1d --- /dev/null +++ b/gateway/src/world/weather_tests.rs @@ -0,0 +1,200 @@ +//! Zone weather: the world-entry packet from a Store, and the live relay across World Sessions. + +use super::*; + +/// A Store with one zone's sky, or one that cannot answer. +struct WeatherFake { + rows: Vec<(u32, codec::ZoneWeatherView)>, + error: Option<&'static str>, +} + +impl WeatherStore for WeatherFake { + fn zone_weather(&self, zone_id: u32) -> Result> { + if let Some(error) = self.error { + return Err(anyhow!("{error}")); + } + Ok(self + .rows + .iter() + .find(|(zone, _)| *zone == zone_id) + .map(|(_, view)| *view)) + } +} + +/// The weather packet a client entering `zone_id` receives. +fn run(store: &WeatherFake, zone_id: u32) -> SMSG_WEATHER { + match zone_weather_message(store, zone_id, WeatherChangeType::Instant) { + ServerOpcodeMessage::SMSG_WEATHER(weather) => *weather, + other => panic!("expected SMSG_WEATHER, got {other}"), + } +} + +/// Story 10: a zone the Module has no weather row for is fine weather. World entry still sends the +/// packet, because a client told nothing keeps rendering whatever sky it arrived with. +#[test] +fn a_zone_with_no_weather_row_is_fine_weather() { + let weather = run( + &WeatherFake { + rows: Vec::new(), + error: None, + }, + 12, + ); + assert_eq!(weather.weather_type, WeatherType::Fine); + assert_eq!(weather.grade, 0.0); + assert_eq!(weather.sound_id, 0); + assert_eq!(weather.change, WeatherChangeType::Instant); +} + +/// A Store that cannot answer the weather question is a degraded sky, never a failed login. +#[test] +fn a_weather_read_failure_is_fine_weather() { + let weather = run( + &WeatherFake { + rows: Vec::new(), + error: Some("shard cache unavailable"), + }, + 12, + ); + assert_eq!(weather.weather_type, WeatherType::Fine); + assert_eq!(weather.grade, 0.0); +} + +#[test] +fn a_zone_with_a_weather_row_gets_that_sky_and_no_other_zone_does() { + let store = WeatherFake { + rows: vec![( + 12, + codec::ZoneWeatherView { + weather_type: 1, + intensity: 0.8, + }, + )], + error: None, + }; + let weather = run(&store, 12); + assert_eq!(weather.weather_type, WeatherType::Rain); + assert_eq!(weather.grade, 0.8); + assert_eq!(run(&store, 40).weather_type, WeatherType::Fine); +} + +/// Drive one World Session through the handshake and world entry, and hand back the client end +/// parked on the first packet that comes AFTER world entry. +fn world_session_in_world( + store: std::sync::Arc, + character_guid: u64, +) -> (UnixStream, 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.clone()).unwrap(); + }); + let (mut c_enc, mut c_dec) = client_handshake(&mut client, "TESTER", K); + CMSG_PLAYER_LOGIN { + guid: Guid::new(character_guid), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + drain_world_entry(&mut client, &mut c_dec); + (client, c_dec, server) +} + +/// One `game_zone_weather` row, as the Module writes it and the relay reads it. +fn zone_weather_row( + zone_id: u32, + weather_type: u8, + intensity: f32, +) -> crate::stdb::bindings::ZoneWeather { + crate::stdb::bindings::ZoneWeather { + zone_id, + weather_type, + intensity, + changed_at_micros: 0, + } +} + +/// Stories 5 and 6 end to end, over the real cipher: two World Sessions on real socket pairs share +/// one shard's relay, one standing in Elwynn Forest and one in Westfall. Forced Elwynn rain must +/// decode as valid rain on the Elwynn client and must not reach the Westfall client at all. +/// +/// Both characters stand on the SAME position, so nothing but the zone can separate them — the +/// spatial index has no say in weather. +/// +/// Westfall's own snow is a sentinel rather than a bare timeout. A session's writer is FIFO, so an +/// Elwynn row that leaked into Westfall's routing would arrive on that socket BEFORE the snow; the +/// assertion therefore fails deterministically if the zone filter goes away, instead of resting on +/// a socket that happened to stay quiet. The bounded silence afterwards closes the mirror case: +/// Westfall's snow must not reach Elwynn either. +#[test] +fn forced_elwynn_rain_reaches_only_the_client_standing_in_elwynn() { + const ELWYNN: u32 = 12; + const WESTFALL: u32 = 40; + let view = std::sync::Arc::new(crate::stdb::world_view::WorldView::new(true)); + + let mut westfall_entity = warrior_entity(); + westfall_entity.guid = 2; + westfall_entity.zone_id = WESTFALL; + let session_store = |entity: codec::EntityView| { + std::sync::Arc::new({ + let base = tester_store(7); + WorldFake { + session: SessionState { + login_entity: Some(entity), + relay_view: Some(view.clone()), + ..base.session + }, + ..base + } + }) + }; + + let (mut elwynn_client, mut elwynn_dec, elwynn_server) = + world_session_in_world(session_store(warrior_entity()), 1); + let (mut westfall_client, mut westfall_dec, westfall_server) = + world_session_in_world(session_store(westfall_entity), 2); + + // Elwynn is forced to heavy rain; Westfall drifts into light snow of its own. + crate::stdb::world_view::relay_zone_weather(&view, 0, &zone_weather_row(ELWYNN, 1, 0.75)); + crate::stdb::world_view::relay_zone_weather(&view, 0, &zone_weather_row(WESTFALL, 2, 0.30)); + + match ServerOpcodeMessage::read_encrypted(&mut elwynn_client, &mut elwynn_dec).unwrap() { + ServerOpcodeMessage::SMSG_WEATHER(m) => { + assert_eq!(m.weather_type, WeatherType::Rain); + assert_eq!(m.grade, 0.75); + assert_eq!(m.sound_id, 8535, "0.75 is the heavy rain band"); + assert_eq!(m.change, WeatherChangeType::Smooth); + } + other => panic!("the Elwynn client must receive Elwynn's rain, got {other}"), + } + match ServerOpcodeMessage::read_encrypted(&mut westfall_client, &mut westfall_dec).unwrap() { + ServerOpcodeMessage::SMSG_WEATHER(m) => { + assert_eq!( + m.weather_type, + WeatherType::Snow, + "the first weather the Westfall client sees must be Westfall's own — rain here \ + means Elwynn's row leaked across the zone boundary" + ); + assert_eq!(m.grade, 0.30); + assert_eq!(m.sound_id, 8536, "0.30 is the light snow band"); + } + other => panic!("the Westfall client must receive Westfall's snow, got {other}"), + } + + // Neither zone's sky is echoed to the other. A short deadline is enough: both sockets have + // already delivered a packet enqueued after the one being tested for. + let quiet = std::time::Duration::from_millis(250); + for (name, client, dec) in [ + ("Elwynn", &mut elwynn_client, &mut elwynn_dec), + ("Westfall", &mut westfall_client, &mut westfall_dec), + ] { + client.set_read_timeout(Some(quiet)).unwrap(); + assert!( + ServerOpcodeMessage::read_encrypted(client, dec).is_err(), + "the {name} client must receive exactly one weather packet, its own zone's" + ); + } + + drop(elwynn_client); + drop(westfall_client); + elwynn_server.join().unwrap(); + westfall_server.join().unwrap(); +} diff --git a/gateway/src/world/whisper.rs b/gateway/src/world/whisper.rs index bfca62122..e918ccef2 100644 --- a/gateway/src/world/whisper.rs +++ b/gateway/src/world/whisper.rs @@ -6,10 +6,11 @@ //! Request. Sharded and unsharded Gateways run this same path; on an unsharded Gateway //! `world_stores()` is empty and every read is one cache read. +use crate::world::{ShardRoutingStore, SocialStore}; use anyhow::Result; use super::presence::{self, AwayStatus}; -use super::{WhisperTargetFacts, WorldStore}; +use super::WhisperTargetFacts; use lyracore_shared::chat::chat_kind; /// The online Character `typed_name` reaches, with the facts the Module's whisper Gates need. @@ -23,7 +24,7 @@ use lyracore_shared::chat::chat_kind; /// A candidate whose Realm Presence cannot be read is skipped; if no other candidate is online, /// that read's error is the answer, so the caller never reports a Character missing when a Shard /// only failed to answer. -pub(crate) fn target_facts( +pub(crate) fn target_facts( store: &St, speaker_guid: u64, typed_name: &str, @@ -84,7 +85,7 @@ fn away_kind_of(away: AwayStatus) -> u8 { /// /// The session's own handle propagates its `Err` instead: the whole session already reads through /// that database, so a failure there is not "one Shard is down". -pub(crate) fn ignored_anywhere( +pub(crate) fn ignored_anywhere( store: &St, owner_guid: u64, other_guid: u64, diff --git a/gateway/src/world/whisper_tests.rs b/gateway/src/world/whisper_tests.rs index a2c13b072..7e5f9db26 100644 --- a/gateway/src/world/whisper_tests.rs +++ b/gateway/src/world/whisper_tests.rs @@ -80,7 +80,12 @@ fn a_playerbot_reaches_nobody_because_the_gate_reads_the_session_flag() { #[test] fn the_ignore_verdict_is_read_from_whichever_shard_holds_the_target() { let (_realm, world, instances, _calls) = party_topology(); - instances.contacts.lock().unwrap().push((VIM, GINGER, true)); + instances + .social + .contacts + .lock() + .unwrap() + .push((VIM, GINGER, true)); assert!( world.contact_lists(VIM).unwrap().1.is_empty(), "fixture: the sender's own shard knows nothing about Vim's ignore list" @@ -97,7 +102,12 @@ fn the_ignore_verdict_is_read_from_whichever_shard_holds_the_target() { #[test] fn an_ignore_of_somebody_else_is_not_the_speakers() { let (_realm, world, _instances, _calls) = party_topology(); - world.contacts.lock().unwrap().push((TRIN, VIM, true)); + world + .social + .contacts + .lock() + .unwrap() + .push((TRIN, VIM, true)); assert_eq!( whisper::target_facts(world.as_ref(), GINGER, "Trin").unwrap(), Some(facts(TRIN, "Trin")) @@ -109,12 +119,18 @@ fn an_ignore_of_somebody_else_is_not_the_speakers() { #[test] fn an_unreachable_shard_does_not_make_a_whisper_look_ignored() { let (_realm, world, _instances, _calls) = party_topology(); - let broken = std::sync::Arc::new(InMemoryStore { - shard: "unreachable".into(), - contact_lists_error: Some("shard is unreachable".into()), + let broken = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "unreachable".into(), + ..Default::default() + }, + social: SocialState { + contact_lists_error: Some("shard is unreachable".into()), + ..Default::default() + }, ..Default::default() }); - world.peers.lock().unwrap().push(broken); + world.topology.peers.lock().unwrap().push(broken); assert_eq!( whisper::target_facts(world.as_ref(), GINGER, "Trin").unwrap(), Some(facts(TRIN, "Trin")) @@ -126,7 +142,7 @@ fn an_unreachable_shard_does_not_make_a_whisper_look_ignored() { #[test] fn an_afk_target_conveys_its_auto_reply_from_its_own_shard() { let (_realm, world, instances, _calls) = party_topology(); - instances.member_entities.lock().unwrap().push(( + instances.social.member_entities.lock().unwrap().push(( VIM, codec::MemberEntity { player_flags: 0x02, @@ -134,11 +150,13 @@ fn an_afk_target_conveys_its_auto_reply_from_its_own_shard() { }, )); instances + .social .auto_replies .lock() .unwrap() .insert(VIM, "brb food".to_string()); world + .social .auto_replies .lock() .unwrap() @@ -158,7 +176,7 @@ fn an_afk_target_conveys_its_auto_reply_from_its_own_shard() { #[test] fn a_dnd_target_without_stored_text_conveys_an_empty_reply() { let (_realm, world, instances, _calls) = party_topology(); - instances.member_entities.lock().unwrap().push(( + instances.social.member_entities.lock().unwrap().push(( VIM, codec::MemberEntity { player_flags: 0x04, @@ -178,7 +196,7 @@ fn a_dnd_target_without_stored_text_conveys_an_empty_reply() { #[test] fn a_reply_row_without_the_away_flag_is_ignored() { let (_realm, world, instances, _calls) = party_topology(); - instances.member_entities.lock().unwrap().push(( + instances.social.member_entities.lock().unwrap().push(( VIM, codec::MemberEntity { player_flags: 0x10, @@ -186,6 +204,7 @@ fn a_reply_row_without_the_away_flag_is_ignored() { }, )); instances + .social .auto_replies .lock() .unwrap() @@ -201,28 +220,49 @@ fn a_reply_row_without_the_away_flag_is_ignored() { #[test] fn a_homonym_on_the_senders_own_shard_does_not_shadow_the_online_target() { const HOMONYM: u64 = 9; - let realm = std::sync::Arc::new(InMemoryStore { - shard: "lyracore-realm".into(), - is_realm: true, + let realm = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "lyracore-realm".into(), + ..Default::default() + }, + party: PartyState { + is_realm: true, + ..Default::default() + }, ..Default::default() }); - let world = std::sync::Arc::new(InMemoryStore { - shard: "world".into(), - realm: Some(realm.clone()), + let world = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "world".into(), + realm: Some(realm.clone()), + ..Default::default() + }, characters: vec![character(GINGER, "Ginger"), character(HOMONYM, "Vim")], - live_guids: vec![GINGER, HOMONYM], - offline_guids: vec![HOMONYM], + session: SessionState { + live_guids: vec![GINGER, HOMONYM], + ..Default::default() + }, + social: SocialState { + offline_guids: vec![HOMONYM], + ..Default::default() + }, ..Default::default() }); - let instances = std::sync::Arc::new(InMemoryStore { - shard: "instances".into(), - realm: Some(realm.clone()), + let instances = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "instances".into(), + realm: Some(realm.clone()), + ..Default::default() + }, characters: vec![character(VIM, "Vim")], - live_guids: vec![VIM], + session: SessionState { + live_guids: vec![VIM], + ..Default::default() + }, ..Default::default() }); for shard in [&world, &instances] { - *shard.peers.lock().unwrap() = vec![world.clone(), instances.clone()]; + *shard.topology.peers.lock().unwrap() = vec![world.clone(), instances.clone()]; } assert_eq!( presence::resolve_by_name(world.as_ref(), "Vim").unwrap(), @@ -240,12 +280,15 @@ fn a_homonym_on_the_senders_own_shard_does_not_shadow_the_online_target() { #[test] fn an_unreadable_candidate_is_an_error_not_a_missing_character() { let (_realm, world, _instances, _calls) = party_topology(); - let broken = std::sync::Arc::new(InMemoryStore { - shard: "unreachable".into(), - world_shard_set_error: Some("shard is unreachable".into()), + let broken = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "unreachable".into(), + world_shard_set_error: Some("shard is unreachable".into()), + ..Default::default() + }, ..Default::default() }); - world.peers.lock().unwrap().push(broken); + world.topology.peers.lock().unwrap().push(broken); assert_eq!( world.character_guid_by_name("Dormant").unwrap(), Some(DORMANT) @@ -268,7 +311,7 @@ fn say_yell_and_emotes_stay_on_the_players_own_shard_when_sharded() { world.send_emote(7, 0, 4, 4, TRIN).expect("targeted emote"); assert_eq!( - world.chats.lock().unwrap().clone(), + world.speech.chats.lock().unwrap().clone(), vec![ (0, 0, "hello Elwynn".to_string()), (1, 0, "HELP".to_string()) @@ -280,7 +323,7 @@ fn say_yell_and_emotes_stay_on_the_players_own_shard_when_sharded() { log.iter().all(|(shard, _)| shard == "world"), "no spatial chat may reach another database. Calls were {log:?}" ); - assert!(realm.realm_whispers.lock().unwrap().is_empty()); + assert!(realm.chat.realm_whispers.lock().unwrap().is_empty()); } /// A whisper line names its sender by guid and the client resolves the name over @@ -316,31 +359,40 @@ fn a_name_query_resolves_a_character_on_another_shard() { #[test] fn a_real_session_whispers_across_shards_as_its_own_character() { let (realm, _world, instances, _calls) = party_topology(); - let session_shard = std::sync::Arc::new(InMemoryStore { - shard: "world".into(), - username: "TESTER".into(), - session: Some(WorldSession { - account_id: 7, - session_key: K, - }), - login_entity: Some(warrior_entity()), - realm: Some(realm.clone()), + let session_shard = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "world".into(), + realm: Some(realm.clone()), + ..Default::default() + }, + session: SessionState { + username: "TESTER".into(), + session: Some(WorldSession { + account_id: 7, + session_key: K, + }), + login_entity: Some(warrior_entity()), + live_guids: vec![GINGER], + ..Default::default() + }, characters: vec![character(GINGER, "Ginger")], - live_guids: vec![GINGER], - speaker_facts: Some(SpeakerFacts { - race: 1, - chat_tag: 1, - name: "Ginger".to_string(), - }), + chat: ChatState { + speaker_facts: Some(SpeakerFacts { + race: 1, + chat_tag: 1, + name: "Ginger".to_string(), + }), + ..Default::default() + }, ..Default::default() }); - *session_shard.peers.lock().unwrap() = vec![session_shard.clone(), instances.clone()]; - *instances.peers.lock().unwrap() = vec![session_shard.clone(), instances.clone()]; + *session_shard.topology.peers.lock().unwrap() = vec![session_shard.clone(), instances.clone()]; + *instances.topology.peers.lock().unwrap() = vec![session_shard.clone(), instances.clone()]; let (mut client, server_end) = world_session_socket_pair(); let server_store = session_shard.clone(); let server = std::thread::spawn(move || { - run_world_session(server_end, server_store.as_ref()).unwrap(); + run_world_session(server_end, server_store.clone()).unwrap(); }); let (mut c_enc, mut c_dec) = client_handshake(&mut client, "TESTER", K); CMSG_PLAYER_LOGIN { @@ -348,9 +400,7 @@ fn a_real_session_whispers_across_shards_as_its_own_character() { } .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); - for _ in 0..WORLD_ENTRY_PACKETS { - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - } + drain_world_entry(&mut client, &mut c_dec); CMSG_MESSAGECHAT { chat_type: CMSG_MESSAGECHAT_ChatType::Whisper { @@ -380,7 +430,7 @@ fn a_real_session_whispers_across_shards_as_its_own_character() { server.join().unwrap(); assert_eq!( - session_shard.realm_whispers.lock().unwrap().clone(), + session_shard.chat.realm_whispers.lock().unwrap().clone(), vec![( GINGER, WhisperRequest { @@ -396,3 +446,29 @@ fn a_real_session_whispers_across_shards_as_its_own_character() { )] ); } + +#[test] +fn messagechat_whisper_to_an_unknown_player_replies_player_not_found() { + let mut s = quest_store(); + s.chat.speaker_facts = Some(human_speaker()); + let store = std::sync::Arc::new(s); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); + CMSG_MESSAGECHAT { + chat_type: CMSG_MESSAGECHAT_ChatType::Whisper { + target_player: "Ghost".into(), + }, + language: Language::Universal, + message: "hello?".into(), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec) + .expect("a whisper to nobody must answer SMSG_CHAT_PLAYER_NOT_FOUND, and nothing arrived") + { + ServerOpcodeMessage::SMSG_CHAT_PLAYER_NOT_FOUND(m) => assert_eq!(m.name, "Ghost"), + other => panic!("expected SMSG_CHAT_PLAYER_NOT_FOUND, got {other}"), + } + drop(client); + server.join().unwrap(); + assert!(store.chat.realm_whispers.lock().unwrap().is_empty()); +} diff --git a/gateway/src/world/who.rs b/gateway/src/world/who.rs index 01699446a..aee714f3a 100644 --- a/gateway/src/world/who.rs +++ b/gateway/src/world/who.rs @@ -1,9 +1,10 @@ //! `/who`: a pure filter over [`presence::in_world_characters`], plus the `SMSG_WHO` response //! builder `CMSG_WHO` (`social.rs`) drives. Behavior pinned against cm:MiscHandler.cpp:71-268. +use crate::world::{GuildActionStore, ShardRoutingStore, SocialStore}; use anyhow::Result; -use super::{presence, WorldStore}; +use super::presence; use crate::codec; use lyracore_shared::faction; use wow_world_messages::vanilla::CMSG_WHO; @@ -106,7 +107,7 @@ fn effective_max_level(wire_max_level: u8) -> u8 { /// rule that reads one — the common case (no search string) then costs no zone lookups at all. /// The Guild name is two keyed reads of the Realm-core cache per row. When Realm-core cannot /// answer, every row lists with an empty Guild name rather than the reply failing. -pub(crate) fn respond( +pub(crate) fn respond( store: &St, requester_race: u8, request: &CMSG_WHO, diff --git a/gateway/src/world/who_tests.rs b/gateway/src/world/who_tests.rs index a3ad5a933..2b772635e 100644 --- a/gateway/src/world/who_tests.rs +++ b/gateway/src/world/who_tests.rs @@ -3,7 +3,7 @@ //! against hand-written candidates; this file covers what only a Store can exercise. //! //! What EXECUTES here is production `world::who`, against the same in-memory multi-database -//! topology `party_tests::party_topology` builds, and a plain single-shard `InMemoryStore` for the +//! topology `party_tests::party_topology` builds, and a plain single-shard `WorldFake` for the //! unsharded case. use super::party_tests::{character, party_topology}; @@ -85,9 +85,12 @@ fn fifty_matches_list_forty_nine_and_report_fifty() { .map(|i| character(100 + i, &format!("Who{i}"))) .collect(); let live_guids = characters.iter().map(|c| c.guid).collect(); - let store = std::sync::Arc::new(InMemoryStore { + let store = std::sync::Arc::new(WorldFake { characters, - live_guids, + session: SessionState { + live_guids, + ..Default::default() + }, ..Default::default() }); @@ -104,7 +107,7 @@ fn fifty_matches_list_forty_nine_and_report_fifty() { /// `world_stores()` fan-out, but the level-range rule still applies exactly as it would sharded. #[test] fn an_unsharded_gateway_answers_who_with_the_same_filters() { - let store = std::sync::Arc::new(InMemoryStore { + let store = std::sync::Arc::new(WorldFake { characters: vec![ codec::CharacterView { guid: 1, @@ -123,7 +126,10 @@ fn an_unsharded_gateway_answers_who_with_the_same_filters() { ..Default::default() }, ], - live_guids: vec![1, 2], + session: SessionState { + live_guids: vec![1, 2], + ..Default::default() + }, ..Default::default() }); assert!( @@ -146,7 +152,7 @@ fn an_unsharded_gateway_answers_who_with_the_same_filters() { /// zone the Fake was never told about stays blank and fails the match. #[test] fn a_search_string_matches_a_zone_name_the_store_resolves() { - let store = std::sync::Arc::new(InMemoryStore { + let store = std::sync::Arc::new(WorldFake { characters: vec![codec::CharacterView { guid: 1, name: "Ginger".into(), @@ -156,8 +162,14 @@ fn a_search_string_matches_a_zone_name_the_store_resolves() { zone_id: 12, ..Default::default() }], - live_guids: vec![1], - zone_names: [(12, "Elwynn Forest".to_string())].into(), + session: SessionState { + live_guids: vec![1], + ..Default::default() + }, + social: SocialState { + zone_names: [(12, "Elwynn Forest".to_string())].into(), + ..Default::default() + }, ..Default::default() }); let mut request = wide_open_who(); @@ -174,7 +186,7 @@ fn a_search_string_matches_a_zone_name_the_store_resolves() { // The same request against a Store that never learned the zone's name: the blank lookup must // fail the match rather than matching everything by accident. - let blank = std::sync::Arc::new(InMemoryStore { + let blank = std::sync::Arc::new(WorldFake { characters: vec![codec::CharacterView { guid: 1, name: "Ginger".into(), @@ -184,7 +196,10 @@ fn a_search_string_matches_a_zone_name_the_store_resolves() { zone_id: 12, ..Default::default() }], - live_guids: vec![1], + session: SessionState { + live_guids: vec![1], + ..Default::default() + }, ..Default::default() }); let (_, body) = who::respond(blank.as_ref(), 1, &request) @@ -196,3 +211,85 @@ fn a_search_string_matches_a_zone_name_the_store_resolves() { "a blank zone name must not match \"elwynn\"" ); } + +#[test] +fn who_reply_lists_every_online_player_with_guild_level_and_zone() { + let mut s = quest_store(); + s.guild.guild_memberships = vec![codec::GuildMemberView { + character_guid: 2, + guild_id: 7, + name: "Alpha".into(), + ..Default::default() + }]; + s.guild.guilds = vec![codec::GuildView { + guild_id: 7, + name: "Boundary Test".into(), + ..Default::default() + }]; + s.characters = vec![ + // The requester. Human like Alpha/Bravo (so the team gate passes them), but a class + // outside the request's `class_mask` — the requester is not exempt from its own filters, + // so this keeps the assertions below at exactly the two matches. + codec::CharacterView { + guid: 1, + name: "Tester".into(), + race: 1, + class: 4, + level: 10, + zone_id: 12, + ..Default::default() + }, + codec::CharacterView { + guid: 2, + name: "Alpha".into(), + race: 1, + class: 1, + level: 5, + zone_id: 12, + ..Default::default() + }, + codec::CharacterView { + guid: 3, + name: "Bravo".into(), + race: 1, + class: 1, + level: 60, + zone_id: 12, + ..Default::default() + }, + ]; + let store = std::sync::Arc::new(s); + let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); + CMSG_WHO { + minimum_level: Level::new(1), + maximum_level: Level::new(60), + player_name: String::new(), + guild_name: String::new(), + race_mask: 1 << 1, // Human + class_mask: 1 << 1, // Warrior + zones: Vec::new(), + search_strings: Vec::new(), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + // RAW-encoded (codec::build_who_response_raw): gtker's typed reader assumes the wrong 5875 + // layout (see that builder's doc comment), so this reads the cmangos body by hand. + let (opcode, body) = read_raw_frame(&mut client, &mut c_dec); + assert_eq!(opcode, codec::social::SMSG_WHO_OPCODE); + let online_players = u32::from_le_bytes(body[4..8].try_into().unwrap()); + assert_eq!(online_players, 2); + let mut rest = &body[8..]; + for (name, guild, level) in [("Alpha", "Boundary Test", 5u32), ("Bravo", "", 60)] { + let name_end = rest.iter().position(|&b| b == 0).unwrap(); + assert_eq!(std::str::from_utf8(&rest[..name_end]).unwrap(), name); + rest = &rest[name_end + 1..]; + let guild_end = rest.iter().position(|&b| b == 0).unwrap(); + assert_eq!(std::str::from_utf8(&rest[..guild_end]).unwrap(), guild); + rest = &rest[guild_end + 1..]; + assert_eq!(u32::from_le_bytes(rest[0..4].try_into().unwrap()), level); + rest = &rest[16..]; // level, class, race, zone: u32 each + } + assert!(rest.is_empty(), "exactly two listed rows"); + drop(client); + server.join().unwrap(); +} diff --git a/gateway/src/world/wire_corruption_tests.rs b/gateway/src/world/wire_corruption_tests.rs index 97fec4da5..31598dd03 100644 --- a/gateway/src/world/wire_corruption_tests.rs +++ b/gateway/src/world/wire_corruption_tests.rs @@ -1,7 +1,7 @@ //! "SMSG_COMPRESSED_MOVES corrupts at crowd scale" — regression pins. //! //! A child module of `world::tests` for the same reason as its siblings — it reaches -//! `InMemoryStore`/`client_handshake`/`world_handshake` and drives the ACTUAL `spawn_writer` with a +//! `WorldFake`/`client_handshake`/`world_handshake` and drives the ACTUAL `spawn_writer` with a //! REAL `wow_srp` cipher pair, without widening anything. use super::*; @@ -41,19 +41,22 @@ use super::*; /// in order. A stream desync from a wrong size field would manifest here as either a decode `Err` /// or a frame decoding to the wrong `ServerOpcodeMessage` variant. fn movement_burst_over_a_real_cipher(n: usize) { - let store = std::sync::Arc::new(InMemoryStore { - username: "TESTER".into(), - session: Some(WorldSession { - account_id: 42, - session_key: K, - }), + let store = std::sync::Arc::new(WorldFake { + session: SessionState { + username: "TESTER".into(), + session: Some(WorldSession { + account_id: 42, + session_key: K, + }), + ..Default::default() + }, ..Default::default() }); let (mut client, server_end) = world_session_socket_pair(); let server_store = store.clone(); let server = std::thread::spawn(move || { let mut s = server_end; - let (_conn, encrypt) = world_handshake(&mut s, server_store.as_ref()) + let (_conn, encrypt) = world_handshake(&mut s, server_store.clone()) .unwrap() .expect("handshake should succeed"); (s, encrypt) @@ -310,19 +313,22 @@ fn writer_trace_dump_writes_a_file_with_the_traced_frames() { #[test] fn oversized_raw_body_ends_the_session_instead_of_wrapping_the_size_field() { - let store = std::sync::Arc::new(InMemoryStore { - username: "TESTER".into(), - session: Some(WorldSession { - account_id: 42, - session_key: K, - }), + let store = std::sync::Arc::new(WorldFake { + session: SessionState { + username: "TESTER".into(), + session: Some(WorldSession { + account_id: 42, + session_key: K, + }), + ..Default::default() + }, ..Default::default() }); let (mut client, server_end) = world_session_socket_pair(); let server_store = store.clone(); let server = std::thread::spawn(move || { let mut s = server_end; - let (_conn, encrypt) = world_handshake(&mut s, server_store.as_ref()) + let (_conn, encrypt) = world_handshake(&mut s, server_store.clone()) .unwrap() .expect("handshake should succeed"); (s, encrypt) @@ -408,19 +414,22 @@ fn oversized_raw_body_ends_the_session_instead_of_wrapping_the_size_field() { /// here as a decode `Err` or a wrong-variant decode — precisely the crash shape this file guards /// against, but attributable to the SPELL/COMBAT plane instead of movement. fn combat_cast_burst_over_a_real_cipher(n: usize) { - let store = std::sync::Arc::new(InMemoryStore { - username: "TESTER".into(), - session: Some(WorldSession { - account_id: 42, - session_key: K, - }), + let store = std::sync::Arc::new(WorldFake { + session: SessionState { + username: "TESTER".into(), + session: Some(WorldSession { + account_id: 42, + session_key: K, + }), + ..Default::default() + }, ..Default::default() }); let (mut client, server_end) = world_session_socket_pair(); let server_store = store.clone(); let server = std::thread::spawn(move || { let mut s = server_end; - let (_conn, encrypt) = world_handshake(&mut s, server_store.as_ref()) + let (_conn, encrypt) = world_handshake(&mut s, server_store.clone()) .unwrap() .expect("handshake should succeed"); (s, encrypt) diff --git a/module/src/realm_core.rs b/module/src/realm_core.rs index 748b4ca4d..eaba3f90e 100644 --- a/module/src/realm_core.rs +++ b/module/src/realm_core.rs @@ -24,11 +24,11 @@ //! //! The gateway treats an entry as a hint it must confirm: it accepts the hint only when the shard it //! names actually holds the character row, and otherwise falls back to probing the connected shards -//! and writes the corrected entry back (`gateway/src/config.rs::resolve_home_shard`, and the tests -//! there). So a stale — or entirely absent, or maliciously wrong — index entry costs one extra cache -//! lookup at login and then heals a settled row. Party partition projection uses only its ordered -//! Realm revision and pending phase; a Transfer compare-and-set prevents an old worker from moving -//! that state backward. +//! and writes the corrected entry back (`gateway/src/realm_core.rs::locate_home_shard`, and the +//! tests there). So a stale — or entirely absent, or maliciously wrong — index entry costs one extra +//! cache lookup at login and then heals a settled row. Party partition projection uses only its +//! ordered Realm revision and pending phase; a Transfer compare-and-set prevents an old worker from +//! moving that state backward. use spacetimedb::{reducer, table, Identity, ReducerContext, Table}; @@ -364,14 +364,10 @@ fn next_shard_revision(current: Option<&CharacterShard>, map_id: u32, instance_i /// gateway-coordination reducer (`provision_account` / `establish_session`): the index is a routing /// input, so a client that could write it could redirect another player's login. /// -/// Two callers, both on the gateway's realm-core handle: `world::transfer::run_transfer` publishes -/// the settled destination here as a required step of every escrowed transfer (strictly -/// after `finish_transfer` committed, so it can only ever name a destination the escrow reached), -/// and `home_shard`'s login self-heal rewrites it whenever the fallback probe disagrees with what -/// the index says. Only the FIRST of those runs in production today: the gateway's world-entry -/// resolver is `settle_home_shard`, which overrides `home_shard` and scans the shards instead of -/// consulting this table, so the index is currently written-but-never-read and its self-heal never -/// fires. The instance-placement work is what changes that. +/// The Gateway's one caller is the login self-heal in `realm_core::locate_home_shard`, which +/// `settle_home_shard` runs at every world entry: it reads this index first, confirms the hint on +/// the Shard it names, and rewrites the entry when the Shard that holds the Character disagrees. +/// A Transfer settles its destination through `finish_character_shard_transfer` instead. #[reducer] pub fn set_character_shard( ctx: &ReducerContext, diff --git a/module/src/transfer/mod.rs b/module/src/transfer/mod.rs index f1a6601e8..a974c9aa1 100644 --- a/module/src/transfer/mod.rs +++ b/module/src/transfer/mod.rs @@ -1899,7 +1899,7 @@ pub(crate) fn apply_finish(sink: &mut S, transfer_id: u64) { // "the directory says where it settled" can never disagree on this database. // // Cross-database, this is only HALF the index. There is no transaction spanning - // two SpacetimeDB databases, so realm-core's copy — the one `home_shard` actually reads — + // two SpacetimeDB databases, so realm-core's copy — the one `locate_home_shard` reads — // cannot be written from here at all. What the gateway does instead is REPLICATE this row: // `world::transfer::run_transfer` publishes the same `(guid, map, instance)` to realm-core // as a required step of the drive, strictly AFTER `finish_transfer` returned Ok, so it can @@ -1908,12 +1908,9 @@ pub(crate) fn apply_finish(sink: &mut S, transfer_id: u64) { // fails) between `finish_transfer` and step 6, realm-core's copy keeps naming the old // shard, and the recovery path does not re-drive `run_transfer`, so nothing republishes it. // The index is therefore still specified as a HINT the gateway CONFIRMS by probing rather - // than trusts, and the login self-heal is still the terminal fallback — except that the - // self-heal is currently unreachable in production (the gateway's `settle_home_shard` - // overrides the `home_shard` that hosts it and locates the character by scanning instead). - // Nothing in production reads this directory yet either, so the two cancel out today; both - // have to be fixed together before anything routes on it. See - // `gateway/src/realm_core.rs::publish_shard_index`. + // than trusts, and the login self-heal is the terminal fallback: `settle_home_shard` runs + // `gateway/src/realm_core.rs::locate_home_shard` at every world entry, which confirms the + // hint on the Shard it names, scans on a miss, and rewrites a wrong entry. // // MERGE ORDER: this runs AFTER the cascade, never before. `game_character_shard` // is itself character-owned (`realm_core.rs`'s delete sweep), so a cascade that ran second