nerdware/apps/server/src/game/round.ts
AyoubBenziza 03a0dfa998 feat: end-of-game round recap (all rounds with answers + scorers)
- shared: RoundRecap (+ scorers); game:end carries recap[]
- server: GameRound.recap(ctx) per mode (quiz: prompt + correct answer +
  image; blindtest: "title — artist" + addedBy + links); engine accumulates a
  history per round (with that round's scorers from the score deltas) and sends
  it at game:end
- client: GameEndView shows a collapsible "Récapitulatif des manches" — each
  round's label, answer, media thumbnail (image/youtube) and who scored
- engine test dummy round implements recap

Verified e2e: game:end recap lists every round with answer + scorers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 00:54:40 +02:00

76 lines
2.8 KiB
TypeScript

// 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,
MediaControlPayload,
RevealPayload,
RoundRecap,
RoundType,
ScoreDelta,
} from "@nerdware/shared"
import type { ServerRoom } from "../rooms"
/** Partie du récap fournie par l'épreuve (le moteur complète index/type/scorers). */
export type RoundRecapInfo = Omit<RoundRecap, "index" | "type" | "scorers">
/** 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
/**
* Joueur recevant un round:start privé avec `mine: true` et exclu des votants
* (blindtest : le contributeur du titre). Jamais révélé aux autres.
*/
secretPlayerId?: string
/** 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<string, Answer>
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[]
/** Résumé de la manche pour le récap de fin de partie (intitulé + réponse + média). */
recap(ctx: RoundContext): RoundRecapInfo
}
/** Vue minimale du moteur exposée à la room (évite un cycle d'import). */
export interface RoomGameController {
run(): Promise<void>
handleVote(playerId: string, answer: Answer): void
/** Relais média du DJ (blindtest) : rebroadcast media:sync à toute la room. */
handleMediaControl(playerId: string, payload: MediaControlPayload): void
}