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.room.status = "in_round"
this.broadcastState() 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, type: round.type,
djId: this.runtime.djId ?? undefined,
startsAt: startedAt, startsAt: startedAt,
endsAt, endsAt,
payload: start.payload, 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. // Attend la première condition de fin : timer écoulé OU tous votés.
await new Promise<void>((resolve) => { 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[] { private eligibleVoters(): string[] {
const djId = this.runtime?.djId
return [...this.room.players.values()] return [...this.room.players.values()]
.filter((p) => p.connected) .filter((p) => p.connected && p.id !== djId)
.map((p) => p.id) .map((p) => p.id)
} }

View file

@ -44,19 +44,37 @@ describe("match", () => {
}) })
describe("BlindtestRound", () => { 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 rooms = new RoomManager()
const { room, player: a } = rooms.create("Alice", "sa") 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)] room.blindtestTracks = [track(a.id)]
prepareBlindtestForRoom(room) prepareBlindtestForRoom(room)
const round = new BlindtestRound() const round = new BlindtestRound()
const start = round.start(room) 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" }) 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", () => { test("who_added : points si on devine le bon contributeur", () => {
const rooms = new RoomManager() const rooms = new RoomManager()
const { room, player: a } = rooms.create("Alice", "sa") 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 // Épreuve Blindtest : un titre = une manche. Le contributeur du titre est le DJ
// contributeur) pilote la lecture ; tout le monde vote à l'aveugle. // (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 { import type {
Answer, Answer,
@ -18,24 +19,15 @@ import { takeTrack } from "./pool"
const TITLE_POINTS = 60 const TITLE_POINTS = 60
const ARTIST_POINTS = 40 const ARTIST_POINTS = 40
const WHO_POINTS = 100 const WHO_POINTS = 100
/** Points gagnés par le contributeur pour chaque joueur qui le devine mal. */
const MISDIRECTION_POINTS = 50
interface BlindtestRoundData { interface BlindtestRoundData {
track: BlindtestTrack track: BlindtestTrack
} }
/** Tire un DJ neutre : un joueur connecté qui n'a pas soumis ce titre. */ /** Résultat d'un joueur VOTANT (≠ contributeur) selon le mode. */
function pickDj(room: ServerRoom, submittedBy: string): string | null { function voterResult(
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(
mode: BlindtestMode, mode: BlindtestMode,
answer: Answer | undefined, answer: Answer | undefined,
track: BlindtestTrack track: BlindtestTrack
@ -68,7 +60,8 @@ export class BlindtestRound implements GameRound {
start(room: ServerRoom): RoundStart { start(room: ServerRoom): RoundStart {
const track = takeTrack(room) 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 const payload: BlindtestRoundPayload | null = track
? { trackId: track.id, youtubeId: track.youtubeId } ? { trackId: track.id, youtubeId: track.youtubeId }
: null : null
@ -76,23 +69,50 @@ export class BlindtestRound implements GameRound {
} }
submitAnswer(ctx: RoundContext, playerId: string, answer: Answer): void { submitAnswer(ctx: RoundContext, playerId: string, answer: Answer): void {
// Premier vote verrouillé (idempotent). const { track } = ctx.data as BlindtestRoundData
if (ctx.votes.has(playerId)) { // Le contributeur (DJ) ne vote pas ; premier vote verrouillé pour les autres.
if (playerId === track.submittedBy || ctx.votes.has(playerId)) {
return return
} }
ctx.votes.set(playerId, answer) 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): { reveal(ctx: RoundContext): {
truth: BlindtestRevealTruth truth: BlindtestRevealTruth
perPlayerResult: BlindtestPerPlayerResult perPlayerResult: BlindtestPerPlayerResult
} { } {
const { track } = ctx.data as BlindtestRoundData 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) const submitter = ctx.room.players.get(track.submittedBy)
return { return {
truth: { truth: {
@ -102,22 +122,16 @@ export class BlindtestRound implements GameRound {
submittedBy: track.submittedBy, submittedBy: track.submittedBy,
submittedByName: submitter?.name ?? "?", submittedByName: submitter?.name ?? "?",
}, },
perPlayerResult, perPlayerResult: this.buildResults(ctx),
} }
} }
score(ctx: RoundContext): ScoreDelta[] { score(ctx: RoundContext): ScoreDelta[] {
const { track } = ctx.data as BlindtestRoundData const perPlayer = this.buildResults(ctx)
const mode = ctx.room.settings.blindtestMode
const deltas: ScoreDelta[] = [] const deltas: ScoreDelta[] = []
for (const [playerId, answer] of ctx.votes) { for (const [playerId, result] of Object.entries(perPlayer)) {
// Le contributeur ne marque pas sur son propre titre. if (result.points > 0) {
if (playerId === track.submittedBy) { deltas.push({ playerId, delta: result.points })
continue
}
const { points } = resultFor(mode, answer, track)
if (points > 0) {
deltas.push({ playerId, delta: points })
} }
} }
return deltas return deltas

View file

@ -128,6 +128,18 @@ export function registerRoomHandlers(
}) })
return 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 }) ack({ ok: true, data: null })
// Précharge le contenu (pool quiz depuis la DB), puis lance la boucle. // Précharge le contenu (pool quiz depuis la DB), puis lance la boucle.
// Fire-and-forget : la boucle de jeu broadcast son propre état. // Fire-and-forget : la boucle de jeu broadcast son propre état.

View file

@ -11,13 +11,19 @@ import type {
} from "@nerdware/shared" } from "@nerdware/shared"
import { Button } from "@workspace/ui/components/button" import { Button } from "@workspace/ui/components/button"
import { useRoomStore } from "@/store/room" import { useRoomStore } from "@/store/room"
import { useYoutube } from "@/lib/youtube" import { useYoutube, type YoutubeApi } from "@/lib/youtube"
import { Countdown } from "@/components/countdown" import { Countdown } from "@/components/countdown"
import { Avatar } from "@/components/avatar" import { Avatar } from "@/components/avatar"
const inputClass = 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" "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 }) { export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) {
const round = useRoomStore((s) => s.round) const round = useRoomStore((s) => s.round)
const reveal = useRoomStore((s) => s.reveal) const reveal = useRoomStore((s) => s.reveal)
@ -58,19 +64,6 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) {
} }
}, [showReveal, ready, api]) }, [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 ( return (
<div className="flex w-full max-w-md flex-col gap-5"> <div className="flex w-full max-w-md flex-col gap-5">
<header className="flex items-center justify-between"> <header className="flex items-center justify-between">
@ -86,9 +79,13 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) {
)} )}
</header> </header>
{/* Pochette : le lecteur YouTube est caché derrière (son seulement). */} {/* Cadre 16:9 : le lecteur YouTube est caché derrière le disque pendant la
<div className="relative mx-auto aspect-square w-48 overflow-hidden rounded-2xl"> manche (son seulement), puis révélé (bannière visible) au reveal. */}
<div ref={hostRef} className="absolute inset-0" /> <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 && ( {!showReveal && (
<div className="bg-card absolute inset-0 flex items-center justify-center"> <div className="bg-card absolute inset-0 flex items-center justify-center">
<motion.div <motion.div
@ -102,25 +99,10 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) {
</div> </div>
{isDj && !showReveal && ( {isDj && !showReveal && (
<div className="flex flex-col items-center gap-2"> <DjControls ready={ready} api={api} mediaControl={mediaControl} />
<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>
)} )}
{!showReveal && ( {!isDj && !showReveal && (
<VoteForm <VoteForm
mode={snapshot.settings.blindtestMode} mode={snapshot.settings.blindtestMode}
snapshot={snapshot} snapshot={snapshot}
@ -133,6 +115,7 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) {
{showReveal && truth && ( {showReveal && truth && (
<RevealCard <RevealCard
truth={truth} truth={truth}
isDj={isDj}
result={ result={
(reveal?.perPlayerResult as BlindtestPerPlayerResult)[playerId ?? ""] (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({ function VoteForm({
mode, mode,
snapshot, snapshot,
@ -238,9 +307,11 @@ function VoteForm({
function RevealCard({ function RevealCard({
truth, truth,
isDj,
result, result,
}: { }: {
truth: BlindtestRevealTruth truth: BlindtestRevealTruth
isDj: boolean
result?: BlindtestPerPlayerResult[string] result?: BlindtestPerPlayerResult[string]
}) { }) {
return ( return (
@ -252,11 +323,13 @@ function RevealCard({
<Avatar seed={truth.submittedByName} className="size-6" /> <Avatar seed={truth.submittedByName} className="size-6" />
<span className="font-medium">{truth.submittedByName}</span> <span className="font-medium">{truth.submittedByName}</span>
</p> </p>
{result && ( {result && (result.points > 0 || !isDj) && (
<p <p
className={`text-sm font-medium ${result.points > 0 ? "text-green-500" : "text-red-500"}`} 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> </p>
)} )}
</div> </div>

View file

@ -25,6 +25,15 @@ const MODE_LABELS: Record<BlindtestMode, string> = {
const inputClass = 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" "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. */ /** Construit la séquence de manches selon le type de partie. */
function buildRounds( function buildRounds(
gameType: GameType, gameType: GameType,
@ -37,21 +46,12 @@ function buildRounds(
if (gameType === "blindtest") { if (gameType === "blindtest") {
return Array.from({ length: totalTracks }, () => ({ type: "blindtest" })) return Array.from({ length: totalTracks }, () => ({ type: "blindtest" }))
} }
// Mixte : on alterne quiz / blindtest pour multiplier les changements de mode. // Mixte : ordre mélangé pour qu'on ne sache pas ce qui vient ensuite.
const rounds: RoundConfig[] = [] const rounds: RoundConfig[] = [
let q = quizCount ...Array.from({ length: quizCount }, () => ({ type: "quiz" as const })),
let b = totalTracks ...Array.from({ length: totalTracks }, () => ({ type: "blindtest" as const })),
while (q > 0 || b > 0) { ]
if (q > 0) { return shuffle(rounds)
rounds.push({ type: "quiz" })
q--
}
if (b > 0) {
rounds.push({ type: "blindtest" })
b--
}
}
return rounds
} }
export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
@ -71,8 +71,13 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
const showQuiz = gameType === "quiz" || gameType === "mixed" const showQuiz = gameType === "quiz" || gameType === "mixed"
const showBlindtest = gameType === "blindtest" || 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 rounds = buildRounds(gameType, showQuiz ? count : 0, totalTracks)
const canStart = rounds.length > 0 const canStart =
rounds.length > 0 && (!showBlindtest || blindtestAvailable)
async function start() { async function start() {
setError(null) setError(null)
@ -117,17 +122,29 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
</section> </section>
{isHost && ( {isHost && (
<div className="flex flex-col gap-1">
<div className="flex gap-2"> <div className="flex gap-2">
{GAME_TYPES.map((t) => ( {GAME_TYPES.map((t) => {
const needsThree = t.value !== "quiz" && !blindtestAvailable
return (
<Button <Button
key={t.value} key={t.value}
className="flex-1" className="flex-1"
variant={gameType === t.value ? "default" : "secondary"} variant={gameType === t.value ? "default" : "secondary"}
disabled={needsThree}
title={needsThree ? "Blindtest : 3 joueurs minimum" : undefined}
onClick={() => updateSettings({ gameType: t.value })} onClick={() => updateSettings({ gameType: t.value })}
> >
{t.label} {t.label}
</Button> </Button>
))} )
})}
</div>
{!blindtestAvailable && (
<p className="text-muted-foreground text-center text-xs">
Le blindtest se débloque à 3 joueurs.
</p>
)}
</div> </div>
)} )}

View file

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