Initial copy of business card prop with profile

This commit is contained in:
Evan Carroll 2026-01-24 10:03:10 -06:00
parent 4f0f88504a
commit 9541fb1927
16 changed files with 1193 additions and 71 deletions

View file

@ -866,6 +866,125 @@ impl From<&PropSource> for PropOrigin {
}
}
// ============================================================================
// State types for inventory item state management
// ============================================================================
/// Scope of state visibility for inventory items.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StateScope {
/// Server-wide scope: visible to everyone.
Server,
/// Realm scope: visible only to realm members.
Realm,
/// User scope: visible to those inspecting the item.
User,
}
impl std::fmt::Display for StateScope {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
StateScope::Server => write!(f, "server"),
StateScope::Realm => write!(f, "realm"),
StateScope::User => write!(f, "user"),
}
}
}
/// Visibility of state: public or private.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StateVisibility {
/// Public state: persists through transfer, visible based on scope.
Public,
/// Private state: cleared on transfer, only visible to owner.
Private,
}
impl std::fmt::Display for StateVisibility {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
StateVisibility::Public => write!(f, "public"),
StateVisibility::Private => write!(f, "private"),
}
}
}
/// Hint for how to render/handle a prop's state (detected from state content).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum PropActionHint {
/// Render as a business card with profile info and social links.
BusinessCard,
/// Render as an info panel with formatted JSON.
InfoPanel,
/// Render as a list of external links/buttons.
ExternalLinks,
}
/// View of prop state filtered by viewer permissions.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PropStateView {
/// The prop ID (inventory item or loose prop).
pub prop_id: Uuid,
/// The prop name.
pub prop_name: String,
/// Display name of the owner (if known).
pub owner_display_name: Option<String>,
/// Server-scoped public state (visible to everyone).
pub server_state: serde_json::Value,
/// Realm-scoped public state (None if viewer not in same realm).
pub realm_state: Option<serde_json::Value>,
/// User-scoped public state (visible when inspecting).
pub user_state: serde_json::Value,
/// Private state bundle (only included if viewer is owner).
pub private_state: Option<PrivateStateBundle>,
/// Detected action hint based on state content.
pub action_hint: Option<PropActionHint>,
}
/// Bundle of private state (only visible to owner).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrivateStateBundle {
pub server_private_state: serde_json::Value,
pub realm_private_state: serde_json::Value,
pub user_private_state: serde_json::Value,
}
impl PropStateView {
/// Detect action hint from state content.
pub fn detect_action_hint(server_state: &serde_json::Value) -> Option<PropActionHint> {
use serde_json::Value;
// Check for business card pattern (profile with social links)
if let Some(profile) = server_state.get("profile") {
let profile: &Value = profile;
if profile.get("linkedin").is_some()
|| profile.get("github").is_some()
|| profile.get("twitter").is_some()
|| profile.get("website").is_some()
{
return Some(PropActionHint::BusinessCard);
}
}
// Check for external links pattern
if server_state.get("links").is_some() {
return Some(PropActionHint::ExternalLinks);
}
// If there's any non-empty state, show as info panel
if let Some(obj) = server_state.as_object() {
if !obj.is_empty() {
return Some(PropActionHint::InfoPanel);
}
}
None
}
}
/// An inventory item (user-owned prop).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
@ -882,6 +1001,28 @@ pub struct InventoryItem {
/// The source of this prop (ServerLibrary, RealmLibrary, or UserUpload)
pub origin: PropOrigin,
pub acquired_at: DateTime<Utc>,
// Public state columns (persist through transfer)
/// Server-scoped state visible to everyone.
#[serde(default)]
pub server_state: serde_json::Value,
/// Realm-scoped state visible to realm members.
#[serde(default)]
pub realm_state: serde_json::Value,
/// User-scoped state visible to item inspectors.
#[serde(default)]
pub user_state: serde_json::Value,
// Private state columns (cleared on transfer)
/// Private server-scoped state, only visible to owner.
#[serde(default)]
pub server_private_state: serde_json::Value,
/// Private realm-scoped state, only visible to owner.
#[serde(default)]
pub realm_private_state: serde_json::Value,
/// Private user-scoped state, only visible to owner.
#[serde(default)]
pub user_private_state: serde_json::Value,
}
impl InventoryItem {
@ -960,6 +1101,10 @@ pub struct LoosePropRow {
pub prop_asset_path: String,
pub is_locked: bool,
pub locked_by: Option<Uuid>,
// Public state columns (loose props only have public state)
pub server_state: serde_json::Value,
pub realm_state: serde_json::Value,
pub user_state: serde_json::Value,
}
#[cfg(feature = "ssr")]
@ -989,6 +1134,9 @@ impl From<LoosePropRow> for LooseProp {
prop_asset_path: row.prop_asset_path,
is_locked: row.is_locked,
locked_by: row.locked_by,
server_state: row.server_state,
realm_state: row.realm_state,
user_state: row.user_state,
}
}
}
@ -1017,6 +1165,16 @@ pub struct LooseProp {
/// User ID of the moderator who locked this prop.
#[serde(default)]
pub locked_by: Option<Uuid>,
// Public state columns (loose props only have public state, private is cleared on drop)
/// Server-scoped state visible to everyone.
#[serde(default)]
pub server_state: serde_json::Value,
/// Realm-scoped state visible to realm members.
#[serde(default)]
pub realm_state: serde_json::Value,
/// User-scoped state from previous owner.
#[serde(default)]
pub user_state: serde_json::Value,
}
/// A server-wide prop (global library).

