feature/quiz-mode #4

Merged
ayoub merged 10 commits from feature/quiz-mode into dev 2026-06-10 09:54:24 +00:00
17 changed files with 767 additions and 76 deletions
Showing only changes of commit 5cc9113b8c - Show all commits

View file

@ -0,0 +1,4 @@
// Point d'enregistrement de toutes les épreuves. Importé une fois au démarrage
// pour ses effets de bord (registerRound). Ajouter un mode = une ligne ici.
import "./quiz"

View file

@ -0,0 +1,7 @@
import { registerRound } from "../../registry"
import { QuizRound } from "./quiz-round"
// Brancher l'épreuve = un seul registerRound, aucune plomberie à toucher.
registerRound("quiz", () => new QuizRound())
export { QuizRound }

View file

@ -0,0 +1,110 @@
// Banque de questions en dur pour la V1 (mode quiz culture geek).
// Le seed Open Trivia DB + le back-office manuel arrivent aux étapes 5 et 7 ;
// d'ici là, ce petit jeu suffit à jouer une partie complète de bout en bout.
import type { QuizFormat } from "@nerdware/shared"
export interface QuizQuestion {
id: string
format: QuizFormat
prompt: string
choices: string[]
correctIndex: number
category: string
difficulty: number
}
const TRUE_FALSE = ["Vrai", "Faux"]
export const QUIZ_QUESTIONS: QuizQuestion[] = [
{
id: "q-zelda-princess",
format: "mcq",
prompt: "Dans la saga The Legend of Zelda, quel est le nom du héros ?",
choices: ["Zelda", "Link", "Ganon", "Navi"],
correctIndex: 1,
category: "Jeux vidéo",
difficulty: 1,
},
{
id: "q-mario-plumber",
format: "truefalse",
prompt: "Mario est plombier de profession.",
choices: TRUE_FALSE,
correctIndex: 0,
category: "Jeux vidéo",
difficulty: 1,
},
{
id: "q-onepiece-captain",
format: "mcq",
prompt: "Qui est le capitaine de l'équipage du Chapeau de paille dans One Piece ?",
choices: ["Zoro", "Sanji", "Luffy", "Usopp"],
correctIndex: 2,
category: "Manga",
difficulty: 1,
},
{
id: "q-pokemon-first",
format: "mcq",
prompt: "Quel Pokémon porte le numéro 001 dans le Pokédex national ?",
choices: ["Salamèche", "Carapuce", "Pikachu", "Bulbizarre"],
correctIndex: 3,
category: "Jeux vidéo",
difficulty: 2,
},
{
id: "q-naruto-village",
format: "mcq",
prompt: "De quel village ninja Naruto est-il originaire ?",
choices: ["Konoha", "Suna", "Kiri", "Iwa"],
correctIndex: 0,
category: "Manga",
difficulty: 2,
},
{
id: "q-minecraft-creeper",
format: "truefalse",
prompt: "Dans Minecraft, le Creeper explose au contact du joueur.",
choices: TRUE_FALSE,
correctIndex: 0,
category: "Jeux vidéo",
difficulty: 1,
},
{
id: "q-starwars-vador",
format: "mcq",
prompt: "Dans Star Wars, qui est le père de Luke Skywalker ?",
choices: ["Obi-Wan Kenobi", "Dark Vador", "Yoda", "Palpatine"],
correctIndex: 1,
category: "Pop culture",
difficulty: 1,
},
{
id: "q-dbz-kamehameha",
format: "mcq",
prompt: "Dans Dragon Ball, quelle attaque emblématique Sangoku utilise-t-il ?",
choices: ["Genkidama", "Kamehameha", "Final Flash", "Makankosappo"],
correctIndex: 1,
category: "Manga",
difficulty: 1,
},
{
id: "q-tetris-origin",
format: "truefalse",
prompt: "Le jeu Tetris a été créé par un développeur soviétique.",
choices: TRUE_FALSE,
correctIndex: 0,
category: "Jeux vidéo",
difficulty: 2,
},
{
id: "q-matrix-pill",
format: "mcq",
prompt: "Dans Matrix, quelle pilule Neo doit-il prendre pour découvrir la vérité ?",
choices: ["La bleue", "La rouge", "La verte", "La jaune"],
correctIndex: 1,
category: "Pop culture",
difficulty: 1,
},
]

