support aquiring server props and test dropping them

This commit is contained in:
Evan Carroll 2026-01-20 18:33:15 -06:00
parent 3e1afb82c8
commit 7852790a1e
9 changed files with 858 additions and 150 deletions

View file

@ -295,6 +295,7 @@ pub enum AvatarLayer {
#[default]
Clothes,
Accessories,
Emote,
}
impl std::fmt::Display for AvatarLayer {
@ -303,6 +304,7 @@ impl std::fmt::Display for AvatarLayer {
AvatarLayer::Skin => write!(f, "skin"),
AvatarLayer::Clothes => write!(f, "clothes"),
AvatarLayer::Accessories => write!(f, "accessories"),
AvatarLayer::Emote => write!(f, "emote"),
}
}
}
@ -315,6 +317,7 @@ impl std::str::FromStr for AvatarLayer {
"skin" => Ok(AvatarLayer::Skin),
"clothes" => Ok(AvatarLayer::Clothes),
"accessories" => Ok(AvatarLayer::Accessories),
"emote" => Ok(AvatarLayer::Emote),
_ => Err(format!("Invalid avatar layer: {}", s)),
}
}
@ -685,21 +688,40 @@ pub struct InventoryResponse {
pub items: Vec<InventoryItem>,
}
/// A public prop from server or realm library.
/// Used for the public inventory tabs (Server/Realm).
/// Extended prop info for acquisition UI (works for both server and realm props).
/// Includes ownership and availability status for the current user.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct PublicProp {
pub struct PropAcquisitionInfo {
pub id: Uuid,
pub name: String,
pub asset_path: String,
pub description: Option<String>,
pub is_unique: bool,
/// User already has this prop in their inventory.
pub user_owns: bool,
/// For unique props: someone has already claimed it.
pub is_claimed: bool,
/// Prop is within its availability window (or has no window).
pub is_available: bool,
}
/// Response for public props list.
/// Request to acquire a prop (server or realm).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PublicPropsResponse {
pub props: Vec<PublicProp>,
pub struct AcquirePropRequest {
pub prop_id: Uuid,
}
/// Response after acquiring a prop.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AcquirePropResponse {
pub item: InventoryItem,
}
/// Response for prop acquisition list with status.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PropAcquisitionListResponse {
pub props: Vec<PropAcquisitionInfo>,
}
/// A prop dropped in a channel, available for pickup.

View file