View file

@ -23,7 +23,13 @@ pub async fn list_user_inventory<'e>(
is_portable,
is_droppable,
origin,
acquired_at
acquired_at,
server_state,
realm_state,
user_state,
server_private_state,
realm_private_state,
user_private_state
FROM auth.inventory
WHERE user_id = $1
ORDER BY acquired_at DESC
@ -251,7 +257,13 @@ pub async fn acquire_server_prop<'e>(
origin,
is_transferable,
is_portable,
is_droppable
is_droppable,
server_state,
realm_state,
user_state,
server_private_state,
realm_private_state,
user_private_state
)
SELECT
$2,
@ -262,7 +274,13 @@ pub async fn acquire_server_prop<'e>(
'server_library'::server.prop_origin,
oc.is_transferable,
oc.is_portable,
oc.is_droppable
oc.is_droppable,
'{}'::jsonb,
'{}'::jsonb,
'{}'::jsonb,
'{}'::jsonb,
'{}'::jsonb,
'{}'::jsonb
FROM ownership_check oc
WHERE oc.is_active = true
AND oc.is_public = true
@ -280,7 +298,13 @@ pub async fn acquire_server_prop<'e>(
is_portable,
is_droppable,
origin,
acquired_at
acquired_at,
server_state,
realm_state,
user_state,
server_private_state,
realm_private_state,
user_private_state
)
SELECT * FROM inserted
"#,
@ -428,7 +452,13 @@ pub async fn acquire_realm_prop<'e>(
origin,
is_transferable,
is_portable,
is_droppable
is_droppable,
server_state,
realm_state,
user_state,
server_private_state,
realm_private_state,
user_private_state
)
SELECT
$3,
@ -439,7 +469,13 @@ pub async fn acquire_realm_prop<'e>(
'realm_library'::server.prop_origin,
oc.is_transferable,
true, -- realm props are portable by default
oc.is_droppable
oc.is_droppable,
'{}'::jsonb,
'{}'::jsonb,
'{}'::jsonb,
'{}'::jsonb,
'{}'::jsonb,
'{}'::jsonb
FROM ownership_check oc
WHERE oc.is_active = true
AND oc.is_public = true
@ -457,7 +493,13 @@ pub async fn acquire_realm_prop<'e>(
is_portable,
is_droppable,
origin,
acquired_at
acquired_at,
server_state,
realm_state,
user_state,
server_private_state,
realm_private_state,
user_private_state
)
SELECT * FROM inserted
"#,
@ -547,3 +589,103 @@ pub async fn get_realm_prop_acquisition_error<'e>(
)),
}
}
use crate::models::{StateScope, StateVisibility};
/// Update state on an inventory item.
///
/// Only the item owner can update state.
/// If `merge` is true, the new state is deep-merged with existing state.
/// If `merge` is false, the new state replaces existing state.
///
/// Returns the updated state value.
pub async fn update_inventory_item_state<'e>(
executor: impl PgExecutor<'e>,
user_id: Uuid,
item_id: Uuid,
scope: StateScope,
visibility: StateVisibility,
state: serde_json::Value,
merge: bool,
) -> Result<serde_json::Value, AppError> {
// Determine which column to update based on scope and visibility
let column_name = match (scope, visibility) {
(StateScope::Server, StateVisibility::Public) => "server_state",
(StateScope::Server, StateVisibility::Private) => "server_private_state",
(StateScope::Realm, StateVisibility::Public) => "realm_state",
(StateScope::Realm, StateVisibility::Private) => "realm_private_state",
(StateScope::User, StateVisibility::Public) => "user_state",
(StateScope::User, StateVisibility::Private) => "user_private_state",
};
// Build the update expression based on merge flag
// If merge is true, use jsonb_strip_nulls(old || new) for deep merge
// If merge is false, just replace with new value
let update_expr = if merge {
format!("{} = jsonb_strip_nulls({} || $3)", column_name, column_name)
} else {
format!("{} = $3", column_name)
};
// Build the full query
let query = format!(
r#"
UPDATE auth.inventory
SET {}
WHERE id = $1 AND user_id = $2
RETURNING {}
"#,
update_expr, column_name
);
let result: Option<(serde_json::Value,)> = sqlx::query_as(&query)
.bind(item_id)
.bind(user_id)
.bind(&state)
.fetch_optional(executor)
.await?;
match result {
Some((new_state,)) => Ok(new_state),
None => Err(AppError::NotFound(
"Inventory item not found or not owned by user".to_string(),
)),
}
}
/// Get an inventory item by ID with ownership verification.
pub async fn get_inventory_item<'e>(
executor: impl PgExecutor<'e>,
item_id: Uuid,
user_id: Uuid,
) -> Result<Option<InventoryItem>, AppError> {
let item = sqlx::query_as::<_, InventoryItem>(
r#"
SELECT
id,
COALESCE(server_prop_id, realm_prop_id) as prop_id,
prop_name,
prop_asset_path,
layer,
is_transferable,
is_portable,
is_droppable,
origin,
acquired_at,
server_state,
realm_state,
user_state,
server_private_state,
realm_private_state,
user_private_state
FROM auth.inventory
WHERE id = $1 AND user_id = $2
"#,
)
.bind(item_id)
.bind(user_id)
.fetch_optional(executor)
.await?;
Ok(item)
}

