From 9c4bf097cdf1223f20487c9d1e6f3ce3c9c6c510 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sun, 11 Oct 2026 07:30:30 +0200 Subject: [PATCH 1/3] fix(gateway): relay live gear changes to nearby peers --- gateway/src/stdb/world_view.rs | 351 ++++++++++++++++++++++++++++++++- 1 file changed, 344 insertions(+), 7 deletions(-) diff --git a/gateway/src/stdb/world_view.rs b/gateway/src/stdb/world_view.rs index 070b0d643..81a8922d5 100644 --- a/gateway/src/stdb/world_view.rs +++ b/gateway/src/stdb/world_view.rs @@ -2106,6 +2106,7 @@ fn item_owner_job( } fn item_inserted(view: &WorldView, coord: &Coordinator, shard: ShardId, row: &ItemInstance) { + item_visible_changed(view, shard, None, Some(row), item_slot_read(coord)); let (coord, row) = (coord.clone(), row.clone()); item_owner_job(view, shard, row.owner_guid, move |viewer| { let enchantment = item_enchantment_word(&coord, &row); @@ -2141,6 +2142,7 @@ fn item_updated( old: &ItemInstance, row: &ItemInstance, ) { + item_visible_changed(view, shard, Some(old), Some(row), item_slot_read(coord)); let (coord, old, row) = (coord.clone(), old.clone(), row.clone()); item_owner_job(view, shard, row.owner_guid, move |viewer| { let guard = coord.0.coord(); @@ -2154,6 +2156,7 @@ fn item_updated( } fn item_deleted(view: &WorldView, coord: &Coordinator, shard: ShardId, row: &ItemInstance) { + item_visible_changed(view, shard, Some(row), None, item_slot_read(coord)); let (coord, row) = (coord.clone(), row.clone()); item_owner_job(view, shard, row.owner_guid, move |viewer| { let guard = coord.0.coord(); @@ -2161,6 +2164,77 @@ fn item_deleted(view: &WorldView, coord: &Coordinator, shard: ShardId, row: &Ite }); } +type ItemSlotRead = Arc Option + Send + Sync>; + +fn item_slot_read(coord: &Coordinator) -> ItemSlotRead { + let coord = coord.clone(); + Arc::new(move |owner_guid, slot| { + coord + .0 + .coord() + .conn + .db + .game_item_instance() + .iter() + .find(|item| item.owner_guid == owner_guid && item.slot == slot) + .map(|item| super::reads::view_of_item_row(&item)) + }) +} + +/// Peers see worn gear only. Read the current occupant on the writer so a swap or a later +/// replacement cannot leave a slot empty because an earlier item moved out of it. +fn item_visible_changed( + view: &WorldView, + shard: ShardId, + old: Option<&ItemInstance>, + new: Option<&ItemInstance>, + item_in_slot: ItemSlotRead, +) { + let visible = |row: &ItemInstance| { + ( + row.owner_guid, + row.slot, + row.entry, + row.random_property_id, + row.enchant_id, + ) + }; + if old.map(visible) == new.map(visible) { + return; + } + let mut slots = Vec::with_capacity(2); + for row in old.into_iter().chain(new) { + let slot = (row.owner_guid, row.slot); + if row.slot <= 18 && !slots.contains(&slot) { + slots.push(slot); + } + } + for (owner_guid, slot) in slots { + let key = view + .spatial + .entity_cell_on_shard(EntityLayer::WorldEntity, owner_guid, shard); + for viewer in view.cell_audience(shard, key, BOX_HALF_SPAN, &[]) { + if viewer.self_guid == owner_guid { + continue; + } + let item_in_slot = item_in_slot.clone(); + enqueue(viewer, move |viewer| { + if !viewer.created.lock().unwrap().contains(&owner_guid) { + return Vec::new(); + } + let item = item_in_slot(owner_guid, slot).unwrap_or_default(); + crate::codec::build_visible_item_values(owner_guid, slot, &item) + .map(|values| { + vec![Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( + Box::new(values), + ))] + }) + .unwrap_or_default() + }); + } + } +} + /// Send a rest-state change to its owner on the callback's Shard. fn rest_state_appeared(view: &WorldView, shard: ShardId, row: &RestStateEvent) { let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.character_guid)) else { @@ -3426,17 +3500,18 @@ mod family_audience_tests { 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, 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, + 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, }; use crate::stdb::aoi::ViewerGates; use crate::stdb::bindings::{ AddonMessage, CharacterExplored, CharacterQuest, GuildEvent, GuildPetition, - InstanceRemoval, LevelupEvent, MailArrival, PlayerReputation, SystemMessageEvent, - TeleportEvent, XpEvent, ZoneWeather, + InstanceRemoval, ItemInstance, LevelupEvent, MailArrival, PlayerReputation, + SystemMessageEvent, TeleportEvent, XpEvent, ZoneWeather, }; use crate::stdb::subscriptions::{private_recipient_audience, quest_update_packets}; use crate::stdb::world_index::{CellKey, EntityLayer}; @@ -4938,6 +5013,268 @@ mod family_audience_tests { ); } + 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 260. + assert!( + decoded[0] + .fields + .iter() + .all(|&(field, _)| (260..488).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) + }) + }; + let item = worn_item(8); + committed.lock().unwrap().push(item.clone()); + item_visible_changed(&view, 0, None, Some(&item), read.clone()); + assert_eq!(visible_items(queued_job(&rx)), [(8, 1337, 117, 823)]); + + 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); From 6834cca16a46d02632d654abd83ee6c1be834ed8 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sun, 11 Oct 2026 07:40:14 +0200 Subject: [PATCH 2/3] test(gateway): verify live gear relays through the Coordinator --- .github/workflows/module-durable.yml | 1 + gateway/src/stdb/world_view.rs | 26 ++- .../src/stdb/world_view/item_durable_tests.rs | 163 ++++++++++++++++++ 3 files changed, 181 insertions(+), 9 deletions(-) create mode 100644 gateway/src/stdb/world_view/item_durable_tests.rs diff --git a/.github/workflows/module-durable.yml b/.github/workflows/module-durable.yml index 16de6bd68..2f0abbad7 100644 --- a/.github/workflows/module-durable.yml +++ b/.github/workflows/module-durable.yml @@ -103,6 +103,7 @@ jobs: cargo test -p lyracore-gateway --bin lyracore-gateway stdb::armor::tests::resistance_projection_adds_property_effects_only_while_the_item_is_worn_and_unbroken -- --ignored --exact cargo test -p lyracore-gateway --test provision_cli provision_writes_the_account_to_the_world_shard_and_realm_core -- --ignored --exact cargo test -p lyracore-gateway --test startup_relays -- --ignored --test-threads=1 + cargo test -p lyracore-gateway --bin lyracore-gateway stdb::world_view::item_durable_tests::live_worn_item_insert_enchant_and_delete_reach_a_nearby_peer -- --ignored --exact - name: Run Benilla against private Module instances if: matrix.shard == 0 diff --git a/gateway/src/stdb/world_view.rs b/gateway/src/stdb/world_view.rs index 81a8922d5..043f5b8fa 100644 --- a/gateway/src/stdb/world_view.rs +++ b/gateway/src/stdb/world_view.rs @@ -2181,8 +2181,8 @@ fn item_slot_read(coord: &Coordinator) -> ItemSlotRead { }) } -/// Peers see worn gear only. Read the current occupant on the writer so a swap or a later -/// replacement cannot leave a slot empty because an earlier item moved out of it. +/// Peers see worn gear only. The first eligible writer reads the current slot occupant; +/// all recipients share it so the item table is scanned once per changed slot. fn item_visible_changed( view: &WorldView, shard: ShardId, @@ -2210,6 +2210,7 @@ fn item_visible_changed( } } for (owner_guid, slot) in slots { + let item = Arc::new(OnceLock::new()); let key = view .spatial .entity_cell_on_shard(EntityLayer::WorldEntity, owner_guid, shard); @@ -2218,12 +2219,13 @@ fn item_visible_changed( continue; } let item_in_slot = item_in_slot.clone(); + let item = item.clone(); enqueue(viewer, move |viewer| { if !viewer.created.lock().unwrap().contains(&owner_guid) { return Vec::new(); } - let item = item_in_slot(owner_guid, slot).unwrap_or_default(); - crate::codec::build_visible_item_values(owner_guid, slot, &item) + let item = item.get_or_init(|| item_in_slot(owner_guid, slot).unwrap_or_default()); + crate::codec::build_visible_item_values(owner_guid, slot, item) .map(|values| { vec![Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( Box::new(values), @@ -3483,6 +3485,9 @@ mod cell_audience_tests; #[cfg(test)] mod relay_bench; +#[cfg(test)] +mod item_durable_tests; + #[cfg(all(test, unix))] mod benilla_movement_tests { #[test] @@ -5057,12 +5062,12 @@ mod family_audience_tests { 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 260. + // Build 5875 exposes nineteen 12-word visible items starting at field 258. assert!( decoded[0] .fields .iter() - .all(|&(field, _)| (260..488).contains(&field)), + .all(|&(field, _)| (258..486).contains(&field)), "no TYPE, inventory pointers, item fields or Character sheet fields" ); } @@ -5091,10 +5096,13 @@ mod family_audience_tests { .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); - committed.lock().unwrap().push(item.clone()); - item_visible_changed(&view, 0, None, Some(&item), read.clone()); - assert_eq!(visible_items(queued_job(&rx)), [(8, 1337, 117, 823)]); for changed in [ ItemInstance { diff --git a/gateway/src/stdb/world_view/item_durable_tests.rs b/gateway/src/stdb/world_view/item_durable_tests.rs new file mode 100644 index 000000000..7494b5ddd --- /dev/null +++ b/gateway/src/stdb/world_view/item_durable_tests.rs @@ -0,0 +1,163 @@ +use super::*; +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::{Object, ServerMessage, UpdateMask, VisibleItemIndex}; + +struct TopologyEnv { + previous: [Option; 3], + _guard: std::sync::MutexGuard<'static, ()>, +} + +const TOPOLOGY_VARS: [&str; 3] = [ + "LYRACORE_SHARD_MAP", + "LYRACORE_SHARD_MAP_FILE", + "LYRACORE_REALM_CORE", +]; + +impl TopologyEnv { + fn clear() -> Self { + let guard = super::super::subscriptions::DURABLE_TOPOLOGY_ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let previous = TOPOLOGY_VARS.map(std::env::var_os); + for name in TOPOLOGY_VARS { + std::env::remove_var(name); + } + Self { + previous, + _guard: guard, + } + } +} + +impl Drop for TopologyEnv { + fn drop(&mut self) { + for (name, previous) in TOPOLOGY_VARS.into_iter().zip(&self.previous) { + match previous { + Some(value) => std::env::set_var(name, value), + None => std::env::remove_var(name), + } + } + } +} + +fn worn_values(outbound: Outbound, heard: &mut Vec<(u32, u32, u32)>) { + let message = match outbound { + Outbound::Job(job) => { + for message in job() { + worn_values(message, heard); + } + return; + } + Outbound::One(message) => message, + _ => panic!("the item peer Relay emits typed Character VALUES"), + }; + let ServerOpcodeMessage::SMSG_UPDATE_OBJECT(update) = message else { + panic!("private inventory feedback reached a peer"); + }; + let [Object::Values { + guid1, + mask1: UpdateMask::Player(mask), + }] = update.objects.as_slice() + else { + panic!("private item objects reached a peer"); + }; + assert_eq!(guid1.guid(), 1); + 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 puts the main-hand visible item in fields 438 through 449. + assert!(decoded[0] + .fields + .iter() + .all(|&(field, _)| (438..450).contains(&field))); + let Some(item) = mask.player_visible_item(VisibleItemIndex::Index15) else { + return; + }; + assert_eq!(item.enchants[1], 0); + heard.push((item.item, item.random_property_id, item.enchants[0])); +} + +fn expect_worn(rx: &Receiver, expected: (u32, u32, u32)) { + let mut heard = Vec::new(); + assert!( + poll_until(POLL_TIMEOUT, || { + for outbound in rx.try_iter() { + worn_values(outbound, &mut heard); + } + heard.contains(&expected) + }), + "expected worn item {expected:?}, received {heard:?}" + ); + assert!(heard.iter().all(|item| *item == expected)); +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn live_worn_item_insert_enchant_and_delete_reach_a_nearby_peer() { + let _topology = TopologyEnv::clear(); + let mut standalone = Standalone::start("worn-item-relay"); + standalone.publish_module(); + standalone.assert_call("claim_operator", &[]); + standalone.assert_call("install_guid_range", &["0"]); + standalone.assert_call("debug_spawn_player_entity", &["1"]); + standalone.assert_sql("DELETE FROM game_item_instance WHERE owner_guid = 1 AND slot = 15"); + + let cfg = GatewayConfig { + logon_bind: "127.0.0.1:0".into(), + world_bind: "127.0.0.1:0".into(), + stdb_uri: standalone.server().into(), + module_name: standalone.shard_name().into(), + coordinator_token: Some(standalone.owner_token()), + gateway_id: "worn-item-relay-test".into(), + blocking_task_capacity: BlockingTaskCapacity::new(1), + }; + let runtime = tokio::runtime::Runtime::new().unwrap(); + let coordinator = runtime.block_on(Coordinator::connect(&cfg)).unwrap(); + let view = coordinator.world_view(); + let anchor = view + .spatial + .entity_cell_on_shard(EntityLayer::WorldEntity, 1, 0) + .unwrap(); + 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: 2, + bound_identity: spacetimedb_sdk::Identity::from_byte_array([2; 32]), + map_id: anchor.map_id, + instance_id: anchor.instance_id, + zone_id: AtomicU32::new(0), + tx, + created: Arc::new(Mutex::new(HashSet::from([1, 2]))), + gates: Arc::new(ViewerGates::default()), + skill_slots: Arc::new(Mutex::default()), + explored: Mutex::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(), anchor, 0); + + standalone.assert_call("debug_equip_weapon", &["1", "50"]); + expect_worn(&rx, (50, 0, 0)); + standalone.assert_sql( + "UPDATE game_item_instance SET enchant_id = 7745, random_property_id = 117 WHERE owner_guid = 1 AND slot = 15", + ); + expect_worn(&rx, (50, 117, 823)); + standalone + .assert_sql("UPDATE game_item_instance SET slot = 23 WHERE owner_guid = 1 AND slot = 15"); + expect_worn(&rx, (0, 0, 0)); + standalone + .assert_sql("UPDATE game_item_instance SET slot = 15 WHERE owner_guid = 1 AND slot = 23"); + expect_worn(&rx, (50, 117, 823)); + standalone.assert_sql("DELETE FROM game_item_instance WHERE owner_guid = 1 AND slot = 15"); + expect_worn(&rx, (0, 0, 0)); + view.remove_viewer(viewer.session); +} From 1cc2e0a3f59535a105f0013259ed4a548cdbc751 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sun, 11 Oct 2026 08:06:37 +0200 Subject: [PATCH 3/3] test(gateway): isolate gear relay checks from creature updates --- gateway/src/stdb/world_view/item_durable_tests.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/gateway/src/stdb/world_view/item_durable_tests.rs b/gateway/src/stdb/world_view/item_durable_tests.rs index 7494b5ddd..309434bcb 100644 --- a/gateway/src/stdb/world_view/item_durable_tests.rs +++ b/gateway/src/stdb/world_view/item_durable_tests.rs @@ -62,7 +62,7 @@ fn worn_values(outbound: Outbound, heard: &mut Vec<(u32, u32, u32)>) { mask1: UpdateMask::Player(mask), }] = update.objects.as_slice() else { - panic!("private item objects reached a peer"); + panic!("expected Character VALUES, got {:?}", update.objects); }; assert_eq!(guid1.guid(), 1); let mut bytes = Vec::new(); @@ -103,6 +103,8 @@ fn live_worn_item_insert_enchant_and_delete_reach_a_nearby_peer() { standalone.publish_module(); standalone.assert_call("claim_operator", &[]); standalone.assert_call("install_guid_range", &["0"]); + // Seeded Northshire creatures must not add unrelated packets to this item Relay check. + standalone.assert_sql("UPDATE game_character SET map_id = 1 WHERE guid = 1"); standalone.assert_call("debug_spawn_player_entity", &["1"]); standalone.assert_sql("DELETE FROM game_item_instance WHERE owner_guid = 1 AND slot = 15");