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>
125 lines
3.7 KiB
TypeScript
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
|
|
}
|
|
}
|