1522 lines
57 KiB
Rust
1522 lines
57 KiB
Rust
//! Narrow injected boundaries between orchestration and grid/world I/O.
|
|
|
|
use crate::policy::AuthorizedAction;
|
|
use crate::types::{GridEvent, GridEventKind, ToolCallOutcome};
|
|
#[cfg(feature = "live-grid")]
|
|
use libremetaverse_types::UUID;
|
|
use libremetaverse_types::compat::CancellationToken;
|
|
#[cfg(feature = "live-grid")]
|
|
use std::collections::BTreeMap;
|
|
use std::error::Error;
|
|
use std::fmt;
|
|
use std::future::Future;
|
|
use std::pin::Pin;
|
|
#[cfg(feature = "live-grid")]
|
|
use std::sync::Arc;
|
|
#[cfg(feature = "live-grid")]
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
#[cfg(feature = "live-grid")]
|
|
use std::time::Duration;
|
|
use tokio::sync::mpsc;
|
|
|
|
pub type BackendFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub enum BackendError {
|
|
Configuration { component: &'static str },
|
|
EventQueueClosed,
|
|
RejectedMutation,
|
|
Operation { operation: &'static str },
|
|
}
|
|
|
|
impl fmt::Display for BackendError {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::Configuration { component } => {
|
|
write!(formatter, "backend configuration failed for {component}")
|
|
}
|
|
Self::EventQueueClosed => formatter.write_str("grid-event owner closed its queue"),
|
|
Self::RejectedMutation => {
|
|
formatter.write_str("world mutation lacks an approved policy decision")
|
|
}
|
|
Self::Operation { operation } => {
|
|
write!(formatter, "backend operation failed: {operation}")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Error for BackendError {}
|
|
|
|
/// One owned grid event source. Implementations send into the coordinator-owned
|
|
/// bounded queue and must finish when cancellation is requested.
|
|
pub trait GridBackend: Send + Sync + 'static {
|
|
fn name(&self) -> &'static str;
|
|
|
|
fn run(
|
|
&self,
|
|
events: mpsc::Sender<GridEvent>,
|
|
cancellation: CancellationToken,
|
|
) -> BackendFuture<'_, Result<(), BackendError>>;
|
|
}
|
|
|
|
/// Backend for an already authorized tool action. `AuthorizedAction` has no
|
|
/// public constructor and binds the exact name, arguments, principal, budget,
|
|
/// and one policy authorization.
|
|
pub trait AuthorizedToolBackend: Send + Sync + 'static {
|
|
fn apply(
|
|
&self,
|
|
action: AuthorizedAction,
|
|
cancellation: CancellationToken,
|
|
) -> BackendFuture<'_, Result<ToolCallOutcome, BackendError>>;
|
|
}
|
|
|
|
/// Marker for world-mutating backends. Production mutations use the same
|
|
/// opaque authorization boundary as every other tool backend.
|
|
pub trait WorldMutator: AuthorizedToolBackend {}
|
|
|
|
/// Inert deterministic backend used by the foundational offline service.
|
|
///
|
|
/// It performs no login or network operation and is available without the
|
|
/// live-grid dependency graph.
|
|
#[derive(Clone, Copy, Debug, Default)]
|
|
pub struct OfflineGridBackend;
|
|
|
|
impl OfflineGridBackend {
|
|
#[must_use]
|
|
pub const fn new() -> Self {
|
|
Self
|
|
}
|
|
}
|
|
|
|
/// Live-feature composition owner that guarantees production adapters reuse
|
|
/// the existing `libremetaverse` manager/client graph.
|
|
#[cfg(feature = "live-grid")]
|
|
pub struct LibremetaverseClientOwner {
|
|
client: libremetaverse::GridClient,
|
|
network: libremetaverse::NetworkManager,
|
|
agent: Arc<libremetaverse::AgentManager>,
|
|
delivery_generation: Arc<std::sync::RwLock<Option<u64>>>,
|
|
}
|
|
|
|
/// Read-only adapter over caches already owned and populated by the shared
|
|
/// native manager graph. Capture never sends a packet or requests a capability.
|
|
#[cfg(feature = "live-grid")]
|
|
#[derive(Clone)]
|
|
pub struct LibremetaverseWorldSnapshotSource {
|
|
client: libremetaverse::GridClient,
|
|
agent: Arc<libremetaverse::AgentManager>,
|
|
}
|
|
|
|
#[cfg(feature = "live-grid")]
|
|
#[derive(Clone)]
|
|
pub struct LibremetaverseEmbodimentSink {
|
|
client: libremetaverse::GridClient,
|
|
agent: Arc<libremetaverse::AgentManager>,
|
|
generation: Arc<std::sync::RwLock<Option<u64>>>,
|
|
}
|
|
|
|
#[cfg(feature = "live-grid")]
|
|
impl fmt::Debug for LibremetaverseClientOwner {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
formatter
|
|
.debug_struct("LibremetaverseClientOwner")
|
|
.field("client", &"[NATIVE CLIENT REDACTED]")
|
|
.finish_non_exhaustive()
|
|
}
|
|
}
|
|
|
|
/// Feature-gated session adapter that reuses the existing native
|
|
/// `NetworkManager` login, event, disconnect, and logout lifecycle.
|
|
#[cfg(feature = "live-grid")]
|
|
#[derive(Clone)]
|
|
pub struct LibremetaverseSessionBackend {
|
|
network: libremetaverse::NetworkManager,
|
|
agent: Arc<libremetaverse::AgentManager>,
|
|
throttle: libremetaverse::AgentThrottle,
|
|
login_url: String,
|
|
first_name: String,
|
|
last_name: String,
|
|
password: crate::config::SecretString,
|
|
interaction: Option<crate::interaction::InteractionIngress>,
|
|
perception: Option<crate::perception::PerceptionIngress>,
|
|
behavior: Option<crate::behavior::BehaviorIngress>,
|
|
delivery_generation: Arc<std::sync::RwLock<Option<u64>>>,
|
|
}
|
|
|
|
#[cfg(feature = "live-grid")]
|
|
impl fmt::Debug for LibremetaverseSessionBackend {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
formatter
|
|
.debug_struct("LibremetaverseSessionBackend")
|
|
.field("transport_connected", &self.network.native_connected())
|
|
.field("login_url", &"[REDACTED ENDPOINT]")
|
|
.field("avatar", &"[REDACTED IDENTITY]")
|
|
.field("password", &self.password)
|
|
.finish_non_exhaustive()
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "live-grid")]
|
|
impl LibremetaverseClientOwner {
|
|
/// Builds the shared client composition root without starting login or I/O.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns a backend configuration error if the shared client defaults are invalid.
|
|
pub fn new() -> Result<Self, BackendError> {
|
|
let client = libremetaverse::GridClientBuilder::default()
|
|
.build()
|
|
.map_err(|_| BackendError::Configuration {
|
|
component: "libremetaverse client defaults",
|
|
})?;
|
|
// The compatibility client caches its network manager weakly. Retain the
|
|
// composition root before managers register packet subscriptions on it.
|
|
let network = client.network();
|
|
let _grid = client.grid();
|
|
let _terrain = client.terrain();
|
|
let agent = Arc::new(
|
|
libremetaverse::AgentManager::new(Some(Arc::new(client.clone()))).map_err(|_| {
|
|
BackendError::Configuration {
|
|
component: "native agent manager",
|
|
}
|
|
})?,
|
|
);
|
|
Ok(Self {
|
|
client,
|
|
network,
|
|
agent,
|
|
delivery_generation: Arc::new(std::sync::RwLock::new(None)),
|
|
})
|
|
}
|
|
|
|
#[must_use]
|
|
pub const fn client(&self) -> &libremetaverse::GridClient {
|
|
&self.client
|
|
}
|
|
|
|
/// Returns the one native agent manager owned by this composition root.
|
|
/// `MetaCrate` adapters clone the `Arc`; they never construct a parallel
|
|
/// compatibility manager graph.
|
|
#[must_use]
|
|
pub fn agent(&self) -> Arc<libremetaverse::AgentManager> {
|
|
Arc::clone(&self.agent)
|
|
}
|
|
|
|
/// Creates the supervised live-session adapter without logging in.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Rejects a malformed avatar identity before any network operation.
|
|
pub fn session_backend(
|
|
&self,
|
|
connection: crate::config::GridConnection,
|
|
) -> Result<LibremetaverseSessionBackend, BackendError> {
|
|
self.session_backend_inner(connection, None, None, None)
|
|
}
|
|
|
|
/// Creates a supervised live session whose generation owns exactly one
|
|
/// native chat and IM subscription.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Rejects a malformed avatar identity before any subscription or login.
|
|
pub fn session_backend_with_interaction(
|
|
&self,
|
|
connection: crate::config::GridConnection,
|
|
ingress: crate::interaction::InteractionIngress,
|
|
) -> Result<LibremetaverseSessionBackend, BackendError> {
|
|
self.session_backend_inner(connection, Some(ingress), None, None)
|
|
}
|
|
|
|
/// Creates a session that generation-fences interaction and perception.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Rejects a malformed avatar identity before any subscription or login.
|
|
pub fn session_backend_with_services(
|
|
&self,
|
|
connection: crate::config::GridConnection,
|
|
interaction: crate::interaction::InteractionIngress,
|
|
perception: crate::perception::PerceptionIngress,
|
|
) -> Result<LibremetaverseSessionBackend, BackendError> {
|
|
self.session_backend_inner(connection, Some(interaction), Some(perception), None)
|
|
}
|
|
|
|
/// Creates a session that generation-fences interaction, perception, and embodiment.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Rejects a malformed avatar identity before any subscription or login.
|
|
pub fn session_backend_with_agent_services(
|
|
&self,
|
|
connection: crate::config::GridConnection,
|
|
interaction: crate::interaction::InteractionIngress,
|
|
perception: crate::perception::PerceptionIngress,
|
|
behavior: crate::behavior::BehaviorIngress,
|
|
) -> Result<LibremetaverseSessionBackend, BackendError> {
|
|
self.session_backend_inner(
|
|
connection,
|
|
Some(interaction),
|
|
Some(perception),
|
|
Some(behavior),
|
|
)
|
|
}
|
|
|
|
fn session_backend_inner(
|
|
&self,
|
|
connection: crate::config::GridConnection,
|
|
interaction: Option<crate::interaction::InteractionIngress>,
|
|
perception: Option<crate::perception::PerceptionIngress>,
|
|
behavior: Option<crate::behavior::BehaviorIngress>,
|
|
) -> Result<LibremetaverseSessionBackend, BackendError> {
|
|
let avatar = connection.avatar_name.trim();
|
|
let (first_name, last_name) = avatar
|
|
.split_once(char::is_whitespace)
|
|
.map_or((avatar, "Resident"), |(first, last)| (first, last.trim()));
|
|
if first_name.is_empty() || last_name.is_empty() {
|
|
return Err(BackendError::Configuration {
|
|
component: "grid avatar identity",
|
|
});
|
|
}
|
|
Ok(LibremetaverseSessionBackend {
|
|
network: self.network.clone(),
|
|
agent: Arc::clone(&self.agent),
|
|
throttle: libremetaverse::AgentThrottle::new_with_grid_client(self.client.clone())
|
|
.map_err(|_| BackendError::Configuration {
|
|
component: "libremetaverse agent throttle",
|
|
})?,
|
|
login_url: connection.login_url.expose_url().to_owned(),
|
|
first_name: first_name.to_owned(),
|
|
last_name: last_name.to_owned(),
|
|
password: connection.password,
|
|
interaction,
|
|
perception,
|
|
behavior,
|
|
delivery_generation: Arc::clone(&self.delivery_generation),
|
|
})
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn interaction_sink(&self) -> LibremetaverseInteractionSink {
|
|
LibremetaverseInteractionSink {
|
|
agent: Arc::clone(&self.agent),
|
|
delivery_generation: Arc::clone(&self.delivery_generation),
|
|
}
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn world_snapshot_source(&self) -> LibremetaverseWorldSnapshotSource {
|
|
LibremetaverseWorldSnapshotSource {
|
|
client: self.client.clone(),
|
|
agent: Arc::clone(&self.agent),
|
|
}
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn embodiment_sink(&self) -> LibremetaverseEmbodimentSink {
|
|
LibremetaverseEmbodimentSink {
|
|
client: self.client.clone(),
|
|
agent: Arc::clone(&self.agent),
|
|
generation: Arc::clone(&self.delivery_generation),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "live-grid")]
|
|
impl LibremetaverseWorldSnapshotSource {
|
|
#[must_use]
|
|
pub fn current_region_handle(&self) -> Option<u64> {
|
|
self.client
|
|
.network()
|
|
.native_current_sim()
|
|
.map(|simulator| simulator.handle)
|
|
}
|
|
|
|
// One atomic projection keeps native cache reads together. Terrain lookup
|
|
// takes integral local coordinates, so finite region positions are floored.
|
|
#[allow(clippy::too_many_lines, clippy::cast_possible_truncation)]
|
|
fn capture_blocking(
|
|
&self,
|
|
generation: u64,
|
|
) -> Result<crate::perception::WorldSnapshot, crate::perception::PerceptionError> {
|
|
let network = self.client.network();
|
|
let simulator = network
|
|
.native_current_sim()
|
|
.ok_or(crate::perception::PerceptionError::SourceUnavailable)?;
|
|
if !network.native_connected()
|
|
|| !simulator
|
|
.native_is_event_queue_running(None)
|
|
.unwrap_or(false)
|
|
{
|
|
return Err(crate::perception::PerceptionError::NotReady);
|
|
}
|
|
let agent_position = self.agent.sim_position();
|
|
let position = crate::perception::WorldPosition {
|
|
x: f64::from(agent_position.x),
|
|
y: f64::from(agent_position.y),
|
|
z: f64::from(agent_position.z),
|
|
};
|
|
let avatar_cache = simulator
|
|
.objects_avatars
|
|
.read()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
let avatars_truncated = avatar_cache.len() > 2_048;
|
|
let mut avatars = BTreeMap::new();
|
|
for (local_id, avatar) in avatar_cache
|
|
.iter()
|
|
.filter(|(_, avatar)| avatar.id != UUID::zero())
|
|
{
|
|
avatars.insert(
|
|
*local_id,
|
|
crate::perception::ObservedAvatar {
|
|
id: avatar.id,
|
|
name: crate::perception::sanitize_untrusted(&avatar.name()),
|
|
position: native_position(avatar.position),
|
|
},
|
|
);
|
|
if avatars.len() > 2_048 {
|
|
avatars.pop_last();
|
|
}
|
|
}
|
|
drop(avatar_cache);
|
|
let avatars = avatars.into_values().collect();
|
|
let object_cache = simulator
|
|
.objects_primitives
|
|
.read()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
let objects_truncated = object_cache.len() > 2_048;
|
|
let mut objects = BTreeMap::new();
|
|
for (local_id, primitive) in object_cache
|
|
.iter()
|
|
.filter(|(_, primitive)| primitive.id != UUID::zero())
|
|
{
|
|
let observed = {
|
|
let (name, description) = primitive.properties.as_ref().map_or_else(
|
|
|| (String::new(), String::new()),
|
|
|properties| {
|
|
(
|
|
crate::perception::sanitize_untrusted(&properties.name),
|
|
crate::perception::sanitize_untrusted(&properties.description),
|
|
)
|
|
},
|
|
);
|
|
crate::perception::ObservedObject {
|
|
id: primitive.id,
|
|
local_id: primitive.local_id,
|
|
name,
|
|
description,
|
|
hover_text: crate::perception::sanitize_untrusted(&primitive.text),
|
|
position: native_position(primitive.position),
|
|
scale: native_position(primitive.scale),
|
|
is_attachment: primitive.is_attachment,
|
|
}
|
|
};
|
|
objects.insert(*local_id, observed);
|
|
if objects.len() > 2_048 {
|
|
objects.pop_last();
|
|
}
|
|
}
|
|
drop(object_cache);
|
|
let objects = objects.into_values().collect();
|
|
let parcel = simulator
|
|
.parcels
|
|
.read()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.values()
|
|
.filter(|parcel| {
|
|
agent_position.x >= parcel.aabb_min.x
|
|
&& agent_position.x <= parcel.aabb_max.x
|
|
&& agent_position.y >= parcel.aabb_min.y
|
|
&& agent_position.y <= parcel.aabb_max.y
|
|
})
|
|
.min_by_key(|parcel| parcel.local_id)
|
|
.map(|parcel| crate::perception::ParcelSnapshot {
|
|
name: crate::perception::sanitize_untrusted(&parcel.name),
|
|
description: crate::perception::sanitize_untrusted(&parcel.desc),
|
|
area: Some(parcel.area),
|
|
category: Some(format!("{:?}", parcel.category)),
|
|
});
|
|
if let Some(data) = self
|
|
.client
|
|
.environment()
|
|
.region_environment()
|
|
.and_then(|message| message.environment())
|
|
{
|
|
// Read the environment data from the manager's last region message.
|
|
let _ = data.day_cycle();
|
|
}
|
|
let mut terrain_height = 0.0;
|
|
let terrain_observed = agent_position.x.is_finite()
|
|
&& agent_position.y.is_finite()
|
|
&& simulator
|
|
.terrain_height_at_point(
|
|
agent_position.x.floor() as i32,
|
|
agent_position.y.floor() as i32,
|
|
&mut terrain_height,
|
|
)
|
|
.unwrap_or(false);
|
|
let environment = Some(crate::perception::EnvironmentSnapshot {
|
|
water_height: Some(simulator.water_height),
|
|
terrain_height: terrain_observed.then_some(terrain_height),
|
|
sun_phase: None,
|
|
region_access: Some(format!("{:?}", simulator.access)),
|
|
});
|
|
let (inventory, landmarks, inventory_truncated) = native_inventory_metadata(&self.client);
|
|
Ok(crate::perception::WorldSnapshot {
|
|
generation,
|
|
observed_unix_millis: crate::perception::unix_millis_now(),
|
|
region_id: simulator.region_id,
|
|
region_name: crate::perception::sanitize_untrusted(&simulator.name),
|
|
agent: crate::perception::AgentSnapshot {
|
|
id: self.agent.agent_id(),
|
|
position: Some(position),
|
|
health: Some(self.agent.health()),
|
|
sitting_on_local_id: (self.agent.sitting_on() != 0)
|
|
.then_some(self.agent.sitting_on()),
|
|
},
|
|
avatars,
|
|
avatars_truncated,
|
|
objects,
|
|
objects_truncated,
|
|
parcel,
|
|
environment,
|
|
inventory,
|
|
inventory_truncated,
|
|
landmarks,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "live-grid")]
|
|
impl crate::perception::WorldSnapshotSource for LibremetaverseWorldSnapshotSource {
|
|
fn capture(
|
|
&self,
|
|
generation: u64,
|
|
cancellation: CancellationToken,
|
|
) -> crate::perception::SnapshotFuture<'_> {
|
|
let source = self.clone();
|
|
Box::pin(async move {
|
|
if cancellation.is_cancellation_requested() {
|
|
return Err(crate::perception::PerceptionError::Cancelled);
|
|
}
|
|
let worker = tokio::task::spawn_blocking(move || source.capture_blocking(generation));
|
|
tokio::select! {
|
|
() = cancellation.cancelled() => Err(crate::perception::PerceptionError::Cancelled),
|
|
result = worker => result
|
|
.map_err(|_| crate::perception::PerceptionError::SourceUnavailable)?,
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "live-grid")]
|
|
impl LibremetaverseEmbodimentSink {
|
|
fn ensure_generation(&self, generation: u64) -> Result<(), crate::behavior::BehaviorError> {
|
|
let current = *self
|
|
.generation
|
|
.read()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
if current == Some(generation) && self.client.network().native_connected() {
|
|
Ok(())
|
|
} else {
|
|
Err(crate::behavior::BehaviorError::NotReady)
|
|
}
|
|
}
|
|
|
|
fn native_pose(
|
|
&self,
|
|
generation: u64,
|
|
) -> Result<crate::behavior::EmbodiedPose, crate::behavior::BehaviorError> {
|
|
self.ensure_generation(generation)?;
|
|
let simulator = self
|
|
.client
|
|
.network()
|
|
.native_current_sim()
|
|
.ok_or(crate::behavior::BehaviorError::NotReady)?;
|
|
let position = self.agent.sim_position();
|
|
let rotation = self.agent.sim_rotation();
|
|
let sin_yaw = 2.0 * f64::from(rotation.w * rotation.z + rotation.x * rotation.y);
|
|
let cos_yaw = 1.0 - 2.0 * f64::from(rotation.y * rotation.y + rotation.z * rotation.z);
|
|
Ok(crate::behavior::EmbodiedPose {
|
|
generation,
|
|
region_id: simulator.region_id,
|
|
position: native_position(position),
|
|
heading_degrees: sin_yaw.atan2(cos_yaw).to_degrees().rem_euclid(360.0),
|
|
sitting: self.agent.sitting_on() != 0,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "live-grid")]
|
|
#[allow(clippy::cast_possible_truncation)]
|
|
impl crate::behavior::EmbodimentSink for LibremetaverseEmbodimentSink {
|
|
fn current_pose(
|
|
&self,
|
|
generation: u64,
|
|
cancellation: CancellationToken,
|
|
) -> crate::behavior::EmbodimentFuture<'_, crate::behavior::EmbodiedPose> {
|
|
Box::pin(async move {
|
|
if cancellation.is_cancellation_requested() {
|
|
return Err(crate::behavior::BehaviorError::Cancelled);
|
|
}
|
|
self.native_pose(generation)
|
|
})
|
|
}
|
|
|
|
fn resolve_avatar(
|
|
&self,
|
|
generation: u64,
|
|
avatar_id: UUID,
|
|
cancellation: CancellationToken,
|
|
) -> crate::behavior::EmbodimentFuture<'_, crate::perception::WorldPosition> {
|
|
Box::pin(async move {
|
|
if cancellation.is_cancellation_requested() {
|
|
return Err(crate::behavior::BehaviorError::Cancelled);
|
|
}
|
|
self.ensure_generation(generation)?;
|
|
let simulator = self
|
|
.client
|
|
.network()
|
|
.native_current_sim()
|
|
.ok_or(crate::behavior::BehaviorError::NotReady)?;
|
|
let object_position = simulator
|
|
.objects_avatars
|
|
.read()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.values()
|
|
.find(|avatar| avatar.id == avatar_id)
|
|
.map(|avatar| native_position(avatar.position));
|
|
let coarse_position = self
|
|
.client
|
|
.grid()
|
|
.native_coarse_position(simulator.handle, avatar_id);
|
|
object_position
|
|
.or_else(|| {
|
|
let origin = self.agent.sim_position();
|
|
coarse_position.map(|position| crate::perception::WorldPosition {
|
|
x: unwrap_coarse_axis(position.x, origin.x),
|
|
y: unwrap_coarse_axis(position.y, origin.y),
|
|
z: f64::from(position.z),
|
|
})
|
|
})
|
|
.ok_or(crate::behavior::BehaviorError::TargetUnavailable)
|
|
})
|
|
}
|
|
|
|
fn face_point(
|
|
&self,
|
|
generation: u64,
|
|
point: crate::perception::WorldPosition,
|
|
cancellation: CancellationToken,
|
|
) -> crate::behavior::EmbodimentFuture<'_, ()> {
|
|
Box::pin(async move {
|
|
if cancellation.is_cancellation_requested() {
|
|
return Err(crate::behavior::BehaviorError::Cancelled);
|
|
}
|
|
self.ensure_generation(generation)?;
|
|
self.agent
|
|
.movement
|
|
.turn_toward(
|
|
libremetaverse_types::Vector3 {
|
|
x: point.x as f32,
|
|
y: point.y as f32,
|
|
z: point.z as f32,
|
|
},
|
|
Some(true),
|
|
)
|
|
.map_err(|_| crate::behavior::BehaviorError::NativeOperation)?;
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
fn begin_walk(
|
|
&self,
|
|
generation: u64,
|
|
point: crate::perception::WorldPosition,
|
|
cancellation: CancellationToken,
|
|
) -> crate::behavior::EmbodimentFuture<'_, ()> {
|
|
Box::pin(async move {
|
|
if cancellation.is_cancellation_requested() {
|
|
return Err(crate::behavior::BehaviorError::Cancelled);
|
|
}
|
|
self.ensure_generation(generation)?;
|
|
self.agent
|
|
.auto_pilot_local(
|
|
point.x.round() as i32,
|
|
point.y.round() as i32,
|
|
point.z as f32,
|
|
)
|
|
.map_err(|_| crate::behavior::BehaviorError::NativeOperation)
|
|
})
|
|
}
|
|
|
|
fn validate_walk_target(
|
|
&self,
|
|
generation: u64,
|
|
point: crate::perception::WorldPosition,
|
|
cancellation: CancellationToken,
|
|
) -> crate::behavior::EmbodimentFuture<'_, ()> {
|
|
Box::pin(async move {
|
|
if cancellation.is_cancellation_requested() {
|
|
return Err(crate::behavior::BehaviorError::Cancelled);
|
|
}
|
|
self.ensure_generation(generation)?;
|
|
let simulator = self
|
|
.client
|
|
.network()
|
|
.native_current_sim()
|
|
.ok_or(crate::behavior::BehaviorError::NotReady)?;
|
|
let current_position = self.agent.sim_position();
|
|
let reported_bounds_contain_agent = f64::from(current_position.x)
|
|
<= f64::from(simulator.size_x)
|
|
&& f64::from(current_position.y) <= f64::from(simulator.size_y);
|
|
if point.x < 0.0
|
|
|| point.y < 0.0
|
|
|| (reported_bounds_contain_agent
|
|
&& (point.x > f64::from(simulator.size_x)
|
|
|| point.y > f64::from(simulator.size_y)))
|
|
{
|
|
return Err(crate::behavior::BehaviorError::RegionBoundary);
|
|
}
|
|
let parcels = simulator
|
|
.parcels
|
|
.read()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
let parcel_at = |x: f32, y: f32| {
|
|
parcels
|
|
.values()
|
|
.filter(|parcel| {
|
|
x >= parcel.aabb_min.x
|
|
&& x <= parcel.aabb_max.x
|
|
&& y >= parcel.aabb_min.y
|
|
&& y <= parcel.aabb_max.y
|
|
})
|
|
.map(|parcel| parcel.local_id)
|
|
.min()
|
|
};
|
|
let current = parcel_at(current_position.x, current_position.y);
|
|
let target = parcel_at(point.x as f32, point.y as f32);
|
|
if current
|
|
.zip(target)
|
|
.is_some_and(|(current, target)| current != target)
|
|
|| (reported_bounds_contain_agent && (current.is_none() || target.is_none()))
|
|
{
|
|
return Err(crate::behavior::BehaviorError::RegionBoundary);
|
|
}
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
fn stop(
|
|
&self,
|
|
generation: u64,
|
|
cancellation: CancellationToken,
|
|
) -> crate::behavior::EmbodimentFuture<'_, ()> {
|
|
Box::pin(async move {
|
|
if cancellation.is_cancellation_requested() {
|
|
return Err(crate::behavior::BehaviorError::Cancelled);
|
|
}
|
|
self.ensure_generation(generation)?;
|
|
self.agent
|
|
.auto_pilot_cancel()
|
|
.map(|_| ())
|
|
.map_err(|_| crate::behavior::BehaviorError::NativeOperation)
|
|
})
|
|
}
|
|
|
|
fn sit(
|
|
&self,
|
|
generation: u64,
|
|
cancellation: CancellationToken,
|
|
) -> crate::behavior::EmbodimentFuture<'_, ()> {
|
|
Box::pin(async move {
|
|
if cancellation.is_cancellation_requested() {
|
|
return Err(crate::behavior::BehaviorError::Cancelled);
|
|
}
|
|
self.ensure_generation(generation)?;
|
|
self.agent
|
|
.sit()
|
|
.map_err(|_| crate::behavior::BehaviorError::NativeOperation)
|
|
})
|
|
}
|
|
|
|
fn stand(
|
|
&self,
|
|
generation: u64,
|
|
cancellation: CancellationToken,
|
|
) -> crate::behavior::EmbodimentFuture<'_, ()> {
|
|
Box::pin(async move {
|
|
if cancellation.is_cancellation_requested() {
|
|
return Err(crate::behavior::BehaviorError::Cancelled);
|
|
}
|
|
self.ensure_generation(generation)?;
|
|
self.agent
|
|
.stand()
|
|
.map(|_| ())
|
|
.map_err(|_| crate::behavior::BehaviorError::NativeOperation)
|
|
})
|
|
}
|
|
|
|
fn set_camera(
|
|
&self,
|
|
generation: u64,
|
|
position: crate::perception::WorldPosition,
|
|
target: crate::perception::WorldPosition,
|
|
vertical_fov_degrees: f64,
|
|
cancellation: CancellationToken,
|
|
) -> crate::behavior::EmbodimentFuture<'_, ()> {
|
|
Box::pin(async move {
|
|
if cancellation.is_cancellation_requested() {
|
|
return Err(crate::behavior::BehaviorError::Cancelled);
|
|
}
|
|
self.ensure_generation(generation)?;
|
|
self.agent
|
|
.movement
|
|
.camera
|
|
.look_at_with_vector3_vector3(
|
|
libremetaverse_types::Vector3 {
|
|
x: position.x as f32,
|
|
y: position.y as f32,
|
|
z: position.z as f32,
|
|
},
|
|
libremetaverse_types::Vector3 {
|
|
x: target.x as f32,
|
|
y: target.y as f32,
|
|
z: target.z as f32,
|
|
},
|
|
)
|
|
.map_err(|_| crate::behavior::BehaviorError::NativeOperation)?;
|
|
self.agent
|
|
.movement
|
|
.set_fov_vertical_angle((vertical_fov_degrees as f32).to_radians())
|
|
.and_then(|()| self.agent.movement.send_update_with_boolean(Some(true)))
|
|
.map_err(|_| crate::behavior::BehaviorError::NativeOperation)
|
|
})
|
|
}
|
|
|
|
fn reset_camera(
|
|
&self,
|
|
generation: u64,
|
|
cancellation: CancellationToken,
|
|
) -> crate::behavior::EmbodimentFuture<'_, ()> {
|
|
Box::pin(async move {
|
|
if cancellation.is_cancellation_requested() {
|
|
return Err(crate::behavior::BehaviorError::Cancelled);
|
|
}
|
|
self.ensure_generation(generation)?;
|
|
native_reset_camera(&self.agent)
|
|
.and_then(|()| self.agent.movement.send_update_with_boolean(Some(true)))
|
|
.map_err(|_| crate::behavior::BehaviorError::NativeOperation)
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "live-grid")]
|
|
fn unwrap_coarse_axis(encoded: f32, origin: f32) -> f64 {
|
|
f64::from(encoded + ((origin - encoded) / 256.0).round() * 256.0)
|
|
}
|
|
|
|
#[cfg(all(test, feature = "live-grid"))]
|
|
mod live_grid_tests {
|
|
#[test]
|
|
fn coarse_avatar_axis_unwraps_to_nearest_varregion_tile() {
|
|
assert!((super::unwrap_coarse_axis(164.0, 678.0) - 676.0).abs() < f64::EPSILON);
|
|
assert!((super::unwrap_coarse_axis(26.0, 539.0) - 538.0).abs() < f64::EPSILON);
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "live-grid")]
|
|
fn native_position(value: libremetaverse_types::Vector3) -> crate::perception::WorldPosition {
|
|
crate::perception::WorldPosition {
|
|
x: f64::from(value.x),
|
|
y: f64::from(value.y),
|
|
z: f64::from(value.z),
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "live-grid")]
|
|
fn native_reset_camera(agent: &libremetaverse::AgentManager) -> Result<(), libremetaverse::Error> {
|
|
let avatar = agent.sim_position();
|
|
let forward = libremetaverse_types::Vector3::mul_with_vector3_quaternion(
|
|
libremetaverse_types::Vector3::unit_x(),
|
|
agent.sim_rotation(),
|
|
);
|
|
let position = libremetaverse_types::Vector3 {
|
|
x: avatar.x,
|
|
y: avatar.y,
|
|
z: avatar.z + 1.6,
|
|
};
|
|
let target = libremetaverse_types::Vector3 {
|
|
x: position.x + forward.x * 4.0,
|
|
y: position.y + forward.y * 4.0,
|
|
z: position.z + forward.z * 4.0,
|
|
};
|
|
agent
|
|
.movement
|
|
.camera
|
|
.look_at_with_vector3_vector3(position, target)?;
|
|
agent.movement.set_fov_vertical_angle(60.0_f32.to_radians())
|
|
}
|
|
|
|
#[cfg(feature = "live-grid")]
|
|
fn native_inventory_metadata(
|
|
client: &libremetaverse::GridClient,
|
|
) -> (
|
|
Vec<crate::perception::InventoryMetadata>,
|
|
Vec<crate::perception::LandmarkMetadata>,
|
|
bool,
|
|
) {
|
|
let Some(store) = client.inventory().store() else {
|
|
return (Vec::new(), Vec::new(), false);
|
|
};
|
|
let mut inventory = Vec::new();
|
|
let mut landmarks = Vec::new();
|
|
let mut pending = vec![(store.root_node(), String::new())];
|
|
let mut visited = 0_usize;
|
|
let mut truncated = false;
|
|
while let Some((node, parent_path)) = pending.pop() {
|
|
if visited >= 2_048 {
|
|
truncated = true;
|
|
break;
|
|
}
|
|
visited += 1;
|
|
let path = node.data().map_or_else(
|
|
|| parent_path.clone(),
|
|
|data| {
|
|
let base = data.inventory_base();
|
|
let name = crate::perception::sanitize_untrusted(&base.name());
|
|
let path = crate::perception::sanitize_untrusted(&if parent_path.is_empty() {
|
|
format!("/{name}")
|
|
} else {
|
|
format!("{parent_path}/{name}")
|
|
});
|
|
if let Some(item) = data.inventory_item() {
|
|
let modified_unix_millis = item
|
|
.creation_date()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.ok()
|
|
.and_then(|duration| u64::try_from(duration.as_millis()).ok());
|
|
inventory.push(crate::perception::InventoryMetadata {
|
|
id: base.uuid(),
|
|
name: name.clone(),
|
|
kind: format!("{:?}", item.inventory_type()),
|
|
path: path.clone(),
|
|
modified_unix_millis,
|
|
});
|
|
if item.asset_type() == libremetaverse_types::AssetType::Landmark {
|
|
landmarks.push(crate::perception::LandmarkMetadata {
|
|
id: base.uuid(),
|
|
name,
|
|
// Region is stored in the landmark asset body. The
|
|
// read-only metadata tool never downloads or decodes it.
|
|
region_id: None,
|
|
});
|
|
}
|
|
}
|
|
path
|
|
},
|
|
);
|
|
let mut children = node.nodes().values();
|
|
children.sort_by_key(|child| {
|
|
child.data().map_or_else(String::new, |value| {
|
|
crate::perception::sanitize_untrusted(&value.inventory_base().name())
|
|
})
|
|
});
|
|
for child in children.into_iter().rev() {
|
|
pending.push((child, path.clone()));
|
|
}
|
|
}
|
|
(inventory, landmarks, truncated || !pending.is_empty())
|
|
}
|
|
|
|
#[cfg(feature = "live-grid")]
|
|
#[derive(Clone)]
|
|
pub struct LibremetaverseInteractionSink {
|
|
agent: Arc<libremetaverse::AgentManager>,
|
|
delivery_generation: Arc<std::sync::RwLock<Option<u64>>>,
|
|
}
|
|
|
|
#[cfg(feature = "live-grid")]
|
|
impl fmt::Debug for LibremetaverseInteractionSink {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
formatter
|
|
.debug_struct("LibremetaverseInteractionSink")
|
|
.field("agent", &"[NATIVE AGENT MANAGER]")
|
|
.finish_non_exhaustive()
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "live-grid")]
|
|
impl crate::interaction::InteractionSink for LibremetaverseInteractionSink {
|
|
fn deliver(
|
|
&self,
|
|
outbound: crate::interaction::OutboundInteraction,
|
|
cancellation: CancellationToken,
|
|
) -> crate::interaction::DeliveryFuture<'_> {
|
|
let result = if cancellation.is_cancellation_requested() {
|
|
Err(crate::interaction::InteractionDeliveryError::Disconnected)
|
|
} else {
|
|
let generation = self
|
|
.delivery_generation
|
|
.read()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
if *generation == Some(outbound.generation) {
|
|
match outbound.channel {
|
|
crate::interaction::InteractionChannel::PublicChat => self.agent.chat(
|
|
outbound.body.into_inner(),
|
|
0,
|
|
libremetaverse::ChatType::Normal,
|
|
Some(false),
|
|
),
|
|
crate::interaction::InteractionChannel::DirectIm => {
|
|
self.agent.instant_message_with_uuid_string(
|
|
outbound.recipient_id,
|
|
outbound.body.into_inner(),
|
|
)
|
|
}
|
|
}
|
|
.map_err(|_| crate::interaction::InteractionDeliveryError::Failed)
|
|
} else {
|
|
Err(crate::interaction::InteractionDeliveryError::Disconnected)
|
|
}
|
|
};
|
|
Box::pin(async move { result })
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "live-grid")]
|
|
struct LibremetaverseSession {
|
|
generation: u64,
|
|
network: libremetaverse::NetworkManager,
|
|
signals: mpsc::Receiver<crate::session::SessionSignal>,
|
|
subscriptions: Vec<libremetaverse_types::compat::Subscription>,
|
|
interaction: Option<crate::interaction::InteractionIngress>,
|
|
perception: Option<crate::perception::PerceptionIngress>,
|
|
behavior: Option<crate::behavior::BehaviorIngress>,
|
|
delivery_generation: Arc<std::sync::RwLock<Option<u64>>>,
|
|
}
|
|
|
|
#[cfg(feature = "live-grid")]
|
|
impl crate::session::GridSession for LibremetaverseSession {
|
|
fn generation(&self) -> u64 {
|
|
self.generation
|
|
}
|
|
|
|
fn next_signal(
|
|
&mut self,
|
|
cancellation: CancellationToken,
|
|
) -> crate::session::SessionFuture<'_, crate::session::SessionSignal> {
|
|
Box::pin(async move {
|
|
tokio::select! {
|
|
signal = self.signals.recv() => signal.unwrap_or_else(|| {
|
|
crate::session::SessionSignal::Disconnected(
|
|
crate::session::SessionFailure::new(
|
|
crate::session::SessionFailureKind::TransientTransport,
|
|
),
|
|
)
|
|
}),
|
|
() = cancellation.cancelled() => crate::session::SessionSignal::Disconnected(
|
|
crate::session::SessionFailure::new(
|
|
crate::session::SessionFailureKind::TransientTransport,
|
|
),
|
|
),
|
|
}
|
|
})
|
|
}
|
|
|
|
fn logout(
|
|
mut self: Box<Self>,
|
|
cancellation: CancellationToken,
|
|
) -> crate::session::SessionFuture<'static, Result<(), crate::session::SessionFailure>> {
|
|
Box::pin(async move {
|
|
if let Some(interaction) = &self.interaction {
|
|
let _ = interaction.try_disconnected();
|
|
}
|
|
if let Some(perception) = &self.perception {
|
|
perception.disconnected();
|
|
}
|
|
if let Some(behavior) = &self.behavior {
|
|
behavior.disconnected();
|
|
tokio::task::yield_now().await;
|
|
}
|
|
*self
|
|
.delivery_generation
|
|
.write()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
|
|
self.subscriptions.clear();
|
|
self.network
|
|
.native_logout_async(Some(cancellation))
|
|
.await
|
|
.map_err(|_| {
|
|
crate::session::SessionFailure::new(
|
|
crate::session::SessionFailureKind::TransientTransport,
|
|
)
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "live-grid")]
|
|
impl crate::session::GridSessionBackend for LibremetaverseSessionBackend {
|
|
#[allow(clippy::too_many_lines)]
|
|
fn login(
|
|
&self,
|
|
generation: u64,
|
|
cancellation: CancellationToken,
|
|
) -> crate::session::SessionFuture<
|
|
'_,
|
|
Result<Box<dyn crate::session::GridSession>, crate::session::SessionFailure>,
|
|
> {
|
|
Box::pin(async move {
|
|
*self
|
|
.delivery_generation
|
|
.write()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
|
|
let mut start = "last";
|
|
loop {
|
|
let mut params = self
|
|
.network
|
|
.native_default_login_params(
|
|
self.first_name.clone(),
|
|
self.last_name.clone(),
|
|
self.password.expose_secret().to_owned(),
|
|
"MetaCrate".to_owned(),
|
|
env!("CARGO_PKG_VERSION").to_owned(),
|
|
)
|
|
.map_err(|_| {
|
|
crate::session::SessionFailure::new(
|
|
crate::session::SessionFailureKind::InvalidConfiguration,
|
|
)
|
|
})?;
|
|
params.uri.clone_from(&self.login_url);
|
|
start.clone_into(&mut params.start);
|
|
let logged_in = self
|
|
.network
|
|
.native_login(params, Some(cancellation.clone()))
|
|
.await
|
|
.map_err(|_| {
|
|
crate::session::SessionFailure::new(
|
|
crate::session::SessionFailureKind::TransientTransport,
|
|
)
|
|
})?;
|
|
if logged_in {
|
|
let simulator = self.network.current_sim().ok_or_else(|| {
|
|
crate::session::SessionFailure::new(
|
|
crate::session::SessionFailureKind::TransientTransport,
|
|
)
|
|
})?;
|
|
self.throttle
|
|
.set_with_simulator(Some(simulator))
|
|
.map_err(|_| {
|
|
crate::session::SessionFailure::new(
|
|
crate::session::SessionFailureKind::TransientTransport,
|
|
)
|
|
})?;
|
|
native_reset_camera(&self.agent).map_err(|_| {
|
|
crate::session::SessionFailure::new(
|
|
crate::session::SessionFailureKind::TransientTransport,
|
|
)
|
|
})?;
|
|
break;
|
|
}
|
|
let error_key = self.network.native_login_error_key();
|
|
let message = self.network.native_login_message();
|
|
if start == "last" && native_last_location_unavailable(&message) {
|
|
start = "home";
|
|
continue;
|
|
}
|
|
return Err(classify_native_login_failure(&error_key, &message));
|
|
}
|
|
|
|
// Native login has already installed the current simulator and
|
|
// parsed inventory state. The existing GridClient managers retain
|
|
// world/inventory and movement-worker ownership; this generation
|
|
// adds only one disconnect and one readiness subscription.
|
|
let (sender, signals) = mpsc::channel(8);
|
|
let disconnected_sender = sender.clone();
|
|
let disconnected_interaction = self.interaction.clone();
|
|
let disconnected_perception = self.perception.clone();
|
|
let disconnected_behavior = self.behavior.clone();
|
|
let disconnected_generation = Arc::clone(&self.delivery_generation);
|
|
let disconnected = self
|
|
.network
|
|
.native_subscribe_disconnected(Arc::new(move |event| {
|
|
*disconnected_generation
|
|
.write()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
|
|
if let Some(interaction) = &disconnected_interaction {
|
|
let _ = interaction.try_disconnected();
|
|
}
|
|
if let Some(perception) = &disconnected_perception {
|
|
perception.disconnected();
|
|
}
|
|
if let Some(behavior) = &disconnected_behavior {
|
|
behavior.disconnected();
|
|
}
|
|
let kind = match event.reason() {
|
|
libremetaverse::NetworkManagerDisconnectType::NetworkTimeout
|
|
| libremetaverse::NetworkManagerDisconnectType::ClientInitiated => {
|
|
crate::session::SessionFailureKind::TransientTransport
|
|
}
|
|
libremetaverse::NetworkManagerDisconnectType::ServerInitiated => {
|
|
crate::session::SessionFailureKind::Kicked
|
|
}
|
|
libremetaverse::NetworkManagerDisconnectType::SimShutdown => {
|
|
crate::session::SessionFailureKind::Maintenance
|
|
}
|
|
};
|
|
let _ =
|
|
disconnected_sender.try_send(crate::session::SessionSignal::Disconnected(
|
|
crate::session::SessionFailure::new(kind),
|
|
));
|
|
}));
|
|
let ready_sender = sender.clone();
|
|
let ready_interaction = self.interaction.clone();
|
|
let ready_perception = self.perception.clone();
|
|
let ready_behavior = self.behavior.clone();
|
|
let ready_network = self.network.clone();
|
|
let ready_agent = Arc::clone(&self.agent);
|
|
let ready_generation = Arc::clone(&self.delivery_generation);
|
|
let ready_once = Arc::new(AtomicBool::new(false));
|
|
let callback_ready = Arc::clone(&ready_once);
|
|
let ready = self
|
|
.network
|
|
.native_subscribe_event_queue_running(Arc::new(move |_| {
|
|
if !callback_ready.swap(true, Ordering::AcqRel) {
|
|
*ready_generation
|
|
.write()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(generation);
|
|
if let Some(interaction) = &ready_interaction {
|
|
let _ = interaction.try_connected(generation, ready_agent.agent_id());
|
|
}
|
|
if let (Some(perception), Some(simulator)) =
|
|
(&ready_perception, ready_network.native_current_sim())
|
|
{
|
|
let _ = perception.connected(generation, simulator.region_id);
|
|
}
|
|
if let (Some(behavior), Some(simulator)) =
|
|
(&ready_behavior, ready_network.native_current_sim())
|
|
{
|
|
let _ = behavior.connected(generation, simulator.region_id);
|
|
}
|
|
let _ = ready_sender.try_send(crate::session::SessionSignal::Ready);
|
|
}
|
|
}));
|
|
let mut subscriptions = vec![disconnected, ready];
|
|
if let Some(perception) = &self.perception {
|
|
let crossing_perception = perception.clone();
|
|
let crossing_behavior = self.behavior.clone();
|
|
let crossing_network = self.network.clone();
|
|
subscriptions.push(self.network.native_subscribe_sim_changed(Arc::new(
|
|
move |_| {
|
|
if let Some(simulator) = crossing_network.native_current_sim() {
|
|
let _ =
|
|
crossing_perception.region_changed(generation, simulator.region_id);
|
|
if let Some(behavior) = &crossing_behavior {
|
|
let _ = behavior.region_changed(generation, simulator.region_id);
|
|
}
|
|
}
|
|
},
|
|
)));
|
|
}
|
|
if let Some(interaction) = &self.interaction {
|
|
let chat_ingress = interaction.clone();
|
|
let chat_agent = Arc::clone(&self.agent);
|
|
subscriptions.push(self.agent.subscribe_chat_from_simulator(Arc::new(
|
|
move |event| {
|
|
if let Some(inbound) = native_public_interaction(&chat_agent, &event) {
|
|
let _ = chat_ingress.try_submit(inbound);
|
|
}
|
|
},
|
|
)));
|
|
let im_ingress = interaction.clone();
|
|
let im_agent = Arc::clone(&self.agent);
|
|
subscriptions.push(self.agent.subscribe_im(Arc::new(move |event| {
|
|
if let Some(inbound) = native_im_interaction(&im_agent, &event) {
|
|
let _ = im_ingress.try_submit(inbound);
|
|
}
|
|
})));
|
|
}
|
|
if !self.network.native_connected() {
|
|
let _ = sender.try_send(crate::session::SessionSignal::Disconnected(
|
|
crate::session::SessionFailure::new(
|
|
crate::session::SessionFailureKind::TransientTransport,
|
|
),
|
|
));
|
|
} else if self
|
|
.network
|
|
.native_current_sim()
|
|
.and_then(|simulator| simulator.native_is_event_queue_running(None).ok())
|
|
.unwrap_or(false)
|
|
&& !ready_once.swap(true, Ordering::AcqRel)
|
|
{
|
|
*self
|
|
.delivery_generation
|
|
.write()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(generation);
|
|
if let Some(interaction) = &self.interaction {
|
|
let _ = interaction.try_connected(generation, self.agent.agent_id());
|
|
}
|
|
if let (Some(perception), Some(simulator)) =
|
|
(&self.perception, self.network.native_current_sim())
|
|
{
|
|
let _ = perception.connected(generation, simulator.region_id);
|
|
}
|
|
if let (Some(behavior), Some(simulator)) =
|
|
(&self.behavior, self.network.native_current_sim())
|
|
{
|
|
let _ = behavior.connected(generation, simulator.region_id);
|
|
}
|
|
let _ = sender.try_send(crate::session::SessionSignal::Ready);
|
|
}
|
|
let session: Box<dyn crate::session::GridSession> = Box::new(LibremetaverseSession {
|
|
generation,
|
|
network: self.network.clone(),
|
|
signals,
|
|
subscriptions,
|
|
interaction: self.interaction.clone(),
|
|
perception: self.perception.clone(),
|
|
behavior: self.behavior.clone(),
|
|
delivery_generation: Arc::clone(&self.delivery_generation),
|
|
});
|
|
Ok(session)
|
|
})
|
|
}
|
|
|
|
fn flush_audit(
|
|
&self,
|
|
_cancellation: CancellationToken,
|
|
) -> crate::session::SessionFuture<'_, Result<(), crate::session::SessionFailure>> {
|
|
Box::pin(async { Ok(()) })
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "live-grid")]
|
|
fn native_public_interaction(
|
|
agent: &libremetaverse::AgentManager,
|
|
event: &libremetaverse::ChatEventArgs,
|
|
) -> Option<crate::interaction::InboundInteraction> {
|
|
if event.audible_level() == libremetaverse::ChatAudibleLevel::Not
|
|
|| !matches!(
|
|
event.type_(),
|
|
libremetaverse::ChatType::Normal
|
|
| libremetaverse::ChatType::Whisper
|
|
| libremetaverse::ChatType::Shout
|
|
)
|
|
{
|
|
return None;
|
|
}
|
|
let source = match event.source_type() {
|
|
libremetaverse::ChatSourceType::Agent => crate::interaction::InboundSource::Avatar,
|
|
libremetaverse::ChatSourceType::Object => crate::interaction::InboundSource::Object,
|
|
libremetaverse::ChatSourceType::System => crate::interaction::InboundSource::System,
|
|
};
|
|
let source_id = event.source_id();
|
|
let owner_id = event.owner_id();
|
|
let message = event.message();
|
|
let name = event.from_name();
|
|
let bucket = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap_or(Duration::ZERO)
|
|
.as_secs()
|
|
/ 5;
|
|
let delivery_id = native_delivery_id(
|
|
"chat",
|
|
&[
|
|
&source_id.to_string(),
|
|
&owner_id.to_string(),
|
|
&bucket.to_string(),
|
|
&message,
|
|
],
|
|
);
|
|
crate::interaction::InboundInteraction::new(
|
|
delivery_id,
|
|
source_id,
|
|
name,
|
|
crate::interaction::InteractionChannel::PublicChat,
|
|
source,
|
|
crate::interaction::ImDialogKind::OneToOneMessage,
|
|
event.audible_level() == libremetaverse::ChatAudibleLevel::Fully,
|
|
native_muted(agent, source_id, owner_id),
|
|
message,
|
|
)
|
|
.ok()
|
|
}
|
|
|
|
#[cfg(feature = "live-grid")]
|
|
fn native_im_interaction(
|
|
agent: &libremetaverse::AgentManager,
|
|
event: &libremetaverse::InstantMessageEventArgs,
|
|
) -> Option<crate::interaction::InboundInteraction> {
|
|
let im = event.im();
|
|
let dialog = match im.dialog {
|
|
libremetaverse::InstantMessageDialog::MessageFromAgent if !im.group_im => {
|
|
crate::interaction::ImDialogKind::OneToOneMessage
|
|
}
|
|
libremetaverse::InstantMessageDialog::StartTyping
|
|
| libremetaverse::InstantMessageDialog::StopTyping => {
|
|
crate::interaction::ImDialogKind::Typing
|
|
}
|
|
libremetaverse::InstantMessageDialog::SessionSend => {
|
|
crate::interaction::ImDialogKind::GroupOrConference
|
|
}
|
|
_ => crate::interaction::ImDialogKind::Unsupported,
|
|
};
|
|
let timestamp = im
|
|
.timestamp
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap_or(Duration::ZERO)
|
|
.as_millis()
|
|
.to_string();
|
|
let delivery_id = native_delivery_id(
|
|
"im",
|
|
&[
|
|
&im.from_agent_id.to_string(),
|
|
&im.im_session_id.to_string(),
|
|
×tamp,
|
|
&im.message,
|
|
],
|
|
);
|
|
crate::interaction::InboundInteraction::new(
|
|
delivery_id,
|
|
im.from_agent_id,
|
|
im.from_agent_name,
|
|
crate::interaction::InteractionChannel::DirectIm,
|
|
crate::interaction::InboundSource::Avatar,
|
|
dialog,
|
|
false,
|
|
native_muted(agent, im.from_agent_id, UUID::zero()),
|
|
im.message,
|
|
)
|
|
.ok()
|
|
}
|
|
|
|
#[cfg(feature = "live-grid")]
|
|
fn native_muted(agent: &libremetaverse::AgentManager, source: UUID, owner: UUID) -> bool {
|
|
agent
|
|
.mute_list
|
|
.read()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.values()
|
|
.any(|entry| entry.id == source || (owner != UUID::zero() && entry.id == owner))
|
|
}
|
|
|
|
#[cfg(feature = "live-grid")]
|
|
fn native_delivery_id(prefix: &str, fields: &[&str]) -> String {
|
|
use sha2::Digest as _;
|
|
use std::fmt::Write as _;
|
|
let mut digest = sha2::Sha256::new();
|
|
for field in fields {
|
|
digest.update(field.as_bytes());
|
|
digest.update([0]);
|
|
}
|
|
let mut output = String::with_capacity(prefix.len() + 65);
|
|
output.push_str(prefix);
|
|
output.push('-');
|
|
for byte in digest.finalize() {
|
|
let _ = write!(output, "{byte:02x}");
|
|
}
|
|
output
|
|
}
|
|
|
|
#[cfg(feature = "live-grid")]
|
|
fn classify_native_login_failure(error_key: &str, message: &str) -> crate::session::SessionFailure {
|
|
let normalized = error_key.trim().to_ascii_lowercase();
|
|
let kind = if native_last_location_unavailable(message)
|
|
|| message
|
|
.trim()
|
|
.to_ascii_lowercase()
|
|
.contains("already logged in")
|
|
{
|
|
crate::session::SessionFailureKind::ServerFailure
|
|
} else if matches!(
|
|
normalized.as_str(),
|
|
"key" | "password" | "credential" | "account" | "username" | "user"
|
|
) {
|
|
crate::session::SessionFailureKind::InvalidCredentials
|
|
} else if normalized == "canceled" {
|
|
crate::session::SessionFailureKind::TransientTransport
|
|
} else {
|
|
crate::session::SessionFailureKind::ServerFailure
|
|
};
|
|
crate::session::SessionFailure::new(kind)
|
|
}
|
|
|
|
#[cfg(feature = "live-grid")]
|
|
fn native_last_location_unavailable(message: &str) -> bool {
|
|
let message = message.trim().to_ascii_lowercase();
|
|
message.contains("failed to verify user presence")
|
|
|| message.contains("access denied to region")
|
|
}
|
|
|
|
impl GridBackend for OfflineGridBackend {
|
|
fn name(&self) -> &'static str {
|
|
"offline-fake"
|
|
}
|
|
|
|
fn run(
|
|
&self,
|
|
events: mpsc::Sender<GridEvent>,
|
|
cancellation: CancellationToken,
|
|
) -> BackendFuture<'_, Result<(), BackendError>> {
|
|
Box::pin(async move {
|
|
let ready = GridEvent {
|
|
sequence: 1,
|
|
kind: GridEventKind::BackendReady,
|
|
};
|
|
tokio::select! {
|
|
() = cancellation.cancelled() => return Ok(()),
|
|
result = events.send(ready) => {
|
|
result.map_err(|_| BackendError::EventQueueClosed)?;
|
|
}
|
|
}
|
|
cancellation.cancelled().await;
|
|
Ok(())
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use libremetaverse_types::compat::CancellationTokenSource;
|
|
|
|
#[tokio::test]
|
|
async fn offline_backend_stops_on_cancellation() {
|
|
let backend = OfflineGridBackend::new();
|
|
let (sender, mut receiver) = mpsc::channel(1);
|
|
let cancellation = CancellationTokenSource::new();
|
|
let run = backend.run(sender, cancellation.token());
|
|
tokio::pin!(run);
|
|
tokio::select! {
|
|
event = receiver.recv() => {
|
|
assert_eq!(event.expect("ready event").kind, GridEventKind::BackendReady);
|
|
}
|
|
result = &mut run => panic!("backend exited before ready: {result:?}"),
|
|
}
|
|
cancellation.cancel();
|
|
run.await.expect("clean cancellation");
|
|
}
|
|
|
|
#[cfg(feature = "live-grid")]
|
|
#[test]
|
|
fn stale_presence_is_retryable_without_masking_bad_credentials() {
|
|
assert!(native_last_location_unavailable(
|
|
"Failed to verify user presence in the grid, access denied to region",
|
|
));
|
|
assert_eq!(
|
|
classify_native_login_failure(
|
|
"account",
|
|
"Failed to verify user presence in the grid, access denied to region",
|
|
)
|
|
.kind(),
|
|
crate::session::SessionFailureKind::ServerFailure,
|
|
);
|
|
assert_eq!(
|
|
classify_native_login_failure("account", "Invalid credentials").kind(),
|
|
crate::session::SessionFailureKind::InvalidCredentials,
|
|
);
|
|
}
|
|
}
|