feature/round-recap #11

Merged
ayoub merged 4 commits from feature/round-recap into dev 2026-06-10 23:14:59 +00:00
9 changed files with 171 additions and 7 deletions
Showing only changes of commit 03a0dfa998 - Show all commits

View file

@ -46,6 +46,7 @@ const dummyRound: GameRound = {
} }
return deltas return deltas
}, },
recap: () => ({ label: "2+2 ?", answer: "4" }),
} }
function setup(roundDurationSec: number): { function setup(roundDurationSec: number): {

View file

@ -7,6 +7,7 @@ import type {
MediaControlPayload, MediaControlPayload,
PlayerScore, PlayerScore,
RoundConfig, RoundConfig,
RoundRecap,
} from "@nerdware/shared" } from "@nerdware/shared"
import type { IoServer } from "../socket" import type { IoServer } from "../socket"
import type { RoomManager, ServerRoom } from "../rooms" import type { RoomManager, ServerRoom } from "../rooms"
@ -43,6 +44,7 @@ export class GameEngine implements RoomGameController {
private runtime: RoundRuntime | null = null private runtime: RoundRuntime | null = null
private timer: ReturnType<typeof setTimeout> | null = null private timer: ReturnType<typeof setTimeout> | null = null
private resolveRoundEnd: (() => void) | null = null private resolveRoundEnd: (() => void) | null = null
private readonly history: RoundRecap[] = []
private readonly createRound: (type: RoundConfig["type"]) => GameRound private readonly createRound: (type: RoundConfig["type"]) => GameRound
private readonly revealPauseMs: number private readonly revealPauseMs: number
private readonly leadMs: number private readonly leadMs: number
@ -83,6 +85,7 @@ export class GameEngine implements RoomGameController {
this.io.to(this.room.code).emit("game:end", { this.io.to(this.room.code).emit("game:end", {
finalScores: this.scoreboard(), finalScores: this.scoreboard(),
tracks: tracks.length > 0 ? tracks : undefined, 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.broadcastState()
this.io.to(this.room.code).emit("round:reveal", round.reveal(ctx)) 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.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.room.status = "scores"
this.io.to(this.room.code).emit("score:update", { scores: this.scoreboard() }) this.io.to(this.room.code).emit("score:update", { scores: this.scoreboard() })
this.broadcastState() this.broadcastState()

View file

@ -11,7 +11,12 @@ import type {
BlindtestRoundPayload, BlindtestRoundPayload,
ScoreDelta, ScoreDelta,
} from "@nerdware/shared" } 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 type { BlindtestTrack, ServerRoom } from "../../../rooms"
import { fuzzyMatch } from "../../match" import { fuzzyMatch } from "../../match"
import { takeTrack } from "./pool" import { takeTrack } from "./pool"
@ -152,4 +157,16 @@ export class BlindtestRound implements GameRound {
} }
return deltas 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 ?? "?",
}
}
} }

View file

@ -11,7 +11,12 @@ import type {
import { hasDb } from "../../../db" import { hasDb } from "../../../db"
import { markQuestionPlayed } from "../../../db/quiz-repo" import { markQuestionPlayed } from "../../../db/quiz-repo"
import { fuzzyMatch } from "../../match" 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 { ServerRoom } from "../../../rooms"
import type { QuizQuestion } from "./questions" import type { QuizQuestion } from "./questions"
import { takeQuestion } from "./pool" import { takeQuestion } from "./pool"
@ -141,4 +146,18 @@ export class QuizRound implements GameRound {
} }
return deltas 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,
}
}
} }

View file

