diff --git a/gateway/src/codec/auction/subscriptions.rs b/gateway/src/codec/auction/subscriptions.rs index 37c5df9d..f15385a9 100644 --- a/gateway/src/codec/auction/subscriptions.rs +++ b/gateway/src/codec/auction/subscriptions.rs @@ -93,3 +93,6 @@ crate::stdb::relays::declare_relays! { insert(Live) => |context, _event, row| auction_notice_appeared(&context.view, row); } } + +#[cfg(test)] +mod packet_tests; diff --git a/gateway/src/codec/auction/subscriptions/packet_tests.rs b/gateway/src/codec/auction/subscriptions/packet_tests.rs new file mode 100644 index 00000000..da6d068f --- /dev/null +++ b/gateway/src/codec/auction/subscriptions/packet_tests.rs @@ -0,0 +1,91 @@ +use super::*; +use crate::stdb::bindings::AuctionNotice; +use crate::world::Outbound; +use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; + +/// Auction Notice kind decoding (`cm:AuctionHouseHandler.cpp`/`AuctionHouseMgr.cpp`): Outbid +/// and Won go out on `SMSG_AUCTION_BIDDER_NOTIFICATION`, Sold, Expired and New bid go out on +/// `SMSG_AUCTION_OWNER_NOTIFICATION`, Removed goes out on `SMSG_AUCTION_REMOVED_NOTIFICATION`, +/// and an unrecognized kind or an unimported house both drop the packet. The Auction Mail the +/// same transaction wrote still reaches the inbox. +#[test] +fn auction_notice_kinds_decode_to_their_own_packet_and_an_unknown_one_drops() { + use lyracore_shared::auction::auction_notice::{EXPIRED, NEW_BID, OUTBID, REMOVED, SOLD, WON}; + use wow_world_messages::vanilla::AuctionHouse; + + let notice = |kind: u8, house: u32| AuctionNotice { + id: 1, + recipient_guid: 7, + kind, + house, + auction_id: 41, + item_entry: 25, + random_property_id: 117, + bid: 500, + out_bid: 25, + bidder_guid: 9, + created_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, + }; + + for kind in [OUTBID, WON] { + let out = auction_notice_outbound(¬ice(kind, 1)); + assert_eq!( + out.len(), + 1, + "kind {kind} must decode to exactly one packet" + ); + match &out[0] { + Outbound::One(ServerOpcodeMessage::SMSG_AUCTION_BIDDER_NOTIFICATION(packet)) => { + assert_eq!(packet.auction_house, AuctionHouse::Stormwind); + assert_eq!(packet.bidder.guid(), 9); + } + Outbound::One(other) => { + panic!("kind {kind}: expected SMSG_AUCTION_BIDDER_NOTIFICATION, got {other}") + } + _ => { + panic!("kind {kind}: expected a single SMSG_AUCTION_BIDDER_NOTIFICATION packet") + } + } + } + + for kind in [SOLD, EXPIRED, NEW_BID] { + let out = auction_notice_outbound(¬ice(kind, 1)); + assert_eq!( + out.len(), + 1, + "kind {kind} must decode to exactly one packet" + ); + match &out[0] { + Outbound::One(ServerOpcodeMessage::SMSG_AUCTION_OWNER_NOTIFICATION(packet)) => { + assert_eq!(packet.auction_id, 41); + } + Outbound::One(other) => { + panic!("kind {kind}: expected SMSG_AUCTION_OWNER_NOTIFICATION, got {other}") + } + _ => { + panic!("kind {kind}: expected a single SMSG_AUCTION_OWNER_NOTIFICATION packet") + } + } + } + + let out = auction_notice_outbound(¬ice(REMOVED, 1)); + assert!( + matches!( + out.as_slice(), + [Outbound::One(ServerOpcodeMessage::SMSG_AUCTION_REMOVED_NOTIFICATION(packet))] + if packet.item == 41 + && packet.item_template == 25 + && packet.random_property_id == 117 + ), + "Removed goes out on SMSG_AUCTION_REMOVED_NOTIFICATION" + ); + + assert!( + auction_notice_outbound(¬ice(200, 1)).is_empty(), + "an unknown kind must drop, not guess a packet" + ); + assert!( + auction_notice_outbound(¬ice(OUTBID, 99)).is_empty(), + "a house outside 1-7 must drop rather than send an unresolved house" + ); +} diff --git a/gateway/src/codec/channel/subscriptions.rs b/gateway/src/codec/channel/subscriptions.rs index 2f4bd931..fea423cf 100644 --- a/gateway/src/codec/channel/subscriptions.rs +++ b/gateway/src/codec/channel/subscriptions.rs @@ -51,3 +51,6 @@ crate::stdb::relays::declare_relays! { insert(Live) => |context, _event, row| channel_notice_appeared(&context.view, row); } } + +#[cfg(test)] +mod audience_tests; diff --git a/gateway/src/codec/channel/subscriptions/audience_tests.rs b/gateway/src/codec/channel/subscriptions/audience_tests.rs new file mode 100644 index 00000000..8153357e --- /dev/null +++ b/gateway/src/codec/channel/subscriptions/audience_tests.rs @@ -0,0 +1,62 @@ +use super::*; +use crate::codec::social::subscriptions::contact_changed; +use crate::stdb::world_view::test_support::{ + contact_entry as ignore_row, registered_viewer as listener, +}; +use std::sync::mpsc::Receiver; + +const SPEAKER: u64 = 10; + +/// Every raw packet queued for one viewer, jobs run in order, as `(opcode, body)`. +fn received_raw(rx: &Receiver) -> Vec<(u16, Vec)> { + let mut packets = Vec::new(); + while let Ok(outbound) = rx.try_recv() { + let Outbound::Job(job) = outbound else { + panic!("the Relay must enqueue packet work as a writer job"); + }; + for packet in job() { + let Outbound::Raw { opcode, body } = packet else { + panic!("a Channel Notice is one raw packet"); + }; + packets.push((opcode, body)); + } + } + packets +} + +/// JOINED naming `SPEAKER` in "Tx": notice 0x00, the name, then the guid (cm:Channel.cpp:760-764). +#[test] +fn a_channel_notice_reaches_each_recipient_on_any_shard_and_nobody_else() { + let view = WorldView::new(true); + let (_, first_rx) = listener(&view, 0, 20); + let (_, second_rx) = listener(&view, 1, 30); + let (_, bystander_rx) = listener(&view, 0, 40); + contact_changed(&view, 0, &ignore_row(20, SPEAKER, true), true); + + channel_notice_appeared( + &view, + &ChatChannelNoticeEvent { + id: 1, + notice: 0x00, + channel_name: "Tx".to_string(), + subject_guid: SPEAKER, + actor_guid: 0, + old_flags: 0, + new_flags: 0, + channel_flags: 0, + text: String::new(), + recipients: vec![20, 30, 99], + created_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, + }, + ); + + let mut joined = vec![0x00, b'T', b'x', 0]; + joined.extend(SPEAKER.to_le_bytes()); + assert_eq!( + received_raw(&first_rx), + [(0x0099, joined.clone())], + "notices skip the ignore list" + ); + assert_eq!(received_raw(&second_rx), [(0x0099, joined)]); + assert!(received_raw(&bystander_rx).is_empty()); +} diff --git a/gateway/src/codec/combat/subscriptions.rs b/gateway/src/codec/combat/subscriptions.rs index 279d4c3f..df634722 100644 --- a/gateway/src/codec/combat/subscriptions.rs +++ b/gateway/src/codec/combat/subscriptions.rs @@ -1353,3 +1353,6 @@ pub(crate) mod tests { assert_eq!(*packet, codec::build_force_run_speed(7, 0.7)); } } + +#[cfg(test)] +mod packet_tests; diff --git a/gateway/src/codec/combat/subscriptions/packet_tests.rs b/gateway/src/codec/combat/subscriptions/packet_tests.rs new file mode 100644 index 00000000..1d8aa044 --- /dev/null +++ b/gateway/src/codec/combat/subscriptions/packet_tests.rs @@ -0,0 +1,392 @@ +use super::*; +use crate::stdb::bindings::{CombatEvent, SpellCastEvent}; +use crate::world::Outbound; +use std::collections::HashSet; +use std::sync::Mutex; +use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; + +fn swing_event() -> CombatEvent { + CombatEvent { + id: 1, + attacker_guid: 7, + target_guid: 11, + damage: 23, + hit_info: 0, + killing_blow: false, + created_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, + blocked_amount: 0, + ranged_spell_id: 0, + ammo_display_id: 0, + spell_swing: false, + impact_delay_ms: 0, + map_id: 0, + instance_id: 0, + grid_x: 0, + grid_y: 0, + } +} + +#[test] +fn ranged_launch_relays_one_spell_go_for_a_hit_or_miss() { + let created = Mutex::new(HashSet::from([7])); + for (hit_info, hits, misses) in [(0, 1, 0), (2, 0, 1)] { + let row = CombatEvent { + damage: 0, + hit_info, + ranged_spell_id: 75, + ammo_display_id: 5996, + impact_delay_ms: 500, + ..swing_event() + }; + let out = combat_event_outbound(&created, &row); + let [Outbound::One(ServerOpcodeMessage::SMSG_SPELL_GO(go))] = out.as_slice() else { + panic!("a ranged launch must emit exactly one SPELL_GO"); + }; + assert_eq!((go.caster.guid(), go.spell), (7, 75)); + assert_eq!((go.hits.len(), go.misses.len()), (hits, misses)); + assert!(combat_event_outbound(&Mutex::new(HashSet::new()), &row).is_empty()); + } +} + +#[test] +fn ranged_impact_relays_only_its_authoritative_damage() { + let row = SpellCastEvent { + id: 1, + caster_guid: 7, + spell_id: 75, + created_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, + target_guid: 11, + cast_time_ms: 0, + is_completion: false, + damage: 23, + school: 0, + is_crit: false, + resisted: 0, + absorbed: 0, + is_interrupted: false, + cooldown_ms: 0, + delay_ms: 0, + healed: 0, + is_proc_log: true, + swing_hit_info: 0, + client_initiated: false, + map_id: 0, + instance_id: 0, + grid_x: 0, + grid_y: 0, + failure_reason: 0, + kind: 5, + }; + let out = cast_event_outbound(7, &row); + let [Outbound::One(ServerOpcodeMessage::SMSG_SPELLNONMELEEDAMAGELOG(log))] = out.as_slice() + else { + panic!("an impact must emit exactly one damage log"); + }; + assert_eq!( + ( + log.attacker.guid(), + log.target.guid(), + log.spell, + log.damage + ), + (7, 11, 75, 23) + ); +} + +#[test] +fn melee_swing_relays_damage_then_stops_on_a_killing_blow() { + let created = Mutex::new(HashSet::from([7])); + let mut row = swing_event(); + let out = combat_event_outbound(&created, &row); + assert!(matches!( + out.as_slice(), + [Outbound::One( + ServerOpcodeMessage::SMSG_ATTACKERSTATEUPDATE(_) + )] + )); + + row.killing_blow = true; + let out = combat_event_outbound(&created, &row); + assert!(matches!( + out.as_slice(), + [ + Outbound::One(ServerOpcodeMessage::SMSG_ATTACKERSTATEUPDATE(_)), + Outbound::One(ServerOpcodeMessage::SMSG_ATTACKSTOP(_)) + ] + )); +} + +const HEROIC_STRIKE_CASTER: u64 = 7; + +/// The module's `CAST_FAIL_NO_POWER`. +const NO_POWER_CODE: u8 = 1; + +fn spell_cast_event(kind: u8) -> SpellCastEvent { + SpellCastEvent { + id: 1, + caster_guid: HEROIC_STRIKE_CASTER, + spell_id: 133, + created_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, + target_guid: 11, + cast_time_ms: 0, + is_completion: false, + damage: 0, + school: 0, + is_crit: false, + resisted: 0, + absorbed: 0, + is_interrupted: false, + cooldown_ms: 0, + delay_ms: 0, + healed: 0, + is_proc_log: false, + swing_hit_info: 0, + client_initiated: false, + map_id: 0, + instance_id: 0, + grid_x: 0, + grid_y: 0, + failure_reason: 0, + kind, + } +} + +/// An interruption signal row for `spell_id` cast by `HEROIC_STRIKE_CASTER`, carrying +/// `failure_reason`. Every other field stays at the module baseline's zero, which is what such a +/// row really looks like on the wire. +fn interrupted_cast(spell_id: u32, failure_reason: u8) -> SpellCastEvent { + SpellCastEvent { + spell_id, + target_guid: 0, + is_interrupted: true, + failure_reason, + ..spell_cast_event(3) + } +} + +#[test] +fn explicit_spell_cast_event_kinds_override_legacy_discriminators() { + let contradictory = |kind| SpellCastEvent { + cast_time_ms: 1_500, + is_completion: true, + damage: 23, + is_interrupted: true, + delay_ms: 500, + is_proc_log: true, + kind, + ..spell_cast_event(kind) + }; + + assert!(matches!( + cast_event_outbound(HEROIC_STRIKE_CASTER, &contradictory(1)).as_slice(), + [Outbound::One(ServerOpcodeMessage::SMSG_SPELL_START(_))] + )); + assert!(matches!( + cast_event_outbound(HEROIC_STRIKE_CASTER + 1, &contradictory(2)).as_slice(), + [ + Outbound::One(ServerOpcodeMessage::SMSG_SPELL_GO(_)), + Outbound::One(ServerOpcodeMessage::SMSG_SPELLNONMELEEDAMAGELOG(_)) + ] + )); + + let interrupt = SpellCastEvent { + kind: 3, + ..spell_cast_event(3) + }; + assert!(matches!( + cast_event_outbound(HEROIC_STRIKE_CASTER, &interrupt).as_slice(), + [Outbound::One(ServerOpcodeMessage::SMSG_SPELL_FAILURE(_))] + )); + + let pushback = SpellCastEvent { + delay_ms: 500, + kind: 4, + ..spell_cast_event(4) + }; + assert!(matches!( + cast_event_outbound(HEROIC_STRIKE_CASTER, &pushback).as_slice(), + [Outbound::One(ServerOpcodeMessage::SMSG_SPELL_DELAYED(_))] + )); + + let proc_log = SpellCastEvent { + damage: 23, + kind: 5, + ..spell_cast_event(5) + }; + assert!(matches!( + cast_event_outbound(HEROIC_STRIKE_CASTER, &proc_log).as_slice(), + [Outbound::One( + ServerOpcodeMessage::SMSG_SPELLNONMELEEDAMAGELOG(_) + )] + )); +} + +#[test] +fn legacy_spell_cast_event_rows_keep_the_field_based_decoder() { + let start = SpellCastEvent { + cast_time_ms: 1_500, + ..spell_cast_event(0) + }; + assert!(matches!( + cast_event_outbound(HEROIC_STRIKE_CASTER, &start).as_slice(), + [Outbound::One(ServerOpcodeMessage::SMSG_SPELL_START(_))] + )); + + let go = spell_cast_event(0); + assert!(matches!( + cast_event_outbound(HEROIC_STRIKE_CASTER + 1, &go).as_slice(), + [ + Outbound::One(ServerOpcodeMessage::SMSG_SPELL_START(_)), + Outbound::One(ServerOpcodeMessage::SMSG_SPELL_GO(_)) + ] + )); + + let interrupt = SpellCastEvent { + is_interrupted: true, + ..spell_cast_event(0) + }; + assert!(matches!( + cast_event_outbound(HEROIC_STRIKE_CASTER, &interrupt).as_slice(), + [Outbound::One(ServerOpcodeMessage::SMSG_SPELL_FAILURE(_))] + )); + + let pushback = SpellCastEvent { + delay_ms: 500, + ..spell_cast_event(0) + }; + assert!(matches!( + cast_event_outbound(HEROIC_STRIKE_CASTER, &pushback).as_slice(), + [Outbound::One(ServerOpcodeMessage::SMSG_SPELL_DELAYED(_))] + )); + + let proc_log = SpellCastEvent { + damage: 23, + is_proc_log: true, + ..spell_cast_event(0) + }; + assert!(matches!( + cast_event_outbound(HEROIC_STRIKE_CASTER, &proc_log).as_slice(), + [Outbound::One( + ServerOpcodeMessage::SMSG_SPELLNONMELEEDAMAGELOG(_) + )] + )); + + let old_precedence = SpellCastEvent { + cast_time_ms: 1_500, + is_interrupted: true, + delay_ms: 500, + is_proc_log: true, + ..spell_cast_event(0) + }; + assert!(matches!( + cast_event_outbound(HEROIC_STRIKE_CASTER, &old_precedence).as_slice(), + [Outbound::One(ServerOpcodeMessage::SMSG_SPELL_FAILURE(_))] + )); +} + +#[test] +fn unknown_spell_cast_event_kind_emits_no_packet() { + let row = SpellCastEvent { + is_interrupted: true, + delay_ms: 500, + is_proc_log: true, + kind: u8::MAX, + ..spell_cast_event(u8::MAX) + }; + assert!(cast_event_outbound(HEROIC_STRIKE_CASTER, &row).is_empty()); +} + +/// A queued strike that could not pay its cost at the swing: the caster gets the cast-bar +/// teardown and then the failed cast result naming the queued spell with the vanilla Not Enough +/// Power reason (0x4D). No SMSG_SPELL_GO — the strike never fired. +#[test] +fn a_deferred_power_failure_relays_the_teardown_then_the_failed_cast_result() { + let out = cast_event_outbound(HEROIC_STRIKE_CASTER, &interrupted_cast(78, NO_POWER_CODE)); + + assert_eq!(out.len(), 2, "expected exactly the two vanilla messages"); + match &out[0] { + Outbound::One(ServerOpcodeMessage::SMSG_SPELL_FAILURE(m)) => { + assert_eq!(m.guid.guid(), HEROIC_STRIKE_CASTER); + assert_eq!(m.spell, 78); + } + _ => panic!("expected the interruption teardown first"), + } + match &out[1] { + Outbound::Raw { opcode, body } => { + assert_eq!(*opcode, 0x0130); + // spell(u32 LE) + CAST_FAILED(0x02) + SPELL_FAILED_NO_POWER(0x4D). + assert_eq!(body, &vec![78, 0, 0, 0, 0x02, 0x4D]); + } + _ => panic!("expected the raw failed cast result second"), + } +} + +/// The failed cast result is caster-private: a bystander watching the same row gets nothing. +#[test] +fn a_bystander_sees_neither_half_of_a_deferred_power_failure() { + let out = cast_event_outbound( + HEROIC_STRIKE_CASTER + 1, + &interrupted_cast(78, NO_POWER_CODE), + ); + assert!(out.is_empty(), "both messages are caster-private"); +} + +/// An interruption with no concrete reason (a damaged mid-cast timed spell) still sends the +/// teardown alone — the cast result belongs to a failure the server can name. +#[test] +fn an_ordinary_interruption_still_sends_the_teardown_alone() { + let out = cast_event_outbound(HEROIC_STRIKE_CASTER, &interrupted_cast(133, 0)); + assert_eq!(out.len(), 1, "expected the teardown alone"); + assert!(matches!( + &out[0], + Outbound::One(ServerOpcodeMessage::SMSG_SPELL_FAILURE(_)) + )); +} + +#[test] +fn stealth_both_inserts_hide_both_deletes_reveal_created_dedupes() { + // A Stealth cast applies TWO A_STEALTH auras and the SDK fires both inserts AFTER applying the + // delta, so both see post-count 2 -> both HIDE (the per-viewer `created` set makes the 2nd a + // no-op at the call site). A break removes both -> two deletes seeing post-count 0 -> both REVEAL + // (created-deduped to one). The decision is purely "stealthed -> Hide, not stealthed -> Reveal". + assert_eq!(stealth_action(true, 1), StealthAction::Hide); + assert_eq!(stealth_action(true, 2), StealthAction::Hide); + assert_eq!(stealth_action(true, 3), StealthAction::Hide); + assert_eq!(stealth_action(false, 1), StealthAction::None); // still stealthed after one of two deletes + assert_eq!(stealth_action(false, 0), StealthAction::Reveal); + assert_eq!(stealth_action(false, 2), StealthAction::None); // over-count delete does not reveal + // An insert can never leave count 0 (it adds one) -> defensively a no-op. + assert_eq!(stealth_action(true, 0), StealthAction::None); +} + +#[test] +fn stealth_dedup_composes_with_a_real_hashset_exactly_once() { + // `stealth_visibility` pairs `stealth_action`'s Hide/Reveal decision with `created`'s + // remove()/insert() return value (only act if the set's membership actually changed) — this + // composes that SAME discipline over a real `std::collections::HashSet` (not a mock) to + // pin the "exactly once" guarantee a Stealth cast's two A_STEALTH inserts/deletes rely on. + let target = 99u64; + let mut created: HashSet = HashSet::from([target]); // starts visible + let mut destroys = 0; + // A Stealth cast applies TWO A_STEALTH auras; the SDK fires both inserts AFTER applying the + // whole delta, so both see post-count 2 -> both decide Hide. + for count in [2, 2] { + if stealth_action(true, count) == StealthAction::Hide && created.remove(&target) { + destroys += 1; + } + } + assert_eq!(destroys, 1, "two HIDE decisions must destroy exactly once"); + assert!(!created.contains(&target)); + // A break removes both auras; both deletes see post-count 0 -> both decide Reveal. + let mut creates = 0; + for count in [0, 0] { + if stealth_action(false, count) == StealthAction::Reveal && created.insert(target) { + creates += 1; + } + } + assert_eq!( + creates, 1, + "two REVEAL decisions must (re)create exactly once" + ); + assert!(created.contains(&target)); +} diff --git a/gateway/src/codec/corpse/subscriptions.rs b/gateway/src/codec/corpse/subscriptions.rs index 253ab9ae..18eecad4 100644 --- a/gateway/src/codec/corpse/subscriptions.rs +++ b/gateway/src/codec/corpse/subscriptions.rs @@ -166,3 +166,6 @@ crate::stdb::relays::declare_relays! { delete(Snapshot) => |context, _event, row| self_res_option_changed(&context.view, context.shard, row.character_guid, 0); } } + +#[cfg(test)] +mod audience_tests; diff --git a/gateway/src/codec/corpse/subscriptions/audience_tests.rs b/gateway/src/codec/corpse/subscriptions/audience_tests.rs new file mode 100644 index 00000000..8783e2d1 --- /dev/null +++ b/gateway/src/codec/corpse/subscriptions/audience_tests.rs @@ -0,0 +1,55 @@ +use super::*; +use crate::stdb::world_index::CellKey; +use crate::stdb::world_view::test_support::{identity, queued_job, viewer_with_tx}; +use crate::stdb::world_view::WorldView; +use crate::world::{Outbound, SessionTx}; +use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; + +/// The owner's `PLAYER_SELF_RES_SPELL` from one queued VALUES packet. +fn self_res_spell_of(outbound: Vec) -> Option { + use wow_world_messages::vanilla::{Object, UpdateMask}; + let [Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(packet))] = outbound.as_slice() + else { + panic!("the owner must receive one SMSG_UPDATE_OBJECT"); + }; + match packet.objects.as_slice() { + [Object::Values { + mask1: UpdateMask::Player(player), + .. + }] => player.player_self_res_spell(), + other => panic!("expected one Player VALUES block, got {other:?}"), + } +} + +#[test] +fn a_self_resurrect_option_reaches_only_its_character_on_this_shard() { + let view = WorldView::new(true); + let anchor = CellKey::at(0, 0, 0, 0); + let (owner_tx, owner_rx) = SessionTx::with_depth(0); + let (bystander_tx, bystander_rx) = SessionTx::with_depth(0); + let (remote_tx, remote_rx) = SessionTx::with_depth(0); + view.add_viewer_on_shard(viewer_with_tx(1, 9001, identity(1), owner_tx), anchor, 0); + view.add_viewer_on_shard( + viewer_with_tx(2, 9002, identity(2), bystander_tx), + anchor, + 0, + ); + view.add_viewer_on_shard(viewer_with_tx(3, 9003, identity(3), remote_tx), anchor, 1); + + self_res_option_changed(&view, 0, 9001, 3026); + assert_eq!(self_res_spell_of(queued_job(&owner_rx)), Some(3026)); + assert!(owner_rx.try_recv().is_err(), "one change queues one job"); + assert!( + bystander_rx.try_recv().is_err(), + "a nearby Character never sees another Character's option" + ); + + self_res_option_changed(&view, 0, 9003, 3026); + assert!( + remote_rx.try_recv().is_err(), + "a Character on another Shard is not this Shard's to address" + ); + + self_res_option_changed(&view, 0, 9001, 0); + assert_eq!(self_res_spell_of(queued_job(&owner_rx)), Some(0)); +} diff --git a/gateway/src/codec/duel/subscriptions.rs b/gateway/src/codec/duel/subscriptions.rs index 9fbf2c77..0cbb1a27 100644 --- a/gateway/src/codec/duel/subscriptions.rs +++ b/gateway/src/codec/duel/subscriptions.rs @@ -205,3 +205,9 @@ crate::stdb::relays::declare_relays! { insert(Live) => |context, _event, row| duel_event_appeared(&context.view, &context.coord, context.shard, row); } } + +#[cfg(test)] +mod packet_tests; + +#[cfg(test)] +mod audience_tests; diff --git a/gateway/src/codec/duel/subscriptions/audience_tests.rs b/gateway/src/codec/duel/subscriptions/audience_tests.rs new file mode 100644 index 00000000..1e589da5 --- /dev/null +++ b/gateway/src/codec/duel/subscriptions/audience_tests.rs @@ -0,0 +1,9 @@ +use super::*; + +#[test] +fn duel_winner_aoi_fan_excludes_the_two_private_recipients() { + assert!(!duel_winner_audience(10, 10, 20)); + assert!(!duel_winner_audience(20, 10, 20)); + assert!(duel_winner_audience(30, 10, 20)); + assert!(!duel_winner_audience(0, 10, 20)); +} diff --git a/gateway/src/codec/duel/subscriptions/packet_tests.rs b/gateway/src/codec/duel/subscriptions/packet_tests.rs new file mode 100644 index 00000000..4995e47f --- /dev/null +++ b/gateway/src/codec/duel/subscriptions/packet_tests.rs @@ -0,0 +1,137 @@ +use super::*; +use crate::codec; +use crate::stdb::bindings::DuelEvent; +use crate::world::Outbound; +use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; + +fn duel_event(kind: u8, completion_kind: u8) -> DuelEvent { + DuelEvent { + id: 1, + recipient_identity: spacetimedb_sdk::Identity::from_byte_array([0u8; 32]), + recipient_guid: 10, + kind, + completion_kind, + duel_id: 3, + flag_guid: 0xF110_2000_0000_0003, + flag_entry: 21680, + initiator_guid: 10, + challenged_guid: 20, + winner_guid: 0, + loser_guid: 0, + map_id: 1, + instance_id: 7, + flag_x: 3.0, + flag_y: 4.0, + flag_z: 5.0, + flag_orientation: 1.25, + created_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, + winner_name: String::new(), + loser_name: String::new(), + } +} + +#[test] +fn duel_request_projects_one_typed_request_with_flag_arbiter_and_challenger() { + let template = codec::GameObjectTemplateView { + type_id: 16, + display_id: 787, + name: "Duel Arbiter".into(), + data0: 0, + data1: 0, + data2: 0, + }; + let row = duel_event(0, 0); + let out = duel_event_outbound(&row, Some(&template)); + + assert_eq!(out.len(), 5); + let requests: Vec<_> = out + .iter() + .filter_map(|message| match message { + Outbound::One(ServerOpcodeMessage::SMSG_DUEL_REQUESTED(request)) => Some(request), + _ => None, + }) + .collect(); + assert_eq!(requests.len(), 1); + assert_eq!( + requests[0].initiator, + wow_world_messages::Guid::new(row.flag_guid) + ); + assert_eq!( + requests[0].target, + wow_world_messages::Guid::new(row.initiator_guid) + ); +} + +#[test] +fn duel_countdown_active_and_interruption_project_the_expected_edges() { + let countdown = duel_event_outbound(&duel_event(1, 0), None); + assert!(matches!( + countdown.as_slice(), + [Outbound::One(ServerOpcodeMessage::SMSG_DUEL_COUNTDOWN(message))] + if message.time.as_secs() == 3_000 + )); + + let active = duel_event_outbound(&duel_event(2, 0), None); + assert_eq!(active.len(), 2); + assert!(active.iter().all(|out| matches!(out, Outbound::Raw { .. }))); + + let interrupted = duel_event_outbound(&duel_event(3, 0), None); + assert_eq!(interrupted.len(), 4); + assert!(matches!( + &interrupted[0], + Outbound::One(ServerOpcodeMessage::SMSG_DUEL_COMPLETE(message)) + if !message.ended_without_interruption + )); + assert!(matches!( + interrupted.last(), + Some(Outbound::One(ServerOpcodeMessage::SMSG_DESTROY_OBJECT(_))) + )); +} + +#[test] +fn duel_winner_projects_names_and_the_terminal_reason() { + let mut won = duel_event(3, 2); + won.winner_guid = 10; + won.loser_guid = 20; + won.winner_name = "Winner".into(); + won.loser_name = "Loser".into(); + let out = duel_event_outbound(&won, None); + assert!(matches!( + out.as_slice(), + [ + Outbound::One(ServerOpcodeMessage::SMSG_DUEL_COMPLETE(_)), + Outbound::One(ServerOpcodeMessage::SMSG_DUEL_WINNER(message)), + .. + // `opponent_name` is written first on the wire and must carry the winner. + ] if message.reason == wow_world_base::shared::duel_winner_reason_vanilla_tbc_wrath::DuelWinnerReason::Won + && message.opponent_name == "Winner" + && message.initiator_name == "Loser" + )); + + let mut fled_event = duel_event(3, 3); + fled_event.winner_guid = 10; + fled_event.loser_guid = 20; + fled_event.winner_name = "Winner".into(); + fled_event.loser_name = "Loser".into(); + let fled = duel_event_outbound(&fled_event, None); + assert!(matches!( + &fled[1], + Outbound::One(ServerOpcodeMessage::SMSG_DUEL_WINNER(message)) + if message.reason == wow_world_base::shared::duel_winner_reason_vanilla_tbc_wrath::DuelWinnerReason::Fled + )); +} + +#[test] +fn duel_boundary_edges_project_only_the_recipient_transition() { + let out_of_bounds = duel_event_outbound(&duel_event(4, 0), None); + assert!(matches!( + out_of_bounds.as_slice(), + [Outbound::One(ServerOpcodeMessage::SMSG_DUEL_OUTOFBOUNDS)] + )); + + let in_bounds = duel_event_outbound(&duel_event(5, 0), None); + assert!(matches!( + in_bounds.as_slice(), + [Outbound::One(ServerOpcodeMessage::SMSG_DUEL_INBOUNDS)] + )); +} diff --git a/gateway/src/codec/entity/subscriptions.rs b/gateway/src/codec/entity/subscriptions.rs index add9f4f5..77236725 100644 --- a/gateway/src/codec/entity/subscriptions.rs +++ b/gateway/src/codec/entity/subscriptions.rs @@ -2,8 +2,9 @@ use crate::codec::combat::subscriptions::aura_sync; use crate::codec::guild::subscriptions::guild_projection_of; -use crate::codec::movement::subscriptions::append_resident_creature_after_create; -use crate::codec::movement::subscriptions::unix_now_micros; +use crate::codec::movement::subscriptions::{ + append_resident_creature_after_create, unix_now_micros, +}; use crate::codec::taxi::subscriptions::append_resident_taxi_after_create; use crate::codec::weather::subscriptions::zone_crossed; use crate::codec::{self, CreateKind}; @@ -885,6 +886,12 @@ crate::stdb::relays::declare_relays! { #[cfg(test)] pub(crate) mod tests { use super::*; + pub(crate) fn creature_create_outbound() -> Vec { + let mut creature = creature_entity(); + creature.unit_bytes_0 = 0x0000_0101; + peer_create_outbound(&entity_view(creature, 0), &[], &[], None).unwrap() + } + /// A live-entity row with no pending change; tests clone it and mutate one field at a time. /// `type_mask` carries the PLAYER bit by default (most relayed fields are player-only). pub(crate) fn player_entity() -> WorldEntity { @@ -1164,3 +1171,9 @@ pub(crate) mod tests { assert_eq!(project_flags(7, &entity, None, None, &[], &[], &[]), 0x42); } } + +#[cfg(test)] +mod packet_tests; + +#[cfg(test)] +mod audience_tests; diff --git a/gateway/src/codec/entity/subscriptions/audience_tests.rs b/gateway/src/codec/entity/subscriptions/audience_tests.rs new file mode 100644 index 00000000..f699ca46 --- /dev/null +++ b/gateway/src/codec/entity/subscriptions/audience_tests.rs @@ -0,0 +1,55 @@ +use super::*; +use crate::stdb::bindings::PlayerReputation; +use crate::stdb::world_index::CellKey; +use crate::stdb::world_view::test_support::{identity, queued_job, viewer_with_tx}; +use crate::stdb::world_view::WorldView; +use crate::world::{Outbound, SessionTx}; + +fn reputation(character_guid: u64, reputation_index: i32, standing: i32) -> PlayerReputation { + PlayerReputation { + id: 1, + character_guid, + owner_identity: identity(1), + faction_id: 72, + standing, + reputation_index, + at_war: false, + } +} + +#[test] +fn reputation_insert_and_update_route_only_to_the_owner_by_reputation_index() { + let view = WorldView::new(true); + let (owner_tx, owner_rx) = SessionTx::with_depth(0); + let (other_tx, other_rx) = SessionTx::with_depth(0); + let owner = viewer_with_tx(1, 9001, identity(1), owner_tx); + let other = viewer_with_tx(2, 9002, identity(2), other_tx); + let anchor = CellKey::at(0, 0, 0, 0); + view.add_viewer_on_shard(owner, anchor, 0); + view.add_viewer_on_shard(other, anchor, 0); + + reputation_appeared(&view, 0, &reputation(9001, 19, 3175)); + reputation_appeared(&view, 0, &reputation(9001, 19, 3200)); + + assert!( + other_rx.try_recv().is_err(), + "an unrelated owner receives nothing" + ); + for standing in [3175, 3200] { + let (opcode, body) = crate::codec::build_set_faction_standing_raw(19, standing) + .expect("a valid reputation index has a packet"); + match queued_job(&owner_rx).as_slice() { + [Outbound::Raw { + opcode: actual_opcode, + body: actual_body, + }] => { + assert_eq!((*actual_opcode, actual_body), (opcode, &body)); + } + _ => panic!("the reputation row must use its reputation_index, not faction_id"), + } + } + assert!( + owner_rx.try_recv().is_err(), + "each reputation row queues exactly one job" + ); +} diff --git a/gateway/src/codec/entity/subscriptions/packet_tests.rs b/gateway/src/codec/entity/subscriptions/packet_tests.rs new file mode 100644 index 00000000..b06125c2 --- /dev/null +++ b/gateway/src/codec/entity/subscriptions/packet_tests.rs @@ -0,0 +1,1020 @@ +use super::*; +use crate::codec; +use crate::codec::combat::subscriptions::tests::aura; +use crate::codec::combat::subscriptions::A_STEALTH; +use crate::codec::entity::subscriptions::tests::{creature_entity, player_entity}; +use crate::codec::CreateKind; +use crate::stdb::aoi::ViewerGates; +use crate::stdb::bindings::{EncounterEquip, WorldEntity}; +use crate::stdb::views::entity_view; +use crate::stdb::world_view; +use crate::stdb::world_view::Viewer; +use crate::world::{Outbound, SessionTx}; +use std::collections::HashSet; +use std::sync::{Arc, Mutex}; +use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; + +fn viewer_with_created(creature_guid: u64) -> Viewer { + let (tx, _rx) = SessionTx::with_depth(0); + Viewer { + active: std::sync::atomic::AtomicBool::new(true), + session: 1, + self_guid: 7, + bound_identity: spacetimedb_sdk::Identity::from_byte_array([0; 32]), + map_id: 0, + instance_id: 0, + zone_id: 0.into(), + tx, + created: Arc::new(Mutex::new(HashSet::from([creature_guid]))), + gates: Arc::new(ViewerGates::default()), + skill_slots: Arc::new(Mutex::new((std::collections::HashMap::new(), 0))), + explored: Mutex::new(world_view::ExplorationReplay::default()), + motion_pending: Arc::new(world_view::MotionPending::default()), + member_stats: Default::default(), + ignored: Mutex::default(), + friends: Mutex::default(), + team: lyracore_shared::faction::TEAM_ALLIANCE, + group_events: Default::default(), + } +} + +#[test] +fn encounter_equipment_reaches_live_and_late_viewers_in_wire_order() { + let creature_guid = 0xF130_0000_0000_002A; + let viewer = viewer_with_created(creature_guid); + let equipment = EncounterEquip { + creature_guid, + instance_id: 0, + main_hand: 2_196, + off_hand: 2_716, + ranged: 0, + }; + + for (row, cleared, expected) in [ + (equipment.clone(), false, (2_196, 2_716, 0)), + ( + EncounterEquip { + main_hand: 2_716, + off_hand: 0, + ranged: 2_196, + ..equipment.clone() + }, + false, + (2_716, 0, 2_196), + ), + (equipment.clone(), true, (0, 0, 0)), + ] { + let out = encounter_equip_outbound(&viewer, &row, cleared); + let (expected_opcode, expected_body) = + codec::build_virtual_item_values(creature_guid, expected.0, expected.1, expected.2); + assert!(matches!( + out.as_slice(), + [Outbound::Raw { opcode, body }] + if *opcode == expected_opcode && *body == expected_body + )); + } + + let creature = codec::EntityView { + guid: creature_guid, + type_mask: lyracore_shared::constants::type_mask::CREATURE, + entry: 42, + scale_x: 1.0, + health: 1, + max_health: 1, + level: 1, + run_speed_mult_bp: 10_000, + unit_bytes_0: 0x0101, + display_id: 1, + native_display_id: 1, + ..Default::default() + }; + let create = codec::build_create_object(&creature, CreateKind::Peer, &[], &[]).unwrap(); + let mut late = vec![Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( + Box::new(create), + ))]; + append_encounter_equip_after_create(&mut late, &viewer, &equipment); + assert!(matches!( + late.as_slice(), + [ + Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(_)), + Outbound::Raw { .. } + ] + )); +} + +fn created_player(out: &[Outbound]) -> wow_world_messages::vanilla::UpdatePlayer { + use wow_world_messages::vanilla::{Object, UpdateMask}; + let Some(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(create))) = out.first() else { + panic!("a peer CREATE comes first"); + }; + let [Object::CreateObject2 { + mask2: UpdateMask::Player(player), + .. + }] = create.objects.as_slice() + else { + panic!("the CREATE must carry a player mask"); + }; + player.clone() +} + +/// A Human Warrior: the CREATE encoder refuses race 0. +fn human_warrior() -> WorldEntity { + WorldEntity { + unit_bytes_0: 1 | (1 << 8) | (1 << 24), + ..player_entity() + } +} + +#[test] +fn a_peer_player_create_carries_its_guild_projection() { + let row = human_warrior(); + let view = peer_entity_view(&row, 0, |guid| { + assert_eq!(guid, row.guid); + (7, 3) + }); + let player = created_player(&peer_create_outbound(&view, &[], &[], None).unwrap()); + assert_eq!(player.player_guildid(), Some(7)); + assert_eq!(player.player_guildrank(), Some(3)); +} + +#[test] +fn a_peer_outside_any_guild_is_created_with_an_explicit_zero() { + let row = human_warrior(); + let player = created_player( + &peer_create_outbound(&peer_entity_view(&row, 0, |_| (0, 0)), &[], &[], None).unwrap(), + ); + assert_eq!(player.player_guildid(), Some(0)); + assert_eq!(player.player_guildrank(), Some(0)); +} + +#[test] +fn a_creature_create_reads_no_guild() { + let view = peer_entity_view(&creature_entity(), 0, |_| panic!("a creature has no Guild")); + assert_eq!((view.guild_id, view.guild_rank), (0, 0)); +} + +#[test] +fn no_change_emits_nothing() { + let e = player_entity(); + assert!(entity_update_to_outbound(&e, &e).is_empty()); +} + +#[test] +fn single_health_change_emits_one() { + let old = player_entity(); + let mut new = old.clone(); + new.health = 50; + let out = entity_update_to_outbound(&old, &new); + // Decode + pin: guid and the exact health value ride the health-only VALUES packet. + assert_eq!( + out, + vec![ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( + codec::build_health_values(new.guid, 50) + ))] + ); +} + +/// a sheath change must relay, or drawing a weapon is invisible to everyone but the player +/// who did it. The state lives in BYTE 0 of `unit_bytes_2` — pin the decoded packet, and pin that +/// an unchanged row stays silent (the client re-sends its current state on every weapon swap, and +/// a relay per no-op is a broadcast amplifier on a busy cell). +#[test] +fn sheath_change_relays_and_a_no_op_stays_silent() { + let old = player_entity(); + let mut new = old.clone(); + new.unit_bytes_2 = 1; // SHEATH_STATE_MELEE — weapons drawn + assert_eq!( + entity_update_to_outbound(&old, &new), + vec![ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( + codec::build_sheath_values(new.guid, 1) + ))] + ); + assert!( + entity_update_to_outbound(&old, &old.clone()).is_empty(), + "an unchanged row must not relay a sheath packet" + ); +} + +#[test] +fn independent_field_changes_each_emit() { + // health (any unit) + dynamic_flags (any unit) + power (player) = three separate packets, + // in the relay's fixed emission order — decoded and pinned by guid + value, not just count. + let old = player_entity(); + let mut new = old.clone(); + new.health = 50; + new.dynamic_flags = 1; + new.power = 30; + let power_b = (new.unit_bytes_0 >> 24) as u8; + let out = entity_update_to_outbound(&old, &new); + assert_eq!( + out, + vec![ + ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(codec::build_health_values( + new.guid, 50 + ))), + ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(codec::build_dynamic_flags_values( + new.guid, 1 + ))), + ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(codec::build_power_values( + new.guid, power_b, 30 + ))), + ] + ); +} + +#[test] +fn dynamic_flag_values_uses_the_viewer_projection() { + let mut old = creature_entity(); + old.dynamic_flags = 0; + let mut new = old.clone(); + new.dynamic_flags = lyracore_shared::constants::unit_dynamic_flags::TAPPED; + let projected = + new.dynamic_flags | lyracore_shared::constants::unit_dynamic_flags::TAPPED_BY_PLAYER; + assert_eq!( + entity_update_to_outbound_with_dynamic_flags(&old, &new, projected), + vec![ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( + codec::build_dynamic_flags_values(new.guid, projected) + ))] + ); +} + +#[test] +fn levelup_is_exclusive_with_field_diffs() { + // A ding collapses level + vitals + xp into ONE combined VALUES block, NOT separate health/xp + // packets — even though health and xp also changed this tick. + let old = player_entity(); + let mut new = old.clone(); + new.level = 2; + new.health = 120; + new.max_health = 120; + new.xp = 10; + assert_eq!(entity_update_to_outbound(&old, &new).len(), 1); +} + +/// `/afk` sets PLAYER_FLAGS_AFK (0x02, descriptor index 190). Self and every observer run this +/// same diff, so each client holding the object gets one VALUES update carrying the new word. +#[test] +fn an_afk_toggle_relays_player_flags_to_every_viewer() { + let old = player_entity(); + for (flags, label) in [(0x02, "AFK"), (0x04, "DND"), (0x00, "cleared")] { + let mut new = old.clone(); + new.player_flags = flags; + let before = if flags == 0 { + let mut away = old.clone(); + away.player_flags = 0x02; + away + } else { + old.clone() + }; + let out = entity_update_to_outbound(&before, &new); + let [ServerOpcodeMessage::SMSG_UPDATE_OBJECT(update)] = out.as_slice() else { + panic!("{label}: expected one VALUES update, got {out:?}"); + }; + let mut wire = Vec::new(); + wow_world_messages::vanilla::ServerMessage::write_unencrypted_server(&**update, &mut wire) + .unwrap(); + let values = lyracore_shared::values_mask::parse_values_updates(&wire[4..]); + assert_eq!(values.len(), 1, "{label}"); + assert_eq!(values[0].guid, 1, "{label}"); + assert!( + values[0].fields.contains(&(190, flags)), + "{label}: PLAYER_FLAGS missing from {:?}", + values[0].fields + ); + assert!( + !values[0].fields.iter().any(|&(index, _)| index == 2), + "{label}: OBJECT_FIELD_TYPE leaked into a partial update" + ); + } +} + +#[test] +fn player_only_fields_are_gated_for_creatures() { + // A creature (no PLAYER bit) relays health + dynamic_flags but NOT xp/coinage/ghost/power — + // decoded and pinned as exactly those two packets, not just a count of two. + let mut old = player_entity(); + old.type_mask = 0; + let mut new = old.clone(); + new.health = 50; // emits (any unit) + new.dynamic_flags = 1; // emits (any unit) + new.xp = 10; // gated out (player-only) + new.money = 10; // gated out + new.power = 30; // gated out + new.player_flags = 0x10; // gated out + let out = entity_update_to_outbound(&old, &new); + assert_eq!( + out, + vec![ + ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(codec::build_health_values( + new.guid, 50 + ))), + ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(codec::build_dynamic_flags_values( + new.guid, 1 + ))), + ] + ); +} + +#[test] +fn creature_levelup_does_not_take_the_exclusive_branch() { + // The level-up branch is player-only; a creature whose level changed still diffs health + // normally. A bare length check can't tell the two branches apart here — the player-only + // ding ALSO emits exactly one packet — so decode the one packet and pin it as the plain + // Unit-mask health update (build_health_values), not the Player-mask combined ding + // (build_levelup_values) a regressed player-gate would otherwise produce with the same count. + let mut old = player_entity(); + old.type_mask = 0; + let mut new = old.clone(); + new.level = 5; + new.health = 50; + let out = entity_update_to_outbound(&old, &new); + assert_eq!( + out, + vec![ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( + codec::build_health_values(new.guid, new.health) + ))] + ); +} + +#[test] +fn target_change_emits_one() { + // UNIT_FIELD_TARGET relay — any unit, one packet, guid + target value pinned. + let old = player_entity(); + let mut new = old.clone(); + new.target_guid = 0xDEAD_BEEF; + let out = entity_update_to_outbound(&old, &new); + assert_eq!( + out, + vec![ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( + codec::build_target_values(new.guid, 0xDEAD_BEEF) + ))] + ); +} + +#[test] +fn bank_bag_slot_purchase_relays_and_the_rest_byte_survives() { + // A bought bank bag slot lands in byte 2 of PLAYER_BYTES_2; the relay must carry the FULL + // field (a partial VALUES overwrites the whole u32) so byte 0 (facial hair) and byte 3 (rest + // state — HAZARD: a zero byte 3 crashes the 5875 client's XP bar) ride along unchanged. + let mut old = player_entity(); + old.player_bytes_2 = lyracore_shared::packing::player_bytes_2_with_rest(7, 0, false); // facial hair 7, 0 slots, NORMAL + let mut new = old.clone(); + new.player_bytes_2 = lyracore_shared::packing::with_bank_bag_slots(old.player_bytes_2, 1); + let out = entity_update_to_outbound(&old, &new); + assert_eq!( + out, + vec![ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( + codec::build_bank_bag_slots_values(new.guid, new.player_bytes_2) + ))] + ); + let (facial_hair, _, bank_slots, rest) = lyracore_shared::packing::unpack4(new.player_bytes_2); + assert_eq!( + facial_hair, 7, + "facial hair (byte 0) must survive the purchase" + ); + assert_eq!(bank_slots, 1); + assert_eq!( + rest, + lyracore_shared::packing::REST_STATE_NORMAL, + "rest state (byte 3) must stay a valid non-zero value" + ); +} + +#[test] +fn a_rest_state_flip_alone_does_not_relay_the_bank_slot_packet() { + // Byte 3 changing (rest flip) must NOT trip the byte-2-scoped bank slot relay — that byte + // already relays through its own dedicated `game_rest_state_event` path. + let old = player_entity(); + let mut new = old.clone(); + new.player_bytes_2 = lyracore_shared::packing::player_bytes_2_with_rest(0, 0, true); // RESTED + assert!(entity_update_to_outbound(&old, &new).is_empty()); +} + +#[test] +fn max_vitals_change_emits_one() { + // A non-level-up max_health change (gear/aura) — any unit, one packet, guid + max_health/ + // power values pinned (max_power carries through unchanged from `player_entity()`). + let old = player_entity(); + let mut new = old.clone(); + new.max_health = 150; + let power_b = (new.unit_bytes_0 >> 24) as u8; + let out = entity_update_to_outbound(&old, &new); + assert_eq!( + out, + vec![ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( + codec::build_max_vitals_values(new.guid, 150, power_b, new.max_power) + ))] + ); +} + +#[test] +fn ghost_transition_edge_detects_the_spirit_healer_reveal() { + const GHOST: u32 = lyracore_shared::constants::player_flags::GHOST; // 0x10 + // alive→ghost (Release Spirit): REVEAL the in-scope spirit healers (Some(true)). + assert_eq!(ghost_transition(0, GHOST, GHOST), Some(true)); + // ghost→alive (reclaim corpse / spirit-healer res): DESTROY them (Some(false)). + assert_eq!(ghost_transition(GHOST, 0, GHOST), Some(false)); + // No GHOST-bit change → no reveal work, even when OTHER player-flags toggle this update + // (the common heartbeat / unrelated flag write must NOT re-fire the reveal). + assert_eq!(ghost_transition(0, 0, GHOST), None); + assert_eq!(ghost_transition(GHOST, GHOST, GHOST), None); + assert_eq!(ghost_transition(0x4 | GHOST, 0x8 | GHOST, GHOST), None); // ghost stays set, others flip + assert_eq!(ghost_transition(0x4, 0x8, GHOST), None); // ghost stays clear, others flip +} + +#[test] +fn in_combat_flag_change_emits_one() { + // UNIT_FLAG_IN_COMBAT toggling on unit_flags — any unit, one VALUES packet, guid + the + // exact post-toggle flags word pinned. + let old = player_entity(); + let mut new = old.clone(); + new.unit_flags |= lyracore_shared::constants::unit_flags::IN_COMBAT; + let out = entity_update_to_outbound(&old, &new); + assert_eq!( + out, + vec![ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( + codec::build_unit_flags_values(new.guid, new.unit_flags) + ))] + ); +} + +#[test] +fn taxi_mount_and_flight_flag_change_emit_one_atomic_presentation_values() { + let old = player_entity(); + let mut new = old.clone(); + new.mount_display_id = 1147; + new.unit_flags |= lyracore_shared::constants::unit_flags::TAXI_FLIGHT; + assert_eq!( + entity_update_to_outbound(&old, &new), + vec![ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( + codec::build_taxi_presentation_values(new.guid, new.mount_display_id, new.unit_flags,) + ))] + ); +} + +#[test] +fn taxi_landing_emits_one_atomic_presentation_update_not_a_land_mount_duplicate() { + let mut old = player_entity(); + old.mount_display_id = 1147; + old.unit_flags |= lyracore_shared::constants::unit_flags::TAXI_FLIGHT; + let mut new = old.clone(); + new.mount_display_id = 0; + new.unit_flags &= !lyracore_shared::constants::unit_flags::TAXI_FLIGHT; + assert_eq!( + entity_update_to_outbound(&old, &new), + vec![ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( + codec::build_taxi_presentation_values(new.guid, new.mount_display_id, new.unit_flags,) + ))] + ); +} + +#[test] +fn land_mount_display_change_emits_the_standalone_builder_not_taxi_presentation() { + // A land mount changes ONLY `mount_display_id` — TAXI_FLIGHT is untouched — so it must relay + // through the decoupled single-field builder, not the taxi-coupled one. + let old = player_entity(); + let mut new = old.clone(); + new.mount_display_id = 1147; + assert_eq!( + entity_update_to_outbound(&old, &new), + vec![ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( + codec::build_mount_display_values(new.guid, new.mount_display_id) + ))] + ); +} + +#[test] +fn land_dismount_display_change_emits_the_standalone_builder_with_zero() { + let mut old = player_entity(); + old.mount_display_id = 1147; + let mut new = old.clone(); + new.mount_display_id = 0; // dismount — TAXI_FLIGHT was never set for a land mount + assert_eq!( + entity_update_to_outbound(&old, &new), + vec![ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( + codec::build_mount_display_values(new.guid, 0) + ))] + ); +} + +#[test] +fn a_land_mount_display_change_alongside_an_unrelated_flag_change_emits_both_packets() { + // The flag change is real but NOT the taxi flight bit — both relay, separately, neither + // coupled through the taxi builder. + let old = player_entity(); + let mut new = old.clone(); + new.mount_display_id = 1147; + new.unit_flags |= lyracore_shared::constants::unit_flags::IN_COMBAT; + assert_eq!( + entity_update_to_outbound(&old, &new), + vec![ + ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(codec::build_mount_display_values( + new.guid, + new.mount_display_id + ))), + ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(codec::build_unit_flags_values( + new.guid, + new.unit_flags + ))), + ] + ); +} + +#[test] +fn run_speed_mult_bp_change_emits_force_run_speed_change_with_the_derived_speed() { + // GM playtest `.speed`: a run_speed_mult_bp change relays + // SMSG_FORCE_RUN_SPEED_CHANGE with speed = BASE_RUN_SPEED (7.0) * bp/10000 — pin the derived + // value end-to-end (this is the "codec pin for the speed message" this feature calls for). + let old = player_entity(); + let mut new = old.clone(); + new.run_speed_mult_bp = 30_000; // .speed 3 -> 3x + let out = entity_update_to_outbound(&old, &new); + assert_eq!( + out, + vec![ServerOpcodeMessage::SMSG_FORCE_RUN_SPEED_CHANGE(Box::new( + codec::build_force_run_speed(new.guid, 21.0) + ))] + ); +} + +#[test] +fn appearance_change_relays_the_wisp_and_the_restored_native_display() { + let alive = player_entity(); + let mut wisp = alive.clone(); + wisp.display_id = 1825; + for (old, new) in [(&alive, &wisp), (&wisp, &alive)] { + assert_eq!( + entity_update_to_outbound(old, new), + vec![ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( + codec::build_display_values(new.guid, new.display_id), + ))] + ); + assert_eq!(new.native_display_id, old.native_display_id); + } +} + +#[test] +fn run_speed_mult_bp_unchanged_emits_nothing_extra() { + let old = player_entity(); + let mut new = old.clone(); + new.health = 50; // an unrelated change must not spuriously trigger the speed relay + let out = entity_update_to_outbound(&old, &new); + assert!(!out + .iter() + .any(|m| matches!(m, ServerOpcodeMessage::SMSG_FORCE_RUN_SPEED_CHANGE(_)))); +} + +#[test] +fn creature_relays_target_and_max_vitals() { + // The target + max-vitals arms are NOT player-gated: a creature relays both, decoded and + // pinned in emission order (target before max-vitals) with their exact values. + let mut old = player_entity(); + old.type_mask = 0; + let mut new = old.clone(); + new.target_guid = 7; + new.max_power = 50; + let power_b = (new.unit_bytes_0 >> 24) as u8; + let out = entity_update_to_outbound(&old, &new); + assert_eq!( + out, + vec![ + ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(codec::build_target_values( + new.guid, 7 + ))), + ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(codec::build_max_vitals_values( + new.guid, + new.max_health, + power_b, + 50 + ))), + ] + ); +} + +#[test] +fn peer_reveal_restores_remaining_auras_after_create() { + use codec::update_mask::idx; + use wow_world_messages::vanilla::{Object, UpdateMask, VisibleItemIndex}; + + for mut entity in [player_entity(), creature_entity()] { + entity.unit_bytes_0 = 0x0000_0101; + let mut remaining = aura(entity.guid, 0xA1, 1, 10, 0, i64::MAX); + remaining.spell_id = 1459; + remaining.level = 40; + remaining.flags = 9; + let index = world_view::AuraIndex::default(); + index.upsert(0, &remaining); + let mut other_shard = remaining.clone(); + other_shard.spell_id = 9999; + index.upsert(1, &other_shard); + + let mut stealth = aura(entity.guid, A_STEALTH, 0, 0, 0, i64::MAX); + stealth.id = 2; + stealth.slot = 0; + let mut second_stealth = stealth.clone(); + second_stealth.id = 3; + second_stealth.slot = 1; + index.upsert(0, &stealth); + index.upsert(0, &second_stealth); + index.remove(0, &stealth); + assert_eq!(index.stealth_count(0, entity.guid), 1); + index.remove(0, &second_stealth); + assert_eq!(index.stealth_count(0, entity.guid), 0); + + let view = entity_view(entity, 0); + let out = peer_create_outbound( + &view, + &[codec::ItemInstanceView { + guid: 700, + entry: 1337, + enchantment: 823, + ..Default::default() + }], + &index.on_target(0, view.guid), + None, + ) + .unwrap(); + let [Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(create)), Outbound::Raw { opcode, body }] = + out.as_slice() + else { + panic!("a peer needs CREATE followed by its remaining aura array"); + }; + let [Object::CreateObject2 { guid3, mask2, .. }] = create.objects.as_slice() else { + panic!("the first packet must create the peer"); + }; + assert_eq!(guid3.guid(), view.guid); + if let UpdateMask::Player(player) = mask2 { + let visible = player + .player_visible_item(VisibleItemIndex::Index0) + .unwrap(); + assert_eq!((visible.item, visible.enchants), (1337, [823, 0])); + } + assert_eq!(*opcode, 0x00A9); + let decoded = lyracore_shared::values_mask::parse_values_updates(body); + assert_eq!(decoded.len(), 1); + assert_eq!(decoded[0].guid, view.guid); + let fields: std::collections::HashMap<_, _> = decoded[0].fields.iter().copied().collect(); + for slot in 0..idx::AURA_SLOTS { + assert_eq!( + fields[&(idx::UNIT_AURA + slot)], + if slot == 3 { 1459 } else { 0 } + ); + } + assert_eq!(fields[&idx::UNIT_AURAFLAGS], 0x9000); + assert_eq!(fields[&idx::UNIT_AURALEVELS], 0x2800_0000); + assert!(!fields.contains_key(&idx::OBJECT_TYPE)); + } +} + +#[test] +fn peer_create_without_auras_needs_no_followup() { + let mut entity = player_entity(); + entity.unit_bytes_0 = 0x0000_0101; + let out = peer_create_outbound(&entity_view(entity, 0), &[], &[], None).unwrap(); + assert!(matches!( + out.as_slice(), + [Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(_))] + )); +} + +#[test] +fn peer_create_restores_the_wisp_appearance_and_movement_speeds() { + use lyracore_shared::spell::taxonomy::{A_MOD_SPEED, SPEED_MOVE, SPEED_SWIM}; + use wow_world_messages::vanilla::{Object, UpdateMask}; + let mut entity = player_entity(); + entity.display_id = 1825; + entity.unit_bytes_0 = 0x0000_0104; + entity.run_speed_mult_bp = 10_000; + let view = entity_view(entity, 0); + let auras = [ + aura( + view.guid, + A_MOD_SPEED, + i32::from(SPEED_MOVE), + 25, + 0, + i64::MAX, + ), + aura( + view.guid, + A_MOD_SPEED, + i32::from(SPEED_MOVE), + 50, + 0, + i64::MAX, + ), + aura( + view.guid, + A_MOD_SPEED, + i32::from(SPEED_SWIM), + 25, + 0, + i64::MAX, + ), + aura( + view.guid, + A_MOD_SPEED, + i32::from(SPEED_SWIM), + 50, + 0, + i64::MAX, + ), + ]; + let out = peer_create_outbound(&view, &[], &auras, None).unwrap(); + let Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(create)) = &out[0] else { + panic!("peer CREATE"); + }; + let Object::CreateObject2 { + movement2, + mask2: UpdateMask::Player(player), + .. + } = &create.objects[0] + else { + panic!("Character CREATE"); + }; + assert_eq!(player.unit_displayid(), Some(1825)); + let wow_world_messages::vanilla::MovementBlock_UpdateFlag_Living::Living { + running_speed, + swimming_speed, + .. + } = movement2.update_flag.get_living().unwrap() + else { + panic!("living movement"); + }; + assert_eq!(*running_speed, 10.5); + assert!((*swimming_speed - lyracore_shared::constants::speeds::SWIM * 1.5).abs() < 0.000001); +} + +#[test] +fn peer_create_uses_the_remaining_ghost_speed_and_restores_baseline_after_removal() { + use lyracore_shared::spell::taxonomy::{A_MOD_SPEED, SPEED_MOVE, SPEED_SWIM}; + use wow_world_messages::vanilla::{MovementBlock_UpdateFlag_Living, Object}; + let mut entity = player_entity(); + entity.unit_bytes_0 = 0x0000_0101; + entity.run_speed_mult_bp = 10_000; + let view = entity_view(entity, 0); + let ghost = vec![ + aura( + view.guid, + A_MOD_SPEED, + i32::from(SPEED_MOVE), + 25, + 0, + i64::MAX, + ), + aura( + view.guid, + A_MOD_SPEED, + i32::from(SPEED_SWIM), + 25, + 0, + i64::MAX, + ), + ]; + for (auras, expected_run, expected_swim) in + [(ghost, 8.75, 5.902_777_7), (vec![], 7.0, 4.722_222)] + { + let out = peer_create_outbound(&view, &[], &auras, None).unwrap(); + let Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(create)) = &out[0] else { + panic!("peer CREATE"); + }; + let Object::CreateObject2 { movement2, .. } = &create.objects[0] else { + panic!("Character CREATE"); + }; + let MovementBlock_UpdateFlag_Living::Living { + running_speed, + swimming_speed, + .. + } = movement2.update_flag.get_living().unwrap() + else { + panic!("living movement"); + }; + assert_eq!(*running_speed, expected_run); + assert!((*swimming_speed - expected_swim).abs() < 0.000001); + } +} + +#[test] +fn peer_create_preserves_gm_run_speed_without_movement_auras() { + use wow_world_messages::vanilla::{MovementBlock_UpdateFlag_Living, Object}; + let mut entity = player_entity(); + entity.unit_bytes_0 = 0x0000_0101; + entity.run_speed_mult_bp = 30_000; + let view = entity_view(entity, 0); + let out = peer_create_outbound(&view, &[], &[], None).unwrap(); + let Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(create)) = &out[0] else { + panic!("peer CREATE"); + }; + let Object::CreateObject2 { movement2, .. } = &create.objects[0] else { + panic!("Character CREATE"); + }; + let MovementBlock_UpdateFlag_Living::Living { running_speed, .. } = + movement2.update_flag.get_living().unwrap() + else { + panic!("living movement"); + }; + assert_eq!(*running_speed, 21.0); +} + +#[test] +fn peer_create_applies_always_and_not_stack_run_bonuses() { + use lyracore_shared::spell::taxonomy::{A_MOD_SPEED, SPEED_MOVE_ALWAYS, SPEED_MOVE_NOT_STACK}; + use wow_world_messages::vanilla::{MovementBlock_UpdateFlag_Living, Object}; + let mut entity = player_entity(); + entity.unit_bytes_0 = 0x0000_0101; + let view = entity_view(entity, 0); + for speed_kind in [SPEED_MOVE_ALWAYS, SPEED_MOVE_NOT_STACK] { + let auras = [aura( + view.guid, + A_MOD_SPEED, + i32::from(speed_kind), + 50, + 0, + i64::MAX, + )]; + let out = peer_create_outbound(&view, &[], &auras, None).unwrap(); + let Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(create)) = &out[0] else { + panic!("peer CREATE"); + }; + let Object::CreateObject2 { movement2, .. } = &create.objects[0] else { + panic!("Character CREATE"); + }; + let MovementBlock_UpdateFlag_Living::Living { running_speed, .. } = + movement2.update_flag.get_living().unwrap() + else { + panic!("living movement"); + }; + assert_eq!(*running_speed, 10.5); + } +} + +// ---- AOI re-entry, initial-apply, instance isolation -------------- +// +// `offer_peer_create`/`is_update_reentry` themselves need a live SDK connection (`RemoteTables` +// is only constructible off a real subscription) to exercise end-to-end, so — per this repo's +// established convention (`ghost_transition`, `stealth_action`, `entity_update_to_outbound`) — +// the DECISION logic they're built from is extracted pure (`peer_create_gate`, +// `is_update_reentry`) and tested directly here; both are the ACTUAL functions +// `offer_peer_create`/the `on_update` callback call, not a parallel re-implementation. + +#[test] +fn peer_create_gate_excludes_self_regardless_of_other_fields() { + // Self is never offered a CREATE of its own row (the dedup set is pre-seeded with + // self_guid instead) — true even if every other gate would otherwise pass. + assert!(!peer_create_gate(1, 1, 0, 0, 0, true, false)); +} + +#[test] +fn peer_create_gate_excludes_a_different_instance_work_item_190() { + // Same instance (both open-world 0, or both some future dungeon instance N) -> visible. + assert!(peer_create_gate(2, 1, 0, 0, 0, false, false)); + assert!(peer_create_gate(2, 1, 7, 7, 0, false, false)); + // Different instance (e.g. a dungeon party's row vs. an open-world viewer, or two + // different parties' instances of the same dungeon) -> excluded. This is the gate the + // The login sweep and the re-entry path BOTH sit behind — a cross-instance row must + // never be marked `created`, even transiently, by either path. + assert!(!peer_create_gate(2, 1, 7, 0, 0, false, false)); + assert!(!peer_create_gate(2, 1, 3, 7, 0, false, false)); +} + +#[test] +fn peer_create_gate_spirithealer_and_stealth_gates_unaffected_by_the_refactor() { + const SPIRITHEALER: u32 = lyracore_shared::constants::npc_flags::SPIRITHEALER; + // A spirit healer is invisible to a living viewer... + assert!(!peer_create_gate(2, 1, 0, 0, SPIRITHEALER, false, false)); + assert!(peer_create_gate(2, 1, 0, 0, SPIRITHEALER, true, false)); + // A non-spirit-healer entity is unaffected by the viewer's ghost state. + assert!(peer_create_gate(2, 1, 0, 0, 0, false, false)); + // A currently-stealthed peer is excluded regardless of everything else. + assert!(!peer_create_gate(2, 1, 0, 0, 0, false, true)); +} + +#[test] +fn reentry_after_leaving_aoi_recreates_exactly_once_work_item_144() { + // The exact re-entry scenario: observer sees peer (CREATE) -> peer leaves the AOI box + // (on_delete evicts it from `created`, sends DESTROY) -> peer returns, delivered as an + // UPDATE of the SDK's still-cached row (not a fresh on_insert) -> the observer must + // re-CREATE it, not silently drop it forever. + let self_guid = 1u64; + let peer = 42u64; + let mut created: HashSet = HashSet::from([self_guid, peer]); // currently visible + + // Leave: on_delete's `if del_created.lock().unwrap().remove(&row.guid)` — must actually + // evict, or the later re-entry check below sees a false "already shown". + assert!( + created.remove(&peer), + "on_delete must evict the leaving peer from `created`" + ); + assert!(!created.contains(&peer)); + + // Re-entry arrives as an on_update, not on_insert (the SDK's refcounted cache still holds + // the row). The callback's actual dispatch predicate must say "treat as re-entry". + assert!(is_update_reentry(peer, self_guid, created.contains(&peer))); + + // `offer_peer_create`'s gate passes (same instance, not stealthed, not a spirit healer) -> + // the dedup insert succeeds -> exactly one CREATE goes out. + assert!(peer_create_gate(peer, self_guid, 0, 0, 0, false, false)); + assert!( + created.insert(peer), + "the re-entry offer must (re)insert into `created`" + ); + + // A THIRD sighting of the same still-visible peer (an ordinary field update, e.g. a + // heartbeat) must NOT be treated as another re-entry — it's already shown, so it diffs + // normally instead of re-CREATEing (which would desync the client). + assert!(!is_update_reentry(peer, self_guid, created.contains(&peer))); + + // Self never triggers the re-entry branch, even if (hypothetically) it were ever evicted. + assert!(!is_update_reentry(self_guid, self_guid, false)); +} + +#[test] +fn login_resident_sweep_creates_every_pre_existing_peer_exactly_once_work_item_145() { + // The login-sweep scenario: a fresh login's AOI box already contains peers/creatures (rows + // resident in the connection's cache from the just-applied subscription) whose + // `on_insert` may or may not have fired. The sweep in `subscribe_player_events` offers + // every resident row through this SAME gate + dedup — model it directly. + let self_guid = 1u64; + let residents = [2u64, 3, 4]; // pre-existing peers already in the login AOI box + let mut created: HashSet = HashSet::from([self_guid]); + + let mut creates_sent = 0; + for guid in residents { + if peer_create_gate(guid, self_guid, 0, 0, 0, false, false) && created.insert(guid) { + creates_sent += 1; + } + } + assert_eq!( + creates_sent, 3, + "every pre-existing resident must get a CREATE at login, not just self (the login-sweep bug: \ + only self + gameobjects appeared)" + ); + assert!(residents.iter().all(|g| created.contains(g))); + + // Idempotency (the sweep's own correctness claim): if the SDK ALSO fired an apply-time + // on_insert for one resident before the sweep runs, the sweep must not double-create it. + creates_sent = 0; + for guid in residents { + if peer_create_gate(guid, self_guid, 0, 0, 0, false, false) && created.insert(guid) { + creates_sent += 1; + } + } + assert_eq!( + creates_sent, 0, + "a second sweep over already-created residents must be a no-op" + ); +} + +#[test] +fn login_resident_sweep_excludes_cross_instance_residents_work_item_190() { + // A row resident in the connection's cache from a DIFFERENT instance than the viewer + // (always a no-op today since every entity is instance 0, but the + // gate must already hold the line) must never be swept into `created`. + let self_guid = 1u64; + let viewer_instance_id = 0u64; + let same_instance_peer = 2u64; + let other_instance_peer = 3u64; + let mut created: HashSet = HashSet::from([self_guid]); + + for (guid, row_instance) in [(same_instance_peer, 0u64), (other_instance_peer, 7u64)] { + if peer_create_gate( + guid, + self_guid, + row_instance, + viewer_instance_id, + 0, + false, + false, + ) { + created.insert(guid); + } + } + assert!(created.contains(&same_instance_peer)); + assert!( + !created.contains(&other_instance_peer), + "a cross-instance resident must stay excluded" + ); +} + +#[test] +fn corpse_and_gameobject_creates_relay_only_within_the_viewers_instance_work_item_190_slice_2() { + // Open-world rows to an open-world viewer: byte-identical to the previous behavior (always relayed). + assert!(instance_relay_gate(0, Some(0))); + // A per-instance GO copy / in-instance corpse reaches ONLY viewers inside that instance… + assert!(instance_relay_gate(7, Some(7))); + assert!( + !instance_relay_gate(7, Some(0)), + "an open-world viewer must not see instance 7's rows" + ); + assert!( + !instance_relay_gate(7, Some(9)), + "another party's instance must not see them either" + ); + // …and an instanced viewer no longer receives the static instance-0 originals (they are the + // COPY SOURCES on the dungeon map — the copies are what render inside the run). + assert!(!instance_relay_gate(0, Some(7))); + // Unknown viewer instance (entity not resident — the AOI-on login window) relays NOTHING, + // not even instance 0: suppress-then-sweep, never guess. + assert!(!instance_relay_gate(0, None)); + assert!(!instance_relay_gate(7, None)); +} diff --git a/gateway/src/codec/guild/subscriptions.rs b/gateway/src/codec/guild/subscriptions.rs index 118dc121..7e787fff 100644 --- a/gateway/src/codec/guild/subscriptions.rs +++ b/gateway/src/codec/guild/subscriptions.rs @@ -334,3 +334,6 @@ crate::stdb::relays::declare_relays! { delete(Snapshot) => |context, _event, row| guild_membership_changed(&context.view, &guild_membership_read(&context.realm), row.character_guid); } } + +#[cfg(test)] +mod audience_tests; diff --git a/gateway/src/codec/guild/subscriptions/audience_tests.rs b/gateway/src/codec/guild/subscriptions/audience_tests.rs new file mode 100644 index 00000000..af0f13bf --- /dev/null +++ b/gateway/src/codec/guild/subscriptions/audience_tests.rs @@ -0,0 +1,742 @@ +use super::*; +use crate::stdb::bindings::{GuildEvent, GuildPetition}; +use crate::stdb::world_index::{CellKey, EntityLayer}; +use crate::stdb::world_view::test_support::{identity, queued_job, viewer_with_tx}; +use crate::stdb::world_view::{ + GuildMembershipRead, GuildRosterSnapshotRead, PetitionRead, WorldView, +}; +use crate::world::{GuildEventSnapshot, Outbound, SessionTx}; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::mpsc::Receiver; +use std::sync::Arc; +use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; + +fn raw_packets(outbound: Vec) -> Vec<(u16, Vec)> { + outbound + .into_iter() + .map(|out| match out { + Outbound::Raw { opcode, body } => (opcode, body), + _ => panic!("guild relays write raw packets"), + }) + .collect() +} + +fn membership(rows: &[(u64, u32, u32)]) -> GuildMembershipRead { + let rows = rows.to_vec(); + Arc::new(move |guid| { + rows.iter() + .find(|(member, _, _)| *member == guid) + .map(|(_, guild_id, rank_id)| (*guild_id, *rank_id)) + }) +} + +/// The broadcast (`0..=13`) and addressed-membership (`0x40..`) kinds these audience tests use +/// never read a Guild snapshot, so a closure that panics if called proves it. +fn unreachable_snapshot_read() -> GuildRosterSnapshotRead { + Arc::new(|_guild_id, _kind| panic!("this Guild Event kind must not read a snapshot")) +} + +/// A snapshot read that always answers `snapshot` and counts its own calls, so a test can +/// assert a settings-change event reads the Realm-core cache once and shares the result with +/// every recipient. +fn counting_snapshot_read( + snapshot: GuildEventSnapshot, +) -> (GuildRosterSnapshotRead, Arc) { + let reads = Arc::new(AtomicU32::new(0)); + let recorded = reads.clone(); + let read: GuildRosterSnapshotRead = Arc::new(move |_guild_id, _kind| { + recorded.fetch_add(1, Ordering::Relaxed); + snapshot.clone() + }); + (read, reads) +} + +/// SMSG_GUILD_ROSTER for one member whose officer note is `officer_note`, so two calls with +/// different notes give two packets a test can tell apart by content. +fn roster_variant(officer_note: &str) -> ServerOpcodeMessage { + let guild = crate::codec::GuildView { + guild_id: 7, + name: "Knights".into(), + ..Default::default() + }; + let line = crate::codec::GuildRosterLine { + guid: 9001, + name: "Alice".into(), + officer_note: officer_note.into(), + ..Default::default() + }; + ServerOpcodeMessage::SMSG_GUILD_ROSTER(Box::new(crate::codec::build_guild_roster( + &guild, + [line], + ))) +} + +#[test] +fn a_membership_change_reaches_the_member_and_viewers_that_created_it() { + let view = WorldView::new(true); + let anchor = CellKey::at(0, 0, 0, 0); + let (member_tx, member_rx) = SessionTx::with_depth(0); + let member = viewer_with_tx(1, 9001, identity(1), member_tx); + let (observer_tx, observer_rx) = SessionTx::with_depth(0); + let observer = viewer_with_tx(2, 9002, identity(2), observer_tx); + observer.created.lock().unwrap().insert(9001); + let (stranger_tx, stranger_rx) = SessionTx::with_depth(0); + let stranger = viewer_with_tx(3, 9003, identity(3), stranger_tx); + for viewer in [&member, &observer, &stranger] { + view.add_viewer_on_shard(viewer.clone(), anchor, 0); + } + view.spatial + .upsert_entity(EntityLayer::WorldEntity, 9001, anchor, 0, 0); + + guild_membership_changed(&view, &membership(&[(9001, 7, 0)]), 9001); + let founded = crate::codec::build_guild_values(9001, 7, 0); + assert_eq!(raw_packets(queued_job(&member_rx)), vec![founded.clone()]); + assert_eq!(raw_packets(queued_job(&observer_rx)), vec![founded]); + assert!(queued_job(&stranger_rx).is_empty()); + + guild_membership_changed(&view, &membership(&[]), 9001); + assert_eq!( + raw_packets(queued_job(&member_rx)), + vec![crate::codec::build_guild_values(9001, 0, 0)] + ); +} + +#[test] +fn a_removal_reaches_the_member_before_its_entity_is_indexed() { + let view = WorldView::new(true); + let anchor = CellKey::at(0, 0, 0, 0); + let (member_tx, member_rx) = SessionTx::with_depth(0); + let (other_tx, other_rx) = SessionTx::with_depth(0); + view.add_viewer_on_shard(viewer_with_tx(1, 9001, identity(1), member_tx), anchor, 0); + view.add_viewer_on_shard(viewer_with_tx(2, 9002, identity(2), other_tx), anchor, 0); + + guild_membership_changed(&view, &membership(&[]), 9001); + assert_eq!( + raw_packets(queued_job(&member_rx)), + vec![crate::codec::build_guild_values(9001, 0, 0)] + ); + assert!(other_rx.try_recv().is_err()); +} + +fn guild_event(recipient_guid: u64, kind: u8, subject_guid: u64) -> GuildEvent { + GuildEvent { + id: 1, + guild_id: 7, + recipient_guid, + kind, + subject_guid, + other_guid: 0, + strings: vec!["Alice".to_string()], + created_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, + } +} + +#[test] +fn a_broadcast_guild_event_reaches_online_members_and_nobody_else() { + use lyracore_shared::guild::event_kind; + let view = WorldView::new(true); + let anchor = CellKey::at(0, 0, 0, 0); + let mut receivers = Vec::new(); + for (session, guid) in [(1, 9001), (2, 9002), (3, 9003), (4, 9004)] { + let (tx, rx) = SessionTx::with_depth(0); + // Members stand on different Shards: the audience is the Guild, not the Shard. + view.add_viewer_on_shard( + viewer_with_tx(session, guid, identity(session as u8), tx), + anchor, + session as usize, + ); + receivers.push(rx); + } + let [alice, bob, stranger, other_guild] = <[_; 4]>::try_from(receivers).unwrap(); + let members = membership(&[(9001, 7, 0), (9002, 7, 4), (9004, 8, 0)]); + let snapshot_read = unreachable_snapshot_read(); + + guild_event_appeared( + &view, + &members, + &snapshot_read, + &guild_event(0, event_kind::SIGNED_OFF, 9001), + ); + let signed_off = + crate::codec::build_guild_event_raw(event_kind::SIGNED_OFF, &["Alice".to_string()], 9001); + assert_eq!(raw_packets(queued_job(&alice)), vec![signed_off.clone()]); + assert_eq!(raw_packets(queued_job(&bob)), vec![signed_off]); + assert!(stranger.try_recv().is_err()); + assert!(other_guild.try_recv().is_err()); + + guild_event_appeared( + &view, + &members, + &snapshot_read, + &guild_event(0, event_kind::SIGNED_ON, 9001), + ); + assert!( + alice.try_recv().is_err(), + "a member does not hear its own sign-on" + ); + assert_eq!(raw_packets(queued_job(&bob)).len(), 1); +} + +#[test] +fn an_addressed_guild_event_reaches_only_its_recipient() { + use lyracore_shared::guild::event_kind; + let view = WorldView::new(true); + let anchor = CellKey::at(0, 0, 0, 0); + let (recipient_tx, recipient_rx) = SessionTx::with_depth(0); + let (member_tx, member_rx) = SessionTx::with_depth(0); + view.add_viewer_on_shard( + viewer_with_tx(1, 9003, identity(1), recipient_tx), + anchor, + 0, + ); + view.add_viewer_on_shard(viewer_with_tx(2, 9001, identity(2), member_tx), anchor, 0); + + // DISBANDED (0x08) is addressed per former member, so it exercises the generic addressed + // path through the raw SMSG_GUILD_EVENT builder. + guild_event_appeared( + &view, + &membership(&[(9001, 7, 0)]), + &unreachable_snapshot_read(), + &guild_event(9003, event_kind::DISBANDED, 9001), + ); + assert_eq!(raw_packets(queued_job(&recipient_rx)).len(), 1); + assert!(member_rx.try_recv().is_err()); +} + +#[test] +fn tabard_changed_reaches_every_online_member_with_the_new_emblem() { + use lyracore_shared::guild::event_kind; + let view = WorldView::new(true); + let anchor = CellKey::at(0, 0, 0, 0); + let (leader_tx, leader_rx) = SessionTx::with_depth(0); + let (member_tx, member_rx) = SessionTx::with_depth(0); + view.add_viewer_on_shard(viewer_with_tx(1, 9001, identity(1), leader_tx), anchor, 0); + view.add_viewer_on_shard(viewer_with_tx(2, 9002, identity(2), member_tx), anchor, 1); + let emblem = crate::codec::GuildView { + guild_id: 7, + name: "Tabard Guild".into(), + emblem_style: 11, + emblem_color: 12, + border_style: 3, + border_color: 14, + background_color: 15, + ..Default::default() + }; + let query = ServerOpcodeMessage::SMSG_GUILD_QUERY_RESPONSE(Box::new( + crate::codec::build_guild_query_response(&emblem), + )); + let snapshot = GuildEventSnapshot { + query: Some(query.clone()), + ..GuildEventSnapshot::default() + }; + let (snapshot_read, reads) = counting_snapshot_read(snapshot); + let tabard_changed = GuildEvent { + strings: Vec::new(), + ..guild_event(0, event_kind::TABARD_CHANGED, 0) + }; + + guild_event_appeared( + &view, + &membership(&[(9001, 7, 0), (9002, 7, 4)]), + &snapshot_read, + &tabard_changed, + ); + + for rx in [&leader_rx, &member_rx] { + let [Outbound::Raw { opcode, body }, Outbound::One(response)] = + <[Outbound; 2]>::try_from(queued_job(rx)).ok().unwrap() + else { + panic!("TABARD_CHANGED renders the event, then the query response"); + }; + assert_eq!((opcode, body), (0x0092, vec![9, 0])); + assert_eq!(response, query); + } + assert_eq!( + reads.load(Ordering::Relaxed), + 1, + "one snapshot read serves every recipient" + ); +} + +/// `0x50` (`event_kind::ROSTER_TO_ACTOR`) is addressed, so it takes the same recipient-only +/// path SIGNED_ON's twin above already proves. This test pins that the actor's job reads the +/// shared snapshot and picks the roster its own Guild Rank sees. +#[test] +fn roster_to_actor_reaches_only_the_actor_with_the_roster_its_rank_sees() { + use lyracore_shared::guild::event_kind; + let view = WorldView::new(true); + let anchor = CellKey::at(0, 0, 0, 0); + let (actor_tx, actor_rx) = SessionTx::with_depth(0); + let (other_tx, other_rx) = SessionTx::with_depth(0); + view.add_viewer_on_shard(viewer_with_tx(1, 9001, identity(1), actor_tx), anchor, 0); + view.add_viewer_on_shard(viewer_with_tx(2, 9002, identity(2), other_tx), anchor, 0); + let shown = roster_variant("secret"); + let snapshot = GuildEventSnapshot { + roster_hidden: Some(roster_variant("")), + roster_shown: Some(shown.clone()), + rank_rights: vec![(0, 0xF_F1FF)], + ..GuildEventSnapshot::default() + }; + let (snapshot_read, reads) = counting_snapshot_read(snapshot); + + guild_event_appeared( + &view, + &membership(&[(9001, 7, 0), (9002, 7, 0)]), + &snapshot_read, + &guild_event(9001, event_kind::ROSTER_TO_ACTOR, 0), + ); + + let [Outbound::One(actual)] = <[Outbound; 1]>::try_from(queued_job(&actor_rx)) + .ok() + .unwrap() + else { + panic!("expected one roster packet"); + }; + assert_eq!(actual, shown, "rank 0 holds VIEWOFFNOTE in this snapshot"); + assert!(other_rx.try_recv().is_err()); + assert_eq!(reads.load(Ordering::Relaxed), 1); +} + +#[test] +fn roster_to_actor_with_no_membership_row_sends_nothing() { + use lyracore_shared::guild::event_kind; + let view = WorldView::new(true); + let anchor = CellKey::at(0, 0, 0, 0); + let (actor_tx, actor_rx) = SessionTx::with_depth(0); + view.add_viewer_on_shard(viewer_with_tx(1, 9001, identity(1), actor_tx), anchor, 0); + + guild_event_appeared( + &view, + &membership(&[]), + &unreachable_snapshot_read(), + &guild_event(9001, event_kind::ROSTER_TO_ACTOR, 0), + ); + + assert!(queued_job(&actor_rx).is_empty()); +} + +/// The same guard also has to check which Guild the membership row names: `map_or(0, ...)` +/// ignored the returned guild_id, so a member who moved to another Guild between this job's +/// enqueue and its run would still get a roster, rendered under its new Guild's own Rank +/// Rights. +#[test] +fn roster_to_actor_with_membership_in_another_guild_sends_nothing() { + use lyracore_shared::guild::event_kind; + let view = WorldView::new(true); + let anchor = CellKey::at(0, 0, 0, 0); + let (actor_tx, actor_rx) = SessionTx::with_depth(0); + view.add_viewer_on_shard(viewer_with_tx(1, 9001, identity(1), actor_tx), anchor, 0); + + guild_event_appeared( + &view, + &membership(&[(9001, 8, 0)]), + &unreachable_snapshot_read(), + &guild_event(9001, event_kind::ROSTER_TO_ACTOR, 0), + ); + + assert!(queued_job(&actor_rx).is_empty()); +} + +/// `0x80` (`event_kind::ROSTER_REFRESH`) is a broadcast kind, so it takes the same audience +/// path the SIGNED_OFF broadcast test above already proves. This test pins that every online +/// member's own job reads one shared snapshot and picks the roster its own rank sees, so a +/// large Guild costs one Realm-core read for the whole broadcast, not one per member. +#[test] +fn roster_refresh_gives_each_online_member_the_roster_its_own_rank_sees() { + use lyracore_shared::guild::event_kind; + let view = WorldView::new(true); + let anchor = CellKey::at(0, 0, 0, 0); + let mut receivers = Vec::new(); + for (session, guid) in [(1, 9001), (2, 9002), (3, 9003)] { + let (tx, rx) = SessionTx::with_depth(0); + view.add_viewer_on_shard( + viewer_with_tx(session, guid, identity(session as u8), tx), + anchor, + 0, + ); + receivers.push(rx); + } + let [leader, member, stranger] = <[_; 3]>::try_from(receivers).unwrap(); + let hidden = roster_variant(""); + let shown = roster_variant("secret"); + let query = ServerOpcodeMessage::SMSG_GUILD_QUERY_RESPONSE(Box::new( + crate::codec::build_guild_query_response(&crate::codec::GuildView { + guild_id: 7, + ..Default::default() + }), + )); + let snapshot = GuildEventSnapshot { + query: Some(query), + roster_hidden: Some(hidden.clone()), + roster_shown: Some(shown.clone()), + rank_rights: vec![(0, 0xF_F1FF), (4, 0x43)], + }; + let (snapshot_read, reads) = counting_snapshot_read(snapshot); + + guild_event_appeared( + &view, + &membership(&[(9001, 7, 0), (9002, 7, 4)]), + &snapshot_read, + &guild_event(0, event_kind::ROSTER_REFRESH, 0), + ); + + let leader_out = queued_job(&leader); + let member_out = queued_job(&member); + assert!(stranger.try_recv().is_err()); + let [Outbound::One(_), Outbound::One(leader_roster)] = + <[Outbound; 2]>::try_from(leader_out).ok().unwrap() + else { + panic!("expected a query response then a roster"); + }; + assert_eq!(leader_roster, shown, "rank 0 holds VIEWOFFNOTE"); + let [Outbound::One(_), Outbound::One(member_roster)] = + <[Outbound; 2]>::try_from(member_out).ok().unwrap() + else { + panic!("expected a query response then a roster"); + }; + assert_eq!(member_roster, hidden, "rank 4 cannot view officer notes"); + assert_eq!( + reads.load(Ordering::Relaxed), + 1, + "one snapshot read serves every recipient" + ); +} + +#[test] +fn an_invite_event_reaches_its_target_as_smsg_guild_invite() { + use lyracore_shared::guild::event_kind; + use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; + let view = WorldView::new(true); + let anchor = CellKey::at(0, 0, 0, 0); + let (target_tx, target_rx) = SessionTx::with_depth(0); + view.add_viewer_on_shard(viewer_with_tx(1, 9003, identity(1), target_tx), anchor, 0); + + let mut row = guild_event(9003, event_kind::INVITE, 0); + row.strings = vec!["Alice".to_string(), "Tracer Guild".to_string()]; + guild_event_appeared(&view, &membership(&[]), &unreachable_snapshot_read(), &row); + + let outbound = queued_job(&target_rx); + let [Outbound::One(ServerOpcodeMessage::SMSG_GUILD_INVITE(invite))] = outbound.as_slice() + else { + panic!( + "expected exactly one SMSG_GUILD_INVITE, got {} packets", + outbound.len() + ); + }; + assert_eq!(invite.player_name, "Alice"); + assert_eq!(invite.guild_name, "Tracer Guild"); +} + +#[test] +fn a_decline_event_reaches_the_inviter_as_raw_smsg_guild_decline() { + use lyracore_shared::guild::event_kind; + let view = WorldView::new(true); + let anchor = CellKey::at(0, 0, 0, 0); + let (inviter_tx, inviter_rx) = SessionTx::with_depth(0); + view.add_viewer_on_shard(viewer_with_tx(1, 9003, identity(1), inviter_tx), anchor, 0); + + let row = guild_event(9003, event_kind::DECLINE, 0); + guild_event_appeared(&view, &membership(&[]), &unreachable_snapshot_read(), &row); + + assert_eq!( + raw_packets(queued_job(&inviter_rx)), + vec![(0x0086, b"Alice\0".to_vec())] + ); +} + +const CHARTER: u64 = 0x4000_0000_0000_0101; + +fn night_watch() -> crate::codec::PetitionView { + crate::codec::PetitionView { + petition_id: 42, + charter_item_guid: CHARTER, + owner_guid: 9001, + name: "Night Watch".into(), + signers: vec![9002], + } +} + +/// A Petition read that answers `petition` for the Charter and counts its calls. +fn counting_petition_read( + petition: Option, +) -> (PetitionRead, Arc) { + let reads = Arc::new(AtomicU32::new(0)); + let recorded = reads.clone(); + let read: PetitionRead = Arc::new(move |charter_item_guid| { + recorded.fetch_add(1, Ordering::Relaxed); + petition + .clone() + .filter(|petition| petition.charter_item_guid == charter_item_guid) + }); + (read, reads) +} + +fn unreachable_petition_read() -> PetitionRead { + Arc::new(|_charter_item_guid| panic!("this petition kind must not read a Petition")) +} + +fn petition_event(recipient_guid: u64, kind: u8, subject_guid: u64) -> GuildEvent { + GuildEvent { + guild_id: 0, + other_guid: CHARTER, + strings: Vec::new(), + ..guild_event(recipient_guid, kind, subject_guid) + } +} + +/// Registers the recipient 9003 and a bystander 9004 on one shard. +fn recipient_and_bystander(view: &WorldView) -> (Receiver, Receiver) { + let anchor = CellKey::at(0, 0, 0, 0); + let (recipient_tx, recipient_rx) = SessionTx::with_depth(0); + let (bystander_tx, bystander_rx) = SessionTx::with_depth(0); + view.add_viewer_on_shard( + viewer_with_tx(1, 9003, identity(1), recipient_tx), + anchor, + 0, + ); + view.add_viewer_on_shard( + viewer_with_tx(2, 9004, identity(2), bystander_tx), + anchor, + 0, + ); + (recipient_rx, bystander_rx) +} + +#[test] +fn an_offer_shows_its_target_the_signature_window_read_once() { + use lyracore_shared::guild::event_kind; + let view = WorldView::new(true); + let (target_rx, bystander_rx) = recipient_and_bystander(&view); + let (read, reads) = counting_petition_read(Some(night_watch())); + + petition_event_appeared( + &view, + &read, + &petition_event(9003, event_kind::PETITION_OFFERED, 9001), + ); + + let outbound = queued_job(&target_rx); + let [Outbound::One(ServerOpcodeMessage::SMSG_PETITION_SHOW_SIGNATURES(window))] = + outbound.as_slice() + else { + panic!("expected one SMSG_PETITION_SHOW_SIGNATURES"); + }; + assert_eq!( + **window, + crate::codec::build_petition_show_signatures(&night_watch()) + ); + assert_eq!(reads.load(Ordering::Relaxed), 1); + assert!(bystander_rx.try_recv().is_err()); +} + +#[test] +fn signature_events_render_sign_results_for_the_charter_and_the_signer() { + use lyracore_shared::guild::event_kind; + use wow_world_messages::vanilla::PetitionResult; + let view = WorldView::new(true); + let (owner_rx, _bystander_rx) = recipient_and_bystander(&view); + for (kind, result) in [ + (event_kind::PETITION_SIGNED, PetitionResult::Ok), + ( + event_kind::PETITION_ALREADY_SIGNED, + PetitionResult::AlreadySigned, + ), + ] { + petition_event_appeared( + &view, + &unreachable_petition_read(), + &petition_event(9003, kind, 9002), + ); + let outbound = queued_job(&owner_rx); + let [Outbound::One(ServerOpcodeMessage::SMSG_PETITION_SIGN_RESULTS(results))] = + outbound.as_slice() + else { + panic!("expected one SMSG_PETITION_SIGN_RESULTS"); + }; + assert_eq!( + ( + results.petition.guid(), + results.owner.guid(), + results.result + ), + (CHARTER, 9002, result) + ); + } +} + +#[test] +fn a_decline_event_names_the_decliner_to_the_owner() { + use lyracore_shared::guild::event_kind; + let view = WorldView::new(true); + let (owner_rx, _bystander_rx) = recipient_and_bystander(&view); + petition_event_appeared( + &view, + &unreachable_petition_read(), + &petition_event(9003, event_kind::PETITION_DECLINED, 9002), + ); + let outbound = queued_job(&owner_rx); + let [Outbound::One(ServerOpcodeMessage::MSG_PETITION_DECLINE(declined))] = outbound.as_slice() + else { + panic!("expected one MSG_PETITION_DECLINE"); + }; + assert_eq!(declined.petition.guid(), 9002); +} + +#[test] +fn a_founder_event_names_the_new_guild() { + use lyracore_shared::guild::event_kind; + use wow_world_messages::vanilla::{GuildCommand, GuildCommandResult}; + let view = WorldView::new(true); + let (signer_rx, _bystander_rx) = recipient_and_bystander(&view); + let row = GuildEvent { + guild_id: 12, + strings: vec!["Night Watch".into()], + ..petition_event(9003, event_kind::FOUNDER, 0) + }; + petition_event_appeared(&view, &unreachable_petition_read(), &row); + let outbound = queued_job(&signer_rx); + let [Outbound::One(ServerOpcodeMessage::SMSG_GUILD_COMMAND_RESULT(result))] = + outbound.as_slice() + else { + panic!("expected one SMSG_GUILD_COMMAND_RESULT"); + }; + assert_eq!( + (result.command, result.string.as_str(), result.result), + ( + GuildCommand::Founder, + "Night Watch", + GuildCommandResult::PlayerNoMoreInGuild + ) + ); +} + +#[test] +fn a_lost_signature_sends_the_owner_a_fresh_petition_query() { + use lyracore_shared::guild::event_kind; + let view = WorldView::new(true); + let (owner_rx, _bystander_rx) = recipient_and_bystander(&view); + let (read, _) = counting_petition_read(Some(night_watch())); + petition_event_appeared( + &view, + &read, + &petition_event(9003, event_kind::PETITION_CHANGED, 9002), + ); + let outbound = queued_job(&owner_rx); + let [Outbound::One(ServerOpcodeMessage::SMSG_PETITION_QUERY_RESPONSE(query))] = + outbound.as_slice() + else { + panic!("expected one SMSG_PETITION_QUERY_RESPONSE"); + }; + assert_eq!( + ( + query.petition_id, + query.charter_owner.guid(), + query.guild_name.as_str() + ), + (42, 9001, "Night Watch") + ); + + let (gone, _) = counting_petition_read(None); + petition_event_appeared( + &view, + &gone, + &petition_event(9003, event_kind::PETITION_CHANGED, 9002), + ); + assert!( + queued_job(&owner_rx).is_empty(), + "a Petition gone by the time the job runs renders nothing" + ); +} + +#[test] +fn a_new_petition_shows_its_id_on_the_owners_charter() { + let view = WorldView::new(true); + let (owner_rx, bystander_rx) = recipient_and_bystander(&view); + charter_petition_opened( + &view, + &GuildPetition { + petition_id: 42, + charter_item_guid: CHARTER, + owner_guid: 9003, + owner_name: "Owner".into(), + team: 469, + name: "Night Watch".into(), + created_micros: 0, + }, + ); + let packets = raw_packets(queued_job(&owner_rx)); + assert_eq!(packets.len(), 1); + assert_eq!(packets[0].0, 0x00A9); + let updates = lyracore_shared::values_mask::parse_values_updates(&packets[0].1); + assert_eq!(updates.len(), 1); + assert_eq!(updates[0].guid, CHARTER); + assert_eq!(updates[0].fields, vec![(22, 42)]); + assert!(bystander_rx.try_recv().is_err()); +} + +#[test] +fn a_disband_reaches_each_former_member_exactly_once() { + use lyracore_shared::guild::event_kind; + let view = WorldView::new(true); + let anchor = CellKey::at(0, 0, 0, 0); + let mut receivers = Vec::new(); + for (session, guid) in [(1, 9001), (2, 9002)] { + let (tx, rx) = SessionTx::with_depth(0); + view.add_viewer_on_shard( + viewer_with_tx(session, guid, identity(session as u8), tx), + anchor, + 0, + ); + receivers.push(rx); + } + let [alice_rx, bob_rx] = <[_; 2]>::try_from(receivers).unwrap(); + // A disbanded Guild's members are already gone by the time the relay job runs, so + // `membership` answers `None` for both. DISBANDED is addressed, not read from current + // membership. + let no_members = membership(&[]); + + guild_event_appeared( + &view, + &no_members, + &unreachable_snapshot_read(), + &guild_event(9001, event_kind::DISBANDED, 0), + ); + guild_event_appeared( + &view, + &no_members, + &unreachable_snapshot_read(), + &guild_event(9002, event_kind::DISBANDED, 0), + ); + + assert_eq!(queued_job(&alice_rx).len(), 1); + assert_eq!(queued_job(&bob_rx).len(), 1); +} + +#[test] +fn a_removed_event_does_not_reach_the_removed_member() { + use lyracore_shared::guild::event_kind; + let view = WorldView::new(true); + let anchor = CellKey::at(0, 0, 0, 0); + let (remaining_tx, remaining_rx) = SessionTx::with_depth(0); + let (removed_tx, removed_rx) = SessionTx::with_depth(0); + view.add_viewer_on_shard( + viewer_with_tx(1, 9001, identity(1), remaining_tx), + anchor, + 0, + ); + // The removed Character's viewer is still on this Gateway (mid-logout, or simply still + // connected); its membership is already gone, so `online_audience` no longer includes it. + view.add_viewer_on_shard(viewer_with_tx(2, 9002, identity(2), removed_tx), anchor, 0); + + // Only 9001 remains a member of guild 7; 9002 was just removed. + let members = membership(&[(9001, 7, 0)]); + guild_event_appeared( + &view, + &members, + &unreachable_snapshot_read(), + &guild_event(0, event_kind::REMOVED, 0), + ); + + assert_eq!(queued_job(&remaining_rx).len(), 1); + assert!(removed_rx.try_recv().is_err()); +} diff --git a/gateway/src/codec/item/subscriptions.rs b/gateway/src/codec/item/subscriptions.rs index 70a6e3b3..b034da7a 100644 --- a/gateway/src/codec/item/subscriptions.rs +++ b/gateway/src/codec/item/subscriptions.rs @@ -608,3 +608,9 @@ crate::stdb::relays::declare_relays! { insert(Live) => |context, _event, row| trade_event_appeared(&context.view, context.shard, row); } } + +#[cfg(test)] +mod packet_tests; + +#[cfg(test)] +mod audience_tests; diff --git a/gateway/src/codec/item/subscriptions/audience_tests.rs b/gateway/src/codec/item/subscriptions/audience_tests.rs new file mode 100644 index 00000000..54131af7 --- /dev/null +++ b/gateway/src/codec/item/subscriptions/audience_tests.rs @@ -0,0 +1,335 @@ +use super::*; +use crate::stdb::bindings::ItemInstance; +use crate::stdb::world_index::{CellKey, EntityLayer}; +use crate::stdb::world_view::test_support::{identity, queued_job, viewer_with_tx}; +use crate::stdb::world_view::WorldView; +use crate::world::{Outbound, SessionTx}; +use std::sync::{Arc, Mutex}; +use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; + +fn worn_item(slot: u8) -> ItemInstance { + ItemInstance { + guid: 700, + entry: 1337, + owner_identity: identity(1), + owner_guid: 9001, + slot, + stack_count: 1, + durability: 20, + created_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, + enchant_id: 7745, + soulbound: false, + random_property_id: 117, + item_text_id: 0, + } +} + +fn visible_items(out: Vec) -> Vec<(u8, u32, u32, u32)> { + use wow_world_messages::vanilla::{Object, ServerMessage, UpdateMask, VisibleItemIndex}; + let mut items = Vec::new(); + for message in out { + let Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(update)) = message else { + panic!("peers receive only visible item VALUES"); + }; + let [Object::Values { + guid1, + mask1: UpdateMask::Player(mask), + }] = update.objects.as_slice() + else { + panic!("peers receive no private item objects"); + }; + assert_eq!(guid1.guid(), 9001); + for slot in 0..=18 { + if let Some(item) = mask.player_visible_item(VisibleItemIndex::try_from(slot).unwrap()) + { + assert_eq!(item.enchants[1], 0); + items.push((slot, item.item, item.random_property_id, item.enchants[0])); + } + } + let mut bytes = Vec::new(); + update.write_unencrypted_server(&mut bytes).unwrap(); + let decoded = lyracore_shared::values_mask::parse_values_updates(&bytes[4..]); + assert_eq!(decoded.len(), 1); + // Build 5875 exposes nineteen 12-word visible items starting at field 258. + assert!( + decoded[0] + .fields + .iter() + .all(|&(field, _)| (258..486).contains(&field)), + "no TYPE, inventory pointers, item fields or Character sheet fields" + ); + } + items +} + +#[test] +fn visible_gear_changes_reach_peers_with_current_slot_contents() { + let view = WorldView::new(true); + let (tx, rx) = SessionTx::with_depth(0); + let peer = viewer_with_tx(2, 9002, identity(2), tx); + peer.created.lock().unwrap().insert(9001); + let anchor = CellKey::at(0, 0, 0, 0); + view.add_viewer_on_shard(peer, anchor, 0); + view.spatial + .upsert_entity(EntityLayer::WorldEntity, 9001, anchor, 0, 0); + let committed = Arc::new(Mutex::new(Vec::::new())); + let read: super::ItemSlotRead = { + let committed = committed.clone(); + Arc::new(move |owner, slot| { + committed + .lock() + .unwrap() + .iter() + .find(|item| item.owner_guid == owner && item.slot == slot) + .map(crate::stdb::reads::view_of_item_row) + }) + }; + for slot in [0, 8, 18] { + let item = worn_item(slot); + *committed.lock().unwrap() = vec![item.clone()]; + item_visible_changed(&view, 0, None, Some(&item), read.clone()); + assert_eq!(visible_items(queued_job(&rx)), [(slot, 1337, 117, 823)]); + } + let item = worn_item(8); + + for changed in [ + ItemInstance { + enchant_id: 7748, + ..item.clone() + }, + ItemInstance { + enchant_id: 0, + ..item.clone() + }, + ItemInstance { + entry: 42, + ..item.clone() + }, + ItemInstance { + random_property_id: 118, + ..item.clone() + }, + ] { + *committed.lock().unwrap() = vec![changed.clone()]; + item_visible_changed(&view, 0, Some(&item), Some(&changed), read.clone()); + let enchant = match changed.enchant_id { + 7748 => 724, + 0 => 0, + _ => 823, + }; + assert_eq!( + visible_items(queued_job(&rx)), + [(8, changed.entry, changed.random_property_id, enchant)] + ); + } + + let bagged = ItemInstance { + slot: 23, + ..item.clone() + }; + *committed.lock().unwrap() = vec![bagged.clone()]; + item_visible_changed(&view, 0, Some(&item), Some(&bagged), read.clone()); + assert_eq!(visible_items(queued_job(&rx)), [(8, 0, 0, 0)]); + *committed.lock().unwrap() = vec![item.clone()]; + item_visible_changed(&view, 0, Some(&bagged), Some(&item), read.clone()); + assert_eq!(visible_items(queued_job(&rx)), [(8, 1337, 117, 823)]); + + committed.lock().unwrap().clear(); + item_visible_changed(&view, 0, Some(&item), None, read); + assert_eq!(visible_items(queued_job(&rx)), [(8, 0, 0, 0)]); + assert!(rx.try_recv().is_err()); +} + +#[test] +fn visible_gear_swap_and_delete_read_replacements_when_the_writer_runs() { + let view = WorldView::new(true); + let (tx, rx) = SessionTx::with_depth(0); + let peer = viewer_with_tx(2, 9002, identity(2), tx); + peer.created.lock().unwrap().insert(9001); + let anchor = CellKey::at(0, 0, 0, 0); + view.add_viewer_on_shard(peer, anchor, 0); + view.spatial + .upsert_entity(EntityLayer::WorldEntity, 9001, anchor, 0, 0); + let old = worn_item(8); + let moved = ItemInstance { + slot: 23, + ..old.clone() + }; + let committed = Arc::new(Mutex::new(vec![old.clone()])); + let read: super::ItemSlotRead = { + let committed = committed.clone(); + Arc::new(move |owner, slot| { + committed + .lock() + .unwrap() + .iter() + .find(|item| item.owner_guid == owner && item.slot == slot) + .map(crate::stdb::reads::view_of_item_row) + }) + }; + item_visible_changed(&view, 0, Some(&old), Some(&moved), read.clone()); + item_visible_changed(&view, 0, Some(&old), None, read); + *committed.lock().unwrap() = vec![ItemInstance { + guid: 701, + entry: 42, + enchant_id: 7748, + ..old + }]; + for _ in 0..2 { + assert_eq!(visible_items(queued_job(&rx)), [(8, 42, 117, 724)]); + } + assert!(rx.try_recv().is_err()); +} + +#[test] +fn visible_gear_is_scoped_to_peers_that_still_see_the_character() { + let view = WorldView::new(true); + let anchor = CellKey::at(0, 0, 0, 0); + view.spatial + .upsert_entity(EntityLayer::WorldEntity, 9001, anchor, 0, 0); + let item = worn_item(8); + let read: super::ItemSlotRead = + Arc::new(|_, _| Some(crate::stdb::reads::view_of_item_row(&worn_item(8)))); + let mut queues = Vec::new(); + for (session, guid, cell, shard) in [ + (1, 9001, anchor, 0), + (2, 9002, anchor, 0), + (3, 9003, CellKey::at(0, 0, 10, 0), 0), + (4, 9004, CellKey::at(0, 1, 0, 0), 0), + (5, 9005, CellKey::at(1, 0, 0, 0), 0), + (6, 9006, anchor, 1), + ] { + let (tx, rx) = SessionTx::with_depth(0); + let viewer = viewer_with_tx(session, guid, identity(session as u8), tx); + viewer.created.lock().unwrap().insert(9001); + view.add_viewer_on_shard(viewer.clone(), cell, shard); + queues.push((viewer, rx)); + } + let (peer, rx) = &queues[1]; + item_visible_changed(&view, 0, None, Some(&item), read.clone()); + peer.created.lock().unwrap().remove(&9001); + assert!(queued_job(rx).is_empty(), "hidden before the writer runs"); + item_visible_changed(&view, 0, None, Some(&item), read.clone()); + peer.created.lock().unwrap().insert(9001); + assert_eq!( + visible_items(queued_job(rx)), + [(8, 1337, 117, 823)], + "CREATE ran before the gear job" + ); + item_visible_changed(&view, 0, None, Some(&item), read.clone()); + view.remove_viewer(peer.session); + assert!(queued_job(rx).is_empty(), "departed World Session"); + for (_, rx) in &queues { + assert!( + rx.try_recv().is_err(), + "owner, distant, other map/instance/Shard receive no peer job" + ); + } + view.spatial + .upsert_entity(EntityLayer::WorldEntity, 9001, anchor, 1, 0); + item_visible_changed(&view, 0, None, Some(&item), read); + for (_, rx) in &queues { + assert!( + rx.try_recv().is_err(), + "source Shard cannot relay after Transfer" + ); + } +} + +#[test] +fn visible_gear_ignores_bagged_items_and_changes_to_private_fields() { + let view = WorldView::new(true); + let anchor = CellKey::at(0, 0, 0, 0); + let (tx, rx) = SessionTx::with_depth(0); + view.add_viewer_on_shard(viewer_with_tx(2, 9002, identity(2), tx), anchor, 0); + view.spatial + .upsert_entity(EntityLayer::WorldEntity, 9001, anchor, 0, 0); + let read: super::ItemSlotRead = Arc::new(|_, _| panic!("no gear read needed")); + for slot in [19, 23, 120] { + let bagged = worn_item(slot); + item_visible_changed(&view, 0, None, Some(&bagged), read.clone()); + item_visible_changed(&view, 0, Some(&bagged), None, read.clone()); + item_visible_changed( + &view, + 0, + Some(&bagged), + Some(&ItemInstance { + enchant_id: 0, + ..bagged.clone() + }), + read.clone(), + ); + } + let old = worn_item(8); + let new = ItemInstance { + durability: 0, + stack_count: 3, + soulbound: true, + ..old.clone() + }; + item_visible_changed(&view, 0, Some(&old), Some(&new), read); + assert!(rx.try_recv().is_err()); +} + +#[test] +fn item_rows_route_only_to_the_owner_and_follow_transfer_replacement() { + let view = WorldView::new(true); + let (source_tx, source_rx) = SessionTx::with_depth(0); + let (other_tx, other_rx) = SessionTx::with_depth(0); + let (destination_tx, destination_rx) = SessionTx::with_depth(0); + let source = viewer_with_tx(1, 9001, identity(1), source_tx); + let other = viewer_with_tx(2, 9002, identity(2), other_tx); + let destination = viewer_with_tx(3, 9001, identity(1), destination_tx); + let anchor = CellKey::at(0, 0, 0, 0); + view.add_viewer_on_shard(source.clone(), anchor, 0); + view.add_viewer_on_shard(other, anchor, 0); + + item_owner_job(&view, 0, source.self_guid, |_| { + vec![Outbound::Raw { + opcode: 1, + body: vec![1], + }] + }); + assert!(matches!( + queued_job(&source_rx).as_slice(), + [Outbound::Raw { .. }] + )); + assert!( + other_rx.try_recv().is_err(), + "an unrelated item owner receives nothing" + ); + + view.remove_viewer(source.session); + item_owner_job(&view, 0, source.self_guid, |_| { + vec![Outbound::Raw { + opcode: 2, + body: vec![2], + }] + }); + assert!( + source_rx.try_recv().is_err(), + "a removed source viewer receives no cascade delete" + ); + + view.add_viewer_on_shard(destination, anchor, 1); + item_owner_job(&view, 0, source.self_guid, |_| { + vec![Outbound::Raw { + opcode: 2, + body: vec![2], + }] + }); + assert!( + destination_rx.try_recv().is_err(), + "a source-shard delta applied after destination registration must be dropped" + ); + item_owner_job(&view, 1, source.self_guid, |_| { + vec![Outbound::Raw { + opcode: 3, + body: vec![3], + }] + }); + assert!(matches!( + queued_job(&destination_rx).as_slice(), + [Outbound::Raw { opcode: 3, .. }] + )); +} diff --git a/gateway/src/codec/item/subscriptions/packet_tests.rs b/gateway/src/codec/item/subscriptions/packet_tests.rs new file mode 100644 index 00000000..09d3870f --- /dev/null +++ b/gateway/src/codec/item/subscriptions/packet_tests.rs @@ -0,0 +1,522 @@ +use super::*; +use crate::codec; +use crate::stdb::bindings::{ItemInstance, TradeEvent}; +use crate::stdb::reads::view_of_item_row; +use crate::world::Outbound; +use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; + +/// The trade-status wire mapping: every `lyracore_shared::trade::event_kind` the module +/// emits decodes to its `SMSG_TRADE_STATUS` variant — `BeginTrade` carrying the counterparty +/// guid the client needs to open the window — and an unknown kind (newer module mid-rollout) +/// drops rather than desyncing the trade window. +#[test] +fn trade_event_kinds_decode_to_their_trade_status_variants() { + use lyracore_shared::trade::event_kind as kind; + use wow_world_messages::vanilla::SMSG_TRADE_STATUS; + + let event = |k: u8| TradeEvent { + id: 1, + recipient_identity: spacetimedb_sdk::Identity::from_byte_array([0u8; 32]), + kind: k, + other_guid: 77, + created_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, + recipient_guid: 1, + payload: String::new(), + }; + let status_of = |k: u8| -> SMSG_TRADE_STATUS { + let out = trade_event_outbound(&event(k)); + assert_eq!(out.len(), 1, "kind {k} must decode to exactly one packet"); + match &out[0] { + Outbound::One(ServerOpcodeMessage::SMSG_TRADE_STATUS(s)) => **s, + Outbound::One(other) => panic!("kind {k}: expected SMSG_TRADE_STATUS, got {other}"), + _ => panic!("kind {k}: expected a single SMSG_TRADE_STATUS packet"), + } + }; + + assert_eq!( + status_of(kind::BEGIN_TRADE), + SMSG_TRADE_STATUS::BeginTrade { + unknown1: wow_world_messages::Guid::new(77) + }, + "BeginTrade must carry the initiator guid off the row" + ); + assert_eq!(status_of(kind::OPEN_WINDOW), SMSG_TRADE_STATUS::OpenWindow); + assert_eq!( + status_of(kind::TRADE_CANCELED), + SMSG_TRADE_STATUS::TradeCanceled + ); + assert_eq!(status_of(kind::BUSY), SMSG_TRADE_STATUS::Busy); + assert_eq!(status_of(kind::NO_TARGET), SMSG_TRADE_STATUS::NoTarget); + assert_eq!( + status_of(kind::TARGET_TO_FAR), + SMSG_TRADE_STATUS::TargetToFar + ); + assert_eq!( + status_of(kind::WRONG_FACTION), + SMSG_TRADE_STATUS::WrongFaction + ); + assert_eq!(status_of(kind::YOU_DEAD), SMSG_TRADE_STATUS::YouDead); + assert_eq!(status_of(kind::TARGET_DEAD), SMSG_TRADE_STATUS::TargetDead); + assert_eq!(status_of(kind::IGNORE_YOU), SMSG_TRADE_STATUS::IgnoreYou); + assert_eq!( + status_of(kind::TRADE_ACCEPT), + SMSG_TRADE_STATUS::TradeAccept + ); + assert_eq!( + status_of(kind::BACK_TO_TRADE), + SMSG_TRADE_STATUS::BackToTrade + ); + assert_eq!( + status_of(kind::TRADE_COMPLETE), + SMSG_TRADE_STATUS::TradeComplete + ); + // The bag-space refusal closes the window naming WHOSE bags overflowed. + let full_self = status_of(kind::INV_FULL_SELF); + let full_partner = status_of(kind::INV_FULL_PARTNER); + match (&full_self, &full_partner) { + ( + SMSG_TRADE_STATUS::CloseWindow { + target_error: false, + inventory_result: a, + .. + }, + SMSG_TRADE_STATUS::CloseWindow { + target_error: true, + inventory_result: b, + .. + }, + ) => { + assert_eq!( + *a, + wow_world_messages::vanilla::InventoryResult::InventoryFull + ); + assert_eq!( + *b, + wow_world_messages::vanilla::InventoryResult::InventoryFull + ); + } + other => panic!("expected CloseWindow pair with flipped target_error, got {other:?}"), + } + // The purse-failure pair mirrors it with NotEnoughMoney. + match ( + &status_of(kind::GOLD_FAIL_SELF), + &status_of(kind::GOLD_FAIL_PARTNER), + ) { + ( + SMSG_TRADE_STATUS::CloseWindow { + target_error: false, + inventory_result: a, + .. + }, + SMSG_TRADE_STATUS::CloseWindow { + target_error: true, + inventory_result: b, + .. + }, + ) => { + assert_eq!( + *a, + wow_world_messages::vanilla::InventoryResult::NotEnoughMoney + ); + assert_eq!( + *b, + wow_world_messages::vanilla::InventoryResult::NotEnoughMoney + ); + } + other => panic!("expected NotEnoughMoney CloseWindow pair, got {other:?}"), + } + + assert!( + trade_event_outbound(&event(200)).is_empty(), + "an unknown kind must drop, not guess a status" + ); +} + +/// The OFFER_* kinds decode to the fixed-444-byte extended status: the polarity byte +/// comes from the KIND (never inferred), the window-visible item fields survive the payload +/// round-trip into the right wire slots, unused slots stay zeroed, and a malformed payload +/// drops the packet entirely. +#[test] +fn offer_events_decode_to_the_extended_status_with_the_kind_deciding_self_player() { + use lyracore_shared::trade::{encode_offer, event_kind as kind, OfferSlot}; + use wow_world_messages::vanilla::SMSG_TRADE_STATUS_EXTENDED; + + let payload = encode_offer( + 1_2345, + &[OfferSlot { + trade_slot: 6, + entry: 6948, + display_id: 6418, + stack_count: 5, + enchantment: 2564, + durability: 34, + max_durability: 40, + random_property_id: 0, + }], + ); + let event = |k: u8, p: &str| TradeEvent { + id: 1, + recipient_identity: spacetimedb_sdk::Identity::from_byte_array([0u8; 32]), + kind: k, + other_guid: 77, + created_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, + recipient_guid: 1, + payload: p.to_string(), + }; + let extended = |k: u8| -> SMSG_TRADE_STATUS_EXTENDED { + let out = trade_event_outbound(&event(k, &payload)); + match out.first() { + Some(Outbound::One(ServerOpcodeMessage::SMSG_TRADE_STATUS_EXTENDED(m))) => **m, + _ => panic!("kind {k}: expected one SMSG_TRADE_STATUS_EXTENDED"), + } + }; + + // Wire polarity (mangoszero): the `self_player` BYTE is "1 means traders data, 0 means + // own", so your OWN echo carries 0 and the partner's side carries 1. + let own = extended(kind::OFFER_SELF); + assert!( + !own.self_player, + "OFFER_SELF describes your own window: wire byte 0" + ); + let theirs = extended(kind::OFFER_PARTNER); + assert!( + theirs.self_player, + "OFFER_PARTNER describes the partner's side: wire byte 1" + ); + + assert_eq!(own.money_in_trade.as_int(), 1_2345); + assert_eq!((own.trade_slot_count1, own.trade_slot_count2), (7, 7)); + let s = &own.trade_slots[6]; + assert_eq!( + (s.trade_slot_number, s.item, s.display_id, s.stack_count), + (6, 6948, 6418, 5) + ); + assert_eq!( + (s.enchantment, s.durability, s.max_durability), + (2564, 34, 40) + ); + assert_eq!( + own.trade_slots[0], + Default::default(), + "untouched slots stay zeroed" + ); + + assert!( + trade_event_outbound(&event(kind::OFFER_SELF, "not|a,valid,payload")).is_empty(), + "a malformed offer payload must drop the packet" + ); +} + +#[test] +fn a_compatibility_enchant_in_an_offer_goes_out_as_its_client_enchantment() { + use lyracore_shared::trade::{encode_offer, event_kind as kind, OfferSlot}; + let slot = |trade_slot, enchantment| OfferSlot { + trade_slot, + entry: 6948, + display_id: 6418, + stack_count: 1, + enchantment, + durability: 40, + max_durability: 40, + random_property_id: 0, + }; + let payload = encode_offer(0, &[slot(0, 7745), slot(1, 7748)]); + let event = TradeEvent { + id: 1, + recipient_identity: spacetimedb_sdk::Identity::from_byte_array([0u8; 32]), + kind: kind::OFFER_SELF, + other_guid: 77, + created_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, + recipient_guid: 1, + payload, + }; + let out = trade_event_outbound(&event); + let Some(Outbound::One(ServerOpcodeMessage::SMSG_TRADE_STATUS_EXTENDED(extended))) = + out.first() + else { + panic!("expected one SMSG_TRADE_STATUS_EXTENDED"); + }; + assert_eq!(extended.trade_slots[0].enchantment, 823); + assert_eq!(extended.trade_slots[1].enchantment, 724); +} + +#[test] +fn bag_content_parts_boundaries() { + assert_eq!(codec::bag_content_parts(119), None); // just below the bag-content range + assert_eq!(codec::bag_content_parts(120), Some((19, 0))); // first bag, first slot + assert_eq!(codec::bag_content_parts(137), Some((19, 17))); // first bag, last slot (0..MAX_BAG_SIZE) + assert_eq!(codec::bag_content_parts(138), Some((20, 0))); // second bag, first slot + assert_eq!(codec::bag_content_parts(191), Some((22, 17))); // fourth (last) bag, last slot + assert_eq!(codec::bag_content_parts(192), None); +} + +fn item_view(guid: u64, entry: u32) -> codec::ItemInstanceView { + codec::ItemInstanceView { + guid, + entry, + ..Default::default() + } +} + +fn item_row(slot: u8, enchant_id: u32) -> ItemInstance { + ItemInstance { + guid: 0x4000_0000_0000_0007, + entry: 1337, + owner_identity: spacetimedb_sdk::Identity::from_byte_array([0u8; 32]), + owner_guid: 7, + slot, + stack_count: 1, + durability: 20, + created_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, + enchant_id, + soulbound: false, + random_property_id: 0, + item_text_id: 0, + } +} + +/// The enchantment word of the item update and the first enchant word of the visible item in an +/// in-place enchant outbound, with the raw field indexes of every VALUES block. +fn enchant_words(out: &[Outbound]) -> (Option, Option, Vec>) { + use wow_world_messages::vanilla::{Object, ServerMessage, UpdateMask, VisibleItemIndex}; + let (mut item_word, mut visible_word) = (None, None); + let mut field_indexes = Vec::new(); + for message in out { + let Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(update)) = message else { + continue; + }; + let mut bytes = Vec::new(); + update.write_unencrypted_server(&mut bytes).unwrap(); + for values in lyracore_shared::values_mask::parse_values_updates(&bytes[4..]) { + field_indexes.push(values.fields.iter().map(|&(idx, _)| idx).collect()); + } + match &update.objects[0] { + Object::Values { + mask1: UpdateMask::Item(item), + .. + } => item_word = item.item_enchantment().map(|word| word as u32), + Object::Values { + mask1: UpdateMask::Player(player), + .. + } => { + visible_word = player + .player_visible_item(VisibleItemIndex::Index8) + .map(|visible| visible.enchants[0]) + } + _ => {} + } + } + (item_word, visible_word, field_indexes) +} + +#[test] +fn enchanting_a_worn_item_in_place_updates_its_enchant_word_and_visible_item() { + for (stored, client) in [(7745, 823), (7748, 724), (2564, 2564)] { + let out = enchant_change_outbound(7, &item_row(8, 0), &item_row(8, stored)); + let (item_word, visible_word, fields) = enchant_words(&out); + assert_eq!(item_word, Some(client), "stored {stored}"); + assert_eq!(visible_word, Some(client), "stored {stored}"); + assert!( + fields.iter().all(|idx| !idx.contains(&2)), + "OBJECT_FIELD_TYPE must stay off the partial update" + ); + } +} + +#[test] +fn enchanting_a_bagged_item_in_place_updates_only_its_enchant_word() { + let out = enchant_change_outbound(7, &item_row(23, 0), &item_row(23, 7745)); + let (item_word, visible_word, _) = enchant_words(&out); + assert_eq!(item_word, Some(823)); + assert_eq!(visible_word, None); +} + +#[test] +fn a_change_that_leaves_the_enchant_alone_sends_no_enchant_word() { + let mut worn = item_row(8, 7745); + worn.durability = 3; + assert!(enchant_change_outbound(7, &item_row(8, 7745), &worn).is_empty()); +} + +#[test] +fn an_enchant_change_on_a_guild_charter_leaves_its_petition_id_alone() { + let charter = |enchant_id| ItemInstance { + entry: lyracore_shared::guild::GUILD_CHARTER_ENTRY, + ..item_row(23, enchant_id) + }; + assert!(enchant_change_outbound(7, &charter(0), &charter(7745)).is_empty()); +} + +#[test] +fn removing_a_permanent_enchant_in_place_clears_the_word() { + let out = enchant_change_outbound(7, &item_row(8, 7745), &item_row(8, 0)); + let (item_word, visible_word, _) = enchant_words(&out); + assert_eq!((item_word, visible_word), (Some(0), Some(0))); +} + +#[test] +fn a_slot_change_with_an_enchant_change_leaves_the_visible_item_to_the_slot_projection() { + let out = enchant_change_outbound(7, &item_row(23, 0), &item_row(8, 7745)); + let (item_word, visible_word, _) = enchant_words(&out); + assert_eq!((item_word, visible_word), (Some(823), None)); +} + +#[test] +fn a_worn_item_moved_into_a_slot_shows_its_client_enchantment_on_the_visible_item() { + use wow_world_messages::vanilla::{Object, UpdateMask, VisibleItemIndex}; + let committed = item_row(8, 7748); + let mut out = Vec::new(); + append_final_item_slots(7, 23, 8, &mut out, |slot| { + (slot == 8).then(|| view_of_item_row(&committed)) + }); + let mut words = Vec::new(); + for message in &out { + let Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(update)) = message else { + continue; + }; + if let Object::Values { + mask1: UpdateMask::Player(player), + .. + } = &update.objects[0] + { + words.extend( + player + .player_visible_item(VisibleItemIndex::Index8) + .map(|visible| visible.enchants), + ); + } + } + assert_eq!(words, [[724, 0]]); +} + +#[test] +fn occupied_equipment_swap_projects_committed_slot_occupants() { + use wow_world_messages::vanilla::{Guid, ItemSlot, Object, UpdateMask, VisibleItemIndex}; + + const CHARACTER: u64 = 7; + const EQUIPMENT_SLOT: u8 = 0; + const BACKPACK_SLOT: u8 = 23; + const DISPLACED_GUID: u64 = 101; + const INCOMING_GUID: u64 = 202; + const INCOMING_ENTRY: u32 = 1337; + + let committed = [ + (EQUIPMENT_SLOT, INCOMING_GUID, INCOMING_ENTRY), + (BACKPACK_SLOT, DISPLACED_GUID, 42), + ]; + let item_in_slot = |slot| { + committed + .iter() + .find(|item| item.0 == slot) + .map(|item| codec::ItemInstanceView { + guid: item.1, + entry: item.2, + ..Default::default() + }) + }; + let mut outbound = Vec::new(); + + append_final_item_slots( + CHARACTER, + EQUIPMENT_SLOT, + BACKPACK_SLOT, + &mut outbound, + item_in_slot, + ); + append_final_item_slots( + CHARACTER, + BACKPACK_SLOT, + EQUIPMENT_SLOT, + &mut outbound, + item_in_slot, + ); + + let mut equipment = Vec::new(); + let mut backpack = Vec::new(); + let mut visible_head = Vec::new(); + for message in &outbound { + let Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(update)) = message else { + continue; + }; + let Object::Values { + mask1: UpdateMask::Player(player), + .. + } = &update.objects[0] + else { + continue; + }; + equipment.extend(player.player_field_inv(ItemSlot::Head)); + backpack.extend(player.player_field_inv(ItemSlot::Inventory0)); + visible_head.extend( + player + .player_visible_item(VisibleItemIndex::Index0) + .map(|item| item.item), + ); + } + + assert_eq!(equipment, [Guid::new(INCOMING_GUID)]); + assert_eq!(backpack, [Guid::new(DISPLACED_GUID)]); + assert_eq!(visible_head, [INCOMING_ENTRY]); + assert!(!equipment.contains(&Guid::new(0))); + assert!(!backpack.contains(&Guid::new(0))); +} + +#[test] +fn empty_equipment_destination_clears_backpack_and_sets_equipment() { + use wow_world_messages::vanilla::{Guid, ItemSlot, Object, UpdateMask}; + + let mut outbound = Vec::new(); + append_final_item_slots(7, 23, 0, &mut outbound, |slot| { + (slot == 0).then(|| item_view(202, 1337)) + }); + + let mut equipment = Vec::new(); + let mut backpack = Vec::new(); + for message in &outbound { + let Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(update)) = message else { + continue; + }; + let Object::Values { + mask1: UpdateMask::Player(player), + .. + } = &update.objects[0] + else { + continue; + }; + equipment.extend(player.player_field_inv(ItemSlot::Head)); + backpack.extend(player.player_field_inv(ItemSlot::Inventory0)); + } + + assert_eq!(equipment, [Guid::new(202)]); + assert_eq!(backpack, [Guid::new(0)]); +} + +#[test] +fn ordinary_backpack_move_clears_source_and_sets_destination() { + use wow_world_messages::vanilla::{Guid, ItemSlot, Object, UpdateMask}; + + let mut outbound = Vec::new(); + append_final_item_slots(7, 23, 24, &mut outbound, |slot| { + (slot == 24).then(|| item_view(202, 1337)) + }); + + let mut source = Vec::new(); + let mut destination = Vec::new(); + for message in &outbound { + let Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(update)) = message else { + continue; + }; + let Object::Values { + mask1: UpdateMask::Player(player), + .. + } = &update.objects[0] + else { + continue; + }; + source.extend(player.player_field_inv(ItemSlot::Inventory0)); + destination.extend(player.player_field_inv(ItemSlot::Inventory1)); + } + + assert_eq!(source, [Guid::new(0)]); + assert_eq!(destination, [Guid::new(202)]); +} diff --git a/gateway/src/codec/leveling/subscriptions.rs b/gateway/src/codec/leveling/subscriptions.rs index 9c976194..ad247c6a 100644 --- a/gateway/src/codec/leveling/subscriptions.rs +++ b/gateway/src/codec/leveling/subscriptions.rs @@ -159,3 +159,6 @@ crate::stdb::relays::declare_relays! { insert(Snapshot) => |context, _event, row| rest_state_appeared(&context.view, context.shard, row); } } + +#[cfg(test)] +mod audience_tests; diff --git a/gateway/src/codec/leveling/subscriptions/audience_tests.rs b/gateway/src/codec/leveling/subscriptions/audience_tests.rs new file mode 100644 index 00000000..c6e646b3 --- /dev/null +++ b/gateway/src/codec/leveling/subscriptions/audience_tests.rs @@ -0,0 +1,53 @@ +use super::*; +use crate::stdb::bindings::CharacterExplored; +use crate::stdb::world_view::test_support::viewer; +use crate::world::Outbound; + +fn explored(area_bit: i32, area_id: u32, experience: u32) -> CharacterExplored { + CharacterExplored { + id: 1, + character_guid: 9001, + area_bit, + area_id, + experience, + } +} + +#[test] +fn exploration_replays_fog_before_discovery_and_reconnects_stay_quiet() { + let viewer = viewer(1, 9001); + viewer.explored.lock().unwrap().seed([40]); + + let resident = explored(40, 87, 120); + let resident_out = exploration_outbound_for_word(&viewer, &resident, 1, 1 << 8); + let (fog_opcode, fog_body) = crate::codec::build_explored_zones_values(9001, 1, 1 << 8); + match resident_out.as_slice() { + [Outbound::Raw { opcode, body }] => { + assert_eq!((*opcode, body), (fog_opcode, &fog_body)); + } + _ => panic!("a seeded area restores fog without discovery feedback"), + } + + let fresh = explored(41, 88, 121); + let fresh_out = exploration_outbound_for_word(&viewer, &fresh, 1, (1 << 8) | (1 << 9)); + let (new_fog_opcode, new_fog_body) = + crate::codec::build_explored_zones_values(9001, 1, (1 << 8) | (1 << 9)); + let (discovery_opcode, discovery_body) = + crate::codec::build_exploration_experience_raw(88, 121); + match fresh_out.as_slice() { + [Outbound::Raw { + opcode: fog_opcode, + body: fog_body, + }, Outbound::Raw { + opcode: toast_opcode, + body: toast_body, + }] => { + assert_eq!((*fog_opcode, fog_body), (new_fog_opcode, &new_fog_body)); + assert_eq!( + (*toast_opcode, toast_body), + (discovery_opcode, &discovery_body) + ); + } + _ => panic!("fog VALUES must precede discovery feedback for a new area"), + } +} diff --git a/gateway/src/codec/mail/subscriptions.rs b/gateway/src/codec/mail/subscriptions.rs index d1906523..cf81c73c 100644 --- a/gateway/src/codec/mail/subscriptions.rs +++ b/gateway/src/codec/mail/subscriptions.rs @@ -39,3 +39,6 @@ crate::stdb::relays::declare_relays! { insert(Live) => |context, _event, row| mail_arrived(&context.view, row); } } + +#[cfg(test)] +mod audience_tests; diff --git a/gateway/src/codec/mail/subscriptions/audience_tests.rs b/gateway/src/codec/mail/subscriptions/audience_tests.rs new file mode 100644 index 00000000..4105dd5e --- /dev/null +++ b/gateway/src/codec/mail/subscriptions/audience_tests.rs @@ -0,0 +1,51 @@ +use super::*; +use crate::stdb::bindings::MailArrival; +use crate::stdb::world_index::CellKey; +use crate::stdb::world_view::test_support::{identity, queued_job, viewer_with_tx}; +use crate::stdb::world_view::WorldView; +use crate::world::{Outbound, SessionTx}; +use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; + +#[test] +fn a_mail_arrival_reaches_its_recipient_on_any_shard_and_an_unknown_guid_gets_nothing() { + let view = WorldView::new(true); + let (recipient_tx, recipient_rx) = SessionTx::with_depth(0); + let recipient = viewer_with_tx(1, 9001, identity(1), recipient_tx); + let (bystander_tx, bystander_rx) = SessionTx::with_depth(0); + let bystander = viewer_with_tx(2, 9002, identity(2), bystander_tx); + let anchor = CellKey::at(0, 0, 0, 0); + view.add_viewer_on_shard(recipient.clone(), anchor, 1); + view.add_viewer_on_shard(bystander, anchor, 0); + + let row = MailArrival { + id: 1, + recipient_guid: recipient.self_guid, + created_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, + }; + mail_arrived(&view, &row); + + let outbound = queued_job(&recipient_rx); + let [Outbound::One(ServerOpcodeMessage::SMSG_RECEIVED_MAIL(packet))] = outbound.as_slice() + else { + panic!("the recipient must receive one SMSG_RECEIVED_MAIL"); + }; + assert_eq!(packet.unknown1, 0); + assert!( + recipient_rx.try_recv().is_err(), + "one arrival queues one job" + ); + assert!( + bystander_rx.try_recv().is_err(), + "a second Character must receive no queued work" + ); + + mail_arrived( + &view, + &MailArrival { + recipient_guid: 9999, + ..row + }, + ); + assert!(recipient_rx.try_recv().is_err()); + assert!(bystander_rx.try_recv().is_err()); +} diff --git a/gateway/src/codec/movement/subscriptions.rs b/gateway/src/codec/movement/subscriptions.rs index c1e672f7..9e228b90 100644 --- a/gateway/src/codec/movement/subscriptions.rs +++ b/gateway/src/codec/movement/subscriptions.rs @@ -342,3 +342,9 @@ crate::stdb::relays::declare_relays! { update(Snapshot) => |context, _event, _old, row| creature_leg(&context.view, context.shard, row); } } + +#[cfg(test)] +mod packet_tests; + +#[cfg(test)] +mod audience_tests; diff --git a/gateway/src/codec/movement/subscriptions/audience_tests.rs b/gateway/src/codec/movement/subscriptions/audience_tests.rs new file mode 100644 index 00000000..a34853c6 --- /dev/null +++ b/gateway/src/codec/movement/subscriptions/audience_tests.rs @@ -0,0 +1,43 @@ +use super::*; +use crate::stdb::world_index::CellKey; +use crate::stdb::world_view::test_support::{identity, queued_job, teleport, viewer_with_tx}; +use crate::stdb::world_view::WorldView; +use crate::world::{Outbound, SessionTx}; +use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; + +#[test] +fn teleport_routes_one_writer_job_to_its_owner_and_keeps_cross_map_order() { + let view = WorldView::new(true); + let (owner_tx, owner_rx) = SessionTx::with_depth(0); + let (other_tx, other_rx) = SessionTx::with_depth(0); + let owner = viewer_with_tx(1, 9001, identity(1), owner_tx); + let other = viewer_with_tx(2, 9002, identity(2), other_tx); + let anchor = CellKey::at(0, 0, 0, 0); + view.add_viewer_on_shard(owner.clone(), anchor, 0); + view.add_viewer_on_shard(other, anchor, 0); + + teleport_appeared(&view, 0, &teleport(owner.self_guid)); + + assert!( + other_rx.try_recv().is_err(), + "an unrelated viewer receives nothing" + ); + let out = queued_job(&owner_rx); + assert_eq!(out.len(), 1, "one teleport row creates one writer result"); + match &out[0] { + Outbound::Batch(messages) => { + assert!(matches!( + messages.as_slice(), + [ + ServerOpcodeMessage::SMSG_TRANSFER_PENDING(_), + ServerOpcodeMessage::SMSG_NEW_WORLD(_) + ] + )); + } + _ => panic!("cross-map teleport must remain one ordered writer batch"), + } + assert!( + owner_rx.try_recv().is_err(), + "the owner receives exactly one job" + ); +} diff --git a/gateway/src/codec/movement/subscriptions/packet_tests.rs b/gateway/src/codec/movement/subscriptions/packet_tests.rs new file mode 100644 index 00000000..952c6542 --- /dev/null +++ b/gateway/src/codec/movement/subscriptions/packet_tests.rs @@ -0,0 +1,315 @@ +use super::*; +use crate::codec::entity::subscriptions::tests::creature_create_outbound; +use crate::stdb::bindings::CreatureSpline; +use crate::world::Outbound; +use std::collections::HashSet; +use std::sync::{Arc, Mutex}; +use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; +use wow_world_messages::vanilla::Vector3d; + +// ---- Cross-map teleport relay -------------------------------------------- + +#[test] +fn teleport_relay_same_map_is_byte_identical_to_the_pre_224_ack() { + let out = build_teleport_relay(true, 7, 0, 1.0, 2.0, 3.0, 0.5).unwrap(); + match out { + Outbound::One(ServerOpcodeMessage::MSG_MOVE_TELEPORT_ACK(m)) => { + assert_eq!(m.guid.guid(), 7); + assert_eq!( + m.info.position, + wow_world_messages::vanilla::Vector3d { + x: 1.0, + y: 2.0, + z: 3.0 + } + ); + assert_eq!(m.info.orientation, 0.5); + } + _ => panic!("expected a same-map relay to be a single MSG_MOVE_TELEPORT_ACK"), + } +} + +#[test] +fn teleport_relay_cross_map_sends_transfer_pending_then_new_world() { + let out = build_teleport_relay(false, 7, 36, 10.0, 20.0, 30.0, 1.5).unwrap(); + match out { + Outbound::Batch(msgs) => { + assert_eq!(msgs.len(), 2, "TRANSFER_PENDING + NEW_WORLD, nothing else"); + match &msgs[0] { + ServerOpcodeMessage::SMSG_TRANSFER_PENDING(p) => { + assert_eq!(p.map.as_int(), 36); + assert!(p.has_transport.is_none()); + } + _ => panic!("expected SMSG_TRANSFER_PENDING first"), + } + match &msgs[1] { + ServerOpcodeMessage::SMSG_NEW_WORLD(w) => { + assert_eq!(w.map.as_int(), 36); + assert_eq!( + w.position, + wow_world_messages::vanilla::Vector3d { + x: 10.0, + y: 20.0, + z: 30.0 + } + ); + assert_eq!(w.orientation, 1.5); + } + _ => panic!("expected SMSG_NEW_WORLD second"), + } + } + _ => { + panic!("expected a cross-map relay to be a Batch of [TRANSFER_PENDING, NEW_WORLD]") + } + } +} + +#[test] +fn teleport_relay_rejects_a_map_id_the_client_enum_does_not_know() { + // `Map` is a closed vanilla enum; an id past its known range must surface as an Err (never + // silently drop the whole relay or panic). + assert!(build_teleport_relay(false, 7, u32::MAX, 0.0, 0.0, 0.0, 0.0).is_err()); +} + +fn creature_spline(guid: u64, start_micros: u64, dur_ms: u32) -> CreatureSpline { + CreatureSpline { + guid, + start_micros, + dur_ms, + sx: 10.0, + sy: 20.0, + sz: 30.0, + dx: 30.0, + dy: 40.0, + dz: 50.0, + map_id: 0, + instance_id: 0, + grid_x: 0, + grid_y: 0, + spline_id: 10, + run: true, + cell: lyracore_shared::spatial::grid_cell_id(0, 0), + facing: false, + facing_angle: 0.0, + path: None, + } +} + +#[test] +fn an_empty_retained_path_stops_the_client_instead_of_sending_invalid_geometry() { + let created = Mutex::new(HashSet::from([99])); + let mut row = creature_spline(99, 1_000_000, 1_000); + row.path = Some(crate::stdb::bindings::CreaturePath { + navigation: crate::stdb::bindings::NavigationInputs { + imported_revision: None, + navigation_enabled: false, + collision_enabled: false, + coverage_enabled: false, + static_generation: None, + coverage_generation: None, + }, + points: Vec::new(), + }); + let outbound = creature_leg_outbound(&created, &row); + let [Outbound::One(ServerOpcodeMessage::SMSG_MONSTER_MOVE(stop))] = outbound.as_slice() else { + panic!("malformed paths need a stop"); + }; + assert_eq!(stop.duration, 0); + assert_eq!(stop.splines[0], stop.spline_point); +} + +#[test] +fn resident_waypoint_path_resumes_after_the_turn() { + let guid = 99; + let created = Mutex::new(HashSet::from([guid])); + let mut row = creature_spline(guid, 1_000_000, 7_000); + (row.sx, row.sy, row.sz) = (0.0, 0.0, 0.0); + (row.dx, row.dy, row.dz) = (3.0, 4.0, 0.0); + row.path = Some(crate::stdb::bindings::CreaturePath { + navigation: crate::stdb::bindings::NavigationInputs { + imported_revision: None, + navigation_enabled: false, + collision_enabled: false, + coverage_enabled: false, + static_generation: None, + coverage_generation: None, + }, + points: vec![ + crate::stdb::bindings::CreaturePathPoint { + x: 3.0, + y: 0.0, + z: 0.0, + }, + crate::stdb::bindings::CreaturePathPoint { + x: 3.0, + y: 4.0, + z: 0.0, + }, + ], + }); + let mut outbound = Vec::new(); + append_resident_creature_after_create(&mut outbound, &created, 0, Some(&row), 4_500_000); + let [Outbound::Raw { opcode, body }] = outbound.as_slice() else { + panic!("expected a retained path"); + }; + assert_eq!(*opcode, 0xdd); + assert_eq!(&body[..2], &[1, 99]); + let f32_at = |offset| f32::from_le_bytes(body[offset..offset + 4].try_into().unwrap()); + let u32_at = |offset| u32::from_le_bytes(body[offset..offset + 4].try_into().unwrap()); + assert_eq!((f32_at(2), f32_at(6), f32_at(10)), (3.0, 0.5, 0.0)); + assert_eq!(u32_at(23), 3_500); + assert_eq!(u32_at(27), 1); + assert_eq!((f32_at(31), f32_at(35), f32_at(39)), (3.0, 4.0, 0.0)); + assert_eq!(body.len(), 43); +} + +#[test] +fn resident_creature_replay_follows_create_from_the_current_point() { + let guid = 99; + let created = Mutex::new(HashSet::from([guid])); + let row = creature_spline(guid, 1_000_000, 1_000); + let mut outbound = creature_create_outbound(); + + append_resident_creature_after_create(&mut outbound, &created, 0, Some(&row), 1_250_500); + + let [Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(create)), Outbound::One(ServerOpcodeMessage::SMSG_MONSTER_MOVE(movement))] = + outbound.as_slice() + else { + panic!("a resident creature needs CREATE followed by MONSTER_MOVE"); + }; + let [wow_world_messages::vanilla::Object::CreateObject2 { guid3, .. }] = + create.objects.as_slice() + else { + panic!("the first packet must create the resident creature"); + }; + assert_eq!(guid3.guid(), guid); + assert_eq!(movement.guid.guid(), guid); + assert!((movement.spline_point.x - 15.01).abs() < 0.0001); + assert!((movement.spline_point.y - 25.01).abs() < 0.0001); + assert!((movement.spline_point.z - 35.01).abs() < 0.0001); + assert_eq!( + movement.splines, + vec![Vector3d { + x: 30.0, + y: 40.0, + z: 50.0, + }] + ); + assert_eq!( + movement.duration, 750, + "sub-millisecond remainder rounds up so replay cannot move faster than the durable leg" + ); +} + +#[test] +fn resident_creature_replay_skips_absent_expired_and_ineligible_legs() { + let guid = 99; + let row = creature_spline(guid, 1_000_000, 1_000); + let mut outbound = Vec::new(); + + append_resident_creature_after_create( + &mut outbound, + &Mutex::new(HashSet::from([guid])), + 0, + None, + 1_250_000, + ); + append_resident_creature_after_create( + &mut outbound, + &Mutex::new(HashSet::new()), + 0, + Some(&row), + 1_250_000, + ); + append_resident_creature_after_create( + &mut outbound, + &Mutex::new(HashSet::from([guid])), + 1, + Some(&row), + 1_250_000, + ); + append_resident_creature_after_create( + &mut outbound, + &Mutex::new(HashSet::from([guid])), + 0, + Some(&row), + 2_000_000, + ); + + assert!(outbound.is_empty()); +} + +#[test] +fn resident_facing_replay_follows_create_even_after_its_timestamp() { + let guid = 99; + let created = Mutex::new(HashSet::from([guid])); + let mut row = creature_spline(guid, 1_000_000, 0); + row.facing = true; + row.facing_angle = 2.1; + let mut outbound = creature_create_outbound(); + + append_resident_creature_after_create(&mut outbound, &created, 0, Some(&row), 9_000_000); + + let [Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(_)), Outbound::One(ServerOpcodeMessage::SMSG_MONSTER_MOVE(movement))] = + outbound.as_slice() + else { + panic!("a resident creature needs CREATE followed by its facing state"); + }; + assert_eq!( + movement.move_type, + wow_world_messages::vanilla::SMSG_MONSTER_MOVE_MonsterMoveType::FacingAngle { angle: 2.1 } + ); +} + +/// a `facing`-flagged `game_creature_spline` row relays as `SMSG_MONSTER_MOVE`'s +/// `FacingAngle` variant, not the `Normal` one every other leg uses — the whole point being that +/// a stationary creature otherwise has no wire-level way to tell the client its heading changed. +#[test] +fn a_facing_leg_relays_as_the_facing_angle_variant() { + let created = Arc::new(Mutex::new(HashSet::from([42u64]))); + let row = CreatureSpline { + guid: 42, + start_micros: 0, + dur_ms: 0, + sx: 10.0, + sy: 20.0, + sz: 30.0, + dx: 10.0, + dy: 20.0, + dz: 30.0, + map_id: 0, + instance_id: 0, + grid_x: 0, + grid_y: 0, + spline_id: 7, + run: false, + cell: lyracore_shared::spatial::grid_cell_id(0, 0), + facing: true, + facing_angle: 2.1, + path: None, + }; + let out = creature_leg_outbound(&created, &row); + assert_eq!(out.len(), 1); + let Outbound::One(ServerOpcodeMessage::SMSG_MONSTER_MOVE(msg)) = &out[0] else { + panic!("expected SMSG_MONSTER_MOVE"); + }; + assert_eq!( + msg.move_type, + wow_world_messages::vanilla::SMSG_MONSTER_MOVE_MonsterMoveType::FacingAngle { angle: 2.1 }, + "facing leg must use the FacingAngle variant, not Normal" + ); + assert_eq!(msg.duration, 0); + + // A non-facing leg keeps the Normal variant unchanged. + let mut normal_row = row.clone(); + normal_row.facing = false; + normal_row.dur_ms = 500; + let out = creature_leg_outbound(&created, &normal_row); + let Outbound::One(ServerOpcodeMessage::SMSG_MONSTER_MOVE(msg)) = &out[0] else { + panic!("expected SMSG_MONSTER_MOVE"); + }; + assert_eq!( + msg.move_type, + wow_world_messages::vanilla::SMSG_MONSTER_MOVE_MonsterMoveType::Normal + ); +} diff --git a/gateway/src/codec/quest/subscriptions.rs b/gateway/src/codec/quest/subscriptions.rs index f935e71b..6a22db57 100644 --- a/gateway/src/codec/quest/subscriptions.rs +++ b/gateway/src/codec/quest/subscriptions.rs @@ -195,3 +195,6 @@ fn quest_requirements_changed(view: &WorldView, coord: &Coordinator, shard: Shar }); } } + +#[cfg(test)] +mod audience_tests; diff --git a/gateway/src/codec/quest/subscriptions/audience_tests.rs b/gateway/src/codec/quest/subscriptions/audience_tests.rs new file mode 100644 index 00000000..c15c615f --- /dev/null +++ b/gateway/src/codec/quest/subscriptions/audience_tests.rs @@ -0,0 +1,42 @@ +use super::*; +use crate::stdb::bindings::CharacterQuest; +use crate::stdb::world_view::test_support::identity; +use crate::world::Outbound; +use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; + +#[test] +fn quest_update_orders_kills_then_timer_then_full_log() { + let old = CharacterQuest { + id: 1, + character_guid: 9001, + owner_identity: identity(1), + quest_entry: 77, + counts: vec![1], + rewarded: false, + deadline_micros: 0, + failed: false, + }; + let updated = CharacterQuest { + counts: vec![2], + failed: true, + ..old.clone() + }; + let outbound = quest_update_packets( + &old, + &updated, + &[(0, 0, 123, 4)], + Some(Outbound::Raw { + opcode: 1, + body: vec![0], + }), + ); + + assert!(matches!( + outbound.as_slice(), + [ + Outbound::One(ServerOpcodeMessage::SMSG_QUESTUPDATE_ADD_KILL(_)), + Outbound::One(ServerOpcodeMessage::SMSG_QUESTUPDATE_FAILEDTIMER(_)), + Outbound::Raw { .. }, + ] + )); +} diff --git a/gateway/src/codec/social/subscriptions.rs b/gateway/src/codec/social/subscriptions.rs index 141d1646..d394d261 100644 --- a/gateway/src/codec/social/subscriptions.rs +++ b/gateway/src/codec/social/subscriptions.rs @@ -1158,3 +1158,15 @@ fn group_event_callback( ); } } + +#[cfg(test)] +mod packet_tests; + +#[cfg(test)] +mod audience_tests; + +#[cfg(test)] +mod realm_chat_relay_tests; + +#[cfg(test)] +mod account_claim_relay_tests; diff --git a/gateway/src/codec/social/subscriptions/account_claim_relay_tests.rs b/gateway/src/codec/social/subscriptions/account_claim_relay_tests.rs new file mode 100644 index 00000000..f18026d1 --- /dev/null +++ b/gateway/src/codec/social/subscriptions/account_claim_relay_tests.rs @@ -0,0 +1,280 @@ +use super::*; +use crate::stdb::aoi::ViewerGates; +use crate::stdb::bindings::{AccountClaim, Character}; +use crate::stdb::world_index::CellKey; +use crate::stdb::world_view::{ExplorationReplay, MotionPending, Viewer, WorldView}; +use crate::world::{Outbound, SessionTx}; +use std::collections::{HashMap, HashSet}; +use std::sync::mpsc::Receiver; +use std::sync::{Arc, Mutex}; + +const ALLIANCE: u32 = lyracore_shared::faction::TEAM_ALLIANCE; + +const HORDE: u32 = lyracore_shared::faction::TEAM_HORDE; + +const FRIEND: u64 = 10; + +/// A registered viewer for `self_guid` on `shard`, already listing [`FRIEND`]. +fn listener( + view: &WorldView, + shard: usize, + self_guid: u64, + team: u32, +) -> (Arc, Receiver) { + let (tx, rx) = SessionTx::with_depth(0); + let viewer = Arc::new(Viewer { + active: std::sync::atomic::AtomicBool::new(true), + session: view.next_session_id(), + self_guid, + bound_identity: spacetimedb_sdk::Identity::from_byte_array([self_guid as u8; 32]), + map_id: 0, + instance_id: 0, + zone_id: 0.into(), + tx, + created: Arc::new(Mutex::new(HashSet::new())), + gates: Arc::new(ViewerGates::default()), + skill_slots: Arc::new(Mutex::new((HashMap::new(), 0))), + explored: Mutex::new(ExplorationReplay::default()), + motion_pending: Arc::new(MotionPending::default()), + member_stats: Default::default(), + ignored: Mutex::default(), + friends: Mutex::new(HashSet::from([FRIEND])), + team, + group_events: Default::default(), + }); + view.add_viewer_on_shard(viewer.clone(), CellKey::at(0, 0, 0, 0), shard); + (viewer, rx) +} + +fn claim(generation: u64, character_guid: u64, closed: bool) -> AccountClaim { + AccountClaim { + account_id: 1, + generation, + request_nonce: 1, + character_guid, + expires_micros: 1_000_000, + closed, + } +} + +/// A `game_character` row with only the columns this relay reads set to something other than +/// zero, the rest at whatever a fresh row would have. `session_start_micros` is `player_login`'s +/// own stamp — 0 outside a session, a real timestamp inside one. +fn character_row( + guid: u64, + race: u8, + class: u8, + level: u8, + zone_id: u32, + online: bool, + session_start_micros: u64, +) -> Character { + Character { + guid, + account_id: 0, + owner_identity: spacetimedb_sdk::Identity::ZERO, + name: String::new(), + race, + class, + gender: 0, + skin: 0, + face: 0, + hair_style: 0, + hair_color: 0, + facial_hair: 0, + level, + xp: 0, + next_level_xp: 0, + map_id: 0, + zone_id, + x: 0.0, + y: 0.0, + z: 0.0, + orientation: 0.0, + first_login: false, + online, + money: 0, + rested_xp: 0, + last_logout_micros: 0, + home_map: 0, + home_zone: 0, + home_x: 0.0, + home_y: 0.0, + home_z: 0.0, + played_total_secs: 0, + session_start_micros, + health: 0, + power: 0, + respec_count: 0, + death_expire_micros: 0, + pending_instance_id: 0, + gm_level: 0, + pending_ghost: false, + resting: false, + rested_since_micros: 0, + pending_godmode: false, + pending_run_speed_mult_bp: 0, + bank_bag_slots: 0, + watched_faction_index: -1, + } +} + +/// One case per bullet in `claim_closed`'s doc comment, rows written by hand. +#[test] +fn claim_closed_covers_every_bullet() { + // Inserted open (the account's first-ever claim): not a close. + assert_eq!(claim_closed(None, &claim(1, FRIEND, false)), None); + + // A renewal — only `expires_micros` moves: not a close. + let old = claim(1, FRIEND, false); + let mut renewed = old.clone(); + renewed.expires_micros += 1; + assert_eq!(claim_closed(Some(&old), &renewed), None); + + // Closed (logout): the Character stops being live. + let old = claim(1, FRIEND, false); + let closed = claim(1, FRIEND, true); + assert_eq!(claim_closed(Some(&old), &closed), Some(FRIEND)); + + // Reopened after being closed (a fresh login, a new generation): not a close. + let old = claim(1, FRIEND, true); + let reopened = claim(2, FRIEND, false); + assert_eq!(claim_closed(Some(&old), &reopened), None); + + // Replaced while live, by a DIFFERENT Character (a relogin as someone else on the same + // Account without an intervening close): the OLD Character stops being live. + let old = claim(1, FRIEND, false); + let replaced = claim(2, 99, false); + assert_eq!(claim_closed(Some(&old), &replaced), Some(FRIEND)); + + // Reaped while already closed (the claim reaper closes an already-closed row, or closes it + // twice across a race): not a (new) close. + let old = claim(1, FRIEND, true); + let still_closed = claim(1, FRIEND, true); + assert_eq!(claim_closed(Some(&old), &still_closed), None); +} + +/// `friend_notice_candidates` collects every friend-listing viewer on any Shard, and never the +/// subject's own viewer, whatever it lists. +#[test] +fn friend_notice_candidates_lists_every_friend_listing_viewer_but_never_the_subject() { + let view = WorldView::new(true); + let (friend, _friend_rx) = listener(&view, 0, 20, ALLIANCE); + let (elsewhere, _elsewhere_rx) = listener(&view, 1, 21, ALLIANCE); + let (other_team, _other_team_rx) = listener(&view, 0, 22, HORDE); + let (non_friend, _non_friend_rx) = listener(&view, 0, 23, ALLIANCE); + non_friend.friends.lock().unwrap().clear(); + let (subject, _subject_rx) = listener(&view, 0, FRIEND, ALLIANCE); + subject.friends.lock().unwrap().insert(FRIEND); // hypothetically lists itself + + let mut candidates: Vec = friend_notice_candidates(&view, FRIEND) + .iter() + .map(|viewer| viewer.self_guid) + .collect(); + candidates.sort_unstable(); + + assert_eq!( + candidates, + [friend.self_guid, elsewhere.self_guid, other_team.self_guid], + "every friend-listing viewer on any Shard is a candidate, team included (each caller \ + checks it its own way), but never a non-friend and never the subject's own viewer" + ); +} + +#[test] +fn character_online_changed_reaches_a_same_team_friend_with_the_just_committed_row() { + let view = WorldView::new(true); + let (_friend, friend_rx) = listener(&view, 0, 20, ALLIANCE); + let (_other_team, other_team_rx) = listener(&view, 0, 21, HORDE); + let (non_friend, non_friend_rx) = listener(&view, 0, 22, ALLIANCE); + non_friend.friends.lock().unwrap().clear(); + + // Last logged out at level 21 in zone 12; this login already leveled up and landed the + // Character in zone 33 — the row's OWN fields, not a stale earlier read. + let old = character_row(FRIEND, 1, 4, 21, 12, false, 0); + let new = character_row(FRIEND, 1, 4, 22, 33, true, 500_000); + + character_online_changed(&view, &old, &new); + + match friend_rx + .try_recv() + .expect("a same-team friend must hear it") + { + Outbound::Job(job) => match job().as_slice() { + [Outbound::Raw { opcode, body }] => { + assert_eq!(*opcode, crate::codec::social::SMSG_FRIEND_STATUS_OPCODE); + assert_eq!(body[0], 0x02, "ONLINE"); + assert_eq!(body[1..9], FRIEND.to_le_bytes()); + assert_eq!( + body[9], 1, + "status ONLINE — Away Status always clears at login" + ); + assert_eq!(body[10..14], 33u32.to_le_bytes(), "the POST-login zone"); + assert_eq!(body[14..18], 22u32.to_le_bytes(), "the POST-login level"); + assert_eq!(body[18..22], 4u32.to_le_bytes(), "class"); + } + _ => panic!("expected one Raw packet"), + }, + _ => panic!("expected a job"), + } + assert!( + other_team_rx.try_recv().is_err(), + "a viewer on the other team hears nothing" + ); + assert!( + non_friend_rx.try_recv().is_err(), + "a same-team viewer who does not list the guid as a friend hears nothing" + ); + assert!( + view.take_character_online(FRIEND), + "the login must be marked so the later claim close can find it" + ); +} + +/// An UPDATE that is not a login — an already-online Character's level-up, a logout, or a +/// same-database transfer's freeze/rebuild with no real login in between — fires nothing and +/// marks nothing. +#[test] +fn character_online_changed_ignores_every_update_that_is_not_a_login() { + let view = WorldView::new(true); + let (_friend, friend_rx) = listener(&view, 0, 20, ALLIANCE); + + let old = character_row(FRIEND, 1, 4, 21, 33, true, 500_000); + let new = character_row(FRIEND, 1, 4, 22, 33, true, 500_000); // a level-up, same session + character_online_changed(&view, &old, &new); + assert!(friend_rx.try_recv().is_err()); + assert!(!view.take_character_online(FRIEND)); + + let old = character_row(FRIEND, 1, 4, 22, 33, true, 500_000); + let new = character_row(FRIEND, 1, 4, 22, 33, false, 0); // a real logout + character_online_changed(&view, &old, &new); + assert!(friend_rx.try_recv().is_err()); + + // A same-database transfer's freeze zeroes session_start_micros without touching `online` + // (`freeze_live_entity` calls `persist_entity` with `set_offline: false`) — no login + // happened, so this must not fire either, even though `online` reads true on both sides. + let old = character_row(FRIEND, 1, 4, 22, 33, true, 500_000); + let new = character_row(FRIEND, 1, 4, 22, 33, true, 0); // transfer freeze, still online + character_online_changed(&view, &old, &new); + assert!(friend_rx.try_recv().is_err()); + assert!(!view.take_character_online(FRIEND)); +} + +#[test] +fn character_online_changed_fires_for_a_real_login_even_when_online_was_stuck_true() { + let view = WorldView::new(true); + let (_friend, friend_rx) = listener(&view, 0, 20, ALLIANCE); + + let old = character_row(FRIEND, 1, 4, 20, 12, true, 0); + let new = character_row(FRIEND, 1, 4, 20, 12, true, 700_000); + character_online_changed(&view, &old, &new); + + assert!( + friend_rx.try_recv().is_ok(), + "the real login must be heard even though `online` never flipped" + ); + assert!( + view.take_character_online(FRIEND), + "the login must be marked so the later claim close can find it" + ); +} diff --git a/gateway/src/codec/social/subscriptions/audience_tests.rs b/gateway/src/codec/social/subscriptions/audience_tests.rs new file mode 100644 index 00000000..e2d6deeb --- /dev/null +++ b/gateway/src/codec/social/subscriptions/audience_tests.rs @@ -0,0 +1,171 @@ +use super::*; +use crate::stdb::bindings::{InstanceRemoval, SystemMessageEvent}; +use crate::stdb::world_index::CellKey; +use crate::stdb::world_view::test_support::{identity, queued_job, viewer_with_tx}; +use crate::stdb::world_view::WorldView; +use crate::world::{Outbound, SessionTx}; +use std::sync::Arc; +use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; + +#[test] +fn a_system_message_reaches_only_its_recipient_and_missing_recipients_are_dropped() { + let view = WorldView::new(true); + let (recipient_tx, recipient_rx) = SessionTx::with_depth(0); + let recipient = viewer_with_tx(1, 9001, identity(1), recipient_tx); + let (bystander_tx, bystander_rx) = SessionTx::with_depth(0); + let bystander = viewer_with_tx(2, 9002, identity(2), bystander_tx); + let anchor = CellKey::at(0, 0, 0, 0); + view.add_viewer_on_shard(recipient.clone(), anchor, 0); + view.add_viewer_on_shard(bystander, anchor, 0); + + let row = SystemMessageEvent { + id: 1, + recipient_identity: recipient.bound_identity, + recipient_guid: recipient.self_guid, + message: "Package loaded".to_string(), + created_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, + }; + system_message_appeared(&view, &row); + + let outbound = queued_job(&recipient_rx); + let [Outbound::One(ServerOpcodeMessage::SMSG_MESSAGECHAT(actual))] = outbound.as_slice() else { + panic!("the recipient must receive one System Message packet"); + }; + assert_eq!( + **actual, + crate::codec::build_gm_system_message(row.message.clone()) + ); + assert!( + bystander_rx.try_recv().is_err(), + "a second Character must receive no queued work" + ); + + let missing = SystemMessageEvent { + recipient_guid: 9999, + ..row + }; + system_message_appeared(&view, &missing); + assert!(recipient_rx.try_recv().is_err()); + assert!(bystander_rx.try_recv().is_err()); +} + +fn at_micros(micros: i64) -> spacetimedb_sdk::ScheduleAt { + spacetimedb_sdk::ScheduleAt::Time(spacetimedb_sdk::Timestamp::from_micros_since_unix_epoch( + micros, + )) +} + +/// The one `SMSG_RAID_GROUP_ONLY` timer a writer job produced. +fn raid_group_only_timer(out: &[Outbound]) -> u32 { + match out { + [Outbound::One(ServerOpcodeMessage::SMSG_RAID_GROUP_ONLY(packet))] => { + assert_eq!( + packet.error, + wow_world_messages::vanilla::RaidGroupError::Required + ); + packet.homebind_timer + } + _ => panic!("expected one SMSG_RAID_GROUP_ONLY"), + } +} + +#[test] +fn an_instance_removal_shows_and_hides_the_countdown_for_its_owner_only() { + let view = Arc::new(WorldView::new(true)); + let (owner_tx, owner_rx) = SessionTx::with_depth(0); + let (other_tx, other_rx) = SessionTx::with_depth(0); + let owner = viewer_with_tx(1, 9001, identity(1), owner_tx); + let other = viewer_with_tx(2, 9002, identity(2), other_tx); + let anchor = CellKey::at(36, 7, 0, 0); + view.add_viewer_on_shard(owner.clone(), anchor, 1); + view.add_viewer_on_shard(other, anchor, 1); + // Due in 2100: far enough ahead that the time left is always the full countdown. + let row = InstanceRemoval { + scheduled_id: 3, + scheduled_at: at_micros(4_102_444_800_000_000), + character_guid: owner.self_guid, + instance_id: 7, + group_id: 42, + }; + + instance_removal_started(&view, 0, &row); + assert!(owner_rx.try_recv().is_err(), "another Shard's row"); + instance_removal_started(&view, 1, &row); + assert_eq!(raid_group_only_timer(&queued_job(&owner_rx)), 60_000); + assert!(view.countdown_shown(owner.self_guid)); + instance_removal_ended(&view, 1, &row); + assert_eq!(raid_group_only_timer(&queued_job(&owner_rx)), 0); + assert!(!view.countdown_shown(owner.self_guid)); + assert!(owner_rx.try_recv().is_err(), "one job per row change"); + assert!( + other_rx.try_recv().is_err(), + "an unrelated viewer receives nothing" + ); +} + +/// A world-port drops the source viewer before the source Shard deletes the row, so the delete +/// reaches nobody. The countdown the client still shows is hidden at the next world entry. +#[test] +fn a_world_entry_hides_a_countdown_whose_row_is_gone() { + let view = Arc::new(WorldView::new(true)); + let (tx, rx) = SessionTx::with_depth(0); + let source = viewer_with_tx(1, 9001, identity(1), tx); + view.add_viewer_on_shard(source.clone(), CellKey::at(36, 7, 0, 0), 1); + let row = InstanceRemoval { + scheduled_id: 3, + scheduled_at: at_micros(4_102_444_800_000_000), + character_guid: source.self_guid, + instance_id: 7, + group_id: 42, + }; + instance_removal_started(&view, 1, &row); + queued_job(&rx); + view.remove_viewer(source.session); + instance_removal_ended(&view, 1, &row); + + let shown = view.countdown_shown(9001); + assert_eq!(resident_countdown_ms(None, shown, 0), Some(0)); + assert_eq!(resident_countdown_ms(None, false, 0), None); + assert_eq!( + resident_countdown_ms( + Some(&at_micros(1_900_000_000_000_000)), + false, + 1_899_999_955_000_000 + ), + Some(45_000) + ); +} + +/// The world-entry sweep and a replayed insert both show the time left, not a fresh 60 s. +#[test] +fn the_countdown_shows_the_time_left_rounded_up_and_nothing_once_due() { + let due = 1_900_000_000_000_000; + assert_eq!( + instance_removal_time_left_ms(&at_micros(due), 1_899_999_955_000_000), + Some(45_000) + ); + assert_eq!( + instance_removal_time_left_ms(&at_micros(due), 1_899_999_999_999_999), + Some(1) + ); + assert_eq!( + instance_removal_time_left_ms(&at_micros(due), 1_899_999_900_000_000), + Some(60_000), + "a Gateway clock behind the Module's shows no more than the full countdown" + ); + assert_eq!( + instance_removal_time_left_ms(&at_micros(due), 1_900_000_000_000_000), + None + ); + assert_eq!( + instance_removal_time_left_ms(&at_micros(due), 1_900_000_000_000_005), + None + ); + let interval = spacetimedb_sdk::ScheduleAt::Interval( + spacetimedb_sdk::TimeDuration::from_micros(1_000_000), + ); + assert_eq!( + instance_removal_time_left_ms(&interval, 1_899_999_955_000_000), + None + ); +} diff --git a/gateway/src/codec/social/subscriptions/packet_tests.rs b/gateway/src/codec/social/subscriptions/packet_tests.rs new file mode 100644 index 00000000..76a8f28c --- /dev/null +++ b/gateway/src/codec/social/subscriptions/packet_tests.rs @@ -0,0 +1,161 @@ +use super::*; +use crate::codec; +use crate::codec::entity::subscriptions::tests::player_entity; +use crate::stdb::bindings::{ChatEvent, SystemMessageEvent}; +use crate::world::Outbound; +use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; + +#[test] +fn chat_event_target_reaches_every_addressable_creature_packet() { + let base = ChatEvent { + id: 1, + sender_guid: 0xF130_0000_0000_002A, + chat_type: 0, + language: 0, + message: "You there!".into(), + created_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, + target_guid: 77, + }; + for chat_type in [ + lyracore_shared::chat::broadcast_chat::SAY, + lyracore_shared::chat::broadcast_chat::YELL, + lyracore_shared::chat::broadcast_chat::CREATURE_TEXT_EMOTE, + ] { + let out = chat_event_message( + &ChatEvent { + chat_type, + ..base.clone() + }, + Some("Defias Thug".into()), + 0, + ); + let Outbound::One(ServerOpcodeMessage::SMSG_MESSAGECHAT(message)) = out else { + panic!("expected creature chat packet"); + }; + match &message.chat_type { + wow_world_messages::vanilla::SMSG_MESSAGECHAT_ChatType::MonsterSay { + target, .. + } + | wow_world_messages::vanilla::SMSG_MESSAGECHAT_ChatType::MonsterYell { + target, .. + } => assert_eq!(target.guid(), 77), + // `MonsterEmote` has no addressee field on the wire; its single guid names the + // speaker, so the addressed target is legitimately absent here. + wow_world_messages::vanilla::SMSG_MESSAGECHAT_ChatType::MonsterEmote { + monster, + .. + } => assert_eq!(monster.guid(), 0xF130_0000_0000_002A), + other => panic!("expected addressable creature chat, got {other:?}"), + } + } +} + +#[test] +fn system_message_outbound_reuses_the_existing_system_chat_builder() { + let row = SystemMessageEvent { + id: 1, + recipient_identity: spacetimedb_sdk::Identity::from_byte_array([7; 32]), + recipient_guid: 9001, + message: "Package loaded".to_string(), + created_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, + }; + let outbound = system_message_event_outbound(&row); + let [Outbound::One(ServerOpcodeMessage::SMSG_MESSAGECHAT(actual))] = outbound.as_slice() else { + panic!("one System Message packet expected"); + }; + + assert_eq!( + **actual, + codec::build_gm_system_message(row.message.clone()) + ); +} + +/// cm:Player.cpp:16571-16599: a player's say, yell and `/e` carry `GetChatTag()`, AFK 1 and +/// DND 2. A creature's line carries none, whatever its flags word holds. +#[test] +fn a_nearby_line_carries_the_player_speakers_away_tag() { + let mut speaker = player_entity(); + assert_eq!(nearby_chat_tag(Some(&speaker)), 0); + speaker.player_flags = 0x02; + assert_eq!(nearby_chat_tag(Some(&speaker)), 1); + speaker.player_flags = 0x04 | 0x10; + assert_eq!(nearby_chat_tag(Some(&speaker)), 2); + speaker.type_mask = 0; + assert_eq!(nearby_chat_tag(Some(&speaker)), 0, "a creature"); + assert_eq!(nearby_chat_tag(None), 0, "a speaker already gone"); +} + +// ---- the SAY/YELL range math ----------------------------------------------------------------- + +#[test] +fn chat_in_range_gates_by_distance() { + // `chat_in_range` only ever sees plain map/x/y — distance in WORLD SPACE. + let range_sq = 25.0f32 * 25.0; // SAY_RANGE_SQ, spelled out so the test pins the shape not the const + // 10 yards apart -> in range. + assert!(chat_in_range( + 0, 0, 100.0, 100.0, 0, 0, 105.0, 108.0, range_sq + )); + // Exactly on the boundary (25yd) -> in range (inclusive, `<=`). + assert!(chat_in_range( + 0, 0, 100.0, 100.0, 0, 0, 125.0, 100.0, range_sq + )); + // Just past the boundary -> out of range: this is the assertion the wire script's "beyond + // /say range but within view -> NO chat delivery" case pins end-to-end. + assert!(!chat_in_range( + 0, 0, 100.0, 100.0, 0, 0, 125.01, 100.0, range_sq + )); +} + +#[test] +fn chat_in_range_never_crosses_a_map_or_instance_boundary_regardless_of_distance() { + // Same coordinates, different map -> never in range (also guards the arithmetic: comparing + // x/y across two unrelated maps would otherwise produce a meaningless "distance"). + assert!(!chat_in_range( + 0, 0, 100.0, 100.0, 1, 0, 100.0, 100.0, 90_000.0 + )); + // Same map, different instance (e.g. an open-world speaker vs. a dungeon-instance listener + // whose rows happen to share a database) -> never in range either (mirrors `peer_create_gate`'s + // instance isolation). + assert!(!chat_in_range( + 0, 0, 100.0, 100.0, 0, 1, 100.0, 100.0, 90_000.0 + )); +} + +#[test] +fn chat_in_range_yell_reaches_further_than_say_at_the_same_distance() { + // A speaker 200yd away: outside SAY range, inside YELL range — yell's wider radius must + // not be silently clipped to say range. + assert!(!chat_in_range( + 0, + 0, + 0.0, + 0.0, + 0, + 0, + 200.0, + 0.0, + SAY_RANGE_SQ + )); + assert!(chat_in_range( + 0, + 0, + 0.0, + 0.0, + 0, + 0, + 200.0, + 0.0, + YELL_RANGE_SQ + )); +} + +/// The text-emote range shares SAY's 25yd radius (`ListenRange.TextEmote` = `ListenRange.Say` +/// = 25 in cm:mangosd.conf.dist.in:1092): the same boundary math, over the shared constant. +#[test] +fn chat_in_range_gates_a_text_emote_at_the_same_25yd_boundary_as_say() { + let range_sq = lyracore_shared::chat::broadcast_chat::TEXT_EMOTE_RANGE_YD + * lyracore_shared::chat::broadcast_chat::TEXT_EMOTE_RANGE_YD; + assert_eq!(range_sq, SAY_RANGE_SQ); + assert!(chat_in_range(0, 0, 0.0, 0.0, 0, 0, 25.0, 0.0, range_sq)); + assert!(!chat_in_range(0, 0, 0.0, 0.0, 0, 0, 25.01, 0.0, range_sq)); +} diff --git a/gateway/src/codec/social/subscriptions/realm_chat_relay_tests.rs b/gateway/src/codec/social/subscriptions/realm_chat_relay_tests.rs new file mode 100644 index 00000000..5537aced --- /dev/null +++ b/gateway/src/codec/social/subscriptions/realm_chat_relay_tests.rs @@ -0,0 +1,144 @@ +use super::*; +use crate::stdb::bindings::RealmChatEvent; +use crate::stdb::world_view::test_support::{ + contact_entry as ignore_row, registered_viewer as listener, +}; +use crate::stdb::world_view::WorldView; +use crate::world::Outbound; +use std::sync::mpsc::Receiver; + +const SPEAKER: u64 = 10; + +fn party_line(recipients: &[u64], ignorable: bool) -> RealmChatEvent { + RealmChatEvent { + id: 1, + kind: 1, + speaker_guid: SPEAKER, + language: 7, + chat_tag: 0, + channel_name: String::new(), + message: "form up".to_string(), + recipients: recipients.to_vec(), + ignorable, + created_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, + } +} + +/// Every queued packet for one viewer, jobs run in order. +/// The body of every packet queued for one viewer, jobs run in order. Each packet must be one +/// framed `SMSG_MESSAGECHAT`: size u16 big-endian, then opcode 0x0096 little-endian. +fn received(rx: &Receiver) -> Vec> { + let mut bodies = Vec::new(); + while let Ok(outbound) = rx.try_recv() { + let Outbound::Job(job) = outbound else { + panic!("the Relay must enqueue packet work as a writer job"); + }; + for packet in job() { + let Outbound::One(message) = packet else { + panic!("a Realm Chat Line is one packet"); + }; + let mut frame = Vec::new(); + message.write_unencrypted_server(&mut frame).unwrap(); + let body = frame.split_off(4); + assert_eq!(frame[..2], ((body.len() + 2) as u16).to_be_bytes()); + assert_eq!(frame[2..], [0x96, 0x00], "SMSG_MESSAGECHAT"); + bodies.push(body); + } + } + bodies +} + +/// A PARTY line in Common from `SPEAKER` saying "form up", written out from cm:Chat.cpp:3637-3641 +/// rather than built with the codec the Relay calls: type, language, the sender guid twice, the +/// sized message with its terminator, then the chat tag. +fn expected_line() -> Vec { + let mut body = vec![0x01]; // CHAT_MSG_PARTY + body.extend(7u32.to_le_bytes()); // LANG_COMMON + body.extend(SPEAKER.to_le_bytes()); + body.extend(SPEAKER.to_le_bytes()); + body.extend(8u32.to_le_bytes()); // "form up" and its terminator + body.extend(b"form up\0"); + body.push(0); // CHAT_TAG_NONE + body +} + +#[test] +fn a_line_reaches_each_recipient_once_on_any_shard_and_nobody_else() { + let view = WorldView::new(true); + let (_, speaker_rx) = listener(&view, 0, SPEAKER); + let (_, member_rx) = listener(&view, 1, 20); + let (_, bystander_rx) = listener(&view, 0, 30); + + realm_chat_appeared(&view, &party_line(&[SPEAKER, 20, 99], false)); + + assert_eq!(received(&speaker_rx), [expected_line()]); + assert_eq!(received(&member_rx), [expected_line()]); + assert!( + received(&bystander_rx).is_empty(), + "a Character the Module did not name hears nothing" + ); +} + +#[test] +fn an_ignorable_line_skips_only_the_listener_who_ignores_the_speaker() { + let view = WorldView::new(true); + let (_, ignorer_rx) = listener(&view, 0, 20); + let (_, other_rx) = listener(&view, 1, 30); + contact_changed(&view, 0, &ignore_row(20, SPEAKER, true), true); + + realm_chat_appeared(&view, &party_line(&[20, 30], true)); + assert!(received(&ignorer_rx).is_empty()); + assert_eq!(received(&other_rx), [expected_line()]); + + realm_chat_appeared(&view, &party_line(&[20, 30], false)); + assert_eq!( + received(&ignorer_rx), + [expected_line()], + "a line the Module marked not ignorable reaches an ignorer too" + ); + assert_eq!(received(&other_rx), [expected_line()]); +} + +#[test] +fn the_ignore_set_follows_its_owners_ignore_rows_on_the_owners_shard() { + let view = WorldView::new(true); + let (viewer, _rx) = listener(&view, 0, 20); + + contact_changed(&view, 0, &ignore_row(20, SPEAKER, false), true); + assert!(!viewer.ignores(SPEAKER), "a friend row is not an ignore"); + + contact_changed(&view, 1, &ignore_row(20, SPEAKER, true), true); + assert!( + !viewer.ignores(SPEAKER), + "another Shard's copy of the owner's rows does not address this viewer" + ); + + contact_changed(&view, 0, &ignore_row(20, SPEAKER, true), true); + assert!(viewer.ignores(SPEAKER)); + + contact_changed(&view, 0, &ignore_row(20, SPEAKER, true), false); + assert!(!viewer.ignores(SPEAKER)); +} + +/// The friend set follows the same rule as the ignore set, off the SAME contact rows and +/// callbacks, filtered by `is_ignore` the other way. +#[test] +fn the_friend_set_follows_its_owners_friend_rows_on_the_owners_shard() { + let view = WorldView::new(true); + let (viewer, _rx) = listener(&view, 0, 20); + + contact_changed(&view, 0, &ignore_row(20, SPEAKER, true), true); + assert!(!viewer.is_friend(SPEAKER), "an ignore row is not a friend"); + + contact_changed(&view, 1, &ignore_row(20, SPEAKER, false), true); + assert!( + !viewer.is_friend(SPEAKER), + "another Shard's copy of the owner's rows does not address this viewer" + ); + + contact_changed(&view, 0, &ignore_row(20, SPEAKER, false), true); + assert!(viewer.is_friend(SPEAKER)); + + contact_changed(&view, 0, &ignore_row(20, SPEAKER, false), false); + assert!(!viewer.is_friend(SPEAKER)); +} diff --git a/gateway/src/codec/taxi/subscriptions.rs b/gateway/src/codec/taxi/subscriptions.rs index 0f47d7c3..84f1dfb3 100644 --- a/gateway/src/codec/taxi/subscriptions.rs +++ b/gateway/src/codec/taxi/subscriptions.rs @@ -107,3 +107,6 @@ crate::stdb::relays::declare_relays! { update(Snapshot) => |context, _event, _old, row| taxi_spline(&context.view, context.shard, row); } } + +#[cfg(test)] +mod packet_tests; diff --git a/gateway/src/codec/taxi/subscriptions/packet_tests.rs b/gateway/src/codec/taxi/subscriptions/packet_tests.rs new file mode 100644 index 00000000..e5dfb733 --- /dev/null +++ b/gateway/src/codec/taxi/subscriptions/packet_tests.rs @@ -0,0 +1,56 @@ +use super::*; +use crate::codec; +use crate::stdb::bindings::TaxiPassengerSpline; +use crate::world::Outbound; +use std::collections::HashSet; +use std::sync::Mutex; +use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; + +fn taxi_row(character_guid: u64) -> TaxiPassengerSpline { + TaxiPassengerSpline { + character_guid, + map_id: 0, + instance_id: 0, + grid_x: 0, + grid_y: 0, + cell: lyracore_shared::spatial::grid_cell_id(0, 0), + start_x: 1.0, + start_y: 2.0, + start_z: 3.0, + points: vec![4.0, 5.0, 6.0, 7.0, 8.0, 9.0], + duration_ms: 1_000, + spline_id: 10, + } +} + +#[test] +fn taxi_spline_reaches_self_and_only_created_observers() { + let row = taxi_row(42); + let created = Mutex::new(HashSet::new()); + assert_eq!(taxi_spline_outbound(&created, 42, &row).len(), 1); + assert!(taxi_spline_outbound(&created, 7, &row).is_empty()); + + created.lock().unwrap().insert(42); + assert_eq!(taxi_spline_outbound(&created, 7, &row).len(), 1); +} + +#[test] +fn malformed_taxi_spline_is_not_written_to_a_client() { + let mut row = taxi_row(42); + row.points.pop(); + assert!(taxi_spline_outbound(&Mutex::new(HashSet::new()), 42, &row).is_empty()); +} + +#[test] +fn resident_taxi_spline_is_chained_after_passenger_create() { + let row = taxi_row(42); + let created = Mutex::new(HashSet::from([42])); + let mut outbound = vec![Outbound::One(ServerOpcodeMessage::SMSG_DESTROY_OBJECT( + codec::build_destroy_object(99), + ))]; + + append_resident_taxi_after_create(&mut outbound, &created, 7, &row); + + assert!(matches!(outbound.first(), Some(Outbound::One(_)))); + assert!(matches!(outbound.get(1), Some(Outbound::Raw { opcode, .. }) if *opcode == 0x00DD)); +} diff --git a/gateway/src/codec/weather/subscriptions.rs b/gateway/src/codec/weather/subscriptions.rs index 395affcf..151b0fdb 100644 --- a/gateway/src/codec/weather/subscriptions.rs +++ b/gateway/src/codec/weather/subscriptions.rs @@ -121,3 +121,6 @@ crate::stdb::relays::declare_relays! { update(Snapshot) => |context, _event, _old, row| weather_changed(&context.view, context.shard, row); } } + +#[cfg(test)] +mod audience_tests; diff --git a/gateway/src/codec/weather/subscriptions/audience_tests.rs b/gateway/src/codec/weather/subscriptions/audience_tests.rs new file mode 100644 index 00000000..67dd08d5 --- /dev/null +++ b/gateway/src/codec/weather/subscriptions/audience_tests.rs @@ -0,0 +1,167 @@ +use super::*; +use crate::stdb::bindings::ZoneWeather; +use crate::stdb::world_index::CellKey; +use crate::stdb::world_view::test_support::{identity, queued_job, viewer_in_zone}; +use crate::stdb::world_view::WorldView; +use crate::world::{Outbound, SessionTx}; +use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; +use wow_world_messages::vanilla::{WeatherChangeType, WeatherType}; + +const ELWYNN: u32 = 12; + +const WESTFALL: u32 = 40; + +fn zone_weather(zone_id: u32, weather_type: u8, intensity: f32) -> ZoneWeather { + ZoneWeather { + zone_id, + weather_type, + intensity, + changed_at_micros: 0, + } +} + +/// A `WeatherStore` holding one zone's sky. Stands in for a shard's `game_zone_weather` cache, +/// which a unit test has no node to populate. +#[derive(Clone)] +struct CannedWeather(Option<(u32, u8, f32)>); + +impl crate::world::WeatherStore for CannedWeather { + fn zone_weather(&self, zone_id: u32) -> anyhow::Result> { + Ok(self.0.and_then(|(zone, weather_type, intensity)| { + (zone == zone_id).then_some(crate::codec::ZoneWeatherView { + weather_type, + intensity, + }) + })) + } +} + +fn only_weather(out: Vec) -> wow_world_messages::vanilla::SMSG_WEATHER { + match out.as_slice() { + [Outbound::One(ServerOpcodeMessage::SMSG_WEATHER(m))] => **m, + other => panic!( + "expected exactly one SMSG_WEATHER, got {} messages", + other.len() + ), + } +} + +/// Stories 5 and 6, the Elwynn/Westfall pair: a zone's weather reaches every viewer standing in +/// THAT zone and no viewer anywhere else, however near they are — weather is per zone, and the +/// spatial index has no say in it. +#[test] +fn a_weather_row_reaches_its_own_zone_and_no_other() { + let view = WorldView::new(true); + let (elwynn_tx, elwynn_rx) = SessionTx::with_depth(0); + let (also_elwynn_tx, also_elwynn_rx) = SessionTx::with_depth(0); + let (westfall_tx, westfall_rx) = SessionTx::with_depth(0); + let (zoneless_tx, zoneless_rx) = SessionTx::with_depth(0); + let anchor = CellKey::at(0, 0, 0, 0); + for (session, guid, zone, tx) in [ + (1, 9001, ELWYNN, elwynn_tx), + (2, 9002, ELWYNN, also_elwynn_tx), + (3, 9003, WESTFALL, westfall_tx), + (4, 9004, 0, zoneless_tx), + ] { + let v = viewer_in_zone(session, guid, identity(session as u8), zone, tx); + view.add_viewer_on_shard(v, anchor, 0); + } + + weather_changed(&view, 0, &zone_weather(ELWYNN, 1, 0.5)); + + for rx in [&elwynn_rx, &also_elwynn_rx] { + let m = only_weather(queued_job(rx)); + assert_eq!(m.weather_type, WeatherType::Rain); + assert_eq!(m.grade, 0.5); + assert_eq!(m.sound_id, 8534); + assert_eq!( + m.change, + WeatherChangeType::Smooth, + "a scheduled change fades in rather than snapping" + ); + } + assert!( + westfall_rx.try_recv().is_err(), + "Elwynn's weather must not reach a viewer standing in Westfall" + ); + assert!( + zoneless_rx.try_recv().is_err(), + "the unresolved zone matches no weather row" + ); +} + +/// Story 7: crossing into a new zone replaces the stale source-zone sky ONCE, instantly. +/// Staying put is silence — the slow scheduler must not turn every grid crossing into a packet. +#[test] +fn crossing_into_a_zone_sends_its_weather_once() { + let view = WorldView::new(true); + let (walker_tx, walker_rx) = SessionTx::with_depth(0); + let (resident_tx, resident_rx) = SessionTx::with_depth(0); + let anchor = CellKey::at(0, 0, 0, 0); + let walker = viewer_in_zone(1, 9001, identity(1), ELWYNN, walker_tx); + let resident = viewer_in_zone(2, 9002, identity(2), WESTFALL, resident_tx); + view.add_viewer_on_shard(walker.clone(), anchor, 0); + view.add_viewer_on_shard(resident.clone(), anchor, 0); + let store = CannedWeather(Some((WESTFALL, 3, 0.9))); + + // Elwynn → Westfall. + zone_crossed(&view, &store, 0, walker.self_guid, ELWYNN, WESTFALL); + let m = only_weather(queued_job(&walker_rx)); + assert_eq!(m.weather_type, WeatherType::Storm); + assert_eq!(m.grade, 0.9); + assert_eq!(m.sound_id, 8558); + assert_eq!(m.change, WeatherChangeType::Instant); + + // Still walking around Westfall; a row the Module could not resolve a zone for; and a row + // whose own delta claims a crossing the viewer has already been told about (a re-created + // entity row starts from the unresolved zone). + zone_crossed(&view, &store, 0, walker.self_guid, WESTFALL, WESTFALL); + zone_crossed(&view, &store, 0, walker.self_guid, WESTFALL, 0); + zone_crossed(&view, &store, 0, walker.self_guid, 0, WESTFALL); + assert!( + walker_rx.try_recv().is_err(), + "staying inside a zone sends nothing, the unresolved zone is not a crossing, and a \ + zone the viewer is already in is not re-sent" + ); + assert_eq!( + walker.zone_id.load(std::sync::atomic::Ordering::Relaxed), + WESTFALL, + "an unresolved zone must not erase the zone the viewer is really in" + ); + assert!( + resident_rx.try_recv().is_err(), + "another player's crossing is not this viewer's business" + ); + + // …and the arriving viewer now routes with the destination zone. + weather_changed(&view, 0, &zone_weather(WESTFALL, 2, 0.3)); + assert_eq!( + only_weather(queued_job(&walker_rx)).weather_type, + WeatherType::Snow + ); +} + +/// Story 10: a destination with no weather row is fine weather, not a missing packet — the +/// client would otherwise keep rendering the sky of the zone it left. +#[test] +fn crossing_into_a_zone_with_no_weather_row_sends_fine_weather() { + let view = WorldView::new(true); + let (tx, rx) = SessionTx::with_depth(0); + let walker = viewer_in_zone(1, 9001, identity(1), ELWYNN, tx); + view.add_viewer_on_shard(walker.clone(), CellKey::at(0, 0, 0, 0), 0); + + zone_crossed( + &view, + &CannedWeather(None), + 0, + walker.self_guid, + ELWYNN, + WESTFALL, + ); + + let m = only_weather(queued_job(&rx)); + assert_eq!(m.weather_type, WeatherType::Fine); + assert_eq!(m.grade, 0.0); + assert_eq!(m.sound_id, 0); + assert_eq!(m.change, WeatherChangeType::Instant); +} diff --git a/gateway/src/stdb/subscriptions.rs b/gateway/src/stdb/subscriptions.rs index 4fb46938..af23db09 100644 --- a/gateway/src/stdb/subscriptions.rs +++ b/gateway/src/stdb/subscriptions.rs @@ -1,72 +1,8 @@ //! World Session registration, queue metrics and durable background work. use crate::codec; -#[cfg(test)] -use crate::codec::auction::subscriptions::auction_notice_outbound; -#[cfg(test)] -use crate::codec::combat::subscriptions::cast_event_outbound; -#[cfg(test)] -use crate::codec::combat::subscriptions::combat_event_outbound; -#[cfg(test)] -use crate::codec::combat::subscriptions::stealth_action; -#[cfg(test)] -use crate::codec::combat::subscriptions::StealthAction; -#[cfg(test)] -use crate::codec::combat::subscriptions::A_STEALTH; use crate::codec::corpse::subscriptions::relay_corpse_create; -#[cfg(test)] -use crate::codec::duel::subscriptions::duel_event_outbound; -#[cfg(test)] -use crate::codec::entity::subscriptions::append_encounter_equip_after_create; -#[cfg(test)] -use crate::codec::entity::subscriptions::encounter_equip_outbound; -#[cfg(test)] -use crate::codec::entity::subscriptions::entity_update_to_outbound; -#[cfg(test)] -use crate::codec::entity::subscriptions::entity_update_to_outbound_with_dynamic_flags; -#[cfg(test)] -use crate::codec::entity::subscriptions::ghost_transition; use crate::codec::entity::subscriptions::instance_relay_gate; -#[cfg(test)] -use crate::codec::entity::subscriptions::is_update_reentry; -#[cfg(test)] -use crate::codec::entity::subscriptions::peer_create_gate; -#[cfg(test)] -use crate::codec::entity::subscriptions::peer_create_outbound; -#[cfg(test)] -use crate::codec::entity::subscriptions::peer_entity_view; -#[cfg(test)] -use crate::codec::item::subscriptions::append_final_item_slots; -#[cfg(test)] -use crate::codec::item::subscriptions::enchant_change_outbound; -#[cfg(test)] -use crate::codec::item::subscriptions::trade_event_outbound; -#[cfg(test)] -use crate::codec::movement::subscriptions::append_resident_creature_after_create; -#[cfg(test)] -use crate::codec::movement::subscriptions::build_teleport_relay; -#[cfg(test)] -use crate::codec::movement::subscriptions::creature_leg_outbound; -#[cfg(test)] -use crate::codec::movement::subscriptions::motion_outbound; -#[cfg(test)] -use crate::codec::social::subscriptions::chat_event_message; -#[cfg(test)] -use crate::codec::social::subscriptions::chat_in_range; -#[cfg(test)] -use crate::codec::social::subscriptions::nearby_chat_tag; -#[cfg(test)] -use crate::codec::social::subscriptions::system_message_event_outbound; -#[cfg(test)] -use crate::codec::social::subscriptions::SAY_RANGE_SQ; -#[cfg(test)] -use crate::codec::social::subscriptions::YELL_RANGE_SQ; -#[cfg(test)] -use crate::codec::taxi::subscriptions::append_resident_taxi_after_create; -#[cfg(test)] -use crate::codec::taxi::subscriptions::taxi_spline_outbound; -#[cfg(test)] -use crate::codec::CreateKind; use crate::world::{Outbound, SessionTx}; use anyhow::{anyhow, Result}; use spacetimedb_sdk::Table; @@ -75,16 +11,10 @@ use std::sync::atomic::Ordering; use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; -#[cfg(test)] -use wow_world_messages::vanilla::Vector3d; use super::aoi::ViewerGates; use super::bindings::*; use super::connection::Coordinator; -#[cfg(test)] -use super::reads::view_of_item_row; -#[cfg(test)] -use super::views::entity_view; use super::world_index::CellKey; use super::world_view::{self, Viewer, WorldView}; @@ -1193,8 +1123,13 @@ fn next_bot_transfer_claim_token() -> Result { #[cfg(test)] mod tests { use super::*; - use crate::codec::combat::subscriptions::tests::aura; - use crate::codec::entity::subscriptions::tests::{creature_entity, player_entity}; + use crate::codec; + use crate::codec::movement::subscriptions::{creature_leg_outbound, motion_outbound}; + use crate::stdb::bindings::{CreatureSpline, EntityMotion}; + use crate::world::{Outbound, SessionTx}; + use std::collections::HashSet; + use std::sync::{Arc, Mutex}; + use wow_world_messages::vanilla::Vector3d; #[test] fn transfer_work_continues_after_the_prior_pass_then_wraps() { @@ -1207,2817 +1142,203 @@ mod tests { assert_eq!(wrapped, [1, 2, 3, 4, 5, 6]); } - fn swing_event() -> CombatEvent { - CombatEvent { - id: 1, - attacker_guid: 7, - target_guid: 11, - damage: 23, - hit_info: 0, - killing_blow: false, - created_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, - blocked_amount: 0, - ranged_spell_id: 0, - ammo_display_id: 0, - spell_swing: false, - impact_delay_ms: 0, - map_id: 0, - instance_id: 0, - grid_x: 0, - grid_y: 0, - } - } + // ── Peer-motion relay: the CALL SITE, not just the codec helper ────────── + // + // §8 of the agent playbook: the dominant failure here is a test that pins an extracted helper + // while the wiring that calls it stays free to vanish. `codec::tests` proves the raw builder + // emits the same bytes the typed path did; these tests drive `relay_entity_motion` / + // `relay_creature_leg` themselves — a real `SessionTx`, a real `created` set, a real row — + // so deleting the send, sending the wrong variant, passing the wrong guid or opcode, or dropping + // a guard all show up here. - #[test] - fn ranged_launch_relays_one_spell_go_for_a_hit_or_miss() { - let created = Mutex::new(HashSet::from([7])); - for (hit_info, hits, misses) in [(0, 1, 0), (2, 0, 1)] { - let row = CombatEvent { - damage: 0, - hit_info, - ranged_spell_id: 75, - ammo_display_id: 5996, - impact_delay_ms: 500, - ..swing_event() - }; - let out = combat_event_outbound(&created, &row); - let [Outbound::One(ServerOpcodeMessage::SMSG_SPELL_GO(go))] = out.as_slice() else { - panic!("a ranged launch must emit exactly one SPELL_GO"); - }; - assert_eq!((go.caster.guid(), go.spell), (7, 75)); - assert_eq!((go.hits.len(), go.misses.len()), (hits, misses)); - assert!(combat_event_outbound(&Mutex::new(HashSet::new()), &row).is_empty()); + /// `MOTION_CALLS`/`SENT`/`DROPPED` are process-global atomics and the test binary runs tests in + /// PARALLEL, so any test that moves them or measures a delta around them must hold this lock. + /// Without it, the delta a counter test measures silently includes whatever a concurrent relay + /// test did (which is exactly how the shed test below first turned the motion-counters test red). Poison is + /// ignored deliberately: a panicking test has already failed and must not cascade into every + /// other one. + static MOTION_COUNTERS: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + /// Test shims for the two relays the shared-connection model split in half. + /// + /// Before the shared-connection model, each of these pushed straight onto the session channel + /// from the per-player SDK + /// callback. Now the SHARED dispatch (`stdb::world_view`) makes the shed decision on the pump + /// and enqueues a job, and the job calls the `*_outbound` half on the session's writer thread. + /// These shims are that pair, back to back, so the wire-pinning and counter tests below keep + /// asserting on the queue exactly as they did — and keep failing if either half regresses. + fn relay_entity_motion( + tx: &SessionTx, + created: &Arc>>, + self_guid: u64, + row: &EntityMotion, + ) { + if shed_motion_at_depth(tx.depth()) { + MOTION_DROPPED.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + return; + } + for o in motion_outbound(created, self_guid, row) { + if tx.send(o).is_err() { + MOTION_DROPPED.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } } } - #[test] - fn ranged_impact_relays_only_its_authoritative_damage() { - let row = SpellCastEvent { - id: 1, - caster_guid: 7, - spell_id: 75, - created_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, - target_guid: 11, - cast_time_ms: 0, - is_completion: false, - damage: 23, - school: 0, - is_crit: false, - resisted: 0, - absorbed: 0, - is_interrupted: false, - cooldown_ms: 0, - delay_ms: 0, - healed: 0, - is_proc_log: true, - swing_hit_info: 0, - client_initiated: false, - map_id: 0, - instance_id: 0, - grid_x: 0, - grid_y: 0, - failure_reason: 0, - kind: 5, - }; - let out = cast_event_outbound(7, &row); - let [Outbound::One(ServerOpcodeMessage::SMSG_SPELLNONMELEEDAMAGELOG(log))] = out.as_slice() - else { - panic!("an impact must emit exactly one damage log"); - }; - assert_eq!( - ( - log.attacker.guid(), - log.target.guid(), - log.spell, - log.damage - ), - (7, 11, 75, 23) - ); + fn relay_creature_leg( + tx: &SessionTx, + created: &Arc>>, + row: &CreatureSpline, + ) { + if shed_motion_at_depth(tx.depth()) { + MOTION_DROPPED.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + return; + } + for o in creature_leg_outbound(created, row) { + if tx.send(o).is_err() { + MOTION_DROPPED.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } + } } - #[test] - fn melee_swing_relays_damage_then_stops_on_a_killing_blow() { - let created = Mutex::new(HashSet::from([7])); - let mut row = swing_event(); - let out = combat_event_outbound(&created, &row); - assert!(matches!( - out.as_slice(), - [Outbound::One( - ServerOpcodeMessage::SMSG_ATTACKERSTATEUPDATE(_) - )] - )); - - row.killing_blow = true; - let out = combat_event_outbound(&created, &row); - assert!(matches!( - out.as_slice(), - [ - Outbound::One(ServerOpcodeMessage::SMSG_ATTACKERSTATEUPDATE(_)), - Outbound::One(ServerOpcodeMessage::SMSG_ATTACKSTOP(_)) - ] - )); + fn motion_counter_lock() -> std::sync::MutexGuard<'static, ()> { + MOTION_COUNTERS.lock().unwrap_or_else(|e| e.into_inner()) } - fn viewer_with_created(creature_guid: u64) -> Viewer { - let (tx, _rx) = SessionTx::with_depth(0); - Viewer { - active: std::sync::atomic::AtomicBool::new(true), - session: 1, - self_guid: 7, - bound_identity: spacetimedb_sdk::Identity::from_byte_array([0; 32]), + /// One motion row for `guid`, carrying the wire-form `MovementInfo` the module stores. + fn motion_row(guid: u64, opcode: u16, movement_info: Vec) -> EntityMotion { + EntityMotion { + guid, map_id: 0, instance_id: 0, - zone_id: 0.into(), - tx, - created: Arc::new(Mutex::new(HashSet::from([creature_guid]))), - gates: Arc::new(ViewerGates::default()), - skill_slots: Arc::new(Mutex::new((std::collections::HashMap::new(), 0))), - explored: Mutex::new(world_view::ExplorationReplay::default()), - motion_pending: Arc::new(world_view::MotionPending::default()), - member_stats: Default::default(), - ignored: Mutex::default(), - friends: Mutex::default(), - team: lyracore_shared::faction::TEAM_ALLIANCE, - group_events: Default::default(), + grid_x: 0, + grid_y: 0, + cell: lyracore_shared::spatial::grid_cell_id(0, 0), + opcode, + movement_info, + seq: 0, } } - #[test] - fn encounter_equipment_reaches_live_and_late_viewers_in_wire_order() { - let creature_guid = 0xF130_0000_0000_002A; - let viewer = viewer_with_created(creature_guid); - let equipment = EncounterEquip { - creature_guid, - instance_id: 0, - main_hand: 2_196, - off_hand: 2_716, - ranged: 0, - }; - - for (row, cleared, expected) in [ - (equipment.clone(), false, (2_196, 2_716, 0)), - ( - EncounterEquip { - main_hand: 2_716, - off_hand: 0, - ranged: 2_196, - ..equipment.clone() - }, - false, - (2_716, 0, 2_196), - ), - (equipment.clone(), true, (0, 0, 0)), - ] { - let out = encounter_equip_outbound(&viewer, &row, cleared); - let (expected_opcode, expected_body) = - codec::build_virtual_item_values(creature_guid, expected.0, expected.1, expected.2); - assert!(matches!( - out.as_slice(), - [Outbound::Raw { opcode, body }] - if *opcode == expected_opcode && *body == expected_body - )); - } - - let creature = codec::EntityView { - guid: creature_guid, - type_mask: lyracore_shared::constants::type_mask::CREATURE, - entry: 42, - scale_x: 1.0, - health: 1, - max_health: 1, - level: 1, - run_speed_mult_bp: 10_000, - unit_bytes_0: 0x0101, - display_id: 1, - native_display_id: 1, - ..Default::default() + /// A running-forward movement block plus the bytes the module would have stored for it. + fn moving_forward() -> (wow_world_messages::vanilla::MovementInfo, Vec) { + let info = wow_world_messages::vanilla::MovementInfo { + flags: wow_world_messages::vanilla::MovementInfo_MovementFlags::new_forward(), + timestamp: 0x0BAD_F00D, + position: Vector3d { + x: -8949.95, + y: -132.493, + z: 83.5312, + }, + orientation: 2.5, + fall_time: 0.0, }; - let create = codec::build_create_object(&creature, CreateKind::Peer, &[], &[]).unwrap(); - let mut late = vec![Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( - Box::new(create), - ))]; - append_encounter_equip_after_create(&mut late, &viewer, &equipment); - assert!(matches!( - late.as_slice(), - [ - Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(_)), - Outbound::Raw { .. } - ] - )); + let bytes = codec::movement_info_to_bytes(&info).unwrap(); + (info, bytes) } + /// What the observer's writer actually receives for one peer heartbeat: an `Outbound::Raw` whose + /// opcode and body equal what the TYPED path would have serialized. The typed + /// message is built here independently of the raw builder, so this fails if the relay passes the + /// wrong guid, the wrong opcode, the row's bytes in the wrong place — or stops sending at all. #[test] - fn chat_event_target_reaches_every_addressable_creature_packet() { - let base = ChatEvent { - id: 1, - sender_guid: 0xF130_0000_0000_002A, - chat_type: 0, - language: 0, - message: "You there!".into(), - created_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, - target_guid: 77, - }; - for chat_type in [ - lyracore_shared::chat::broadcast_chat::SAY, - lyracore_shared::chat::broadcast_chat::YELL, - lyracore_shared::chat::broadcast_chat::CREATURE_TEXT_EMOTE, - ] { - let out = chat_event_message( - &ChatEvent { - chat_type, - ..base.clone() - }, - Some("Defias Thug".into()), - 0, - ); - let Outbound::One(ServerOpcodeMessage::SMSG_MESSAGECHAT(message)) = out else { - panic!("expected creature chat packet"); - }; - match &message.chat_type { - wow_world_messages::vanilla::SMSG_MESSAGECHAT_ChatType::MonsterSay { - target, - .. - } - | wow_world_messages::vanilla::SMSG_MESSAGECHAT_ChatType::MonsterYell { - target, - .. - } => assert_eq!(target.guid(), 77), - // `MonsterEmote` has no addressee field on the wire; its single guid names the - // speaker, so the addressed target is legitimately absent here. - wow_world_messages::vanilla::SMSG_MESSAGECHAT_ChatType::MonsterEmote { - monster, - .. - } => assert_eq!(monster.guid(), 0xF130_0000_0000_002A), - other => panic!("expected addressable creature chat, got {other:?}"), + fn the_motion_relay_queues_the_bytes_the_typed_path_would_have_sent() { + let _counters = motion_counter_lock(); + let (info, bytes) = moving_forward(); + let (tx, rx) = crate::world::SessionTx::with_depth(0); + let created = Arc::new(Mutex::new(HashSet::from([42u64]))); + let opcode = lyracore_shared::opcodes::movement::MSG_MOVE_START_FORWARD; + + relay_entity_motion(&tx, &created, 1, &motion_row(42, opcode as u16, bytes)); + + let mut framed = Vec::new(); + codec::build_movement_relay(opcode, 42, info) + .unwrap() + .write_unencrypted_server(&mut framed) + .unwrap(); + match rx + .try_recv() + .expect("the observer's writer must have been handed a packet") + { + Outbound::Raw { opcode, body } => { + assert_eq!( + opcode, + u16::from_le_bytes([framed[2], framed[3]]), + "relayed opcode" + ); + assert_eq!( + body, + &framed[4..], + "relayed body must match the typed serialization" + ); } + Outbound::One(m) => panic!("peer motion must ride Outbound::Raw, got a typed {m}"), + Outbound::Batch(v) => panic!( + "peer motion must ride Outbound::Raw, got a {}-batch", + v.len() + ), + Outbound::Job(_) => panic!("peer motion must be encoded by the time it is queued"), } + assert!(rx.try_recv().is_err(), "exactly one packet per motion row"); } + /// The two guards that were free before the raw path and must stay: a player never receives + /// their OWN movement echoed back (they would fight the server for authority over their + /// position), and no `MSG_MOVE_*` goes out for a guid this client never got a CREATE for + /// (a movement packet for an unknown entity desyncs the client). #[test] - fn system_message_outbound_reuses_the_existing_system_chat_builder() { - let row = SystemMessageEvent { - id: 1, - recipient_identity: spacetimedb_sdk::Identity::from_byte_array([7; 32]), - recipient_guid: 9001, - message: "Package loaded".to_string(), - created_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, - }; - let outbound = system_message_event_outbound(&row); - let [Outbound::One(ServerOpcodeMessage::SMSG_MESSAGECHAT(actual))] = outbound.as_slice() - else { - panic!("one System Message packet expected"); - }; + fn the_motion_relay_skips_self_and_uncreated_movers() { + let _counters = motion_counter_lock(); + let (_, bytes) = moving_forward(); + let opcode = lyracore_shared::opcodes::movement::MSG_MOVE_HEARTBEAT as u16; + let (tx, rx) = crate::world::SessionTx::with_depth(0); + let created = Arc::new(Mutex::new(HashSet::from([42u64, 7u64]))); - assert_eq!( - **actual, - codec::build_gm_system_message(row.message.clone()) + relay_entity_motion(&tx, &created, 7, &motion_row(7, opcode, bytes.clone())); + assert!( + rx.try_recv().is_err(), + "a player's own movement must never be echoed back" ); - } - fn duel_event(kind: u8, completion_kind: u8) -> DuelEvent { - DuelEvent { - id: 1, - recipient_identity: spacetimedb_sdk::Identity::from_byte_array([0u8; 32]), - recipient_guid: 10, - kind, - completion_kind, - duel_id: 3, - flag_guid: 0xF110_2000_0000_0003, - flag_entry: 21680, - initiator_guid: 10, - challenged_guid: 20, - winner_guid: 0, - loser_guid: 0, - map_id: 1, - instance_id: 7, - flag_x: 3.0, - flag_y: 4.0, - flag_z: 5.0, - flag_orientation: 1.25, - created_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, - winner_name: String::new(), - loser_name: String::new(), - } + relay_entity_motion(&tx, &created, 7, &motion_row(999, opcode, bytes)); + assert!( + rx.try_recv().is_err(), + "no MSG_MOVE for a guid this client never got a CREATE for" + ); } + /// A malformed row must produce no packet at all — the raw path memcpys the block, so a short + /// one would be framed with a `size` the client trusts and would desync its header stream. #[test] - fn duel_request_projects_one_typed_request_with_flag_arbiter_and_challenger() { - let template = codec::GameObjectTemplateView { - type_id: 16, - display_id: 787, - name: "Duel Arbiter".into(), - data0: 0, - data1: 0, - data2: 0, - }; - let row = duel_event(0, 0); - let out = duel_event_outbound(&row, Some(&template)); + fn the_motion_relay_drops_a_truncated_movement_block() { + let _counters = motion_counter_lock(); + let (_, bytes) = moving_forward(); + let (tx, rx) = crate::world::SessionTx::with_depth(0); + let created = Arc::new(Mutex::new(HashSet::from([42u64]))); + let opcode = lyracore_shared::opcodes::movement::MSG_MOVE_HEARTBEAT as u16; - assert_eq!(out.len(), 5); - let requests: Vec<_> = out - .iter() - .filter_map(|message| match message { - Outbound::One(ServerOpcodeMessage::SMSG_DUEL_REQUESTED(request)) => Some(request), - _ => None, - }) - .collect(); - assert_eq!(requests.len(), 1); - assert_eq!( - requests[0].initiator, - wow_world_messages::Guid::new(row.flag_guid) + relay_entity_motion( + &tx, + &created, + 1, + &motion_row(42, opcode, bytes[..20].to_vec()), ); - assert_eq!( - requests[0].target, - wow_world_messages::Guid::new(row.initiator_guid) + assert!( + rx.try_recv().is_err(), + "a truncated movement block must not be framed" ); } + /// + /// Deltas rather than absolutes because the counter is a process-global atomic; this is the only + /// test that increments it, so the deltas are exact. #[test] - fn duel_countdown_active_and_interruption_project_the_expected_edges() { - let countdown = duel_event_outbound(&duel_event(1, 0), None); - assert!(matches!( - countdown.as_slice(), - [Outbound::One(ServerOpcodeMessage::SMSG_DUEL_COUNTDOWN(message))] - if message.time.as_secs() == 3_000 - )); - - let active = duel_event_outbound(&duel_event(2, 0), None); - assert_eq!(active.len(), 2); - assert!(active.iter().all(|out| matches!(out, Outbound::Raw { .. }))); - - let interrupted = duel_event_outbound(&duel_event(3, 0), None); - assert_eq!(interrupted.len(), 4); - assert!(matches!( - &interrupted[0], - Outbound::One(ServerOpcodeMessage::SMSG_DUEL_COMPLETE(message)) - if !message.ended_without_interruption - )); - assert!(matches!( - interrupted.last(), - Some(Outbound::One(ServerOpcodeMessage::SMSG_DESTROY_OBJECT(_))) - )); - } - - #[test] - fn duel_winner_projects_names_and_the_terminal_reason() { - let mut won = duel_event(3, 2); - won.winner_guid = 10; - won.loser_guid = 20; - won.winner_name = "Winner".into(); - won.loser_name = "Loser".into(); - let out = duel_event_outbound(&won, None); - assert!(matches!( - out.as_slice(), - [ - Outbound::One(ServerOpcodeMessage::SMSG_DUEL_COMPLETE(_)), - Outbound::One(ServerOpcodeMessage::SMSG_DUEL_WINNER(message)), - .. - // `opponent_name` is written first on the wire and must carry the winner. - ] if message.reason == wow_world_base::shared::duel_winner_reason_vanilla_tbc_wrath::DuelWinnerReason::Won - && message.opponent_name == "Winner" - && message.initiator_name == "Loser" - )); - - let mut fled_event = duel_event(3, 3); - fled_event.winner_guid = 10; - fled_event.loser_guid = 20; - fled_event.winner_name = "Winner".into(); - fled_event.loser_name = "Loser".into(); - let fled = duel_event_outbound(&fled_event, None); - assert!(matches!( - &fled[1], - Outbound::One(ServerOpcodeMessage::SMSG_DUEL_WINNER(message)) - if message.reason == wow_world_base::shared::duel_winner_reason_vanilla_tbc_wrath::DuelWinnerReason::Fled - )); - } - - #[test] - fn duel_boundary_edges_project_only_the_recipient_transition() { - let out_of_bounds = duel_event_outbound(&duel_event(4, 0), None); - assert!(matches!( - out_of_bounds.as_slice(), - [Outbound::One(ServerOpcodeMessage::SMSG_DUEL_OUTOFBOUNDS)] - )); - - let in_bounds = duel_event_outbound(&duel_event(5, 0), None); - assert!(matches!( - in_bounds.as_slice(), - [Outbound::One(ServerOpcodeMessage::SMSG_DUEL_INBOUNDS)] - )); - } - - /// The trade-status wire mapping: every `lyracore_shared::trade::event_kind` the module - /// emits decodes to its `SMSG_TRADE_STATUS` variant — `BeginTrade` carrying the counterparty - /// guid the client needs to open the window — and an unknown kind (newer module mid-rollout) - /// drops rather than desyncing the trade window. - #[test] - fn trade_event_kinds_decode_to_their_trade_status_variants() { - use lyracore_shared::trade::event_kind as kind; - use wow_world_messages::vanilla::SMSG_TRADE_STATUS; - - let event = |k: u8| TradeEvent { - id: 1, - recipient_identity: spacetimedb_sdk::Identity::from_byte_array([0u8; 32]), - kind: k, - other_guid: 77, - created_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, - recipient_guid: 1, - payload: String::new(), - }; - let status_of = |k: u8| -> SMSG_TRADE_STATUS { - let out = trade_event_outbound(&event(k)); - assert_eq!(out.len(), 1, "kind {k} must decode to exactly one packet"); - match &out[0] { - Outbound::One(ServerOpcodeMessage::SMSG_TRADE_STATUS(s)) => **s, - Outbound::One(other) => panic!("kind {k}: expected SMSG_TRADE_STATUS, got {other}"), - _ => panic!("kind {k}: expected a single SMSG_TRADE_STATUS packet"), - } - }; - - assert_eq!( - status_of(kind::BEGIN_TRADE), - SMSG_TRADE_STATUS::BeginTrade { - unknown1: wow_world_messages::Guid::new(77) - }, - "BeginTrade must carry the initiator guid off the row" - ); - assert_eq!(status_of(kind::OPEN_WINDOW), SMSG_TRADE_STATUS::OpenWindow); - assert_eq!( - status_of(kind::TRADE_CANCELED), - SMSG_TRADE_STATUS::TradeCanceled - ); - assert_eq!(status_of(kind::BUSY), SMSG_TRADE_STATUS::Busy); - assert_eq!(status_of(kind::NO_TARGET), SMSG_TRADE_STATUS::NoTarget); - assert_eq!( - status_of(kind::TARGET_TO_FAR), - SMSG_TRADE_STATUS::TargetToFar - ); - assert_eq!( - status_of(kind::WRONG_FACTION), - SMSG_TRADE_STATUS::WrongFaction - ); - assert_eq!(status_of(kind::YOU_DEAD), SMSG_TRADE_STATUS::YouDead); - assert_eq!(status_of(kind::TARGET_DEAD), SMSG_TRADE_STATUS::TargetDead); - assert_eq!(status_of(kind::IGNORE_YOU), SMSG_TRADE_STATUS::IgnoreYou); - assert_eq!( - status_of(kind::TRADE_ACCEPT), - SMSG_TRADE_STATUS::TradeAccept - ); - assert_eq!( - status_of(kind::BACK_TO_TRADE), - SMSG_TRADE_STATUS::BackToTrade - ); - assert_eq!( - status_of(kind::TRADE_COMPLETE), - SMSG_TRADE_STATUS::TradeComplete - ); - // The bag-space refusal closes the window naming WHOSE bags overflowed. - let full_self = status_of(kind::INV_FULL_SELF); - let full_partner = status_of(kind::INV_FULL_PARTNER); - match (&full_self, &full_partner) { - ( - SMSG_TRADE_STATUS::CloseWindow { - target_error: false, - inventory_result: a, - .. - }, - SMSG_TRADE_STATUS::CloseWindow { - target_error: true, - inventory_result: b, - .. - }, - ) => { - assert_eq!( - *a, - wow_world_messages::vanilla::InventoryResult::InventoryFull - ); - assert_eq!( - *b, - wow_world_messages::vanilla::InventoryResult::InventoryFull - ); - } - other => panic!("expected CloseWindow pair with flipped target_error, got {other:?}"), - } - // The purse-failure pair mirrors it with NotEnoughMoney. - match ( - &status_of(kind::GOLD_FAIL_SELF), - &status_of(kind::GOLD_FAIL_PARTNER), - ) { - ( - SMSG_TRADE_STATUS::CloseWindow { - target_error: false, - inventory_result: a, - .. - }, - SMSG_TRADE_STATUS::CloseWindow { - target_error: true, - inventory_result: b, - .. - }, - ) => { - assert_eq!( - *a, - wow_world_messages::vanilla::InventoryResult::NotEnoughMoney - ); - assert_eq!( - *b, - wow_world_messages::vanilla::InventoryResult::NotEnoughMoney - ); - } - other => panic!("expected NotEnoughMoney CloseWindow pair, got {other:?}"), - } - - assert!( - trade_event_outbound(&event(200)).is_empty(), - "an unknown kind must drop, not guess a status" - ); - } - - /// Auction Notice kind decoding (`cm:AuctionHouseHandler.cpp`/`AuctionHouseMgr.cpp`): Outbid - /// and Won go out on `SMSG_AUCTION_BIDDER_NOTIFICATION`, Sold, Expired and New bid go out on - /// `SMSG_AUCTION_OWNER_NOTIFICATION`, Removed goes out on `SMSG_AUCTION_REMOVED_NOTIFICATION`, - /// and an unrecognized kind or an unimported house both drop the packet. The Auction Mail the - /// same transaction wrote still reaches the inbox. - #[test] - fn auction_notice_kinds_decode_to_their_own_packet_and_an_unknown_one_drops() { - use lyracore_shared::auction::auction_notice::{ - EXPIRED, NEW_BID, OUTBID, REMOVED, SOLD, WON, - }; - use wow_world_messages::vanilla::AuctionHouse; - - let notice = |kind: u8, house: u32| AuctionNotice { - id: 1, - recipient_guid: 7, - kind, - house, - auction_id: 41, - item_entry: 25, - random_property_id: 117, - bid: 500, - out_bid: 25, - bidder_guid: 9, - created_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, - }; - - for kind in [OUTBID, WON] { - let out = auction_notice_outbound(¬ice(kind, 1)); - assert_eq!( - out.len(), - 1, - "kind {kind} must decode to exactly one packet" - ); - match &out[0] { - Outbound::One(ServerOpcodeMessage::SMSG_AUCTION_BIDDER_NOTIFICATION(packet)) => { - assert_eq!(packet.auction_house, AuctionHouse::Stormwind); - assert_eq!(packet.bidder.guid(), 9); - } - Outbound::One(other) => { - panic!("kind {kind}: expected SMSG_AUCTION_BIDDER_NOTIFICATION, got {other}") - } - _ => { - panic!("kind {kind}: expected a single SMSG_AUCTION_BIDDER_NOTIFICATION packet") - } - } - } - - for kind in [SOLD, EXPIRED, NEW_BID] { - let out = auction_notice_outbound(¬ice(kind, 1)); - assert_eq!( - out.len(), - 1, - "kind {kind} must decode to exactly one packet" - ); - match &out[0] { - Outbound::One(ServerOpcodeMessage::SMSG_AUCTION_OWNER_NOTIFICATION(packet)) => { - assert_eq!(packet.auction_id, 41); - } - Outbound::One(other) => { - panic!("kind {kind}: expected SMSG_AUCTION_OWNER_NOTIFICATION, got {other}") - } - _ => { - panic!("kind {kind}: expected a single SMSG_AUCTION_OWNER_NOTIFICATION packet") - } - } - } - - let out = auction_notice_outbound(¬ice(REMOVED, 1)); - assert!( - matches!( - out.as_slice(), - [Outbound::One(ServerOpcodeMessage::SMSG_AUCTION_REMOVED_NOTIFICATION(packet))] - if packet.item == 41 - && packet.item_template == 25 - && packet.random_property_id == 117 - ), - "Removed goes out on SMSG_AUCTION_REMOVED_NOTIFICATION" - ); - - assert!( - auction_notice_outbound(¬ice(200, 1)).is_empty(), - "an unknown kind must drop, not guess a packet" - ); - assert!( - auction_notice_outbound(¬ice(OUTBID, 99)).is_empty(), - "a house outside 1-7 must drop rather than send an unresolved house" - ); - } - - /// The OFFER_* kinds decode to the fixed-444-byte extended status: the polarity byte - /// comes from the KIND (never inferred), the window-visible item fields survive the payload - /// round-trip into the right wire slots, unused slots stay zeroed, and a malformed payload - /// drops the packet entirely. - #[test] - fn offer_events_decode_to_the_extended_status_with_the_kind_deciding_self_player() { - use lyracore_shared::trade::{encode_offer, event_kind as kind, OfferSlot}; - use wow_world_messages::vanilla::SMSG_TRADE_STATUS_EXTENDED; - - let payload = encode_offer( - 1_2345, - &[OfferSlot { - trade_slot: 6, - entry: 6948, - display_id: 6418, - stack_count: 5, - enchantment: 2564, - durability: 34, - max_durability: 40, - random_property_id: 0, - }], - ); - let event = |k: u8, p: &str| TradeEvent { - id: 1, - recipient_identity: spacetimedb_sdk::Identity::from_byte_array([0u8; 32]), - kind: k, - other_guid: 77, - created_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, - recipient_guid: 1, - payload: p.to_string(), - }; - let extended = |k: u8| -> SMSG_TRADE_STATUS_EXTENDED { - let out = trade_event_outbound(&event(k, &payload)); - match out.first() { - Some(Outbound::One(ServerOpcodeMessage::SMSG_TRADE_STATUS_EXTENDED(m))) => **m, - _ => panic!("kind {k}: expected one SMSG_TRADE_STATUS_EXTENDED"), - } - }; - - // Wire polarity (mangoszero): the `self_player` BYTE is "1 means traders data, 0 means - // own", so your OWN echo carries 0 and the partner's side carries 1. - let own = extended(kind::OFFER_SELF); - assert!( - !own.self_player, - "OFFER_SELF describes your own window: wire byte 0" - ); - let theirs = extended(kind::OFFER_PARTNER); - assert!( - theirs.self_player, - "OFFER_PARTNER describes the partner's side: wire byte 1" - ); - - assert_eq!(own.money_in_trade.as_int(), 1_2345); - assert_eq!((own.trade_slot_count1, own.trade_slot_count2), (7, 7)); - let s = &own.trade_slots[6]; - assert_eq!( - (s.trade_slot_number, s.item, s.display_id, s.stack_count), - (6, 6948, 6418, 5) - ); - assert_eq!( - (s.enchantment, s.durability, s.max_durability), - (2564, 34, 40) - ); - assert_eq!( - own.trade_slots[0], - Default::default(), - "untouched slots stay zeroed" - ); - - assert!( - trade_event_outbound(&event(kind::OFFER_SELF, "not|a,valid,payload")).is_empty(), - "a malformed offer payload must drop the packet" - ); - } - - #[test] - fn a_compatibility_enchant_in_an_offer_goes_out_as_its_client_enchantment() { - use lyracore_shared::trade::{encode_offer, event_kind as kind, OfferSlot}; - let slot = |trade_slot, enchantment| OfferSlot { - trade_slot, - entry: 6948, - display_id: 6418, - stack_count: 1, - enchantment, - durability: 40, - max_durability: 40, - random_property_id: 0, - }; - let payload = encode_offer(0, &[slot(0, 7745), slot(1, 7748)]); - let event = TradeEvent { - id: 1, - recipient_identity: spacetimedb_sdk::Identity::from_byte_array([0u8; 32]), - kind: kind::OFFER_SELF, - other_guid: 77, - created_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, - recipient_guid: 1, - payload, - }; - let out = trade_event_outbound(&event); - let Some(Outbound::One(ServerOpcodeMessage::SMSG_TRADE_STATUS_EXTENDED(extended))) = - out.first() - else { - panic!("expected one SMSG_TRADE_STATUS_EXTENDED"); - }; - assert_eq!(extended.trade_slots[0].enchantment, 823); - assert_eq!(extended.trade_slots[1].enchantment, 724); - } - - const HEROIC_STRIKE_CASTER: u64 = 7; - /// The module's `CAST_FAIL_NO_POWER`. - const NO_POWER_CODE: u8 = 1; - - fn spell_cast_event(kind: u8) -> SpellCastEvent { - SpellCastEvent { - id: 1, - caster_guid: HEROIC_STRIKE_CASTER, - spell_id: 133, - created_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, - target_guid: 11, - cast_time_ms: 0, - is_completion: false, - damage: 0, - school: 0, - is_crit: false, - resisted: 0, - absorbed: 0, - is_interrupted: false, - cooldown_ms: 0, - delay_ms: 0, - healed: 0, - is_proc_log: false, - swing_hit_info: 0, - client_initiated: false, - map_id: 0, - instance_id: 0, - grid_x: 0, - grid_y: 0, - failure_reason: 0, - kind, - } - } - - /// An interruption signal row for `spell_id` cast by `HEROIC_STRIKE_CASTER`, carrying - /// `failure_reason`. Every other field stays at the module baseline's zero, which is what such a - /// row really looks like on the wire. - fn interrupted_cast(spell_id: u32, failure_reason: u8) -> SpellCastEvent { - SpellCastEvent { - spell_id, - target_guid: 0, - is_interrupted: true, - failure_reason, - ..spell_cast_event(3) - } - } - - #[test] - fn explicit_spell_cast_event_kinds_override_legacy_discriminators() { - let contradictory = |kind| SpellCastEvent { - cast_time_ms: 1_500, - is_completion: true, - damage: 23, - is_interrupted: true, - delay_ms: 500, - is_proc_log: true, - kind, - ..spell_cast_event(kind) - }; - - assert!(matches!( - cast_event_outbound(HEROIC_STRIKE_CASTER, &contradictory(1)).as_slice(), - [Outbound::One(ServerOpcodeMessage::SMSG_SPELL_START(_))] - )); - assert!(matches!( - cast_event_outbound(HEROIC_STRIKE_CASTER + 1, &contradictory(2)).as_slice(), - [ - Outbound::One(ServerOpcodeMessage::SMSG_SPELL_GO(_)), - Outbound::One(ServerOpcodeMessage::SMSG_SPELLNONMELEEDAMAGELOG(_)) - ] - )); - - let interrupt = SpellCastEvent { - kind: 3, - ..spell_cast_event(3) - }; - assert!(matches!( - cast_event_outbound(HEROIC_STRIKE_CASTER, &interrupt).as_slice(), - [Outbound::One(ServerOpcodeMessage::SMSG_SPELL_FAILURE(_))] - )); - - let pushback = SpellCastEvent { - delay_ms: 500, - kind: 4, - ..spell_cast_event(4) - }; - assert!(matches!( - cast_event_outbound(HEROIC_STRIKE_CASTER, &pushback).as_slice(), - [Outbound::One(ServerOpcodeMessage::SMSG_SPELL_DELAYED(_))] - )); - - let proc_log = SpellCastEvent { - damage: 23, - kind: 5, - ..spell_cast_event(5) - }; - assert!(matches!( - cast_event_outbound(HEROIC_STRIKE_CASTER, &proc_log).as_slice(), - [Outbound::One( - ServerOpcodeMessage::SMSG_SPELLNONMELEEDAMAGELOG(_) - )] - )); - } - - #[test] - fn legacy_spell_cast_event_rows_keep_the_field_based_decoder() { - let start = SpellCastEvent { - cast_time_ms: 1_500, - ..spell_cast_event(0) - }; - assert!(matches!( - cast_event_outbound(HEROIC_STRIKE_CASTER, &start).as_slice(), - [Outbound::One(ServerOpcodeMessage::SMSG_SPELL_START(_))] - )); - - let go = spell_cast_event(0); - assert!(matches!( - cast_event_outbound(HEROIC_STRIKE_CASTER + 1, &go).as_slice(), - [ - Outbound::One(ServerOpcodeMessage::SMSG_SPELL_START(_)), - Outbound::One(ServerOpcodeMessage::SMSG_SPELL_GO(_)) - ] - )); - - let interrupt = SpellCastEvent { - is_interrupted: true, - ..spell_cast_event(0) - }; - assert!(matches!( - cast_event_outbound(HEROIC_STRIKE_CASTER, &interrupt).as_slice(), - [Outbound::One(ServerOpcodeMessage::SMSG_SPELL_FAILURE(_))] - )); - - let pushback = SpellCastEvent { - delay_ms: 500, - ..spell_cast_event(0) - }; - assert!(matches!( - cast_event_outbound(HEROIC_STRIKE_CASTER, &pushback).as_slice(), - [Outbound::One(ServerOpcodeMessage::SMSG_SPELL_DELAYED(_))] - )); - - let proc_log = SpellCastEvent { - damage: 23, - is_proc_log: true, - ..spell_cast_event(0) - }; - assert!(matches!( - cast_event_outbound(HEROIC_STRIKE_CASTER, &proc_log).as_slice(), - [Outbound::One( - ServerOpcodeMessage::SMSG_SPELLNONMELEEDAMAGELOG(_) - )] - )); - - let old_precedence = SpellCastEvent { - cast_time_ms: 1_500, - is_interrupted: true, - delay_ms: 500, - is_proc_log: true, - ..spell_cast_event(0) - }; - assert!(matches!( - cast_event_outbound(HEROIC_STRIKE_CASTER, &old_precedence).as_slice(), - [Outbound::One(ServerOpcodeMessage::SMSG_SPELL_FAILURE(_))] - )); - } - - #[test] - fn unknown_spell_cast_event_kind_emits_no_packet() { - let row = SpellCastEvent { - is_interrupted: true, - delay_ms: 500, - is_proc_log: true, - kind: u8::MAX, - ..spell_cast_event(u8::MAX) - }; - assert!(cast_event_outbound(HEROIC_STRIKE_CASTER, &row).is_empty()); - } - - /// A queued strike that could not pay its cost at the swing: the caster gets the cast-bar - /// teardown and then the failed cast result naming the queued spell with the vanilla Not Enough - /// Power reason (0x4D). No SMSG_SPELL_GO — the strike never fired. - #[test] - fn a_deferred_power_failure_relays_the_teardown_then_the_failed_cast_result() { - let out = cast_event_outbound(HEROIC_STRIKE_CASTER, &interrupted_cast(78, NO_POWER_CODE)); - - assert_eq!(out.len(), 2, "expected exactly the two vanilla messages"); - match &out[0] { - Outbound::One(ServerOpcodeMessage::SMSG_SPELL_FAILURE(m)) => { - assert_eq!(m.guid.guid(), HEROIC_STRIKE_CASTER); - assert_eq!(m.spell, 78); - } - _ => panic!("expected the interruption teardown first"), - } - match &out[1] { - Outbound::Raw { opcode, body } => { - assert_eq!(*opcode, 0x0130); - // spell(u32 LE) + CAST_FAILED(0x02) + SPELL_FAILED_NO_POWER(0x4D). - assert_eq!(body, &vec![78, 0, 0, 0, 0x02, 0x4D]); - } - _ => panic!("expected the raw failed cast result second"), - } - } - - /// The failed cast result is caster-private: a bystander watching the same row gets nothing. - #[test] - fn a_bystander_sees_neither_half_of_a_deferred_power_failure() { - let out = cast_event_outbound( - HEROIC_STRIKE_CASTER + 1, - &interrupted_cast(78, NO_POWER_CODE), - ); - assert!(out.is_empty(), "both messages are caster-private"); - } - - /// An interruption with no concrete reason (a damaged mid-cast timed spell) still sends the - /// teardown alone — the cast result belongs to a failure the server can name. - #[test] - fn an_ordinary_interruption_still_sends_the_teardown_alone() { - let out = cast_event_outbound(HEROIC_STRIKE_CASTER, &interrupted_cast(133, 0)); - assert_eq!(out.len(), 1, "expected the teardown alone"); - assert!(matches!( - &out[0], - Outbound::One(ServerOpcodeMessage::SMSG_SPELL_FAILURE(_)) - )); - } - - fn created_player(out: &[Outbound]) -> wow_world_messages::vanilla::UpdatePlayer { - use wow_world_messages::vanilla::{Object, UpdateMask}; - let Some(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(create))) = out.first() - else { - panic!("a peer CREATE comes first"); - }; - let [Object::CreateObject2 { - mask2: UpdateMask::Player(player), - .. - }] = create.objects.as_slice() - else { - panic!("the CREATE must carry a player mask"); - }; - player.clone() - } - - /// A Human Warrior: the CREATE encoder refuses race 0. - fn human_warrior() -> WorldEntity { - WorldEntity { - unit_bytes_0: 1 | (1 << 8) | (1 << 24), - ..player_entity() - } - } - - #[test] - fn a_peer_player_create_carries_its_guild_projection() { - let row = human_warrior(); - let view = peer_entity_view(&row, 0, |guid| { - assert_eq!(guid, row.guid); - (7, 3) - }); - let player = created_player(&peer_create_outbound(&view, &[], &[], None).unwrap()); - assert_eq!(player.player_guildid(), Some(7)); - assert_eq!(player.player_guildrank(), Some(3)); - } - - #[test] - fn a_peer_outside_any_guild_is_created_with_an_explicit_zero() { - let row = human_warrior(); - let player = created_player( - &peer_create_outbound(&peer_entity_view(&row, 0, |_| (0, 0)), &[], &[], None).unwrap(), - ); - assert_eq!(player.player_guildid(), Some(0)); - assert_eq!(player.player_guildrank(), Some(0)); - } - - #[test] - fn a_creature_create_reads_no_guild() { - let view = peer_entity_view(&creature_entity(), 0, |_| panic!("a creature has no Guild")); - assert_eq!((view.guild_id, view.guild_rank), (0, 0)); - } - - #[test] - fn no_change_emits_nothing() { - let e = player_entity(); - assert!(entity_update_to_outbound(&e, &e).is_empty()); - } - - #[test] - fn single_health_change_emits_one() { - let old = player_entity(); - let mut new = old.clone(); - new.health = 50; - let out = entity_update_to_outbound(&old, &new); - // Decode + pin: guid and the exact health value ride the health-only VALUES packet. - assert_eq!( - out, - vec![ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( - codec::build_health_values(new.guid, 50) - ))] - ); - } - - /// a sheath change must relay, or drawing a weapon is invisible to everyone but the player - /// who did it. The state lives in BYTE 0 of `unit_bytes_2` — pin the decoded packet, and pin that - /// an unchanged row stays silent (the client re-sends its current state on every weapon swap, and - /// a relay per no-op is a broadcast amplifier on a busy cell). - #[test] - fn sheath_change_relays_and_a_no_op_stays_silent() { - let old = player_entity(); - let mut new = old.clone(); - new.unit_bytes_2 = 1; // SHEATH_STATE_MELEE — weapons drawn - assert_eq!( - entity_update_to_outbound(&old, &new), - vec![ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( - codec::build_sheath_values(new.guid, 1) - ))] - ); - assert!( - entity_update_to_outbound(&old, &old.clone()).is_empty(), - "an unchanged row must not relay a sheath packet" - ); - } - - #[test] - fn independent_field_changes_each_emit() { - // health (any unit) + dynamic_flags (any unit) + power (player) = three separate packets, - // in the relay's fixed emission order — decoded and pinned by guid + value, not just count. - let old = player_entity(); - let mut new = old.clone(); - new.health = 50; - new.dynamic_flags = 1; - new.power = 30; - let power_b = (new.unit_bytes_0 >> 24) as u8; - let out = entity_update_to_outbound(&old, &new); - assert_eq!( - out, - vec![ - ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(codec::build_health_values( - new.guid, 50 - ))), - ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( - codec::build_dynamic_flags_values(new.guid, 1) - )), - ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(codec::build_power_values( - new.guid, power_b, 30 - ))), - ] - ); - } - - #[test] - fn dynamic_flag_values_uses_the_viewer_projection() { - let mut old = creature_entity(); - old.dynamic_flags = 0; - let mut new = old.clone(); - new.dynamic_flags = lyracore_shared::constants::unit_dynamic_flags::TAPPED; - let projected = - new.dynamic_flags | lyracore_shared::constants::unit_dynamic_flags::TAPPED_BY_PLAYER; - assert_eq!( - entity_update_to_outbound_with_dynamic_flags(&old, &new, projected), - vec![ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( - codec::build_dynamic_flags_values(new.guid, projected) - ))] - ); - } - - #[test] - fn levelup_is_exclusive_with_field_diffs() { - // A ding collapses level + vitals + xp into ONE combined VALUES block, NOT separate health/xp - // packets — even though health and xp also changed this tick. - let old = player_entity(); - let mut new = old.clone(); - new.level = 2; - new.health = 120; - new.max_health = 120; - new.xp = 10; - assert_eq!(entity_update_to_outbound(&old, &new).len(), 1); - } - - /// cm:Player.cpp:16571-16599: a player's say, yell and `/e` carry `GetChatTag()`, AFK 1 and - /// DND 2. A creature's line carries none, whatever its flags word holds. - #[test] - fn a_nearby_line_carries_the_player_speakers_away_tag() { - let mut speaker = player_entity(); - assert_eq!(nearby_chat_tag(Some(&speaker)), 0); - speaker.player_flags = 0x02; - assert_eq!(nearby_chat_tag(Some(&speaker)), 1); - speaker.player_flags = 0x04 | 0x10; - assert_eq!(nearby_chat_tag(Some(&speaker)), 2); - speaker.type_mask = 0; - assert_eq!(nearby_chat_tag(Some(&speaker)), 0, "a creature"); - assert_eq!(nearby_chat_tag(None), 0, "a speaker already gone"); - } - - /// `/afk` sets PLAYER_FLAGS_AFK (0x02, descriptor index 190). Self and every observer run this - /// same diff, so each client holding the object gets one VALUES update carrying the new word. - #[test] - fn an_afk_toggle_relays_player_flags_to_every_viewer() { - let old = player_entity(); - for (flags, label) in [(0x02, "AFK"), (0x04, "DND"), (0x00, "cleared")] { - let mut new = old.clone(); - new.player_flags = flags; - let before = if flags == 0 { - let mut away = old.clone(); - away.player_flags = 0x02; - away - } else { - old.clone() - }; - let out = entity_update_to_outbound(&before, &new); - let [ServerOpcodeMessage::SMSG_UPDATE_OBJECT(update)] = out.as_slice() else { - panic!("{label}: expected one VALUES update, got {out:?}"); - }; - let mut wire = Vec::new(); - wow_world_messages::vanilla::ServerMessage::write_unencrypted_server( - &**update, &mut wire, - ) - .unwrap(); - let values = lyracore_shared::values_mask::parse_values_updates(&wire[4..]); - assert_eq!(values.len(), 1, "{label}"); - assert_eq!(values[0].guid, 1, "{label}"); - assert!( - values[0].fields.contains(&(190, flags)), - "{label}: PLAYER_FLAGS missing from {:?}", - values[0].fields - ); - assert!( - !values[0].fields.iter().any(|&(index, _)| index == 2), - "{label}: OBJECT_FIELD_TYPE leaked into a partial update" - ); - } - } - - #[test] - fn player_only_fields_are_gated_for_creatures() { - // A creature (no PLAYER bit) relays health + dynamic_flags but NOT xp/coinage/ghost/power — - // decoded and pinned as exactly those two packets, not just a count of two. - let mut old = player_entity(); - old.type_mask = 0; - let mut new = old.clone(); - new.health = 50; // emits (any unit) - new.dynamic_flags = 1; // emits (any unit) - new.xp = 10; // gated out (player-only) - new.money = 10; // gated out - new.power = 30; // gated out - new.player_flags = 0x10; // gated out - let out = entity_update_to_outbound(&old, &new); - assert_eq!( - out, - vec![ - ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(codec::build_health_values( - new.guid, 50 - ))), - ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( - codec::build_dynamic_flags_values(new.guid, 1) - )), - ] - ); - } - - #[test] - fn creature_levelup_does_not_take_the_exclusive_branch() { - // The level-up branch is player-only; a creature whose level changed still diffs health - // normally. A bare length check can't tell the two branches apart here — the player-only - // ding ALSO emits exactly one packet — so decode the one packet and pin it as the plain - // Unit-mask health update (build_health_values), not the Player-mask combined ding - // (build_levelup_values) a regressed player-gate would otherwise produce with the same count. - let mut old = player_entity(); - old.type_mask = 0; - let mut new = old.clone(); - new.level = 5; - new.health = 50; - let out = entity_update_to_outbound(&old, &new); - assert_eq!( - out, - vec![ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( - codec::build_health_values(new.guid, new.health) - ))] - ); - } - - #[test] - fn target_change_emits_one() { - // UNIT_FIELD_TARGET relay — any unit, one packet, guid + target value pinned. - let old = player_entity(); - let mut new = old.clone(); - new.target_guid = 0xDEAD_BEEF; - let out = entity_update_to_outbound(&old, &new); - assert_eq!( - out, - vec![ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( - codec::build_target_values(new.guid, 0xDEAD_BEEF) - ))] - ); - } - - #[test] - fn bank_bag_slot_purchase_relays_and_the_rest_byte_survives() { - // A bought bank bag slot lands in byte 2 of PLAYER_BYTES_2; the relay must carry the FULL - // field (a partial VALUES overwrites the whole u32) so byte 0 (facial hair) and byte 3 (rest - // state — HAZARD: a zero byte 3 crashes the 5875 client's XP bar) ride along unchanged. - let mut old = player_entity(); - old.player_bytes_2 = lyracore_shared::packing::player_bytes_2_with_rest(7, 0, false); // facial hair 7, 0 slots, NORMAL - let mut new = old.clone(); - new.player_bytes_2 = lyracore_shared::packing::with_bank_bag_slots(old.player_bytes_2, 1); - let out = entity_update_to_outbound(&old, &new); - assert_eq!( - out, - vec![ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( - codec::build_bank_bag_slots_values(new.guid, new.player_bytes_2) - ))] - ); - let (facial_hair, _, bank_slots, rest) = - lyracore_shared::packing::unpack4(new.player_bytes_2); - assert_eq!( - facial_hair, 7, - "facial hair (byte 0) must survive the purchase" - ); - assert_eq!(bank_slots, 1); - assert_eq!( - rest, - lyracore_shared::packing::REST_STATE_NORMAL, - "rest state (byte 3) must stay a valid non-zero value" - ); - } - - #[test] - fn a_rest_state_flip_alone_does_not_relay_the_bank_slot_packet() { - // Byte 3 changing (rest flip) must NOT trip the byte-2-scoped bank slot relay — that byte - // already relays through its own dedicated `game_rest_state_event` path. - let old = player_entity(); - let mut new = old.clone(); - new.player_bytes_2 = lyracore_shared::packing::player_bytes_2_with_rest(0, 0, true); // RESTED - assert!(entity_update_to_outbound(&old, &new).is_empty()); - } - - #[test] - fn max_vitals_change_emits_one() { - // A non-level-up max_health change (gear/aura) — any unit, one packet, guid + max_health/ - // power values pinned (max_power carries through unchanged from `player_entity()`). - let old = player_entity(); - let mut new = old.clone(); - new.max_health = 150; - let power_b = (new.unit_bytes_0 >> 24) as u8; - let out = entity_update_to_outbound(&old, &new); - assert_eq!( - out, - vec![ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( - codec::build_max_vitals_values(new.guid, 150, power_b, new.max_power) - ))] - ); - } - - #[test] - fn ghost_transition_edge_detects_the_spirit_healer_reveal() { - const GHOST: u32 = lyracore_shared::constants::player_flags::GHOST; // 0x10 - // alive→ghost (Release Spirit): REVEAL the in-scope spirit healers (Some(true)). - assert_eq!(ghost_transition(0, GHOST, GHOST), Some(true)); - // ghost→alive (reclaim corpse / spirit-healer res): DESTROY them (Some(false)). - assert_eq!(ghost_transition(GHOST, 0, GHOST), Some(false)); - // No GHOST-bit change → no reveal work, even when OTHER player-flags toggle this update - // (the common heartbeat / unrelated flag write must NOT re-fire the reveal). - assert_eq!(ghost_transition(0, 0, GHOST), None); - assert_eq!(ghost_transition(GHOST, GHOST, GHOST), None); - assert_eq!(ghost_transition(0x4 | GHOST, 0x8 | GHOST, GHOST), None); // ghost stays set, others flip - assert_eq!(ghost_transition(0x4, 0x8, GHOST), None); // ghost stays clear, others flip - } - - // ---- the SAY/YELL range math ----------------------------------------------------------------- - - #[test] - fn chat_in_range_gates_by_distance() { - // `chat_in_range` only ever sees plain map/x/y — distance in WORLD SPACE. - let range_sq = 25.0f32 * 25.0; // SAY_RANGE_SQ, spelled out so the test pins the shape not the const - // 10 yards apart -> in range. - assert!(chat_in_range( - 0, 0, 100.0, 100.0, 0, 0, 105.0, 108.0, range_sq - )); - // Exactly on the boundary (25yd) -> in range (inclusive, `<=`). - assert!(chat_in_range( - 0, 0, 100.0, 100.0, 0, 0, 125.0, 100.0, range_sq - )); - // Just past the boundary -> out of range: this is the assertion the wire script's "beyond - // /say range but within view -> NO chat delivery" case pins end-to-end. - assert!(!chat_in_range( - 0, 0, 100.0, 100.0, 0, 0, 125.01, 100.0, range_sq - )); - } - - #[test] - fn chat_in_range_never_crosses_a_map_or_instance_boundary_regardless_of_distance() { - // Same coordinates, different map -> never in range (also guards the arithmetic: comparing - // x/y across two unrelated maps would otherwise produce a meaningless "distance"). - assert!(!chat_in_range( - 0, 0, 100.0, 100.0, 1, 0, 100.0, 100.0, 90_000.0 - )); - // Same map, different instance (e.g. an open-world speaker vs. a dungeon-instance listener - // whose rows happen to share a database) -> never in range either (mirrors `peer_create_gate`'s - // instance isolation). - assert!(!chat_in_range( - 0, 0, 100.0, 100.0, 0, 1, 100.0, 100.0, 90_000.0 - )); - } - - #[test] - fn chat_in_range_yell_reaches_further_than_say_at_the_same_distance() { - // A speaker 200yd away: outside SAY range, inside YELL range — yell's wider radius must - // not be silently clipped to say range. - assert!(!chat_in_range( - 0, - 0, - 0.0, - 0.0, - 0, - 0, - 200.0, - 0.0, - SAY_RANGE_SQ - )); - assert!(chat_in_range( - 0, - 0, - 0.0, - 0.0, - 0, - 0, - 200.0, - 0.0, - YELL_RANGE_SQ - )); - } - - /// The text-emote range shares SAY's 25yd radius (`ListenRange.TextEmote` = `ListenRange.Say` - /// = 25 in cm:mangosd.conf.dist.in:1092): the same boundary math, over the shared constant. - #[test] - fn chat_in_range_gates_a_text_emote_at_the_same_25yd_boundary_as_say() { - let range_sq = lyracore_shared::chat::broadcast_chat::TEXT_EMOTE_RANGE_YD - * lyracore_shared::chat::broadcast_chat::TEXT_EMOTE_RANGE_YD; - assert_eq!(range_sq, SAY_RANGE_SQ); - assert!(chat_in_range(0, 0, 0.0, 0.0, 0, 0, 25.0, 0.0, range_sq)); - assert!(!chat_in_range(0, 0, 0.0, 0.0, 0, 0, 25.01, 0.0, range_sq)); - } - - #[test] - fn in_combat_flag_change_emits_one() { - // UNIT_FLAG_IN_COMBAT toggling on unit_flags — any unit, one VALUES packet, guid + the - // exact post-toggle flags word pinned. - let old = player_entity(); - let mut new = old.clone(); - new.unit_flags |= lyracore_shared::constants::unit_flags::IN_COMBAT; - let out = entity_update_to_outbound(&old, &new); - assert_eq!( - out, - vec![ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( - codec::build_unit_flags_values(new.guid, new.unit_flags) - ))] - ); - } - - #[test] - fn taxi_mount_and_flight_flag_change_emit_one_atomic_presentation_values() { - let old = player_entity(); - let mut new = old.clone(); - new.mount_display_id = 1147; - new.unit_flags |= lyracore_shared::constants::unit_flags::TAXI_FLIGHT; - assert_eq!( - entity_update_to_outbound(&old, &new), - vec![ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( - codec::build_taxi_presentation_values( - new.guid, - new.mount_display_id, - new.unit_flags, - ) - ))] - ); - } - - #[test] - fn taxi_landing_emits_one_atomic_presentation_update_not_a_land_mount_duplicate() { - let mut old = player_entity(); - old.mount_display_id = 1147; - old.unit_flags |= lyracore_shared::constants::unit_flags::TAXI_FLIGHT; - let mut new = old.clone(); - new.mount_display_id = 0; - new.unit_flags &= !lyracore_shared::constants::unit_flags::TAXI_FLIGHT; - assert_eq!( - entity_update_to_outbound(&old, &new), - vec![ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( - codec::build_taxi_presentation_values( - new.guid, - new.mount_display_id, - new.unit_flags, - ) - ))] - ); - } - - #[test] - fn land_mount_display_change_emits_the_standalone_builder_not_taxi_presentation() { - // A land mount changes ONLY `mount_display_id` — TAXI_FLIGHT is untouched — so it must relay - // through the decoupled single-field builder, not the taxi-coupled one. - let old = player_entity(); - let mut new = old.clone(); - new.mount_display_id = 1147; - assert_eq!( - entity_update_to_outbound(&old, &new), - vec![ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( - codec::build_mount_display_values(new.guid, new.mount_display_id) - ))] - ); - } - - #[test] - fn land_dismount_display_change_emits_the_standalone_builder_with_zero() { - let mut old = player_entity(); - old.mount_display_id = 1147; - let mut new = old.clone(); - new.mount_display_id = 0; // dismount — TAXI_FLIGHT was never set for a land mount - assert_eq!( - entity_update_to_outbound(&old, &new), - vec![ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( - codec::build_mount_display_values(new.guid, 0) - ))] - ); - } - - #[test] - fn a_land_mount_display_change_alongside_an_unrelated_flag_change_emits_both_packets() { - // The flag change is real but NOT the taxi flight bit — both relay, separately, neither - // coupled through the taxi builder. - let old = player_entity(); - let mut new = old.clone(); - new.mount_display_id = 1147; - new.unit_flags |= lyracore_shared::constants::unit_flags::IN_COMBAT; - assert_eq!( - entity_update_to_outbound(&old, &new), - vec![ - ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( - codec::build_mount_display_values(new.guid, new.mount_display_id) - )), - ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(codec::build_unit_flags_values( - new.guid, - new.unit_flags - ))), - ] - ); - } - - #[test] - fn run_speed_mult_bp_change_emits_force_run_speed_change_with_the_derived_speed() { - // GM playtest `.speed`: a run_speed_mult_bp change relays - // SMSG_FORCE_RUN_SPEED_CHANGE with speed = BASE_RUN_SPEED (7.0) * bp/10000 — pin the derived - // value end-to-end (this is the "codec pin for the speed message" this feature calls for). - let old = player_entity(); - let mut new = old.clone(); - new.run_speed_mult_bp = 30_000; // .speed 3 -> 3x - let out = entity_update_to_outbound(&old, &new); - assert_eq!( - out, - vec![ServerOpcodeMessage::SMSG_FORCE_RUN_SPEED_CHANGE(Box::new( - codec::build_force_run_speed(new.guid, 21.0) - ))] - ); - } - - #[test] - fn appearance_change_relays_the_wisp_and_the_restored_native_display() { - let alive = player_entity(); - let mut wisp = alive.clone(); - wisp.display_id = 1825; - for (old, new) in [(&alive, &wisp), (&wisp, &alive)] { - assert_eq!( - entity_update_to_outbound(old, new), - vec![ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( - codec::build_display_values(new.guid, new.display_id), - ))] - ); - assert_eq!(new.native_display_id, old.native_display_id); - } - } - - #[test] - fn run_speed_mult_bp_unchanged_emits_nothing_extra() { - let old = player_entity(); - let mut new = old.clone(); - new.health = 50; // an unrelated change must not spuriously trigger the speed relay - let out = entity_update_to_outbound(&old, &new); - assert!(!out - .iter() - .any(|m| matches!(m, ServerOpcodeMessage::SMSG_FORCE_RUN_SPEED_CHANGE(_)))); - } - - #[test] - fn stealth_both_inserts_hide_both_deletes_reveal_created_dedupes() { - // A Stealth cast applies TWO A_STEALTH auras and the SDK fires both inserts AFTER applying the - // delta, so both see post-count 2 -> both HIDE (the per-viewer `created` set makes the 2nd a - // no-op at the call site). A break removes both -> two deletes seeing post-count 0 -> both REVEAL - // (created-deduped to one). The decision is purely "stealthed -> Hide, not stealthed -> Reveal". - assert_eq!(stealth_action(true, 1), StealthAction::Hide); - assert_eq!(stealth_action(true, 2), StealthAction::Hide); - assert_eq!(stealth_action(true, 3), StealthAction::Hide); - assert_eq!(stealth_action(false, 1), StealthAction::None); // still stealthed after one of two deletes - assert_eq!(stealth_action(false, 0), StealthAction::Reveal); - assert_eq!(stealth_action(false, 2), StealthAction::None); // over-count delete does not reveal - // An insert can never leave count 0 (it adds one) -> defensively a no-op. - assert_eq!(stealth_action(true, 0), StealthAction::None); - } - - #[test] - fn creature_relays_target_and_max_vitals() { - // The target + max-vitals arms are NOT player-gated: a creature relays both, decoded and - // pinned in emission order (target before max-vitals) with their exact values. - let mut old = player_entity(); - old.type_mask = 0; - let mut new = old.clone(); - new.target_guid = 7; - new.max_power = 50; - let power_b = (new.unit_bytes_0 >> 24) as u8; - let out = entity_update_to_outbound(&old, &new); - assert_eq!( - out, - vec![ - ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(codec::build_target_values( - new.guid, 7 - ))), - ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(codec::build_max_vitals_values( - new.guid, - new.max_health, - power_b, - 50 - ))), - ] - ); - } - - #[test] - fn peer_reveal_restores_remaining_auras_after_create() { - use codec::update_mask::idx; - use wow_world_messages::vanilla::{Object, UpdateMask, VisibleItemIndex}; - - for mut entity in [player_entity(), creature_entity()] { - entity.unit_bytes_0 = 0x0000_0101; - let mut remaining = aura(entity.guid, 0xA1, 1, 10, 0, i64::MAX); - remaining.spell_id = 1459; - remaining.level = 40; - remaining.flags = 9; - let index = world_view::AuraIndex::default(); - index.upsert(0, &remaining); - let mut other_shard = remaining.clone(); - other_shard.spell_id = 9999; - index.upsert(1, &other_shard); - - let mut stealth = aura(entity.guid, A_STEALTH, 0, 0, 0, i64::MAX); - stealth.id = 2; - stealth.slot = 0; - let mut second_stealth = stealth.clone(); - second_stealth.id = 3; - second_stealth.slot = 1; - index.upsert(0, &stealth); - index.upsert(0, &second_stealth); - index.remove(0, &stealth); - assert_eq!(index.stealth_count(0, entity.guid), 1); - index.remove(0, &second_stealth); - assert_eq!(index.stealth_count(0, entity.guid), 0); - - let view = entity_view(entity, 0); - let out = peer_create_outbound( - &view, - &[codec::ItemInstanceView { - guid: 700, - entry: 1337, - enchantment: 823, - ..Default::default() - }], - &index.on_target(0, view.guid), - None, - ) - .unwrap(); - let [Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(create)), Outbound::Raw { opcode, body }] = - out.as_slice() - else { - panic!("a peer needs CREATE followed by its remaining aura array"); - }; - let [Object::CreateObject2 { guid3, mask2, .. }] = create.objects.as_slice() else { - panic!("the first packet must create the peer"); - }; - assert_eq!(guid3.guid(), view.guid); - if let UpdateMask::Player(player) = mask2 { - let visible = player - .player_visible_item(VisibleItemIndex::Index0) - .unwrap(); - assert_eq!((visible.item, visible.enchants), (1337, [823, 0])); - } - assert_eq!(*opcode, 0x00A9); - let decoded = lyracore_shared::values_mask::parse_values_updates(body); - assert_eq!(decoded.len(), 1); - assert_eq!(decoded[0].guid, view.guid); - let fields: std::collections::HashMap<_, _> = - decoded[0].fields.iter().copied().collect(); - for slot in 0..idx::AURA_SLOTS { - assert_eq!( - fields[&(idx::UNIT_AURA + slot)], - if slot == 3 { 1459 } else { 0 } - ); - } - assert_eq!(fields[&idx::UNIT_AURAFLAGS], 0x9000); - assert_eq!(fields[&idx::UNIT_AURALEVELS], 0x2800_0000); - assert!(!fields.contains_key(&idx::OBJECT_TYPE)); - } - } - - #[test] - fn peer_create_without_auras_needs_no_followup() { - let mut entity = player_entity(); - entity.unit_bytes_0 = 0x0000_0101; - let out = peer_create_outbound(&entity_view(entity, 0), &[], &[], None).unwrap(); - assert!(matches!( - out.as_slice(), - [Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(_))] - )); - } - - #[test] - fn peer_create_restores_the_wisp_appearance_and_movement_speeds() { - use lyracore_shared::spell::taxonomy::{A_MOD_SPEED, SPEED_MOVE, SPEED_SWIM}; - use wow_world_messages::vanilla::{Object, UpdateMask}; - let mut entity = player_entity(); - entity.display_id = 1825; - entity.unit_bytes_0 = 0x0000_0104; - entity.run_speed_mult_bp = 10_000; - let view = entity_view(entity, 0); - let auras = [ - aura( - view.guid, - A_MOD_SPEED, - i32::from(SPEED_MOVE), - 25, - 0, - i64::MAX, - ), - aura( - view.guid, - A_MOD_SPEED, - i32::from(SPEED_MOVE), - 50, - 0, - i64::MAX, - ), - aura( - view.guid, - A_MOD_SPEED, - i32::from(SPEED_SWIM), - 25, - 0, - i64::MAX, - ), - aura( - view.guid, - A_MOD_SPEED, - i32::from(SPEED_SWIM), - 50, - 0, - i64::MAX, - ), - ]; - let out = peer_create_outbound(&view, &[], &auras, None).unwrap(); - let Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(create)) = &out[0] else { - panic!("peer CREATE"); - }; - let Object::CreateObject2 { - movement2, - mask2: UpdateMask::Player(player), - .. - } = &create.objects[0] - else { - panic!("Character CREATE"); - }; - assert_eq!(player.unit_displayid(), Some(1825)); - let wow_world_messages::vanilla::MovementBlock_UpdateFlag_Living::Living { - running_speed, - swimming_speed, - .. - } = movement2.update_flag.get_living().unwrap() - else { - panic!("living movement"); - }; - assert_eq!(*running_speed, 10.5); - assert!( - (*swimming_speed - lyracore_shared::constants::speeds::SWIM * 1.5).abs() < 0.000001 - ); - } - - #[test] - fn peer_create_uses_the_remaining_ghost_speed_and_restores_baseline_after_removal() { - use lyracore_shared::spell::taxonomy::{A_MOD_SPEED, SPEED_MOVE, SPEED_SWIM}; - use wow_world_messages::vanilla::{MovementBlock_UpdateFlag_Living, Object}; - let mut entity = player_entity(); - entity.unit_bytes_0 = 0x0000_0101; - entity.run_speed_mult_bp = 10_000; - let view = entity_view(entity, 0); - let ghost = vec![ - aura( - view.guid, - A_MOD_SPEED, - i32::from(SPEED_MOVE), - 25, - 0, - i64::MAX, - ), - aura( - view.guid, - A_MOD_SPEED, - i32::from(SPEED_SWIM), - 25, - 0, - i64::MAX, - ), - ]; - for (auras, expected_run, expected_swim) in - [(ghost, 8.75, 5.902_777_7), (vec![], 7.0, 4.722_222)] - { - let out = peer_create_outbound(&view, &[], &auras, None).unwrap(); - let Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(create)) = &out[0] else { - panic!("peer CREATE"); - }; - let Object::CreateObject2 { movement2, .. } = &create.objects[0] else { - panic!("Character CREATE"); - }; - let MovementBlock_UpdateFlag_Living::Living { - running_speed, - swimming_speed, - .. - } = movement2.update_flag.get_living().unwrap() - else { - panic!("living movement"); - }; - assert_eq!(*running_speed, expected_run); - assert!((*swimming_speed - expected_swim).abs() < 0.000001); - } - } - - #[test] - fn peer_create_preserves_gm_run_speed_without_movement_auras() { - use wow_world_messages::vanilla::{MovementBlock_UpdateFlag_Living, Object}; - let mut entity = player_entity(); - entity.unit_bytes_0 = 0x0000_0101; - entity.run_speed_mult_bp = 30_000; - let view = entity_view(entity, 0); - let out = peer_create_outbound(&view, &[], &[], None).unwrap(); - let Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(create)) = &out[0] else { - panic!("peer CREATE"); - }; - let Object::CreateObject2 { movement2, .. } = &create.objects[0] else { - panic!("Character CREATE"); - }; - let MovementBlock_UpdateFlag_Living::Living { running_speed, .. } = - movement2.update_flag.get_living().unwrap() - else { - panic!("living movement"); - }; - assert_eq!(*running_speed, 21.0); - } - - #[test] - fn peer_create_applies_always_and_not_stack_run_bonuses() { - use lyracore_shared::spell::taxonomy::{ - A_MOD_SPEED, SPEED_MOVE_ALWAYS, SPEED_MOVE_NOT_STACK, - }; - use wow_world_messages::vanilla::{MovementBlock_UpdateFlag_Living, Object}; - let mut entity = player_entity(); - entity.unit_bytes_0 = 0x0000_0101; - let view = entity_view(entity, 0); - for speed_kind in [SPEED_MOVE_ALWAYS, SPEED_MOVE_NOT_STACK] { - let auras = [aura( - view.guid, - A_MOD_SPEED, - i32::from(speed_kind), - 50, - 0, - i64::MAX, - )]; - let out = peer_create_outbound(&view, &[], &auras, None).unwrap(); - let Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(create)) = &out[0] else { - panic!("peer CREATE"); - }; - let Object::CreateObject2 { movement2, .. } = &create.objects[0] else { - panic!("Character CREATE"); - }; - let MovementBlock_UpdateFlag_Living::Living { running_speed, .. } = - movement2.update_flag.get_living().unwrap() - else { - panic!("living movement"); - }; - assert_eq!(*running_speed, 10.5); - } - } - - #[test] - fn bag_content_parts_boundaries() { - assert_eq!(codec::bag_content_parts(119), None); // just below the bag-content range - assert_eq!(codec::bag_content_parts(120), Some((19, 0))); // first bag, first slot - assert_eq!(codec::bag_content_parts(137), Some((19, 17))); // first bag, last slot (0..MAX_BAG_SIZE) - assert_eq!(codec::bag_content_parts(138), Some((20, 0))); // second bag, first slot - assert_eq!(codec::bag_content_parts(191), Some((22, 17))); // fourth (last) bag, last slot - assert_eq!(codec::bag_content_parts(192), None); - } - - fn item_view(guid: u64, entry: u32) -> codec::ItemInstanceView { - codec::ItemInstanceView { - guid, - entry, - ..Default::default() - } - } - - fn item_row(slot: u8, enchant_id: u32) -> ItemInstance { - ItemInstance { - guid: 0x4000_0000_0000_0007, - entry: 1337, - owner_identity: spacetimedb_sdk::Identity::from_byte_array([0u8; 32]), - owner_guid: 7, - slot, - stack_count: 1, - durability: 20, - created_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, - enchant_id, - soulbound: false, - random_property_id: 0, - item_text_id: 0, - } - } - - /// The enchantment word of the item update and the first enchant word of the visible item in an - /// in-place enchant outbound, with the raw field indexes of every VALUES block. - fn enchant_words(out: &[Outbound]) -> (Option, Option, Vec>) { - use wow_world_messages::vanilla::{Object, ServerMessage, UpdateMask, VisibleItemIndex}; - let (mut item_word, mut visible_word) = (None, None); - let mut field_indexes = Vec::new(); - for message in out { - let Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(update)) = message else { - continue; - }; - let mut bytes = Vec::new(); - update.write_unencrypted_server(&mut bytes).unwrap(); - for values in lyracore_shared::values_mask::parse_values_updates(&bytes[4..]) { - field_indexes.push(values.fields.iter().map(|&(idx, _)| idx).collect()); - } - match &update.objects[0] { - Object::Values { - mask1: UpdateMask::Item(item), - .. - } => item_word = item.item_enchantment().map(|word| word as u32), - Object::Values { - mask1: UpdateMask::Player(player), - .. - } => { - visible_word = player - .player_visible_item(VisibleItemIndex::Index8) - .map(|visible| visible.enchants[0]) - } - _ => {} - } - } - (item_word, visible_word, field_indexes) - } - - #[test] - fn enchanting_a_worn_item_in_place_updates_its_enchant_word_and_visible_item() { - for (stored, client) in [(7745, 823), (7748, 724), (2564, 2564)] { - let out = enchant_change_outbound(7, &item_row(8, 0), &item_row(8, stored)); - let (item_word, visible_word, fields) = enchant_words(&out); - assert_eq!(item_word, Some(client), "stored {stored}"); - assert_eq!(visible_word, Some(client), "stored {stored}"); - assert!( - fields.iter().all(|idx| !idx.contains(&2)), - "OBJECT_FIELD_TYPE must stay off the partial update" - ); - } - } - - #[test] - fn enchanting_a_bagged_item_in_place_updates_only_its_enchant_word() { - let out = enchant_change_outbound(7, &item_row(23, 0), &item_row(23, 7745)); - let (item_word, visible_word, _) = enchant_words(&out); - assert_eq!(item_word, Some(823)); - assert_eq!(visible_word, None); - } - - #[test] - fn a_change_that_leaves_the_enchant_alone_sends_no_enchant_word() { - let mut worn = item_row(8, 7745); - worn.durability = 3; - assert!(enchant_change_outbound(7, &item_row(8, 7745), &worn).is_empty()); - } - - #[test] - fn an_enchant_change_on_a_guild_charter_leaves_its_petition_id_alone() { - let charter = |enchant_id| ItemInstance { - entry: lyracore_shared::guild::GUILD_CHARTER_ENTRY, - ..item_row(23, enchant_id) - }; - assert!(enchant_change_outbound(7, &charter(0), &charter(7745)).is_empty()); - } - - #[test] - fn removing_a_permanent_enchant_in_place_clears_the_word() { - let out = enchant_change_outbound(7, &item_row(8, 7745), &item_row(8, 0)); - let (item_word, visible_word, _) = enchant_words(&out); - assert_eq!((item_word, visible_word), (Some(0), Some(0))); - } - - #[test] - fn a_slot_change_with_an_enchant_change_leaves_the_visible_item_to_the_slot_projection() { - let out = enchant_change_outbound(7, &item_row(23, 0), &item_row(8, 7745)); - let (item_word, visible_word, _) = enchant_words(&out); - assert_eq!((item_word, visible_word), (Some(823), None)); - } - - #[test] - fn a_worn_item_moved_into_a_slot_shows_its_client_enchantment_on_the_visible_item() { - use wow_world_messages::vanilla::{Object, UpdateMask, VisibleItemIndex}; - let committed = item_row(8, 7748); - let mut out = Vec::new(); - append_final_item_slots(7, 23, 8, &mut out, |slot| { - (slot == 8).then(|| view_of_item_row(&committed)) - }); - let mut words = Vec::new(); - for message in &out { - let Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(update)) = message else { - continue; - }; - if let Object::Values { - mask1: UpdateMask::Player(player), - .. - } = &update.objects[0] - { - words.extend( - player - .player_visible_item(VisibleItemIndex::Index8) - .map(|visible| visible.enchants), - ); - } - } - assert_eq!(words, [[724, 0]]); - } - - #[test] - fn occupied_equipment_swap_projects_committed_slot_occupants() { - use wow_world_messages::vanilla::{Guid, ItemSlot, Object, UpdateMask, VisibleItemIndex}; - - const CHARACTER: u64 = 7; - const EQUIPMENT_SLOT: u8 = 0; - const BACKPACK_SLOT: u8 = 23; - const DISPLACED_GUID: u64 = 101; - const INCOMING_GUID: u64 = 202; - const INCOMING_ENTRY: u32 = 1337; - - let committed = [ - (EQUIPMENT_SLOT, INCOMING_GUID, INCOMING_ENTRY), - (BACKPACK_SLOT, DISPLACED_GUID, 42), - ]; - let item_in_slot = |slot| { - committed - .iter() - .find(|item| item.0 == slot) - .map(|item| codec::ItemInstanceView { - guid: item.1, - entry: item.2, - ..Default::default() - }) - }; - let mut outbound = Vec::new(); - - append_final_item_slots( - CHARACTER, - EQUIPMENT_SLOT, - BACKPACK_SLOT, - &mut outbound, - item_in_slot, - ); - append_final_item_slots( - CHARACTER, - BACKPACK_SLOT, - EQUIPMENT_SLOT, - &mut outbound, - item_in_slot, - ); - - let mut equipment = Vec::new(); - let mut backpack = Vec::new(); - let mut visible_head = Vec::new(); - for message in &outbound { - let Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(update)) = message else { - continue; - }; - let Object::Values { - mask1: UpdateMask::Player(player), - .. - } = &update.objects[0] - else { - continue; - }; - equipment.extend(player.player_field_inv(ItemSlot::Head)); - backpack.extend(player.player_field_inv(ItemSlot::Inventory0)); - visible_head.extend( - player - .player_visible_item(VisibleItemIndex::Index0) - .map(|item| item.item), - ); - } - - assert_eq!(equipment, [Guid::new(INCOMING_GUID)]); - assert_eq!(backpack, [Guid::new(DISPLACED_GUID)]); - assert_eq!(visible_head, [INCOMING_ENTRY]); - assert!(!equipment.contains(&Guid::new(0))); - assert!(!backpack.contains(&Guid::new(0))); - } - - #[test] - fn empty_equipment_destination_clears_backpack_and_sets_equipment() { - use wow_world_messages::vanilla::{Guid, ItemSlot, Object, UpdateMask}; - - let mut outbound = Vec::new(); - append_final_item_slots(7, 23, 0, &mut outbound, |slot| { - (slot == 0).then(|| item_view(202, 1337)) - }); - - let mut equipment = Vec::new(); - let mut backpack = Vec::new(); - for message in &outbound { - let Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(update)) = message else { - continue; - }; - let Object::Values { - mask1: UpdateMask::Player(player), - .. - } = &update.objects[0] - else { - continue; - }; - equipment.extend(player.player_field_inv(ItemSlot::Head)); - backpack.extend(player.player_field_inv(ItemSlot::Inventory0)); - } - - assert_eq!(equipment, [Guid::new(202)]); - assert_eq!(backpack, [Guid::new(0)]); - } - - #[test] - fn ordinary_backpack_move_clears_source_and_sets_destination() { - use wow_world_messages::vanilla::{Guid, ItemSlot, Object, UpdateMask}; - - let mut outbound = Vec::new(); - append_final_item_slots(7, 23, 24, &mut outbound, |slot| { - (slot == 24).then(|| item_view(202, 1337)) - }); - - let mut source = Vec::new(); - let mut destination = Vec::new(); - for message in &outbound { - let Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(update)) = message else { - continue; - }; - let Object::Values { - mask1: UpdateMask::Player(player), - .. - } = &update.objects[0] - else { - continue; - }; - source.extend(player.player_field_inv(ItemSlot::Inventory0)); - destination.extend(player.player_field_inv(ItemSlot::Inventory1)); - } - - assert_eq!(source, [Guid::new(0)]); - assert_eq!(destination, [Guid::new(202)]); - } - - #[test] - fn stealth_dedup_composes_with_a_real_hashset_exactly_once() { - // `stealth_visibility` pairs `stealth_action`'s Hide/Reveal decision with `created`'s - // remove()/insert() return value (only act if the set's membership actually changed) — this - // composes that SAME discipline over a real `std::collections::HashSet` (not a mock) to - // pin the "exactly once" guarantee a Stealth cast's two A_STEALTH inserts/deletes rely on. - let target = 99u64; - let mut created: HashSet = HashSet::from([target]); // starts visible - let mut destroys = 0; - // A Stealth cast applies TWO A_STEALTH auras; the SDK fires both inserts AFTER applying the - // whole delta, so both see post-count 2 -> both decide Hide. - for count in [2, 2] { - if stealth_action(true, count) == StealthAction::Hide && created.remove(&target) { - destroys += 1; - } - } - assert_eq!(destroys, 1, "two HIDE decisions must destroy exactly once"); - assert!(!created.contains(&target)); - // A break removes both auras; both deletes see post-count 0 -> both decide Reveal. - let mut creates = 0; - for count in [0, 0] { - if stealth_action(false, count) == StealthAction::Reveal && created.insert(target) { - creates += 1; - } - } - assert_eq!( - creates, 1, - "two REVEAL decisions must (re)create exactly once" - ); - assert!(created.contains(&target)); - } - - // ---- Cross-map teleport relay -------------------------------------------- - - #[test] - fn teleport_relay_same_map_is_byte_identical_to_the_pre_224_ack() { - let out = build_teleport_relay(true, 7, 0, 1.0, 2.0, 3.0, 0.5).unwrap(); - match out { - Outbound::One(ServerOpcodeMessage::MSG_MOVE_TELEPORT_ACK(m)) => { - assert_eq!(m.guid.guid(), 7); - assert_eq!( - m.info.position, - wow_world_messages::vanilla::Vector3d { - x: 1.0, - y: 2.0, - z: 3.0 - } - ); - assert_eq!(m.info.orientation, 0.5); - } - _ => panic!("expected a same-map relay to be a single MSG_MOVE_TELEPORT_ACK"), - } - } - - #[test] - fn teleport_relay_cross_map_sends_transfer_pending_then_new_world() { - let out = build_teleport_relay(false, 7, 36, 10.0, 20.0, 30.0, 1.5).unwrap(); - match out { - Outbound::Batch(msgs) => { - assert_eq!(msgs.len(), 2, "TRANSFER_PENDING + NEW_WORLD, nothing else"); - match &msgs[0] { - ServerOpcodeMessage::SMSG_TRANSFER_PENDING(p) => { - assert_eq!(p.map.as_int(), 36); - assert!(p.has_transport.is_none()); - } - _ => panic!("expected SMSG_TRANSFER_PENDING first"), - } - match &msgs[1] { - ServerOpcodeMessage::SMSG_NEW_WORLD(w) => { - assert_eq!(w.map.as_int(), 36); - assert_eq!( - w.position, - wow_world_messages::vanilla::Vector3d { - x: 10.0, - y: 20.0, - z: 30.0 - } - ); - assert_eq!(w.orientation, 1.5); - } - _ => panic!("expected SMSG_NEW_WORLD second"), - } - } - _ => { - panic!("expected a cross-map relay to be a Batch of [TRANSFER_PENDING, NEW_WORLD]") - } - } - } - - #[test] - fn teleport_relay_rejects_a_map_id_the_client_enum_does_not_know() { - // `Map` is a closed vanilla enum; an id past its known range must surface as an Err (never - // silently drop the whole relay or panic). - assert!(build_teleport_relay(false, 7, u32::MAX, 0.0, 0.0, 0.0, 0.0).is_err()); - } - - // ---- AOI re-entry, initial-apply, instance isolation -------------- - // - // `offer_peer_create`/`is_update_reentry` themselves need a live SDK connection (`RemoteTables` - // is only constructible off a real subscription) to exercise end-to-end, so — per this repo's - // established convention (`ghost_transition`, `stealth_action`, `entity_update_to_outbound`) — - // the DECISION logic they're built from is extracted pure (`peer_create_gate`, - // `is_update_reentry`) and tested directly here; both are the ACTUAL functions - // `offer_peer_create`/the `on_update` callback call, not a parallel re-implementation. - - #[test] - fn peer_create_gate_excludes_self_regardless_of_other_fields() { - // Self is never offered a CREATE of its own row (the dedup set is pre-seeded with - // self_guid instead) — true even if every other gate would otherwise pass. - assert!(!peer_create_gate(1, 1, 0, 0, 0, true, false)); - } - - #[test] - fn peer_create_gate_excludes_a_different_instance_work_item_190() { - // Same instance (both open-world 0, or both some future dungeon instance N) -> visible. - assert!(peer_create_gate(2, 1, 0, 0, 0, false, false)); - assert!(peer_create_gate(2, 1, 7, 7, 0, false, false)); - // Different instance (e.g. a dungeon party's row vs. an open-world viewer, or two - // different parties' instances of the same dungeon) -> excluded. This is the gate the - // The login sweep and the re-entry path BOTH sit behind — a cross-instance row must - // never be marked `created`, even transiently, by either path. - assert!(!peer_create_gate(2, 1, 7, 0, 0, false, false)); - assert!(!peer_create_gate(2, 1, 3, 7, 0, false, false)); - } - - #[test] - fn peer_create_gate_spirithealer_and_stealth_gates_unaffected_by_the_refactor() { - const SPIRITHEALER: u32 = lyracore_shared::constants::npc_flags::SPIRITHEALER; - // A spirit healer is invisible to a living viewer... - assert!(!peer_create_gate(2, 1, 0, 0, SPIRITHEALER, false, false)); - assert!(peer_create_gate(2, 1, 0, 0, SPIRITHEALER, true, false)); - // A non-spirit-healer entity is unaffected by the viewer's ghost state. - assert!(peer_create_gate(2, 1, 0, 0, 0, false, false)); - // A currently-stealthed peer is excluded regardless of everything else. - assert!(!peer_create_gate(2, 1, 0, 0, 0, false, true)); - } - - #[test] - fn reentry_after_leaving_aoi_recreates_exactly_once_work_item_144() { - // The exact re-entry scenario: observer sees peer (CREATE) -> peer leaves the AOI box - // (on_delete evicts it from `created`, sends DESTROY) -> peer returns, delivered as an - // UPDATE of the SDK's still-cached row (not a fresh on_insert) -> the observer must - // re-CREATE it, not silently drop it forever. - let self_guid = 1u64; - let peer = 42u64; - let mut created: HashSet = HashSet::from([self_guid, peer]); // currently visible - - // Leave: on_delete's `if del_created.lock().unwrap().remove(&row.guid)` — must actually - // evict, or the later re-entry check below sees a false "already shown". - assert!( - created.remove(&peer), - "on_delete must evict the leaving peer from `created`" - ); - assert!(!created.contains(&peer)); - - // Re-entry arrives as an on_update, not on_insert (the SDK's refcounted cache still holds - // the row). The callback's actual dispatch predicate must say "treat as re-entry". - assert!(is_update_reentry(peer, self_guid, created.contains(&peer))); - - // `offer_peer_create`'s gate passes (same instance, not stealthed, not a spirit healer) -> - // the dedup insert succeeds -> exactly one CREATE goes out. - assert!(peer_create_gate(peer, self_guid, 0, 0, 0, false, false)); - assert!( - created.insert(peer), - "the re-entry offer must (re)insert into `created`" - ); - - // A THIRD sighting of the same still-visible peer (an ordinary field update, e.g. a - // heartbeat) must NOT be treated as another re-entry — it's already shown, so it diffs - // normally instead of re-CREATEing (which would desync the client). - assert!(!is_update_reentry(peer, self_guid, created.contains(&peer))); - - // Self never triggers the re-entry branch, even if (hypothetically) it were ever evicted. - assert!(!is_update_reentry(self_guid, self_guid, false)); - } - - #[test] - fn login_resident_sweep_creates_every_pre_existing_peer_exactly_once_work_item_145() { - // The login-sweep scenario: a fresh login's AOI box already contains peers/creatures (rows - // resident in the connection's cache from the just-applied subscription) whose - // `on_insert` may or may not have fired. The sweep in `subscribe_player_events` offers - // every resident row through this SAME gate + dedup — model it directly. - let self_guid = 1u64; - let residents = [2u64, 3, 4]; // pre-existing peers already in the login AOI box - let mut created: HashSet = HashSet::from([self_guid]); - - let mut creates_sent = 0; - for guid in residents { - if peer_create_gate(guid, self_guid, 0, 0, 0, false, false) && created.insert(guid) { - creates_sent += 1; - } - } - assert_eq!( - creates_sent, 3, - "every pre-existing resident must get a CREATE at login, not just self (the login-sweep bug: \ - only self + gameobjects appeared)" - ); - assert!(residents.iter().all(|g| created.contains(g))); - - // Idempotency (the sweep's own correctness claim): if the SDK ALSO fired an apply-time - // on_insert for one resident before the sweep runs, the sweep must not double-create it. - creates_sent = 0; - for guid in residents { - if peer_create_gate(guid, self_guid, 0, 0, 0, false, false) && created.insert(guid) { - creates_sent += 1; - } - } - assert_eq!( - creates_sent, 0, - "a second sweep over already-created residents must be a no-op" - ); - } - - #[test] - fn login_resident_sweep_excludes_cross_instance_residents_work_item_190() { - // A row resident in the connection's cache from a DIFFERENT instance than the viewer - // (always a no-op today since every entity is instance 0, but the - // gate must already hold the line) must never be swept into `created`. - let self_guid = 1u64; - let viewer_instance_id = 0u64; - let same_instance_peer = 2u64; - let other_instance_peer = 3u64; - let mut created: HashSet = HashSet::from([self_guid]); - - for (guid, row_instance) in [(same_instance_peer, 0u64), (other_instance_peer, 7u64)] { - if peer_create_gate( - guid, - self_guid, - row_instance, - viewer_instance_id, - 0, - false, - false, - ) { - created.insert(guid); - } - } - assert!(created.contains(&same_instance_peer)); - assert!( - !created.contains(&other_instance_peer), - "a cross-instance resident must stay excluded" - ); - } - - #[test] - fn corpse_and_gameobject_creates_relay_only_within_the_viewers_instance_work_item_190_slice_2() - { - // Open-world rows to an open-world viewer: byte-identical to the previous behavior (always relayed). - assert!(instance_relay_gate(0, Some(0))); - // A per-instance GO copy / in-instance corpse reaches ONLY viewers inside that instance… - assert!(instance_relay_gate(7, Some(7))); - assert!( - !instance_relay_gate(7, Some(0)), - "an open-world viewer must not see instance 7's rows" - ); - assert!( - !instance_relay_gate(7, Some(9)), - "another party's instance must not see them either" - ); - // …and an instanced viewer no longer receives the static instance-0 originals (they are the - // COPY SOURCES on the dungeon map — the copies are what render inside the run). - assert!(!instance_relay_gate(0, Some(7))); - // Unknown viewer instance (entity not resident — the AOI-on login window) relays NOTHING, - // not even instance 0: suppress-then-sweep, never guess. - assert!(!instance_relay_gate(0, None)); - assert!(!instance_relay_gate(7, None)); - } - - // ── Peer-motion relay: the CALL SITE, not just the codec helper ────────── - // - // §8 of the agent playbook: the dominant failure here is a test that pins an extracted helper - // while the wiring that calls it stays free to vanish. `codec::tests` proves the raw builder - // emits the same bytes the typed path did; these tests drive `relay_entity_motion` / - // `relay_creature_leg` themselves — a real `SessionTx`, a real `created` set, a real row — - // so deleting the send, sending the wrong variant, passing the wrong guid or opcode, or dropping - // a guard all show up here. - - /// `MOTION_CALLS`/`SENT`/`DROPPED` are process-global atomics and the test binary runs tests in - /// PARALLEL, so any test that moves them or measures a delta around them must hold this lock. - /// Without it, the delta a counter test measures silently includes whatever a concurrent relay - /// test did (which is exactly how the shed test below first turned the motion-counters test red). Poison is - /// ignored deliberately: a panicking test has already failed and must not cascade into every - /// other one. - static MOTION_COUNTERS: std::sync::Mutex<()> = std::sync::Mutex::new(()); - - /// Test shims for the two relays the shared-connection model split in half. - /// - /// Before the shared-connection model, each of these pushed straight onto the session channel - /// from the per-player SDK - /// callback. Now the SHARED dispatch (`stdb::world_view`) makes the shed decision on the pump - /// and enqueues a job, and the job calls the `*_outbound` half on the session's writer thread. - /// These shims are that pair, back to back, so the wire-pinning and counter tests below keep - /// asserting on the queue exactly as they did — and keep failing if either half regresses. - fn relay_entity_motion( - tx: &SessionTx, - created: &Arc>>, - self_guid: u64, - row: &EntityMotion, - ) { - if shed_motion_at_depth(tx.depth()) { - MOTION_DROPPED.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - return; - } - for o in motion_outbound(created, self_guid, row) { - if tx.send(o).is_err() { - MOTION_DROPPED.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - } - } - } - - fn relay_creature_leg( - tx: &SessionTx, - created: &Arc>>, - row: &CreatureSpline, - ) { - if shed_motion_at_depth(tx.depth()) { - MOTION_DROPPED.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - return; - } - for o in creature_leg_outbound(created, row) { - if tx.send(o).is_err() { - MOTION_DROPPED.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - } - } - } - - fn motion_counter_lock() -> std::sync::MutexGuard<'static, ()> { - MOTION_COUNTERS.lock().unwrap_or_else(|e| e.into_inner()) - } - - /// One motion row for `guid`, carrying the wire-form `MovementInfo` the module stores. - fn motion_row(guid: u64, opcode: u16, movement_info: Vec) -> EntityMotion { - EntityMotion { - guid, - map_id: 0, - instance_id: 0, - grid_x: 0, - grid_y: 0, - cell: lyracore_shared::spatial::grid_cell_id(0, 0), - opcode, - movement_info, - seq: 0, - } - } - - fn taxi_row(character_guid: u64) -> TaxiPassengerSpline { - TaxiPassengerSpline { - character_guid, - map_id: 0, - instance_id: 0, - grid_x: 0, - grid_y: 0, - cell: lyracore_shared::spatial::grid_cell_id(0, 0), - start_x: 1.0, - start_y: 2.0, - start_z: 3.0, - points: vec![4.0, 5.0, 6.0, 7.0, 8.0, 9.0], - duration_ms: 1_000, - spline_id: 10, - } - } - - fn creature_spline(guid: u64, start_micros: u64, dur_ms: u32) -> CreatureSpline { - CreatureSpline { - guid, - start_micros, - dur_ms, - sx: 10.0, - sy: 20.0, - sz: 30.0, - dx: 30.0, - dy: 40.0, - dz: 50.0, - map_id: 0, - instance_id: 0, - grid_x: 0, - grid_y: 0, - spline_id: 10, - run: true, - cell: lyracore_shared::spatial::grid_cell_id(0, 0), - facing: false, - facing_angle: 0.0, - path: None, - } - } - - fn creature_create_outbound() -> Vec { - let mut creature = creature_entity(); - creature.unit_bytes_0 = 0x0000_0101; - peer_create_outbound(&entity_view(creature, 0), &[], &[], None).unwrap() - } - - #[test] - fn taxi_spline_reaches_self_and_only_created_observers() { - let row = taxi_row(42); - let created = Mutex::new(HashSet::new()); - assert_eq!(taxi_spline_outbound(&created, 42, &row).len(), 1); - assert!(taxi_spline_outbound(&created, 7, &row).is_empty()); - - created.lock().unwrap().insert(42); - assert_eq!(taxi_spline_outbound(&created, 7, &row).len(), 1); - } - - #[test] - fn malformed_taxi_spline_is_not_written_to_a_client() { - let mut row = taxi_row(42); - row.points.pop(); - assert!(taxi_spline_outbound(&Mutex::new(HashSet::new()), 42, &row).is_empty()); - } - - #[test] - fn resident_taxi_spline_is_chained_after_passenger_create() { - let row = taxi_row(42); - let created = Mutex::new(HashSet::from([42])); - let mut outbound = vec![Outbound::One(ServerOpcodeMessage::SMSG_DESTROY_OBJECT( - codec::build_destroy_object(99), - ))]; - - append_resident_taxi_after_create(&mut outbound, &created, 7, &row); - - assert!(matches!(outbound.first(), Some(Outbound::One(_)))); - assert!(matches!(outbound.get(1), Some(Outbound::Raw { opcode, .. }) if *opcode == 0x00DD)); - } - - #[test] - fn an_empty_retained_path_stops_the_client_instead_of_sending_invalid_geometry() { - let created = Mutex::new(HashSet::from([99])); - let mut row = creature_spline(99, 1_000_000, 1_000); - row.path = Some(crate::stdb::bindings::CreaturePath { - navigation: crate::stdb::bindings::NavigationInputs { - imported_revision: None, - navigation_enabled: false, - collision_enabled: false, - coverage_enabled: false, - static_generation: None, - coverage_generation: None, - }, - points: Vec::new(), - }); - let outbound = creature_leg_outbound(&created, &row); - let [Outbound::One(ServerOpcodeMessage::SMSG_MONSTER_MOVE(stop))] = outbound.as_slice() - else { - panic!("malformed paths need a stop"); - }; - assert_eq!(stop.duration, 0); - assert_eq!(stop.splines[0], stop.spline_point); - } - - #[test] - fn resident_waypoint_path_resumes_after_the_turn() { - let guid = 99; - let created = Mutex::new(HashSet::from([guid])); - let mut row = creature_spline(guid, 1_000_000, 7_000); - (row.sx, row.sy, row.sz) = (0.0, 0.0, 0.0); - (row.dx, row.dy, row.dz) = (3.0, 4.0, 0.0); - row.path = Some(crate::stdb::bindings::CreaturePath { - navigation: crate::stdb::bindings::NavigationInputs { - imported_revision: None, - navigation_enabled: false, - collision_enabled: false, - coverage_enabled: false, - static_generation: None, - coverage_generation: None, - }, - points: vec![ - crate::stdb::bindings::CreaturePathPoint { - x: 3.0, - y: 0.0, - z: 0.0, - }, - crate::stdb::bindings::CreaturePathPoint { - x: 3.0, - y: 4.0, - z: 0.0, - }, - ], - }); - let mut outbound = Vec::new(); - append_resident_creature_after_create(&mut outbound, &created, 0, Some(&row), 4_500_000); - let [Outbound::Raw { opcode, body }] = outbound.as_slice() else { - panic!("expected a retained path"); - }; - assert_eq!(*opcode, 0xdd); - assert_eq!(&body[..2], &[1, 99]); - let f32_at = |offset| f32::from_le_bytes(body[offset..offset + 4].try_into().unwrap()); - let u32_at = |offset| u32::from_le_bytes(body[offset..offset + 4].try_into().unwrap()); - assert_eq!((f32_at(2), f32_at(6), f32_at(10)), (3.0, 0.5, 0.0)); - assert_eq!(u32_at(23), 3_500); - assert_eq!(u32_at(27), 1); - assert_eq!((f32_at(31), f32_at(35), f32_at(39)), (3.0, 4.0, 0.0)); - assert_eq!(body.len(), 43); - } - - #[test] - fn resident_creature_replay_follows_create_from_the_current_point() { - let guid = 99; - let created = Mutex::new(HashSet::from([guid])); - let row = creature_spline(guid, 1_000_000, 1_000); - let mut outbound = creature_create_outbound(); - - append_resident_creature_after_create(&mut outbound, &created, 0, Some(&row), 1_250_500); - - let [Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(create)), Outbound::One(ServerOpcodeMessage::SMSG_MONSTER_MOVE(movement))] = - outbound.as_slice() - else { - panic!("a resident creature needs CREATE followed by MONSTER_MOVE"); - }; - let [wow_world_messages::vanilla::Object::CreateObject2 { guid3, .. }] = - create.objects.as_slice() - else { - panic!("the first packet must create the resident creature"); - }; - assert_eq!(guid3.guid(), guid); - assert_eq!(movement.guid.guid(), guid); - assert!((movement.spline_point.x - 15.01).abs() < 0.0001); - assert!((movement.spline_point.y - 25.01).abs() < 0.0001); - assert!((movement.spline_point.z - 35.01).abs() < 0.0001); - assert_eq!( - movement.splines, - vec![Vector3d { - x: 30.0, - y: 40.0, - z: 50.0, - }] - ); - assert_eq!( - movement.duration, 750, - "sub-millisecond remainder rounds up so replay cannot move faster than the durable leg" - ); - } - - #[test] - fn resident_creature_replay_skips_absent_expired_and_ineligible_legs() { - let guid = 99; - let row = creature_spline(guid, 1_000_000, 1_000); - let mut outbound = Vec::new(); - - append_resident_creature_after_create( - &mut outbound, - &Mutex::new(HashSet::from([guid])), - 0, - None, - 1_250_000, - ); - append_resident_creature_after_create( - &mut outbound, - &Mutex::new(HashSet::new()), - 0, - Some(&row), - 1_250_000, - ); - append_resident_creature_after_create( - &mut outbound, - &Mutex::new(HashSet::from([guid])), - 1, - Some(&row), - 1_250_000, - ); - append_resident_creature_after_create( - &mut outbound, - &Mutex::new(HashSet::from([guid])), - 0, - Some(&row), - 2_000_000, - ); - - assert!(outbound.is_empty()); - } - - #[test] - fn resident_facing_replay_follows_create_even_after_its_timestamp() { - let guid = 99; - let created = Mutex::new(HashSet::from([guid])); - let mut row = creature_spline(guid, 1_000_000, 0); - row.facing = true; - row.facing_angle = 2.1; - let mut outbound = creature_create_outbound(); - - append_resident_creature_after_create(&mut outbound, &created, 0, Some(&row), 9_000_000); - - let [Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(_)), Outbound::One(ServerOpcodeMessage::SMSG_MONSTER_MOVE(movement))] = - outbound.as_slice() - else { - panic!("a resident creature needs CREATE followed by its facing state"); - }; - assert_eq!( - movement.move_type, - wow_world_messages::vanilla::SMSG_MONSTER_MOVE_MonsterMoveType::FacingAngle { - angle: 2.1, - } - ); - } - - /// A running-forward movement block plus the bytes the module would have stored for it. - fn moving_forward() -> (wow_world_messages::vanilla::MovementInfo, Vec) { - let info = wow_world_messages::vanilla::MovementInfo { - flags: wow_world_messages::vanilla::MovementInfo_MovementFlags::new_forward(), - timestamp: 0x0BAD_F00D, - position: Vector3d { - x: -8949.95, - y: -132.493, - z: 83.5312, - }, - orientation: 2.5, - fall_time: 0.0, - }; - let bytes = codec::movement_info_to_bytes(&info).unwrap(); - (info, bytes) - } - - /// What the observer's writer actually receives for one peer heartbeat: an `Outbound::Raw` whose - /// opcode and body equal what the TYPED path would have serialized. The typed - /// message is built here independently of the raw builder, so this fails if the relay passes the - /// wrong guid, the wrong opcode, the row's bytes in the wrong place — or stops sending at all. - #[test] - fn the_motion_relay_queues_the_bytes_the_typed_path_would_have_sent() { - let _counters = motion_counter_lock(); - let (info, bytes) = moving_forward(); - let (tx, rx) = crate::world::SessionTx::with_depth(0); - let created = Arc::new(Mutex::new(HashSet::from([42u64]))); - let opcode = lyracore_shared::opcodes::movement::MSG_MOVE_START_FORWARD; - - relay_entity_motion(&tx, &created, 1, &motion_row(42, opcode as u16, bytes)); - - let mut framed = Vec::new(); - codec::build_movement_relay(opcode, 42, info) - .unwrap() - .write_unencrypted_server(&mut framed) - .unwrap(); - match rx - .try_recv() - .expect("the observer's writer must have been handed a packet") - { - Outbound::Raw { opcode, body } => { - assert_eq!( - opcode, - u16::from_le_bytes([framed[2], framed[3]]), - "relayed opcode" - ); - assert_eq!( - body, - &framed[4..], - "relayed body must match the typed serialization" - ); - } - Outbound::One(m) => panic!("peer motion must ride Outbound::Raw, got a typed {m}"), - Outbound::Batch(v) => panic!( - "peer motion must ride Outbound::Raw, got a {}-batch", - v.len() - ), - Outbound::Job(_) => panic!("peer motion must be encoded by the time it is queued"), - } - assert!(rx.try_recv().is_err(), "exactly one packet per motion row"); - } - - /// The two guards that were free before the raw path and must stay: a player never receives - /// their OWN movement echoed back (they would fight the server for authority over their - /// position), and no `MSG_MOVE_*` goes out for a guid this client never got a CREATE for - /// (a movement packet for an unknown entity desyncs the client). - #[test] - fn the_motion_relay_skips_self_and_uncreated_movers() { - let _counters = motion_counter_lock(); - let (_, bytes) = moving_forward(); - let opcode = lyracore_shared::opcodes::movement::MSG_MOVE_HEARTBEAT as u16; - let (tx, rx) = crate::world::SessionTx::with_depth(0); - let created = Arc::new(Mutex::new(HashSet::from([42u64, 7u64]))); - - relay_entity_motion(&tx, &created, 7, &motion_row(7, opcode, bytes.clone())); - assert!( - rx.try_recv().is_err(), - "a player's own movement must never be echoed back" - ); - - relay_entity_motion(&tx, &created, 7, &motion_row(999, opcode, bytes)); - assert!( - rx.try_recv().is_err(), - "no MSG_MOVE for a guid this client never got a CREATE for" - ); - } - - /// A malformed row must produce no packet at all — the raw path memcpys the block, so a short - /// one would be framed with a `size` the client trusts and would desync its header stream. - #[test] - fn the_motion_relay_drops_a_truncated_movement_block() { - let _counters = motion_counter_lock(); - let (_, bytes) = moving_forward(); - let (tx, rx) = crate::world::SessionTx::with_depth(0); - let created = Arc::new(Mutex::new(HashSet::from([42u64]))); - let opcode = lyracore_shared::opcodes::movement::MSG_MOVE_HEARTBEAT as u16; - - relay_entity_motion( - &tx, - &created, - 1, - &motion_row(42, opcode, bytes[..20].to_vec()), - ); - assert!( - rx.try_recv().is_err(), - "a truncated movement block must not be framed" - ); - } - - /// - /// Deltas rather than absolutes because the counter is a process-global atomic; this is the only - /// test that increments it, so the deltas are exact. - #[test] - fn a_discarded_relay_send_increments_the_dropped_counter_287() { - let _counters = motion_counter_lock(); - use std::sync::atomic::Ordering::Relaxed; - let (_, bytes) = moving_forward(); - let created = Arc::new(Mutex::new(HashSet::from([42u64]))); - let opcode = lyracore_shared::opcodes::movement::MSG_MOVE_HEARTBEAT as u16; - let before = MOTION_DROPPED.load(Relaxed); + fn a_discarded_relay_send_increments_the_dropped_counter_287() { + let _counters = motion_counter_lock(); + use std::sync::atomic::Ordering::Relaxed; + let (_, bytes) = moving_forward(); + let created = Arc::new(Mutex::new(HashSet::from([42u64]))); + let opcode = lyracore_shared::opcodes::movement::MSG_MOVE_HEARTBEAT as u16; + let before = MOTION_DROPPED.load(Relaxed); // A live receiver: delivered, so nothing is counted as dropped. let (tx, rx) = crate::world::SessionTx::with_depth(0); @@ -4072,61 +1393,6 @@ mod tests { ); } - /// a `facing`-flagged `game_creature_spline` row relays as `SMSG_MONSTER_MOVE`'s - /// `FacingAngle` variant, not the `Normal` one every other leg uses — the whole point being that - /// a stationary creature otherwise has no wire-level way to tell the client its heading changed. - #[test] - fn a_facing_leg_relays_as_the_facing_angle_variant() { - let created = Arc::new(Mutex::new(HashSet::from([42u64]))); - let row = CreatureSpline { - guid: 42, - start_micros: 0, - dur_ms: 0, - sx: 10.0, - sy: 20.0, - sz: 30.0, - dx: 10.0, - dy: 20.0, - dz: 30.0, - map_id: 0, - instance_id: 0, - grid_x: 0, - grid_y: 0, - spline_id: 7, - run: false, - cell: lyracore_shared::spatial::grid_cell_id(0, 0), - facing: true, - facing_angle: 2.1, - path: None, - }; - let out = creature_leg_outbound(&created, &row); - assert_eq!(out.len(), 1); - let Outbound::One(ServerOpcodeMessage::SMSG_MONSTER_MOVE(msg)) = &out[0] else { - panic!("expected SMSG_MONSTER_MOVE"); - }; - assert_eq!( - msg.move_type, - wow_world_messages::vanilla::SMSG_MONSTER_MOVE_MonsterMoveType::FacingAngle { - angle: 2.1 - }, - "facing leg must use the FacingAngle variant, not Normal" - ); - assert_eq!(msg.duration, 0); - - // A non-facing leg keeps the Normal variant unchanged. - let mut normal_row = row.clone(); - normal_row.facing = false; - normal_row.dur_ms = 500; - let out = creature_leg_outbound(&created, &normal_row); - let Outbound::One(ServerOpcodeMessage::SMSG_MONSTER_MOVE(msg)) = &out[0] else { - panic!("expected SMSG_MONSTER_MOVE"); - }; - assert_eq!( - msg.move_type, - wow_world_messages::vanilla::SMSG_MONSTER_MOVE_MonsterMoveType::Normal - ); - } - /// The operator-facing half of the delivery instrument: the 10-second line must carry the /// delivery ratio, the /// per-movement fan-out, and the dropped count — the three numbers the 371-client run had to diff --git a/gateway/src/stdb/world_view.rs b/gateway/src/stdb/world_view.rs index 47340ab3..76f1a22e 100644 --- a/gateway/src/stdb/world_view.rs +++ b/gateway/src/stdb/world_view.rs @@ -4,111 +4,14 @@ //! and enqueue owned rows; the World Session writer builds packets. Arming registers callbacks //! before reconciling the cache on the same Shard pump. -#[cfg(test)] -use crate::codec::combat::subscriptions::{ - aura_audience, cast_audience, combat_audience, impact_audience, melee_audience, -}; -#[cfg(test)] -use crate::codec::item::subscriptions::ItemSlotRead; -#[cfg(test)] -use crate::codec::social::subscriptions::{chat_audience, emote_audience}; - -#[cfg(test)] -use crate::codec::addon::subscriptions::addon_message_appeared; -#[cfg(test)] -use crate::codec::breath::subscriptions::breath_relay_appeared; -#[cfg(test)] -use crate::codec::channel::subscriptions::channel_notice_appeared; -#[cfg(test)] -use crate::codec::combat::subscriptions::cast_event_appeared; -#[cfg(test)] -use crate::codec::combat::subscriptions::combat_event_appeared; -#[cfg(test)] -use crate::codec::combat::subscriptions::impact_appeared; -#[cfg(test)] -use crate::codec::combat::subscriptions::melee_disengaged; -#[cfg(test)] -use crate::codec::combat::subscriptions::melee_engaged; -#[cfg(test)] -use crate::codec::corpse::subscriptions::resurrect_offered; -#[cfg(test)] -use crate::codec::corpse::subscriptions::self_res_option_changed; -#[cfg(test)] -use crate::codec::duel::subscriptions::duel_winner_audience; -use crate::codec::entity::subscriptions::entity_key; -#[cfg(test)] -use crate::codec::entity::subscriptions::entity_vanished; -use crate::codec::entity::subscriptions::offer_create_job; -#[cfg(test)] -use crate::codec::entity::subscriptions::reputation_appeared; +use crate::codec::entity::subscriptions::{entity_key, offer_create_job}; use crate::codec::gameobject::subscriptions::gameobject_appeared; -#[cfg(test)] -use crate::codec::gameobject::subscriptions::gameobject_vanished; -#[cfg(test)] -use crate::codec::guild::subscriptions::charter_petition_opened; -#[cfg(test)] -use crate::codec::guild::subscriptions::guild_event_appeared; -#[cfg(test)] -use crate::codec::guild::subscriptions::guild_membership_changed; -#[cfg(test)] -use crate::codec::guild::subscriptions::petition_event_appeared; -#[cfg(test)] -use crate::codec::item::subscriptions::item_owner_job; -#[cfg(test)] -use crate::codec::item::subscriptions::item_visible_changed; -#[cfg(test)] -use crate::codec::item::subscriptions::trade_event_appeared; -#[cfg(test)] -use crate::codec::leveling::subscriptions::exploration_outbound_for_word; -#[cfg(test)] -use crate::codec::leveling::subscriptions::levelup_appeared; -#[cfg(test)] -use crate::codec::leveling::subscriptions::rest_state_appeared; -#[cfg(test)] -use crate::codec::leveling::subscriptions::xp_appeared; -#[cfg(test)] -use crate::codec::mail::subscriptions::mail_arrived; -#[cfg(test)] -use crate::codec::movement::subscriptions::creature_leg; -#[cfg(test)] -use crate::codec::movement::subscriptions::motion; -#[cfg(test)] -use crate::codec::movement::subscriptions::teleport_appeared; -#[cfg(test)] -use crate::codec::social::subscriptions::character_online_changed; -#[cfg(test)] -use crate::codec::social::subscriptions::claim_closed; -#[cfg(test)] -use crate::codec::social::subscriptions::contact_changed; -#[cfg(test)] -use crate::codec::social::subscriptions::friend_notice_candidates; -#[cfg(test)] -use crate::codec::social::subscriptions::instance_removal_ended; -#[cfg(test)] -use crate::codec::social::subscriptions::instance_removal_started; -#[cfg(test)] -use crate::codec::social::subscriptions::instance_removal_time_left_ms; -#[cfg(test)] -use crate::codec::social::subscriptions::realm_chat_appeared; -use crate::codec::social::subscriptions::replace_contact_sets; -#[cfg(test)] -use crate::codec::social::subscriptions::resident_countdown_ms; -use crate::codec::social::subscriptions::resident_instance_removal_outbound; -#[cfg(test)] -use crate::codec::social::subscriptions::system_message_appeared; -#[cfg(test)] -use crate::codec::taxi::subscriptions::taxi_spline; -#[cfg(test)] -use crate::codec::trainer::subscriptions::skill_changed; -#[cfg(test)] -use crate::codec::weather::subscriptions::weather_changed; -#[cfg(test)] -use crate::codec::weather::subscriptions::zone_crossed; +use crate::codec::social::subscriptions::{ + replace_contact_sets, resident_instance_removal_outbound, +}; use std::collections::{HashMap, HashSet}; use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, RwLock}; -#[cfg(test)] -use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; use anyhow::{anyhow, Result}; use lyracore_shared::spatial::{BOX_HALF_SPAN, GRID_CELL_SIZE}; @@ -773,22 +676,6 @@ fn is_initial_apply(event: &spacetimedb_sdk::Event) -> bool { matches!(event, spacetimedb_sdk::Event::SubscribeApplied) } -// =============================================================================================== -// Entity dispatch -// =============================================================================================== - -// =============================================================================================== -// Gameobject dispatch -// =============================================================================================== - -// =============================================================================================== -// Motion + creature legs — no index of their own: the row carries its own cell. -// =============================================================================================== - -// =============================================================================================== -// Non-spatial dispatch (shared-call path) -// =============================================================================================== - /// Every viewer a shard-broadcast family may reach: the sessions whose characters `shard`'s /// database owns — the same audience the per-player subscriptions produced (each player connection /// was to its own shard's database, and the base queries were unscoped `SELECT *`s). Filtered by @@ -925,296 +812,28 @@ mod benilla_movement_tests { #[test] fn benilla_clients_observe_each_others_movement_within_aoi() { crate::world::tests::benilla_tests::two_clients_observe_movement(|view, row| { - super::motion(view, 0, row, &Default::default()); + crate::codec::movement::subscriptions::motion(view, 0, row, &Default::default()); }); } } #[allow(clippy::items_after_test_module)] #[cfg(test)] -mod family_audience_tests { - use super::{ - addon_message_appeared, charter_petition_opened, duel_winner_audience, - exploration_outbound_for_word, guild_event_appeared, guild_membership_changed, - instance_removal_ended, instance_removal_started, instance_removal_time_left_ms, - is_initial_apply, item_owner_job, item_visible_changed, levelup_appeared, mail_arrived, - petition_event_appeared, reputation_appeared, resident_countdown_ms, - self_res_option_changed, sweep_into_view, system_message_appeared, teleport_appeared, - weather_changed, xp_appeared, zone_crossed, BoundIdentity, ExplorationReplay, - GuildMembershipRead, GuildRosterSnapshotRead, MotionPending, OwnerGuid, PetitionRead, - Viewer, WorldView, - }; - #[cfg(test)] - use crate::codec::quest::subscriptions::quest_update_packets; +mod routing_tests { + use super::*; + use crate::codec::addon::subscriptions::addon_message_appeared; + use crate::codec::leveling::subscriptions::{levelup_appeared, xp_appeared}; + use crate::codec::movement::subscriptions::teleport_appeared; use crate::codec::social::subscriptions::private_recipient_audience; - use crate::stdb::aoi::ViewerGates; - use crate::stdb::bindings::{ - AddonMessage, CharacterExplored, CharacterQuest, GuildEvent, GuildPetition, - InstanceRemoval, ItemInstance, LevelupEvent, MailArrival, PlayerReputation, - SystemMessageEvent, TeleportEvent, XpEvent, ZoneWeather, - }; + use crate::stdb::bindings::{AddonMessage, LevelupEvent, TeleportEvent, XpEvent}; use crate::stdb::world_index::{CellKey, EntityLayer}; - use crate::world::GuildEventSnapshot; + use crate::stdb::world_view::test_support::{ + identity, queued_job, teleport, viewer, viewer_with_tx, + }; use crate::world::{Outbound, SessionTx}; - use std::collections::{HashMap, HashSet}; - use std::sync::atomic::{AtomicU32, Ordering}; - use std::sync::mpsc::Receiver; + use std::collections::HashSet; use std::sync::{Arc, Mutex}; use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; - use wow_world_messages::vanilla::{WeatherChangeType, WeatherType}; - - fn identity(byte: u8) -> spacetimedb_sdk::Identity { - spacetimedb_sdk::Identity::from_byte_array([byte; 32]) - } - - pub(crate) fn viewer(session: u64, self_guid: u64) -> Arc { - let (tx, _rx) = SessionTx::with_depth(0); - viewer_with_tx(session, self_guid, identity(session as u8), tx) - } - - fn viewer_with_tx( - session: u64, - self_guid: u64, - bound_identity: spacetimedb_sdk::Identity, - tx: SessionTx, - ) -> Arc { - viewer_in_zone(session, self_guid, bound_identity, 0, tx) - } - - /// A viewer already standing in `zone_id` — the weather relay's audience state. - fn viewer_in_zone( - session: u64, - self_guid: u64, - bound_identity: spacetimedb_sdk::Identity, - zone_id: u32, - tx: SessionTx, - ) -> Arc { - Arc::new(Viewer { - active: std::sync::atomic::AtomicBool::new(true), - session, - self_guid, - bound_identity, - map_id: 0, - instance_id: 0, - zone_id: zone_id.into(), - tx, - created: Arc::new(Mutex::new(HashSet::new())), - gates: Arc::new(ViewerGates::default()), - skill_slots: Arc::new(Mutex::new((HashMap::new(), 0))), - explored: Mutex::new(ExplorationReplay::default()), - motion_pending: Arc::new(MotionPending::default()), - member_stats: Default::default(), - ignored: Mutex::default(), - friends: Mutex::default(), - team: lyracore_shared::faction::TEAM_ALLIANCE, - group_events: Default::default(), - }) - } - - fn queued_job(rx: &Receiver) -> Vec { - match rx.try_recv().expect("one addressed writer job") { - Outbound::Job(job) => job(), - _ => panic!("shared owner dispatch must enqueue packet work as a writer job"), - } - } - - fn explored(area_bit: i32, area_id: u32, experience: u32) -> CharacterExplored { - CharacterExplored { - id: 1, - character_guid: 9001, - area_bit, - area_id, - experience, - } - } - - fn reputation(character_guid: u64, reputation_index: i32, standing: i32) -> PlayerReputation { - PlayerReputation { - id: 1, - character_guid, - owner_identity: identity(1), - faction_id: 72, - standing, - reputation_index, - at_war: false, - } - } - - fn teleport(mover_guid: u64) -> TeleportEvent { - TeleportEvent { - id: 1, - recipient_identity: identity(1), - mover_guid, - map_id: 36, - x: 10.0, - y: 20.0, - z: 30.0, - orientation: 1.5, - created_micros: 0, - cross_map: true, - } - } - - const ELWYNN: u32 = 12; - const WESTFALL: u32 = 40; - - fn zone_weather(zone_id: u32, weather_type: u8, intensity: f32) -> ZoneWeather { - ZoneWeather { - zone_id, - weather_type, - intensity, - changed_at_micros: 0, - } - } - - /// A `WeatherStore` holding one zone's sky. Stands in for a shard's `game_zone_weather` cache, - /// which a unit test has no node to populate. - #[derive(Clone)] - struct CannedWeather(Option<(u32, u8, f32)>); - - impl crate::world::WeatherStore for CannedWeather { - fn zone_weather( - &self, - zone_id: u32, - ) -> anyhow::Result> { - Ok(self.0.and_then(|(zone, weather_type, intensity)| { - (zone == zone_id).then_some(crate::codec::ZoneWeatherView { - weather_type, - intensity, - }) - })) - } - } - - fn only_weather(out: Vec) -> wow_world_messages::vanilla::SMSG_WEATHER { - match out.as_slice() { - [Outbound::One(ServerOpcodeMessage::SMSG_WEATHER(m))] => **m, - other => panic!( - "expected exactly one SMSG_WEATHER, got {} messages", - other.len() - ), - } - } - - /// Stories 5 and 6, the Elwynn/Westfall pair: a zone's weather reaches every viewer standing in - /// THAT zone and no viewer anywhere else, however near they are — weather is per zone, and the - /// spatial index has no say in it. - #[test] - fn a_weather_row_reaches_its_own_zone_and_no_other() { - let view = WorldView::new(true); - let (elwynn_tx, elwynn_rx) = SessionTx::with_depth(0); - let (also_elwynn_tx, also_elwynn_rx) = SessionTx::with_depth(0); - let (westfall_tx, westfall_rx) = SessionTx::with_depth(0); - let (zoneless_tx, zoneless_rx) = SessionTx::with_depth(0); - let anchor = CellKey::at(0, 0, 0, 0); - for (session, guid, zone, tx) in [ - (1, 9001, ELWYNN, elwynn_tx), - (2, 9002, ELWYNN, also_elwynn_tx), - (3, 9003, WESTFALL, westfall_tx), - (4, 9004, 0, zoneless_tx), - ] { - let v = viewer_in_zone(session, guid, identity(session as u8), zone, tx); - view.add_viewer_on_shard(v, anchor, 0); - } - - weather_changed(&view, 0, &zone_weather(ELWYNN, 1, 0.5)); - - for rx in [&elwynn_rx, &also_elwynn_rx] { - let m = only_weather(queued_job(rx)); - assert_eq!(m.weather_type, WeatherType::Rain); - assert_eq!(m.grade, 0.5); - assert_eq!(m.sound_id, 8534); - assert_eq!( - m.change, - WeatherChangeType::Smooth, - "a scheduled change fades in rather than snapping" - ); - } - assert!( - westfall_rx.try_recv().is_err(), - "Elwynn's weather must not reach a viewer standing in Westfall" - ); - assert!( - zoneless_rx.try_recv().is_err(), - "the unresolved zone matches no weather row" - ); - } - - /// Story 7: crossing into a new zone replaces the stale source-zone sky ONCE, instantly. - /// Staying put is silence — the slow scheduler must not turn every grid crossing into a packet. - #[test] - fn crossing_into_a_zone_sends_its_weather_once() { - let view = WorldView::new(true); - let (walker_tx, walker_rx) = SessionTx::with_depth(0); - let (resident_tx, resident_rx) = SessionTx::with_depth(0); - let anchor = CellKey::at(0, 0, 0, 0); - let walker = viewer_in_zone(1, 9001, identity(1), ELWYNN, walker_tx); - let resident = viewer_in_zone(2, 9002, identity(2), WESTFALL, resident_tx); - view.add_viewer_on_shard(walker.clone(), anchor, 0); - view.add_viewer_on_shard(resident.clone(), anchor, 0); - let store = CannedWeather(Some((WESTFALL, 3, 0.9))); - - // Elwynn → Westfall. - zone_crossed(&view, &store, 0, walker.self_guid, ELWYNN, WESTFALL); - let m = only_weather(queued_job(&walker_rx)); - assert_eq!(m.weather_type, WeatherType::Storm); - assert_eq!(m.grade, 0.9); - assert_eq!(m.sound_id, 8558); - assert_eq!(m.change, WeatherChangeType::Instant); - - // Still walking around Westfall; a row the Module could not resolve a zone for; and a row - // whose own delta claims a crossing the viewer has already been told about (a re-created - // entity row starts from the unresolved zone). - zone_crossed(&view, &store, 0, walker.self_guid, WESTFALL, WESTFALL); - zone_crossed(&view, &store, 0, walker.self_guid, WESTFALL, 0); - zone_crossed(&view, &store, 0, walker.self_guid, 0, WESTFALL); - assert!( - walker_rx.try_recv().is_err(), - "staying inside a zone sends nothing, the unresolved zone is not a crossing, and a \ - zone the viewer is already in is not re-sent" - ); - assert_eq!( - walker.zone_id.load(std::sync::atomic::Ordering::Relaxed), - WESTFALL, - "an unresolved zone must not erase the zone the viewer is really in" - ); - assert!( - resident_rx.try_recv().is_err(), - "another player's crossing is not this viewer's business" - ); - - // …and the arriving viewer now routes with the destination zone. - weather_changed(&view, 0, &zone_weather(WESTFALL, 2, 0.3)); - assert_eq!( - only_weather(queued_job(&walker_rx)).weather_type, - WeatherType::Snow - ); - } - - /// Story 10: a destination with no weather row is fine weather, not a missing packet — the - /// client would otherwise keep rendering the sky of the zone it left. - #[test] - fn crossing_into_a_zone_with_no_weather_row_sends_fine_weather() { - let view = WorldView::new(true); - let (tx, rx) = SessionTx::with_depth(0); - let walker = viewer_in_zone(1, 9001, identity(1), ELWYNN, tx); - view.add_viewer_on_shard(walker.clone(), CellKey::at(0, 0, 0, 0), 0); - - zone_crossed( - &view, - &CannedWeather(None), - 0, - walker.self_guid, - ELWYNN, - WESTFALL, - ); - - let m = only_weather(queued_job(&rx)); - assert_eq!(m.weather_type, WeatherType::Fine); - assert_eq!(m.grade, 0.0); - assert_eq!(m.sound_id, 0); - assert_eq!(m.change, WeatherChangeType::Instant); - } /// The private tier's whole privacy guarantee (group/resurrect): the addressee and /// NOBODY else. The zero cases are the leak vectors — an unset recipient must never match a @@ -1241,1543 +860,115 @@ mod family_audience_tests { } #[test] - fn a_system_message_reaches_only_its_recipient_and_missing_recipients_are_dropped() { + fn one_recenter_reports_both_layers_and_keeps_equal_guids_independent() { let view = WorldView::new(true); - let (recipient_tx, recipient_rx) = SessionTx::with_depth(0); - let recipient = viewer_with_tx(1, 9001, identity(1), recipient_tx); - let (bystander_tx, bystander_rx) = SessionTx::with_depth(0); - let bystander = viewer_with_tx(2, 9002, identity(2), bystander_tx); - let anchor = CellKey::at(0, 0, 0, 0); - view.add_viewer_on_shard(recipient.clone(), anchor, 0); - view.add_viewer_on_shard(bystander, anchor, 0); - - let row = SystemMessageEvent { - id: 1, - recipient_identity: recipient.bound_identity, - recipient_guid: recipient.self_guid, - message: "Package loaded".to_string(), - created_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, - }; - system_message_appeared(&view, &row); + let viewer = viewer(1, 9001); + view.add_viewer_on_shard(viewer.clone(), CellKey::at(0, 0, 0, 0), 0); - let outbound = queued_job(&recipient_rx); - let [Outbound::One(ServerOpcodeMessage::SMSG_MESSAGECHAT(actual))] = outbound.as_slice() - else { - panic!("the recipient must receive one System Message packet"); - }; + // Moving the anchor one cell right adds x=3 to the 5x5 neighbourhood. The same GUID in + // both row tables must appear once in each independent layer delta. + let entering = CellKey::at(0, 0, 3, 0); + view.spatial + .upsert_entity(EntityLayer::WorldEntity, 77, entering, 0, 0); + view.spatial + .upsert_entity(EntityLayer::GameObject, 77, entering, 1, 0); + let delta = view + .spatial + .move_viewer_delta(viewer.session, CellKey::at(0, 0, 1, 0)) + .expect("a cell crossing recenters"); + assert_eq!(delta.world_entities.entered, vec![(77, 0)]); + assert_eq!(delta.game_objects.entered, vec![(77, 1)]); + + // Removing one table's row cannot erase the equal-valued GUID in the other table. assert_eq!( - **actual, - crate::codec::build_gm_system_message(row.message.clone()) + view.spatial.remove_entity(EntityLayer::WorldEntity, 77, 0), + Some((entering, 0)) ); - assert!( - bystander_rx.try_recv().is_err(), - "a second Character must receive no queued work" + assert!(view + .spatial + .visible_entities(EntityLayer::WorldEntity, viewer.session) + .is_empty()); + assert_eq!( + view.spatial + .visible_entities(EntityLayer::GameObject, viewer.session), + vec![(77, 1)] ); - - let missing = SystemMessageEvent { - recipient_guid: 9999, - ..row - }; - system_message_appeared(&view, &missing); - assert!(recipient_rx.try_recv().is_err()); - assert!(bystander_rx.try_recv().is_err()); - } - - fn raw_packets(outbound: Vec) -> Vec<(u16, Vec)> { - outbound - .into_iter() - .map(|out| match out { - Outbound::Raw { opcode, body } => (opcode, body), - _ => panic!("guild relays write raw packets"), - }) - .collect() - } - - fn membership(rows: &[(u64, u32, u32)]) -> GuildMembershipRead { - let rows = rows.to_vec(); - Arc::new(move |guid| { - rows.iter() - .find(|(member, _, _)| *member == guid) - .map(|(_, guild_id, rank_id)| (*guild_id, *rank_id)) - }) - } - - /// The broadcast (`0..=13`) and addressed-membership (`0x40..`) kinds these audience tests use - /// never read a Guild snapshot, so a closure that panics if called proves it. - fn unreachable_snapshot_read() -> GuildRosterSnapshotRead { - Arc::new(|_guild_id, _kind| panic!("this Guild Event kind must not read a snapshot")) - } - - /// A snapshot read that always answers `snapshot` and counts its own calls, so a test can - /// assert a settings-change event reads the Realm-core cache once and shares the result with - /// every recipient. - fn counting_snapshot_read( - snapshot: GuildEventSnapshot, - ) -> (GuildRosterSnapshotRead, Arc) { - let reads = Arc::new(AtomicU32::new(0)); - let recorded = reads.clone(); - let read: GuildRosterSnapshotRead = Arc::new(move |_guild_id, _kind| { - recorded.fetch_add(1, Ordering::Relaxed); - snapshot.clone() - }); - (read, reads) - } - - /// SMSG_GUILD_ROSTER for one member whose officer note is `officer_note`, so two calls with - /// different notes give two packets a test can tell apart by content. - fn roster_variant(officer_note: &str) -> ServerOpcodeMessage { - let guild = crate::codec::GuildView { - guild_id: 7, - name: "Knights".into(), - ..Default::default() - }; - let line = crate::codec::GuildRosterLine { - guid: 9001, - name: "Alice".into(), - officer_note: officer_note.into(), - ..Default::default() - }; - ServerOpcodeMessage::SMSG_GUILD_ROSTER(Box::new(crate::codec::build_guild_roster( - &guild, - [line], - ))) } #[test] - fn a_membership_change_reaches_the_member_and_viewers_that_created_it() { + fn removing_an_old_session_preserves_the_new_relogin_owner_mapping() { let view = WorldView::new(true); + let old = viewer(1, 9001); + let (new_tx, _new_rx) = SessionTx::with_depth(0); + let new = viewer_with_tx(2, 9001, old.bound_identity, new_tx); let anchor = CellKey::at(0, 0, 0, 0); - let (member_tx, member_rx) = SessionTx::with_depth(0); - let member = viewer_with_tx(1, 9001, identity(1), member_tx); - let (observer_tx, observer_rx) = SessionTx::with_depth(0); - let observer = viewer_with_tx(2, 9002, identity(2), observer_tx); - observer.created.lock().unwrap().insert(9001); - let (stranger_tx, stranger_rx) = SessionTx::with_depth(0); - let stranger = viewer_with_tx(3, 9003, identity(3), stranger_tx); - for viewer in [&member, &observer, &stranger] { - view.add_viewer_on_shard(viewer.clone(), anchor, 0); - } - view.spatial - .upsert_entity(EntityLayer::WorldEntity, 9001, anchor, 0, 0); - - guild_membership_changed(&view, &membership(&[(9001, 7, 0)]), 9001); - let founded = crate::codec::build_guild_values(9001, 7, 0); - assert_eq!(raw_packets(queued_job(&member_rx)), vec![founded.clone()]); - assert_eq!(raw_packets(queued_job(&observer_rx)), vec![founded]); - assert!(queued_job(&stranger_rx).is_empty()); + view.add_viewer_on_shard(old.clone(), anchor, 0); + view.add_viewer_on_shard(new.clone(), anchor, 0); - guild_membership_changed(&view, &membership(&[]), 9001); + view.remove_viewer(old.session); + assert_eq!(view.session_of_owner(9001), Some(new.session)); + assert!(view.viewer(new.session).is_some()); + view.remove_viewer(old.session); // idempotent + assert_eq!(view.session_of_owner(9001), Some(new.session)); assert_eq!( - raw_packets(queued_job(&member_rx)), - vec![crate::codec::build_guild_values(9001, 0, 0)] + view.viewer_of_identity(BoundIdentity(new.bound_identity)) + .map(|viewer| viewer.session), + Some(new.session) ); } #[test] - fn a_removal_reaches_the_member_before_its_entity_is_indexed() { + fn replacing_a_session_cleans_every_old_registry_index() { let view = WorldView::new(true); - let anchor = CellKey::at(0, 0, 0, 0); - let (member_tx, member_rx) = SessionTx::with_depth(0); - let (other_tx, other_rx) = SessionTx::with_depth(0); - view.add_viewer_on_shard(viewer_with_tx(1, 9001, identity(1), member_tx), anchor, 0); - view.add_viewer_on_shard(viewer_with_tx(2, 9002, identity(2), other_tx), anchor, 0); + let old = viewer(7, 9001); + let replacement = Arc::new(Viewer { + active: std::sync::atomic::AtomicBool::new(true), + session: old.session, + self_guid: 9002, + bound_identity: identity(99), + map_id: 1, + instance_id: 2, + zone_id: 0.into(), + tx: old.tx.clone(), + created: old.created.clone(), + gates: old.gates.clone(), + skill_slots: old.skill_slots.clone(), + explored: Mutex::new(ExplorationReplay::default()), + motion_pending: old.motion_pending.clone(), + member_stats: Default::default(), + ignored: Mutex::default(), + friends: Mutex::default(), + team: lyracore_shared::faction::TEAM_ALLIANCE, + group_events: Default::default(), + }); + view.add_viewer_on_shard(old.clone(), CellKey::at(0, 0, 0, 0), 3); + view.add_viewer_on_shard(replacement.clone(), CellKey::at(1, 2, 0, 0), 4); - guild_membership_changed(&view, &membership(&[]), 9001); + assert!(view.viewer_of_owner(OwnerGuid(old.self_guid)).is_none()); + assert!(view + .viewer_of_identity(BoundIdentity(old.bound_identity)) + .is_none()); + assert!(view.viewers_on_shard(3).is_empty()); assert_eq!( - raw_packets(queued_job(&member_rx)), - vec![crate::codec::build_guild_values(9001, 0, 0)] + view.viewer_of_owner(OwnerGuid(replacement.self_guid)) + .map(|viewer| viewer.session), + Some(replacement.session) ); - assert!(other_rx.try_recv().is_err()); - } - - fn guild_event(recipient_guid: u64, kind: u8, subject_guid: u64) -> GuildEvent { - GuildEvent { - id: 1, - guild_id: 7, - recipient_guid, - kind, - subject_guid, - other_guid: 0, - strings: vec!["Alice".to_string()], - created_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, - } - } + assert_eq!( + view.viewer_of_identity(BoundIdentity(replacement.bound_identity)) + .map(|viewer| viewer.session), + Some(replacement.session) + ); + assert_eq!(view.viewers_on_shard(4).len(), 1); - #[test] - fn a_broadcast_guild_event_reaches_online_members_and_nobody_else() { - use lyracore_shared::guild::event_kind; - let view = WorldView::new(true); - let anchor = CellKey::at(0, 0, 0, 0); - let mut receivers = Vec::new(); - for (session, guid) in [(1, 9001), (2, 9002), (3, 9003), (4, 9004)] { - let (tx, rx) = SessionTx::with_depth(0); - // Members stand on different Shards: the audience is the Guild, not the Shard. - view.add_viewer_on_shard( - viewer_with_tx(session, guid, identity(session as u8), tx), - anchor, - session as usize, - ); - receivers.push(rx); - } - let [alice, bob, stranger, other_guild] = <[_; 4]>::try_from(receivers).unwrap(); - let members = membership(&[(9001, 7, 0), (9002, 7, 4), (9004, 8, 0)]); - let snapshot_read = unreachable_snapshot_read(); - - guild_event_appeared( - &view, - &members, - &snapshot_read, - &guild_event(0, event_kind::SIGNED_OFF, 9001), - ); - let signed_off = crate::codec::build_guild_event_raw( - event_kind::SIGNED_OFF, - &["Alice".to_string()], - 9001, - ); - assert_eq!(raw_packets(queued_job(&alice)), vec![signed_off.clone()]); - assert_eq!(raw_packets(queued_job(&bob)), vec![signed_off]); - assert!(stranger.try_recv().is_err()); - assert!(other_guild.try_recv().is_err()); - - guild_event_appeared( - &view, - &members, - &snapshot_read, - &guild_event(0, event_kind::SIGNED_ON, 9001), - ); - assert!( - alice.try_recv().is_err(), - "a member does not hear its own sign-on" - ); - assert_eq!(raw_packets(queued_job(&bob)).len(), 1); - } - - #[test] - fn an_addressed_guild_event_reaches_only_its_recipient() { - use lyracore_shared::guild::event_kind; - let view = WorldView::new(true); - let anchor = CellKey::at(0, 0, 0, 0); - let (recipient_tx, recipient_rx) = SessionTx::with_depth(0); - let (member_tx, member_rx) = SessionTx::with_depth(0); - view.add_viewer_on_shard( - viewer_with_tx(1, 9003, identity(1), recipient_tx), - anchor, - 0, - ); - view.add_viewer_on_shard(viewer_with_tx(2, 9001, identity(2), member_tx), anchor, 0); - - // DISBANDED (0x08) is addressed per former member, so it exercises the generic addressed - // path through the raw SMSG_GUILD_EVENT builder. - guild_event_appeared( - &view, - &membership(&[(9001, 7, 0)]), - &unreachable_snapshot_read(), - &guild_event(9003, event_kind::DISBANDED, 9001), - ); - assert_eq!(raw_packets(queued_job(&recipient_rx)).len(), 1); - assert!(member_rx.try_recv().is_err()); - } - - #[test] - fn tabard_changed_reaches_every_online_member_with_the_new_emblem() { - use lyracore_shared::guild::event_kind; - let view = WorldView::new(true); - let anchor = CellKey::at(0, 0, 0, 0); - let (leader_tx, leader_rx) = SessionTx::with_depth(0); - let (member_tx, member_rx) = SessionTx::with_depth(0); - view.add_viewer_on_shard(viewer_with_tx(1, 9001, identity(1), leader_tx), anchor, 0); - view.add_viewer_on_shard(viewer_with_tx(2, 9002, identity(2), member_tx), anchor, 1); - let emblem = crate::codec::GuildView { - guild_id: 7, - name: "Tabard Guild".into(), - emblem_style: 11, - emblem_color: 12, - border_style: 3, - border_color: 14, - background_color: 15, - ..Default::default() - }; - let query = ServerOpcodeMessage::SMSG_GUILD_QUERY_RESPONSE(Box::new( - crate::codec::build_guild_query_response(&emblem), - )); - let snapshot = GuildEventSnapshot { - query: Some(query.clone()), - ..GuildEventSnapshot::default() - }; - let (snapshot_read, reads) = counting_snapshot_read(snapshot); - let tabard_changed = GuildEvent { - strings: Vec::new(), - ..guild_event(0, event_kind::TABARD_CHANGED, 0) - }; - - guild_event_appeared( - &view, - &membership(&[(9001, 7, 0), (9002, 7, 4)]), - &snapshot_read, - &tabard_changed, - ); - - for rx in [&leader_rx, &member_rx] { - let [Outbound::Raw { opcode, body }, Outbound::One(response)] = - <[Outbound; 2]>::try_from(queued_job(rx)).ok().unwrap() - else { - panic!("TABARD_CHANGED renders the event, then the query response"); - }; - assert_eq!((opcode, body), (0x0092, vec![9, 0])); - assert_eq!(response, query); - } - assert_eq!( - reads.load(Ordering::Relaxed), - 1, - "one snapshot read serves every recipient" - ); - } - - /// `0x50` (`event_kind::ROSTER_TO_ACTOR`) is addressed, so it takes the same recipient-only - /// path SIGNED_ON's twin above already proves. This test pins that the actor's job reads the - /// shared snapshot and picks the roster its own Guild Rank sees. - #[test] - fn roster_to_actor_reaches_only_the_actor_with_the_roster_its_rank_sees() { - use lyracore_shared::guild::event_kind; - let view = WorldView::new(true); - let anchor = CellKey::at(0, 0, 0, 0); - let (actor_tx, actor_rx) = SessionTx::with_depth(0); - let (other_tx, other_rx) = SessionTx::with_depth(0); - view.add_viewer_on_shard(viewer_with_tx(1, 9001, identity(1), actor_tx), anchor, 0); - view.add_viewer_on_shard(viewer_with_tx(2, 9002, identity(2), other_tx), anchor, 0); - let shown = roster_variant("secret"); - let snapshot = GuildEventSnapshot { - roster_hidden: Some(roster_variant("")), - roster_shown: Some(shown.clone()), - rank_rights: vec![(0, 0xF_F1FF)], - ..GuildEventSnapshot::default() - }; - let (snapshot_read, reads) = counting_snapshot_read(snapshot); - - guild_event_appeared( - &view, - &membership(&[(9001, 7, 0), (9002, 7, 0)]), - &snapshot_read, - &guild_event(9001, event_kind::ROSTER_TO_ACTOR, 0), - ); - - let [Outbound::One(actual)] = <[Outbound; 1]>::try_from(queued_job(&actor_rx)) - .ok() - .unwrap() - else { - panic!("expected one roster packet"); - }; - assert_eq!(actual, shown, "rank 0 holds VIEWOFFNOTE in this snapshot"); - assert!(other_rx.try_recv().is_err()); - assert_eq!(reads.load(Ordering::Relaxed), 1); - } - - #[test] - fn roster_to_actor_with_no_membership_row_sends_nothing() { - use lyracore_shared::guild::event_kind; - let view = WorldView::new(true); - let anchor = CellKey::at(0, 0, 0, 0); - let (actor_tx, actor_rx) = SessionTx::with_depth(0); - view.add_viewer_on_shard(viewer_with_tx(1, 9001, identity(1), actor_tx), anchor, 0); - - guild_event_appeared( - &view, - &membership(&[]), - &unreachable_snapshot_read(), - &guild_event(9001, event_kind::ROSTER_TO_ACTOR, 0), - ); - - assert!(queued_job(&actor_rx).is_empty()); - } - - /// The same guard also has to check which Guild the membership row names: `map_or(0, ...)` - /// ignored the returned guild_id, so a member who moved to another Guild between this job's - /// enqueue and its run would still get a roster, rendered under its new Guild's own Rank - /// Rights. - #[test] - fn roster_to_actor_with_membership_in_another_guild_sends_nothing() { - use lyracore_shared::guild::event_kind; - let view = WorldView::new(true); - let anchor = CellKey::at(0, 0, 0, 0); - let (actor_tx, actor_rx) = SessionTx::with_depth(0); - view.add_viewer_on_shard(viewer_with_tx(1, 9001, identity(1), actor_tx), anchor, 0); - - guild_event_appeared( - &view, - &membership(&[(9001, 8, 0)]), - &unreachable_snapshot_read(), - &guild_event(9001, event_kind::ROSTER_TO_ACTOR, 0), - ); - - assert!(queued_job(&actor_rx).is_empty()); - } - - /// `0x80` (`event_kind::ROSTER_REFRESH`) is a broadcast kind, so it takes the same audience - /// path the SIGNED_OFF broadcast test above already proves. This test pins that every online - /// member's own job reads one shared snapshot and picks the roster its own rank sees, so a - /// large Guild costs one Realm-core read for the whole broadcast, not one per member. - #[test] - fn roster_refresh_gives_each_online_member_the_roster_its_own_rank_sees() { - use lyracore_shared::guild::event_kind; - let view = WorldView::new(true); - let anchor = CellKey::at(0, 0, 0, 0); - let mut receivers = Vec::new(); - for (session, guid) in [(1, 9001), (2, 9002), (3, 9003)] { - let (tx, rx) = SessionTx::with_depth(0); - view.add_viewer_on_shard( - viewer_with_tx(session, guid, identity(session as u8), tx), - anchor, - 0, - ); - receivers.push(rx); - } - let [leader, member, stranger] = <[_; 3]>::try_from(receivers).unwrap(); - let hidden = roster_variant(""); - let shown = roster_variant("secret"); - let query = ServerOpcodeMessage::SMSG_GUILD_QUERY_RESPONSE(Box::new( - crate::codec::build_guild_query_response(&crate::codec::GuildView { - guild_id: 7, - ..Default::default() - }), - )); - let snapshot = GuildEventSnapshot { - query: Some(query), - roster_hidden: Some(hidden.clone()), - roster_shown: Some(shown.clone()), - rank_rights: vec![(0, 0xF_F1FF), (4, 0x43)], - }; - let (snapshot_read, reads) = counting_snapshot_read(snapshot); - - guild_event_appeared( - &view, - &membership(&[(9001, 7, 0), (9002, 7, 4)]), - &snapshot_read, - &guild_event(0, event_kind::ROSTER_REFRESH, 0), - ); - - let leader_out = queued_job(&leader); - let member_out = queued_job(&member); - assert!(stranger.try_recv().is_err()); - let [Outbound::One(_), Outbound::One(leader_roster)] = - <[Outbound; 2]>::try_from(leader_out).ok().unwrap() - else { - panic!("expected a query response then a roster"); - }; - assert_eq!(leader_roster, shown, "rank 0 holds VIEWOFFNOTE"); - let [Outbound::One(_), Outbound::One(member_roster)] = - <[Outbound; 2]>::try_from(member_out).ok().unwrap() - else { - panic!("expected a query response then a roster"); - }; - assert_eq!(member_roster, hidden, "rank 4 cannot view officer notes"); - assert_eq!( - reads.load(Ordering::Relaxed), - 1, - "one snapshot read serves every recipient" - ); - } - - #[test] - fn an_invite_event_reaches_its_target_as_smsg_guild_invite() { - use lyracore_shared::guild::event_kind; - use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; - let view = WorldView::new(true); - let anchor = CellKey::at(0, 0, 0, 0); - let (target_tx, target_rx) = SessionTx::with_depth(0); - view.add_viewer_on_shard(viewer_with_tx(1, 9003, identity(1), target_tx), anchor, 0); - - let mut row = guild_event(9003, event_kind::INVITE, 0); - row.strings = vec!["Alice".to_string(), "Tracer Guild".to_string()]; - guild_event_appeared(&view, &membership(&[]), &unreachable_snapshot_read(), &row); - - let outbound = queued_job(&target_rx); - let [Outbound::One(ServerOpcodeMessage::SMSG_GUILD_INVITE(invite))] = outbound.as_slice() - else { - panic!( - "expected exactly one SMSG_GUILD_INVITE, got {} packets", - outbound.len() - ); - }; - assert_eq!(invite.player_name, "Alice"); - assert_eq!(invite.guild_name, "Tracer Guild"); - } - - #[test] - fn a_decline_event_reaches_the_inviter_as_raw_smsg_guild_decline() { - use lyracore_shared::guild::event_kind; - let view = WorldView::new(true); - let anchor = CellKey::at(0, 0, 0, 0); - let (inviter_tx, inviter_rx) = SessionTx::with_depth(0); - view.add_viewer_on_shard(viewer_with_tx(1, 9003, identity(1), inviter_tx), anchor, 0); - - let row = guild_event(9003, event_kind::DECLINE, 0); - guild_event_appeared(&view, &membership(&[]), &unreachable_snapshot_read(), &row); - - assert_eq!( - raw_packets(queued_job(&inviter_rx)), - vec![(0x0086, b"Alice\0".to_vec())] - ); - } - - const CHARTER: u64 = 0x4000_0000_0000_0101; - - fn night_watch() -> crate::codec::PetitionView { - crate::codec::PetitionView { - petition_id: 42, - charter_item_guid: CHARTER, - owner_guid: 9001, - name: "Night Watch".into(), - signers: vec![9002], - } - } - - /// A Petition read that answers `petition` for the Charter and counts its calls. - fn counting_petition_read( - petition: Option, - ) -> (PetitionRead, Arc) { - let reads = Arc::new(AtomicU32::new(0)); - let recorded = reads.clone(); - let read: PetitionRead = Arc::new(move |charter_item_guid| { - recorded.fetch_add(1, Ordering::Relaxed); - petition - .clone() - .filter(|petition| petition.charter_item_guid == charter_item_guid) - }); - (read, reads) - } - - fn unreachable_petition_read() -> PetitionRead { - Arc::new(|_charter_item_guid| panic!("this petition kind must not read a Petition")) - } - - fn petition_event(recipient_guid: u64, kind: u8, subject_guid: u64) -> GuildEvent { - GuildEvent { - guild_id: 0, - other_guid: CHARTER, - strings: Vec::new(), - ..guild_event(recipient_guid, kind, subject_guid) - } - } - - /// Registers the recipient 9003 and a bystander 9004 on one shard. - fn recipient_and_bystander(view: &WorldView) -> (Receiver, Receiver) { - let anchor = CellKey::at(0, 0, 0, 0); - let (recipient_tx, recipient_rx) = SessionTx::with_depth(0); - let (bystander_tx, bystander_rx) = SessionTx::with_depth(0); - view.add_viewer_on_shard( - viewer_with_tx(1, 9003, identity(1), recipient_tx), - anchor, - 0, - ); - view.add_viewer_on_shard( - viewer_with_tx(2, 9004, identity(2), bystander_tx), - anchor, - 0, - ); - (recipient_rx, bystander_rx) - } - - #[test] - fn an_offer_shows_its_target_the_signature_window_read_once() { - use lyracore_shared::guild::event_kind; - let view = WorldView::new(true); - let (target_rx, bystander_rx) = recipient_and_bystander(&view); - let (read, reads) = counting_petition_read(Some(night_watch())); - - petition_event_appeared( - &view, - &read, - &petition_event(9003, event_kind::PETITION_OFFERED, 9001), - ); - - let outbound = queued_job(&target_rx); - let [Outbound::One(ServerOpcodeMessage::SMSG_PETITION_SHOW_SIGNATURES(window))] = - outbound.as_slice() - else { - panic!("expected one SMSG_PETITION_SHOW_SIGNATURES"); - }; - assert_eq!( - **window, - crate::codec::build_petition_show_signatures(&night_watch()) - ); - assert_eq!(reads.load(Ordering::Relaxed), 1); - assert!(bystander_rx.try_recv().is_err()); - } - - #[test] - fn signature_events_render_sign_results_for_the_charter_and_the_signer() { - use lyracore_shared::guild::event_kind; - use wow_world_messages::vanilla::PetitionResult; - let view = WorldView::new(true); - let (owner_rx, _bystander_rx) = recipient_and_bystander(&view); - for (kind, result) in [ - (event_kind::PETITION_SIGNED, PetitionResult::Ok), - ( - event_kind::PETITION_ALREADY_SIGNED, - PetitionResult::AlreadySigned, - ), - ] { - petition_event_appeared( - &view, - &unreachable_petition_read(), - &petition_event(9003, kind, 9002), - ); - let outbound = queued_job(&owner_rx); - let [Outbound::One(ServerOpcodeMessage::SMSG_PETITION_SIGN_RESULTS(results))] = - outbound.as_slice() - else { - panic!("expected one SMSG_PETITION_SIGN_RESULTS"); - }; - assert_eq!( - ( - results.petition.guid(), - results.owner.guid(), - results.result - ), - (CHARTER, 9002, result) - ); - } - } - - #[test] - fn a_decline_event_names_the_decliner_to_the_owner() { - use lyracore_shared::guild::event_kind; - let view = WorldView::new(true); - let (owner_rx, _bystander_rx) = recipient_and_bystander(&view); - petition_event_appeared( - &view, - &unreachable_petition_read(), - &petition_event(9003, event_kind::PETITION_DECLINED, 9002), - ); - let outbound = queued_job(&owner_rx); - let [Outbound::One(ServerOpcodeMessage::MSG_PETITION_DECLINE(declined))] = - outbound.as_slice() - else { - panic!("expected one MSG_PETITION_DECLINE"); - }; - assert_eq!(declined.petition.guid(), 9002); - } - - #[test] - fn a_founder_event_names_the_new_guild() { - use lyracore_shared::guild::event_kind; - use wow_world_messages::vanilla::{GuildCommand, GuildCommandResult}; - let view = WorldView::new(true); - let (signer_rx, _bystander_rx) = recipient_and_bystander(&view); - let row = GuildEvent { - guild_id: 12, - strings: vec!["Night Watch".into()], - ..petition_event(9003, event_kind::FOUNDER, 0) - }; - petition_event_appeared(&view, &unreachable_petition_read(), &row); - let outbound = queued_job(&signer_rx); - let [Outbound::One(ServerOpcodeMessage::SMSG_GUILD_COMMAND_RESULT(result))] = - outbound.as_slice() - else { - panic!("expected one SMSG_GUILD_COMMAND_RESULT"); - }; - assert_eq!( - (result.command, result.string.as_str(), result.result), - ( - GuildCommand::Founder, - "Night Watch", - GuildCommandResult::PlayerNoMoreInGuild - ) - ); - } - - #[test] - fn a_lost_signature_sends_the_owner_a_fresh_petition_query() { - use lyracore_shared::guild::event_kind; - let view = WorldView::new(true); - let (owner_rx, _bystander_rx) = recipient_and_bystander(&view); - let (read, _) = counting_petition_read(Some(night_watch())); - petition_event_appeared( - &view, - &read, - &petition_event(9003, event_kind::PETITION_CHANGED, 9002), - ); - let outbound = queued_job(&owner_rx); - let [Outbound::One(ServerOpcodeMessage::SMSG_PETITION_QUERY_RESPONSE(query))] = - outbound.as_slice() - else { - panic!("expected one SMSG_PETITION_QUERY_RESPONSE"); - }; - assert_eq!( - ( - query.petition_id, - query.charter_owner.guid(), - query.guild_name.as_str() - ), - (42, 9001, "Night Watch") - ); - - let (gone, _) = counting_petition_read(None); - petition_event_appeared( - &view, - &gone, - &petition_event(9003, event_kind::PETITION_CHANGED, 9002), - ); - assert!( - queued_job(&owner_rx).is_empty(), - "a Petition gone by the time the job runs renders nothing" - ); - } - - #[test] - fn a_new_petition_shows_its_id_on_the_owners_charter() { - let view = WorldView::new(true); - let (owner_rx, bystander_rx) = recipient_and_bystander(&view); - charter_petition_opened( - &view, - &GuildPetition { - petition_id: 42, - charter_item_guid: CHARTER, - owner_guid: 9003, - owner_name: "Owner".into(), - team: 469, - name: "Night Watch".into(), - created_micros: 0, - }, - ); - let packets = raw_packets(queued_job(&owner_rx)); - assert_eq!(packets.len(), 1); - assert_eq!(packets[0].0, 0x00A9); - let updates = lyracore_shared::values_mask::parse_values_updates(&packets[0].1); - assert_eq!(updates.len(), 1); - assert_eq!(updates[0].guid, CHARTER); - assert_eq!(updates[0].fields, vec![(22, 42)]); - assert!(bystander_rx.try_recv().is_err()); - } - - #[test] - fn a_disband_reaches_each_former_member_exactly_once() { - use lyracore_shared::guild::event_kind; - let view = WorldView::new(true); - let anchor = CellKey::at(0, 0, 0, 0); - let mut receivers = Vec::new(); - for (session, guid) in [(1, 9001), (2, 9002)] { - let (tx, rx) = SessionTx::with_depth(0); - view.add_viewer_on_shard( - viewer_with_tx(session, guid, identity(session as u8), tx), - anchor, - 0, - ); - receivers.push(rx); - } - let [alice_rx, bob_rx] = <[_; 2]>::try_from(receivers).unwrap(); - // A disbanded Guild's members are already gone by the time the relay job runs, so - // `membership` answers `None` for both. DISBANDED is addressed, not read from current - // membership. - let no_members = membership(&[]); - - guild_event_appeared( - &view, - &no_members, - &unreachable_snapshot_read(), - &guild_event(9001, event_kind::DISBANDED, 0), - ); - guild_event_appeared( - &view, - &no_members, - &unreachable_snapshot_read(), - &guild_event(9002, event_kind::DISBANDED, 0), - ); - - assert_eq!(queued_job(&alice_rx).len(), 1); - assert_eq!(queued_job(&bob_rx).len(), 1); - } - - #[test] - fn a_removed_event_does_not_reach_the_removed_member() { - use lyracore_shared::guild::event_kind; - let view = WorldView::new(true); - let anchor = CellKey::at(0, 0, 0, 0); - let (remaining_tx, remaining_rx) = SessionTx::with_depth(0); - let (removed_tx, removed_rx) = SessionTx::with_depth(0); - view.add_viewer_on_shard( - viewer_with_tx(1, 9001, identity(1), remaining_tx), - anchor, - 0, - ); - // The removed Character's viewer is still on this Gateway (mid-logout, or simply still - // connected); its membership is already gone, so `online_audience` no longer includes it. - view.add_viewer_on_shard(viewer_with_tx(2, 9002, identity(2), removed_tx), anchor, 0); - - // Only 9001 remains a member of guild 7; 9002 was just removed. - let members = membership(&[(9001, 7, 0)]); - guild_event_appeared( - &view, - &members, - &unreachable_snapshot_read(), - &guild_event(0, event_kind::REMOVED, 0), - ); - - assert_eq!(queued_job(&remaining_rx).len(), 1); - assert!(removed_rx.try_recv().is_err()); - } - - /// The owner's `PLAYER_SELF_RES_SPELL` from one queued VALUES packet. - fn self_res_spell_of(outbound: Vec) -> Option { - use wow_world_messages::vanilla::{Object, UpdateMask}; - let [Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(packet))] = outbound.as_slice() - else { - panic!("the owner must receive one SMSG_UPDATE_OBJECT"); - }; - match packet.objects.as_slice() { - [Object::Values { - mask1: UpdateMask::Player(player), - .. - }] => player.player_self_res_spell(), - other => panic!("expected one Player VALUES block, got {other:?}"), - } - } - - #[test] - fn a_self_resurrect_option_reaches_only_its_character_on_this_shard() { - let view = WorldView::new(true); - let anchor = CellKey::at(0, 0, 0, 0); - let (owner_tx, owner_rx) = SessionTx::with_depth(0); - let (bystander_tx, bystander_rx) = SessionTx::with_depth(0); - let (remote_tx, remote_rx) = SessionTx::with_depth(0); - view.add_viewer_on_shard(viewer_with_tx(1, 9001, identity(1), owner_tx), anchor, 0); - view.add_viewer_on_shard( - viewer_with_tx(2, 9002, identity(2), bystander_tx), - anchor, - 0, - ); - view.add_viewer_on_shard(viewer_with_tx(3, 9003, identity(3), remote_tx), anchor, 1); - - self_res_option_changed(&view, 0, 9001, 3026); - assert_eq!(self_res_spell_of(queued_job(&owner_rx)), Some(3026)); - assert!(owner_rx.try_recv().is_err(), "one change queues one job"); - assert!( - bystander_rx.try_recv().is_err(), - "a nearby Character never sees another Character's option" - ); - - self_res_option_changed(&view, 0, 9003, 3026); - assert!( - remote_rx.try_recv().is_err(), - "a Character on another Shard is not this Shard's to address" - ); - - self_res_option_changed(&view, 0, 9001, 0); - assert_eq!(self_res_spell_of(queued_job(&owner_rx)), Some(0)); - } - - #[test] - fn a_mail_arrival_reaches_its_recipient_on_any_shard_and_an_unknown_guid_gets_nothing() { - let view = WorldView::new(true); - let (recipient_tx, recipient_rx) = SessionTx::with_depth(0); - let recipient = viewer_with_tx(1, 9001, identity(1), recipient_tx); - let (bystander_tx, bystander_rx) = SessionTx::with_depth(0); - let bystander = viewer_with_tx(2, 9002, identity(2), bystander_tx); - let anchor = CellKey::at(0, 0, 0, 0); - view.add_viewer_on_shard(recipient.clone(), anchor, 1); - view.add_viewer_on_shard(bystander, anchor, 0); - - let row = MailArrival { - id: 1, - recipient_guid: recipient.self_guid, - created_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, - }; - mail_arrived(&view, &row); - - let outbound = queued_job(&recipient_rx); - let [Outbound::One(ServerOpcodeMessage::SMSG_RECEIVED_MAIL(packet))] = outbound.as_slice() - else { - panic!("the recipient must receive one SMSG_RECEIVED_MAIL"); - }; - assert_eq!(packet.unknown1, 0); - assert!( - recipient_rx.try_recv().is_err(), - "one arrival queues one job" - ); - assert!( - bystander_rx.try_recv().is_err(), - "a second Character must receive no queued work" - ); - - mail_arrived( - &view, - &MailArrival { - recipient_guid: 9999, - ..row - }, - ); - assert!(recipient_rx.try_recv().is_err()); - assert!(bystander_rx.try_recv().is_err()); - } - - #[test] - fn duel_winner_aoi_fan_excludes_the_two_private_recipients() { - assert!(!duel_winner_audience(10, 10, 20)); - assert!(!duel_winner_audience(20, 10, 20)); - assert!(duel_winner_audience(30, 10, 20)); - assert!(!duel_winner_audience(0, 10, 20)); - } - - #[test] - fn one_recenter_reports_both_layers_and_keeps_equal_guids_independent() { - let view = WorldView::new(true); - let viewer = viewer(1, 9001); - view.add_viewer_on_shard(viewer.clone(), CellKey::at(0, 0, 0, 0), 0); - - // Moving the anchor one cell right adds x=3 to the 5x5 neighbourhood. The same GUID in - // both row tables must appear once in each independent layer delta. - let entering = CellKey::at(0, 0, 3, 0); - view.spatial - .upsert_entity(EntityLayer::WorldEntity, 77, entering, 0, 0); - view.spatial - .upsert_entity(EntityLayer::GameObject, 77, entering, 1, 0); - let delta = view - .spatial - .move_viewer_delta(viewer.session, CellKey::at(0, 0, 1, 0)) - .expect("a cell crossing recenters"); - assert_eq!(delta.world_entities.entered, vec![(77, 0)]); - assert_eq!(delta.game_objects.entered, vec![(77, 1)]); - - // Removing one table's row cannot erase the equal-valued GUID in the other table. - assert_eq!( - view.spatial.remove_entity(EntityLayer::WorldEntity, 77, 0), - Some((entering, 0)) - ); - assert!(view - .spatial - .visible_entities(EntityLayer::WorldEntity, viewer.session) - .is_empty()); - assert_eq!( - view.spatial - .visible_entities(EntityLayer::GameObject, viewer.session), - vec![(77, 1)] - ); - } - - #[test] - fn removing_an_old_session_preserves_the_new_relogin_owner_mapping() { - let view = WorldView::new(true); - let old = viewer(1, 9001); - let (new_tx, _new_rx) = SessionTx::with_depth(0); - let new = viewer_with_tx(2, 9001, old.bound_identity, new_tx); - let anchor = CellKey::at(0, 0, 0, 0); - view.add_viewer_on_shard(old.clone(), anchor, 0); - view.add_viewer_on_shard(new.clone(), anchor, 0); - - view.remove_viewer(old.session); - assert_eq!(view.session_of_owner(9001), Some(new.session)); - assert!(view.viewer(new.session).is_some()); - view.remove_viewer(old.session); // idempotent - assert_eq!(view.session_of_owner(9001), Some(new.session)); - assert_eq!( - view.viewer_of_identity(BoundIdentity(new.bound_identity)) - .map(|viewer| viewer.session), - Some(new.session) - ); - } - - #[test] - fn quest_owner_routing_follows_a_transfer_to_its_destination_viewer() { - let view = WorldView::new(true); - let source = viewer(1, 9001); - let destination = viewer(2, 9001); - let anchor = CellKey::at(0, 0, 0, 0); - view.add_viewer_on_shard(source.clone(), anchor, 0); - view.remove_viewer(source.session); - view.add_viewer_on_shard(destination.clone(), anchor, 1); - - assert_eq!( - view.viewer_of_owner(OwnerGuid(9001)) - .map(|viewer| viewer.session), - Some(destination.session), - "a later quest row must route only to the destination viewer" - ); - assert!( - view.viewer_of_owner_on_shard(0, OwnerGuid(9001)).is_none(), - "a source-shard cascade delta applied after the transfer must find no viewer" - ); - assert_eq!( - view.viewer_of_owner_on_shard(1, OwnerGuid(9001)) - .map(|viewer| viewer.session), - Some(destination.session) - ); - } - - #[test] - fn quest_update_orders_kills_then_timer_then_full_log() { - let old = CharacterQuest { - id: 1, - character_guid: 9001, - owner_identity: identity(1), - quest_entry: 77, - counts: vec![1], - rewarded: false, - deadline_micros: 0, - failed: false, - }; - let updated = CharacterQuest { - counts: vec![2], - failed: true, - ..old.clone() - }; - let outbound = quest_update_packets( - &old, - &updated, - &[(0, 0, 123, 4)], - Some(Outbound::Raw { - opcode: 1, - body: vec![0], - }), - ); - - assert!(matches!( - outbound.as_slice(), - [ - Outbound::One(ServerOpcodeMessage::SMSG_QUESTUPDATE_ADD_KILL(_)), - Outbound::One(ServerOpcodeMessage::SMSG_QUESTUPDATE_FAILEDTIMER(_)), - Outbound::Raw { .. }, - ] - )); - } - - #[test] - fn replacing_a_session_cleans_every_old_registry_index() { - let view = WorldView::new(true); - let old = viewer(7, 9001); - let replacement = Arc::new(Viewer { - active: std::sync::atomic::AtomicBool::new(true), - session: old.session, - self_guid: 9002, - bound_identity: identity(99), - map_id: 1, - instance_id: 2, - zone_id: 0.into(), - tx: old.tx.clone(), - created: old.created.clone(), - gates: old.gates.clone(), - skill_slots: old.skill_slots.clone(), - explored: Mutex::new(ExplorationReplay::default()), - motion_pending: old.motion_pending.clone(), - member_stats: Default::default(), - ignored: Mutex::default(), - friends: Mutex::default(), - team: lyracore_shared::faction::TEAM_ALLIANCE, - group_events: Default::default(), - }); - view.add_viewer_on_shard(old.clone(), CellKey::at(0, 0, 0, 0), 3); - view.add_viewer_on_shard(replacement.clone(), CellKey::at(1, 2, 0, 0), 4); - - assert!(view.viewer_of_owner(OwnerGuid(old.self_guid)).is_none()); - assert!(view - .viewer_of_identity(BoundIdentity(old.bound_identity)) - .is_none()); - assert!(view.viewers_on_shard(3).is_empty()); - assert_eq!( - view.viewer_of_owner(OwnerGuid(replacement.self_guid)) - .map(|viewer| viewer.session), - Some(replacement.session) - ); - assert_eq!( - view.viewer_of_identity(BoundIdentity(replacement.bound_identity)) - .map(|viewer| viewer.session), - Some(replacement.session) - ); - assert_eq!(view.viewers_on_shard(4).len(), 1); - - view.remove_viewer(replacement.session); - assert!(view - .viewer_of_owner(OwnerGuid(replacement.self_guid)) - .is_none()); - assert!(view - .viewer_of_identity(BoundIdentity(replacement.bound_identity)) - .is_none()); - assert!(view.viewers_on_shard(4).is_empty()); - } - - #[test] - fn teleport_routes_one_writer_job_to_its_owner_and_keeps_cross_map_order() { - let view = WorldView::new(true); - let (owner_tx, owner_rx) = SessionTx::with_depth(0); - let (other_tx, other_rx) = SessionTx::with_depth(0); - let owner = viewer_with_tx(1, 9001, identity(1), owner_tx); - let other = viewer_with_tx(2, 9002, identity(2), other_tx); - let anchor = CellKey::at(0, 0, 0, 0); - view.add_viewer_on_shard(owner.clone(), anchor, 0); - view.add_viewer_on_shard(other, anchor, 0); - - teleport_appeared(&view, 0, &teleport(owner.self_guid)); - - assert!( - other_rx.try_recv().is_err(), - "an unrelated viewer receives nothing" - ); - let out = queued_job(&owner_rx); - assert_eq!(out.len(), 1, "one teleport row creates one writer result"); - match &out[0] { - Outbound::Batch(messages) => { - assert!(matches!( - messages.as_slice(), - [ - ServerOpcodeMessage::SMSG_TRANSFER_PENDING(_), - ServerOpcodeMessage::SMSG_NEW_WORLD(_) - ] - )); - } - _ => panic!("cross-map teleport must remain one ordered writer batch"), - } - assert!( - owner_rx.try_recv().is_err(), - "the owner receives exactly one job" - ); - } - - fn at_micros(micros: i64) -> spacetimedb_sdk::ScheduleAt { - spacetimedb_sdk::ScheduleAt::Time(spacetimedb_sdk::Timestamp::from_micros_since_unix_epoch( - micros, - )) - } - - /// The one `SMSG_RAID_GROUP_ONLY` timer a writer job produced. - fn raid_group_only_timer(out: &[Outbound]) -> u32 { - match out { - [Outbound::One(ServerOpcodeMessage::SMSG_RAID_GROUP_ONLY(packet))] => { - assert_eq!( - packet.error, - wow_world_messages::vanilla::RaidGroupError::Required - ); - packet.homebind_timer - } - _ => panic!("expected one SMSG_RAID_GROUP_ONLY"), - } - } - - #[test] - fn an_instance_removal_shows_and_hides_the_countdown_for_its_owner_only() { - let view = Arc::new(WorldView::new(true)); - let (owner_tx, owner_rx) = SessionTx::with_depth(0); - let (other_tx, other_rx) = SessionTx::with_depth(0); - let owner = viewer_with_tx(1, 9001, identity(1), owner_tx); - let other = viewer_with_tx(2, 9002, identity(2), other_tx); - let anchor = CellKey::at(36, 7, 0, 0); - view.add_viewer_on_shard(owner.clone(), anchor, 1); - view.add_viewer_on_shard(other, anchor, 1); - // Due in 2100: far enough ahead that the time left is always the full countdown. - let row = InstanceRemoval { - scheduled_id: 3, - scheduled_at: at_micros(4_102_444_800_000_000), - character_guid: owner.self_guid, - instance_id: 7, - group_id: 42, - }; - - instance_removal_started(&view, 0, &row); - assert!(owner_rx.try_recv().is_err(), "another Shard's row"); - instance_removal_started(&view, 1, &row); - assert_eq!(raid_group_only_timer(&queued_job(&owner_rx)), 60_000); - assert!(view.countdown_shown(owner.self_guid)); - instance_removal_ended(&view, 1, &row); - assert_eq!(raid_group_only_timer(&queued_job(&owner_rx)), 0); - assert!(!view.countdown_shown(owner.self_guid)); - assert!(owner_rx.try_recv().is_err(), "one job per row change"); - assert!( - other_rx.try_recv().is_err(), - "an unrelated viewer receives nothing" - ); - } - - /// A world-port drops the source viewer before the source Shard deletes the row, so the delete - /// reaches nobody. The countdown the client still shows is hidden at the next world entry. - #[test] - fn a_world_entry_hides_a_countdown_whose_row_is_gone() { - let view = Arc::new(WorldView::new(true)); - let (tx, rx) = SessionTx::with_depth(0); - let source = viewer_with_tx(1, 9001, identity(1), tx); - view.add_viewer_on_shard(source.clone(), CellKey::at(36, 7, 0, 0), 1); - let row = InstanceRemoval { - scheduled_id: 3, - scheduled_at: at_micros(4_102_444_800_000_000), - character_guid: source.self_guid, - instance_id: 7, - group_id: 42, - }; - instance_removal_started(&view, 1, &row); - queued_job(&rx); - view.remove_viewer(source.session); - instance_removal_ended(&view, 1, &row); - - let shown = view.countdown_shown(9001); - assert_eq!(resident_countdown_ms(None, shown, 0), Some(0)); - assert_eq!(resident_countdown_ms(None, false, 0), None); - assert_eq!( - resident_countdown_ms( - Some(&at_micros(1_900_000_000_000_000)), - false, - 1_899_999_955_000_000 - ), - Some(45_000) - ); - } - - /// The world-entry sweep and a replayed insert both show the time left, not a fresh 60 s. - #[test] - fn the_countdown_shows_the_time_left_rounded_up_and_nothing_once_due() { - let due = 1_900_000_000_000_000; - assert_eq!( - instance_removal_time_left_ms(&at_micros(due), 1_899_999_955_000_000), - Some(45_000) - ); - assert_eq!( - instance_removal_time_left_ms(&at_micros(due), 1_899_999_999_999_999), - Some(1) - ); - assert_eq!( - instance_removal_time_left_ms(&at_micros(due), 1_899_999_900_000_000), - Some(60_000), - "a Gateway clock behind the Module's shows no more than the full countdown" - ); - assert_eq!( - instance_removal_time_left_ms(&at_micros(due), 1_900_000_000_000_000), - None - ); - assert_eq!( - instance_removal_time_left_ms(&at_micros(due), 1_900_000_000_000_005), - None - ); - let interval = spacetimedb_sdk::ScheduleAt::Interval( - spacetimedb_sdk::TimeDuration::from_micros(1_000_000), - ); - assert_eq!( - instance_removal_time_left_ms(&interval, 1_899_999_955_000_000), - None - ); - } - - fn worn_item(slot: u8) -> ItemInstance { - ItemInstance { - guid: 700, - entry: 1337, - owner_identity: identity(1), - owner_guid: 9001, - slot, - stack_count: 1, - durability: 20, - created_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, - enchant_id: 7745, - soulbound: false, - random_property_id: 117, - item_text_id: 0, - } - } - - fn visible_items(out: Vec) -> Vec<(u8, u32, u32, u32)> { - use wow_world_messages::vanilla::{Object, ServerMessage, UpdateMask, VisibleItemIndex}; - let mut items = Vec::new(); - for message in out { - let Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(update)) = message else { - panic!("peers receive only visible item VALUES"); - }; - let [Object::Values { - guid1, - mask1: UpdateMask::Player(mask), - }] = update.objects.as_slice() - else { - panic!("peers receive no private item objects"); - }; - assert_eq!(guid1.guid(), 9001); - for slot in 0..=18 { - if let Some(item) = - mask.player_visible_item(VisibleItemIndex::try_from(slot).unwrap()) - { - assert_eq!(item.enchants[1], 0); - items.push((slot, item.item, item.random_property_id, item.enchants[0])); - } - } - let mut bytes = Vec::new(); - update.write_unencrypted_server(&mut bytes).unwrap(); - let decoded = lyracore_shared::values_mask::parse_values_updates(&bytes[4..]); - assert_eq!(decoded.len(), 1); - // Build 5875 exposes nineteen 12-word visible items starting at field 258. - assert!( - decoded[0] - .fields - .iter() - .all(|&(field, _)| (258..486).contains(&field)), - "no TYPE, inventory pointers, item fields or Character sheet fields" - ); - } - items - } - - #[test] - fn visible_gear_changes_reach_peers_with_current_slot_contents() { - let view = WorldView::new(true); - let (tx, rx) = SessionTx::with_depth(0); - let peer = viewer_with_tx(2, 9002, identity(2), tx); - peer.created.lock().unwrap().insert(9001); - let anchor = CellKey::at(0, 0, 0, 0); - view.add_viewer_on_shard(peer, anchor, 0); - view.spatial - .upsert_entity(EntityLayer::WorldEntity, 9001, anchor, 0, 0); - let committed = Arc::new(Mutex::new(Vec::::new())); - let read: super::ItemSlotRead = { - let committed = committed.clone(); - Arc::new(move |owner, slot| { - committed - .lock() - .unwrap() - .iter() - .find(|item| item.owner_guid == owner && item.slot == slot) - .map(crate::stdb::reads::view_of_item_row) - }) - }; - for slot in [0, 8, 18] { - let item = worn_item(slot); - *committed.lock().unwrap() = vec![item.clone()]; - item_visible_changed(&view, 0, None, Some(&item), read.clone()); - assert_eq!(visible_items(queued_job(&rx)), [(slot, 1337, 117, 823)]); - } - let item = worn_item(8); - - for changed in [ - ItemInstance { - enchant_id: 7748, - ..item.clone() - }, - ItemInstance { - enchant_id: 0, - ..item.clone() - }, - ItemInstance { - entry: 42, - ..item.clone() - }, - ItemInstance { - random_property_id: 118, - ..item.clone() - }, - ] { - *committed.lock().unwrap() = vec![changed.clone()]; - item_visible_changed(&view, 0, Some(&item), Some(&changed), read.clone()); - let enchant = match changed.enchant_id { - 7748 => 724, - 0 => 0, - _ => 823, - }; - assert_eq!( - visible_items(queued_job(&rx)), - [(8, changed.entry, changed.random_property_id, enchant)] - ); - } - - let bagged = ItemInstance { - slot: 23, - ..item.clone() - }; - *committed.lock().unwrap() = vec![bagged.clone()]; - item_visible_changed(&view, 0, Some(&item), Some(&bagged), read.clone()); - assert_eq!(visible_items(queued_job(&rx)), [(8, 0, 0, 0)]); - *committed.lock().unwrap() = vec![item.clone()]; - item_visible_changed(&view, 0, Some(&bagged), Some(&item), read.clone()); - assert_eq!(visible_items(queued_job(&rx)), [(8, 1337, 117, 823)]); - - committed.lock().unwrap().clear(); - item_visible_changed(&view, 0, Some(&item), None, read); - assert_eq!(visible_items(queued_job(&rx)), [(8, 0, 0, 0)]); - assert!(rx.try_recv().is_err()); - } - - #[test] - fn visible_gear_swap_and_delete_read_replacements_when_the_writer_runs() { - let view = WorldView::new(true); - let (tx, rx) = SessionTx::with_depth(0); - let peer = viewer_with_tx(2, 9002, identity(2), tx); - peer.created.lock().unwrap().insert(9001); - let anchor = CellKey::at(0, 0, 0, 0); - view.add_viewer_on_shard(peer, anchor, 0); - view.spatial - .upsert_entity(EntityLayer::WorldEntity, 9001, anchor, 0, 0); - let old = worn_item(8); - let moved = ItemInstance { - slot: 23, - ..old.clone() - }; - let committed = Arc::new(Mutex::new(vec![old.clone()])); - let read: super::ItemSlotRead = { - let committed = committed.clone(); - Arc::new(move |owner, slot| { - committed - .lock() - .unwrap() - .iter() - .find(|item| item.owner_guid == owner && item.slot == slot) - .map(crate::stdb::reads::view_of_item_row) - }) - }; - item_visible_changed(&view, 0, Some(&old), Some(&moved), read.clone()); - item_visible_changed(&view, 0, Some(&old), None, read); - *committed.lock().unwrap() = vec![ItemInstance { - guid: 701, - entry: 42, - enchant_id: 7748, - ..old - }]; - for _ in 0..2 { - assert_eq!(visible_items(queued_job(&rx)), [(8, 42, 117, 724)]); - } - assert!(rx.try_recv().is_err()); - } - - #[test] - fn visible_gear_is_scoped_to_peers_that_still_see_the_character() { - let view = WorldView::new(true); - let anchor = CellKey::at(0, 0, 0, 0); - view.spatial - .upsert_entity(EntityLayer::WorldEntity, 9001, anchor, 0, 0); - let item = worn_item(8); - let read: super::ItemSlotRead = - Arc::new(|_, _| Some(crate::stdb::reads::view_of_item_row(&worn_item(8)))); - let mut queues = Vec::new(); - for (session, guid, cell, shard) in [ - (1, 9001, anchor, 0), - (2, 9002, anchor, 0), - (3, 9003, CellKey::at(0, 0, 10, 0), 0), - (4, 9004, CellKey::at(0, 1, 0, 0), 0), - (5, 9005, CellKey::at(1, 0, 0, 0), 0), - (6, 9006, anchor, 1), - ] { - let (tx, rx) = SessionTx::with_depth(0); - let viewer = viewer_with_tx(session, guid, identity(session as u8), tx); - viewer.created.lock().unwrap().insert(9001); - view.add_viewer_on_shard(viewer.clone(), cell, shard); - queues.push((viewer, rx)); - } - let (peer, rx) = &queues[1]; - item_visible_changed(&view, 0, None, Some(&item), read.clone()); - peer.created.lock().unwrap().remove(&9001); - assert!(queued_job(rx).is_empty(), "hidden before the writer runs"); - item_visible_changed(&view, 0, None, Some(&item), read.clone()); - peer.created.lock().unwrap().insert(9001); - assert_eq!( - visible_items(queued_job(rx)), - [(8, 1337, 117, 823)], - "CREATE ran before the gear job" - ); - item_visible_changed(&view, 0, None, Some(&item), read.clone()); - view.remove_viewer(peer.session); - assert!(queued_job(rx).is_empty(), "departed World Session"); - for (_, rx) in &queues { - assert!( - rx.try_recv().is_err(), - "owner, distant, other map/instance/Shard receive no peer job" - ); - } - view.spatial - .upsert_entity(EntityLayer::WorldEntity, 9001, anchor, 1, 0); - item_visible_changed(&view, 0, None, Some(&item), read); - for (_, rx) in &queues { - assert!( - rx.try_recv().is_err(), - "source Shard cannot relay after Transfer" - ); - } - } - - #[test] - fn visible_gear_ignores_bagged_items_and_changes_to_private_fields() { - let view = WorldView::new(true); - let anchor = CellKey::at(0, 0, 0, 0); - let (tx, rx) = SessionTx::with_depth(0); - view.add_viewer_on_shard(viewer_with_tx(2, 9002, identity(2), tx), anchor, 0); - view.spatial - .upsert_entity(EntityLayer::WorldEntity, 9001, anchor, 0, 0); - let read: super::ItemSlotRead = Arc::new(|_, _| panic!("no gear read needed")); - for slot in [19, 23, 120] { - let bagged = worn_item(slot); - item_visible_changed(&view, 0, None, Some(&bagged), read.clone()); - item_visible_changed(&view, 0, Some(&bagged), None, read.clone()); - item_visible_changed( - &view, - 0, - Some(&bagged), - Some(&ItemInstance { - enchant_id: 0, - ..bagged.clone() - }), - read.clone(), - ); - } - let old = worn_item(8); - let new = ItemInstance { - durability: 0, - stack_count: 3, - soulbound: true, - ..old.clone() - }; - item_visible_changed(&view, 0, Some(&old), Some(&new), read); - assert!(rx.try_recv().is_err()); - } - - #[test] - fn item_rows_route_only_to_the_owner_and_follow_transfer_replacement() { - let view = WorldView::new(true); - let (source_tx, source_rx) = SessionTx::with_depth(0); - let (other_tx, other_rx) = SessionTx::with_depth(0); - let (destination_tx, destination_rx) = SessionTx::with_depth(0); - let source = viewer_with_tx(1, 9001, identity(1), source_tx); - let other = viewer_with_tx(2, 9002, identity(2), other_tx); - let destination = viewer_with_tx(3, 9001, identity(1), destination_tx); - let anchor = CellKey::at(0, 0, 0, 0); - view.add_viewer_on_shard(source.clone(), anchor, 0); - view.add_viewer_on_shard(other, anchor, 0); - - item_owner_job(&view, 0, source.self_guid, |_| { - vec![Outbound::Raw { - opcode: 1, - body: vec![1], - }] - }); - assert!(matches!( - queued_job(&source_rx).as_slice(), - [Outbound::Raw { .. }] - )); - assert!( - other_rx.try_recv().is_err(), - "an unrelated item owner receives nothing" - ); - - view.remove_viewer(source.session); - item_owner_job(&view, 0, source.self_guid, |_| { - vec![Outbound::Raw { - opcode: 2, - body: vec![2], - }] - }); - assert!( - source_rx.try_recv().is_err(), - "a removed source viewer receives no cascade delete" - ); - - view.add_viewer_on_shard(destination, anchor, 1); - item_owner_job(&view, 0, source.self_guid, |_| { - vec![Outbound::Raw { - opcode: 2, - body: vec![2], - }] - }); - assert!( - destination_rx.try_recv().is_err(), - "a source-shard delta applied after destination registration must be dropped" - ); - item_owner_job(&view, 1, source.self_guid, |_| { - vec![Outbound::Raw { - opcode: 3, - body: vec![3], - }] - }); - assert!(matches!( - queued_job(&destination_rx).as_slice(), - [Outbound::Raw { opcode: 3, .. }] - )); + view.remove_viewer(replacement.session); + assert!(view + .viewer_of_owner(OwnerGuid(replacement.self_guid)) + .is_none()); + assert!(view + .viewer_of_identity(BoundIdentity(replacement.bound_identity)) + .is_none()); + assert!(view.viewers_on_shard(4).is_empty()); } #[test] @@ -2869,82 +1060,6 @@ mod family_audience_tests { } } - #[test] - fn exploration_replays_fog_before_discovery_and_reconnects_stay_quiet() { - let viewer = viewer(1, 9001); - viewer.explored.lock().unwrap().seed([40]); - - let resident = explored(40, 87, 120); - let resident_out = exploration_outbound_for_word(&viewer, &resident, 1, 1 << 8); - let (fog_opcode, fog_body) = crate::codec::build_explored_zones_values(9001, 1, 1 << 8); - match resident_out.as_slice() { - [Outbound::Raw { opcode, body }] => { - assert_eq!((*opcode, body), (fog_opcode, &fog_body)); - } - _ => panic!("a seeded area restores fog without discovery feedback"), - } - - let fresh = explored(41, 88, 121); - let fresh_out = exploration_outbound_for_word(&viewer, &fresh, 1, (1 << 8) | (1 << 9)); - let (new_fog_opcode, new_fog_body) = - crate::codec::build_explored_zones_values(9001, 1, (1 << 8) | (1 << 9)); - let (discovery_opcode, discovery_body) = - crate::codec::build_exploration_experience_raw(88, 121); - match fresh_out.as_slice() { - [Outbound::Raw { - opcode: fog_opcode, - body: fog_body, - }, Outbound::Raw { - opcode: toast_opcode, - body: toast_body, - }] => { - assert_eq!((*fog_opcode, fog_body), (new_fog_opcode, &new_fog_body)); - assert_eq!( - (*toast_opcode, toast_body), - (discovery_opcode, &discovery_body) - ); - } - _ => panic!("fog VALUES must precede discovery feedback for a new area"), - } - } - - #[test] - fn reputation_insert_and_update_route_only_to_the_owner_by_reputation_index() { - let view = WorldView::new(true); - let (owner_tx, owner_rx) = SessionTx::with_depth(0); - let (other_tx, other_rx) = SessionTx::with_depth(0); - let owner = viewer_with_tx(1, 9001, identity(1), owner_tx); - let other = viewer_with_tx(2, 9002, identity(2), other_tx); - let anchor = CellKey::at(0, 0, 0, 0); - view.add_viewer_on_shard(owner, anchor, 0); - view.add_viewer_on_shard(other, anchor, 0); - - reputation_appeared(&view, 0, &reputation(9001, 19, 3175)); - reputation_appeared(&view, 0, &reputation(9001, 19, 3200)); - - assert!( - other_rx.try_recv().is_err(), - "an unrelated owner receives nothing" - ); - for standing in [3175, 3200] { - let (opcode, body) = crate::codec::build_set_faction_standing_raw(19, standing) - .expect("a valid reputation index has a packet"); - match queued_job(&owner_rx).as_slice() { - [Outbound::Raw { - opcode: actual_opcode, - body: actual_body, - }] => { - assert_eq!((*actual_opcode, actual_body), (opcode, &body)); - } - _ => panic!("the reputation row must use its reputation_index, not faction_id"), - } - } - assert!( - owner_rx.try_recv().is_err(), - "each reputation row queues exactly one job" - ); - } - /// The reconnect contract, behaviorally: a re-arm builds a fresh callback generation over the /// same shared view (each generation is a `view.clone()` capture, exactly what `wire_insert` /// takes), and the viewer registered before the reconnect still receives owner relays through @@ -3095,208 +1210,57 @@ mod family_audience_tests { assert!(queued_job(&rx).is_empty(), "there are no resident rows"); assert!(rx.try_recv().is_err(), "one sweep must be one writer job"); } -} - -#[cfg(test)] -mod realm_chat_relay_tests { - use super::{ - channel_notice_appeared, contact_changed, realm_chat_appeared, ExplorationReplay, - MotionPending, Viewer, WorldView, - }; - use crate::stdb::aoi::ViewerGates; - use crate::stdb::bindings::{ChatChannelNoticeEvent, ContactEntry, RealmChatEvent}; - use crate::stdb::world_index::CellKey; - use crate::world::{Outbound, SessionTx}; - use std::collections::{HashMap, HashSet}; - use std::sync::mpsc::Receiver; - use std::sync::{Arc, Mutex}; - - const SPEAKER: u64 = 10; - - /// A registered viewer for `self_guid` on `shard`, with the receiving end of its writer queue. - fn listener( - view: &WorldView, - shard: usize, - self_guid: u64, - ) -> (Arc, Receiver) { - let (tx, rx) = SessionTx::with_depth(0); - let viewer = Arc::new(Viewer { - active: std::sync::atomic::AtomicBool::new(true), - session: view.next_session_id(), - self_guid, - bound_identity: spacetimedb_sdk::Identity::from_byte_array([self_guid as u8; 32]), - map_id: 0, - instance_id: 0, - zone_id: 0.into(), - tx, - created: Arc::new(Mutex::new(HashSet::new())), - gates: Arc::new(ViewerGates::default()), - skill_slots: Arc::new(Mutex::new((HashMap::new(), 0))), - explored: Mutex::new(ExplorationReplay::default()), - motion_pending: Arc::new(MotionPending::default()), - member_stats: Default::default(), - ignored: Mutex::default(), - friends: Mutex::default(), - team: lyracore_shared::faction::TEAM_ALLIANCE, - group_events: Default::default(), - }); - view.add_viewer_on_shard(viewer.clone(), CellKey::at(0, 0, 0, 0), shard); - (viewer, rx) - } - - fn party_line(recipients: &[u64], ignorable: bool) -> RealmChatEvent { - RealmChatEvent { - id: 1, - kind: 1, - speaker_guid: SPEAKER, - language: 7, - chat_tag: 0, - channel_name: String::new(), - message: "form up".to_string(), - recipients: recipients.to_vec(), - ignorable, - created_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, - } - } - - fn ignore_row(owner_guid: u64, target_guid: u64, is_ignore: bool) -> ContactEntry { - ContactEntry { - id: 1, - owner_guid, - owner_identity: spacetimedb_sdk::Identity::ZERO, - target_guid, - is_ignore, - } - } - - /// Every queued packet for one viewer, jobs run in order. - /// The body of every packet queued for one viewer, jobs run in order. Each packet must be one - /// framed `SMSG_MESSAGECHAT`: size u16 big-endian, then opcode 0x0096 little-endian. - fn received(rx: &Receiver) -> Vec> { - let mut bodies = Vec::new(); - while let Ok(outbound) = rx.try_recv() { - let Outbound::Job(job) = outbound else { - panic!("the Relay must enqueue packet work as a writer job"); - }; - for packet in job() { - let Outbound::One(message) = packet else { - panic!("a Realm Chat Line is one packet"); - }; - let mut frame = Vec::new(); - message.write_unencrypted_server(&mut frame).unwrap(); - let body = frame.split_off(4); - assert_eq!(frame[..2], ((body.len() + 2) as u16).to_be_bytes()); - assert_eq!(frame[2..], [0x96, 0x00], "SMSG_MESSAGECHAT"); - bodies.push(body); - } - } - bodies - } - - /// A PARTY line in Common from `SPEAKER` saying "form up", written out from cm:Chat.cpp:3637-3641 - /// rather than built with the codec the Relay calls: type, language, the sender guid twice, the - /// sized message with its terminator, then the chat tag. - fn expected_line() -> Vec { - let mut body = vec![0x01]; // CHAT_MSG_PARTY - body.extend(7u32.to_le_bytes()); // LANG_COMMON - body.extend(SPEAKER.to_le_bytes()); - body.extend(SPEAKER.to_le_bytes()); - body.extend(8u32.to_le_bytes()); // "form up" and its terminator - body.extend(b"form up\0"); - body.push(0); // CHAT_TAG_NONE - body - } - + /// **The other half of the fix:** the online mark is one-shot and starts unset, so a claim + /// that closes for a Character this Gateway never watched come online — a login that fails + /// after `claim_session` succeeds, before `enter_world`/`player_login` ever runs — has nothing + /// to consume. No Fake reaches a Coordinator-backed `account_claim_changed` call to prove the + /// end-to-end "no notice at all" path for a failed login directly. #[test] - fn a_line_reaches_each_recipient_once_on_any_shard_and_nobody_else() { + fn take_character_online_answers_false_for_a_login_it_never_observed() { let view = WorldView::new(true); - let (_, speaker_rx) = listener(&view, 0, SPEAKER); - let (_, member_rx) = listener(&view, 1, 20); - let (_, bystander_rx) = listener(&view, 0, 30); - - realm_chat_appeared(&view, &party_line(&[SPEAKER, 20, 99], false)); - - assert_eq!(received(&speaker_rx), [expected_line()]); - assert_eq!(received(&member_rx), [expected_line()]); assert!( - received(&bystander_rx).is_empty(), - "a Character the Module did not name hears nothing" + !view.take_character_online(10), + "nothing marked this guid online, so there is nothing to consume" ); } - #[test] - fn an_ignorable_line_skips_only_the_listener_who_ignores_the_speaker() { + fn quest_owner_routing_follows_a_transfer_to_its_destination_viewer() { let view = WorldView::new(true); - let (_, ignorer_rx) = listener(&view, 0, 20); - let (_, other_rx) = listener(&view, 1, 30); - contact_changed(&view, 0, &ignore_row(20, SPEAKER, true), true); - - realm_chat_appeared(&view, &party_line(&[20, 30], true)); - assert!(received(&ignorer_rx).is_empty()); - assert_eq!(received(&other_rx), [expected_line()]); + let source = viewer(1, 9001); + let destination = viewer(2, 9001); + let anchor = CellKey::at(0, 0, 0, 0); + view.add_viewer_on_shard(source.clone(), anchor, 0); + view.remove_viewer(source.session); + view.add_viewer_on_shard(destination.clone(), anchor, 1); - realm_chat_appeared(&view, &party_line(&[20, 30], false)); assert_eq!( - received(&ignorer_rx), - [expected_line()], - "a line the Module marked not ignorable reaches an ignorer too" + view.viewer_of_owner(OwnerGuid(9001)) + .map(|viewer| viewer.session), + Some(destination.session), + "a later quest row must route only to the destination viewer" ); - assert_eq!(received(&other_rx), [expected_line()]); - } - - /// Every raw packet queued for one viewer, jobs run in order, as `(opcode, body)`. - fn received_raw(rx: &Receiver) -> Vec<(u16, Vec)> { - let mut packets = Vec::new(); - while let Ok(outbound) = rx.try_recv() { - let Outbound::Job(job) = outbound else { - panic!("the Relay must enqueue packet work as a writer job"); - }; - for packet in job() { - let Outbound::Raw { opcode, body } = packet else { - panic!("a Channel Notice is one raw packet"); - }; - packets.push((opcode, body)); - } - } - packets - } - - /// JOINED naming `SPEAKER` in "Tx": notice 0x00, the name, then the guid (cm:Channel.cpp:760-764). - #[test] - fn a_channel_notice_reaches_each_recipient_on_any_shard_and_nobody_else() { - let view = WorldView::new(true); - let (_, first_rx) = listener(&view, 0, 20); - let (_, second_rx) = listener(&view, 1, 30); - let (_, bystander_rx) = listener(&view, 0, 40); - contact_changed(&view, 0, &ignore_row(20, SPEAKER, true), true); - - channel_notice_appeared( - &view, - &ChatChannelNoticeEvent { - id: 1, - notice: 0x00, - channel_name: "Tx".to_string(), - subject_guid: SPEAKER, - actor_guid: 0, - old_flags: 0, - new_flags: 0, - channel_flags: 0, - text: String::new(), - recipients: vec![20, 30, 99], - created_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, - }, + assert!( + view.viewer_of_owner_on_shard(0, OwnerGuid(9001)).is_none(), + "a source-shard cascade delta applied after the transfer must find no viewer" ); - - let mut joined = vec![0x00, b'T', b'x', 0]; - joined.extend(SPEAKER.to_le_bytes()); assert_eq!( - received_raw(&first_rx), - [(0x0099, joined.clone())], - "notices skip the ignore list" + view.viewer_of_owner_on_shard(1, OwnerGuid(9001)) + .map(|viewer| viewer.session), + Some(destination.session) ); - assert_eq!(received_raw(&second_rx), [(0x0099, joined)]); - assert!(received_raw(&bystander_rx).is_empty()); } +} + +#[cfg(test)] +pub(crate) mod test_support; + +#[cfg(test)] +mod reconciliation_tests { + use super::test_support::{contact_entry as ignore_row, registered_viewer as listener}; + use super::*; + use crate::codec::social::subscriptions::contact_changed; + + const SPEAKER: u64 = 10; /// A resubscribe replays inserts but not the deletes it missed. Reconciling the Shard must /// rebuild each of its viewers' ignore sets from the rows that are there now. @@ -3340,50 +1304,6 @@ mod realm_chat_relay_tests { ); } - #[test] - fn the_ignore_set_follows_its_owners_ignore_rows_on_the_owners_shard() { - let view = WorldView::new(true); - let (viewer, _rx) = listener(&view, 0, 20); - - contact_changed(&view, 0, &ignore_row(20, SPEAKER, false), true); - assert!(!viewer.ignores(SPEAKER), "a friend row is not an ignore"); - - contact_changed(&view, 1, &ignore_row(20, SPEAKER, true), true); - assert!( - !viewer.ignores(SPEAKER), - "another Shard's copy of the owner's rows does not address this viewer" - ); - - contact_changed(&view, 0, &ignore_row(20, SPEAKER, true), true); - assert!(viewer.ignores(SPEAKER)); - - contact_changed(&view, 0, &ignore_row(20, SPEAKER, true), false); - assert!(!viewer.ignores(SPEAKER)); - } - - /// The friend set follows the same rule as the ignore set, off the SAME contact rows and - /// callbacks, filtered by `is_ignore` the other way. - #[test] - fn the_friend_set_follows_its_owners_friend_rows_on_the_owners_shard() { - let view = WorldView::new(true); - let (viewer, _rx) = listener(&view, 0, 20); - - contact_changed(&view, 0, &ignore_row(20, SPEAKER, true), true); - assert!(!viewer.is_friend(SPEAKER), "an ignore row is not a friend"); - - contact_changed(&view, 1, &ignore_row(20, SPEAKER, false), true); - assert!( - !viewer.is_friend(SPEAKER), - "another Shard's copy of the owner's rows does not address this viewer" - ); - - contact_changed(&view, 0, &ignore_row(20, SPEAKER, false), true); - assert!(viewer.is_friend(SPEAKER)); - - contact_changed(&view, 0, &ignore_row(20, SPEAKER, false), false); - assert!(!viewer.is_friend(SPEAKER)); - } - /// A resubscribe rebuilds the friend set the same way it rebuilds the ignore set. #[test] fn reconciling_a_shard_rebuilds_its_viewers_friend_sets_from_the_contact_rows() { @@ -3405,303 +1325,6 @@ mod realm_chat_relay_tests { } } -#[cfg(test)] -mod account_claim_relay_tests { - use super::{ - character_online_changed, claim_closed, friend_notice_candidates, AccountClaim, Character, - ExplorationReplay, MotionPending, Viewer, WorldView, - }; - use crate::stdb::aoi::ViewerGates; - use crate::stdb::world_index::CellKey; - use crate::world::{Outbound, SessionTx}; - use std::collections::{HashMap, HashSet}; - use std::sync::mpsc::Receiver; - use std::sync::{Arc, Mutex}; - - const ALLIANCE: u32 = lyracore_shared::faction::TEAM_ALLIANCE; - const HORDE: u32 = lyracore_shared::faction::TEAM_HORDE; - const FRIEND: u64 = 10; - - /// A registered viewer for `self_guid` on `shard`, already listing [`FRIEND`]. - fn listener( - view: &WorldView, - shard: usize, - self_guid: u64, - team: u32, - ) -> (Arc, Receiver) { - let (tx, rx) = SessionTx::with_depth(0); - let viewer = Arc::new(Viewer { - active: std::sync::atomic::AtomicBool::new(true), - session: view.next_session_id(), - self_guid, - bound_identity: spacetimedb_sdk::Identity::from_byte_array([self_guid as u8; 32]), - map_id: 0, - instance_id: 0, - zone_id: 0.into(), - tx, - created: Arc::new(Mutex::new(HashSet::new())), - gates: Arc::new(ViewerGates::default()), - skill_slots: Arc::new(Mutex::new((HashMap::new(), 0))), - explored: Mutex::new(ExplorationReplay::default()), - motion_pending: Arc::new(MotionPending::default()), - member_stats: Default::default(), - ignored: Mutex::default(), - friends: Mutex::new(HashSet::from([FRIEND])), - team, - group_events: Default::default(), - }); - view.add_viewer_on_shard(viewer.clone(), CellKey::at(0, 0, 0, 0), shard); - (viewer, rx) - } - - fn claim(generation: u64, character_guid: u64, closed: bool) -> AccountClaim { - AccountClaim { - account_id: 1, - generation, - request_nonce: 1, - character_guid, - expires_micros: 1_000_000, - closed, - } - } - - /// A `game_character` row with only the columns this relay reads set to something other than - /// zero, the rest at whatever a fresh row would have. `session_start_micros` is `player_login`'s - /// own stamp — 0 outside a session, a real timestamp inside one. - fn character_row( - guid: u64, - race: u8, - class: u8, - level: u8, - zone_id: u32, - online: bool, - session_start_micros: u64, - ) -> Character { - Character { - guid, - account_id: 0, - owner_identity: spacetimedb_sdk::Identity::ZERO, - name: String::new(), - race, - class, - gender: 0, - skin: 0, - face: 0, - hair_style: 0, - hair_color: 0, - facial_hair: 0, - level, - xp: 0, - next_level_xp: 0, - map_id: 0, - zone_id, - x: 0.0, - y: 0.0, - z: 0.0, - orientation: 0.0, - first_login: false, - online, - money: 0, - rested_xp: 0, - last_logout_micros: 0, - home_map: 0, - home_zone: 0, - home_x: 0.0, - home_y: 0.0, - home_z: 0.0, - played_total_secs: 0, - session_start_micros, - health: 0, - power: 0, - respec_count: 0, - death_expire_micros: 0, - pending_instance_id: 0, - gm_level: 0, - pending_ghost: false, - resting: false, - rested_since_micros: 0, - pending_godmode: false, - pending_run_speed_mult_bp: 0, - bank_bag_slots: 0, - watched_faction_index: -1, - } - } - - /// One case per bullet in `claim_closed`'s doc comment, rows written by hand. - #[test] - fn claim_closed_covers_every_bullet() { - // Inserted open (the account's first-ever claim): not a close. - assert_eq!(claim_closed(None, &claim(1, FRIEND, false)), None); - - // A renewal — only `expires_micros` moves: not a close. - let old = claim(1, FRIEND, false); - let mut renewed = old.clone(); - renewed.expires_micros += 1; - assert_eq!(claim_closed(Some(&old), &renewed), None); - - // Closed (logout): the Character stops being live. - let old = claim(1, FRIEND, false); - let closed = claim(1, FRIEND, true); - assert_eq!(claim_closed(Some(&old), &closed), Some(FRIEND)); - - // Reopened after being closed (a fresh login, a new generation): not a close. - let old = claim(1, FRIEND, true); - let reopened = claim(2, FRIEND, false); - assert_eq!(claim_closed(Some(&old), &reopened), None); - - // Replaced while live, by a DIFFERENT Character (a relogin as someone else on the same - // Account without an intervening close): the OLD Character stops being live. - let old = claim(1, FRIEND, false); - let replaced = claim(2, 99, false); - assert_eq!(claim_closed(Some(&old), &replaced), Some(FRIEND)); - - // Reaped while already closed (the claim reaper closes an already-closed row, or closes it - // twice across a race): not a (new) close. - let old = claim(1, FRIEND, true); - let still_closed = claim(1, FRIEND, true); - assert_eq!(claim_closed(Some(&old), &still_closed), None); - } - - /// `friend_notice_candidates` collects every friend-listing viewer on any Shard, and never the - /// subject's own viewer, whatever it lists. - #[test] - fn friend_notice_candidates_lists_every_friend_listing_viewer_but_never_the_subject() { - let view = WorldView::new(true); - let (friend, _friend_rx) = listener(&view, 0, 20, ALLIANCE); - let (elsewhere, _elsewhere_rx) = listener(&view, 1, 21, ALLIANCE); - let (other_team, _other_team_rx) = listener(&view, 0, 22, HORDE); - let (non_friend, _non_friend_rx) = listener(&view, 0, 23, ALLIANCE); - non_friend.friends.lock().unwrap().clear(); - let (subject, _subject_rx) = listener(&view, 0, FRIEND, ALLIANCE); - subject.friends.lock().unwrap().insert(FRIEND); // hypothetically lists itself - - let mut candidates: Vec = friend_notice_candidates(&view, FRIEND) - .iter() - .map(|viewer| viewer.self_guid) - .collect(); - candidates.sort_unstable(); - - assert_eq!( - candidates, - [friend.self_guid, elsewhere.self_guid, other_team.self_guid], - "every friend-listing viewer on any Shard is a candidate, team included (each caller \ - checks it its own way), but never a non-friend and never the subject's own viewer" - ); - } - - #[test] - fn character_online_changed_reaches_a_same_team_friend_with_the_just_committed_row() { - let view = WorldView::new(true); - let (_friend, friend_rx) = listener(&view, 0, 20, ALLIANCE); - let (_other_team, other_team_rx) = listener(&view, 0, 21, HORDE); - let (non_friend, non_friend_rx) = listener(&view, 0, 22, ALLIANCE); - non_friend.friends.lock().unwrap().clear(); - - // Last logged out at level 21 in zone 12; this login already leveled up and landed the - // Character in zone 33 — the row's OWN fields, not a stale earlier read. - let old = character_row(FRIEND, 1, 4, 21, 12, false, 0); - let new = character_row(FRIEND, 1, 4, 22, 33, true, 500_000); - - character_online_changed(&view, &old, &new); - - match friend_rx - .try_recv() - .expect("a same-team friend must hear it") - { - Outbound::Job(job) => match job().as_slice() { - [Outbound::Raw { opcode, body }] => { - assert_eq!(*opcode, crate::codec::social::SMSG_FRIEND_STATUS_OPCODE); - assert_eq!(body[0], 0x02, "ONLINE"); - assert_eq!(body[1..9], FRIEND.to_le_bytes()); - assert_eq!( - body[9], 1, - "status ONLINE — Away Status always clears at login" - ); - assert_eq!(body[10..14], 33u32.to_le_bytes(), "the POST-login zone"); - assert_eq!(body[14..18], 22u32.to_le_bytes(), "the POST-login level"); - assert_eq!(body[18..22], 4u32.to_le_bytes(), "class"); - } - _ => panic!("expected one Raw packet"), - }, - _ => panic!("expected a job"), - } - assert!( - other_team_rx.try_recv().is_err(), - "a viewer on the other team hears nothing" - ); - assert!( - non_friend_rx.try_recv().is_err(), - "a same-team viewer who does not list the guid as a friend hears nothing" - ); - assert!( - view.take_character_online(FRIEND), - "the login must be marked so the later claim close can find it" - ); - } - - /// An UPDATE that is not a login — an already-online Character's level-up, a logout, or a - /// same-database transfer's freeze/rebuild with no real login in between — fires nothing and - /// marks nothing. - #[test] - fn character_online_changed_ignores_every_update_that_is_not_a_login() { - let view = WorldView::new(true); - let (_friend, friend_rx) = listener(&view, 0, 20, ALLIANCE); - - let old = character_row(FRIEND, 1, 4, 21, 33, true, 500_000); - let new = character_row(FRIEND, 1, 4, 22, 33, true, 500_000); // a level-up, same session - character_online_changed(&view, &old, &new); - assert!(friend_rx.try_recv().is_err()); - assert!(!view.take_character_online(FRIEND)); - - let old = character_row(FRIEND, 1, 4, 22, 33, true, 500_000); - let new = character_row(FRIEND, 1, 4, 22, 33, false, 0); // a real logout - character_online_changed(&view, &old, &new); - assert!(friend_rx.try_recv().is_err()); - - // A same-database transfer's freeze zeroes session_start_micros without touching `online` - // (`freeze_live_entity` calls `persist_entity` with `set_offline: false`) — no login - // happened, so this must not fire either, even though `online` reads true on both sides. - let old = character_row(FRIEND, 1, 4, 22, 33, true, 500_000); - let new = character_row(FRIEND, 1, 4, 22, 33, true, 0); // transfer freeze, still online - character_online_changed(&view, &old, &new); - assert!(friend_rx.try_recv().is_err()); - assert!(!view.take_character_online(FRIEND)); - } - - #[test] - fn character_online_changed_fires_for_a_real_login_even_when_online_was_stuck_true() { - let view = WorldView::new(true); - let (_friend, friend_rx) = listener(&view, 0, 20, ALLIANCE); - - let old = character_row(FRIEND, 1, 4, 20, 12, true, 0); - let new = character_row(FRIEND, 1, 4, 20, 12, true, 700_000); - character_online_changed(&view, &old, &new); - - assert!( - friend_rx.try_recv().is_ok(), - "the real login must be heard even though `online` never flipped" - ); - assert!( - view.take_character_online(FRIEND), - "the login must be marked so the later claim close can find it" - ); - } - - /// **The other half of the fix:** the online mark is one-shot and starts unset, so a claim - /// that closes for a Character this Gateway never watched come online — a login that fails - /// after `claim_session` succeeds, before `enter_world`/`player_login` ever runs — has nothing - /// to consume. No Fake reaches a Coordinator-backed `account_claim_changed` call to prove the - /// end-to-end "no notice at all" path for a failed login directly. - #[test] - fn take_character_online_answers_false_for_a_login_it_never_observed() { - let view = WorldView::new(true); - assert!( - !view.take_character_online(FRIEND), - "nothing marked this guid online, so there is nothing to consume" - ); - } -} - #[cfg(test)] mod group_event_order_tests { use super::{GroupEvent, GroupEventCursor, GroupEventSnapshot, GroupEventSource}; diff --git a/gateway/src/stdb/world_view/cell_audience_tests.rs b/gateway/src/stdb/world_view/cell_audience_tests.rs index 783c92c5..130356fc 100644 --- a/gateway/src/stdb/world_view/cell_audience_tests.rs +++ b/gateway/src/stdb/world_view/cell_audience_tests.rs @@ -1,10 +1,24 @@ //! The cell-anchored relays against the whole-shard-then-gate path they replaced. use super::*; +use crate::codec::breath::subscriptions::breath_relay_appeared; +use crate::codec::combat::subscriptions::{ + aura_audience, cast_audience, cast_event_appeared, combat_audience, combat_event_appeared, + impact_appeared, impact_audience, melee_audience, melee_disengaged, melee_engaged, +}; +use crate::codec::corpse::subscriptions::resurrect_offered; +use crate::codec::entity::subscriptions::entity_vanished; +use crate::codec::gameobject::subscriptions::{gameobject_appeared, gameobject_vanished}; +use crate::codec::item::subscriptions::trade_event_appeared; +use crate::codec::leveling::subscriptions::rest_state_appeared; +use crate::codec::movement::subscriptions::{creature_leg, motion}; +use crate::codec::social::subscriptions::{chat_audience, emote_audience}; +use crate::codec::taxi::subscriptions::taxi_spline; +use crate::codec::trainer::subscriptions::skill_changed; + use crate::codec::combat::subscriptions::A_STEALTH; use crate::codec::property_tests::Rng; -use crate::codec::social::subscriptions::chat_in_range; -use crate::codec::social::subscriptions::chat_range_yd; +use crate::codec::social::subscriptions::{chat_in_range, chat_range_yd}; use crate::stdb::bindings::{ Aura, ChatEvent, CombatEvent, EmoteEvent, MeleeAttack, SpellCastEvent, SpellImpactEvent, }; diff --git a/gateway/src/stdb/world_view/item_durable_tests.rs b/gateway/src/stdb/world_view/item_durable_tests.rs index 309434bc..604f3479 100644 --- a/gateway/src/stdb/world_view/item_durable_tests.rs +++ b/gateway/src/stdb/world_view/item_durable_tests.rs @@ -3,6 +3,7 @@ use crate::accept::BlockingTaskCapacity; use crate::config::GatewayConfig; use crate::durable_test_support::{poll_until, Standalone, POLL_TIMEOUT}; use std::sync::mpsc::Receiver; +use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; use wow_world_messages::vanilla::{Object, ServerMessage, UpdateMask, VisibleItemIndex}; struct TopologyEnv { diff --git a/gateway/src/stdb/world_view/relay_bench.rs b/gateway/src/stdb/world_view/relay_bench.rs index c4a80e13..7ea9a086 100644 --- a/gateway/src/stdb/world_view/relay_bench.rs +++ b/gateway/src/stdb/world_view/relay_bench.rs @@ -7,6 +7,11 @@ //! not a check. use super::*; +use crate::codec::combat::subscriptions::{ + aura_audience, cast_event_appeared, combat_event_appeared, impact_appeared, +}; +use crate::codec::social::subscriptions::chat_audience; + use crate::codec::property_tests::Rng; use crate::stdb::bindings::{Aura, ChatEvent, CombatEvent, SpellCastEvent, SpellImpactEvent}; use crate::world::{Outbound, SessionTx}; diff --git a/gateway/src/stdb/world_view/test_support.rs b/gateway/src/stdb/world_view/test_support.rs new file mode 100644 index 00000000..249800e5 --- /dev/null +++ b/gateway/src/stdb/world_view/test_support.rs @@ -0,0 +1,103 @@ +use super::*; +use crate::stdb::aoi::ViewerGates; +use crate::world::{Outbound, SessionTx}; +use std::collections::{HashMap, HashSet}; +use std::sync::mpsc::Receiver; +use std::sync::{Arc, Mutex}; + +pub(crate) fn identity(byte: u8) -> spacetimedb_sdk::Identity { + spacetimedb_sdk::Identity::from_byte_array([byte; 32]) +} + +pub(crate) fn viewer(session: u64, self_guid: u64) -> Arc { + let (tx, _rx) = SessionTx::with_depth(0); + viewer_with_tx(session, self_guid, identity(session as u8), tx) +} + +pub(crate) fn viewer_with_tx( + session: u64, + self_guid: u64, + bound_identity: spacetimedb_sdk::Identity, + tx: SessionTx, +) -> Arc { + viewer_in_zone(session, self_guid, bound_identity, 0, tx) +} + +/// A viewer already standing in `zone_id` for weather Relay tests. +pub(crate) fn viewer_in_zone( + session: u64, + self_guid: u64, + bound_identity: spacetimedb_sdk::Identity, + zone_id: u32, + tx: SessionTx, +) -> Arc { + Arc::new(Viewer { + active: std::sync::atomic::AtomicBool::new(true), + session, + self_guid, + bound_identity, + map_id: 0, + instance_id: 0, + zone_id: zone_id.into(), + tx, + created: Arc::new(Mutex::new(HashSet::new())), + gates: Arc::new(ViewerGates::default()), + skill_slots: Arc::new(Mutex::new((HashMap::new(), 0))), + explored: Mutex::new(ExplorationReplay::default()), + motion_pending: Arc::new(MotionPending::default()), + member_stats: Default::default(), + ignored: Mutex::default(), + friends: Mutex::default(), + team: lyracore_shared::faction::TEAM_ALLIANCE, + group_events: Default::default(), + }) +} + +pub(crate) fn queued_job(rx: &Receiver) -> Vec { + match rx.try_recv().expect("one addressed writer job") { + Outbound::Job(job) => job(), + _ => panic!("shared owner dispatch must enqueue packet work as a writer job"), + } +} + +pub(crate) fn teleport(mover_guid: u64) -> TeleportEvent { + TeleportEvent { + id: 1, + recipient_identity: identity(1), + mover_guid, + map_id: 36, + x: 10.0, + y: 20.0, + z: 30.0, + orientation: 1.5, + created_micros: 0, + cross_map: true, + } +} + +/// A registered viewer for `self_guid` on `shard`, with the receiving end of its writer queue. +pub(crate) fn registered_viewer( + view: &WorldView, + shard: usize, + self_guid: u64, +) -> (Arc, Receiver) { + let (tx, rx) = SessionTx::with_depth(0); + let viewer = viewer_with_tx( + view.next_session_id(), + self_guid, + identity(self_guid as u8), + tx, + ); + view.add_viewer_on_shard(viewer.clone(), CellKey::at(0, 0, 0, 0), shard); + (viewer, rx) +} + +pub(crate) fn contact_entry(owner_guid: u64, target_guid: u64, is_ignore: bool) -> ContactEntry { + ContactEntry { + id: 1, + owner_guid, + owner_identity: spacetimedb_sdk::Identity::ZERO, + target_guid, + is_ignore, + } +} diff --git a/module/src/group.rs b/module/src/group.rs index 67871fe4..c96dce18 100644 --- a/module/src/group.rs +++ b/module/src/group.rs @@ -34,6 +34,10 @@ //! multipliers and the raid XP rate are a documented follow-up. Kill quest-credit goes to every //! in-range member (vanilla). +mod mirror; +#[cfg_attr(not(feature = "debug_reducers"), allow(unused_imports))] +pub use mirror::sync_group_mirror; + use spacetimedb::{reducer, table, Identity, ReducerContext, Table, Timestamp}; use crate::{game_character, game_melee_attack, game_pending_cast, game_threat, game_world_entity}; @@ -176,7 +180,7 @@ crate::character_owned!(restamp, fn sweep_restamp_game_group_member(ctx, charact // destination. Its own AC called for that mirror to be deleted once membership had one // authoritative home, and the group slice gives it one: `game_group` / `game_group_member` / `game_group_invite` // are authoritative on REALM-CORE, and each world shard's copy is a gateway-maintained write-through -// cache (`sync_group_mirror` below) — the same relationship `game_account` / `game_session` have. +// cache (`sync_group_mirror`) — the same relationship `game_account` / `game_session` have. // // So the blob must not carry membership: it would race the authority. The gateway re-pushes the // realm-core roster onto the destination at world entry (`world::party::on_world_entry`), which is @@ -2216,338 +2220,6 @@ fn next_group_revision(current: Option, was_active: bool) -> u64 { .expect("party roster revision exhausted") } -/// Replace this database's MIRROR of one party with realm-core's authoritative roster. -/// -/// The world shards keep their `game_group` / `game_group_member` tables — the constraint is that a -/// live deployment cannot drop a table, and the invalidation story is the better answer anyway: -/// roughly fifty in-world reads (kill-XP split and quest credit in `combat`, the loot-method and -/// round-robin/master rules in `loot.rs`, `/p` chat in `chat.rs`, the party's dungeon binding in -/// `instance.rs`) resolve membership through `group_of`/`members_of` against the LOCAL rows, and -/// every one of them is a hot-path read that must not become a cross-database call. So the shard's -/// copy becomes a gateway-maintained write-through cache of realm-core's, exactly as `game_account` -/// and `game_session` became caches of realm-core's copies. -/// -/// Invalidation: the gateway re-pushes this after every party op it runs (to every connected world -/// shard) and at every world entry (to the shard the character just entered — which is what carries -/// a party across a shard boundary now that the blob mirror is gone). A shard that misses a push -/// re-syncs on the next of either. Nothing here NOTIFIES anybody: the realm-core plane owns the -/// client-facing relay, and a mirror that pushed its own `SMSG_GROUP_LIST` would double-render the -/// party frame on the acting player's shard. -/// -/// Mechanical by design — no leadership arbitration, no disband rule, no roll resolution. Those are -/// decisions, and decisions belong to the authority. An empty `members` is the disband/last-member -/// case and deletes the group row. The one local rule it runs is the Instance Removal rule, for -/// each Character whose membership the push changes: the mirror is how this Shard learns of it. -/// -/// `group_kind` is the [`GroupKind`] byte and `raid_slots[n]` is `members[n]`'s [`RaidSlot`] byte. -/// A length mismatch, an unknown kind, more members than the kind holds, or an invalid slot refuses -/// the whole push. -#[reducer] -#[allow(clippy::too_many_arguments)] // The roster and initiating Actor are the reducer wire contract. -pub fn sync_group_mirror( - ctx: &ReducerContext, - group_id: u64, - leader_guid: u64, - loot_method_setting: u8, - loot_threshold: u8, - master_looter_guid: u64, - members: Vec, - request_actor: crate::account_ownership::SessionActor, - partitions: Vec, - roster_revision: u64, - group_kind: u8, - raid_slots: Vec, -) -> Result<(), String> { - crate::helpers::require_operator(ctx)?; - crate::account_ownership::require_actor(ctx, request_actor)?; - if group_id == 0 { - return Err("group 0 is not a party".to_string()); - } - if roster_revision == 0 { - return Err("group mirror has no Realm roster revision".to_string()); - } - if members.len() > RAID_MAX_MEMBERS || partitions.len() > RAID_MAX_MEMBERS * 2 { - return Err( - "group mirror exceeds the bounded current and departed group-member limit".to_string(), - ); - } - let (kind, slots) = mirror_kind_and_slots(&members, group_kind, &raid_slots)?; - let mut partition_guids = std::collections::BTreeSet::new(); - for partition in &partitions { - if partition.group_id != group_id - || partition.membership_revision == 0 - || (partition.locator_revision == 0 && partition.state != PartyPartitionState::Unknown) - || partition.member_active != members.contains(&partition.character_guid) - || (!partition.member_active && partition.state != PartyPartitionState::Unknown) - || !partition_guids.insert(partition.character_guid) - { - return Err("group mirror has an invalid member partition".to_string()); - } - } - if members.iter().any(|guid| !partition_guids.contains(guid)) { - return Err("group mirror is missing a current member partition".to_string()); - } - let groups = ctx.db.game_group(); - let member_tbl = ctx.db.game_group_member(); - let partition_tbl = ctx.db.game_group_member_partition(); - let current_revision = ctx - .db - .game_group_roster_revision() - .group_id() - .find(group_id); - let incoming_active = !members.is_empty(); - match roster_update( - current_revision - .as_ref() - .map(|current| (current.revision, current.active)), - roster_revision, - incoming_active, - ) { - RosterUpdate::Keep => return Ok(()), - RosterUpdate::Conflict => { - return Err("group mirror conflicts with the accepted roster state".to_string()); - } - RosterUpdate::Apply => {} - } - if current_revision - .as_ref() - .is_some_and(|current| current.revision == roster_revision) - { - if let Some(group) = groups.group_id().find(group_id) { - if incoming_active - && (group.leader_guid != leader_guid - || group.loot_method != loot_method_setting - || group.loot_threshold != loot_threshold - || group.master_looter_guid != master_looter_guid - || group_kind_of(&group) != kind - || mirror_slots_differ(ctx, group_id, &slots)) - { - return Err("group mirror conflicts with the accepted party rules".to_string()); - } - } - } - let advances_roster = current_revision - .as_ref() - .is_none_or(|current| roster_revision > current.revision); - if advances_roster { - let stale_partitions: Vec<_> = partition_tbl - .by_group() - .filter(&group_id) - .filter(|partition| { - partition.member_active && !members.contains(&partition.character_guid) - }) - .collect(); - for mut partition in stale_partitions { - partition.member_active = false; - partition.state = PartyPartitionState::Unknown; - partition_tbl.character_guid().update(partition); - } - } - for partition in partitions { - match partition_tbl - .character_guid() - .find(partition.character_guid) - .map(|current| partition_update(¤t, &partition)) - { - Some(PartitionUpdate::Keep) => {} - Some(PartitionUpdate::Conflict) => { - return Err(format!( - "group mirror has conflicting partition facts for member {}", - partition.character_guid - )); - } - Some(PartitionUpdate::Replace) => { - partition_tbl.character_guid().update(partition); - } - None => { - partition_tbl.insert(partition); - } - } - } - let effective_members: Vec<_> = partition_tbl - .by_group() - .filter(&group_id) - .filter(|row| row.member_active) - .map(|row| (row.membership_revision, row.character_guid)) - .collect::>() - .into_iter() - .map(|(_, guid)| guid) - .collect(); - if effective_members != members { - return Err("group mirror roster conflicts with its accepted revision".to_string()); - } - let current: Vec<(u64, u64)> = member_tbl - .by_group() - .filter(&group_id) - .map(|m| (m.id, m.character_guid)) - .collect(); - let (stale_row_ids, arriving) = mirror_plan(¤t, &effective_members); - // Every guid whose membership this push changes. Only these can start or end an Instance - // Removal, so a push that moves Raid Slots or the lead evaluates nothing. - let mut membership_changed = arriving.clone(); - for id in stale_row_ids { - if let Some((_, guid)) = current.iter().find(|(row_id, _)| *row_id == id) { - crate::loot::tag::revoke_group_member(ctx, group_id, *guid); - membership_changed.push(*guid); - } - member_tbl.id().delete(id); - } - if effective_members.is_empty() { - groups.group_id().delete(group_id); - store_group_revision(ctx, group_id, roster_revision, false); - for guid in membership_changed { - crate::instance::reconcile_instance_removal(ctx, guid); - } - return Ok(()); - } - // A character is in at most one group (the `by_character` uniqueness the whole system assumes), - // so an arriving member's row in some OTHER group on this shard — a mirror that missed the push - // where they left it — is stale by construction and goes first. - for guid in &arriving { - for m in member_tbl.by_character().filter(guid).collect::>() { - crate::loot::tag::revoke_group_member(ctx, m.group_id, *guid); - member_tbl.id().delete(m.id); - } - } - let effective_leader = if effective_members.contains(&leader_guid) { - leader_guid - } else { - *effective_members - .first() - .ok_or_else(|| "group mirror has no effective leader".to_string())? - }; - match groups.group_id().find(group_id) { - Some(mut g) => { - g.leader_guid = effective_leader; - g.loot_method = loot_method_setting; - g.loot_threshold = loot_threshold; - g.master_looter_guid = master_looter_guid; - g.group_type = kind.wire(); - groups.group_id().update(g); - } - None => { - groups.insert(Group { - group_id, - leader_guid: effective_leader, - loot_method: loot_method_setting, - loot_threshold, - // The round-robin cursor is per-SHARD kill state, not realm state: it advances on - // this shard's kills and means nothing on another. A fresh mirror starts it at 0. - rr_cursor: 0, - master_looter_guid, - group_type: kind.wire(), - }); - } - } - write_mirror_slots(ctx, group_id, &slots); - // `slots` is keyed by `members`, which equals the effective roster here, so every arriving - // member has an entry and the default below is unreachable. - for guid in arriving { - // The identity binding is this shard's, re-derived here the same way `player_login` restamps - // every other character-owned row. A member who has never logged in HERE — or who is mid-hop, - // which is why this reads through the in-transit chokepoint rather than raw — has no - // readable character row and mirrors as ZERO; their own login restamps it. - let owner_identity = crate::helpers::event_recipient_identity( - crate::helpers::character_by_guid(ctx, guid).map(|c| c.owner_identity), - ); - member_tbl.insert(GroupMember { - id: 0, - group_id, - character_guid: guid, - owner_identity, - raid_slot: slots.get(&guid).copied().unwrap_or_default().wire(), - }); - } - store_group_revision(ctx, group_id, roster_revision, true); - for guid in membership_changed { - crate::instance::reconcile_instance_removal(ctx, guid); - } - Ok(()) -} - -/// Record the Roster Revision a mirror push was accepted at. -fn store_group_revision(ctx: &ReducerContext, group_id: u64, revision: u64, active: bool) { - let revisions = ctx.db.game_group_roster_revision(); - let row = GroupRosterRevision { - group_id, - revision, - active, - }; - if revisions.group_id().find(group_id).is_some() { - revisions.group_id().update(row); - } else { - revisions.insert(row); - } -} - -/// Check the kind byte and one Raid Slot byte per member, and key the slots by member guid. Pure. -fn mirror_kind_and_slots( - members: &[u64], - group_kind: u8, - raid_slots: &[u8], -) -> Result<(GroupKind, std::collections::BTreeMap), String> { - let kind = GroupKind::from_wire(group_kind) - .ok_or_else(|| format!("group mirror has unknown group kind {group_kind}"))?; - if members.len() > kind.member_cap() { - return Err(format!( - "group mirror has {} members for a group that holds at most {}", - members.len(), - kind.member_cap() - )); - } - if raid_slots.len() != members.len() { - return Err(format!( - "group mirror has {} Raid Slots for {} members", - raid_slots.len(), - members.len() - )); - } - let slots = members - .iter() - .zip(raid_slots) - .map(|(&guid, &byte)| { - RaidSlot::from_wire(byte) - .map(|slot| (guid, slot)) - .ok_or_else(|| format!("group mirror has invalid Raid Slot {byte} for {guid}")) - }) - .collect::>()?; - Ok((kind, slots)) -} - -/// Give every mirrored member of `group_id` the Raid Slot `slots` names. `sync_group_mirror` keys -/// `slots` by the effective roster, so every remaining row has an entry. -fn write_mirror_slots( - ctx: &ReducerContext, - group_id: u64, - slots: &std::collections::BTreeMap, -) { - let member_tbl = ctx.db.game_group_member(); - for mut row in member_tbl.by_group().filter(&group_id).collect::>() { - let slot = slots.get(&row.character_guid).copied().unwrap_or_default(); - if row.raid_slot != slot.wire() { - row.raid_slot = slot.wire(); - member_tbl.id().update(row); - } - } -} - -/// Whether a mirrored member of `group_id` holds another Raid Slot than `slots` gives it. -fn mirror_slots_differ( - ctx: &ReducerContext, - group_id: u64, - slots: &std::collections::BTreeMap, -) -> bool { - ctx.db - .game_group_member() - .by_group() - .filter(&group_id) - .any(|row| { - slots - .get(&row.character_guid) - .is_some_and(|slot| slot.wire() != row.raid_slot) - }) -} - /// Acknowledged Realm-core party authority read for one companion command attempt. Companion /// Orders cover the whole Raid: both lists are bounded at the member cap of the Group's kind, and a /// longer list is a damaged mirror. @@ -2606,110 +2278,6 @@ pub fn admit_party_command_authority( Ok(()) } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum RosterUpdate { - Keep, - Apply, - Conflict, -} - -fn roster_update( - current: Option<(u64, bool)>, - incoming_revision: u64, - incoming_active: bool, -) -> RosterUpdate { - let Some((current_revision, current_active)) = current else { - return RosterUpdate::Apply; - }; - match incoming_revision.cmp(¤t_revision) { - std::cmp::Ordering::Less => RosterUpdate::Keep, - std::cmp::Ordering::Greater => RosterUpdate::Apply, - std::cmp::Ordering::Equal if current_active == incoming_active => RosterUpdate::Apply, - std::cmp::Ordering::Equal => RosterUpdate::Conflict, - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum PartitionUpdate { - Keep, - Replace, - Conflict, -} - -fn partition_state_order(state: PartyPartitionState) -> u8 { - match state { - PartyPartitionState::Unknown => 0, - PartyPartitionState::Known => 1, - PartyPartitionState::PendingTransfer => 2, - } -} - -fn partition_update( - current: &GroupMemberPartition, - incoming: &GroupMemberPartition, -) -> PartitionUpdate { - match incoming - .membership_revision - .cmp(¤t.membership_revision) - { - std::cmp::Ordering::Less => return PartitionUpdate::Keep, - std::cmp::Ordering::Greater => return PartitionUpdate::Replace, - std::cmp::Ordering::Equal => {} - } - if current.member_active != incoming.member_active { - return if current.member_active { - PartitionUpdate::Replace - } else { - PartitionUpdate::Keep - }; - } - if !current.member_active { - return PartitionUpdate::Keep; - } - if current.group_id != incoming.group_id { - return PartitionUpdate::Conflict; - } - match incoming.locator_revision.cmp(¤t.locator_revision) { - std::cmp::Ordering::Less => PartitionUpdate::Keep, - std::cmp::Ordering::Greater => PartitionUpdate::Replace, - std::cmp::Ordering::Equal => { - let incoming_order = partition_state_order(incoming.state); - let current_order = partition_state_order(current.state); - match incoming_order.cmp(¤t_order) { - std::cmp::Ordering::Less => PartitionUpdate::Keep, - std::cmp::Ordering::Greater => PartitionUpdate::Replace, - std::cmp::Ordering::Equal - if incoming.map_id == current.map_id - && incoming.instance_id == current.instance_id => - { - PartitionUpdate::Keep - } - std::cmp::Ordering::Equal => PartitionUpdate::Conflict, - } - } - } -} - -/// The row-level diff [`sync_group_mirror`] applies: which mirrored member ROWS of this group are no -/// longer in the authoritative roster (delete them, by row id) and which roster members have no row -/// yet (insert them, by guid). Pure — the reducer around it is unreachable without a live node, so -/// the decision it makes is extracted here where a test can drive it. -/// -/// `current` is `(member_row_id, character_guid)` for this group's rows on THIS database. -pub(crate) fn mirror_plan(current: &[(u64, u64)], incoming: &[u64]) -> (Vec, Vec) { - let stale = current - .iter() - .filter(|(_, guid)| !incoming.contains(guid)) - .map(|(id, _)| *id) - .collect(); - let arriving = incoming - .iter() - .filter(|guid| !current.iter().any(|(_, have)| have == *guid)) - .copied() - .collect(); - (stale, arriving) -} - // =========================================================================================== // Kill rewards: XP split + shared quest credit // =========================================================================================== @@ -3025,6 +2593,17 @@ fn random_roll_on( #[cfg(test)] mod tests { + #[test] + fn realm_roster_revision_advances_from_new_existing_and_migrated_groups() { + assert_eq!(next_group_revision(None, false), 1, "new party"); + assert_eq!(next_group_revision(None, true), 2, "migrated live party"); + assert_eq!( + next_group_revision(Some(7), true), + 8, + "existing party change" + ); + } + use super::*; #[test] @@ -3297,22 +2876,6 @@ mod tests { ); } - #[test] - fn a_mirror_push_needs_a_known_kind_and_one_valid_slot_per_member() { - let (kind, slots) = mirror_kind_and_slots(&[10, 11], 1, &[0, 0x81]).unwrap(); - assert_eq!(kind, GroupKind::Raid); - assert_eq!(slots[&11], RaidSlot::new(1, true).unwrap()); - assert!(mirror_kind_and_slots(&[10, 11], 2, &[0, 0]).is_err()); - assert!(mirror_kind_and_slots(&[10, 11], 1, &[0]).is_err()); - assert!(mirror_kind_and_slots(&[10], 1, &[0, 0]).is_err()); - assert!(mirror_kind_and_slots(&[10, 11], 1, &[0, 8]).is_err()); - assert_eq!( - mirror_kind_and_slots(&[], 0, &[]).map(|(kind, slots)| (kind, slots.len())), - Ok((GroupKind::Party, 0)), - "the disband tombstone carries no members and no slots" - ); - } - // ---- Target Icons (cm:Group.cpp:583-601) ---- fn held(icon: u8, target_guid: u64) -> TargetIcon { @@ -3409,130 +2972,4 @@ mod tests { assert!(!valid_loot_threshold(7)); assert!(!valid_loot_threshold(255)); } - - // ---- The realm-core mirror (group slice) ---- - - #[test] - fn the_mirror_diff_deletes_departed_rows_and_inserts_arriving_members() { - // Steady state: the mirror already matches the authority — nothing to do at all. (A diff - // that re-inserted every member on every push would churn `game_group_member` row ids on - // every party op, on every shard.) - let current = [(10u64, 100u64), (11, 200)]; - assert_eq!(mirror_plan(¤t, &[100, 200]), (vec![], vec![])); - // A member joined: only the newcomer is inserted. - assert_eq!(mirror_plan(¤t, &[100, 200, 300]), (vec![], vec![300])); - // A member left: only their ROW is deleted, addressed by row id (not guid — the delete is - // applied through the member table's PK). - assert_eq!(mirror_plan(¤t, &[100]), (vec![11], vec![])); - // A swap does both, and the ROW ID of the departing member is what comes back. - assert_eq!(mirror_plan(¤t, &[200, 300]), (vec![10], vec![300])); - // Disband: every row goes and nothing arrives. - assert_eq!(mirror_plan(¤t, &[]), (vec![10, 11], vec![])); - // A shard with no mirror yet (a party crossing a boundary for the first time) inserts all. - assert_eq!(mirror_plan(&[], &[100, 200]), (vec![], vec![100, 200])); - } - - fn partition( - membership_revision: u64, - member_active: bool, - locator_revision: u64, - state: PartyPartitionState, - map_id: u32, - ) -> GroupMemberPartition { - GroupMemberPartition { - character_guid: 100, - group_id: 7, - membership_revision, - member_active, - map_id, - instance_id: 0, - locator_revision, - state, - } - } - - #[test] - fn partition_updates_follow_realm_membership_and_locator_order() { - let known = partition(10, true, 4, PartyPartitionState::Known, 0); - let transient_unknown = partition(10, true, 4, PartyPartitionState::Unknown, 0); - assert_eq!( - partition_update(&known, &transient_unknown), - PartitionUpdate::Keep, - "a transient read miss at the same Realm locator cannot erase a confirmed partition" - ); - assert_eq!( - partition_update(&transient_unknown, &known), - PartitionUpdate::Replace, - "the same settled Realm locator recovers after a transient read miss" - ); - - let pending = partition(10, true, 4, PartyPartitionState::PendingTransfer, 0); - assert_eq!(partition_update(&known, &pending), PartitionUpdate::Replace); - assert_eq!(partition_update(&pending, &known), PartitionUpdate::Keep); - let destination = partition(10, true, 5, PartyPartitionState::Known, 36); - assert_eq!( - partition_update(&pending, &destination), - PartitionUpdate::Replace, - "Realm-core's next locator revision settles the crossing before arrival release" - ); - - let removed = partition(10, false, 5, PartyPartitionState::Unknown, 36); - assert_eq!( - partition_update(&destination, &removed), - PartitionUpdate::Replace - ); - assert_eq!( - partition_update(&removed, &destination), - PartitionUpdate::Keep - ); - let rejoined = partition(11, true, 5, PartyPartitionState::Known, 36); - assert_eq!( - partition_update(&removed, &rejoined), - PartitionUpdate::Replace - ); - } - - #[test] - fn complete_roster_revision_orders_members_rules_and_disband() { - assert_eq!(next_group_revision(None, false), 1, "new party"); - assert_eq!(next_group_revision(None, true), 2, "migrated live party"); - assert_eq!( - next_group_revision(Some(7), true), - 8, - "existing party change" - ); - assert_eq!(roster_update(None, 1, true), RosterUpdate::Apply); - assert_eq!( - roster_update(Some((2, true)), 1, true), - RosterUpdate::Keep, - "a delayed pre-join roster cannot remove the newer member or restore old rules" - ); - assert_eq!( - roster_update(Some((2, true)), 3, true), - RosterUpdate::Apply, - "a revisioned leave or leadership/loot change replaces the complete roster" - ); - assert_eq!( - roster_update(Some((4, false)), 3, true), - RosterUpdate::Keep, - "the durable disband tombstone survives restart and rejects an older live roster" - ); - assert_eq!( - roster_update(Some((4, false)), 4, true), - RosterUpdate::Conflict, - "equal-revision state cannot revive a disbanded party" - ); - } - - #[test] - fn equal_revisions_cannot_disagree_about_a_known_partition() { - let current = partition(10, true, 4, PartyPartitionState::Known, 0); - let conflicting = partition(10, true, 4, PartyPartitionState::Known, 36); - assert_eq!( - partition_update(¤t, &conflicting), - PartitionUpdate::Conflict - ); - } - - // event_recipient_identity's pinned test moved to helpers.rs with the function itself. } diff --git a/module/src/group/mirror.rs b/module/src/group/mirror.rs new file mode 100644 index 00000000..ac88b3b3 --- /dev/null +++ b/module/src/group/mirror.rs @@ -0,0 +1,554 @@ +//! Roster Revision and Party Partition rules for a World Shard's Group mirror. + +use super::{ + game_group, game_group_member, game_group_member_partition, game_group_roster_revision, + group_kind_of, Group, GroupMember, GroupMemberPartition, GroupRosterRevision, + PartyPartitionState, +}; +use lyracore_shared::group::{GroupKind, RaidSlot, RAID_MAX_MEMBERS}; +use spacetimedb::{reducer, ReducerContext, Table}; + +/// Apply one Realm-core roster to this World Shard's Group mirror atomically. +/// Older Roster Revisions are ignored; equal revisions must agree on the roster and its rules. +/// Party Partition ordering preserves newer location and membership facts. Membership changes +/// also revoke departed Loot Tags and reconcile Instance Removal in this transaction. +#[reducer] +#[allow(clippy::too_many_arguments)] // The roster and initiating Actor are the reducer wire contract. +pub fn sync_group_mirror( + ctx: &ReducerContext, + group_id: u64, + leader_guid: u64, + loot_method_setting: u8, + loot_threshold: u8, + master_looter_guid: u64, + members: Vec, + request_actor: crate::account_ownership::SessionActor, + partitions: Vec, + roster_revision: u64, + group_kind: u8, + raid_slots: Vec, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + crate::account_ownership::require_actor(ctx, request_actor)?; + if group_id == 0 { + return Err("group 0 is not a party".to_string()); + } + if roster_revision == 0 { + return Err("group mirror has no Realm roster revision".to_string()); + } + if members.len() > RAID_MAX_MEMBERS || partitions.len() > RAID_MAX_MEMBERS * 2 { + return Err( + "group mirror exceeds the bounded current and departed group-member limit".to_string(), + ); + } + let (kind, slots) = mirror_kind_and_slots(&members, group_kind, &raid_slots)?; + let mut partition_guids = std::collections::BTreeSet::new(); + for partition in &partitions { + if partition.group_id != group_id + || partition.membership_revision == 0 + || (partition.locator_revision == 0 && partition.state != PartyPartitionState::Unknown) + || partition.member_active != members.contains(&partition.character_guid) + || (!partition.member_active && partition.state != PartyPartitionState::Unknown) + || !partition_guids.insert(partition.character_guid) + { + return Err("group mirror has an invalid member partition".to_string()); + } + } + if members.iter().any(|guid| !partition_guids.contains(guid)) { + return Err("group mirror is missing a current member partition".to_string()); + } + let groups = ctx.db.game_group(); + let member_tbl = ctx.db.game_group_member(); + let partition_tbl = ctx.db.game_group_member_partition(); + let current_revision = ctx + .db + .game_group_roster_revision() + .group_id() + .find(group_id); + let incoming_active = !members.is_empty(); + match roster_update( + current_revision + .as_ref() + .map(|current| (current.revision, current.active)), + roster_revision, + incoming_active, + ) { + RosterUpdate::Keep => return Ok(()), + RosterUpdate::Conflict => { + return Err("group mirror conflicts with the accepted roster state".to_string()); + } + RosterUpdate::Apply => {} + } + if current_revision + .as_ref() + .is_some_and(|current| current.revision == roster_revision) + { + if let Some(group) = groups.group_id().find(group_id) { + if incoming_active + && (group.leader_guid != leader_guid + || group.loot_method != loot_method_setting + || group.loot_threshold != loot_threshold + || group.master_looter_guid != master_looter_guid + || group_kind_of(&group) != kind + || mirror_slots_differ(ctx, group_id, &slots)) + { + return Err("group mirror conflicts with the accepted party rules".to_string()); + } + } + } + let advances_roster = current_revision + .as_ref() + .is_none_or(|current| roster_revision > current.revision); + if advances_roster { + let stale_partitions: Vec<_> = partition_tbl + .by_group() + .filter(&group_id) + .filter(|partition| { + partition.member_active && !members.contains(&partition.character_guid) + }) + .collect(); + for mut partition in stale_partitions { + partition.member_active = false; + partition.state = PartyPartitionState::Unknown; + partition_tbl.character_guid().update(partition); + } + } + for partition in partitions { + match partition_tbl + .character_guid() + .find(partition.character_guid) + .map(|current| partition_update(¤t, &partition)) + { + Some(PartitionUpdate::Keep) => {} + Some(PartitionUpdate::Conflict) => { + return Err(format!( + "group mirror has conflicting partition facts for member {}", + partition.character_guid + )); + } + Some(PartitionUpdate::Replace) => { + partition_tbl.character_guid().update(partition); + } + None => { + partition_tbl.insert(partition); + } + } + } + let effective_members: Vec<_> = partition_tbl + .by_group() + .filter(&group_id) + .filter(|row| row.member_active) + .map(|row| (row.membership_revision, row.character_guid)) + .collect::>() + .into_iter() + .map(|(_, guid)| guid) + .collect(); + if effective_members != members { + return Err("group mirror roster conflicts with its accepted revision".to_string()); + } + let current: Vec<(u64, u64)> = member_tbl + .by_group() + .filter(&group_id) + .map(|m| (m.id, m.character_guid)) + .collect(); + let (stale_row_ids, arriving) = mirror_plan(¤t, &effective_members); + // Every guid whose membership this push changes. Only these can start or end an Instance + // Removal, so a push that moves Raid Slots or the lead evaluates nothing. + let mut membership_changed = arriving.clone(); + for id in stale_row_ids { + if let Some((_, guid)) = current.iter().find(|(row_id, _)| *row_id == id) { + crate::loot::tag::revoke_group_member(ctx, group_id, *guid); + membership_changed.push(*guid); + } + member_tbl.id().delete(id); + } + if effective_members.is_empty() { + groups.group_id().delete(group_id); + store_group_revision(ctx, group_id, roster_revision, false); + for guid in membership_changed { + crate::instance::reconcile_instance_removal(ctx, guid); + } + return Ok(()); + } + // A character is in at most one group (the `by_character` uniqueness the whole system assumes), + // so an arriving member's row in some OTHER group on this shard — a mirror that missed the push + // where they left it — is stale by construction and goes first. + for guid in &arriving { + for m in member_tbl.by_character().filter(guid).collect::>() { + crate::loot::tag::revoke_group_member(ctx, m.group_id, *guid); + member_tbl.id().delete(m.id); + } + } + let effective_leader = if effective_members.contains(&leader_guid) { + leader_guid + } else { + *effective_members + .first() + .ok_or_else(|| "group mirror has no effective leader".to_string())? + }; + match groups.group_id().find(group_id) { + Some(mut g) => { + g.leader_guid = effective_leader; + g.loot_method = loot_method_setting; + g.loot_threshold = loot_threshold; + g.master_looter_guid = master_looter_guid; + g.group_type = kind.wire(); + groups.group_id().update(g); + } + None => { + groups.insert(Group { + group_id, + leader_guid: effective_leader, + loot_method: loot_method_setting, + loot_threshold, + // The round-robin cursor is per-SHARD kill state, not realm state: it advances on + // this shard's kills and means nothing on another. A fresh mirror starts it at 0. + rr_cursor: 0, + master_looter_guid, + group_type: kind.wire(), + }); + } + } + write_mirror_slots(ctx, group_id, &slots); + // `slots` is keyed by `members`, which equals the effective roster here, so every arriving + // member has an entry and the default below is unreachable. + for guid in arriving { + // The identity binding is this shard's, re-derived here the same way `player_login` restamps + // every other character-owned row. A member who has never logged in HERE — or who is mid-hop, + // which is why this reads through the in-transit chokepoint rather than raw — has no + // readable character row and mirrors as ZERO; their own login restamps it. + let owner_identity = crate::helpers::event_recipient_identity( + crate::helpers::character_by_guid(ctx, guid).map(|c| c.owner_identity), + ); + member_tbl.insert(GroupMember { + id: 0, + group_id, + character_guid: guid, + owner_identity, + raid_slot: slots.get(&guid).copied().unwrap_or_default().wire(), + }); + } + store_group_revision(ctx, group_id, roster_revision, true); + for guid in membership_changed { + crate::instance::reconcile_instance_removal(ctx, guid); + } + Ok(()) +} + +/// Record the Roster Revision a mirror push was accepted at. +fn store_group_revision(ctx: &ReducerContext, group_id: u64, revision: u64, active: bool) { + let revisions = ctx.db.game_group_roster_revision(); + let row = GroupRosterRevision { + group_id, + revision, + active, + }; + if revisions.group_id().find(group_id).is_some() { + revisions.group_id().update(row); + } else { + revisions.insert(row); + } +} + +/// Check the kind byte and one Raid Slot byte per member, and key the slots by member guid. Pure. +fn mirror_kind_and_slots( + members: &[u64], + group_kind: u8, + raid_slots: &[u8], +) -> Result<(GroupKind, std::collections::BTreeMap), String> { + let kind = GroupKind::from_wire(group_kind) + .ok_or_else(|| format!("group mirror has unknown group kind {group_kind}"))?; + if members.len() > kind.member_cap() { + return Err(format!( + "group mirror has {} members for a group that holds at most {}", + members.len(), + kind.member_cap() + )); + } + if raid_slots.len() != members.len() { + return Err(format!( + "group mirror has {} Raid Slots for {} members", + raid_slots.len(), + members.len() + )); + } + let slots = members + .iter() + .zip(raid_slots) + .map(|(&guid, &byte)| { + RaidSlot::from_wire(byte) + .map(|slot| (guid, slot)) + .ok_or_else(|| format!("group mirror has invalid Raid Slot {byte} for {guid}")) + }) + .collect::>()?; + Ok((kind, slots)) +} + +/// Give every mirrored member of `group_id` the Raid Slot `slots` names. `sync_group_mirror` keys +/// `slots` by the effective roster, so every remaining row has an entry. +fn write_mirror_slots( + ctx: &ReducerContext, + group_id: u64, + slots: &std::collections::BTreeMap, +) { + let member_tbl = ctx.db.game_group_member(); + for mut row in member_tbl.by_group().filter(&group_id).collect::>() { + let slot = slots.get(&row.character_guid).copied().unwrap_or_default(); + if row.raid_slot != slot.wire() { + row.raid_slot = slot.wire(); + member_tbl.id().update(row); + } + } +} + +/// Whether a mirrored member of `group_id` holds another Raid Slot than `slots` gives it. +fn mirror_slots_differ( + ctx: &ReducerContext, + group_id: u64, + slots: &std::collections::BTreeMap, +) -> bool { + ctx.db + .game_group_member() + .by_group() + .filter(&group_id) + .any(|row| { + slots + .get(&row.character_guid) + .is_some_and(|slot| slot.wire() != row.raid_slot) + }) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RosterUpdate { + Keep, + Apply, + Conflict, +} + +fn roster_update( + current: Option<(u64, bool)>, + incoming_revision: u64, + incoming_active: bool, +) -> RosterUpdate { + let Some((current_revision, current_active)) = current else { + return RosterUpdate::Apply; + }; + match incoming_revision.cmp(¤t_revision) { + std::cmp::Ordering::Less => RosterUpdate::Keep, + std::cmp::Ordering::Greater => RosterUpdate::Apply, + std::cmp::Ordering::Equal if current_active == incoming_active => RosterUpdate::Apply, + std::cmp::Ordering::Equal => RosterUpdate::Conflict, + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PartitionUpdate { + Keep, + Replace, + Conflict, +} + +fn partition_state_order(state: PartyPartitionState) -> u8 { + match state { + PartyPartitionState::Unknown => 0, + PartyPartitionState::Known => 1, + PartyPartitionState::PendingTransfer => 2, + } +} + +fn partition_update( + current: &GroupMemberPartition, + incoming: &GroupMemberPartition, +) -> PartitionUpdate { + match incoming + .membership_revision + .cmp(¤t.membership_revision) + { + std::cmp::Ordering::Less => return PartitionUpdate::Keep, + std::cmp::Ordering::Greater => return PartitionUpdate::Replace, + std::cmp::Ordering::Equal => {} + } + if current.member_active != incoming.member_active { + return if current.member_active { + PartitionUpdate::Replace + } else { + PartitionUpdate::Keep + }; + } + if !current.member_active { + return PartitionUpdate::Keep; + } + if current.group_id != incoming.group_id { + return PartitionUpdate::Conflict; + } + match incoming.locator_revision.cmp(¤t.locator_revision) { + std::cmp::Ordering::Less => PartitionUpdate::Keep, + std::cmp::Ordering::Greater => PartitionUpdate::Replace, + std::cmp::Ordering::Equal => { + let incoming_order = partition_state_order(incoming.state); + let current_order = partition_state_order(current.state); + match incoming_order.cmp(¤t_order) { + std::cmp::Ordering::Less => PartitionUpdate::Keep, + std::cmp::Ordering::Greater => PartitionUpdate::Replace, + std::cmp::Ordering::Equal + if incoming.map_id == current.map_id + && incoming.instance_id == current.instance_id => + { + PartitionUpdate::Keep + } + std::cmp::Ordering::Equal => PartitionUpdate::Conflict, + } + } + } +} + +/// Return departed member row ids and arriving Character guids for the accepted roster. +fn mirror_plan(current: &[(u64, u64)], incoming: &[u64]) -> (Vec, Vec) { + let stale = current + .iter() + .filter(|(_, guid)| !incoming.contains(guid)) + .map(|(id, _)| *id) + .collect(); + let arriving = incoming + .iter() + .filter(|guid| !current.iter().any(|(_, have)| have == *guid)) + .copied() + .collect(); + (stale, arriving) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_mirror_push_needs_a_known_kind_and_one_valid_slot_per_member() { + let (kind, slots) = mirror_kind_and_slots(&[10, 11], 1, &[0, 0x81]).unwrap(); + assert_eq!(kind, GroupKind::Raid); + assert_eq!(slots[&11], RaidSlot::new(1, true).unwrap()); + assert!(mirror_kind_and_slots(&[10, 11], 2, &[0, 0]).is_err()); + assert!(mirror_kind_and_slots(&[10, 11], 1, &[0]).is_err()); + assert!(mirror_kind_and_slots(&[10], 1, &[0, 0]).is_err()); + assert!(mirror_kind_and_slots(&[10, 11], 1, &[0, 8]).is_err()); + assert_eq!( + mirror_kind_and_slots(&[], 0, &[]).map(|(kind, slots)| (kind, slots.len())), + Ok((GroupKind::Party, 0)), + "the disband tombstone carries no members and no slots" + ); + } + + #[test] + fn the_mirror_diff_deletes_departed_rows_and_inserts_arriving_members() { + // Steady state: the mirror already matches the authority — nothing to do at all. (A diff + // that re-inserted every member on every push would churn `game_group_member` row ids on + // every party op, on every shard.) + let current = [(10u64, 100u64), (11, 200)]; + assert_eq!(mirror_plan(¤t, &[100, 200]), (vec![], vec![])); + // A member joined: only the newcomer is inserted. + assert_eq!(mirror_plan(¤t, &[100, 200, 300]), (vec![], vec![300])); + // A member left: only their ROW is deleted, addressed by row id (not guid — the delete is + // applied through the member table's PK). + assert_eq!(mirror_plan(¤t, &[100]), (vec![11], vec![])); + // A swap does both, and the ROW ID of the departing member is what comes back. + assert_eq!(mirror_plan(¤t, &[200, 300]), (vec![10], vec![300])); + // Disband: every row goes and nothing arrives. + assert_eq!(mirror_plan(¤t, &[]), (vec![10, 11], vec![])); + // A shard with no mirror yet (a party crossing a boundary for the first time) inserts all. + assert_eq!(mirror_plan(&[], &[100, 200]), (vec![], vec![100, 200])); + } + + fn partition( + membership_revision: u64, + member_active: bool, + locator_revision: u64, + state: PartyPartitionState, + map_id: u32, + ) -> GroupMemberPartition { + GroupMemberPartition { + character_guid: 100, + group_id: 7, + membership_revision, + member_active, + map_id, + instance_id: 0, + locator_revision, + state, + } + } + + #[test] + fn partition_updates_follow_realm_membership_and_locator_order() { + let known = partition(10, true, 4, PartyPartitionState::Known, 0); + let transient_unknown = partition(10, true, 4, PartyPartitionState::Unknown, 0); + assert_eq!( + partition_update(&known, &transient_unknown), + PartitionUpdate::Keep, + "a transient read miss at the same Realm locator cannot erase a confirmed partition" + ); + assert_eq!( + partition_update(&transient_unknown, &known), + PartitionUpdate::Replace, + "the same settled Realm locator recovers after a transient read miss" + ); + + let pending = partition(10, true, 4, PartyPartitionState::PendingTransfer, 0); + assert_eq!(partition_update(&known, &pending), PartitionUpdate::Replace); + assert_eq!(partition_update(&pending, &known), PartitionUpdate::Keep); + let destination = partition(10, true, 5, PartyPartitionState::Known, 36); + assert_eq!( + partition_update(&pending, &destination), + PartitionUpdate::Replace, + "Realm-core's next locator revision settles the crossing before arrival release" + ); + + let removed = partition(10, false, 5, PartyPartitionState::Unknown, 36); + assert_eq!( + partition_update(&destination, &removed), + PartitionUpdate::Replace + ); + assert_eq!( + partition_update(&removed, &destination), + PartitionUpdate::Keep + ); + let rejoined = partition(11, true, 5, PartyPartitionState::Known, 36); + assert_eq!( + partition_update(&removed, &rejoined), + PartitionUpdate::Replace + ); + } + + #[test] + fn complete_roster_revision_orders_members_rules_and_disband() { + assert_eq!(roster_update(None, 1, true), RosterUpdate::Apply); + assert_eq!( + roster_update(Some((2, true)), 1, true), + RosterUpdate::Keep, + "a delayed pre-join roster cannot remove the newer member or restore old rules" + ); + assert_eq!( + roster_update(Some((2, true)), 3, true), + RosterUpdate::Apply, + "a revisioned leave or leadership/loot change replaces the complete roster" + ); + assert_eq!( + roster_update(Some((4, false)), 3, true), + RosterUpdate::Keep, + "the durable disband tombstone survives restart and rejects an older live roster" + ); + assert_eq!( + roster_update(Some((4, false)), 4, true), + RosterUpdate::Conflict, + "equal-revision state cannot revive a disbanded party" + ); + } + + #[test] + fn equal_revisions_cannot_disagree_about_a_known_partition() { + let current = partition(10, true, 4, PartyPartitionState::Known, 0); + let conflicting = partition(10, true, 4, PartyPartitionState::Known, 36); + assert_eq!( + partition_update(¤t, &conflicting), + PartitionUpdate::Conflict + ); + } +}