Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions deployment/vercel/api/lobbies.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
32 changes: 28 additions & 4 deletions deployment/vercel/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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";
Expand All @@ -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();
Expand All @@ -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;
Expand Down
108 changes: 97 additions & 11 deletions modules/lobby/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>();
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<LobbyStatus, ()> {
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 {
Expand Down Expand Up @@ -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)?;
Expand Down Expand Up @@ -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::<Vec<_>>();
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(())
}

Expand All @@ -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)
);
}
}
Loading