// 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 /** Secret de reconnexion (refresh/coupure). */ reconnectToken: string } /** Titre soumis pour le blindtest. `submittedBy` reste secret jusqu'au reveal. */ export interface BlindtestTrack { id: string youtubeId: string url: string title: string artist: string submittedBy: string } /** 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 /** Titres soumis pour le blindtest (mélangés au lancement). */ blindtestTracks: BlindtestTrack[] /** 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, blindtestTracks: [], 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) } /** * Retire un joueur d'une room (départ volontaire). Réassigne l'hôte si besoin, * supprime la room si plus personne. Renvoie la room (ou null si supprimée). */ leave(code: string, playerId: string): ServerRoom | null { const room = this.rooms.get(code) if (!room) { return null } room.players.delete(playerId) room.scores.delete(playerId) if (room.players.size === 0) { this.rooms.delete(code) return null } if (room.hostId === playerId) { const named = [...room.players.values()].find((p) => p.name !== "") room.hostId = (named ?? [...room.players.values()][0]).id } return room } /** * 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 { // Seuls les joueurs ayant choisi un pseudo sont visibles dans la room. const named = [...room.players.values()].filter((p) => p.name !== "") return { code: room.code, status: room.status, hostId: room.hostId, players: named.map((p) => ({ id: p.id, name: p.name, connected: p.connected, })), settings: room.settings, scores: named.map((p) => ({ playerId: p.id, score: room.scores.get(p.id) ?? 0, })), currentRound: room.currentRound, totalRounds: room.settings.rounds.length, submissions: named.map((p) => ({ playerId: p.id, count: room.blindtestTracks.filter((t) => t.submittedBy === p.id).length, })), } } private makePlayer(name: string, socketId: string): ServerPlayer { return { id: randomUUID(), name, connected: true, socketId, reconnectToken: randomUUID(), } } 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" } }