nerdware/apps/server/src/game/modes/quiz/quiz-round.ts
AyoubBenziza 42a36fc442 feat: blindtest needs 2 real players + bot difficulty setting
- blindtest is only available with >=3 players AND >=2 humans (a bot can't be DJ):
  lobby gate + tooltips updated, and server game:start rejects otherwise
- new botDifficulty setting (easy/normal/hard) drives how often bots answer
  correctly (quiz + blindtest who-added / title-artist); host picks it in the
  lobby when at least one bot is present

Verified e2e: blindtest start rejected with 1 human + 2 bots (NEED_THREE).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 18:03:03 +02:00

196 lines
6.2 KiB
TypeScript

// É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<string, number>
}
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,
}
}
}