feature/round-recap #11
9 changed files with 403 additions and 7 deletions
|
|
@ -46,6 +46,7 @@ const dummyRound: GameRound = {
|
||||||
}
|
}
|
||||||
return deltas
|
return deltas
|
||||||
},
|
},
|
||||||
|
recap: () => ({ label: "2+2 ?", answer: "4", answers: [] }),
|
||||||
}
|
}
|
||||||
|
|
||||||
function setup(roundDurationSec: number): {
|
function setup(roundDurationSec: number): {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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,46 @@ export class BlindtestRound implements GameRound {
|
||||||
}
|
}
|
||||||
return deltas
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,34 @@ 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] ?? "")
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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). */
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 scores: PlayerScore[] = finalScores ?? snapshot.scores
|
||||||
const ranked = [...scores].sort((a, b) => b.score - a.score)
|
const ranked = [...scores].sort((a, b) => b.score - a.score)
|
||||||
const nameOf = (id: string) =>
|
const nameOf = (id: string) =>
|
||||||
|
|
@ -217,11 +250,23 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
</ol>
|
</ol>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{gameRecap && gameRecap.length > 0 && (
|
||||||
|
<FunStats recap={gameRecap} snapshot={snapshot} />
|
||||||
|
)}
|
||||||
|
|
||||||
{gameTracks && gameTracks.length > 0 && (
|
{gameTracks && gameTracks.length > 0 && (
|
||||||
<TracksRecap tracks={gameTracks} />
|
<TracksRecap tracks={gameTracks} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{gameRecap && gameRecap.length > 0 && (
|
||||||
|
<RoundsRecap recap={gameRecap} nameOf={nameOf} playerId={playerId} />
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<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 ? (
|
{isHost ? (
|
||||||
<Button className="w-full" onClick={returnToLobby}>
|
<Button className="w-full" onClick={returnToLobby}>
|
||||||
Rejouer
|
Rejouer
|
||||||
|
|
@ -304,3 +349,208 @@ function TracksRecap({ tracks }: { tracks: BlindtestTrackInfo[] }) {
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
playerId,
|
||||||
|
}: {
|
||||||
|
recap: RoundRecap[]
|
||||||
|
nameOf: (id: string) => string
|
||||||
|
playerId: string | null
|
||||||
|
}) {
|
||||||
|
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) => {
|
||||||
|
const pointsBy = new Map(r.scorers.map((s) => [s.playerId, s.points]))
|
||||||
|
const found = r.answers.filter((a) => a.correct).length
|
||||||
|
// Tri : plus de points d'abord, puis bonnes réponses.
|
||||||
|
const answers = [...r.answers].sort(
|
||||||
|
(a, b) =>
|
||||||
|
(pointsBy.get(b.playerId) ?? 0) - (pointsBy.get(a.playerId) ?? 0) ||
|
||||||
|
Number(b.correct) - Number(a.correct)
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<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")}
|
||||||
|
{" · "}
|
||||||
|
<span className="text-green-500">✓ {found}</span>
|
||||||
|
</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>
|
||||||
|
<ul className="mt-1 flex flex-col gap-0.5">
|
||||||
|
{answers.length === 0 ? (
|
||||||
|
<li className="text-muted-foreground text-[10px]">
|
||||||
|
Aucune réponse
|
||||||
|
</li>
|
||||||
|
) : (
|
||||||
|
answers.map((a) => (
|
||||||
|
<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
|
||||||
|
? "font-semibold"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{nameOf(a.playerId)}
|
||||||
|
{a.playerId === playerId && " (toi)"} :
|
||||||
|
</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>
|
||||||
|
</details>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
})
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,39 @@ 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é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). */
|
||||||
|
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[]
|
||||||
|
/** Réponse de chaque joueur ayant répondu. */
|
||||||
|
answers: RoundPlayerAnswer[]
|
||||||
|
}
|
||||||
|
|
||||||
// --- Snapshot diffusé au client (room:state) -----------------------------
|
// --- Snapshot diffusé au client (room:state) -----------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue