nerdware/apps/server/src/game/modes/blindtest/blindtest-round.test.ts
AyoubBenziza 7a41bbf8c1 feat: DJ can be contributor, volume, theme switch, clock sync, 90s blindtest
Playtest feedback round 2:
- blindtest DJ can now be anyone incl. the track's contributor (pickDj only
  excludes bots) — no more "immunity" for whoever isn't picked
- blindtest rounds last 90s (longer listen + typing)
- per-player volume slider (persisted), YT player setVolume + origin/enablejsapi
- theme switcher (light/dark/system) on home + room header
- clock sync: clients estimate the server-clock offset (time:sync) so the
  countdown is identical across devices (was off by each device's clock skew);
  also used for blindtest media sync

Note on duplicates: anti-repeat is per room code (quiz_played) — it works when
you replay in the SAME room, but a new room starts fresh. Verified e2e: two
chained games in one room share 0 questions.

Verified: typecheck 4/4, lint 4/4, 24 server tests, web build; e2e dedup (0
overlap) and clock/DJ behavior.

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

142 lines
5.3 KiB
TypeScript

import { describe, expect, test } from "bun:test"
import { RoomManager, type BlindtestTrack } from "../../../rooms"
import type { RoundContext } from "../../round"
import { BlindtestRound } from "./blindtest-round"
import { prepareBlindtestForRoom } from "./pool"
import { fuzzyMatch, normalize } from "../../match"
function track(submittedBy: string): BlindtestTrack {
return {
id: "t1",
youtubeId: "abc12345678",
url: "https://youtu.be/abc12345678",
title: "The Legend of Zelda Main Theme",
artist: "Koji Kondo",
submittedBy,
}
}
function ctxFor(
room: ReturnType<RoomManager["create"]>["room"],
t: BlindtestTrack,
mode: "title_artist" | "who_added" | "mixed"
): RoundContext {
room.settings.blindtestMode = mode
return {
room,
djId: null,
votes: new Map(),
startedAt: 0,
endsAt: 10_000,
data: { track: t },
}
}
describe("match", () => {
test("normalize retire accents/ponctuation/casse", () => {
expect(normalize("Pokémon: Rouge & Bleu!")).toBe("pokemon rouge bleu")
})
test("fuzzyMatch tolère fautes et réponses partielles", () => {
expect(fuzzyMatch("zelda", "The Legend of Zelda")).toBe(true)
expect(fuzzyMatch("koji kondo", "Koji Kondo")).toBe(true)
expect(fuzzyMatch("mario", "The Legend of Zelda")).toBe(false)
})
})
describe("BlindtestRound", () => {
test("start : le DJ est un humain connecté (le contributeur peut tomber DJ)", () => {
const rooms = new RoomManager()
const { room, player: a } = rooms.create("Alice", "sa")
const { player: b } = rooms.join(room.code, "Bob", "sb")
room.blindtestTracks = [track(a.id)]
prepareBlindtestForRoom(room)
const round = new BlindtestRound()
const start = round.start(room)
// N'importe quel humain (Alice incluse), jamais un bot ni null.
expect([a.id, b.id]).toContain(start.djId!)
expect(start.payload).toEqual({ trackId: "t1", youtubeId: "abc12345678" })
})
test("start : un bot n'est jamais DJ", () => {
const rooms = new RoomManager()
const { room, player: a } = rooms.create("Alice", "sa")
rooms.addBot(room)
rooms.addBot(room)
room.blindtestTracks = [track(a.id)]
prepareBlindtestForRoom(room)
const round = new BlindtestRound()
// Alice est la seule humaine → DJ forcément Alice (les bots sont exclus).
expect(round.start(room).djId).toBe(a.id)
})
test("who_added : le contributeur gagne par tromperie (mauvaises devinettes)", () => {
const rooms = new RoomManager()
const { room, player: a } = rooms.create("Alice", "sa")
const { player: b } = rooms.join(room.code, "Bob", "sb")
const { player: c } = rooms.join(room.code, "Carol", "sc")
const t = track(a.id) // Alice = contributrice/DJ
const round = new BlindtestRound()
const ctx = ctxFor(room, t, "who_added")
round.submitAnswer(ctx, b.id, { guessedPlayerId: c.id }) // faux
round.submitAnswer(ctx, c.id, { guessedPlayerId: a.id }) // juste
round.submitAnswer(ctx, a.id, { guessedPlayerId: b.id }) // contributrice : non scorée
const deltas = round.score(ctx)
// Carol devine juste → 100 ; Alice trompe Bob (1 mauvaise devinette) → 50
expect(deltas).toContainEqual({ playerId: c.id, delta: 100 })
expect(deltas).toContainEqual({ playerId: a.id, delta: 50 })
expect(deltas.find((d) => d.playerId === b.id)).toBeUndefined()
})
test("who_added : points si on devine le bon contributeur", () => {
const rooms = new RoomManager()
const { room, player: a } = rooms.create("Alice", "sa")
const { player: b } = rooms.join(room.code, "Bob", "sb")
const t = track(a.id)
const round = new BlindtestRound()
const ctx = ctxFor(room, t, "who_added")
round.submitAnswer(ctx, b.id, { guessedPlayerId: a.id }) // juste
const deltas = round.score(ctx)
expect(deltas).toEqual([{ playerId: b.id, delta: 100 }])
})
test("title_artist : 60 titre + 40 artiste, fuzzy", () => {
const rooms = new RoomManager()
const { room, player: a } = rooms.create("Alice", "sa")
const { player: b } = rooms.join(room.code, "Bob", "sb")
const t = track(a.id)
const round = new BlindtestRound()
const ctx = ctxFor(room, t, "title_artist")
round.submitAnswer(ctx, b.id, { title: "zelda", artist: "koji kondo" })
const { perPlayerResult } = round.reveal(ctx)
expect(perPlayerResult[b.id]).toMatchObject({
titleCorrect: true,
artistCorrect: true,
points: 100,
})
expect(round.score(ctx)).toEqual([{ playerId: b.id, delta: 100 }])
})
test("le contributeur ne marque pas sur son propre titre", () => {
const rooms = new RoomManager()
const { room, player: a } = rooms.create("Alice", "sa")
const t = track(a.id)
const round = new BlindtestRound()
const ctx = ctxFor(room, t, "who_added")
round.submitAnswer(ctx, a.id, { guessedPlayerId: a.id })
expect(round.score(ctx)).toEqual([])
})
test("reveal expose le titre, l'artiste et le contributeur", () => {
const rooms = new RoomManager()
const { room, player: a } = rooms.create("Alice", "sa")
const t = track(a.id)
const round = new BlindtestRound()
const ctx = ctxFor(room, t, "title_artist")
const { truth } = round.reveal(ctx)
expect(truth.submittedBy).toBe(a.id)
expect(truth.submittedByName).toBe("Alice")
expect(truth.title).toBe("The Legend of Zelda Main Theme")
})
})