From 21f85a1b586f2745b5f777175ea86c6ca84c4483 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Tue, 11 Aug 2026 13:10:27 +0000 Subject: [PATCH] Implement remaining TestClient service commands (#94) --- README.md | 17 +- api/SHIM-COVERAGE.md | 2 +- crates/libremetaverse/src/animation.rs | 178 +- crates/libremetaverse/src/avatar_manager.rs | 96 +- crates/libremetaverse/src/generated.rs | 4 +- crates/libremetaverse/src/group_manager.rs | 14 + docs/test-client.md | 46 +- programs/README.md | 9 +- programs/src/test_client.rs | 51 +- programs/src/test_client/services.rs | 2673 +++++++++++++++++++ programs/tests/test_client_services_cli.rs | 215 ++ tests/compat/tests/avatar_manager_shims.rs | 17 +- tools/generate_api_shims.py | 2 + 13 files changed, 3300 insertions(+), 24 deletions(-) create mode 100644 programs/src/test_client/services.rs create mode 100644 programs/tests/test_client_services_cli.rs diff --git a/README.md b/README.md index 2412970..fdd7119 100644 --- a/README.md +++ b/README.md @@ -501,13 +501,16 @@ link-aware hierarchy traversal, and deterministic export. Their command surfaces, limits, isolated CLI tests, and the status of every remaining program are documented in the [`native programs guide`](programs/README.md). -The native `TestClient` now exposes its system, communication, inventory, -appearance, asset, movement, object, parcel, estate, and grid command groups -through one bounded multi-client shell. World commands use the native manager +The native `TestClient` now exposes every non-voice system, communication, +inventory, appearance, asset, movement, object, parcel, estate, grid, social, +directory, and statistics command group through one bounded multi-client shell. +World commands use the native manager APIs for movement and teleportation, object inspection and linkset transfer, parcel and map queries, estate terrain transfer, texture caching, and primitive -creation. Offline fake-world directives exercise the same parsing, filtering, -output, cancellation, and safety paths without connecting to a privileged live -grid. Command ownership, resource limits, explicit live-operation gates, and -the focused verification command are documented in the +creation. Social and search commands use cancellable friend, group, avatar, and +paged-directory APIs; complete profile cloning combines capability data with +the simulator's interest block. Offline fixtures exercise the same parsing, +filtering, output, cancellation, and safety paths without connecting to a +privileged live grid. Command ownership, resource limits, explicit +live-operation gates, and the focused verification command are documented in the [`TestClient` guide](docs/test-client.md). diff --git a/api/SHIM-COVERAGE.md b/api/SHIM-COVERAGE.md index 24a1490..31c8ead 100644 --- a/api/SHIM-COVERAGE.md +++ b/api/SHIM-COVERAGE.md @@ -4,7 +4,7 @@ Generated by `python3 tools/generate_api_shims.py`; do not edit by hand. | Assembly | Types | Members | Status | |---|---:|---:|---| -| `LibreMetaverse` | 2,711 | 27,281 | native implementation: 401 types / 17,026 members; remaining surface is callable failure-only shims | +| `LibreMetaverse` | 2,711 | 27,281 | native implementation: 401 types / 17,027 members; remaining surface is callable failure-only shims | | `LibreMetaverse.Imaging.Abstractions` | 3 | 20 | native implementation: 3 types / 20 members; no generated shims remain | | `LibreMetaverse.Imaging.Skia` | 1 | 3 | native implementation: 1 type / 3 members; no generated shims remain | | `LibreMetaverse.LslTools` | 164 | 768 | native implementation: 164 types / 768 members; no generated shims remain | diff --git a/crates/libremetaverse/src/animation.rs b/crates/libremetaverse/src/animation.rs index cff504c..cdb4d33 100644 --- a/crates/libremetaverse/src/animation.rs +++ b/crates/libremetaverse/src/animation.rs @@ -1,6 +1,10 @@ //! Bounded decoder for the simulator's binary BVH animation asset format. -#![allow(clippy::needless_pass_by_value, clippy::unused_self)] // Mapped C# signatures are fixed. +#![allow( + clippy::needless_pass_by_value, + clippy::too_many_lines, + clippy::unused_self +)] // Mapped C# signatures and the source catalog are fixed. use libremetaverse_types::compat::Object; use libremetaverse_types::{Error, UUID, Vector3}; @@ -16,6 +20,163 @@ pub(crate) fn dance1() -> UUID { .expect("built-in DANCE1 UUID must be valid") } +/// Returns the complete built-in viewer animation catalog from the pinned +/// `LibreMetaverse` source snapshot. +pub(crate) fn builtin_animations() -> std::collections::HashMap { + const ENTRIES: &[(&str, &str)] = &[ + ("AFRAID", "6b61c8e8-4747-0d75-12d7-e49ff207a4ca"), + ("AIM_BAZOOKA_R", "b5b4a67d-0aee-30d2-72cd-77b333e932ef"), + ("AIM_BOW_L", "46bb4359-de38-4ed8-6a22-f1f52fe8f506"), + ("AIM_HANDGUN_R", "3147d815-6338-b932-f011-16b56d9ac18b"), + ("AIM_RIFLE_R", "ea633413-8006-180a-c3ba-96dd1d756720"), + ("ANGRY", "5747a48e-073e-c331-f6f3-7c2149613d3e"), + ("AWAY", "fd037134-85d4-f241-72c6-4f42164fedee"), + ("BACKFLIP", "c4ca6188-9127-4f31-0158-23c4e2f93304"), + ("BELLY_LAUGH", "18b3a4b5-b463-bd48-e4b6-71eaac76c515"), + ("BLOW_KISS", "db84829b-462c-ee83-1e27-9bbee66bd624"), + ("BORED", "b906c4ba-703b-1940-32a3-0c7f7d791510"), + ("BOW", "82e99230-c906-1403-4d9c-3889dd98daba"), + ("BRUSH", "349a3801-54f9-bf2c-3bd0-1ac89772af01"), + ("BUSY", "efcf670c-2d18-8128-973a-034ebc806b67"), + ("CLAP", "9b0c1c4e-8ac7-7969-1494-28c874c4f668"), + ("COURTBOW", "9ba1c942-08be-e43a-fb29-16ad440efc50"), + ("CROUCH", "201f3fdf-cb1f-dbec-201f-7333e328ae7c"), + ("CROUCHWALK", "47f5f6fb-22e5-ae44-f871-73aaaf4a6022"), + ("CRY", "92624d3e-1068-f1aa-a5ec-8244585193ed"), + ("CUSTOMIZE", "038fcec9-5ebd-8a8e-0e2e-6e71a0a1ac53"), + ("CUSTOMIZE_DONE", "6883a61a-b27b-5914-a61e-dda118a9ee2c"), + ("DANCE1", "b68a3d7c-de9e-fc87-eec8-543d787e5b0d"), + ("DANCE2", "928cae18-e31d-76fd-9cc9-2f55160ff818"), + ("DANCE3", "30047778-10ea-1af7-6881-4db7a3a5a114"), + ("DANCE4", "951469f4-c7b2-c818-9dee-ad7eea8c30b7"), + ("DANCE5", "4bd69a1d-1114-a0b4-625f-84e0a5237155"), + ("DANCE6", "cd28b69b-9c95-bb78-3f94-8d605ff1bb12"), + ("DANCE7", "a54d8ee2-28bb-80a9-7f0c-7afbbe24a5d6"), + ("DANCE8", "b0dc417c-1f11-af36-2e80-7e7489fa7cdc"), + ("DEAD", "57abaae6-1d17-7b1b-5f98-6d11a6411276"), + ("DRINK", "0f86e355-dd31-a61c-fdb0-3a96b9aad05f"), + ("EMBARRASSED", "514af488-9051-044a-b3fc-d4dbf76377c6"), + ("EXPRESS_AFRAID", "aa2df84d-cf8f-7218-527b-424a52de766e"), + ("EXPRESS_ANGER", "1a03b575-9634-b62a-5767-3a679e81f4de"), + ("EXPRESS_BORED", "214aa6c1-ba6a-4578-f27c-ce7688f61d0d"), + ("EXPRESS_CRY", "d535471b-85bf-3b4d-a542-93bea4f59d33"), + ("EXPRESS_DISDAIN", "d4416ff1-09d3-300f-4183-1b68a19b9fc1"), + ( + "EXPRESS_EMBARRASSED", + "0b8c8211-d78c-33e8-fa28-c51a9594e424", + ), + ("EXPRESS_FROWN", "fee3df48-fa3d-1015-1e26-a205810e3001"), + ("EXPRESS_KISS", "1e8d90cc-a84e-e135-884c-7c82c8b03a14"), + ("EXPRESS_LAUGH", "62570842-0950-96f8-341c-809e65110823"), + ("EXPRESS_OPEN_MOUTH", "d63bc1f9-fc81-9625-a0c6-007176d82eb7"), + ("EXPRESS_REPULSED", "f76cda94-41d4-a229-2872-e0296e58afe1"), + ("EXPRESS_SAD", "eb6ebfb2-a4b3-a19c-d388-4dd5c03823f7"), + ("EXPRESS_SHRUG", "a351b1bc-cc94-aac2-7bea-a7e6ebad15ef"), + ("EXPRESS_SMILE", "b7c7c833-e3d3-c4e3-9fc0-131237446312"), + ("EXPRESS_SURPRISE", "728646d9-cc79-08b2-32d6-937f0a835c24"), + ("EXPRESS_TONGUE_OUT", "835965c6-7f2f-bda2-5deb-2478737f91bf"), + ("EXPRESS_TOOTHSMILE", "b92ec1a5-e7ce-a76b-2b05-bcdb9311417e"), + ("EXPRESS_WINK", "da020525-4d94-59d6-23d7-81fdebf33148"), + ("EXPRESS_WORRY", "9c05e5c7-6f07-6ca4-ed5a-b230390c3950"), + ("FALLDOWN", "666307d9-a860-572d-6fd4-c3ab8865c094"), + ("FEMALE_WALK", "f5fc7433-043d-e819-8298-f519a119b688"), + ("FINGER_WAG", "c1bc7f36-3ba0-d844-f93c-93be945d644f"), + ("FIST_PUMP", "7db00ccd-f380-f3ee-439d-61968ec69c8a"), + ("FLY", "aec4610c-757f-bc4e-c092-c6e9caf18daf"), + ("FLYSLOW", "2b5a38b2-5e00-3a97-a495-4c826bc443e6"), + ("HELLO", "9b29cd61-c45b-5689-ded2-91756b8d76a9"), + ("HOLD_BAZOOKA_R", "ef62d355-c815-4816-2474-b1acc21094a6"), + ("HOLD_BOW_L", "8b102617-bcba-037b-86c1-b76219f90c88"), + ("HOLD_HANDGUN_R", "efdc1727-8b8a-c800-4077-975fc27ee2f2"), + ("HOLD_RIFLE_R", "3d94bad0-c55b-7dcc-8763-033c59405d33"), + ("HOLD_THROW_R", "7570c7b5-1f22-56dd-56ef-a9168241bbb6"), + ("HOVER", "4ae8016b-31b9-03bb-c401-b1ea941db41d"), + ("HOVER_DOWN", "20f063ea-8306-2562-0b07-5c853b37b31e"), + ("HOVER_UP", "62c5de58-cb33-5743-3d07-9e4cd4352864"), + ("IMPATIENT", "5ea3991f-c293-392e-6860-91dfa01278a3"), + ("JUMP", "2305bd75-1ca9-b03b-1faa-b176b8a8c49e"), + ("JUMP_FOR_JOY", "709ea28e-1573-c023-8bf8-520c8bc637fa"), + ("KISS_MY_BUTT", "19999406-3a3a-d58c-a2ac-d72e555dcf51"), + ("LAND", "7a17b059-12b2-41b1-570a-186368b6aa6f"), + ("LAUGH_SHORT", "ca5b3f14-3194-7a2b-c894-aa699b718d1f"), + ("MEDIUM_LAND", "f4f00d6e-b9fe-9292-f4cb-0ae06ea58d57"), + ("MOTORCYCLE_SIT", "08464f78-3a8e-2944-cba5-0c94aff3af29"), + ("MUSCLE_BEACH", "315c3a41-a5f3-0ba4-27da-f893f769e69b"), + ("NO", "5a977ed9-7f72-44e9-4c4c-6e913df8ae74"), + ("NO_UNHAPPY", "d83fa0e5-97ed-7eb2-e798-7bd006215cb4"), + ("NYAH_NYAH", "f061723d-0a18-754f-66ee-29a44795a32f"), + ("ONETWO_PUNCH", "eefc79be-daae-a239-8c04-890f5d23654a"), + ("PEACE", "b312b10e-65ab-a0a4-8b3c-1326ea8e3ed9"), + ("POINT_ME", "17c024cc-eef2-f6a0-3527-9869876d7752"), + ("POINT_YOU", "ec952cca-61ef-aa3b-2789-4d1344f016de"), + ("PRE_JUMP", "7a4e87fe-de39-6fcb-6223-024b00893244"), + ("PUNCH_LEFT", "f3300ad9-3462-1d07-2044-0fef80062da0"), + ("PUNCH_RIGHT", "c8e42d32-7310-6906-c903-cab5d4a34656"), + ("REPULSED", "36f81a92-f076-5893-dc4b-7c3795e487cf"), + ("ROUNDHOUSE_KICK", "49aea43b-5ac3-8a44-b595-96100af0beda"), + ("RPS_COUNTDOWN", "35db4f7e-28c2-6679-cea9-3ee108f7fc7f"), + ("RPS_PAPER", "0836b67f-7f7b-f37b-c00a-460dc1521f5a"), + ("RPS_ROCK", "42dd95d5-0bc6-6392-f650-777304946c0f"), + ("RPS_SCISSORS", "16803a9f-5140-e042-4d7b-d28ba247c325"), + ("RUN", "05ddbff8-aaa9-92a1-2b74-8fe77a29b445"), + ("SAD", "0eb702e2-cc5a-9a88-56a5-661a55c0676a"), + ("SALUTE", "cd7668a6-7011-d7e2-ead8-fc69eff1a104"), + ("SHOOT_BOW_L", "e04d450d-fdb5-0432-fd68-818aaf5935f8"), + ("SHOUT", "6bd01860-4ebd-127a-bb3d-d1427e8e0c42"), + ("SHRUG", "70ea714f-3a97-d742-1b01-590a8fcd1db5"), + ("SIT", "1a5fe8ac-a804-8a5d-7cbd-56bd83184568"), + ("SIT_FEMALE", "b1709c8d-ecd3-54a1-4f28-d55ac0840782"), + ("SIT_GENERIC", "245f3c54-f1c0-bf2e-811f-46d8eeb386e7"), + ("SIT_GROUND", "1c7600d6-661f-b87b-efe2-d7421eb93c86"), + ( + "SIT_GROUND_staticRAINED", + "1a2bd58e-87ff-0df8-0b4c-53e047b0bb6e", + ), + ("SIT_TO_STAND", "a8dee56f-2eae-9e7a-05a2-6fb92b97e21e"), + ("SLEEP", "f2bed5f9-9d44-39af-b0cd-257b2a17fe40"), + ("SMOKE_IDLE", "d2f2ee58-8ad1-06c9-d8d3-3827ba31567a"), + ("SMOKE_INHALE", "6802d553-49da-0778-9f85-1599a2266526"), + ("SMOKE_THROW_DOWN", "0a9fb970-8b44-9114-d3a9-bf69cfe804d6"), + ("SNAPSHOT", "eae8905b-271a-99e2-4c0e-31106afd100c"), + ("STAND", "2408fe9e-df1d-1d7d-f4ff-1384fa7b350f"), + ("STANDUP", "3da1d753-028a-5446-24f3-9c9b856d9422"), + ("STAND_1", "15468e00-3400-bb66-cecc-646d7c14458e"), + ("STAND_2", "370f3a20-6ca6-9971-848c-9a01bc42ae3c"), + ("STAND_3", "42b46214-4b44-79ae-deb8-0df61424ff4b"), + ("STAND_4", "f22fed8b-a5ed-2c93-64d5-bdd8b93c889f"), + ("STRETCH", "80700431-74ec-a008-14f8-77575e73693f"), + ("STRIDE", "1cb562b0-ba21-2202-efb3-30f82cdf9595"), + ("SURF", "41426836-7437-7e89-025d-0aa4d10f1d69"), + ("SURPRISE", "313b9881-4302-73c0-c7d0-0e7a36b6c224"), + ("SWORD_STRIKE", "85428680-6bf9-3e64-b489-6f81087c24bd"), + ("TALK", "5c682a95-6da4-a463-0bf6-0f5b7be129d1"), + ("TANTRUM", "11000694-3f41-adc2-606b-eee1d66f3724"), + ("THROW_R", "aa134404-7dac-7aca-2cba-435f9db875ca"), + ("TRYON_SHIRT", "83ff59fe-2346-f236-9009-4e3608af64c1"), + ("TURNLEFT", "56e0ba0d-4a9f-7f27-6117-32f2ebbf6135"), + ("TURNRIGHT", "2d6daa51-3192-6794-8e2e-a15f8338ec30"), + ("TYPE", "c541c47f-e0c0-058b-ad1a-d6ae3a4584d9"), + ("WALK", "6ed24bd8-91aa-4b12-ccc7-c97c857ab4e0"), + ("WHISPER", "7693f268-06c7-ea71-fa21-2b30d6533f8f"), + ("WHISTLE", "b1ed7982-c68e-a982-7561-52a88a5298c0"), + ("WINK", "869ecdad-a44b-671e-3266-56aef2e3ac2e"), + ("WINK_HOLLYWOOD", "c0c4030f-c02b-49de-24ba-2331f43fe41c"), + ("WORRY", "9f496bd2-589a-709f-16cc-69bf7df1d36c"), + ("YES", "15dd911d-be82-2856-26db-27659b142875"), + ("YES_HAPPY", "b8c8b2a3-9008-1771-3bfc-90924955ab2d"), + ("YOGA_FLOAT", "42ecd00b-9947-a97c-400a-bbc9174c7aeb"), + ]; + ENTRIES + .iter() + .map(|(name, id)| { + ( + UUID::new_with_string((*id).to_owned()).expect("built-in animation UUID is valid"), + (*name).to_owned(), + ) + }) + .collect() +} + #[derive(Clone, Debug)] pub struct BinBVHJointKey { pub key_element: Vector3, @@ -338,3 +499,18 @@ impl<'a> Cursor<'a> { String::from_utf8(bytes.to_vec()).map_err(|_| Error::Argument) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn built_in_catalog_contains_every_pinned_animation_once() { + let catalog = builtin_animations(); + assert_eq!(catalog.len(), 135); + assert_eq!(catalog.get(&dance1()).map(String::as_str), Some("DANCE1")); + let yoga = UUID::new_with_string("42ecd00b-9947-a97c-400a-bbc9174c7aeb".into()) + .expect("YOGA_FLOAT UUID"); + assert_eq!(catalog.get(&yoga).map(String::as_str), Some("YOGA_FLOAT")); + } +} diff --git a/crates/libremetaverse/src/avatar_manager.rs b/crates/libremetaverse/src/avatar_manager.rs index bc50ff8..0120c7d 100644 --- a/crates/libremetaverse/src/avatar_manager.rs +++ b/crates/libremetaverse/src/avatar_manager.rs @@ -10,9 +10,10 @@ use crate::client_core::ClientWeakHandle; use crate::packet_catalog::PacketType; use crate::packets::{ - AvatarAnimationPacket, AvatarPickerRequestPacket, AvatarPropertiesRequestPacket, - ClassifiedInfoRequestPacket, GenericMessagePacket, GenericMessagePacketParamListBlock, - TrackAgentPacket, UUIDNameRequestPacket, UUIDNameRequestPacketUUIDNameBlockBlock, + AvatarAnimationPacket, AvatarInterestsReplyPacket, AvatarPickerRequestPacket, + AvatarPropertiesRequestPacket, ClassifiedInfoRequestPacket, GenericMessagePacket, + GenericMessagePacketParamListBlock, TrackAgentPacket, UUIDNameRequestPacket, + UUIDNameRequestPacketUUIDNameBlockBlock, }; use crate::{Error, GridClient}; use libremetaverse_structured_data::{OSD, OSDMap, OSDParser}; @@ -121,6 +122,27 @@ fn handle_avatar_animation(inner: &AvatarManagerInner, data: Vec) { }); } +fn decode_avatar_interests(data: Vec, avatar_id: UUID) -> Option { + let mut offset = 0; + let packet = AvatarInterestsReplyPacket::new_with_bytes_int32(data, &mut offset).ok()?; + if packet.agent_data.avatar_id != avatar_id { + return None; + } + Some(crate::AvatarInterests { + languages_text: String::from_utf8_lossy(&packet.properties_data.languages_text) + .trim_end_matches('\0') + .to_owned(), + skills_mask: packet.properties_data.skills_mask, + skills_text: String::from_utf8_lossy(&packet.properties_data.skills_text) + .trim_end_matches('\0') + .to_owned(), + want_to_mask: packet.properties_data.want_to_mask, + want_to_text: String::from_utf8_lossy(&packet.properties_data.want_to_text) + .trim_end_matches('\0') + .to_owned(), + }) +} + impl AvatarAnimationEventArgs { pub(crate) fn native_new(avatar_id: UUID, animations: Vec) -> Result { if animations.len() > 256 { @@ -476,6 +498,53 @@ impl AvatarManager { ) } + /// Requests the legacy profile-interest block and waits for the matching + /// simulator reply. + /// + /// The modern `AgentProfile` capability includes groups and picks but does + /// not include the five interest fields. Consumers that need a complete + /// editable profile can pair this request with [`Self::request_agent_profile`]. + /// + /// # Errors + /// + /// Returns an error if there is no active simulator, the request cannot be + /// encoded or sent, the matching reply channel closes, or cancellation is + /// requested. + pub async fn request_avatar_interests( + &self, + avatar_id: UUID, + cancellation_token: Option, + ) -> Result { + let network = self.live_network()?; + let token = cancellation_token.unwrap_or_default(); + token.throw_if_cancellation_requested()?; + let (sender, receiver) = tokio::sync::oneshot::channel(); + let sender = Arc::new(Mutex::new(Some(sender))); + let callback = Arc::clone(&sender); + let subscription = network.subscribe_raw_packet(Arc::new(move |event| { + if event.packet_type != PacketType::AvatarInterestsReply { + return; + } + let Some(interests) = decode_avatar_interests(event.data, avatar_id) else { + return; + }; + if let Some(sender) = callback + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + { + let _ = sender.send(interests); + } + })); + self.native_request_avatar_properties(avatar_id)?; + let result = tokio::select! { + value = receiver => value.map_err(|_| Error::InvalidOperation), + () = token.cancelled() => Err(Error::Cancelled), + }; + drop(subscription); + result + } + pub(crate) fn native_request_avatar_name_search( &self, name: String, @@ -1018,4 +1087,25 @@ mod tests { assert_eq!(*received.lock().expect("event lock"), [0]); } + + #[test] + fn avatar_interests_reply_is_correlated_and_decoded() { + let avatar_id = UUID::random().expect("avatar id"); + let mut packet = AvatarInterestsReplyPacket::new_with_constructor().expect("packet"); + packet.agent_data.avatar_id = avatar_id; + packet.properties_data.languages_text = b"Rust, LSL\0".to_vec(); + packet.properties_data.skills_mask = 0x12; + packet.properties_data.skills_text = b"Protocol testing\0".to_vec(); + packet.properties_data.want_to_mask = 0x34; + packet.properties_data.want_to_text = b"Build things\0".to_vec(); + let bytes = packet.to_bytes_with_method().expect("packet bytes"); + + assert!(decode_avatar_interests(bytes.clone(), UUID::zero()).is_none()); + let interests = decode_avatar_interests(bytes, avatar_id).expect("matching reply"); + assert_eq!(interests.languages_text, "Rust, LSL"); + assert_eq!(interests.skills_mask, 0x12); + assert_eq!(interests.skills_text, "Protocol testing"); + assert_eq!(interests.want_to_mask, 0x34); + assert_eq!(interests.want_to_text, "Build things"); + } } diff --git a/crates/libremetaverse/src/generated.rs b/crates/libremetaverse/src/generated.rs index 1d4521c..2fcf7ad 100644 --- a/crates/libremetaverse/src/generated.rs +++ b/crates/libremetaverse/src/generated.rs @@ -2811,7 +2811,9 @@ impl Animations { libremetaverse_types::compat::FrozenDictionary, crate::Error, > { - libremetaverse_types::not_implemented("M:LibreMetaverse.Animations.ToDictionary") + Ok(libremetaverse_types::compat::FrozenDictionary( + crate::animation::builtin_animations(), + )) } } diff --git a/crates/libremetaverse/src/group_manager.rs b/crates/libremetaverse/src/group_manager.rs index ca4d40c..2f5bbea 100644 --- a/crates/libremetaverse/src/group_manager.rs +++ b/crates/libremetaverse/src/group_manager.rs @@ -28,6 +28,11 @@ fn mutex(value: &Mutex) -> std::sync::MutexGuard<'_, T> { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) } +fn read(value: &RwLock) -> std::sync::RwLockReadGuard<'_, T> { + value + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} fn write(value: &RwLock) -> std::sync::RwLockWriteGuard<'_, T> { value .write() @@ -260,6 +265,15 @@ impl GroupManager { pub fn current_group_count(&self) -> i32 { self.inner.group_count.load(Ordering::Acquire) } + + /// Returns a stable snapshot of the groups currently reported for this agent. + /// + /// The cache is replaced atomically whenever an `AgentGroupDataUpdate` arrives, + /// so callers never observe a partially decoded group list. + #[must_use] + pub fn current_groups(&self) -> HashMap { + read(&self.inner.groups).clone() + } pub fn group_membership_limit(&self) -> i32 { let mut client = (*self.inner.client).clone(); client.self_().benefits().group_membership_limit() diff --git a/docs/test-client.md b/docs/test-client.md index 18c255b..202a5de 100644 --- a/docs/test-client.md +++ b/docs/test-client.md @@ -3,8 +3,26 @@ The `test-client` binary is a native Rust multi-client shell. It does not load the former C# executable or start a CLR process. The command registry owns the implemented system, communication, inventory, appearance, asset, movement, -object, parcel, estate, and grid commands, and reports the remaining upstream -targets through `pending_test_client_commands()`. +object, parcel, estate, grid, agent, friends, groups, directory, and statistics +commands. `pending_test_client_commands()` now reports only the two explicitly +owned voice-native adapters (`ParcelVoiceInfo` and `VoiceAcountCommand`). + +## Social, directory, and statistics behavior + +Directory searches use native correlated, paged result streams and retain the +upstream 20-second response bound. Group cache, member, and role requests are +correlated with their native events and preserve the upstream timeouts. Friend +mapping, UUID/name resolution, event details, and group operations propagate +shell cancellation. + +`who` maintains a live coarse-location roster and resolves display names; +`bots` also tracks source UUIDs from decoded viewer-effect packets. Profile +cloning combines the AgentProfile capability with the matching legacy interests +reply to replace profile text, images, URL, interests, and picks before joining +and activating the target's public groups. `play list` exposes all 135 built-in +animations from the pinned source catalog and also accepts arbitrary animation +asset UUIDs. Statistics come from each simulator's thread-safe native counters. +Directory and collection results are bounded at 65,535 entries. ## World command behavior @@ -39,6 +57,13 @@ terrain download/upload additionally require `--allow-estate-actions`. Consequently the default invocation cannot move an avatar, alter or create an object, upload an asset, or invoke an estate-owner action. +Profile cloning, generic messages, animation starts, touches, group activation, +invitations, joins, and leaves use the same two-part mutation gate. Read-only +friend, group, directory, region, uptime, dilation, and statistics commands do +not require a gate. Profile cloning and direct group joins additionally require +`--allow-spending`, because an open group can charge an enrollment fee and the +client cannot safely infer that fee before every UUID-based join. + Output paths reject parent-directory traversal. Timeouts, cancellation, file limits, result limits, and linkset limits apply equally to live and fake-grid runs. Passwords and login secrets continue to pass through the shell's common @@ -52,13 +77,22 @@ Fake scripts can seed the same command layer with `!world-region`, `!world-agent-location`, `!world-estate`, `!world-asset`, and `!world-syntax` tab-separated directives. These fixtures never open a network connection. +The service layer additionally accepts `!service-avatar`, `!service-friend`, +`!service-group`, `!service-group-result`, `!service-group-member`, +`!service-group-role`, `!service-person`, `!service-classified`, +`!service-event`, `!service-event-info`, `!service-land`, `!service-place`, +`!service-sim`, `!service-animation`, and `!service-utilization[-row]` fixtures. + Run the issue-focused compatibility gate with: ```text cargo test -p libremetaverse-programs --test test_client_world_cli +cargo test -p libremetaverse-programs --test test_client_services_cli ``` -The gate drives every command owned by the movement/object/land/grid issue, -checks the exact fake backend calls, verifies destructive-operation guards, -round-trips an exported linkset through import, compares two exports byte for -byte, and validates generated terrain, texture, and TGA files. +The world gate drives every command owned by the movement/object/land/grid +issue, checks the exact fake backend calls, round-trips a linkset through +import, and validates generated assets. The services gate drives every one of +the 28 remaining non-voice commands in a single offline session, verifies the +destructive-operation guards, and proves the pending inventory contains only +the two explicitly owned voice adapters. diff --git a/programs/README.md b/programs/README.md index 69b5a95..a43c43c 100644 --- a/programs/README.md +++ b/programs/README.md @@ -13,7 +13,7 @@ here so a source entry is never mistaken for a completed port. | `prim-inspector` | PrimInspector | Implemented with live and deterministic fake-grid discovery | | `inventory-explorer` | InventoryExplorer | Implemented with live inventory and deterministic AIS fixtures | | `irc-gateway` | IRCGateway | Implemented with live IRC/grid transports and deterministic offline scripts | -| `test-client` | TestClient | Native shell, registry, system, communication, inventory, appearance, asset, movement, object, parcel, estate, and grid groups implemented; remaining command group tracked by #94 | +| `test-client` | TestClient | All non-voice command groups implemented with live and deterministic fake-grid backends; voice adapters tracked by #95 and #96 | | `vivox-test` | VivoxTest | Pending milestone 11 issue #95 | | `webrtc-test` | WebRtcTest | Pending milestone 11 issue #96 | @@ -184,6 +184,13 @@ transfer, and grid map/layer/location/wind commands. `textures on` and primitive textures. Detailed command behavior and limits are in [`docs/test-client.md`](../docs/test-client.md). +The final non-voice wave adds nearby-agent and bot inspection, complete profile +cloning, friend mapping, group membership and role operations, paged directory +searches, event details, animation control, object touch, and region/network +statistics. Live profile cloning combines the modern AgentProfile capability +with the legacy interests reply so text, images, URL, interests, picks, and +public groups are all synchronized. + The native `load` command reads a portable command-alias manifest instead of a CLR assembly. Each non-comment line is tab-separated `namedescriptiontemplate`; `{args}` or `$*` inserts the quoted command diff --git a/programs/src/test_client.rs b/programs/src/test_client.rs index b0c2036..884bb2f 100644 --- a/programs/src/test_client.rs +++ b/programs/src/test_client.rs @@ -1,6 +1,7 @@ //! Native `TestClient` shell, registry, and first-wave command groups. mod inventory; +mod services; mod world; use crate::commands::TEST_CLIENT_COMMANDS; @@ -135,6 +136,34 @@ pub const IMPLEMENTED_TEST_CLIENT_COMMANDS: &[&str] = &[ "ShowEffectsCommand", "SleepCommand", "WaitForLoginCommand", + "BotsCommand", + "CloneProfileCommand", + "GenericMessageCommand", + "PlayAnimationCommand", + "TouchCommand", + "WhoCommand", + "Key2NameCommand", + "SearchClassifiedsCommand", + "SearchEventsCommand", + "SearchGroupsCommand", + "SearchLandCommand", + "SearchPeopleCommand", + "SearchPlacesCommand", + "ShowEventDetailsCommand", + "FriendsCommand", + "MapFriendCommand", + "ActivateGroupCommand", + "GroupMembersCommand", + "GroupRolesCommand", + "GroupsCommand", + "InviteGroupCommand", + "JoinGroupCommand", + "LeaveGroupCommand", + "DilationCommand", + "NetstatsCommand", + "RegionInfoCommand", + "StatsCommand", + "UptimeCommand", ]; #[must_use] @@ -153,7 +182,7 @@ pub fn pending_test_client_commands() -> Vec<&'static str> { version, about = "Run the native LibreMetaverse multi-client command shell", long_about = None, - after_help = "Owned commands include @, debug, echomaster, help, im, imgroup, load, login, logpacket, logout, md5, quit, say, setmaster, setmasterkey, shout, showeffects, sleep, waitforlogin, whisper, plus the inventory, appearance, asset, movement, object, parcel, estate, and grid groups. Run `help` or `help COMMAND` inside the shell for the complete command registry." + after_help = "Owned commands include @, debug, echomaster, help, im, imgroup, load, login, logpacket, logout, md5, quit, say, setmaster, setmasterkey, shout, showeffects, sleep, waitforlogin, and whisper. All non-voice inventory, appearance, asset, movement, object, parcel, estate, grid, social, directory, and statistics commands are also native. Run `help` or `help COMMAND` inside the shell for the complete command registry." )] #[allow(clippy::struct_excessive_bools)] struct Cli { @@ -296,7 +325,7 @@ enum ClientEvent { type BackendFuture<'a, T> = Pin + Send + 'a>>; type InventoryFuture<'a, T> = Pin + 'a>>; -trait ClientBackend: Send + Sync + inventory::Backend + world::Backend { +trait ClientBackend: Send + Sync + inventory::Backend + services::Backend + world::Backend { fn id(&self) -> UUID; fn name(&self) -> String; fn connected(&self) -> bool; @@ -332,6 +361,7 @@ struct LiveBackend { subscriptions: Mutex>, inventory_state: Mutex, world_state: Mutex, + services_state: Mutex, closed: AtomicBool, } @@ -393,6 +423,7 @@ impl LiveBackend { subscriptions: Mutex::new(Vec::new()), inventory_state: Mutex::new(inventory::State::new(account_policy)), world_state: Mutex::new(world::State::new(account_policy, get_textures)), + services_state: Mutex::new(services::State::new(account_policy)), closed: AtomicBool::new(false), }); backend.install(&events, &dropped); @@ -512,6 +543,7 @@ impl LiveBackend { }, ))); world::install_live(self, &mut subscriptions); + services::install_live(self, &mut subscriptions); *lock(&self.subscriptions) = subscriptions; } } @@ -680,6 +712,7 @@ struct FakeBackend { group_members: Arc>>, inventory_state: Mutex, world_state: Mutex, + services_state: Mutex, } impl FakeBackend { @@ -700,6 +733,7 @@ impl FakeBackend { group_members, inventory_state: Mutex::new(inventory::State::new(policy)), world_state: Mutex::new(world::State::new(policy, get_textures)), + services_state: Mutex::new(services::State::new(policy)), } } @@ -820,10 +854,13 @@ impl ProgramOutput { enum CommandCategory { Appearance, Communication, + Friends, + Groups, Inventory, Movement, Objects, Parcel, + Search, Simulator, TestClient, Other, @@ -838,6 +875,7 @@ enum CommandHandler { Im, ImGroup, Inventory(inventory::Command), + Services(services::Command), World(world::Command), Load, Login, @@ -999,6 +1037,7 @@ fn built_in_commands() -> HashMap { .into_iter() .chain(communication_commands()) .chain(inventory::commands()) + .chain(services::commands()) .chain(world::commands()) .map(|(name, description, category, handler)| { ( @@ -1780,6 +1819,9 @@ async fn execute_client_command( CommandHandler::Inventory(command) => { inventory::execute(client.backend.as_ref(), *command, args, from, cancellation).await } + CommandHandler::Services(command) => { + services::execute(client.backend.as_ref(), *command, args, cancellation).await + } CommandHandler::World(command) => { world::execute(client.backend.as_ref(), *command, args, cancellation).await } @@ -2367,6 +2409,10 @@ async fn run_fake_event_directive( result.map_err(|reason| ProgramError::InvalidInput { line, reason })?; return Ok(()); } + if let Some(result) = services::apply_fake_directive(manager, fields) { + result.map_err(|reason| ProgramError::InvalidInput { line, reason })?; + return Ok(()); + } match fields { ["chat", client, source, name, message] => { let event = ClientEvent::Chat { @@ -2670,6 +2716,7 @@ mod tests { assert!(TEST_CLIENT_COMMANDS.contains(command)); assert!(!pending.contains(command)); } + assert_eq!(pending, ["ParcelVoiceInfo", "VoiceAcountCommand"]); } #[test] diff --git a/programs/src/test_client/services.rs b/programs/src/test_client/services.rs new file mode 100644 index 0000000..42aca3b --- /dev/null +++ b/programs/src/test_client/services.rs @@ -0,0 +1,2673 @@ +//! Agent, friends, groups, directory, and statistics commands for native `TestClient`. + +#![allow( + clippy::elidable_lifetime_names, + clippy::format_push_string, + clippy::needless_pass_by_value, + clippy::too_many_lines, + private_interfaces +)] + +use super::{FakeBackend, InventoryFuture as BackendFuture, LiveBackend, lock}; +use libremetaverse::packets::{ + GenericMessagePacket, GenericMessagePacketParamListBlock, PacketType, +}; +use libremetaverse::types::compat::{CancellationToken, Subscription}; +use libremetaverse::types::{UUID, Vector3, Vector3d}; +use libremetaverse::{ + DirectoryManagerAgentSearchData as Person, DirectoryManagerClassified as Classified, + DirectoryManagerDirectoryParcel as Land, DirectoryManagerEventInfo as EventInfo, + DirectoryManagerEventsSearchData as Event, DirectoryManagerGroupSearchData as GroupResult, + DirectoryManagerPlacesSearchData as Place, Group, GroupMember, GroupRole, +}; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant, SystemTime}; +use tokio::sync::oneshot; + +const MAX_RESULTS: usize = 65_535; + +#[derive(Clone, Copy)] +pub(super) enum Command { + Bots, + CloneProfile, + SendGeneric, + Play, + Touch, + Who, + KeyToName, + SearchClassifieds, + SearchEvents, + SearchGroups, + SearchLand, + SearchPeople, + SearchPlaces, + ShowEvent, + Friends, + MapFriend, + ActivateGroup, + GroupMembers, + GroupRoles, + Groups, + InviteGroup, + JoinGroup, + LeaveGroup, + Dilation, + NetStats, + RegionInfo, + Stats, + Uptime, +} + +pub(super) fn commands() -> Vec { + use super::{CommandCategory as Cat, CommandHandler}; + use Command as C; + let service = |command| CommandHandler::Services(command); + vec![ + ( + "bots", + "List avatars not observed producing viewer effects", + Cat::Other, + service(C::Bots), + ), + ( + "cloneprofile", + "Clone another avatar profile. Usage: cloneprofile [target-uuid] --confirm", + Cat::Other, + service(C::CloneProfile), + ), + ( + "sendgeneric", + "Send a generic message. Usage: sendgeneric [method] [parameters...] --confirm", + Cat::Other, + service(C::SendGeneric), + ), + ( + "play", + "Play an animation. Usage: play [uuid|name|list|show] [--confirm]", + Cat::Appearance, + service(C::Play), + ), + ( + "touch", + "Touch an object. Usage: touch [object-uuid] --confirm", + Cat::Objects, + service(C::Touch), + ), + ( + "who", + "List all avatars currently observed", + Cat::Other, + service(C::Who), + ), + ( + "key2name", + "Resolve a UUID to an avatar or group name. Usage: key2name [uuid]", + Cat::Search, + service(C::KeyToName), + ), + ( + "searchclassifieds", + "Search classified ads. Usage: searchclassifieds [text]", + Cat::Search, + service(C::SearchClassifieds), + ), + ( + "searchevents", + "Search events. Usage: searchevents [text]", + Cat::Search, + service(C::SearchEvents), + ), + ( + "searchgroups", + "Search groups. Usage: searchgroups [text]", + Cat::Groups, + service(C::SearchGroups), + ), + ( + "searchland", + "Search land for sale. Usage: searchland [mainland|auction|estate|all] [max-price] [min-size]", + Cat::Search, + service(C::SearchLand), + ), + ( + "searchpeople", + "Search avatars. Usage: searchpeople [text]", + Cat::Search, + service(C::SearchPeople), + ), + ( + "searchplaces", + "Search places. Usage: searchplaces [text]", + Cat::Search, + service(C::SearchPlaces), + ), + ( + "showevent", + "Show event details. Usage: showevent [event-id]", + Cat::Other, + service(C::ShowEvent), + ), + ( + "friends", + "List avatar friends", + Cat::Friends, + service(C::Friends), + ), + ( + "mapfriend", + "Locate a mappable friend. Usage: mapfriend [uuid]", + Cat::Friends, + service(C::MapFriend), + ), + ( + "activategroup", + "Set a group as active. Usage: activategroup [group-name] --confirm", + Cat::Groups, + service(C::ActivateGroup), + ), + ( + "groupmembers", + "List group members. Usage: groupmembers [group-name]", + Cat::Groups, + service(C::GroupMembers), + ), + ( + "grouproles", + "List group roles. Usage: grouproles [group-name]", + Cat::Groups, + service(C::GroupRoles), + ), + ( + "groups", + "List avatar groups", + Cat::Groups, + service(C::Groups), + ), + ( + "invitegroup", + "Invite an avatar to a group. Usage: invitegroup [avatar-uuid] [group-uuid] [role-uuid...] --confirm", + Cat::Groups, + service(C::InviteGroup), + ), + ( + "joingroup", + "Join a group. Usage: joingroup [group-name] | joingroup uuid [group-uuid] --confirm", + Cat::Groups, + service(C::JoinGroup), + ), + ( + "leavegroup", + "Leave a group. Usage: leavegroup [group-name] --confirm", + Cat::Groups, + service(C::LeaveGroup), + ), + ( + "dilation", + "Show time dilation for the current simulator", + Cat::Simulator, + service(C::Dilation), + ), + ( + "netstats", + "Show packet and capability utilization statistics", + Cat::Simulator, + service(C::NetStats), + ), + ( + "regioninfo", + "Show current simulator information", + Cat::Simulator, + service(C::RegionInfo), + ), + ( + "stats", + "Show connection and simulator statistics", + Cat::Simulator, + service(C::Stats), + ), + ( + "uptime", + "Show login name, start time, and elapsed time", + Cat::TestClient, + service(C::Uptime), + ), + ] +} + +#[derive(Clone, Debug, Default)] +struct AvatarView { + id: UUID, + name: String, + group: String, + position: Vector3, + local_id: u32, +} + +#[derive(Clone, Debug, Default)] +struct FriendView { + id: UUID, + name: String, + online: bool, + region_handle: u64, + position: Vector3, +} + +#[derive(Clone, Debug)] +struct GroupView { + group: Group, + members: BTreeMap, + roles: BTreeMap, +} + +#[derive(Clone, Debug, Default)] +struct SimView { + name: String, + id: UUID, + handle: u64, + size_x: u32, + size_y: u32, + access: String, + flags: String, + terrain_base: [UUID; 4], + terrain_detail: [UUID; 4], + water_height: f32, + colo: String, + cpu_ratio: i32, + cpu_class: i32, + product_sku: String, + product_name: String, + dilation: f32, + incoming_bps: i32, + outgoing_bps: i32, + resent_out: i32, + resent_in: i32, + fps: i32, + physics_fps: f32, + agent_updates: f32, + objects: i32, + scripted_objects: i32, + frame_time: f32, + net_time: f32, + image_time: f32, + physics_time: f32, + script_time: f32, + other_time: f32, + agents: i32, + child_agents: i32, + active_scripts: i32, +} + +#[derive(Clone, Debug, Default)] +struct UtilizationView { + enabled: bool, + packets: BTreeMap, + capabilities: BTreeMap, +} + +#[derive(Clone, Debug, Default)] +struct Snapshot { + avatars: BTreeMap, + effect_sources: HashSet, + friends: BTreeMap, + groups: BTreeMap, + classifieds: Vec, + events: Vec, + group_results: Vec, + land: Vec, + people: Vec, + places: Vec, + event_info: BTreeMap, + simulators: Vec, + inbox_count: i32, + utilization: UtilizationView, + animations: BTreeMap, + signaled_animations: BTreeMap, + key_names: BTreeMap, +} + +pub(super) struct State { + policy: super::inventory::MutationPolicy, + fake: Snapshot, + created: Instant, + created_wall: SystemTime, +} + +impl State { + pub(super) fn new(policy: super::inventory::MutationPolicy) -> Self { + let fake = Snapshot { + animations: libremetaverse::Animations::to_dictionary() + .expect("built-in animation catalog is valid") + .0 + .into_iter() + .map(|(id, name)| (name, id)) + .collect(), + ..Snapshot::default() + }; + Self { + policy, + fake, + created: Instant::now(), + created_wall: SystemTime::now(), + } + } +} + +#[derive(Clone, Debug)] +enum Query { + Key(UUID), + Classifieds(String), + Events(String), + Groups { + text: String, + timeout: Duration, + }, + Land { + kind: String, + max_price: i32, + min_size: i32, + }, + People(String), + Places(String), + EventInfo(u32), + MapFriend(UUID), + GroupMembers(UUID), + GroupRoles(UUID), + RefreshGroups, +} + +#[derive(Clone, Debug)] +enum Mutation { + CloneProfile(UUID), + Generic { + method: String, + parameters: Vec, + }, + Play(UUID), + Touch(UUID), + ActivateGroup(UUID), + Invite { + avatar: UUID, + group: UUID, + roles: Vec, + }, + JoinGroup(UUID), + LeaveGroup(UUID), +} + +pub(super) trait Backend { + fn services_state(&self) -> &Mutex; + fn services_name(&self) -> String; + fn services_snapshot<'a>( + &'a self, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result>; + fn services_query<'a>( + &'a self, + query: Query, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result>; + fn services_mutate<'a>( + &'a self, + mutation: Mutation, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result>; + fn apply_services_fixture(&self, _fields: &[&str]) -> Result<(), &'static str> { + Err("services fixture requires fake-grid mode") + } +} + +pub(super) async fn execute( + backend: &B, + command: Command, + args: &[String], + cancellation: CancellationToken, +) -> String { + let result = match command { + Command::Bots => bots(backend, cancellation).await, + Command::CloneProfile => clone_profile(backend, args, cancellation).await, + Command::SendGeneric => send_generic(backend, args, cancellation).await, + Command::Play => play(backend, args, cancellation).await, + Command::Touch => touch(backend, args, cancellation).await, + Command::Who => who(backend, cancellation).await, + Command::KeyToName => key_to_name(backend, args, cancellation).await, + Command::SearchClassifieds => search_classifieds(backend, args, cancellation).await, + Command::SearchEvents => search_events(backend, args, cancellation).await, + Command::SearchGroups => search_groups(backend, args, cancellation).await, + Command::SearchLand => search_land(backend, args, cancellation).await, + Command::SearchPeople => search_people(backend, args, cancellation).await, + Command::SearchPlaces => search_places(backend, args, cancellation).await, + Command::ShowEvent => show_event(backend, args, cancellation).await, + Command::Friends => friends(backend, cancellation).await, + Command::MapFriend => map_friend(backend, args, cancellation).await, + Command::ActivateGroup => activate_group(backend, args, cancellation).await, + Command::GroupMembers => group_members(backend, args, cancellation).await, + Command::GroupRoles => group_roles(backend, args, cancellation).await, + Command::Groups => groups(backend, cancellation).await, + Command::InviteGroup => invite_group(backend, args, cancellation).await, + Command::JoinGroup => join_group(backend, args, cancellation).await, + Command::LeaveGroup => leave_group(backend, args, cancellation).await, + Command::Dilation => dilation(backend, cancellation).await, + Command::NetStats => netstats(backend, cancellation).await, + Command::RegionInfo => region_info(backend, cancellation).await, + Command::Stats => stats(backend, cancellation).await, + Command::Uptime => Ok(uptime(backend)), + }; + result.unwrap_or_else(|error| error) +} + +fn parse_uuid(value: &str, message: &'static str) -> Result { + UUID::new_with_string(value.into()).map_err(|_| message.into()) +} + +fn authorize<'a, B: Backend + ?Sized>( + backend: &B, + args: &'a [String], + spending: bool, +) -> Result<&'a [String], String> { + let policy = lock(backend.services_state()).policy; + if !policy.mutations() { + return Err("Live mutations are disabled; restart with --allow-live-mutations".into()); + } + if spending && !policy.spending() { + return Err("Group join blocked; restart with --allow-spending".into()); + } + if args.last().map(String::as_str) != Some("--confirm") { + return Err("This command changes live grid state; append --confirm".into()); + } + Ok(&args[..args.len() - 1]) +} + +fn avatar_line(avatar: &AvatarView) -> String { + format!( + "{} (Group: {}, Location: {:?}, UUID: {} LocalID: {})", + avatar.name, avatar.group, avatar.position, avatar.id, avatar.local_id + ) +} + +async fn who( + backend: &B, + cancellation: CancellationToken, +) -> Result { + let snapshot = backend.services_snapshot(cancellation).await?; + Ok(snapshot + .avatars + .values() + .map(avatar_line) + .collect::>() + .join("\n")) +} + +async fn bots( + backend: &B, + cancellation: CancellationToken, +) -> Result { + let snapshot = backend.services_snapshot(cancellation).await?; + Ok(snapshot + .avatars + .values() + .filter(|avatar| !snapshot.effect_sources.contains(&avatar.id)) + .map(|avatar| format!("{} Is Probably a bot", avatar_line(avatar))) + .collect::>() + .join("\n")) +} + +async fn clone_profile( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + let args = authorize(backend, args, true)?; + if args.len() != 1 { + return Err("Clones another avatars profile as closely as possible. WARNING: This command will destroy your existing profile! Usage: cloneprofile [targetuuid] --confirm".into()); + } + let target = parse_uuid(&args[0], "Usage: cloneprofile [targetuuid] --confirm")?; + backend + .services_mutate(Mutation::CloneProfile(target), cancellation) + .await?; + Ok(format!( + "Synchronized our profile to the profile of {target}" + )) +} + +async fn send_generic( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + let args = authorize(backend, args, false)?; + let Some(method) = args.first() else { + return Err("Usage: sendgeneric method arg1 arg2 ... --confirm".into()); + }; + let parameters = args[1..].to_vec(); + backend + .services_mutate( + Mutation::Generic { + method: method.clone(), + parameters: parameters.clone(), + }, + cancellation, + ) + .await?; + let suffix = if parameters.is_empty() { + String::new() + } else { + format!(" {}", parameters.join(" ")) + }; + Ok(format!( + "Sent generic message with method {method}, params{suffix}" + )) +} + +async fn play( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + const USAGE: &str = "Usage:\n\tplay list - list the built in animations\n\tplay show - show any currently playing animations\n\tplay UUID --confirm - play an animation asset\n\tplay ANIMATION --confirm - play a value returned from \"play list\"\n"; + if args.len() == 1 && args[0].eq_ignore_ascii_case("list") { + let snapshot = backend.services_snapshot(cancellation).await?; + return Ok(snapshot + .animations + .keys() + .cloned() + .collect::>() + .join("\n")); + } + if args.len() == 1 && args[0].eq_ignore_ascii_case("show") { + let snapshot = backend.services_snapshot(cancellation).await?; + return Ok(snapshot + .signaled_animations + .iter() + .map(|(id, sequence)| { + snapshot + .animations + .iter() + .find_map(|(name, candidate)| (candidate == id).then_some(name.as_str())) + .map_or_else( + || format!("The {id} Asset Animation is being played, sequence is {sequence}"), + |name| format!("The {name} System Animation is being played, sequence is {sequence}"), + ) + }) + .collect::()); + } + let args = authorize(backend, args, false)?; + if args.len() != 1 { + return Err(USAGE.into()); + } + let snapshot = backend.services_snapshot(cancellation.clone()).await?; + let id = UUID::new_with_string(args[0].clone()) + .ok() + .or_else(|| { + snapshot + .animations + .get(&args[0].to_ascii_uppercase()) + .copied() + }) + .ok_or(USAGE)?; + backend + .services_mutate(Mutation::Play(id), cancellation) + .await?; + Ok(String::new()) +} + +async fn touch( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + let args = authorize(backend, args, false)?; + if args.len() != 1 { + return Err("Usage: touch [object-uuid] --confirm".into()); + } + let id = UUID::new_with_string(args[0].clone()) + .map_err(|_| format!("{} is not a valid UUID", args[0]))?; + backend + .services_mutate(Mutation::Touch(id), cancellation) + .await +} + +async fn key_to_name( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + if args.is_empty() { + return Err("Usage: key2name UUID".into()); + } + let key = parse_uuid(args[0].trim(), "invalid UUID") + .map_err(|_| format!("UUID {} appears to be invalid", args[0].trim()))?; + let snapshot = backend + .services_query(Query::Key(key), cancellation) + .await?; + snapshot.key_names.get(&key).map_or_else( + || { + Ok( + "Timeout waiting for reply, this could mean the Key is not an avatar or a group" + .into(), + ) + }, + |(group, name)| { + Ok(format!( + "{}: {name} {key}", + if *group { "Group" } else { "Avatar" } + )) + }, + ) +} + +fn search_text(args: &[String], usage: &'static str) -> Result { + if args.is_empty() { + Err(usage.into()) + } else { + Ok(args.join(" ").trim_end().into()) + } +} + +async fn search_classifieds( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + let text = search_text(args, "Usage: searchclassifieds [search text]")?; + let snapshot = backend + .services_query(Query::Classifieds(text.clone()), cancellation) + .await?; + let mut output = format!( + "Your search string '{text}' returned {} classified ads\n", + snapshot.classifieds.len() + ); + for ad in snapshot.classifieds { + output.push_str(&format!("{}\n", ad.to_string())); + } + Ok(output) +} + +async fn search_events( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + let text = search_text(args, "Usage: searchevents [search text]")?; + let snapshot = backend + .services_query(Query::Events(text.clone()), cancellation) + .await?; + let mut output = String::new(); + if snapshot.events.len() == 1 && snapshot.events[0].id == 0 { + output.push_str("No Results matched your search string\n"); + } else { + for event in &snapshot.events { + output.push_str(&format!( + "Event ID: {} Event Name: {} Event Date: {}\n", + event.id, event.name, event.date + )); + } + } + output.push_str(&format!( + "Your query '{text}' matched {} Events. ", + snapshot.events.len() + )); + Ok(output) +} + +async fn search_groups( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + let text = search_text(args, "Usage: searchgroups [search text]")?; + let snapshot = backend + .services_query( + Query::Groups { + text: text.clone(), + timeout: Duration::from_secs(20), + }, + cancellation, + ) + .await?; + let mut output = String::new(); + if snapshot.group_results.is_empty() { + output.push_str("Didn't find any groups that matched your query :(\n"); + } + for group in &snapshot.group_results { + output.push_str(&format!( + "Group {} ({}) has {} members\n", + group.group_name, group.group_id, group.members + )); + } + output.push_str(&format!( + "Your query '{text}' matched {} Groups. ", + snapshot.group_results.len() + )); + Ok(output) +} + +fn land_usage() -> String { + "Usage: searchland [type] [max price] [min size]\n\twhere [type] is one of: mainland, auction, estate, all\n\tif [max price] or [min size] are 0 that parameter will be ignored\n\nexample: \"searchland mainland 0 512\" // shows the lowest priced mainland that is larger than 512/m2\n".into() +} + +async fn search_land( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + if args.len() < 3 { + return Err(land_usage()); + } + let raw = args[0].to_ascii_lowercase(); + let kind = if raw.starts_with("au") { + "auction" + } else if raw.starts_with('m') { + "mainland" + } else if raw.starts_with('e') { + "estate" + } else if raw.starts_with("al") { + "all" + } else { + return Err(land_usage()); + } + .to_owned(); + let max_price = args[1].parse().map_err(|_| land_usage())?; + let min_size = args[2].parse().map_err(|_| land_usage())?; + let snapshot = backend + .services_query( + Query::Land { + kind, + max_price, + min_size, + }, + cancellation, + ) + .await?; + let mut output = String::new(); + for parcel in &snapshot.land { + output.push_str(&format!("{}\n", parcel.to_string())); + } + output.push_str(&format!("{} results\n", snapshot.land.len())); + Ok(output) +} + +async fn search_people( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + let text = search_text(args, "Usage: searchpeople [search text]")?; + let snapshot = backend + .services_query(Query::People(text.clone()), cancellation) + .await?; + let mut output = String::new(); + if snapshot.people.is_empty() { + output.push_str("Didn't find any people that matched your query :(\n"); + } + for person in &snapshot.people { + output.push_str(&format!( + "{} {} ({})\n", + person.first_name, person.last_name, person.agent_id + )); + } + output.push_str(&format!( + "Your query '{text}' matched {} People. ", + snapshot.people.len() + )); + Ok(output) +} + +async fn search_places( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + let text = search_text(args, "Usage: searchplaces [search text]")?; + let snapshot = backend + .services_query(Query::Places(text.clone()), cancellation) + .await?; + let mut output = format!( + "Your search string '{text}' returned {} results\n", + snapshot.places.len() + ); + for place in snapshot.places { + output.push_str(&format!("{}\n", place.to_string())); + } + Ok(output) +} + +async fn show_event( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + if args.is_empty() { + return Err("Usage: showevent [eventID] (use searchevents to get ID)".into()); + } + let id = args[0] + .parse() + .map_err(|_| "Usage: showevent [eventID] (use searchevents to get ID)".to_owned())?; + let snapshot = backend + .services_query(Query::EventInfo(id), cancellation) + .await?; + let event = snapshot + .event_info + .get(&id) + .ok_or_else(|| format!("Sent query for Event {id}"))?; + let x = event.global_pos.x.rem_euclid(256.0); + let y = event.global_pos.y.rem_euclid(256.0); + Ok(format!( + "Sent query for Event {id}\nsecondlife://{}/{x}/{y}/0\n{}", + event.sim_name, + event.to_string() + )) +} + +async fn friends( + backend: &B, + cancellation: CancellationToken, +) -> Result { + let snapshot = backend.services_snapshot(cancellation).await?; + if snapshot.friends.is_empty() { + return Ok("No Friends".into()); + } + let mut output = format!("has {} friends:\n", snapshot.friends.len()); + for friend in snapshot.friends.values() { + output.push_str(&format!("{}, {}\n", friend.id, friend.name)); + } + Ok(output) +} + +async fn map_friend( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + if args.len() != 1 { + return Err("Usage: mapfriend [avatar UUID]".into()); + } + let id = parse_uuid(&args[0], "Usage: mapfriend [avatar UUID]")?; + let snapshot = backend + .services_query(Query::MapFriend(id), cancellation) + .await?; + let friend = snapshot + .friends + .get(&id) + .ok_or_else(|| format!("Timeout waiting for reply, Do you have mapping rights on {id}?"))?; + if !friend.online || friend.region_handle == 0 { + return Ok(format!("Found Friend {id}, But they appear to be offline")); + } + Ok(format!( + "Found Friend {id} in {} at {}/{}", + friend.region_handle, friend.position.x, friend.position.y + )) +} + +fn resolve_group(snapshot: &Snapshot, name: &str) -> Option<(UUID, String)> { + snapshot + .groups + .iter() + .find(|(_, group)| group.group.name.eq_ignore_ascii_case(name)) + .map(|(id, group)| (*id, group.group.name.clone())) +} + +async fn activate_group( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + let args = authorize(backend, args, false)?; + if args.is_empty() { + return Err("Set a group as active. Usage: activategroup GroupName --confirm".into()); + } + let name = args.join(" ").trim().to_owned(); + let snapshot = backend + .services_query(Query::RefreshGroups, cancellation.clone()) + .await?; + let Some((id, _)) = resolve_group(&snapshot, &name) else { + return Ok(format!( + "{} doesn't seem to be member of the group {name}", + backend.services_name() + )); + }; + backend + .services_mutate(Mutation::ActivateGroup(id), cancellation) + .await +} + +async fn group_members( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + if args.is_empty() { + return Err("Dump group members to console. Usage: groupmembers GroupName".into()); + } + let name = args.join(" ").trim().to_owned(); + let groups = backend + .services_query(Query::RefreshGroups, cancellation.clone()) + .await?; + let Some((id, canonical)) = resolve_group(&groups, &name) else { + return Ok(format!( + "{} doesn't seem to be member of the group {name}", + backend.services_name() + )); + }; + let snapshot = backend + .services_query(Query::GroupMembers(id), cancellation) + .await?; + let group = snapshot + .groups + .get(&id) + .ok_or("Group members reply did not contain the requested group")?; + let mut output = format!("GroupMembers: GroupUUID {id}\nGroupMembers: GroupName {canonical}\n"); + for member in group.members.keys() { + output.push_str(&format!("GroupMembers: MemberUUID {member}\n")); + } + output.push_str(&format!( + "GroupMembers: MemberCount {}\n{} got group members", + group.members.len(), + backend.services_name() + )); + Ok(output) +} + +async fn group_roles( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + if args.is_empty() { + return Err("Dump group roles to console. Usage: grouproles GroupName".into()); + } + let name = args.join(" ").trim().to_owned(); + let groups = backend + .services_query(Query::RefreshGroups, cancellation.clone()) + .await?; + let Some((id, canonical)) = resolve_group(&groups, &name) else { + return Ok(format!( + "{} doesn't seem to have any roles in the group {name}", + backend.services_name() + )); + }; + let snapshot = backend + .services_query(Query::GroupRoles(id), cancellation) + .await?; + let group = snapshot + .groups + .get(&id) + .ok_or("Group roles reply did not contain the requested group")?; + let mut output = format!("GroupRole: GroupUUID {id}\nGroupRole: GroupName {canonical}\n"); + for role in group.roles.values() { + output.push_str(&format!( + "GroupRole: Role {} {}|{}\n", + role.id, role.name, role.title + )); + } + output.push_str(&format!( + "GroupRole: RoleCount {}\n{} got group roles", + group.roles.len(), + backend.services_name() + )); + Ok(output) +} + +async fn groups( + backend: &B, + cancellation: CancellationToken, +) -> Result { + let snapshot = backend + .services_query(Query::RefreshGroups, cancellation) + .await?; + if snapshot.groups.is_empty() { + return Ok("No groups".into()); + } + let mut output = format!("got {} groups:\n", snapshot.groups.len()); + for (id, group) in snapshot.groups { + output.push_str(&format!("{id}, {}\n", group.group.name)); + } + Ok(output) +} + +async fn invite_group( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + let args = authorize(backend, args, false)?; + if args.len() < 2 { + return Err("invite an avatar into a group. Usage: invitegroup AvatarUUID GroupUUID RoleUUID* --confirm".into()); + } + let avatar = parse_uuid(&args[0], "parse error avatar UUID")?; + let group = parse_uuid(&args[1], "parse error group UUID")?; + let mut roles = if args.len() == 2 { + vec![UUID::zero()] + } else { + Vec::new() + }; + for role in &args[2..] { + if let Ok(id) = UUID::new_with_string(role.clone()) { + roles.push(id); + } + } + backend + .services_mutate( + Mutation::Invite { + avatar, + group, + roles, + }, + cancellation, + ) + .await?; + Ok(format!("invited {avatar} to {group}")) +} + +async fn join_group( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + let args = authorize(backend, args, true)?; + if args.is_empty() { + return Err( + "join a group. Usage: joingroup GroupName | joingroup UUID GroupId --confirm".into(), + ); + } + let (id, name) = if args[0].eq_ignore_ascii_case("uuid") { + if args.len() < 2 { + return Err( + "join a group. Usage: joingroup GroupName | joingroup UUID GroupId --confirm" + .into(), + ); + } + ( + parse_uuid(&args[1], "invalid group UUID") + .map_err(|_| format!("{} doesn't seem a valid UUID", args[1]))?, + args[1].clone(), + ) + } else { + let name = args.join(" ").trim().to_owned(); + let snapshot = backend + .services_query( + Query::Groups { + text: name.clone(), + timeout: Duration::from_mins(1), + }, + cancellation.clone(), + ) + .await?; + let exact: Vec<_> = snapshot + .group_results + .iter() + .filter(|group| group.group_name.eq_ignore_ascii_case(&name)) + .collect(); + if exact.len() != 1 { + if snapshot.group_results.is_empty() { + return Ok(format!("Unable to obtain UUID for group {name}")); + } + return Ok(format!( + "Ambiguous name. Found {} groups", + snapshot.group_results.len() + )); + } + (exact[0].group_id, exact[0].group_name.clone()) + }; + backend + .services_mutate(Mutation::JoinGroup(id), cancellation) + .await?; + Ok(format!("Joined the group {name}")) +} + +async fn leave_group( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + let args = authorize(backend, args, false)?; + if args.is_empty() { + return Err("Leave a group. Usage: leavegroup GroupName --confirm".into()); + } + let name = args.join(" ").trim().to_owned(); + let snapshot = backend + .services_query(Query::RefreshGroups, cancellation.clone()) + .await?; + let Some((id, canonical)) = resolve_group(&snapshot, &name) else { + return Ok(format!( + "{} doesn't seem to be member of the group {name}", + backend.services_name() + )); + }; + backend + .services_mutate(Mutation::LeaveGroup(id), cancellation) + .await?; + Ok(format!( + "{} has left the group {canonical}", + backend.services_name() + )) +} + +async fn dilation( + backend: &B, + cancellation: CancellationToken, +) -> Result { + let snapshot = backend.services_snapshot(cancellation).await?; + let sim = snapshot + .simulators + .first() + .ok_or("No current simulator available")?; + Ok(format!("Dilation is {}", sim.dilation)) +} + +fn format_bytes(bytes: i64) -> String { + for (divisor, suffix) in [ + (1_i64 << 30, "GB"), + (1 << 20, "MB"), + (1 << 10, "KB"), + (1, "Bytes"), + ] { + if bytes > divisor { + if divisor == 1 { + return format!("{bytes} {suffix}"); + } + let hundredths = i128::from(bytes) * 100 / i128::from(divisor); + let whole = hundredths / 100; + let fraction = hundredths.abs() % 100; + let number = if fraction == 0 { + whole.to_string() + } else if fraction % 10 == 0 { + format!("{whole}.{}", fraction / 10) + } else { + format!("{whole}.{fraction:02}") + }; + return format!("{number} {suffix}"); + } + } + "0".into() +} + +fn utilization_table(title: &str, rows: &BTreeMap, total_title: &str) -> String { + let mut output = format!("{title:<30}|Sent|Recv| TX Bytes | RX Bytes |\n"); + let mut totals = [0_i64; 4]; + for (name, row) in rows { + output.push_str(&format!( + "{name:<30}|{:4}|{:4}|{:<10}|{:<10}|\n", + row[0], + row[1], + format_bytes(row[2]), + format_bytes(row[3]) + )); + for index in 0..4 { + totals[index] += row[index]; + } + } + output.push_str(&format!( + "{total_title:>30}|{:4}|{:4}|{:<10}|{:<10}|\n", + totals[0], + totals[1], + format_bytes(totals[2]), + format_bytes(totals[3]) + )); + output +} + +async fn netstats( + backend: &B, + cancellation: CancellationToken, +) -> Result { + let snapshot = backend.services_snapshot(cancellation).await?; + if !snapshot.utilization.enabled { + return Ok( + "TrackUtilization is not enabled in Settings.Packets, statistics not available".into(), + ); + } + Ok(format!( + "\n{}\n\n{}", + utilization_table( + "Message Name", + &snapshot.utilization.capabilities, + "Capabilities Totals" + ), + utilization_table( + "Packet Name", + &snapshot.utilization.packets, + "Packet Totals" + ) + )) +} + +async fn region_info( + backend: &B, + cancellation: CancellationToken, +) -> Result { + let snapshot = backend.services_snapshot(cancellation).await?; + let sim = snapshot + .simulators + .first() + .ok_or("No current simulator available")?; + let x = sim.handle >> 32; + let y = sim.handle & 0xffff_ffff; + Ok(format!( + "{}\nUUID: {}\nHandle: {} (X: {x} Y: {y})\nSize: X: {}, Y: {}\nAccess: {}\nFlags: {}\nTerrainBase0: {}\nTerrainBase1: {}\nTerrainBase2: {}\nTerrainBase3: {}\nTerrainDetail0: {}\nTerrainDetail1: {}\nTerrainDetail2: {}\nTerrainDetail3: {}\nWater Height: {}\nDatacenter:{}\nCPU Ratio:{}\nCPU Class:{}\nRegion SKU/Type:{} {}\n", + sim.name, + sim.id, + sim.handle, + sim.size_x, + sim.size_y, + sim.access, + sim.flags, + sim.terrain_base[0], + sim.terrain_base[1], + sim.terrain_base[2], + sim.terrain_base[3], + sim.terrain_detail[0], + sim.terrain_detail[1], + sim.terrain_detail[2], + sim.terrain_detail[3], + sim.water_height, + sim.colo, + sim.cpu_ratio, + sim.cpu_class, + sim.product_sku, + sim.product_name + )) +} + +async fn stats( + backend: &B, + cancellation: CancellationToken, +) -> Result { + let snapshot = backend.services_snapshot(cancellation).await?; + let mut output = String::new(); + for sim in &snapshot.simulators { + output.push_str(&format!( + "[{}] Dilation: {} InBPS: {} OutBPS: {} ResentOut: {} ResentIn: {}\n", + sim.name, + sim.dilation, + sim.incoming_bps, + sim.outgoing_bps, + sim.resent_out, + sim.resent_in + )); + } + let sim = snapshot + .simulators + .first() + .ok_or("No current simulator available")?; + output.push_str(&format!("Packets in the queue: {}FPS : {} PhysicsFPS : {} AgentUpdates : {} Objects : {} Scripted Objects : {}\n", snapshot.inbox_count, sim.fps, sim.physics_fps, sim.agent_updates, sim.objects, sim.scripted_objects)); + output.push_str(&format!("Frame Time : {} Net Time : {} Image Time : {} Physics Time : {} Script Time : {} Other Time : {}\n", sim.frame_time, sim.net_time, sim.image_time, sim.physics_time, sim.script_time, sim.other_time)); + output.push_str(&format!( + "Agents : {} Child Agents : {} Active Scripts : {}\n", + sim.agents, sim.child_agents, sim.active_scripts + )); + Ok(output) +} + +fn uptime(backend: &B) -> String { + let state = lock(backend.services_state()); + let since = state + .created_wall + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + format!( + "I am {}, Up Since: unix:{since} ({:?})", + backend.services_name(), + state.created.elapsed() + ) +} + +impl Backend for FakeBackend { + fn services_state(&self) -> &Mutex { + &self.services_state + } + fn services_name(&self) -> String { + self.name.clone() + } + fn services_snapshot<'a>( + &'a self, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result> { + Box::pin(async move { + if cancellation.is_cancellation_requested() { + Err("Services query cancelled".into()) + } else { + Ok(lock(&self.services_state).fake.clone()) + } + }) + } + fn services_query<'a>( + &'a self, + query: Query, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result> { + Box::pin(async move { + if cancellation.is_cancellation_requested() { + return Err("Services query cancelled".into()); + } + self.record(format!("CALL service-query {}", query_name(&query))); + let mut snapshot = lock(&self.services_state).fake.clone(); + match query { + Query::Key(key) => snapshot.key_names.retain(|id, _| *id == key), + Query::Classifieds(text) => snapshot + .classifieds + .retain(|item| contains(&item.name, &text)), + Query::Events(text) => snapshot.events.retain(|item| contains(&item.name, &text)), + Query::Groups { text, .. } => snapshot + .group_results + .retain(|item| contains(&item.group_name, &text)), + Query::Land { + kind: _, + max_price, + min_size, + } => snapshot.land.retain(|item| { + (max_price <= 0 || item.sale_price <= max_price) + && (min_size <= 0 || item.actual_area >= min_size) + }), + Query::People(text) => snapshot + .people + .retain(|item| contains(&item.to_string(), &text)), + Query::Places(text) => snapshot.places.retain(|item| contains(&item.name, &text)), + Query::EventInfo(id) => snapshot.event_info.retain(|event, _| *event == id), + Query::MapFriend(id) => snapshot.friends.retain(|friend, _| *friend == id), + Query::GroupMembers(id) | Query::GroupRoles(id) => { + snapshot.groups.retain(|group, _| *group == id); + } + Query::RefreshGroups => {} + } + Ok(snapshot) + }) + } + fn services_mutate<'a>( + &'a self, + mutation: Mutation, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result> { + Box::pin(async move { + if cancellation.is_cancellation_requested() { + return Err("Services mutation cancelled".into()); + } + let mut state = lock(&self.services_state); + let output = match mutation { + Mutation::CloneProfile(id) => { + self.record(format!("CALL profile-clone {id}")); + "Profile synchronized".into() + } + Mutation::Generic { method, parameters } => { + self.record(format!( + "CALL generic-message {method} {}", + parameters.join(" ") + )); + "Generic message sent".into() + } + Mutation::Play(id) => { + state.fake.signaled_animations.insert(id, 1); + self.record(format!("CALL animation-start {id} reliable=true")); + "Animation started".into() + } + Mutation::Touch(id) => { + self.record(format!("CALL object-touch {id}")); + format!("Touched object {id}") + } + Mutation::ActivateGroup(id) => { + let name = state + .fake + .groups + .get(&id) + .map_or("(unknown)", |group| group.group.name.as_str()); + self.record(format!("CALL group-activate {id}")); + format!("Active group is now {name} ( )") + } + Mutation::Invite { + avatar, + group, + roles, + } => { + self.record(format!( + "CALL group-invite avatar={avatar} group={group} roles={}", + roles + .iter() + .map(ToString::to_string) + .collect::>() + .join(",") + )); + "Invitation sent".into() + } + Mutation::JoinGroup(id) => { + let result = state + .fake + .group_results + .iter() + .find(|group| group.group_id == id) + .cloned(); + if let Some(result) = result { + state.fake.groups.entry(id).or_insert_with(|| GroupView { + group: empty_group(id, result.group_name), + ..GroupView::default() + }); + } + self.record(format!("CALL group-join {id}")); + "Group joined".into() + } + Mutation::LeaveGroup(id) => { + state.fake.groups.remove(&id); + self.record(format!("CALL group-leave {id}")); + "Group left".into() + } + }; + Ok(output) + }) + } + fn apply_services_fixture(&self, fields: &[&str]) -> Result<(), &'static str> { + apply_fixture(self, fields) + } +} + +fn query_name(query: &Query) -> &'static str { + match query { + Query::Key(_) => "key-to-name", + Query::Classifieds(_) => "classifieds", + Query::Events(_) => "events", + Query::Groups { .. } => "groups", + Query::Land { .. } => "land", + Query::People(_) => "people", + Query::Places(_) => "places", + Query::EventInfo(_) => "event-info", + Query::MapFriend(_) => "map-friend", + Query::GroupMembers(_) => "group-members", + Query::GroupRoles(_) => "group-roles", + Query::RefreshGroups => "refresh-groups", + } +} + +fn contains(value: &str, query: &str) -> bool { + value + .to_ascii_lowercase() + .contains(&query.to_ascii_lowercase()) +} + +fn empty_group(id: UUID, name: String) -> Group { + Group { + accept_notices: false, + allow_publish: false, + charter: String::new(), + contribution: 0, + founder_id: UUID::zero(), + group_membership_count: 0, + group_roles_count: 0, + id, + insignia_id: UUID::zero(), + list_in_profile: false, + mature_publish: false, + member_title: String::new(), + membership_fee: 0, + money: 0, + name, + open_enrollment: false, + owner_role: UUID::zero(), + powers: libremetaverse::GroupPowers(0), + show_in_list: false, + } +} + +fn fixture_uuid(value: &str) -> Result { + UUID::new_with_string(value.into()).map_err(|_| "services fixture UUID is invalid") +} +fn fixture_number(value: &str) -> Result { + value + .parse() + .map_err(|_| "services fixture number is invalid") +} +fn fixture_bool(value: &str) -> Result { + match value { + "true" => Ok(true), + "false" => Ok(false), + _ => Err("services fixture boolean is invalid"), + } +} +fn fixture_vector(x: &str, y: &str, z: &str) -> Result { + Vector3::new_with_single_single_single( + fixture_number(x)?, + fixture_number(y)?, + fixture_number(z)?, + ) + .map_err(|_| "services fixture vector is invalid") +} + +pub(super) fn apply_fake_directive( + manager: &super::ClientManager, + fields: &[&str], +) -> Option> { + if !fields + .first() + .is_some_and(|name| name.starts_with("service-")) + { + return None; + } + let client = fields + .get(1) + .and_then(|value| UUID::new_with_string((*value).into()).ok()); + let Some(client) = client else { + return Some(Err("services fixture client UUID is invalid")); + }; + let Some(client) = manager.clients.get(&client) else { + return Some(Err("services fixture client is not registered")); + }; + Some(client.backend.apply_services_fixture(fields)) +} + +#[allow(clippy::too_many_lines)] +fn apply_fixture(backend: &FakeBackend, fields: &[&str]) -> Result<(), &'static str> { + let mut state = lock(&backend.services_state); + let fixture = &mut state.fake; + match fields { + ["service-avatar", _, id, name, group, x, y, z, local, effect] => { + let id = fixture_uuid(id)?; + fixture.avatars.insert( + id, + AvatarView { + id, + name: (*name).into(), + group: (*group).into(), + position: fixture_vector(x, y, z)?, + local_id: fixture_number(local)?, + }, + ); + if fixture_bool(effect)? { + fixture.effect_sources.insert(id); + } + fixture.key_names.insert(id, (false, (*name).into())); + } + ["service-friend", _, id, name, online, handle, x, y, z] => { + let id = fixture_uuid(id)?; + fixture.friends.insert( + id, + FriendView { + id, + name: (*name).into(), + online: fixture_bool(online)?, + region_handle: fixture_number(handle)?, + position: fixture_vector(x, y, z)?, + }, + ); + } + ["service-group", _, id, name] => { + let id = fixture_uuid(id)?; + fixture.groups.insert( + id, + GroupView { + group: empty_group(id, (*name).into()), + ..GroupView::default() + }, + ); + fixture.key_names.insert(id, (true, (*name).into())); + } + ["service-group-result", _, id, name, members] => fixture.group_results.push(GroupResult { + group_id: fixture_uuid(id)?, + group_name: (*name).into(), + members: fixture_number(members)?, + }), + ["service-group-member", _, group, member] => { + let group = fixture_uuid(group)?; + let member = fixture_uuid(member)?; + fixture + .groups + .get_mut(&group) + .ok_or("service-group must precede member")? + .members + .insert( + member, + GroupMember { + contribution: 0, + id: member, + is_owner: false, + online_status: String::new(), + powers: libremetaverse::GroupPowers(0), + title: String::new(), + }, + ); + } + ["service-group-role", _, group, role, name, title] => { + let group = fixture_uuid(group)?; + let role = fixture_uuid(role)?; + fixture + .groups + .get_mut(&group) + .ok_or("service-group must precede role")? + .roles + .insert( + role, + GroupRole { + description: String::new(), + group_id: group, + id: role, + members: 0, + name: (*name).into(), + powers: libremetaverse::GroupPowers(0), + title: (*title).into(), + }, + ); + } + ["service-person", _, id, first, last, online] => fixture.people.push(Person { + agent_id: fixture_uuid(id)?, + first_name: (*first).into(), + last_name: (*last).into(), + online: fixture_bool(online)?, + }), + ["service-classified", _, id, name, price] => fixture.classifieds.push(Classified { + creation_date: SystemTime::UNIX_EPOCH, + expiration_date: SystemTime::UNIX_EPOCH, + flags: libremetaverse::DirectoryManagerClassifiedFlags(0), + id: fixture_uuid(id)?, + name: (*name).into(), + price: fixture_number(price)?, + }), + ["service-event", _, id, name, date] => fixture.events.push(Event { + date: (*date).into(), + flags: libremetaverse::DirectoryManagerEventFlags::PG, + id: fixture_number(id)?, + name: (*name).into(), + owner: UUID::zero(), + time: 0, + }), + ["service-event-info", _, id, name, sim, x, y, z] => { + let id = fixture_number(id)?; + fixture.event_info.insert( + id, + EventInfo { + amount: 0, + category: libremetaverse::DirectoryManagerEventCategories::All, + cover: 0, + creator: UUID::zero(), + date: String::new(), + date_utc: 0, + desc: String::new(), + duration: 0, + flags: libremetaverse::DirectoryManagerEventFlags::PG, + global_pos: Vector3d::new_with_double_double_double( + fixture_number(x)?, + fixture_number(y)?, + fixture_number(z)?, + ) + .map_err(|_| "services fixture vector is invalid")?, + id, + name: (*name).into(), + sim_name: (*sim).into(), + }, + ); + } + ["service-land", _, id, name, area, price] => fixture.land.push(Land { + actual_area: fixture_number(area)?, + auction: false, + dwell: 0.0, + for_sale: true, + id: fixture_uuid(id)?, + name: (*name).into(), + sale_price: fixture_number(price)?, + }), + ["service-place", _, name, sim, x, y, z] => fixture.places.push(Place { + actual_area: 0, + billable_area: 0, + desc: String::new(), + dwell: 0.0, + flags: libremetaverse::DirectoryManagerPlacesFlags::NotForSale, + global_x: fixture_number(x)?, + global_y: fixture_number(y)?, + global_z: fixture_number(z)?, + name: (*name).into(), + owner_id: UUID::zero(), + price: 0, + sku: String::new(), + sim_name: (*sim).into(), + snapshot_id: UUID::zero(), + }), + ["service-sim", _, name, id, handle, dilation] => fixture.simulators.push(SimView { + name: (*name).into(), + id: fixture_uuid(id)?, + handle: fixture_number(handle)?, + size_x: 256, + size_y: 256, + dilation: fixture_number(dilation)?, + ..SimView::default() + }), + ["service-animation", _, name, id, sequence] => { + let id = fixture_uuid(id)?; + fixture.animations.insert((*name).into(), id); + fixture + .signaled_animations + .insert(id, fixture_number(sequence)?); + } + ["service-utilization", _, enabled] => { + fixture.utilization.enabled = fixture_bool(enabled)?; + } + [ + "service-utilization-row", + _, + kind, + name, + tx_count, + rx_count, + tx_bytes, + rx_bytes, + ] => { + let row = [ + fixture_number(tx_count)?, + fixture_number(rx_count)?, + fixture_number(tx_bytes)?, + fixture_number(rx_bytes)?, + ]; + match *kind { + "packet" => { + fixture.utilization.packets.insert((*name).into(), row); + } + "capability" => { + fixture.utilization.capabilities.insert((*name).into(), row); + } + _ => return Err("services utilization kind is invalid"), + } + } + _ => return Err("invalid services fake directive"), + } + Ok(()) +} + +impl Backend for LiveBackend { + fn services_state(&self) -> &Mutex { + &self.services_state + } + fn services_name(&self) -> String { + self.name.clone() + } + fn services_snapshot<'a>( + &'a self, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result> { + Box::pin(async move { + cancellation + .throw_if_cancellation_requested() + .map_err(|_| "Services query cancelled")?; + let (friends, groups, simulators, inbox, track, animations) = { + let mut client = lock(&self.client); + let friends = client.friends().friend_list(); + let groups = client.groups().current_groups(); + let simulators = client.network().simulators.snapshot(); + let inbox = client.network().inbox_count(); + let track = client.settings_ref().packets().track_utilization; + let animations = client + .self_() + .signaled_animations + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + (friends, groups, simulators, inbox, track, animations) + }; + let mut snapshot = lock(&self.services_state).fake.clone(); + snapshot.friends = friends + .into_iter() + .map(|(id, friend)| { + ( + id, + FriendView { + id, + name: friend.name(), + online: friend.is_online(), + ..FriendView::default() + }, + ) + }) + .collect(); + snapshot.groups = groups + .into_iter() + .map(|(id, group)| { + ( + id, + GroupView { + group, + ..GroupView::default() + }, + ) + }) + .collect(); + if track { + snapshot.utilization.packets.clear(); + for sim in &simulators { + snapshot.utilization.packets.insert( + format!("UDP {}", sim.name), + [ + sim.stats.get_sent_packets().unwrap_or_default(), + sim.stats.get_recv_packets().unwrap_or_default(), + sim.stats.get_sent_bytes().unwrap_or_default(), + sim.stats.get_recv_bytes().unwrap_or_default(), + ], + ); + } + } + snapshot.simulators = simulators + .into_iter() + .map(sim_view) + .collect::>()?; + if let Some(current) = self.network.current_sim() + && let Some(index) = snapshot + .simulators + .iter() + .position(|sim| sim.handle == current.handle) + { + snapshot.simulators.swap(0, index); + } + snapshot.inbox_count = inbox; + snapshot.utilization.enabled = track; + snapshot.signaled_animations = animations.into_iter().collect(); + Ok(snapshot) + }) + } + fn services_query<'a>( + &'a self, + query: Query, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result> { + Box::pin(async move { + match query { + Query::Key(id) => live_key(self, id, cancellation).await, + Query::Classifieds(text) => { + let stream = self + .directory + .search_classifieds( + text, + Some(libremetaverse::DirectoryManagerClassifiedCategories::Any), + Some(libremetaverse::DirectoryManagerClassifiedQueryFlags( + libremetaverse::DirectoryManagerClassifiedQueryFlags::MATURE.0 + | libremetaverse::DirectoryManagerClassifiedQueryFlags::PG.0, + )), + Some(cancellation.clone()), + ) + .map_err(|_| "Could not start classified search")?; + let values = collect_stream( + stream, + cancellation.clone(), + Duration::from_secs(20), + "Timeout waiting for simulator to respond to query.", + ) + .await?; + let mut snapshot = self.services_snapshot(cancellation).await?; + snapshot.classifieds = values; + Ok(snapshot) + } + Query::Events(text) => { + let stream = self + .directory + .search_events(text, None, None, None, Some(cancellation.clone())) + .map_err(|_| "Could not start events search")?; + let values = collect_stream( + stream, + cancellation.clone(), + Duration::from_secs(20), + "Timeout waiting for simulator to respond.", + ) + .await?; + let mut snapshot = self.services_snapshot(cancellation).await?; + snapshot.events = values; + Ok(snapshot) + } + Query::Groups { text, timeout } => { + let stream = self + .directory + .search_groups(text, None, Some(cancellation.clone())) + .map_err(|_| "Could not start group search")?; + let values = collect_stream( + stream, + cancellation.clone(), + timeout, + "Timeout waiting for simulator to respond.", + ) + .await?; + let mut snapshot = self.services_snapshot(cancellation).await?; + snapshot.group_results = values; + Ok(snapshot) + } + Query::Land { + kind, + max_price, + min_size, + } => { + let type_flags = match kind.as_str() { + "auction" => libremetaverse::DirectoryManagerSearchTypeFlags::AUCTION, + "mainland" => libremetaverse::DirectoryManagerSearchTypeFlags::MAINLAND, + "estate" => libremetaverse::DirectoryManagerSearchTypeFlags::ESTATE, + _ => libremetaverse::DirectoryManagerSearchTypeFlags::ANY, + }; + let mut bits = libremetaverse::DirectoryManagerDirFindFlags::SORT_ASC.0 + | libremetaverse::DirectoryManagerDirFindFlags::PER_METER_SORT.0 + | libremetaverse::DirectoryManagerDirFindFlags::INCLUDE_ADULT.0 + | libremetaverse::DirectoryManagerDirFindFlags::INCLUDE_PG.0 + | libremetaverse::DirectoryManagerDirFindFlags::INCLUDE_MATURE.0; + if max_price > 0 { + bits |= libremetaverse::DirectoryManagerDirFindFlags::LIMIT_BY_PRICE.0; + } + if min_size > 0 { + bits |= libremetaverse::DirectoryManagerDirFindFlags::LIMIT_BY_AREA.0; + } + let stream = self + .directory + .search_land( + type_flags, + Some(libremetaverse::DirectoryManagerDirFindFlags(bits)), + Some(max_price), + Some(min_size), + Some(cancellation.clone()), + ) + .map_err(|_| "Could not start land search")?; + let values = collect_stream( + stream, + cancellation.clone(), + Duration::from_secs(20), + "Timeout waiting for simulator to respond.", + ) + .await?; + let mut snapshot = self.services_snapshot(cancellation).await?; + snapshot.land = values; + Ok(snapshot) + } + Query::People(text) => { + let stream = self + .directory + .search_people(text, Some(cancellation.clone())) + .map_err(|_| "Could not start people search")?; + let values = collect_stream( + stream, + cancellation.clone(), + Duration::from_secs(20), + "Timeout waiting for simulator to respond.", + ) + .await?; + let mut snapshot = self.services_snapshot(cancellation).await?; + snapshot.people = values; + Ok(snapshot) + } + Query::Places(text) => { + let flags = libremetaverse::DirectoryManagerDirFindFlags( + libremetaverse::DirectoryManagerDirFindFlags::INCLUDE_PG.0 + | libremetaverse::DirectoryManagerDirFindFlags::INCLUDE_MATURE.0, + ); + let stream = self + .directory + .search_places( + flags, + libremetaverse::ParcelCategory::Any, + text, + None, + None, + Some(cancellation.clone()), + ) + .map_err(|_| "Could not start places search")?; + let values = collect_stream( + stream, + cancellation.clone(), + Duration::from_secs(20), + "Timeout waiting for simulator to respond to query.", + ) + .await?; + let mut snapshot = self.services_snapshot(cancellation).await?; + snapshot.places = values; + Ok(snapshot) + } + Query::EventInfo(id) => { + let value = tokio::select! { result=self.directory.get_event_info(id,Some(cancellation.clone()))=>result.map_err(|_|"Event lookup failed")?.flatten(), ()=tokio::time::sleep(Duration::from_secs(20))=>return Err("Timeout waiting for simulator to respond.".into()), ()=cancellation.cancelled()=>return Err("Event lookup cancelled".into()) }; + let mut snapshot = self.services_snapshot(cancellation).await?; + if let Some(value) = value { + snapshot.event_info.insert(id, value); + } + Ok(snapshot) + } + Query::MapFriend(id) => live_map_friend(self, id, cancellation).await, + Query::GroupMembers(id) => live_group_members(self, id, cancellation).await, + Query::GroupRoles(id) => live_group_roles(self, id, cancellation).await, + Query::RefreshGroups => live_groups(self, cancellation).await, + } + }) + } + fn services_mutate<'a>( + &'a self, + mutation: Mutation, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result> { + Box::pin(async move { + cancellation + .throw_if_cancellation_requested() + .map_err(|_| "Services mutation cancelled")?; + match mutation { + Mutation::CloneProfile(id) => live_clone_profile(self, id, cancellation).await, + Mutation::Generic { method, parameters } => live_generic(self, method, parameters), + Mutation::Play(id) => { + self.with_agent(|agent| agent.animation_start(id, true)) + .map_err(|_| "Could not start animation")?; + Ok("Animation started".into()) + } + Mutation::Touch(id) => live_touch(self, id, cancellation).await, + Mutation::ActivateGroup(id) => live_activate_group(self, id, cancellation).await, + Mutation::Invite { + avatar, + group, + roles, + } => { + lock(&self.client) + .groups() + .invite(group, roles, avatar) + .map_err(|_| "Group invitation failed")?; + Ok("Invitation sent".into()) + } + Mutation::JoinGroup(id) => live_group_operation(self, id, true, cancellation).await, + Mutation::LeaveGroup(id) => { + live_group_operation(self, id, false, cancellation).await + } + } + }) + } +} + +fn sim_view(sim: libremetaverse::Simulator) -> Result { + Ok(SimView { + name: sim.name.clone(), + id: sim.id, + handle: sim.handle, + size_x: sim.size_x, + size_y: sim.size_y, + access: format!("{:?}", sim.access), + flags: format!("{:?}", sim.flags), + terrain_base: [ + sim.terrain_base0, + sim.terrain_base1, + sim.terrain_base2, + sim.terrain_base3, + ], + terrain_detail: [ + sim.terrain_detail0, + sim.terrain_detail1, + sim.terrain_detail2, + sim.terrain_detail3, + ], + water_height: sim.water_height, + colo: sim.colo_location.clone(), + cpu_ratio: sim.cpu_ratio, + cpu_class: sim.cpu_class, + product_sku: sim.product_sku.clone(), + product_name: sim.product_name.clone(), + dilation: sim.stats.dilation(), + incoming_bps: sim + .stats + .get_incoming_bps() + .map_err(|_| "Could not read simulator statistics")?, + outgoing_bps: sim + .stats + .get_outgoing_bps() + .map_err(|_| "Could not read simulator statistics")?, + resent_out: sim + .stats + .get_resent_packets() + .map_err(|_| "Could not read simulator statistics")?, + resent_in: sim + .stats + .get_received_resends() + .map_err(|_| "Could not read simulator statistics")?, + fps: sim.stats.fps(), + physics_fps: sim.stats.physics_fps(), + agent_updates: sim.stats.agent_updates(), + objects: sim.stats.objects(), + scripted_objects: sim.stats.scripted_objects(), + frame_time: sim.stats.frame_time(), + net_time: sim.stats.net_time(), + image_time: sim.stats.image_time(), + physics_time: sim.stats.physics_time(), + script_time: sim.stats.script_time(), + other_time: sim.stats.other_time(), + agents: sim.stats.agents(), + child_agents: sim.stats.child_agents(), + active_scripts: sim.stats.active_scripts(), + }) +} + +async fn collect_stream( + stream: libremetaverse::types::compat::IAsyncEnumerable, + cancellation: CancellationToken, + timeout: Duration, + timeout_message: &'static str, +) -> Result, String> { + let deadline = tokio::time::Instant::now() + timeout; + let mut values = Vec::new(); + loop { + let next = tokio::select! { value=stream.next()=>value, ()=tokio::time::sleep_until(deadline)=>return Err(timeout_message.into()), ()=cancellation.cancelled()=>return Err("Directory search cancelled".into()) }; + let Some(value) = next else { return Ok(values) }; + if values.len() == MAX_RESULTS { + return Err("Directory result exceeds 65,535 entries".into()); + } + values.push(value); + } +} + +async fn live_key( + backend: &LiveBackend, + id: UUID, + cancellation: CancellationToken, +) -> Result { + let (avatars, groups) = { + let client = lock(&backend.client); + (client.avatars(), client.groups()) + }; + let (group_sender, group_receiver) = oneshot::channel(); + let group_sender = Arc::new(Mutex::new(Some(group_sender))); + let callback = Arc::clone(&group_sender); + let subscription = groups.subscribe_group_profile(Arc::new(move |event| { + let group = event.group(); + if group.id == id + && let Some(sender) = lock(&callback).take() + { + let _ = sender.send(group.name); + } + })); + groups + .request_group_profile(id) + .map_err(|_| "Group lookup request failed")?; + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + let display_lookup = avatars.get_display_names(vec![id], Some(cancellation.clone())); + tokio::pin!(display_lookup); + tokio::pin!(group_receiver); + let (display, group_name) = tokio::select! { + result=&mut display_lookup=>{ + let display=result.ok().and_then(|(success,values,_)| success.then_some(values).flatten()).and_then(|values|values.into_iter().find(|value|value.id()==id)).map(|value|value.legacy_full_name()).filter(|name|!name.trim().is_empty()); + if display.is_some() {(display,None)} else { + let group=tokio::select! {value=&mut group_receiver=>value.ok(),()=tokio::time::sleep_until(deadline)=>None,()=cancellation.cancelled()=>return Err("Key lookup cancelled".into())}; + (None,group) + } + }, + value=&mut group_receiver=>(None,value.ok()), + ()=tokio::time::sleep_until(deadline)=>(None,None), + ()=cancellation.cancelled()=>return Err("Key lookup cancelled".into()), + }; + drop(subscription); + let mut snapshot = backend.services_snapshot(cancellation).await?; + if let Some(name) = display { + snapshot.key_names.insert(id, (false, name)); + } else if let Some(name) = group_name { + snapshot.key_names.insert(id, (true, name)); + } + Ok(snapshot) +} + +async fn live_map_friend( + backend: &LiveBackend, + id: UUID, + cancellation: CancellationToken, +) -> Result { + let friends = lock(&backend.client).friends(); + let (sender, receiver) = oneshot::channel(); + let sender = Arc::new(Mutex::new(Some(sender))); + let callback = Arc::clone(&sender); + let subscription = friends.subscribe_friend_found_reply(Arc::new(move |event| { + if event.agent_id() == id + && let Some(sender) = lock(&callback).take() + { + let _ = sender.send((event.region_handle(), event.location())); + } + })); + friends + .map_friend(id) + .map_err(|_| "Friend map request failed")?; + let found = tokio::select! { value=receiver=>value.ok(), ()=tokio::time::sleep(Duration::from_secs(10))=>None, ()=cancellation.cancelled()=>return Err("Friend map request cancelled".into()) }; + drop(subscription); + let mut snapshot = backend.services_snapshot(cancellation).await?; + let (handle, position) = found + .ok_or_else(|| format!("Timeout waiting for reply, Do you have mapping rights on {id}?"))?; + let friend = snapshot.friends.entry(id).or_insert_with(|| FriendView { + id, + ..FriendView::default() + }); + friend.online = handle != 0; + friend.region_handle = handle; + friend.position = position; + Ok(snapshot) +} + +async fn live_groups( + backend: &LiveBackend, + cancellation: CancellationToken, +) -> Result { + let groups = lock(&backend.client).groups(); + let cached = groups.current_groups(); + if cached.is_empty() { + let (sender, receiver) = oneshot::channel(); + let sender = Arc::new(Mutex::new(Some(sender))); + let callback = Arc::clone(&sender); + let subscription = groups.subscribe_current_groups(Arc::new(move |event| { + if let Some(sender) = lock(&callback).take() { + let _ = sender.send(event.groups()); + } + })); + groups + .request_current_groups() + .map_err(|_| "Could not refresh groups")?; + tokio::select! {value=receiver=>{value.map_err(|_|"Group cache reply channel closed")?;},()=tokio::time::sleep(Duration::from_secs(30))=>return Err("Groups cache failed.".into()),()=cancellation.cancelled()=>return Err("Group refresh cancelled".into())} + drop(subscription); + } + backend.services_snapshot(cancellation).await +} + +async fn live_group_members( + backend: &LiveBackend, + id: UUID, + cancellation: CancellationToken, +) -> Result { + let groups = lock(&backend.client).groups(); + let (sender, receiver) = oneshot::channel(); + let sender = Arc::new(Mutex::new(Some(sender))); + let callback = Arc::clone(&sender); + let request = Arc::new(Mutex::new(UUID::zero())); + let expected = Arc::clone(&request); + let subscription = groups.subscribe_group_members_reply(Arc::new(move |event| { + if event.request_id() == *lock(&expected) + && let Some(sender) = lock(&callback).take() + { + let _ = sender.send(event.members()); + } + })); + *lock(&request) = groups + .request_group_members(id) + .map_err(|_| "Group members request failed")?; + let members = tokio::select! {value=receiver=>value.map_err(|_|"Group members reply channel closed")?,()=tokio::time::sleep(Duration::from_secs(30))=>return Err("Timeout waiting for group members".into()),()=cancellation.cancelled()=>return Err("Group members request cancelled".into())}; + drop(subscription); + let mut snapshot = backend.services_snapshot(cancellation).await?; + snapshot + .groups + .entry(id) + .or_insert_with(|| GroupView { + group: empty_group(id, String::new()), + ..GroupView::default() + }) + .members = members.into_iter().collect(); + Ok(snapshot) +} + +async fn live_group_roles( + backend: &LiveBackend, + id: UUID, + cancellation: CancellationToken, +) -> Result { + let groups = lock(&backend.client).groups(); + let (sender, receiver) = oneshot::channel(); + let sender = Arc::new(Mutex::new(Some(sender))); + let callback = Arc::clone(&sender); + let request = Arc::new(Mutex::new(UUID::zero())); + let expected = Arc::clone(&request); + let subscription = groups.subscribe_group_role_data_reply(Arc::new(move |event| { + if event.request_id() == *lock(&expected) + && let Some(sender) = lock(&callback).take() + { + let _ = sender.send(event.roles()); + } + })); + *lock(&request) = groups + .request_group_roles(id) + .map_err(|_| "Group roles request failed")?; + let roles = tokio::select! {value=receiver=>value.map_err(|_|"Group roles reply channel closed")?,()=tokio::time::sleep(Duration::from_secs(30))=>return Err("Timeout waiting for group roles".into()),()=cancellation.cancelled()=>return Err("Group roles request cancelled".into())}; + drop(subscription); + let mut snapshot = backend.services_snapshot(cancellation).await?; + snapshot + .groups + .entry(id) + .or_insert_with(|| GroupView { + group: empty_group(id, String::new()), + ..GroupView::default() + }) + .roles = roles.into_iter().collect(); + Ok(snapshot) +} + +async fn live_group_operation( + backend: &LiveBackend, + id: UUID, + join: bool, + cancellation: CancellationToken, +) -> Result { + let groups = lock(&backend.client).groups(); + let (sender, receiver) = oneshot::channel(); + let sender = Arc::new(Mutex::new(Some(sender))); + let callback = Arc::clone(&sender); + let subscription = if join { + groups.subscribe_group_joined_reply(Arc::new(move |event| { + if event.group_id() == id + && let Some(sender) = lock(&callback).take() + { + let _ = sender.send(event.success()); + } + })) + } else { + groups.subscribe_group_leave_reply(Arc::new(move |event| { + if event.group_id() == id + && let Some(sender) = lock(&callback).take() + { + let _ = sender.send(event.success()); + } + })) + }; + if join { + groups + .request_join_group(id) + .map_err(|_| "Group join request failed")?; + } else { + groups + .leave_group(id) + .map_err(|_| "Group leave request failed")?; + } + let success = tokio::select! {value=receiver=>value.unwrap_or(false),()=tokio::time::sleep(if join {Duration::from_mins(1)} else {Duration::from_secs(30)})=>return Err(if join{"Timeout waiting to join group".into()}else{"Timeout waiting to leave group".into()}),()=cancellation.cancelled()=>return Err("Group operation cancelled".into())}; + drop(subscription); + if !success { + return Err(if join { + "Unable to join the group".into() + } else { + "failed to leave the group".into() + }); + } + if join { + groups + .activate_group(id) + .map_err(|_| "Joined group but could not activate it")?; + } + Ok(if join { + "Group joined".into() + } else { + "Group left".into() + }) +} + +async fn live_activate_group( + backend: &LiveBackend, + id: UUID, + cancellation: CancellationToken, +) -> Result { + let (groups, agent) = { + let client = lock(&backend.client).clone(); + let groups = client.groups(); + let agent = libremetaverse::AgentManager::new(Some(Arc::new(client))) + .map_err(|_| "Could not acquire the live agent manager")?; + (groups, agent) + }; + let (sender, receiver) = oneshot::channel(); + let sender = Arc::new(Mutex::new(Some(sender))); + let callback = Arc::clone(&sender); + let subscription = agent.subscribe_agent_data_reply(Arc::new(move |event| { + if event.active_group_id() == id + && let Some(sender) = lock(&callback).take() + { + let _ = sender.send((event.group_name(), event.group_title())); + } + })); + groups + .activate_group(id) + .map_err(|_| "Failed to activate group")?; + let (name, title) = tokio::select! { + value=receiver=>value.map_err(|_|"Active-group reply channel closed")?, + ()=tokio::time::sleep(Duration::from_secs(30))=>return Err("Failed to activate the group".into()), + ()=cancellation.cancelled()=>return Err("Group activation cancelled".into()), + }; + drop(subscription); + Ok(format!("Active group is now {name} ( {title} )")) +} + +async fn live_touch( + backend: &LiveBackend, + id: UUID, + cancellation: CancellationToken, +) -> Result { + let (objects, found) = { + let client = lock(&backend.client); + let objects = client.objects(); + let found = client + .network() + .simulators + .snapshot() + .into_iter() + .find_map(|sim| { + sim.objects_primitives + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .values() + .find(|prim| prim.id == id) + .map(|prim| (sim.clone(), prim.local_id)) + }); + (objects, found) + }; + let (sim, local) = found.ok_or_else(|| format!("Unable to find object {id}"))?; + objects + .click_object_with_simulator_u_int32_cancellation_token(sim, local, Some(cancellation)) + .await + .map_err(|_| "Object touch failed")?; + Ok(format!("Touched object {local}")) +} + +fn live_generic( + backend: &LiveBackend, + method: String, + parameters: Vec, +) -> Result { + let (agent, session, sim) = { + let mut client = lock(&backend.client); + let agent = client.self_(); + ( + agent.agent_id(), + agent.session_id(), + client.network().current_sim(), + ) + }; + let sim = sim.ok_or("No current simulator available")?; + let mut packet = GenericMessagePacket::new_with_constructor() + .map_err(|_| "Could not construct generic message")?; + packet.agent_data.agent_id = agent; + packet.agent_data.session_id = session; + packet.agent_data.transaction_id = UUID::zero(); + packet.method_data.method = method.into_bytes(); + packet.method_data.invoice = UUID::zero(); + packet.param_list = parameters + .into_iter() + .map(|value| { + let mut block = GenericMessagePacketParamListBlock::new_with_constructor() + .map_err(|_| "Could not construct generic parameter")?; + block.parameter = value.into_bytes(); + Ok(block) + }) + .collect::>()?; + let bytes = packet + .to_bytes_with_method() + .map_err(|_| "Could not encode generic message")?; + sim.send_packet_data( + bytes.clone(), + i32::try_from(bytes.len()).map_err(|_| "Generic message is too large")?, + PacketType::GenericMessage, + false, + ) + .map_err(|_| "Could not send generic message")?; + Ok("Generic message sent".into()) +} + +async fn live_clone_profile( + backend: &LiveBackend, + target: UUID, + cancellation: CancellationToken, +) -> Result { + let (avatars, own, client, groups) = { + let mut client = lock(&backend.client); + let own = client.self_().agent_id(); + (client.avatars(), own, client.clone(), client.groups()) + }; + let agent = libremetaverse::AgentManager::new(Some(Arc::new(client))) + .map_err(|_| "Could not acquire the live agent manager")?; + let profile_token = libremetaverse::types::compat::CancellationTokenSource::new(); + let target_profile = tokio::select! { + result=avatars.request_agent_profile(target,Some(profile_token.token()))=>{let (success,profile)=result.map_err(|_|"Failed to retrieve profile")?;if !success{return Err("Failed to retrieve profile".into())}profile.ok_or("Failed to retrieve profile")?}, + ()=tokio::time::sleep(Duration::from_secs(5))=>{profile_token.cancel();return Err("Failed to retrieve profile".into())}, + ()=cancellation.cancelled()=>{profile_token.cancel();return Err("Profile clone cancelled".into())}, + }; + let interests_token = libremetaverse::types::compat::CancellationTokenSource::new(); + let target_interests = tokio::select! { + result=avatars.request_avatar_interests(target,Some(interests_token.token()))=>result.map_err(|_|"Failed to retrieve a complete profile for that UUID")?, + ()=tokio::time::sleep(Duration::from_secs(5))=>{interests_token.cancel();return Err("Failed to retrieve a complete profile for that UUID".into())}, + ()=cancellation.cancelled()=>{interests_token.cancel();return Err("Profile clone cancelled".into())}, + }; + let own_token = libremetaverse::types::compat::CancellationTokenSource::new(); + let own_profile = tokio::select! { + result=avatars.request_agent_profile(own,Some(own_token.token()))=>{let (success,profile)=result.map_err(|_|"Failed to retrieve current profile picks")?;if !success{return Err("Failed to retrieve current profile picks".into())}profile.ok_or("Failed to retrieve current profile picks")?}, + ()=tokio::time::sleep(Duration::from_secs(5))=>{own_token.cancel();return Err("Failed to retrieve current profile picks".into())}, + ()=cancellation.cancelled()=>{own_token.cancel();return Err("Profile clone cancelled".into())}, + }; + let mut properties = libremetaverse::AvatarAvatarProperties { + about_text: target_profile.second_life_about_text.clone(), + born_on: String::new(), + charter_member: String::new(), + first_life_image: target_profile.first_life_image_id, + first_life_text: target_profile.first_life_about_text.clone(), + flags: libremetaverse::ProfileFlags::ALLOW_PUBLISH, + partner: UUID::zero(), + profile_image: target_profile.second_life_image_id, + profile_url: target_profile.home_page.clone(), + }; + properties.set_mature_publish(target_profile.is_mature_profile); + agent + .update_interests(target_interests) + .map_err(|_| "Failed to update profile interests")?; + agent + .update_profile_with_avatar_properties_cancellation_token( + properties, + Some(cancellation.clone()), + ) + .await + .map_err(|_| "Failed to update profile")?; + for pick in own_profile.picks { + cancellation + .throw_if_cancellation_requested() + .map_err(|_| "Profile clone cancelled")?; + agent + .pick_delete(pick.id) + .map_err(|_| "Failed to remove an existing pick")?; + } + for pick in target_profile.picks { + cancellation + .throw_if_cancellation_requested() + .map_err(|_| "Profile clone cancelled")?; + let position = Vector3d::new_with_double_double_double( + pick.grid_x + pick.region_x, + pick.grid_y + pick.region_y, + pick.region_z, + ) + .map_err(|_| "Target pick contains invalid coordinates")?; + agent + .pick_info_update( + pick.id, + false, + pick.parcel_id, + pick.name, + position, + pick.snapshot_id, + pick.description, + ) + .map_err(|_| "Failed to copy profile pick")?; + } + for group in target_profile.groups { + cancellation + .throw_if_cancellation_requested() + .map_err(|_| "Profile clone cancelled")?; + if groups.current_groups().contains_key(&group.id) { + continue; + } + live_group_operation(backend, group.id, true, cancellation.clone()) + .await + .map_err(|error| { + format!( + "Profile updated, but group {} could not be joined: {error}", + group.id + ) + })?; + } + Ok("Profile synchronized".into()) +} + +pub(super) fn install_live(backend: &Arc, subscriptions: &mut Vec) { + let weak = Arc::downgrade(backend); + let grid = lock(&backend.client).grid(); + let runtime = tokio::runtime::Handle::current(); + subscriptions.push( + grid.subscribe_coarse_location_update(Arc::new(move |event| { + let Some(backend) = weak.upgrade() else { + return; + }; + let simulator = event.simulator(); + let reverse = simulator + .global_to_local_id + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + .map(|(id, local)| (*id, *local)) + .collect::>(); + let positions = event.positions(); + let ids = positions.keys().copied().collect::>(); + { + let mut state = lock(&backend.services_state); + for (id, position) in positions { + let local = reverse.get(&id).copied().unwrap_or_default(); + let avatar = state.fake.avatars.entry(id).or_insert_with(|| AvatarView { + id, + ..AvatarView::default() + }); + avatar.position = position; + avatar.local_id = local; + } + let removed = event.removed_entries(); + state.fake.avatars.retain(|id, _| !removed.contains(id)); + } + if ids.is_empty() { + return; + } + let weak = Arc::downgrade(&backend); + let avatars = lock(&backend.client).avatars(); + runtime.spawn(async move { + let Ok((true, Some(names), _)) = avatars.get_display_names(ids, None).await else { + return; + }; + let Some(backend) = weak.upgrade() else { + return; + }; + let mut state = lock(&backend.services_state); + for name in names { + if let Some(avatar) = state.fake.avatars.get_mut(&name.id()) { + avatar.name = name.legacy_full_name(); + } + } + }); + })), + ); + let weak = Arc::downgrade(backend); + subscriptions.push(backend.network.subscribe_packet( + PacketType::ViewerEffect, + Arc::new(move |event| { + let Some(backend) = weak.upgrade() else { + return; + }; + let Some(bytes) = event.raw_data() else { + return; + }; + let mut index = 0; + let Ok(packet) = libremetaverse::packets::ViewerEffectPacket::new_with_bytes_int32( + bytes, &mut index, + ) else { + return; + }; + let mut state = lock(&backend.services_state); + for effect in packet.effect { + state.fake.effect_sources.insert(effect.agent_id); + } + }), + false, + )); +} + +impl Default for GroupView { + fn default() -> Self { + Self { + group: empty_group(UUID::zero(), String::new()), + members: BTreeMap::new(), + roles: BTreeMap::new(), + } + } +} diff --git a/programs/tests/test_client_services_cli.rs b/programs/tests/test_client_services_cli.rs new file mode 100644 index 0000000..2127167 --- /dev/null +++ b/programs/tests/test_client_services_cli.rs @@ -0,0 +1,215 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; + +const CLIENT: &str = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; +const AVATAR: &str = "11111111-2222-3333-4444-555555555555"; +const BOT: &str = "22222222-3333-4444-5555-666666666666"; +const GROUP: &str = "33333333-4444-5555-6666-777777777777"; +const MEMBER: &str = "44444444-5555-6666-7777-888888888888"; +const ROLE: &str = "55555555-6666-7777-8888-999999999999"; +const CLASSIFIED: &str = "66666666-7777-8888-9999-aaaaaaaaaaaa"; +const LAND: &str = "77777777-8888-9999-aaaa-bbbbbbbbbbbb"; +const SIM: &str = "88888888-9999-aaaa-bbbb-cccccccccccc"; +const OBJECT: &str = "99999999-aaaa-bbbb-cccc-dddddddddddd"; +const DANCE1: &str = "b68a3d7c-de9e-fc87-eec8-543d787e5b0d"; + +static TEMP_ID: AtomicU64 = AtomicU64::new(0); + +struct TestDir(PathBuf); +impl TestDir { + fn new() -> Self { + let id = TEMP_ID.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "metacrate-test-client-services-{}-{id}", + std::process::id() + )); + fs::create_dir(&path).expect("create test directory"); + Self(path) + } + fn write(&self, contents: &str) -> PathBuf { + let path = self.0.join("session.tsv"); + fs::write(&path, contents).expect("write script"); + path + } +} +impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} +fn path_text(path: &Path) -> &str { + path.to_str().expect("UTF-8 test path") +} +fn run(args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_test-client")) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .expect("run test-client") +} + +#[test] +#[allow(clippy::too_many_lines)] +fn full_offline_session_exercises_every_remaining_non_voice_command() { + let directory = TestDir::new(); + let script = directory.write(&format!( + "!client\t{CLIENT}\tAlice\tBot\n\ + !service-avatar\t{CLIENT}\t{AVATAR}\tActive Resident\tBuilders\t128\t129\t25\t10\ttrue\n\ + !service-avatar\t{CLIENT}\t{BOT}\tQuiet Bot\tNone\t130\t131\t25\t11\tfalse\n\ + !service-friend\t{CLIENT}\t{AVATAR}\tActive Resident\ttrue\t4294967298000\t128\t129\t25\n\ + !service-group\t{CLIENT}\t{GROUP}\tBuilders Guild\n\ + !service-group-result\t{CLIENT}\t{GROUP}\tBuilders Guild\t42\n\ + !service-group-member\t{CLIENT}\t{GROUP}\t{MEMBER}\n\ + !service-group-role\t{CLIENT}\t{GROUP}\t{ROLE}\tOfficer\tArchitect\n\ + !service-person\t{CLIENT}\t{AVATAR}\tActive\tResident\ttrue\n\ + !service-classified\t{CLIENT}\t{CLASSIFIED}\tBuilder Services\t25\n\ + !service-event\t{CLIENT}\t7\tBuilding Class\tToday\n\ + !service-event-info\t{CLIENT}\t7\tBuilding Class\tTest Region\t1000\t2000\t25\n\ + !service-land\t{CLIENT}\t{LAND}\tBuilder Parcel\t1024\t500\n\ + !service-place\t{CLIENT}\tBuilder Plaza\tTest Region\t128\t128\t25\n\ + !service-sim\t{CLIENT}\tTest Region\t{SIM}\t4294967298000\t0.98\n\ + !service-utilization\t{CLIENT}\ttrue\n\ + !service-utilization-row\t{CLIENT}\tpacket\tAgentUpdate\t2\t3\t2048\t4096\n\ + !service-utilization-row\t{CLIENT}\tcapability\tEventQueueGet\t1\t4\t512\t8192\n\ + who\n\ + bots\n\ + friends\n\ + mapfriend {AVATAR}\n\ + key2name {AVATAR}\n\ + key2name {GROUP}\n\ + searchclassifieds Builder\n\ + searchevents Building\n\ + searchgroups Builders\n\ + searchland mainland 1000 512\n\ + searchpeople Active\n\ + searchplaces Builder\n\ + showevent 7\n\ + groups\n\ + groupmembers Builders Guild\n\ + grouproles Builders Guild\n\ + play list\n\ + play show\n\ + dilation\n\ + netstats\n\ + regioninfo\n\ + stats\n\ + uptime\n\ + cloneprofile {AVATAR} --confirm\n\ + sendgeneric test-method alpha beta --confirm\n\ + play DANCE1 --confirm\n\ + touch {OBJECT} --confirm\n\ + activategroup Builders Guild --confirm\n\ + invitegroup {AVATAR} {GROUP} {ROLE} --confirm\n\ + joingroup Builders Guild --confirm\n\ + leavegroup Builders Guild --confirm\n\ + quit\n" + )); + let output = run(&[ + "--allow-live-mutations", + "--allow-spending", + "--fake-script", + path_text(&script), + ]); + let stdout = String::from_utf8(output.stdout).expect("UTF-8 stdout"); + let stderr = String::from_utf8(output.stderr).expect("UTF-8 stderr"); + assert!( + output.status.success(), + "stdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert!(stderr.is_empty(), "{stderr}"); + for expected in [ + "Active Resident (Group: Builders", + "Quiet Bot (Group: None", + "Is Probably a bot", + "has 1 friends:", + "Found Friend 11111111-2222-3333-4444-555555555555", + "Avatar: Active Resident", + "Group: Builders Guild", + "returned 1 classified ads", + "matched 1 Events", + "matched 1 Groups", + "1 results", + "matched 1 People", + "returned 1 results", + " Region/232/208/0", + "got 1 groups", + "MemberCount 1", + "RoleCount 1", + "AFRAID", + DANCE1, + "Dilation is 0.98", + "Capabilities Totals", + "Packet Totals", + "UUID: 88888888-9999-aaaa-bbbb-cccccccccccc", + "Packets in the queue:", + "I am Alice Bot, Up Since:", + "Synchronized our profile", + "Sent generic message with method test-method", + "Touched object", + "Active group is now Builders Guild", + "invited", + "Joined the group Builders Guild", + "has left the group Builders Guild", + "CALL profile-clone", + "CALL generic-message", + "CALL animation-start", + "CALL object-touch", + "CALL group-activate", + "CALL group-invite", + "CALL group-join", + "CALL group-leave", + ] { + assert!(stdout.contains(expected), "missing {expected:?}:\n{stdout}"); + } +} + +#[test] +fn social_mutations_require_startup_and_per_command_confirmation() { + let directory = TestDir::new(); + let script = directory.write(&format!( + "!client\t{CLIENT}\tAlice\tBot\nplay {DANCE1} --confirm\nquit\n" + )); + let output = run(&["--fake-script", path_text(&script)]); + let stdout = String::from_utf8(output.stdout).expect("UTF-8 stdout"); + assert!(output.status.success()); + assert!(stdout.contains("Live mutations are disabled"), "{stdout}"); + assert!(!stdout.contains("CALL animation-start"), "{stdout}"); + + let script = directory.write(&format!( + "!client\t{CLIENT}\tAlice\tBot\ntouch {OBJECT}\nquit\n" + )); + let output = run(&[ + "--allow-live-mutations", + "--fake-script", + path_text(&script), + ]); + let stdout = String::from_utf8(output.stdout).expect("UTF-8 stdout"); + assert!(output.status.success()); + assert!(stdout.contains("append --confirm"), "{stdout}"); + assert!(!stdout.contains("CALL object-touch"), "{stdout}"); + + let script = directory.write(&format!( + "!client\t{CLIENT}\tAlice\tBot\njoingroup uuid {GROUP} --confirm\nquit\n" + )); + let output = run(&[ + "--allow-live-mutations", + "--fake-script", + path_text(&script), + ]); + let stdout = String::from_utf8(output.stdout).expect("UTF-8 stdout"); + assert!(output.status.success()); + assert!(stdout.contains("restart with --allow-spending"), "{stdout}"); + assert!(!stdout.contains("CALL group-join"), "{stdout}"); +} + +#[test] +fn command_inventory_leaves_only_explicit_voice_adapters() { + assert_eq!( + libremetaverse_programs::test_client::pending_test_client_commands(), + ["ParcelVoiceInfo", "VoiceAcountCommand"] + ); +} diff --git a/tests/compat/tests/avatar_manager_shims.rs b/tests/compat/tests/avatar_manager_shims.rs index d684101..4ec0750 100644 --- a/tests/compat/tests/avatar_manager_shims.rs +++ b/tests/compat/tests/avatar_manager_shims.rs @@ -1,8 +1,8 @@ use libremetaverse::animesh::AnimeshManager; use libremetaverse::appearance::CurrentOutfitFolder; use libremetaverse::{ - AgentManager, AppearanceManagerWearableData, AssetDownload, AssetManager, AssetUpload, - ChatEventArgs, ChatType, InventoryException, InventoryItem, InventoryManager, + AgentManager, Animations, AppearanceManagerWearableData, AssetDownload, AssetManager, + AssetUpload, ChatEventArgs, ChatType, InventoryException, InventoryItem, InventoryManager, TeleportEventArgs, }; use libremetaverse_types::compat::{CancellationToken, EventHandler, Subscription}; @@ -116,3 +116,16 @@ fn avatar_facing_constructors_preserve_native_defaults_and_catalog_errors() { "M:LibreMetaverse.InventoryException.#ctor" ); } + +#[test] +fn built_in_animation_dictionary_matches_the_pinned_source_catalog() { + let catalog = Animations::to_dictionary().expect("animation dictionary").0; + assert_eq!(catalog.len(), 135); + assert_eq!( + catalog.get(&Animations::dance1()).map(String::as_str), + Some("DANCE1") + ); + let yoga = UUID::new_with_string("42ecd00b-9947-a97c-400a-bbc9174c7aeb".into()) + .expect("YOGA_FLOAT UUID"); + assert_eq!(catalog.get(&yoga).map(String::as_str), Some("YOGA_FLOAT")); +} diff --git a/tools/generate_api_shims.py b/tools/generate_api_shims.py index 46cf8ba..25ebe49 100644 --- a/tools/generate_api_shims.py +++ b/tools/generate_api_shims.py @@ -624,6 +624,8 @@ NATIVE_GENERATED_TYPES = { NATIVE_MEMBER_BODIES = { "F:LibreMetaverse.Animations.DANCE1": "crate::animation::dance1()", + "M:LibreMetaverse.Animations.ToDictionary": + "Ok(libremetaverse_types::compat::FrozenDictionary(crate::animation::builtin_animations()))", "M:LibreMetaverse.RLV.RlvCommon.TryGetAttachmentPointFromItemName(System.String,System.Nullable{LibreMetaverse.RLV.RlvAttachmentPoint}@)": "Self::native_try_get_attachment_point_from_item_name(item_name, attachment_point)", "M:LibreMetaverse.RLV.RlvRestriction.#ctor(LibreMetaverse.RLV.RlvRestrictionType,System.Guid,System.String,System.Collections.Generic.ICollection{System.Object})":