View file

@ -53,7 +53,10 @@ pub async fn list_channel_loose_props<'e>(
COALESCE(sp.name, rp.name) as prop_name,
COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path,
lp.is_locked,
lp.locked_by
lp.locked_by,
lp.server_state,
lp.realm_state,
lp.user_state
FROM scene.loose_props lp
LEFT JOIN server.props sp ON lp.server_prop_id = sp.id
LEFT JOIN realm.props rp ON lp.realm_prop_id = rp.id
@ -69,11 +72,37 @@ pub async fn list_channel_loose_props<'e>(
Ok(rows.into_iter().map(LooseProp::from).collect())
}
/// Result row type for drop_prop_to_canvas query.
/// Uses a struct instead of tuple because sqlx has a limit on tuple size.
#[derive(Debug, sqlx::FromRow)]
struct DropPropResult {
item_existed: bool,
was_droppable: bool,
was_deleted: bool,
id: Option<Uuid>,
channel_id: Option<Uuid>,
server_prop_id: Option<Uuid>,
realm_prop_id: Option<Uuid>,
position_x: Option<f32>,
position_y: Option<f32>,
scale: Option<f32>,
dropped_by: Option<Uuid>,
expires_at: Option<chrono::DateTime<chrono::Utc>>,
created_at: Option<chrono::DateTime<chrono::Utc>>,
prop_name: Option<String>,
prop_asset_path: Option<String>,
server_state: Option<serde_json::Value>,
realm_state: Option<serde_json::Value>,
user_state: Option<serde_json::Value>,
}
/// Drop a prop from inventory to the canvas.
///
/// Deletes from inventory and inserts into loose_props with 30-minute expiry.
/// Returns the created loose prop.
/// Returns an error if the prop is non-droppable (essential prop).
/// Note: Public state (server_state, realm_state, user_state) is transferred to the loose prop.
/// Private state is NOT transferred (cleared when dropped).
pub async fn drop_prop_to_canvas<'e>(
executor: impl PgExecutor<'e>,
inventory_item_id: Uuid,
@ -85,23 +114,8 @@ pub async fn drop_prop_to_canvas<'e>(
// Single CTE that checks existence/droppability and performs the operation atomically.
// Returns status flags plus the LooseProp data (if successful).
// Includes scale inherited from the source prop's default_scale.
let result: Option<(
bool,
bool,
bool,
Option<Uuid>,
Option<Uuid>,
Option<Uuid>,
Option<Uuid>,
Option<f32>,
Option<f32>,
Option<f32>,
Option<Uuid>,
Option<chrono::DateTime<chrono::Utc>>,
Option<chrono::DateTime<chrono::Utc>>,
Option<String>,
Option<String>,
)> = sqlx::query_as(
// Transfers public state columns (server_state, realm_state, user_state).
let result: Option<DropPropResult> = sqlx::query_as(
r#"
WITH item_info AS (
SELECT
@ -111,6 +125,9 @@ pub async fn drop_prop_to_canvas<'e>(
inv.realm_prop_id,
inv.prop_name,
inv.prop_asset_path,
inv.server_state,
inv.realm_state,
inv.user_state,
COALESCE(sp.default_scale, rp.default_scale, 1.0) as default_scale
FROM auth.inventory inv
LEFT JOIN server.props sp ON inv.server_prop_id = sp.id
@ -120,7 +137,8 @@ pub async fn drop_prop_to_canvas<'e>(
deleted_item AS (
DELETE FROM auth.inventory
WHERE id = $1 AND user_id = $2 AND is_droppable = true
RETURNING id, server_prop_id, realm_prop_id, prop_name, prop_asset_path
RETURNING id, server_prop_id, realm_prop_id, prop_name, prop_asset_path,
server_state, realm_state, user_state
),
inserted_prop AS (
INSERT INTO scene.loose_props (
@ -130,7 +148,10 @@ pub async fn drop_prop_to_canvas<'e>(
position,
scale,
dropped_by,
expires_at
expires_at,
server_state,
realm_state,
user_state
)
SELECT
$3,
@ -139,7 +160,10 @@ pub async fn drop_prop_to_canvas<'e>(
public.make_virtual_point($4::real, $5::real),
(SELECT default_scale FROM item_info),
$2,
now() + interval '30 minutes'
now() + interval '30 minutes',
di.server_state,
di.realm_state,
di.user_state
FROM deleted_item di
RETURNING
id,
@ -151,7 +175,10 @@ pub async fn drop_prop_to_canvas<'e>(
scale,
dropped_by,
expires_at,
created_at
created_at,
server_state,
realm_state,
user_state
)
SELECT
EXISTS(SELECT 1 FROM item_info) AS item_existed,
@ -168,7 +195,10 @@ pub async fn drop_prop_to_canvas<'e>(
ip.expires_at,
ip.created_at,
di.prop_name,
di.prop_asset_path
di.prop_asset_path,
ip.server_state,
ip.realm_state,
ip.user_state
FROM (SELECT 1) AS dummy
LEFT JOIN inserted_prop ip ON true
LEFT JOIN deleted_item di ON true
@ -189,41 +219,44 @@ pub async fn drop_prop_to_canvas<'e>(
"Unexpected error dropping prop to canvas".to_string(),
))
}
Some((false, _, _, _, _, _, _, _, _, _, _, _, _, _, _)) => {
Some(r) if !r.item_existed => {
// Item didn't exist
Err(AppError::NotFound(
"Inventory item not found or not owned by user".to_string(),
))
}
Some((true, false, _, _, _, _, _, _, _, _, _, _, _, _, _)) => {
Some(r) if r.item_existed && !r.was_droppable => {
// Item existed but is not droppable
Err(AppError::Forbidden(
"This prop cannot be dropped - it is an essential prop".to_string(),
))
}
Some((true, true, false, _, _, _, _, _, _, _, _, _, _, _, _)) => {
Some(r) if r.item_existed && r.was_droppable && !r.was_deleted => {
// Item was droppable but delete failed (shouldn't happen)
Err(AppError::Internal(
"Unexpected error dropping prop to canvas".to_string(),
))
}
Some((
true,
true,
true,
Some(id),
Some(channel_id),
Some(DropPropResult {
item_existed: true,
was_droppable: true,
was_deleted: true,
id: Some(id),
channel_id: Some(channel_id),
server_prop_id,
realm_prop_id,
Some(position_x),
Some(position_y),
Some(scale),
position_x: Some(position_x),
position_y: Some(position_y),
scale: Some(scale),
dropped_by,
Some(expires_at),
Some(created_at),
Some(prop_name),
Some(prop_asset_path),
)) => {
expires_at: Some(expires_at),
created_at: Some(created_at),
prop_name: Some(prop_name),
prop_asset_path: Some(prop_asset_path),
server_state: Some(server_state),
realm_state: Some(realm_state),
user_state: Some(user_state),
}) => {
// Construct PropSource from the nullable columns
let source = if let Some(sid) = server_prop_id {
PropSource::Server(sid)
@ -250,6 +283,9 @@ pub async fn drop_prop_to_canvas<'e>(
prop_asset_path,
is_locked: false,
locked_by: None,
server_state,
realm_state,
user_state,
})
}
_ => {
@ -264,19 +300,23 @@ pub async fn drop_prop_to_canvas<'e>(
/// Pick up a loose prop (delete from loose_props, insert to inventory).
///
/// Returns the created inventory item.
/// Note: Public state (server_state, realm_state, user_state) is transferred from the loose prop.
/// Private state is initialized to empty (cleared for new owner).
pub async fn pick_up_loose_prop<'e>(
executor: impl PgExecutor<'e>,
loose_prop_id: Uuid,
user_id: Uuid,
) -> Result<InventoryItem, AppError> {
// Use a CTE to delete from loose_props and insert to inventory
// Public state is transferred, private state is initialized to empty
let item = sqlx::query_as::<_, InventoryItem>(
r#"
WITH deleted_prop AS (
DELETE FROM scene.loose_props
WHERE id = $1
AND (expires_at IS NULL OR expires_at > now())
RETURNING id, server_prop_id, realm_prop_id
RETURNING id, server_prop_id, realm_prop_id,
server_state, realm_state, user_state
),
source_info AS (
SELECT
@ -287,7 +327,10 @@ pub async fn pick_up_loose_prop<'e>(
COALESCE(sp.is_portable, true) as is_portable,
COALESCE(sp.is_droppable, rp.is_droppable, true) as is_droppable,
dp.server_prop_id,
dp.realm_prop_id
dp.realm_prop_id,
dp.server_state,
dp.realm_state,
dp.user_state
FROM deleted_prop dp
LEFT JOIN server.props sp ON dp.server_prop_id = sp.id
LEFT JOIN realm.props rp ON dp.realm_prop_id = rp.id
@ -305,7 +348,15 @@ pub async fn pick_up_loose_prop<'e>(
is_portable,
is_droppable,
provenance,
acquired_at
acquired_at,
-- Transfer public state from loose prop
server_state,
realm_state,
user_state,
-- Initialize private state to empty (cleared for new owner)
server_private_state,
realm_private_state,
user_private_state
)
SELECT
$2,
@ -319,9 +370,20 @@ pub async fn pick_up_loose_prop<'e>(
COALESCE(si.is_portable, true),
COALESCE(si.is_droppable, true),
'[]'::jsonb,
now()
now(),
-- Transfer public state
si.server_state,
si.realm_state,
si.user_state,
-- Private state cleared for new owner
'{}'::jsonb,
'{}'::jsonb,
'{}'::jsonb
FROM source_info si
RETURNING id, server_prop_id, realm_prop_id, prop_name, prop_asset_path, layer, is_transferable, is_portable, is_droppable, origin, acquired_at
RETURNING id, server_prop_id, realm_prop_id, prop_name, prop_asset_path, layer,
is_transferable, is_portable, is_droppable, origin, acquired_at,
server_state, realm_state, user_state,
server_private_state, realm_private_state, user_private_state
)
SELECT
ii.id,
@ -333,7 +395,13 @@ pub async fn pick_up_loose_prop<'e>(
ii.is_portable,
ii.is_droppable,
ii.origin,
ii.acquired_at
ii.acquired_at,
ii.server_state,
ii.realm_state,
ii.user_state,
ii.server_private_state,
ii.realm_private_state,
ii.user_private_state
FROM inserted_item ii
"#,
)
@ -381,7 +449,10 @@ pub async fn update_loose_prop_scale<'e>(
expires_at,
created_at,
is_locked,
locked_by
locked_by,
server_state,
realm_state,
user_state
)
SELECT
u.id,
@ -397,7 +468,10 @@ pub async fn update_loose_prop_scale<'e>(
COALESCE(sp.name, rp.name) as prop_name,
COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path,
u.is_locked,
u.locked_by
u.locked_by,
u.server_state,
u.realm_state,
u.user_state
FROM updated u
LEFT JOIN server.props sp ON u.server_prop_id = sp.id
LEFT JOIN realm.props rp ON u.realm_prop_id = rp.id
@ -433,7 +507,10 @@ pub async fn get_loose_prop_by_id<'e>(
COALESCE(sp.name, rp.name) as prop_name,
COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path,
lp.is_locked,
lp.locked_by
lp.locked_by,
lp.server_state,
lp.realm_state,
lp.user_state
FROM scene.loose_props lp
LEFT JOIN server.props sp ON lp.server_prop_id = sp.id
LEFT JOIN realm.props rp ON lp.realm_prop_id = rp.id
@ -474,7 +551,10 @@ pub async fn move_loose_prop<'e>(
expires_at,
created_at,
is_locked,
locked_by
locked_by,
server_state,
realm_state,
user_state
)
SELECT
u.id,
@ -490,7 +570,10 @@ pub async fn move_loose_prop<'e>(
COALESCE(sp.name, rp.name) as prop_name,
COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path,
u.is_locked,
u.locked_by
u.locked_by,
u.server_state,
u.realm_state,
u.user_state
FROM updated u
LEFT JOIN server.props sp ON u.server_prop_id = sp.id
LEFT JOIN realm.props rp ON u.realm_prop_id = rp.id
@ -531,7 +614,10 @@ pub async fn lock_loose_prop<'e>(
expires_at,
created_at,
is_locked,
locked_by
locked_by,
server_state,
realm_state,
user_state
)
SELECT
u.id,
@ -547,7 +633,10 @@ pub async fn lock_loose_prop<'e>(
COALESCE(sp.name, rp.name) as prop_name,
COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path,
u.is_locked,
u.locked_by
u.locked_by,
u.server_state,
u.realm_state,
u.user_state
FROM updated u
LEFT JOIN server.props sp ON u.server_prop_id = sp.id
LEFT JOIN realm.props rp ON u.realm_prop_id = rp.id
@ -586,7 +675,10 @@ pub async fn unlock_loose_prop<'e>(
expires_at,
created_at,
is_locked,
locked_by
locked_by,
server_state,
realm_state,
user_state
)
SELECT
u.id,
@ -602,7 +694,10 @@ pub async fn unlock_loose_prop<'e>(
COALESCE(sp.name, rp.name) as prop_name,
COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path,
u.is_locked,
u.locked_by
u.locked_by,
u.server_state,
u.realm_state,
u.user_state
FROM updated u
LEFT JOIN server.props sp ON u.server_prop_id = sp.id
LEFT JOIN realm.props rp ON u.realm_prop_id = rp.id

