// Gestion des rooms en mémoire. Source de vérité côté serveur. // Pas de Redis en V1 : une simple Map suffit pour jouer entre potes. import { randomUUID } from "node:crypto" import { DEFAULT_ROOM_SETTINGS, type RoomSettings, type RoomSnapshot, type RoomStatus, } from "@nerdware/shared" import type { RoomGameController } from "./game/round" /** Joueur côté serveur : porte le socketId, jamais exposé au client. */ export interface ServerPlayer { id: string name: string connected: boolean socketId: string | null } /** Room côté serveur : état runtime complet (le snapshot public en dérive). */ export interface ServerRoom { code: string status: RoomStatus hostId: string players: Map settings: RoomSettings scores: Map currentRound: number /** Moteur de jeu actif (null tant qu'on est dans le lobby). */ game: RoomGameController | null } /** Codes lisibles, sans caractères ambigus (0/O, 1/I/L). */ const CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" const CODE_LENGTH = 4 export class RoomManager { private rooms = new Map() /** Crée une room et y place le créateur comme hôte. */ create(playerName: string, socketId: string): { room: ServerRoom; player: ServerPlayer } { const code = this.generateCode() const player = this.makePlayer(playerName, socketId) const room: ServerRoom = { code, status: "lobby", hostId: player.id, players: new Map([[player.id, player]]), settings: { ...DEFAULT_ROOM_SETTINGS }, scores: new Map([[player.id, 0]]), currentRound: -1, game: null, } this.rooms.set(code, room) return { room, player } } /** Ajoute un joueur à une room existante. Lève une erreur si introuvable ou fermée. */ join( code: string, playerName: string, socketId: string ): { room: ServerRoom; player: ServerPlayer } { const room = this.rooms.get(code) if (!room) { throw new RoomError("ROOM_NOT_FOUND", "Cette room n'existe pas.") } if (room.status !== "lobby") { throw new RoomError("ROOM_IN_PROGRESS", "La partie a déjà commencé.") } const player = this.makePlayer(playerName, socketId) room.players.set(player.id, player) room.scores.set(player.id, 0) return { room, player } } get(code: string): ServerRoom | undefined { return this.rooms.get(code) } /** * Marque le joueur d'un socket comme déconnecté. * Retourne la room affectée et le playerId, ou null si non rattaché. * La room et le joueur restent en mémoire pour permettre un futur rejoin. */ handleDisconnect(socketId: string): { room: ServerRoom; playerId: string } | null { for (const room of this.rooms.values()) { for (const player of room.players.values()) { if (player.socketId === socketId) { player.connected = false player.socketId = null return { room, playerId: player.id } } } } return null } /** Projette la room vers la vue publique diffusée aux clients (sans secrets). */ toSnapshot(room: ServerRoom): RoomSnapshot { return { code: room.code, status: room.status, hostId: room.hostId, players: [...room.players.values()].map((p) => ({ id: p.id, name: p.name, connected: p.connected, })), settings: room.settings, scores: [...room.scores.entries()].map(([playerId, score]) => ({ playerId, score, })), currentRound: room.currentRound, totalRounds: room.settings.rounds.length, } } private makePlayer(name: string, socketId: string): ServerPlayer { return { id: randomUUID(), name, connected: true, socketId } } private generateCode(): string { // Très peu de collisions à cette échelle ; on retire quand même par sûreté. for (let attempt = 0; attempt < 100; attempt++) { let code = "" for (let i = 0; i < CODE_LENGTH; i++) { code += CODE_ALPHABET[Math.floor(Math.random() * CODE_ALPHABET.length)] } if (!this.rooms.has(code)) { return code } } throw new RoomError("CODE_EXHAUSTED", "Impossible de générer un code de room.") } } /** Erreur métier portant un code stable pour le client. */ export class RoomError extends Error { constructor( public code: string, message: string ) { super(message) this.name = "RoomError" } }