From 8a0134f4d3b8af6df56ab46c556d921d79f3b432 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 15:40:42 +0200 Subject: [PATCH 01/17] refactor(gateway): split WorldStore into Store family traits WorldStore had 185 own methods, 85 of them with default bodies. A Fake silently answered with a default for any method it did not write. Each method now sits on the family trait whose handler calls it: ShardRouting, Session, Character, Transfer, Party, Mail, Social, Npc, Trainer, Bank, Combat, Death and Trade are new, and LootWindowStore and ChatActionStore grow. WorldStore is a method-less umbrella with a blanket impl. Every default body is gone. The Coordinator adapter moves to one file per family under stdb/store, and InMemoryStore implements each family in its own block, with the seven answers it took from defaults now written out. Two methods had no caller once the defaults went: home_shard and publish_shard_index. They leave the traits, the Fake keeps them as inherent helpers, and the production code only they reached is deleted: realm_core::settle_shard_index, realm_core::publish_shard_index, config::resolve_home_shard, HomeShard, and the tests of those functions. Co-Authored-By: Claude Opus 5.5 (1M context) --- gateway/src/config.rs | 161 +- gateway/src/realm_core.rs | 196 +- gateway/src/stdb/connection.rs | 2 +- gateway/src/stdb/mod.rs | 3 +- gateway/src/stdb/reads/account.rs | 2 +- gateway/src/stdb/store/bank.rs | 17 + gateway/src/stdb/store/character.rs | 212 + gateway/src/stdb/store/combat.rs | 27 + gateway/src/stdb/store/death.rs | 33 + gateway/src/stdb/store/mail.rs | 208 + gateway/src/stdb/store/mod.rs | 20 + gateway/src/stdb/store/npc.rs | 74 + gateway/src/stdb/store/party.rs | 266 + gateway/src/stdb/store/session.rs | 109 + gateway/src/stdb/store/shard_routing.rs | 173 + gateway/src/stdb/store/social.rs | 91 + gateway/src/stdb/store/trade.rs | 55 + gateway/src/stdb/store/trainer.rs | 91 + gateway/src/stdb/store/transfer.rs | 255 + ...bscriptions_party_command_durable_tests.rs | 2 +- gateway/src/stdb/world_store.rs | 1656 +---- gateway/src/world/handlers/bank.rs | 13 + gateway/src/world/handlers/channel.rs | 26 + gateway/src/world/handlers/char.rs | 66 + gateway/src/world/handlers/chat.rs | 83 + gateway/src/world/handlers/combat.rs | 22 + gateway/src/world/handlers/death.rs | 33 + gateway/src/world/handlers/loot.rs | 214 + gateway/src/world/handlers/mod.rs | 14 +- gateway/src/world/handlers/query.rs | 72 + gateway/src/world/handlers/trade.rs | 43 + gateway/src/world/handlers/trainer.rs | 87 + gateway/src/world/mail.rs | 251 + gateway/src/world/mod.rs | 12 +- gateway/src/world/party.rs | 166 + gateway/src/world/social.rs | 99 + gateway/src/world/store.rs | 1459 +---- gateway/src/world/tests.rs | 5317 +++++++++-------- gateway/src/world/transfer.rs | 122 +- gateway/src/world/transfer_tests.rs | 2 +- 40 files changed, 5810 insertions(+), 5944 deletions(-) create mode 100644 gateway/src/stdb/store/bank.rs create mode 100644 gateway/src/stdb/store/character.rs create mode 100644 gateway/src/stdb/store/combat.rs create mode 100644 gateway/src/stdb/store/death.rs create mode 100644 gateway/src/stdb/store/mail.rs create mode 100644 gateway/src/stdb/store/mod.rs create mode 100644 gateway/src/stdb/store/npc.rs create mode 100644 gateway/src/stdb/store/party.rs create mode 100644 gateway/src/stdb/store/session.rs create mode 100644 gateway/src/stdb/store/shard_routing.rs create mode 100644 gateway/src/stdb/store/social.rs create mode 100644 gateway/src/stdb/store/trade.rs create mode 100644 gateway/src/stdb/store/trainer.rs create mode 100644 gateway/src/stdb/store/transfer.rs create mode 100644 gateway/src/world/handlers/death.rs 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..92577e9b0 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; @@ -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..88c918036 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 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..3c5441805 --- /dev/null +++ b/gateway/src/stdb/store/character.rs @@ -0,0 +1,212 @@ +//! `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/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 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/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..da45f6176 --- /dev/null +++ b/gateway/src/stdb/store/mod.rs @@ -0,0 +1,20 @@ +//! `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 mail; +mod npc; +mod party; +mod session; +mod shard_routing; +mod social; +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/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_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/handlers/bank.rs b/gateway/src/world/handlers/bank.rs index 310530508..284fe9fd1 100644 --- a/gateway/src/world/handlers/bank.rs +++ b/gateway/src/world/handlers/bank.rs @@ -4,6 +4,19 @@ 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 diff --git a/gateway/src/world/handlers/channel.rs b/gateway/src/world/handlers/channel.rs index 6b9dd454c..ac4e4daa5 100644 --- a/gateway/src/world/handlers/channel.rs +++ b/gateway/src/world/handlers/channel.rs @@ -575,6 +575,32 @@ mod tests { fn speaker_gm_level(&self, _speaker_guid: u64) -> Result { Ok(0) } + + fn send_chat( + &self, + _account_id: u64, + _self_guid: u64, + _chat_type: u8, + _language: u8, + _message: String, + ) -> Result { + unreachable!("the channel dispatcher never speaks a say, yell or emote line") + } + + fn send_emote( + &self, + _account_id: u64, + _self_guid: u64, + _text_emote: u32, + _emote_anim: u32, + _target_guid: u64, + ) -> Result<()> { + unreachable!("the channel dispatcher never performs a text emote") + } + + fn gm_command(&self, _account_name: &str, _self_guid: u64, _text: String) -> Result<()> { + unreachable!("the channel dispatcher never runs a GM command") + } } impl ChannelActionStore for InMemoryChannelActions { diff --git a/gateway/src/world/handlers/char.rs b/gateway/src/world/handlers/char.rs index 795c65e0d..3ccae62a4 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 diff --git a/gateway/src/world/handlers/chat.rs b/gateway/src/world/handlers/chat.rs index 8bb04929d..a91a504be 100644 --- a/gateway/src/world/handlers/chat.rs +++ b/gateway/src/world/handlers/chat.rs @@ -82,6 +82,37 @@ pub(crate) trait ChatActionStore: Send + Sync { /// The speaker's GM level, read on its Home Shard. The Chat Flood Limiter never mutes a /// Character above 0. fn speaker_gm_level(&self, speaker_guid: u64) -> Result; + + /// 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 { @@ -112,6 +143,32 @@ impl ChatActionStore for crate::stdb::Coordinator { fn speaker_gm_level(&self, speaker_guid: u64) -> Result { Ok(crate::stdb::Coordinator::home_gm_level(self, speaker_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) + } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -481,6 +538,32 @@ mod tests { fn speaker_gm_level(&self, _speaker_guid: u64) -> Result { Ok(0) } + + fn send_chat( + &self, + _account_id: u64, + _self_guid: u64, + _chat_type: u8, + _language: u8, + _message: String, + ) -> Result { + unreachable!("the chat action dispatcher never speaks a say, yell or emote line") + } + + fn send_emote( + &self, + _account_id: u64, + _self_guid: u64, + _text_emote: u32, + _emote_anim: u32, + _target_guid: u64, + ) -> Result<()> { + unreachable!("the chat action dispatcher never performs a text emote") + } + + fn gm_command(&self, _account_name: &str, _self_guid: u64, _text: String) -> Result<()> { + unreachable!("the chat action dispatcher never runs a GM command") + } } fn speaker() -> SpeakerFacts { diff --git a/gateway/src/world/handlers/combat.rs b/gateway/src/world/handlers/combat.rs index 3cfbae8b5..f4f67b64c 100644 --- a/gateway/src/world/handlers/combat.rs +++ b/gateway/src/world/handlers/combat.rs @@ -4,6 +4,28 @@ 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. diff --git a/gateway/src/world/handlers/death.rs b/gateway/src/world/handlers/death.rs new file mode 100644 index 000000000..926022347 --- /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, slice 4): 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`, slice 5): 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` (slice 5). + fn corpse_location(&self, owner_guid: u64) -> Result>; +} diff --git a/gateway/src/world/handlers/loot.rs b/gateway/src/world/handlers/loot.rs index a8c2fd43d..8f747ee23 100644 --- a/gateway/src/world/handlers/loot.rs +++ b/gateway/src/world/handlers/loot.rs @@ -43,6 +43,78 @@ pub(crate) trait LootWindowStore: Send + Sync { target_guid: u64, loot_slot: u8, ) -> Result; + + // 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; } /// How the Module answered a loot Durable Request. A Refusal is an outcome; a timeout or transport @@ -156,6 +228,83 @@ impl LootWindowStore for crate::stdb::Coordinator { ) -> Result { crate::stdb::Coordinator::take_loot(self, account_id, actor_guid, target_guid, loot_slot) } + + 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) + } } /// The target whose loot window is currently open for this world session. @@ -737,6 +886,71 @@ mod tests { )); request_status(self.item_take_refusal, &self.item_take_fatal_error) } + + 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!("the loot window never runs a Loot Roll op") + } + + fn realm_loot_vote( + &self, + _corpse_guid: u64, + _slot: u8, + _actor_guid: u64, + _vote: u8, + ) -> Result { + unreachable!("the loot window never casts a Loot Roll vote") + } + + fn pending_local_rolls(&self) -> Result> { + unreachable!("the loot window never reads pending Loot Rolls") + } + + fn settle_loot_roll(&self, _corpse_guid: u64, _slot: u8, _winner_guid: u64) -> Result<()> { + unreachable!("the loot window never settles a Loot Roll") + } + + fn clear_promoted_loot_roll(&self, _roll_id: u64) -> Result<()> { + unreachable!("the loot window never clears a promoted Loot Roll") + } + + fn loot_won_since(&self, _after_id: u64) -> Result<(u64, Vec<(u64, u8, u64)>)> { + unreachable!("the loot window never reads won Loot Rolls") + } + + fn loot_roll( + &self, + _account_id: u64, + _self_guid: u64, + _corpse_guid: u64, + _loot_slot: u32, + _vote: u8, + ) -> Result { + unreachable!("the loot window never records a Loot Roll vote") + } + + fn loot_master_give( + &self, + _account_id: u64, + _self_guid: u64, + _corpse_guid: u64, + _loot_slot: u8, + _target_guid: u64, + ) -> Result { + unreachable!("the loot window never assigns master loot") + } } fn player() -> LootWindowPlayer { diff --git a/gateway/src/world/handlers/mod.rs b/gateway/src/world/handlers/mod.rs index c9337885e..fe4ac52f7 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; @@ -41,7 +42,7 @@ 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}; pub(crate) use cast::{dispatch_cast, CastOutcome, CastPlayer, CastStore, CastTransition}; pub(crate) use channel::{ dispatch_channel_action, ChannelActionOutcome, ChannelActionStore, ChannelOutcome, @@ -49,12 +50,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, }; -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, @@ -84,7 +86,7 @@ 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, @@ -93,8 +95,8 @@ 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}; 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..11c22a392 100644 --- a/gateway/src/world/handlers/query.rs +++ b/gateway/src/world/handlers/query.rs @@ -7,6 +7,78 @@ 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 diff --git a/gateway/src/world/handlers/trade.rs b/gateway/src/world/handlers/trade.rs index fd2b71484..7e62bfb90 100644 --- a/gateway/src/world/handlers/trade.rs +++ b/gateway/src/world/handlers/trade.rs @@ -8,6 +8,49 @@ use super::super::social::self_guid; use super::super::*; +/// 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, diff --git a/gateway/src/world/handlers/trainer.rs b/gateway/src/world/handlers/trainer.rs index 6007151e5..d08429446 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`, 195 slice B). + /// `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)] diff --git a/gateway/src/world/mail.rs b/gateway/src/world/mail.rs index dcd051a49..863f6ecf3 100644 --- a/gateway/src/world/mail.rs +++ b/gateway/src/world/mail.rs @@ -8,6 +8,257 @@ 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, diff --git a/gateway/src/world/mod.rs b/gateway/src/world/mod.rs index 11b34cc53..7cf8dd2d2 100644 --- a/gateway/src/world/mod.rs +++ b/gateway/src/world/mod.rs @@ -79,11 +79,21 @@ 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, TaxiActionStore, + TradeStore, TrainerStore, VendorActionStore, +}; use login_queue::{Admission, LoginQueue}; +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. diff --git a/gateway/src/world/party.rs b/gateway/src/world/party.rs index 001c49509..45eab52f4 100644 --- a/gateway/src/world/party.rs +++ b/gateway/src/world/party.rs @@ -49,6 +49,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 diff --git a/gateway/src/world/social.rs b/gateway/src/world/social.rs index daa43fce1..58664fae8 100644 --- a/gateway/src/world/social.rs +++ b/gateway/src/world/social.rs @@ -13,6 +13,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. diff --git a/gateway/src/world/store.rs b/gateway/src/world/store.rs index c89c3f7e7..fbc222352 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,30 @@ 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 + + MailStore + + SocialStore + + NpcStore + + TrainerStore + + BankStore + + CombatStore + + DeathStore + + TradeStore + + AuctionActionStore + CastStore + ChannelActionStore + ChatActionStore + DuelActionStore + GuildActionStore + ItemActionStore - + LootWindowStore + MeetingStoneActionStore + MeleeActionStore + MemberStatsStore @@ -42,629 +45,116 @@ 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 + + MailStore + + SocialStore + + NpcStore + + TrainerStore + + BankStore + + CombatStore + + DeathStore + + TradeStore + + AuctionActionStore + + CastStore + + ChannelActionStore + + ChatActionStore + + 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 +195,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 +205,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 +212,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/tests.rs b/gateway/src/world/tests.rs index 08f127c17..ffdece6b1 100644 --- a/gateway/src/world/tests.rs +++ b/gateway/src/world/tests.rs @@ -1134,9 +1134,7 @@ impl InMemoryStore { self.rec(what); Ok(db) } -} -impl WorldStore for InMemoryStore { fn home_shard(&self, _character_guid: u64) -> Option> { let nth = self .home_shard_calls @@ -1147,9 +1145,32 @@ impl WorldStore for InMemoryStore { }; resolved.map(|h| h as std::sync::Arc) } - fn pending_system_messages(&self, _self_guid: u64) -> Vec { - self.pending_system_messages.clone() + + /// 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(()) } +} + +impl ShardRoutingStore for InMemoryStore { fn shard_name(&self) -> &str { &self.shard } @@ -1192,836 +1213,261 @@ impl WorldStore for InMemoryStore { (*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 realm_store(&self) -> Option> { + self.realm + .clone() + .map(|r| r as std::sync::Arc) } - 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, - }) + fn party_cleanup_realm(&self) -> Result>> { + if let Some(error) = &self.party_cleanup_realm_error { + return Err(anyhow!(error.clone())); + } + Ok(self.realm_store()) } - /// `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 - )); + fn party_command_realm(&self) -> Result>> { + if let Some(error) = &self.party_command_realm_error { + return Err(anyhow!(error.clone())); } - 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(()) + Ok(self.realm_store()) } - /// `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, - ), - ); + fn transfer_realm(&self) -> Result>> { + if let Some(error) = &self.transfer_realm_error { + return Err(anyhow!(error.clone())); } - Ok(()) + Ok(self.realm_store()) } - /// `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(()) + fn world_stores(&self) -> Vec> { + self.peers + .lock() + .unwrap() + .iter() + .map(|p| p.clone() as std::sync::Arc) + .collect() } - /// `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" - )); + fn party_command_worlds(&self) -> Result>> { + if let Some(error) = &self.world_shard_set_error { + return Err(anyhow!(error.clone())); } - lk(&db.characters).remove(&escrow.character_guid); - lk(&db.in_rows).remove(&transfer_id); - out.remove(&transfer_id); - Ok(()) + Ok(self.world_stores()) } +} - /// `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(()) +impl SessionStore for InMemoryStore { + fn pending_system_messages(&self, _self_guid: u64) -> Vec { + self.pending_system_messages.clone() } - fn release_player_transfer_arrival( + fn lookup_session(&self, account_name: &str) -> Result> { + Ok((account_name == self.username) + .then(|| self.session.clone()) + .flatten()) + } + + fn player_login( &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(()); + _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); + } } - lk(&db.in_rows).remove(&transfer_id); - lk(&db.arrival_sources).remove(&transfer_id); - Ok(()) + self.login_entity + .clone() + .ok_or_else(|| anyhow!("no login entity configured")) } - 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 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 realm_character_partition( + fn subscribe_player_events( &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 + _account_id: u64, + self_guid: u64, + arrival: &codec::EntityView, + tx: SessionTx, + ) -> Result { + self.rec("subscribe_player_events"); + self.subscribed .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, - })); + .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()); } - 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 Some(view) = &self.relay_view else { + return Ok(PlayerSubscriptions::empty()); }; - 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 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))); } - 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) + Ok(subs) } - fn finish_player_shard_index_transfer( + fn client_command( &self, - plan: &super::transfer::TransferPlan, - _source_map: u32, - _source_instance: u64, - source_revision: u64, + account_id: u64, + self_guid: u64, + cmd: String, + payload: String, ) -> 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 - }); + self.client_commands + .lock() + .unwrap() + .push((account_id, self_guid, cmd, payload)); 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")); + 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); } - Ok(()) + // `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 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 entity_max_health(&self, _guid: u64) -> u32 { + 100 } - fn finish_pending_shard_index_transfer( + fn claim_session(&self, account_id: u64, _character_guid: u64) -> Result { + Ok(WorldSessionToken { + account_id, + generation: 1, + request_nonce: 1, + }) + } + + fn bind_session( &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")); + _token: WorldSessionToken, + ) -> Result>> { + Ok(None) + } + + 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(()), } - *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 player_combat_until_ms(&self, _player_guid: u64) -> u64 { + self.combat_until_ms } +} - fn sync_transfer_pending(&self, character_guid: u64) -> Result<()> { - super::party::sync_transfer_arrival_mirror(self, character_guid)?; - self.xstep("sync_transfer_pending")?; - Ok(()) +impl CharacterStore for InMemoryStore { + 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 mark_bot_transfer_arrival_ready( + fn create_character( &self, - _intent_id: u64, - _bot_guid: u64, - _controller_generation: u64, - _claim_token: u64, - ) -> Result<()> { - self.xstep("mark_bot_transfer_arrival_ready")?; - Ok(()) + _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 bot_transfer_arrival_matches( + fn delete_character( &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, - )) + 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 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())); + 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()) } @@ -2042,35 +1488,11 @@ impl WorldStore for InMemoryStore { } 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 @@ -2078,2057 +1500,2639 @@ impl WorldStore for InMemoryStore { .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 effective_magic_resistances(&self, _guid: u64) -> [u32; 6] { + [0; 6] } - 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 spell_modifiers(&self, _character_guid: u64) -> Vec<(u32, u8, i32, bool)> { + Vec::new() // no modifier packets in the harness (login stays byte-identical) } - 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 player_learned_spells(&self, _player_guid: u64) -> Result> { + Ok(self.learned_spells.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 player_reputations(&self, _player_guid: u64) -> Result> { + Ok(self.reputations.clone()) } - fn mailbox_in_range(&self, mailbox_guid: u64, _player_guid: u64) -> Result { - self.rec("mailbox_in_range"); - Ok(self.mailboxes.contains(&mailbox_guid)) + + fn player_actions(&self, _player_guid: u64) -> Result> { + Ok(self.player_actions.clone()) } - /// 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)) +} + +impl TransferStore for InMemoryStore { + 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() { - Some((_, m)) => { - m.was_read = true; - Ok(()) - } - None => Err(anyhow!(lyracore_shared::mail::NOT_YOUR_MAIL)), + 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(), + }) } - /// 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(()) + + 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, + }) } - /// 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)); + + /// `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 m.check_flags & lyracore_shared::mail::CHECK_MASK_RETURNED != 0 { - return Err(anyhow!(lyracore_shared::mail::ALREADY_RETURNED)); + if out + .values() + .any(|e| e.character_guid == plan.character_guid) + { + return Err(anyhow!( + "character {} is already in transit", + plan.character_guid + )); } - 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), + 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(()) } - /// 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)); + /// `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")); } - ( - 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); + // 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" + )); } - return Err(e); + 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(()); } - 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; + if db.live(guid) { + return Err(anyhow!("character {guid} is already live on this shard")); } - 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, + // 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(()) } - 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)); - } + /// `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(()) } - /// 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)); + + /// `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 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)); + 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(()) } - /// 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) - { + + /// `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 self.bags_full.load(std::sync::atomic::Ordering::Relaxed) { - return Err(anyhow!(lyracore_shared::mail::INVENTORY_FULL)); + if lk(&db.bot_arrivals).contains_key(&transfer_id) { + return Err(anyhow!( + "transfer {transfer_id}: session-less arrival is owned by its Transfer Intent" + )); } - self.granted_letters - .lock() - .unwrap() - .push((payee_guid, item_text_id)); + lk(&db.in_rows).remove(&transfer_id); + lk(&db.arrival_sources).remove(&transfer_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)); + + 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(()); }; - m.check_flags |= lyracore_shared::mail::CHECK_FLAG_LETTER_GRANTED; + 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(()) } - /// 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())) + + 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, + }) } - /// 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( + + fn begin_shard_index_transfer( &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) + 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), + }); }; - self.credit(recipient_guid, money); - Ok(()) + 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) } - /// Models `mail_escrow::apply_fence`: the whole cost leaves the purse into a fence row here. - fn mail_fence( + + fn finish_player_shard_index_transfer( &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, + plan: &super::transfer::TransferPlan, + _source_map: u32, + _source_instance: u64, + source_revision: u64, ) -> 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 + 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")); } - 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)); + *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(()) } - /// Models `mail_escrow::apply_commit`: the row plus a receipt, idempotent on the escrow id. - fn mail_commit( + + fn bind_bot_transfer_locator( &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, + _intent: &super::transfer::BotTransferIntent, + source_revision: u64, + _claim_token: u64, ) -> 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) { + self.rec("bind_bot_transfer_locator"); + let Some(current) = *self.realm_partition.lock().unwrap() else { 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; - } + }; + if current.transfer_pending || current.revision != source_revision { + return Err(anyhow!("Transfer Intent Realm locator changed")); } 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) + + 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(()); } - 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)); + 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(()) } - /// Models `mail_escrow::apply_take_item_fence`: the ATTACHMENT leaves the row into a fence. - fn mail_take_item_fence( + + fn finish_pending_shard_index_transfer( &self, - escrow_id: u64, - payee_guid: u64, - mail_id: u64, - expect_entry: u32, + character_guid: u64, + destination_map: u32, + destination_instance: u64, + arrival: &super::transfer::TransferArrival, ) -> 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(()); + if arrival.character_guid != character_guid { + return Err(anyhow!("arrival fence names another Character")); } - 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 + let mut phase = self.realm_partition.lock().unwrap(); + let Some(current) = *phase else { + return Ok(()); }; - 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)); + 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(()) } - /// 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( + + 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, - escrow_id: u64, - payee_guid: u64, - _mail_id: u64, - item: mail::AttachedItem, + _intent_id: u64, + _bot_guid: u64, + _controller_generation: u64, + _claim_token: u64, ) -> 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)); + self.xstep("mark_bot_transfer_arrival_ready")?; Ok(()) } - /// Models `mail_escrow::apply_payout`: the credit plus a receipt, idempotent on the escrow id. - fn mail_payout( + + fn bot_transfer_arrival_matches( &self, - escrow_id: u64, - payee_guid: u64, - _mail_id: u64, - amount: u32, + 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.rec("mail_payout"); - self.mail_kill("mail_payout")?; - let mut receipts = self.mail_receipts.lock().unwrap(); - if receipts.iter().any(|(id, _)| *id == escrow_id) { + self.xstep("release_bot_transfer_arrival")?; + let Some(db) = self.xdb.as_ref() else { return Ok(()); - } - if !self - .purses - .lock() - .unwrap() - .iter() - .any(|(g, _)| *g == payee_guid) + }; + if transfer_id != intent.bot_guid + || intent.source_module_identity == spacetimedb_sdk::Identity::ZERO + || intent.id == 0 + || intent.created_micros <= 0 { - return Err(anyhow!(lyracore_shared::mail::NOT_IN_WORLD)); + 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); } - 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(()) + + 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}" + )); } - None => Err(anyhow!("mail escrow {escrow_id}: nothing fenced here")), + 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(()) } - /// 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 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 mail_escrows_of(&self, sender_guid: u64) -> Result> { - self.rec("mail_escrows_of"); +} + +impl PartyStore for InMemoryStore { + 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 - .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 + .members_in_transit .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 + .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 trainer_list( + + fn party_holder_observation( &self, - _player_guid: u64, - _trainer_guid: u64, - ) -> Result> { - Ok(self.trainer_spells.clone()) + character_guid: u64, + map_id: u32, + instance_id: u64, + ) -> Result { + Ok(super::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 buy_trainer_spell( + + // 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, - _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), + 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 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 group_accept(&self, _account_id: u64, _self_guid: u64) -> Result { + self.rec("group_accept"); + Ok(PartyOutcome::Ran) } - fn set_action_button( + + 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, - _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}")); + 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.reset_talents_calls + self.group_loot_methods .lock() .unwrap() - .push((account_id, self_guid, trainer_guid)); - Ok(()) + .push((loot_setting, master_guid, loot_threshold)); + Ok(PartyOutcome::Ran) } - 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); + + 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 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}")); + + 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); } - 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); + 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); } - // `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}")); + if !roster.has_member(bot_guid) + || (authority_member_guid != 0 && !roster.has_member(authority_member_guid)) + { + return Ok(super::party::CompanionCommandOutcome::NotMember); } - self.selected_targets.lock().unwrap().push(target_guid); - Ok(()) + Ok(super::party::CompanionCommandOutcome::Applied) } - 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")); + + 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(()) + Ok(outcome) } - fn pet_command( + + fn finish_party_command_intent( &self, - _account_id: u64, - _self_guid: u64, - _data: u32, - _target_guid: u64, + 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 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( + + fn confirm_party_command_receipt( &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 + 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() - .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(()) + .iter() + .find(|(source, id, _)| *source == source_identity && *id == intent_id) + .map(|(_, _, outcome)| *outcome)) } - 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 + + fn confirm_party_command_holder(&self, guid: u64) -> Result { + Ok( + if self + .party_command_in_transit .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(()), - } + .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 reclaim_corpse(&self, _account_id: u64, self_guid: u64, corpse_guid: u64) -> Result<()> { - self.reclaimed_corpses + + fn entity_partition(&self, guid: u64) -> Option<(u32, u64)> { + self.entity_partitions .lock() .unwrap() - .push((self_guid, corpse_guid)); - Ok(()) + .iter() + .find(|(candidate, _, _)| *candidate == guid) + .map(|(_, map, instance)| (*map, *instance)) } - fn resurrect_response(&self, _account_id: u64, self_guid: u64, accept: bool) -> Result<()> { - self.resurrect_responses + + fn claim_bot_invite_intent(&self, intent_id: u64) -> Result { + if let Some(refusal) = self + .bot_intent_claim_refusals .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(()), + .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 spirit_healer_res(&self, _account_id: u64, self_guid: u64, healer_guid: u64) -> Result<()> { - self.spirit_healer_calls + + fn admit_sessionless_group_action(&self, character_guid: u64) -> Result { + self.rec("admit_sessionless_group_action"); + if self + .sessionless_admission_unavailable .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 + .contains(&character_guid) + { + anyhow::bail!("World Shard admission unavailable"); + } + if let Some(refusal) = self + .sessionless_admission .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); - } + .get(&character_guid) + { + return Ok(PartyOutcome::Refused(*refusal)); } - Ok((friends, ignored)) - } - fn ignored_guids(&self, owner_guid: u64) -> Result> { - if let Some(e) = &self.contact_lists_error { - return Err(anyhow!("{e}")); + if !self.entity_in_world(character_guid) + || !matches!( + self.character_presence(character_guid), + Ok(Some((false, ..))) + ) + { + return Ok(PartyOutcome::Refused(GroupRefusal::ActorUnavailable)); } - Ok(self - .contacts + if self + .suppress_after_admission .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)) + .contains(&character_guid) + { + self.sessionless_admission + .lock() + .unwrap() + .insert(character_guid, GroupRefusal::ActionSuppressed); + } + Ok(PartyOutcome::Ran) } - 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( + + /// 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, - _account_id: u64, - _self_guid: u64, + op: u8, + actor_guid: u64, target_guid: u64, + arg_a: u8, + arg_b: u8, + arg_c: u64, ) -> Result { - self.rec("group_invite"); - if let Some(e) = &self.trade_error { - return faked_party(e); + 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}")), } - 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) + + /// 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 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 + .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 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.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 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.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 group_uninvite( + &self, + _account_id: u64, + _self_guid: u64, + _target_guid: u64, + ) -> Result { + self.rec("group_uninvite"); + Ok(PartyOutcome::Ran) + } +} + +impl MailStore for InMemoryStore { + 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(()) } - // 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 + + /// 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() - .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 + .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((self_guid, trade_slot, inv_slot)); + .push((payee_guid, item_text_id)); 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)); + + /// 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(()) } - fn set_trade_gold(&self, _account_id: u64, self_guid: u64, copper: u32) -> Result<()> { - self.rec("set_trade_gold"); - self.set_trade_golds + + /// 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() - .push((self_guid, copper)); - Ok(()) + .iter() + .find(|(id, _)| *id == item_text_id) + .map(|(_, text)| text.clone())) } - fn accept_trade(&self, _account_id: u64, self_guid: u64) -> Result<()> { - self.rec("accept_trade"); - self.accepted_trades.lock().unwrap().push(self_guid); - Ok(()) + + /// 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)) } - fn unaccept_trade(&self, _account_id: u64, self_guid: u64) -> Result<()> { - self.rec("unaccept_trade"); - self.unaccepted_trades.lock().unwrap().push(self_guid); + + /// 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(()) } - fn busy_trade(&self, _account_id: u64, self_guid: u64) -> Result<()> { - self.rec("busy_trade"); - self.busy_trades.lock().unwrap().push(self_guid); + + /// 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(()) } - fn ignore_trade(&self, _account_id: u64, self_guid: u64) -> Result<()> { - self.rec("ignore_trade"); - self.ignore_trades.lock().unwrap().push(self_guid); + + /// 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(()) } - 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( + + /// Models `mail_escrow::apply_take_fence`: the copper leaves the ROW into a fence here. + fn mail_take_money_fence( &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 + 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() - .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())); + .iter() + .any(|(_, e)| e.escrow_id == escrow_id) + { + return Ok(()); } - Ok(self.realm_store()) + 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(()) } - fn party_command_realm(&self) -> Result>> { - if let Some(error) = &self.party_command_realm_error { - return Err(anyhow!(error.clone())); + /// 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(()); } - Ok(self.realm_store()) + 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(()) } - fn transfer_realm(&self) -> Result>> { - if let Some(error) = &self.transfer_realm_error { - return Err(anyhow!(error.clone())); + /// 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(()); } - Ok(self.realm_store()) - } - - fn claim_party_command_intent(&self, intent_id: u64, claim_token: u64) -> Result<()> { - self.party_command_claims + drop(receipts); + self.store_snapshot(payee_guid, &item)?; + self.mail_receipts .lock() .unwrap() - .push((intent_id, claim_token)); + .push((escrow_id, payee_guid)); Ok(()) } - fn admit_party_command_authority( + /// Models `mail_escrow::apply_payout`: the credit plus a receipt, idempotent on the escrow id. + fn mail_payout( &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); + 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(()); } - expected_members.sort_unstable(); - let mut current_members = self - .party_command_authority_members + if !self + .purses .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)) + .iter() + .any(|(g, _)| *g == payee_guid) { - return Ok(super::party::CompanionCommandOutcome::NotMember); + 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")), } - Ok(super::party::CompanionCommandOutcome::Applied) } - fn apply_admitted_party_command( - &self, - command: &super::party::AdmittedCompanionCommand, - ) -> Result { - self.admitted_party_commands + /// 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() - .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, - )); + .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(()) + } } - 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())); + 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 - .party_command_receipts + .mail_escrows .lock() .unwrap() .iter() - .find(|(source, id, _)| *source == source_identity && *id == intent_id) - .map(|(_, _, outcome)| *outcome)) + .filter(|(owner, _)| *owner == sender_guid) + .map(|(_, e)| e.clone()) + .collect()) } +} - 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 - }, - ) +impl SocialStore for InMemoryStore { + 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 entity_partition(&self, guid: u64) -> Option<(u32, u64)> { - self.entity_partitions + 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(|(candidate, _, _)| *candidate == guid) - .map(|(_, map, instance)| (*map, *instance)) + .find(|(g, _)| *g == guid) + .map(|(_, e)| e.clone()) } - fn world_stores(&self) -> Vec> { - self.peers - .lock() - .unwrap() - .iter() - .map(|p| p.clone() as std::sync::Arc) - .collect() + fn character_in_transit(&self, guid: u64) -> bool { + self.members_between_places.lock().unwrap().contains(&guid) } - fn party_command_worlds(&self) -> Result>> { + 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(self.world_stores()) + Ok(()) } - 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 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 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"); + 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}")); } - if let Some(refusal) = self - .sessionless_admission - .lock() - .unwrap() - .get(&character_guid) - { - return Ok(PartyOutcome::Refused(*refusal)); + 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); + } } - if !self.entity_in_world(character_guid) - || !matches!( - self.character_presence(character_guid), - Ok(Some((false, ..))) - ) - { - return Ok(PartyOutcome::Refused(GroupRefusal::ActorUnavailable)); + Ok((friends, ignored)) + } + + fn ignored_guids(&self, owner_guid: u64) -> Result> { + if let Some(e) = &self.contact_lists_error { + return Err(anyhow!("{e}")); } - if self - .suppress_after_admission + Ok(self + .contacts .lock() .unwrap() - .contains(&character_guid) - { - self.sessionless_admission - .lock() - .unwrap() - .insert(character_guid, GroupRefusal::ActionSuppressed); - } - Ok(PartyOutcome::Ran) + .iter() + .filter(|&&(owner, _, is_ignore)| owner == owner_guid && is_ignore) + .map(|&(_, target, _)| target) + .collect()) } - /// The module's `realm_group_op`, modelled: the rules the ROUTING depends on, applied to the - /// authority this handle owns. - fn realm_group_op( + 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, - op: u8, - actor_guid: u64, + _account_id: u64, + _self_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()); - } + _target_race: u8, + ) -> Result { + if let Some(e) = &self.trade_error { + return faked_contact(e); } - 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}")), + 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); } - Ok(PartyOutcome::Ran) + 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) } - /// The visibility receipt: the op commits, then the Coordinator cache holds it. - fn realm_group_op_visible( + fn del_ignore( &self, - op: u8, - actor_guid: u64, + _account_id: u64, + _self_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 + ) -> Result { + self.remove_contact(target_guid, true) } +} - 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())); - } +impl NpcStore for InMemoryStore { + 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.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.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 inspect(&self, _account_id: u64, _self_guid: u64, target_guid: u64) -> Result<()> { + if let Some(e) = &self.trade_error { + return Err(anyhow!("{e}")); } - if self.is_realm { - return Ok(self.realm_cache(|p| p.group_of(character_guid).and_then(|g| p.roster(g)))); + // 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(self - .mirror + Ok(()) + } + + 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() - .iter() - .find(|r| r.has_member(character_guid)) - .cloned()) + .push((option_id, option_row_id)); + Ok(()) } +} - fn group_roster_by_id(&self, group_id: u64) -> Result> { - if self.is_realm { - return Ok(self.realm_cache(|p| p.roster(group_id))); +impl TrainerStore for InMemoryStore { + 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), } - 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 talent_grant_spell(&self, _talent_id: u32) -> u32 { + self.talent_grant } - 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())); + 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 learn_talent(&self, _account_id: u64, _self_guid: u64, _talent_id: u32) -> Result<()> { + match &self.trade_error { + Some(e) => Err(anyhow!("{e}")), + None => Ok(()), } - Ok(self - .mirror_revisions - .lock() - .unwrap() - .get(&group_id) - .copied()) } - fn party_member_guids(&self) -> Result> { - if !self.is_realm { - return Ok(Vec::new()); + 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}")); } - Ok(self - .party + self.reset_talents_calls .lock() .unwrap() - .members - .iter() - .map(|(_, guid)| *guid) - .collect()) + .push((account_id, self_guid, trainer_guid)); + Ok(()) } - 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()); - } + 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 superseded_old_rank(&self, _new_spell: u32, _player_guid: u64) -> Option { + self.trainer_superseded + } + + fn character_presence(&self, guid: u64) -> Result> { Ok(self - .mirror - .lock() - .unwrap() + .characters .iter() - .map(|roster| roster.group_id) - .collect()) + .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, + ) + })) } +} - 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 { +impl BankStore for InMemoryStore { + 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.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()); - } + self.auto_banked_items.lock().unwrap().push(slot); 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())); + 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}")); } - if let Some(refusal) = self.loot_action_refusal { - return Ok(LootActionStatus::Refused(refusal)); + self.bought_bank_slots.lock().unwrap().push(banker_guid); + Ok(()) + } +} + +impl CombatStore for InMemoryStore { + 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.loot_rolls - .lock() - .unwrap() - .push((corpse_guid, loot_slot, vote)); - Ok(LootActionStatus::Applied) + self.selected_targets.lock().unwrap().push(target_guid); + Ok(()) } - fn loot_master_give( + + fn pet_command( &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) + _data: u32, + _target_guid: u64, + ) -> Result<()> { + Ok(()) } - // --- Realm-wide loot rolls --- + fn set_sheathed(&self, _account_id: u64, self_guid: u64, state: u8) -> Result<()> { + self.sheathed.lock().unwrap().push((self_guid, state)); + Ok(()) + } +} - #[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}")); - } +impl DeathStore for InMemoryStore { + fn repop(&self, _account_id: u64, self_guid: u64) -> Result<()> { + self.repopped.lock().unwrap().push(self_guid); 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 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 pending_local_rolls(&self) -> Result> { - Ok(self.pending_rolls.lock().unwrap().clone()) + fn resurrect_response(&self, _account_id: u64, self_guid: u64, accept: bool) -> Result<()> { + self.resurrect_responses + .lock() + .unwrap() + .push((self_guid, accept)); + Ok(()) } - 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}")); + 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(()), } - self.settled_rolls + } + + fn spirit_healer_res(&self, _account_id: u64, self_guid: u64, healer_guid: u64) -> Result<()> { + self.spirit_healer_calls .lock() .unwrap() - .push((corpse_guid, slot, winner_guid)); + .push((self_guid, healer_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 + fn corpse_location(&self, _owner_guid: u64) -> Result> { + Ok(None) + } +} + +impl TradeStore for InMemoryStore { + // 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() - .retain(|r| r.roll_id != roll_id); + .push((self_guid, target_guid)); 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 begin_trade(&self, _account_id: u64, self_guid: u64) -> Result<()> { + self.rec("begin_trade"); + self.begun_trades.lock().unwrap().push(self_guid); + Ok(()) } - fn group_uninvite( - &self, - _account_id: u64, - _self_guid: u64, - _target_guid: u64, - ) -> Result { - self.rec("group_uninvite"); - Ok(PartyOutcome::Ran) + 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 gossip_select( + + fn set_trade_item( &self, _account_id: u64, - _self_guid: u64, - _npc_guid: u64, - option_id: u32, - option_row_id: u32, + self_guid: u64, + trade_slot: u8, + inv_slot: u8, ) -> Result<()> { - self.gossip_selects + self.rec("set_trade_item"); + self.set_trade_items .lock() .unwrap() - .push((option_id, option_row_id)); + .push((self_guid, trade_slot, inv_slot)); 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 + + 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((owner, target_guid, false)); - Ok(ContactOutcome::Done) + .push((self_guid, copper)); + Ok(()) } - fn del_friend( - &self, - _account_id: u64, - _self_guid: u64, - target_guid: u64, - ) -> Result { - self.remove_contact(target_guid, false) + + 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 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 unaccept_trade(&self, _account_id: u64, self_guid: u64) -> Result<()> { + self.rec("unaccept_trade"); + self.unaccepted_trades.lock().unwrap().push(self_guid); + Ok(()) } - fn del_ignore( - &self, - _account_id: u64, - _self_guid: u64, - target_guid: u64, - ) -> Result { - self.remove_contact(target_guid, true) + + 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(()) } } @@ -4370,6 +4374,62 @@ impl ChatActionStore for InMemoryStore { fn speaker_gm_level(&self, _speaker_guid: u64) -> Result { Ok(self.gm_level) } + + 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(()) + } + } + } } impl ChannelActionStore for InMemoryStore { @@ -5086,6 +5146,149 @@ impl LootWindowStore for InMemoryStore { .push((target_guid, loot_slot)); Ok(LootWindowRequestStatus::Applied) } + + 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) + } + + #[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 ns(s: &str) -> NormalizedString { 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..0aa2af4e1 100644 --- a/gateway/src/world/transfer_tests.rs +++ b/gateway/src/world/transfer_tests.rs @@ -216,7 +216,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. From 6bbc387aeab11c605b05632c889c4f8e087236bf Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 15:57:47 +0200 Subject: [PATCH 02/17] refactor(gateway): route the World Session through RoutedStore The World Session carried a home pin, a generic store and the on_home_shard! macro that picked between them at each call. Handlers were bound on all of WorldStore, so a signature did not show which families a handler needed. RoutedStore now holds the listener's handle and the pinned Home Shard. route_home pins only when settle_home_shard names a new shard, and leave_world clears the pin. run_world_session takes an Arc, and each frame dispatches against RoutedStore::current(). Every old-style handler and helper is bound on exactly the Store families it calls. Helpers that use their own store as the Realm-core handle on a single-database gateway keep the WorldStore bound. Co-Authored-By: Claude Opus 5.5 (1M context) --- gateway/src/codec/tests.rs | 2 +- gateway/src/realm_core.rs | 2 +- gateway/src/stdb/reads/account.rs | 4 +- gateway/src/stdb/subscriptions.rs | 7 +- gateway/src/world/coalesce.rs | 2 +- gateway/src/world/framing_tests.rs | 8 +- gateway/src/world/handlers/bank.rs | 2 +- gateway/src/world/handlers/cast/mod.rs | 4 +- gateway/src/world/handlers/channel.rs | 2 +- gateway/src/world/handlers/char.rs | 43 +++--- gateway/src/world/handlers/combat.rs | 2 +- gateway/src/world/handlers/guild.rs | 8 +- gateway/src/world/handlers/loot.rs | 4 +- gateway/src/world/handlers/mail.rs | 4 +- gateway/src/world/handlers/query.rs | 23 ++- gateway/src/world/handlers/quest.rs | 2 +- gateway/src/world/handlers/trade.rs | 2 +- gateway/src/world/handlers/trainer.rs | 4 +- gateway/src/world/loot.rs | 11 +- gateway/src/world/mail.rs | 49 +++--- gateway/src/world/mail_tests.rs | 28 ++-- gateway/src/world/member_stats_tests.rs | 2 +- gateway/src/world/mod.rs | 167 +++++++++------------ gateway/src/world/party.rs | 84 +++++++---- gateway/src/world/party_mirror.rs | 9 +- gateway/src/world/party_tests.rs | 2 +- gateway/src/world/presence.rs | 32 ++-- gateway/src/world/shard_routing_tests.rs | 16 +- gateway/src/world/social.rs | 43 ++++-- gateway/src/world/tests.rs | 102 +++++++------ gateway/src/world/transfer_tests.rs | 2 +- gateway/src/world/whisper.rs | 7 +- gateway/src/world/whisper_tests.rs | 2 +- gateway/src/world/who.rs | 5 +- gateway/src/world/wire_corruption_tests.rs | 6 +- 35 files changed, 387 insertions(+), 305 deletions(-) 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/realm_core.rs b/gateway/src/realm_core.rs index 92577e9b0..0731484a3 100644 --- a/gateway/src/realm_core.rs +++ b/gateway/src/realm_core.rs @@ -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 diff --git a/gateway/src/stdb/reads/account.rs b/gateway/src/stdb/reads/account.rs index 88c918036..f21f40d7f 100644 --- a/gateway/src/stdb/reads/account.rs +++ b/gateway/src/stdb/reads/account.rs @@ -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/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/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/framing_tests.rs b/gateway/src/world/framing_tests.rs index c32c0bda8..ffc232d4f 100644 --- a/gateway/src/world/framing_tests.rs +++ b/gateway/src/world/framing_tests.rs @@ -50,7 +50,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 +284,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 +332,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); @@ -382,7 +382,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. diff --git a/gateway/src/world/handlers/bank.rs b/gateway/src/world/handlers/bank.rs index 284fe9fd1..bfa810cc8 100644 --- a/gateway/src/world/handlers/bank.rs +++ b/gateway/src/world/handlers/bank.rs @@ -23,7 +23,7 @@ pub(crate) trait BankStore: Send + Sync { /// 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..824f16fcd 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. diff --git a/gateway/src/world/handlers/channel.rs b/gateway/src/world/handlers/channel.rs index ac4e4daa5..f4d3bb0c6 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 3ccae62a4..2a1737e91 100644 --- a/gateway/src/world/handlers/char.rs +++ b/gateway/src/world/handlers/char.rs @@ -81,7 +81,7 @@ pub(crate) trait CharacterStore: Send + Sync { /// `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, @@ -141,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, @@ -325,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, @@ -412,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 @@ -453,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 { @@ -482,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); @@ -525,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/combat.rs b/gateway/src/world/handlers/combat.rs index f4f67b64c..a4295ae42 100644 --- a/gateway/src/world/handlers/combat.rs +++ b/gateway/src/world/handlers/combat.rs @@ -29,7 +29,7 @@ pub(crate) trait CombatStore: Send + Sync { /// 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/guild.rs b/gateway/src/world/handlers/guild.rs index e794c23df..fbb0e3393 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<()> { diff --git a/gateway/src/world/handlers/loot.rs b/gateway/src/world/handlers/loot.rs index 8f747ee23..75be2bdab 100644 --- a/gateway/src/world/handlers/loot.rs +++ b/gateway/src/world/handlers/loot.rs @@ -542,7 +542,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 + LootWindowStore + NpcStore + ShardRoutingStore + ?Sized, +>( tx: &SessionTx, store: &St, conn: &mut WorldConn, 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/query.rs b/gateway/src/world/handlers/query.rs index 11c22a392..aa7e64350 100644 --- a/gateway/src/world/handlers/query.rs +++ b/gateway/src/world/handlers/query.rs @@ -85,7 +85,9 @@ pub(crate) trait NpcStore: Send + Sync { /// "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, @@ -124,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, @@ -158,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 + + ChatActionStore + + GuildActionStore + + NpcStore + + PartyStore + + QuestActionStore + + SessionStore + + ShardRoutingStore + + SocialStore + + 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/trade.rs b/gateway/src/world/handlers/trade.rs index 7e62bfb90..14eca1de4 100644 --- a/gateway/src/world/handlers/trade.rs +++ b/gateway/src/world/handlers/trade.rs @@ -51,7 +51,7 @@ pub(crate) trait TradeStore: Send + Sync { fn ignore_trade(&self, account_id: u64, self_guid: u64) -> Result<()>; } -pub(crate) fn handle_trade( +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 d08429446..95b15951d 100644 --- a/gateway/src/world/handlers/trainer.rs +++ b/gateway/src/world/handlers/trainer.rs @@ -121,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, @@ -359,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/loot.rs b/gateway/src/world/loot.rs index e2647e611..5a45ee7bd 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::{LootWindowStore, 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. +/// [`LootWindowStore::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 [`LootWindowStore::clear_promoted_loot_roll`] addresses. pub roll_id: u64, pub corpse_guid: u64, pub slot: u8, @@ -31,7 +32,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 +102,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 +140,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/mail.rs b/gateway/src/world/mail.rs index 863f6ecf3..0d5426742 100644 --- a/gateway/src/world/mail.rs +++ b/gateway/src/world/mail.rs @@ -2,6 +2,7 @@ //! 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}; @@ -298,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> { @@ -316,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, @@ -325,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 { @@ -333,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, @@ -345,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, @@ -357,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, @@ -369,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, @@ -395,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)?, @@ -409,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> { @@ -424,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, @@ -530,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()?; @@ -565,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, @@ -611,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 { @@ -647,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, @@ -675,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, @@ -726,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, @@ -765,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, @@ -856,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, @@ -900,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, @@ -935,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. @@ -943,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..c678fae77 100644 --- a/gateway/src/world/mail_tests.rs +++ b/gateway/src/world/mail_tests.rs @@ -255,7 +255,7 @@ fn an_empty_mailbox_is_answered_with_an_empty_list_packet() { 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); @@ -288,7 +288,7 @@ 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); @@ -616,7 +616,7 @@ 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); @@ -673,7 +673,7 @@ fn a_refused_return_still_acks_and_never_kills_the_session() { 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); @@ -723,7 +723,7 @@ 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); @@ -765,7 +765,7 @@ 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); @@ -822,7 +822,7 @@ fn a_refused_delete_still_acks_and_never_kills_the_session() { 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); @@ -1269,7 +1269,7 @@ fn send_over_the_wire( 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); @@ -1399,7 +1399,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); @@ -1831,7 +1831,7 @@ 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) } @@ -2769,7 +2769,7 @@ 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) } @@ -3094,7 +3094,7 @@ fn deleting_a_priced_mail_answers_the_internal_error_and_keeps_the_mail() { 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) } @@ -3510,7 +3510,7 @@ 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) } @@ -3558,7 +3558,7 @@ fn a_letter_copy_into_a_full_bag_over_the_wire_answers_equip_error() { 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) } diff --git a/gateway/src/world/member_stats_tests.rs b/gateway/src/world/member_stats_tests.rs index ca23142b9..18bde1ebc 100644 --- a/gateway/src/world/member_stats_tests.rs +++ b/gateway/src/world/member_stats_tests.rs @@ -650,7 +650,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 7cf8dd2d2..97ca8da39 100644 --- a/gateway/src/world/mod.rs +++ b/gateway/src/world/mod.rs @@ -376,16 +376,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 @@ -434,29 +426,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 @@ -468,22 +467,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 @@ -491,7 +485,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(()) @@ -501,7 +495,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 { @@ -509,18 +503,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 } @@ -566,9 +558,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()) } @@ -590,22 +582,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> { @@ -705,7 +693,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, @@ -1020,10 +1008,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()) } @@ -1052,22 +1037,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<()> { @@ -1122,7 +1103,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) { @@ -1145,9 +1126,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 { @@ -1165,8 +1145,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)?; } })(); @@ -1183,7 +1164,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); @@ -1195,7 +1176,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) => { @@ -1208,7 +1189,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) => { @@ -1222,17 +1203,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 @@ -1270,9 +1251,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<()> { @@ -1664,7 +1645,7 @@ fn dispatch( Ok(()) } -fn dispatch_raw_auction_browse( +fn dispatch_raw_auction_browse( tx: &SessionTx, store: &St, conn: &mut WorldConn, @@ -1692,7 +1673,7 @@ fn dispatch_raw_auction_browse( } } -fn forward_movement( +fn forward_movement( store: &St, conn: &mut WorldConn, opcode: u32, @@ -1952,12 +1933,14 @@ 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:#}"); } }); diff --git a/gateway/src/world/party.rs b/gateway/src/world/party.rs index 45eab52f4..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; @@ -351,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, @@ -578,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, @@ -592,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, @@ -673,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, @@ -686,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, @@ -763,7 +768,7 @@ pub enum PartyCommandHolder { InTransit, } -fn receipt_anywhere( +fn receipt_anywhere( source: &St, source_identity: spacetimedb_sdk::Identity, intent_id: u64, @@ -782,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, @@ -987,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> { @@ -998,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, @@ -1019,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, @@ -1034,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, @@ -1051,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 { @@ -1071,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) => {} @@ -1111,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, @@ -1208,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, @@ -1273,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)); } @@ -1288,7 +1308,7 @@ fn invite_gate(store: &St, target: u64) -> Result( +fn cross_faction_invite( store: &St, inviter: u64, target: u64, @@ -1412,7 +1432,7 @@ struct ServerLeave { previous_roster: Option, } -fn run_server_leave( +fn run_server_leave( store: &St, realm: &dyn WorldStore, leaver_guid: u64, @@ -1484,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 { @@ -1525,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(()); }; @@ -1587,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, @@ -1643,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, @@ -1713,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, @@ -1732,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; }; @@ -1754,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> { @@ -1793,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)) @@ -1802,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<()> { @@ -1848,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_tests.rs b/gateway/src/world/party_tests.rs index 447a7ceed..4aa8d8657 100644 --- a/gateway/src/world/party_tests.rs +++ b/gateway/src/world/party_tests.rs @@ -1734,7 +1734,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. 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/shard_routing_tests.rs b/gateway/src/world/shard_routing_tests.rs index 84026a3c4..b924ce269 100644 --- a/gateway/src/world/shard_routing_tests.rs +++ b/gateway/src/world/shard_routing_tests.rs @@ -118,7 +118,7 @@ pub(super) fn drive_routed_session( 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); @@ -336,7 +336,7 @@ 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) } @@ -375,7 +375,7 @@ 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 { @@ -413,7 +413,7 @@ fn a_spurious_worldport_ack_is_ignored_on_a_session_pinned_off_the_default_shard 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) } @@ -454,7 +454,7 @@ fn a_spurious_worldport_ack_is_ignored_on_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); CMSG_PLAYER_LOGIN { guid: Guid::new(1) } @@ -482,7 +482,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); @@ -528,7 +528,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 @@ -547,7 +547,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); diff --git a/gateway/src/world/social.rs b/gateway/src/world/social.rs index 58664fae8..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; @@ -131,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, @@ -346,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, @@ -373,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, @@ -405,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, @@ -426,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, @@ -452,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, @@ -481,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, @@ -634,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], @@ -675,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, @@ -745,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/tests.rs b/gateway/src/world/tests.rs index ffdece6b1..58f99f2f9 100644 --- a/gateway/src/world/tests.rs +++ b/gateway/src/world/tests.rs @@ -5378,7 +5378,7 @@ fn handshake_succeeds_and_traffic_is_encrypted_both_ways() { 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()) + let (mut conn, _encrypt) = world_handshake(&mut s, server_store.clone()) .unwrap() .expect("handshake should succeed"); assert_eq!(conn.account_id, 42); @@ -5418,7 +5418,7 @@ 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 = 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, @@ -5465,7 +5465,7 @@ fn a_silent_world_connection_is_closed_at_the_pre_auth_deadline() { let server = std::thread::spawn(move || { run_world_session_with_queue_and_deadline( server_end, - &store, + std::sync::Arc::new(store), &LoginQueue::unlimited(), &mut deadline, ) @@ -5509,7 +5509,7 @@ fn slow_auth_session_bytes_cannot_extend_the_pre_auth_deadline() { let result = world_handshake_with_queue_and_deadline( &mut stream, - &store, + std::sync::Arc::new(store), &LoginQueue::unlimited(), &mut deadline, ); @@ -5547,7 +5547,7 @@ fn world_challenge_write_uses_the_absolute_pre_auth_deadline() { let result = world_handshake_with_queue_and_deadline( &mut stream, - &store, + std::sync::Arc::new(store), &LoginQueue::unlimited(), &mut deadline, ); @@ -5581,7 +5581,7 @@ fn post_auth_world_traffic_has_no_pre_auth_deadline() { let server = std::thread::spawn(move || { run_world_session_with_queue_and_deadline( server_end, - server_store.as_ref(), + server_store.clone(), &LoginQueue::unlimited(), &mut deadline, ) @@ -5614,7 +5614,7 @@ fn queued_handshake_sends_wait_queue_then_admits_once_a_seat_frees() { 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) + 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); @@ -5665,7 +5665,7 @@ fn disconnecting_while_queued_leaves_the_line_without_taking_a_seat() { 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(); + 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" @@ -5721,7 +5721,7 @@ fn a_restarted_gateway_completes_the_handshake_from_realm_state_alone() { 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) + let established = world_handshake(&mut s, std::sync::Arc::new(store)) .unwrap() .expect("handshake should succeed"); established.0.account_id @@ -5768,7 +5768,9 @@ fn a_gateway_that_cannot_reach_the_session_store_rejects_rather_than_guessing() let server = std::thread::spawn(move || { let mut s = server_end; assert!( - world_handshake(&mut s, &store).unwrap().is_none(), + world_handshake(&mut s, std::sync::Arc::new(store)) + .unwrap() + .is_none(), "no session material ⇒ no session, never a best-effort one" ); }); @@ -5800,7 +5802,7 @@ fn bad_proof_is_rejected() { 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(); + let conn = world_handshake(&mut s, std::sync::Arc::new(store)).unwrap(); assert!(conn.is_none(), "bad proof must not establish a connection"); }); @@ -5842,7 +5844,9 @@ fn unknown_account_is_rejected_cleanly() { 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()); + assert!(world_handshake(&mut s, std::sync::Arc::new(store)) + .unwrap() + .is_none()); }); let (_enc, _dec) = drive_auth(&mut client, "NOBODY", K); @@ -5884,7 +5888,7 @@ fn char_enum_returns_the_seeded_character() { 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(); }); // Full handshake, then request the character list over the encrypted channel. @@ -5930,7 +5934,7 @@ fn guild_query_answers_at_character_select() { 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); @@ -6018,7 +6022,7 @@ fn a_member_enters_the_world_with_its_guild_on_the_self_create_and_signs_on() { 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) } @@ -6337,7 +6341,7 @@ fn a_world_port_that_fails_after_sign_on_still_signs_off() { }); 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 = 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) @@ -6380,7 +6384,7 @@ fn char_create_replies_success_then_name_in_use() { 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); @@ -6439,7 +6443,7 @@ fn char_delete_replies_success_and_dispatches_owned_guid() { 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); @@ -6467,7 +6471,7 @@ fn char_delete_failure_replies_failed_and_keeps_session_alive() { 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); @@ -6662,7 +6666,7 @@ fn char_delete_of_a_guild_leader_replies_failed_and_deletes_nothing() { 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); @@ -6764,7 +6768,7 @@ fn login_replays_a_pending_package_system_message() { 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); @@ -6808,7 +6812,7 @@ fn player_login_emits_sequence_then_self_create() { 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); @@ -6892,7 +6896,7 @@ 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(); + 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) } @@ -6946,7 +6950,7 @@ fn world_session_in_world( ) -> (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(); + run_world_session(server_end, store.clone()).unwrap(); }); let (mut c_enc, mut c_dec) = client_handshake(&mut client, "TESTER", K); CMSG_PLAYER_LOGIN { @@ -7079,7 +7083,7 @@ fn worldport_ack_reenters_with_fresh_subscription_and_empty_loot_state() { 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); @@ -7176,7 +7180,7 @@ fn worldport_removes_the_source_viewer_before_routing_and_registers_a_replacemen 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) } @@ -7232,7 +7236,7 @@ fn login_initialize_factions_carries_persisted_standing_at_its_reputation_index( 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); @@ -7293,7 +7297,7 @@ fn login_with_resident_items_and_reputation_emits_no_gain_feedback() { 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) } @@ -7346,7 +7350,7 @@ fn login_fills_a_resident_suffix_items_enchantment_slots_after_the_entry_batch() 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) } @@ -7382,7 +7386,7 @@ fn inbound_movement_is_recorded_under_its_opcode() { 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, _c_dec) = client_handshake(&mut client, "TESTER", K); @@ -7432,7 +7436,7 @@ fn a_movement_packet_for_a_despawned_entity_never_kills_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())); + 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 { @@ -7505,7 +7509,7 @@ fn a_reappearing_entity_resets_the_movement_desync_tolerance() { }); let (mut client, server_end) = world_session_socket_pair(); let server_store = store.clone(); - let server = std::thread::spawn(move || run_world_session(server_end, server_store.as_ref())); + 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(), @@ -7590,7 +7594,7 @@ fn a_movement_failure_that_is_not_a_desync_is_still_session_fatal() { }); let (mut client, server_end) = world_session_socket_pair(); let server_store = store.clone(); - let server = std::thread::spawn(move || run_world_session(server_end, server_store.as_ref())); + 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 { @@ -7628,7 +7632,7 @@ fn a_movement_desync_that_never_heals_still_ends_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())); + 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(), @@ -7693,7 +7697,7 @@ fn a_world_port_whose_transfer_cannot_be_driven_aborts_the_clients_loading_scree 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 = 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) @@ -7763,7 +7767,7 @@ fn a_world_port_whose_world_entry_fails_also_aborts_the_clients_loading_screen() 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 = 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) @@ -8049,7 +8053,7 @@ fn enter_world( 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(); + 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 { @@ -8275,7 +8279,7 @@ fn a_group_invite_timeout_is_not_answered_as_a_refusal() { 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())) + .send(run_world_session(server_end, server_store.clone())) .unwrap(); }); let (mut c_enc, mut c_dec) = client_handshake(&mut client, "TESTER", K); @@ -8725,7 +8729,7 @@ fn an_add_friend_timeout_is_not_answered_as_a_refusal() { 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())) + .send(run_world_session(server_end, server_store.clone())) .unwrap(); }); let (mut c_enc, mut c_dec) = client_handshake(&mut client, "TESTER", K); @@ -8893,7 +8897,7 @@ fn login_sends_the_quest_log_descriptor_raw_update_after_the_create_packet() { 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) } @@ -9304,7 +9308,7 @@ fn item_action_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 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); CMSG_AUTOEQUIP_ITEM { @@ -9335,7 +9339,7 @@ fn item_reducer_transport_loss_ends_the_world_session() { 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())) + .send(run_world_session(server_end, server_store.clone())) .unwrap(); }); let (mut c_enc, mut c_dec) = client_handshake(&mut client, "TESTER", K); @@ -9836,7 +9840,7 @@ fn a_cast_before_entering_the_world_answers_nothing_and_keeps_the_session_alive( 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); @@ -10038,7 +10042,7 @@ 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 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); CMSG_LOOT { @@ -10484,7 +10488,7 @@ fn melee_opcodes_at_character_select_answer_nothing_and_keep_the_session_alive() 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_ATTACKSWING { @@ -10523,7 +10527,7 @@ fn attackswing_desync_error_is_session_fatal() { 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 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) @@ -10927,7 +10931,7 @@ fn a_trainer_reducer_timeout_is_not_answered_as_a_refusal() { 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())) + .send(run_world_session(server_end, server_store.clone())) .unwrap(); }); let (mut c_enc, mut c_dec) = client_handshake(&mut client, "TESTER", K); @@ -13186,7 +13190,7 @@ fn reducer_transport_loss_ends_an_admitted_session_and_frees_one_queue_seat() { 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()); + run_world_session_with_queue(server_end, server_store.clone(), server_queue.as_ref()); result_tx.send(result).unwrap(); }); @@ -13291,7 +13295,7 @@ fn competing_world_sessions_close_the_old_socket_without_removing_the_winner() { let (done, result) = std::sync::mpsc::channel(); std::thread::spawn(move || { let _entered = runtime.enter(); - let _ = done.send(run_world_session(server, &coord)); + let _ = done.send(run_world_session(server, std::sync::Arc::new(coord))); }); (client, result) } diff --git a/gateway/src/world/transfer_tests.rs b/gateway/src/world/transfer_tests.rs index 0aa2af4e1..520eb8875 100644 --- a/gateway/src/world/transfer_tests.rs +++ b/gateway/src/world/transfer_tests.rs @@ -838,7 +838,7 @@ fn a_failed_transfer_fails_the_login_instead_of_entering_the_world_anyway() { ..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) 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..ba095ab45 100644 --- a/gateway/src/world/whisper_tests.rs +++ b/gateway/src/world/whisper_tests.rs @@ -340,7 +340,7 @@ fn a_real_session_whispers_across_shards_as_its_own_character() { 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 { 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/wire_corruption_tests.rs b/gateway/src/world/wire_corruption_tests.rs index 97fec4da5..64acb1ad5 100644 --- a/gateway/src/world/wire_corruption_tests.rs +++ b/gateway/src/world/wire_corruption_tests.rs @@ -53,7 +53,7 @@ fn movement_burst_over_a_real_cipher(n: usize) { 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) @@ -322,7 +322,7 @@ fn oversized_raw_body_ends_the_session_instead_of_wrapping_the_size_field() { 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) @@ -420,7 +420,7 @@ fn combat_cast_burst_over_a_real_cipher(n: usize) { 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) From cea792f5dc89022b724444055225d9d590ba6970 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 16:03:05 +0200 Subject: [PATCH 03/17] docs(gateway): carry the comment sweep into the moved Store declarations Co-Authored-By: Claude Opus 5.5 (1M context) --- gateway/src/world/handlers/death.rs | 6 +++--- gateway/src/world/handlers/trainer.rs | 2 +- gateway/src/world/tests.rs | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/gateway/src/world/handlers/death.rs b/gateway/src/world/handlers/death.rs index 926022347..ac5f56db0 100644 --- a/gateway/src/world/handlers/death.rs +++ b/gateway/src/world/handlers/death.rs @@ -4,12 +4,12 @@ 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, slice 4): the module + /// 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`, slice 5): the module validates the caller + /// 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<()>; @@ -28,6 +28,6 @@ pub(crate) trait DeathStore: Send + Sync { /// 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` (slice 5). + /// 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/trainer.rs b/gateway/src/world/handlers/trainer.rs index 95b15951d..be61585a3 100644 --- a/gateway/src/world/handlers/trainer.rs +++ b/gateway/src/world/handlers/trainer.rs @@ -51,7 +51,7 @@ pub(crate) trait TrainerStore: Send + Sync { action_type: u8, ) -> Result<()>; - /// Persist the rep pane's At-War checkbox (`CMSG_SET_FACTION_ATWAR`, 195 slice B). + /// 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, diff --git a/gateway/src/world/tests.rs b/gateway/src/world/tests.rs index 58f99f2f9..cc08e0e6d 100644 --- a/gateway/src/world/tests.rs +++ b/gateway/src/world/tests.rs @@ -4055,7 +4055,7 @@ impl DeathStore for InMemoryStore { } impl TradeStore for InMemoryStore { - // Trade : pure recorders, the module owns every gate, so the fake just proves which + // 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"); From 23f8e849784d1731744ccc5642c29c4f34ae4707 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 16:10:08 +0200 Subject: [PATCH 04/17] refactor(gateway): give Loot Rolls and Speech their own Store families LootWindowStore carried the eight Loot Roll methods and ChatActionStore the three Speech methods, so the loot window, chat and channel Fakes held unreachable stubs for calls their dispatchers never make. LootRollStore (world/loot.rs) now holds the vote, the master looter's assignment and the promotion relay's reads and requests. SpeechStore (handlers/chat.rs) holds say, yell and `/e` lines, text emotes and the GM dot-command. Each caller is bounded on the families it calls, and the Coordinator adapters move to stdb/store. Speech joins both glossaries. The Module comments that named the deleted home_shard resolver now name locate_home_shard, which settle_home_shard runs at every world entry. Co-Authored-By: Claude Opus 5.5 (1M context) --- CONTEXT.md | 7 +- CORE_TERMS.md | 5 + gateway/src/stdb/store/loot_roll.rs | 87 +++++++++++ gateway/src/stdb/store/mod.rs | 2 + gateway/src/stdb/store/speech.rs | 35 +++++ gateway/src/world/handlers/channel.rs | 26 ---- gateway/src/world/handlers/chat.rs | 55 +------ gateway/src/world/handlers/loot.rs | 216 +------------------------- gateway/src/world/handlers/mod.rs | 2 +- gateway/src/world/handlers/query.rs | 4 +- gateway/src/world/loot.rs | 84 +++++++++- gateway/src/world/mod.rs | 5 +- gateway/src/world/store.rs | 4 + gateway/src/world/tests.rs | 4 + module/src/realm_core.rs | 22 ++- module/src/transfer/mod.rs | 11 +- 16 files changed, 246 insertions(+), 323 deletions(-) create mode 100644 gateway/src/stdb/store/loot_roll.rs create mode 100644 gateway/src/stdb/store/speech.rs diff --git a/CONTEXT.md b/CONTEXT.md index a69bad899..f9413ad22 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -37,7 +37,7 @@ A World of Warcraft 1.12.1 server. Game state lives in SpacetimeDB; the gateway **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 diff --git a/CORE_TERMS.md b/CORE_TERMS.md index 8bc604fd8..550543f7c 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 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/mod.rs b/gateway/src/stdb/store/mod.rs index da45f6176..c72d6a6d1 100644 --- a/gateway/src/stdb/store/mod.rs +++ b/gateway/src/stdb/store/mod.rs @@ -9,12 +9,14 @@ 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/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/world/handlers/channel.rs b/gateway/src/world/handlers/channel.rs index f4d3bb0c6..c72d48fa1 100644 --- a/gateway/src/world/handlers/channel.rs +++ b/gateway/src/world/handlers/channel.rs @@ -575,32 +575,6 @@ mod tests { fn speaker_gm_level(&self, _speaker_guid: u64) -> Result { Ok(0) } - - fn send_chat( - &self, - _account_id: u64, - _self_guid: u64, - _chat_type: u8, - _language: u8, - _message: String, - ) -> Result { - unreachable!("the channel dispatcher never speaks a say, yell or emote line") - } - - fn send_emote( - &self, - _account_id: u64, - _self_guid: u64, - _text_emote: u32, - _emote_anim: u32, - _target_guid: u64, - ) -> Result<()> { - unreachable!("the channel dispatcher never performs a text emote") - } - - fn gm_command(&self, _account_name: &str, _self_guid: u64, _text: String) -> Result<()> { - unreachable!("the channel dispatcher never runs a GM command") - } } impl ChannelActionStore for InMemoryChannelActions { diff --git a/gateway/src/world/handlers/chat.rs b/gateway/src/world/handlers/chat.rs index a91a504be..a088f5052 100644 --- a/gateway/src/world/handlers/chat.rs +++ b/gateway/src/world/handlers/chat.rs @@ -82,7 +82,10 @@ pub(crate) trait ChatActionStore: Send + Sync { /// The speaker's GM level, read on its Home Shard. The Chat Flood Limiter never mutes a /// Character above 0. fn speaker_gm_level(&self, speaker_guid: u64) -> Result; +} +/// 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. @@ -143,32 +146,6 @@ impl ChatActionStore for crate::stdb::Coordinator { fn speaker_gm_level(&self, speaker_guid: u64) -> Result { Ok(crate::stdb::Coordinator::home_gm_level(self, speaker_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) - } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -538,32 +515,6 @@ mod tests { fn speaker_gm_level(&self, _speaker_guid: u64) -> Result { Ok(0) } - - fn send_chat( - &self, - _account_id: u64, - _self_guid: u64, - _chat_type: u8, - _language: u8, - _message: String, - ) -> Result { - unreachable!("the chat action dispatcher never speaks a say, yell or emote line") - } - - fn send_emote( - &self, - _account_id: u64, - _self_guid: u64, - _text_emote: u32, - _emote_anim: u32, - _target_guid: u64, - ) -> Result<()> { - unreachable!("the chat action dispatcher never performs a text emote") - } - - fn gm_command(&self, _account_name: &str, _self_guid: u64, _text: String) -> Result<()> { - unreachable!("the chat action dispatcher never runs a GM command") - } } fn speaker() -> SpeakerFacts { diff --git a/gateway/src/world/handlers/loot.rs b/gateway/src/world/handlers/loot.rs index 75be2bdab..35cd5108f 100644 --- a/gateway/src/world/handlers/loot.rs +++ b/gateway/src/world/handlers/loot.rs @@ -43,78 +43,6 @@ pub(crate) trait LootWindowStore: Send + Sync { target_guid: u64, loot_slot: u8, ) -> Result; - - // 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; } /// How the Module answered a loot Durable Request. A Refusal is an outcome; a timeout or transport @@ -228,83 +156,6 @@ impl LootWindowStore for crate::stdb::Coordinator { ) -> Result { crate::stdb::Coordinator::take_loot(self, account_id, actor_guid, target_guid, loot_slot) } - - 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) - } } /// The target whose loot window is currently open for this world session. @@ -543,7 +394,7 @@ 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< - St: DeathStore + LootWindowStore + NpcStore + ShardRoutingStore + ?Sized, + St: DeathStore + LootRollStore + LootWindowStore + NpcStore + ShardRoutingStore + ?Sized, >( tx: &SessionTx, store: &St, @@ -888,71 +739,6 @@ mod tests { )); request_status(self.item_take_refusal, &self.item_take_fatal_error) } - - 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!("the loot window never runs a Loot Roll op") - } - - fn realm_loot_vote( - &self, - _corpse_guid: u64, - _slot: u8, - _actor_guid: u64, - _vote: u8, - ) -> Result { - unreachable!("the loot window never casts a Loot Roll vote") - } - - fn pending_local_rolls(&self) -> Result> { - unreachable!("the loot window never reads pending Loot Rolls") - } - - fn settle_loot_roll(&self, _corpse_guid: u64, _slot: u8, _winner_guid: u64) -> Result<()> { - unreachable!("the loot window never settles a Loot Roll") - } - - fn clear_promoted_loot_roll(&self, _roll_id: u64) -> Result<()> { - unreachable!("the loot window never clears a promoted Loot Roll") - } - - fn loot_won_since(&self, _after_id: u64) -> Result<(u64, Vec<(u64, u8, u64)>)> { - unreachable!("the loot window never reads won Loot Rolls") - } - - fn loot_roll( - &self, - _account_id: u64, - _self_guid: u64, - _corpse_guid: u64, - _loot_slot: u32, - _vote: u8, - ) -> Result { - unreachable!("the loot window never records a Loot Roll vote") - } - - fn loot_master_give( - &self, - _account_id: u64, - _self_guid: u64, - _corpse_guid: u64, - _loot_slot: u8, - _target_guid: u64, - ) -> Result { - unreachable!("the loot window never assigns master loot") - } } fn player() -> LootWindowPlayer { diff --git a/gateway/src/world/handlers/mod.rs b/gateway/src/world/handlers/mod.rs index fe4ac52f7..eddd6d3d7 100644 --- a/gateway/src/world/handlers/mod.rs +++ b/gateway/src/world/handlers/mod.rs @@ -53,7 +53,7 @@ pub(crate) use channel::{resolve_online_character, ResolvedTarget}; 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, CombatStore}; pub(crate) use death::DeathStore; diff --git a/gateway/src/world/handlers/query.rs b/gateway/src/world/handlers/query.rs index aa7e64350..7de7b3c0b 100644 --- a/gateway/src/world/handlers/query.rs +++ b/gateway/src/world/handlers/query.rs @@ -126,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, @@ -163,7 +163,6 @@ fn speak_nearby( pub(crate) fn handle_query< St: CastStore + CharacterStore - + ChatActionStore + GuildActionStore + NpcStore + PartyStore @@ -171,6 +170,7 @@ pub(crate) fn handle_query< + SessionStore + ShardRoutingStore + SocialStore + + SpeechStore + TaxiActionStore + TrainerStore + VendorActionStore diff --git a/gateway/src/world/loot.rs b/gateway/src/world/loot.rs index 5a45ee7bd..c7391de53 100644 --- a/gateway/src/world/loot.rs +++ b/gateway/src/world/loot.rs @@ -1,16 +1,16 @@ //! Realm loot routing. See `docs/realm-loot-routing.md` for the contract and rationale. -use crate::world::{LootWindowStore, ShardRoutingStore}; +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 — -/// [`LootWindowStore::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 [`LootWindowStore::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, @@ -23,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 → @@ -32,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, diff --git a/gateway/src/world/mod.rs b/gateway/src/world/mod.rs index 97ca8da39..db6761279 100644 --- a/gateway/src/world/mod.rs +++ b/gateway/src/world/mod.rs @@ -82,10 +82,11 @@ pub(crate) use handlers::{ pub(crate) use handlers::{ AuctionActionStore, BankStore, CastStore, ChannelActionStore, CharacterStore, ChatActionStore, CombatStore, DeathStore, DuelActionStore, GuildActionStore, ItemActionStore, LootWindowStore, - MeetingStoneActionStore, MeleeActionStore, NpcStore, QuestActionStore, TaxiActionStore, - TradeStore, TrainerStore, VendorActionStore, + 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; diff --git a/gateway/src/world/store.rs b/gateway/src/world/store.rs index fbc222352..b63e17015 100644 --- a/gateway/src/world/store.rs +++ b/gateway/src/world/store.rs @@ -20,6 +20,7 @@ pub trait WorldStore: + TransferStore + PartyStore + LootWindowStore + + LootRollStore + MailStore + SocialStore + NpcStore @@ -32,6 +33,7 @@ pub trait WorldStore: + CastStore + ChannelActionStore + ChatActionStore + + SpeechStore + DuelActionStore + GuildActionStore + ItemActionStore @@ -54,6 +56,7 @@ impl WorldStore for T where + TransferStore + PartyStore + LootWindowStore + + LootRollStore + MailStore + SocialStore + NpcStore @@ -66,6 +69,7 @@ impl WorldStore for T where + CastStore + ChannelActionStore + ChatActionStore + + SpeechStore + DuelActionStore + GuildActionStore + ItemActionStore diff --git a/gateway/src/world/tests.rs b/gateway/src/world/tests.rs index cc08e0e6d..d273be57c 100644 --- a/gateway/src/world/tests.rs +++ b/gateway/src/world/tests.rs @@ -4374,7 +4374,9 @@ impl ChatActionStore for InMemoryStore { fn speaker_gm_level(&self, _speaker_guid: u64) -> Result { Ok(self.gm_level) } +} +impl SpeechStore for InMemoryStore { fn send_chat( &self, _account_id: u64, @@ -5146,7 +5148,9 @@ impl LootWindowStore for InMemoryStore { .push((target_guid, loot_slot)); Ok(LootWindowRequestStatus::Applied) } +} +impl LootRollStore for InMemoryStore { fn loot_roll( &self, _account_id: u64, 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 From 7749cf29cda22d4c51790e3c6bce467d647455a4 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 16:20:36 +0200 Subject: [PATCH 05/17] test(gateway): rename InMemoryStore to WorldFake and group its state by Store family The shared Fake held 223 flat fields. Each field now sits in the state struct of the Store family whose methods read it, so a test reads `store..x`. Three fixtures several families read stay on WorldFake itself: characters, npc_refuses and trade_error. The field moves and the struct literals were rewritten mechanically and checked by the compiler. No test changes what it asserts. Co-Authored-By: Claude Opus 5.5 (1M context) --- gateway/src/world/alpha_test_tools_tests.rs | 32 +- gateway/src/world/framing_tests.rs | 33 +- gateway/src/world/loot_tests.rs | 125 +- gateway/src/world/mail_tests.rs | 835 +++-- gateway/src/world/member_stats_tests.rs | 153 +- gateway/src/world/party_mirror_tests.rs | 17 +- gateway/src/world/party_tests.rs | 1097 ++++-- gateway/src/world/presence_tests.rs | 133 +- gateway/src/world/shard_routing_tests.rs | 229 +- gateway/src/world/tests.rs | 3633 ++++++++++++------- gateway/src/world/trade_tests.rs | 66 +- gateway/src/world/transfer_tests.rs | 327 +- gateway/src/world/whisper_tests.rs | 144 +- gateway/src/world/who_tests.rs | 35 +- gateway/src/world/wire_corruption_tests.rs | 47 +- 15 files changed, 4403 insertions(+), 2503 deletions(-) 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/framing_tests.rs b/gateway/src/world/framing_tests.rs index ffc232d4f..981361baa 100644 --- a/gateway/src/world/framing_tests.rs +++ b/gateway/src/world/framing_tests.rs @@ -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() }) } @@ -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 \ @@ -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/loot_tests.rs b/gateway/src/world/loot_tests.rs index 077b70f8b..0df3cf3af 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,8 @@ 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()); } diff --git a/gateway/src/world/mail_tests.rs b/gateway/src/world/mail_tests.rs index c678fae77..18ad32d6a 100644 --- a/gateway/src/world/mail_tests.rs +++ b/gateway/src/world/mail_tests.rs @@ -16,38 +16,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 +101,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 +214,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 +247,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,20 +262,29 @@ 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(); @@ -468,7 +502,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 +519,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 +539,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, @@ -668,7 +702,7 @@ 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(); @@ -817,7 +851,7 @@ 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(); @@ -881,22 +915,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 +947,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 +1095,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 +1112,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 +1149,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 +1164,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 +1260,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 +1283,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 +1299,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,23 +1325,32 @@ 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(); @@ -1323,32 +1410,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 +1461,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 +1473,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 +1488,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); } @@ -1463,7 +1569,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 +1581,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 +1605,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 +1624,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 +1633,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 +1654,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 +1669,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 +1679,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 +1688,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 +1722,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 +1743,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 +1759,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 +1780,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 +1814,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 +1823,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 +1839,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 +1849,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 +1958,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, @@ -1862,8 +2004,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 +2019,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 +2122,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 +2148,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 +2173,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 +2206,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 +2233,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 +2273,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 +2282,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 +2309,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 +2326,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 +2377,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 +2401,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 +2409,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 +2426,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 +2493,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 +2514,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 +2585,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 +2625,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 +2701,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 +2738,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 +2760,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 +2768,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 +2776,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 +2800,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 +2813,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 +2821,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 +2832,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 +2847,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 +2866,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 +2877,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 +2902,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 +2943,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, @@ -2801,26 +2992,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 +3174,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 +3214,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 +3253,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,7 +3287,7 @@ 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(); @@ -3126,15 +3324,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 +3343,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 +3386,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 +3405,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 +3423,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 +3441,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 +3460,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 +3492,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 +3522,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 +3532,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 +3560,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 +3572,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 +3597,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 +3615,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 +3627,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 +3645,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 +3658,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 +3676,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 +3702,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(), @@ -3545,13 +3753,14 @@ 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); @@ -3594,9 +3803,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 +3819,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 +3887,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 +3956,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 +3974,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 +3995,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 +4011,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 +4037,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 +4074,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 +4083,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 +4120,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/member_stats_tests.rs b/gateway/src/world/member_stats_tests.rs index 18bde1ebc..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)); 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 4aa8d8657..1d38f6133 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)); @@ -1762,7 +2037,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" ); @@ -1837,7 +2112,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 +2149,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 +2180,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 +2215,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 +2232,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 +2261,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 +2278,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 +2293,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 +2302,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 +2312,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 +2344,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 +2358,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 +2376,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 +2384,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 +2399,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 +2429,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 +2444,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 +2468,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 +2481,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 +2523,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 +2533,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 +2544,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 +2556,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 +2567,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 +2576,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 +2584,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 +2596,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 +2604,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 +2616,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 +2634,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 +2649,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 +2664,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 +2685,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 +2734,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 +2764,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 +2803,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 +2813,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 +2825,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 +2848,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 +2863,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 +2883,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 +2915,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 +3012,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 +3031,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 +3064,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 +3077,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 +3092,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 +3108,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 +3119,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 +3153,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 +3170,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 +3196,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 +3234,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 +3268,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 +3289,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 +3311,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 +3337,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 +3362,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 +3392,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 +3415,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 +3469,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 +3532,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 +3554,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 +3569,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 +3604,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 +3624,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 +3642,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 +3654,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 +3676,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 +3717,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 +3735,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 +3768,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 +3807,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 +3866,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 +3913,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 +3981,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 +4026,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 +4052,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 +4165,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 +4179,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 +4198,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 +4224,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 +4271,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 +4300,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 +4328,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 +4345,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 +4377,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 +4552,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 +4656,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 +4692,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 +4736,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 +4755,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 +4783,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 +4796,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 +4842,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 +4852,7 @@ fn a_bot_leave_that_disbands_reaches_the_instance_pool_while_the_realm_cache_lag ); assert!( !instances + .party .mirror .lock() .unwrap() @@ -4472,6 +4870,7 @@ 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(); diff --git a/gateway/src/world/presence_tests.rs b/gateway/src/world/presence_tests.rs index 122910b1e..0ec16c9ca 100644 --- a/gateway/src/world/presence_tests.rs +++ b/gateway/src/world/presence_tests.rs @@ -14,8 +14,13 @@ 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/shard_routing_tests.rs b/gateway/src/world/shard_routing_tests.rs index b924ce269..f85235d58 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,7 +126,7 @@ 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(); @@ -193,18 +207,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 +261,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 +315,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() }); @@ -378,22 +416,39 @@ 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 `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,11 +457,6 @@ 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() }); @@ -431,12 +481,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,10 +498,16 @@ 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(); @@ -470,7 +528,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" ); @@ -538,7 +599,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`. diff --git a/gateway/src/world/tests.rs b/gateway/src/world/tests.rs index d273be57c..970169b4f 100644 --- a/gateway/src/world/tests.rs +++ b/gateway/src/world/tests.rs @@ -99,30 +99,30 @@ fn world_session_socket_pair_times_out_when_the_server_writes_nothing() { } /// 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 +/// `WorldFake` 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. +/// `WorldFake` 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. +/// `party_tests` so it reaches `WorldFake` 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 +/// same reason — it reaches `WorldFake` (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. +/// reaches `WorldFake` and `party_tests`' fixture characters without widening anything. #[path = "presence_tests.rs"] mod presence_tests; @@ -133,18 +133,18 @@ mod presence_tests; 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. +/// the same reason — it reaches `WorldFake` 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. +/// reaches `WorldFake` (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 +/// reaches `WorldFake` 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; @@ -316,17 +316,105 @@ fn delivered_after(delay_secs: u32) -> i64 { } #[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, +struct WorldFake { + characters: Vec, + /// 195: `npc_refuses_interaction` return — false (derive-Default) keeps every fixture NPC open. + npc_refuses: bool, + /// When set, buy/sell return this error (a gameplay failure) instead of `Ok`. + trade_error: Option, + topology: TopologyState, + session: SessionState, + character: CharacterState, + transfer: TransferState, + party: PartyState, + mail: MailState, + social: SocialState, + npc: NpcState, + trainer: TrainerState, + bank: BankState, + combat: CombatState, + death: DeathState, + trade: TradeState, + cast: CastState, + taxi: TaxiState, + melee: MeleeState, + chat: ChatState, + speech: SpeechState, + channel: ChannelState, + guild: GuildState, + auction: AuctionState, + quest: QuestState, + vendor: VendorState, + item: ItemState, + weather: WeatherState, + member_stats: MemberStatsState, + loot_window: LootWindowState, + loot_roll: LootRollState, +} + +#[derive(Default)] +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. + 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 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, + /// When set, deleted Character cleanup cannot reach Realm-core. + party_cleanup_realm_error: Option, + party_command_realm_error: Option, + 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). + 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>, + /// Whether this character still had an addressable viewer at each transfer-resolution call. + viewer_present_at_settle: std::sync::Mutex>, +} + +#[derive(Default)] +struct SessionState { /// WORLDPORT_ACK gate: true = entity present -> a spurious ack is ignored; /// false (derive-Default) = absent -> a genuine transfer is pending. entity_in_world: bool, @@ -335,48 +423,14 @@ struct InMemoryStore { 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, @@ -385,11 +439,56 @@ struct InMemoryStore { /// 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, + /// The `WorldEntry` of every `player_login`, in order. + login_entries: std::sync::Mutex>, + /// Parked private System Messages world entry replays (a Package `on_login` hook's output). + 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). + 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>>, + /// 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, + /// 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, + /// Optional real shared view used by world-port ordering tests. + relay_view: Option>, + /// 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, +} + +#[derive(Default)] +struct CharacterState { + /// When set, `character_by_guid` cannot answer from this Shard. + character_read_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. @@ -401,208 +500,15 @@ struct InMemoryStore { /// 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>, + /// How many times a caller asked for the two-snapshot durable absence check. + durable_absence_checks: std::sync::atomic::AtomicUsize, +} - 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>>, +#[derive(Default)] +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. @@ -610,22 +516,24 @@ struct InMemoryStore { /// 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, + /// The transfer step to fail at, simulating a gateway killed before that step's + /// transaction committed. `None` = nothing fails. + 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. + 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, +} + +#[derive(Default)] +struct PartyState { + group_invites: std::sync::Mutex>, + /// Recorded `group_loot_method` calls: (loot_setting, master_guid, loot_threshold). + 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. bot_invite_intents: std::sync::Mutex>, @@ -652,27 +560,52 @@ struct InMemoryStore { /// 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, + /// 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>, + 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>, + /// Characters Realm-core reports in a pending Transfer. Read on the realm handle only. + members_in_transit: std::sync::Mutex>, +} + +#[derive(Default)] +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 @@ -723,73 +656,357 @@ struct InMemoryStore { /// 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, + /// `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, +} + +#[derive(Default)] +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. + contacts: std::sync::Mutex>, + /// This Shard's stored Auto-Replies, by Character guid. + 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. + 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, `contact_lists` fails with this message on THIS shard — the + /// unreachable-database arm of the realm-wide ignore-list union. + contact_lists_error: Option, + /// Live `game_world_entity` rows on THIS shard, as the columns Member Stats read. + 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. + members_between_places: std::sync::Mutex>, +} + +#[derive(Default)] +struct NpcState { + /// `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, + /// 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>, + /// 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, + /// Spawned GameObject type returned for the `CMSG_GAMEOBJ_USE` questgiver classification. + gameobject_type: Option, +} + +#[derive(Default)] +struct TrainerState { + /// 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, the trainer buy answers this Refusal instead of learning the spell. + trainer_buy_refusal: Option, + /// 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, + /// 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, + /// 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, +} + +#[derive(Default)] +struct BankState { + /// 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>, +} + +#[derive(Default)] +struct CombatState { + /// Recorded `set_sheathed` dispatches: (self_guid, state), the `CMSG_SETSHEATHED` route. + sheathed: 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, +} + +#[derive(Default)] +struct DeathState { + /// 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>, +} + +#[derive(Default)] +struct TradeState { + /// 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>, +} + +#[derive(Default)] +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. + item_templates: Vec, + /// 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>, + /// The caller's owned items, for the Auto Shot ammo block on the activation START. + player_items_fixture: Vec, + /// 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>, +} + +#[derive(Default)] +struct TaxiState { + /// 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>, +} + +#[derive(Default)] +struct MeleeState { + /// 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, + /// 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>, +} + +#[derive(Default)] +struct ChatState { + /// 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, + /// 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>, + /// Recorded `set_away` requests: `(speaker_guid, kind, message)`. + away_requests: 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>, +} + +#[derive(Default)] +struct SpeechState { + /// What `send_chat` answers; `None` delivers. + 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. + 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 `send_chat` lines: (chat_type, language, message). + chats: std::sync::Mutex>, +} + +#[derive(Default)] +struct ChannelState { + /// What `channel_op` answers. `None` succeeds. + channel_outcome: Option, + /// Recorded `channel_op` calls: `(actor_guid, op, request)`. + channel_ops: std::sync::Mutex>, +} + +#[derive(Default)] +struct GuildState { + /// 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, + /// A Character whose guild lookup fails, as when one Realm-core read errors. + guild_lookup_error_for: Option, +} + +#[derive(Default)] +struct AuctionState { + /// The auctioneer's house and faction verdict returned to the focused auction seam. + auction_interaction: Option, +} + +#[derive(Default)] +struct QuestState { + /// 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>, + /// 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, + /// 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>, +} + +#[derive(Default)] +struct VendorState { + /// Vendor stock the seam's `vendor_stock` read returns (empty by default). + 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. + buyback_ring: Vec<(u32, u32, u32, u32)>, + /// Item-instance guid → bag slot, for the vendor repair target. + item_slots: Vec<(u64, u8)>, + /// Recorded `vendor_buyback` calls: (vendor_guid, slot) — pins the 69→0 slot mapping. + bought_back: std::sync::Mutex>, +} + +#[derive(Default)] +struct ItemState { + /// Recorded `use_item` slots. + used_items: std::sync::Mutex>, +} + +#[derive(Default)] +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. + 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, +} + +#[derive(Default)] +struct MemberStatsState { + /// How many `member_presence` reads reached this handle. + member_presence_reads: std::sync::atomic::AtomicUsize, +} + +#[derive(Default)] +struct LootWindowState { + /// The lootable copper `loot_target_money` reports for any target (default 0). + corpse_money: u32, + /// 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, + /// 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>, + /// Recorded non-questgiver GameObject uses owned by the loot-window seam. + gameobjects_used: std::sync::Mutex>, + corpse_loot_reads: std::sync::Mutex>, +} + +#[derive(Default)] +struct LootRollState { + /// 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, /// 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)] @@ -828,85 +1045,6 @@ struct InMemoryStore { /// 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, } /// The Fake's reducer edge for a party op: the Module answers a Refusal as the bare tag, and @@ -926,11 +1064,16 @@ fn faked_contact(error: &str) -> Result { } } -impl InMemoryStore { +impl WorldFake { /// 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 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 { @@ -942,25 +1085,27 @@ impl InMemoryStore { /// 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() { + if let Some(stale) = &*self.party.stale_party.lock().unwrap() { return read(stale); } - read(&self.party.lock().unwrap()) + read(&self.party.party.lock().unwrap()) } /// Record one player-scoped call against THIS handle's shard. fn rec(&self, what: &str) { - self.calls + self.topology + .calls .lock() .unwrap() - .push((self.shard.clone(), what.to_string())); + .push((self.topology.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 + self.social + .away_flags .get(&guid) .copied() .map(presence::away_from_player_flags) @@ -970,8 +1115,11 @@ impl InMemoryStore { /// 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); + 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(()) } @@ -979,7 +1127,7 @@ impl InMemoryStore { /// 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(); + let mut purses = self.mail.purses.lock().unwrap(); match purses.iter_mut().find(|(g, _)| *g == guid) { Some((_, money)) if *money >= copper => { *money -= copper; @@ -990,7 +1138,7 @@ impl InMemoryStore { } fn credit(&self, guid: u64, copper: u32) { - let mut purses = self.purses.lock().unwrap(); + let mut purses = self.mail.purses.lock().unwrap(); if let Some((_, money)) = purses.iter_mut().find(|(g, _)| *g == guid) { *money = money.saturating_add(copper); } @@ -1002,7 +1150,7 @@ impl InMemoryStore { if item_guid == 0 { return Ok(mail::AttachedItem::default()); } - let mut items = self.mail_items.lock().unwrap(); + let mut items = self.mail.mail_items.lock().unwrap(); match items .iter() .position(|(g, owner, _)| *g == item_guid && *owner == sender_guid) @@ -1018,10 +1166,14 @@ impl InMemoryStore { /// 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) { + if self + .mail + .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 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(()) @@ -1030,7 +1182,8 @@ impl InMemoryStore { /// 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 + self.mail + .mail_items .lock() .unwrap() .iter() @@ -1063,7 +1216,7 @@ impl InMemoryStore { } else { lyracore_shared::mail::CHECK_MASK_HAS_BODY }; - let mut mails = self.mails.lock().unwrap(); + 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, @@ -1088,7 +1241,8 @@ impl InMemoryStore { )); drop(mails); if item.item_text_id != 0 { - self.mail_item_text_ids + self.mail + .mail_item_text_ids .lock() .unwrap() .push((id, item.item_text_id)); @@ -1097,7 +1251,8 @@ impl InMemoryStore { /// 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 + self.mail + .mail_item_text_ids .lock() .unwrap() .iter() @@ -1107,7 +1262,7 @@ impl InMemoryStore { /// 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 mut ids = self.mail.mail_item_text_ids.lock().unwrap(); let Some(at) = ids.iter().position(|(id, _)| *id == mail_id) else { return 0; }; @@ -1115,7 +1270,8 @@ impl InMemoryStore { } fn saw_same_account(&self, call: &'static str, same_account: bool) { - self.same_account_seen + self.mail + .same_account_seen .lock() .unwrap() .push((call, same_account)); @@ -1125,10 +1281,11 @@ impl InMemoryStore { /// before this step's transaction committed", which is exactly a truncated drive. 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.kill_at.as_deref() == Some(what) { + if self.transfer.kill_at.as_deref() == Some(what) { return Err(anyhow!("gateway killed at {what}")); } self.rec(what); @@ -1137,11 +1294,12 @@ impl InMemoryStore { 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.home_after_flip, nth) { + let resolved = match (&self.topology.home_after_flip, nth) { (Some(flipped), n) if n >= 1 => Some(flipped.clone()), - _ => self.home.clone(), + _ => self.topology.home.clone(), }; resolved.map(|h| h as std::sync::Arc) } @@ -1154,7 +1312,7 @@ impl InMemoryStore { map_id: u32, instance_id: u64, ) -> Result<()> { - if let Some(e) = &self.publish_error { + 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 @@ -1162,7 +1320,8 @@ impl InMemoryStore { // 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 + self.transfer + .realm_index .lock() .unwrap() .push((character_guid, map_id, instance_id)); @@ -1170,9 +1329,9 @@ impl InMemoryStore { } } -impl ShardRoutingStore for InMemoryStore { +impl ShardRoutingStore for WorldFake { fn shard_name(&self) -> &str { - &self.shard + &self.topology.shard } // The Fake enforces the Module's escrow guards so transfer ordering affects outcomes. @@ -1181,17 +1340,18 @@ impl ShardRoutingStore for InMemoryStore { &self, character_guid: u64, ) -> Result>> { - if let Some(view) = &self.relay_view { - self.viewer_present_at_settle.lock().unwrap().push( + 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.settle_error { + 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.settle_ok_calls { + if nth >= self.topology.settle_ok_calls { return Err(anyhow!("{e}")); } } @@ -1200,7 +1360,11 @@ impl ShardRoutingStore for InMemoryStore { 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); + self.topology + .bound_sessions + .lock() + .unwrap() + .push(account_id); Ok(()) } @@ -1209,39 +1373,41 @@ impl ShardRoutingStore for InMemoryStore { map_id: u32, instance_id: u64, ) -> Option> { - let (m, i, shard) = self.location_shard.as_ref()?; + 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.realm + self.topology + .realm .clone() .map(|r| r as std::sync::Arc) } fn party_cleanup_realm(&self) -> Result>> { - if let Some(error) = &self.party_cleanup_realm_error { + 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.party_command_realm_error { + 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.transfer_realm_error { + if let Some(error) = &self.topology.transfer_realm_error { return Err(anyhow!(error.clone())); } Ok(self.realm_store()) } fn world_stores(&self) -> Vec> { - self.peers + self.topology + .peers .lock() .unwrap() .iter() @@ -1250,21 +1416,21 @@ impl ShardRoutingStore for InMemoryStore { } fn party_command_worlds(&self) -> Result>> { - if let Some(error) = &self.world_shard_set_error { + if let Some(error) = &self.topology.world_shard_set_error { return Err(anyhow!(error.clone())); } Ok(self.world_stores()) } } -impl SessionStore for InMemoryStore { +impl SessionStore for WorldFake { fn pending_system_messages(&self, _self_guid: u64) -> Vec { - self.pending_system_messages.clone() + self.session.pending_system_messages.clone() } fn lookup_session(&self, account_name: &str) -> Result> { - Ok((account_name == self.username) - .then(|| self.session.clone()) + Ok((account_name == self.session.username) + .then(|| self.session.session.clone()) .flatten()) } @@ -1275,19 +1441,21 @@ impl SessionStore for InMemoryStore { entry: codec::WorldEntry, ) -> Result { self.rec("player_login"); - self.login_entries.lock().unwrap().push(entry); + 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.worldport_login_error { + if let Some(e) = &self.session.worldport_login_error { return Err(anyhow!("{e}")); } - if let Some(e) = self.worldport_entity.clone() { + if let Some(e) = self.session.worldport_entity.clone() { return Ok(e); } } - self.login_entity + self.session + .login_entity .clone() .ok_or_else(|| anyhow!("no login entity configured")) } @@ -1300,10 +1468,10 @@ impl SessionStore for InMemoryStore { info: &MovementInfo, ) -> Result<()> { self.rec("movement_update"); - if let Some(e) = &self.movement_error { + if let Some(e) = &self.session.movement_error { return Err(anyhow!("movement_update reducer failed: {e}")); } - self.moves.lock().unwrap().push(( + self.session.moves.lock().unwrap().push(( opcode, info.position.x, info.position.y, @@ -1322,20 +1490,22 @@ impl SessionStore for InMemoryStore { 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()); + 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.relay_view else { + 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.member_stats_before_party_frame, + self.session.member_stats_before_party_frame, subs.member_stats_record(), ) { let delivered = codec::MemberStats::default(); @@ -1353,7 +1523,8 @@ impl SessionStore for InMemoryStore { cmd: String, payload: String, ) -> Result<()> { - self.client_commands + self.session + .client_commands .lock() .unwrap() .push((account_id, self_guid, cmd, payload)); @@ -1361,15 +1532,16 @@ impl SessionStore for InMemoryStore { } fn entity_in_world(&self, guid: u64) -> bool { - self.entity_presence_checks + self.session + .entity_presence_checks .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - if let Some(present) = &self.entity_presence { + 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.entity_in_world || self.live_guids.contains(&guid) + self.session.entity_in_world || self.session.live_guids.contains(&guid) } fn entity_max_health(&self, _guid: u64) -> u32 { @@ -1393,24 +1565,32 @@ impl SessionStore for InMemoryStore { fn release_session(&self, _token: WorldSessionToken) -> Result<()> { self.rec("logout"); - self.logout_called + self.session + .logout_called .store(true, std::sync::atomic::Ordering::SeqCst); - match &self.logout_error { + match &self.session.logout_error { Some(e) => Err(anyhow!("{e}")), None => Ok(()), } } fn player_combat_until_ms(&self, _player_guid: u64) -> u64 { - self.combat_until_ms + self.session.combat_until_ms } } -impl CharacterStore for InMemoryStore { +impl CharacterStore for WorldFake { 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()); + out.extend( + self.character + .created_characters + .lock() + .unwrap() + .iter() + .cloned(), + ); Ok(out) } @@ -1425,6 +1605,7 @@ impl CharacterStore for InMemoryStore { ) -> Result { if self.characters.iter().any(|c| c.name == name) || self + .character .created_characters .lock() .unwrap() @@ -1435,9 +1616,11 @@ impl CharacterStore for InMemoryStore { } let guid = 500 + self + .character .next_created_guid .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - self.created_characters + self.character + .created_characters .lock() .unwrap() .push(codec::CharacterView { @@ -1456,32 +1639,35 @@ impl CharacterStore for InMemoryStore { account_id: u64, character_guid: u64, ) -> Result { - self.deleted + 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_read_error { + 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.durable_absence_checks + self.character + .durable_absence_checks .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - if let Some(error) = &self.world_shard_set_error { + 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.peers.lock().unwrap().iter() { + for shard in self.topology.peers.lock().unwrap().iter() { if shard.character_by_guid(guid)?.is_some() { return Ok(true); } @@ -1495,7 +1681,8 @@ impl CharacterStore for InMemoryStore { fn effective_armor(&self, _guid: u64) -> u32 { // No gear/auras in the test store → effective == the login entity's armor. - self.login_entity + self.session + .login_entity .as_ref() .map(|e| e.effective_armor) .unwrap_or(0) @@ -1510,22 +1697,23 @@ impl CharacterStore for InMemoryStore { } fn player_learned_spells(&self, _player_guid: u64) -> Result> { - Ok(self.learned_spells.clone()) + Ok(self.character.learned_spells.clone()) } fn player_reputations(&self, _player_guid: u64) -> Result> { - Ok(self.reputations.clone()) + Ok(self.character.reputations.clone()) } fn player_actions(&self, _player_guid: u64) -> Result> { - Ok(self.player_actions.clone()) + Ok(self.character.player_actions.clone()) } } -impl TransferStore for InMemoryStore { +impl TransferStore for WorldFake { fn escrowed_transfer(&self, character_guid: u64) -> Option { - let db = self.xdb.as_ref()?; + let db = self.transfer.xdb.as_ref()?; if self + .transfer .escrow_reads_before_visible .fetch_update( std::sync::atomic::Ordering::SeqCst, @@ -1549,7 +1737,7 @@ impl TransferStore for InMemoryStore { } fn character_destination(&self, character_guid: u64) -> Option { - let c = self.xdb.as_ref()?.get(character_guid)?; + let c = self.transfer.xdb.as_ref()?.get(character_guid)?; Some(super::transfer::TransferPlan { transfer_id: super::transfer::transfer_id_for(character_guid), character_guid, @@ -1721,7 +1909,7 @@ impl TransferStore for InMemoryStore { /// `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 { + let Some(_) = self.transfer.xdb.as_ref() else { return Ok(()); }; let db = self.xstep("release_transfer")?; @@ -1746,7 +1934,7 @@ impl TransferStore for InMemoryStore { character_guid: u64, source: super::transfer::RealmLocatorPredecessor, ) -> Result<()> { - let Some(db) = self.xdb.as_ref() else { + let Some(db) = self.transfer.xdb.as_ref() else { return Ok(()); }; self.xstep("release_transfer")?; @@ -1768,7 +1956,7 @@ impl TransferStore for InMemoryStore { } fn transfer_arrival(&self, transfer_id: u64) -> Option { - let db = self.xdb.as_ref()?; + 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) @@ -1794,7 +1982,7 @@ impl TransferStore for InMemoryStore { plan: &super::transfer::TransferPlan, bot_intent: Option<(&super::transfer::BotTransferIntent, u64)>, ) -> Result { - let mut phase = self.realm_partition.lock().unwrap(); + let mut phase = self.transfer.realm_partition.lock().unwrap(); let Some(current) = *phase else { return Ok(super::party::RealmCharacterPartition { map_id: 0, @@ -1880,7 +2068,7 @@ impl TransferStore for InMemoryStore { _source_instance: u64, source_revision: u64, ) -> Result<()> { - let mut phase = self.realm_partition.lock().unwrap(); + let mut phase = self.transfer.realm_partition.lock().unwrap(); let Some(current) = *phase else { drop(phase); return self.publish_shard_index( @@ -1911,7 +2099,7 @@ impl TransferStore for InMemoryStore { _claim_token: u64, ) -> Result<()> { self.rec("bind_bot_transfer_locator"); - let Some(current) = *self.realm_partition.lock().unwrap() else { + let Some(current) = *self.transfer.realm_partition.lock().unwrap() else { return Ok(()); }; if current.transfer_pending || current.revision != source_revision { @@ -1922,7 +2110,7 @@ impl TransferStore for InMemoryStore { 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 mut phase = self.transfer.realm_partition.lock().unwrap(); let Some(current) = *phase else { drop(phase); return self.publish_shard_index( @@ -1991,7 +2179,7 @@ impl TransferStore for InMemoryStore { if arrival.character_guid != character_guid { return Err(anyhow!("arrival fence names another Character")); } - let mut phase = self.realm_partition.lock().unwrap(); + let mut phase = self.transfer.realm_partition.lock().unwrap(); let Some(current) = *phase else { return Ok(()); }; @@ -2058,7 +2246,7 @@ impl TransferStore for InMemoryStore { transfer_id: u64, intent: &super::transfer::BotTransferIntent, ) -> bool { - let Some(db) = self.xdb.as_ref() else { + let Some(db) = self.transfer.xdb.as_ref() else { return false; }; lk(&db.in_rows).get(&transfer_id) == Some(&intent.bot_guid) @@ -2083,7 +2271,7 @@ impl TransferStore for InMemoryStore { intent: &super::transfer::BotTransferIntent, ) -> Result<()> { self.xstep("release_bot_transfer_arrival")?; - let Some(db) = self.xdb.as_ref() else { + let Some(db) = self.transfer.xdb.as_ref() else { return Ok(()); }; if transfer_id != intent.bot_guid @@ -2117,7 +2305,8 @@ impl TransferStore for InMemoryStore { } fn instance_partition(&self, instance_id: u64) -> Option<(u32, u64)> { - self.xdb + self.transfer + .xdb .as_ref() .and_then(|db| lk(&db.instance_partitions).get(&instance_id).copied()) } @@ -2163,7 +2352,7 @@ impl TransferStore for InMemoryStore { } } -impl PartyStore for InMemoryStore { +impl PartyStore for WorldFake { fn realm_character_partition( &self, character_guid: u64, @@ -2174,6 +2363,7 @@ impl PartyStore for InMemoryStore { // 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() @@ -2191,7 +2381,7 @@ impl PartyStore for InMemoryStore { bot_controller_generation: 0, })); } - Ok(*self.realm_partition.lock().unwrap()) + Ok(*self.transfer.realm_partition.lock().unwrap()) } fn party_holder_observation( @@ -2225,7 +2415,7 @@ impl PartyStore for InMemoryStore { if let Some(e) = &self.trade_error { return faked_party(e); } - self.group_invites.lock().unwrap().push(target_guid); + self.party.group_invites.lock().unwrap().push(target_guid); Ok(PartyOutcome::Ran) } @@ -2256,15 +2446,17 @@ impl PartyStore for InMemoryStore { if let Some(e) = &self.trade_error { return faked_party(e); } - self.group_loot_methods - .lock() - .unwrap() - .push((loot_setting, master_guid, loot_threshold)); + 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_command_claims + self.party + .party_command_claims .lock() .unwrap() .push((intent_id, claim_token)); @@ -2288,6 +2480,7 @@ impl PartyStore for InMemoryStore { } expected_members.sort_unstable(); let mut current_members = self + .party .party_command_authority_members .lock() .unwrap() @@ -2309,15 +2502,17 @@ impl PartyStore for InMemoryStore { &self, command: &super::party::AdmittedCompanionCommand, ) -> Result { - self.admitted_party_commands + self.party + .admitted_party_commands .lock() .unwrap() .push(command.clone()); let outcome = self + .party .party_command_apply_outcome .unwrap_or(super::party::CompanionCommandOutcome::Applied); if outcome != super::party::CompanionCommandOutcome::WaitingForCapacity { - self.party_command_receipts.lock().unwrap().push(( + self.party.party_command_receipts.lock().unwrap().push(( command.source_identity, command.intent_id, outcome, @@ -2332,7 +2527,8 @@ impl PartyStore for InMemoryStore { claim_token: u64, outcome: super::party::CompanionCommandOutcome, ) -> Result<()> { - self.party_command_finishes + self.party + .party_command_finishes .lock() .unwrap() .push((intent_id, claim_token, outcome)); @@ -2344,10 +2540,17 @@ impl PartyStore for InMemoryStore { source_identity: spacetimedb_sdk::Identity, intent_id: u64, ) -> Result> { - if let Some(error) = self.party_command_receipt_error.lock().unwrap().as_ref() { + 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() @@ -2359,6 +2562,7 @@ impl PartyStore for InMemoryStore { fn confirm_party_command_holder(&self, guid: u64) -> Result { Ok( if self + .party .party_command_in_transit .lock() .unwrap() @@ -2378,7 +2582,8 @@ impl PartyStore for InMemoryStore { } fn entity_partition(&self, guid: u64) -> Option<(u32, u64)> { - self.entity_partitions + self.party + .entity_partitions .lock() .unwrap() .iter() @@ -2388,6 +2593,7 @@ impl PartyStore for InMemoryStore { fn claim_bot_invite_intent(&self, intent_id: u64) -> Result { if let Some(refusal) = self + .party .bot_intent_claim_refusals .lock() .unwrap() @@ -2395,7 +2601,7 @@ impl PartyStore for InMemoryStore { { return Ok(PartyOutcome::Refused(*refusal)); } - let mut intents = self.bot_invite_intents.lock().unwrap(); + 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)); }; @@ -2406,6 +2612,7 @@ impl PartyStore for InMemoryStore { fn admit_sessionless_group_action(&self, character_guid: u64) -> Result { self.rec("admit_sessionless_group_action"); if self + .party .sessionless_admission_unavailable .lock() .unwrap() @@ -2414,6 +2621,7 @@ impl PartyStore for InMemoryStore { anyhow::bail!("World Shard admission unavailable"); } if let Some(refusal) = self + .party .sessionless_admission .lock() .unwrap() @@ -2430,12 +2638,14 @@ impl PartyStore for InMemoryStore { return Ok(PartyOutcome::Refused(GroupRefusal::ActorUnavailable)); } if self + .party .suppress_after_admission .lock() .unwrap() .contains(&character_guid) { - self.sessionless_admission + self.party + .sessionless_admission .lock() .unwrap() .insert(character_guid, GroupRefusal::ActionSuppressed); @@ -2445,6 +2655,7 @@ impl PartyStore for InMemoryStore { /// 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, @@ -2456,9 +2667,13 @@ impl PartyStore for InMemoryStore { ) -> 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(); + 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()); } @@ -2483,7 +2698,7 @@ impl PartyStore for InMemoryStore { p.events.push((target_guid, kind::INVITE)); } realm_op::ACCEPT => { - if let Some(e) = &self.party_accept_error { + if let Some(e) = &self.party.party_accept_error { return faked_party(e); } let Some(inviter) = p @@ -2540,6 +2755,7 @@ impl PartyStore for InMemoryStore { } realm_op::LEAVE => { if self + .party .party_leave_failures .fetch_update( std::sync::atomic::Ordering::SeqCst, @@ -2554,6 +2770,7 @@ impl PartyStore for InMemoryStore { return Ok(GroupRefusal::NotInGroup.into()); } if self + .party .party_leave_commit_then_error .swap(false, std::sync::atomic::Ordering::SeqCst) { @@ -2639,7 +2856,9 @@ impl PartyStore for InMemoryStore { 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 => { + 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 @@ -2667,7 +2886,7 @@ impl PartyStore for InMemoryStore { 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; + *self.party.stale_party.lock().unwrap() = None; outcome } @@ -2687,18 +2906,20 @@ impl PartyStore for InMemoryStore { 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.group_roster_error_on_read.lock().unwrap() { + 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.is_realm { + 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() @@ -2722,10 +2943,11 @@ impl PartyStore for InMemoryStore { } fn group_roster_by_id(&self, group_id: u64) -> Result> { - if self.is_realm { + if self.party.is_realm { return Ok(self.realm_cache(|p| p.roster(group_id))); } Ok(self + .party .mirror .lock() .unwrap() @@ -2746,10 +2968,11 @@ impl PartyStore for InMemoryStore { } fn held_roster_revision(&self, group_id: u64) -> Result> { - if self.is_realm { + if self.party.is_realm { return Ok(self.realm_cache(|p| p.revisions.get(&group_id).copied())); } Ok(self + .party .mirror_revisions .lock() .unwrap() @@ -2758,10 +2981,11 @@ impl PartyStore for InMemoryStore { } fn party_member_guids(&self) -> Result> { - if !self.is_realm { + if !self.party.is_realm { return Ok(Vec::new()); } Ok(self + .party .party .lock() .unwrap() @@ -2772,11 +2996,12 @@ impl PartyStore for InMemoryStore { } fn party_group_ids(&self) -> Result> { - if let Some(error) = &*self.party_group_ids_error.lock().unwrap() { + if let Some(error) = &*self.party.party_group_ids_error.lock().unwrap() { return Err(anyhow!(error.clone())); } - if self.is_realm { + if self.party.is_realm { return Ok(self + .party .party .lock() .unwrap() @@ -2786,6 +3011,7 @@ impl PartyStore for InMemoryStore { .collect()); } Ok(self + .party .mirror .lock() .unwrap() @@ -2797,6 +3023,7 @@ impl PartyStore for InMemoryStore { fn sync_group_mirror(&self, roster: &super::party::GroupRoster) -> Result<()> { self.rec("sync_group_mirror"); if self + .party .mirror_failures .fetch_update( std::sync::atomic::Ordering::SeqCst, @@ -2807,14 +3034,15 @@ impl PartyStore for InMemoryStore { { return Err(anyhow!("World Shard mirror connection interrupted")); } - if let Some(e) = &self.mirror_error { + if let Some(e) = &self.party.mirror_error { return Err(anyhow!("{e}")); } - self.mirror_revisions + self.party + .mirror_revisions .lock() .unwrap() .insert(roster.group_id, roster.roster_revision); - let mut mirror = self.mirror.lock().unwrap(); + 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. @@ -2835,10 +3063,11 @@ impl PartyStore for InMemoryStore { } } -impl MailStore for InMemoryStore { +impl MailStore for WorldFake { fn mail_list(&self, recipient_guid: u64) -> Result> { self.rec("mail_list"); Ok(self + .mail .mails .lock() .unwrap() @@ -2850,6 +3079,7 @@ impl MailStore for InMemoryStore { fn mail_by_id(&self, mail_id: u64) -> Result> { Ok(self + .mail .mails .lock() .unwrap() @@ -2860,6 +3090,7 @@ impl MailStore for InMemoryStore { fn realm_account_name(&self, character_guid: u64) -> Result> { Ok(self + .mail .realm_accounts .lock() .unwrap() @@ -2870,7 +3101,7 @@ impl MailStore for InMemoryStore { fn mailbox_in_range(&self, mailbox_guid: u64, _player_guid: u64) -> Result { self.rec("mailbox_in_range"); - Ok(self.mailboxes.contains(&mailbox_guid)) + Ok(self.mail.mailboxes.contains(&mailbox_guid)) } /// Models the module's `apply_mark_read`: the row lookup scoped to `recipient_guid` IS the @@ -2878,7 +3109,7 @@ impl MailStore for InMemoryStore { /// 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 mut mails = self.mail.mails.lock().unwrap(); let now = mail::now_secs(); match mails .iter_mut() @@ -2896,7 +3127,7 @@ impl MailStore for InMemoryStore { /// 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 mut mails = self.mail.mails.lock().unwrap(); let now = mail::now_secs(); let Some(at) = mails .iter() @@ -2919,7 +3150,7 @@ impl MailStore for InMemoryStore { 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 mut mails = self.mail.mails.lock().unwrap(); let now = mail::now_secs(); let Some((to, m)) = mails .iter_mut() @@ -2961,7 +3192,7 @@ impl MailStore for InMemoryStore { 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(( + self.mail.sent_mail.lock().unwrap().push(( sender_guid, recipient_guid, subject.clone(), @@ -2987,7 +3218,7 @@ impl MailStore for InMemoryStore { /// 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 mails = self.mail.mails.lock().unwrap(); let now = mail::now_secs(); let Some((_, m)) = mails .iter() @@ -3029,7 +3260,7 @@ impl MailStore for InMemoryStore { } return Err(e); } - let mut mails = self.mails.lock().unwrap(); + 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) @@ -3061,7 +3292,11 @@ impl MailStore for InMemoryStore { fn mail_item_room(&self, _payee_guid: u64) -> Result<()> { self.rec("mail_item_room"); - if self.bags_full.load(std::sync::atomic::Ordering::Relaxed) { + if self + .mail + .bags_full + .load(std::sync::atomic::Ordering::Relaxed) + { return Err(anyhow!(lyracore_shared::mail::INVENTORY_FULL)); } Ok(()) @@ -3074,7 +3309,7 @@ impl MailStore for InMemoryStore { /// 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 mut mails = self.mail.mails.lock().unwrap(); let now = mail::now_secs(); let Some((_, m)) = mails .iter_mut() @@ -3092,7 +3327,7 @@ impl MailStore for InMemoryStore { 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(); + let mut texts = self.mail.item_texts.lock().unwrap(); if !texts.iter().any(|(id, _)| *id == text_id) { texts.push((text_id, text)); } @@ -3107,6 +3342,7 @@ impl MailStore for InMemoryStore { 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() @@ -3115,10 +3351,15 @@ impl MailStore for InMemoryStore { { return Ok(()); } - if self.bags_full.load(std::sync::atomic::Ordering::Relaxed) { + if self + .mail + .bags_full + .load(std::sync::atomic::Ordering::Relaxed) + { return Err(anyhow!(lyracore_shared::mail::INVENTORY_FULL)); } - self.granted_letters + self.mail + .granted_letters .lock() .unwrap() .push((payee_guid, item_text_id)); @@ -3129,7 +3370,7 @@ impl MailStore for InMemoryStore { /// `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 mut mails = self.mail.mails.lock().unwrap(); let Some((_, m)) = mails .iter_mut() .find(|(to, m)| *to == recipient_guid && m.id == mail_id) @@ -3144,6 +3385,7 @@ impl MailStore for InMemoryStore { fn item_text(&self, item_text_id: u32) -> Result> { self.rec("item_text"); Ok(self + .mail .item_texts .lock() .unwrap() @@ -3164,6 +3406,7 @@ impl MailStore for InMemoryStore { self.rec("owns_item_with_text"); Ok(item_text_id != 0 && self + .mail .granted_letters .lock() .unwrap() @@ -3176,7 +3419,7 @@ impl MailStore for InMemoryStore { 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 mut mails = self.mail.mails.lock().unwrap(); let now = mail::now_secs(); let Some((_, m)) = mails .iter_mut() @@ -3211,7 +3454,7 @@ impl MailStore for InMemoryStore { self.rec("mail_fence"); self.saw_same_account("mail_fence", same_account); self.mail_kill("mail_fence")?; - let escrows = self.mail_escrows.lock().unwrap(); + 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 } @@ -3221,7 +3464,7 @@ impl MailStore for InMemoryStore { 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(( + self.mail.mail_escrows.lock().unwrap().push(( sender_guid, mail::HeldEscrow { escrow_id, @@ -3238,7 +3481,7 @@ impl MailStore for InMemoryStore { reward: None, }, )); - self.attested.lock().unwrap().push((escrow_id, false)); + self.mail.attested.lock().unwrap().push((escrow_id, false)); Ok(()) } @@ -3259,14 +3502,14 @@ impl MailStore for InMemoryStore { ) -> Result<()> { self.rec("mail_commit"); self.mail_kill("mail_commit")?; - let mut receipts = self.mail_receipts.lock().unwrap(); + 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 = mail::now_secs(); - let owed = self.mails.lock().unwrap().iter().any(|(to, m)| { + 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 { @@ -3277,7 +3520,7 @@ impl MailStore for InMemoryStore { } receipts.push((escrow_id, recipient_guid)); drop(receipts); - self.sent_mail.lock().unwrap().push(( + self.mail.sent_mail.lock().unwrap().push(( sender_guid, recipient_guid, subject.clone(), @@ -3303,7 +3546,7 @@ impl MailStore for InMemoryStore { 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(); + 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; @@ -3317,6 +3560,7 @@ impl MailStore for InMemoryStore { // once however the drive is interrupted. if cod_source_mail_id != 0 { if let Some((_, m)) = self + .mail .mails .lock() .unwrap() @@ -3340,6 +3584,7 @@ impl MailStore for InMemoryStore { self.rec("mail_take_money_fence"); self.mail_kill("mail_take_money_fence")?; if self + .mail .mail_escrows .lock() .unwrap() @@ -3349,7 +3594,7 @@ impl MailStore for InMemoryStore { return Ok(()); } let money = { - let mut mails = self.mails.lock().unwrap(); + let mut mails = self.mail.mails.lock().unwrap(); let now = mail::now_secs(); let Some((_, m)) = mails .iter_mut() @@ -3367,7 +3612,7 @@ impl MailStore for InMemoryStore { } std::mem::take(&mut m.money) }; - self.mail_escrows.lock().unwrap().push(( + self.mail.mail_escrows.lock().unwrap().push(( payee_guid, mail::HeldEscrow { escrow_id, @@ -3384,7 +3629,7 @@ impl MailStore for InMemoryStore { reward: None, }, )); - self.attested.lock().unwrap().push((escrow_id, false)); + self.mail.attested.lock().unwrap().push((escrow_id, false)); Ok(()) } @@ -3399,6 +3644,7 @@ impl MailStore for InMemoryStore { self.rec("mail_take_item_fence"); self.mail_kill("mail_take_item_fence")?; if self + .mail .mail_escrows .lock() .unwrap() @@ -3408,7 +3654,7 @@ impl MailStore for InMemoryStore { return Ok(()); } let item = { - let mut mails = self.mails.lock().unwrap(); + let mut mails = self.mail.mails.lock().unwrap(); let now = mail::now_secs(); let Some((_, m)) = mails .iter_mut() @@ -3440,7 +3686,7 @@ impl MailStore for InMemoryStore { m.item_soulbound = false; item }; - self.mail_escrows.lock().unwrap().push(( + self.mail.mail_escrows.lock().unwrap().push(( payee_guid, mail::HeldEscrow { escrow_id, @@ -3457,7 +3703,7 @@ impl MailStore for InMemoryStore { reward: None, }, )); - self.attested.lock().unwrap().push((escrow_id, false)); + self.mail.attested.lock().unwrap().push((escrow_id, false)); Ok(()) } @@ -3472,13 +3718,14 @@ impl MailStore for InMemoryStore { ) -> Result<()> { self.rec("mail_item_payout"); self.mail_kill("mail_item_payout")?; - let receipts = self.mail_receipts.lock().unwrap(); + 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_receipts + self.mail + .mail_receipts .lock() .unwrap() .push((escrow_id, payee_guid)); @@ -3495,11 +3742,12 @@ impl MailStore for InMemoryStore { ) -> Result<()> { self.rec("mail_payout"); self.mail_kill("mail_payout")?; - let mut receipts = self.mail_receipts.lock().unwrap(); + 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() @@ -3517,7 +3765,7 @@ impl MailStore for InMemoryStore { 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(); + let mut attested = self.mail.attested.lock().unwrap(); match attested.iter_mut().find(|(id, _)| *id == escrow_id) { Some((_, done)) => { *done = true; @@ -3532,6 +3780,7 @@ impl MailStore for InMemoryStore { self.rec("mail_settle"); self.mail_kill("mail_settle")?; let attested = self + .mail .attested .lock() .unwrap() @@ -3544,11 +3793,13 @@ impl MailStore for InMemoryStore { "mail escrow {escrow_id}: delivery not attested — refusing to destroy the fence" )), Some(true) => { - self.mail_escrows + self.mail + .mail_escrows .lock() .unwrap() .retain(|(_, e)| e.escrow_id != escrow_id); - self.attested + self.mail + .attested .lock() .unwrap() .retain(|(id, _)| *id != escrow_id); @@ -3560,6 +3811,7 @@ impl MailStore for InMemoryStore { 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, @@ -3571,6 +3823,7 @@ impl MailStore for InMemoryStore { return Ok(Vec::new()); // the coordinator cache has not caught up yet } Ok(self + .mail .mail_escrows .lock() .unwrap() @@ -3581,7 +3834,7 @@ impl MailStore for InMemoryStore { } } -impl SocialStore for InMemoryStore { +impl SocialStore for WorldFake { fn character_identity(&self, guid: u64) -> Result> { Ok(self.characters.iter().find(|c| c.guid == guid).map(|c| { presence::CharacterIdentity { @@ -3593,7 +3846,7 @@ impl SocialStore for InMemoryStore { 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), + session_online: !self.social.offline_guids.contains(&guid), } })) } @@ -3605,7 +3858,8 @@ impl SocialStore for InMemoryStore { // 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 + self.social + .member_entities .lock() .unwrap() .iter() @@ -3614,23 +3868,27 @@ impl SocialStore for InMemoryStore { } fn character_in_transit(&self, guid: u64) -> bool { - self.members_between_places.lock().unwrap().contains(&guid) + self.social + .members_between_places + .lock() + .unwrap() + .contains(&guid) } fn auto_reply_text(&self, guid: u64) -> Result> { - Ok(self.auto_replies.lock().unwrap().get(&guid).cloned()) + 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.peers.lock().unwrap().iter() { - if let Some(error) = &peer.world_shard_set_error { + 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.world_shard_set_error { + if let Some(error) = &self.topology.world_shard_set_error { return Err(anyhow!(error.clone())); } Ok(()) @@ -3651,7 +3909,7 @@ impl SocialStore for InMemoryStore { 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), + player_flags: self.social.away_flags.get(&c.guid).copied().unwrap_or(0), ..Default::default() }); presence::RealmPresence { @@ -3661,11 +3919,11 @@ impl SocialStore for InMemoryStore { 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), + session_online: !self.social.offline_guids.contains(&c.guid), whereabouts: presence::Whereabouts::InWorld { away: self.away(c.guid), entity, - shard_name: self.shard.clone(), + shard_name: self.topology.shard.clone(), }, } }) @@ -3673,14 +3931,18 @@ impl SocialStore for InMemoryStore { } fn zone_name(&self, zone_id: u32) -> String { - self.zone_names.get(&zone_id).cloned().unwrap_or_default() + 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.contact_lists_error { + if let Some(e) = &self.social.contact_lists_error { return Err(anyhow!("{e}")); } - let contacts = self.contacts.lock().unwrap(); + 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() { @@ -3697,10 +3959,11 @@ impl SocialStore for InMemoryStore { } fn ignored_guids(&self, owner_guid: u64) -> Result> { - if let Some(e) = &self.contact_lists_error { + if let Some(e) = &self.social.contact_lists_error { return Err(anyhow!("{e}")); } Ok(self + .social .contacts .lock() .unwrap() @@ -3728,8 +3991,14 @@ impl SocialStore for InMemoryStore { 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 + 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)); @@ -3754,8 +4023,14 @@ impl SocialStore for InMemoryStore { 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 + 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)); @@ -3772,7 +4047,7 @@ impl SocialStore for InMemoryStore { } } -impl NpcStore for InMemoryStore { +impl NpcStore for WorldFake { fn creature_template(&self, _entry: u32) -> Result> { Ok(None) } @@ -3791,7 +4066,7 @@ impl NpcStore for InMemoryStore { } fn gameobject_type(&self, _go_guid: u64) -> Result> { - Ok(self.gameobject_type) + Ok(self.npc.gameobject_type) } fn enter_areatrigger(&self, _account_id: u64, _self_guid: u64, _trigger_id: u32) -> Result<()> { @@ -3803,13 +4078,14 @@ impl NpcStore for InMemoryStore { } fn bind_home(&self, _account_id: u64, _self_guid: u64) -> Result<()> { - self.home_bound + self.npc + .home_bound .store(true, std::sync::atomic::Ordering::SeqCst); Ok(()) } fn npc_is_innkeeper(&self, _guid: u64) -> Result { - Ok(self.innkeeper) + Ok(self.npc.innkeeper) } fn npc_gossip_text_id(&self, _npc_guid: u64) -> u32 { @@ -3817,11 +4093,11 @@ impl NpcStore for InMemoryStore { } fn npc_text_for_id(&self, _text_id: u32) -> Option { - self.npc_text_view.clone() + self.npc.npc_text_view.clone() } fn gossip_options(&self, _npc_guid: u64) -> Result> { - Ok(self.gossip_opts.clone()) + Ok(self.npc.gossip_opts.clone()) } fn inspect(&self, _account_id: u64, _self_guid: u64, target_guid: u64) -> Result<()> { @@ -3845,7 +4121,8 @@ impl NpcStore for InMemoryStore { option_id: u32, option_row_id: u32, ) -> Result<()> { - self.gossip_selects + self.npc + .gossip_selects .lock() .unwrap() .push((option_id, option_row_id)); @@ -3853,9 +4130,9 @@ impl NpcStore for InMemoryStore { } } -impl TrainerStore for InMemoryStore { +impl TrainerStore for WorldFake { fn trainer_serves(&self, _player_guid: u64, _trainer_guid: u64) -> Result { - Ok(!self.trainer_refuses_class) // default true — every existing fixture trainer serves + Ok(!self.trainer.trainer_refuses_class) // default true — every existing fixture trainer serves } fn trainer_list( @@ -3863,7 +4140,7 @@ impl TrainerStore for InMemoryStore { _player_guid: u64, _trainer_guid: u64, ) -> Result> { - Ok(self.trainer_spells.clone()) + Ok(self.trainer.trainer_spells.clone()) } fn buy_trainer_spell( @@ -3873,7 +4150,7 @@ impl TrainerStore for InMemoryStore { _trainer_guid: u64, _spell_id: u32, ) -> Result { - if let Some(refusal) = self.trainer_buy_refusal { + if let Some(refusal) = self.trainer.trainer_buy_refusal { return Ok(refusal.into()); } match &self.trade_error { @@ -3883,7 +4160,7 @@ impl TrainerStore for InMemoryStore { } fn talent_grant_spell(&self, _talent_id: u32) -> u32 { - self.talent_grant + self.trainer.talent_grant } fn set_faction_at_war( @@ -3908,7 +4185,7 @@ impl TrainerStore for InMemoryStore { } fn talent_pane_sync(&self, _character_guid: u64, _talent_id: u32) -> (u32, u32, u32) { - self.talent_pane + self.trainer.talent_pane } fn talent_points_spent(&self, _character_guid: u64) -> u32 { @@ -3923,13 +4200,14 @@ impl TrainerStore for InMemoryStore { } fn reset_talents(&self, account_id: u64, self_guid: u64, trainer_guid: u64) -> Result<()> { - if let Some(e) = &self.reset_talents_error { + if let Some(e) = &self.trainer.reset_talents_error { return Err(anyhow!("{e}")); } - self.reset_talents_calls - .lock() - .unwrap() - .push((account_id, self_guid, trainer_guid)); + self.trainer.reset_talents_calls.lock().unwrap().push(( + account_id, + self_guid, + trainer_guid, + )); Ok(()) } @@ -3939,14 +4217,15 @@ impl TrainerStore for InMemoryStore { 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 + self.trainer + .trainer_offer_skill_lines .get(&spell_id) .copied() .unwrap_or(0) } fn superseded_old_rank(&self, _new_spell: u32, _player_guid: u64) -> Option { - self.trainer_superseded + self.trainer.trainer_superseded } fn character_presence(&self, guid: u64) -> Result> { @@ -3958,7 +4237,7 @@ impl TrainerStore for InMemoryStore { // default, so a seeded character is online exactly as it always was. .map(|c| { ( - !self.offline_guids.contains(&guid), + !self.social.offline_guids.contains(&guid), c.level, c.class, c.zone_id, @@ -3967,12 +4246,12 @@ impl TrainerStore for InMemoryStore { } } -impl BankStore for InMemoryStore { +impl BankStore for WorldFake { 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); + self.bank.auto_banked_items.lock().unwrap().push(slot); Ok(()) } @@ -3980,18 +4259,26 @@ impl BankStore for InMemoryStore { if let Some(e) = &self.trade_error { return Err(anyhow!("{e}")); } - self.bought_bank_slots.lock().unwrap().push(banker_guid); + self.bank + .bought_bank_slots + .lock() + .unwrap() + .push(banker_guid); Ok(()) } } -impl CombatStore for InMemoryStore { +impl CombatStore for WorldFake { 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 { + if let Some(e) = &self.combat.set_target_error { return Err(anyhow!("{e}")); } - self.selected_targets.lock().unwrap().push(target_guid); + self.combat + .selected_targets + .lock() + .unwrap() + .push(target_guid); Ok(()) } @@ -4006,19 +4293,24 @@ impl CombatStore for InMemoryStore { } fn set_sheathed(&self, _account_id: u64, self_guid: u64, state: u8) -> Result<()> { - self.sheathed.lock().unwrap().push((self_guid, state)); + self.combat + .sheathed + .lock() + .unwrap() + .push((self_guid, state)); Ok(()) } } -impl DeathStore for InMemoryStore { +impl DeathStore for WorldFake { fn repop(&self, _account_id: u64, self_guid: u64) -> Result<()> { - self.repopped.lock().unwrap().push(self_guid); + self.death.repopped.lock().unwrap().push(self_guid); Ok(()) } fn reclaim_corpse(&self, _account_id: u64, self_guid: u64, corpse_guid: u64) -> Result<()> { - self.reclaimed_corpses + self.death + .reclaimed_corpses .lock() .unwrap() .push((self_guid, corpse_guid)); @@ -4026,7 +4318,8 @@ impl DeathStore for InMemoryStore { } fn resurrect_response(&self, _account_id: u64, self_guid: u64, accept: bool) -> Result<()> { - self.resurrect_responses + self.death + .resurrect_responses .lock() .unwrap() .push((self_guid, accept)); @@ -4034,15 +4327,16 @@ impl DeathStore for InMemoryStore { } fn self_resurrect(&self, _account_id: u64, self_guid: u64) -> Result<()> { - self.self_resurrects.lock().unwrap().push(self_guid); - match &self.self_resurrect_error { + self.death.self_resurrects.lock().unwrap().push(self_guid); + match &self.death.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 + self.death + .spirit_healer_calls .lock() .unwrap() .push((self_guid, healer_guid)); @@ -4054,12 +4348,13 @@ impl DeathStore for InMemoryStore { } } -impl TradeStore for InMemoryStore { +impl TradeStore for WorldFake { // 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 + self.trade + .initiated_trades .lock() .unwrap() .push((self_guid, target_guid)); @@ -4068,13 +4363,13 @@ impl TradeStore for InMemoryStore { fn begin_trade(&self, _account_id: u64, self_guid: u64) -> Result<()> { self.rec("begin_trade"); - self.begun_trades.lock().unwrap().push(self_guid); + self.trade.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); + self.trade.cancelled_trades.lock().unwrap().push(self_guid); Ok(()) } @@ -4086,7 +4381,8 @@ impl TradeStore for InMemoryStore { inv_slot: u8, ) -> Result<()> { self.rec("set_trade_item"); - self.set_trade_items + self.trade + .set_trade_items .lock() .unwrap() .push((self_guid, trade_slot, inv_slot)); @@ -4095,7 +4391,8 @@ impl TradeStore for InMemoryStore { fn clear_trade_item(&self, _account_id: u64, self_guid: u64, trade_slot: u8) -> Result<()> { self.rec("clear_trade_item"); - self.cleared_trade_items + self.trade + .cleared_trade_items .lock() .unwrap() .push((self_guid, trade_slot)); @@ -4104,7 +4401,8 @@ impl TradeStore for InMemoryStore { fn set_trade_gold(&self, _account_id: u64, self_guid: u64, copper: u32) -> Result<()> { self.rec("set_trade_gold"); - self.set_trade_golds + self.trade + .set_trade_golds .lock() .unwrap() .push((self_guid, copper)); @@ -4113,25 +4411,25 @@ impl TradeStore for InMemoryStore { fn accept_trade(&self, _account_id: u64, self_guid: u64) -> Result<()> { self.rec("accept_trade"); - self.accepted_trades.lock().unwrap().push(self_guid); + self.trade.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); + self.trade.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); + self.trade.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); + self.trade.ignore_trades.lock().unwrap().push(self_guid); Ok(()) } } @@ -4140,7 +4438,7 @@ impl TradeStore for InMemoryStore { /// 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 { +impl CastStore for WorldFake { fn cast_item_target( &self, _account_id: u64, @@ -4152,11 +4450,11 @@ impl CastStore for InMemoryStore { } fn cancel_aura(&self, _account_id: u64, _self_guid: u64, spell_id: u32) -> Result<()> { - self.cancelled_auras.lock().unwrap().push(spell_id); + self.cast.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); + self.cast.cancelled_casts.lock().unwrap().push(self_guid); Ok(()) } fn cast_spell( @@ -4166,7 +4464,11 @@ impl CastStore for InMemoryStore { spell_id: u32, target_guid: u64, ) -> Result<()> { - self.casts.lock().unwrap().push((spell_id, target_guid)); + self.cast + .casts + .lock() + .unwrap() + .push((spell_id, target_guid)); Ok(()) } fn start_ranged_attack( @@ -4176,7 +4478,8 @@ impl CastStore for InMemoryStore { target_guid: u64, spell_id: u32, ) -> Result<()> { - self.ranged_attacks + self.cast + .ranged_attacks .lock() .unwrap() .push((target_guid, spell_id)); @@ -4242,10 +4545,11 @@ impl CastStore for InMemoryStore { // 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()) + 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) @@ -4255,31 +4559,33 @@ impl CastStore for InMemoryStore { /// 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 { +impl TaxiActionStore for WorldFake { fn taxi_node_status( &self, character_guid: u64, npc_guid: u64, ) -> Result> { - self.taxi_calls + self.taxi + .taxi_calls .lock() .unwrap() .push(("status", character_guid, npc_guid)); - if let Some(error) = &self.taxi_error { + if let Some(error) = &self.taxi.taxi_error { return Err(anyhow!("{error}")); } - Ok(self.taxi_status) + Ok(self.taxi.taxi_status) } fn open_taxi(&self, character_guid: u64, npc_guid: u64) -> Result> { - self.taxi_calls + self.taxi + .taxi_calls .lock() .unwrap() .push(("open", character_guid, npc_guid)); - if let Some(error) = &self.taxi_error { + if let Some(error) = &self.taxi.taxi_error { return Err(anyhow!("{error}")); } - Ok(self.taxi_map.clone()) + Ok(self.taxi.taxi_map.clone()) } fn activate_taxi( @@ -4289,24 +4595,26 @@ impl TaxiActionStore for InMemoryStore { source_client_node_id: u32, destination_client_node_id: u32, ) -> Result { - self.taxi_calls + self.taxi + .taxi_calls .lock() .unwrap() .push(("activate", character_guid, npc_guid)); - self.taxi_activation_inputs.lock().unwrap().push(( + self.taxi.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 { + if let Some(error) = &self.taxi.taxi_error { return Err(anyhow!("{error}")); } - Ok(self.taxi_activation) + Ok(self.taxi.taxi_activation) } fn arm_taxi_flight(&self, character_guid: u64) -> Result<()> { - self.taxi_calls + self.taxi + .taxi_calls .lock() .unwrap() .push(("arm", character_guid, 0)); @@ -4314,35 +4622,40 @@ impl TaxiActionStore for InMemoryStore { } } -impl MeleeActionStore for InMemoryStore { +impl MeleeActionStore for WorldFake { fn start_attack(&self, _account_id: u64, _self_guid: u64, _target_guid: u64) -> Result<()> { self.rec("start_attack"); - match &self.start_attack_error { + match &self.melee.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); + self.melee.stop_attacks.lock().unwrap().push(self_guid); Ok(()) } } -impl ChatActionStore for InMemoryStore { +impl ChatActionStore for WorldFake { fn speaker_facts(&self, _speaker_guid: u64) -> Result> { - Ok(self.speaker_facts.clone()) + Ok(self.chat.speaker_facts.clone()) } fn realm_chat(&self, speaker_guid: u64, request: RealmChatRequest) -> Result { - self.realm_chats + self.chat + .realm_chats .lock() .unwrap() .push((speaker_guid, request)); - Ok(self.realm_chat_outcome.unwrap_or(ChatOutcome::Delivered)) + Ok(self + .chat + .realm_chat_outcome + .unwrap_or(ChatOutcome::Delivered)) } fn set_away(&self, speaker_guid: u64, kind: u8, message: String) -> Result<()> { - self.away_requests + self.chat + .away_requests .lock() .unwrap() .push((speaker_guid, kind, message)); @@ -4361,22 +4674,26 @@ impl ChatActionStore for InMemoryStore { /// 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 + self.chat + .realm_whispers .lock() .unwrap() .push((speaker_guid, request)); - if let Some(e) = &self.realm_whisper_error { + if let Some(e) = &self.chat.realm_whisper_error { return Err(anyhow!("{e}")); } - Ok(self.realm_whisper_outcome.unwrap_or(ChatOutcome::Delivered)) + Ok(self + .chat + .realm_whisper_outcome + .unwrap_or(ChatOutcome::Delivered)) } fn speaker_gm_level(&self, _speaker_guid: u64) -> Result { - Ok(self.gm_level) + Ok(self.chat.gm_level) } } -impl SpeechStore for InMemoryStore { +impl SpeechStore for WorldFake { fn send_chat( &self, _account_id: u64, @@ -4388,11 +4705,15 @@ impl SpeechStore for InMemoryStore { // 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 + self.speech + .chats .lock() .unwrap() .push((chat_type, language, message)); - Ok(self.send_chat_outcome.unwrap_or(ChatOutcome::Delivered)) + Ok(self + .speech + .send_chat_outcome + .unwrap_or(ChatOutcome::Delivered)) } fn send_emote( @@ -4408,23 +4729,29 @@ impl SpeechStore for InMemoryStore { } fn gm_command(&self, account_name: &str, _self_guid: u64, text: String) -> Result<()> { - if let Some(alpha_test_tools) = &self.gm_alpha_test_tools { + if let Some(alpha_test_tools) = &self.speech.gm_alpha_test_tools { let authorized = alpha_test_tools.load(std::sync::atomic::Ordering::SeqCst); - self.gm_commands + self.speech + .gm_commands .lock() .unwrap() .push((account_name.to_string(), text.clone())); - self.gm_authority_results.lock().unwrap().push(authorized); + self.speech + .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); + self.speech.gm_gameplay_changes.lock().unwrap().push(text); return Ok(()); } return Err(anyhow!("permission denied")); } - match &self.gm_command_error { + match &self.speech.gm_command_error { Some(e) => Err(anyhow!("{e}")), None => { - self.gm_commands + self.speech + .gm_commands .lock() .unwrap() .push((account_name.to_string(), text)); @@ -4434,18 +4761,19 @@ impl SpeechStore for InMemoryStore { } } -impl ChannelActionStore for InMemoryStore { +impl ChannelActionStore for WorldFake { fn channel_op( &self, actor_guid: u64, op: u8, request: ChannelRequest, ) -> Result { - self.channel_ops + self.channel + .channel_ops .lock() .unwrap() .push((actor_guid, op, request)); - Ok(self.channel_outcome.unwrap_or(ChannelOutcome::Done)) + Ok(self.channel.channel_outcome.unwrap_or(ChannelOutcome::Done)) } fn channel_roster(&self, _team: u32, _channel_name: &str) -> Result> { @@ -4463,7 +4791,7 @@ impl ChannelActionStore for InMemoryStore { /// 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 { +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, @@ -4497,7 +4825,7 @@ impl MeetingStoneActionStore for InMemoryStore { } } -impl DuelActionStore for InMemoryStore { +impl DuelActionStore for WorldFake { fn duel_accept(&self, _account_id: u64, _actor_guid: u64, _flag_guid: u64) -> Result<()> { Ok(()) } @@ -4507,9 +4835,10 @@ impl DuelActionStore for InMemoryStore { } } -impl GuildActionStore for InMemoryStore { +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) @@ -4518,6 +4847,7 @@ impl GuildActionStore for InMemoryStore { fn guild(&self, guild_id: u32) -> Result> { Ok(self + .guild .guilds .iter() .find(|guild| guild.guild_id == guild_id) @@ -4541,7 +4871,7 @@ impl GuildActionStore for InMemoryStore { level: c.level, zone_id: c.zone_id, last_logout_micros: 0, - online: !self.offline_guids.contains(&c.guid), + online: !self.social.offline_guids.contains(&c.guid), in_transit: false, realm_account_id: 0, })) @@ -4613,6 +4943,7 @@ impl GuildActionStore for InMemoryStore { charter_item_guid: u64, ) -> Result> { Ok(self + .guild .guild_petitions .iter() .find(|petition| petition.charter_item_guid == charter_item_guid) @@ -4621,6 +4952,7 @@ impl GuildActionStore for InMemoryStore { fn guild_petition_of_owner(&self, owner_guid: u64) -> Result> { Ok(self + .guild .guild_petitions .iter() .find(|petition| petition.owner_guid == owner_guid) @@ -4629,13 +4961,14 @@ impl GuildActionStore for InMemoryStore { 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(self.held_charters.contains(&charter_item_guid)) + Ok(self.guild.held_charters.contains(&charter_item_guid)) } fn guild_destroy_charter(&self, _actor_guid: u64, _charter_item_guid: u64) -> Result<()> { @@ -4645,10 +4978,11 @@ impl GuildActionStore for InMemoryStore { fn guild_character_guids(&self) -> Result> { let mut guids: Vec = self + .guild .guild_memberships .iter() .map(|member| member.character_guid) - .chain(self.guild_petitions.iter().flat_map(|petition| { + .chain(self.guild.guild_petitions.iter().flat_map(|petition| { std::iter::once(petition.owner_guid).chain(petition.signers.iter().copied()) })) .collect(); @@ -4658,7 +4992,7 @@ impl GuildActionStore for InMemoryStore { } fn guild_names_character(&self, character_guid: u64) -> Result { - if self.guild_lookup_error_for == Some(character_guid) { + 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)) @@ -4673,9 +5007,9 @@ impl GuildActionStore for InMemoryStore { } /// Realm-core refuses every fee: the socket tests only watch the steps run. -impl crate::world::guild_fee::GuildFeeStore for InMemoryStore { +impl crate::world::guild_fee::GuildFeeStore for WorldFake { fn guild_fee_held(&self, _actor_guid: u64) -> Result> { - Ok(self.guild_fee_hold.lock().unwrap().clone()) + Ok(self.guild.guild_fee_hold.lock().unwrap().clone()) } fn guild_fee_hold( @@ -4706,19 +5040,19 @@ impl crate::world::guild_fee::GuildFeeStore for InMemoryStore { _accepted: bool, ) -> Result<()> { self.rec("guild_fee_finish"); - *self.guild_fee_hold.lock().unwrap() = None; + *self.guild.guild_fee_hold.lock().unwrap() = None; Ok(()) } } -impl AuctionActionStore for InMemoryStore { +impl AuctionActionStore for WorldFake { fn auction_interaction( &self, _player_guid: u64, _auctioneer_guid: u64, ) -> Result> { self.rec("auction_interaction"); - Ok(self.auction_interaction) + Ok(self.auction.auction_interaction) } fn create_auction( @@ -4760,17 +5094,18 @@ impl AuctionActionStore for InMemoryStore { } } -impl QuestActionStore for InMemoryStore { +impl QuestActionStore for WorldFake { fn giver_quest_evals( &self, _giver_guid: u64, _player_guid: u64, ) -> Result> { - Ok(self.quest_evals.clone()) + 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) @@ -4800,7 +5135,7 @@ impl QuestActionStore for InMemoryStore { } fn player_quest_log(&self, _player_guid: u64) -> Result> { - Ok(self.quest_log_slots.clone()) + Ok(self.quest.quest_log_slots.clone()) } fn abandon_quest(&self, _account_id: u64, _self_guid: u64, _quest_id: u32) -> Result<()> { @@ -4813,6 +5148,7 @@ impl QuestActionStore for InMemoryStore { fn quest_status(&self, _player_guid: u64, quest_id: u32) -> (bool, bool) { match self + .quest .quest_log .lock() .unwrap() @@ -4835,23 +5171,26 @@ impl QuestActionStore for InMemoryStore { if let Some(e) = &self.trade_error { return Err(anyhow!("{e}")); } - self.turned_in + self.quest + .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 + if let Some(letter) = self.quest.turn_in_reward_letter.clone() { + self.mail + .attested .lock() .unwrap() .push((letter.escrow_id, false)); - self.mail_escrows + self.mail + .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(), + 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)), @@ -4881,9 +5220,9 @@ impl QuestActionStore for InMemoryStore { } } -impl VendorActionStore for InMemoryStore { +impl VendorActionStore for WorldFake { fn vendor_stock(&self, _vendor_guid: u64) -> Result> { - Ok(self.vendor_stock.clone()) + Ok(self.vendor.vendor_stock.clone()) } fn vendor_refuses_interaction(&self, _vendor_guid: u64, _player_guid: u64) -> Result { @@ -4905,7 +5244,7 @@ impl VendorActionStore for InMemoryStore { } fn buyback_slots(&self, _player_guid: u64) -> Vec<(u32, u32, u32, u32)> { - self.buyback_ring.clone() + self.vendor.buyback_ring.clone() } fn random_property_enchant_ids(&self, _random_property_id: u32) -> [u32; 3] { @@ -4913,7 +5252,8 @@ impl VendorActionStore for InMemoryStore { } fn vendor_item_slot(&self, item_guid: u64) -> Option { - self.item_slots + self.vendor + .item_slots .iter() .find(|(g, _)| *g == item_guid) .map(|&(_, s)| s) @@ -4955,12 +5295,16 @@ impl VendorActionStore for InMemoryStore { if let Some(e) = &self.trade_error { return Err(anyhow!("{e}")); } - self.bought_back.lock().unwrap().push((vendor_guid, slot)); + self.vendor + .bought_back + .lock() + .unwrap() + .push((vendor_guid, slot)); Ok(()) } } -impl InMemoryStore { +impl WorldFake { /// 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 { @@ -4973,7 +5317,7 @@ impl InMemoryStore { } } -impl ItemActionStore for InMemoryStore { +impl ItemActionStore for WorldFake { fn equip_item( &self, _account_id: u64, @@ -5003,17 +5347,18 @@ impl ItemActionStore for InMemoryStore { } fn use_item(&self, _account_id: u64, _self_guid: u64, slot: u8) -> Result { - self.used_items.lock().unwrap().push(slot); + self.item.used_items.lock().unwrap().push(slot); self.canned_item_action() } } -impl WeatherStore for InMemoryStore { +impl WeatherStore for WorldFake { fn zone_weather(&self, zone_id: u32) -> Result> { - if let Some(e) = &self.weather_error { + if let Some(e) = &self.weather.weather_error { return Err(anyhow!("{e}")); } Ok(self + .weather .zone_weather .iter() .find(|(zone, _)| *zone == zone_id) @@ -5024,16 +5369,17 @@ impl WeatherStore for InMemoryStore { /// 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 { +impl MemberStatsStore for WorldFake { fn group_mates(&self, self_guid: u64) -> Result> { - let roster = match &self.realm { + let roster = match &self.topology.realm { Some(realm) => { - let party = realm.party.lock().unwrap(); + let party = realm.party.party.lock().unwrap(); party .group_of(self_guid) .and_then(|group| party.roster(group)) } None => self + .party .mirror .lock() .unwrap() @@ -5050,7 +5396,8 @@ impl MemberStatsStore for InMemoryStore { } fn member_presence(&self, guid: u64) -> Result { - self.member_presence_reads + 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 @@ -5069,9 +5416,9 @@ impl MemberStatsStore for InMemoryStore { } } -impl LootWindowStore for InMemoryStore { +impl LootWindowStore for WorldFake { fn loot_target_money(&self, _target_guid: u64) -> Result { - Ok(self.corpse_money) + Ok(self.loot_window.corpse_money) } fn loot_target_items( @@ -5079,11 +5426,13 @@ impl LootWindowStore for InMemoryStore { _target_guid: u64, viewer_guid: u64, ) -> Result> { - self.corpse_loot_reads + self.loot_window + .corpse_loot_reads .lock() .unwrap() .push((_target_guid, viewer_guid)); Ok(self + .loot_window .corpse_loot_by_viewer .get(&viewer_guid) .cloned() @@ -5096,7 +5445,11 @@ impl LootWindowStore for InMemoryStore { _actor_guid: u64, target_guid: u64, ) -> Result { - self.gameobjects_used.lock().unwrap().push(target_guid); + self.loot_window + .gameobjects_used + .lock() + .unwrap() + .push(target_guid); Ok(LootWindowRequestStatus::Applied) } @@ -5115,13 +5468,13 @@ impl LootWindowStore for InMemoryStore { _actor_guid: u64, target_guid: u64, ) -> Result { - if let Some(error) = &self.skinning_failure { + if let Some(error) = &self.loot_window.skinning_failure { return Err(anyhow!(error.clone())); } - if let Some(refusal) = self.skinning_refusal { + if let Some(refusal) = self.loot_window.skinning_refusal { return Ok(LootWindowRequestStatus::Refused(refusal)); } - self.skinned.lock().unwrap().push(target_guid); + self.loot_window.skinned.lock().unwrap().push(target_guid); Ok(LootWindowRequestStatus::Applied) } @@ -5131,7 +5484,11 @@ impl LootWindowStore for InMemoryStore { _actor_guid: u64, target_guid: u64, ) -> Result { - self.money_looted.lock().unwrap().push(target_guid); + self.loot_window + .money_looted + .lock() + .unwrap() + .push(target_guid); Ok(LootWindowRequestStatus::Applied) } @@ -5142,7 +5499,8 @@ impl LootWindowStore for InMemoryStore { target_guid: u64, loot_slot: u8, ) -> Result { - self.items_looted + self.loot_window + .items_looted .lock() .unwrap() .push((target_guid, loot_slot)); @@ -5150,7 +5508,7 @@ impl LootWindowStore for InMemoryStore { } } -impl LootRollStore for InMemoryStore { +impl LootRollStore for WorldFake { fn loot_roll( &self, _account_id: u64, @@ -5159,13 +5517,14 @@ impl LootRollStore for InMemoryStore { loot_slot: u32, vote: u8, ) -> Result { - if let Some(failure) = &self.loot_action_failure { + if let Some(failure) = &self.loot_roll.loot_action_failure { return Err(anyhow!(failure.clone())); } - if let Some(refusal) = self.loot_action_refusal { + if let Some(refusal) = self.loot_roll.loot_action_refusal { return Ok(LootActionStatus::Refused(refusal)); } - self.loot_rolls + self.loot_roll + .loot_rolls .lock() .unwrap() .push((corpse_guid, loot_slot, vote)); @@ -5180,16 +5539,17 @@ impl LootRollStore for InMemoryStore { loot_slot: u8, target_guid: u64, ) -> Result { - if let Some(failure) = &self.loot_action_failure { + if let Some(failure) = &self.loot_roll.loot_action_failure { return Err(anyhow!(failure.clone())); } - if let Some(refusal) = self.loot_action_refusal { + if let Some(refusal) = self.loot_roll.loot_action_refusal { return Ok(LootActionStatus::Refused(refusal)); } - self.loot_master_gives - .lock() - .unwrap() - .push((corpse_guid, loot_slot, target_guid)); + self.loot_roll.loot_master_gives.lock().unwrap().push(( + corpse_guid, + loot_slot, + target_guid, + )); Ok(LootActionStatus::Applied) } @@ -5209,7 +5569,7 @@ impl LootRollStore for InMemoryStore { source_roll_id: u64, ) -> Result<()> { self.rec("realm_loot_op"); - self.realm_loot_ops.lock().unwrap().push(( + self.loot_roll.realm_loot_ops.lock().unwrap().push(( op, corpse_guid, slot, @@ -5222,7 +5582,7 @@ impl LootRollStore for InMemoryStore { promotion_source, source_roll_id, )); - if let Some(e) = &self.realm_loot_op_error { + if let Some(e) = &self.loot_roll.realm_loot_op_error { return Err(anyhow!("{e}")); } Ok(()) @@ -5248,24 +5608,26 @@ impl LootRollStore for InMemoryStore { spacetimedb_sdk::Identity::ZERO, 0, )?; - if let Some(failure) = &self.loot_action_failure { + 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.pending_rolls.lock().unwrap().clone()) + 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.settle_loot_roll_error { + if let Some(e) = &self.loot_roll.settle_loot_roll_error { return Err(anyhow!("{e}")); } - self.settled_rolls + self.loot_roll + .settled_rolls .lock() .unwrap() .push((corpse_guid, slot, winner_guid)); @@ -5274,8 +5636,9 @@ impl LootRollStore for InMemoryStore { 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 + self.loot_roll.cleared_rolls.lock().unwrap().push(roll_id); + self.loot_roll + .pending_rolls .lock() .unwrap() .retain(|r| r.roll_id != roll_id); @@ -5283,7 +5646,7 @@ impl LootRollStore for InMemoryStore { } fn loot_won_since(&self, after_id: u64) -> Result<(u64, Vec<(u64, u8, u64)>)> { - let events = self.won_events.lock().unwrap(); + let events = self.loot_roll.won_events.lock().unwrap(); let watermark = events.len() as u64; let wins = events .iter() @@ -5362,14 +5725,17 @@ fn auth_session(username: &str, client_seed: u32, client_proof: [u8; 20]) -> CMS /// 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, - }), +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() } } @@ -5721,7 +6087,7 @@ fn a_restarted_gateway_completes_the_handshake_from_realm_state_alone() { account_id: 42, session_key: K, }; - let handshake_once = |store: InMemoryStore| { + 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; @@ -5735,18 +6101,24 @@ fn a_restarted_gateway_completes_the_handshake_from_realm_state_alone() { server.join().unwrap() }; - let store_before = InMemoryStore { - username: "TESTER".into(), - session: Some(realm_session()), + 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 = InMemoryStore { - username: "TESTER".into(), - session: Some(realm_session()), // re-READ from the realm, not carried over + 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!( @@ -5763,9 +6135,12 @@ fn a_gateway_that_cannot_reach_the_session_store_rejects_rather_than_guessing() // `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, + let store = WorldFake { + session: SessionState { + username: "TESTER".into(), + session: None, + ..Default::default() + }, ..Default::default() }; let (mut client, server_end) = world_session_socket_pair(); @@ -5794,12 +6169,15 @@ fn a_gateway_that_cannot_reach_the_session_store_rejects_rather_than_guessing() 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, - }), + let store = WorldFake { + session: SessionState { + username: "TESTER".into(), + session: Some(WorldSession { + account_id: 1, + session_key: K, + }), + ..Default::default() + }, ..Default::default() }; @@ -5839,9 +6217,12 @@ fn bad_proof_is_rejected() { #[test] fn unknown_account_is_rejected_cleanly() { - let store = InMemoryStore { - username: "TESTER".into(), - session: None, // account exists nowhere / no session + let store = WorldFake { + session: SessionState { + username: "TESTER".into(), + session: None, // account exists nowhere / no session + ..Default::default() + }, ..Default::default() }; @@ -5884,9 +6265,12 @@ fn char_enum_returns_the_seeded_character() { first_login: true, ..Default::default() }; - let store = std::sync::Arc::new(InMemoryStore { - characters: vec![tester], - ..tester_store(7) + 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(); @@ -5921,18 +6305,24 @@ fn char_enum_returns_the_seeded_character() { #[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 store = std::sync::Arc::new({ + let base = tester_store(7); + WorldFake { + guild: GuildState { + 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() + }], + ..base.guild + }, + ..base + } }); let (mut client, server_end) = world_session_socket_pair(); @@ -5988,23 +6378,33 @@ fn sync( } /// 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) +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: &InMemoryStore) -> Vec { +fn recorded(store: &WorldFake) -> Vec { store + .topology .calls .lock() .unwrap() @@ -6054,34 +6454,43 @@ fn a_member_enters_the_world_with_its_guild_on_the_self_create_and_signs_on() { #[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() + let store = std::sync::Arc::new({ + let base = tester_store(7); + WorldFake { + session: SessionState { + login_entity: Some(warrior_entity()), + ..base.session }, - codec::CharacterView { - guid: 2, - name: "Target".into(), - ..Default::default() + guild: GuildState { + 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() + }], + ..base.guild }, - ], - ..tester_store(7) + characters: vec![ + codec::CharacterView { + guid: 1, + name: "Warrior".into(), + ..Default::default() + }, + codec::CharacterView { + guid: 2, + name: "Target".into(), + ..Default::default() + }, + ], + ..base + } }); let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); @@ -6175,12 +6584,18 @@ fn logout_signs_the_member_off_before_the_account_claim_is_released() { #[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 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); @@ -6191,7 +6606,7 @@ fn world_entry_finishes_a_leftover_fee_hold_on_the_home_shard() { && position(&calls, "guild_fee_decide") < position(&calls, "guild_fee_finish"), "{calls:?}" ); - assert_eq!(*store.guild_fee_hold.lock().unwrap(), None); + assert_eq!(*store.guild.guild_fee_hold.lock().unwrap(), None); } #[test] @@ -6241,26 +6656,32 @@ fn a_member_who_is_not_the_leader_saves_no_emblem_over_the_socket() { #[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, + let store = std::sync::Arc::new({ + let base = guild_member_store(); + WorldFake { + guild: GuildState { + 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() + }], + ..base.guild + }, + // 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() }], - ..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() + ..base + } }); let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); @@ -6337,11 +6758,17 @@ fn the_settings_opcodes_reach_their_dispatch_entries_over_the_socket() { #[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 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(); @@ -6381,9 +6808,12 @@ fn char_create_replies_success_then_name_in_use() { level: 1, ..Default::default() }; - let store = std::sync::Arc::new(InMemoryStore { - characters: vec![tester], - ..tester_store(7) + 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(); @@ -6440,9 +6870,12 @@ fn char_delete_replies_success_and_dispatches_owned_guid() { level: 1, ..Default::default() }; - let store = std::sync::Arc::new(InMemoryStore { - characters: vec![tester], - ..tester_store(7) + 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(); @@ -6460,7 +6893,7 @@ fn char_delete_replies_success_and_dispatches_owned_guid() { } other => panic!("expected SMSG_CHAR_DELETE, got {other}"), } - assert_eq!(*store.deleted.lock().unwrap(), vec![(7, 5)]); + assert_eq!(*store.character.deleted.lock().unwrap(), vec![(7, 5)]); drop(client); server.join().unwrap(); @@ -6468,9 +6901,15 @@ fn char_delete_replies_success_and_dispatches_owned_guid() { #[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 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(); @@ -6507,15 +6946,18 @@ fn char_delete_failure_replies_failed_and_keeps_session_alive() { /// 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 { +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(), +) -> 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 { @@ -6526,33 +6968,40 @@ fn guild_cleanup_topology_failing_lookup_for( .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, + 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.peers.lock().unwrap() = vec![world.clone(), instances]; + *world.topology.peers.lock().unwrap() = vec![world.clone(), instances]; world } -fn forgotten(store: &InMemoryStore) -> Vec { +fn forgotten(store: &WorldFake) -> Vec { store + .topology .calls .lock() .unwrap() @@ -6576,8 +7025,9 @@ fn deleted(guids: &[u64]) -> crate::world::GuildCleanup { } } -fn durable_absence_checks(store: &InMemoryStore) -> usize { +fn durable_absence_checks(store: &WorldFake) -> usize { store + .character .durable_absence_checks .load(std::sync::atomic::Ordering::SeqCst) } @@ -6633,12 +7083,18 @@ fn a_failed_lookup_still_cleans_the_other_deleted_characters() { #[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()), + 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()) @@ -6651,21 +7107,27 @@ fn an_unavailable_world_shard_defers_every_guild_cleanup() { /// 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 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(); @@ -6683,7 +7145,7 @@ fn char_delete_of_a_guild_leader_replies_failed_and_deletes_nothing() { } other => panic!("expected SMSG_CHAR_DELETE, got {other}"), } - assert!(store.deleted.lock().unwrap().is_empty()); + assert!(store.character.deleted.lock().unwrap().is_empty()); drop(client); server.join().unwrap(); @@ -6763,10 +7225,16 @@ 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 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(); @@ -6800,17 +7268,26 @@ 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, + 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.weather }, - )], - ..tester_store(7) + ..base + } }); let (mut client, server_end) = world_session_socket_pair(); @@ -6896,7 +7373,7 @@ fn player_login_emits_sequence_then_self_create() { } /// Drive one login to completion and hand back the world-entry weather packet. -fn world_entry_weather(store: std::sync::Arc) -> SMSG_WEATHER { +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 || { @@ -6923,9 +7400,15 @@ fn world_entry_weather(store: std::sync::Arc) -> SMSG_WEATHER { /// 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) + let weather = world_entry_weather(std::sync::Arc::new({ + let base = tester_store(7); + WorldFake { + session: SessionState { + login_entity: Some(warrior_entity()), + ..base.session + }, + ..base + } })); assert_eq!(weather.weather_type, WeatherType::Fine); assert_eq!(weather.grade, 0.0); @@ -6937,10 +7420,19 @@ fn world_entry_into_a_zone_with_no_weather_row_sends_fine_weather() { /// 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) + let weather = world_entry_weather(std::sync::Arc::new({ + let base = tester_store(7); + WorldFake { + session: SessionState { + login_entity: Some(warrior_entity()), + ..base.session + }, + weather: WeatherState { + weather_error: Some("shard cache unavailable".into()), + ..base.weather + }, + ..base + } })); assert_eq!(weather.weather_type, WeatherType::Fine); assert_eq!(weather.grade, 0.0); @@ -6949,7 +7441,7 @@ fn a_weather_read_failure_still_completes_world_entry() { /// 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, + store: std::sync::Arc, character_guid: u64, ) -> (UnixStream, DecrypterHalf, std::thread::JoinHandle<()>) { let (mut client, server_end) = world_session_socket_pair(); @@ -7004,10 +7496,16 @@ fn forced_elwynn_rain_reaches_only_the_client_standing_in_elwynn() { 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) + std::sync::Arc::new({ + let base = tester_store(7); + WorldFake { + session: SessionState { + login_entity: Some(entity), + relay_view: Some(view.clone()), + ..base.session + }, + ..base + } }) }; @@ -7075,13 +7573,22 @@ fn worldport_ack_reenters_with_fresh_subscription_and_empty_loot_state() { 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 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(); @@ -7131,7 +7638,7 @@ fn worldport_ack_reenters_with_fresh_subscription_and_empty_loot_state() { // 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(); + let calls = store.session.subscribed.lock().unwrap().clone(); assert_eq!( calls.len(), 2, @@ -7156,12 +7663,12 @@ fn worldport_ack_reenters_with_fresh_subscription_and_empty_loot_state() { drop(client); server.join().unwrap(); assert!( - store.money_looted.lock().unwrap().is_empty(), + 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.login_entries.lock().unwrap().as_slice(), + store.session.login_entries.lock().unwrap().as_slice(), [codec::WorldEntry::FreshLogin, codec::WorldEntry::WorldPort] ); } @@ -7173,12 +7680,18 @@ fn worldport_removes_the_source_viewer_before_routing_and_registers_a_replacemen 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 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(); @@ -7206,7 +7719,12 @@ fn worldport_removes_the_source_viewer_before_routing_and_registers_a_replacemen } assert_eq!( - store.viewer_present_at_settle.lock().unwrap().as_slice(), + 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" @@ -7229,12 +7747,21 @@ 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 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(); @@ -7278,24 +7805,36 @@ fn login_initialize_factions_carries_persisted_standing_at_its_reputation_index( #[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 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(); @@ -7336,19 +7875,28 @@ fn login_with_resident_items_and_reputation_emits_no_gain_feedback() { #[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 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(); @@ -7382,9 +7930,15 @@ fn inbound_movement_is_recorded_under_its_opcode() { MSG_MOVE_HEARTBEAT_Client, MovementInfo, MovementInfo_MovementFlags, Vector3d, }; - let store = std::sync::Arc::new(InMemoryStore { - entity_in_world: true, - ..tester_store(7) + 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(); @@ -7412,7 +7966,7 @@ fn inbound_movement_is_recorded_under_its_opcode() { drop(client); // server reads the heartbeat, then EOF server.join().unwrap(); - let moves = store.moves.lock().unwrap(); + let moves = store.session.moves.lock().unwrap(); assert_eq!(moves.len(), 1); let (opcode, x, _, _, o, t) = moves[0]; assert_eq!( @@ -7432,10 +7986,19 @@ fn a_movement_packet_for_a_despawned_entity_never_kills_the_session() { }; 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 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(); @@ -7463,6 +8026,7 @@ fn a_movement_packet_for_a_despawned_entity_never_kills_the_session() { .unwrap(); for _ in 0..100 { if store + .session .entity_presence_checks .load(std::sync::atomic::Ordering::SeqCst) != 0 @@ -7473,6 +8037,7 @@ fn a_movement_packet_for_a_despawned_entity_never_kills_the_session() { } assert_ne!( store + .session .entity_presence_checks .load(std::sync::atomic::Ordering::SeqCst), 0, @@ -7507,9 +8072,15 @@ fn a_reappearing_entity_resets_the_movement_desync_tolerance() { 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 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(); @@ -7540,6 +8111,7 @@ fn a_reappearing_entity_resets_the_movement_desync_tolerance() { let wait_for_checks = |n| { for _ in 0..100 { if store + .session .entity_presence_checks .load(std::sync::atomic::Ordering::SeqCst) >= n @@ -7580,7 +8152,7 @@ fn a_reappearing_entity_resets_the_movement_desync_tolerance() { .unwrap() .expect("a restored entity must reset the tolerance before a later transfer tail arrives"); assert_eq!( - store.moves.lock().unwrap().len(), + store.session.moves.lock().unwrap().len(), 2, "only movements sent while the entity was present may reach the shared batch" ); @@ -7591,10 +8163,16 @@ 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 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(); @@ -7629,10 +8207,16 @@ fn a_movement_desync_that_never_heals_still_ends_the_session() { 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 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(); @@ -7687,16 +8271,28 @@ fn a_world_port_whose_transfer_cannot_be_driven_aborts_the_clients_loading_scree 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 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(); @@ -7742,6 +8338,7 @@ fn a_world_port_whose_transfer_cannot_be_driven_aborts_the_clients_loading_scree ); 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" @@ -7758,15 +8355,24 @@ fn a_world_port_whose_world_entry_fails_also_aborts_the_clients_loading_screen() 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 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(); @@ -7866,7 +8472,7 @@ fn movement_state_changes_forward_immediately_and_in_order_never_delayed() { drop(client); server.join().unwrap(); - let moves = store.moves.lock().unwrap(); + let moves = store.session.moves.lock().unwrap(); let opcodes: Vec = moves.iter().map(|(op, ..)| *op).collect(); assert_eq!( opcodes, @@ -7942,7 +8548,7 @@ fn non_movement_opcode_flushes_pending_heartbeat_before_being_handled() { // 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(); + let moves = store.session.moves.lock().unwrap(); assert_eq!( moves.len(), 2, @@ -7993,10 +8599,16 @@ fn desync_error_classifies_entity_missing_as_fatal_but_not_transient() { /// 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) +fn quest_store() -> WorldFake { + { + let base = tester_store(7); + WorldFake { + session: SessionState { + login_entity: Some(warrior_entity()), + ..base.session + }, + ..base + } } } @@ -8042,7 +8654,7 @@ fn eval(quest_id: u32, role: u8, active: bool, complete: bool) -> codec::GiverQu /// 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, + store: std::sync::Arc, guid: u64, ) -> ( UnixStream, @@ -8053,7 +8665,7 @@ fn enter_world( 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 has_quest_log = !store.quest.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 || { @@ -8112,7 +8724,7 @@ fn human_warrior_sword_template() -> codec::ItemTemplateView { 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()]; + 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); @@ -8172,6 +8784,7 @@ 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() @@ -8220,7 +8833,7 @@ fn group_invite_by_name_replies_party_command_result_success() { } other => panic!("expected SMSG_PARTY_COMMAND_RESULT, got {other}"), } - assert_eq!(store.group_invites.lock().unwrap().as_slice(), &[2]); + assert_eq!(store.party.group_invites.lock().unwrap().as_slice(), &[2]); drop(client); let _ = server.join(); } @@ -8275,7 +8888,7 @@ fn a_group_invite_timeout_is_not_answered_as_a_refusal() { name: "Buddy".into(), ..Default::default() }]; - s.login_entity = Some(warrior_entity()); + 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(); @@ -8325,7 +8938,7 @@ fn group_invite_unknown_name_replies_bad_player_name() { } other => panic!("expected SMSG_PARTY_COMMAND_RESULT, got {other}"), } - assert!(store.group_invites.lock().unwrap().is_empty()); + assert!(store.party.group_invites.lock().unwrap().is_empty()); drop(client); let _ = server.join(); } @@ -8417,8 +9030,11 @@ fn add_friend_on_another_shard_answers_added_online_with_presence() { ..Default::default() }]; let store = std::sync::Arc::new(s); - let peer = std::sync::Arc::new(InMemoryStore { - shard: "instances".into(), + let peer = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "instances".into(), + ..Default::default() + }, characters: vec![codec::CharacterView { guid: 2, name: "Vim".into(), @@ -8426,20 +9042,23 @@ fn add_friend_on_another_shard_answers_added_online_with_presence() { 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() - }, - )]), + 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.peers.lock().unwrap() = vec![store.clone(), peer.clone()]; - *peer.peers.lock().unwrap() = vec![store.clone(), peer.clone()]; + *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() } @@ -8470,10 +9089,13 @@ fn friend_list_shows_a_friend_in_another_shards_instance_online_afk_or_dnd() { race: 1, ..Default::default() }]; - s.contacts = std::sync::Mutex::new(vec![(1, 2, false)]); // Ginger already friends Vim + 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(InMemoryStore { - shard: "instances".into(), + let peer = std::sync::Arc::new(WorldFake { + topology: TopologyState { + shard: "instances".into(), + ..Default::default() + }, characters: vec![codec::CharacterView { guid: 2, name: "Vim".into(), @@ -8481,19 +9103,22 @@ fn friend_list_shows_a_friend_in_another_shards_instance_online_afk_or_dnd() { 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() - }, - )]), + 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.peers.lock().unwrap() = vec![store.clone(), peer.clone()]; - *peer.peers.lock().unwrap() = vec![store.clone(), peer.clone()]; + *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 {} @@ -8725,7 +9350,7 @@ fn an_add_friend_timeout_is_not_answered_as_a_refusal() { name: "Buddy".into(), ..Default::default() }]; - s.login_entity = Some(warrior_entity()); + 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(); @@ -8812,7 +9437,7 @@ fn quest_choose_reward_relays_inventory_before_completion_over_the_cipher() { // 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()]; + s.quest.quest_details = vec![detail.clone()]; let reward_item = codec::ItemInstanceView { guid: 0x4000_0000_0000_0042, entry: 25, @@ -8827,7 +9452,7 @@ fn quest_choose_reward_relays_inventory_before_completion_over_the_cipher() { item_text_id: 0, enchantment: 0, }; - s.turn_in_reward_item = Some(reward_item.clone()); + 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 { @@ -8880,7 +9505,7 @@ fn quest_choose_reward_relays_inventory_before_completion_over_the_cipher() { drop(client); server.join().unwrap(); assert_eq!( - store.turned_in.lock().unwrap().as_slice(), + store.quest.turned_in.lock().unwrap().as_slice(), &[(7, 50, 1234, 2)] ); } @@ -8895,7 +9520,7 @@ fn login_sends_the_quest_log_descriptor_raw_update_after_the_create_packet() { timer: 0, }]; let mut s = quest_store(); - s.quest_log_slots = slots.clone(); + s.quest.quest_log_slots = slots.clone(); let store = std::sync::Arc::new(s); let (mut client, server_end) = world_session_socket_pair(); @@ -8945,12 +9570,15 @@ fn inspect_in_range_friendly_target_replies_smsg_inspect_with_the_target_guid() 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 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 @@ -8979,10 +9607,16 @@ fn inspect_refused_target_sends_no_reply() { 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 store = std::sync::Arc::new({ + let base = tester_store(7); + WorldFake { + session: SessionState { + login_entity: Some(warrior_entity()), + ..base.session + }, + trade_error: Some("not enough money to buy that item".into()), + ..base + } }); let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); CMSG_BUY_ITEM { @@ -9032,13 +9666,16 @@ 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 store = std::sync::Arc::new({ + let base = quest_store(); + WorldFake { + npc_refuses: true, + characters: vec![codec::CharacterView { + guid: 1, + ..Default::default() + }], + ..base + } }); let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); CMSG_BANKER_ACTIVATE { @@ -9065,7 +9702,7 @@ fn banker_activate_on_a_standing_refusing_banker_sends_no_reply() { 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( + s.npc.gossip_opts = vec![opt( 0, "I would like to check my deposit box.", gossip_option::BANKER, @@ -9092,7 +9729,7 @@ fn gossip_select_on_an_imported_banker_option_opens_the_bank_window() { 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)]; + 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); @@ -9133,7 +9770,10 @@ fn autobank_item_from_the_main_bag_dispatches_auto_bank_item() { .unwrap(); drop(client); server.join().unwrap(); - assert_eq!(store.auto_banked_items.lock().unwrap().as_slice(), &[23]); + assert_eq!( + store.bank.auto_banked_items.lock().unwrap().as_slice(), + &[23] + ); } #[test] @@ -9149,16 +9789,22 @@ fn autostore_bank_item_from_the_main_bag_dispatches_auto_bank_item() { .unwrap(); drop(client); server.join().unwrap(); - assert_eq!(store.auto_banked_items.lock().unwrap().as_slice(), &[39]); + assert_eq!( + store.bank.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 store = std::sync::Arc::new({ + let base = quest_store(); + WorldFake { + trade_error: Some("bank full".into()), + ..base + } }); let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); CMSG_AUTOBANK_ITEM { @@ -9190,7 +9836,7 @@ fn autobank_item_from_a_sub_bag_is_unsupported_and_does_not_dispatch() { drop(client); server.join().unwrap(); assert!( - store.auto_banked_items.lock().unwrap().is_empty(), + store.bank.auto_banked_items.lock().unwrap().is_empty(), "a sub-bag source must not be routed through auto_bank_item" ); } @@ -9208,7 +9854,7 @@ fn autostore_bank_item_from_a_sub_bag_is_unsupported_and_does_not_dispatch() { drop(client); server.join().unwrap(); assert!( - store.auto_banked_items.lock().unwrap().is_empty(), + store.bank.auto_banked_items.lock().unwrap().is_empty(), "a sub-bag source must not be routed through auto_bank_item" ); } @@ -9230,7 +9876,10 @@ fn buy_bank_slot_success_sends_ok_and_reaches_the_named_banker() { } drop(client); server.join().unwrap(); - assert_eq!(store.bought_bank_slots.lock().unwrap().as_slice(), &[88]); + assert_eq!( + store.bank.bought_bank_slots.lock().unwrap().as_slice(), + &[88] + ); } #[test] @@ -9274,10 +9923,16 @@ fn buy_bank_slot_failure_maps_the_bracketed_code_to_the_matching_result() { 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 store = std::sync::Arc::new({ + let base = tester_store(7); + WorldFake { + session: SessionState { + login_entity: Some(warrior_entity()), + ..base.session + }, + trade_error: Some(ItemRefusal::CannotEquip.as_tag().into()), + ..base + } }); 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). @@ -9333,10 +9988,16 @@ fn item_action_before_player_login_is_handled_without_panicking() { 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 store = std::sync::Arc::new({ + let base = tester_store(7); + WorldFake { + session: SessionState { + login_entity: Some(warrior_entity()), + ..base.session + }, + trade_error: Some("equip_item reducer transport disconnected: channel closed".into()), + ..base + } }); let (mut client, server_end) = world_session_socket_pair(); let server_store = store.clone(); @@ -9380,10 +10041,19 @@ fn item_reducer_transport_loss_ends_the_world_session() { 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 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); @@ -9423,12 +10093,13 @@ fn logout_while_out_of_combat_succeeds_and_clears_open_loot() { // 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.money_looted.lock().unwrap().is_empty(), + store.loot_window.money_looted.lock().unwrap().is_empty(), "logout must discard the open loot target" ); } @@ -9440,10 +10111,16 @@ fn logout_while_in_combat_is_denied() { // 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 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); @@ -9501,16 +10178,22 @@ fn played_time_replies_with_the_durable_total_plus_the_live_session_span() { .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 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); @@ -9555,9 +10238,14 @@ fn imported_auction_interaction() -> AuctionInteraction { #[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 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); let auctioneer = Guid::new(42); @@ -9625,15 +10313,21 @@ fn refused_auctioneer_interaction_keeps_the_encrypted_world_session_alive() { // 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 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); @@ -9754,7 +10448,7 @@ fn instant_cast_sends_start_then_raw_cast_result_ok_then_go_and_threads_the_targ 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)]); + assert_eq!(store.cast.casts.lock().unwrap().as_slice(), &[(100, 77)]); } #[test] @@ -9772,7 +10466,7 @@ fn set_sheathed_routes_the_clients_z_press_to_the_store() { drop(client); server.join().unwrap(); assert_eq!( - store.sheathed.lock().unwrap().as_slice(), + store.combat.sheathed.lock().unwrap().as_slice(), &[(1, expect)], "{sent:?} must reach set_sheathed as byte {expect}" ); @@ -9825,11 +10519,11 @@ fn auto_shot_intercept_starts_the_ranged_attack_instead_of_casting() { drop(client); server.join().unwrap(); assert_eq!( - store.ranged_attacks.lock().unwrap().as_slice(), + store.cast.ranged_attacks.lock().unwrap().as_slice(), &[(88, spell)] ); assert!( - store.casts.lock().unwrap().is_empty(), + store.cast.casts.lock().unwrap().is_empty(), "spell {spell} must not reach cast_spell" ); } @@ -9864,7 +10558,7 @@ fn a_cast_before_entering_the_world_answers_nothing_and_keeps_the_session_alive( } drop(client); server.join().unwrap(); - assert_eq!(store.casts.lock().unwrap().as_slice(), &[(100, 77)]); + assert_eq!(store.cast.casts.lock().unwrap().as_slice(), &[(100, 77)]); } #[test] @@ -9897,7 +10591,7 @@ fn cancelling_auto_repeat_still_tears_the_ranged_loop_down_through_stop_attack() } drop(client); server.join().unwrap(); - assert_eq!(store.stop_attacks.lock().unwrap().as_slice(), &[1]); + assert_eq!(store.melee.stop_attacks.lock().unwrap().as_slice(), &[1]); } // ── Quest giver routing (CMSG_QUESTGIVER_HELLO) ────────────────────────────────────────────────── @@ -9909,8 +10603,8 @@ fn quest_hello_reaches_the_quest_module_and_its_raw_details_body_survives_the_ci // 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")]; + 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 { @@ -9935,7 +10629,7 @@ fn quest_query_answers_the_raw_definition_body_through_the_cipher() { // 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")]; + 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 } @@ -9954,10 +10648,11 @@ fn quest_query_answers_the_raw_definition_body_through_the_cipher() { #[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 + 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.clone(), 1); @@ -9976,14 +10671,24 @@ fn questgiver_gameobject_bypasses_the_chest_lifecycle() { } drop(client); server.join().unwrap(); - assert!(store.gameobjects_used.lock().unwrap().is_empty()); - assert!(store.corpse_loot_reads.lock().unwrap().is_empty()); + assert!(store + .loot_window + .gameobjects_used + .lock() + .unwrap() + .is_empty()); + assert!(store + .loot_window + .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); + s.npc.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); @@ -9995,9 +10700,22 @@ fn non_chest_gameobject_preserves_the_general_use_path() { 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(), + store + .loot_window + .gameobjects_used + .lock() + .unwrap() + .as_slice(), + &[91] + ); + assert_eq!( + store + .loot_window + .corpse_loot_reads + .lock() + .unwrap() + .as_slice(), &[(91, 1)] ); } @@ -10007,8 +10725,10 @@ fn non_chest_gameobject_preserves_the_general_use_path() { #[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)]); + s.npc.gameobject_type = Some(lyracore_shared::constants::go_type::CHEST); + s.loot_window + .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); @@ -10033,12 +10753,28 @@ fn chest_dispatch_opens_the_shared_window_and_tracks_its_target() { 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(), + store + .loot_window + .gameobjects_used + .lock() + .unwrap() + .as_slice(), + &[90] + ); + assert_eq!( + store + .loot_window + .corpse_loot_reads + .lock() + .unwrap() + .as_slice(), &[(90, 1)] ); - assert_eq!(store.items_looted.lock().unwrap().as_slice(), &[(90, 4)]); + assert_eq!( + store.loot_window.items_looted.lock().unwrap().as_slice(), + &[(90, 4)] + ); } #[test] @@ -10060,17 +10796,28 @@ fn loot_before_player_login_is_handled_without_panicking() { .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()); + assert!(store + .loot_window + .corpse_loot_reads + .lock() + .unwrap() + .is_empty()); + assert!(store.loot_window.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 store = std::sync::Arc::new({ + let base = tester_store(7); + WorldFake { + session: SessionState { + login_entity: Some(entity), + ..base.session + }, + ..base + } }); let (mut client, mut c_enc, _c_dec, server) = enter_world(store.clone(), 0); @@ -10084,15 +10831,26 @@ fn loot_with_a_zero_player_guid_is_handled_without_panicking() { 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()); + assert!(store + .loot_window + .corpse_loot_reads + .lock() + .unwrap() + .is_empty()); + assert!(store.loot_window.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 store = std::sync::Arc::new({ + let base = quest_store(); + WorldFake { + loot_window: LootWindowState { + skinning_refusal: Some(LootWindowRefusal::Unanswered), + ..base.loot_window + }, + ..base + } }); let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); @@ -10109,14 +10867,20 @@ fn skinning_refusal_keeps_the_world_session_alive() { drop(client); server.join().unwrap(); - assert!(store.skinned.lock().unwrap().is_empty()); + assert!(store.loot_window.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 store = std::sync::Arc::new({ + let base = quest_store(); + WorldFake { + loot_window: LootWindowState { + skinning_failure: Some("gw_skin reducer timed out after 10s".to_string()), + ..base.loot_window + }, + ..base + } }); let (mut client, mut c_enc, _c_dec, server) = enter_world(store, 1); @@ -10141,7 +10905,7 @@ fn loot_opens_the_window_and_loot_money_drives_the_tracked_guid() { // 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; + s.loot_window.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); @@ -10175,12 +10939,12 @@ fn loot_opens_the_window_and_loot_money_drives_the_tracked_guid() { drop(client); server.join().unwrap(); assert_eq!( - store.money_looted.lock().unwrap().as_slice(), + store.loot_window.money_looted.lock().unwrap().as_slice(), &[60], "the TRACKED guid was looted" ); assert!( - store.skinned.lock().unwrap().is_empty(), + store.loot_window.skinned.lock().unwrap().is_empty(), "a corpse with money is not skinned" ); } @@ -10208,13 +10972,16 @@ fn loot_money_with_zero_copper_still_clears_with_no_notify() { } drop(client); server.join().unwrap(); - assert_eq!(store.money_looted.lock().unwrap().as_slice(), &[60]); + assert_eq!( + store.loot_window.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 + s.loot_window.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); @@ -10237,11 +11004,11 @@ fn loot_release_clears_the_tracked_target_so_take_requests_are_noops() { drop(client); server.join().unwrap(); assert!( - store.money_looted.lock().unwrap().is_empty(), + store.loot_window.money_looted.lock().unwrap().is_empty(), "release cleared the tracked target" ); assert!( - store.items_looted.lock().unwrap().is_empty(), + store.loot_window.items_looted.lock().unwrap().is_empty(), "release cleared the tracked target before an item take" ); } @@ -10271,7 +11038,7 @@ fn loot_method_dispatches_the_decoded_setting_threshold_and_master() { drop(client); server.join().unwrap(); assert_eq!( - store.group_loot_methods.lock().unwrap().as_slice(), + store.party.group_loot_methods.lock().unwrap().as_slice(), &[( GroupLootSetting::MasterLoot.as_int(), 7, @@ -10297,7 +11064,7 @@ fn loot_roll_dispatches_the_corpse_slot_and_vote() { drop(client); server.join().unwrap(); assert_eq!( - store.loot_rolls.lock().unwrap().as_slice(), + store.loot_roll.loot_rolls.lock().unwrap().as_slice(), &[(60, 2, RollVote::Need.as_int())] ); } @@ -10319,7 +11086,7 @@ fn loot_master_give_dispatches_the_corpse_slot_and_target() { drop(client); server.join().unwrap(); assert_eq!( - store.loot_master_gives.lock().unwrap().as_slice(), + store.loot_roll.loot_master_gives.lock().unwrap().as_slice(), &[(60, 3, 9)] ); } @@ -10329,7 +11096,7 @@ 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); + 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 { @@ -10362,7 +11129,7 @@ fn loot_master_give_refusals_keep_the_world_session_alive() { LootRefusal::RecipientInventoryFull, ] { let mut s = quest_store(); - s.loot_action_refusal = Some(refusal); + 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 { @@ -10388,7 +11155,7 @@ fn loot_master_give_refusals_keep_the_world_session_alive() { #[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()); + 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 { @@ -10409,7 +11176,8 @@ fn loot_roll_timeout_ends_the_world_session() { #[test] fn loot_master_give_transport_failure_ends_the_world_session() { let mut s = quest_store(); - s.loot_action_failure = Some("gw_loot_master_give reducer transport disconnected".to_string()); + 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 { @@ -10513,7 +11281,7 @@ fn melee_opcodes_at_character_select_answer_nothing_and_keep_the_session_alive() drop(client); server.join().unwrap(); assert_eq!( - store.stop_attacks.lock().unwrap().as_slice(), + store.melee.stop_attacks.lock().unwrap().as_slice(), &[0], "the stop still reaches the durable seam under the legacy zero actor" ); @@ -10526,7 +11294,7 @@ fn attackswing_desync_error_is_session_fatal() { // 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()); + s.melee.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(); @@ -10558,13 +11326,13 @@ fn attackswing_desync_error_is_session_fatal() { #[test] fn who_reply_lists_every_online_player_with_guild_level_and_zone() { let mut s = quest_store(); - s.guild_memberships = vec![codec::GuildMemberView { + s.guild.guild_memberships = vec![codec::GuildMemberView { character_guid: 2, guild_id: 7, name: "Alpha".into(), ..Default::default() }]; - s.guilds = vec![codec::GuildView { + s.guild.guilds = vec![codec::GuildView { guild_id: 7, name: "Boundary Test".into(), ..Default::default() @@ -10642,9 +11410,15 @@ 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 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 { @@ -10688,7 +11462,7 @@ fn buyback_maps_the_wire_slot_enum_to_zero_based_ring_slots() { drop(client); server.join().unwrap(); assert_eq!( - store.bought_back.lock().unwrap().as_slice(), + store.vendor.bought_back.lock().unwrap().as_slice(), &[(99, 0), (99, 12)] ); } @@ -10724,7 +11498,8 @@ fn trainer_buy_success_replies_succeeded_then_pushes_the_learned_spell() { #[test] fn a_riding_buy_confirms_without_echoing_the_offering_as_a_learned_spell() { let mut s = quest_store(); - s.trainer_offer_skill_lines + s.trainer + .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); @@ -10764,7 +11539,8 @@ fn a_riding_buy_confirms_without_echoing_the_offering_as_a_learned_spell() { 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.character.learned_spells = + vec![lyracore_shared::constants::armor_proficiency::PLATE_PASSIVE_SPELL_ID]; s.characters = vec![codec::CharacterView { guid: 1, class: 1, @@ -10853,7 +11629,7 @@ fn trainer_buy_rank_upgrade_supersedes_the_previous_rank_spell() { // 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 + s.trainer.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 { @@ -10900,7 +11676,7 @@ fn every_module_refusal_has_one_client_failure_reason() { }; for refusal in TrainerRefusal::ALL { let mut s = quest_store(); - s.trainer_buy_refusal = Some(refusal); + s.trainer.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 { @@ -10925,10 +11701,16 @@ fn every_module_refusal_has_one_client_failure_reason() { /// 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 store = std::sync::Arc::new({ + let base = tester_store(7); + WorldFake { + session: SessionState { + login_entity: Some(warrior_entity()), + ..base.session + }, + trade_error: Some("gw_trainer_buy reducer timed out after 10s".into()), + ..base + } }); let (mut client, server_end) = world_session_socket_pair(); let server_store = store.clone(); @@ -10969,7 +11751,7 @@ 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; + s.trainer.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 { @@ -10996,7 +11778,7 @@ fn learn_talent_passive_pushes_rank_spell_and_points() { // 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 + s.trainer.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 { @@ -11024,7 +11806,7 @@ 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 + s.trainer.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 { @@ -11058,7 +11840,7 @@ fn learn_talent_rank_upgrade_supersedes_the_previous_rank_spell() { #[test] fn list_inventory_opens_the_vendor_window_over_the_socket() { let mut s = quest_store(); - s.vendor_stock = vec![codec::VendorItemView { + s.vendor.vendor_stock = vec![codec::VendorItemView { item_entry: 4540, display_id: 6353, buy_price: 25, @@ -11084,7 +11866,7 @@ 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 { + s.vendor.vendor_stock = vec![codec::VendorItemView { item_entry: 4540, display_id: 6353, buy_price: 25, @@ -11110,14 +11892,17 @@ fn gossip_select_on_a_vendor_opens_the_inventory_window() { ); drop(client); server.join().unwrap(); - assert!(!store.home_bound.load(std::sync::atomic::Ordering::SeqCst)); + 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.innkeeper = true; + 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); @@ -11136,7 +11921,10 @@ fn gossip_select_on_an_innkeeper_binds_home_and_completes() { drop(client); server.join().unwrap(); assert!( - store.home_bound.load(std::sync::atomic::Ordering::SeqCst), + store + .npc + .home_bound + .load(std::sync::atomic::Ordering::SeqCst), "bind_home must have run" ); } @@ -11145,7 +11933,7 @@ fn gossip_select_on_an_innkeeper_binds_home_and_completes() { 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; + 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); @@ -11162,7 +11950,10 @@ fn gossip_select_of_any_other_option_completes_without_binding() { } drop(client); server.join().unwrap(); - assert!(!store.home_bound.load(std::sync::atomic::Ordering::SeqCst)); + assert!(!store + .npc + .home_bound + .load(std::sync::atomic::Ordering::SeqCst)); } // --- Imported gossip menu options + multi-slot npc_text ------------------------------------------- @@ -11199,7 +11990,7 @@ fn opt(icon: u32, text: &str, action: u32) -> codec::GossipOptionView { #[test] fn taxi_status_query_returns_the_persisted_bit_without_opening() { let mut s = quest_store(); - s.taxi_status = Some(codec::TaxiNodeStatusView { + s.taxi.taxi_status = Some(codec::TaxiNodeStatusView { npc_guid: 90, known: false, }); @@ -11217,7 +12008,10 @@ fn taxi_status_query_returns_the_persisted_bit_without_opening() { } other => panic!("expected SMSG_TAXINODE_STATUS, got {other}"), } - assert_eq!(*store.taxi_calls.lock().unwrap(), vec![("status", 1, 90)]); + assert_eq!( + *store.taxi.taxi_calls.lock().unwrap(), + vec![("status", 1, 90)] + ); drop(client); server.join().unwrap(); } @@ -11226,8 +12020,8 @@ fn taxi_status_query_returns_the_persisted_bit_without_opening() { 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 { + s.npc.gossip_opts = vec![opt(0, "Show me your flight routes.", gossip_option::TAXI)]; + s.taxi.taxi_map = Some(codec::TaxiMapView { npc_guid: 90, source_client_node_id: 255, available_client_node_ids: vec![255, 256], @@ -11267,7 +12061,7 @@ fn direct_taxi_query_and_taxi_gossip_share_one_open_operation() { } assert_eq!( - *store.taxi_calls.lock().unwrap(), + *store.taxi.taxi_calls.lock().unwrap(), vec![("open", 1, 90), ("open", 1, 90)] ); drop(client); @@ -11277,10 +12071,10 @@ fn direct_taxi_query_and_taxi_gossip_share_one_open_operation() { #[test] fn activate_taxi_gameplay_refusal_replies_and_keeps_the_socket_alive() { let mut s = quest_store(); - s.taxi_activation = codec::TaxiActivationResult { + s.taxi.taxi_activation = codec::TaxiActivationResult { result_code: lyracore_shared::constants::taxi_protocol::ACTIVATE_NOT_ENOUGH_MONEY, }; - s.taxi_status = Some(codec::TaxiNodeStatusView { + s.taxi.taxi_status = Some(codec::TaxiNodeStatusView { npc_guid: 90, known: true, }); @@ -11302,7 +12096,7 @@ fn activate_taxi_gameplay_refusal_replies_and_keeps_the_socket_alive() { other => panic!("expected SMSG_ACTIVATETAXIREPLY, got {other}"), } assert_eq!( - *store.taxi_activation_inputs.lock().unwrap(), + *store.taxi.taxi_activation_inputs.lock().unwrap(), vec![(1, 90, 255, 256)] ); @@ -11323,7 +12117,7 @@ fn activate_taxi_gameplay_refusal_replies_and_keeps_the_socket_alive() { #[test] fn activate_taxi_success_round_trips_over_the_encrypted_socket() { let mut s = quest_store(); - s.taxi_activation = codec::TaxiActivationResult { + s.taxi.taxi_activation = codec::TaxiActivationResult { result_code: lyracore_shared::constants::taxi_protocol::ACTIVATE_OK, }; let store = std::sync::Arc::new(s); @@ -11344,7 +12138,7 @@ fn activate_taxi_success_round_trips_over_the_encrypted_socket() { other => panic!("expected SMSG_ACTIVATETAXIREPLY, got {other}"), } assert_eq!( - *store.taxi_activation_inputs.lock().unwrap(), + *store.taxi.taxi_activation_inputs.lock().unwrap(), vec![(1, 90, 255, 256)] ); @@ -11356,8 +12150,8 @@ fn activate_taxi_success_round_trips_over_the_encrypted_socket() { 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()); + 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); @@ -11381,7 +12175,7 @@ fn gossip_hello_renders_imported_options_verbatim_with_a_trailing_farewell() { // 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![ + 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( @@ -11391,11 +12185,11 @@ fn gossip_hello_renders_imported_options_verbatim_with_a_trailing_farewell() { ), ]; // Fallback signals present too — must be ignored while options are imported. - s.vendor_stock = vec![codec::VendorItemView { + s.vendor.vendor_stock = vec![codec::VendorItemView { item_entry: 1, ..Default::default() }]; - s.innkeeper = true; + 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 { @@ -11426,8 +12220,8 @@ fn gossip_hello_renders_imported_options_verbatim_with_a_trailing_farewell() { 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 { + 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, @@ -11452,14 +12246,17 @@ fn gossip_select_on_an_imported_vendor_option_opens_the_inventory_window() { ); drop(client); server.join().unwrap(); - assert!(!store.home_bound.load(std::sync::atomic::Ordering::SeqCst)); + 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.gossip_opts = vec![ + s.npc.gossip_opts = vec![ opt(0, "Chat.", gossip_option::GOSSIP), opt(0, "Stay here.", gossip_option::INNKEEPER), ]; @@ -11480,7 +12277,10 @@ fn gossip_select_on_an_imported_innkeeper_option_binds_home() { drop(client); server.join().unwrap(); assert!( - store.home_bound.load(std::sync::atomic::Ordering::SeqCst), + store + .npc + .home_bound + .load(std::sync::atomic::Ordering::SeqCst), "bind_home must have run" ); } @@ -11499,7 +12299,7 @@ fn the_same_option_row_reaches_the_module_by_row_id_from_either_viewer() { }; // 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(); + 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); @@ -11517,8 +12317,8 @@ fn the_same_option_row_reaches_the_module_by_row_id_from_either_viewer() { // 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(); + 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); @@ -11534,8 +12334,8 @@ fn the_same_option_row_reaches_the_module_by_row_id_from_either_viewer() { 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]; + 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), @@ -11554,7 +12354,7 @@ fn a_quest_taken_while_the_window_is_open_does_not_shift_the_click() { gated.cond_value1 = 60; let mut inn = opt(0, "Stay here.", gossip_option::INNKEEPER); inn.row_id = 4002; - s.gossip_opts = vec![gated, inn]; + 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. @@ -11562,7 +12362,7 @@ fn a_quest_taken_while_the_window_is_open_does_not_shift_the_click() { 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)); + store.quest.quest_log.lock().unwrap().push((60, false)); CMSG_GOSSIP_SELECT_OPTION { guid: Guid::new(90), gossip_list_id: 0, @@ -11577,11 +12377,14 @@ fn a_quest_taken_while_the_window_is_open_does_not_shift_the_click() { drop(client); server.join().unwrap(); assert!( - store.home_bound.load(std::sync::atomic::Ordering::SeqCst), + 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.gossip_selects.lock().unwrap()[0], + 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" ); @@ -11591,7 +12394,7 @@ fn a_quest_taken_while_the_window_is_open_does_not_shift_the_click() { 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)]; + 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); @@ -11609,9 +12412,12 @@ fn a_select_with_no_open_menu_just_closes_the_window() { } drop(client); server.join().unwrap(); - assert!(!store.home_bound.load(std::sync::atomic::Ordering::SeqCst)); + assert!(!store + .npc + .home_bound + .load(std::sync::atomic::Ordering::SeqCst)); assert_eq!( - store.gossip_selects.lock().unwrap()[0].1, + store.npc.gossip_selects.lock().unwrap()[0].1, codec::SYNTHESIZED_ROW_ID, "no imported row was selected" ); @@ -11621,8 +12427,8 @@ fn a_select_with_no_open_menu_just_closes_the_window() { 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 { + 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, @@ -11652,8 +12458,8 @@ fn an_imported_menu_missing_its_vendor_row_still_reaches_the_stock() { 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; + 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); @@ -11671,20 +12477,26 @@ fn an_imported_menu_missing_its_bind_row_still_offers_the_hearth() { } drop(client); server.join().unwrap(); - assert!(store.home_bound.load(std::sync::atomic::Ordering::SeqCst)); + 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) -> InMemoryStore { - InMemoryStore { - characters: vec![codec::CharacterView { - guid: 1, - level, - ..Default::default() - }], - ..quest_store() +fn quest_store_at_level(level: u8) -> WorldFake { + { + let base = quest_store(); + WorldFake { + characters: vec![codec::CharacterView { + guid: 1, + level, + ..Default::default() + }], + ..base + } } } @@ -11695,7 +12507,7 @@ fn gossip_hello_hides_unlearn_talents_below_level_10() { // 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![ + s.npc.gossip_opts = vec![ opt(0, "I require warrior training.", gossip_option::TRAINER), opt( 0, @@ -11731,7 +12543,7 @@ fn gossip_hello_hides_unlearn_talents_below_level_10() { 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![ + s.npc.gossip_opts = vec![ opt(0, "I require warrior training.", gossip_option::TRAINER), opt( 0, @@ -11773,7 +12585,7 @@ fn gossip_hello_shows_unlearn_talents_at_level_10_and_select_routes_to_reset_tal } drop(client); server.join().unwrap(); - let calls = store.reset_talents_calls.lock().unwrap(); + let calls = store.trainer.reset_talents_calls.lock().unwrap(); assert_eq!( calls.as_slice(), &[(7, 1, 90)], @@ -11788,10 +12600,10 @@ fn gossip_hello_shows_a_quest_gated_option_once_the_quest_is_taken() { // 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 + 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 { @@ -11818,13 +12630,13 @@ fn gossip_hello_and_select_option_stay_position_aligned_under_a_hidden_option() // `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![ + 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.gossip_opts[1].cond_type = gossip_condition::QUEST_TAKEN; - s.gossip_opts[1].cond_value1 = 60; // never taken in this store + 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 { @@ -11856,7 +12668,10 @@ fn gossip_hello_and_select_option_stay_position_aligned_under_a_hidden_option() drop(client); server.join().unwrap(); assert!( - store.home_bound.load(std::sync::atomic::Ordering::SeqCst), + store + .npc + .home_bound + .load(std::sync::atomic::Ordering::SeqCst), "position 1 must resolve to the innkeeper option, not the hidden one" ); } @@ -11875,7 +12690,7 @@ fn npc_text_query_ships_the_imported_8_slot_view() { 0.4, ); let mut s = quest_store(); - s.npc_text_view = Some(view); + 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 { @@ -11920,7 +12735,7 @@ fn messagechat_say_and_yell_route_to_chat_types_0_and_1() { 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(), + 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" ); @@ -11940,7 +12755,7 @@ fn messagechat_emote_routes_to_chat_type_3() { 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(), + 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)" ); @@ -11969,12 +12784,12 @@ fn messagechat_dot_say_diverts_to_gm_command_never_touching_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(), ".heal".to_string())], "the proof-validated Account name and raw dot-command must reach the Store together" ); assert!( - store.chats.lock().unwrap().is_empty(), + store.speech.chats.lock().unwrap().is_empty(), "a dot-command must NEVER reach send_chat" ); } @@ -11995,11 +12810,11 @@ fn messagechat_non_dot_say_is_byte_identical_to_before_223() { drop(client); server.join().unwrap(); assert_eq!( - store.chats.lock().unwrap().as_slice(), + store.speech.chats.lock().unwrap().as_slice(), &[(0u8, 0u8, "hi".to_string())] ); assert!( - store.gm_commands.lock().unwrap().is_empty(), + store.speech.gm_commands.lock().unwrap().is_empty(), "a plain Say line must never reach gm_command" ); } @@ -12010,7 +12825,7 @@ fn messagechat_dot_say_error_relays_a_system_chat_line_to_the_sender_only() { // to the SENDER as a System SMSG_MESSAGECHAT carrying the module's raw message VERBATIM — no // "reducer failed" wrapper prefix, no broadcast, no game_chat_event row. let mut s = quest_store(); - s.gm_command_error = Some("permission denied".to_string()); + 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 { @@ -12036,7 +12851,7 @@ fn messagechat_dot_say_error_relays_a_system_chat_line_to_the_sender_only() { } drop(client); server.join().unwrap(); - assert!(store.chats.lock().unwrap().is_empty()); + assert!(store.speech.chats.lock().unwrap().is_empty()); } #[test] @@ -12083,7 +12898,7 @@ fn force_run_speed_change_ack_is_swallowed_with_no_reply_and_no_session_teardown #[test] fn messagechat_whisper_to_an_unknown_player_replies_player_not_found() { let mut s = quest_store(); - s.speaker_facts = Some(human_speaker()); + 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 { @@ -12103,18 +12918,21 @@ fn messagechat_whisper_to_an_unknown_player_replies_player_not_found() { } drop(client); server.join().unwrap(); - assert!(store.realm_whispers.lock().unwrap().is_empty()); + assert!(store.chat.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() +fn chat_session_store() -> WorldFake { + { + let base = quest_store(); + WorldFake { + characters: vec![codec::CharacterView { + guid: 1, + name: "Warrior".into(), + ..Default::default() + }], + ..base + } } } @@ -12141,7 +12959,7 @@ fn messagechat_afk_and_dnd_become_set_away_requests_from_the_sessions_character( drop(client); server.join().unwrap(); assert_eq!( - store.away_requests.lock().unwrap().clone(), + store.chat.away_requests.lock().unwrap().clone(), vec![(1, 0x14, "Brb".to_string()), (1, 0x15, String::new())] ); } @@ -12151,7 +12969,7 @@ fn messagechat_afk_and_dnd_become_set_away_requests_from_the_sessions_character( #[test] fn chat_ignored_becomes_an_ignored_line_to_the_dropped_speaker() { let mut s = chat_session_store(); - s.speaker_facts = Some(SpeakerFacts { + s.chat.speaker_facts = Some(SpeakerFacts { race: 1, chat_tag: 1, name: "Warrior".to_string(), @@ -12167,7 +12985,7 @@ fn chat_ignored_becomes_an_ignored_line_to_the_dropped_speaker() { }); drop(client); server.join().unwrap(); - let requests = store.realm_chats.lock().unwrap(); + let requests = store.chat.realm_chats.lock().unwrap(); assert_eq!( requests.as_slice(), &[( @@ -12193,7 +13011,7 @@ 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()); + 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 { @@ -12214,14 +13032,14 @@ fn messagechat_guild_becomes_one_realm_chat_request_from_the_sessions_character( } drop(client); server.join().unwrap(); - let requests = store.realm_chats.lock().unwrap(); + let requests = store.chat.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(), + store.speech.chats.lock().unwrap().is_empty(), "a guild line never becomes a say line" ); } @@ -12230,7 +13048,7 @@ fn messagechat_guild_becomes_one_realm_chat_request_from_the_sessions_character( 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()); + 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 { @@ -12251,7 +13069,7 @@ fn messagechat_officer_becomes_one_realm_chat_request_from_the_sessions_characte } drop(client); server.join().unwrap(); - let requests = store.realm_chats.lock().unwrap(); + let requests = store.chat.realm_chats.lock().unwrap(); assert_eq!(requests.len(), 1); let (speaker_guid, request) = &requests[0]; assert_eq!(*speaker_guid, 1); @@ -12272,7 +13090,7 @@ 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()); + 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 { @@ -12293,7 +13111,7 @@ fn messagechat_party_becomes_one_realm_chat_request_from_the_sessions_character( } drop(client); server.join().unwrap(); - let requests = store.realm_chats.lock().unwrap(); + let requests = store.chat.realm_chats.lock().unwrap(); assert_eq!(requests.len(), 1); let (speaker_guid, request) = &requests[0]; assert_eq!(*speaker_guid, 1); @@ -12301,7 +13119,7 @@ fn messagechat_party_becomes_one_realm_chat_request_from_the_sessions_character( assert_eq!(request.language, 7); assert_eq!(request.message, "form up"); assert!( - store.chats.lock().unwrap().is_empty(), + store.speech.chats.lock().unwrap().is_empty(), "a party line never becomes a say line" ); } @@ -12309,8 +13127,8 @@ fn messagechat_party_becomes_one_realm_chat_request_from_the_sessions_character( #[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( + s.chat.speaker_facts = Some(human_speaker()); + s.chat.realm_chat_outcome = Some(ChatOutcome::Refused( lyracore_shared::chat::ChatRefusal::NotInGroup, )); let store = std::sync::Arc::new(s); @@ -12336,15 +13154,21 @@ fn messagechat_party_from_an_ungrouped_caller_replies_not_in_group() { } /// 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 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 + } } } @@ -12390,8 +13214,8 @@ fn a_flooding_session_is_muted_before_any_chat_durable_request() { 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()); + assert_eq!(store.speech.chats.lock().unwrap().len(), 11); + assert!(store.chat.realm_chats.lock().unwrap().is_empty()); } #[test] @@ -12415,7 +13239,7 @@ fn a_muted_session_still_sends_addon_lines() { sync(&mut client, &mut c_enc, &mut c_dec, |_, _| {}); drop(client); server.join().unwrap(); - let requests = store.realm_chats.lock().unwrap(); + let requests = store.chat.realm_chats.lock().unwrap(); assert_eq!(requests.len(), 1); assert_eq!(requests[0].1.language, 0xFFFF_FFFF); } @@ -12424,7 +13248,7 @@ fn a_muted_session_still_sends_addon_lines() { 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; + 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| { @@ -12436,14 +13260,14 @@ fn a_game_master_is_never_muted_for_flooding() { }); drop(client); server.join().unwrap(); - assert_eq!(store.chats.lock().unwrap().len(), 15); + 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.send_chat_outcome = Some(ChatOutcome::Refused( + s.speech.send_chat_outcome = Some(ChatOutcome::Refused( lyracore_shared::chat::ChatRefusal::UnknownLanguage, )); let store = std::sync::Arc::new(s); @@ -12467,7 +13291,7 @@ fn a_say_line_in_a_language_the_speaker_does_not_know_answers_the_vanilla_notice #[test] fn join_channel_runs_the_channel_op_as_the_sessions_character() { let mut s = quest_store(); - s.speaker_facts = Some(human_speaker()); + 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); wow_world_messages::vanilla::CMSG_JOIN_CHANNEL { @@ -12487,7 +13311,7 @@ fn join_channel_runs_the_channel_op_as_the_sessions_character() { } drop(client); server.join().unwrap(); - let ops = store.channel_ops.lock().unwrap(); + let ops = store.channel.channel_ops.lock().unwrap(); assert_eq!(ops.len(), 1); let (actor_guid, op, request) = &ops[0]; assert_eq!((*actor_guid, *op), (1, 0)); @@ -12498,8 +13322,8 @@ fn join_channel_runs_the_channel_op_as_the_sessions_character() { #[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( + s.chat.speaker_facts = Some(human_speaker()); + s.channel.channel_outcome = Some(ChannelOutcome::Refused( lyracore_shared::channel::ChannelRefusal::WrongPassword, )); let store = std::sync::Arc::new(s); @@ -12525,7 +13349,7 @@ fn a_refused_join_answers_the_notice_on_the_session() { } // 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) +// below stay here: `WorldFake`'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. @@ -12888,7 +13712,7 @@ fn parse_blob(blob: &[u8]) -> (u64, FakeChar) { #[test] fn trainer_list_replies_smsg_trainer_list_with_the_fixture_spells() { let mut s = quest_store(); - s.trainer_spells = vec![codec::TrainerSpellView { + s.trainer.trainer_spells = vec![codec::TrainerSpellView { spell_id: 100, cost: 10, required_level: 1, @@ -12918,8 +13742,8 @@ fn trainer_list_replies_smsg_trainer_list_with_the_fixture_spells() { #[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 { + s.trainer.trainer_refuses_class = true; + s.trainer.trainer_spells = vec![codec::TrainerSpellView { spell_id: 100, cost: 10, required_level: 1, @@ -12959,7 +13783,7 @@ fn gossip_hides_the_train_and_unlearn_options_for_a_class_the_trainer_does_not_s // 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![ + s.npc.gossip_opts = vec![ opt(0, "Well met, traveler.", gossip_option::GOSSIP), opt(1, "I would like to train.", gossip_option::TRAINER), opt( @@ -12969,7 +13793,7 @@ fn gossip_hides_the_train_and_unlearn_options_for_a_class_the_trainer_does_not_s ), opt(1, "I'd like to browse your goods.", gossip_option::VENDOR), ]; - s.trainer_refuses_class = true; + 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 { @@ -13009,7 +13833,7 @@ 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![ + s.npc.gossip_opts = vec![ opt(0, "Well met, traveler.", gossip_option::GOSSIP), opt(1, "I would like to train.", gossip_option::TRAINER), opt( @@ -13056,7 +13880,7 @@ fn repop_request_dispatches_repop_for_the_caller() { .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]); + assert_eq!(store.death.repopped.lock().unwrap().as_slice(), &[1]); } #[test] @@ -13071,7 +13895,7 @@ fn reclaim_corpse_dispatches_with_the_wire_corpse_guid() { drop(client); server.join().unwrap(); assert_eq!( - store.reclaimed_corpses.lock().unwrap().as_slice(), + store.death.reclaimed_corpses.lock().unwrap().as_slice(), &[(1, 777)] ); } @@ -13089,7 +13913,7 @@ fn resurrect_response_accept_maps_status_byte_to_true() { drop(client); server.join().unwrap(); assert_eq!( - store.resurrect_responses.lock().unwrap().as_slice(), + store.death.resurrect_responses.lock().unwrap().as_slice(), &[(1, true)] ); } @@ -13109,7 +13933,7 @@ fn resurrect_response_decline_maps_status_byte_to_false() { drop(client); server.join().unwrap(); assert_eq!( - store.resurrect_responses.lock().unwrap().as_slice(), + store.death.resurrect_responses.lock().unwrap().as_slice(), &[(1, false)] ); } @@ -13123,14 +13947,20 @@ fn self_res_dispatches_self_resurrect_for_the_caller() { .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]); + assert_eq!(store.death.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 store = std::sync::Arc::new({ + let base = quest_store(); + WorldFake { + death: DeathState { + self_resurrect_error: Some("no Self-Resurrection Option".into()), + ..base.death + }, + ..base + } }); let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); CMSG_SELF_RES {} @@ -13147,7 +13977,7 @@ fn a_refused_self_res_sends_nothing_and_keeps_the_session() { } drop(client); server.join().unwrap(); - assert_eq!(store.self_resurrects.lock().unwrap().as_slice(), &[1]); + assert_eq!(store.death.self_resurrects.lock().unwrap().as_slice(), &[1]); } #[test] @@ -13170,7 +14000,7 @@ fn spirit_healer_activate_dispatches_and_confirms_the_healer_guid() { // 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(), + store.death.spirit_healer_calls.lock().unwrap().as_slice(), &[(1, 888)] ); } @@ -13179,13 +14009,22 @@ fn spirit_healer_activate_dispatches_and_confirms_the_healer_guid() { #[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 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.combat + }, + ..base + } }); let queue = std::sync::Arc::new(LoginQueue::new(1, 0)); let (mut client, server_end) = world_session_socket_pair(); @@ -13228,6 +14067,7 @@ fn reducer_transport_loss_ends_an_admitted_session_and_frees_one_queue_seat() { ); assert!( store + .session .logout_called .load(std::sync::atomic::Ordering::SeqCst), "teardown still attempts leave-world cleanup" @@ -13256,7 +14096,10 @@ fn set_selection_dispatches_set_target_with_the_wire_guid() { .unwrap(); drop(client); server.join().unwrap(); - assert_eq!(store.selected_targets.lock().unwrap().as_slice(), &[321]); + assert_eq!( + store.combat.selected_targets.lock().unwrap().as_slice(), + &[321] + ); } #[test] @@ -13268,7 +14111,10 @@ fn cancel_aura_dispatches_with_the_wire_spell_id() { .unwrap(); drop(client); server.join().unwrap(); - assert_eq!(store.cancelled_auras.lock().unwrap().as_slice(), &[5555]); + assert_eq!( + store.cast.cancelled_auras.lock().unwrap().as_slice(), + &[5555] + ); } #[test] @@ -13280,7 +14126,7 @@ fn cancel_cast_dispatches_for_the_caller() { .unwrap(); drop(client); server.join().unwrap(); - assert_eq!(store.cancelled_casts.lock().unwrap().as_slice(), &[1]); + assert_eq!(store.cast.cancelled_casts.lock().unwrap().as_slice(), &[1]); } #[test] @@ -13505,29 +14351,38 @@ fn every_petition_opcode_reaches_its_dispatch_entry_over_the_socket() { 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() + let store = std::sync::Arc::new({ + let base = tester_store(7); + WorldFake { + session: SessionState { + login_entity: Some(warrior_entity()), + ..base.session }, - codec::CharacterView { - guid: 2, - name: "Target".into(), - ..Default::default() + characters: vec![ + codec::CharacterView { + guid: 1, + name: "Warrior".into(), + ..Default::default() + }, + codec::CharacterView { + guid: 2, + name: "Target".into(), + ..Default::default() + }, + ], + guild: GuildState { + 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], + ..base.guild }, - ], - 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) + ..base + } }); let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); let mut next = diff --git a/gateway/src/world/trade_tests.rs b/gateway/src/world/trade_tests.rs index d5b0f3d77..16b626b72 100644 --- a/gateway/src/world/trade_tests.rs +++ b/gateway/src/world/trade_tests.rs @@ -17,7 +17,10 @@ fn initiate_trade_dispatches_with_the_wire_target_guid() { .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)]); + assert_eq!( + store.trade.initiated_trades.lock().unwrap().as_slice(), + &[(1, 2)] + ); } /// **AC: tested from both sides** — the SAME wire flow works with the seats swapped: player 2 @@ -31,7 +34,10 @@ fn initiate_trade_dispatches_from_the_other_side_too() { .unwrap(); drop(client); server.join().unwrap(); - assert_eq!(store.initiated_trades.lock().unwrap().as_slice(), &[(2, 1)]); + assert_eq!( + store.trade.initiated_trades.lock().unwrap().as_slice(), + &[(2, 1)] + ); } /// **AC: the full handshake round trip, one store, both parties** — A proposes, B's client @@ -58,10 +64,17 @@ fn the_handshake_flow_dispatches_initiate_then_begin_then_cancel() { 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]); + assert_eq!( + store.trade.initiated_trades.lock().unwrap().as_slice(), + &[(1, 2)] + ); + assert_eq!(store.trade.begun_trades.lock().unwrap().as_slice(), &[2]); + assert_eq!( + store.trade.cancelled_trades.lock().unwrap().as_slice(), + &[2] + ); let calls: Vec = store + .topology .calls .lock() .unwrap() @@ -97,15 +110,15 @@ fn offer_mutations_dispatch_with_wire_arguments() { drop(client); server.join().unwrap(); assert_eq!( - store.set_trade_items.lock().unwrap().as_slice(), + store.trade.set_trade_items.lock().unwrap().as_slice(), &[(1, 2, 23)] ); assert_eq!( - store.cleared_trade_items.lock().unwrap().as_slice(), + store.trade.cleared_trade_items.lock().unwrap().as_slice(), &[(1, 2)] ); assert_eq!( - store.set_trade_golds.lock().unwrap().as_slice(), + store.trade.set_trade_golds.lock().unwrap().as_slice(), &[(1, 1_2345)] ); } @@ -127,7 +140,7 @@ fn set_trade_item_from_a_sub_bag_is_ignored_not_misaddressed() { .unwrap(); drop(client); server.join().unwrap(); - assert!(store.set_trade_items.lock().unwrap().is_empty()); + assert!(store.trade.set_trade_items.lock().unwrap().is_empty()); } /// **AC: the full loop's wire half**, A initiates and offers an item, B answers, offers @@ -170,16 +183,22 @@ fn the_full_loop_dispatches_offer_and_dual_accept_in_order() { 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(), + store.trade.initiated_trades.lock().unwrap().as_slice(), + &[(1, 2)] + ); + assert_eq!( + store.trade.set_trade_items.lock().unwrap().as_slice(), &[(1, 0, 23)] ); assert_eq!( - store.set_trade_golds.lock().unwrap().as_slice(), + store.trade.set_trade_golds.lock().unwrap().as_slice(), &[(2, 500)] ); - assert_eq!(store.accepted_trades.lock().unwrap().as_slice(), &[1, 2]); + assert_eq!( + store.trade.accepted_trades.lock().unwrap().as_slice(), + &[1, 2] + ); } /// **AC: the accept-reset wire half**, after an accept, a further offer mutation and an @@ -202,9 +221,15 @@ fn unaccept_and_post_accept_mutations_dispatch_for_the_acting_seat() { .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]); + assert_eq!(store.trade.accepted_trades.lock().unwrap().as_slice(), &[1]); + assert_eq!( + store.trade.set_trade_golds.lock().unwrap().as_slice(), + &[(1, 9)] + ); + assert_eq!( + store.trade.unaccepted_trades.lock().unwrap().as_slice(), + &[1] + ); } /// **AC: the decline flow**, the proposed target's client answers a `BeginTrade` it @@ -222,8 +247,8 @@ fn decline_opcodes_dispatch_their_own_verbs_for_the_declining_side() { .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]); + assert_eq!(store.trade.busy_trades.lock().unwrap().as_slice(), &[2]); + assert_eq!(store.trade.ignore_trades.lock().unwrap().as_slice(), &[2]); } /// **AC: either side can cancel** — the initiator's own `CMSG_CANCEL_TRADE` dispatches as @@ -237,5 +262,8 @@ fn cancel_trade_dispatches_for_the_initiating_side_too() { .unwrap(); drop(client); server.join().unwrap(); - assert_eq!(store.cancelled_trades.lock().unwrap().as_slice(), &[1]); + assert_eq!( + store.trade.cancelled_trades.lock().unwrap().as_slice(), + &[1] + ); } diff --git a/gateway/src/world/transfer_tests.rs b/gateway/src/world/transfer_tests.rs index 520eb8875..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" @@ -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,16 +839,22 @@ 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(); @@ -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/whisper_tests.rs b/gateway/src/world/whisper_tests.rs index ba095ab45..c7a575438 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,26 +359,35 @@ 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(); @@ -380,7 +432,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 { diff --git a/gateway/src/world/who_tests.rs b/gateway/src/world/who_tests.rs index a3ad5a933..a0562f5b0 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) diff --git a/gateway/src/world/wire_corruption_tests.rs b/gateway/src/world/wire_corruption_tests.rs index 64acb1ad5..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,12 +41,15 @@ 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(); @@ -310,12 +313,15 @@ 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(); @@ -408,12 +414,15 @@ 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(); From 4cdf571db3e5c46f54f8f987774d5eb768bfe528 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 16:31:42 +0200 Subject: [PATCH 06/17] test(gateway): move WorldFake into world/test_support WorldFake and its fixtures leave the 13,000-line world/tests.rs. Each Store family's state struct and impl now sit together in test_support/world_fake/.rs, and shard routing sits in test_support/topology.rs. A later change to one family's Fake edits one file. Code motion only: items gain pub(crate) so the test modules reach them, and paths that named a parent module through `super::` now name it from the crate root. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/recipes/add-an-opcode.md | 7 +- gateway/src/world/mod.rs | 2 + gateway/src/world/test_support/mod.rs | 24 + gateway/src/world/test_support/topology.rs | 182 + .../world/test_support/world_fake/auction.rs | 56 + .../src/world/test_support/world_fake/bank.rs | 32 + .../src/world/test_support/world_fake/cast.rs | 141 + .../world/test_support/world_fake/channel.rs | 37 + .../test_support/world_fake/character.rs | 157 + .../src/world/test_support/world_fake/chat.rs | 78 + .../world/test_support/world_fake/combat.rs | 47 + .../world/test_support/world_fake/death.rs | 65 + .../src/world/test_support/world_fake/duel.rs | 11 + .../world/test_support/world_fake/guild.rs | 227 + .../src/world/test_support/world_fake/item.rs | 55 + .../test_support/world_fake/loot_roll.rs | 201 + .../test_support/world_fake/loot_window.rs | 119 + .../src/world/test_support/world_fake/mail.rs | 1027 +++ .../test_support/world_fake/meeting_stone.rs | 37 + .../world/test_support/world_fake/melee.rs | 25 + .../test_support/world_fake/member_stats.rs | 57 + .../src/world/test_support/world_fake/mod.rs | 112 + .../src/world/test_support/world_fake/npc.rs | 103 + .../world/test_support/world_fake/party.rs | 1075 +++ .../world/test_support/world_fake/quest.rs | 148 + .../world/test_support/world_fake/session.rs | 229 + .../world/test_support/world_fake/social.rs | 283 + .../world/test_support/world_fake/speech.rs | 92 + .../src/world/test_support/world_fake/taxi.rs | 78 + .../world/test_support/world_fake/trade.rs | 112 + .../world/test_support/world_fake/trainer.rs | 145 + .../world/test_support/world_fake/transfer.rs | 818 +++ .../world/test_support/world_fake/vendor.rs | 98 + .../world/test_support/world_fake/weather.rs | 26 + gateway/src/world/tests.rs | 5735 +---------------- 35 files changed, 5906 insertions(+), 5735 deletions(-) create mode 100644 gateway/src/world/test_support/mod.rs create mode 100644 gateway/src/world/test_support/topology.rs create mode 100644 gateway/src/world/test_support/world_fake/auction.rs create mode 100644 gateway/src/world/test_support/world_fake/bank.rs create mode 100644 gateway/src/world/test_support/world_fake/cast.rs create mode 100644 gateway/src/world/test_support/world_fake/channel.rs create mode 100644 gateway/src/world/test_support/world_fake/character.rs create mode 100644 gateway/src/world/test_support/world_fake/chat.rs create mode 100644 gateway/src/world/test_support/world_fake/combat.rs create mode 100644 gateway/src/world/test_support/world_fake/death.rs create mode 100644 gateway/src/world/test_support/world_fake/duel.rs create mode 100644 gateway/src/world/test_support/world_fake/guild.rs create mode 100644 gateway/src/world/test_support/world_fake/item.rs create mode 100644 gateway/src/world/test_support/world_fake/loot_roll.rs create mode 100644 gateway/src/world/test_support/world_fake/loot_window.rs create mode 100644 gateway/src/world/test_support/world_fake/mail.rs create mode 100644 gateway/src/world/test_support/world_fake/meeting_stone.rs create mode 100644 gateway/src/world/test_support/world_fake/melee.rs create mode 100644 gateway/src/world/test_support/world_fake/member_stats.rs create mode 100644 gateway/src/world/test_support/world_fake/mod.rs create mode 100644 gateway/src/world/test_support/world_fake/npc.rs create mode 100644 gateway/src/world/test_support/world_fake/party.rs create mode 100644 gateway/src/world/test_support/world_fake/quest.rs create mode 100644 gateway/src/world/test_support/world_fake/session.rs create mode 100644 gateway/src/world/test_support/world_fake/social.rs create mode 100644 gateway/src/world/test_support/world_fake/speech.rs create mode 100644 gateway/src/world/test_support/world_fake/taxi.rs create mode 100644 gateway/src/world/test_support/world_fake/trade.rs create mode 100644 gateway/src/world/test_support/world_fake/trainer.rs create mode 100644 gateway/src/world/test_support/world_fake/transfer.rs create mode 100644 gateway/src/world/test_support/world_fake/vendor.rs create mode 100644 gateway/src/world/test_support/world_fake/weather.rs 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/world/mod.rs b/gateway/src/world/mod.rs index db6761279..f2a323d26 100644 --- a/gateway/src/world/mod.rs +++ b/gateway/src/world/mod.rs @@ -1948,5 +1948,7 @@ pub async fn run( } } +#[cfg(all(test, unix))] +mod test_support; #[cfg(all(test, unix))] mod tests; diff --git a/gateway/src/world/test_support/mod.rs b/gateway/src/world/test_support/mod.rs new file mode 100644 index 000000000..dc1514fcb --- /dev/null +++ b/gateway/src/world/test_support/mod.rs @@ -0,0 +1,24 @@ +//! Shared test support for World Session tests: [`WorldFake`], the multi-shard Fake every Store +//! family is implemented on, and its shard topology. + +mod topology; +mod world_fake; + +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, LootWindowRefusal, + 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::item::ItemRefusal; +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..9723e3305 --- /dev/null +++ b/gateway/src/world/test_support/world_fake/bank.rs @@ -0,0 +1,32 @@ +use super::super::*; + +#[derive(Default)] +pub(crate) struct BankState { + /// 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. + pub(crate) auto_banked_items: std::sync::Mutex>, + /// Recorded `buy_bank_slot` calls — the banker guid named on each `CMSG_BUY_BANK_SLOT`. + pub(crate) bought_bank_slots: std::sync::Mutex>, +} + +impl BankStore for WorldFake { + 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.bank.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.bank + .bought_bank_slots + .lock() + .unwrap() + .push(banker_guid); + 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..01fbf55a9 --- /dev/null +++ b/gateway/src/world/test_support/world_fake/cast.rs @@ -0,0 +1,141 @@ +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, + /// Recorded `cast_spell` dispatches: (spell_id, target_guid) — pins target threading. + pub(crate) casts: std::sync::Mutex>, + /// Recorded `start_ranged_attack` dispatches: (target_guid, spell_id) — the Auto Shot intercept. + pub(crate) ranged_attacks: std::sync::Mutex>, + /// The caller's owned items, for the Auto Shot ammo block on the activation START. + pub(crate) player_items_fixture: Vec, + /// Recorded `cancel_aura` spell ids — CMSG_CANCEL_AURA. + pub(crate) cancelled_auras: std::sync::Mutex>, + /// Recorded `cancel_cast` self_guids — CMSG_CANCEL_CAST. + pub(crate) cancelled_casts: std::sync::Mutex>, +} + +/// 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 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<()> { + self.cast.cancelled_auras.lock().unwrap().push(spell_id); + Ok(()) + } + fn cancel_cast(&self, _account_id: u64, self_guid: u64) -> Result<()> { + self.cast.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.cast + .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.cast + .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.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..3b6d8d97b --- /dev/null +++ b/gateway/src/world/test_support/world_fake/channel.rs @@ -0,0 +1,37 @@ +use super::super::*; + +#[derive(Default)] +pub(crate) struct ChannelState { + /// What `channel_op` answers. `None` succeeds. + pub(crate) channel_outcome: Option, + /// Recorded `channel_op` calls: `(actor_guid, op, request)`. + pub(crate) channel_ops: std::sync::Mutex>, +} + +impl ChannelActionStore for WorldFake { + fn channel_op( + &self, + actor_guid: u64, + op: u8, + request: ChannelRequest, + ) -> Result { + self.channel + .channel_ops + .lock() + .unwrap() + .push((actor_guid, op, request)); + Ok(self.channel.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) + } +} 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..20ab53216 --- /dev/null +++ b/gateway/src/world/test_support/world_fake/chat.rs @@ -0,0 +1,78 @@ +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, + /// What `realm_chat` answers. `None` delivers. + pub(crate) realm_chat_outcome: 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 `set_away` requests: `(speaker_guid, kind, message)`. + pub(crate) away_requests: std::sync::Mutex>, + /// What `realm_whisper` answers. `None` delivers. + pub(crate) realm_whisper_outcome: Option, + /// When set, `realm_whisper` fails with this message. + pub(crate) realm_whisper_error: Option, + /// 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(self + .chat + .realm_chat_outcome + .unwrap_or(ChatOutcome::Delivered)) + } + + fn set_away(&self, speaker_guid: u64, kind: u8, message: String) -> Result<()> { + self.chat + .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.chat + .realm_whispers + .lock() + .unwrap() + .push((speaker_guid, request)); + if let Some(e) = &self.chat.realm_whisper_error { + return Err(anyhow!("{e}")); + } + Ok(self + .chat + .realm_whisper_outcome + .unwrap_or(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..9f73d748c --- /dev/null +++ b/gateway/src/world/test_support/world_fake/combat.rs @@ -0,0 +1,47 @@ +use super::super::*; + +#[derive(Default)] +pub(crate) struct CombatState { + /// Recorded `set_sheathed` dispatches: (self_guid, state), the `CMSG_SETSHEATHED` route. + pub(crate) sheathed: 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.) + pub(crate) 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. + pub(crate) set_target_error: Option, +} + +impl CombatStore for WorldFake { + fn set_target(&self, _account_id: u64, _self_guid: u64, target_guid: u64) -> Result<()> { + self.rec("set_target"); + if let Some(e) = &self.combat.set_target_error { + return Err(anyhow!("{e}")); + } + self.combat + .selected_targets + .lock() + .unwrap() + .push(target_guid); + 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.combat + .sheathed + .lock() + .unwrap() + .push((self_guid, state)); + 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..96f5d37b7 --- /dev/null +++ b/gateway/src/world/test_support/world_fake/death.rs @@ -0,0 +1,65 @@ +use super::super::*; + +#[derive(Default)] +pub(crate) struct DeathState { + /// Recorded `repop` calls — the caller's self_guid, one entry per CMSG_REPOP_REQUEST. + pub(crate) repopped: std::sync::Mutex>, + /// Recorded `reclaim_corpse` calls — `(self_guid, corpse_guid)` off CMSG_RECLAIM_CORPSE. + pub(crate) reclaimed_corpses: std::sync::Mutex>, + /// Recorded `resurrect_response` calls — `(self_guid, accept)`, pinning the wire's + /// `status != 0` → bool mapping. + pub(crate) resurrect_responses: std::sync::Mutex>, + /// Recorded `self_resurrect` calls: the caller's self_guid off CMSG_SELF_RES. + pub(crate) self_resurrects: std::sync::Mutex>, + /// When set, `self_resurrect` returns this Refusal after recording the call. + pub(crate) self_resurrect_error: Option, + /// Recorded `spirit_healer_res` calls — `(self_guid, healer_guid)` off + /// CMSG_SPIRIT_HEALER_ACTIVATE. + pub(crate) spirit_healer_calls: std::sync::Mutex>, +} + +impl DeathStore for WorldFake { + fn repop(&self, _account_id: u64, self_guid: u64) -> Result<()> { + self.death.repopped.lock().unwrap().push(self_guid); + Ok(()) + } + + fn reclaim_corpse(&self, _account_id: u64, self_guid: u64, corpse_guid: u64) -> Result<()> { + self.death + .reclaimed_corpses + .lock() + .unwrap() + .push((self_guid, corpse_guid)); + Ok(()) + } + + fn resurrect_response(&self, _account_id: u64, self_guid: u64, accept: bool) -> Result<()> { + self.death + .resurrect_responses + .lock() + .unwrap() + .push((self_guid, accept)); + Ok(()) + } + + fn self_resurrect(&self, _account_id: u64, self_guid: u64) -> Result<()> { + self.death.self_resurrects.lock().unwrap().push(self_guid); + match &self.death.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.death + .spirit_healer_calls + .lock() + .unwrap() + .push((self_guid, healer_guid)); + 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..57a574991 --- /dev/null +++ b/gateway/src/world/test_support/world_fake/guild.rs @@ -0,0 +1,227 @@ +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, + /// Guild Charters the logged-in Character holds on this Home Shard. + pub(crate) held_charters: 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(self.guild.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 + .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 the steps run. +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> + { + 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.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..83056da6a --- /dev/null +++ b/gateway/src/world/test_support/world_fake/item.rs @@ -0,0 +1,55 @@ +use super::super::*; + +#[derive(Default)] +pub(crate) struct ItemState { + /// Recorded `use_item` slots. + pub(crate) used_items: std::sync::Mutex>, +} + +impl ItemActionStore for WorldFake { + 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.item.used_items.lock().unwrap().push(slot); + self.canned_item_action() + } +} + +impl WorldFake { + /// 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. + pub(crate) 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}")), + } + } +} 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..c069852b2 --- /dev/null +++ b/gateway/src/world/test_support/world_fake/loot_window.rs @@ -0,0 +1,119 @@ +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 target and slot for `CMSG_AUTOSTORE_LOOT_ITEM`. + pub(crate) items_looted: std::sync::Mutex>, + /// Recorded `skin_corpse` targets (the empty-loot-window skinning fallback). + pub(crate) skinned: std::sync::Mutex>, + /// Typed legacy skinning refusal returned by the empty-loot fallback. + pub(crate) skinning_refusal: Option, + /// Infrastructure failure returned by the empty-loot skinning fallback. + pub(crate) skinning_failure: 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. + pub(crate) corpse_loot_by_viewer: std::collections::HashMap>, + /// Recorded non-questgiver GameObject uses owned by the loot-window seam. + pub(crate) gameobjects_used: std::sync::Mutex>, + pub(crate) corpse_loot_reads: std::sync::Mutex>, +} + +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> { + self.loot_window + .corpse_loot_reads + .lock() + .unwrap() + .push((_target_guid, viewer_guid)); + 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 { + if let Some(error) = &self.loot_window.skinning_failure { + return Err(anyhow!(error.clone())); + } + if let Some(refusal) = self.loot_window.skinning_refusal { + return Ok(LootWindowRequestStatus::Refused(refusal)); + } + self.loot_window.skinned.lock().unwrap().push(target_guid); + 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_looted + .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..088a82734 --- /dev/null +++ b/gateway/src/world/test_support/world_fake/melee.rs @@ -0,0 +1,25 @@ +use super::super::*; + +#[derive(Default)] +pub(crate) struct MeleeState { + /// 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. + pub(crate) start_attack_error: Option, + /// Recorded `stop_attack` dispatches: the actor guid. The Auto Shot teardowns reach this + /// through `WorldStore`'s `MeleeActionStore` supertrait, so it pins that resolution. + pub(crate) stop_attacks: std::sync::Mutex>, +} + +impl MeleeActionStore for WorldFake { + fn start_attack(&self, _account_id: u64, _self_guid: u64, _target_guid: u64) -> Result<()> { + self.rec("start_attack"); + match &self.melee.start_attack_error { + Some(e) => Err(anyhow!("{e}")), + None => Ok(()), + } + } + fn stop_attack(&self, _account_id: u64, self_guid: u64) -> Result<()> { + self.melee.stop_attacks.lock().unwrap().push(self_guid); + 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..3b31fad2e --- /dev/null +++ b/gateway/src/world/test_support/world_fake/mod.rs @@ -0,0 +1,112 @@ +//! [`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::bank::*; +pub(crate) use self::cast::*; +pub(crate) use self::channel::*; +pub(crate) use self::character::*; +pub(crate) use self::chat::*; +pub(crate) use self::combat::*; +pub(crate) use self::death::*; +pub(crate) use self::guild::*; +pub(crate) use self::item::*; +pub(crate) use self::loot_roll::*; +pub(crate) use self::loot_window::*; +pub(crate) use self::mail::*; +pub(crate) use self::melee::*; +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::trade::*; +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) bank: BankState, + pub(crate) combat: CombatState, + pub(crate) death: DeathState, + pub(crate) trade: TradeState, + pub(crate) cast: CastState, + pub(crate) taxi: TaxiState, + pub(crate) melee: MeleeState, + pub(crate) chat: ChatState, + pub(crate) speech: SpeechState, + pub(crate) channel: ChannelState, + pub(crate) guild: GuildState, + pub(crate) auction: AuctionState, + pub(crate) quest: QuestState, + pub(crate) vendor: VendorState, + pub(crate) item: ItemState, + 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..8f225d7b7 --- /dev/null +++ b/gateway/src/world/test_support/world_fake/taxi.rs @@ -0,0 +1,78 @@ +use super::super::*; + +#[derive(Default)] +pub(crate) struct TaxiState { + /// Taxi seam fixtures and operation log. Both direct query opcodes and TAXI gossip must append + /// the same `open` operation here. + pub(crate) taxi_status: Option, + pub(crate) taxi_map: Option, + pub(crate) taxi_activation: codec::TaxiActivationResult, + pub(crate) taxi_activation_inputs: std::sync::Mutex>, + pub(crate) taxi_error: Option, + pub(crate) taxi_calls: std::sync::Mutex>, +} + +/// 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 WorldFake { + fn taxi_node_status( + &self, + character_guid: u64, + npc_guid: u64, + ) -> Result> { + self.taxi + .taxi_calls + .lock() + .unwrap() + .push(("status", character_guid, npc_guid)); + if let Some(error) = &self.taxi.taxi_error { + return Err(anyhow!("{error}")); + } + Ok(self.taxi.taxi_status) + } + + fn open_taxi(&self, character_guid: u64, npc_guid: u64) -> Result> { + self.taxi + .taxi_calls + .lock() + .unwrap() + .push(("open", character_guid, npc_guid)); + 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 { + self.taxi + .taxi_calls + .lock() + .unwrap() + .push(("activate", character_guid, npc_guid)); + self.taxi.taxi_activation_inputs.lock().unwrap().push(( + character_guid, + npc_guid, + source_client_node_id, + destination_client_node_id, + )); + if let Some(error) = &self.taxi.taxi_error { + return Err(anyhow!("{error}")); + } + Ok(self.taxi.taxi_activation) + } + + fn arm_taxi_flight(&self, character_guid: u64) -> Result<()> { + self.taxi + .taxi_calls + .lock() + .unwrap() + .push(("arm", character_guid, 0)); + 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..f633c5020 --- /dev/null +++ b/gateway/src/world/test_support/world_fake/trade.rs @@ -0,0 +1,112 @@ +use super::super::*; + +#[derive(Default)] +pub(crate) struct TradeState { + /// Recorded `initiate_trade` calls, `(self_guid, target_guid)` off CMSG_INITIATE_TRADE. + pub(crate) initiated_trades: std::sync::Mutex>, + /// Recorded `begin_trade` self_guids, CMSG_BEGIN_TRADE. + pub(crate) begun_trades: std::sync::Mutex>, + /// Recorded `cancel_trade` self_guids, CMSG_CANCEL_TRADE. + pub(crate) 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. + pub(crate) set_trade_items: std::sync::Mutex>, + /// Recorded `clear_trade_item` calls, `(self_guid, trade_slot)`. + pub(crate) cleared_trade_items: std::sync::Mutex>, + /// Recorded `set_trade_gold` calls, `(self_guid, copper)` after the wire's Gold decode. + pub(crate) set_trade_golds: std::sync::Mutex>, + /// Recorded `accept_trade` self_guids, CMSG_ACCEPT_TRADE. + pub(crate) accepted_trades: std::sync::Mutex>, + /// Recorded `unaccept_trade` self_guids, CMSG_UNACCEPT_TRADE. + pub(crate) unaccepted_trades: std::sync::Mutex>, + /// Recorded `busy_trade` self_guids, CMSG_BUSY_TRADE. + pub(crate) busy_trades: std::sync::Mutex>, + /// Recorded `ignore_trade` self_guids, CMSG_IGNORE_TRADE. + pub(crate) ignore_trades: std::sync::Mutex>, +} + +impl TradeStore for WorldFake { + // 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.trade + .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.trade.begun_trades.lock().unwrap().push(self_guid); + Ok(()) + } + + fn cancel_trade(&self, _account_id: u64, self_guid: u64) -> Result<()> { + self.rec("cancel_trade"); + self.trade.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.trade + .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.trade + .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.trade + .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.trade.accepted_trades.lock().unwrap().push(self_guid); + Ok(()) + } + + fn unaccept_trade(&self, _account_id: u64, self_guid: u64) -> Result<()> { + self.rec("unaccept_trade"); + self.trade.unaccepted_trades.lock().unwrap().push(self_guid); + Ok(()) + } + + fn busy_trade(&self, _account_id: u64, self_guid: u64) -> Result<()> { + self.rec("busy_trade"); + self.trade.busy_trades.lock().unwrap().push(self_guid); + Ok(()) + } + + fn ignore_trade(&self, _account_id: u64, self_guid: u64) -> Result<()> { + self.rec("ignore_trade"); + self.trade.ignore_trades.lock().unwrap().push(self_guid); + 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..a3897910b --- /dev/null +++ b/gateway/src/world/test_support/world_fake/trainer.rs @@ -0,0 +1,145 @@ +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, + /// When set, the trainer buy answers this Refusal instead of learning the spell. + pub(crate) trainer_buy_refusal: Option, + /// What `talent_grant_spell` returns (0 = passive talent → no SMSG_LEARNED_SPELL push). + pub(crate) talent_grant: u32, + /// What `talent_pane_sync` returns: (teach rank-spell, superseded prev, points remaining). + pub(crate) 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. + pub(crate) trainer_superseded: Option, + /// Recorded `reset_talents` dispatches: (account_id, self_guid, trainer_guid) — the unlearn-talents + /// gossip select. + pub(crate) reset_talents_calls: std::sync::Mutex>, + /// When set, `reset_talents` returns this error instead of recording the call. + pub(crate) reset_talents_error: Option, + /// Trainer rows `trainer_list` returns for ANY (player, trainer) pair — the + /// CMSG_TRAINER_LIST fixture. Empty by default (an empty trainer window). + pub(crate) 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. + pub(crate) trainer_offer_skill_lines: std::collections::HashMap, +} + +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(self.trainer.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.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.trainer.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.trainer.talent_pane + } + + 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<()> { + match &self.trade_error { + Some(e) => Err(anyhow!("{e}")), + None => Ok(()), + } + } + + fn reset_talents(&self, account_id: u64, self_guid: u64, trainer_guid: u64) -> Result<()> { + if let Some(e) = &self.trainer.reset_talents_error { + return Err(anyhow!("{e}")); + } + 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 // 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 + .trainer_offer_skill_lines + .get(&spell_id) + .copied() + .unwrap_or(0) + } + + fn superseded_old_rank(&self, _new_spell: u32, _player_guid: u64) -> Option { + self.trainer.trainer_superseded + } + + 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..3f798de3f --- /dev/null +++ b/gateway/src/world/test_support/world_fake/vendor.rs @@ -0,0 +1,98 @@ +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)>, + /// Item-instance guid → bag slot, for the vendor repair target. + pub(crate) item_slots: Vec<(u64, u8)>, + /// Recorded `vendor_buyback` calls: (vendor_guid, slot) — pins the 69→0 slot mapping. + pub(crate) bought_back: std::sync::Mutex>, +} + +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<()> { + match &self.trade_error { + Some(e) => Err(anyhow!("{e}")), + None => 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 { + self.vendor + .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.vendor + .bought_back + .lock() + .unwrap() + .push((vendor_guid, slot)); + 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..f8e66ef07 --- /dev/null +++ b/gateway/src/world/test_support/world_fake/weather.rs @@ -0,0 +1,26 @@ +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)>, + /// 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. + pub(crate) weather_error: Option, +} + +impl WeatherStore for WorldFake { + fn zone_weather(&self, zone_id: u32) -> Result> { + if let Some(e) = &self.weather.weather_error { + return Err(anyhow!("{e}")); + } + 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 970169b4f..2ce8ecb6d 100644 --- a/gateway/src/world/tests.rs +++ b/gateway/src/world/tests.rs @@ -1,13 +1,9 @@ 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, CastStore, ChannelOutcome, ChatOutcome, LootWindowRefusal, + RealmChatRequest, 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}; @@ -292,5372 +288,6 @@ 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); - -/// 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 - } -} - -/// 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) - } -} - -#[derive(Default)] -struct WorldFake { - characters: Vec, - /// 195: `npc_refuses_interaction` return — false (derive-Default) keeps every fixture NPC open. - npc_refuses: bool, - /// When set, buy/sell return this error (a gameplay failure) instead of `Ok`. - trade_error: Option, - topology: TopologyState, - session: SessionState, - character: CharacterState, - transfer: TransferState, - party: PartyState, - mail: MailState, - social: SocialState, - npc: NpcState, - trainer: TrainerState, - bank: BankState, - combat: CombatState, - death: DeathState, - trade: TradeState, - cast: CastState, - taxi: TaxiState, - melee: MeleeState, - chat: ChatState, - speech: SpeechState, - channel: ChannelState, - guild: GuildState, - auction: AuctionState, - quest: QuestState, - vendor: VendorState, - item: ItemState, - weather: WeatherState, - member_stats: MemberStatsState, - loot_window: LootWindowState, - loot_roll: LootRollState, -} - -#[derive(Default)] -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. - 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 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, - /// When set, deleted Character cleanup cannot reach Realm-core. - party_cleanup_realm_error: Option, - party_command_realm_error: Option, - 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). - 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>, - /// Whether this character still had an addressable viewer at each transfer-resolution call. - viewer_present_at_settle: std::sync::Mutex>, -} - -#[derive(Default)] -struct SessionState { - /// 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, - login_entity: Option, - moves: std::sync::Mutex>, - 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. - turn_in_reward_item: Option, - 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). - 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, - /// The `WorldEntry` of every `player_login`, in order. - login_entries: std::sync::Mutex>, - /// Parked private System Messages world entry replays (a Package `on_login` hook's output). - 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). - 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>>, - /// 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, - /// 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, - /// Optional real shared view used by world-port ordering tests. - relay_view: Option>, - /// 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, -} - -#[derive(Default)] -struct CharacterState { - /// When set, `character_by_guid` cannot answer from this Shard. - character_read_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)>, - /// 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, - /// How many times a caller asked for the two-snapshot durable absence check. - durable_absence_checks: std::sync::atomic::AtomicUsize, -} - -#[derive(Default)] -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. - 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 transfer step to fail at, simulating a gateway killed before that step's - /// transaction committed. `None` = nothing fails. - 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. - 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, -} - -#[derive(Default)] -struct PartyState { - group_invites: std::sync::Mutex>, - /// Recorded `group_loot_method` calls: (loot_setting, master_guid, loot_threshold). - 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. - 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>, - /// 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, `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>, - 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>, - /// Characters Realm-core reports in a pending Transfer. Read on the realm handle only. - members_in_transit: std::sync::Mutex>, -} - -#[derive(Default)] -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. - 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>, - /// `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, -} - -#[derive(Default)] -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. - contacts: std::sync::Mutex>, - /// This Shard's stored Auto-Replies, by Character guid. - 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. - 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, `contact_lists` fails with this message on THIS shard — the - /// unreachable-database arm of the realm-wide ignore-list union. - contact_lists_error: Option, - /// Live `game_world_entity` rows on THIS shard, as the columns Member Stats read. - 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. - members_between_places: std::sync::Mutex>, -} - -#[derive(Default)] -struct NpcState { - /// `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, - /// 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>, - /// 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, - /// Spawned GameObject type returned for the `CMSG_GAMEOBJ_USE` questgiver classification. - gameobject_type: Option, -} - -#[derive(Default)] -struct TrainerState { - /// 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, the trainer buy answers this Refusal instead of learning the spell. - trainer_buy_refusal: Option, - /// 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, - /// 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, - /// 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, -} - -#[derive(Default)] -struct BankState { - /// 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>, -} - -#[derive(Default)] -struct CombatState { - /// Recorded `set_sheathed` dispatches: (self_guid, state), the `CMSG_SETSHEATHED` route. - sheathed: 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, -} - -#[derive(Default)] -struct DeathState { - /// 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>, -} - -#[derive(Default)] -struct TradeState { - /// 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>, -} - -#[derive(Default)] -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. - item_templates: Vec, - /// 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>, - /// The caller's owned items, for the Auto Shot ammo block on the activation START. - player_items_fixture: Vec, - /// 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>, -} - -#[derive(Default)] -struct TaxiState { - /// 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>, -} - -#[derive(Default)] -struct MeleeState { - /// 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, - /// 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>, -} - -#[derive(Default)] -struct ChatState { - /// 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, - /// 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>, - /// Recorded `set_away` requests: `(speaker_guid, kind, message)`. - away_requests: 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>, -} - -#[derive(Default)] -struct SpeechState { - /// What `send_chat` answers; `None` delivers. - 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. - 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 `send_chat` lines: (chat_type, language, message). - chats: std::sync::Mutex>, -} - -#[derive(Default)] -struct ChannelState { - /// What `channel_op` answers. `None` succeeds. - channel_outcome: Option, - /// Recorded `channel_op` calls: `(actor_guid, op, request)`. - channel_ops: std::sync::Mutex>, -} - -#[derive(Default)] -struct GuildState { - /// 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, - /// A Character whose guild lookup fails, as when one Realm-core read errors. - guild_lookup_error_for: Option, -} - -#[derive(Default)] -struct AuctionState { - /// The auctioneer's house and faction verdict returned to the focused auction seam. - auction_interaction: Option, -} - -#[derive(Default)] -struct QuestState { - /// 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>, - /// 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, - /// 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>, -} - -#[derive(Default)] -struct VendorState { - /// Vendor stock the seam's `vendor_stock` read returns (empty by default). - 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. - buyback_ring: Vec<(u32, u32, u32, u32)>, - /// Item-instance guid → bag slot, for the vendor repair target. - item_slots: Vec<(u64, u8)>, - /// Recorded `vendor_buyback` calls: (vendor_guid, slot) — pins the 69→0 slot mapping. - bought_back: std::sync::Mutex>, -} - -#[derive(Default)] -struct ItemState { - /// Recorded `use_item` slots. - used_items: std::sync::Mutex>, -} - -#[derive(Default)] -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. - 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, -} - -#[derive(Default)] -struct MemberStatsState { - /// How many `member_presence` reads reached this handle. - member_presence_reads: std::sync::atomic::AtomicUsize, -} - -#[derive(Default)] -struct LootWindowState { - /// The lootable copper `loot_target_money` reports for any target (default 0). - corpse_money: u32, - /// 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, - /// 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>, - /// Recorded non-questgiver GameObject uses owned by the loot-window seam. - gameobjects_used: std::sync::Mutex>, - corpse_loot_reads: std::sync::Mutex>, -} - -#[derive(Default)] -struct LootRollState { - /// 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, - /// 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>, -} - -/// 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}")), - } -} - -/// [`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}")), - } -} - -impl WorldFake { - /// 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 - .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) - } - - /// 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.party.stale_party.lock().unwrap() { - return read(stale); - } - read(&self.party.party.lock().unwrap()) - } - - /// Record one player-scoped call against THIS handle's shard. - fn rec(&self, what: &str) { - self.topology - .calls - .lock() - .unwrap() - .push((self.topology.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.social - .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.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. - 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)), - } - } - - 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. - fn detach(&self, sender_guid: u64, item_guid: u64) -> Result { - if item_guid == 0 { - return Ok(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. - fn store_snapshot(&self, owner_guid: u64, item: &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)] - 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.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. - 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. - 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 - } - - fn saw_same_account(&self, call: &'static str, same_account: bool) { - self.mail - .same_account_seen - .lock() - .unwrap() - .push((call, same_account)); - } - - /// 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 - .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) - } - - 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) - } - - /// 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.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(()) - } -} - -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 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 - } -} - -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()) - } -} - -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| 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.transfer.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.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: super::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(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 begin_shard_index_transfer( - &self, - plan: &super::transfer::TransferPlan, - bot_intent: Option<(&super::transfer::BotTransferIntent, u64)>, - ) -> Result { - let mut phase = self.transfer.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.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(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.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: &super::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(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.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(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.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: &super::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(()) - } -} - -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(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.transfer.realm_partition.lock().unwrap()) - } - - fn party_holder_observation( - &self, - character_guid: u64, - map_id: u32, - instance_id: u64, - ) -> Result { - Ok(super::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(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 - .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.party - .admitted_party_commands - .lock() - .unwrap() - .push(command.clone()); - let outcome = self - .party - .party_command_apply_outcome - .unwrap_or(super::party::CompanionCommandOutcome::Applied); - if outcome != super::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: super::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) - { - 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.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: &super::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) - } -} - -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 = 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 = 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 = 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 = 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.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, - &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 = 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 = 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, - 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: 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 = 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 `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 = 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, - 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.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 = 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.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.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: 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 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 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(()) - } -} - -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(self.trainer.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.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.trainer.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.trainer.talent_pane - } - - 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<()> { - match &self.trade_error { - Some(e) => Err(anyhow!("{e}")), - None => Ok(()), - } - } - - fn reset_talents(&self, account_id: u64, self_guid: u64, trainer_guid: u64) -> Result<()> { - if let Some(e) = &self.trainer.reset_talents_error { - return Err(anyhow!("{e}")); - } - 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 // 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 - .trainer_offer_skill_lines - .get(&spell_id) - .copied() - .unwrap_or(0) - } - - fn superseded_old_rank(&self, _new_spell: u32, _player_guid: u64) -> Option { - self.trainer.trainer_superseded - } - - 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, - ) - })) - } -} - -impl BankStore for WorldFake { - 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.bank.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.bank - .bought_bank_slots - .lock() - .unwrap() - .push(banker_guid); - Ok(()) - } -} - -impl CombatStore for WorldFake { - fn set_target(&self, _account_id: u64, _self_guid: u64, target_guid: u64) -> Result<()> { - self.rec("set_target"); - if let Some(e) = &self.combat.set_target_error { - return Err(anyhow!("{e}")); - } - self.combat - .selected_targets - .lock() - .unwrap() - .push(target_guid); - 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.combat - .sheathed - .lock() - .unwrap() - .push((self_guid, state)); - Ok(()) - } -} - -impl DeathStore for WorldFake { - fn repop(&self, _account_id: u64, self_guid: u64) -> Result<()> { - self.death.repopped.lock().unwrap().push(self_guid); - Ok(()) - } - - fn reclaim_corpse(&self, _account_id: u64, self_guid: u64, corpse_guid: u64) -> Result<()> { - self.death - .reclaimed_corpses - .lock() - .unwrap() - .push((self_guid, corpse_guid)); - Ok(()) - } - - fn resurrect_response(&self, _account_id: u64, self_guid: u64, accept: bool) -> Result<()> { - self.death - .resurrect_responses - .lock() - .unwrap() - .push((self_guid, accept)); - Ok(()) - } - - fn self_resurrect(&self, _account_id: u64, self_guid: u64) -> Result<()> { - self.death.self_resurrects.lock().unwrap().push(self_guid); - match &self.death.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.death - .spirit_healer_calls - .lock() - .unwrap() - .push((self_guid, healer_guid)); - Ok(()) - } - - fn corpse_location(&self, _owner_guid: u64) -> Result> { - Ok(None) - } -} - -impl TradeStore for WorldFake { - // 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.trade - .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.trade.begun_trades.lock().unwrap().push(self_guid); - Ok(()) - } - - fn cancel_trade(&self, _account_id: u64, self_guid: u64) -> Result<()> { - self.rec("cancel_trade"); - self.trade.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.trade - .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.trade - .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.trade - .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.trade.accepted_trades.lock().unwrap().push(self_guid); - Ok(()) - } - - fn unaccept_trade(&self, _account_id: u64, self_guid: u64) -> Result<()> { - self.rec("unaccept_trade"); - self.trade.unaccepted_trades.lock().unwrap().push(self_guid); - Ok(()) - } - - fn busy_trade(&self, _account_id: u64, self_guid: u64) -> Result<()> { - self.rec("busy_trade"); - self.trade.busy_trades.lock().unwrap().push(self_guid); - Ok(()) - } - - fn ignore_trade(&self, _account_id: u64, self_guid: u64) -> Result<()> { - self.rec("ignore_trade"); - self.trade.ignore_trades.lock().unwrap().push(self_guid); - Ok(()) - } -} - -/// 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 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<()> { - self.cast.cancelled_auras.lock().unwrap().push(spell_id); - Ok(()) - } - fn cancel_cast(&self, _account_id: u64, self_guid: u64) -> Result<()> { - self.cast.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.cast - .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.cast - .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.cast.player_items_fixture.clone()) - } - fn item_template(&self, entry: u32) -> Result> { - Ok(self - .cast - .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 WorldFake { - fn taxi_node_status( - &self, - character_guid: u64, - npc_guid: u64, - ) -> Result> { - self.taxi - .taxi_calls - .lock() - .unwrap() - .push(("status", character_guid, npc_guid)); - if let Some(error) = &self.taxi.taxi_error { - return Err(anyhow!("{error}")); - } - Ok(self.taxi.taxi_status) - } - - fn open_taxi(&self, character_guid: u64, npc_guid: u64) -> Result> { - self.taxi - .taxi_calls - .lock() - .unwrap() - .push(("open", character_guid, npc_guid)); - 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 { - self.taxi - .taxi_calls - .lock() - .unwrap() - .push(("activate", character_guid, npc_guid)); - self.taxi.taxi_activation_inputs.lock().unwrap().push(( - character_guid, - npc_guid, - source_client_node_id, - destination_client_node_id, - )); - if let Some(error) = &self.taxi.taxi_error { - return Err(anyhow!("{error}")); - } - Ok(self.taxi.taxi_activation) - } - - fn arm_taxi_flight(&self, character_guid: u64) -> Result<()> { - self.taxi - .taxi_calls - .lock() - .unwrap() - .push(("arm", character_guid, 0)); - Ok(()) - } -} - -impl MeleeActionStore for WorldFake { - fn start_attack(&self, _account_id: u64, _self_guid: u64, _target_guid: u64) -> Result<()> { - self.rec("start_attack"); - match &self.melee.start_attack_error { - Some(e) => Err(anyhow!("{e}")), - None => Ok(()), - } - } - fn stop_attack(&self, _account_id: u64, self_guid: u64) -> Result<()> { - self.melee.stop_attacks.lock().unwrap().push(self_guid); - Ok(()) - } -} - -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(self - .chat - .realm_chat_outcome - .unwrap_or(ChatOutcome::Delivered)) - } - - fn set_away(&self, speaker_guid: u64, kind: u8, message: String) -> Result<()> { - self.chat - .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.chat - .realm_whispers - .lock() - .unwrap() - .push((speaker_guid, request)); - if let Some(e) = &self.chat.realm_whisper_error { - return Err(anyhow!("{e}")); - } - Ok(self - .chat - .realm_whisper_outcome - .unwrap_or(ChatOutcome::Delivered)) - } - - fn speaker_gm_level(&self, _speaker_guid: u64) -> Result { - Ok(self.chat.gm_level) - } -} - -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(()) - } - } - } -} - -impl ChannelActionStore for WorldFake { - fn channel_op( - &self, - actor_guid: u64, - op: u8, - request: ChannelRequest, - ) -> Result { - self.channel - .channel_ops - .lock() - .unwrap() - .push((actor_guid, op, request)); - Ok(self.channel.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 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) - } -} - -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(()) - } -} - -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(self.guild.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 - .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 the steps run. -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> - { - 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.guild_fee_hold.lock().unwrap() = None; - Ok(()) - } -} - -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: 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 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(()) - } -} - -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<()> { - match &self.trade_error { - Some(e) => Err(anyhow!("{e}")), - None => 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 { - self.vendor - .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.vendor - .bought_back - .lock() - .unwrap() - .push((vendor_guid, slot)); - Ok(()) - } -} - -impl WorldFake { - /// 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 WorldFake { - 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.item.used_items.lock().unwrap().push(slot); - self.canned_item_action() - } -} - -impl WeatherStore for WorldFake { - fn zone_weather(&self, zone_id: u32) -> Result> { - if let Some(e) = &self.weather.weather_error { - return Err(anyhow!("{e}")); - } - Ok(self - .weather - .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 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, - }, - ) - } -} - -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> { - self.loot_window - .corpse_loot_reads - .lock() - .unwrap() - .push((_target_guid, viewer_guid)); - 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 { - if let Some(error) = &self.loot_window.skinning_failure { - return Err(anyhow!(error.clone())); - } - if let Some(refusal) = self.loot_window.skinning_refusal { - return Ok(LootWindowRequestStatus::Refused(refusal)); - } - self.loot_window.skinned.lock().unwrap().push(target_guid); - 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_looted - .lock() - .unwrap() - .push((target_guid, loot_slot)); - Ok(LootWindowRequestStatus::Applied) - } -} - -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)) - } -} - fn ns(s: &str) -> NormalizedString { NormalizedString::new(s).unwrap() } @@ -13348,365 +7978,6 @@ fn a_refused_join_answers_the_notice_on_the_session() { server.join().unwrap(); } -// The cross-database transfer TESTS live in `transfer_tests.rs`, but the fixture types -// below stay here: `WorldFake`'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] From f8026508220030be908dfa4d6f40d4b9412fee19 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 16:34:25 +0200 Subject: [PATCH 07/17] test(gateway): drain world entry up to its SMSG_WEATHER Forty-seven socket tests read a fixed 13 frames (12 on a world-port) to get past world entry, so a change to the login sequence edited every one of them. drain_world_entry reads up to and including the SMSG_WEATHER that always closes the entry batch, and fails after 64 frames. Tests that inspect the batch iterate the drained frames, so their assertions are unchanged. WORLD_ENTRY_PACKETS and WORLD_REENTRY_PACKETS are gone. Co-Authored-By: Claude Opus 5.5 (1M context) --- gateway/src/world/mail_tests.rs | 56 +++------- gateway/src/world/party_tests.rs | 17 ++- gateway/src/world/shard_routing_tests.rs | 28 ++--- gateway/src/world/test_support/entry.rs | 27 +++++ gateway/src/world/test_support/mod.rs | 2 + gateway/src/world/tests.rs | 132 +++++++---------------- gateway/src/world/whisper_tests.rs | 4 +- 7 files changed, 97 insertions(+), 169 deletions(-) create mode 100644 gateway/src/world/test_support/entry.rs diff --git a/gateway/src/world/mail_tests.rs b/gateway/src/world/mail_tests.rs index 18ad32d6a..43fe0ee6a 100644 --- a/gateway/src/world/mail_tests.rs +++ b/gateway/src/world/mail_tests.rs @@ -296,9 +296,7 @@ fn an_empty_mailbox_is_answered_with_an_empty_list_packet() { 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), @@ -329,9 +327,7 @@ fn a_seeded_mail_reaches_the_client_as_a_mail_list_row() { 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), @@ -657,9 +653,7 @@ fn return_acks_with_send_mail_result_and_the_next_list_is_empty() { 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), @@ -714,9 +708,7 @@ fn a_refused_return_still_acks_and_never_kills_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_MAIL_RETURN_TO_SENDER { mailbox_id: Guid::new(MAILBOX), @@ -764,9 +756,7 @@ fn mark_as_read_sends_no_reply_but_the_next_list_shows_it() { 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), @@ -806,9 +796,7 @@ fn delete_acks_with_send_mail_result_and_the_next_list_is_empty() { 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), @@ -863,9 +851,7 @@ fn a_refused_delete_still_acks_and_never_kills_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_MAIL_DELETE { mailbox_id: Guid::new(MAILBOX), @@ -1363,9 +1349,7 @@ fn send_over_the_wire( 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), @@ -1522,9 +1506,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), } @@ -1979,9 +1961,7 @@ fn taking_money_over_the_wire_acks_and_credits_the_purse() { 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), @@ -2966,9 +2946,7 @@ fn a_refused_priced_take_reaches_the_client_as_not_enough_money() { 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), @@ -3298,9 +3276,7 @@ fn deleting_a_priced_mail_answers_the_internal_error_and_keeps_the_mail() { 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), @@ -3724,9 +3700,7 @@ fn a_letter_copy_over_the_wire_acks_made_permanent_ok() { 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), @@ -3773,9 +3747,7 @@ fn a_letter_copy_into_a_full_bag_over_the_wire_answers_equip_error() { 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), diff --git a/gateway/src/world/party_tests.rs b/gateway/src/world/party_tests.rs index 1d38f6133..d82d2fc72 100644 --- a/gateway/src/world/party_tests.rs +++ b/gateway/src/world/party_tests.rs @@ -2019,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!( @@ -2085,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; diff --git a/gateway/src/world/shard_routing_tests.rs b/gateway/src/world/shard_routing_tests.rs index f85235d58..34880eb60 100644 --- a/gateway/src/world/shard_routing_tests.rs +++ b/gateway/src/world/shard_routing_tests.rs @@ -146,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. @@ -380,15 +378,11 @@ fn a_world_port_keeps_the_pin_when_the_home_shard_still_owns_the_new_map() { 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(); @@ -469,9 +463,7 @@ fn a_spurious_worldport_ack_is_ignored_on_a_session_pinned_off_the_default_shard 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 {} @@ -518,9 +510,7 @@ fn a_spurious_worldport_ack_is_ignored_on_the_default_shard() { 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(); @@ -551,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(); @@ -662,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/test_support/entry.rs b/gateway/src/world/test_support/entry.rs new file mode 100644 index 000000000..4abc8a1b0 --- /dev/null +++ b/gateway/src/world/test_support/entry.rs @@ -0,0 +1,27 @@ +//! World entry as a socket test reads it. + +use super::*; + +/// 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"); +} diff --git a/gateway/src/world/test_support/mod.rs b/gateway/src/world/test_support/mod.rs index dc1514fcb..5bebd9da2 100644 --- a/gateway/src/world/test_support/mod.rs +++ b/gateway/src/world/test_support/mod.rs @@ -1,9 +1,11 @@ //! 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 topology; mod world_fake; +pub(crate) use self::entry::*; pub(crate) use self::topology::*; pub(crate) use self::world_fake::*; diff --git a/gateway/src/world/tests.rs b/gateway/src/world/tests.rs index 2ce8ecb6d..de552bc72 100644 --- a/gateway/src/world/tests.rs +++ b/gateway/src/world/tests.rs @@ -1,6 +1,6 @@ use super::handlers::{ - AuctionInteraction, CastStore, ChannelOutcome, ChatOutcome, LootWindowRefusal, - RealmChatRequest, SpeakerFacts, WhisperRequest, WhisperTargetFacts, + AuctionInteraction, ChannelOutcome, ChatOutcome, LootWindowRefusal, RealmChatRequest, + SpeakerFacts, WhisperRequest, WhisperTargetFacts, }; use super::party::PartyOutcome; use super::test_support::*; @@ -277,17 +277,6 @@ const K: [u8; 40] = [ 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; - fn ns(s: &str) -> NormalizedString { NormalizedString::new(s).unwrap() } @@ -981,8 +970,8 @@ fn guild_query_answers_at_character_select() { } /// 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 +/// 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, @@ -1063,10 +1052,8 @@ fn a_member_enters_the_world_with_its_guild_on_the_self_create_and_signs_on() { .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() - { + 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), .. @@ -1407,9 +1394,7 @@ fn a_world_port_that_fails_after_sign_on_still_signs_off() { 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(); @@ -1877,9 +1862,7 @@ fn login_replays_a_pending_package_system_message() { 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); 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:?}"); @@ -1935,8 +1918,8 @@ fn player_login_emits_sequence_then_self_create() { 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() { + 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); @@ -2014,10 +1997,8 @@ fn world_entry_weather(store: std::sync::Arc) -> SMSG_WEATHER { .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() - { + for message in drain_world_entry(&mut client, &mut c_dec) { + if let ServerOpcodeMessage::SMSG_WEATHER(m) = message { weather = Some(*m); } } @@ -2084,9 +2065,7 @@ fn world_session_in_world( } .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); (client, c_dec, server) } @@ -2232,9 +2211,7 @@ fn worldport_ack_reenters_with_fresh_subscription_and_empty_loot_state() { .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(); - } + drain_world_entry(&mut client, &mut c_dec); let _ = open_loot_window(&mut client, &mut c_enc, &mut c_dec, 60); @@ -2244,8 +2221,8 @@ fn worldport_ack_reenters_with_fresh_subscription_and_empty_loot_state() { // 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() { + 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 \ @@ -2333,9 +2310,7 @@ fn worldport_removes_the_source_viewer_before_routing_and_registers_a_replacemen 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); let source_session = view .viewer_of_owner(crate::stdb::world_view::OwnerGuid(1)) .expect("login registers the source viewer") @@ -2344,9 +2319,7 @@ fn worldport_removes_the_source_viewer_before_routing_and_registers_a_replacemen 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); assert_eq!( store @@ -2405,13 +2378,10 @@ fn login_initialize_factions_carries_persisted_standing_at_its_reputation_index( .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. + // Drain the whole world entry 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() - { + for message in drain_world_entry(&mut client, &mut c_dec) { + if let ServerOpcodeMessage::SMSG_INITIALIZE_FACTIONS(m) = message { factions = Some(m.factions); } } @@ -2477,10 +2447,9 @@ fn login_with_resident_items_and_reputation_emits_no_gain_feedback() { .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(); + // 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, @@ -2539,10 +2508,9 @@ fn login_fills_a_resident_suffix_items_enchantment_slots_after_the_entry_batch() .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(); - } + // 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); @@ -2932,9 +2900,7 @@ fn a_world_port_whose_transfer_cannot_be_driven_aborts_the_clients_loading_scree 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 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) @@ -3012,9 +2978,7 @@ fn a_world_port_whose_world_entry_fails_also_aborts_the_clients_loading_screen() 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(); @@ -3277,9 +3241,7 @@ fn eval(quest_id: u32, role: u8, active: bool, complete: bool) -> codec::GiverQu } /// 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 +/// 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. @@ -3296,7 +3258,6 @@ fn enter_world( // 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 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.clone()).unwrap(); @@ -3307,10 +3268,7 @@ fn enter_world( } .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(); - } + 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 @@ -3533,9 +3491,7 @@ fn a_group_invite_timeout_is_not_answered_as_a_refusal() { 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_GROUP_INVITE { name: "Buddy".into(), } @@ -3995,9 +3951,7 @@ fn an_add_friend_timeout_is_not_answered_as_a_refusal() { 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_ADD_FRIEND { name: "Buddy".into(), } @@ -4164,9 +4118,7 @@ fn login_sends_the_quest_log_descriptor_raw_update_after_the_create_packet() { .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(); - } + 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); @@ -4641,9 +4593,7 @@ fn item_reducer_transport_loss_ends_the_world_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); CMSG_AUTOEQUIP_ITEM { source_bag: 255, @@ -5934,9 +5884,7 @@ fn attackswing_desync_error_is_session_fatal() { 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_ATTACKSWING { guid: Guid::new(90), } @@ -6354,9 +6302,7 @@ fn a_trainer_reducer_timeout_is_not_answered_as_a_refusal() { 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_TRAINER_BUY_SPELL { guid: Guid::new(70), @@ -8312,9 +8258,7 @@ fn reducer_transport_loss_ends_an_admitted_session_and_frees_one_queue_seat() { 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); assert_eq!( queue.active(), 1, diff --git a/gateway/src/world/whisper_tests.rs b/gateway/src/world/whisper_tests.rs index c7a575438..adc280b2d 100644 --- a/gateway/src/world/whisper_tests.rs +++ b/gateway/src/world/whisper_tests.rs @@ -400,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 { From 97165d43e654bbc9d34f93fc831aba5690511e70 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 16:42:25 +0200 Subject: [PATCH 08/17] test(gateway): split the World Session socket tests by Store family world/tests.rs held 194 socket tests across every family. They now live in one file per family (auction, bank, cast, channel, character, chat, combat, death, guild, item, loot_window, melee, npc, quest, social, taxi, trainer, vendor, weather) or join the existing party, loot, who and whisper files. session_tests.rs takes the handshake, admission, world entry, movement, coalescing and logout-order tests. The world-entry helpers (handshake, tester_store, quest_store, enter_world) move to test_support/entry.rs. tests.rs keeps the shared imports, the module list and the helpers several files use. Each test keeps its name and body. Co-Authored-By: Claude Opus 5.5 (1M context) --- gateway/src/world/auction_tests.rs | 121 + gateway/src/world/bank_tests.rs | 218 + gateway/src/world/cast_tests.rs | 212 + gateway/src/world/channel_tests.rs | 63 + gateway/src/world/character_tests.rs | 205 + gateway/src/world/chat_tests.rs | 499 ++ gateway/src/world/combat_tests.rs | 42 + gateway/src/world/death_tests.rs | 137 + gateway/src/world/framing_tests.rs | 2 +- gateway/src/world/guild_tests.rs | 834 +++ gateway/src/world/item_tests.rs | 115 + gateway/src/world/loot_tests.rs | 148 + gateway/src/world/loot_window_tests.rs | 379 + gateway/src/world/mail_tests.rs | 2 + gateway/src/world/melee_tests.rs | 115 + gateway/src/world/npc_tests.rs | 933 +++ gateway/src/world/party_tests.rs | 172 + gateway/src/world/presence_tests.rs | 2 +- gateway/src/world/quest_tests.rs | 171 + gateway/src/world/session_tests.rs | 2281 ++++++ gateway/src/world/social_tests.rs | 488 ++ gateway/src/world/taxi_tests.rs | 184 + gateway/src/world/test_support/entry.rs | 228 + gateway/src/world/tests.rs | 8703 +---------------------- gateway/src/world/trainer_tests.rs | 439 ++ gateway/src/world/vendor_tests.rs | 128 + gateway/src/world/weather_tests.rs | 188 + gateway/src/world/whisper_tests.rs | 26 + gateway/src/world/who_tests.rs | 82 + 29 files changed, 8570 insertions(+), 8547 deletions(-) create mode 100644 gateway/src/world/auction_tests.rs create mode 100644 gateway/src/world/bank_tests.rs create mode 100644 gateway/src/world/cast_tests.rs create mode 100644 gateway/src/world/channel_tests.rs create mode 100644 gateway/src/world/character_tests.rs create mode 100644 gateway/src/world/chat_tests.rs create mode 100644 gateway/src/world/combat_tests.rs create mode 100644 gateway/src/world/death_tests.rs create mode 100644 gateway/src/world/guild_tests.rs create mode 100644 gateway/src/world/item_tests.rs create mode 100644 gateway/src/world/loot_window_tests.rs create mode 100644 gateway/src/world/melee_tests.rs create mode 100644 gateway/src/world/npc_tests.rs create mode 100644 gateway/src/world/quest_tests.rs create mode 100644 gateway/src/world/session_tests.rs create mode 100644 gateway/src/world/social_tests.rs create mode 100644 gateway/src/world/taxi_tests.rs create mode 100644 gateway/src/world/trainer_tests.rs create mode 100644 gateway/src/world/vendor_tests.rs create mode 100644 gateway/src/world/weather_tests.rs diff --git a/gateway/src/world/auction_tests.rs b/gateway/src/world/auction_tests.rs new file mode 100644 index 000000000..b9a5c8f53 --- /dev/null +++ b/gateway/src/world/auction_tests.rs @@ -0,0 +1,121 @@ +//! The auction house over an encrypted World Session. + +use super::*; + +#[test] +fn auction_house_round_trip_stays_typed_and_ordered_over_an_encrypted_session() { + 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); + 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({ + 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..541c908d0 --- /dev/null +++ b/gateway/src/world/bank_tests.rs @@ -0,0 +1,218 @@ +//! Bank opcodes over an encrypted World Session. + +use super::*; + +#[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({ + let base = quest_store(); + WorldFake { + npc_refuses: true, + characters: vec![codec::CharacterView { + guid: 1, + ..Default::default() + }], + ..base + } + }); + 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 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.bank.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.bank.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({ + let base = quest_store(); + WorldFake { + trade_error: Some("bank full".into()), + ..base + } + }); + 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.bank.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.bank.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.bank.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(); + } +} diff --git a/gateway/src/world/cast_tests.rs b/gateway/src/world/cast_tests.rs new file mode 100644 index 000000000..7bec5f510 --- /dev/null +++ b/gateway/src/world/cast_tests.rs @@ -0,0 +1,212 @@ +//! 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. + +use super::*; + +/// 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; + +/// `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 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.cast.casts.lock().unwrap().as_slice(), &[(100, 77)]); +} + +#[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.cast.ranged_attacks.lock().unwrap().as_slice(), + &[(88, spell)] + ); + assert!( + store.cast.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.clone()).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.cast.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.melee.stop_attacks.lock().unwrap().as_slice(), &[1]); +} + +#[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.cast.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.cast.cancelled_casts.lock().unwrap().as_slice(), &[1]); +} diff --git a/gateway/src/world/channel_tests.rs b/gateway/src/world/channel_tests.rs new file mode 100644 index 000000000..fcaa8e43b --- /dev/null +++ b/gateway/src/world/channel_tests.rs @@ -0,0 +1,63 @@ +//! Chat Channel opcodes over an encrypted World Session. + +use super::*; + +#[test] +fn join_channel_runs_the_channel_op_as_the_sessions_character() { + 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); + 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.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.chat.speaker_facts = Some(human_speaker()); + s.channel.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(); +} 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..79b7dca31 --- /dev/null +++ b/gateway/src/world/chat_tests.rs @@ -0,0 +1,499 @@ +//! Chat lines over an encrypted World Session: Speech, the GM dot-command, away status, Realm Chat +//! Lines and the Chat Flood Limiter. + +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()); +} + +/// `quest_store` plus the session's own Character row, which the `sync` sentinel needs. +fn chat_session_store() -> WorldFake { + let base = quest_store(); + WorldFake { + characters: vec![codec::CharacterView { + guid: 1, + name: "Warrior".into(), + ..Default::default() + }], + ..base + } +} + +/// `/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.chat.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.chat.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.chat.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.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::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.chat.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.speech.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.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::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.chat.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"); +} + +#[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.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::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.chat.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.speech.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.chat.speaker_facts = Some(human_speaker()); + s.chat.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() -> 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/combat_tests.rs b/gateway/src/world/combat_tests.rs new file mode 100644 index 000000000..5ab8ea8c5 --- /dev/null +++ b/gateway/src/world/combat_tests.rs @@ -0,0 +1,42 @@ +//! Targeting and sheathing over an encrypted World Session. + +use super::*; + +#[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.combat.sheathed.lock().unwrap().as_slice(), + &[(1, expect)], + "{sent:?} must reach set_sheathed as byte {expect}" + ); + } +} + +#[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.combat.selected_targets.lock().unwrap().as_slice(), + &[321] + ); +} diff --git a/gateway/src/world/death_tests.rs b/gateway/src/world/death_tests.rs new file mode 100644 index 000000000..8fdaa49a7 --- /dev/null +++ b/gateway/src/world/death_tests.rs @@ -0,0 +1,137 @@ +//! Death and resurrection opcodes over an encrypted World Session. + +use super::*; + +#[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.death.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.death.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.death.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.death.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.death.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({ + let base = quest_store(); + WorldFake { + death: DeathState { + self_resurrect_error: Some("no Self-Resurrection Option".into()), + ..base.death + }, + ..base + } + }); + 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.death.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.death.spirit_healer_calls.lock().unwrap().as_slice(), + &[(1, 888)] + ); +} diff --git a/gateway/src/world/framing_tests.rs b/gateway/src/world/framing_tests.rs index 981361baa..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: diff --git a/gateway/src/world/guild_tests.rs b/gateway/src/world/guild_tests.rs new file mode 100644 index 000000000..266f0e671 --- /dev/null +++ b/gateway/src/world/guild_tests.rs @@ -0,0 +1,834 @@ +//! Guild opcodes over an encrypted World Session, with sign-on at world entry and sign-off at +//! logout, plus the guild cleanup a Character delete triggers. + +use super::*; + +#[test] +fn guild_query_answers_at_character_select() { + let store = std::sync::Arc::new({ + let base = tester_store(7); + WorldFake { + guild: GuildState { + 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() + }], + ..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); + 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(); +} + +/// 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 every_membership_opcode_reaches_its_durable_request() { + let store = std::sync::Arc::new({ + 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() + }], + guilds: vec![codec::GuildView { + guild_id: 7, + name: "Tracer Guild".into(), + leader_guid: 1, + ..Default::default() + }], + ..base.guild + }, + characters: vec![ + codec::CharacterView { + guid: 1, + name: "Warrior".into(), + ..Default::default() + }, + codec::CharacterView { + guid: 2, + name: "Target".into(), + ..Default::default() + }, + ], + ..base + } + }); + 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({ + 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 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({ + let base = guild_member_store(); + WorldFake { + guild: GuildState { + 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() + }], + ..base.guild + }, + // 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() + }], + ..base + } + }); + 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({ + 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(); +} + +/// 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({ + let base = tester_store(7); + WorldFake { + session: SessionState { + login_entity: Some(warrior_entity()), + ..base.session + }, + characters: vec![ + codec::CharacterView { + guid: 1, + name: "Warrior".into(), + ..Default::default() + }, + codec::CharacterView { + guid: 2, + name: "Target".into(), + ..Default::default() + }, + ], + guild: GuildState { + 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], + ..base.guild + }, + ..base + } + }); + 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:?}"); + } + 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/item_tests.rs b/gateway/src/world/item_tests.rs new file mode 100644 index 000000000..2adeac301 --- /dev/null +++ b/gateway/src/world/item_tests.rs @@ -0,0 +1,115 @@ +//! Item actions over an encrypted World Session. + +use super::*; + +#[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({ + let base = tester_store(7); + WorldFake { + session: SessionState { + login_entity: Some(warrior_entity()), + ..base.session + }, + trade_error: Some(ItemRefusal::CannotEquip.as_tag().into()), + ..base + } + }); + 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.clone())); + 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({ + let base = tester_store(7); + WorldFake { + session: SessionState { + login_entity: Some(warrior_entity()), + ..base.session + }, + trade_error: Some("equip_item reducer transport disconnected: channel closed".into()), + ..base + } + }); + 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_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" + ); +} diff --git a/gateway/src/world/loot_tests.rs b/gateway/src/world/loot_tests.rs index 0df3cf3af..0f1015c97 100644 --- a/gateway/src/world/loot_tests.rs +++ b/gateway/src/world/loot_tests.rs @@ -423,3 +423,151 @@ fn a_non_disband_capable_op_never_flushes_pending_rolls() { ); 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..d675c02bc --- /dev/null +++ b/gateway/src/world/loot_window_tests.rs @@ -0,0 +1,379 @@ +//! The loot window and GameObject use over an encrypted World Session. + +use super::*; + +#[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.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 + .loot_window + .gameobjects_used + .lock() + .unwrap() + .is_empty()); + assert!(store + .loot_window + .corpse_loot_reads + .lock() + .unwrap() + .is_empty()); +} + +#[test] +fn non_chest_gameobject_preserves_the_general_use_path() { + let mut s = quest_store(); + s.npc.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 + .loot_window + .gameobjects_used + .lock() + .unwrap() + .as_slice(), + &[91] + ); + assert_eq!( + store + .loot_window + .corpse_loot_reads + .lock() + .unwrap() + .as_slice(), + &[(91, 1)] + ); +} + +#[test] +fn chest_dispatch_opens_the_shared_window_and_tracks_its_target() { + 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![(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 + .loot_window + .gameobjects_used + .lock() + .unwrap() + .as_slice(), + &[90] + ); + assert_eq!( + store + .loot_window + .corpse_loot_reads + .lock() + .unwrap() + .as_slice(), + &[(90, 1)] + ); + assert_eq!( + store.loot_window.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.clone())); + 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 + .loot_window + .corpse_loot_reads + .lock() + .unwrap() + .is_empty()); + assert!(store.loot_window.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({ + let base = tester_store(7); + WorldFake { + session: SessionState { + login_entity: Some(entity), + ..base.session + }, + ..base + } + }); + 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 + .loot_window + .corpse_loot_reads + .lock() + .unwrap() + .is_empty()); + assert!(store.loot_window.skinned.lock().unwrap().is_empty()); +} + +#[test] +fn skinning_refusal_keeps_the_world_session_alive() { + let store = std::sync::Arc::new({ + let base = quest_store(); + WorldFake { + loot_window: LootWindowState { + skinning_refusal: Some(LootWindowRefusal::Unanswered), + ..base.loot_window + }, + ..base + } + }); + 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.loot_window.skinned.lock().unwrap().is_empty()); +} + +#[test] +fn skinning_infrastructure_failure_ends_the_world_session() { + let store = std::sync::Arc::new({ + let base = quest_store(); + WorldFake { + loot_window: LootWindowState { + skinning_failure: Some("gw_skin reducer timed out after 10s".to_string()), + ..base.loot_window + }, + ..base + } + }); + 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.loot_window.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.loot_window.money_looted.lock().unwrap().as_slice(), + &[60], + "the TRACKED guid was looted" + ); + assert!( + store.loot_window.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.loot_window.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.loot_window.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.loot_window.money_looted.lock().unwrap().is_empty(), + "release cleared the tracked target" + ); + assert!( + store.loot_window.items_looted.lock().unwrap().is_empty(), + "release cleared the tracked target before an item take" + ); +} + +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) +} diff --git a/gateway/src/world/mail_tests.rs b/gateway/src/world/mail_tests.rs index 43fe0ee6a..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; diff --git a/gateway/src/world/melee_tests.rs b/gateway/src/world/melee_tests.rs new file mode 100644 index 000000000..13402766a --- /dev/null +++ b/gateway/src/world/melee_tests.rs @@ -0,0 +1,115 @@ +//! Melee attack opcodes over an encrypted World Session. + +use super::*; + +#[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.clone()).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.melee.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.melee.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.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); + 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); +} 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_tests.rs b/gateway/src/world/party_tests.rs index d82d2fc72..6ca4443c4 100644 --- a/gateway/src/world/party_tests.rs +++ b/gateway/src/world/party_tests.rs @@ -4875,3 +4875,175 @@ fn a_rejoin_retries_a_failed_mirror_push() { 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_tests.rs b/gateway/src/world/presence_tests.rs index 0ec16c9ca..238ef7dee 100644 --- a/gateway/src/world/presence_tests.rs +++ b/gateway/src/world/presence_tests.rs @@ -8,7 +8,7 @@ //! //! 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::*; 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..d467d8987 --- /dev/null +++ b/gateway/src/world/session_tests.rs @@ -0,0 +1,2281 @@ +//! 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.weather + }, + ..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.combat + }, + ..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/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/taxi_tests.rs b/gateway/src/world/taxi_tests.rs new file mode 100644 index 000000000..8fe2671a4 --- /dev/null +++ b/gateway/src/world/taxi_tests.rs @@ -0,0 +1,184 @@ +//! Taxi opcodes over an encrypted World Session. + +use super::*; + +#[test] +fn taxi_status_query_returns_the_persisted_bit_without_opening() { + let mut s = quest_store(); + s.taxi.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.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.npc.gossip_opts = vec![opt(0, "Show me your flight routes.", gossip_option::TAXI)]; + s.taxi.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.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.taxi_activation = codec::TaxiActivationResult { + result_code: lyracore_shared::constants::taxi_protocol::ACTIVATE_NOT_ENOUGH_MONEY, + }; + s.taxi.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.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.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.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.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 index 4abc8a1b0..bb71e1581 100644 --- a/gateway/src/world/test_support/entry.rs +++ b/gateway/src/world/test_support/entry.rs @@ -1,6 +1,9 @@ //! 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; @@ -25,3 +28,228 @@ pub(crate) fn drain_world_entry( } 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/tests.rs b/gateway/src/world/tests.rs index de552bc72..e6f1854ac 100644 --- a/gateway/src/world/tests.rs +++ b/gateway/src/world/tests.rs @@ -1,3 +1,7 @@ +//! 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::{ AuctionInteraction, ChannelOutcome, ChatOutcome, LootWindowRefusal, RealmChatRequest, SpeakerFacts, WhisperRequest, WhisperTargetFacts, @@ -6,7 +10,7 @@ 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; @@ -14,161 +18,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 -/// `WorldFake` 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 -/// `WorldFake` 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 `WorldFake` 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 `WorldFake` (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 `WorldFake` 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 `WorldFake` 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 `WorldFake` (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 `WorldFake` 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::{ @@ -203,7 +52,6 @@ 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, @@ -271,703 +119,76 @@ 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, -]; - -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) -> WorldFake { - WorldFake { - session: SessionState { - entity_in_world: true, - username: "TESTER".into(), - session: Some(WorldSession { - account_id, - session_key: K, - }), - ..Default::default() - }, - ..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.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 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 guild_query_answers_at_character_select() { - let store = std::sync::Arc::new({ - let base = tester_store(7); - WorldFake { - guild: GuildState { - 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() - }], - ..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); - 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}"), - } +#[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 = "channel_tests.rs"] +mod channel_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; - drop(client); - server.join().unwrap(); -} +use shard_routing_tests::ShardCallLog; /// 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 @@ -996,42 +217,6 @@ fn sync( } } -/// 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() -} - fn position(calls: &[String], what: &str) -> usize { calls .iter() @@ -1039,7687 +224,115 @@ fn position(calls: &[String], what: &str) -> usize { .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.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 every_membership_opcode_reaches_its_durable_request() { - let store = std::sync::Arc::new({ - 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() - }], - guilds: vec![codec::GuildView { - guild_id: 7, - name: "Tracer Guild".into(), - leader_guid: 1, - ..Default::default() - }], - ..base.guild - }, - characters: vec![ - codec::CharacterView { - guid: 1, - name: "Warrior".into(), - ..Default::default() - }, - codec::CharacterView { - guid: 2, - name: "Target".into(), - ..Default::default() - }, - ], - ..base - } - }); - 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({ - 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 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({ - let base = guild_member_store(); - WorldFake { - guild: GuildState { - 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() - }], - ..base.guild - }, - // 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() - }], - ..base - } - }); - 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({ - 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")); -} - -#[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(); -} - -/// 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(); -} - -/// 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({ - 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.weather - }, - ..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(); -} - -/// 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.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 weather = None; - for message in drain_world_entry(&mut client, &mut c_dec) { - if let ServerOpcodeMessage::SMSG_WEATHER(m) = message { - 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({ - let base = tester_store(7); - WorldFake { - session: SessionState { - login_entity: Some(warrior_entity()), - ..base.session - }, - ..base - } - })); - 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({ - let base = tester_store(7); - WorldFake { - session: SessionState { - login_entity: Some(warrior_entity()), - ..base.session - }, - weather: WeatherState { - weather_error: Some("shard cache unavailable".into()), - ..base.weather - }, - ..base - } - })); - 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.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(); -} - -#[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"))); -} - -// =========================================================================================== -// 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() -> WorldFake { - { - let base = tester_store(7); - WorldFake { - session: SessionState { - login_entity: Some(warrior_entity()), - ..base.session - }, - ..base - } - } -} - -/// 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 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. -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) -} - -/// 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 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 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(); -} - -/// 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(); -} - -#[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(); -} - -// ── 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({ - 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(); -} - -// ── 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({ - let base = tester_store(7); - WorldFake { - session: SessionState { - login_entity: Some(warrior_entity()), - ..base.session - }, - trade_error: Some("not enough money to buy that item".into()), - ..base - } - }); - 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({ - let base = quest_store(); - WorldFake { - npc_refuses: true, - characters: vec![codec::CharacterView { - guid: 1, - ..Default::default() - }], - ..base - } - }); - 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.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 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.bank.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.bank.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({ - let base = quest_store(); - WorldFake { - trade_error: Some("bank full".into()), - ..base - } - }); - 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.bank.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.bank.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.bank.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({ - let base = tester_store(7); - WorldFake { - session: SessionState { - login_entity: Some(warrior_entity()), - ..base.session - }, - trade_error: Some(ItemRefusal::CannotEquip.as_tag().into()), - ..base - } - }); - 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.clone())); - 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({ - let base = tester_store(7); - WorldFake { - session: SessionState { - login_entity: Some(warrior_entity()), - ..base.session - }, - trade_error: Some("equip_item reducer transport disconnected: channel closed".into()), - ..base - } - }); - 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_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({ - 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(); -} - -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({ - 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); - 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({ - 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(); -} - -// =========================================================================================== -// 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.cast.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.combat.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.cast.ranged_attacks.lock().unwrap().as_slice(), - &[(88, spell)] - ); - assert!( - store.cast.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.clone()).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.cast.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.melee.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.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(); -} - -#[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.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 - .loot_window - .gameobjects_used - .lock() - .unwrap() - .is_empty()); - assert!(store - .loot_window - .corpse_loot_reads - .lock() - .unwrap() - .is_empty()); -} - -#[test] -fn non_chest_gameobject_preserves_the_general_use_path() { - let mut s = quest_store(); - s.npc.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 - .loot_window - .gameobjects_used - .lock() - .unwrap() - .as_slice(), - &[91] - ); - assert_eq!( - store - .loot_window - .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.npc.gameobject_type = Some(lyracore_shared::constants::go_type::CHEST); - s.loot_window - .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 - .loot_window - .gameobjects_used - .lock() - .unwrap() - .as_slice(), - &[90] - ); - assert_eq!( - store - .loot_window - .corpse_loot_reads - .lock() - .unwrap() - .as_slice(), - &[(90, 1)] - ); - assert_eq!( - store.loot_window.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.clone())); - 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 - .loot_window - .corpse_loot_reads - .lock() - .unwrap() - .is_empty()); - assert!(store.loot_window.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({ - let base = tester_store(7); - WorldFake { - session: SessionState { - login_entity: Some(entity), - ..base.session - }, - ..base - } - }); - 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 - .loot_window - .corpse_loot_reads - .lock() - .unwrap() - .is_empty()); - assert!(store.loot_window.skinned.lock().unwrap().is_empty()); -} - -#[test] -fn skinning_refusal_keeps_the_world_session_alive() { - let store = std::sync::Arc::new({ - let base = quest_store(); - WorldFake { - loot_window: LootWindowState { - skinning_refusal: Some(LootWindowRefusal::Unanswered), - ..base.loot_window - }, - ..base - } - }); - 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.loot_window.skinned.lock().unwrap().is_empty()); -} - -#[test] -fn skinning_infrastructure_failure_ends_the_world_session() { - let store = std::sync::Arc::new({ - let base = quest_store(); - WorldFake { - loot_window: LootWindowState { - skinning_failure: Some("gw_skin reducer timed out after 10s".to_string()), - ..base.loot_window - }, - ..base - } - }); - 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.loot_window.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.loot_window.money_looted.lock().unwrap().as_slice(), - &[60], - "the TRACKED guid was looted" - ); - assert!( - store.loot_window.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.loot_window.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.loot_window.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.loot_window.money_looted.lock().unwrap().is_empty(), - "release cleared the tracked target" - ); - assert!( - store.loot_window.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.party.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_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" - ); -} - -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.clone()).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.melee.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.melee.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.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); - 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.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(); -} - -#[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({ - 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 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.vendor.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 - .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.character.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.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.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({ - let base = tester_store(7); - WorldFake { - session: SessionState { - login_entity: Some(warrior_entity()), - ..base.session - }, - trade_error: Some("gw_trainer_buy reducer timed out after 10s".into()), - ..base - } - }); - 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_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.trainer.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.trainer.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.trainer.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.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.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)); -} - -// --- 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.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.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.npc.gossip_opts = vec![opt(0, "Show me your flight routes.", gossip_option::TAXI)]; - s.taxi.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.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.taxi_activation = codec::TaxiActivationResult { - result_code: lyracore_shared::constants::taxi_protocol::ACTIVATE_NOT_ENOUGH_MONEY, - }; - s.taxi.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.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.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.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.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"); -} - -#[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 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()); -} - -#[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.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()); -} - -/// `quest_store` plus the session's own Character row, which the `sync` sentinel needs. -fn chat_session_store() -> WorldFake { - { - let base = quest_store(); - WorldFake { - characters: vec![codec::CharacterView { - guid: 1, - name: "Warrior".into(), - ..Default::default() - }], - ..base - } - } -} - -/// `/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.chat.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.chat.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.chat.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.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::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.chat.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.speech.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.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::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.chat.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.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::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.chat.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.speech.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.chat.speaker_facts = Some(human_speaker()); - s.chat.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() -> 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(), +/// 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(), } - .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.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); - 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}"), +/// 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, } - drop(client); - server.join().unwrap(); - let ops = store.channel.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.chat.speaker_facts = Some(human_speaker()); - s.channel.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}"), +fn imported_auction_interaction() -> AuctionInteraction { + AuctionInteraction { + house: super::handlers::AuctionHousePolicy { + id: 1, + deposit_rate: 5, + consignment_rate: 5, + }, + refuses_interaction: false, } - drop(client); - server.join().unwrap(); } -// ── Trainer dispatch (CMSG_TRAINER_LIST) ───────────────────────────────────────────────────────── - -#[test] -fn trainer_list_replies_smsg_trainer_list_with_the_fixture_spells() { - let mut s = quest_store(); - s.trainer.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(); -} +/// `SMSG_LOOT_RESPONSE`, read raw because the loot window is an `Outbound::Raw` packet. +const OP_LOOT_RESPONSE: u16 = 0x0160; -#[test] -fn trainer_list_is_silently_dropped_for_a_player_the_trainer_does_not_serve() { - let mut s = quest_store(); - s.trainer.trainer_refuses_class = true; - s.trainer.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(); +/// 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) } -#[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), +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, &mut c_enc) + .write_encrypted_client(&mut *client, 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(); + let (opcode, body) = read_raw_frame(client, dec); + assert_eq!(opcode, OP_LOOT_RESPONSE); + body } -#[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); +/// 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(90), + guid: Guid::new(npc), } - .write_encrypted_client(&mut client, &mut c_enc) + .write_encrypted_client(&mut *client, 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:?}" - ); - } + match ServerOpcodeMessage::read_encrypted(&mut *client, dec).unwrap() { + ServerOpcodeMessage::SMSG_GOSSIP_MESSAGE(m) => *m, 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.death.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.death.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.death.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.death.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.death.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({ - let base = quest_store(); - WorldFake { - death: DeathState { - self_resurrect_error: Some("no Self-Resurrection Option".into()), - ..base.death - }, - ..base - } - }); - 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.death.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.death.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({ - 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.combat - }, - ..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] -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.combat.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.cast.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.cast.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, 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}"), - } +/// 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() } - 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({ - let base = tester_store(7); - WorldFake { - session: SessionState { - login_entity: Some(warrior_entity()), - ..base.session - }, - characters: vec![ - codec::CharacterView { - guid: 1, - name: "Warrior".into(), - ..Default::default() - }, - codec::CharacterView { - guid: 2, - name: "Target".into(), - ..Default::default() - }, - ], - guild: GuildState { - 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], - ..base.guild - }, - ..base - } - }); - 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/trainer_tests.rs b/gateway/src/world/trainer_tests.rs new file mode 100644 index 000000000..6c1e5b79e --- /dev/null +++ b/gateway/src/world/trainer_tests.rs @@ -0,0 +1,439 @@ +//! Trainer and talent opcodes over an encrypted World Session. + +use super::*; + +#[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 + .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.character.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.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.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({ + let base = tester_store(7); + WorldFake { + session: SessionState { + login_entity: Some(warrior_entity()), + ..base.session + }, + trade_error: Some("gw_trainer_buy reducer timed out after 10s".into()), + ..base + } + }); + 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_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.trainer.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.trainer.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.trainer.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 trainer_list_replies_smsg_trainer_list_with_the_fixture_spells() { + let mut s = quest_store(); + s.trainer.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.trainer_refuses_class = true; + s.trainer.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(); +} diff --git a/gateway/src/world/vendor_tests.rs b/gateway/src/world/vendor_tests.rs new file mode 100644 index 000000000..5cfdfe3a5 --- /dev/null +++ b/gateway/src/world/vendor_tests.rs @@ -0,0 +1,128 @@ +//! Vendor opcodes over an encrypted World Session, and the buyback ring at login. + +use super::*; + +#[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({ + let base = tester_store(7); + WorldFake { + session: SessionState { + login_entity: Some(warrior_entity()), + ..base.session + }, + trade_error: Some("not enough money to buy that item".into()), + ..base + } + }); + 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(); +} + +#[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({ + 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 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.vendor.bought_back.lock().unwrap().as_slice(), + &[(99, 0), (99, 12)] + ); +} + +#[test] +fn list_inventory_opens_the_vendor_window_over_the_socket() { + 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, 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(); +} diff --git a/gateway/src/world/weather_tests.rs b/gateway/src/world/weather_tests.rs new file mode 100644 index 000000000..13e325cbb --- /dev/null +++ b/gateway/src/world/weather_tests.rs @@ -0,0 +1,188 @@ +//! Zone weather at world entry and on a live World Session. + +use super::*; + +/// 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.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 weather = None; + for message in drain_world_entry(&mut client, &mut c_dec) { + if let ServerOpcodeMessage::SMSG_WEATHER(m) = message { + 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({ + let base = tester_store(7); + WorldFake { + session: SessionState { + login_entity: Some(warrior_entity()), + ..base.session + }, + ..base + } + })); + 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({ + let base = tester_store(7); + WorldFake { + session: SessionState { + login_entity: Some(warrior_entity()), + ..base.session + }, + weather: WeatherState { + weather_error: Some("shard cache unavailable".into()), + ..base.weather + }, + ..base + } + })); + 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.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_tests.rs b/gateway/src/world/whisper_tests.rs index adc280b2d..7e5f9db26 100644 --- a/gateway/src/world/whisper_tests.rs +++ b/gateway/src/world/whisper_tests.rs @@ -446,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_tests.rs b/gateway/src/world/who_tests.rs index a0562f5b0..2b772635e 100644 --- a/gateway/src/world/who_tests.rs +++ b/gateway/src/world/who_tests.rs @@ -211,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(); +} From 83a56e6ad72ef6712d2894871ae9492fa170a1a2 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 17:03:02 +0200 Subject: [PATCH 09/17] test(gateway): test loot window and death requests at the handler Death requests run through handle_loot against HandleLootFake, which models who is alive and asserts outcomes. Loot window socket tests become dispatcher tests on InMemoryLootWindow. The socket tests that remain check GameObject type routing. WorldFake keeps only the loot window state that session and routing tests read. Co-Authored-By: Claude Sonnet 5.5 --- gateway/src/world/death_tests.rs | 261 +++++++----- gateway/src/world/handle_loot_fake.rs | 385 ++++++++++++++++++ gateway/src/world/handlers/loot.rs | 128 ++++++ gateway/src/world/loot_window_tests.rs | 374 ++--------------- .../world/test_support/world_fake/death.rs | 57 +-- .../test_support/world_fake/loot_window.rs | 49 +-- .../src/world/test_support/world_fake/mod.rs | 2 - 7 files changed, 712 insertions(+), 544 deletions(-) create mode 100644 gateway/src/world/handle_loot_fake.rs diff --git a/gateway/src/world/death_tests.rs b/gateway/src/world/death_tests.rs index 8fdaa49a7..39e4700df 100644 --- a/gateway/src/world/death_tests.rs +++ b/gateway/src/world/death_tests.rs @@ -1,137 +1,174 @@ -//! Death and resurrection opcodes over an encrypted World Session. +//! Death and resurrection requests, handled against a Fake that models only who is alive. use super::*; +use handle_loot_fake::{HandleLootFake, Life}; -#[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.death.repopped.lock().unwrap().as_slice(), &[1]); +#[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 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.death.reclaimed_corpses.lock().unwrap().as_slice(), - &[(1, 777)] +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 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.death.resurrect_responses.lock().unwrap().as_slice(), - &[(1, true)] - ); +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 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.death.resurrect_responses.lock().unwrap().as_slice(), - &[(1, false)] - ); +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 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.death.self_resurrects.lock().unwrap().as_slice(), &[1]); +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 = std::sync::Arc::new({ - let base = quest_store(); - WorldFake { - death: DeathState { - self_resurrect_error: Some("no Self-Resurrection Option".into()), - ..base.death - }, - ..base - } - }); - 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.death.self_resurrects.lock().unwrap().as_slice(), &[1]); + 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_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.death.spirit_healer_calls.lock().unwrap().as_slice(), - &[(1, 888)] +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/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/loot.rs b/gateway/src/world/handlers/loot.rs index 35cd5108f..230da9ef0 100644 --- a/gateway/src/world/handlers/loot.rs +++ b/gateway/src/world/handlers/loot.rs @@ -1740,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/loot_window_tests.rs b/gateway/src/world/loot_window_tests.rs index d675c02bc..7d371039a 100644 --- a/gateway/src/world/loot_window_tests.rs +++ b/gateway/src/world/loot_window_tests.rs @@ -1,5 +1,7 @@ -//! The loot window and GameObject use over an encrypted World Session. +//! 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::*; #[test] @@ -12,368 +14,54 @@ fn questgiver_gameobject_bypasses_the_chest_lifecycle() { .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); + 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(); - - 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 - .loot_window - .gameobjects_used - .lock() - .unwrap() - .is_empty()); - assert!(store - .loot_window - .corpse_loot_reads - .lock() - .unwrap() - .is_empty()); -} - -#[test] -fn non_chest_gameobject_preserves_the_general_use_path() { - let mut s = quest_store(); - s.npc.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 - .loot_window - .gameobjects_used - .lock() - .unwrap() - .as_slice(), - &[91] - ); - assert_eq!( - store - .loot_window - .corpse_loot_reads - .lock() - .unwrap() - .as_slice(), - &[(91, 1)] - ); -} - -#[test] -fn chest_dispatch_opens_the_shared_window_and_tracks_its_target() { - 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![(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), + CMSG_QUESTGIVER_STATUS_QUERY { + guid: Guid::new(50), } .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 - .loot_window - .gameobjects_used - .lock() - .unwrap() - .as_slice(), - &[90] - ); - assert_eq!( - store - .loot_window - .corpse_loot_reads - .lock() - .unwrap() - .as_slice(), - &[(90, 1)] - ); - assert_eq!( - store.loot_window.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.clone())); - 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 - .loot_window - .corpse_loot_reads - .lock() - .unwrap() - .is_empty()); - assert!(store.loot_window.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({ - let base = tester_store(7); - WorldFake { - session: SessionState { - login_entity: Some(entity), - ..base.session - }, - ..base - } - }); - 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 - .loot_window - .corpse_loot_reads - .lock() - .unwrap() - .is_empty()); - assert!(store.loot_window.skinned.lock().unwrap().is_empty()); -} - -#[test] -fn skinning_refusal_keeps_the_world_session_alive() { - let store = std::sync::Arc::new({ - let base = quest_store(); - WorldFake { - loot_window: LootWindowState { - skinning_refusal: Some(LootWindowRefusal::Unanswered), - ..base.loot_window - }, - ..base - } - }); - 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.loot_window.skinned.lock().unwrap().is_empty()); -} - -#[test] -fn skinning_infrastructure_failure_ends_the_world_session() { - let store = std::sync::Arc::new({ - let base = quest_store(); - WorldFake { - loot_window: LootWindowState { - skinning_failure: Some("gw_skin reducer timed out after 10s".to_string()), - ..base.loot_window - }, - ..base + ServerOpcodeMessage::SMSG_QUESTGIVER_QUEST_DETAILS(details) => { + assert_eq!(details.quest_id, 1234) } - }); - 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.loot_window.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.loot_window.money_looted.lock().unwrap().as_slice(), - &[60], - "the TRACKED guid was looted" - ); - assert!( - store.loot_window.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), + other => panic!("expected quest details from a questgiver GameObject, got {other}"), } - .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(); + // The loot window would have answered before the status query's reply. 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}") - } + ServerOpcodeMessage::SMSG_QUESTGIVER_STATUS(_) => {} + other => panic!("a questgiver GameObject opened a loot window: {other}"), } drop(client); server.join().unwrap(); - assert_eq!( - store.loot_window.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.loot_window.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.loot_window.money_looted.lock().unwrap().is_empty(), - "release cleared the tracked target" - ); - assert!( - store.loot_window.items_looted.lock().unwrap().is_empty(), - "release cleared the tracked target before an item take" +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), + }), ); -} -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) + 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/test_support/world_fake/death.rs b/gateway/src/world/test_support/world_fake/death.rs index 96f5d37b7..8b1561cb8 100644 --- a/gateway/src/world/test_support/world_fake/death.rs +++ b/gateway/src/world/test_support/world_fake/death.rs @@ -1,61 +1,30 @@ use super::super::*; -#[derive(Default)] -pub(crate) struct DeathState { - /// Recorded `repop` calls — the caller's self_guid, one entry per CMSG_REPOP_REQUEST. - pub(crate) repopped: std::sync::Mutex>, - /// Recorded `reclaim_corpse` calls — `(self_guid, corpse_guid)` off CMSG_RECLAIM_CORPSE. - pub(crate) reclaimed_corpses: std::sync::Mutex>, - /// Recorded `resurrect_response` calls — `(self_guid, accept)`, pinning the wire's - /// `status != 0` → bool mapping. - pub(crate) resurrect_responses: std::sync::Mutex>, - /// Recorded `self_resurrect` calls: the caller's self_guid off CMSG_SELF_RES. - pub(crate) self_resurrects: std::sync::Mutex>, - /// When set, `self_resurrect` returns this Refusal after recording the call. - pub(crate) self_resurrect_error: Option, - /// Recorded `spirit_healer_res` calls — `(self_guid, healer_guid)` off - /// CMSG_SPIRIT_HEALER_ACTIVATE. - pub(crate) spirit_healer_calls: std::sync::Mutex>, -} - +/// 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<()> { - self.death.repopped.lock().unwrap().push(self_guid); + 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<()> { - self.death - .reclaimed_corpses - .lock() - .unwrap() - .push((self_guid, corpse_guid)); + 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<()> { - self.death - .resurrect_responses - .lock() - .unwrap() - .push((self_guid, accept)); + 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<()> { - self.death.self_resurrects.lock().unwrap().push(self_guid); - match &self.death.self_resurrect_error { - Some(e) => Err(anyhow!("{e}")), - None => 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<()> { - self.death - .spirit_healer_calls - .lock() - .unwrap() - .push((self_guid, healer_guid)); + fn spirit_healer_res( + &self, + _account_id: u64, + _self_guid: u64, + _healer_guid: u64, + ) -> Result<()> { Ok(()) } diff --git a/gateway/src/world/test_support/world_fake/loot_window.rs b/gateway/src/world/test_support/world_fake/loot_window.rs index c069852b2..1e1fbe99c 100644 --- a/gateway/src/world/test_support/world_fake/loot_window.rs +++ b/gateway/src/world/test_support/world_fake/loot_window.rs @@ -6,24 +6,9 @@ pub(crate) struct LootWindowState { 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 target and slot for `CMSG_AUTOSTORE_LOOT_ITEM`. - pub(crate) items_looted: std::sync::Mutex>, - /// Recorded `skin_corpse` targets (the empty-loot-window skinning fallback). - pub(crate) skinned: std::sync::Mutex>, - /// Typed legacy skinning refusal returned by the empty-loot fallback. - pub(crate) skinning_refusal: Option, - /// Infrastructure failure returned by the empty-loot skinning fallback. - pub(crate) skinning_failure: 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. + /// 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>, - /// Recorded non-questgiver GameObject uses owned by the loot-window seam. - pub(crate) gameobjects_used: std::sync::Mutex>, - pub(crate) corpse_loot_reads: std::sync::Mutex>, } impl LootWindowStore for WorldFake { @@ -36,11 +21,6 @@ impl LootWindowStore for WorldFake { _target_guid: u64, viewer_guid: u64, ) -> Result> { - self.loot_window - .corpse_loot_reads - .lock() - .unwrap() - .push((_target_guid, viewer_guid)); Ok(self .loot_window .corpse_loot_by_viewer @@ -53,13 +33,8 @@ impl LootWindowStore for WorldFake { &self, _account_id: u64, _actor_guid: u64, - target_guid: u64, + _target_guid: u64, ) -> Result { - self.loot_window - .gameobjects_used - .lock() - .unwrap() - .push(target_guid); Ok(LootWindowRequestStatus::Applied) } @@ -76,15 +51,8 @@ impl LootWindowStore for WorldFake { &self, _account_id: u64, _actor_guid: u64, - target_guid: u64, + _target_guid: u64, ) -> Result { - if let Some(error) = &self.loot_window.skinning_failure { - return Err(anyhow!(error.clone())); - } - if let Some(refusal) = self.loot_window.skinning_refusal { - return Ok(LootWindowRequestStatus::Refused(refusal)); - } - self.loot_window.skinned.lock().unwrap().push(target_guid); Ok(LootWindowRequestStatus::Applied) } @@ -106,14 +74,9 @@ impl LootWindowStore for WorldFake { &self, _account_id: u64, _actor_guid: u64, - target_guid: u64, - loot_slot: u8, + _target_guid: u64, + _loot_slot: u8, ) -> Result { - self.loot_window - .items_looted - .lock() - .unwrap() - .push((target_guid, loot_slot)); Ok(LootWindowRequestStatus::Applied) } } diff --git a/gateway/src/world/test_support/world_fake/mod.rs b/gateway/src/world/test_support/world_fake/mod.rs index 3b31fad2e..21d7a2a2e 100644 --- a/gateway/src/world/test_support/world_fake/mod.rs +++ b/gateway/src/world/test_support/world_fake/mod.rs @@ -37,7 +37,6 @@ pub(crate) use self::channel::*; pub(crate) use self::character::*; pub(crate) use self::chat::*; pub(crate) use self::combat::*; -pub(crate) use self::death::*; pub(crate) use self::guild::*; pub(crate) use self::item::*; pub(crate) use self::loot_roll::*; @@ -84,7 +83,6 @@ pub(crate) struct WorldFake { pub(crate) trainer: TrainerState, pub(crate) bank: BankState, pub(crate) combat: CombatState, - pub(crate) death: DeathState, pub(crate) trade: TradeState, pub(crate) cast: CastState, pub(crate) taxi: TaxiState, From 5dd1f82c3b4f0dfd69ae33ebf64a9359a0b3f2c9 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 17:05:34 +0200 Subject: [PATCH 10/17] test(gateway): run trainer, bank, combat and trade tests at the handler Each family drives its handler through a local run helper against a Fake that implements only the Stores the handler is bounded on. Tests assert the packets sent or the Fake's resulting state, not a call log. The four families lose their state from WorldFake, except the fields other tests still read. Co-Authored-By: Claude Sonnet 5.5 --- gateway/src/world/bank_tests.rs | 379 +++++---- gateway/src/world/combat_tests.rs | 89 +- gateway/src/world/family_harness.rs | 137 +++ gateway/src/world/session_tests.rs | 1 - .../src/world/test_support/world_fake/bank.rs | 25 +- .../world/test_support/world_fake/combat.rs | 26 +- .../src/world/test_support/world_fake/mod.rs | 4 - .../world/test_support/world_fake/trade.rs | 87 +- .../world/test_support/world_fake/trainer.rs | 53 +- gateway/src/world/trade_tests.rs | 532 +++++++----- gateway/src/world/trainer_tests.rs | 803 ++++++++++-------- 11 files changed, 1199 insertions(+), 937 deletions(-) create mode 100644 gateway/src/world/family_harness.rs diff --git a/gateway/src/world/bank_tests.rs b/gateway/src/world/bank_tests.rs index 541c908d0..4a3df3925 100644 --- a/gateway/src/world/bank_tests.rs +++ b/gateway/src/world/bank_tests.rs @@ -1,191 +1,242 @@ -//! Bank opcodes over an encrypted World Session. +//! 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::trainer_tests::family_harness::{drain_outbound, in_world_conn, npc_store_refusing_by}; use super::*; +use std::collections::BTreeSet; +use std::sync::Mutex; -#[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), +/// 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() } - .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}"), + + fn banked(&self) -> Vec { + self.banked.lock().unwrap().iter().copied().collect() } - 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({ - let base = quest_store(); - WorldFake { - npc_refuses: true, - characters: vec![codec::CharacterView { - guid: 1, - ..Default::default() - }], - ..base +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); } - }); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - CMSG_BANKER_ACTIVATE { - guid: Guid::new(77), + Ok(()) } - .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}") + + 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(()) } - drop(client); - server.join().unwrap(); +} + +/// 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 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.bank.auto_banked_items.lock().unwrap().as_slice(), - &[23] +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 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.bank.auto_banked_items.lock().unwrap().as_slice(), - &[39] +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_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({ - let base = quest_store(); - WorldFake { - trade_error: Some("bank full".into()), - ..base - } - }); - 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(); +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 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.bank.auto_banked_items.lock().unwrap().is_empty(), - "a sub-bag source must not be routed through auto_bank_item" +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 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(); +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!( - store.bank.auto_banked_items.lock().unwrap().is_empty(), - "a sub-bag source must not be routed through auto_bank_item" + 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 = 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.bank.bought_bank_slots.lock().unwrap().as_slice(), - &[88] + 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() { - // 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", @@ -197,22 +248,20 @@ fn buy_bank_slot_failure_maps_the_bracketed_code_to_the_matching_result() { ), ("[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(); + 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/combat_tests.rs b/gateway/src/world/combat_tests.rs index 5ab8ea8c5..855ec32af 100644 --- a/gateway/src/world/combat_tests.rs +++ b/gateway/src/world/combat_tests.rs @@ -1,6 +1,57 @@ -//! Targeting and sheathing over an encrypted World Session. +//! 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::trainer_tests::family_harness::in_world_conn; 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() { @@ -9,34 +60,24 @@ fn set_sheathed_routes_the_clients_z_press_to_the_store() { (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(); + let store = CombatFake::default(); + run(&store, CMSG_SETSHEATHED { sheathed: sent }); assert_eq!( - store.combat.sheathed.lock().unwrap().as_slice(), - &[(1, expect)], - "{sent:?} must reach set_sheathed as byte {expect}" + store.sheath_of(1), + Some(expect), + "{sent:?} must reach the Store as byte {expect}" ); } } #[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.combat.selected_targets.lock().unwrap().as_slice(), - &[321] +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/family_harness.rs b/gateway/src/world/family_harness.rs new file mode 100644 index 000000000..0150e6233 --- /dev/null +++ b/gateway/src/world/family_harness.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::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/session_tests.rs b/gateway/src/world/session_tests.rs index d467d8987..46a414445 100644 --- a/gateway/src/world/session_tests.rs +++ b/gateway/src/world/session_tests.rs @@ -2006,7 +2006,6 @@ fn reducer_transport_loss_ends_an_admitted_session_and_frees_one_queue_seat() { }, combat: CombatState { set_target_error: Some("transport disconnected".into()), - ..base.combat }, ..base } diff --git a/gateway/src/world/test_support/world_fake/bank.rs b/gateway/src/world/test_support/world_fake/bank.rs index 9723e3305..a184df7d8 100644 --- a/gateway/src/world/test_support/world_fake/bank.rs +++ b/gateway/src/world/test_support/world_fake/bank.rs @@ -1,32 +1,11 @@ use super::super::*; -#[derive(Default)] -pub(crate) struct BankState { - /// 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. - pub(crate) auto_banked_items: std::sync::Mutex>, - /// Recorded `buy_bank_slot` calls — the banker guid named on each `CMSG_BUY_BANK_SLOT`. - pub(crate) bought_bank_slots: std::sync::Mutex>, -} - impl BankStore for WorldFake { - 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.bank.auto_banked_items.lock().unwrap().push(slot); + 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<()> { - if let Some(e) = &self.trade_error { - return Err(anyhow!("{e}")); - } - self.bank - .bought_bank_slots - .lock() - .unwrap() - .push(banker_guid); + 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/combat.rs b/gateway/src/world/test_support/world_fake/combat.rs index 9f73d748c..fd7798758 100644 --- a/gateway/src/world/test_support/world_fake/combat.rs +++ b/gateway/src/world/test_support/world_fake/combat.rs @@ -2,28 +2,17 @@ use super::super::*; #[derive(Default)] pub(crate) struct CombatState { - /// Recorded `set_sheathed` dispatches: (self_guid, state), the `CMSG_SETSHEATHED` route. - pub(crate) sheathed: 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.) - pub(crate) 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. pub(crate) set_target_error: Option, } impl CombatStore for WorldFake { - fn set_target(&self, _account_id: u64, _self_guid: u64, target_guid: u64) -> Result<()> { - self.rec("set_target"); - if let Some(e) = &self.combat.set_target_error { - return Err(anyhow!("{e}")); + 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(()), } - self.combat - .selected_targets - .lock() - .unwrap() - .push(target_guid); - Ok(()) } fn pet_command( @@ -36,12 +25,7 @@ impl CombatStore for WorldFake { Ok(()) } - fn set_sheathed(&self, _account_id: u64, self_guid: u64, state: u8) -> Result<()> { - self.combat - .sheathed - .lock() - .unwrap() - .push((self_guid, state)); + fn set_sheathed(&self, _account_id: u64, _self_guid: u64, _state: u8) -> Result<()> { Ok(()) } } diff --git a/gateway/src/world/test_support/world_fake/mod.rs b/gateway/src/world/test_support/world_fake/mod.rs index 3b31fad2e..524eacd55 100644 --- a/gateway/src/world/test_support/world_fake/mod.rs +++ b/gateway/src/world/test_support/world_fake/mod.rs @@ -31,7 +31,6 @@ mod vendor; mod weather; pub(crate) use self::auction::*; -pub(crate) use self::bank::*; pub(crate) use self::cast::*; pub(crate) use self::channel::*; pub(crate) use self::character::*; @@ -52,7 +51,6 @@ pub(crate) use self::session::*; pub(crate) use self::social::*; pub(crate) use self::speech::*; pub(crate) use self::taxi::*; -pub(crate) use self::trade::*; pub(crate) use self::trainer::*; pub(crate) use self::transfer::*; pub(crate) use self::vendor::*; @@ -82,10 +80,8 @@ pub(crate) struct WorldFake { pub(crate) social: SocialState, pub(crate) npc: NpcState, pub(crate) trainer: TrainerState, - pub(crate) bank: BankState, pub(crate) combat: CombatState, pub(crate) death: DeathState, - pub(crate) trade: TradeState, pub(crate) cast: CastState, pub(crate) taxi: TaxiState, pub(crate) melee: MeleeState, diff --git a/gateway/src/world/test_support/world_fake/trade.rs b/gateway/src/world/test_support/world_fake/trade.rs index f633c5020..5424bc18c 100644 --- a/gateway/src/world/test_support/world_fake/trade.rs +++ b/gateway/src/world/test_support/world_fake/trade.rs @@ -1,112 +1,49 @@ use super::super::*; -#[derive(Default)] -pub(crate) struct TradeState { - /// Recorded `initiate_trade` calls, `(self_guid, target_guid)` off CMSG_INITIATE_TRADE. - pub(crate) initiated_trades: std::sync::Mutex>, - /// Recorded `begin_trade` self_guids, CMSG_BEGIN_TRADE. - pub(crate) begun_trades: std::sync::Mutex>, - /// Recorded `cancel_trade` self_guids, CMSG_CANCEL_TRADE. - pub(crate) 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. - pub(crate) set_trade_items: std::sync::Mutex>, - /// Recorded `clear_trade_item` calls, `(self_guid, trade_slot)`. - pub(crate) cleared_trade_items: std::sync::Mutex>, - /// Recorded `set_trade_gold` calls, `(self_guid, copper)` after the wire's Gold decode. - pub(crate) set_trade_golds: std::sync::Mutex>, - /// Recorded `accept_trade` self_guids, CMSG_ACCEPT_TRADE. - pub(crate) accepted_trades: std::sync::Mutex>, - /// Recorded `unaccept_trade` self_guids, CMSG_UNACCEPT_TRADE. - pub(crate) unaccepted_trades: std::sync::Mutex>, - /// Recorded `busy_trade` self_guids, CMSG_BUSY_TRADE. - pub(crate) busy_trades: std::sync::Mutex>, - /// Recorded `ignore_trade` self_guids, CMSG_IGNORE_TRADE. - pub(crate) ignore_trades: std::sync::Mutex>, -} - impl TradeStore for WorldFake { - // 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.trade - .initiated_trades - .lock() - .unwrap() - .push((self_guid, target_guid)); + 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<()> { - self.rec("begin_trade"); - self.trade.begun_trades.lock().unwrap().push(self_guid); + fn begin_trade(&self, _account_id: u64, _self_guid: u64) -> Result<()> { Ok(()) } - fn cancel_trade(&self, _account_id: u64, self_guid: u64) -> Result<()> { - self.rec("cancel_trade"); - self.trade.cancelled_trades.lock().unwrap().push(self_guid); + 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, + _self_guid: u64, + _trade_slot: u8, + _inv_slot: u8, ) -> Result<()> { - self.rec("set_trade_item"); - self.trade - .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.trade - .cleared_trade_items - .lock() - .unwrap() - .push((self_guid, trade_slot)); + 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<()> { - self.rec("set_trade_gold"); - self.trade - .set_trade_golds - .lock() - .unwrap() - .push((self_guid, copper)); + 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<()> { - self.rec("accept_trade"); - self.trade.accepted_trades.lock().unwrap().push(self_guid); + fn accept_trade(&self, _account_id: u64, _self_guid: u64) -> Result<()> { Ok(()) } - fn unaccept_trade(&self, _account_id: u64, self_guid: u64) -> Result<()> { - self.rec("unaccept_trade"); - self.trade.unaccepted_trades.lock().unwrap().push(self_guid); + fn unaccept_trade(&self, _account_id: u64, _self_guid: u64) -> Result<()> { Ok(()) } - fn busy_trade(&self, _account_id: u64, self_guid: u64) -> Result<()> { - self.rec("busy_trade"); - self.trade.busy_trades.lock().unwrap().push(self_guid); + fn busy_trade(&self, _account_id: u64, _self_guid: u64) -> Result<()> { Ok(()) } - fn ignore_trade(&self, _account_id: u64, self_guid: u64) -> Result<()> { - self.rec("ignore_trade"); - self.trade.ignore_trades.lock().unwrap().push(self_guid); + 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 index a3897910b..ad6992ba1 100644 --- a/gateway/src/world/test_support/world_fake/trainer.rs +++ b/gateway/src/world/test_support/world_fake/trainer.rs @@ -5,27 +5,9 @@ pub(crate) struct TrainerState { /// Spelled as a refusal so derive-Default (false) keeps every fixture trainer serving; the /// trait method reads the negation. pub(crate) trainer_refuses_class: bool, - /// When set, the trainer buy answers this Refusal instead of learning the spell. - pub(crate) trainer_buy_refusal: Option, - /// What `talent_grant_spell` returns (0 = passive talent → no SMSG_LEARNED_SPELL push). - pub(crate) talent_grant: u32, - /// What `talent_pane_sync` returns: (teach rank-spell, superseded prev, points remaining). - pub(crate) 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. - pub(crate) trainer_superseded: Option, /// Recorded `reset_talents` dispatches: (account_id, self_guid, trainer_guid) — the unlearn-talents /// gossip select. pub(crate) reset_talents_calls: std::sync::Mutex>, - /// When set, `reset_talents` returns this error instead of recording the call. - pub(crate) reset_talents_error: Option, - /// Trainer rows `trainer_list` returns for ANY (player, trainer) pair — the - /// CMSG_TRAINER_LIST fixture. Empty by default (an empty trainer window). - pub(crate) 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. - pub(crate) trainer_offer_skill_lines: std::collections::HashMap, } impl TrainerStore for WorldFake { @@ -38,7 +20,7 @@ impl TrainerStore for WorldFake { _player_guid: u64, _trainer_guid: u64, ) -> Result> { - Ok(self.trainer.trainer_spells.clone()) + Ok(Vec::new()) } fn buy_trainer_spell( @@ -48,17 +30,11 @@ impl TrainerStore for WorldFake { _trainer_guid: u64, _spell_id: u32, ) -> Result { - if let Some(refusal) = self.trainer.trainer_buy_refusal { - return Ok(refusal.into()); - } - match &self.trade_error { - Some(e) => Err(anyhow!("{e}")), - None => Ok(crate::world::TrainerBuyOutcome::Learned), - } + Ok(crate::world::TrainerBuyOutcome::Learned) } fn talent_grant_spell(&self, _talent_id: u32) -> u32 { - self.trainer.talent_grant + 0 } fn set_faction_at_war( @@ -83,7 +59,7 @@ impl TrainerStore for WorldFake { } fn talent_pane_sync(&self, _character_guid: u64, _talent_id: u32) -> (u32, u32, u32) { - self.trainer.talent_pane + (0, 0, 0) } fn talent_points_spent(&self, _character_guid: u64) -> u32 { @@ -91,16 +67,10 @@ impl TrainerStore for WorldFake { } 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(()), - } + Ok(()) } fn reset_talents(&self, account_id: u64, self_guid: u64, trainer_guid: u64) -> Result<()> { - if let Some(e) = &self.trainer.reset_talents_error { - return Err(anyhow!("{e}")); - } self.trainer.reset_talents_calls.lock().unwrap().push(( account_id, self_guid, @@ -110,20 +80,15 @@ impl TrainerStore for WorldFake { } fn resolve_learn_target(&self, spell_id: u32) -> u32 { - spell_id // mock: self-contained ranks (no wrapper table in the mock store) + spell_id // self-contained ranks (no wrapper table in the Fake) } - 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 - .trainer_offer_skill_lines - .get(&spell_id) - .copied() - .unwrap_or(0) + 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 { - self.trainer.trainer_superseded + None } fn character_presence(&self, guid: u64) -> Result> { diff --git a/gateway/src/world/trade_tests.rs b/gateway/src/world/trade_tests.rs index 16b626b72..920b98324 100644 --- a/gateway/src/world/trade_tests.rs +++ b/gateway/src/world/trade_tests.rs @@ -1,269 +1,367 @@ -//! 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::trainer_tests::family_harness::in_world_conn; 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.** +#[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 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(); +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.trade.initiated_trades.lock().unwrap().as_slice(), - &[(1, 2)] + store.phase(), + Phase::Proposed { + initiator: 1, + target: 2 + } ); } -/// **AC: tested from both sides** — the SAME wire flow works with the seats swapped: player 2 -/// initiates against player 1. +/// The same flow works with the seats swapped: Character 2 initiates against Character 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(); +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.trade.initiated_trades.lock().unwrap().as_slice(), - &[(2, 1)] + store.phase(), + Phase::Proposed { + initiator: 2, + target: 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. +/// 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_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(); +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.trade.initiated_trades.lock().unwrap().as_slice(), - &[(1, 2)] + store.phase(), + Phase::Proposed { + initiator: 1, + target: 2 + } ); - assert_eq!(store.trade.begun_trades.lock().unwrap().as_slice(), &[2]); + + run(&store, 2, CMSG_BEGIN_TRADE {}); assert_eq!( - store.trade.cancelled_trades.lock().unwrap().as_slice(), - &[2] + store.phase(), + Phase::Open { + initiator: 1, + target: 2 + } ); - let calls: Vec = store - .topology - .calls - .lock() - .unwrap() - .iter() - .filter(|(_, what)| what.contains("trade")) - .map(|(_, what)| what.clone()) - .collect(); - assert_eq!(calls, ["initiate_trade", "begin_trade", "cancel_trade"]); + + run(&store, 2, CMSG_CANCEL_TRADE {}); + assert_eq!(store.phase(), Phase::Cancelled { by: 2 }); } -/// **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. +/// 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_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(); - assert_eq!( - store.trade.set_trade_items.lock().unwrap().as_slice(), - &[(1, 2, 23)] +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.trade.cleared_trade_items.lock().unwrap().as_slice(), - &[(1, 2)] - ); - assert_eq!( - store.trade.set_trade_golds.lock().unwrap().as_slice(), - &[(1, 1_2345)] + 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.trade.set_trade_items.lock().unwrap().is_empty()); + let store = TradeFake::default(); + run( + &store, + 1, + CMSG_SET_TRADE_ITEM { + trade_slot: 0, + bag: 19, + slot: 2, + }, + ); + assert!(store.items().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.) +/// 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(); +fn the_full_loop_records_each_seats_offer_and_both_accepts() { + let store = TradeFake::default(); - assert_eq!( - store.trade.initiated_trades.lock().unwrap().as_slice(), - &[(1, 2)] - ); - assert_eq!( - store.trade.set_trade_items.lock().unwrap().as_slice(), - &[(1, 0, 23)] + run(&store, 1, CMSG_INITIATE_TRADE { guid: Guid::new(2) }); + run( + &store, + 1, + CMSG_SET_TRADE_ITEM { + trade_slot: 0, + bag: 255, + slot: 23, + }, ); - assert_eq!( - store.trade.set_trade_golds.lock().unwrap().as_slice(), - &[(2, 500)] + 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.trade.accepted_trades.lock().unwrap().as_slice(), - &[1, 2] + store.phase(), + Phase::Open { + initiator: 1, + target: 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.trade.accepted_trades.lock().unwrap().as_slice(), &[1]); - assert_eq!( - store.trade.set_trade_golds.lock().unwrap().as_slice(), - &[(1, 9)] - ); - assert_eq!( - store.trade.unaccepted_trades.lock().unwrap().as_slice(), - &[1] +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()); } -/// **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. +/// 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.trade.busy_trades.lock().unwrap().as_slice(), &[2]); - assert_eq!(store.trade.ignore_trades.lock().unwrap().as_slice(), &[2]); +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 }); + } } -/// **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). +/// 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.trade.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 index 6c1e5b79e..2a9b90213 100644 --- a/gateway/src/world/trainer_tests.rs +++ b/gateway/src/world/trainer_tests.rs @@ -1,205 +1,366 @@ -//! Trainer and talent opcodes over an encrypted World Session. +//! 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; -#[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); +#[path = "family_harness.rs"] +pub(super) mod family_harness; +use family_harness::{drain_outbound, in_world_conn, npc_store_refusing_by}; + +/// 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() } - 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 - .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, +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()) } - .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}"), + + fn spell_modifiers(&self, _character_guid: u64) -> Vec<(u32, u8, i32, bool)> { + Vec::new() } - // The follow-up whose reply we DO expect, proving the buy emitted nothing further. - CMSG_GOSSIP_HELLO { - guid: Guid::new(70), + + fn characters(&self, _account_id: u64) -> Result> { + unimplemented!("characters") } - .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}"), + + fn create_character( + &self, + _account_id: u64, + _name: &str, + _race: u8, + _class: u8, + _gender: u8, + _appearance: codec::Appearance, + ) -> Result { + unimplemented!("create_character") } - 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.character.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, + fn delete_character( + &self, + _account_id: u64, + _character_guid: u64, + ) -> Result { + unimplemented!("delete_character") } - .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}"), + + fn character_by_guid(&self, _guid: u64) -> Result> { + unimplemented!("character_by_guid") } - 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}"), + + fn character_exists_on_any_world_shard(&self, _guid: u64) -> Result { + unimplemented!("character_exists_on_any_world_shard") } - 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}"), + + 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") } - 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, +impl TrainerStore for TrainerFake { + fn trainer_serves(&self, _player_guid: u64, _trainer_guid: u64) -> Result { + Ok(!self.refuses_class) } - .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}"), + + fn trainer_list( + &self, + _player_guid: u64, + _trainer_guid: u64, + ) -> Result> { + Ok(self.spells.clone()) } - 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}"), + + 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), + } } - // A follow-up whose reply we DO expect, proving the buy emitted nothing further. - CMSG_GOSSIP_HELLO { - guid: Guid::new(70), + + 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 } - .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}"), + + 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))) } - 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.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); +/// 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: 1234, + 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.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}"), +} + +fn pick_talent() -> CMSG_LEARN_TALENT { + CMSG_LEARN_TALENT { + talent: Talent::BurningSoul, + requested_rank: 0, } - drop(client); - server.join().unwrap(); +} + +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() { - use lyracore_shared::trainer::TrainerRefusal; let expected = |refusal| match refusal { TrainerRefusal::NotEnoughMoney => TrainingFailureReason::NotEnoughMoney, TrainerRefusal::LevelTooLow | TrainerRefusal::PreviousRankMissing => { @@ -210,230 +371,146 @@ fn every_module_refusal_has_one_client_failure_reason() { } }; for refusal in TrainerRefusal::ALL { - let mut s = quest_store(); - s.trainer.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(); + 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. +/// 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 = std::sync::Arc::new({ - let base = tester_store(7); - WorldFake { - session: SessionState { - login_entity: Some(warrior_entity()), - ..base.session - }, - trade_error: Some("gw_trainer_buy reducer timed out after 10s".into()), - ..base - } - }); - 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_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"); + 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!( - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).is_err(), - "the socket closes instead of claiming the purchase failed" + 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() { - // 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.trainer.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(); + 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() { - // 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.trainer.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" + 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) ); - drop(client); - server.join().unwrap(); } +/// 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() { - // 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.trainer.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}"), - } + 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!( - read_raw_frame(&mut client, &mut c_dec).0, - 0x00A9, - "the CHARACTER_POINTS1 VALUES push" + 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" ); - drop(client); - server.join().unwrap(); } #[test] fn trainer_list_replies_smsg_trainer_list_with_the_fixture_spells() { - let mut s = quest_store(); - s.trainer.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(); + 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 mut s = quest_store(); - s.trainer.trainer_refuses_class = true; - s.trainer.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(); + 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) + ); } From 0d44b6eaed313a197586868049a3b666f4711ba8 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 17:05:51 +0200 Subject: [PATCH 11/17] test(gateway): test guild, chat, channel and weather behaviour without the socket Guild, chat and channel opcode behaviour was already covered by the InMemory*Actions dispatcher tests. Delete the socket duplicates, add the one guild membership opcode test the dispatcher lacked, and test weather through zone_weather_message with a local Fake. Session-level guild, chat and cleanup tests stay on WorldFake. Remove the channel state and the Fake fields no test reads. Co-Authored-By: Claude Sonnet 5.5 --- gateway/src/world/channel_tests.rs | 63 --- gateway/src/world/chat_tests.rs | 227 +-------- gateway/src/world/guild_tests.rs | 478 +----------------- gateway/src/world/handlers/guild.rs | 70 +++ gateway/src/world/session_tests.rs | 1 - .../world/test_support/world_fake/channel.rs | 21 +- .../src/world/test_support/world_fake/chat.rs | 28 +- .../world/test_support/world_fake/guild.rs | 10 +- .../src/world/test_support/world_fake/mod.rs | 2 - .../world/test_support/world_fake/weather.rs | 6 - gateway/src/world/tests.rs | 15 +- gateway/src/world/weather_tests.rs | 108 ++-- 12 files changed, 148 insertions(+), 881 deletions(-) delete mode 100644 gateway/src/world/channel_tests.rs diff --git a/gateway/src/world/channel_tests.rs b/gateway/src/world/channel_tests.rs deleted file mode 100644 index fcaa8e43b..000000000 --- a/gateway/src/world/channel_tests.rs +++ /dev/null @@ -1,63 +0,0 @@ -//! Chat Channel opcodes over an encrypted World Session. - -use super::*; - -#[test] -fn join_channel_runs_the_channel_op_as_the_sessions_character() { - 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); - 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.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.chat.speaker_facts = Some(human_speaker()); - s.channel.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(); -} diff --git a/gateway/src/world/chat_tests.rs b/gateway/src/world/chat_tests.rs index 79b7dca31..41d8d298c 100644 --- a/gateway/src/world/chat_tests.rs +++ b/gateway/src/world/chat_tests.rs @@ -1,5 +1,6 @@ -//! Chat lines over an encrypted World Session: Speech, the GM dot-command, away status, Realm Chat -//! Lines and the Chat Flood Limiter. +//! 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::*; @@ -143,228 +144,6 @@ fn messagechat_dot_say_error_relays_a_system_chat_line_to_the_sender_only() { assert!(store.speech.chats.lock().unwrap().is_empty()); } -/// `quest_store` plus the session's own Character row, which the `sync` sentinel needs. -fn chat_session_store() -> WorldFake { - let base = quest_store(); - WorldFake { - characters: vec![codec::CharacterView { - guid: 1, - name: "Warrior".into(), - ..Default::default() - }], - ..base - } -} - -/// `/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.chat.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.chat.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.chat.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.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::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.chat.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.speech.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.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::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.chat.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"); -} - -#[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.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::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.chat.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.speech.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.chat.speaker_facts = Some(human_speaker()); - s.chat.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() -> WorldFake { let base = quest_store(); diff --git a/gateway/src/world/guild_tests.rs b/gateway/src/world/guild_tests.rs index 266f0e671..907143993 100644 --- a/gateway/src/world/guild_tests.rs +++ b/gateway/src/world/guild_tests.rs @@ -1,55 +1,9 @@ -//! Guild opcodes over an encrypted World Session, with sign-on at world entry and sign-off at -//! logout, plus the guild cleanup a Character delete triggers. +//! 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::*; -#[test] -fn guild_query_answers_at_character_select() { - let store = std::sync::Arc::new({ - let base = tester_store(7); - WorldFake { - guild: GuildState { - 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() - }], - ..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); - 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(); -} - /// Character 1 as a member of Guild 7 at rank 3. fn guild_member_store() -> WorldFake { let base = tester_store(7); @@ -114,117 +68,6 @@ fn a_member_enters_the_world_with_its_guild_on_the_self_create_and_signs_on() { assert!(recorded(&store).contains(&"guild_op:SignOn".to_string())); } -#[test] -fn every_membership_opcode_reaches_its_durable_request() { - let store = std::sync::Arc::new({ - 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() - }], - guilds: vec![codec::GuildView { - guild_id: 7, - name: "Tracer Guild".into(), - leader_guid: 1, - ..Default::default() - }], - ..base.guild - }, - characters: vec![ - codec::CharacterView { - guid: 1, - name: "Warrior".into(), - ..Default::default() - }, - codec::CharacterView { - guid: 2, - name: "Target".into(), - ..Default::default() - }, - ], - ..base - } - }); - 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()); @@ -271,153 +114,6 @@ fn world_entry_finishes_a_leftover_fee_hold_on_the_home_shard() { assert_eq!(*store.guild.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({ - let base = guild_member_store(); - WorldFake { - guild: GuildState { - 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() - }], - ..base.guild - }, - // 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() - }], - ..base - } - }); - 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({ @@ -664,171 +360,3 @@ fn char_delete_of_a_guild_leader_replies_failed_and_deletes_nothing() { drop(client); server.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({ - let base = tester_store(7); - WorldFake { - session: SessionState { - login_entity: Some(warrior_entity()), - ..base.session - }, - characters: vec![ - codec::CharacterView { - guid: 1, - name: "Warrior".into(), - ..Default::default() - }, - codec::CharacterView { - guid: 2, - name: "Target".into(), - ..Default::default() - }, - ], - guild: GuildState { - 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], - ..base.guild - }, - ..base - } - }); - 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:?}"); - } - 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/handlers/guild.rs b/gateway/src/world/handlers/guild.rs index fbb0e3393..4b50c38e7 100644 --- a/gateway/src/world/handlers/guild.rs +++ b/gateway/src/world/handlers/guild.rs @@ -3663,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/session_tests.rs b/gateway/src/world/session_tests.rs index d467d8987..6f682af53 100644 --- a/gateway/src/world/session_tests.rs +++ b/gateway/src/world/session_tests.rs @@ -640,7 +640,6 @@ fn player_login_emits_sequence_then_self_create() { intensity: 0.8, }, )], - ..base.weather }, ..base } diff --git a/gateway/src/world/test_support/world_fake/channel.rs b/gateway/src/world/test_support/world_fake/channel.rs index 3b6d8d97b..75b0a6b9f 100644 --- a/gateway/src/world/test_support/world_fake/channel.rs +++ b/gateway/src/world/test_support/world_fake/channel.rs @@ -1,26 +1,13 @@ use super::super::*; -#[derive(Default)] -pub(crate) struct ChannelState { - /// What `channel_op` answers. `None` succeeds. - pub(crate) channel_outcome: Option, - /// Recorded `channel_op` calls: `(actor_guid, op, request)`. - pub(crate) channel_ops: std::sync::Mutex>, -} - impl ChannelActionStore for WorldFake { fn channel_op( &self, - actor_guid: u64, - op: u8, - request: ChannelRequest, + _actor_guid: u64, + _op: u8, + _request: ChannelRequest, ) -> Result { - self.channel - .channel_ops - .lock() - .unwrap() - .push((actor_guid, op, request)); - Ok(self.channel.channel_outcome.unwrap_or(ChannelOutcome::Done)) + Ok(ChannelOutcome::Done) } fn channel_roster(&self, _team: u32, _channel_name: &str) -> Result> { diff --git a/gateway/src/world/test_support/world_fake/chat.rs b/gateway/src/world/test_support/world_fake/chat.rs index 20ab53216..c606cc30d 100644 --- a/gateway/src/world/test_support/world_fake/chat.rs +++ b/gateway/src/world/test_support/world_fake/chat.rs @@ -4,18 +4,10 @@ use super::super::*; pub(crate) struct ChatState { /// What `speaker_facts` answers for every speaker. `None` models a speaker with no live entity. pub(crate) speaker_facts: Option, - /// What `realm_chat` answers. `None` delivers. - pub(crate) realm_chat_outcome: 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 `set_away` requests: `(speaker_guid, kind, message)`. - pub(crate) away_requests: std::sync::Mutex>, - /// What `realm_whisper` answers. `None` delivers. - pub(crate) realm_whisper_outcome: Option, - /// When set, `realm_whisper` fails with this message. - pub(crate) realm_whisper_error: Option, /// Recorded `realm_whisper` requests, with the speaker guid the session authenticated. pub(crate) realm_whispers: std::sync::Mutex>, } @@ -31,18 +23,10 @@ impl ChatActionStore for WorldFake { .lock() .unwrap() .push((speaker_guid, request)); - Ok(self - .chat - .realm_chat_outcome - .unwrap_or(ChatOutcome::Delivered)) + Ok(ChatOutcome::Delivered) } - fn set_away(&self, speaker_guid: u64, kind: u8, message: String) -> Result<()> { - self.chat - .away_requests - .lock() - .unwrap() - .push((speaker_guid, kind, message)); + fn set_away(&self, _speaker_guid: u64, _kind: u8, _message: String) -> Result<()> { Ok(()) } @@ -63,13 +47,7 @@ impl ChatActionStore for WorldFake { .lock() .unwrap() .push((speaker_guid, request)); - if let Some(e) = &self.chat.realm_whisper_error { - return Err(anyhow!("{e}")); - } - Ok(self - .chat - .realm_whisper_outcome - .unwrap_or(ChatOutcome::Delivered)) + Ok(ChatOutcome::Delivered) } fn speaker_gm_level(&self, _speaker_guid: u64) -> Result { diff --git a/gateway/src/world/test_support/world_fake/guild.rs b/gateway/src/world/test_support/world_fake/guild.rs index 57a574991..fa8bdefb3 100644 --- a/gateway/src/world/test_support/world_fake/guild.rs +++ b/gateway/src/world/test_support/world_fake/guild.rs @@ -10,8 +10,6 @@ pub(crate) struct GuildState { pub(crate) guild_fee_hold: std::sync::Mutex>, /// Realm-core Petitions. pub(crate) guild_petitions: Vec, - /// Guild Charters the logged-in Character holds on this Home Shard. - pub(crate) held_charters: Vec, /// A Character whose guild lookup fails, as when one Realm-core read errors. pub(crate) guild_lookup_error_for: Option, } @@ -148,12 +146,11 @@ impl GuildActionStore for WorldFake { .any(|guild| guild.name.eq_ignore_ascii_case(name))) } - fn guild_holds_charter(&self, _actor_guid: u64, charter_item_guid: u64) -> Result { - Ok(self.guild.held_charters.contains(&charter_item_guid)) + 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<()> { - self.rec("guild_destroy_charter"); Ok(()) } @@ -187,7 +184,7 @@ impl GuildActionStore for WorldFake { } } -/// Realm-core refuses every fee: the socket tests only watch the steps run. +/// 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()) @@ -199,7 +196,6 @@ impl crate::world::guild_fee::GuildFeeStore for WorldFake { _request: crate::world::guild_fee::FeeRequest, ) -> Result> { - self.rec("guild_fee_hold"); Ok(Err(lyracore_shared::guild::GuildRefusal::NotEnoughMoney)) } diff --git a/gateway/src/world/test_support/world_fake/mod.rs b/gateway/src/world/test_support/world_fake/mod.rs index 3b31fad2e..426eed758 100644 --- a/gateway/src/world/test_support/world_fake/mod.rs +++ b/gateway/src/world/test_support/world_fake/mod.rs @@ -33,7 +33,6 @@ mod weather; pub(crate) use self::auction::*; pub(crate) use self::bank::*; pub(crate) use self::cast::*; -pub(crate) use self::channel::*; pub(crate) use self::character::*; pub(crate) use self::chat::*; pub(crate) use self::combat::*; @@ -91,7 +90,6 @@ pub(crate) struct WorldFake { pub(crate) melee: MeleeState, pub(crate) chat: ChatState, pub(crate) speech: SpeechState, - pub(crate) channel: ChannelState, pub(crate) guild: GuildState, pub(crate) auction: AuctionState, pub(crate) quest: QuestState, diff --git a/gateway/src/world/test_support/world_fake/weather.rs b/gateway/src/world/test_support/world_fake/weather.rs index f8e66ef07..f59887164 100644 --- a/gateway/src/world/test_support/world_fake/weather.rs +++ b/gateway/src/world/test_support/world_fake/weather.rs @@ -6,16 +6,10 @@ pub(crate) struct WeatherState { /// 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)>, - /// 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. - pub(crate) weather_error: Option, } impl WeatherStore for WorldFake { fn zone_weather(&self, zone_id: u32) -> Result> { - if let Some(e) = &self.weather.weather_error { - return Err(anyhow!("{e}")); - } Ok(self .weather .zone_weather diff --git a/gateway/src/world/tests.rs b/gateway/src/world/tests.rs index e6f1854ac..88446d41d 100644 --- a/gateway/src/world/tests.rs +++ b/gateway/src/world/tests.rs @@ -3,8 +3,8 @@ //! This module holds the imports those files share and the helpers more than one of them uses. use super::handlers::{ - AuctionInteraction, ChannelOutcome, ChatOutcome, LootWindowRefusal, RealmChatRequest, - SpeakerFacts, WhisperRequest, WhisperTargetFacts, + AuctionInteraction, ChatOutcome, LootWindowRefusal, SpeakerFacts, WhisperRequest, + WhisperTargetFacts, }; use super::party::PartyOutcome; use super::test_support::*; @@ -73,15 +73,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, @@ -127,8 +118,6 @@ mod auction_tests; mod bank_tests; #[path = "cast_tests.rs"] mod cast_tests; -#[path = "channel_tests.rs"] -mod channel_tests; #[path = "character_tests.rs"] mod character_tests; #[path = "chat_tests.rs"] diff --git a/gateway/src/world/weather_tests.rs b/gateway/src/world/weather_tests.rs index 13e325cbb..d2b9c1c1d 100644 --- a/gateway/src/world/weather_tests.rs +++ b/gateway/src/world/weather_tests.rs @@ -1,71 +1,83 @@ -//! Zone weather at world entry and on a live World Session. +//! Zone weather: the world-entry packet from a Store, and the live relay across World Sessions. use super::*; -/// 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.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 weather = None; - for message in drain_world_entry(&mut client, &mut c_dec) { - if let ServerOpcodeMessage::SMSG_WEATHER(m) = message { - weather = Some(*m); +/// 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}"), } - 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. +/// packet, because 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({ - let base = tester_store(7); - WorldFake { - session: SessionState { - login_entity: Some(warrior_entity()), - ..base.session - }, - ..base - } - })); +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: the -/// session completes world entry and the player lands under clear skies. +/// A Store that cannot answer the weather question is a degraded sky, never a failed login. #[test] -fn a_weather_read_failure_still_completes_world_entry() { - let weather = world_entry_weather(std::sync::Arc::new({ - let base = tester_store(7); - WorldFake { - session: SessionState { - login_entity: Some(warrior_entity()), - ..base.session - }, - weather: WeatherState { - weather_error: Some("shard cache unavailable".into()), - ..base.weather - }, - ..base - } - })); +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( From 99a26df896b22b7229afe504bb9c73482db2a0ef Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 17:06:32 +0200 Subject: [PATCH 12/17] test(gateway): drop imports the loot window and death conversion left unused Co-Authored-By: Claude Sonnet 5.5 --- gateway/src/world/test_support/mod.rs | 10 +++++----- gateway/src/world/tests.rs | 8 ++------ 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/gateway/src/world/test_support/mod.rs b/gateway/src/world/test_support/mod.rs index 5bebd9da2..5f52c5a38 100644 --- a/gateway/src/world/test_support/mod.rs +++ b/gateway/src/world/test_support/mod.rs @@ -12,11 +12,11 @@ pub(crate) use self::world_fake::*; 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, + 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::*; diff --git a/gateway/src/world/tests.rs b/gateway/src/world/tests.rs index e6f1854ac..b617490fc 100644 --- a/gateway/src/world/tests.rs +++ b/gateway/src/world/tests.rs @@ -3,8 +3,8 @@ //! This module holds the imports those files share and the helpers more than one of them uses. use super::handlers::{ - AuctionInteraction, ChannelOutcome, ChatOutcome, LootWindowRefusal, RealmChatRequest, - SpeakerFacts, WhisperRequest, WhisperTargetFacts, + AuctionInteraction, ChannelOutcome, ChatOutcome, RealmChatRequest, SpeakerFacts, + WhisperRequest, WhisperTargetFacts, }; use super::party::PartyOutcome; use super::test_support::*; @@ -55,7 +55,6 @@ use wow_world_messages::vanilla::{ CMSG_AUTOBANK_ITEM, CMSG_AUTOEQUIP_ITEM, CMSG_AUTOSTORE_BANK_ITEM, - CMSG_AUTOSTORE_LOOT_ITEM, CMSG_BANKER_ACTIVATE, CMSG_BUYBACK_ITEM, CMSG_BUY_BANK_SLOT, @@ -91,7 +90,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, @@ -102,9 +100,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, From 4b15c77a5ccc8ad5401c19b192f5021b83a0cf3d Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 17:08:33 +0200 Subject: [PATCH 13/17] test(gateway): share the vendor, auction, item, taxi, melee and cast Fakes with the family tests The handler test modules become reachable from the World Session tests. The vendor, taxi, melee and cast Fakes model state a test can read back (buyback ring, accepted route, engagements, auras, pending casts), so a test can assert an outcome and not a call log. Co-Authored-By: Claude Sonnet 5.5 --- gateway/src/world/handlers/auction.rs | 6 +-- gateway/src/world/handlers/cast/mod.rs | 26 +++++++++- gateway/src/world/handlers/item.rs | 24 ++++----- gateway/src/world/handlers/melee.rs | 36 +++++++++----- gateway/src/world/handlers/mod.rs | 12 +++++ gateway/src/world/handlers/taxi.rs | 40 ++++++++++----- gateway/src/world/handlers/vendor.rs | 69 ++++++++++++++------------ 7 files changed, 140 insertions(+), 73 deletions(-) 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/cast/mod.rs b/gateway/src/world/handlers/cast/mod.rs index 824f16fcd..64ffd9ff4 100644 --- a/gateway/src/world/handlers/cast/mod.rs +++ b/gateway/src/world/handlers/cast/mod.rs @@ -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/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/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 eddd6d3d7..ceedd81d9 100644 --- a/gateway/src/world/handlers/mod.rs +++ b/gateway/src/world/handlers/mod.rs @@ -42,8 +42,12 @@ pub(crate) use auction::{ }; #[cfg(test)] pub(crate) use auction::{AuctionHousePolicy, AuctionInteraction}; +#[cfg(test)] +pub(crate) use auction::tests::{store_with, InMemoryAuctionActions}; pub(crate) use bank::{handle_bank, BankStore}; pub(crate) use cast::{dispatch_cast, CastOutcome, CastPlayer, CastStore, CastTransition}; +#[cfg(test)] +pub(crate) use cast::tests::InMemoryCasts; pub(crate) use channel::{ dispatch_channel_action, ChannelActionOutcome, ChannelActionStore, ChannelOutcome, ChannelRequest, ChannelRoster, @@ -68,6 +72,8 @@ pub(crate) use guild::{ pub(crate) use item::{ dispatch_item_action, ItemActionOutcome, ItemActionPlayer, ItemActionResult, ItemActionStore, }; +#[cfg(test)] +pub(crate) use item::tests::InMemoryItemActions; pub(crate) use loot::{ dispatch_loot_window, handle_loot, LootActionStatus, LootWindowOutcome, LootWindowPlayer, LootWindowRefusal, LootWindowRequestStatus, LootWindowStore, OpenLootState, @@ -81,6 +87,8 @@ pub(crate) use melee::{ dispatch_melee_action, MeleeActionOutcome, MeleeActionPlayer, MeleeActionStore, }; #[cfg(test)] +pub(crate) use melee::tests::InMemoryMeleeActions; +#[cfg(test)] pub(crate) use member_stats::MemberSnapshot; pub(crate) use member_stats::{ dispatch_member_stats, member_stats_tick, MemberPresence, MemberStatsOutcome, @@ -95,11 +103,15 @@ pub(crate) use taxi::{ dispatch_taxi_action, queue_reply_then_arm, TaxiActionOutcome, TaxiActionPlayer, TaxiActionStore, }; +#[cfg(test)] +pub(crate) use taxi::tests::InMemoryTaxiActions; pub(crate) use trade::{handle_trade, TradeStore}; pub(crate) use trainer::{handle_trainer, TrainerBuyOutcome, TrainerStore}; pub(crate) use vendor::{ dispatch_vendor_action, VendorActionOutcome, VendorActionPlayer, VendorActionStore, }; +#[cfg(test)] +pub(crate) use vendor::tests::InMemoryVendorActions; pub(crate) use weather::{zone_weather_message, WeatherStore}; /// Open the bank window for `banker_guid`. Single chokepoint for `CMSG_BANKER_ACTIVATE` and the 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/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 { From c735ef4d4517f8bb9e5055af8eb8d1a8e6c37e65 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 17:08:33 +0200 Subject: [PATCH 14/17] test(gateway): run the vendor, auction, item, taxi, melee and cast tests at the handler Each family's behaviour tests call its dispatcher through a local run helper against the family Fake. Session-level tests stay on WorldFake: the buyback replay at world entry, the raw auction browse, the auction refusal followed by WHO, and the two taxi gossip tests. WorldFake loses the item and melee state and most of the vendor, taxi and cast state. Co-Authored-By: Claude Sonnet 5.5 --- gateway/src/world/auction_tests.rs | 109 ++++-- gateway/src/world/cast_tests.rs | 353 ++++++++++-------- gateway/src/world/item_tests.rs | 159 ++++---- gateway/src/world/melee_tests.rs | 192 +++++----- gateway/src/world/taxi_tests.rs | 216 ++++++----- gateway/src/world/test_support/mod.rs | 1 - .../src/world/test_support/world_fake/cast.rs | 48 +-- .../src/world/test_support/world_fake/item.rs | 31 +- .../world/test_support/world_fake/melee.rs | 20 +- .../src/world/test_support/world_fake/mod.rs | 4 - .../src/world/test_support/world_fake/taxi.rs | 67 +--- .../world/test_support/world_fake/vendor.rs | 41 +- gateway/src/world/vendor_tests.rs | 147 ++++---- 13 files changed, 671 insertions(+), 717 deletions(-) diff --git a/gateway/src/world/auction_tests.rs b/gateway/src/world/auction_tests.rs index b9a5c8f53..5323be5ba 100644 --- a/gateway/src/world/auction_tests.rs +++ b/gateway/src/world/auction_tests.rs @@ -1,84 +1,121 @@ -//! The auction house over an encrypted World Session. +//! 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::*; -#[test] -fn auction_house_round_trip_stays_typed_and_ordered_over_an_encrypted_session() { - let store = std::sync::Arc::new({ - let base = quest_store(); - WorldFake { - auction: AuctionState { - auction_interaction: Some(imported_auction_interaction()), - }, - ..base +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") } - }); - let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); + } +} + +/// 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); - MSG_AUCTION_HELLO_Client { auctioneer } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { + 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 first, got {other}"), + other => panic!("expected auction hello, got {other}"), } - CMSG_AUCTION_LIST_ITEMS { + let owner = 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_LIST_RESULT(result) => { + }; + 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 browse view second, got {other}"), + other => panic!("expected empty owner view, got {other}"), } - CMSG_AUCTION_LIST_OWNER_ITEMS { + let bidder = 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_OWNER_LIST_RESULT(result) => { + }; + 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 owner view third, got {other}"), + other => panic!("expected empty bidder view, got {other}"), } +} - CMSG_AUCTION_LIST_BIDDER_ITEMS { - auctioneer, +#[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_BIDDER_LIST_RESULT(result) => { + ServerOpcodeMessage::SMSG_AUCTION_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}"), + 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). + // 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 { diff --git a/gateway/src/world/cast_tests.rs b/gateway/src/world/cast_tests.rs index 7bec5f510..d069ff86e 100644 --- a/gateway/src/world/cast_tests.rs +++ b/gateway/src/world/cast_tests.rs @@ -1,18 +1,63 @@ -//! 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. +//! 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; -/// 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. +/// `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; -const OP_SPELL_START: u16 = 0x0131; +/// 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, + } + } +} -const OP_SPELL_GO: u16 = 0x0132; +/// 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 { @@ -27,186 +72,166 @@ fn unit_targets(guid: u64) -> SpellCastTargets { #[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)" + // 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 (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.cast.casts.lock().unwrap().as_slice(), &[(100, 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 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. + // 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 = 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)" + 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), + }, ); - // Nothing else may follow on the activation path (the old phantom GO fired the shoot - // animation instantly). - let mut probe = [0u8; 1]; + 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!( - client.read(&mut probe).map(|n| n == 0).unwrap_or(true), - "spell {spell}: no packet may follow the activation START" + caster.ranged_repeat, + "spell {spell}: the session arms the loop" ); - drop(client); - server.join().unwrap(); assert_eq!( - store.cast.ranged_attacks.lock().unwrap().as_slice(), - &[(88, spell)] - ); - assert!( - store.cast.casts.lock().unwrap().is_empty(), - "spell {spell} must not reach cast_spell" + 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_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.clone()).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.cast.casts.lock().unwrap().as_slice(), &[(100, 77)]); +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 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.melee.stop_attacks.lock().unwrap().as_slice(), &[1]); + // 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_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.cast.cancelled_auras.lock().unwrap().as_slice(), - &[5555] +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_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.cast.cancelled_casts.lock().unwrap().as_slice(), &[1]); +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/item_tests.rs b/gateway/src/world/item_tests.rs index 2adeac301..071847de9 100644 --- a/gateway/src/world/item_tests.rs +++ b/gateway/src/world/item_tests.rs @@ -1,115 +1,82 @@ -//! Item actions over an encrypted World Session. +//! Item actions through their dispatcher. +use super::handlers::InMemoryItemActions; use super::*; -#[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({ - let base = tester_store(7); - WorldFake { - session: SessionState { - login_entity: Some(warrior_entity()), - ..base.session - }, - trade_error: Some(ItemRefusal::CannotEquip.as_tag().into()), - ..base - } - }); - 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}"), +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: 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}"), + source_slot, } - 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.clone())); - let (mut c_enc, _c_dec) = client_handshake(&mut client, "TESTER", K); - - CMSG_AUTOEQUIP_ITEM { - source_bag: 255, - source_slot: 24, +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" + ); } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - drop(client); +} - server - .join() - .expect("an item action without a selected player must not panic") +#[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() { - // 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({ - let base = tester_store(7); - WorldFake { - session: SessionState { - login_entity: Some(warrior_entity()), - ..base.session - }, - trade_error: Some("equip_item reducer transport disconnected: channel closed".into()), - ..base - } - }); - 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_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"); + // 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")); - assert!( - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).is_err(), - "the socket closes instead of translating transport loss into gameplay feedback" - ); } diff --git a/gateway/src/world/melee_tests.rs b/gateway/src/world/melee_tests.rs index 13402766a..32f6faf87 100644 --- a/gateway/src/world/melee_tests.rs +++ b/gateway/src/world/melee_tests.rs @@ -1,115 +1,133 @@ -//! Melee attack opcodes over an encrypted World Session. +//! Melee attack opcodes through their dispatcher. +use super::handlers::InMemoryMeleeActions; use super::*; -#[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); +/// 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(90), + guid: Guid::new(target), } - .write_encrypted_client(&mut client, &mut c_enc) - .unwrap(); - match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_ATTACKSTART(a) => { +} + +#[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); } - other => panic!("expected SMSG_ATTACKSTART, got {other}"), + _ => panic!("expected one SMSG_ATTACKSTART"), } // 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) => { + 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); } - 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}"), + _ => panic!("expected one SMSG_ATTACKSTOP"), } - drop(client); - server.join().unwrap(); + // 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_keep_the_session_alive() { +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 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.clone()).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.melee.stop_attacks.lock().unwrap().as_slice(), - &[0], - "the stop still reaches the durable seam under the legacy zero actor" - ); + // 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: 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.melee.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.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); - 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"); + // 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:#}" ); - drop(client); } diff --git a/gateway/src/world/taxi_tests.rs b/gateway/src/world/taxi_tests.rs index 8fe2671a4..b80778753 100644 --- a/gateway/src/world/taxi_tests.rs +++ b/gateway/src/world/taxi_tests.rs @@ -1,55 +1,95 @@ -//! Taxi opcodes over an encrypted World Session. +//! 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() { - let mut s = quest_store(); - s.taxi.taxi_status = Some(codec::TaxiNodeStatusView { + // 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 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 { + let query = 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() { + }; + 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}"), } - assert_eq!( - *store.taxi.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.npc.gossip_opts = vec![opt(0, "Show me your flight routes.", gossip_option::TAXI)]; - s.taxi.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 { +fn direct_taxi_query_opens_the_taxi_map() { + let actions = taxi_map_actions(); + let query = 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() { + }; + match only_message(run(&actions, query)) { ServerOpcodeMessage::SMSG_SHOWTAXINODES(map) => { assert_eq!(map.guid.guid(), 90); assert_eq!(map.nearest_node, 255); @@ -58,6 +98,25 @@ fn direct_taxi_query_and_taxi_gossip_share_one_open_operation() { } 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 { @@ -67,98 +126,61 @@ fn direct_taxi_query_and_taxi_gossip_share_one_open_operation() { } .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}"), - } + let gossip = ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); + assert_eq!(gossip, direct); - assert_eq!( - *store.taxi.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.taxi_activation = codec::TaxiActivationResult { +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, }; - s.taxi.taxi_status = Some(codec::TaxiNodeStatusView { + *actions.status.lock().unwrap() = 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 - ), + match only_message(run(&actions, activate_90())) { + ServerOpcodeMessage::SMSG_ACTIVATETAXIREPLY(reply) => { + assert_eq!(reply.reply, ActivateTaxiReply::NotEnoughMoney); + } other => panic!("expected SMSG_ACTIVATETAXIREPLY, got {other}"), } - assert_eq!( - *store.taxi.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 { + let query = 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(), + only_message(run(&actions, query)), 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.taxi_activation = codec::TaxiActivationResult { +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, }; - 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 - ), + match only_message(run(&actions, activate_90())) { + ServerOpcodeMessage::SMSG_ACTIVATETAXIREPLY(reply) => { + assert_eq!(reply.reply, ActivateTaxiReply::Ok); + } other => panic!("expected SMSG_ACTIVATETAXIREPLY, got {other}"), } - assert_eq!( - *store.taxi.taxi_activation_inputs.lock().unwrap(), - vec![(1, 90, 255, 256)] - ); - - drop(client); - server.join().unwrap(); } #[test] diff --git a/gateway/src/world/test_support/mod.rs b/gateway/src/world/test_support/mod.rs index 5bebd9da2..1f4ef1b38 100644 --- a/gateway/src/world/test_support/mod.rs +++ b/gateway/src/world/test_support/mod.rs @@ -21,6 +21,5 @@ use super::handlers::{ use super::party::PartyOutcome; use super::*; use lyracore_shared::group::{GroupKind, GroupRefusal, RaidSlot}; -use lyracore_shared::item::ItemRefusal; use lyracore_shared::loot::LootRefusal; use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; diff --git a/gateway/src/world/test_support/world_fake/cast.rs b/gateway/src/world/test_support/world_fake/cast.rs index 01fbf55a9..c534505ab 100644 --- a/gateway/src/world/test_support/world_fake/cast.rs +++ b/gateway/src/world/test_support/world_fake/cast.rs @@ -5,22 +5,12 @@ 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, - /// Recorded `cast_spell` dispatches: (spell_id, target_guid) — pins target threading. - pub(crate) casts: std::sync::Mutex>, - /// Recorded `start_ranged_attack` dispatches: (target_guid, spell_id) — the Auto Shot intercept. - pub(crate) ranged_attacks: std::sync::Mutex>, - /// The caller's owned items, for the Auto Shot ammo block on the activation START. + /// The caller's owned items, read by world entry. pub(crate) player_items_fixture: Vec, - /// Recorded `cancel_aura` spell ids — CMSG_CANCEL_AURA. - pub(crate) cancelled_auras: std::sync::Mutex>, - /// Recorded `cancel_cast` self_guids — CMSG_CANCEL_CAST. - pub(crate) cancelled_casts: std::sync::Mutex>, } -/// 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. +/// 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, @@ -32,49 +22,35 @@ impl CastStore for WorldFake { Ok(()) } - fn cancel_aura(&self, _account_id: u64, _self_guid: u64, spell_id: u32) -> Result<()> { - self.cast.cancelled_auras.lock().unwrap().push(spell_id); + 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<()> { - self.cast.cancelled_casts.lock().unwrap().push(self_guid); + 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, + _spell_id: u32, + _target_guid: u64, ) -> Result<()> { - self.cast - .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, + _target_guid: u64, + _spell_id: u32, ) -> Result<()> { - self.cast - .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) + fn spell_is_ranged_auto_repeat(&self, _spell_id: u32) -> bool { + false } - /// 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) + None } fn spell_queues_next_swing(&self, _spell_id: u32) -> bool { false diff --git a/gateway/src/world/test_support/world_fake/item.rs b/gateway/src/world/test_support/world_fake/item.rs index 83056da6a..d66290ab4 100644 --- a/gateway/src/world/test_support/world_fake/item.rs +++ b/gateway/src/world/test_support/world_fake/item.rs @@ -1,11 +1,6 @@ use super::super::*; -#[derive(Default)] -pub(crate) struct ItemState { - /// Recorded `use_item` slots. - pub(crate) used_items: std::sync::Mutex>, -} - +/// Item behaviour is tested through `InMemoryItemActions`; every verb here succeeds. impl ItemActionStore for WorldFake { fn equip_item( &self, @@ -13,7 +8,7 @@ impl ItemActionStore for WorldFake { _self_guid: u64, _from_slot: u8, ) -> Result { - self.canned_item_action() + Ok(ItemActionResult::Done) } fn unequip_item( @@ -22,7 +17,7 @@ impl ItemActionStore for WorldFake { _self_guid: u64, _from_slot: u8, ) -> Result { - self.canned_item_action() + Ok(ItemActionResult::Done) } fn move_item( @@ -32,24 +27,10 @@ impl ItemActionStore for WorldFake { _from_slot: u8, _to_slot: u8, ) -> Result { - self.canned_item_action() + Ok(ItemActionResult::Done) } - fn use_item(&self, _account_id: u64, _self_guid: u64, slot: u8) -> Result { - self.item.used_items.lock().unwrap().push(slot); - self.canned_item_action() - } -} - -impl WorldFake { - /// 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. - pub(crate) 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}")), - } + 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/melee.rs b/gateway/src/world/test_support/world_fake/melee.rs index 088a82734..72d8dc1db 100644 --- a/gateway/src/world/test_support/world_fake/melee.rs +++ b/gateway/src/world/test_support/world_fake/melee.rs @@ -1,25 +1,13 @@ use super::super::*; -#[derive(Default)] -pub(crate) struct MeleeState { - /// 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. - pub(crate) start_attack_error: Option, - /// Recorded `stop_attack` dispatches: the actor guid. The Auto Shot teardowns reach this - /// through `WorldStore`'s `MeleeActionStore` supertrait, so it pins that resolution. - pub(crate) stop_attacks: std::sync::Mutex>, -} - +/// 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"); - match &self.melee.start_attack_error { - Some(e) => Err(anyhow!("{e}")), - None => Ok(()), - } + Ok(()) } - fn stop_attack(&self, _account_id: u64, self_guid: u64) -> Result<()> { - self.melee.stop_attacks.lock().unwrap().push(self_guid); + fn stop_attack(&self, _account_id: u64, _self_guid: u64) -> Result<()> { Ok(()) } } diff --git a/gateway/src/world/test_support/world_fake/mod.rs b/gateway/src/world/test_support/world_fake/mod.rs index 3b31fad2e..ca6082388 100644 --- a/gateway/src/world/test_support/world_fake/mod.rs +++ b/gateway/src/world/test_support/world_fake/mod.rs @@ -39,11 +39,9 @@ pub(crate) use self::chat::*; pub(crate) use self::combat::*; pub(crate) use self::death::*; pub(crate) use self::guild::*; -pub(crate) use self::item::*; pub(crate) use self::loot_roll::*; pub(crate) use self::loot_window::*; pub(crate) use self::mail::*; -pub(crate) use self::melee::*; pub(crate) use self::member_stats::*; pub(crate) use self::npc::*; pub(crate) use self::party::*; @@ -88,7 +86,6 @@ pub(crate) struct WorldFake { pub(crate) trade: TradeState, pub(crate) cast: CastState, pub(crate) taxi: TaxiState, - pub(crate) melee: MeleeState, pub(crate) chat: ChatState, pub(crate) speech: SpeechState, pub(crate) channel: ChannelState, @@ -96,7 +93,6 @@ pub(crate) struct WorldFake { pub(crate) auction: AuctionState, pub(crate) quest: QuestState, pub(crate) vendor: VendorState, - pub(crate) item: ItemState, pub(crate) weather: WeatherState, pub(crate) member_stats: MemberStatsState, pub(crate) loot_window: LootWindowState, diff --git a/gateway/src/world/test_support/world_fake/taxi.rs b/gateway/src/world/test_support/world_fake/taxi.rs index 8f225d7b7..d86038f08 100644 --- a/gateway/src/world/test_support/world_fake/taxi.rs +++ b/gateway/src/world/test_support/world_fake/taxi.rs @@ -2,41 +2,27 @@ use super::super::*; #[derive(Default)] pub(crate) struct TaxiState { - /// Taxi seam fixtures and operation log. Both direct query opcodes and TAXI gossip must append - /// the same `open` operation here. - pub(crate) taxi_status: Option, + /// The taxi map the open operation returns, for the TAXI gossip socket tests. pub(crate) taxi_map: Option, - pub(crate) taxi_activation: codec::TaxiActivationResult, - pub(crate) taxi_activation_inputs: std::sync::Mutex>, pub(crate) taxi_error: Option, - pub(crate) taxi_calls: std::sync::Mutex>, } -/// Taxi behavior has focused handler tests. The broad encrypted-session store opts out unless a -/// socket test explicitly needs a taxi reply. +/// 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, + _character_guid: u64, + _npc_guid: u64, ) -> Result> { - self.taxi - .taxi_calls - .lock() - .unwrap() - .push(("status", character_guid, npc_guid)); - if let Some(error) = &self.taxi.taxi_error { - return Err(anyhow!("{error}")); - } - Ok(self.taxi.taxi_status) + Ok(None) } - fn open_taxi(&self, character_guid: u64, npc_guid: u64) -> Result> { - self.taxi - .taxi_calls - .lock() - .unwrap() - .push(("open", character_guid, npc_guid)); + fn open_taxi( + &self, + _character_guid: u64, + _npc_guid: u64, + ) -> Result> { if let Some(error) = &self.taxi.taxi_error { return Err(anyhow!("{error}")); } @@ -45,34 +31,15 @@ impl TaxiActionStore for WorldFake { fn activate_taxi( &self, - character_guid: u64, - npc_guid: u64, - source_client_node_id: u32, - destination_client_node_id: u32, + _character_guid: u64, + _npc_guid: u64, + _source_client_node_id: u32, + _destination_client_node_id: u32, ) -> Result { - self.taxi - .taxi_calls - .lock() - .unwrap() - .push(("activate", character_guid, npc_guid)); - self.taxi.taxi_activation_inputs.lock().unwrap().push(( - character_guid, - npc_guid, - source_client_node_id, - destination_client_node_id, - )); - if let Some(error) = &self.taxi.taxi_error { - return Err(anyhow!("{error}")); - } - Ok(self.taxi.taxi_activation) + Ok(codec::TaxiActivationResult::default()) } - fn arm_taxi_flight(&self, character_guid: u64) -> Result<()> { - self.taxi - .taxi_calls - .lock() - .unwrap() - .push(("arm", character_guid, 0)); + fn arm_taxi_flight(&self, _character_guid: u64) -> Result<()> { Ok(()) } } diff --git a/gateway/src/world/test_support/world_fake/vendor.rs b/gateway/src/world/test_support/world_fake/vendor.rs index 3f798de3f..11135f580 100644 --- a/gateway/src/world/test_support/world_fake/vendor.rs +++ b/gateway/src/world/test_support/world_fake/vendor.rs @@ -7,12 +7,10 @@ pub(crate) struct VendorState { /// 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)>, - /// Item-instance guid → bag slot, for the vendor repair target. - pub(crate) item_slots: Vec<(u64, u8)>, - /// Recorded `vendor_buyback` calls: (vendor_guid, slot) — pins the 69→0 slot mapping. - pub(crate) bought_back: std::sync::Mutex>, } +/// 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()) @@ -30,10 +28,7 @@ impl VendorActionStore for WorldFake { _item_entry: u32, _count: u32, ) -> Result<()> { - match &self.trade_error { - Some(e) => Err(anyhow!("{e}")), - None => Ok(()), - } + Ok(()) } fn buyback_slots(&self, _player_guid: u64) -> Vec<(u32, u32, u32, u32)> { @@ -44,12 +39,8 @@ impl VendorActionStore for WorldFake { [0; 3] } - fn vendor_item_slot(&self, item_guid: u64) -> Option { - self.vendor - .item_slots - .iter() - .find(|(g, _)| *g == item_guid) - .map(|&(_, s)| s) + fn vendor_item_slot(&self, _item_guid: u64) -> Option { + None } fn vendor_repair( @@ -59,10 +50,7 @@ impl VendorActionStore for WorldFake { _npc_guid: u64, _slot: u8, ) -> Result<()> { - match &self.trade_error { - Some(e) => Err(anyhow!("{e}")), - None => Ok(()), - } + Ok(()) } fn vendor_sell( @@ -72,27 +60,16 @@ impl VendorActionStore for WorldFake { _vendor_guid: u64, _slot: u8, ) -> Result<()> { - match &self.trade_error { - Some(e) => Err(anyhow!("{e}")), - None => Ok(()), - } + Ok(()) } fn vendor_buyback( &self, _account_id: u64, _self_guid: u64, - vendor_guid: u64, - slot: u8, + _vendor_guid: u64, + _slot: u8, ) -> Result<()> { - if let Some(e) = &self.trade_error { - return Err(anyhow!("{e}")); - } - self.vendor - .bought_back - .lock() - .unwrap() - .push((vendor_guid, slot)); Ok(()) } } diff --git a/gateway/src/world/vendor_tests.rs b/gateway/src/world/vendor_tests.rs index 5cfdfe3a5..a83f741e9 100644 --- a/gateway/src/world/vendor_tests.rs +++ b/gateway/src/world/vendor_tests.rs @@ -1,33 +1,42 @@ -//! Vendor opcodes over an encrypted World Session, and the buyback ring at login. +//! 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 store = std::sync::Arc::new({ - let base = tester_store(7); - WorldFake { - session: SessionState { - login_entity: Some(warrior_entity()), - ..base.session - }, - trade_error: Some("not enough money to buy that item".into()), - ..base - } - }); - 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) => { + 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!( @@ -35,17 +44,16 @@ fn buy_item_err_sends_smsg_buy_failed() { "BuyResult maps to NotEnoughMoney" ); } - other => panic!("expected SMSG_BUY_FAILED, got {other}"), + other => panic!("expected one SMSG_BUY_FAILED, got {} packets", other.len()), } - 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.) + // 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 { @@ -73,56 +81,49 @@ fn login_replays_a_persisted_buyback_ring_after_the_login_sequence() { #[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, + // 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, + }, + ); } - .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.vendor.bought_back.lock().unwrap().as_slice(), - &[(99, 0), (99, 12)] - ); + assert_eq!(actions.ring.lock().unwrap().as_slice(), &ring[1..12]); } #[test] -fn list_inventory_opens_the_vendor_window_over_the_socket() { - let mut s = quest_store(); - s.vendor.vendor_stock = vec![codec::VendorItemView { - item_entry: 4540, - display_id: 6353, - buy_price: 25, +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 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), + }; + 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()), } - .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(); } From 4e5d641b35ab3137f2b822b1c544a7dfd05a9f77 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 17:11:16 +0200 Subject: [PATCH 15/17] docs(gateway): name the Protocol Family and share the family test harness Co-Authored-By: Claude Opus 5.5 (1M context) --- CONTEXT.md | 6 +++++- CORE_TERMS.md | 4 ++++ docs/architecture.md | 2 +- gateway/src/stdb/store/character.rs | 5 +++-- gateway/src/world/bank_tests.rs | 1 - gateway/src/world/combat_tests.rs | 1 - .../src/world/{family_harness.rs => test_support/family.rs} | 2 +- gateway/src/world/test_support/mod.rs | 2 ++ gateway/src/world/trade_tests.rs | 1 - gateway/src/world/trainer_tests.rs | 4 ---- 10 files changed, 16 insertions(+), 12 deletions(-) rename gateway/src/world/{family_harness.rs => test_support/family.rs} (99%) diff --git a/CONTEXT.md b/CONTEXT.md index f9413ad22..3aeb0a109 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -33,7 +33,7 @@ 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) @@ -1228,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 550543f7c..e83e2bb31 100644 --- a/CORE_TERMS.md +++ b/CORE_TERMS.md @@ -119,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/gateway/src/stdb/store/character.rs b/gateway/src/stdb/store/character.rs index 3c5441805..2621d4d90 100644 --- a/gateway/src/stdb/store/character.rs +++ b/gateway/src/stdb/store/character.rs @@ -80,11 +80,12 @@ impl CharacterStore for Coordinator { /// 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 + /// `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/tests.rs`). + /// `a_freshly_created_characters_first_login_transfers_off_the_default_shard` + /// (`world/shard_routing_tests.rs`). fn create_character( &self, account_id: u64, diff --git a/gateway/src/world/bank_tests.rs b/gateway/src/world/bank_tests.rs index 4a3df3925..a1fec55fb 100644 --- a/gateway/src/world/bank_tests.rs +++ b/gateway/src/world/bank_tests.rs @@ -2,7 +2,6 @@ //! the handler is bounded on. use super::handlers::{handle_bank, BankStore, NpcStore}; -use super::trainer_tests::family_harness::{drain_outbound, in_world_conn, npc_store_refusing_by}; use super::*; use std::collections::BTreeSet; use std::sync::Mutex; diff --git a/gateway/src/world/combat_tests.rs b/gateway/src/world/combat_tests.rs index 855ec32af..90b930314 100644 --- a/gateway/src/world/combat_tests.rs +++ b/gateway/src/world/combat_tests.rs @@ -2,7 +2,6 @@ //! Store the handler is bounded on. use super::handlers::{handle_combat, CombatStore}; -use super::trainer_tests::family_harness::in_world_conn; use super::*; use std::collections::BTreeMap; use std::sync::Mutex; diff --git a/gateway/src/world/family_harness.rs b/gateway/src/world/test_support/family.rs similarity index 99% rename from gateway/src/world/family_harness.rs rename to gateway/src/world/test_support/family.rs index 0150e6233..62d39d5e8 100644 --- a/gateway/src/world/family_harness.rs +++ b/gateway/src/world/test_support/family.rs @@ -1,7 +1,7 @@ //! Pieces the handler-level family tests share: a session connection to run a handler against, and //! a drain for what the handler sent. A family test owns its Fake and its `run` helper. -use super::super::*; +use super::*; use std::sync::mpsc::Receiver; /// A connection for `account_id` in the world as `self_guid`. The session's routed Store is diff --git a/gateway/src/world/test_support/mod.rs b/gateway/src/world/test_support/mod.rs index de92587dc..9deb32354 100644 --- a/gateway/src/world/test_support/mod.rs +++ b/gateway/src/world/test_support/mod.rs @@ -2,10 +2,12 @@ //! 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::*; diff --git a/gateway/src/world/trade_tests.rs b/gateway/src/world/trade_tests.rs index 920b98324..d923639a6 100644 --- a/gateway/src/world/trade_tests.rs +++ b/gateway/src/world/trade_tests.rs @@ -3,7 +3,6 @@ //! relay, so the handler sends nothing and a test reads the Fake's Trade Session instead. use super::handlers::{handle_trade, TradeStore}; -use super::trainer_tests::family_harness::in_world_conn; use super::*; use std::collections::{BTreeMap, BTreeSet}; use std::sync::Mutex; diff --git a/gateway/src/world/trainer_tests.rs b/gateway/src/world/trainer_tests.rs index 2a9b90213..653ec22a5 100644 --- a/gateway/src/world/trainer_tests.rs +++ b/gateway/src/world/trainer_tests.rs @@ -8,10 +8,6 @@ use lyracore_shared::constants::armor_proficiency::{ }; use lyracore_shared::trainer::TrainerRefusal; -#[path = "family_harness.rs"] -pub(super) mod family_harness; -use family_harness::{drain_outbound, in_world_conn, npc_store_refusing_by}; - /// What the Module would answer a trainer window, a buy and a talent pick, set per test. #[derive(Default)] struct TrainerFake { From efff177a36c489acaf8adcf710753d0739660787 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 17:11:30 +0200 Subject: [PATCH 16/17] style(gateway): sort the test re-exports Co-Authored-By: Claude Opus 5.5 (1M context) --- gateway/src/world/handlers/mod.rs | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/gateway/src/world/handlers/mod.rs b/gateway/src/world/handlers/mod.rs index ceedd81d9..2c18cfb6a 100644 --- a/gateway/src/world/handlers/mod.rs +++ b/gateway/src/world/handlers/mod.rs @@ -34,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, @@ -42,12 +44,10 @@ pub(crate) use auction::{ }; #[cfg(test)] pub(crate) use auction::{AuctionHousePolicy, AuctionInteraction}; -#[cfg(test)] -pub(crate) use auction::tests::{store_with, InMemoryAuctionActions}; pub(crate) use bank::{handle_bank, BankStore}; -pub(crate) use cast::{dispatch_cast, CastOutcome, CastPlayer, CastStore, CastTransition}; #[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, ChannelRequest, ChannelRoster, @@ -69,11 +69,11 @@ 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, }; -#[cfg(test)] -pub(crate) use item::tests::InMemoryItemActions; pub(crate) use loot::{ dispatch_loot_window, handle_loot, LootActionStatus, LootWindowOutcome, LootWindowPlayer, LootWindowRefusal, LootWindowRequestStatus, LootWindowStore, OpenLootState, @@ -83,12 +83,12 @@ 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, }; #[cfg(test)] -pub(crate) use melee::tests::InMemoryMeleeActions; -#[cfg(test)] pub(crate) use member_stats::MemberSnapshot; pub(crate) use member_stats::{ dispatch_member_stats, member_stats_tick, MemberPresence, MemberStatsOutcome, @@ -99,19 +99,19 @@ 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, }; -#[cfg(test)] -pub(crate) use taxi::tests::InMemoryTaxiActions; 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, }; -#[cfg(test)] -pub(crate) use vendor::tests::InMemoryVendorActions; pub(crate) use weather::{zone_weather_message, WeatherStore}; /// Open the bank window for `banker_guid`. Single chokepoint for `CMSG_BANKER_ACTIVATE` and the From 22c97b03138b564fa4a75be034950e5f82bfeb2c Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 17:24:05 +0200 Subject: [PATCH 17/17] test(gateway): cover GameObject use routing for chests and other types over a World Session The World Fake now records use_gameobject and take_loot calls. Co-Authored-By: Claude Sonnet 5.5 --- gateway/src/world/loot_window_tests.rs | 69 +++++++++++++++++++ .../test_support/world_fake/loot_window.rs | 20 +++++- 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/gateway/src/world/loot_window_tests.rs b/gateway/src/world/loot_window_tests.rs index 7d371039a..01b329960 100644 --- a/gateway/src/world/loot_window_tests.rs +++ b/gateway/src/world/loot_window_tests.rs @@ -3,6 +3,7 @@ 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() { @@ -42,6 +43,74 @@ fn questgiver_gameobject_bypasses_the_chest_lifecycle() { 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)]); diff --git a/gateway/src/world/test_support/world_fake/loot_window.rs b/gateway/src/world/test_support/world_fake/loot_window.rs index 1e1fbe99c..0d307c69d 100644 --- a/gateway/src/world/test_support/world_fake/loot_window.rs +++ b/gateway/src/world/test_support/world_fake/loot_window.rs @@ -6,6 +6,10 @@ pub(crate) struct LootWindowState { 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>, @@ -33,8 +37,13 @@ impl LootWindowStore for WorldFake { &self, _account_id: u64, _actor_guid: u64, - _target_guid: u64, + target_guid: u64, ) -> Result { + self.loot_window + .gameobjects_used + .lock() + .unwrap() + .push(target_guid); Ok(LootWindowRequestStatus::Applied) } @@ -74,9 +83,14 @@ impl LootWindowStore for WorldFake { &self, _account_id: u64, _actor_guid: u64, - _target_guid: u64, - _loot_slot: u8, + target_guid: u64, + loot_slot: u8, ) -> Result { + self.loot_window + .items_taken + .lock() + .unwrap() + .push((target_guid, loot_slot)); Ok(LootWindowRequestStatus::Applied) } }