View file

@ -0,0 +1,59 @@
import { describe, expect, test } from "bun:test"
import { RoomManager } from "../../../rooms"
import type { RoundContext } from "../../round"
import { QuizRound } from "./quiz-round"
import { QUIZ_QUESTIONS } from "./questions"
const question = QUIZ_QUESTIONS[0] // "Link" → correctIndex 1
function makeCtx(): { ctx: RoundContext; p1: string; p2: string } {
const rooms = new RoomManager()
const { room, player: a } = rooms.create("Alice", "sa")
const { player: b } = rooms.join(room.code, "Bob", "sb")
const ctx: RoundContext = {
room,
djId: null,
votes: new Map(),
startedAt: 0,
endsAt: 10_000,
data: { question, votedAt: new Map() },
}
return { ctx, p1: a.id, p2: b.id }
}
describe("QuizRound", () => {
test("verrouille le premier vote (idempotent)", () => {
const round = new QuizRound(() => 1000)
const { ctx, p1 } = makeCtx()
round.submitAnswer(ctx, p1, { choiceIndex: 1 })
round.submitAnswer(ctx, p1, { choiceIndex: 0 }) // ignoré
expect(ctx.votes.get(p1)).toEqual({ choiceIndex: 1 })
})
test("ignore un index hors bornes", () => {
const round = new QuizRound(() => 1000)
const { ctx, p1 } = makeCtx()
round.submitAnswer(ctx, p1, { choiceIndex: 99 })
expect(ctx.votes.has(p1)).toBe(false)
})
test("reveal expose la vérité + le résultat par joueur", () => {
const round = new QuizRound(() => 1000)
const { ctx, p1, p2 } = makeCtx()
round.submitAnswer(ctx, p1, { choiceIndex: 1 }) // juste
round.submitAnswer(ctx, p2, { choiceIndex: 0 }) // faux
const { truth, perPlayerResult } = round.reveal(ctx)
expect(truth.correctIndex).toBe(question.correctIndex)
expect(perPlayerResult[p1]).toEqual({ choiceIndex: 1, correct: true })
expect(perPlayerResult[p2]).toEqual({ choiceIndex: 0, correct: false })
})
test("score = base + bonus rapidité pour les bonnes réponses uniquement", () => {
const round = new QuizRound(() => 1000) // vote à t=1000 sur 10000 → reste 90%
const { ctx, p1, p2 } = makeCtx()
round.submitAnswer(ctx, p1, { choiceIndex: 1 }) // juste, rapide
round.submitAnswer(ctx, p2, { choiceIndex: 0 }) // faux
const deltas = round.score(ctx)
expect(deltas).toEqual([{ playerId: p1, delta: 190 }]) // 100 + round(100 * 0.9)
})
})

View file

