From 8bc184fab7ae1cc083fe9568d24e3e172a9ad2a7 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Thu, 11 Jun 2026 00:20:28 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20bigger=20image=5Freveal,=20optional=20i?= =?UTF-8?q?mage=20prompt,=20mixed=20sub-mode=20toggles,=20Pok=C3=A9mon=20s?= =?UTF-8?q?eed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - image_reveal display: larger, object-contain (whole image up to 58vh), no crop - image prompt optional in back-office (defaults to "Qui est-ce ?"), overridable - mixed mode reworked: select "Mixte" then toggle sub-modes (Quiz/Images/ Blindtest). Blindtest sub-mode locked under 3 players; mixed itself no longer needs 3. Server quiz pool formats derived from gameType + mixedModes. shared: MixMode + RoomSettings.mixedModes (default quiz+image). - DB: prompt uniqueness is now a partial index (excludes image_reveal) + unique index on image_url; lets many images share the generic prompt, dedup by URL. - seed: db:seed:images pulls Pokémon (PokéAPI) as image_reveal questions with official artwork (remote URL) + FR/EN accepted answers; idempotent by image_url. Verified: migration applied, Pokémon seed inserts FR+EN, image mode serves only image_reveal, all checks green (23 tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../drizzle/0001_fancy_captain_stacy.sql | 3 + apps/server/drizzle/meta/0001_snapshot.json | 288 ++++++++++++++++++ apps/server/drizzle/meta/_journal.json | 7 + apps/server/package.json | 1 + apps/server/src/admin/routes.ts | 5 +- apps/server/src/db/schema.ts | 53 ++-- apps/server/src/db/seed-images.ts | 94 ++++++ apps/server/src/game/modes/quiz/pool.ts | 27 +- apps/server/src/handlers.ts | 1 + apps/web/src/components/lobby-view.tsx | 79 ++++- apps/web/src/components/quiz-view.tsx | 9 +- apps/web/src/pages/admin.tsx | 8 +- apps/web/src/store/room.ts | 1 + packages/shared/src/domain.ts | 6 + packages/shared/src/events.ts | 2 + 15 files changed, 535 insertions(+), 49 deletions(-) create mode 100644 apps/server/drizzle/0001_fancy_captain_stacy.sql create mode 100644 apps/server/drizzle/meta/0001_snapshot.json create mode 100644 apps/server/src/db/seed-images.ts diff --git a/apps/server/drizzle/0001_fancy_captain_stacy.sql b/apps/server/drizzle/0001_fancy_captain_stacy.sql new file mode 100644 index 0000000..63bef25 --- /dev/null +++ b/apps/server/drizzle/0001_fancy_captain_stacy.sql @@ -0,0 +1,3 @@ +ALTER TABLE "quiz_question" DROP CONSTRAINT "quiz_question_prompt_unique";--> statement-breakpoint +CREATE UNIQUE INDEX "quiz_question_prompt_unique" ON "quiz_question" USING btree ("prompt") WHERE "quiz_question"."format" <> 'image_reveal';--> statement-breakpoint +CREATE UNIQUE INDEX "quiz_question_image_unique" ON "quiz_question" USING btree ("image_url") WHERE "quiz_question"."image_url" is not null; \ No newline at end of file diff --git a/apps/server/drizzle/meta/0001_snapshot.json b/apps/server/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..a125720 --- /dev/null +++ b/apps/server/drizzle/meta/0001_snapshot.json @@ -0,0 +1,288 @@ +{ + "id": "6de8c2ee-3634-455b-ba4b-372368fc2e66", + "prevId": "c68e2f59-3d1b-4fca-8443-17fe243e2919", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.game_history": { + "name": "game_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "played_at": { + "name": "played_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "modes": { + "name": "modes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "results": { + "name": "results", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_category": { + "name": "quiz_category", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "quiz_category_name_unique": { + "name": "quiz_category_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_played": { + "name": "quiz_played", + "schema": "", + "columns": { + "question_id": { + "name": "question_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "played_at": { + "name": "played_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "quiz_played_question_id_quiz_question_id_fk": { + "name": "quiz_played_question_id_quiz_question_id_fk", + "tableFrom": "quiz_played", + "tableTo": "quiz_question", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "quiz_played_question_id_room_code_pk": { + "name": "quiz_played_question_id_room_code_pk", + "columns": [ + "question_id", + "room_code" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_question": { + "name": "quiz_question", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "difficulty": { + "name": "difficulty", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "choices": { + "name": "choices", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "correct_index": { + "name": "correct_index", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "accepted_answers": { + "name": "accepted_answers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "quiz_question_prompt_unique": { + "name": "quiz_question_prompt_unique", + "columns": [ + { + "expression": "prompt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"quiz_question\".\"format\" <> 'image_reveal'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "quiz_question_image_unique": { + "name": "quiz_question_image_unique", + "columns": [ + { + "expression": "image_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"quiz_question\".\"image_url\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_question_category_id_quiz_category_id_fk": { + "name": "quiz_question_category_id_quiz_category_id_fk", + "tableFrom": "quiz_question", + "tableTo": "quiz_category", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index 4a441f0..985351f 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1781085893179, "tag": "0000_loose_doctor_spectrum", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1781129455626, + "tag": "0001_fancy_captain_stacy", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/server/package.json b/apps/server/package.json index 6dced34..c2e5525 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -14,6 +14,7 @@ "db:generate": "drizzle-kit generate", "db:migrate": "bun src/db/migrate.ts", "db:seed": "bun src/db/seed.ts", + "db:seed:images": "bun src/db/seed-images.ts", "db:studio": "drizzle-kit studio" }, "dependencies": { diff --git a/apps/server/src/admin/routes.ts b/apps/server/src/admin/routes.ts index 19cc1b8..acc961f 100644 --- a/apps/server/src/admin/routes.ts +++ b/apps/server/src/admin/routes.ts @@ -34,7 +34,10 @@ function validate( if (!FORMATS.includes(format)) { return { error: "Format invalide." } } - const prompt = (body.prompt ?? "").trim() + // L'intitulé est optionnel pour image_reveal (défaut générique). + const prompt = + (body.prompt ?? "").trim() || + (format === "image_reveal" ? "Qui est-ce ?" : "") if (prompt.length === 0) { return { error: "L'intitulé est obligatoire." } } diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts index 668257f..778bc75 100644 --- a/apps/server/src/db/schema.ts +++ b/apps/server/src/db/schema.ts @@ -9,6 +9,7 @@ import { primaryKey, text, timestamp, + uniqueIndex, uuid, } from "drizzle-orm/pg-core" @@ -20,26 +21,38 @@ export const quizCategory = pgTable("quiz_category", { .default(sql`now()`), }) -export const quizQuestion = pgTable("quiz_question", { - id: uuid("id").primaryKey().defaultRandom(), - categoryId: uuid("category_id").references(() => quizCategory.id), - /** 'mcq' | 'truefalse' | 'free' | 'image_reveal' */ - format: text("format").notNull(), - /** Unique → seed idempotent (onConflictDoNothing). */ - prompt: text("prompt").notNull().unique(), - /** 1 (facile) .. 3 (difficile). */ - difficulty: integer("difficulty").notNull().default(1), - /** 'opentdb' | 'manual' */ - source: text("source").notNull(), - // Champs selon le format (NULL si non applicable) : - choices: jsonb("choices").$type(), - correctIndex: integer("correct_index"), - acceptedAnswers: jsonb("accepted_answers").$type(), - imageUrl: text("image_url"), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .default(sql`now()`), -}) +export const quizQuestion = pgTable( + "quiz_question", + { + id: uuid("id").primaryKey().defaultRandom(), + categoryId: uuid("category_id").references(() => quizCategory.id), + /** 'mcq' | 'truefalse' | 'free' | 'image_reveal' */ + format: text("format").notNull(), + prompt: text("prompt").notNull(), + /** 1 (facile) .. 3 (difficile). */ + difficulty: integer("difficulty").notNull().default(1), + /** 'opentdb' | 'manual' */ + source: text("source").notNull(), + // Champs selon le format (NULL si non applicable) : + choices: jsonb("choices").$type(), + correctIndex: integer("correct_index"), + acceptedAnswers: jsonb("accepted_answers").$type(), + imageUrl: text("image_url"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .default(sql`now()`), + }, + // Unique sur l'intitulé SAUF pour les images (même intitulé "Qui est-ce ?" + // pour des images différentes). Les images se dédupliquent par image_url. + (t) => [ + uniqueIndex("quiz_question_prompt_unique") + .on(t.prompt) + .where(sql`${t.format} <> 'image_reveal'`), + uniqueIndex("quiz_question_image_unique") + .on(t.imageUrl) + .where(sql`${t.imageUrl} is not null`), + ] +) /** Anti-répétition : ce qu'un groupe (room_code) a déjà joué. */ export const quizPlayed = pgTable( diff --git a/apps/server/src/db/seed-images.ts b/apps/server/src/db/seed-images.ts new file mode 100644 index 0000000..8cfcb7c --- /dev/null +++ b/apps/server/src/db/seed-images.ts @@ -0,0 +1,94 @@ +// Seed du mode Images depuis PokéAPI : "devine le Pokémon" à partir de +// l'artwork officiel (URL distante, pas d'upload). Réponses acceptées FR + EN. +// bun run db:seed:images (gen 1, 151 Pokémon) +// IMAGE_SEED_COUNT=50 bun run db:seed:images +// Idempotent : déduplication par image_url (onConflictDoNothing). + +import { eq } from "drizzle-orm" +import { drizzle } from "drizzle-orm/postgres-js" +import postgres from "postgres" +import { env } from "../env" +import { quizCategory, quizQuestion, type NewQuizQuestion } from "./schema" + +if (!env.databaseUrl) { + console.error("DATABASE_URL manquant — impossible de seeder.") + process.exit(1) +} + +const COUNT = Number(process.env.IMAGE_SEED_COUNT ?? 151) +const CATEGORY = "Pokémon" +const PROMPT = "Quel est ce Pokémon ?" +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) + +const client = postgres(env.databaseUrl, { max: 1 }) +const db = drizzle(client, { schema: { quizCategory, quizQuestion } }) + +async function upsertCategory(name: string): Promise { + await db.insert(quizCategory).values({ name }).onConflictDoNothing() + const [row] = await db + .select({ id: quizCategory.id }) + .from(quizCategory) + .where(eq(quizCategory.name, name)) + return row.id +} + +interface Pokemon { + name: string + sprites: { other?: { ["official-artwork"]?: { front_default: string | null } } } +} +interface Species { + names: { name: string; language: { name: string } }[] +} + +async function fetchPokemon(id: number): Promise { + try { + const [pRes, sRes] = await Promise.all([ + fetch(`https://pokeapi.co/api/v2/pokemon/${id}`), + fetch(`https://pokeapi.co/api/v2/pokemon-species/${id}`), + ]) + if (!pRes.ok || !sRes.ok) { + return null + } + const p = (await pRes.json()) as Pokemon + const s = (await sRes.json()) as Species + const art = p.sprites.other?.["official-artwork"]?.front_default + if (!art) { + return null + } + const fr = s.names.find((n) => n.language.name === "fr")?.name + const en = s.names.find((n) => n.language.name === "en")?.name ?? p.name + const answers = [...new Set([fr, en].filter(Boolean) as string[])] + return { + categoryId: "", // rempli plus bas + format: "image_reveal", + prompt: PROMPT, + difficulty: 2, + source: "pokeapi", + acceptedAnswers: answers, + imageUrl: art, + } + } catch { + return null + } +} + +console.log(`Seed Images depuis PokéAPI (${COUNT} Pokémon)…`) +const categoryId = await upsertCategory(CATEGORY) +let inserted = 0 +for (let id = 1; id <= COUNT; id++) { + const q = await fetchPokemon(id) + if (q) { + const res = await db + .insert(quizQuestion) + .values({ ...q, categoryId }) + .onConflictDoNothing() + .returning({ id: quizQuestion.id }) + inserted += res.length + } + if (id % 25 === 0) { + console.log(` ${id}/${COUNT}…`) + } + await sleep(40) // courtoisie envers l'API +} +console.log(`Terminé : ${inserted} Pokémon ajoutés (mode Images).`) +await client.end() diff --git a/apps/server/src/game/modes/quiz/pool.ts b/apps/server/src/game/modes/quiz/pool.ts index 569e2a9..aadb522 100644 --- a/apps/server/src/game/modes/quiz/pool.ts +++ b/apps/server/src/game/modes/quiz/pool.ts @@ -2,7 +2,7 @@ // 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 type { QuizFormat, RoomSettings } from "@nerdware/shared" import { hasDb } from "../../../db" import { loadQuizPool } from "../../../db/quiz-repo" import type { ServerRoom } from "../../../rooms" @@ -17,16 +17,25 @@ interface RoomPool { const poolByRoom = new WeakMap() -/** Formats de quiz autorisés selon le type de partie. */ -function quizFormatsFor(gameType: GameType): QuizFormat[] { - if (gameType === "image") { +const QUIZ_FORMATS: QuizFormat[] = ["mcq", "truefalse", "free"] + +/** 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 (gameType === "quiz") { - return ["mcq", "truefalse", "free"] + 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 } - // mixte : tout (le blindtest n'a pas de manche quiz) - return ["mcq", "truefalse", "free", "image_reveal"] + // quiz, ou blindtest retombé sur du quiz (<3 joueurs) + return QUIZ_FORMATS } function shuffle(items: T[]): T[] { @@ -40,7 +49,7 @@ function shuffle(items: T[]): T[] { /** Précharge les questions de la partie. À appeler avant de lancer le moteur. */ export async function prepareQuizForRoom(room: ServerRoom): Promise { - const formats = quizFormatsFor(room.settings.gameType) + const formats = quizFormatsFor(room.settings) const needed = room.settings.rounds.filter((r) => r.type === "quiz").length let queue: QuizQuestion[] = [] let fromDb = false diff --git a/apps/server/src/handlers.ts b/apps/server/src/handlers.ts index 4b15c83..3184db6 100644 --- a/apps/server/src/handlers.ts +++ b/apps/server/src/handlers.ts @@ -106,6 +106,7 @@ export function registerRoomHandlers( } room.settings = { gameType: payload.gameType, + mixedModes: payload.mixedModes, blindtestMode: payload.blindtestMode, roundDuration: payload.roundDuration, tracksPerPlayer: payload.tracksPerPlayer, diff --git a/apps/web/src/components/lobby-view.tsx b/apps/web/src/components/lobby-view.tsx index 272f666..215ba33 100644 --- a/apps/web/src/components/lobby-view.tsx +++ b/apps/web/src/components/lobby-view.tsx @@ -16,9 +16,16 @@ import { SiYoutube } from "@icons-pack/react-simple-icons" import type { BlindtestMode, GameType, + MixMode, RoomSnapshot, RoundConfig, } from "@nerdware/shared" + +const MIX_LABELS: Record = { + quiz: "Quiz", + image: "Images", + blindtest: "Blindtest", +} import { Button } from "@workspace/ui/components/button" import { useRoomStore } from "@/store/room" import { Avatar } from "@/components/avatar" @@ -30,7 +37,7 @@ const GAME_TYPES: { Icon: LucideIcon needsThree: boolean }[] = [ - { value: "mixed", label: "Mixte", Icon: Shuffle, needsThree: true }, + { value: "mixed", label: "Mixte", Icon: Shuffle, needsThree: false }, { value: "quiz", label: "Quiz", Icon: Brain, needsThree: false }, { value: "image", label: "Images", Icon: ImageIcon, needsThree: false }, { value: "blindtest", label: "Blindtest", Icon: Headphones, needsThree: true }, @@ -125,7 +132,8 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { const updateSettings = useRoomStore((s) => s.updateSettings) const startGame = useRoomStore((s) => s.startGame) const isHost = snapshot.hostId === playerId - const { gameType, blindtestMode, tracksPerPlayer } = snapshot.settings + const { gameType, mixedModes, blindtestMode, tracksPerPlayer } = + snapshot.settings const [count, setCount] = useState(5) const [busy, setBusy] = useState(false) @@ -138,16 +146,27 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { // 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 - // Si le mode choisi a besoin du blindtest mais qu'on est <3, on retombe sur - // du quiz (sans toucher au réglage stocké) : se rétablit à 3 joueurs. - const needsBlindtest = gameType === "blindtest" || gameType === "mixed" + // Seul le blindtest "seul" retombe sur du quiz à <3 ; le mixte reste mixte + // (son sous-mode blindtest est juste désactivé tant qu'on n'est pas 3). const effectiveType: GameType = - needsBlindtest && !blindtestAvailable ? "quiz" : gameType - const showQuestions = effectiveType !== "blindtest" - const showBlindtest = - effectiveType === "blindtest" || effectiveType === "mixed" + gameType === "blindtest" && !blindtestAvailable ? "quiz" : gameType + const isMixed = effectiveType === "mixed" - const rounds = buildRounds(effectiveType, showQuestions ? count : 0, totalTracks) + // Sous-modes effectivement actifs. + const wantsQuestions = + effectiveType === "quiz" || + effectiveType === "image" || + (isMixed && (mixedModes.includes("quiz") || mixedModes.includes("image"))) + const showQuestions = wantsQuestions + const showBlindtest = + effectiveType === "blindtest" || + (isMixed && mixedModes.includes("blindtest") && blindtestAvailable) + + const rounds = buildRounds( + effectiveType, + showQuestions ? count : 0, + showBlindtest ? totalTracks : 0 + ) // Blindtest : tout le monde doit avoir soumis son quota de titres. const allSubmitted = !showBlindtest || @@ -155,6 +174,16 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { snapshot.submissions.every((s) => s.count >= tracksPerPlayer)) const canStart = rounds.length > 0 && allSubmitted + function toggleMix(mode: MixMode) { + const set = new Set(mixedModes) + if (set.has(mode)) { + set.delete(mode) + } else { + set.add(mode) + } + updateSettings({ mixedModes: [...set] }) + } + async function start() { setError(null) setBusy(true) @@ -230,11 +259,39 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { {!blindtestAvailable && (

- Blindtest & Mixte se débloquent à 3 joueurs. + Le blindtest se débloque à 3 joueurs.

)} + {/* Sous-modes du mixte (toggles) */} + {isMixed && ( +
+ Sous-modes inclus +
+ {(["quiz", "image", "blindtest"] as const).map((m) => { + const locked = m === "blindtest" && !blindtestAvailable + const on = mixedModes.includes(m) && !locked + return ( + + ) + })} +
+
+ )} + {/* Nombre de questions (quiz / images / mixte) */} {showQuestions && (
diff --git a/apps/web/src/components/quiz-view.tsx b/apps/web/src/components/quiz-view.tsx index 57f662e..4a5d31b 100644 --- a/apps/web/src/components/quiz-view.tsx +++ b/apps/web/src/components/quiz-view.tsx @@ -179,15 +179,12 @@ function ImageReveal({ const displayBlur = revealed ? 0 : blur return ( -
+
) diff --git a/apps/web/src/pages/admin.tsx b/apps/web/src/pages/admin.tsx index 1274411..149b17c 100644 --- a/apps/web/src/pages/admin.tsx +++ b/apps/web/src/pages/admin.tsx @@ -287,7 +287,11 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) { /> setPrompt(e.target.value)} /> @@ -388,7 +392,7 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) { disabled={ create.isPending || uploading || - !prompt.trim() || + (!prompt.trim() && format !== "image_reveal") || !category.trim() || (format === "image_reveal" && !imageUrl) } diff --git a/apps/web/src/store/room.ts b/apps/web/src/store/room.ts index b134866..74cdad0 100644 --- a/apps/web/src/store/room.ts +++ b/apps/web/src/store/room.ts @@ -139,6 +139,7 @@ export const useRoomStore = create((set, get) => ({ const current = get().snapshot?.settings ?? DEFAULT_ROOM_SETTINGS socket.emit("lobby:updateSettings", { gameType: partial.gameType ?? current.gameType, + mixedModes: partial.mixedModes ?? current.mixedModes, blindtestMode: partial.blindtestMode ?? current.blindtestMode, roundDuration: partial.roundDuration ?? current.roundDuration, tracksPerPlayer: partial.tracksPerPlayer ?? current.tracksPerPlayer, diff --git a/packages/shared/src/domain.ts b/packages/shared/src/domain.ts index 9b84a73..0f9a7c7 100644 --- a/packages/shared/src/domain.ts +++ b/packages/shared/src/domain.ts @@ -10,6 +10,9 @@ export type RoundType = "blindtest" | "quiz" /** Type de partie choisi au lobby : mélange par défaut, ou un seul mode. */ export type GameType = "mixed" | "quiz" | "image" | "blindtest" +/** Sous-modes activables quand la partie est "mixed". */ +export type MixMode = "quiz" | "image" | "blindtest" + /** Modes de blindtest, déterminent la forme du vote et le barème. */ export type BlindtestMode = "title_artist" | "who_added" | "mixed" @@ -32,6 +35,8 @@ export interface RoundConfig { export interface RoomSettings { /** Type de partie choisi dans le lobby (pilote l'UI partagée). */ gameType: GameType + /** Sous-modes actifs quand gameType === "mixed". */ + mixedModes: MixMode[] blindtestMode: BlindtestMode /** Durée d'une manche en secondes (def. 60). */ roundDuration: number @@ -44,6 +49,7 @@ export interface RoomSettings { /** Réglages par défaut d'une nouvelle room. */ export const DEFAULT_ROOM_SETTINGS: RoomSettings = { gameType: "mixed", + mixedModes: ["quiz", "image"], blindtestMode: "title_artist", roundDuration: 60, tracksPerPlayer: 2, diff --git a/packages/shared/src/events.ts b/packages/shared/src/events.ts index 2c1f023..71616ba 100644 --- a/packages/shared/src/events.ts +++ b/packages/shared/src/events.ts @@ -5,6 +5,7 @@ import type { Answer, BlindtestMode, GameType, + MixMode, PlayerScore, RoomSnapshot, RoundConfig, @@ -34,6 +35,7 @@ export interface SetNamePayload { export interface UpdateSettingsPayload { gameType: GameType + mixedModes: MixMode[] blindtestMode: BlindtestMode roundDuration: number tracksPerPlayer: number