release/0.1.0 #18
15 changed files with 535 additions and 49 deletions
3
apps/server/drizzle/0001_fancy_captain_stacy.sql
Normal file
3
apps/server/drizzle/0001_fancy_captain_stacy.sql
Normal file
|
|
@ -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;
|
||||
288
apps/server/drizzle/meta/0001_snapshot.json
Normal file
288
apps/server/drizzle/meta/0001_snapshot.json
Normal file
|
|
@ -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": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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." }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
primaryKey,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core"
|
||||
|
||||
|
|
@ -20,13 +21,14 @@ export const quizCategory = pgTable("quiz_category", {
|
|||
.default(sql`now()`),
|
||||
})
|
||||
|
||||
export const quizQuestion = pgTable("quiz_question", {
|
||||
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(),
|
||||
prompt: text("prompt").notNull(),
|
||||
/** 1 (facile) .. 3 (difficile). */
|
||||
difficulty: integer("difficulty").notNull().default(1),
|
||||
/** 'opentdb' | 'manual' */
|
||||
|
|
@ -39,7 +41,18 @@ export const quizQuestion = pgTable("quiz_question", {
|
|||
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(
|
||||
|
|
|
|||
94
apps/server/src/db/seed-images.ts
Normal file
94
apps/server/src/db/seed-images.ts
Normal file
|
|
@ -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<string> {
|
||||
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<NewQuizQuestion | null> {
|
||||
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()
|
||||
|
|
@ -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<ServerRoom, RoomPool>()
|
||||
|
||||
/** 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)
|
||||
}
|
||||
// mixte : tout (le blindtest n'a pas de manche quiz)
|
||||
return ["mcq", "truefalse", "free", "image_reveal"]
|
||||
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[] {
|
||||
|
|
@ -40,7 +49,7 @@ function shuffle<T>(items: T[]): T[] {
|
|||
|
||||
/** Précharge les questions de la partie. À appeler avant de lancer le moteur. */
|
||||
export async function prepareQuizForRoom(room: ServerRoom): Promise<void> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ export function registerRoomHandlers(
|
|||
}
|
||||
room.settings = {
|
||||
gameType: payload.gameType,
|
||||
mixedModes: payload.mixedModes,
|
||||
blindtestMode: payload.blindtestMode,
|
||||
roundDuration: payload.roundDuration,
|
||||
tracksPerPlayer: payload.tracksPerPlayer,
|
||||
|
|
|
|||
|
|
@ -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<MixMode, string> = {
|
||||
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 }) {
|
|||
</div>
|
||||
{!blindtestAvailable && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Blindtest & Mixte se débloquent à 3 joueurs.
|
||||
Le blindtest se débloque à 3 joueurs.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sous-modes du mixte (toggles) */}
|
||||
{isMixed && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium">Sous-modes inclus</span>
|
||||
<div className="flex gap-2">
|
||||
{(["quiz", "image", "blindtest"] as const).map((m) => {
|
||||
const locked = m === "blindtest" && !blindtestAvailable
|
||||
const on = mixedModes.includes(m) && !locked
|
||||
return (
|
||||
<button
|
||||
key={m}
|
||||
disabled={locked}
|
||||
title={locked ? "3 joueurs minimum" : undefined}
|
||||
onClick={() => toggleMix(m)}
|
||||
className={`flex-1 rounded-lg border px-2 py-1.5 text-xs font-medium transition-colors disabled:opacity-40 ${
|
||||
on
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-input hover:bg-muted/60"
|
||||
}`}
|
||||
>
|
||||
{MIX_LABELS[m]}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Nombre de questions (quiz / images / mixte) */}
|
||||
{showQuestions && (
|
||||
<div className="flex flex-col gap-1">
|
||||
|
|
|
|||
|
|
@ -179,15 +179,12 @@ function ImageReveal({
|
|||
const displayBlur = revealed ? 0 : blur
|
||||
|
||||
return (
|
||||
<div className="bg-card relative mx-auto aspect-video w-full overflow-hidden rounded-2xl">
|
||||
<div className="bg-card relative flex w-full justify-center overflow-hidden rounded-2xl p-2">
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
className="size-full object-cover transition-[filter] duration-300"
|
||||
style={{
|
||||
filter: `blur(${displayBlur}px)`,
|
||||
transform: "scale(1.1)", // évite les bords flous
|
||||
}}
|
||||
className="max-h-[58vh] w-auto max-w-full rounded-lg object-contain transition-[filter] duration-300"
|
||||
style={{ filter: `blur(${displayBlur}px)` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -287,7 +287,11 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
|
|||
/>
|
||||
<input
|
||||
className={inputClass}
|
||||
placeholder="Intitulé de la question"
|
||||
placeholder={
|
||||
format === "image_reveal"
|
||||
? "Intitulé (optionnel — déf. « Qui est-ce ? »)"
|
||||
: "Intitulé de la question"
|
||||
}
|
||||
value={prompt}
|
||||
onChange={(e) => 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -139,6 +139,7 @@ export const useRoomStore = create<RoomState>((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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue