nerdware/apps/server/src/rooms.ts
AyoubBenziza f0c327617b feat: leave-room button
- room:leave event: removes the player from the room (not just disconnected),
  reassigns host if the host leaves, deletes the room when empty
- store leaveRoom() emits it and clears the session (reset)
- room header gets a "Quitter" button (always available); end-screen "Quitter"
  now also leaves cleanly

Verified e2e: leaver removed from snapshot, host reassigned to the next player.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 17:33:10 +02:00

198 lines
5.8 KiB
TypeScript

// 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<string, ServerPlayer>
settings: RoomSettings
scores: Map<string, number>
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<string, ServerRoom>()
/** 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"
}
}