View file

@ -5,7 +5,10 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::models::{AvatarRenderData, ChannelMemberInfo, ChannelMemberWithAvatar, ForcedAvatarReason, LooseProp};
use crate::models::{
AvatarRenderData, ChannelMemberInfo, ChannelMemberWithAvatar, ForcedAvatarReason, LooseProp,
PropStateView, StateScope, StateVisibility,
};
/// Default function for serde that returns true (for is_same_scene field).
/// Must be pub for serde derive macro to access via full path.
@ -146,6 +149,29 @@ pub enum ClientMessage {
/// The loose prop ID to delete.
loose_prop_id: Uuid,
},
/// Update state on an inventory item.
UpdateItemState {
/// The inventory item ID to update.
inventory_item_id: Uuid,
/// Which scope to update (server, realm, or user).
scope: StateScope,
/// Whether to update public or private state.
visibility: StateVisibility,
/// The new state value.
state: serde_json::Value,
/// If true, merge with existing state; if false, replace.
#[serde(default)]
merge: bool,
},
/// Request to view a prop's state.
ViewPropState {
/// The prop ID to view (either inventory item or loose prop).
prop_id: Uuid,
/// Whether this is a loose prop (true) or inventory item (false).
is_loose_prop: bool,
},
}
/// Server-to-client WebSocket messages.
@ -348,4 +374,22 @@ pub enum ServerMessage {
/// Display name of who cleared the forced avatar (if mod command).
cleared_by: Option<String>,
},
/// Response with prop state for viewing.
PropStateViewResponse {
/// The prop state view data.
view: PropStateView,
},
/// Confirmation of item state update.
ItemStateUpdated {
/// The inventory item ID that was updated.
inventory_item_id: Uuid,
/// Which scope was updated.
scope: StateScope,
/// Whether public or private state was updated.
visibility: StateVisibility,
/// The new state value.
new_state: serde_json::Value,
},
}