- 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>
198 lines
5.8 KiB
TypeScript
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"
|
|
}
|
|
}
|