feat: round recap shows each player's answer

- shared: RoundRecap gains answers[] (RoundPlayerAnswer: text + correct)
- server: quiz/blindtest recap() build per-player answers (choice text / free
  text / blindtest guess formatted by mode), with correctness
- client: recap lists each player's answer per round, green/red by correctness,
  with their points when they scored

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
AyoubBenziza 2026-06-11 01:00:50 +02:00
parent 03a0dfa998
commit f7ee2204d1
5 changed files with 121 additions and 41 deletions

View file

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

View file

@ -160,13 +160,43 @@ export class BlindtestRound implements GameRound {
recap(ctx: RoundContext): RoundRecapInfo { recap(ctx: RoundContext): RoundRecapInfo {
const { track } = ctx.data as BlindtestRoundData const { track } = ctx.data as BlindtestRoundData
const mode = ctx.room.settings.blindtestMode
const submitter = ctx.room.players.get(track.submittedBy) 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 { return {
label: "Blindtest", label: "Blindtest",
answer: `${track.title}${track.artist}`, answer: `${track.title}${track.artist}`,
youtubeId: track.youtubeId, youtubeId: track.youtubeId,
url: track.url, url: track.url,
addedBy: submitter?.name ?? "?", addedBy: submitter?.name ?? "?",
answers,
} }
} }
} }

View file

@ -152,12 +152,28 @@ export class QuizRound implements GameRound {
const answer = isTextFormat(question) const answer = isTextFormat(question)
? (question.acceptedAnswers?.[0] ?? "") ? (question.acceptedAnswers?.[0] ?? "")
: (question.choices?.[question.correctIndex ?? 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 { return {
label: question.prompt, label: question.prompt,
answer, answer,
category: question.category, category: question.category,
imageUrl: imageUrl:
question.format === "image_reveal" ? question.imageUrl : undefined, question.format === "image_reveal" ? question.imageUrl : undefined,
answers,
} }
} }
} }

View file

@ -332,47 +332,72 @@ function RoundsRecap({
<ListChecks className="size-4" /> Récapitulatif des manches ({recap.length}) <ListChecks className="size-4" /> Récapitulatif des manches ({recap.length})
</summary> </summary>
<ul className="flex flex-col gap-2 p-3 pt-0"> <ul className="flex flex-col gap-2 p-3 pt-0">
{recap.map((r) => ( {recap.map((r) => {
<li key={r.index} className="bg-muted/40 flex gap-3 rounded-lg p-2"> const pointsBy = new Map(r.scorers.map((s) => [s.playerId, s.points]))
{r.youtubeId ? ( return (
<img <li key={r.index} className="bg-muted/40 flex gap-3 rounded-lg p-2">
src={`https://img.youtube.com/vi/${r.youtubeId}/mqdefault.jpg`} {r.youtubeId ? (
alt="" <img
className="h-12 w-16 shrink-0 rounded object-cover" src={`https://img.youtube.com/vi/${r.youtubeId}/mqdefault.jpg`}
/> alt=""
) : r.imageUrl ? ( className="h-12 w-16 shrink-0 rounded object-cover"
<img />
src={recapImage(r.imageUrl)} ) : r.imageUrl ? (
alt="" <img
className="h-12 w-16 shrink-0 rounded object-contain" src={recapImage(r.imageUrl)}
/> alt=""
) : null} className="h-12 w-16 shrink-0 rounded object-contain"
<div className="min-w-0 flex-1"> />
<p className="text-muted-foreground text-[10px] uppercase"> ) : null}
{r.index + 1} · {r.category ?? (r.type === "blindtest" ? "Blindtest" : "Quiz")} <div className="min-w-0 flex-1">
</p> <p className="text-muted-foreground text-[10px] uppercase">
{r.type !== "blindtest" && ( {r.index + 1} ·{" "}
<p className="line-clamp-1 text-xs">{r.label}</p> {r.category ?? (r.type === "blindtest" ? "Blindtest" : "Quiz")}
)} </p>
<p className="text-sm font-medium"> {r.type !== "blindtest" && (
{r.answer} <p className="line-clamp-1 text-xs">{r.label}</p>
{r.addedBy && (
<span className="text-muted-foreground font-normal">
{" "}
· {r.addedBy}
</span>
)} )}
</p> <p className="text-sm font-medium">
<p className="text-muted-foreground text-[10px]"> {r.answer}
{r.scorers.length > 0 {r.addedBy && (
? r.scorers <span className="text-muted-foreground font-normal">
.map((s) => `${nameOf(s.playerId)} +${s.points}`) {" "}
.join(" · ") · {r.addedBy}
: "Personne n'a marqué"} </span>
</p> )}
</div> </p>
</li> <ul className="mt-1 flex flex-col gap-0.5">
))} {r.answers.length === 0 ? (
<li className="text-muted-foreground text-[10px]">
Aucune réponse
</li>
) : (
r.answers.map((a) => (
<li key={a.playerId} className="text-[10px]">
<span className="text-muted-foreground">
{nameOf(a.playerId)} :{" "}
</span>
<span
className={
a.correct ? "text-green-500" : "text-red-400"
}
>
{a.answer || "—"}
</span>
{pointsBy.get(a.playerId) ? (
<span className="text-muted-foreground">
{" "}
+{pointsBy.get(a.playerId)}
</span>
) : null}
</li>
))
)}
</ul>
</div>
</li>
)
})}
</ul> </ul>
</details> </details>
) )

View file

@ -90,6 +90,13 @@ export interface RoundRecapScorer {
points: number 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). */ /** Récapitulatif d'une manche jouée (affiché en fin de partie). */
export interface RoundRecap { export interface RoundRecap {
index: number index: number
@ -106,6 +113,8 @@ export interface RoundRecap {
addedBy?: string addedBy?: string
/** Joueurs ayant marqué sur cette manche. */ /** Joueurs ayant marqué sur cette manche. */
scorers: RoundRecapScorer[] scorers: RoundRecapScorer[]
/** Réponse de chaque joueur ayant répondu. */
answers: RoundPlayerAnswer[]
} }
// --- Snapshot diffusé au client (room:state) ----------------------------- // --- Snapshot diffusé au client (room:state) -----------------------------