feature/blindtest #6

Merged
ayoub merged 9 commits from feature/blindtest into dev 2026-06-10 14:25:59 +00:00
4 changed files with 29 additions and 37 deletions
Showing only changes of commit a2e3fae858 - Show all commits

View file

@ -127,26 +127,13 @@ export class GameEngine implements RoomGameController {
}
this.room.status = "in_round"
this.broadcastState()
// L'identité du DJ (= contributeur du titre) est privée : seul le DJ la
// reçoit, sinon "qui l'a ajouté" deviendrait trivial.
const base = {
this.io.to(this.room.code).emit("round:start", {
type: round.type,
djId: this.runtime.djId ?? undefined,
startsAt: startedAt,
endsAt,
payload: start.payload,
}
const djSocketId = this.runtime.djId
? this.room.players.get(this.runtime.djId)?.socketId
: null
if (djSocketId) {
this.io.to(djSocketId).emit("round:start", {
...base,
djId: this.runtime.djId ?? undefined,
})
this.io.to(this.room.code).except(djSocketId).emit("round:start", base)
} else {
this.io.to(this.room.code).emit("round:start", base)
}
// Attend la première condition de fin : timer écoulé OU tous votés.
await new Promise<void>((resolve) => {
@ -190,11 +177,10 @@ export class GameEngine implements RoomGameController {
}
}
/** Votants éligibles : joueurs connectés, hors DJ (= contributeur, ne vote pas). */
/** Votants éligibles : tous les joueurs connectés (le DJ neutre vote aussi). */
private eligibleVoters(): string[] {
const djId = this.runtime?.djId
return [...this.room.players.values()]
.filter((p) => p.connected && p.id !== djId)
.filter((p) => p.connected)
.map((p) => p.id)
}

View file

@ -44,16 +44,16 @@ describe("match", () => {
})
describe("BlindtestRound", () => {
test("start : le DJ est le contributeur du titre", () => {
test("start : le DJ est neutre (jamais le contributeur)", () => {
const rooms = new RoomManager()
const { room, player: a } = rooms.create("Alice", "sa")
rooms.join(room.code, "Bob", "sb")
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)
expect(start.djId).toBe(a.id) // Alice a ajouté le titre → elle est DJ
expect(start.djId).toBe(b.id) // Bob (pas Alice, la contributrice)
expect(start.payload).toEqual({ trackId: "t1", youtubeId: "abc12345678" })
})
@ -67,7 +67,7 @@ describe("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 }) // ignoré (contributrice)
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 })

View file

@ -26,6 +26,17 @@ 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,
@ -60,8 +71,8 @@ export class BlindtestRound implements GameRound {
start(room: ServerRoom): RoundStart {
const track = takeTrack(room)
// Le DJ EST le contributeur : il lance/pilote son propre titre.
const djId = track ? track.submittedBy : null
// 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
@ -69,9 +80,9 @@ export class BlindtestRound implements GameRound {
}
submitAnswer(ctx: RoundContext, playerId: string, answer: Answer): void {
const { track } = ctx.data as BlindtestRoundData
// Le contributeur (DJ) ne vote pas ; premier vote verrouillé pour les autres.
if (playerId === track.submittedBy || ctx.votes.has(playerId)) {
// 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)

View file

@ -102,7 +102,7 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) {
<DjControls ready={ready} api={api} mediaControl={mediaControl} />
)}
{!isDj && !showReveal && (
{!showReveal && (
<VoteForm
mode={snapshot.settings.blindtestMode}
snapshot={snapshot}
@ -115,7 +115,6 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) {
{showReveal && truth && (
<RevealCard
truth={truth}
isDj={isDj}
result={
(reveal?.perPlayerResult as BlindtestPerPlayerResult)[playerId ?? ""]
}
@ -151,7 +150,7 @@ function DjControls({
return (
<div className="flex flex-col gap-3">
<span className="text-muted-foreground text-center text-xs uppercase">
Tu es le DJ 🎧 c'est ton titre, fais-le deviner 🤫
Tu es le DJ 🎧 pilote la lecture (et vote comme les autres)
</span>
<div className="flex justify-center gap-2">
<Button
@ -307,11 +306,9 @@ function VoteForm({
function RevealCard({
truth,
isDj,
result,
}: {
truth: BlindtestRevealTruth
isDj: boolean
result?: BlindtestPerPlayerResult[string]
}) {
return (
@ -323,13 +320,11 @@ function RevealCard({
<Avatar seed={truth.submittedByName} className="size-6" />
<span className="font-medium">{truth.submittedByName}</span>
</p>
{result && (result.points > 0 || !isDj) && (
{result && (
<p
className={`text-sm font-medium ${result.points > 0 ? "text-green-500" : "text-red-500"}`}
>
{result.points > 0
? `+${result.points} points ${isDj ? "🤫" : "🎉"}`
: "Raté 💥"}
{result.points > 0 ? `+${result.points} points 🎉` : "Raté 💥"}
</p>
)}
</div>