@ -5,11 +5,16 @@ import type {
Answer, Answer,
MediaControlPayload, MediaControlPayload,
RevealPayload, RevealPayload,
RoundRecap,
RoundType, RoundType,
ScoreDelta, ScoreDelta,
} from "@nerdware/shared" } from "@nerdware/shared"
import type { ServerRoom } from "../rooms" 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. */ /** Ce que `start()` renvoie : la partie publique + les données privées de la manche. */
export interface RoundStart { export interface RoundStart {
/** Joueur DJ (blindtest) ; null pour les épreuves sans DJ (quiz). */ /** 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). */ /** Renvoie les variations de score à appliquer (le moteur les applique). */
score(ctx: RoundContext): ScoreDelta[] 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). */ /** Vue minimale du moteur exposée à la room (évite un cycle d'import). */

View file

@ -1,10 +1,19 @@
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { Link } from "wouter" import { Link } from "wouter"
import { motion } from "framer-motion" 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 { 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" 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 { useRoomStore } from "@/store/room"
import { Avatar } from "@/components/avatar" import { Avatar } from "@/components/avatar"
import { celebrate } from "@/lib/confetti" import { celebrate } from "@/lib/confetti"
@ -123,6 +132,7 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
const playerId = useRoomStore((s) => s.playerId) const playerId = useRoomStore((s) => s.playerId)
const finalScores = useRoomStore((s) => s.finalScores) const finalScores = useRoomStore((s) => s.finalScores)
const gameTracks = useRoomStore((s) => s.gameTracks) const gameTracks = useRoomStore((s) => s.gameTracks)
const gameRecap = useRoomStore((s) => s.gameRecap)
const reset = useRoomStore((s) => s.reset) const reset = useRoomStore((s) => s.reset)
const returnToLobby = useRoomStore((s) => s.returnToLobby) const returnToLobby = useRoomStore((s) => s.returnToLobby)
const isHost = snapshot.hostId === playerId const isHost = snapshot.hostId === playerId
@ -221,6 +231,10 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
<TracksRecap tracks={gameTracks} /> <TracksRecap tracks={gameTracks} />
)} )}
{gameRecap && gameRecap.length > 0 && (
<RoundsRecap recap={gameRecap} nameOf={nameOf} />
)}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{isHost ? ( {isHost ? (
<Button className="w-full" onClick={returnToLobby}> <Button className="w-full" onClick={returnToLobby}>
@ -304,3 +318,62 @@ function TracksRecap({ tracks }: { tracks: BlindtestTrackInfo[] }) {
</section> </section>
) )
} }
function RoundsRecap({
recap,
nameOf,
}: {
recap: RoundRecap[]
nameOf: (id: string) => string
}) {
return (
<details className="rounded-xl border text-left">
<summary className="text-muted-foreground flex cursor-pointer items-center gap-1.5 p-3 text-sm font-medium select-none">
<ListChecks className="size-4" /> Récapitulatif des manches ({recap.length})
</summary>
<ul className="flex flex-col gap-2 p-3 pt-0">
{recap.map((r) => (
<li key={r.index} className="bg-muted/40 flex gap-3 rounded-lg p-2">
{r.youtubeId ? (
<img
src={`https://img.youtube.com/vi/${r.youtubeId}/mqdefault.jpg`}
alt=""
className="h-12 w-16 shrink-0 rounded object-cover"
/>
) : r.imageUrl ? (
<img
src={recapImage(r.imageUrl)}
alt=""
className="h-12 w-16 shrink-0 rounded object-contain"
/>
) : null}
<div className="min-w-0 flex-1">
<p className="text-muted-foreground text-[10px] uppercase">
{r.index + 1} · {r.category ?? (r.type === "blindtest" ? "Blindtest" : "Quiz")}
</p>
{r.type !== "blindtest" && (
<p className="line-clamp-1 text-xs">{r.label}</p>
)}
<p className="text-sm font-medium">
{r.answer}
{r.addedBy && (
<span className="text-muted-foreground font-normal">
{" "}
· {r.addedBy}
</span>
)}
</p>
<p className="text-muted-foreground text-[10px]">
{r.scorers.length > 0
? r.scorers
.map((s) => `${nameOf(s.playerId)} +${s.points}`)
.join(" · ")
: "Personne n'a marqué"}
</p>
</div>
</li>
))}
</ul>
</details>
)
}

View file

@ -10,6 +10,7 @@ import {
type RoomCreatedPayload, type RoomCreatedPayload,
type RoomSnapshot, type RoomSnapshot,
type RoundConfig, type RoundConfig,
type RoundRecap,
type RoundStartPayload, type RoundStartPayload,
type SubmitOkPayload, type SubmitOkPayload,
type UpdateSettingsPayload, type UpdateSettingsPayload,
@ -51,6 +52,7 @@ interface RoomState {
hasVoted: boolean hasVoted: boolean
finalScores: PlayerScore[] | null finalScores: PlayerScore[] | null
gameTracks: BlindtestTrackInfo[] | null gameTracks: BlindtestTrackInfo[] | null
gameRecap: RoundRecap[] | null
mediaSync: MediaSyncPayload | null mediaSync: MediaSyncPayload | null
boomKey: number boomKey: number
roundKey: number roundKey: number
@ -90,6 +92,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
hasVoted: false, hasVoted: false,
finalScores: null, finalScores: null,
gameTracks: null, gameTracks: null,
gameRecap: null,
mediaSync: null, mediaSync: null,
boomKey: 0, boomKey: 0,
roundKey: 0, roundKey: 0,
@ -221,6 +224,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
hasVoted: false, hasVoted: false,
finalScores: null, finalScores: null,
gameTracks: null, gameTracks: null,
gameRecap: null,
mediaSync: null, mediaSync: null,
boomKey: 0, boomKey: 0,
roundKey: 0, roundKey: 0,
@ -244,6 +248,7 @@ socket.on("room:state", (snapshot) =>
hasVoted: false, hasVoted: false,
finalScores: null, finalScores: null,
gameTracks: null, gameTracks: null,
gameRecap: null,
mediaSync: null, mediaSync: null,
} }
: { snapshot } : { snapshot }
@ -285,6 +290,10 @@ socket.on("round:voteAck", (voteProgress) =>
) )
socket.on("round:reveal", (reveal) => useRoomStore.setState({ reveal })) socket.on("round:reveal", (reveal) => useRoomStore.setState({ reveal }))
socket.on("media:sync", (mediaSync) => useRoomStore.setState({ mediaSync })) socket.on("media:sync", (mediaSync) => useRoomStore.setState({ mediaSync }))
socket.on("game:end", ({ finalScores, tracks }) => socket.on("game:end", ({ finalScores, tracks, recap }) =>
useRoomStore.setState({ finalScores, gameTracks: tracks ?? null }) useRoomStore.setState({
finalScores,
gameTracks: tracks ?? null,
gameRecap: recap ?? null,
})
) )

View file

@ -84,6 +84,30 @@ export interface ScoreDelta {
delta: number delta: number
} }
/** Joueur ayant marqué sur une manche (récap de fin de partie). */
export interface RoundRecapScorer {
playerId: string
points: number
}
/** Récapitulatif d'une manche jouée (affiché en fin de partie). */
export interface RoundRecap {
index: number
type: RoundType
/** Intitulé (question quiz) ou libellé du mode. */
label: string
/** Bonne réponse révélée. */
answer: string
category?: string
imageUrl?: string
youtubeId?: string
url?: string
/** Blindtest : qui avait ajouté le titre. */
addedBy?: string
/** Joueurs ayant marqué sur cette manche. */
scorers: RoundRecapScorer[]
}
// --- Snapshot diffusé au client (room:state) ----------------------------- // --- Snapshot diffusé au client (room:state) -----------------------------
/** /**

View file

@ -9,6 +9,7 @@ import type {
PlayerScore, PlayerScore,
RoomSnapshot, RoomSnapshot,
RoundConfig, RoundConfig,
RoundRecap,
RoundType, RoundType,
} from "./domain" } from "./domain"
import type { BlindtestTrackInfo } from "./blindtest" import type { BlindtestTrackInfo } from "./blindtest"
@ -118,6 +119,8 @@ export interface GameEndPayload {
finalScores: PlayerScore[] finalScores: PlayerScore[]
/** Titres joués (si la partie comportait du blindtest) — pour récupérer les liens. */ /** Titres joués (si la partie comportait du blindtest) — pour récupérer les liens. */
tracks?: BlindtestTrackInfo[] tracks?: BlindtestTrackInfo[]
/** Récapitulatif de toutes les manches (question + réponse + qui a marqué). */
recap?: RoundRecap[]
spotifyExport?: unknown spotifyExport?: unknown
} }