From 9ed3b8a4cbf0c134013b64a6e815839330639272 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Mon, 8 Jun 2026 15:29:49 +0200 Subject: [PATCH] =?UTF-8?q?feat(server):=20generic=20game=20engine=20?= =?UTF-8?q?=E2=80=94=20GameRound=20interface=20+=20orchestration=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- apps/server/package.json | 1 + apps/server/src/game/engine.test.ts | 133 +++++++++++++++++++++ apps/server/src/game/engine.ts | 172 ++++++++++++++++++++++++++++ apps/server/src/game/registry.ts | 25 ++++ apps/server/src/game/round.ts | 55 +++++++++ apps/server/src/handlers.ts | 41 +++++++ apps/server/src/rooms.ts | 4 + package.json | 3 +- packages/shared/src/events.ts | 1 + turbo.json | 3 + 10 files changed, 437 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/game/engine.test.ts create mode 100644 apps/server/src/game/engine.ts create mode 100644 apps/server/src/game/registry.ts create mode 100644 apps/server/src/game/round.ts diff --git a/apps/server/package.json b/apps/server/package.json index 953e426..34534ed 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "bun --watch src/index.ts", "start": "bun src/index.ts", + "test": "bun test", "build": "bun build ./src/index.ts --target bun --outdir dist", "lint": "eslint", "format": "prettier --write \"**/*.ts\"", diff --git a/apps/server/src/game/engine.test.ts b/apps/server/src/game/engine.test.ts new file mode 100644 index 0000000..a8638d2 --- /dev/null +++ b/apps/server/src/game/engine.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test } from "bun:test" +import type { IoServer } from "../socket" +import { RoomManager, type ServerRoom } from "../rooms" +import { GameEngine, type GameEngineOptions } from "./engine" +import type { GameRound } from "./round" + +interface Emit { + event: string + payload: unknown +} + +/** io stub : capture tous les emits, ignore le ciblage de room. */ +function fakeIo(): { io: IoServer; emits: Emit[] } { + const emits: Emit[] = [] + const io = { + to: () => ({ + emit: (event: string, payload: unknown) => { + emits.push({ event, payload }) + }, + }), + } + return { io: io as unknown as IoServer, emits } +} + +/** Épreuve factice : +10 à qui choisit l'index correct (0). */ +const dummyRound: GameRound = { + type: "quiz", + start: () => ({ + payload: { prompt: "2+2 ?", choices: ["4", "5"] }, + data: { correct: 0 }, + }), + submitAnswer: (ctx, playerId, answer) => { + ctx.votes.set(playerId, answer) + }, + reveal: (ctx) => ({ + truth: { correctIndex: (ctx.data as { correct: number }).correct }, + perPlayerResult: {}, + }), + score: (ctx) => { + const correct = (ctx.data as { correct: number }).correct + const deltas: { playerId: string; delta: number }[] = [] + for (const [playerId, answer] of ctx.votes) { + if ((answer as { choiceIndex: number }).choiceIndex === correct) { + deltas.push({ playerId, delta: 10 }) + } + } + return deltas + }, +} + +function setup(roundDurationSec: number): { + io: IoServer + emits: Emit[] + rooms: RoomManager + room: ServerRoom + p1: string + p2: string + options: GameEngineOptions +} { + const { io, emits } = fakeIo() + const rooms = new RoomManager() + const { room, player: a } = rooms.create("Alice", "sock-a") + const { player: b } = rooms.join(room.code, "Bob", "sock-b") + room.settings.roundDuration = roundDurationSec + room.settings.rounds = [{ type: "quiz" }] + const options: GameEngineOptions = { + createRound: () => dummyRound, + revealPauseMs: 0, + } + return { io, emits, rooms, room, p1: a.id, p2: b.id, options } +} + +const eventsOf = (emits: Emit[]) => emits.map((e) => e.event) + +describe("GameEngine", () => { + test("finit la manche dès que tous les éligibles ont voté (avant le timer)", async () => { + const { io, emits, rooms, room, p1, p2, options } = setup(100) + const engine = new GameEngine(io, rooms, room, options) + + const run = engine.run() + // run() s'exécute jusqu'à round:start de façon synchrone, le runtime est prêt. + expect(eventsOf(emits)).toContain("round:start") + + engine.handleVote(p1, { choiceIndex: 0 }) // juste + engine.handleVote(p2, { choiceIndex: 1 }) // faux → 2/2 votes → fin anticipée + await run + + const events = eventsOf(emits) + expect(events).toContain("round:reveal") + expect(events).toContain("score:update") + expect(events).toContain("game:end") + + expect(room.status).toBe("ended") + expect(room.scores.get(p1)).toBe(10) + expect(room.scores.get(p2)).toBe(0) + + const end = emits.find((e) => e.event === "game:end")!.payload as { + finalScores: { playerId: string; score: number }[] + } + expect(end.finalScores).toHaveLength(2) + }) + + test("émet round:voteAck avec count/total à chaque vote", async () => { + const { io, emits, rooms, room, p1, p2, options } = setup(100) + const engine = new GameEngine(io, rooms, room, options) + const run = engine.run() + + engine.handleVote(p1, { choiceIndex: 0 }) + const ack = emits.find((e) => e.event === "round:voteAck")!.payload as { + count: number + total: number + } + expect(ack).toEqual({ count: 1, total: 2 }) + + engine.handleVote(p2, { choiceIndex: 0 }) + await run + expect(room.scores.get(p1)).toBe(10) + expect(room.scores.get(p2)).toBe(10) + }) + + test("finit la manche au timer si tout le monde n'a pas voté", async () => { + const { io, emits, rooms, room, p1, options } = setup(0.05) // 50 ms + const engine = new GameEngine(io, rooms, room, options) + + const run = engine.run() + engine.handleVote(p1, { choiceIndex: 0 }) // un seul vote → on attend le timer + await run + + expect(eventsOf(emits)).toContain("game:end") + expect(room.status).toBe("ended") + expect(room.scores.get(p1)).toBe(10) + }) +}) diff --git a/apps/server/src/game/engine.ts b/apps/server/src/game/engine.ts new file mode 100644 index 0000000..f4a8df9 --- /dev/null +++ b/apps/server/src/game/engine.ts @@ -0,0 +1,172 @@ +// 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, PlayerScore, RoundConfig } 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 + 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 + /** Permet de patcher l'horloge en test ; défaut = Date.now. */ + now?: () => number +} + +const DEFAULT_REVEAL_PAUSE_MS = 5000 + +export class GameEngine implements RoomGameController { + private runtime: RoundRuntime | null = null + private timer: ReturnType | null = null + private resolveRoundEnd: (() => void) | null = null + private readonly createRound: (type: RoundConfig["type"]) => GameRound + private readonly revealPauseMs: 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.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() + this.io.to(this.room.code).emit("game:end", { finalScores: this.scoreboard() }) + } + + /** 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, + }) + // Fin anticipée : tous les éligibles ont voté. + if (total > 0 && this.runtime.votes.size >= total) { + this.endRound() + } + } + + 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 startedAt = this.now() + const endsAt = startedAt + durationSec * 1000 + + this.runtime = { + round, + djId: start.djId ?? null, + votes: new Map(), + startedAt, + endsAt, + data: start.data, + } + this.room.status = "in_round" + this.broadcastState() + this.io.to(this.room.code).emit("round:start", { + type: round.type, + djId: this.runtime.djId ?? undefined, + endsAt, + payload: start.payload, + }) + + // 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(), durationSec * 1000) + }) + + const ctx = this.context(this.runtime) + this.room.status = "reveal" + this.broadcastState() + this.io.to(this.room.code).emit("round:reveal", round.reveal(ctx)) + + for (const { playerId, delta } of round.score(ctx)) { + this.room.scores.set(playerId, (this.room.scores.get(playerId) ?? 0) + 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 : tous les joueurs connectés (le DJ inclus). */ + private eligibleVoters(): string[] { + return [...this.room.players.values()] + .filter((p) => p.connected) + .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 } diff --git a/apps/server/src/game/registry.ts b/apps/server/src/game/registry.ts new file mode 100644 index 0000000..27be9e5 --- /dev/null +++ b/apps/server/src/game/registry.ts @@ -0,0 +1,25 @@ +// Registre des épreuves. Chaque mode s'enregistre via registerRound() ; +// le moteur instancie une manche fraîche par round via createRound(). + +import type { RoundType } from "@nerdware/shared" +import type { GameRound } from "./round" + +export type RoundFactory = () => GameRound + +const factories = new Map() + +export function registerRound(type: RoundType, factory: RoundFactory): void { + factories.set(type, factory) +} + +export function createRound(type: RoundType): GameRound { + const factory = factories.get(type) + if (!factory) { + throw new Error(`Aucune épreuve enregistrée pour le type "${type}"`) + } + return factory() +} + +export function hasRound(type: RoundType): boolean { + return factories.has(type) +} diff --git a/apps/server/src/game/round.ts b/apps/server/src/game/round.ts new file mode 100644 index 0000000..8f3ca62 --- /dev/null +++ b/apps/server/src/game/round.ts @@ -0,0 +1,55 @@ +// Contrat générique d'une épreuve. Le moteur ne connaît QUE cette interface : +// brancher une nouvelle épreuve = écrire un module, pas retoucher la plomberie. + +import type { Answer, RevealPayload, RoundType, ScoreDelta } from "@nerdware/shared" +import type { ServerRoom } from "../rooms" + +/** Ce que `start()` renvoie : la partie publique + les données privées de la manche. */ +export interface RoundStart { + /** Joueur DJ (blindtest) ; null pour les épreuves sans DJ (quiz). */ + djId?: string | null + /** Durée de la manche en secondes ; défaut = room.settings.roundDuration. */ + durationSec?: number + /** Payload broadcasté aux clients, SANS la réponse. */ + payload: unknown + /** Données privées (ex: la bonne réponse), conservées jusqu'au reveal. Jamais diffusées. */ + data?: unknown +} + +/** Contexte runtime d'une manche, fourni aux callbacks de l'épreuve. */ +export interface RoundContext { + room: ServerRoom + djId: string | null + /** Votes reçus : playerId → réponse. Muté par submitAnswer. */ + votes: Map + startedAt: number + endsAt: number + /** Données privées renvoyées par start(). */ + data: unknown +} + +/** + * Une épreuve. Implémente le cycle start → submitAnswer → reveal → score. + * Le serveur est autoritaire : start/reveal/score ne s'exécutent que côté serveur. + */ +export interface GameRound { + type: RoundType + + /** Prépare la manche (tire le DJ, choisit le contenu) et renvoie le payload public. */ + start(room: ServerRoom): RoundStart + + /** Enregistre/valide le vote d'un joueur dans ctx.votes. Doit être idempotent. */ + submitAnswer(ctx: RoundContext, playerId: string, answer: Answer): void + + /** Calcule la vérité + le détail révélé à tous. */ + reveal(ctx: RoundContext): RevealPayload + + /** Renvoie les variations de score à appliquer (le moteur les applique). */ + score(ctx: RoundContext): ScoreDelta[] +} + +/** Vue minimale du moteur exposée à la room (évite un cycle d'import). */ +export interface RoomGameController { + run(): Promise + handleVote(playerId: string, answer: Answer): void +} diff --git a/apps/server/src/handlers.ts b/apps/server/src/handlers.ts index 8526002..aebdf99 100644 --- a/apps/server/src/handlers.ts +++ b/apps/server/src/handlers.ts @@ -10,6 +10,8 @@ import type { } from "@nerdware/shared" import type { IoServer } from "./socket" import { RoomError, RoomManager, type ServerRoom } from "./rooms" +import { GameEngine } from "./game/engine" +import { hasRound } from "./game/registry" type AppSocket = Socket< ClientToServerEvents, @@ -95,6 +97,45 @@ export function registerRoomHandlers( broadcastState(room) }) + socket.on("game:start", (ack) => { + const code = socket.data.roomCode + const room = code ? rooms.get(code) : undefined + if (!room || room.hostId !== socket.data.playerId) { + ack({ ok: false, error: { code: "NOT_HOST", message: "Seul l'hôte peut lancer." } }) + return + } + if (room.status !== "lobby") { + ack({ ok: false, error: { code: "ALREADY_STARTED", message: "Partie déjà lancée." } }) + return + } + if (room.settings.rounds.length === 0) { + ack({ ok: false, error: { code: "NO_ROUNDS", message: "Aucune épreuve configurée." } }) + return + } + const unknown = room.settings.rounds.find((r) => !hasRound(r.type)) + if (unknown) { + ack({ + ok: false, + error: { code: "UNKNOWN_MODE", message: `Épreuve indisponible : ${unknown.type}.` }, + }) + return + } + ack({ ok: true, data: null }) + const engine = new GameEngine(io, rooms, room) + room.game = engine + // Fire-and-forget : la boucle de jeu broadcast son propre état. + void engine.run().catch((err) => console.error("[game] run failed", err)) + }) + + socket.on("round:vote", (payload) => { + const code = socket.data.roomCode + const room = code ? rooms.get(code) : undefined + if (!room || !room.game || !socket.data.playerId) { + return + } + room.game.handleVote(socket.data.playerId, payload.answer) + }) + socket.on("disconnect", () => { const affected = rooms.handleDisconnect(socket.id) if (!affected) { diff --git a/apps/server/src/rooms.ts b/apps/server/src/rooms.ts index c5dd2fd..fadc8f7 100644 --- a/apps/server/src/rooms.ts +++ b/apps/server/src/rooms.ts @@ -8,6 +8,7 @@ import { 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 { @@ -26,6 +27,8 @@ export interface ServerRoom { settings: RoomSettings scores: Map 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). */ @@ -47,6 +50,7 @@ export class RoomManager { settings: { ...DEFAULT_ROOM_SETTINGS }, scores: new Map([[player.id, 0]]), currentRound: -1, + game: null, } this.rooms.set(code, room) return { room, player } diff --git a/package.json b/package.json index 8198027..51e36c8 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "dev": "turbo dev", "lint": "turbo lint", "format": "turbo format", - "typecheck": "turbo typecheck" + "typecheck": "turbo typecheck", + "test": "turbo test" }, "devDependencies": { "prettier": "^3.8.3", diff --git a/packages/shared/src/events.ts b/packages/shared/src/events.ts index 2a653cf..cc3a8b6 100644 --- a/packages/shared/src/events.ts +++ b/packages/shared/src/events.ts @@ -108,6 +108,7 @@ export interface ClientToServerEvents { "room:create": (payload: RoomCreatePayload, ack: Ack) => void "room:join": (payload: RoomJoinPayload, ack: Ack) => void "lobby:updateSettings": (payload: UpdateSettingsPayload) => void + "game:start": (ack: Ack) => void "blindtest:submitTrack": (payload: SubmitTrackPayload, ack: Ack) => void "media:control": (payload: MediaControlPayload) => void "round:vote": (payload: VotePayload) => void diff --git a/turbo.json b/turbo.json index c5398f5..b2332be 100644 --- a/turbo.json +++ b/turbo.json @@ -16,6 +16,9 @@ "typecheck": { "dependsOn": ["^typecheck"] }, + "test": { + "dependsOn": ["^build"] + }, "dev": { "cache": false, "persistent": true -- 2.45.3