@ -3,7 +3,7 @@
use sqlx::PgExecutor;
use uuid::Uuid;
use crate::models::{InventoryItem, PublicProp};
use crate::models::{InventoryItem, PropAcquisitionInfo};
use chattyness_error::AppError;
/// List all inventory items for a user.
@ -92,66 +92,455 @@ pub async fn drop_inventory_item<'e>(
Ok(())
}
/// List all public server props.
/// List public server props with optional acquisition status.
///
/// Returns props that are:
/// - Active (`is_active = true`)
/// - Public (`is_public = true`)
/// - Currently available (within availability window if set)
pub async fn list_public_server_props<'e>(
/// Returns props that are active and public, with flags indicating:
/// - `user_owns`: Whether the user already has this prop (false if no user_id)
/// - `is_claimed`: Whether a unique prop has been claimed by anyone
/// - `is_available`: Whether the prop is within its availability window
///
/// When `user_id` is None, returns default values for user-specific fields.
pub async fn list_server_props<'e>(
executor: impl PgExecutor<'e>,
) -> Result<Vec<PublicProp>, AppError> {
let props = sqlx::query_as::<_, PublicProp>(
user_id: Option<Uuid>,
) -> Result<Vec<PropAcquisitionInfo>, AppError> {
let props = sqlx::query_as::<_, PropAcquisitionInfo>(
r#"
SELECT
id,
name,
asset_path,
description
FROM server.props
WHERE is_active = true
AND is_public = true
AND (available_from IS NULL OR available_from <= now())
AND (available_until IS NULL OR available_until > now())
ORDER BY name ASC
p.id,
p.name,
p.asset_path,
p.description,
p.is_unique,
CASE
WHEN $1::uuid IS NOT NULL THEN EXISTS(
SELECT 1 FROM auth.inventory i
WHERE i.user_id = $1 AND i.server_prop_id = p.id
)
ELSE false
END AS user_owns,
CASE
WHEN p.is_unique THEN EXISTS(
SELECT 1 FROM auth.inventory i WHERE i.server_prop_id = p.id
)
ELSE false
END AS is_claimed,
(p.available_from IS NULL OR p.available_from <= now())
AND (p.available_until IS NULL OR p.available_until > now()) AS is_available
FROM server.props p
WHERE p.is_active = true
AND p.is_public = true
ORDER BY p.name ASC
"#,
)
.bind(user_id)
.fetch_all(executor)
.await?;
Ok(props)
}
/// List all public realm props for a specific realm.
/// List public realm props with optional acquisition status.
///
/// Returns props that are:
/// - In the specified realm
/// - Active (`is_active = true`)
/// - Public (`is_public = true`)
/// - Currently available (within availability window if set)
pub async fn list_public_realm_props<'e>(
/// Returns props that are active and public in the specified realm, with flags indicating:
/// - `user_owns`: Whether the user already has this prop (false if no user_id)
/// - `is_claimed`: Whether a unique prop has been claimed by anyone
/// - `is_available`: Whether the prop is within its availability window
///
/// When `user_id` is None, returns default values for user-specific fields.
pub async fn list_realm_props<'e>(
executor: impl PgExecutor<'e>,
realm_id: Uuid,
) -> Result<Vec<PublicProp>, AppError> {
let props = sqlx::query_as::<_, PublicProp>(
user_id: Option<Uuid>,
) -> Result<Vec<PropAcquisitionInfo>, AppError> {
let props = sqlx::query_as::<_, PropAcquisitionInfo>(
r#"
SELECT
id,
name,
asset_path,
description
FROM realm.props
WHERE realm_id = $1
AND is_active = true
AND is_public = true
AND (available_from IS NULL OR available_from <= now())
AND (available_until IS NULL OR available_until > now())
ORDER BY name ASC
p.id,
p.name,
p.asset_path,
p.description,
p.is_unique,
CASE
WHEN $2::uuid IS NOT NULL THEN EXISTS(
SELECT 1 FROM auth.inventory i
WHERE i.user_id = $2 AND i.realm_prop_id = p.id
)
ELSE false
END AS user_owns,
CASE
WHEN p.is_unique THEN EXISTS(
SELECT 1 FROM auth.inventory i WHERE i.realm_prop_id = p.id
)
ELSE false
END AS is_claimed,
(p.available_from IS NULL OR p.available_from <= now())
AND (p.available_until IS NULL OR p.available_until > now()) AS is_available
FROM realm.props p
WHERE p.realm_id = $1
AND p.is_active = true
AND p.is_public = true
ORDER BY p.name ASC
"#,
)
.bind(realm_id)
.bind(user_id)
.fetch_all(executor)
.await?;
Ok(props)
}
/// Acquire a server prop into user's inventory.
///
/// Atomically validates and acquires the prop:
/// - Validates prop is active, public, within availability window
/// - For unique props: checks no one owns it yet
/// - For non-unique props: checks user doesn't already own it
/// - Inserts into `auth.inventory` with `origin = server_library`
///
/// Returns the created inventory item or an appropriate error.
pub async fn acquire_server_prop<'e>(
executor: impl PgExecutor<'e>,
prop_id: Uuid,
user_id: Uuid,
) -> Result<InventoryItem, AppError> {
// Use a CTE to atomically check conditions and insert
let result: Option<InventoryItem> = sqlx::query_as(
r#"
WITH prop_check AS (
SELECT
p.id,
p.name,
p.asset_path,
p.default_layer,
p.is_unique,
p.is_transferable,
p.is_portable,
p.is_droppable,
p.is_active,
p.is_public,
(p.available_from IS NULL OR p.available_from <= now()) AS available_from_ok,
(p.available_until IS NULL OR p.available_until > now()) AS available_until_ok
FROM server.props p
WHERE p.id = $1
),
ownership_check AS (
SELECT
pc.*,
EXISTS(
SELECT 1 FROM auth.inventory i
WHERE i.user_id = $2 AND i.server_prop_id = $1
) AS user_owns,
CASE
WHEN pc.is_unique THEN EXISTS(
SELECT 1 FROM auth.inventory i WHERE i.server_prop_id = $1
)
ELSE false
END AS is_claimed
FROM prop_check pc
),
inserted AS (
INSERT INTO auth.inventory (
user_id,
server_prop_id,
prop_name,
prop_asset_path,
layer,
origin,
is_transferable,
is_portable,
is_droppable
)
SELECT
$2,
oc.id,
oc.name,
oc.asset_path,
oc.default_layer,
'server_library'::server.prop_origin,
oc.is_transferable,
oc.is_portable,
oc.is_droppable
FROM ownership_check oc
WHERE oc.is_active = true
AND oc.is_public = true
AND oc.available_from_ok = true
AND oc.available_until_ok = true
AND oc.user_owns = false
AND oc.is_claimed = false
RETURNING
id,
prop_name,
prop_asset_path,
layer,
is_transferable,
is_portable,
is_droppable,
origin,
acquired_at
)
SELECT * FROM inserted
"#,
)
.bind(prop_id)
.bind(user_id)
.fetch_optional(executor)
.await?;
match result {
Some(item) => Ok(item),
None => {
// Need to determine the specific error case
// We'll do a separate query to understand why it failed
Err(AppError::Conflict(
"Unable to acquire prop - it may not exist, not be available, or already owned"
.to_string(),
))
}
}
}
/// Get detailed acquisition error for a server prop.
///
/// This is called when acquire_server_prop fails to determine the specific error.
pub async fn get_server_prop_acquisition_error<'e>(
executor: impl PgExecutor<'e>,
prop_id: Uuid,
user_id: Uuid,
) -> Result<AppError, AppError> {
#[derive(sqlx::FromRow)]
#[allow(dead_code)]
struct PropStatus {
exists: bool,
is_active: bool,
is_public: bool,
is_available: bool,
is_unique: bool,
user_owns: bool,
is_claimed: bool,
}
let status: Option<PropStatus> = sqlx::query_as(
r#"
SELECT
true AS exists,
p.is_active,
p.is_public,
(p.available_from IS NULL OR p.available_from <= now())
AND (p.available_until IS NULL OR p.available_until > now()) AS is_available,
p.is_unique,
EXISTS(
SELECT 1 FROM auth.inventory i
WHERE i.user_id = $2 AND i.server_prop_id = $1
) AS user_owns,
CASE
WHEN p.is_unique THEN EXISTS(
SELECT 1 FROM auth.inventory i WHERE i.server_prop_id = $1
)
ELSE false
END AS is_claimed
FROM server.props p
WHERE p.id = $1
"#,
)
.bind(prop_id)
.bind(user_id)
.fetch_optional(executor)
.await?;
match status {
None => Ok(AppError::NotFound("Server prop not found".to_string())),
Some(s) if !s.is_active || !s.is_public => {
Ok(AppError::Forbidden("This prop is not available".to_string()))
}
Some(s) if !s.is_available => Ok(AppError::Forbidden(
"This prop is not currently available".to_string(),
)),
Some(s) if s.user_owns => Ok(AppError::Conflict("You already own this prop".to_string())),
Some(s) if s.is_claimed => Ok(AppError::Conflict(
"This unique prop has already been claimed by another user".to_string(),
)),
Some(_) => Ok(AppError::Internal(
"Unknown error acquiring prop".to_string(),
)),
}
}
/// Acquire a realm prop into user's inventory.
///
/// Atomically validates and acquires the prop:
/// - Validates prop belongs to realm, is active, public, within availability window
/// - For unique props: checks no one owns it yet
/// - For non-unique props: checks user doesn't already own it
/// - Inserts into `auth.inventory` with `origin = realm_library`
///
/// Returns the created inventory item or an appropriate error.
pub async fn acquire_realm_prop<'e>(
executor: impl PgExecutor<'e>,
prop_id: Uuid,
realm_id: Uuid,
user_id: Uuid,
) -> Result<InventoryItem, AppError> {
// Use a CTE to atomically check conditions and insert
let result: Option<InventoryItem> = sqlx::query_as(
r#"
WITH prop_check AS (
SELECT
p.id,
p.name,
p.asset_path,
p.default_layer,
p.is_unique,
p.is_transferable,
p.is_droppable,
p.is_active,
p.is_public,
(p.available_from IS NULL OR p.available_from <= now()) AS available_from_ok,
(p.available_until IS NULL OR p.available_until > now()) AS available_until_ok
FROM realm.props p
WHERE p.id = $1 AND p.realm_id = $2
),
ownership_check AS (
SELECT
pc.*,
EXISTS(
SELECT 1 FROM auth.inventory i
WHERE i.user_id = $3 AND i.realm_prop_id = $1
) AS user_owns,
CASE
WHEN pc.is_unique THEN EXISTS(
SELECT 1 FROM auth.inventory i WHERE i.realm_prop_id = $1
)
ELSE false
END AS is_claimed
FROM prop_check pc
),
inserted AS (
INSERT INTO auth.inventory (
user_id,
realm_prop_id,
prop_name,
prop_asset_path,
layer,
origin,
is_transferable,
is_portable,
is_droppable
)
SELECT
$3,
oc.id,
oc.name,
oc.asset_path,
oc.default_layer,
'realm_library'::server.prop_origin,
oc.is_transferable,
true, -- realm props are portable by default
oc.is_droppable
FROM ownership_check oc
WHERE oc.is_active = true
AND oc.is_public = true
AND oc.available_from_ok = true
AND oc.available_until_ok = true
AND oc.user_owns = false
AND oc.is_claimed = false
RETURNING
id,
prop_name,
prop_asset_path,
layer,
is_transferable,
is_portable,
is_droppable,
origin,
acquired_at
)
SELECT * FROM inserted
"#,
)
.bind(prop_id)
.bind(realm_id)
.bind(user_id)
.fetch_optional(executor)
.await?;
match result {
Some(item) => Ok(item),
None => {
// Need to determine the specific error case
Err(AppError::Conflict(
"Unable to acquire prop - it may not exist, not be available, or already owned"
.to_string(),
))
}
}
}
/// Get detailed acquisition error for a realm prop.
///
/// This is called when acquire_realm_prop fails to determine the specific error.
pub async fn get_realm_prop_acquisition_error<'e>(
executor: impl PgExecutor<'e>,
prop_id: Uuid,
realm_id: Uuid,
user_id: Uuid,
) -> Result<AppError, AppError> {
#[derive(sqlx::FromRow)]
#[allow(dead_code)]
struct PropStatus {
exists: bool,
is_active: bool,
is_public: bool,
is_available: bool,
is_unique: bool,
user_owns: bool,
is_claimed: bool,
}
let status: Option<PropStatus> = sqlx::query_as(
r#"
SELECT
true AS exists,
p.is_active,
p.is_public,
(p.available_from IS NULL OR p.available_from <= now())
AND (p.available_until IS NULL OR p.available_until > now()) AS is_available,
p.is_unique,
EXISTS(
SELECT 1 FROM auth.inventory i
WHERE i.user_id = $3 AND i.realm_prop_id = $1
) AS user_owns,
CASE
WHEN p.is_unique THEN EXISTS(
SELECT 1 FROM auth.inventory i WHERE i.realm_prop_id = $1
)
ELSE false
END AS is_claimed
FROM realm.props p
WHERE p.id = $1 AND p.realm_id = $2
"#,
)
.bind(prop_id)
.bind(realm_id)
.bind(user_id)
.fetch_optional(executor)
.await?;
match status {
None => Ok(AppError::NotFound("Realm prop not found".to_string())),
Some(s) if !s.is_active || !s.is_public => {
Ok(AppError::Forbidden("This prop is not available".to_string()))
}
Some(s) if !s.is_available => Ok(AppError::Forbidden(
"This prop is not currently available".to_string(),
)),
Some(s) if s.user_owns => Ok(AppError::Conflict("You already own this prop".to_string())),
Some(s) if s.is_claimed => Ok(AppError::Conflict(
"This unique prop has already been claimed by another user".to_string(),
)),
Some(_) => Ok(AppError::Internal(
"Unknown error acquiring prop".to_string(),
)),
}
}

