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
10 changed files with 104 additions and 8 deletions
Showing only changes of commit b91d09b7e7 - Show all commits

View file

@ -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)
) )
) )

View file

@ -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") {

View file

@ -32,7 +32,8 @@ async function loadGroup(
room: ServerRoom, room: ServerRoom,
need: number, need: number,
formats: QuizFormat[], formats: QuizFormat[],
isImage: boolean isImage: boolean,
categories: string[]
): Promise<QueueItem[]> { ): Promise<QueueItem[]> {
if (need <= 0) { if (need <= 0) {
return [] return []
@ -40,7 +41,7 @@ async function loadGroup(
let items: QueueItem[] = [] let items: QueueItem[] = []
if (hasDb) { if (hasDb) {
try { try {
const rows = await loadQuizPool(room.code, need, formats) const rows = await loadQuizPool(room.code, need, formats, categories)
items = rows.map((question) => ({ question, fromDb: true })) 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)
@ -49,7 +50,11 @@ async function loadGroup(
if (items.length < need && !isImage) { if (items.length < need && !isImage) {
// Complément depuis la banque en dur (uniquement le quiz, pas d'images en dur). // Complément depuis la banque en dur (uniquement le quiz, pas d'images en dur).
const bank = shuffle( const bank = shuffle(
QUIZ_QUESTIONS.filter((q) => formats.includes(q.format)) QUIZ_QUESTIONS.filter(
(q) =>
formats.includes(q.format) &&
(categories.length === 0 || categories.includes(q.category))
)
) )
for (const question of bank) { for (const question of bank) {
if (items.length >= need) break if (items.length >= need) break
@ -65,8 +70,9 @@ export async function prepareQuizForRoom(room: ServerRoom): Promise<void> {
const needImage = quizRounds.filter((r) => r.pool === "image").length const needImage = quizRounds.filter((r) => r.pool === "image").length
const needQuiz = quizRounds.length - needImage const needQuiz = quizRounds.length - needImage
const quizGroup = await loadGroup(room, needQuiz, QUIZ_FORMATS, false) const cats = room.settings.categories
const imageGroup = await loadGroup(room, needImage, IMAGE_FORMATS, true) 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). // On assemble la file dans l'ordre des manches (chacune pioche dans son sous-pool).
const queue: QueueItem[] = [] const queue: QueueItem[] = []

View file

@ -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)

View file

@ -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.

View file

@ -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,
@ -133,8 +136,10 @@ 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 [quizCount, setQuizCount] = useState(5) const [quizCount, setQuizCount] = useState(5)
const [imageCount, setImageCount] = useState(5) const [imageCount, setImageCount] = useState(5)
@ -186,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)
@ -325,6 +340,40 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
</div> </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>
)}
{/* Réglages blindtest */} {/* Réglages blindtest */}
{showBlindtest && ( {showBlindtest && (
<div className="flex flex-col gap-3 border-t pt-3"> <div className="flex flex-col gap-3 border-t pt-3">

View 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 []
}
}

View file

@ -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,
}) })
}, },

View file

@ -44,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[]
} }
@ -55,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: [],
} }

View file

@ -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[]
} }