- 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>
149 lines
4.4 KiB
TypeScript
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"
|
|
}
|
|
}
|