release/0.1.0 #18
17 changed files with 529 additions and 283 deletions
|
|
@ -1,6 +1,6 @@
|
|||
// Accès aux questions de quiz en base. No-op si pas de DB (db === null).
|
||||
|
||||
import { and, eq, inArray, isNotNull, notInArray, sql } from "drizzle-orm"
|
||||
import { and, eq, inArray, notInArray, sql } from "drizzle-orm"
|
||||
import { db } from "./index"
|
||||
import { quizCategory, quizPlayed, quizQuestion } from "./schema"
|
||||
import type { QuizQuestion } from "../game/modes/quiz/questions"
|
||||
|
|
@ -28,6 +28,7 @@ export async function loadQuizPool(
|
|||
prompt: quizQuestion.prompt,
|
||||
choices: quizQuestion.choices,
|
||||
correctIndex: quizQuestion.correctIndex,
|
||||
acceptedAnswers: quizQuestion.acceptedAnswers,
|
||||
difficulty: quizQuestion.difficulty,
|
||||
category: quizCategory.name,
|
||||
})
|
||||
|
|
@ -35,8 +36,7 @@ export async function loadQuizPool(
|
|||
.leftJoin(quizCategory, eq(quizQuestion.categoryId, quizCategory.id))
|
||||
.where(
|
||||
and(
|
||||
inArray(quizQuestion.format, ["mcq", "truefalse"]),
|
||||
isNotNull(quizQuestion.correctIndex),
|
||||
inArray(quizQuestion.format, ["mcq", "truefalse", "free"]),
|
||||
notInArray(quizQuestion.id, alreadyPlayed)
|
||||
)
|
||||
)
|
||||
|
|
@ -44,13 +44,18 @@ export async function loadQuizPool(
|
|||
.limit(limit)
|
||||
|
||||
return rows
|
||||
.filter((r) => r.choices && r.correctIndex !== null)
|
||||
.filter((r) =>
|
||||
r.format === "free"
|
||||
? (r.acceptedAnswers?.length ?? 0) > 0
|
||||
: r.choices && r.correctIndex !== null
|
||||
)
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
format: r.format as QuizQuestion["format"],
|
||||
prompt: r.prompt,
|
||||
choices: r.choices as string[],
|
||||
correctIndex: r.correctIndex as number,
|
||||
choices: r.choices ?? undefined,
|
||||
correctIndex: r.correctIndex ?? undefined,
|
||||
acceptedAnswers: r.acceptedAnswers ?? undefined,
|
||||
category: r.category ?? "Quiz",
|
||||
difficulty: r.difficulty,
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -179,6 +179,7 @@ async function seedManual(): Promise<number> {
|
|||
source: "manual",
|
||||
choices: q.choices,
|
||||
correctIndex: q.correctIndex,
|
||||
acceptedAnswers: q.acceptedAnswers,
|
||||
},
|
||||
])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { RoomManager, type BlindtestTrack } from "../../../rooms"
|
|||
import type { RoundContext } from "../../round"
|
||||
import { BlindtestRound } from "./blindtest-round"
|
||||
import { prepareBlindtestForRoom } from "./pool"
|
||||
import { fuzzyMatch, normalize } from "./match"
|
||||
import { fuzzyMatch, normalize } from "../../match"
|
||||
|
||||
function track(submittedBy: string): BlindtestTrack {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import type {
|
|||
} from "@nerdware/shared"
|
||||
import type { GameRound, RoundContext, RoundStart } from "../../round"
|
||||
import type { BlindtestTrack, ServerRoom } from "../../../rooms"
|
||||
import { fuzzyMatch } from "./match"
|
||||
import { fuzzyMatch } from "../../match"
|
||||
import { takeTrack } from "./pool"
|
||||
|
||||
const TITLE_POINTS = 60
|
||||
|
|
|
|||
|
|
@ -8,8 +8,12 @@ export interface QuizQuestion {
|
|||
id: string
|
||||
format: QuizFormat
|
||||
prompt: string
|
||||
choices: string[]
|
||||
correctIndex: number
|
||||
/** mcq/truefalse uniquement. */
|
||||
choices?: string[]
|
||||
/** mcq/truefalse uniquement. */
|
||||
correctIndex?: number
|
||||
/** free : réponses acceptées (matching tolérant). */
|
||||
acceptedAnswers?: string[]
|
||||
category: string
|
||||
difficulty: number
|
||||
}
|
||||
|
|
@ -107,4 +111,45 @@ export const QUIZ_QUESTIONS: QuizQuestion[] = [
|
|||
category: "Pop culture",
|
||||
difficulty: 1,
|
||||
},
|
||||
// Saisie libre (matching tolérant sur acceptedAnswers).
|
||||
{
|
||||
id: "q-free-math-1",
|
||||
format: "free",
|
||||
prompt: "Combien font 7 × 8 ?",
|
||||
acceptedAnswers: ["56"],
|
||||
category: "Maths",
|
||||
difficulty: 1,
|
||||
},
|
||||
{
|
||||
id: "q-free-math-2",
|
||||
format: "free",
|
||||
prompt: "Résous : 12² − 44 = ?",
|
||||
acceptedAnswers: ["100", "cent"],
|
||||
category: "Maths",
|
||||
difficulty: 2,
|
||||
},
|
||||
{
|
||||
id: "q-free-zelda-princess",
|
||||
format: "free",
|
||||
prompt: "Comment s'appelle la princesse dans The Legend of Zelda ?",
|
||||
acceptedAnswers: ["Zelda"],
|
||||
category: "Jeux vidéo",
|
||||
difficulty: 1,
|
||||
},
|
||||
{
|
||||
id: "q-free-pi",
|
||||
format: "free",
|
||||
prompt: "Donne les 3 premières décimales de Pi (après la virgule).",
|
||||
acceptedAnswers: ["141"],
|
||||
category: "Maths",
|
||||
difficulty: 2,
|
||||
},
|
||||
{
|
||||
id: "q-free-onepiece",
|
||||
format: "free",
|
||||
prompt: "Quel est le nom du bateau de l'équipage de Luffy (2e navire) ?",
|
||||
acceptedAnswers: ["Thousand Sunny", "Sunny", "le Thousand Sunny"],
|
||||
category: "Manga",
|
||||
difficulty: 3,
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -2,11 +2,15 @@ import { describe, expect, test } from "bun:test"
|
|||
import { RoomManager } from "../../../rooms"
|
||||
import type { RoundContext } from "../../round"
|
||||
import { QuizRound } from "./quiz-round"
|
||||
import { QUIZ_QUESTIONS } from "./questions"
|
||||
import { QUIZ_QUESTIONS, type QuizQuestion } from "./questions"
|
||||
|
||||
const question = QUIZ_QUESTIONS[0] // "Link" → correctIndex 1
|
||||
|
||||
function makeCtx(): { ctx: RoundContext; p1: string; p2: string } {
|
||||
function makeCtx(q: QuizQuestion = question): {
|
||||
ctx: RoundContext
|
||||
p1: string
|
||||
p2: string
|
||||
} {
|
||||
const rooms = new RoomManager()
|
||||
const { room, player: a } = rooms.create("Alice", "sa")
|
||||
const { player: b } = rooms.join(room.code, "Bob", "sb")
|
||||
|
|
@ -16,7 +20,7 @@ function makeCtx(): { ctx: RoundContext; p1: string; p2: string } {
|
|||
votes: new Map(),
|
||||
startedAt: 0,
|
||||
endsAt: 10_000,
|
||||
data: { question, votedAt: new Map() },
|
||||
data: { question: q, votedAt: new Map() },
|
||||
}
|
||||
return { ctx, p1: a.id, p2: b.id }
|
||||
}
|
||||
|
|
@ -56,4 +60,24 @@ describe("QuizRound", () => {
|
|||
const deltas = round.score(ctx)
|
||||
expect(deltas).toEqual([{ playerId: p1, delta: 190 }]) // 100 + round(100 * 0.9)
|
||||
})
|
||||
|
||||
test("free : matching tolérant sur acceptedAnswers", () => {
|
||||
const free: QuizQuestion = {
|
||||
id: "f1",
|
||||
format: "free",
|
||||
prompt: "7 × 8 ?",
|
||||
acceptedAnswers: ["56"],
|
||||
category: "Maths",
|
||||
difficulty: 1,
|
||||
}
|
||||
const round = new QuizRound(() => 1000)
|
||||
const { ctx, p1, p2 } = makeCtx(free)
|
||||
round.submitAnswer(ctx, p1, { text: " 56 " }) // juste (espaces tolérés)
|
||||
round.submitAnswer(ctx, p2, { text: "57" }) // faux
|
||||
const { truth, perPlayerResult } = round.reveal(ctx)
|
||||
expect(truth).toEqual({ answer: "56" })
|
||||
expect(perPlayerResult[p1].correct).toBe(true)
|
||||
expect(perPlayerResult[p2].correct).toBe(false)
|
||||
expect(round.score(ctx)).toEqual([{ playerId: p1, delta: 190 }])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// Épreuve Quiz : une question = une manche. Pas de DJ, pas d'audio.
|
||||
// Implémente le contrat GameRound ; le moteur fait tout le reste.
|
||||
// Formats : mcq, truefalse (choix), free (saisie libre, matching tolérant).
|
||||
|
||||
import type {
|
||||
Answer,
|
||||
|
|
@ -10,6 +10,7 @@ import type {
|
|||
} from "@nerdware/shared"
|
||||
import { hasDb } from "../../../db"
|
||||
import { markQuestionPlayed } from "../../../db/quiz-repo"
|
||||
import { fuzzyMatch } from "../../match"
|
||||
import type { GameRound, RoundContext, RoundStart } from "../../round"
|
||||
import type { ServerRoom } from "../../../rooms"
|
||||
import type { QuizQuestion } from "./questions"
|
||||
|
|
@ -25,10 +26,29 @@ interface QuizRoundData {
|
|||
votedAt: Map<string, number>
|
||||
}
|
||||
|
||||
function isQuizAnswer(answer: Answer): answer is { choiceIndex: number } {
|
||||
return (
|
||||
typeof (answer as { choiceIndex?: unknown }).choiceIndex === "number"
|
||||
)
|
||||
function asChoice(answer: Answer): number | null {
|
||||
const v = (answer as { choiceIndex?: unknown }).choiceIndex
|
||||
return typeof v === "number" ? v : null
|
||||
}
|
||||
|
||||
function asText(answer: Answer): string | null {
|
||||
const v = (answer as { text?: unknown }).text
|
||||
return typeof v === "string" ? v : null
|
||||
}
|
||||
|
||||
/** Réponse correcte ? Selon le format de la question. */
|
||||
function isCorrect(question: QuizQuestion, answer: Answer | undefined): boolean {
|
||||
if (!answer) {
|
||||
return false
|
||||
}
|
||||
if (question.format === "free") {
|
||||
const text = asText(answer)
|
||||
if (!text) {
|
||||
return false
|
||||
}
|
||||
return (question.acceptedAnswers ?? []).some((a) => fuzzyMatch(text, a))
|
||||
}
|
||||
return asChoice(answer) === question.correctIndex
|
||||
}
|
||||
|
||||
export class QuizRound implements GameRound {
|
||||
|
|
@ -45,10 +65,12 @@ export class QuizRound implements GameRound {
|
|||
const payload: QuizQuestionPayload = {
|
||||
format: question.format,
|
||||
prompt: question.prompt,
|
||||
choices: question.choices,
|
||||
category: question.category,
|
||||
difficulty: question.difficulty,
|
||||
}
|
||||
if (question.format !== "free") {
|
||||
payload.choices = question.choices
|
||||
}
|
||||
const data: QuizRoundData = { question, votedAt: new Map() }
|
||||
return { djId: null, payload, data }
|
||||
}
|
||||
|
|
@ -58,29 +80,42 @@ export class QuizRound implements GameRound {
|
|||
if (ctx.votes.has(playerId)) {
|
||||
return
|
||||
}
|
||||
if (!isQuizAnswer(answer)) {
|
||||
return
|
||||
}
|
||||
const { question, votedAt } = ctx.data as QuizRoundData
|
||||
if (answer.choiceIndex < 0 || answer.choiceIndex >= question.choices.length) {
|
||||
if (question.format === "free") {
|
||||
const text = asText(answer)
|
||||
if (text === null || text.trim().length === 0) {
|
||||
return
|
||||
}
|
||||
ctx.votes.set(playerId, { choiceIndex: answer.choiceIndex })
|
||||
ctx.votes.set(playerId, { text })
|
||||
} else {
|
||||
const choiceIndex = asChoice(answer)
|
||||
const count = question.choices?.length ?? 0
|
||||
if (choiceIndex === null || choiceIndex < 0 || choiceIndex >= count) {
|
||||
return
|
||||
}
|
||||
ctx.votes.set(playerId, { choiceIndex })
|
||||
}
|
||||
votedAt.set(playerId, this.now())
|
||||
}
|
||||
|
||||
reveal(ctx: RoundContext): { truth: QuizRevealTruth; perPlayerResult: QuizPerPlayerResult } {
|
||||
reveal(ctx: RoundContext): {
|
||||
truth: QuizRevealTruth
|
||||
perPlayerResult: QuizPerPlayerResult
|
||||
} {
|
||||
const { question } = ctx.data as QuizRoundData
|
||||
const perPlayerResult: QuizPerPlayerResult = {}
|
||||
for (const player of ctx.room.players.values()) {
|
||||
const vote = ctx.votes.get(player.id) as { choiceIndex: number } | undefined
|
||||
const choiceIndex = vote ? vote.choiceIndex : null
|
||||
const vote = ctx.votes.get(player.id)
|
||||
perPlayerResult[player.id] = {
|
||||
choiceIndex,
|
||||
correct: choiceIndex === question.correctIndex,
|
||||
choiceIndex: vote ? asChoice(vote) : null,
|
||||
correct: isCorrect(question, vote),
|
||||
}
|
||||
}
|
||||
return { truth: { correctIndex: question.correctIndex }, perPlayerResult }
|
||||
const truth: QuizRevealTruth =
|
||||
question.format === "free"
|
||||
? { answer: question.acceptedAnswers?.[0] ?? "" }
|
||||
: { correctIndex: question.correctIndex }
|
||||
return { truth, perPlayerResult }
|
||||
}
|
||||
|
||||
score(ctx: RoundContext): ScoreDelta[] {
|
||||
|
|
@ -88,7 +123,7 @@ export class QuizRound implements GameRound {
|
|||
const total = ctx.endsAt - ctx.startedAt
|
||||
const deltas: ScoreDelta[] = []
|
||||
for (const [playerId, vote] of ctx.votes) {
|
||||
if ((vote as { choiceIndex: number }).choiceIndex !== question.correctIndex) {
|
||||
if (!isCorrect(question, vote)) {
|
||||
continue
|
||||
}
|
||||
// Bonus rapidité : proportionnel au temps restant au moment du vote.
|
||||
|
|
|
|||
|
|
@ -3,9 +3,19 @@
|
|||
Dépose ici des fichiers d'animation pour les transitions entre manches :
|
||||
formats supportés `*.gif`, `*.webp`, `*.apng`, `*.png`, `*.avif`.
|
||||
|
||||
- Ils sont détectés automatiquement au build (`import.meta.glob`).
|
||||
- À chaque manche, un fichier est tiré **au hasard** parmi ceux présents.
|
||||
- S'il n'y en a aucun, l'animation Framer par défaut (numéro + « PRÊT ?! ») est jouée.
|
||||
Rangement par mode (recommandé pour des transitions personnalisées) :
|
||||
|
||||
- `quiz/` → jouées avant une manche de **quiz**
|
||||
- `blindtest/` → jouées avant une manche de **blindtest**
|
||||
- racine (`./`) → **communs**, fallback pour les deux modes
|
||||
|
||||
Règles :
|
||||
|
||||
- Détectés automatiquement au build (`import.meta.glob`).
|
||||
- À chaque manche, un fichier est tiré **au hasard** dans le pool du mode
|
||||
(dossier du mode + communs).
|
||||
- S'il n'y en a aucun, l'animation Framer par défaut est jouée : annonce du
|
||||
mode (« QUIZ » / « BLINDTEST ») quand on change de mode, sinon « Question/Titre N ».
|
||||
|
||||
Le média est centré, limité à 70vh / 80vw, et joué par-dessus le wipe coloré
|
||||
plein écran. Durée d'affichage ≈ 1,3 s (voir `VISIBLE_MS` dans
|
||||
|
|
|
|||
|
|
@ -1,11 +1,21 @@
|
|||
import { useState } from "react"
|
||||
import type { BlindtestMode, RoomSnapshot, RoundConfig } from "@nerdware/shared"
|
||||
import type {
|
||||
BlindtestMode,
|
||||
GameType,
|
||||
RoomSnapshot,
|
||||
RoundConfig,
|
||||
} from "@nerdware/shared"
|
||||
import { Button } from "@workspace/ui/components/button"
|
||||
import { useRoomStore } from "@/store/room"
|
||||
import { Avatar } from "@/components/avatar"
|
||||
|
||||
const QUESTION_OPTIONS = [3, 5, 10]
|
||||
const TRACKS_OPTIONS = [1, 2, 3]
|
||||
const GAME_TYPES: { value: GameType; label: string }[] = [
|
||||
{ value: "mixed", label: "Mixte" },
|
||||
{ value: "quiz", label: "Quiz" },
|
||||
{ value: "blindtest", label: "Blindtest" },
|
||||
]
|
||||
const MODE_LABELS: Record<BlindtestMode, string> = {
|
||||
title_artist: "Titre & artiste",
|
||||
who_added: "Qui l'a ajouté ?",
|
||||
|
|
@ -15,6 +25,35 @@ const MODE_LABELS: Record<BlindtestMode, string> = {
|
|||
const inputClass =
|
||||
"border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
|
||||
|
||||
/** Construit la séquence de manches selon le type de partie. */
|
||||
function buildRounds(
|
||||
gameType: GameType,
|
||||
quizCount: number,
|
||||
totalTracks: number
|
||||
): RoundConfig[] {
|
||||
if (gameType === "quiz") {
|
||||
return Array.from({ length: quizCount }, () => ({ type: "quiz" }))
|
||||
}
|
||||
if (gameType === "blindtest") {
|
||||
return Array.from({ length: totalTracks }, () => ({ type: "blindtest" }))
|
||||
}
|
||||
// Mixte : on alterne quiz / blindtest pour multiplier les changements de mode.
|
||||
const rounds: RoundConfig[] = []
|
||||
let q = quizCount
|
||||
let b = totalTracks
|
||||
while (q > 0 || b > 0) {
|
||||
if (q > 0) {
|
||||
rounds.push({ type: "quiz" })
|
||||
q--
|
||||
}
|
||||
if (b > 0) {
|
||||
rounds.push({ type: "blindtest" })
|
||||
b--
|
||||
}
|
||||
}
|
||||
return rounds
|
||||
}
|
||||
|
||||
export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||
const playerId = useRoomStore((s) => s.playerId)
|
||||
const updateSettings = useRoomStore((s) => s.updateSettings)
|
||||
|
|
@ -27,8 +66,15 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
|||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const totalTracks = snapshot.submissions.reduce((n, s) => n + s.count, 0)
|
||||
const myCount =
|
||||
snapshot.submissions.find((s) => s.playerId === playerId)?.count ?? 0
|
||||
const showQuiz = gameType === "quiz" || gameType === "mixed"
|
||||
const showBlindtest = gameType === "blindtest" || gameType === "mixed"
|
||||
|
||||
async function start(rounds: RoundConfig[]) {
|
||||
const rounds = buildRounds(gameType, showQuiz ? count : 0, totalTracks)
|
||||
const canStart = rounds.length > 0
|
||||
|
||||
async function start() {
|
||||
setError(null)
|
||||
setBusy(true)
|
||||
try {
|
||||
|
|
@ -70,25 +116,22 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
|||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Choix du type de partie (hôte). */}
|
||||
{isHost && (
|
||||
<div className="flex gap-2">
|
||||
{(["quiz", "blindtest"] as const).map((type) => (
|
||||
{GAME_TYPES.map((t) => (
|
||||
<Button
|
||||
key={type}
|
||||
key={t.value}
|
||||
className="flex-1"
|
||||
variant={gameType === type ? "default" : "secondary"}
|
||||
onClick={() => updateSettings({ gameType: type })}
|
||||
variant={gameType === t.value ? "default" : "secondary"}
|
||||
onClick={() => updateSettings({ gameType: t.value })}
|
||||
>
|
||||
{type === "quiz" ? "Quiz" : "Blindtest"}
|
||||
{t.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{gameType === "quiz" ? (
|
||||
isHost ? (
|
||||
<section className="flex flex-col gap-3">
|
||||
{isHost && showQuiz && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Questions de quiz</span>
|
||||
<div className="flex gap-1">
|
||||
|
|
@ -104,107 +147,20 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
start(Array.from({ length: count }, () => ({ type: "quiz" })))
|
||||
}
|
||||
>
|
||||
{busy ? "Lancement…" : "Lancer la partie"}
|
||||
</Button>
|
||||
</section>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-center text-xs">
|
||||
En attente du lancement par l'hôte…
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<BlindtestLobby
|
||||
snapshot={snapshot}
|
||||
isHost={isHost}
|
||||
mode={blindtestMode}
|
||||
tracksPerPlayer={tracksPerPlayer}
|
||||
myCount={
|
||||
snapshot.submissions.find((s) => s.playerId === playerId)?.count ?? 0
|
||||
}
|
||||
totalTracks={totalTracks}
|
||||
busy={busy}
|
||||
onConfig={updateSettings}
|
||||
onStart={() =>
|
||||
start(
|
||||
Array.from({ length: totalTracks }, () => ({ type: "blindtest" }))
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && <p className="text-destructive text-center text-sm">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BlindtestLobby({
|
||||
isHost,
|
||||
mode,
|
||||
tracksPerPlayer,
|
||||
myCount,
|
||||
totalTracks,
|
||||
busy,
|
||||
onConfig,
|
||||
onStart,
|
||||
}: {
|
||||
snapshot: RoomSnapshot
|
||||
isHost: boolean
|
||||
mode: BlindtestMode
|
||||
tracksPerPlayer: number
|
||||
myCount: number
|
||||
totalTracks: number
|
||||
busy: boolean
|
||||
onConfig: (partial: {
|
||||
blindtestMode?: BlindtestMode
|
||||
tracksPerPlayer?: number
|
||||
}) => void
|
||||
onStart: () => void
|
||||
}) {
|
||||
const submitTrack = useRoomStore((s) => s.submitTrack)
|
||||
const [url, setUrl] = useState("")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [feedback, setFeedback] = useState<string | null>(null)
|
||||
const [accepted, setAccepted] = useState<string[]>([])
|
||||
|
||||
const quotaReached = myCount >= tracksPerPlayer
|
||||
|
||||
async function submit() {
|
||||
if (!url.trim()) {
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
setFeedback(null)
|
||||
const res = await submitTrack(url.trim())
|
||||
if (res.accepted) {
|
||||
setAccepted((a) => [...a, res.title ?? url.trim()])
|
||||
setUrl("")
|
||||
setFeedback(null)
|
||||
} else {
|
||||
setFeedback(res.reason ?? "Refusé")
|
||||
}
|
||||
setSubmitting(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-4">
|
||||
{isHost && (
|
||||
{isHost && showBlindtest && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">Mode</span>
|
||||
<span className="text-sm font-medium">Mode blindtest</span>
|
||||
<div className="flex gap-1">
|
||||
{(["title_artist", "who_added", "mixed"] as const).map((m) => (
|
||||
<Button
|
||||
key={m}
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
variant={mode === m ? "default" : "secondary"}
|
||||
onClick={() => onConfig({ blindtestMode: m })}
|
||||
variant={blindtestMode === m ? "default" : "secondary"}
|
||||
onClick={() => updateSettings({ blindtestMode: m })}
|
||||
>
|
||||
{MODE_LABELS[m]}
|
||||
</Button>
|
||||
|
|
@ -219,7 +175,7 @@ function BlindtestLobby({
|
|||
key={n}
|
||||
size="sm"
|
||||
variant={tracksPerPlayer === n ? "default" : "secondary"}
|
||||
onClick={() => onConfig({ tracksPerPlayer: n })}
|
||||
onClick={() => updateSettings({ tracksPerPlayer: n })}
|
||||
>
|
||||
{n}
|
||||
</Button>
|
||||
|
|
@ -229,6 +185,57 @@ function BlindtestLobby({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{showBlindtest && (
|
||||
<TrackSubmission tracksPerPlayer={tracksPerPlayer} myCount={myCount} />
|
||||
)}
|
||||
|
||||
{isHost ? (
|
||||
<Button disabled={busy || !canStart} onClick={start}>
|
||||
{busy ? "Lancement…" : `Lancer (${rounds.length} manches)`}
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-center text-xs">
|
||||
En attente du lancement par l'hôte…
|
||||
{showBlindtest && ` (${totalTracks} titres soumis)`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && <p className="text-destructive text-center text-sm">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TrackSubmission({
|
||||
tracksPerPlayer,
|
||||
myCount,
|
||||
}: {
|
||||
tracksPerPlayer: number
|
||||
myCount: number
|
||||
}) {
|
||||
const submitTrack = useRoomStore((s) => s.submitTrack)
|
||||
const [url, setUrl] = useState("")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [feedback, setFeedback] = useState<string | null>(null)
|
||||
const [accepted, setAccepted] = useState<string[]>([])
|
||||
const quotaReached = myCount >= tracksPerPlayer
|
||||
|
||||
async function submit() {
|
||||
if (!url.trim()) {
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
setFeedback(null)
|
||||
const res = await submitTrack(url.trim())
|
||||
if (res.accepted) {
|
||||
setAccepted((a) => [...a, res.title ?? url.trim()])
|
||||
setUrl("")
|
||||
} else {
|
||||
setFeedback(res.reason ?? "Refusé")
|
||||
}
|
||||
setSubmitting(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
Tes titres ({myCount}/{tracksPerPlayer})
|
||||
|
|
@ -261,16 +268,5 @@ function BlindtestLobby({
|
|||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isHost ? (
|
||||
<Button disabled={busy || totalTracks === 0} onClick={onStart}>
|
||||
{busy ? "Lancement…" : `Lancer (${totalTracks} titres)`}
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-center text-xs">
|
||||
En attente du lancement par l'hôte… ({totalTracks} titres soumis)
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,26 @@
|
|||
import { useState } from "react"
|
||||
import type {
|
||||
QuizPerPlayerResult,
|
||||
QuizQuestionPayload,
|
||||
QuizRevealTruth,
|
||||
RoomSnapshot,
|
||||
} from "@nerdware/shared"
|
||||
import { Button } from "@workspace/ui/components/button"
|
||||
import { useRoomStore } from "@/store/room"
|
||||
import { Countdown } from "@/components/countdown"
|
||||
|
||||
const inputClass =
|
||||
"border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
|
||||
|
||||
export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||
const round = useRoomStore((s) => s.round)
|
||||
const reveal = useRoomStore((s) => s.reveal)
|
||||
const voteProgress = useRoomStore((s) => s.voteProgress)
|
||||
const myChoiceIndex = useRoomStore((s) => s.myChoiceIndex)
|
||||
const hasVoted = useRoomStore((s) => s.hasVoted)
|
||||
const playerId = useRoomStore((s) => s.playerId)
|
||||
const vote = useRoomStore((s) => s.vote)
|
||||
const voteText = useRoomStore((s) => s.voteText)
|
||||
|
||||
if (!round) {
|
||||
return (
|
||||
|
|
@ -24,6 +31,7 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
|
|||
const question = round.payload as QuizQuestionPayload
|
||||
const truth = reveal ? (reveal.truth as QuizRevealTruth) : null
|
||||
const showReveal = truth !== null
|
||||
const isFree = question.format === "free"
|
||||
const myResult = reveal
|
||||
? (reveal.perPlayerResult as QuizPerPlayerResult)[playerId ?? ""]
|
||||
: undefined
|
||||
|
|
@ -66,21 +74,28 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
|
|||
{question.prompt}
|
||||
</h2>
|
||||
|
||||
{isFree ? (
|
||||
<FreeAnswer
|
||||
disabled={showReveal || hasVoted}
|
||||
onSubmit={voteText}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{question.choices.map((choice, index) => (
|
||||
{(question.choices ?? []).map((choice, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className={choiceClass(index)}
|
||||
disabled={showReveal || myChoiceIndex !== null}
|
||||
disabled={showReveal || hasVoted}
|
||||
onClick={() => vote(index)}
|
||||
>
|
||||
{choice}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showReveal &&
|
||||
(myChoiceIndex !== null ? (
|
||||
(hasVoted ? (
|
||||
<p className="text-muted-foreground text-center text-xs">
|
||||
Réponse envoyée — en attente des autres
|
||||
{voteProgress ? ` (${voteProgress.count}/${voteProgress.total})` : ""}
|
||||
|
|
@ -94,16 +109,49 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
|
|||
))}
|
||||
|
||||
{showReveal && (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
{isFree && (
|
||||
<p className="text-sm">
|
||||
<span className="text-muted-foreground">Réponse : </span>
|
||||
<span className="font-medium">{truth?.answer}</span>
|
||||
</p>
|
||||
)}
|
||||
<p
|
||||
className={`text-center text-sm font-medium ${myResult?.correct ? "text-green-500" : "text-red-500"}`}
|
||||
>
|
||||
{myResult?.correct
|
||||
? "Bonne réponse ! 🎉"
|
||||
: myResult?.choiceIndex == null
|
||||
: !hasVoted
|
||||
? "Pas de réponse 😴"
|
||||
: "Raté 💥"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FreeAnswer({
|
||||
disabled,
|
||||
onSubmit,
|
||||
}: {
|
||||
disabled: boolean
|
||||
onSubmit: (text: string) => void
|
||||
}) {
|
||||
const [text, setText] = useState("")
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className={inputClass}
|
||||
placeholder="Ta réponse"
|
||||
value={text}
|
||||
disabled={disabled}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && text.trim() && onSubmit(text)}
|
||||
/>
|
||||
<Button disabled={disabled || !text.trim()} onClick={() => onSubmit(text)}>
|
||||
Valider
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,30 +1,74 @@
|
|||
import { useEffect, useState } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import { AnimatePresence, motion } from "framer-motion"
|
||||
import type { RoundType } from "@nerdware/shared"
|
||||
|
||||
const VISIBLE_MS = 1300 // durée d'affichage avant le wipe de sortie (≈ leadMs serveur)
|
||||
|
||||
// Animations custom déposées par l'utilisateur (gif/webp/apng/png/mp4...).
|
||||
// Glisse simplement des fichiers dans src/assets/transitions/ : ils sont
|
||||
// détectés au build et joués aléatoirement. Sinon, fallback animation Framer.
|
||||
const CUSTOM = Object.values(
|
||||
import.meta.glob("../assets/transitions/*.{gif,webp,apng,png,avif}", {
|
||||
eager: true,
|
||||
import: "default",
|
||||
})
|
||||
) as string[]
|
||||
// Médias custom déposés par l'utilisateur, rangés par mode :
|
||||
// src/assets/transitions/quiz/* → transitions de quiz
|
||||
// src/assets/transitions/blindtest/* → transitions de blindtest
|
||||
// src/assets/transitions/* → communs (fallback)
|
||||
// Détectés au build ; sinon, animation Framer par défaut.
|
||||
const ALL_MEDIA = import.meta.glob(
|
||||
"../assets/transitions/**/*.{gif,webp,apng,png,avif}",
|
||||
{ eager: true, import: "default" }
|
||||
) as Record<string, string>
|
||||
|
||||
const QUIZ_MEDIA: string[] = []
|
||||
const BLINDTEST_MEDIA: string[] = []
|
||||
const SHARED_MEDIA: string[] = []
|
||||
for (const [path, url] of Object.entries(ALL_MEDIA)) {
|
||||
if (path.includes("/transitions/quiz/")) QUIZ_MEDIA.push(url)
|
||||
else if (path.includes("/transitions/blindtest/")) BLINDTEST_MEDIA.push(url)
|
||||
else SHARED_MEDIA.push(url)
|
||||
}
|
||||
|
||||
const THEME: Record<
|
||||
RoundType,
|
||||
{ gradient: string; accent: string; label: string; kind: string; emoji: string }
|
||||
> = {
|
||||
quiz: {
|
||||
gradient: "from-indigo-500 via-blue-600 to-cyan-600",
|
||||
accent: "text-cyan-300",
|
||||
label: "QUIZ",
|
||||
kind: "Question",
|
||||
emoji: "🧠",
|
||||
},
|
||||
blindtest: {
|
||||
gradient: "from-fuchsia-500 via-purple-600 to-indigo-700",
|
||||
accent: "text-yellow-300",
|
||||
label: "BLINDTEST",
|
||||
kind: "Titre",
|
||||
emoji: "🎧",
|
||||
},
|
||||
}
|
||||
|
||||
function mediaFor(type: RoundType): string[] {
|
||||
const own = type === "quiz" ? QUIZ_MEDIA : BLINDTEST_MEDIA
|
||||
return [...own, ...SHARED_MEDIA]
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition "WarioWare" jouée entre les manches : wipe coloré plein écran,
|
||||
* média custom (si présent) ou sunburst + gros numéro qui rebondit, puis sortie.
|
||||
* Transition "WarioWare" entre manches, thémée par mode. Annonce le mode quand
|
||||
* il change (quiz ↔ blindtest), sinon affiche un teaser léger de la manche.
|
||||
* Montée avec une `key` qui change → rejoue à chaque manche.
|
||||
*/
|
||||
export function RoundTransition({ index }: { index: number }) {
|
||||
export function RoundTransition({
|
||||
type,
|
||||
index,
|
||||
modeChanged,
|
||||
}: {
|
||||
type: RoundType
|
||||
index: number
|
||||
modeChanged: boolean
|
||||
}) {
|
||||
const [show, setShow] = useState(true)
|
||||
// Choix stable d'un média custom pour ce montage (varie d'une manche à l'autre).
|
||||
const [media] = useState(() =>
|
||||
CUSTOM.length ? CUSTOM[Math.floor(Math.random() * CUSTOM.length)] : null
|
||||
)
|
||||
const theme = THEME[type]
|
||||
const [media] = useState(() => {
|
||||
const pool = mediaFor(type)
|
||||
return pool.length ? pool[Math.floor(Math.random() * pool.length)] : null
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setShow(false), VISIBLE_MS)
|
||||
|
|
@ -36,7 +80,7 @@ export function RoundTransition({ index }: { index: number }) {
|
|||
{show && (
|
||||
<motion.div
|
||||
key="round-transition"
|
||||
className="pointer-events-auto fixed inset-0 z-50 flex items-center justify-center overflow-hidden bg-gradient-to-br from-fuchsia-500 via-purple-600 to-indigo-700"
|
||||
className={`pointer-events-auto fixed inset-0 z-50 flex items-center justify-center overflow-hidden bg-gradient-to-br ${theme.gradient}`}
|
||||
initial={{ clipPath: "inset(0 0 100% 0)" }}
|
||||
animate={{ clipPath: "inset(0 0 0% 0)" }}
|
||||
exit={{
|
||||
|
|
@ -45,17 +89,6 @@ export function RoundTransition({ index }: { index: number }) {
|
|||
}}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
>
|
||||
{media ? (
|
||||
<motion.img
|
||||
src={media}
|
||||
alt=""
|
||||
className="max-h-[70vh] max-w-[80vw] object-contain"
|
||||
initial={{ scale: 0.6, opacity: 0 }}
|
||||
animate={{ scale: [0.6, 1.08, 1], opacity: 1 }}
|
||||
transition={{ duration: 0.45, times: [0, 0.6, 1], ease: "easeOut" }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<motion.div
|
||||
aria-hidden
|
||||
className="absolute size-[220vmax] opacity-60"
|
||||
|
|
@ -67,6 +100,16 @@ export function RoundTransition({ index }: { index: number }) {
|
|||
transition={{ duration: 7, repeat: Infinity, ease: "linear" }}
|
||||
/>
|
||||
|
||||
{media ? (
|
||||
<motion.img
|
||||
src={media}
|
||||
alt=""
|
||||
className="relative max-h-[70vh] max-w-[80vw] object-contain"
|
||||
initial={{ scale: 0.6, opacity: 0 }}
|
||||
animate={{ scale: [0.6, 1.08, 1], opacity: 1 }}
|
||||
transition={{ duration: 0.45, times: [0, 0.6, 1], ease: "easeOut" }}
|
||||
/>
|
||||
) : (
|
||||
<motion.div
|
||||
className="relative flex flex-col items-center"
|
||||
initial={{ scale: 0, rotate: -12 }}
|
||||
|
|
@ -78,11 +121,22 @@ export function RoundTransition({ index }: { index: number }) {
|
|||
ease: "easeOut",
|
||||
}}
|
||||
>
|
||||
{modeChanged ? (
|
||||
<>
|
||||
<span className="text-[18vmin] leading-none">{theme.emoji}</span>
|
||||
<span
|
||||
className={`font-heading text-[12vmin] leading-none font-black tracking-tight ${theme.accent} drop-shadow-[0_5px_0_rgba(0,0,0,0.25)]`}
|
||||
>
|
||||
{theme.label}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-heading text-2xl font-black tracking-[0.35em] text-white uppercase drop-shadow">
|
||||
Question
|
||||
{theme.kind}
|
||||
</span>
|
||||
<motion.span
|
||||
className="font-heading text-[26vmin] leading-none font-black text-yellow-300 drop-shadow-[0_5px_0_rgba(0,0,0,0.25)]"
|
||||
className={`font-heading text-[26vmin] leading-none font-black ${theme.accent} drop-shadow-[0_5px_0_rgba(0,0,0,0.25)]`}
|
||||
animate={{ scale: [1, 1.07, 1] }}
|
||||
transition={{
|
||||
duration: 0.4,
|
||||
|
|
@ -92,6 +146,8 @@ export function RoundTransition({ index }: { index: number }) {
|
|||
>
|
||||
{index + 1}
|
||||
</motion.span>
|
||||
</>
|
||||
)}
|
||||
<motion.span
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
|
|
@ -101,7 +157,6 @@ export function RoundTransition({ index }: { index: number }) {
|
|||
PRÊT ?!
|
||||
</motion.span>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export function RoomPage({ code }: { code: string }) {
|
|||
const roundKey = useRoomStore((s) => s.roundKey)
|
||||
const voteProgress = useRoomStore((s) => s.voteProgress)
|
||||
const round = useRoomStore((s) => s.round)
|
||||
const roundModeChanged = useRoomStore((s) => s.roundModeChanged)
|
||||
|
||||
// Pas de snapshot pour ce code (accès direct / refresh) : retour à l'accueil.
|
||||
if (!snapshot || snapshot.code !== code) {
|
||||
|
|
@ -70,8 +71,13 @@ export function RoomPage({ code }: { code: string }) {
|
|||
</div>
|
||||
|
||||
{boomKey > 0 && <Boom key={boomKey} />}
|
||||
{roundKey > 0 && (
|
||||
<RoundTransition key={roundKey} index={snapshot.currentRound} />
|
||||
{roundKey > 0 && round && (
|
||||
<RoundTransition
|
||||
key={roundKey}
|
||||
type={round.type}
|
||||
index={snapshot.currentRound}
|
||||
modeChanged={roundModeChanged}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ interface RoomState {
|
|||
mediaSync: MediaSyncPayload | null
|
||||
boomKey: number
|
||||
roundKey: number
|
||||
/** Le type d'épreuve a-t-il changé par rapport à la manche précédente ? */
|
||||
roundModeChanged: boolean
|
||||
|
||||
createRoom: (playerName: string) => Promise<RoomCreatedPayload>
|
||||
joinRoom: (roomCode: string, playerName: string) => Promise<RoomCreatedPayload>
|
||||
|
|
@ -55,6 +57,7 @@ interface RoomState {
|
|||
startGame: (rounds: RoundConfig[]) => Promise<void>
|
||||
submitTrack: (youtubeUrl: string) => Promise<SubmitOkPayload>
|
||||
vote: (choiceIndex: number) => void
|
||||
voteText: (text: string) => void
|
||||
voteBlindtest: (answer: BlindtestAnswer) => void
|
||||
mediaControl: (action: MediaControlPayload["action"], positionSec: number) => void
|
||||
boom: () => void
|
||||
|
|
@ -77,6 +80,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
|
|||
mediaSync: null,
|
||||
boomKey: 0,
|
||||
roundKey: 0,
|
||||
roundModeChanged: false,
|
||||
|
||||
createRoom: (playerName) =>
|
||||
new Promise((resolve, reject) => {
|
||||
|
|
@ -141,6 +145,14 @@ export const useRoomStore = create<RoomState>((set, get) => ({
|
|||
socket.emit("round:vote", { answer: { choiceIndex } })
|
||||
},
|
||||
|
||||
voteText: (text) => {
|
||||
if (get().hasVoted || text.trim().length === 0) {
|
||||
return
|
||||
}
|
||||
set({ hasVoted: true })
|
||||
socket.emit("round:vote", { answer: { text: text.trim() } })
|
||||
},
|
||||
|
||||
voteBlindtest: (answer) => {
|
||||
if (get().hasVoted) {
|
||||
return
|
||||
|
|
@ -170,6 +182,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
|
|||
mediaSync: null,
|
||||
boomKey: 0,
|
||||
roundKey: 0,
|
||||
roundModeChanged: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
|
|
@ -194,6 +207,7 @@ socket.on("round:start", (payload) =>
|
|||
hasVoted: false,
|
||||
mediaSync: null,
|
||||
roundKey: s.roundKey + 1,
|
||||
roundModeChanged: (s.round?.type ?? null) !== payload.type,
|
||||
}))
|
||||
)
|
||||
socket.on("round:voteAck", (voteProgress) =>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ export type RoomStatus = "lobby" | "in_round" | "reveal" | "scores" | "ended"
|
|||
/** Types d'épreuves disponibles. Étendre = ajouter une valeur + un module GameRound. */
|
||||
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"
|
||||
|
||||
/** Modes de blindtest, déterminent la forme du vote et le barème. */
|
||||
export type BlindtestMode = "title_artist" | "who_added" | "mixed"
|
||||
|
||||
|
|
@ -28,7 +31,7 @@ export interface RoundConfig {
|
|||
/** Réglages de la room, modifiables dans le lobby par l'hôte. */
|
||||
export interface RoomSettings {
|
||||
/** Type de partie choisi dans le lobby (pilote l'UI partagée). */
|
||||
gameType: RoundType
|
||||
gameType: GameType
|
||||
blindtestMode: BlindtestMode
|
||||
/** Durée d'une manche en secondes (def. 60). */
|
||||
roundDuration: number
|
||||
|
|
@ -40,7 +43,7 @@ export interface RoomSettings {
|
|||
|
||||
/** Réglages par défaut d'une nouvelle room. */
|
||||
export const DEFAULT_ROOM_SETTINGS: RoomSettings = {
|
||||
gameType: "quiz",
|
||||
gameType: "mixed",
|
||||
blindtestMode: "title_artist",
|
||||
roundDuration: 60,
|
||||
tracksPerPlayer: 2,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import type {
|
||||
Answer,
|
||||
BlindtestMode,
|
||||
GameType,
|
||||
PlayerScore,
|
||||
RoomSnapshot,
|
||||
RoundConfig,
|
||||
|
|
@ -27,7 +28,7 @@ export interface RoomJoinPayload {
|
|||
}
|
||||
|
||||
export interface UpdateSettingsPayload {
|
||||
gameType: RoundType
|
||||
gameType: GameType
|
||||
blindtestMode: BlindtestMode
|
||||
roundDuration: number
|
||||
tracksPerPlayer: number
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ import type { QuizFormat } from "./domain"
|
|||
export interface QuizQuestionPayload {
|
||||
format: QuizFormat
|
||||
prompt: string
|
||||
/** Choix proposés (truefalse = ["Vrai", "Faux"]). */
|
||||
choices: string[]
|
||||
/** Choix proposés (mcq/truefalse). Absent pour `free` (saisie libre). */
|
||||
choices?: string[]
|
||||
category?: string
|
||||
/** 1 (facile) .. 3 (difficile). */
|
||||
difficulty?: number
|
||||
|
|
@ -17,7 +17,10 @@ export interface QuizQuestionPayload {
|
|||
|
||||
/** Vérité révélée à tous (round:reveal → truth). */
|
||||
export interface QuizRevealTruth {
|
||||
correctIndex: number
|
||||
/** Index de la bonne réponse (mcq/truefalse). */
|
||||
correctIndex?: number
|
||||
/** Réponse canonique affichée (free). */
|
||||
answer?: string
|
||||
}
|
||||
|
||||
/** Résultat d'un joueur sur la manche. */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue