release/0.1.0 #18
6 changed files with 164 additions and 103 deletions
|
|
@ -11,9 +11,10 @@ import type { QuizQuestion } from "../game/modes/quiz/questions"
|
|||
*/
|
||||
export async function loadQuizPool(
|
||||
roomCode: string,
|
||||
limit: number
|
||||
limit: number,
|
||||
formats: string[]
|
||||
): Promise<QuizQuestion[]> {
|
||||
if (!db) {
|
||||
if (!db || formats.length === 0) {
|
||||
return []
|
||||
}
|
||||
const alreadyPlayed = db
|
||||
|
|
@ -37,7 +38,7 @@ export async function loadQuizPool(
|
|||
.leftJoin(quizCategory, eq(quizQuestion.categoryId, quizCategory.id))
|
||||
.where(
|
||||
and(
|
||||
inArray(quizQuestion.format, ["mcq", "truefalse", "free", "image_reveal"]),
|
||||
inArray(quizQuestion.format, formats),
|
||||
notInArray(quizQuestion.id, alreadyPlayed)
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
// File d'attente de questions par room. Préchargée au lancement de la partie
|
||||
// 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 { hasDb } from "../../../db"
|
||||
import { loadQuizPool } from "../../../db/quiz-repo"
|
||||
import type { ServerRoom } from "../../../rooms"
|
||||
|
|
@ -10,10 +12,23 @@ interface RoomPool {
|
|||
queue: QuizQuestion[]
|
||||
/** true si les questions viennent de la DB (→ marquer jouées). */
|
||||
fromDb: boolean
|
||||
formats: QuizFormat[]
|
||||
}
|
||||
|
||||
const poolByRoom = new WeakMap<ServerRoom, RoomPool>()
|
||||
|
||||
/** Formats de quiz autorisés selon le type de partie. */
|
||||
function quizFormatsFor(gameType: GameType): QuizFormat[] {
|
||||
if (gameType === "image") {
|
||||
return ["image_reveal"]
|
||||
}
|
||||
if (gameType === "quiz") {
|
||||
return ["mcq", "truefalse", "free"]
|
||||
}
|
||||
// mixte : tout (le blindtest n'a pas de manche quiz)
|
||||
return ["mcq", "truefalse", "free", "image_reveal"]
|
||||
}
|
||||
|
||||
function shuffle<T>(items: T[]): T[] {
|
||||
const arr = [...items]
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
|
|
@ -25,13 +40,14 @@ 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 needed = room.settings.rounds.filter((r) => r.type === "quiz").length
|
||||
let queue: QuizQuestion[] = []
|
||||
let fromDb = false
|
||||
|
||||
if (hasDb) {
|
||||
try {
|
||||
queue = await loadQuizPool(room.code, Math.max(needed, 1))
|
||||
queue = await loadQuizPool(room.code, Math.max(needed, 1), formats)
|
||||
fromDb = queue.length > 0
|
||||
} catch (err) {
|
||||
console.error("[quiz] chargement DB échoué, fallback banque en dur", err)
|
||||
|
|
@ -39,14 +55,14 @@ export async function prepareQuizForRoom(room: ServerRoom): Promise<void> {
|
|||
}
|
||||
|
||||
if (queue.length === 0) {
|
||||
queue = shuffle(QUIZ_QUESTIONS)
|
||||
queue = shuffle(QUIZ_QUESTIONS.filter((q) => formats.includes(q.format)))
|
||||
fromDb = false
|
||||
}
|
||||
|
||||
poolByRoom.set(room, { queue, fromDb })
|
||||
poolByRoom.set(room, { queue, fromDb, formats })
|
||||
}
|
||||
|
||||
/** Pioche la prochaine question. Fallback aléatoire si la file est vide. */
|
||||
/** Pioche la prochaine question. Fallback aléatoire (formats autorisés) si vide. */
|
||||
export function takeQuestion(room: ServerRoom): {
|
||||
question: QuizQuestion
|
||||
fromDb: boolean
|
||||
|
|
@ -55,7 +71,11 @@ export function takeQuestion(room: ServerRoom): {
|
|||
if (pool && pool.queue.length > 0) {
|
||||
return { question: pool.queue.shift()!, fromDb: pool.fromDb }
|
||||
}
|
||||
const question =
|
||||
QUIZ_QUESTIONS[Math.floor(Math.random() * QUIZ_QUESTIONS.length)]
|
||||
return { question, fromDb: false }
|
||||
const formats = pool?.formats ?? ["mcq", "truefalse", "free"]
|
||||
const candidates = QUIZ_QUESTIONS.filter((q) => formats.includes(q.format))
|
||||
const bank = candidates.length > 0 ? candidates : QUIZ_QUESTIONS
|
||||
return {
|
||||
question: bank[Math.floor(Math.random() * bank.length)],
|
||||
fromDb: false,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>vite-monorepo</title>
|
||||
<title>NerdWare — Party game culture geek</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Party game web multi-épreuves (quiz culture geek + blindtest YouTube), à jouer entre potes en temps réel."
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useState } from "react"
|
|||
import {
|
||||
Brain,
|
||||
Headphones,
|
||||
Image as ImageIcon,
|
||||
ListMusic,
|
||||
Minus,
|
||||
Music,
|
||||
|
|
@ -22,12 +23,17 @@ import { Button } from "@workspace/ui/components/button"
|
|||
import { useRoomStore } from "@/store/room"
|
||||
import { Avatar } from "@/components/avatar"
|
||||
|
||||
const QUESTION_OPTIONS = [3, 5, 10]
|
||||
const MAX_TRACKS = 10
|
||||
const GAME_TYPES: { value: GameType; label: string; Icon: LucideIcon }[] = [
|
||||
{ value: "mixed", label: "Mixte", Icon: Shuffle },
|
||||
{ value: "quiz", label: "Quiz", Icon: Brain },
|
||||
{ value: "blindtest", label: "Blindtest", Icon: Headphones },
|
||||
const GAME_TYPES: {
|
||||
value: GameType
|
||||
label: string
|
||||
Icon: LucideIcon
|
||||
needsThree: boolean
|
||||
}[] = [
|
||||
{ value: "mixed", label: "Mixte", Icon: Shuffle, needsThree: true },
|
||||
{ value: "quiz", label: "Quiz", Icon: Brain, needsThree: false },
|
||||
{ value: "image", label: "Images", Icon: ImageIcon, needsThree: false },
|
||||
{ value: "blindtest", label: "Blindtest", Icon: Headphones, needsThree: true },
|
||||
]
|
||||
const MODE_LABELS: Record<BlindtestMode, string> = {
|
||||
title_artist: "Titre & artiste",
|
||||
|
|
@ -100,7 +106,7 @@ function buildRounds(
|
|||
quizCount: number,
|
||||
totalTracks: number
|
||||
): RoundConfig[] {
|
||||
if (gameType === "quiz") {
|
||||
if (gameType === "quiz" || gameType === "image") {
|
||||
return Array.from({ length: quizCount }, () => ({ type: "quiz" }))
|
||||
}
|
||||
if (gameType === "blindtest") {
|
||||
|
|
@ -132,14 +138,16 @@ 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
|
||||
// Tant que le blindtest est indisponible, on retombe sur du quiz (sans
|
||||
// toucher au réglage stocké) : repasse en mixte automatiquement à 3 joueurs.
|
||||
const effectiveType: GameType = blindtestAvailable ? gameType : "quiz"
|
||||
const showQuiz = effectiveType === "quiz" || effectiveType === "mixed"
|
||||
// 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"
|
||||
const effectiveType: GameType =
|
||||
needsBlindtest && !blindtestAvailable ? "quiz" : gameType
|
||||
const showQuestions = effectiveType !== "blindtest"
|
||||
const showBlindtest =
|
||||
effectiveType === "blindtest" || effectiveType === "mixed"
|
||||
|
||||
const rounds = buildRounds(effectiveType, showQuiz ? count : 0, totalTracks)
|
||||
const rounds = buildRounds(effectiveType, showQuestions ? count : 0, totalTracks)
|
||||
// Blindtest : tout le monde doit avoir soumis son quota de titres.
|
||||
const allSubmitted =
|
||||
!showBlindtest ||
|
||||
|
|
@ -190,54 +198,68 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
|||
</section>
|
||||
|
||||
{isHost && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex gap-2">
|
||||
{GAME_TYPES.map(({ value, label, Icon }) => {
|
||||
const needsThree = value !== "quiz" && !blindtestAvailable
|
||||
<section className="flex flex-col gap-4 rounded-xl border p-4">
|
||||
<h2 className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
|
||||
Réglages de la partie
|
||||
</h2>
|
||||
|
||||
{/* Mode de jeu */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium">Mode de jeu</span>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{GAME_TYPES.map(({ value, label, Icon, needsThree }) => {
|
||||
const locked = needsThree && !blindtestAvailable
|
||||
const active = effectiveType === value
|
||||
return (
|
||||
<Button
|
||||
<button
|
||||
key={value}
|
||||
className="flex-1"
|
||||
variant={effectiveType === value ? "default" : "secondary"}
|
||||
disabled={needsThree}
|
||||
title={needsThree ? "Blindtest : 3 joueurs minimum" : undefined}
|
||||
disabled={locked}
|
||||
title={locked ? "3 joueurs minimum" : undefined}
|
||||
onClick={() => updateSettings({ gameType: value })}
|
||||
className={`flex flex-col items-center gap-1.5 rounded-lg border p-2.5 text-xs font-medium transition-colors disabled:opacity-40 ${
|
||||
active
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-input hover:bg-muted/60"
|
||||
}`}
|
||||
>
|
||||
<Icon /> {label}
|
||||
</Button>
|
||||
<Icon className="size-5" />
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{!blindtestAvailable && (
|
||||
<p className="text-muted-foreground text-center text-xs">
|
||||
Le blindtest se débloque à 3 joueurs.
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Blindtest & Mixte se débloquent à 3 joueurs.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Nombre de questions (quiz / images / mixte) */}
|
||||
{showQuestions && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium">
|
||||
{effectiveType === "image" ? (
|
||||
<ImageIcon className="size-4" />
|
||||
) : (
|
||||
<Brain className="size-4" />
|
||||
)}
|
||||
{effectiveType === "image" ? "Images" : "Questions"}
|
||||
</span>
|
||||
<Stepper value={count} min={1} max={20} onChange={setCount} />
|
||||
</div>
|
||||
{effectiveType === "image" && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Crée des questions « Image » dans le back-office au préalable.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isHost && showQuiz && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium">
|
||||
<Brain className="size-4" /> Questions de quiz
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{QUESTION_OPTIONS.map((n) => (
|
||||
<Button
|
||||
key={n}
|
||||
size="sm"
|
||||
variant={count === n ? "default" : "secondary"}
|
||||
onClick={() => setCount(n)}
|
||||
>
|
||||
{n}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isHost && showBlindtest && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Réglages blindtest */}
|
||||
{showBlindtest && (
|
||||
<div className="flex flex-col gap-3 border-t pt-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium">
|
||||
<Music className="size-4" /> Mode blindtest
|
||||
|
|
@ -267,6 +289,8 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{showBlindtest && (
|
||||
<TrackSubmission tracksPerPlayer={tracksPerPlayer} myCount={myCount} />
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState } from "react"
|
||||
import { useRef, useState } from "react"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { Link } from "wouter"
|
||||
import { Trash2 } from "lucide-react"
|
||||
import { Trash2, Upload } from "lucide-react"
|
||||
import type { QuizFormat } from "@nerdware/shared"
|
||||
import { Button } from "@workspace/ui/components/button"
|
||||
import {
|
||||
|
|
@ -197,6 +197,7 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
|
|||
const [imageUrl, setImageUrl] = useState<string | null>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadError, setUploadError] = useState<string | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (input: NewQuestionInput) => adminApi.createQuestion(input),
|
||||
|
|
@ -338,14 +339,25 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
|
|||
{format === "image_reveal" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="text-sm"
|
||||
className="hidden"
|
||||
onChange={(e) => onPickImage(e.target.files?.[0])}
|
||||
/>
|
||||
{uploading && (
|
||||
<p className="text-muted-foreground text-xs">Upload…</p>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={uploading}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload />
|
||||
{uploading
|
||||
? "Upload…"
|
||||
: imageUrl
|
||||
? "Changer l'image"
|
||||
: "Choisir une image"}
|
||||
</Button>
|
||||
{uploadError && (
|
||||
<p className="text-destructive text-xs">{uploadError}</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export type RoomStatus = "lobby" | "in_round" | "reveal" | "scores" | "ended"
|
|||
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" | "blindtest"
|
||||
export type GameType = "mixed" | "quiz" | "image" | "blindtest"
|
||||
|
||||
/** Modes de blindtest, déterminent la forme du vote et le barème. */
|
||||
export type BlindtestMode = "title_artist" | "who_added" | "mixed"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue