nerdware/apps/server/src/game/modes/blindtest/blindtest-round.ts
AyoubBenziza e3315d3b35 feat: mixed games by default, free-text quiz, mode-aware transitions
Mixed mode (default):
- gameType is now mixed | quiz | blindtest, default "mixed"; the lobby
  interleaves quiz + blindtest rounds so modes alternate
- lobby reworked: 3-way game-type switch, quiz count and/or blindtest config
  + track submission shown per selected type

Free-text quiz questions:
- quiz supports the `free` format (e.g. maths) alongside mcq/truefalse:
  payload without choices, {text} vote, tolerant matching on acceptedAnswers
- shared QuizQuestionPayload.choices optional; reveal truth carries answer
- match util moved to game/match.ts (shared by quiz + blindtest)
- repo/seed include free + acceptedAnswers; in-code bank gains free questions
- client quiz view renders a text input for free questions

Mode-aware transitions:
- RoundTransition themed per mode (colors/label/emoji); announces the mode
  on change (quiz <-> blindtest), lighter teaser within the same mode
- custom media buckets per mode: assets/transitions/{quiz,blindtest}/ + shared
- store tracks roundModeChanged

Verified e2e: mixed game alternates quiz/blindtest to completion; free-text
scoring covered by tests (22 pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:41:29 +02:00

125 lines
3.7 KiB
TypeScript

// Épreuve Blindtest : un titre = une manche. Un DJ neutre (jamais le
// contributeur) pilote la lecture ; tout le monde vote à l'aveugle.
import type {
Answer,
BlindtestMode,
BlindtestPerPlayerResult,
BlindtestPlayerResult,
BlindtestRevealTruth,
BlindtestRoundPayload,
ScoreDelta,
} from "@nerdware/shared"
import type { GameRound, RoundContext, RoundStart } from "../../round"
import type { BlindtestTrack, ServerRoom } from "../../../rooms"
import { fuzzyMatch } from "../../match"
import { takeTrack } from "./pool"
const TITLE_POINTS = 60
const ARTIST_POINTS = 40
const WHO_POINTS = 100
interface BlindtestRoundData {
track: BlindtestTrack
}
/** Tire un DJ neutre : un joueur connecté qui n'a pas soumis ce titre. */
function pickDj(room: ServerRoom, submittedBy: string): string | null {
const eligible = [...room.players.values()].filter(
(p) => p.connected && p.id !== submittedBy
)
if (eligible.length === 0) {
return null
}
return eligible[Math.floor(Math.random() * eligible.length)].id
}
/** Calcule le résultat d'un vote selon le mode. */
function resultFor(
mode: BlindtestMode,
answer: Answer | undefined,
track: BlindtestTrack
): BlindtestPlayerResult {
const a = (answer ?? {}) as {
title?: string
artist?: string
guessedPlayerId?: string
}
let points = 0
const result: BlindtestPlayerResult = { points: 0 }
if (mode === "title_artist" || mode === "mixed") {
result.titleCorrect = a.title ? fuzzyMatch(a.title, track.title) : false
result.artistCorrect = a.artist ? fuzzyMatch(a.artist, track.artist) : false
if (result.titleCorrect) points += TITLE_POINTS
if (result.artistCorrect) points += ARTIST_POINTS
}
if (mode === "who_added" || mode === "mixed") {
result.guessedCorrect = a.guessedPlayerId === track.submittedBy
if (result.guessedCorrect) points += WHO_POINTS
}
result.points = points
return result
}
export class BlindtestRound implements GameRound {
readonly type = "blindtest" as const
start(room: ServerRoom): RoundStart {
const track = takeTrack(room)
const djId = track ? pickDj(room, track.submittedBy) : null
const payload: BlindtestRoundPayload | null = track
? { trackId: track.id, youtubeId: track.youtubeId }
: null
return { djId, payload, data: track ? { track } : null }
}
submitAnswer(ctx: RoundContext, playerId: string, answer: Answer): void {
// Premier vote verrouillé (idempotent).
if (ctx.votes.has(playerId)) {
return
}
ctx.votes.set(playerId, answer)
}
reveal(ctx: RoundContext): {
truth: BlindtestRevealTruth
perPlayerResult: BlindtestPerPlayerResult
} {
const { track } = ctx.data as BlindtestRoundData
const mode = ctx.room.settings.blindtestMode
const perPlayerResult: BlindtestPerPlayerResult = {}
for (const player of ctx.room.players.values()) {
perPlayerResult[player.id] = resultFor(mode, ctx.votes.get(player.id), track)
}
const submitter = ctx.room.players.get(track.submittedBy)
return {
truth: {
title: track.title,
artist: track.artist,
youtubeId: track.youtubeId,
submittedBy: track.submittedBy,
submittedByName: submitter?.name ?? "?",
},
perPlayerResult,
}
}
score(ctx: RoundContext): ScoreDelta[] {
const { track } = ctx.data as BlindtestRoundData
const mode = ctx.room.settings.blindtestMode
const deltas: ScoreDelta[] = []
for (const [playerId, answer] of ctx.votes) {
// Le contributeur ne marque pas sur son propre titre.
if (playerId === track.submittedBy) {
continue
}
const { points } = resultFor(mode, answer, track)
if (points > 0) {
deltas.push({ playerId, delta: points })
}
}
return deltas
}
}