release/0.1.0 #18

Merged
ayoub merged 68 commits from release/0.1.0 into main 2026-06-11 18:36:42 +00:00
7 changed files with 255 additions and 105 deletions
Showing only changes of commit a9eb6098f2 - Show all commits

View file

@ -127,13 +127,26 @@ export class GameEngine implements RoomGameController {
}
this.room.status = "in_round"
this.broadcastState()
this.io.to(this.room.code).emit("round:start", {
// 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 = {
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) => {
@ -177,10 +190,11 @@ export class GameEngine implements RoomGameController {
}
}
/** Votants éligibles : tous les joueurs connectés (le DJ inclus). */
/** Votants éligibles : joueurs connectés, hors DJ (= contributeur, ne vote pas). */
private eligibleVoters(): string[] {
const djId = this.runtime?.djId
return [...this.room.players.values()]
.filter((p) => p.connected)
.filter((p) => p.connected && p.id !== djId)
.map((p) => p.id)
}

View file

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

View file

@ -1,5 +1,6 @@
// Épreuve Blindtest : un titre = une manche. Un DJ neutre (jamais le
// contributeur) pilote la lecture ; tout le monde vote à l'aveugle.
// É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,
@ -18,24 +19,15 @@ 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
}
/** Calcule le résultat d'un vote selon le mode. */
function resultFor(
/** Résultat d'un joueur VOTANT (≠ contributeur) selon le mode. */
function voterResult(
mode: BlindtestMode,
answer: Answer | undefined,
track: BlindtestTrack
@ -68,7 +60,8 @@ export class BlindtestRound implements GameRound {
start(room: ServerRoom): RoundStart {
const track = takeTrack(room)
const djId = track ? pickDj(room, track.submittedBy) : null
// Le DJ EST le contributeur : il lance/pilote son propre titre.
const djId = track ? track.submittedBy : null
const payload: BlindtestRoundPayload | null = track
? { trackId: track.id, youtubeId: track.youtubeId }
: null
@ -76,23 +69,50 @@ export class BlindtestRound implements GameRound {
}
submitAnswer(ctx: RoundContext, playerId: string, answer: Answer): void {
// Premier vote verrouillé (idempotent).
if (ctx.votes.has(playerId)) {
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)) {
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 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: {
@ -102,22 +122,16 @@ export class BlindtestRound implements GameRound {
submittedBy: track.submittedBy,
submittedByName: submitter?.name ?? "?",
},
perPlayerResult,
perPlayerResult: this.buildResults(ctx),
}
}
score(ctx: RoundContext): ScoreDelta[] {
const { track } = ctx.data as BlindtestRoundData
const mode = ctx.room.settings.blindtestMode
const perPlayer = this.buildResults(ctx)
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 })
for (const [playerId, result] of Object.entries(perPlayer)) {
if (result.points > 0) {
deltas.push({ playerId, delta: result.points })
}
}
return deltas

View file

@ -128,6 +128,18 @@ export function registerRoomHandlers(
})
return
}
const hasBlindtest = room.settings.rounds.some((r) => r.type === "blindtest")
const connected = [...room.players.values()].filter((p) => p.connected).length
if (hasBlindtest && connected < 3) {
ack({
ok: false,
error: {
code: "NEED_THREE",
message: "Le blindtest nécessite au moins 3 joueurs.",
},
})
return
}
ack({ ok: true, data: null })
// Précharge le contenu (pool quiz depuis la DB), puis lance la boucle.
// Fire-and-forget : la boucle de jeu broadcast son propre état.

View file

@ -11,13 +11,19 @@ import type {
} from "@nerdware/shared"
import { Button } from "@workspace/ui/components/button"
import { useRoomStore } from "@/store/room"
import { useYoutube } from "@/lib/youtube"
import { useYoutube, type YoutubeApi } from "@/lib/youtube"
import { Countdown } from "@/components/countdown"
import { Avatar } from "@/components/avatar"
const inputClass =
"border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
function fmt(s: number): string {
const m = Math.floor(s / 60)
const sec = Math.floor(s % 60)
return `${m}:${sec.toString().padStart(2, "0")}`
}
export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) {
const round = useRoomStore((s) => s.round)
const reveal = useRoomStore((s) => s.reveal)
@ -58,19 +64,6 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) {
}
}, [showReveal, ready, api])
function djPlay() {
api.play()
mediaControl("play", api.time())
}
function djPause() {
api.pause()
mediaControl("pause", api.time())
}
function djRestart() {
api.seek(0)
mediaControl("seek", 0)
}
return (
<div className="flex w-full max-w-md flex-col gap-5">
<header className="flex items-center justify-between">
@ -86,9 +79,13 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) {
)}
</header>
{/* Pochette : le lecteur YouTube est caché derrière (son seulement). */}
<div className="relative mx-auto aspect-square w-48 overflow-hidden rounded-2xl">
<div ref={hostRef} className="absolute inset-0" />
{/* Cadre 16:9 : le lecteur YouTube est caché derrière le disque pendant la
manche (son seulement), puis révélé (bannière visible) au reveal. */}
<div className="bg-card relative aspect-video w-full overflow-hidden rounded-2xl">
<div
ref={hostRef}
className="absolute inset-0 [&>iframe]:absolute [&>iframe]:inset-0 [&>iframe]:size-full"
/>
{!showReveal && (
<div className="bg-card absolute inset-0 flex items-center justify-center">
<motion.div
@ -102,25 +99,10 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) {
</div>
{isDj && !showReveal && (
<div className="flex flex-col items-center gap-2">
<span className="text-muted-foreground text-xs uppercase">
Tu es le DJ 🎧
</span>
<div className="flex gap-2">
<Button size="sm" disabled={!ready} onClick={djPlay}>
<Play /> Play
</Button>
<Button size="sm" variant="secondary" disabled={!ready} onClick={djPause}>
<Pause /> Pause
</Button>
<Button size="sm" variant="secondary" disabled={!ready} onClick={djRestart}>
<Rewind /> Début
</Button>
</div>
</div>
<DjControls ready={ready} api={api} mediaControl={mediaControl} />
)}
{!showReveal && (
{!isDj && !showReveal && (
<VoteForm
mode={snapshot.settings.blindtestMode}
snapshot={snapshot}
@ -133,6 +115,7 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) {
{showReveal && truth && (
<RevealCard
truth={truth}
isDj={isDj}
result={
(reveal?.perPlayerResult as BlindtestPerPlayerResult)[playerId ?? ""]
}
@ -142,6 +125,92 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) {
)
}
function DjControls({
ready,
api,
mediaControl,
}: {
ready: boolean
api: YoutubeApi
mediaControl: (action: "play" | "pause" | "seek", positionSec: number) => void
}) {
const [pos, setPos] = useState(0)
const [dur, setDur] = useState(0)
useEffect(() => {
if (!ready) {
return
}
const id = setInterval(() => {
setPos(api.time())
setDur(api.duration())
}, 400)
return () => clearInterval(id)
}, [ready, api])
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 🤫
</span>
<div className="flex justify-center gap-2">
<Button
size="sm"
disabled={!ready}
onClick={() => {
api.play()
mediaControl("play", api.time())
}}
>
<Play /> Play
</Button>
<Button
size="sm"
variant="secondary"
disabled={!ready}
onClick={() => {
api.pause()
mediaControl("pause", api.time())
}}
>
<Pause /> Pause
</Button>
<Button
size="sm"
variant="secondary"
disabled={!ready}
onClick={() => {
api.seek(0)
setPos(0)
mediaControl("seek", 0)
}}
>
<Rewind /> Début
</Button>
</div>
<div className="flex items-center gap-2 text-xs tabular-nums">
<span className="text-muted-foreground w-9 text-right">{fmt(pos)}</span>
<input
type="range"
min={0}
max={Math.max(dur, 1)}
step="any"
value={Math.min(pos, dur || 0)}
disabled={!ready}
onChange={(e) => {
const v = Number(e.target.value)
setPos(v)
api.seek(v)
mediaControl("seek", v)
}}
className="accent-primary flex-1"
/>
<span className="text-muted-foreground w-9">{fmt(dur)}</span>
</div>
</div>
)
}
function VoteForm({
mode,
snapshot,
@ -238,9 +307,11 @@ function VoteForm({
function RevealCard({
truth,
isDj,
result,
}: {
truth: BlindtestRevealTruth
isDj: boolean
result?: BlindtestPerPlayerResult[string]
}) {
return (
@ -252,11 +323,13 @@ function RevealCard({
<Avatar seed={truth.submittedByName} className="size-6" />
<span className="font-medium">{truth.submittedByName}</span>
</p>
{result && (
{result && (result.points > 0 || !isDj) && (
<p
className={`text-sm font-medium ${result.points > 0 ? "text-green-500" : "text-red-500"}`}
>
{result.points > 0 ? `+${result.points} points 🎉` : "Raté 💥"}
{result.points > 0
? `+${result.points} points ${isDj ? "🤫" : "🎉"}`
: "Raté 💥"}
</p>
)}
</div>

View file

@ -25,6 +25,15 @@ const MODE_LABELS: Record<BlindtestMode, string> = {
const inputClass =
"border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
function shuffle<T>(items: T[]): T[] {
const arr = [...items]
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[arr[i], arr[j]] = [arr[j], arr[i]]
}
return arr
}
/** Construit la séquence de manches selon le type de partie. */
function buildRounds(
gameType: GameType,
@ -37,21 +46,12 @@ function buildRounds(
if (gameType === "blindtest") {
return Array.from({ length: totalTracks }, () => ({ type: "blindtest" }))
}
// Mixte : on alterne quiz / blindtest pour multiplier les changements de mode.
const rounds: RoundConfig[] = []
let q = quizCount
let b = totalTracks
while (q > 0 || b > 0) {
if (q > 0) {
rounds.push({ type: "quiz" })
q--
}
if (b > 0) {
rounds.push({ type: "blindtest" })
b--
}
}
return rounds
// Mixte : ordre mélangé pour qu'on ne sache pas ce qui vient ensuite.
const rounds: RoundConfig[] = [
...Array.from({ length: quizCount }, () => ({ type: "quiz" as const })),
...Array.from({ length: totalTracks }, () => ({ type: "blindtest" as const })),
]
return shuffle(rounds)
}
export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
@ -71,8 +71,13 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
const showQuiz = gameType === "quiz" || gameType === "mixed"
const showBlindtest = gameType === "blindtest" || gameType === "mixed"
// Le blindtest exige au moins 3 joueurs connectés (DJ + 2 devineurs).
const connectedCount = snapshot.players.filter((p) => p.connected).length
const blindtestAvailable = connectedCount >= 3
const rounds = buildRounds(gameType, showQuiz ? count : 0, totalTracks)
const canStart = rounds.length > 0
const canStart =
rounds.length > 0 && (!showBlindtest || blindtestAvailable)
async function start() {
setError(null)
@ -117,17 +122,29 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
</section>
{isHost && (
<div className="flex flex-col gap-1">
<div className="flex gap-2">
{GAME_TYPES.map((t) => (
{GAME_TYPES.map((t) => {
const needsThree = t.value !== "quiz" && !blindtestAvailable
return (
<Button
key={t.value}
className="flex-1"
variant={gameType === t.value ? "default" : "secondary"}
disabled={needsThree}
title={needsThree ? "Blindtest : 3 joueurs minimum" : undefined}
onClick={() => updateSettings({ gameType: t.value })}
>
{t.label}
</Button>
))}
)
})}
</div>
{!blindtestAvailable && (
<p className="text-muted-foreground text-center text-xs">
Le blindtest se débloque à 3 joueurs.
</p>
)}
</div>
)}

View file

@ -28,6 +28,7 @@ export interface YoutubeApi {
pause: () => void
seek: (seconds: number) => void
time: () => number
duration: () => number
}
/**
@ -89,6 +90,7 @@ export function useYoutube(youtubeId: string) {
pause: () => playerRef.current?.pauseVideo(),
seek: (seconds) => playerRef.current?.seekTo(seconds, true),
time: () => playerRef.current?.getCurrentTime() ?? 0,
duration: () => playerRef.current?.getDuration() ?? 0,
}),
[]
)