diff --git a/apps/server/src/game/engine.test.ts b/apps/server/src/game/engine.test.ts index 73f8fa7..991b29d 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", answers: [] }), } 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..20a46c9 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,46 @@ export class BlindtestRound implements GameRound { } return deltas } + + recap(ctx: RoundContext): RoundRecapInfo { + const { track } = ctx.data as BlindtestRoundData + const mode = ctx.room.settings.blindtestMode + const submitter = ctx.room.players.get(track.submittedBy) + const nameOf = (id?: string) => + (id && ctx.room.players.get(id)?.name) || "?" + + const answers = [] + for (const [playerId, vote] of ctx.votes) { + if (playerId === track.submittedBy) { + continue + } + const a = vote as { + title?: string + artist?: string + guessedPlayerId?: string + } + let text = "" + if (mode === "who_added") { + text = nameOf(a.guessedPlayerId) + } else if (mode === "title_artist") { + text = `${a.title ?? ""} / ${a.artist ?? ""}` + } else { + text = `${a.title ?? ""} / ${a.artist ?? ""} → ${nameOf(a.guessedPlayerId)}` + } + answers.push({ + playerId, + answer: text, + correct: voterResult(mode, vote, track).points > 0, + }) + } + + return { + label: "Blindtest", + answer: `${track.title} — ${track.artist}`, + youtubeId: track.youtubeId, + url: track.url, + addedBy: submitter?.name ?? "?", + answers, + } + } } diff --git a/apps/server/src/game/modes/quiz/quiz-round.ts b/apps/server/src/game/modes/quiz/quiz-round.ts index 6ee1bc9..54250a1 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,34 @@ 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] ?? "") + const answers = [] + for (const player of ctx.room.players.values()) { + const vote = ctx.votes.get(player.id) + if (!vote) { + continue + } + const text = isTextFormat(question) + ? (asText(vote) ?? "") + : (question.choices?.[asChoice(vote) ?? -1] ?? "?") + answers.push({ + playerId: player.id, + answer: text, + correct: isCorrect(question, vote), + }) + } + return { + label: question.prompt, + answer, + category: question.category, + imageUrl: + question.format === "image_reveal" ? question.imageUrl : undefined, + answers, + } + } } 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..1f2a5a4 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 @@ -137,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) => @@ -217,11 +250,23 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) { )} + {gameRecap && gameRecap.length > 0 && ( + + )} + {gameTracks && gameTracks.length > 0 && ( )} + {gameRecap && gameRecap.length > 0 && ( + + )} +
+ {isHost ? (