From 1727b8464432245c3f01bb2505dffec43df8f9e2 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Mon, 6 Jul 2026 14:46:31 +0200 Subject: [PATCH 1/2] feat: rebus game mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New "Rébus" mode: guess the geek work from an emoji puzzle (free-text answer, tolerant matching). Implemented as a quiz format `rebus` — reuses the whole quiz machinery (pool, anti-repeat, speed scoring, recap, bots, back-office): - shared: QuizFormat/GameType/MixMode + RoundConfig.pool gain "rebus" - server: rebus is a text format; pool composes a rebus sub-pool (not filtered by category); ~9 seeded emoji rebus in the in-code bank; admin accepts the format - client: lobby tile + sub-mode + count; big centered emoji display in the round; dedicated transition theme (🧩 RÉBUS); FR/EN strings; back-office form option Verified e2e: a rebus game serves only rebus rounds with emoji prompts and the recap shows the right answers. typecheck/lint/build/24 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/src/admin/routes.ts | 4 +- apps/server/src/game/modes/quiz/pool.ts | 23 ++++-- apps/server/src/game/modes/quiz/questions.ts | 78 +++++++++++++++++++ apps/server/src/game/modes/quiz/quiz-round.ts | 6 +- apps/web/src/components/lobby-view.tsx | 30 ++++++- apps/web/src/components/quiz-view.tsx | 15 +++- apps/web/src/components/round-transition.tsx | 17 +++- apps/web/src/i18n/en.ts | 4 + apps/web/src/i18n/fr.ts | 4 + apps/web/src/pages/admin.tsx | 9 ++- apps/web/src/store/room.ts | 8 +- packages/shared/src/domain.ts | 15 ++-- 12 files changed, 184 insertions(+), 29 deletions(-) diff --git a/apps/server/src/admin/routes.ts b/apps/server/src/admin/routes.ts index 8b69f33..a1689b6 100644 --- a/apps/server/src/admin/routes.ts +++ b/apps/server/src/admin/routes.ts @@ -10,7 +10,7 @@ import { env } from "../env" import { db } from "../db" import { quizCategory, quizQuestion, type NewQuizQuestion } from "../db/schema" -const FORMATS = ["mcq", "truefalse", "free", "image_reveal"] as const +const FORMATS = ["mcq", "truefalse", "free", "image_reveal", "rebus"] as const type AdminFormat = (typeof FORMATS)[number] const IMAGE_EXT = [".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif"] @@ -56,7 +56,7 @@ function validate( lang: body.lang === "en" ? "en" : "fr", } - if (format === "free" || format === "image_reveal") { + if (format === "free" || format === "image_reveal" || format === "rebus") { const answers = (body.acceptedAnswers ?? []) .map((a) => a.trim()) .filter(Boolean) diff --git a/apps/server/src/game/modes/quiz/pool.ts b/apps/server/src/game/modes/quiz/pool.ts index ccefa1f..7d8f927 100644 --- a/apps/server/src/game/modes/quiz/pool.ts +++ b/apps/server/src/game/modes/quiz/pool.ts @@ -17,6 +17,7 @@ const poolByRoom = new WeakMap() const QUIZ_FORMATS: QuizFormat[] = ["mcq", "truefalse", "free"] const IMAGE_FORMATS: QuizFormat[] = ["image_reveal"] +const REBUS_FORMATS: QuizFormat[] = ["rebus"] function shuffle(items: T[]): T[] { const arr = [...items] @@ -32,7 +33,7 @@ async function loadGroup( room: ServerRoom, need: number, formats: QuizFormat[], - isImage: boolean, + allowHardcoded: boolean, categories: string[] ): Promise { if (need <= 0) { @@ -47,8 +48,8 @@ async function loadGroup( console.error("[quiz] chargement DB échoué, fallback banque en dur", err) } } - if (items.length < need && !isImage) { - // Complément depuis la banque en dur (uniquement le quiz, pas d'images en dur). + if (items.length < need && allowHardcoded) { + // Complément depuis la banque en dur (quiz + rébus ; pas d'images en dur). const bank = shuffle( QUIZ_QUESTIONS.filter( (q) => @@ -68,16 +69,24 @@ async function loadGroup( export async function prepareQuizForRoom(room: ServerRoom): Promise { const quizRounds = room.settings.rounds.filter((r) => r.type === "quiz") const needImage = quizRounds.filter((r) => r.pool === "image").length - const needQuiz = quizRounds.length - needImage + const needRebus = quizRounds.filter((r) => r.pool === "rebus").length + const needQuiz = quizRounds.length - needImage - needRebus const cats = room.settings.categories - const quizGroup = await loadGroup(room, needQuiz, QUIZ_FORMATS, false, cats) - const imageGroup = await loadGroup(room, needImage, IMAGE_FORMATS, true, cats) + const quizGroup = await loadGroup(room, needQuiz, QUIZ_FORMATS, true, cats) + const imageGroup = await loadGroup(room, needImage, IMAGE_FORMATS, false, cats) + // Les rébus ne sont pas filtrés par catégorie (catégorie « Rébus » à part). + const rebusGroup = await loadGroup(room, needRebus, REBUS_FORMATS, true, []) // On assemble la file dans l'ordre des manches (chacune pioche dans son sous-pool). const queue: QueueItem[] = [] for (const r of quizRounds) { - const group = r.pool === "image" ? imageGroup : quizGroup + const group = + r.pool === "image" + ? imageGroup + : r.pool === "rebus" + ? rebusGroup + : quizGroup const item = group.shift() if (item) { queue.push(item) diff --git a/apps/server/src/game/modes/quiz/questions.ts b/apps/server/src/game/modes/quiz/questions.ts index 4887f25..fb53c1d 100644 --- a/apps/server/src/game/modes/quiz/questions.ts +++ b/apps/server/src/game/modes/quiz/questions.ts @@ -154,4 +154,82 @@ export const QUIZ_QUESTIONS: QuizQuestion[] = [ category: "Manga", difficulty: 3, }, + // Rébus : devine l'œuvre à partir des emojis (réponse libre, matching tolérant). + { + id: "r-sonic", + format: "rebus", + prompt: "🦔 + 💨 + 💍", + acceptedAnswers: ["Sonic", "Sonic the Hedgehog"], + category: "Rébus", + difficulty: 1, + }, + { + id: "r-mario", + format: "rebus", + prompt: "🍄 + 🔨 + 🐢", + acceptedAnswers: ["Mario", "Super Mario", "Super Mario Bros"], + category: "Rébus", + difficulty: 1, + }, + { + id: "r-pacman", + format: "rebus", + prompt: "🟡 + 👻 + 🕹️", + acceptedAnswers: ["Pac-Man", "Pacman", "Pac Man"], + category: "Rébus", + difficulty: 1, + }, + { + id: "r-pokemon", + format: "rebus", + prompt: "⚡ + 🐭 + 🔴⚪", + acceptedAnswers: ["Pokémon", "Pokemon"], + category: "Rébus", + difficulty: 1, + }, + { + id: "r-harry-potter", + format: "rebus", + prompt: "⚡ + 🧙‍♂️ + 🎓", + acceptedAnswers: ["Harry Potter", "Harry"], + category: "Rébus", + difficulty: 2, + }, + { + id: "r-lotr", + format: "rebus", + prompt: "💍 + 🌋 + 🧙‍♂️", + acceptedAnswers: [ + "Le Seigneur des Anneaux", + "Seigneur des Anneaux", + "Lord of the Rings", + "LOTR", + ], + category: "Rébus", + difficulty: 2, + }, + { + id: "r-star-wars", + format: "rebus", + prompt: "⭐ + ⚔️ + 🌌", + acceptedAnswers: ["Star Wars", "La Guerre des Étoiles"], + category: "Rébus", + difficulty: 1, + }, + { + id: "r-minecraft", + format: "rebus", + prompt: "⛏️ + 🟩 + 🧟", + acceptedAnswers: ["Minecraft"], + category: "Rébus", + difficulty: 1, + }, + { + id: "r-zelda", + format: "rebus", + prompt: "🗡️ + 🛡️ + 🧝‍♂️ + 🐴", + acceptedAnswers: ["Zelda", "The Legend of Zelda", "Legend of Zelda"], + category: "Rébus", + difficulty: 2, + }, ] diff --git a/apps/server/src/game/modes/quiz/quiz-round.ts b/apps/server/src/game/modes/quiz/quiz-round.ts index 4b07bd2..797b202 100644 --- a/apps/server/src/game/modes/quiz/quiz-round.ts +++ b/apps/server/src/game/modes/quiz/quiz-round.ts @@ -44,7 +44,11 @@ function asText(answer: Answer): string | null { /** Formats à réponse libre (saisie texte + matching tolérant). */ function isTextFormat(question: QuizQuestion): boolean { - return question.format === "free" || question.format === "image_reveal" + return ( + question.format === "free" || + question.format === "image_reveal" || + question.format === "rebus" + ) } /** Réponse correcte ? Selon le format de la question. */ diff --git a/apps/web/src/components/lobby-view.tsx b/apps/web/src/components/lobby-view.tsx index d7b83d6..0b2b809 100644 --- a/apps/web/src/components/lobby-view.tsx +++ b/apps/web/src/components/lobby-view.tsx @@ -10,6 +10,7 @@ import { Music, Play, Plus, + Puzzle, Shuffle, Trash2, type LucideIcon, @@ -43,6 +44,7 @@ const GAME_TYPES: { { value: "mixed", Icon: Shuffle, needsThree: false }, { value: "quiz", Icon: Brain, needsThree: false }, { value: "image", Icon: ImageIcon, needsThree: false }, + { value: "rebus", Icon: Puzzle, needsThree: false }, { value: "blindtest", Icon: Headphones, needsThree: true }, ] const BOT_DIFFICULTIES = ["easy", "normal", "hard"] as const @@ -110,6 +112,7 @@ function shuffle(items: T[]): T[] { function buildRounds(opts: { quiz: number image: number + rebus: number tracks: number shuffleOrder: boolean }): RoundConfig[] { @@ -122,6 +125,10 @@ function buildRounds(opts: { type: "quiz" as const, pool: "image" as const, })), + ...Array.from({ length: opts.rebus }, () => ({ + type: "quiz" as const, + pool: "rebus" as const, + })), ...Array.from({ length: opts.tracks }, () => ({ type: "blindtest" as const })), ] return opts.shuffleOrder ? shuffle(rounds) : rounds @@ -154,6 +161,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { const [quizCount, setQuizCount] = useState(5) const [imageCount, setImageCount] = useState(5) + const [rebusCount, setRebusCount] = useState(5) const [busy, setBusy] = useState(false) const [error, setError] = useState(null) @@ -179,6 +187,8 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { effectiveType === "quiz" || (isMixed && mixedModes.includes("quiz")) const showImage = effectiveType === "image" || (isMixed && mixedModes.includes("image")) + const showRebus = + effectiveType === "rebus" || (isMixed && mixedModes.includes("rebus")) const showBlindtest = effectiveType === "blindtest" || (isMixed && mixedModes.includes("blindtest") && blindtestAvailable) @@ -186,6 +196,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { const rounds = buildRounds({ quiz: showQuiz ? quizCount : 0, image: showImage ? imageCount : 0, + rebus: showRebus ? rebusCount : 0, tracks: showBlindtest ? totalTracks : 0, shuffleOrder: isMixed, }) @@ -295,7 +306,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { {/* Mode de jeu */}
{t.lobby.gameMode} -
+
{GAME_TYPES.map(({ value, Icon, needsThree }) => { const locked = needsThree && !blindtestAvailable const active = effectiveType === value @@ -339,7 +350,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
{t.lobby.subModes}
- {(["quiz", "image", "blindtest"] as const).map((m) => { + {(["quiz", "image", "rebus", "blindtest"] as const).map((m) => { const locked = m === "blindtest" && !blindtestAvailable const on = mixedModes.includes(m) && !locked const tile = ( @@ -400,6 +411,21 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
)} + {/* Nombre de rébus */} + {showRebus && ( +
+ + {t.lobby.rebusCount} + + +
+ )} + {/* Filtre de catégories (quiz / images) */} {(showQuiz || showImage) && allCategories.length > 0 && (
diff --git a/apps/web/src/components/quiz-view.tsx b/apps/web/src/components/quiz-view.tsx index ff610db..6db7890 100644 --- a/apps/web/src/components/quiz-view.tsx +++ b/apps/web/src/components/quiz-view.tsx @@ -35,7 +35,8 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) { const truth = reveal ? (reveal.truth as QuizRevealTruth) : null const showReveal = truth !== null const isImage = question.format === "image_reveal" - const isText = question.format === "free" || isImage + const isRebus = question.format === "rebus" + const isText = question.format === "free" || isImage || isRebus const myResult = reveal ? (reveal.perPlayerResult as QuizPerPlayerResult)[playerId ?? ""] : undefined @@ -74,9 +75,15 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) { )} -

- {question.prompt} -

+ {isRebus ? ( +
+ {question.prompt} +
+ ) : ( +

+ {question.prompt} +

+ )} {isImage && question.imageUrl && ( -const MEDIA: Record = { quiz: [], image: [], blindtest: [] } +const MEDIA: Record = { + quiz: [], + image: [], + rebus: [], + blindtest: [], +} const SHARED_MEDIA: string[] = [] for (const [path, url] of Object.entries(ALL_MEDIA)) { if (path.includes("/transitions/quiz/")) MEDIA.quiz.push(url) else if (path.includes("/transitions/image/")) MEDIA.image.push(url) + else if (path.includes("/transitions/rebus/")) MEDIA.rebus.push(url) else if (path.includes("/transitions/blindtest/")) MEDIA.blindtest.push(url) else SHARED_MEDIA.push(url) } @@ -46,6 +52,13 @@ const THEME: Record< kind: "Image", emoji: "🖼️", }, + rebus: { + gradient: "from-amber-500 via-orange-600 to-rose-600", + accent: "text-yellow-200", + label: "RÉBUS", + kind: "Rébus", + emoji: "🧩", + }, blindtest: { gradient: "from-fuchsia-500 via-purple-600 to-indigo-700", accent: "text-yellow-300", diff --git a/apps/web/src/i18n/en.ts b/apps/web/src/i18n/en.ts index c9df5c1..63c02a1 100644 --- a/apps/web/src/i18n/en.ts +++ b/apps/web/src/i18n/en.ts @@ -60,6 +60,7 @@ export const en: Dict = { quizCount: "Quiz questions", imageCount: "Images to guess", imageHint: "Requires “Image” questions created in the back-office.", + rebusCount: "Rebuses to guess", categories: "Categories", allCategories: "All", botLevel: "🤖 Bot difficulty", @@ -83,11 +84,13 @@ export const en: Dict = { mixed: "Mixed", quiz: "Quiz", image: "Images", + rebus: "Rebus", blindtest: "Blindtest", }, mixModes: { quiz: "Quiz", image: "Images", + rebus: "Rebus", blindtest: "Blindtest", }, blindtestModes: { @@ -142,6 +145,7 @@ export const en: Dict = { kind: { quiz: "Question", image: "Image", + rebus: "Rebus", blindtest: "Track", }, ready: "READY ?!", diff --git a/apps/web/src/i18n/fr.ts b/apps/web/src/i18n/fr.ts index 48b7a56..c839198 100644 --- a/apps/web/src/i18n/fr.ts +++ b/apps/web/src/i18n/fr.ts @@ -58,6 +58,7 @@ export const fr = { quizCount: "Questions de quiz", imageCount: "Images à deviner", imageHint: "Nécessite des questions « Image » créées dans le back-office.", + rebusCount: "Rébus à deviner", categories: "Catégories", allCategories: "Toutes", botLevel: "🤖 Niveau des bots", @@ -81,11 +82,13 @@ export const fr = { mixed: "Mixte", quiz: "Quiz", image: "Images", + rebus: "Rébus", blindtest: "Blindtest", }, mixModes: { quiz: "Quiz", image: "Images", + rebus: "Rébus", blindtest: "Blindtest", }, blindtestModes: { @@ -141,6 +144,7 @@ export const fr = { kind: { quiz: "Question", image: "Image", + rebus: "Rébus", blindtest: "Titre", }, ready: "PRÊT ?!", diff --git a/apps/web/src/pages/admin.tsx b/apps/web/src/pages/admin.tsx index 8550948..8f0c409 100644 --- a/apps/web/src/pages/admin.tsx +++ b/apps/web/src/pages/admin.tsx @@ -180,7 +180,9 @@ function QuestionRow({ {q.lang.toUpperCase()} · {q.source} {!q.active && " · désactivée"}

- {q.format === "free" || q.format === "image_reveal" ? ( + {q.format === "free" || + q.format === "image_reveal" || + q.format === "rebus" ? (

✔ {q.acceptedAnswers?.join(", ")}

@@ -258,7 +260,7 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) { function submit() { const input: NewQuestionInput = { format, prompt, category, difficulty, lang } - if (format === "free") { + if (format === "free" || format === "rebus") { input.acceptedAnswers = answers .split("\n") .map((a) => a.trim()) @@ -296,6 +298,7 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) { +