Compare commits
4 commits
20e1617b3d
...
ed1747543b
| Author | SHA1 | Date | |
|---|---|---|---|
| ed1747543b | |||
|
|
b91d09b7e7 | ||
|
|
fa7c97c402 | ||
|
|
879bc5b388 |
15 changed files with 275 additions and 118 deletions
|
|
@ -9,10 +9,23 @@ import type { QuizQuestion } from "../game/modes/quiz/questions"
|
||||||
* Pool de questions mcq/truefalse non encore jouées par cette room,
|
* Pool de questions mcq/truefalse non encore jouées par cette room,
|
||||||
* mélangées et limitées. Tableau vide si pas de DB ou rien à servir.
|
* mélangées et limitées. Tableau vide si pas de DB ou rien à servir.
|
||||||
*/
|
*/
|
||||||
|
/** Catégories existantes (pour le filtre du lobby). */
|
||||||
|
export async function listCategories(): Promise<string[]> {
|
||||||
|
if (!db) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const rows = await db
|
||||||
|
.select({ name: quizCategory.name })
|
||||||
|
.from(quizCategory)
|
||||||
|
.orderBy(quizCategory.name)
|
||||||
|
return rows.map((r) => r.name)
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadQuizPool(
|
export async function loadQuizPool(
|
||||||
roomCode: string,
|
roomCode: string,
|
||||||
limit: number,
|
limit: number,
|
||||||
formats: string[]
|
formats: string[],
|
||||||
|
categories: string[] = []
|
||||||
): Promise<QuizQuestion[]> {
|
): Promise<QuizQuestion[]> {
|
||||||
if (!db || formats.length === 0) {
|
if (!db || formats.length === 0) {
|
||||||
return []
|
return []
|
||||||
|
|
@ -39,6 +52,9 @@ export async function loadQuizPool(
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
inArray(quizQuestion.format, formats),
|
inArray(quizQuestion.format, formats),
|
||||||
|
categories.length > 0
|
||||||
|
? inArray(quizCategory.name, categories)
|
||||||
|
: undefined,
|
||||||
notInArray(quizQuestion.id, alreadyPlayed)
|
notInArray(quizQuestion.id, alreadyPlayed)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -175,7 +175,7 @@ export class BlindtestRound implements GameRound {
|
||||||
artist?: string
|
artist?: string
|
||||||
guessedPlayerId?: string
|
guessedPlayerId?: string
|
||||||
}
|
}
|
||||||
let text = ""
|
let text: string
|
||||||
if (mode === "who_added") {
|
if (mode === "who_added") {
|
||||||
text = nameOf(a.guessedPlayerId)
|
text = nameOf(a.guessedPlayerId)
|
||||||
} else if (mode === "title_artist") {
|
} else if (mode === "title_artist") {
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,22 @@
|
||||||
// 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 : on respecte
|
||||||
// depuis la DB (si dispo), sinon depuis la banque en dur. Le QuizRound y pioche.
|
// le sous-pool de chaque manche quiz (quiz "classique" vs images), dans l'ordre
|
||||||
// Les formats servis dépendent du type de partie (quiz / images / mixte).
|
// de la séquence. DB si dispo, sinon banque en dur (pas d'images en dur).
|
||||||
|
|
||||||
import type { QuizFormat, RoomSettings } from "@nerdware/shared"
|
import type { 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"
|
||||||
import { QUIZ_QUESTIONS, type QuizQuestion } from "./questions"
|
import { QUIZ_QUESTIONS, type QuizQuestion } from "./questions"
|
||||||
|
|
||||||
interface RoomPool {
|
interface QueueItem {
|
||||||
queue: QuizQuestion[]
|
question: QuizQuestion
|
||||||
/** 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, QueueItem[]>()
|
||||||
|
|
||||||
const QUIZ_FORMATS: QuizFormat[] = ["mcq", "truefalse", "free"]
|
const QUIZ_FORMATS: QuizFormat[] = ["mcq", "truefalse", "free"]
|
||||||
|
const IMAGE_FORMATS: QuizFormat[] = ["image_reveal"]
|
||||||
/** Formats de quiz autorisés selon le type de partie (et les sous-modes mixtes). */
|
|
||||||
function quizFormatsFor(settings: RoomSettings): QuizFormat[] {
|
|
||||||
if (settings.gameType === "image") {
|
|
||||||
return ["image_reveal"]
|
|
||||||
}
|
|
||||||
if (settings.gameType === "mixed") {
|
|
||||||
const formats: QuizFormat[] = []
|
|
||||||
if (settings.mixedModes.includes("quiz")) {
|
|
||||||
formats.push(...QUIZ_FORMATS)
|
|
||||||
}
|
|
||||||
if (settings.mixedModes.includes("image")) {
|
|
||||||
formats.push("image_reveal")
|
|
||||||
}
|
|
||||||
return formats.length > 0 ? formats : QUIZ_FORMATS
|
|
||||||
}
|
|
||||||
// quiz, ou blindtest retombé sur du quiz (<3 joueurs)
|
|
||||||
return QUIZ_FORMATS
|
|
||||||
}
|
|
||||||
|
|
||||||
function shuffle<T>(items: T[]): T[] {
|
function shuffle<T>(items: T[]): T[] {
|
||||||
const arr = [...items]
|
const arr = [...items]
|
||||||
|
|
@ -47,44 +27,74 @@ function shuffle<T>(items: T[]): T[] {
|
||||||
return arr
|
return arr
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Précharge les questions de la partie. À appeler avant de lancer le moteur. */
|
/** Charge un sous-pool (DB puis complément banque en dur) pour `need` manches. */
|
||||||
export async function prepareQuizForRoom(room: ServerRoom): Promise<void> {
|
async function loadGroup(
|
||||||
const formats = quizFormatsFor(room.settings)
|
room: ServerRoom,
|
||||||
const needed = room.settings.rounds.filter((r) => r.type === "quiz").length
|
need: number,
|
||||||
let queue: QuizQuestion[] = []
|
formats: QuizFormat[],
|
||||||
let fromDb = false
|
isImage: boolean,
|
||||||
|
categories: string[]
|
||||||
|
): Promise<QueueItem[]> {
|
||||||
|
if (need <= 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
let items: QueueItem[] = []
|
||||||
if (hasDb) {
|
if (hasDb) {
|
||||||
try {
|
try {
|
||||||
queue = await loadQuizPool(room.code, Math.max(needed, 1), formats)
|
const rows = await loadQuizPool(room.code, need, formats, categories)
|
||||||
fromDb = queue.length > 0
|
items = rows.map((question) => ({ question, fromDb: true }))
|
||||||
} 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (items.length < need && !isImage) {
|
||||||
if (queue.length === 0) {
|
// Complément depuis la banque en dur (uniquement le quiz, pas d'images en dur).
|
||||||
queue = shuffle(QUIZ_QUESTIONS.filter((q) => formats.includes(q.format)))
|
const bank = shuffle(
|
||||||
fromDb = false
|
QUIZ_QUESTIONS.filter(
|
||||||
|
(q) =>
|
||||||
|
formats.includes(q.format) &&
|
||||||
|
(categories.length === 0 || categories.includes(q.category))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for (const question of bank) {
|
||||||
|
if (items.length >= need) break
|
||||||
|
items.push({ question, fromDb: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
poolByRoom.set(room, { queue, fromDb, formats })
|
/** Précharge les questions de la partie. À appeler avant de lancer le moteur. */
|
||||||
|
export async function prepareQuizForRoom(room: ServerRoom): Promise<void> {
|
||||||
|
const quizRounds = room.settings.rounds.filter((r) => r.type === "quiz")
|
||||||
|
const needImage = quizRounds.filter((r) => r.pool === "image").length
|
||||||
|
const needQuiz = quizRounds.length - needImage
|
||||||
|
|
||||||
|
const cats = room.settings.categories
|
||||||
|
const quizGroup = await loadGroup(room, needQuiz, QUIZ_FORMATS, false, cats)
|
||||||
|
const imageGroup = await loadGroup(room, needImage, IMAGE_FORMATS, true, cats)
|
||||||
|
|
||||||
|
// On assemble la file dans l'ordre des manches (chacune pioche dans son sous-pool).
|
||||||
|
const queue: QueueItem[] = []
|
||||||
|
for (const r of quizRounds) {
|
||||||
|
const group = r.pool === "image" ? imageGroup : quizGroup
|
||||||
|
const item = group.shift()
|
||||||
|
if (item) {
|
||||||
|
queue.push(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
poolByRoom.set(room, queue)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pioche la prochaine question. Fallback aléatoire (formats autorisés) si vide. */
|
/** Pioche la prochaine question. Fallback aléatoire (banque en dur) si vide. */
|
||||||
export function takeQuestion(room: ServerRoom): {
|
export function takeQuestion(room: ServerRoom): {
|
||||||
question: QuizQuestion
|
question: QuizQuestion
|
||||||
fromDb: boolean
|
fromDb: boolean
|
||||||
} {
|
} {
|
||||||
const pool = poolByRoom.get(room)
|
const queue = poolByRoom.get(room)
|
||||||
if (pool && pool.queue.length > 0) {
|
if (queue && queue.length > 0) {
|
||||||
return { question: pool.queue.shift()!, fromDb: pool.fromDb }
|
return queue.shift()!
|
||||||
}
|
|
||||||
const formats = pool?.formats ?? ["mcq", "truefalse", "free"]
|
|
||||||
const candidates = QUIZ_QUESTIONS.filter((q) => formats.includes(q.format))
|
|
||||||
const bank = candidates.length > 0 ? candidates : QUIZ_QUESTIONS
|
|
||||||
return {
|
|
||||||
question: bank[Math.floor(Math.random() * bank.length)],
|
|
||||||
fromDb: false,
|
|
||||||
}
|
}
|
||||||
|
const q = QUIZ_QUESTIONS[Math.floor(Math.random() * QUIZ_QUESTIONS.length)]
|
||||||
|
return { question: q, fromDb: false }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,7 @@ export function registerRoomHandlers(
|
||||||
blindtestMode: payload.blindtestMode,
|
blindtestMode: payload.blindtestMode,
|
||||||
roundDuration: payload.roundDuration,
|
roundDuration: payload.roundDuration,
|
||||||
tracksPerPlayer: payload.tracksPerPlayer,
|
tracksPerPlayer: payload.tracksPerPlayer,
|
||||||
|
categories: payload.categories,
|
||||||
rounds: payload.rounds,
|
rounds: payload.rounds,
|
||||||
}
|
}
|
||||||
broadcastState(room)
|
broadcastState(room)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import fastifyStatic from "@fastify/static"
|
||||||
import { env, isDev } from "./env"
|
import { env, isDev } from "./env"
|
||||||
import { createSocketServer } from "./socket"
|
import { createSocketServer } from "./socket"
|
||||||
import { adminRoutes } from "./admin/routes"
|
import { adminRoutes } from "./admin/routes"
|
||||||
|
import { listCategories } from "./db/quiz-repo"
|
||||||
import "./game/modes" // enregistre les épreuves (registerRound)
|
import "./game/modes" // enregistre les épreuves (registerRound)
|
||||||
|
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
|
|
@ -25,6 +26,9 @@ await app.register(fastifyStatic, { root: uploadsRoot, prefix: "/uploads/" })
|
||||||
|
|
||||||
app.get("/health", async () => ({ status: "ok", uptime: process.uptime() }))
|
app.get("/health", async () => ({ status: "ok", uptime: process.uptime() }))
|
||||||
|
|
||||||
|
// Catégories de quiz disponibles (public, pour le filtre du lobby).
|
||||||
|
app.get("/api/categories", async () => listCategories())
|
||||||
|
|
||||||
await app.register(adminRoutes, { prefix: "/api/admin" })
|
await app.register(adminRoutes, { prefix: "/api/admin" })
|
||||||
|
|
||||||
// Socket.IO se greffe sur le serveur HTTP sous-jacent de Fastify.
|
// Socket.IO se greffe sur le serveur HTTP sous-jacent de Fastify.
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
}, [showReveal, ready, api])
|
}, [showReveal, ready, api])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full max-w-md flex-col gap-5">
|
<div className="flex w-full max-w-lg flex-col gap-5">
|
||||||
<header className="flex items-center justify-between">
|
<header className="flex items-center justify-between">
|
||||||
<span className="text-muted-foreground text-xs uppercase">
|
<span className="text-muted-foreground text-xs uppercase">
|
||||||
Blindtest {snapshot.currentRound + 1} / {snapshot.totalRounds}
|
Blindtest {snapshot.currentRound + 1} / {snapshot.totalRounds}
|
||||||
|
|
|
||||||
|
|
@ -180,7 +180,8 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
const rest = ranked.slice(3)
|
const rest = ranked.slice(3)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full max-w-md flex-col gap-6 text-center">
|
<div className="flex w-full flex-col items-center gap-6">
|
||||||
|
<div className="flex w-full max-w-xl flex-col gap-6 text-center">
|
||||||
<header>
|
<header>
|
||||||
<p className="text-muted-foreground text-xs uppercase">
|
<p className="text-muted-foreground text-xs uppercase">
|
||||||
Partie terminée
|
Partie terminée
|
||||||
|
|
@ -253,16 +254,22 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
{gameRecap && gameRecap.length > 0 && (
|
{gameRecap && gameRecap.length > 0 && (
|
||||||
<FunStats recap={gameRecap} snapshot={snapshot} />
|
<FunStats recap={gameRecap} snapshot={snapshot} />
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Détails (musiques + récap) côte à côte sur desktop. */}
|
||||||
|
{((gameTracks && gameTracks.length > 0) ||
|
||||||
|
(gameRecap && gameRecap.length > 0)) && (
|
||||||
|
<div className="grid w-full gap-6 md:grid-cols-2 md:items-start">
|
||||||
{gameTracks && gameTracks.length > 0 && (
|
{gameTracks && gameTracks.length > 0 && (
|
||||||
<TracksRecap tracks={gameTracks} />
|
<TracksRecap tracks={gameTracks} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{gameRecap && gameRecap.length > 0 && (
|
{gameRecap && gameRecap.length > 0 && (
|
||||||
<RoundsRecap recap={gameRecap} nameOf={nameOf} playerId={playerId} />
|
<RoundsRecap recap={gameRecap} nameOf={nameOf} playerId={playerId} />
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex w-full max-w-sm flex-col gap-2">
|
||||||
<Button variant="secondary" className="w-full" onClick={copyResults}>
|
<Button variant="secondary" className="w-full" onClick={copyResults}>
|
||||||
{copied ? <Check className="text-green-500" /> : <ClipboardList />}
|
{copied ? <Check className="text-green-500" /> : <ClipboardList />}
|
||||||
{copied ? "Copié !" : "Copier les résultats"}
|
{copied ? "Copié !" : "Copier les résultats"}
|
||||||
|
|
@ -354,8 +361,12 @@ interface PlayerStat {
|
||||||
correct: number
|
correct: number
|
||||||
answered: number
|
answered: number
|
||||||
decoy: number
|
decoy: number
|
||||||
|
speed: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Points de base d'une bonne réponse quiz/image ; le surplus = bonus de vitesse.
|
||||||
|
const QUIZ_BASE_POINTS = 100
|
||||||
|
|
||||||
function FunStats({
|
function FunStats({
|
||||||
recap,
|
recap,
|
||||||
snapshot,
|
snapshot,
|
||||||
|
|
@ -364,15 +375,25 @@ function FunStats({
|
||||||
snapshot: RoomSnapshot
|
snapshot: RoomSnapshot
|
||||||
}) {
|
}) {
|
||||||
const stats = new Map<string, PlayerStat>(
|
const stats = new Map<string, PlayerStat>(
|
||||||
snapshot.players.map((p) => [p.id, { correct: 0, answered: 0, decoy: 0 }])
|
snapshot.players.map((p) => [
|
||||||
|
p.id,
|
||||||
|
{ correct: 0, answered: 0, decoy: 0, speed: 0 },
|
||||||
|
])
|
||||||
)
|
)
|
||||||
for (const r of recap) {
|
for (const r of recap) {
|
||||||
const answered = new Set(r.answers.map((a) => a.playerId))
|
const answered = new Set(r.answers.map((a) => a.playerId))
|
||||||
|
const pointsBy = new Map(r.scorers.map((sc) => [sc.playerId, sc.points]))
|
||||||
for (const a of r.answers) {
|
for (const a of r.answers) {
|
||||||
const s = stats.get(a.playerId)
|
const s = stats.get(a.playerId)
|
||||||
if (s) {
|
if (s) {
|
||||||
s.answered++
|
s.answered++
|
||||||
if (a.correct) s.correct++
|
if (a.correct) {
|
||||||
|
s.correct++
|
||||||
|
// Bonus de vitesse = points au-dessus de la base (quiz/images).
|
||||||
|
if (r.type !== "blindtest") {
|
||||||
|
s.speed += Math.max(0, (pointsBy.get(a.playerId) ?? 0) - QUIZ_BASE_POINTS)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (r.type === "blindtest") {
|
if (r.type === "blindtest") {
|
||||||
|
|
@ -388,7 +409,8 @@ function FunStats({
|
||||||
const players = snapshot.players
|
const players = snapshot.players
|
||||||
const total = recap.length
|
const total = recap.length
|
||||||
const nameOf = (id: string) => players.find((p) => p.id === id)?.name ?? "?"
|
const nameOf = (id: string) => players.find((p) => p.id === id)?.name ?? "?"
|
||||||
const stat = (id: string) => stats.get(id) ?? { correct: 0, answered: 0, decoy: 0 }
|
const stat = (id: string) =>
|
||||||
|
stats.get(id) ?? { correct: 0, answered: 0, decoy: 0, speed: 0 }
|
||||||
const winners = (pick: (s: PlayerStat) => number, min = 1) => {
|
const winners = (pick: (s: PlayerStat) => number, min = 1) => {
|
||||||
const max = Math.max(0, ...players.map((p) => pick(stat(p.id))))
|
const max = Math.max(0, ...players.map((p) => pick(stat(p.id))))
|
||||||
return max >= min ? players.filter((p) => pick(stat(p.id)) === max) : []
|
return max >= min ? players.filter((p) => pick(stat(p.id)) === max) : []
|
||||||
|
|
@ -405,6 +427,14 @@ function FunStats({
|
||||||
if (brains.length && brains.length < players.length) {
|
if (brains.length && brains.length < players.length) {
|
||||||
awards.push({ emoji: "🧠", label: "Le cerveau", ids: brains.map((p) => p.id) })
|
awards.push({ emoji: "🧠", label: "Le cerveau", ids: brains.map((p) => p.id) })
|
||||||
}
|
}
|
||||||
|
const fastest = winners((s) => s.speed)
|
||||||
|
if (fastest.length && fastest.length < players.length) {
|
||||||
|
awards.push({
|
||||||
|
emoji: "⚡",
|
||||||
|
label: "Le plus rapide",
|
||||||
|
ids: fastest.map((p) => p.id),
|
||||||
|
})
|
||||||
|
}
|
||||||
const decoy = winners((s) => s.decoy)
|
const decoy = winners((s) => s.decoy)
|
||||||
if (decoy.length) {
|
if (decoy.length) {
|
||||||
awards.push({
|
awards.push({
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import {
|
import {
|
||||||
Brain,
|
Brain,
|
||||||
|
Filter,
|
||||||
Headphones,
|
Headphones,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
ListMusic,
|
ListMusic,
|
||||||
|
|
@ -13,6 +15,7 @@ import {
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { SiYoutube } from "@icons-pack/react-simple-icons"
|
import { SiYoutube } from "@icons-pack/react-simple-icons"
|
||||||
|
import { fetchCategories } from "@/lib/categories"
|
||||||
import type {
|
import type {
|
||||||
BlindtestMode,
|
BlindtestMode,
|
||||||
GameType,
|
GameType,
|
||||||
|
|
@ -107,24 +110,25 @@ function shuffle<T>(items: T[]): T[] {
|
||||||
return arr
|
return arr
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Construit la séquence de manches selon le type de partie. */
|
/** Construit la séquence de manches (chaque manche quiz porte son sous-pool). */
|
||||||
function buildRounds(
|
function buildRounds(opts: {
|
||||||
gameType: GameType,
|
quiz: number
|
||||||
quizCount: number,
|
image: number
|
||||||
totalTracks: number
|
tracks: number
|
||||||
): RoundConfig[] {
|
shuffleOrder: boolean
|
||||||
if (gameType === "quiz" || gameType === "image") {
|
}): RoundConfig[] {
|
||||||
return Array.from({ length: quizCount }, () => ({ type: "quiz" }))
|
|
||||||
}
|
|
||||||
if (gameType === "blindtest") {
|
|
||||||
return Array.from({ length: totalTracks }, () => ({ type: "blindtest" }))
|
|
||||||
}
|
|
||||||
// Mixte : ordre mélangé pour qu'on ne sache pas ce qui vient ensuite.
|
|
||||||
const rounds: RoundConfig[] = [
|
const rounds: RoundConfig[] = [
|
||||||
...Array.from({ length: quizCount }, () => ({ type: "quiz" as const })),
|
...Array.from({ length: opts.quiz }, () => ({
|
||||||
...Array.from({ length: totalTracks }, () => ({ type: "blindtest" as const })),
|
type: "quiz" as const,
|
||||||
|
pool: "quiz" as const,
|
||||||
|
})),
|
||||||
|
...Array.from({ length: opts.image }, () => ({
|
||||||
|
type: "quiz" as const,
|
||||||
|
pool: "image" as const,
|
||||||
|
})),
|
||||||
|
...Array.from({ length: opts.tracks }, () => ({ type: "blindtest" as const })),
|
||||||
]
|
]
|
||||||
return shuffle(rounds)
|
return opts.shuffleOrder ? shuffle(rounds) : rounds
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
|
|
@ -132,10 +136,13 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
const updateSettings = useRoomStore((s) => s.updateSettings)
|
const updateSettings = useRoomStore((s) => s.updateSettings)
|
||||||
const startGame = useRoomStore((s) => s.startGame)
|
const startGame = useRoomStore((s) => s.startGame)
|
||||||
const isHost = snapshot.hostId === playerId
|
const isHost = snapshot.hostId === playerId
|
||||||
const { gameType, mixedModes, blindtestMode, tracksPerPlayer } =
|
const { gameType, mixedModes, blindtestMode, tracksPerPlayer, categories } =
|
||||||
snapshot.settings
|
snapshot.settings
|
||||||
|
const allCategories =
|
||||||
|
useQuery({ queryKey: ["categories"], queryFn: fetchCategories }).data ?? []
|
||||||
|
|
||||||
const [count, setCount] = useState(5)
|
const [quizCount, setQuizCount] = useState(5)
|
||||||
|
const [imageCount, setImageCount] = useState(5)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
|
@ -152,21 +159,21 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
gameType === "blindtest" && !blindtestAvailable ? "quiz" : gameType
|
gameType === "blindtest" && !blindtestAvailable ? "quiz" : gameType
|
||||||
const isMixed = effectiveType === "mixed"
|
const isMixed = effectiveType === "mixed"
|
||||||
|
|
||||||
// Sous-modes effectivement actifs.
|
// Sous-modes effectivement actifs (un réglage par sous-mode).
|
||||||
const wantsQuestions =
|
const showQuiz =
|
||||||
effectiveType === "quiz" ||
|
effectiveType === "quiz" || (isMixed && mixedModes.includes("quiz"))
|
||||||
effectiveType === "image" ||
|
const showImage =
|
||||||
(isMixed && (mixedModes.includes("quiz") || mixedModes.includes("image")))
|
effectiveType === "image" || (isMixed && mixedModes.includes("image"))
|
||||||
const showQuestions = wantsQuestions
|
|
||||||
const showBlindtest =
|
const showBlindtest =
|
||||||
effectiveType === "blindtest" ||
|
effectiveType === "blindtest" ||
|
||||||
(isMixed && mixedModes.includes("blindtest") && blindtestAvailable)
|
(isMixed && mixedModes.includes("blindtest") && blindtestAvailable)
|
||||||
|
|
||||||
const rounds = buildRounds(
|
const rounds = buildRounds({
|
||||||
effectiveType,
|
quiz: showQuiz ? quizCount : 0,
|
||||||
showQuestions ? count : 0,
|
image: showImage ? imageCount : 0,
|
||||||
showBlindtest ? totalTracks : 0
|
tracks: showBlindtest ? totalTracks : 0,
|
||||||
)
|
shuffleOrder: isMixed,
|
||||||
|
})
|
||||||
// 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 ||
|
||||||
|
|
@ -184,6 +191,16 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
updateSettings({ mixedModes: [...set] })
|
updateSettings({ mixedModes: [...set] })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleCategory(name: string) {
|
||||||
|
const set = new Set(categories)
|
||||||
|
if (set.has(name)) {
|
||||||
|
set.delete(name)
|
||||||
|
} else {
|
||||||
|
set.add(name)
|
||||||
|
}
|
||||||
|
updateSettings({ categories: [...set] })
|
||||||
|
}
|
||||||
|
|
||||||
async function start() {
|
async function start() {
|
||||||
setError(null)
|
setError(null)
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
|
|
@ -197,7 +214,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full max-w-md flex-col gap-6">
|
<div className="grid w-full gap-6 md:grid-cols-2 md:items-start">
|
||||||
<section className="flex flex-col gap-2">
|
<section className="flex flex-col gap-2">
|
||||||
<h2 className="text-muted-foreground text-sm font-medium">
|
<h2 className="text-muted-foreground text-sm font-medium">
|
||||||
Joueurs ({snapshot.players.length})
|
Joueurs ({snapshot.players.length})
|
||||||
|
|
@ -226,6 +243,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
{isHost && (
|
{isHost && (
|
||||||
<section className="flex flex-col gap-4 rounded-xl border p-4">
|
<section className="flex flex-col gap-4 rounded-xl border p-4">
|
||||||
<h2 className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
|
<h2 className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
|
||||||
|
|
@ -292,25 +310,67 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Nombre de questions (quiz / images / mixte) */}
|
{/* Nombre de questions de quiz */}
|
||||||
{showQuestions && (
|
{showQuiz && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-1.5 text-sm font-medium">
|
||||||
|
<Brain className="size-4" /> Questions de quiz
|
||||||
|
</span>
|
||||||
|
<Stepper value={quizCount} min={1} max={20} onChange={setQuizCount} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Nombre d'images */}
|
||||||
|
{showImage && (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="flex items-center gap-1.5 text-sm font-medium">
|
<span className="flex items-center gap-1.5 text-sm font-medium">
|
||||||
{effectiveType === "image" ? (
|
<ImageIcon className="size-4" /> Images à deviner
|
||||||
<ImageIcon className="size-4" />
|
|
||||||
) : (
|
|
||||||
<Brain className="size-4" />
|
|
||||||
)}
|
|
||||||
{effectiveType === "image" ? "Images" : "Questions"}
|
|
||||||
</span>
|
</span>
|
||||||
<Stepper value={count} min={1} max={20} onChange={setCount} />
|
<Stepper
|
||||||
|
value={imageCount}
|
||||||
|
min={1}
|
||||||
|
max={20}
|
||||||
|
onChange={setImageCount}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{effectiveType === "image" && (
|
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
Crée des questions « Image » dans le back-office au préalable.
|
Nécessite des questions « Image » créées dans le back-office.
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Filtre de catégories (quiz / images) */}
|
||||||
|
{(showQuiz || showImage) && allCategories.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<span className="flex items-center gap-1.5 text-sm font-medium">
|
||||||
|
<Filter className="size-4" /> Catégories
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => updateSettings({ categories: [] })}
|
||||||
|
className={`rounded-full border px-2.5 py-1 text-xs transition-colors ${
|
||||||
|
categories.length === 0
|
||||||
|
? "border-primary bg-primary/10"
|
||||||
|
: "border-input hover:bg-muted/60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Toutes
|
||||||
|
</button>
|
||||||
|
{allCategories.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
onClick={() => toggleCategory(c)}
|
||||||
|
className={`rounded-full border px-2.5 py-1 text-xs transition-colors ${
|
||||||
|
categories.includes(c)
|
||||||
|
? "border-primary bg-primary/10"
|
||||||
|
: "border-input hover:bg-muted/60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{c}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -374,6 +434,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
|
|
||||||
{error && <p className="text-destructive text-center text-sm">{error}</p>}
|
{error && <p className="text-destructive text-center text-sm">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full max-w-md flex-col gap-5">
|
<div className="flex w-full max-w-lg flex-col gap-5">
|
||||||
<header className="flex items-center justify-between">
|
<header className="flex items-center justify-between">
|
||||||
<span className="text-muted-foreground text-xs uppercase">
|
<span className="text-muted-foreground text-xs uppercase">
|
||||||
Question {snapshot.currentRound + 1} / {snapshot.totalRounds}
|
Question {snapshot.currentRound + 1} / {snapshot.totalRounds}
|
||||||
|
|
|
||||||
15
apps/web/src/lib/categories.ts
Normal file
15
apps/web/src/lib/categories.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
// Liste publique des catégories de quiz (pour le filtre du lobby).
|
||||||
|
|
||||||
|
const SERVER_URL = import.meta.env.VITE_SERVER_URL ?? "http://localhost:3001"
|
||||||
|
|
||||||
|
export async function fetchCategories(): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${SERVER_URL}/api/categories`)
|
||||||
|
if (!res.ok) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return (await res.json()) as string[]
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -90,7 +90,7 @@ function AdminPanel({ onLogout }: { onLogout: () => void }) {
|
||||||
questions.isError && /401|autoris/i.test(String(questions.error))
|
questions.isError && /401|autoris/i.test(String(questions.error))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex min-h-svh w-full max-w-2xl flex-col gap-6 p-6">
|
<div className="mx-auto flex min-h-svh w-full max-w-5xl flex-col gap-6 p-6">
|
||||||
<header className="flex items-center justify-between">
|
<header className="flex items-center justify-between">
|
||||||
<h1 className="font-heading text-2xl font-bold">Back-office quiz</h1>
|
<h1 className="font-heading text-2xl font-bold">Back-office quiz</h1>
|
||||||
<Button size="sm" variant="secondary" onClick={onLogout}>
|
<Button size="sm" variant="secondary" onClick={onLogout}>
|
||||||
|
|
@ -106,7 +106,7 @@ function AdminPanel({ onLogout }: { onLogout: () => void }) {
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="grid gap-6 md:grid-cols-2 md:items-start">
|
||||||
<QuestionForm
|
<QuestionForm
|
||||||
onCreated={() =>
|
onCreated={() =>
|
||||||
qc.invalidateQueries({ queryKey: ["admin-questions"] })
|
qc.invalidateQueries({ queryKey: ["admin-questions"] })
|
||||||
|
|
@ -131,7 +131,7 @@ function AdminPanel({ onLogout }: { onLogout: () => void }) {
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -63,8 +63,21 @@ export function RoomPage({ code }: { code: string }) {
|
||||||
: "game"
|
: "game"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-svh items-start justify-center p-6 pt-12">
|
<div className="relative flex min-h-svh items-start justify-center overflow-hidden p-4 pt-10 sm:p-6 sm:pt-12">
|
||||||
<div className="flex w-full max-w-md flex-col gap-6">
|
{/* Halo d'ambiance en fond */}
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute top-[-20vmin] -z-10 size-[90vmin] rounded-full bg-gradient-to-br from-fuchsia-500/10 via-purple-500/5 to-cyan-400/10 blur-3xl"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`flex w-full flex-col gap-6 transition-[max-width] ${
|
||||||
|
phase === "lobby"
|
||||||
|
? "max-w-3xl"
|
||||||
|
: phase === "ended"
|
||||||
|
? "max-w-4xl"
|
||||||
|
: "max-w-lg"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<header className="flex items-center justify-between">
|
<header className="flex items-center justify-between">
|
||||||
<RoomCode code={snapshot.code} />
|
<RoomCode code={snapshot.code} />
|
||||||
<span
|
<span
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
|
||||||
blindtestMode: partial.blindtestMode ?? current.blindtestMode,
|
blindtestMode: partial.blindtestMode ?? current.blindtestMode,
|
||||||
roundDuration: partial.roundDuration ?? current.roundDuration,
|
roundDuration: partial.roundDuration ?? current.roundDuration,
|
||||||
tracksPerPlayer: partial.tracksPerPlayer ?? current.tracksPerPlayer,
|
tracksPerPlayer: partial.tracksPerPlayer ?? current.tracksPerPlayer,
|
||||||
|
categories: partial.categories ?? current.categories,
|
||||||
rounds: partial.rounds ?? current.rounds,
|
rounds: partial.rounds ?? current.rounds,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ export interface Player {
|
||||||
/** Configuration d'une épreuve planifiée dans la séquence de la partie. */
|
/** Configuration d'une épreuve planifiée dans la séquence de la partie. */
|
||||||
export interface RoundConfig {
|
export interface RoundConfig {
|
||||||
type: RoundType
|
type: RoundType
|
||||||
|
/** Pour une manche quiz : dans quel sous-pool piocher (quiz "classique" ou images). */
|
||||||
|
pool?: "quiz" | "image"
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Réglages de la room, modifiables dans le lobby par l'hôte. */
|
/** Réglages de la room, modifiables dans le lobby par l'hôte. */
|
||||||
|
|
@ -42,6 +44,8 @@ export interface RoomSettings {
|
||||||
roundDuration: number
|
roundDuration: number
|
||||||
/** Nombre de titres que chaque joueur soumet (blindtest). */
|
/** Nombre de titres que chaque joueur soumet (blindtest). */
|
||||||
tracksPerPlayer: number
|
tracksPerPlayer: number
|
||||||
|
/** Catégories de quiz autorisées (vide = toutes). */
|
||||||
|
categories: string[]
|
||||||
/** Séquence d'épreuves de la partie. */
|
/** Séquence d'épreuves de la partie. */
|
||||||
rounds: RoundConfig[]
|
rounds: RoundConfig[]
|
||||||
}
|
}
|
||||||
|
|
@ -53,6 +57,7 @@ export const DEFAULT_ROOM_SETTINGS: RoomSettings = {
|
||||||
blindtestMode: "title_artist",
|
blindtestMode: "title_artist",
|
||||||
roundDuration: 60,
|
roundDuration: 60,
|
||||||
tracksPerPlayer: 2,
|
tracksPerPlayer: 2,
|
||||||
|
categories: [],
|
||||||
rounds: [],
|
rounds: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ export interface UpdateSettingsPayload {
|
||||||
blindtestMode: BlindtestMode
|
blindtestMode: BlindtestMode
|
||||||
roundDuration: number
|
roundDuration: number
|
||||||
tracksPerPlayer: number
|
tracksPerPlayer: number
|
||||||
|
categories: string[]
|
||||||
rounds: RoundConfig[]
|
rounds: RoundConfig[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue