- 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>
196 lines
6.2 KiB
TypeScript
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,
|
|
}
|
|
}
|
|
}
|