nerdware/apps/server/src/game/modes/blindtest/blindtest-round.ts
AyoubBenziza 6251146c19 feat: replay from lobby, contributor mislead card, editable track cards
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>
2026-06-10 15:13:54 +02:00

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
}
}