From 03a0dfa998cf98f297f48bc63958fd0129919700 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Thu, 11 Jun 2026 00:54:40 +0200 Subject: [PATCH 1/4] 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 ? (
+ + ) + })} ) diff --git a/packages/shared/src/domain.ts b/packages/shared/src/domain.ts index a460365..8afd99e 100644 --- a/packages/shared/src/domain.ts +++ b/packages/shared/src/domain.ts @@ -90,6 +90,13 @@ export interface RoundRecapScorer { points: number } +/** Réponse d'un joueur sur une manche (récap). */ +export interface RoundPlayerAnswer { + playerId: string + answer: string + correct: boolean +} + /** Récapitulatif d'une manche jouée (affiché en fin de partie). */ export interface RoundRecap { index: number @@ -106,6 +113,8 @@ export interface RoundRecap { addedBy?: string /** Joueurs ayant marqué sur cette manche. */ scorers: RoundRecapScorer[] + /** Réponse de chaque joueur ayant répondu. */ + answers: RoundPlayerAnswer[] } // --- Snapshot diffusé au client (room:state) ----------------------------- -- 2.45.3 From 331d375349373ec2794a0c46561923cd130ed2e1 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Thu, 11 Jun 2026 01:05:14 +0200 Subject: [PATCH 3/4] =?UTF-8?q?feat(web):=20recap=20polish=20=E2=80=94=20s?= =?UTF-8?q?ort=20answers=20by=20points,=20highlight=20you,=20found=20count?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - per round, answers sorted by points (then correct first) - the local player's line is bolded with "(toi)" - per-round "✓ X" badge showing how many got it right Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/components/game-end-view.tsx | 28 +++++++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/game-end-view.tsx b/apps/web/src/components/game-end-view.tsx index 1434dab..daba468 100644 --- a/apps/web/src/components/game-end-view.tsx +++ b/apps/web/src/components/game-end-view.tsx @@ -232,7 +232,7 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) { )} {gameRecap && gameRecap.length > 0 && ( - + )}
@@ -322,9 +322,11 @@ function TracksRecap({ tracks }: { tracks: BlindtestTrackInfo[] }) { function RoundsRecap({ recap, nameOf, + playerId, }: { recap: RoundRecap[] nameOf: (id: string) => string + playerId: string | null }) { return (
@@ -334,6 +336,13 @@ function RoundsRecap({
    {recap.map((r) => { const pointsBy = new Map(r.scorers.map((s) => [s.playerId, s.points])) + const found = r.answers.filter((a) => a.correct).length + // Tri : plus de points d'abord, puis bonnes réponses. + const answers = [...r.answers].sort( + (a, b) => + (pointsBy.get(b.playerId) ?? 0) - (pointsBy.get(a.playerId) ?? 0) || + Number(b.correct) - Number(a.correct) + ) return (
  • {r.youtubeId ? ( @@ -353,6 +362,8 @@ function RoundsRecap({

    {r.index + 1} ·{" "} {r.category ?? (r.type === "blindtest" ? "Blindtest" : "Quiz")} + {" · "} + ✓ {found}

    {r.type !== "blindtest" && (

    {r.label}

    @@ -367,15 +378,22 @@ function RoundsRecap({ )}

      - {r.answers.length === 0 ? ( + {answers.length === 0 ? (
    • Aucune réponse
    • ) : ( - r.answers.map((a) => ( + answers.map((a) => (
    • - - {nameOf(a.playerId)} :{" "} + + {nameOf(a.playerId)} + {a.playerId === playerId && " (toi)"} :{" "} Date: Thu, 11 Jun 2026 01:11:06 +0200 Subject: [PATCH 4/4] feat(web): end-game awards, copy results, avatars in recap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FunStats: award badges from the recap data — 🎯 Sans-faute, 🧠 Le cerveau (most correct), 🦹 Roi de la tromperie (blindtest decoy points), 🪨 Le boulet (0 correct). Shown with player avatars. - "Copier les résultats" button: copies ranking + per-round answers to clipboard - avatars next to each player in the round-by-round recap Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/components/game-end-view.tsx | 140 +++++++++++++++++++++- 1 file changed, 137 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/game-end-view.tsx b/apps/web/src/components/game-end-view.tsx index daba468..1f2a5a4 100644 --- a/apps/web/src/components/game-end-view.tsx +++ b/apps/web/src/components/game-end-view.tsx @@ -147,6 +147,29 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) { } }, []) + const [copied, setCopied] = useState(false) + async function copyResults() { + const lines = ["NerdWare — Résultats", "", "🏆 Classement"] + ranked.forEach((s, i) => + lines.push(`${i + 1}. ${nameOf(s.playerId)} — ${s.score}`) + ) + if (gameRecap && gameRecap.length > 0) { + lines.push("", "Manches") + gameRecap.forEach((r) => + lines.push( + `${r.index + 1}. ${r.type === "blindtest" ? "🎧" : "🧠"} ${r.answer}` + ) + ) + } + try { + await navigator.clipboard.writeText(lines.join("\n")) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } catch { + // presse-papier indisponible : on ignore + } + } + const scores: PlayerScore[] = finalScores ?? snapshot.scores const ranked = [...scores].sort((a, b) => b.score - a.score) const nameOf = (id: string) => @@ -227,6 +250,10 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) { )} + {gameRecap && gameRecap.length > 0 && ( + + )} + {gameTracks && gameTracks.length > 0 && ( )} @@ -236,6 +263,10 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) { )}
      + {isHost ? (
    • ) : ( answers.map((a) => ( -
    • +
    • + {nameOf(a.playerId)} - {a.playerId === playerId && " (toi)"} :{" "} + {a.playerId === playerId && " (toi)"} : {pointsBy.get(a.playerId) ? ( - {" "} +{pointsBy.get(a.playerId)} ) : null} -- 2.45.3