From 03a0dfa998cf98f297f48bc63958fd0129919700 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Thu, 11 Jun 2026 00:54:40 +0200 Subject: [PATCH] feat: end-of-game round recap (all rounds with answers + scorers) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- apps/server/src/game/engine.test.ts | 1 + apps/server/src/game/engine.ts | 12 ++- .../game/modes/blindtest/blindtest-round.ts | 19 ++++- apps/server/src/game/modes/quiz/quiz-round.ts | 21 ++++- apps/server/src/game/round.ts | 8 ++ apps/web/src/components/game-end-view.tsx | 77 ++++++++++++++++++- apps/web/src/store/room.ts | 13 +++- packages/shared/src/domain.ts | 24 ++++++ packages/shared/src/events.ts | 3 + 9 files changed, 171 insertions(+), 7 deletions(-) diff --git a/apps/server/src/game/engine.test.ts b/apps/server/src/game/engine.test.ts index 73f8fa7..cb8f010 100644 --- a/apps/server/src/game/engine.test.ts +++ b/apps/server/src/game/engine.test.ts @@ -46,6 +46,7 @@ const dummyRound: GameRound = { } return deltas }, + recap: () => ({ label: "2+2 ?", answer: "4" }), } function setup(roundDurationSec: number): { diff --git a/apps/server/src/game/engine.ts b/apps/server/src/game/engine.ts index 8fe2b74..899685d 100644 --- a/apps/server/src/game/engine.ts +++ b/apps/server/src/game/engine.ts @@ -7,6 +7,7 @@ import type { MediaControlPayload, PlayerScore, RoundConfig, + RoundRecap, } from "@nerdware/shared" import type { IoServer } from "../socket" import type { RoomManager, ServerRoom } from "../rooms" @@ -43,6 +44,7 @@ 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 @@ -83,6 +85,7 @@ export class GameEngine implements RoomGameController { 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, }) } @@ -171,9 +174,16 @@ export class GameEngine implements RoomGameController { this.broadcastState() this.io.to(this.room.code).emit("round:reveal", round.reveal(ctx)) - for (const { playerId, delta } of round.score(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() diff --git a/apps/server/src/game/modes/blindtest/blindtest-round.ts b/apps/server/src/game/modes/blindtest/blindtest-round.ts index 0fe58eb..70ae6b6 100644 --- a/apps/server/src/game/modes/blindtest/blindtest-round.ts +++ b/apps/server/src/game/modes/blindtest/blindtest-round.ts @@ -11,7 +11,12 @@ import type { BlindtestRoundPayload, ScoreDelta, } from "@nerdware/shared" -import type { GameRound, RoundContext, RoundStart } from "../../round" +import type { + GameRound, + RoundContext, + RoundRecapInfo, + RoundStart, +} from "../../round" import type { BlindtestTrack, ServerRoom } from "../../../rooms" import { fuzzyMatch } from "../../match" import { takeTrack } from "./pool" @@ -152,4 +157,16 @@ export class BlindtestRound implements GameRound { } return deltas } + + recap(ctx: RoundContext): RoundRecapInfo { + const { track } = ctx.data as BlindtestRoundData + const submitter = ctx.room.players.get(track.submittedBy) + return { + label: "Blindtest", + answer: `${track.title} — ${track.artist}`, + youtubeId: track.youtubeId, + url: track.url, + addedBy: submitter?.name ?? "?", + } + } } diff --git a/apps/server/src/game/modes/quiz/quiz-round.ts b/apps/server/src/game/modes/quiz/quiz-round.ts index 6ee1bc9..056f138 100644 --- a/apps/server/src/game/modes/quiz/quiz-round.ts +++ b/apps/server/src/game/modes/quiz/quiz-round.ts @@ -11,7 +11,12 @@ import type { import { hasDb } from "../../../db" import { markQuestionPlayed } from "../../../db/quiz-repo" import { fuzzyMatch } from "../../match" -import type { GameRound, RoundContext, RoundStart } from "../../round" +import type { + GameRound, + RoundContext, + RoundRecapInfo, + RoundStart, +} from "../../round" import type { ServerRoom } from "../../../rooms" import type { QuizQuestion } from "./questions" import { takeQuestion } from "./pool" @@ -141,4 +146,18 @@ export class QuizRound implements GameRound { } return deltas } + + recap(ctx: RoundContext): RoundRecapInfo { + const { question } = ctx.data as QuizRoundData + const answer = isTextFormat(question) + ? (question.acceptedAnswers?.[0] ?? "") + : (question.choices?.[question.correctIndex ?? 0] ?? "") + return { + label: question.prompt, + answer, + category: question.category, + imageUrl: + question.format === "image_reveal" ? question.imageUrl : undefined, + } + } } diff --git a/apps/server/src/game/round.ts b/apps/server/src/game/round.ts index 9836f36..46be5e9 100644 --- a/apps/server/src/game/round.ts +++ b/apps/server/src/game/round.ts @@ -5,11 +5,16 @@ 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 + + /** 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). */ @@ -57,6 +62,9 @@ export interface GameRound { /** 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). */ diff --git a/apps/web/src/components/game-end-view.tsx b/apps/web/src/components/game-end-view.tsx index 67a67be..c232202 100644 --- a/apps/web/src/components/game-end-view.tsx +++ b/apps/web/src/components/game-end-view.tsx @@ -1,10 +1,19 @@ import { useEffect, useRef, useState } from "react" import { Link } from "wouter" import { motion } from "framer-motion" -import { Check, ClipboardList, Crown, Music } from "lucide-react" +import { Check, ClipboardList, Crown, ListChecks, Music } from "lucide-react" import { SiSpotify, SiYoutube } from "@icons-pack/react-simple-icons" -import type { BlindtestTrackInfo, PlayerScore, RoomSnapshot } from "@nerdware/shared" +import type { + BlindtestTrackInfo, + PlayerScore, + RoomSnapshot, + RoundRecap, +} from "@nerdware/shared" import { Button } from "@workspace/ui/components/button" + +const SERVER_URL = import.meta.env.VITE_SERVER_URL ?? "http://localhost:3001" +const recapImage = (url: string) => + url.startsWith("http") ? url : `${SERVER_URL}${url}` import { useRoomStore } from "@/store/room" import { Avatar } from "@/components/avatar" import { celebrate } from "@/lib/confetti" @@ -123,6 +132,7 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) { const playerId = useRoomStore((s) => s.playerId) const finalScores = useRoomStore((s) => s.finalScores) const gameTracks = useRoomStore((s) => s.gameTracks) + const gameRecap = useRoomStore((s) => s.gameRecap) const reset = useRoomStore((s) => s.reset) const returnToLobby = useRoomStore((s) => s.returnToLobby) const isHost = snapshot.hostId === playerId @@ -221,6 +231,10 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) { )} + {gameRecap && gameRecap.length > 0 && ( + + )} +
{isHost ? (