diff --git a/deployment/vercel/api/lobbies.mjs b/deployment/vercel/api/lobbies.mjs index 4e39f28..0394fe8 100644 --- a/deployment/vercel/api/lobbies.mjs +++ b/deployment/vercel/api/lobbies.mjs @@ -194,6 +194,13 @@ export default async function handler(request, response) { return response.status(200).json({ lobby }); } + if (body.action === "leave") { + const lobbyId = String(body.lobbyId ?? ""); + await callReducer(LOBBY_DATABASE, "leave_lobby", [lobbyId], token); + const lobby = (await listLobbies()).find((candidate) => candidate.lobbyId === lobbyId); + return response.status(200).json({ lobby: lobby ?? null }); + } + return response.status(400).json({ error: "unknown_action" }); } catch (error) { const status = Number(error.statusCode) || 500; diff --git a/deployment/vercel/index.html b/deployment/vercel/index.html index 0e3de3b..24fec10 100644 --- a/deployment/vercel/index.html +++ b/deployment/vercel/index.html @@ -104,7 +104,7 @@ .join(""); } - function render(lobbies) { + function render(lobbies, identity) { list.replaceChildren(); const visible = lobbies.filter((lobby) => !["cancelled"].includes(lobby.status)); if (!visible.length) { @@ -114,6 +114,7 @@ list.append(empty); return; } + const self = String(identity?.identity ?? ""); for (const lobby of visible) { const row = document.createElement("article"); row.className = "lobby"; @@ -127,9 +128,30 @@ names.className = "names"; names.textContent = lobby.failureReason || lobby.members.map((member) => member.displayName).join(" · "); copy.append(title, meta, names); + const actions = document.createElement("div"); + actions.style.display = "grid"; + actions.style.gap = "8px"; + const member = lobby.members.some((entry) => String(entry.identity) === self); + if (member) { + const leave = document.createElement("button"); + leave.textContent = "LEAVE"; + leave.addEventListener("click", async () => { + leave.disabled = true; + status.textContent = `Leaving ${lobby.lobbyId}…`; + try { + await api({ action: "leave", lobbyId: lobby.lobbyId }); + status.textContent = "Left lobby."; + await refresh(); + } catch (error) { + status.textContent = error.message; + leave.disabled = false; + } + }); + actions.append(leave); + } const join = document.createElement("button"); join.textContent = "JOIN"; - join.disabled = lobby.status !== "open" || lobby.memberCount >= lobby.playerCount; + join.disabled = member || lobby.status !== "open" || lobby.memberCount >= lobby.playerCount; join.addEventListener("click", async () => { const displayName = nameInput.value.trim(); if (!displayName) return nameInput.focus(); @@ -144,17 +166,19 @@ join.disabled = false; } }); - row.append(copy, join); + actions.append(join); + row.append(copy, actions); list.append(row); } } async function refresh() { try { + const identity = await session(); const response = await fetch("/api/lobbies", { cache: "no-store" }); const value = await response.json(); if (!response.ok) throw new Error(value.error ?? "Could not load lobbies"); - render(value.lobbies); + render(value.lobbies, identity); status.textContent = "Create a lobby or join an open front."; } catch (error) { status.textContent = error.message; diff --git a/modules/lobby/src/lib.rs b/modules/lobby/src/lib.rs index 245447a..22a8534 100644 --- a/modules/lobby/src/lib.rs +++ b/modules/lobby/src/lib.rs @@ -137,6 +137,35 @@ fn active_lobby_for_identity(ctx: &ReducerContext, identity: Identity) -> Option }) } +fn delete_lobby_and_members(ctx: &ReducerContext, lobby_id: &str) { + let member_keys = ctx + .db + .lobby_member() + .iter() + .filter(|member| member.lobby_id == lobby_id) + .map(|member| member.member_key) + .collect::>(); + for key in member_keys { + ctx.db.lobby_member().member_key().delete(&key); + } + ctx.db.lobby().lobby_id().delete(lobby_id.to_owned()); +} + +/// Pure post-leave status decision. Empty lobbies are deleted rather than kept +/// as Failed/Cancelled rows so membership cannot block future creates. +fn status_after_member_leave( + status: LobbyStatus, + member_count_after: u16, +) -> Result { + if member_count_after == 0 { + return Err(()); + } + Ok(match status { + LobbyStatus::Full => LobbyStatus::Open, + other => other, + }) +} + #[spacetimedb::reducer(init)] pub fn init(ctx: &ReducerContext) { ctx.db.control_config().insert(ControlConfig { @@ -246,6 +275,39 @@ pub fn join_lobby( Ok(()) } +#[spacetimedb::reducer] +pub fn leave_lobby(ctx: &ReducerContext, lobby_id: String) -> Result<(), String> { + let mut lobby = ctx + .db + .lobby() + .lobby_id() + .find(&lobby_id) + .ok_or("lobby does not exist")?; + let key = member_key(&lobby_id, ctx.sender()); + if ctx.db.lobby_member().member_key().find(&key).is_none() { + return Ok(()); + } + ctx.db.lobby_member().member_key().delete(&key); + + let member_count = lobby.member_count.saturating_sub(1); + match status_after_member_leave(lobby.status, member_count) { + Err(()) => { + // Last member left — including creator leaving a Pending/Provisioning + // lobby — so drop the row instead of leaving an orphan that blocks + // create_lobby via active membership. + delete_lobby_and_members(ctx, &lobby_id); + Ok(()) + } + Ok(status) => { + lobby.member_count = member_count; + lobby.status = status; + lobby.updated_at_us = timestamp_us(ctx); + ctx.db.lobby().lobby_id().update(lobby); + Ok(()) + } + } +} + #[spacetimedb::reducer] pub fn begin_provision(ctx: &ReducerContext, lobby_id: String) -> Result<(), String> { require_owner(ctx)?; @@ -337,17 +399,7 @@ pub fn remove_inactive_lobby(ctx: &ReducerContext, lobby_id: String) -> Result<( if !matches!(lobby.status, LobbyStatus::Failed | LobbyStatus::Cancelled) { return Err("only failed or cancelled lobbies may be removed".to_owned()); } - let member_keys = ctx - .db - .lobby_member() - .iter() - .filter(|member| member.lobby_id == lobby_id) - .map(|member| member.member_key) - .collect::>(); - for key in member_keys { - ctx.db.lobby_member().member_key().delete(&key); - } - ctx.db.lobby().lobby_id().delete(&lobby_id); + delete_lobby_and_members(ctx, &lobby_id); Ok(()) } @@ -368,4 +420,38 @@ mod tests { assert_eq!(clean_display_name(&"x".repeat(40)).unwrap().len(), 32); assert!(clean_display_name(" ").is_err()); } + + #[test] + fn last_member_leave_deletes_for_every_live_status() { + for status in [ + LobbyStatus::Pending, + LobbyStatus::Provisioning, + LobbyStatus::Open, + LobbyStatus::Full, + LobbyStatus::Failed, + LobbyStatus::Cancelled, + ] { + assert_eq!(status_after_member_leave(status, 0), Err(())); + } + } + + #[test] + fn non_empty_leave_reopens_full_lobbies_only() { + assert_eq!( + status_after_member_leave(LobbyStatus::Full, 1), + Ok(LobbyStatus::Open) + ); + assert_eq!( + status_after_member_leave(LobbyStatus::Open, 1), + Ok(LobbyStatus::Open) + ); + assert_eq!( + status_after_member_leave(LobbyStatus::Pending, 1), + Ok(LobbyStatus::Pending) + ); + assert_eq!( + status_after_member_leave(LobbyStatus::Provisioning, 1), + Ok(LobbyStatus::Provisioning) + ); + } }