@ -0,0 +1,115 @@
// Épreuve Quiz : une question = une manche. Pas de DJ, pas d'audio.
// Implémente le contrat GameRound ; le moteur fait tout le reste.
import type {
Answer,
QuizPerPlayerResult,
QuizQuestionPayload,
QuizRevealTruth,
ScoreDelta,
} from "@nerdware/shared"
import type { GameRound, RoundContext, RoundStart } from "../../round"
import type { ServerRoom } from "../../../rooms"
import { QUIZ_QUESTIONS, type QuizQuestion } from "./questions"
const BASE_POINTS = 100
const SPEED_BONUS_MAX = 100
/** Données privées de la manche (jamais diffusées). */
interface QuizRoundData {
question: QuizQuestion
/** Premier instant de vote par joueur, pour le bonus rapidité. */
votedAt: Map<string, number>
}
// Anti-répétition à l'échelle d'une partie : questions déjà jouées par room.
const playedByRoom = new WeakMap<ServerRoom, Set<string>>()
function pickQuestion(room: ServerRoom): QuizQuestion {
let played = playedByRoom.get(room)
if (!played) {
played = new Set()
playedByRoom.set(room, played)
}
let pool = QUIZ_QUESTIONS.filter((q) => !played.has(q.id))
if (pool.length === 0) {
// Banque épuisée sur cette partie : on autorise à nouveau les répétitions.
played.clear()
pool = QUIZ_QUESTIONS
}
const question = pool[Math.floor(Math.random() * pool.length)]
played.add(question.id)
return question
}
function isQuizAnswer(answer: Answer): answer is { choiceIndex: number } {
return (
typeof (answer as { choiceIndex?: unknown }).choiceIndex === "number"
)
}
export class QuizRound implements GameRound {
readonly type = "quiz" as const
constructor(private readonly now: () => number = Date.now) {}
start(room: ServerRoom): RoundStart {
const question = pickQuestion(room)
const payload: QuizQuestionPayload = {
format: question.format,
prompt: question.prompt,
choices: question.choices,
category: question.category,
difficulty: question.difficulty,
}
const data: QuizRoundData = { question, votedAt: new Map() }
return { djId: null, payload, data }
}
submitAnswer(ctx: RoundContext, playerId: string, answer: Answer): void {
// Premier vote verrouillé (idempotent) : on ignore les votes suivants.
if (ctx.votes.has(playerId)) {
return
}
if (!isQuizAnswer(answer)) {
return
}
const { question, votedAt } = ctx.data as QuizRoundData
if (answer.choiceIndex < 0 || answer.choiceIndex >= question.choices.length) {
return
}
ctx.votes.set(playerId, { choiceIndex: answer.choiceIndex })
votedAt.set(playerId, this.now())
}
reveal(ctx: RoundContext): { truth: QuizRevealTruth; perPlayerResult: QuizPerPlayerResult } {
const { question } = ctx.data as QuizRoundData
const perPlayerResult: QuizPerPlayerResult = {}
for (const player of ctx.room.players.values()) {
const vote = ctx.votes.get(player.id) as { choiceIndex: number } | undefined
const choiceIndex = vote ? vote.choiceIndex : null
perPlayerResult[player.id] = {
choiceIndex,
correct: choiceIndex === question.correctIndex,
}
}
return { truth: { correctIndex: question.correctIndex }, perPlayerResult }
}
score(ctx: RoundContext): ScoreDelta[] {
const { question, votedAt } = ctx.data as QuizRoundData
const total = ctx.endsAt - ctx.startedAt
const deltas: ScoreDelta[] = []
for (const [playerId, vote] of ctx.votes) {
if ((vote as { choiceIndex: number }).choiceIndex !== question.correctIndex) {
continue
}
// Bonus rapidité : proportionnel au temps restant au moment du vote.
const at = votedAt.get(playerId) ?? ctx.endsAt
const remaining = Math.max(0, Math.min(1, (ctx.endsAt - at) / total))
const bonus = Math.round(SPEED_BONUS_MAX * remaining)
deltas.push({ playerId, delta: BASE_POINTS + bonus })
}
return deltas
}
}

View file

@ -2,6 +2,7 @@ import Fastify from "fastify"
import cors from "@fastify/cors" import cors from "@fastify/cors"
import { env, isDev } from "./env" import { env, isDev } from "./env"
import { createSocketServer } from "./socket" import { createSocketServer } from "./socket"
import "./game/modes" // enregistre les épreuves (registerRound)
const app = Fastify({ const app = Fastify({
logger: isDev logger: isDev

View file

@ -1,13 +1,13 @@
import { Route, Switch } from "wouter" import { Route, Switch } from "wouter"
import { HomePage } from "@/pages/home" import { HomePage } from "@/pages/home"
import { LobbyPage } from "@/pages/lobby" import { RoomPage } from "@/pages/room"
export function App() { export function App() {
return ( return (
<Switch> <Switch>
<Route path="/" component={HomePage} /> <Route path="/" component={HomePage} />
<Route path="/room/:code"> <Route path="/room/:code">
{(params) => <LobbyPage code={params.code.toUpperCase()} />} {(params) => <RoomPage code={params.code.toUpperCase()} />}
</Route> </Route>
<Route> <Route>
<div className="flex min-h-svh items-center justify-center p-6"> <div className="flex min-h-svh items-center justify-center p-6">

View file

@ -0,0 +1,19 @@
import { useEffect, useState } from "react"
/** Secondes restantes jusqu'à `endsAt` (timestamp serveur). */
function secondsLeft(endsAt: number): number {
return Math.max(0, Math.ceil((endsAt - Date.now()) / 1000))
}
export function Countdown({ endsAt }: { endsAt: number }) {
const [left, setLeft] = useState(() => secondsLeft(endsAt))
useEffect(() => {
const id = setInterval(() => setLeft(secondsLeft(endsAt)), 250)
return () => clearInterval(id)
}, [endsAt])
return (
<span className="font-heading text-2xl font-bold tabular-nums">{left}</span>
)
}

View file

@ -0,0 +1,37 @@
import { Link } from "wouter"
import type { PlayerScore, RoomSnapshot } from "@nerdware/shared"
import { Button } from "@workspace/ui/components/button"
import { useRoomStore } from "@/store/room"
import { Scoreboard } from "@/components/scoreboard"
export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
const playerId = useRoomStore((s) => s.playerId)
const finalScores = useRoomStore((s) => s.finalScores)
const reset = useRoomStore((s) => s.reset)
const scores: PlayerScore[] = finalScores ?? snapshot.scores
const winner = [...scores].sort((a, b) => b.score - a.score)[0]
const winnerName =
snapshot.players.find((p) => p.id === winner?.playerId)?.name ?? "?"
return (
<div className="flex w-full max-w-md flex-col gap-6 text-center">
<header>
<p className="text-muted-foreground text-xs uppercase">
Partie terminée
</p>
<h2 className="font-heading text-2xl font-bold">
🏆 {winnerName} l'emporte !
</h2>
</header>
<Scoreboard scores={scores} snapshot={snapshot} playerId={playerId} />
<Link href="/">
<Button variant="secondary" className="w-full" onClick={reset}>
Retour à l'accueil
</Button>
</Link>
</div>
)
}

View file

@ -0,0 +1,87 @@
import { useState } from "react"
import type { RoomSnapshot } from "@nerdware/shared"
import { Button } from "@workspace/ui/components/button"
import { useRoomStore } from "@/store/room"
const QUESTION_OPTIONS = [3, 5, 10]
export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
const playerId = useRoomStore((s) => s.playerId)
const startGame = useRoomStore((s) => s.startGame)
const isHost = snapshot.hostId === playerId
const [count, setCount] = useState(5)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleStart() {
setError(null)
setBusy(true)
try {
await startGame(count)
} catch (err) {
setError((err as { message?: string }).message ?? "Erreur")
} finally {
setBusy(false)
}
}
return (
<div className="flex w-full max-w-md flex-col gap-6">
<section className="flex flex-col gap-2">
<h2 className="text-muted-foreground text-sm font-medium">
Joueurs ({snapshot.players.length})
</h2>
<ul className="flex flex-col gap-1">
{snapshot.players.map((p) => (
<li
key={p.id}
className="bg-muted/40 flex items-center justify-between rounded-md px-3 py-2 text-sm"
>
<span className={p.connected ? "" : "opacity-40"}>
{p.name}
{p.id === playerId && (
<span className="text-muted-foreground"> (toi)</span>
)}
</span>
<span className="text-muted-foreground text-xs">
{p.id === snapshot.hostId ? "Hôte" : ""}
{!p.connected && " · hors ligne"}
</span>
</li>
))}
</ul>
</section>
{isHost ? (
<section className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Questions de quiz</span>
<div className="flex gap-1">
{QUESTION_OPTIONS.map((n) => (
<Button
key={n}
size="sm"
variant={count === n ? "default" : "secondary"}
onClick={() => setCount(n)}
>
{n}
</Button>
))}
</div>
</div>
<Button disabled={busy} onClick={handleStart}>
{busy ? "Lancement…" : "Lancer la partie"}
</Button>
{error && (
<p className="text-destructive text-center text-sm">{error}</p>
)}
</section>
) : (
<p className="text-muted-foreground text-center text-xs">
En attente du lancement de la partie par l'hôte
</p>
)}
</div>
)
}

View file

@ -0,0 +1,113 @@
import type {
QuizPerPlayerResult,
QuizQuestionPayload,
QuizRevealTruth,
RoomSnapshot,
} from "@nerdware/shared"
import { useRoomStore } from "@/store/room"
import { Countdown } from "@/components/countdown"
import { Scoreboard } from "@/components/scoreboard"
export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
const round = useRoomStore((s) => s.round)
const reveal = useRoomStore((s) => s.reveal)
const voteProgress = useRoomStore((s) => s.voteProgress)
const myChoiceIndex = useRoomStore((s) => s.myChoiceIndex)
const playerId = useRoomStore((s) => s.playerId)
const vote = useRoomStore((s) => s.vote)
if (!round) {
return (
<p className="text-muted-foreground text-sm">Préparation de la manche</p>
)
}
const question = round.payload as QuizQuestionPayload
const truth = reveal ? (reveal.truth as QuizRevealTruth) : null
const showReveal = truth !== null
const myResult = reveal
? (reveal.perPlayerResult as QuizPerPlayerResult)[playerId ?? ""]
: undefined
function choiceClass(index: number): string {
const base =
"w-full rounded-lg border px-4 py-3 text-left text-sm transition-colors"
if (showReveal) {
if (index === truth?.correctIndex) {
return `${base} border-green-500 bg-green-500/15 font-medium`
}
if (index === myChoiceIndex) {
return `${base} border-red-500 bg-red-500/10`
}
return `${base} border-input opacity-60`
}
if (index === myChoiceIndex) {
return `${base} border-primary bg-primary/10 font-medium`
}
return `${base} border-input hover:bg-muted/60`
}
return (
<div className="flex w-full max-w-md flex-col gap-5">
<header className="flex items-center justify-between">
<span className="text-muted-foreground text-xs uppercase">
Question {snapshot.currentRound + 1} / {snapshot.totalRounds}
{question.category ? ` · ${question.category}` : ""}
</span>
{!showReveal && (
<Countdown key={round.endsAt} endsAt={round.endsAt} />
)}
</header>
<h2 className="font-heading text-xl font-semibold text-balance">
{question.prompt}
</h2>
<div className="flex flex-col gap-2">
{question.choices.map((choice, index) => (
<button
key={index}
className={choiceClass(index)}
disabled={showReveal || myChoiceIndex !== null}
onClick={() => vote(index)}
>
{choice}
</button>
))}
</div>
{!showReveal &&
(myChoiceIndex !== null ? (
<p className="text-muted-foreground text-center text-xs">
Réponse envoyée en attente des autres
{voteProgress ? ` (${voteProgress.count}/${voteProgress.total})` : ""}
</p>
) : (
voteProgress && (
<p className="text-muted-foreground text-center text-xs">
{voteProgress.count}/{voteProgress.total} ont répondu
</p>
)
))}
{showReveal && (
<div className="flex flex-col gap-3">
<p
className={`text-center text-sm font-medium ${myResult?.correct ? "text-green-500" : "text-red-500"}`}
>
{myResult?.correct
? "Bonne réponse ! 🎉"
: myResult?.choiceIndex == null
? "Pas de réponse 😴"
: "Raté 💥"}
</p>
<Scoreboard
scores={snapshot.scores}
snapshot={snapshot}
playerId={playerId}
/>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,37 @@
import type { PlayerScore, RoomSnapshot } from "@nerdware/shared"
interface ScoreboardProps {
scores: PlayerScore[]
snapshot: RoomSnapshot
playerId: string | null
}
export function Scoreboard({ scores, snapshot, playerId }: ScoreboardProps) {
const nameOf = (id: string) =>
snapshot.players.find((p) => p.id === id)?.name ?? "?"
const ranked = [...scores].sort((a, b) => b.score - a.score)
return (
<ol className="flex flex-col gap-1">
{ranked.map((s, i) => (
<li
key={s.playerId}
className="bg-muted/40 flex items-center justify-between rounded-md px-3 py-2 text-sm"
>
<span className="flex items-center gap-2">
<span className="text-muted-foreground w-5 text-right tabular-nums">
{i + 1}
</span>
<span>
{nameOf(s.playerId)}
{s.playerId === playerId && (
<span className="text-muted-foreground"> (toi)</span>
)}
</span>
</span>
<span className="font-heading font-bold tabular-nums">{s.score}</span>
</li>
))}
</ol>
)
}

View file

@ -1,71 +0,0 @@
import { Link } from "wouter"
import { Button } from "@workspace/ui/components/button"
import { useRoomStore } from "@/store/room"
export function LobbyPage({ code }: { code: string }) {
const snapshot = useRoomStore((s) => s.snapshot)
const playerId = useRoomStore((s) => s.playerId)
const connected = useRoomStore((s) => s.connected)
// Pas de snapshot pour ce code (accès direct / refresh) : on renvoie à l'accueil.
if (!snapshot || snapshot.code !== code) {
return (
<div className="flex min-h-svh flex-col items-center justify-center gap-4 p-6 text-center">
<p className="text-muted-foreground text-sm">
Room introuvable ou session perdue.
</p>
<Link href="/">
<Button variant="secondary">Retour à l'accueil</Button>
</Link>
</div>
)
}
return (
<div className="flex min-h-svh items-center justify-center p-6">
<div className="flex w-full max-w-md flex-col gap-6">
<header className="flex items-center justify-between">
<div>
<p className="text-muted-foreground text-xs uppercase">Code room</p>
<p className="font-heading text-3xl font-bold tracking-widest">
{snapshot.code}
</p>
</div>
<span
className={`size-2.5 rounded-full ${connected ? "bg-green-500" : "bg-red-500"}`}
title={connected ? "Connecté" : "Déconnecté"}
/>
</header>
<section className="flex flex-col gap-2">
<h2 className="text-muted-foreground text-sm font-medium">
Joueurs ({snapshot.players.length})
</h2>
<ul className="flex flex-col gap-1">
{snapshot.players.map((p) => (
<li
key={p.id}
className="bg-muted/40 flex items-center justify-between rounded-md px-3 py-2 text-sm"
>
<span className={p.connected ? "" : "opacity-40"}>
{p.name}
{p.id === playerId && (
<span className="text-muted-foreground"> (toi)</span>
)}
</span>
<span className="text-muted-foreground text-xs">
{p.id === snapshot.hostId ? "Hôte" : ""}
{!p.connected && " · hors ligne"}
</span>
</li>
))}
</ul>
</section>
<p className="text-muted-foreground text-center text-xs">
En attente du lancement de la partie par l'hôte
</p>
</div>
</div>
)
}

View file

@ -0,0 +1,50 @@
import { Link } from "wouter"
import { Button } from "@workspace/ui/components/button"
import { useRoomStore } from "@/store/room"
import { LobbyView } from "@/components/lobby-view"
import { QuizView } from "@/components/quiz-view"
import { GameEndView } from "@/components/game-end-view"
export function RoomPage({ code }: { code: string }) {
const snapshot = useRoomStore((s) => s.snapshot)
const connected = useRoomStore((s) => s.connected)
// Pas de snapshot pour ce code (accès direct / refresh) : retour à l'accueil.
if (!snapshot || snapshot.code !== code) {
return (
<div className="flex min-h-svh flex-col items-center justify-center gap-4 p-6 text-center">
<p className="text-muted-foreground text-sm">
Room introuvable ou session perdue.
</p>
<Link href="/">
<Button variant="secondary">Retour à l'accueil</Button>
</Link>
</div>
)
}
return (
<div className="flex min-h-svh items-start justify-center p-6 pt-12">
<div className="flex w-full max-w-md flex-col gap-6">
<header className="flex items-center justify-between">
<div>
<p className="text-muted-foreground text-xs uppercase">Code room</p>
<p className="font-heading text-2xl font-bold tracking-widest">
{snapshot.code}
</p>
</div>
<span
className={`size-2.5 rounded-full ${connected ? "bg-green-500" : "bg-red-500"}`}
title={connected ? "Connecté" : "Déconnecté"}
/>
</header>
{snapshot.status === "lobby" && <LobbyView snapshot={snapshot} />}
{(snapshot.status === "in_round" ||
snapshot.status === "reveal" ||
snapshot.status === "scores") && <QuizView snapshot={snapshot} />}
{snapshot.status === "ended" && <GameEndView snapshot={snapshot} />}
</div>
</div>
)
}

View file

@ -1,30 +1,62 @@
import { create } from "zustand" import { create } from "zustand"
import type { import type {
ErrorPayload, ErrorPayload,
PlayerScore,
RoomCreatedPayload, RoomCreatedPayload,
RoomSnapshot, RoomSnapshot,
RoundStartPayload,
VoteAckPayload,
} from "@nerdware/shared" } from "@nerdware/shared"
import { socket } from "@/lib/socket" import { socket } from "@/lib/socket"
/** Manche en cours côté client (depuis round:start). */
export interface ActiveRound {
type: RoundStartPayload["type"]
djId?: string
endsAt: number
payload: unknown
}
/** Reveal reçu (round:reveal), payloads typés par mode au moment de l'affichage. */
export interface RoundReveal {
truth: unknown
perPlayerResult: unknown
}
interface RoomState { interface RoomState {
connected: boolean connected: boolean
/** Identité locale, persistée pour reconnaître "moi" dans le snapshot. */ /** Identité locale, pour reconnaître "moi" dans le snapshot. */
playerId: string | null playerId: string | null
roomCode: string | null roomCode: string | null
snapshot: RoomSnapshot | null snapshot: RoomSnapshot | null
lastError: ErrorPayload | null lastError: ErrorPayload | null
// État de jeu (poussé par le moteur serveur).
round: ActiveRound | null
voteProgress: VoteAckPayload | null
reveal: RoundReveal | null
myChoiceIndex: number | null
finalScores: PlayerScore[] | null
createRoom: (playerName: string) => Promise<RoomCreatedPayload> createRoom: (playerName: string) => Promise<RoomCreatedPayload>
joinRoom: (roomCode: string, playerName: string) => Promise<RoomCreatedPayload> joinRoom: (roomCode: string, playerName: string) => Promise<RoomCreatedPayload>
startGame: (questionCount: number) => Promise<void>
vote: (choiceIndex: number) => void
clearError: () => void clearError: () => void
reset: () => void reset: () => void
} }
export const useRoomStore = create<RoomState>((set) => ({ export const useRoomStore = create<RoomState>((set, get) => ({
connected: socket.connected, connected: socket.connected,
playerId: null, playerId: null,
roomCode: null, roomCode: null,
snapshot: null, snapshot: null,
lastError: null, lastError: null,
round: null,
voteProgress: null,
reveal: null,
myChoiceIndex: null,
finalScores: null,
createRoom: (playerName) => createRoom: (playerName) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
@ -52,8 +84,47 @@ export const useRoomStore = create<RoomState>((set) => ({
}) })
}), }),
startGame: (questionCount) =>
new Promise((resolve, reject) => {
const rounds = Array.from({ length: questionCount }, () => ({
type: "quiz" as const,
}))
// On pousse la séquence de manches, puis on lance.
socket.emit("lobby:updateSettings", {
blindtestMode: get().snapshot?.settings.blindtestMode ?? "title_artist",
roundDuration: 20,
rounds,
})
socket.emit("game:start", (res) => {
if (res.ok) {
resolve()
} else {
set({ lastError: res.error })
reject(res.error)
}
})
}),
vote: (choiceIndex) => {
if (get().myChoiceIndex !== null) {
return // vote déjà émis pour cette manche
}
set({ myChoiceIndex: choiceIndex })
socket.emit("round:vote", { answer: { choiceIndex } })
},
clearError: () => set({ lastError: null }), clearError: () => set({ lastError: null }),
reset: () => set({ roomCode: null, snapshot: null, lastError: null }), reset: () =>
set({
roomCode: null,
snapshot: null,
lastError: null,
round: null,
voteProgress: null,
reveal: null,
myChoiceIndex: null,
finalScores: null,
}),
})) }))
// Listeners socket → on pousse l'état serveur dans le store (source de vérité). // Listeners socket → on pousse l'état serveur dans le store (source de vérité).
@ -61,3 +132,24 @@ socket.on("connect", () => useRoomStore.setState({ connected: true }))
socket.on("disconnect", () => useRoomStore.setState({ connected: false })) socket.on("disconnect", () => useRoomStore.setState({ connected: false }))
socket.on("room:state", (snapshot) => useRoomStore.setState({ snapshot })) socket.on("room:state", (snapshot) => useRoomStore.setState({ snapshot }))
socket.on("error", (error) => useRoomStore.setState({ lastError: error })) socket.on("error", (error) => useRoomStore.setState({ lastError: error }))
socket.on("round:start", (payload) =>
useRoomStore.setState({
round: {
type: payload.type,
djId: payload.djId,
endsAt: payload.endsAt,
payload: payload.payload,
},
voteProgress: null,
reveal: null,
myChoiceIndex: null,
})
)
socket.on("round:voteAck", (voteProgress) =>
useRoomStore.setState({ voteProgress })
)
socket.on("round:reveal", (reveal) => useRoomStore.setState({ reveal }))
socket.on("game:end", ({ finalScores }) =>
useRoomStore.setState({ finalScores })
)

View file

@ -1,2 +1,3 @@
export * from "./domain" export * from "./domain"
export * from "./events" export * from "./events"
export * from "./quiz"

View file

@ -0,0 +1,30 @@
// Types concrets de l'épreuve Quiz, partagés client/serveur.
// Le payload générique du moteur est `unknown` ; le client le narrow via type === "quiz".
// V1 : formats mcq + truefalse (free / image_reveal arrivent avec la DB).
import type { QuizFormat } from "./domain"
/** Payload diffusé au lancement d'une manche quiz (round:start), SANS la réponse. */
export interface QuizQuestionPayload {
format: QuizFormat
prompt: string
/** Choix proposés (truefalse = ["Vrai", "Faux"]). */
choices: string[]
category?: string
/** 1 (facile) .. 3 (difficile). */
difficulty?: number
}
/** Vérité révélée à tous (round:reveal → truth). */
export interface QuizRevealTruth {
correctIndex: number
}
/** Résultat d'un joueur sur la manche. */
export interface QuizPlayerResult {
choiceIndex: number | null
correct: boolean
}
/** round:reveal → perPlayerResult : playerId → résultat. */
export type QuizPerPlayerResult = Record<string, QuizPlayerResult>