View file

@ -8,6 +8,30 @@ use uuid::Uuid;
use crate::models::{InventoryItem, LooseProp};
use chattyness_error::AppError;
/// Ensure an instance exists for a scene.
///
/// In this system, scenes are used directly as instances (channel_id = scene_id).
/// This creates an instance record if one doesn't exist, using the scene_id as the instance_id.
/// This is needed for loose_props foreign key constraint.
pub async fn ensure_scene_instance<'e>(
executor: impl PgExecutor<'e>,
scene_id: Uuid,
) -> Result<(), AppError> {
sqlx::query(
r#"
INSERT INTO scene.instances (id, scene_id, instance_type)
SELECT $1, $1, 'public'::scene.instance_type
WHERE EXISTS (SELECT 1 FROM realm.scenes WHERE id = $1)
ON CONFLICT (id) DO NOTHING
"#,
)
.bind(scene_id)
.execute(executor)
.await?;
Ok(())
}
/// List all loose props in a channel (excluding expired).
pub async fn list_channel_loose_props<'e>(
executor: impl PgExecutor<'e>,
@ -106,8 +130,8 @@ pub async fn drop_prop_to_canvas<'e>(
instance_id as channel_id,
server_prop_id,
realm_prop_id,
ST_X(position) as position_x,
ST_Y(position) as position_y,
ST_X(position)::real as position_x,
ST_Y(position)::real as position_y,
dropped_by,
expires_at,
created_at