nerdware/apps/server/src/rooms.ts
AyoubBenziza 9ed3b8a4cb feat(server): generic game engine — GameRound interface + orchestration loop
- game/round.ts: GameRound contract (start/submitAnswer/reveal/score) +
  RoundContext + RoomGameController (decouples room from engine)
- game/registry.ts: register/createRound — adding a mode = one registerRound
- game/engine.ts: server-authoritative orchestration. Per round:
  start → broadcast round:start → wait (timer OR all eligible voted) →
  reveal → apply score deltas → score:update; then game:end. DJ counts as
  eligible voter; round ends on first condition met.
- handlers: game:start (host-only, validates rounds/modes) + round:vote
  wired to the engine
- shared: add game:start client event
- engine.test.ts: covers both end conditions + voteAck (bun test)
- turbo/root: add test task

Roadmap V1 step 3. No UI yet — concrete quiz mode + client come in step 4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:29:49 +02:00

149 lines
4.4 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
}
/** 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
/** 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,
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"
}
}