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>
This commit is contained in:
parent
cb50ef390e
commit
03a0dfa998
9 changed files with 171 additions and 7 deletions
|
|
@ -46,6 +46,7 @@ const dummyRound: GameRound = {
|
|||
}
|
||||
return deltas
|
||||
},
|
||||
recap: () => ({ label: "2+2 ?", answer: "4" }),
|
||||
}
|
||||
|
||||
function setup(roundDurationSec: number): {
|
||||
|
|
|
|||
|
|
@ -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<typeof setTimeout> | 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()
|
||||
|
|
|
|||
|
|
@ -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 ?? "?",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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). */
|
||||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
|||
<TracksRecap tracks={gameTracks} />
|
||||
)}
|
||||
|
||||
{gameRecap && gameRecap.length > 0 && (
|
||||
<RoundsRecap recap={gameRecap} nameOf={nameOf} />
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{isHost ? (
|
||||
<Button className="w-full" onClick={returnToLobby}>
|
||||
|
|
@ -304,3 +318,62 @@ function TracksRecap({ tracks }: { tracks: BlindtestTrackInfo[] }) {
|
|||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
type RoomCreatedPayload,
|
||||
type RoomSnapshot,
|
||||
type RoundConfig,
|
||||
type RoundRecap,
|
||||
type RoundStartPayload,
|
||||
type SubmitOkPayload,
|
||||
type UpdateSettingsPayload,
|
||||
|
|
@ -51,6 +52,7 @@ interface RoomState {
|
|||
hasVoted: boolean
|
||||
finalScores: PlayerScore[] | null
|
||||
gameTracks: BlindtestTrackInfo[] | null
|
||||
gameRecap: RoundRecap[] | null
|
||||
mediaSync: MediaSyncPayload | null
|
||||
boomKey: number
|
||||
roundKey: number
|
||||
|
|
@ -90,6 +92,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
|
|||
hasVoted: false,
|
||||
finalScores: null,
|
||||
gameTracks: null,
|
||||
gameRecap: null,
|
||||
mediaSync: null,
|
||||
boomKey: 0,
|
||||
roundKey: 0,
|
||||
|
|
@ -221,6 +224,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
|
|||
hasVoted: false,
|
||||
finalScores: null,
|
||||
gameTracks: null,
|
||||
gameRecap: null,
|
||||
mediaSync: null,
|
||||
boomKey: 0,
|
||||
roundKey: 0,
|
||||
|
|
@ -244,6 +248,7 @@ socket.on("room:state", (snapshot) =>
|
|||
hasVoted: false,
|
||||
finalScores: null,
|
||||
gameTracks: null,
|
||||
gameRecap: null,
|
||||
mediaSync: null,
|
||||
}
|
||||
: { snapshot }
|
||||
|
|
@ -285,6 +290,10 @@ socket.on("round:voteAck", (voteProgress) =>
|
|||
)
|
||||
socket.on("round:reveal", (reveal) => useRoomStore.setState({ reveal }))
|
||||
socket.on("media:sync", (mediaSync) => useRoomStore.setState({ mediaSync }))
|
||||
socket.on("game:end", ({ finalScores, tracks }) =>
|
||||
useRoomStore.setState({ finalScores, gameTracks: tracks ?? null })
|
||||
socket.on("game:end", ({ finalScores, tracks, recap }) =>
|
||||
useRoomStore.setState({
|
||||
finalScores,
|
||||
gameTracks: tracks ?? null,
|
||||
gameRecap: recap ?? null,
|
||||
})
|
||||
)
|
||||
|
|
|
|||
|
|
@ -84,6 +84,30 @@ export interface ScoreDelta {
|
|||
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) -----------------------------
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type {
|
|||
PlayerScore,
|
||||
RoomSnapshot,
|
||||
RoundConfig,
|
||||
RoundRecap,
|
||||
RoundType,
|
||||
} from "./domain"
|
||||
import type { BlindtestTrackInfo } from "./blindtest"
|
||||
|
|
@ -118,6 +119,8 @@ export interface GameEndPayload {
|
|||
finalScores: PlayerScore[]
|
||||
/** Titres joués (si la partie comportait du blindtest) — pour récupérer les liens. */
|
||||
tracks?: BlindtestTrackInfo[]
|
||||
/** Récapitulatif de toutes les manches (question + réponse + qui a marqué). */
|
||||
recap?: RoundRecap[]
|
||||
spotifyExport?: unknown
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue