feat(web): end-game awards, copy results, avatars in recap

- 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) <noreply@anthropic.com>
This commit is contained in:
AyoubBenziza 2026-06-11 01:11:06 +02:00
parent 331d375349
commit 976ebf0799

View file

@ -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 }) {
</ol>
)}
{gameRecap && gameRecap.length > 0 && (
<FunStats recap={gameRecap} snapshot={snapshot} />
)}
{gameTracks && gameTracks.length > 0 && (
<TracksRecap tracks={gameTracks} />
)}
@ -236,6 +263,10 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
)}
<div className="flex flex-col gap-2">
<Button variant="secondary" className="w-full" onClick={copyResults}>
{copied ? <Check className="text-green-500" /> : <ClipboardList />}
{copied ? "Copié !" : "Copier les résultats"}
</Button>
{isHost ? (
<Button className="w-full" onClick={returnToLobby}>
Rejouer
@ -319,6 +350,106 @@ function TracksRecap({ tracks }: { tracks: BlindtestTrackInfo[] }) {
)
}
interface PlayerStat {
correct: number
answered: number
decoy: number
}
function FunStats({
recap,
snapshot,
}: {
recap: RoundRecap[]
snapshot: RoomSnapshot
}) {
const stats = new Map<string, PlayerStat>(
snapshot.players.map((p) => [p.id, { correct: 0, answered: 0, decoy: 0 }])
)
for (const r of recap) {
const answered = new Set(r.answers.map((a) => a.playerId))
for (const a of r.answers) {
const s = stats.get(a.playerId)
if (s) {
s.answered++
if (a.correct) s.correct++
}
}
if (r.type === "blindtest") {
for (const sc of r.scorers) {
if (!answered.has(sc.playerId)) {
const s = stats.get(sc.playerId)
if (s) s.decoy += sc.points
}
}
}
}
const players = snapshot.players
const total = recap.length
const nameOf = (id: string) => players.find((p) => p.id === id)?.name ?? "?"
const stat = (id: string) => stats.get(id) ?? { correct: 0, answered: 0, decoy: 0 }
const winners = (pick: (s: PlayerStat) => number, min = 1) => {
const max = Math.max(0, ...players.map((p) => pick(stat(p.id))))
return max >= min ? players.filter((p) => pick(stat(p.id)) === max) : []
}
const awards: { emoji: string; label: string; ids: string[] }[] = []
const perfect = players.filter(
(p) => total > 0 && stat(p.id).correct === total
)
if (perfect.length) {
awards.push({ emoji: "🎯", label: "Sans-faute", ids: perfect.map((p) => p.id) })
}
const brains = winners((s) => s.correct)
if (brains.length && brains.length < players.length) {
awards.push({ emoji: "🧠", label: "Le cerveau", ids: brains.map((p) => p.id) })
}
const decoy = winners((s) => s.decoy)
if (decoy.length) {
awards.push({
emoji: "🦹",
label: "Roi de la tromperie",
ids: decoy.map((p) => p.id),
})
}
const boulets = players.filter(
(p) => stat(p.id).answered > 0 && stat(p.id).correct === 0
)
if (boulets.length && total >= 3) {
awards.push({ emoji: "🪨", label: "Le boulet", ids: boulets.map((p) => p.id) })
}
if (awards.length === 0) {
return null
}
return (
<section className="flex flex-col gap-2">
<h3 className="text-muted-foreground text-sm font-medium">Récompenses</h3>
<div className="flex flex-wrap gap-2">
{awards.map((a) => (
<div
key={a.label}
className="bg-muted/40 flex flex-1 flex-col items-center gap-1 rounded-xl border px-3 py-2 text-center"
>
<span className="text-2xl">{a.emoji}</span>
<span className="text-[10px] font-medium uppercase">{a.label}</span>
<div className="flex flex-wrap items-center justify-center gap-1">
{a.ids.map((id) => (
<span key={id} className="flex items-center gap-1 text-xs">
<Avatar seed={nameOf(id)} className="size-5" />
{nameOf(id)}
</span>
))}
</div>
</div>
))}
</div>
</section>
)
}
function RoundsRecap({
recap,
nameOf,
@ -384,7 +515,11 @@ function RoundsRecap({
</li>
) : (
answers.map((a) => (
<li key={a.playerId} className="text-[10px]">
<li
key={a.playerId}
className="flex items-center gap-1 text-[10px]"
>
<Avatar seed={nameOf(a.playerId)} className="size-4" />
<span
className={
a.playerId === playerId
@ -393,7 +528,7 @@ function RoundsRecap({
}
>
{nameOf(a.playerId)}
{a.playerId === playerId && " (toi)"} :{" "}
{a.playerId === playerId && " (toi)"} :
</span>
<span
className={
@ -404,7 +539,6 @@ function RoundsRecap({
</span>
{pointsBy.get(a.playerId) ? (
<span className="text-muted-foreground">
{" "}
+{pointsBy.get(a.playerId)}
</span>
) : null}