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,
|
"when": 1781085893179,
|
||||||
"tag": "0000_loose_doctor_spectrum",
|
"tag": "0000_loose_doctor_spectrum",
|
||||||
"breakpoints": true
|
"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:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "bun src/db/migrate.ts",
|
"db:migrate": "bun src/db/migrate.ts",
|
||||||
"db:seed": "bun src/db/seed.ts",
|
"db:seed": "bun src/db/seed.ts",
|
||||||
|
"db:seed:images": "bun src/db/seed-images.ts",
|
||||||
"db:studio": "drizzle-kit studio"
|
"db:studio": "drizzle-kit studio"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,10 @@ function validate(
|
||||||
if (!FORMATS.includes(format)) {
|
if (!FORMATS.includes(format)) {
|
||||||
return { error: "Format invalide." }
|
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) {
|
if (prompt.length === 0) {
|
||||||
return { error: "L'intitulé est obligatoire." }
|
return { error: "L'intitulé est obligatoire." }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
primaryKey,
|
primaryKey,
|
||||||
text,
|
text,
|
||||||
timestamp,
|
timestamp,
|
||||||
|
uniqueIndex,
|
||||||
uuid,
|
uuid,
|
||||||
} from "drizzle-orm/pg-core"
|
} from "drizzle-orm/pg-core"
|
||||||
|
|
||||||
|
|
@ -20,13 +21,14 @@ export const quizCategory = pgTable("quiz_category", {
|
||||||
.default(sql`now()`),
|
.default(sql`now()`),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const quizQuestion = pgTable("quiz_question", {
|
export const quizQuestion = pgTable(
|
||||||
|
"quiz_question",
|
||||||
|
{
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
categoryId: uuid("category_id").references(() => quizCategory.id),
|
categoryId: uuid("category_id").references(() => quizCategory.id),
|
||||||
/** 'mcq' | 'truefalse' | 'free' | 'image_reveal' */
|
/** 'mcq' | 'truefalse' | 'free' | 'image_reveal' */
|
||||||
format: text("format").notNull(),
|
format: text("format").notNull(),
|
||||||
/** Unique → seed idempotent (onConflictDoNothing). */
|
prompt: text("prompt").notNull(),
|
||||||
prompt: text("prompt").notNull().unique(),
|
|
||||||
/** 1 (facile) .. 3 (difficile). */
|
/** 1 (facile) .. 3 (difficile). */
|
||||||
difficulty: integer("difficulty").notNull().default(1),
|
difficulty: integer("difficulty").notNull().default(1),
|
||||||
/** 'opentdb' | 'manual' */
|
/** 'opentdb' | 'manual' */
|
||||||
|
|
@ -39,7 +41,18 @@ export const quizQuestion = pgTable("quiz_question", {
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`now()`),
|
.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é. */
|
/** Anti-répétition : ce qu'un groupe (room_code) a déjà joué. */
|
||||||
export const quizPlayed = pgTable(
|
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.
|
// 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).
|
// 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 { 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"
|
||||||
|
|
@ -17,16 +17,25 @@ interface RoomPool {
|
||||||
|
|
||||||
const poolByRoom = new WeakMap<ServerRoom, RoomPool>()
|
const poolByRoom = new WeakMap<ServerRoom, RoomPool>()
|
||||||
|
|
||||||
/** Formats de quiz autorisés selon le type de partie. */
|
const QUIZ_FORMATS: QuizFormat[] = ["mcq", "truefalse", "free"]
|
||||||
function quizFormatsFor(gameType: GameType): QuizFormat[] {
|
|
||||||
if (gameType === "image") {
|
/** 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"]
|
return ["image_reveal"]
|
||||||
}
|
}
|
||||||
if (gameType === "quiz") {
|
if (settings.gameType === "mixed") {
|
||||||
return ["mcq", "truefalse", "free"]
|
const formats: QuizFormat[] = []
|
||||||
|
if (settings.mixedModes.includes("quiz")) {
|
||||||
|
formats.push(...QUIZ_FORMATS)
|
||||||
}
|
}
|
||||||
// mixte : tout (le blindtest n'a pas de manche quiz)
|
if (settings.mixedModes.includes("image")) {
|
||||||
return ["mcq", "truefalse", "free", "image_reveal"]
|
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[] {
|
||||||
|
|
@ -40,7 +49,7 @@ function shuffle<T>(items: T[]): T[] {
|
||||||
|
|
||||||
/** Précharge les questions de la partie. À appeler avant de lancer le moteur. */
|
/** Précharge les questions de la partie. À appeler avant de lancer le moteur. */
|
||||||
export async function prepareQuizForRoom(room: ServerRoom): Promise<void> {
|
export async function prepareQuizForRoom(room: ServerRoom): Promise<void> {
|
||||||
const formats = quizFormatsFor(room.settings.gameType)
|
const formats = quizFormatsFor(room.settings)
|
||||||
const needed = room.settings.rounds.filter((r) => r.type === "quiz").length
|
const needed = room.settings.rounds.filter((r) => r.type === "quiz").length
|
||||||
let queue: QuizQuestion[] = []
|
let queue: QuizQuestion[] = []
|
||||||
let fromDb = false
|
let fromDb = false
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,7 @@ export function registerRoomHandlers(
|
||||||
}
|
}
|
||||||
room.settings = {
|
room.settings = {
|
||||||
gameType: payload.gameType,
|
gameType: payload.gameType,
|
||||||
|
mixedModes: payload.mixedModes,
|
||||||
blindtestMode: payload.blindtestMode,
|
blindtestMode: payload.blindtestMode,
|
||||||
roundDuration: payload.roundDuration,
|
roundDuration: payload.roundDuration,
|
||||||
tracksPerPlayer: payload.tracksPerPlayer,
|
tracksPerPlayer: payload.tracksPerPlayer,
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,16 @@ import { SiYoutube } from "@icons-pack/react-simple-icons"
|
||||||
import type {
|
import type {
|
||||||
BlindtestMode,
|
BlindtestMode,
|
||||||
GameType,
|
GameType,
|
||||||
|
MixMode,
|
||||||
RoomSnapshot,
|
RoomSnapshot,
|
||||||
RoundConfig,
|
RoundConfig,
|
||||||
} from "@nerdware/shared"
|
} from "@nerdware/shared"
|
||||||
|
|
||||||
|
const MIX_LABELS: Record<MixMode, string> = {
|
||||||
|
quiz: "Quiz",
|
||||||
|
image: "Images",
|
||||||
|
blindtest: "Blindtest",
|
||||||
|
}
|
||||||
import { Button } from "@workspace/ui/components/button"
|
import { Button } from "@workspace/ui/components/button"
|
||||||
import { useRoomStore } from "@/store/room"
|
import { useRoomStore } from "@/store/room"
|
||||||
import { Avatar } from "@/components/avatar"
|
import { Avatar } from "@/components/avatar"
|
||||||
|
|
@ -30,7 +37,7 @@ const GAME_TYPES: {
|
||||||
Icon: LucideIcon
|
Icon: LucideIcon
|
||||||
needsThree: boolean
|
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: "quiz", label: "Quiz", Icon: Brain, needsThree: false },
|
||||||
{ value: "image", label: "Images", Icon: ImageIcon, needsThree: false },
|
{ value: "image", label: "Images", Icon: ImageIcon, needsThree: false },
|
||||||
{ value: "blindtest", label: "Blindtest", Icon: Headphones, needsThree: true },
|
{ 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 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, blindtestMode, tracksPerPlayer } = snapshot.settings
|
const { gameType, mixedModes, blindtestMode, tracksPerPlayer } =
|
||||||
|
snapshot.settings
|
||||||
|
|
||||||
const [count, setCount] = useState(5)
|
const [count, setCount] = useState(5)
|
||||||
const [busy, setBusy] = useState(false)
|
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).
|
// Le blindtest exige au moins 3 joueurs connectés (DJ + 2 devineurs).
|
||||||
const connectedCount = snapshot.players.filter((p) => p.connected).length
|
const connectedCount = snapshot.players.filter((p) => p.connected).length
|
||||||
const blindtestAvailable = connectedCount >= 3
|
const blindtestAvailable = connectedCount >= 3
|
||||||
// Si le mode choisi a besoin du blindtest mais qu'on est <3, on retombe sur
|
// Seul le blindtest "seul" retombe sur du quiz à <3 ; le mixte reste mixte
|
||||||
// du quiz (sans toucher au réglage stocké) : se rétablit à 3 joueurs.
|
// (son sous-mode blindtest est juste désactivé tant qu'on n'est pas 3).
|
||||||
const needsBlindtest = gameType === "blindtest" || gameType === "mixed"
|
|
||||||
const effectiveType: GameType =
|
const effectiveType: GameType =
|
||||||
needsBlindtest && !blindtestAvailable ? "quiz" : gameType
|
gameType === "blindtest" && !blindtestAvailable ? "quiz" : gameType
|
||||||
const showQuestions = effectiveType !== "blindtest"
|
const isMixed = effectiveType === "mixed"
|
||||||
const showBlindtest =
|
|
||||||
effectiveType === "blindtest" || 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.
|
// Blindtest : tout le monde doit avoir soumis son quota de titres.
|
||||||
const allSubmitted =
|
const allSubmitted =
|
||||||
!showBlindtest ||
|
!showBlindtest ||
|
||||||
|
|
@ -155,6 +174,16 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
snapshot.submissions.every((s) => s.count >= tracksPerPlayer))
|
snapshot.submissions.every((s) => s.count >= tracksPerPlayer))
|
||||||
const canStart = rounds.length > 0 && allSubmitted
|
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() {
|
async function start() {
|
||||||
setError(null)
|
setError(null)
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
|
|
@ -230,11 +259,39 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
</div>
|
</div>
|
||||||
{!blindtestAvailable && (
|
{!blindtestAvailable && (
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
Blindtest & Mixte se débloquent à 3 joueurs.
|
Le blindtest se débloque à 3 joueurs.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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) */}
|
{/* Nombre de questions (quiz / images / mixte) */}
|
||||||
{showQuestions && (
|
{showQuestions && (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
|
|
|
||||||
|
|
@ -179,15 +179,12 @@ function ImageReveal({
|
||||||
const displayBlur = revealed ? 0 : blur
|
const displayBlur = revealed ? 0 : blur
|
||||||
|
|
||||||
return (
|
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
|
<img
|
||||||
src={src}
|
src={src}
|
||||||
alt=""
|
alt=""
|
||||||
className="size-full object-cover transition-[filter] duration-300"
|
className="max-h-[58vh] w-auto max-w-full rounded-lg object-contain transition-[filter] duration-300"
|
||||||
style={{
|
style={{ filter: `blur(${displayBlur}px)` }}
|
||||||
filter: `blur(${displayBlur}px)`,
|
|
||||||
transform: "scale(1.1)", // évite les bords flous
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -287,7 +287,11 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
placeholder="Intitulé de la question"
|
placeholder={
|
||||||
|
format === "image_reveal"
|
||||||
|
? "Intitulé (optionnel — déf. « Qui est-ce ? »)"
|
||||||
|
: "Intitulé de la question"
|
||||||
|
}
|
||||||
value={prompt}
|
value={prompt}
|
||||||
onChange={(e) => setPrompt(e.target.value)}
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -388,7 +392,7 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
|
||||||
disabled={
|
disabled={
|
||||||
create.isPending ||
|
create.isPending ||
|
||||||
uploading ||
|
uploading ||
|
||||||
!prompt.trim() ||
|
(!prompt.trim() && format !== "image_reveal") ||
|
||||||
!category.trim() ||
|
!category.trim() ||
|
||||||
(format === "image_reveal" && !imageUrl)
|
(format === "image_reveal" && !imageUrl)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
|
||||||
const current = get().snapshot?.settings ?? DEFAULT_ROOM_SETTINGS
|
const current = get().snapshot?.settings ?? DEFAULT_ROOM_SETTINGS
|
||||||
socket.emit("lobby:updateSettings", {
|
socket.emit("lobby:updateSettings", {
|
||||||
gameType: partial.gameType ?? current.gameType,
|
gameType: partial.gameType ?? current.gameType,
|
||||||
|
mixedModes: partial.mixedModes ?? current.mixedModes,
|
||||||
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,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,9 @@ export type RoundType = "blindtest" | "quiz"
|
||||||
/** Type de partie choisi au lobby : mélange par défaut, ou un seul mode. */
|
/** Type de partie choisi au lobby : mélange par défaut, ou un seul mode. */
|
||||||
export type GameType = "mixed" | "quiz" | "image" | "blindtest"
|
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. */
|
/** Modes de blindtest, déterminent la forme du vote et le barème. */
|
||||||
export type BlindtestMode = "title_artist" | "who_added" | "mixed"
|
export type BlindtestMode = "title_artist" | "who_added" | "mixed"
|
||||||
|
|
||||||
|
|
@ -32,6 +35,8 @@ export interface RoundConfig {
|
||||||
export interface RoomSettings {
|
export interface RoomSettings {
|
||||||
/** Type de partie choisi dans le lobby (pilote l'UI partagée). */
|
/** Type de partie choisi dans le lobby (pilote l'UI partagée). */
|
||||||
gameType: GameType
|
gameType: GameType
|
||||||
|
/** Sous-modes actifs quand gameType === "mixed". */
|
||||||
|
mixedModes: MixMode[]
|
||||||
blindtestMode: BlindtestMode
|
blindtestMode: BlindtestMode
|
||||||
/** Durée d'une manche en secondes (def. 60). */
|
/** Durée d'une manche en secondes (def. 60). */
|
||||||
roundDuration: number
|
roundDuration: number
|
||||||
|
|
@ -44,6 +49,7 @@ export interface RoomSettings {
|
||||||
/** Réglages par défaut d'une nouvelle room. */
|
/** Réglages par défaut d'une nouvelle room. */
|
||||||
export const DEFAULT_ROOM_SETTINGS: RoomSettings = {
|
export const DEFAULT_ROOM_SETTINGS: RoomSettings = {
|
||||||
gameType: "mixed",
|
gameType: "mixed",
|
||||||
|
mixedModes: ["quiz", "image"],
|
||||||
blindtestMode: "title_artist",
|
blindtestMode: "title_artist",
|
||||||
roundDuration: 60,
|
roundDuration: 60,
|
||||||
tracksPerPlayer: 2,
|
tracksPerPlayer: 2,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import type {
|
||||||
Answer,
|
Answer,
|
||||||
BlindtestMode,
|
BlindtestMode,
|
||||||
GameType,
|
GameType,
|
||||||
|
MixMode,
|
||||||
PlayerScore,
|
PlayerScore,
|
||||||
RoomSnapshot,
|
RoomSnapshot,
|
||||||
RoundConfig,
|
RoundConfig,
|
||||||
|
|
@ -34,6 +35,7 @@ export interface SetNamePayload {
|
||||||
|
|
||||||
export interface UpdateSettingsPayload {
|
export interface UpdateSettingsPayload {
|
||||||
gameType: GameType
|
gameType: GameType
|
||||||
|
mixedModes: MixMode[]
|
||||||
blindtestMode: BlindtestMode
|
blindtestMode: BlindtestMode
|
||||||
roundDuration: number
|
roundDuration: number
|
||||||
tracksPerPlayer: number
|
tracksPerPlayer: number
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue