Return to lobby (replay without recreating the room): - lobby:return (host): reset to lobby, scores to 0, keep players + tracks - client: end screen has "Rejouer" (host) + "Quitter"; store clears transient game state whenever the room goes back to lobby Contributor has nothing to input: - round:start carries a private `mine: true` to the contributor only (via RoundStart.secretPlayerId targeted emit); they're excluded from eligible voters. Client shows a "fais-les se tromper" card instead of the vote form. Editable track submission: - submitTrack ack returns trackId + youtubeId; new blindtest:removeTrack - lobby shows submitted tracks as cards (YouTube thumbnail + title) with delete Verified e2e (3 players): add/remove, private mine flag, return-to-lobby reset. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
155 lines
4.8 KiB
TypeScript
155 lines
4.8 KiB
TypeScript
// Épreuve Blindtest : un titre = une manche. Le contributeur du titre est le DJ
|
|
// (il pilote la lecture, son identité reste cachée aux autres). Il ne marque pas
|
|
// de points, SAUF en "qui l'a ajouté" : il gagne quand un joueur le devine mal.
|
|
|
|
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
|
|
/** Points gagnés par le contributeur pour chaque joueur qui le devine mal. */
|
|
const MISDIRECTION_POINTS = 50
|
|
|
|
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
|
|
}
|
|
|
|
/** Résultat d'un joueur VOTANT (≠ contributeur) selon le mode. */
|
|
function voterResult(
|
|
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)
|
|
// DJ neutre (jamais le contributeur) : il pilote la lecture et vote à l'aveugle.
|
|
const djId = track ? pickDj(room, track.submittedBy) : null
|
|
const payload: BlindtestRoundPayload | null = track
|
|
? { trackId: track.id, youtubeId: track.youtubeId }
|
|
: null
|
|
return {
|
|
djId,
|
|
payload,
|
|
secretPlayerId: track?.submittedBy,
|
|
data: track ? { track } : null,
|
|
}
|
|
}
|
|
|
|
submitAnswer(ctx: RoundContext, playerId: string, answer: Answer): void {
|
|
// Premier vote verrouillé (idempotent). Le contributeur peut voter mais son
|
|
// vote ne lui rapporte rien (voir buildResults) ; ça évite de bloquer la fin.
|
|
if (ctx.votes.has(playerId)) {
|
|
return
|
|
}
|
|
ctx.votes.set(playerId, answer)
|
|
}
|
|
|
|
/** Résultat par joueur (votants + bonus de tromperie du contributeur). */
|
|
private buildResults(ctx: RoundContext): BlindtestPerPlayerResult {
|
|
const { track } = ctx.data as BlindtestRoundData
|
|
const mode = ctx.room.settings.blindtestMode
|
|
const perPlayer: BlindtestPerPlayerResult = {}
|
|
|
|
let wrongGuessers = 0
|
|
for (const player of ctx.room.players.values()) {
|
|
if (player.id === track.submittedBy) {
|
|
continue
|
|
}
|
|
const result = voterResult(mode, ctx.votes.get(player.id), track)
|
|
perPlayer[player.id] = result
|
|
if (
|
|
(mode === "who_added" || mode === "mixed") &&
|
|
result.guessedCorrect === false
|
|
) {
|
|
wrongGuessers++
|
|
}
|
|
}
|
|
|
|
// Contributeur : pas de points hors who_added/mixed ; sinon tromperie.
|
|
const misdirection =
|
|
mode === "who_added" || mode === "mixed"
|
|
? wrongGuessers * MISDIRECTION_POINTS
|
|
: 0
|
|
perPlayer[track.submittedBy] = { points: misdirection }
|
|
|
|
return perPlayer
|
|
}
|
|
|
|
reveal(ctx: RoundContext): {
|
|
truth: BlindtestRevealTruth
|
|
perPlayerResult: BlindtestPerPlayerResult
|
|
} {
|
|
const { track } = ctx.data as BlindtestRoundData
|
|
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: this.buildResults(ctx),
|
|
}
|
|
}
|
|
|
|
score(ctx: RoundContext): ScoreDelta[] {
|
|
const perPlayer = this.buildResults(ctx)
|
|
const deltas: ScoreDelta[] = []
|
|
for (const [playerId, result] of Object.entries(perPlayer)) {
|
|
if (result.points > 0) {
|
|
deltas.push({ playerId, delta: result.points })
|
|
}
|
|
}
|
|
return deltas
|
|
}
|
|
}
|