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
6 changed files with 164 additions and 103 deletions
Showing only changes of commit 62b536cc2b - Show all commits

View file

@ -11,9 +11,10 @@ import type { QuizQuestion } from "../game/modes/quiz/questions"
*/ */
export async function loadQuizPool( export async function loadQuizPool(
roomCode: string, roomCode: string,
limit: number limit: number,
formats: string[]
): Promise<QuizQuestion[]> { ): Promise<QuizQuestion[]> {
if (!db) { if (!db || formats.length === 0) {
return [] return []
} }
const alreadyPlayed = db const alreadyPlayed = db
@ -37,7 +38,7 @@ export async function loadQuizPool(
.leftJoin(quizCategory, eq(quizQuestion.categoryId, quizCategory.id)) .leftJoin(quizCategory, eq(quizQuestion.categoryId, quizCategory.id))
.where( .where(
and( and(
inArray(quizQuestion.format, ["mcq", "truefalse", "free", "image_reveal"]), inArray(quizQuestion.format, formats),
notInArray(quizQuestion.id, alreadyPlayed) notInArray(quizQuestion.id, alreadyPlayed)
) )
) )

View file

@ -1,6 +1,8 @@
// File d'attente de questions par room. Préchargée au lancement de la partie // File d'attente de questions par room. Préchargée au lancement de la partie
// depuis la DB (si dispo), sinon depuis la banque en dur. Le QuizRound y pioche. // depuis la DB (si dispo), sinon depuis la banque en dur. Le QuizRound y pioche.
// Les formats servis dépendent du type de partie (quiz / images / mixte).
import type { GameType, QuizFormat } from "@nerdware/shared"
import { hasDb } from "../../../db" import { hasDb } from "../../../db"
import { loadQuizPool } from "../../../db/quiz-repo" import { loadQuizPool } from "../../../db/quiz-repo"
import type { ServerRoom } from "../../../rooms" import type { ServerRoom } from "../../../rooms"
@ -10,10 +12,23 @@ interface RoomPool {
queue: QuizQuestion[] queue: QuizQuestion[]
/** true si les questions viennent de la DB (→ marquer jouées). */ /** true si les questions viennent de la DB (→ marquer jouées). */
fromDb: boolean fromDb: boolean
formats: QuizFormat[]
} }
const poolByRoom = new WeakMap<ServerRoom, RoomPool>() const poolByRoom = new WeakMap<ServerRoom, RoomPool>()
/** Formats de quiz autorisés selon le type de partie. */
function quizFormatsFor(gameType: GameType): QuizFormat[] {
if (gameType === "image") {
return ["image_reveal"]
}
if (gameType === "quiz") {
return ["mcq", "truefalse", "free"]
}
// mixte : tout (le blindtest n'a pas de manche quiz)
return ["mcq", "truefalse", "free", "image_reveal"]
}
function shuffle<T>(items: T[]): T[] { function shuffle<T>(items: T[]): T[] {
const arr = [...items] const arr = [...items]
for (let i = arr.length - 1; i > 0; i--) { for (let i = arr.length - 1; i > 0; i--) {
@ -25,13 +40,14 @@ function shuffle<T>(items: T[]): T[] {
/** Précharge les questions de la partie. À appeler avant de lancer le moteur. */ /** Précharge les questions de la partie. À appeler avant de lancer le moteur. */
export async function prepareQuizForRoom(room: ServerRoom): Promise<void> { export async function prepareQuizForRoom(room: ServerRoom): Promise<void> {
const formats = quizFormatsFor(room.settings.gameType)
const needed = room.settings.rounds.filter((r) => r.type === "quiz").length const needed = room.settings.rounds.filter((r) => r.type === "quiz").length
let queue: QuizQuestion[] = [] let queue: QuizQuestion[] = []
let fromDb = false let fromDb = false
if (hasDb) { if (hasDb) {
try { try {
queue = await loadQuizPool(room.code, Math.max(needed, 1)) queue = await loadQuizPool(room.code, Math.max(needed, 1), formats)
fromDb = queue.length > 0 fromDb = queue.length > 0
} catch (err) { } catch (err) {
console.error("[quiz] chargement DB échoué, fallback banque en dur", err) console.error("[quiz] chargement DB échoué, fallback banque en dur", err)
@ -39,14 +55,14 @@ export async function prepareQuizForRoom(room: ServerRoom): Promise<void> {
} }
if (queue.length === 0) { if (queue.length === 0) {
queue = shuffle(QUIZ_QUESTIONS) queue = shuffle(QUIZ_QUESTIONS.filter((q) => formats.includes(q.format)))
fromDb = false fromDb = false
} }
poolByRoom.set(room, { queue, fromDb }) poolByRoom.set(room, { queue, fromDb, formats })
} }
/** Pioche la prochaine question. Fallback aléatoire si la file est vide. */ /** Pioche la prochaine question. Fallback aléatoire (formats autorisés) si vide. */
export function takeQuestion(room: ServerRoom): { export function takeQuestion(room: ServerRoom): {
question: QuizQuestion question: QuizQuestion
fromDb: boolean fromDb: boolean
@ -55,7 +71,11 @@ export function takeQuestion(room: ServerRoom): {
if (pool && pool.queue.length > 0) { if (pool && pool.queue.length > 0) {
return { question: pool.queue.shift()!, fromDb: pool.fromDb } return { question: pool.queue.shift()!, fromDb: pool.fromDb }
} }
const question = const formats = pool?.formats ?? ["mcq", "truefalse", "free"]
QUIZ_QUESTIONS[Math.floor(Math.random() * QUIZ_QUESTIONS.length)] const candidates = QUIZ_QUESTIONS.filter((q) => formats.includes(q.format))
return { question, fromDb: false } const bank = candidates.length > 0 ? candidates : QUIZ_QUESTIONS
return {
question: bank[Math.floor(Math.random() * bank.length)],
fromDb: false,
}
} }

View file

@ -1,10 +1,14 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="fr">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>vite-monorepo</title> <title>NerdWare — Party game culture geek</title>
<meta
name="description"
content="Party game web multi-épreuves (quiz culture geek + blindtest YouTube), à jouer entre potes en temps réel."
/>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

@ -2,6 +2,7 @@ import { useState } from "react"
import { import {
Brain, Brain,
Headphones, Headphones,
Image as ImageIcon,
ListMusic, ListMusic,
Minus, Minus,
Music, Music,
@ -22,12 +23,17 @@ import { Button } from "@workspace/ui/components/button"
import { useRoomStore } from "@/store/room" import { useRoomStore } from "@/store/room"
import { Avatar } from "@/components/avatar" import { Avatar } from "@/components/avatar"
const QUESTION_OPTIONS = [3, 5, 10]
const MAX_TRACKS = 10 const MAX_TRACKS = 10
const GAME_TYPES: { value: GameType; label: string; Icon: LucideIcon }[] = [ const GAME_TYPES: {
{ value: "mixed", label: "Mixte", Icon: Shuffle }, value: GameType
{ value: "quiz", label: "Quiz", Icon: Brain }, label: string
{ value: "blindtest", label: "Blindtest", Icon: Headphones }, Icon: LucideIcon
needsThree: boolean
}[] = [
{ value: "mixed", label: "Mixte", Icon: Shuffle, needsThree: true },
{ value: "quiz", label: "Quiz", Icon: Brain, needsThree: false },
{ value: "image", label: "Images", Icon: ImageIcon, needsThree: false },
{ value: "blindtest", label: "Blindtest", Icon: Headphones, needsThree: true },
] ]
const MODE_LABELS: Record<BlindtestMode, string> = { const MODE_LABELS: Record<BlindtestMode, string> = {
title_artist: "Titre & artiste", title_artist: "Titre & artiste",
@ -100,7 +106,7 @@ function buildRounds(
quizCount: number, quizCount: number,
totalTracks: number totalTracks: number
): RoundConfig[] { ): RoundConfig[] {
if (gameType === "quiz") { if (gameType === "quiz" || gameType === "image") {
return Array.from({ length: quizCount }, () => ({ type: "quiz" })) return Array.from({ length: quizCount }, () => ({ type: "quiz" }))
} }
if (gameType === "blindtest") { if (gameType === "blindtest") {
@ -132,14 +138,16 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
// Le blindtest exige au moins 3 joueurs connectés (DJ + 2 devineurs). // Le blindtest exige au moins 3 joueurs connectés (DJ + 2 devineurs).
const connectedCount = snapshot.players.filter((p) => p.connected).length const connectedCount = snapshot.players.filter((p) => p.connected).length
const blindtestAvailable = connectedCount >= 3 const blindtestAvailable = connectedCount >= 3
// Tant que le blindtest est indisponible, on retombe sur du quiz (sans // Si le mode choisi a besoin du blindtest mais qu'on est <3, on retombe sur
// toucher au réglage stocké) : repasse en mixte automatiquement à 3 joueurs. // du quiz (sans toucher au réglage stocké) : se rétablit à 3 joueurs.
const effectiveType: GameType = blindtestAvailable ? gameType : "quiz" const needsBlindtest = gameType === "blindtest" || gameType === "mixed"
const showQuiz = effectiveType === "quiz" || effectiveType === "mixed" const effectiveType: GameType =
needsBlindtest && !blindtestAvailable ? "quiz" : gameType
const showQuestions = effectiveType !== "blindtest"
const showBlindtest = const showBlindtest =
effectiveType === "blindtest" || effectiveType === "mixed" effectiveType === "blindtest" || effectiveType === "mixed"
const rounds = buildRounds(effectiveType, showQuiz ? count : 0, totalTracks) const rounds = buildRounds(effectiveType, showQuestions ? count : 0, totalTracks)
// Blindtest : tout le monde doit avoir soumis son quota de titres. // Blindtest : tout le monde doit avoir soumis son quota de titres.
const allSubmitted = const allSubmitted =
!showBlindtest || !showBlindtest ||
@ -190,54 +198,68 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
</section> </section>
{isHost && ( {isHost && (
<div className="flex flex-col gap-1"> <section className="flex flex-col gap-4 rounded-xl border p-4">
<div className="flex gap-2"> <h2 className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
{GAME_TYPES.map(({ value, label, Icon }) => { Réglages de la partie
const needsThree = value !== "quiz" && !blindtestAvailable </h2>
{/* Mode de jeu */}
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">Mode de jeu</span>
<div className="grid grid-cols-4 gap-2">
{GAME_TYPES.map(({ value, label, Icon, needsThree }) => {
const locked = needsThree && !blindtestAvailable
const active = effectiveType === value
return ( return (
<Button <button
key={value} key={value}
className="flex-1" disabled={locked}
variant={effectiveType === value ? "default" : "secondary"} title={locked ? "3 joueurs minimum" : undefined}
disabled={needsThree}
title={needsThree ? "Blindtest : 3 joueurs minimum" : undefined}
onClick={() => updateSettings({ gameType: value })} onClick={() => updateSettings({ gameType: value })}
className={`flex flex-col items-center gap-1.5 rounded-lg border p-2.5 text-xs font-medium transition-colors disabled:opacity-40 ${
active
? "border-primary bg-primary/10"
: "border-input hover:bg-muted/60"
}`}
> >
<Icon /> {label} <Icon className="size-5" />
</Button> {label}
</button>
) )
})} })}
</div> </div>
{!blindtestAvailable && ( {!blindtestAvailable && (
<p className="text-muted-foreground text-center text-xs"> <p className="text-muted-foreground text-xs">
Le blindtest se débloque à 3 joueurs. Blindtest & Mixte se débloquent à 3 joueurs.
</p>
)}
</div>
{/* Nombre de questions (quiz / images / mixte) */}
{showQuestions && (
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-sm font-medium">
{effectiveType === "image" ? (
<ImageIcon className="size-4" />
) : (
<Brain className="size-4" />
)}
{effectiveType === "image" ? "Images" : "Questions"}
</span>
<Stepper value={count} min={1} max={20} onChange={setCount} />
</div>
{effectiveType === "image" && (
<p className="text-muted-foreground text-xs">
Crée des questions « Image » dans le back-office au préalable.
</p> </p>
)} )}
</div> </div>
)} )}
{isHost && showQuiz && ( {/* Réglages blindtest */}
<div className="flex items-center justify-between"> {showBlindtest && (
<span className="flex items-center gap-1.5 text-sm font-medium"> <div className="flex flex-col gap-3 border-t pt-3">
<Brain className="size-4" /> Questions de quiz
</span>
<div className="flex gap-1">
{QUESTION_OPTIONS.map((n) => (
<Button
key={n}
size="sm"
variant={count === n ? "default" : "secondary"}
onClick={() => setCount(n)}
>
{n}
</Button>
))}
</div>
</div>
)}
{isHost && showBlindtest && (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<span className="flex items-center gap-1.5 text-sm font-medium"> <span className="flex items-center gap-1.5 text-sm font-medium">
<Music className="size-4" /> Mode blindtest <Music className="size-4" /> Mode blindtest
@ -267,6 +289,8 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
</div> </div>
</div> </div>
)} )}
</section>
)}
{showBlindtest && ( {showBlindtest && (
<TrackSubmission tracksPerPlayer={tracksPerPlayer} myCount={myCount} /> <TrackSubmission tracksPerPlayer={tracksPerPlayer} myCount={myCount} />

View file

@ -1,7 +1,7 @@
import { useState } from "react" import { useRef, useState } from "react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { Link } from "wouter" import { Link } from "wouter"
import { Trash2 } from "lucide-react" import { Trash2, Upload } from "lucide-react"
import type { QuizFormat } from "@nerdware/shared" import type { QuizFormat } from "@nerdware/shared"
import { Button } from "@workspace/ui/components/button" import { Button } from "@workspace/ui/components/button"
import { import {
@ -197,6 +197,7 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
const [imageUrl, setImageUrl] = useState<string | null>(null) const [imageUrl, setImageUrl] = useState<string | null>(null)
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [uploadError, setUploadError] = useState<string | null>(null) const [uploadError, setUploadError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const create = useMutation({ const create = useMutation({
mutationFn: (input: NewQuestionInput) => adminApi.createQuestion(input), mutationFn: (input: NewQuestionInput) => adminApi.createQuestion(input),
@ -338,14 +339,25 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
{format === "image_reveal" && ( {format === "image_reveal" && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<input <input
ref={fileInputRef}
type="file" type="file"
accept="image/*" accept="image/*"
className="text-sm" className="hidden"
onChange={(e) => onPickImage(e.target.files?.[0])} onChange={(e) => onPickImage(e.target.files?.[0])}
/> />
{uploading && ( <Button
<p className="text-muted-foreground text-xs">Upload</p> type="button"
)} variant="secondary"
disabled={uploading}
onClick={() => fileInputRef.current?.click()}
>
<Upload />
{uploading
? "Upload…"
: imageUrl
? "Changer l'image"
: "Choisir une image"}
</Button>
{uploadError && ( {uploadError && (
<p className="text-destructive text-xs">{uploadError}</p> <p className="text-destructive text-xs">{uploadError}</p>
)} )}

View file

@ -8,7 +8,7 @@ export type RoomStatus = "lobby" | "in_round" | "reveal" | "scores" | "ended"
export type RoundType = "blindtest" | "quiz" export type RoundType = "blindtest" | "quiz"
/** Type de partie choisi au lobby : mélange par défaut, ou un seul mode. */ /** Type de partie choisi au lobby : mélange par défaut, ou un seul mode. */
export type GameType = "mixed" | "quiz" | "blindtest" export type GameType = "mixed" | "quiz" | "image" | "blindtest"
/** Modes de blindtest, déterminent la forme du vote et le barème. */ /** Modes de blindtest, déterminent la forme du vote et le barème. */
export type BlindtestMode = "title_artist" | "who_added" | "mixed" export type BlindtestMode = "title_artist" | "who_added" | "mixed"