feature/blindtest #6
7 changed files with 255 additions and 105 deletions
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 gap-2">
|
<div className="flex flex-col gap-1">
|
||||||
{GAME_TYPES.map((t) => (
|
<div className="flex gap-2">
|
||||||
<Button
|
{GAME_TYPES.map((t) => {
|
||||||
key={t.value}
|
const needsThree = t.value !== "quiz" && !blindtestAvailable
|
||||||
className="flex-1"
|
return (
|
||||||
variant={gameType === t.value ? "default" : "secondary"}
|
<Button
|
||||||
onClick={() => updateSettings({ gameType: t.value })}
|
key={t.value}
|
||||||
>
|
className="flex-1"
|
||||||
{t.label}
|
variant={gameType === t.value ? "default" : "secondary"}
|
||||||
</Button>
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}),
|
}),
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue