feat(api): add REST endpoints for lobby create and join
- POST /api/v1/lobbies creates a lobby with a Crockford-Base32 6-char code, retrying on unique violation up to 5 times - POST /api/v1/lobbies/:code/join validates lobby state then calls the model's atomic addPlayer, idempotent for repeat joins from the same user - Routes require authentication via requireAuth
This commit is contained in:
parent
8c241636bf
commit
4d1ebe2450
4 changed files with 123 additions and 0 deletions
70
apps/api/src/services/lobbyService.ts
Normal file
70
apps/api/src/services/lobbyService.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { randomInt } from "crypto";
|
||||
import {
|
||||
createLobby as createLobbyModel,
|
||||
getLobbyByCodeWithPlayers,
|
||||
addPlayer,
|
||||
} from "@lila/db";
|
||||
import type { Lobby, LobbyWithPlayers } from "@lila/db";
|
||||
import { MAX_LOBBY_PLAYERS } from "@lila/shared";
|
||||
import { NotFoundError, ConflictError, AppError } from "../errors/AppError.js";
|
||||
|
||||
const CODE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; // Crockford Base32
|
||||
const CODE_LENGTH = 6;
|
||||
const MAX_CODE_ATTEMPTS = 5;
|
||||
|
||||
const generateLobbyCode = (): string => {
|
||||
let code = "";
|
||||
for (let i = 0; i < CODE_LENGTH; i++) {
|
||||
code += CODE_ALPHABET[randomInt(CODE_ALPHABET.length)];
|
||||
}
|
||||
return code;
|
||||
};
|
||||
|
||||
const isUniqueViolation = (err: unknown): boolean => {
|
||||
return (err as { code?: string })?.code === "23505";
|
||||
};
|
||||
|
||||
export const createLobby = async (hostUserId: string): Promise<Lobby> => {
|
||||
for (let i = 0; i < MAX_CODE_ATTEMPTS; i++) {
|
||||
const code = generateLobbyCode();
|
||||
try {
|
||||
return await createLobbyModel(code, hostUserId);
|
||||
} catch (err) {
|
||||
if (isUniqueViolation(err)) continue;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
throw new AppError("Could not generate a unique lobby code", 500);
|
||||
};
|
||||
|
||||
export const joinLobby = async (
|
||||
code: string,
|
||||
userId: string,
|
||||
): Promise<LobbyWithPlayers> => {
|
||||
const lobby = await getLobbyByCodeWithPlayers(code);
|
||||
if (!lobby) {
|
||||
throw new NotFoundError(`Lobby not found: ${code}`);
|
||||
}
|
||||
if (lobby.status !== "waiting") {
|
||||
throw new ConflictError("Game has already started");
|
||||
}
|
||||
if (lobby.players.some((p) => p.userId === userId)) {
|
||||
return lobby; // idempotent: already in lobby
|
||||
}
|
||||
if (lobby.players.length >= MAX_LOBBY_PLAYERS) {
|
||||
throw new ConflictError("Lobby is full");
|
||||
}
|
||||
|
||||
const player = await addPlayer(lobby.id, userId, MAX_LOBBY_PLAYERS);
|
||||
if (!player) {
|
||||
// Race fallback: another request filled the last slot, started the game,
|
||||
// or the user joined concurrently. Pre-checks above handle the common cases.
|
||||
throw new ConflictError("Lobby is no longer available");
|
||||
}
|
||||
|
||||
const fresh = await getLobbyByCodeWithPlayers(code);
|
||||
if (!fresh) {
|
||||
throw new AppError("Lobby disappeared during join", 500);
|
||||
}
|
||||
return fresh;
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue