// Épreuve Quiz : une question = une manche. Pas de DJ, pas d'audio. // Formats : mcq, truefalse (choix), free (saisie libre, matching tolérant). import type { Answer, QuizPerPlayerResult, QuizQuestionPayload, QuizRevealTruth, ScoreDelta, } from "@nerdware/shared" import { hasDb } from "../../../db" import { botCorrectChance } from "../../bot" import { markQuestionPlayed } from "../../../db/quiz-repo" import { fuzzyMatch } from "../../match" import type { GameRound, RoundContext, RoundRecapInfo, RoundStart, } from "../../round" import type { ServerRoom } from "../../../rooms" import type { QuizQuestion } from "./questions" import { takeQuestion } from "./pool" 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 } function asChoice(answer: Answer): number | null { const v = (answer as { choiceIndex?: unknown }).choiceIndex return typeof v === "number" ? v : null } function asText(answer: Answer): string | null { const v = (answer as { text?: unknown }).text return typeof v === "string" ? v : null } /** Formats à réponse libre (saisie texte + matching tolérant). */ function isTextFormat(question: QuizQuestion): boolean { return question.format === "free" || question.format === "image_reveal" } /** Réponse correcte ? Selon le format de la question. */ function isCorrect(question: QuizQuestion, answer: Answer | undefined): boolean { if (!answer) { return false } if (isTextFormat(question)) { const text = asText(answer) if (!text) { return false } return (question.acceptedAnswers ?? []).some((a) => fuzzyMatch(text, a)) } return asChoice(answer) === question.correctIndex } export class QuizRound implements GameRound { readonly type = "quiz" as const constructor(private readonly now: () => number = Date.now) {} start(room: ServerRoom): RoundStart { const { question, fromDb } = takeQuestion(room) // Anti-répétition cross-session : on enregistre la question jouée (DB only). if (fromDb && hasDb) { void markQuestionPlayed(room.code, question.id) } const payload: QuizQuestionPayload = { format: question.format, prompt: question.prompt, category: question.category, difficulty: question.difficulty, } if (question.format === "mcq" || question.format === "truefalse") { payload.choices = question.choices } if (question.format === "image_reveal") { payload.imageUrl = question.imageUrl } 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 } const { question, votedAt } = ctx.data as QuizRoundData if (isTextFormat(question)) { const text = asText(answer) if (text === null || text.trim().length === 0) { return } ctx.votes.set(playerId, { text }) } else { const choiceIndex = asChoice(answer) const count = question.choices?.length ?? 0 if (choiceIndex === null || choiceIndex < 0 || choiceIndex >= count) { return } ctx.votes.set(playerId, { 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) perPlayerResult[player.id] = { choiceIndex: vote ? asChoice(vote) : null, correct: isCorrect(question, vote), } } const truth: QuizRevealTruth = isTextFormat(question) ? { answer: question.acceptedAnswers?.[0] ?? "" } : { correctIndex: question.correctIndex } return { truth, 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 (!isCorrect(question, vote)) { 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 } botAnswer(ctx: RoundContext): Answer { const { question } = ctx.data as QuizRoundData // Le bot répond juste selon le niveau choisi, sinon une réponse plausible. const rightish = Math.random() < botCorrectChance(ctx.room.settings.botDifficulty) if (isTextFormat(question)) { return { text: rightish ? (question.acceptedAnswers?.[0] ?? "?") : "euh…", } } const count = question.choices?.length ?? 1 if (rightish && typeof question.correctIndex === "number") { return { choiceIndex: question.correctIndex } } return { choiceIndex: Math.floor(Math.random() * count) } } 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, } } }