fix: problems with prop dropping
This commit is contained in:
parent
845d64c981
commit
a96581cbf0
4 changed files with 115 additions and 50 deletions
|
|
@ -47,45 +47,26 @@ pub async fn list_channel_loose_props<'e>(
|
||||||
/// Deletes from inventory and inserts into loose_props with 30-minute expiry.
|
/// Deletes from inventory and inserts into loose_props with 30-minute expiry.
|
||||||
/// Returns the created loose prop.
|
/// Returns the created loose prop.
|
||||||
/// Returns an error if the prop is non-droppable (essential prop).
|
/// Returns an error if the prop is non-droppable (essential prop).
|
||||||
pub async fn drop_prop_to_canvas(
|
pub async fn drop_prop_to_canvas<'e>(
|
||||||
pool: &sqlx::PgPool,
|
executor: impl PgExecutor<'e>,
|
||||||
inventory_item_id: Uuid,
|
inventory_item_id: Uuid,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
channel_id: Uuid,
|
channel_id: Uuid,
|
||||||
position_x: f64,
|
position_x: f64,
|
||||||
position_y: f64,
|
position_y: f64,
|
||||||
) -> Result<LooseProp, AppError> {
|
) -> Result<LooseProp, AppError> {
|
||||||
// First check if the item exists and is droppable
|
// Single CTE that checks existence/droppability and performs the operation atomically.
|
||||||
let item_check: Option<(bool,)> = sqlx::query_as(
|
// Returns status flags plus the LooseProp data (if successful).
|
||||||
r#"SELECT is_droppable FROM props.inventory WHERE id = $1 AND user_id = $2"#,
|
let result: Option<(bool, bool, bool, Option<Uuid>, Option<Uuid>, Option<Uuid>, Option<Uuid>, Option<f32>, Option<f32>, Option<Uuid>, Option<chrono::DateTime<chrono::Utc>>, Option<chrono::DateTime<chrono::Utc>>, Option<String>, Option<String>)> = sqlx::query_as(
|
||||||
)
|
|
||||||
.bind(inventory_item_id)
|
|
||||||
.bind(user_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
match item_check {
|
|
||||||
None => {
|
|
||||||
return Err(AppError::NotFound(
|
|
||||||
"Inventory item not found or not owned by user".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Some((false,)) => {
|
|
||||||
return Err(AppError::Forbidden(
|
|
||||||
"This prop cannot be dropped - it is an essential prop".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Some((true,)) => {
|
|
||||||
// Item is droppable, proceed with the drop operation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use a CTE to delete from inventory and insert to loose_props in one query
|
|
||||||
let prop = sqlx::query_as::<_, LooseProp>(
|
|
||||||
r#"
|
r#"
|
||||||
WITH deleted_item AS (
|
WITH item_info AS (
|
||||||
DELETE FROM props.inventory
|
SELECT id, is_droppable, server_prop_id, realm_prop_id, prop_name, prop_asset_path
|
||||||
|
FROM props.inventory
|
||||||
WHERE id = $1 AND user_id = $2
|
WHERE id = $1 AND user_id = $2
|
||||||
|
),
|
||||||
|
deleted_item AS (
|
||||||
|
DELETE FROM props.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
|
||||||
),
|
),
|
||||||
inserted_prop AS (
|
inserted_prop AS (
|
||||||
|
|
@ -117,6 +98,9 @@ pub async fn drop_prop_to_canvas(
|
||||||
created_at
|
created_at
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
|
EXISTS(SELECT 1 FROM item_info) AS item_existed,
|
||||||
|
COALESCE((SELECT is_droppable FROM item_info), false) AS was_droppable,
|
||||||
|
EXISTS(SELECT 1 FROM deleted_item) AS was_deleted,
|
||||||
ip.id,
|
ip.id,
|
||||||
ip.channel_id,
|
ip.channel_id,
|
||||||
ip.server_prop_id,
|
ip.server_prop_id,
|
||||||
|
|
@ -128,8 +112,9 @@ pub async fn drop_prop_to_canvas(
|
||||||
ip.created_at,
|
ip.created_at,
|
||||||
di.prop_name,
|
di.prop_name,
|
||||||
di.prop_asset_path
|
di.prop_asset_path
|
||||||
FROM inserted_prop ip
|
FROM (SELECT 1) AS dummy
|
||||||
CROSS JOIN deleted_item di
|
LEFT JOIN inserted_prop ip ON true
|
||||||
|
LEFT JOIN deleted_item di ON true
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(inventory_item_id)
|
.bind(inventory_item_id)
|
||||||
|
|
@ -137,13 +122,72 @@ pub async fn drop_prop_to_canvas(
|
||||||
.bind(channel_id)
|
.bind(channel_id)
|
||||||
.bind(position_x as f32)
|
.bind(position_x as f32)
|
||||||
.bind(position_y as f32)
|
.bind(position_y as f32)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(executor)
|
||||||
.await?
|
.await?;
|
||||||
.ok_or_else(|| {
|
|
||||||
AppError::Internal("Unexpected error dropping prop to canvas".to_string())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(prop)
|
match result {
|
||||||
|
None => {
|
||||||
|
// Query returned no rows (shouldn't happen with our dummy table)
|
||||||
|
Err(AppError::Internal(
|
||||||
|
"Unexpected error dropping prop to canvas".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Some((false, _, _, _, _, _, _, _, _, _, _, _, _, _)) => {
|
||||||
|
// Item didn't exist
|
||||||
|
Err(AppError::NotFound(
|
||||||
|
"Inventory item not found or not owned by user".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Some((true, false, _, _, _, _, _, _, _, _, _, _, _, _)) => {
|
||||||
|
// 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, _, _, _, _, _, _, _, _, _, _, _)) => {
|
||||||
|
// 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),
|
||||||
|
server_prop_id,
|
||||||
|
realm_prop_id,
|
||||||
|
Some(position_x),
|
||||||
|
Some(position_y),
|
||||||
|
dropped_by,
|
||||||
|
Some(expires_at),
|
||||||
|
Some(created_at),
|
||||||
|
Some(prop_name),
|
||||||
|
Some(prop_asset_path),
|
||||||
|
)) => {
|
||||||
|
// Success! Convert f32 positions to f64.
|
||||||
|
Ok(LooseProp {
|
||||||
|
id,
|
||||||
|
channel_id,
|
||||||
|
server_prop_id,
|
||||||
|
realm_prop_id,
|
||||||
|
position_x: position_x.into(),
|
||||||
|
position_y: position_y.into(),
|
||||||
|
dropped_by,
|
||||||
|
expires_at: Some(expires_at),
|
||||||
|
created_at,
|
||||||
|
prop_name,
|
||||||
|
prop_asset_path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Some fields were unexpectedly null
|
||||||
|
Err(AppError::Internal(
|
||||||
|
"Unexpected null values in drop prop result".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pick up a loose prop (delete from loose_props, insert to inventory).
|
/// Pick up a loose prop (delete from loose_props, insert to inventory).
|
||||||
|
|
|
||||||
|
|
@ -281,13 +281,9 @@ async fn handle_socket(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drop the setup connection - we'll use recv_conn for the receive task
|
// Drop the setup connection - we'll use recv_conn for the receive task
|
||||||
// and pool for cleanup (which will use the same RLS context issue, but leave_channel
|
// and pool for cleanup (leave_channel needs user_id match anyway)
|
||||||
// needs user_id match anyway)
|
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
// Clone pool for use in the receive task (needed for multi-query operations)
|
|
||||||
let recv_pool = pool.clone();
|
|
||||||
|
|
||||||
// Spawn task to handle incoming messages from client
|
// Spawn task to handle incoming messages from client
|
||||||
let recv_task = tokio::spawn(async move {
|
let recv_task = tokio::spawn(async move {
|
||||||
while let Some(Ok(msg)) = receiver.next().await {
|
while let Some(Ok(msg)) = receiver.next().await {
|
||||||
|
|
@ -404,7 +400,7 @@ async fn handle_socket(
|
||||||
let pos_y = member.position_y + offset_y;
|
let pos_y = member.position_y + offset_y;
|
||||||
|
|
||||||
match loose_props::drop_prop_to_canvas(
|
match loose_props::drop_prop_to_canvas(
|
||||||
&recv_pool,
|
&mut *recv_conn,
|
||||||
inventory_item_id,
|
inventory_item_id,
|
||||||
user_id,
|
user_id,
|
||||||
channel_id,
|
channel_id,
|
||||||
|
|
@ -426,6 +422,16 @@ async fn handle_socket(
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("[WS] Drop prop failed: {:?}", e);
|
tracing::error!("[WS] Drop prop failed: {:?}", e);
|
||||||
|
let (code, message) = match &e {
|
||||||
|
chattyness_error::AppError::Forbidden(msg) => {
|
||||||
|
("PROP_NOT_DROPPABLE".to_string(), msg.clone())
|
||||||
|
}
|
||||||
|
chattyness_error::AppError::NotFound(msg) => {
|
||||||
|
("PROP_NOT_FOUND".to_string(), msg.clone())
|
||||||
|
}
|
||||||
|
_ => ("DROP_FAILED".to_string(), format!("{:?}", e)),
|
||||||
|
};
|
||||||
|
let _ = tx.send(ServerMessage::Error { code, message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -453,6 +459,10 @@ async fn handle_socket(
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("[WS] Pick up prop failed: {:?}", e);
|
tracing::error!("[WS] Pick up prop failed: {:?}", e);
|
||||||
|
let _ = tx.send(ServerMessage::Error {
|
||||||
|
code: "PICKUP_FAILED".to_string(),
|
||||||
|
message: format!("{:?}", e),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -251,6 +251,7 @@ pub fn InventoryPopup(
|
||||||
let item = items.get().into_iter().find(|i| i.id == item_id)?;
|
let item = items.get().into_iter().find(|i| i.id == item_id)?;
|
||||||
let handle_drop = handle_drop.clone();
|
let handle_drop = handle_drop.clone();
|
||||||
let is_dropping = dropping.get();
|
let is_dropping = dropping.get();
|
||||||
|
let is_droppable = item.is_droppable;
|
||||||
|
|
||||||
Some(view! {
|
Some(view! {
|
||||||
<div class="mt-4 pt-4 border-t border-gray-700">
|
<div class="mt-4 pt-4 border-t border-gray-700">
|
||||||
|
|
@ -260,15 +261,25 @@ pub fn InventoryPopup(
|
||||||
<p class="text-gray-400 text-sm">
|
<p class="text-gray-400 text-sm">
|
||||||
{if item.is_transferable { "Transferable" } else { "Not transferable" }}
|
{if item.is_transferable { "Transferable" } else { "Not transferable" }}
|
||||||
{if item.is_portable { " \u{2022} Portable" } else { "" }}
|
{if item.is_portable { " \u{2022} Portable" } else { "" }}
|
||||||
|
{if is_droppable { " \u{2022} Droppable" } else { " \u{2022} Essential" }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
// Drop button
|
// Drop button - disabled for non-droppable (essential) props
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors disabled:opacity-50"
|
class=if is_droppable {
|
||||||
on:click=move |_| handle_drop(item_id)
|
"px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors disabled:opacity-50"
|
||||||
disabled=is_dropping
|
} else {
|
||||||
|
"px-4 py-2 bg-gray-600 text-gray-400 rounded-lg cursor-not-allowed"
|
||||||
|
}
|
||||||
|
on:click=move |_| {
|
||||||
|
if is_droppable {
|
||||||
|
handle_drop(item_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
disabled=is_dropping || !is_droppable
|
||||||
|
title=if is_droppable { "" } else { "Essential prop cannot be dropped" }
|
||||||
>
|
>
|
||||||
{if is_dropping { "Dropping..." } else { "Drop" }}
|
{if is_dropping { "Dropping..." } else { "Drop" }}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -264,7 +264,7 @@ fn handle_server_message(
|
||||||
// Heartbeat acknowledged - nothing to do
|
// Heartbeat acknowledged - nothing to do
|
||||||
}
|
}
|
||||||
ServerMessage::Error { code, message } => {
|
ServerMessage::Error { code, message } => {
|
||||||
#[cfg(debug_assertions)]
|
// Always log errors to console (not just debug mode)
|
||||||
web_sys::console::error_1(&format!("[WS] Server error: {} - {}", code, message).into());
|
web_sys::console::error_1(&format!("[WS] Server error: {} - {}", code, message).into());
|
||||||
}
|
}
|
||||||
ServerMessage::ChatMessageReceived {
|
ServerMessage::ChatMessageReceived {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue