// Moteur de jeu générique. Orchestre la séquence de manches d'une room : // pour chaque manche start → (votes | timer) → reveal → score, puis game:end. // Tout est tranché serveur ; les clients n'affichent que ce qui est broadcasté. import type { Answer, MediaControlPayload, PlayerScore, RoundConfig, RoundRecap, } from "@nerdware/shared" import type { IoServer } from "../socket" import type { RoomManager, ServerRoom } from "../rooms" import { createRound as defaultCreateRound, type RoundFactory } from "./registry" import type { GameRound, RoomGameController, RoundContext } from "./round" interface RoundRuntime { round: GameRound djId: string | null /** Contributeur (blindtest) : reçoit round:start privé et ne vote pas. */ secretPlayerId: string | null votes: Map startedAt: number endsAt: number data: unknown } export interface GameEngineOptions { /** Injection de l'instanciation d'épreuve (tests). Défaut = registre global. */ createRound?: (type: RoundConfig["type"]) => GameRound /** Pause d'affichage du reveal/scores entre deux manches (ms). */ revealPauseMs?: number /** Délai de préparation avant le démarrage du chrono (transition WarioWare, ms). */ leadMs?: number /** Permet de patcher l'horloge en test ; défaut = Date.now. */ now?: () => number } const DEFAULT_REVEAL_PAUSE_MS = 5000 // Le chrono ne démarre qu'après ce délai, le temps que la transition s'affiche. const DEFAULT_LEAD_MS = 1600 export class GameEngine implements RoomGameController { private runtime: RoundRuntime | null = null private timer: ReturnType | null = null private resolveRoundEnd: (() => void) | null = null private readonly history: RoundRecap[] = [] private readonly createRound: (type: RoundConfig["type"]) => GameRound private readonly revealPauseMs: number private readonly leadMs: number private readonly now: () => number constructor( private readonly io: IoServer, private readonly rooms: RoomManager, private readonly room: ServerRoom, options: GameEngineOptions = {} ) { this.createRound = options.createRound ?? defaultCreateRound this.revealPauseMs = options.revealPauseMs ?? DEFAULT_REVEAL_PAUSE_MS this.leadMs = options.leadMs ?? DEFAULT_LEAD_MS this.now = options.now ?? Date.now } /** Joue toute la partie, manche par manche, puis émet game:end. */ async run(): Promise { const { rounds } = this.room.settings for (let i = 0; i < rounds.length; i++) { this.room.currentRound = i await this.playRound(rounds[i]) if (i < rounds.length - 1) { await this.sleep(this.revealPauseMs) } } this.room.status = "ended" this.broadcastState() // Récap des titres blindtest joués (pour récupérer les liens YouTube). const tracks = this.room.blindtestTracks.map((t) => ({ title: t.title, artist: t.artist, youtubeId: t.youtubeId, url: t.url, submittedByName: this.room.players.get(t.submittedBy)?.name ?? "?", })) this.io.to(this.room.code).emit("game:end", { finalScores: this.scoreboard(), tracks: tracks.length > 0 ? tracks : undefined, recap: this.history.length > 0 ? this.history : undefined, }) } /** Vote d'un joueur pendant une manche. Délègue à l'épreuve, puis check fin anticipée. */ handleVote(playerId: string, answer: Answer): void { if (!this.runtime || this.room.status !== "in_round") { return } const ctx = this.context(this.runtime) this.runtime.round.submitAnswer(ctx, playerId, answer) const total = this.eligibleVoters().length this.io.to(this.room.code).emit("round:voteAck", { count: this.runtime.votes.size, total, voted: [...this.runtime.votes.keys()], }) // Fin anticipée : tous les éligibles ont voté. if (total > 0 && this.runtime.votes.size >= total) { this.endRound() } } /** Relais média : seul le DJ de la manche peut piloter la lecture. */ handleMediaControl(playerId: string, payload: MediaControlPayload): void { if ( !this.runtime || this.room.status !== "in_round" || playerId !== this.runtime.djId ) { return } this.io.to(this.room.code).emit("media:sync", { action: payload.action, positionSec: payload.positionSec, atServerTs: this.now(), }) } private async playRound(config: RoundConfig): Promise { const round = this.createRound(config.type) const start = round.start(this.room) const durationSec = start.durationSec ?? this.room.settings.roundDuration const now = this.now() // Les réponses ne comptent qu'après le délai de préparation (transition). const startedAt = now + this.leadMs const endsAt = startedAt + durationSec * 1000 this.runtime = { round, djId: start.djId ?? null, secretPlayerId: start.secretPlayerId ?? null, votes: new Map(), startedAt, endsAt, data: start.data, } this.room.status = "in_round" this.broadcastState() const base = { type: round.type, djId: this.runtime.djId ?? undefined, startsAt: startedAt, endsAt, payload: start.payload, } // Le contributeur reçoit un round:start privé (mine: true) ; il reste secret // pour les autres (qui ne reçoivent que `base`). const secretSocketId = this.runtime.secretPlayerId ? this.room.players.get(this.runtime.secretPlayerId)?.socketId : null if (secretSocketId) { this.io.to(secretSocketId).emit("round:start", { ...base, mine: true }) this.io.to(this.room.code).except(secretSocketId).emit("round:start", base) } else { this.io.to(this.room.code).emit("round:start", base) } // Attend la première condition de fin : timer écoulé OU tous votés. await new Promise((resolve) => { this.resolveRoundEnd = resolve this.timer = setTimeout(() => this.endRound(), endsAt - now) }) const ctx = this.context(this.runtime) this.room.status = "reveal" this.broadcastState() this.io.to(this.room.code).emit("round:reveal", round.reveal(ctx)) const deltas = round.score(ctx) for (const { playerId, delta } of deltas) { this.room.scores.set(playerId, (this.room.scores.get(playerId) ?? 0) + delta) } this.history.push({ index: this.room.currentRound, type: round.type, ...round.recap(ctx), scorers: deltas.map((d) => ({ playerId: d.playerId, points: d.delta })), }) this.room.status = "scores" this.io.to(this.room.code).emit("score:update", { scores: this.scoreboard() }) this.broadcastState() this.runtime = null } /** Termine la manche en cours (idempotent : timer et "tous votés" peuvent se croiser). */ private endRound(): void { if (this.timer) { clearTimeout(this.timer) this.timer = null } const resolve = this.resolveRoundEnd this.resolveRoundEnd = null resolve?.() } private context(runtime: RoundRuntime): RoundContext { return { room: this.room, djId: runtime.djId, votes: runtime.votes, startedAt: runtime.startedAt, endsAt: runtime.endsAt, data: runtime.data, } } /** * Votants éligibles : joueurs connectés (DJ neutre inclus), hors contributeur * (blindtest) qui n'a rien à saisir. */ private eligibleVoters(): string[] { const secret = this.runtime?.secretPlayerId return [...this.room.players.values()] .filter((p) => p.connected && p.name !== "" && p.id !== secret) .map((p) => p.id) } private scoreboard(): PlayerScore[] { return [...this.room.scores.entries()].map(([playerId, score]) => ({ playerId, score, })) } private broadcastState(): void { this.io.to(this.room.code).emit("room:state", this.rooms.toSnapshot(this.room)) } private sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } } export type { RoundFactory }