Compare commits

..

No commits in common. "aa386f39a23eab724efa92706c4a429f5eefdd9d" and "0c940fd447e8eb93339afa80c6fbcfa31f57015d" have entirely different histories.

37 changed files with 265 additions and 2391 deletions

View file

@ -1,6 +1,6 @@
// Accès aux questions de quiz en base. No-op si pas de DB (db === null). // Accès aux questions de quiz en base. No-op si pas de DB (db === null).
import { and, eq, inArray, notInArray, sql } from "drizzle-orm" import { and, eq, inArray, isNotNull, notInArray, sql } from "drizzle-orm"
import { db } from "./index" import { db } from "./index"
import { quizCategory, quizPlayed, quizQuestion } from "./schema" import { quizCategory, quizPlayed, quizQuestion } from "./schema"
import type { QuizQuestion } from "../game/modes/quiz/questions" import type { QuizQuestion } from "../game/modes/quiz/questions"
@ -28,7 +28,6 @@ export async function loadQuizPool(
prompt: quizQuestion.prompt, prompt: quizQuestion.prompt,
choices: quizQuestion.choices, choices: quizQuestion.choices,
correctIndex: quizQuestion.correctIndex, correctIndex: quizQuestion.correctIndex,
acceptedAnswers: quizQuestion.acceptedAnswers,
difficulty: quizQuestion.difficulty, difficulty: quizQuestion.difficulty,
category: quizCategory.name, category: quizCategory.name,
}) })
@ -36,7 +35,8 @@ export async function loadQuizPool(
.leftJoin(quizCategory, eq(quizQuestion.categoryId, quizCategory.id)) .leftJoin(quizCategory, eq(quizQuestion.categoryId, quizCategory.id))
.where( .where(
and( and(
inArray(quizQuestion.format, ["mcq", "truefalse", "free"]), inArray(quizQuestion.format, ["mcq", "truefalse"]),
isNotNull(quizQuestion.correctIndex),
notInArray(quizQuestion.id, alreadyPlayed) notInArray(quizQuestion.id, alreadyPlayed)
) )
) )
@ -44,18 +44,13 @@ export async function loadQuizPool(
.limit(limit) .limit(limit)
return rows return rows
.filter((r) => .filter((r) => r.choices && r.correctIndex !== null)
r.format === "free"
? (r.acceptedAnswers?.length ?? 0) > 0
: r.choices && r.correctIndex !== null
)
.map((r) => ({ .map((r) => ({
id: r.id, id: r.id,
format: r.format as QuizQuestion["format"], format: r.format as QuizQuestion["format"],
prompt: r.prompt, prompt: r.prompt,
choices: r.choices ?? undefined, choices: r.choices as string[],
correctIndex: r.correctIndex ?? undefined, correctIndex: r.correctIndex as number,
acceptedAnswers: r.acceptedAnswers ?? undefined,
category: r.category ?? "Quiz", category: r.category ?? "Quiz",
difficulty: r.difficulty, difficulty: r.difficulty,
})) }))

View file

@ -179,7 +179,6 @@ async function seedManual(): Promise<number> {
source: "manual", source: "manual",
choices: q.choices, choices: q.choices,
correctIndex: q.correctIndex, correctIndex: q.correctIndex,
acceptedAnswers: q.acceptedAnswers,
}, },
]) ])
} }

View file

@ -2,12 +2,7 @@
// pour chaque manche start → (votes | timer) → reveal → score, puis game:end. // pour chaque manche start → (votes | timer) → reveal → score, puis game:end.
// Tout est tranché serveur ; les clients n'affichent que ce qui est broadcasté. // Tout est tranché serveur ; les clients n'affichent que ce qui est broadcasté.
import type { import type { Answer, PlayerScore, RoundConfig } from "@nerdware/shared"
Answer,
MediaControlPayload,
PlayerScore,
RoundConfig,
} from "@nerdware/shared"
import type { IoServer } from "../socket" import type { IoServer } from "../socket"
import type { RoomManager, ServerRoom } from "../rooms" import type { RoomManager, ServerRoom } from "../rooms"
import { createRound as defaultCreateRound, type RoundFactory } from "./registry" import { createRound as defaultCreateRound, type RoundFactory } from "./registry"
@ -16,8 +11,6 @@ import type { GameRound, RoomGameController, RoundContext } from "./round"
interface RoundRuntime { interface RoundRuntime {
round: GameRound round: GameRound
djId: string | null djId: string | null
/** Contributeur (blindtest) : reçoit round:start privé et ne vote pas. */
secretPlayerId: string | null
votes: Map<string, Answer> votes: Map<string, Answer>
startedAt: number startedAt: number
endsAt: number endsAt: number
@ -72,18 +65,7 @@ export class GameEngine implements RoomGameController {
} }
this.room.status = "ended" this.room.status = "ended"
this.broadcastState() this.broadcastState()
// Récap des titres blindtest joués (pour récupérer les liens YouTube). this.io.to(this.room.code).emit("game:end", { finalScores: this.scoreboard() })
const tracks = this.room.blindtestTracks.map((t) => ({
title: t.title,
artist: t.artist,
youtubeId: t.youtubeId,
url: t.url,
submittedByName: this.room.players.get(t.submittedBy)?.name ?? "?",
}))
this.io.to(this.room.code).emit("game:end", {
finalScores: this.scoreboard(),
tracks: tracks.length > 0 ? tracks : undefined,
})
} }
/** Vote d'un joueur pendant une manche. Délègue à l'épreuve, puis check fin anticipée. */ /** Vote d'un joueur pendant une manche. Délègue à l'épreuve, puis check fin anticipée. */
@ -105,22 +87,6 @@ export class GameEngine implements RoomGameController {
} }
} }
/** Relais média : seul le DJ de la manche peut piloter la lecture. */
handleMediaControl(playerId: string, payload: MediaControlPayload): void {
if (
!this.runtime ||
this.room.status !== "in_round" ||
playerId !== this.runtime.djId
) {
return
}
this.io.to(this.room.code).emit("media:sync", {
action: payload.action,
positionSec: payload.positionSec,
atServerTs: this.now(),
})
}
private async playRound(config: RoundConfig): Promise<void> { private async playRound(config: RoundConfig): Promise<void> {
const round = this.createRound(config.type) const round = this.createRound(config.type)
const start = round.start(this.room) const start = round.start(this.room)
@ -133,7 +99,6 @@ export class GameEngine implements RoomGameController {
this.runtime = { this.runtime = {
round, round,
djId: start.djId ?? null, djId: start.djId ?? null,
secretPlayerId: start.secretPlayerId ?? null,
votes: new Map(), votes: new Map(),
startedAt, startedAt,
endsAt, endsAt,
@ -141,24 +106,13 @@ export class GameEngine implements RoomGameController {
} }
this.room.status = "in_round" this.room.status = "in_round"
this.broadcastState() this.broadcastState()
const base = { this.io.to(this.room.code).emit("round:start", {
type: round.type, type: round.type,
djId: this.runtime.djId ?? undefined, djId: this.runtime.djId ?? undefined,
startsAt: startedAt, startsAt: startedAt,
endsAt, endsAt,
payload: start.payload, payload: start.payload,
} })
// Le contributeur reçoit un round:start privé (mine: true) ; il reste secret
// pour les autres (qui ne reçoivent que `base`).
const secretSocketId = this.runtime.secretPlayerId
? this.room.players.get(this.runtime.secretPlayerId)?.socketId
: null
if (secretSocketId) {
this.io.to(secretSocketId).emit("round:start", { ...base, mine: true })
this.io.to(this.room.code).except(secretSocketId).emit("round:start", base)
} else {
this.io.to(this.room.code).emit("round:start", base)
}
// Attend la première condition de fin : timer écoulé OU tous votés. // Attend la première condition de fin : timer écoulé OU tous votés.
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
@ -202,14 +156,10 @@ export class GameEngine implements RoomGameController {
} }
} }
/** /** Votants éligibles : tous les joueurs connectés (le DJ inclus). */
* Votants éligibles : joueurs connectés (DJ neutre inclus), hors contributeur
* (blindtest) qui n'a rien à saisir.
*/
private eligibleVoters(): string[] { private eligibleVoters(): string[] {
const secret = this.runtime?.secretPlayerId
return [...this.room.players.values()] return [...this.room.players.values()]
.filter((p) => p.connected && p.name !== "" && p.id !== secret) .filter((p) => p.connected)
.map((p) => p.id) .map((p) => p.id)
} }

View file

@ -1,54 +0,0 @@
// Matching tolérant pour les réponses libres (titre/artiste).
// Normalisation (minuscules, accents, ponctuation) + distance de Levenshtein.
/** Minuscule, sans accents, sans ponctuation, espaces compactés. */
export function normalize(text: string): string {
return text
.toLowerCase()
.normalize("NFD")
.replace(/[̀-ͯ]/g, "") // accents
.replace(/\(.*?\)|\[.*?\]/g, " ") // contenu entre parenthèses/crochets
.replace(/[^a-z0-9\s]/g, " ") // ponctuation
.replace(/\s+/g, " ")
.trim()
}
/** Distance d'édition de Levenshtein. */
export function levenshtein(a: string, b: string): number {
if (a === b) return 0
if (a.length === 0) return b.length
if (b.length === 0) return a.length
let prev = Array.from({ length: b.length + 1 }, (_, i) => i)
for (let i = 1; i <= a.length; i++) {
const curr = [i]
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost)
}
prev = curr
}
return prev[b.length]
}
/**
* Vrai si `guess` correspond à `truth` avec tolérance :
* - égalité après normalisation, ou
* - une normalisation contient l'autre (réponse partielle), ou
* - distance de Levenshtein ~20% de la longueur (fautes de frappe).
*/
export function fuzzyMatch(guess: string, truth: string): boolean {
const g = normalize(guess)
const t = normalize(truth)
if (!g || !t) {
return false
}
if (g === t) {
return true
}
// Réponse contenue (ex: "zelda" pour "the legend of zelda").
if (g.length >= 3 && (t.includes(g) || g.includes(t))) {
return true
}
const tolerance = Math.floor(Math.max(g.length, t.length) * 0.2)
return levenshtein(g, t) <= tolerance
}

View file

@ -1,128 +0,0 @@
import { describe, expect, test } from "bun:test"
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"
function track(submittedBy: string): BlindtestTrack {
return {
id: "t1",
youtubeId: "abc12345678",
url: "https://youtu.be/abc12345678",
title: "The Legend of Zelda Main Theme",
artist: "Koji Kondo",
submittedBy,
}
}
function ctxFor(
room: ReturnType<RoomManager["create"]>["room"],
t: BlindtestTrack,
mode: "title_artist" | "who_added" | "mixed"
): RoundContext {
room.settings.blindtestMode = mode
return {
room,
djId: null,
votes: new Map(),
startedAt: 0,
endsAt: 10_000,
data: { track: t },
}
}
describe("match", () => {
test("normalize retire accents/ponctuation/casse", () => {
expect(normalize("Pokémon: Rouge & Bleu!")).toBe("pokemon rouge bleu")
})
test("fuzzyMatch tolère fautes et réponses partielles", () => {
expect(fuzzyMatch("zelda", "The Legend of Zelda")).toBe(true)
expect(fuzzyMatch("koji kondo", "Koji Kondo")).toBe(true)
expect(fuzzyMatch("mario", "The Legend of Zelda")).toBe(false)
})
})
describe("BlindtestRound", () => {
test("start : le DJ est neutre (jamais le contributeur)", () => {
const rooms = new RoomManager()
const { room, player: a } = rooms.create("Alice", "sa")
const { player: b } = rooms.join(room.code, "Bob", "sb")
room.blindtestTracks = [track(a.id)]
prepareBlindtestForRoom(room)
const round = new BlindtestRound()
const start = round.start(room)
expect(start.djId).toBe(b.id) // Bob (pas Alice, la contributrice)
expect(start.payload).toEqual({ trackId: "t1", youtubeId: "abc12345678" })
})
test("who_added : le contributeur gagne par tromperie (mauvaises devinettes)", () => {
const rooms = new RoomManager()
const { room, player: a } = rooms.create("Alice", "sa")
const { player: b } = rooms.join(room.code, "Bob", "sb")
const { player: c } = rooms.join(room.code, "Carol", "sc")
const t = track(a.id) // Alice = contributrice/DJ
const round = new BlindtestRound()
const ctx = ctxFor(room, t, "who_added")
round.submitAnswer(ctx, b.id, { guessedPlayerId: c.id }) // faux
round.submitAnswer(ctx, c.id, { guessedPlayerId: a.id }) // juste
round.submitAnswer(ctx, a.id, { guessedPlayerId: b.id }) // contributrice : non scorée
const deltas = round.score(ctx)
// Carol devine juste → 100 ; Alice trompe Bob (1 mauvaise devinette) → 50
expect(deltas).toContainEqual({ playerId: c.id, delta: 100 })
expect(deltas).toContainEqual({ playerId: a.id, delta: 50 })
expect(deltas.find((d) => d.playerId === b.id)).toBeUndefined()
})
test("who_added : points si on devine le bon contributeur", () => {
const rooms = new RoomManager()
const { room, player: a } = rooms.create("Alice", "sa")
const { player: b } = rooms.join(room.code, "Bob", "sb")
const t = track(a.id)
const round = new BlindtestRound()
const ctx = ctxFor(room, t, "who_added")
round.submitAnswer(ctx, b.id, { guessedPlayerId: a.id }) // juste
const deltas = round.score(ctx)
expect(deltas).toEqual([{ playerId: b.id, delta: 100 }])
})
test("title_artist : 60 titre + 40 artiste, fuzzy", () => {
const rooms = new RoomManager()
const { room, player: a } = rooms.create("Alice", "sa")
const { player: b } = rooms.join(room.code, "Bob", "sb")
const t = track(a.id)
const round = new BlindtestRound()
const ctx = ctxFor(room, t, "title_artist")
round.submitAnswer(ctx, b.id, { title: "zelda", artist: "koji kondo" })
const { perPlayerResult } = round.reveal(ctx)
expect(perPlayerResult[b.id]).toMatchObject({
titleCorrect: true,
artistCorrect: true,
points: 100,
})
expect(round.score(ctx)).toEqual([{ playerId: b.id, delta: 100 }])
})
test("le contributeur ne marque pas sur son propre titre", () => {
const rooms = new RoomManager()
const { room, player: a } = rooms.create("Alice", "sa")
const t = track(a.id)
const round = new BlindtestRound()
const ctx = ctxFor(room, t, "who_added")
round.submitAnswer(ctx, a.id, { guessedPlayerId: a.id })
expect(round.score(ctx)).toEqual([])
})
test("reveal expose le titre, l'artiste et le contributeur", () => {
const rooms = new RoomManager()
const { room, player: a } = rooms.create("Alice", "sa")
const t = track(a.id)
const round = new BlindtestRound()
const ctx = ctxFor(room, t, "title_artist")
const { truth } = round.reveal(ctx)
expect(truth.submittedBy).toBe(a.id)
expect(truth.submittedByName).toBe("Alice")
expect(truth.title).toBe("The Legend of Zelda Main Theme")
})
})

View file

@ -1,155 +0,0 @@
// Épreuve Blindtest : un titre = une manche. Le contributeur du titre est le DJ
// (il pilote la lecture, son identité reste cachée aux autres). Il ne marque pas
// de points, SAUF en "qui l'a ajouté" : il gagne quand un joueur le devine mal.
import type {
Answer,
BlindtestMode,
BlindtestPerPlayerResult,
BlindtestPlayerResult,
BlindtestRevealTruth,
BlindtestRoundPayload,
ScoreDelta,
} from "@nerdware/shared"
import type { GameRound, RoundContext, RoundStart } from "../../round"
import type { BlindtestTrack, ServerRoom } from "../../../rooms"
import { fuzzyMatch } from "../../match"
import { takeTrack } from "./pool"
const TITLE_POINTS = 60
const ARTIST_POINTS = 40
const WHO_POINTS = 100
/** Points gagnés par le contributeur pour chaque joueur qui le devine mal. */
const MISDIRECTION_POINTS = 50
interface BlindtestRoundData {
track: BlindtestTrack
}
/** Tire un DJ neutre : un joueur connecté qui n'a pas soumis ce titre. */
function pickDj(room: ServerRoom, submittedBy: string): string | null {
const eligible = [...room.players.values()].filter(
(p) => p.connected && p.id !== submittedBy
)
if (eligible.length === 0) {
return null
}
return eligible[Math.floor(Math.random() * eligible.length)].id
}
/** Résultat d'un joueur VOTANT (≠ contributeur) selon le mode. */
function voterResult(
mode: BlindtestMode,
answer: Answer | undefined,
track: BlindtestTrack
): BlindtestPlayerResult {
const a = (answer ?? {}) as {
title?: string
artist?: string
guessedPlayerId?: string
}
let points = 0
const result: BlindtestPlayerResult = { points: 0 }
if (mode === "title_artist" || mode === "mixed") {
result.titleCorrect = a.title ? fuzzyMatch(a.title, track.title) : false
result.artistCorrect = a.artist ? fuzzyMatch(a.artist, track.artist) : false
if (result.titleCorrect) points += TITLE_POINTS
if (result.artistCorrect) points += ARTIST_POINTS
}
if (mode === "who_added" || mode === "mixed") {
result.guessedCorrect = a.guessedPlayerId === track.submittedBy
if (result.guessedCorrect) points += WHO_POINTS
}
result.points = points
return result
}
export class BlindtestRound implements GameRound {
readonly type = "blindtest" as const
start(room: ServerRoom): RoundStart {
const track = takeTrack(room)
// DJ neutre (jamais le contributeur) : il pilote la lecture et vote à l'aveugle.
const djId = track ? pickDj(room, track.submittedBy) : null
const payload: BlindtestRoundPayload | null = track
? { trackId: track.id, youtubeId: track.youtubeId }
: null
return {
djId,
payload,
secretPlayerId: track?.submittedBy,
data: track ? { track } : null,
}
}
submitAnswer(ctx: RoundContext, playerId: string, answer: Answer): void {
// Premier vote verrouillé (idempotent). Le contributeur peut voter mais son
// vote ne lui rapporte rien (voir buildResults) ; ça évite de bloquer la fin.
if (ctx.votes.has(playerId)) {
return
}
ctx.votes.set(playerId, answer)
}
/** Résultat par joueur (votants + bonus de tromperie du contributeur). */
private buildResults(ctx: RoundContext): BlindtestPerPlayerResult {
const { track } = ctx.data as BlindtestRoundData
const mode = ctx.room.settings.blindtestMode
const perPlayer: BlindtestPerPlayerResult = {}
let wrongGuessers = 0
for (const player of ctx.room.players.values()) {
if (player.id === track.submittedBy) {
continue
}
const result = voterResult(mode, ctx.votes.get(player.id), track)
perPlayer[player.id] = result
if (
(mode === "who_added" || mode === "mixed") &&
result.guessedCorrect === false
) {
wrongGuessers++
}
}
// Contributeur : pas de points hors who_added/mixed ; sinon tromperie.
const misdirection =
mode === "who_added" || mode === "mixed"
? wrongGuessers * MISDIRECTION_POINTS
: 0
perPlayer[track.submittedBy] = { points: misdirection }
return perPlayer
}
reveal(ctx: RoundContext): {
truth: BlindtestRevealTruth
perPlayerResult: BlindtestPerPlayerResult
} {
const { track } = ctx.data as BlindtestRoundData
const submitter = ctx.room.players.get(track.submittedBy)
return {
truth: {
title: track.title,
artist: track.artist,
youtubeId: track.youtubeId,
submittedBy: track.submittedBy,
submittedByName: submitter?.name ?? "?",
},
perPlayerResult: this.buildResults(ctx),
}
}
score(ctx: RoundContext): ScoreDelta[] {
const perPlayer = this.buildResults(ctx)
const deltas: ScoreDelta[] = []
for (const [playerId, result] of Object.entries(perPlayer)) {
if (result.points > 0) {
deltas.push({ playerId, delta: result.points })
}
}
return deltas
}
}

View file

@ -1,6 +0,0 @@
import { registerRound } from "../../registry"
import { BlindtestRound } from "./blindtest-round"
registerRound("blindtest", () => new BlindtestRound())
export { BlindtestRound }

View file

@ -1,26 +0,0 @@
// File d'attente des titres blindtest par room, préchargée (mélangée) au
// lancement de la partie. Le BlindtestRound y pioche un titre par manche.
import type { BlindtestTrack, ServerRoom } from "../../../rooms"
const queueByRoom = new WeakMap<ServerRoom, BlindtestTrack[]>()
function shuffle<T>(items: T[]): T[] {
const arr = [...items]
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[arr[i], arr[j]] = [arr[j], arr[i]]
}
return arr
}
/** Mélange les titres soumis dans une file pour la partie. */
export function prepareBlindtestForRoom(room: ServerRoom): void {
queueByRoom.set(room, shuffle(room.blindtestTracks))
}
/** Pioche le prochain titre (ou null si la file est vide). */
export function takeTrack(room: ServerRoom): BlindtestTrack | null {
const queue = queueByRoom.get(room)
return queue && queue.length > 0 ? (queue.shift() ?? null) : null
}

View file

@ -1,20 +0,0 @@
import { describe, expect, test } from "bun:test"
import { extractYoutubeId } from "./youtube"
describe("extractYoutubeId", () => {
test.each([
["https://www.youtube.com/watch?v=dQw4w9WgXcQ", "dQw4w9WgXcQ"],
["https://youtu.be/dQw4w9WgXcQ", "dQw4w9WgXcQ"],
["https://www.youtube.com/shorts/dQw4w9WgXcQ", "dQw4w9WgXcQ"],
["https://www.youtube.com/embed/dQw4w9WgXcQ", "dQw4w9WgXcQ"],
["https://youtube.com/watch?v=dQw4w9WgXcQ&t=42s", "dQw4w9WgXcQ"],
["dQw4w9WgXcQ", "dQw4w9WgXcQ"],
])("extrait l'ID de %s", (url, expected) => {
expect(extractYoutubeId(url)).toBe(expected)
})
test("renvoie null pour une URL non YouTube", () => {
expect(extractYoutubeId("https://example.com")).toBeNull()
expect(extractYoutubeId("")).toBeNull()
})
})

View file

@ -1,53 +0,0 @@
// Extraction d'ID YouTube + vérification via oEmbed (existe + embeddable),
// qui fournit aussi titre et auteur pour le reveal.
/** Extrait l'ID vidéo (11 chars) d'une URL YouTube (watch, youtu.be, shorts, embed). */
export function extractYoutubeId(input: string): string | null {
const url = input.trim()
// ID brut directement collé.
if (/^[\w-]{11}$/.test(url)) {
return url
}
const patterns = [
/[?&]v=([\w-]{11})/, // watch?v=ID
/youtu\.be\/([\w-]{11})/, // youtu.be/ID
/\/shorts\/([\w-]{11})/, // /shorts/ID
/\/embed\/([\w-]{11})/, // /embed/ID
]
for (const re of patterns) {
const m = url.match(re)
if (m) {
return m[1]
}
}
return null
}
export interface YoutubeMeta {
title: string
artist: string
}
/**
* Vérifie via oEmbed que la vidéo existe et est intégrable, et renvoie
* { title, artist }. null si introuvable/privée/embedding désactivé.
*/
export async function fetchYoutubeMeta(
youtubeId: string
): Promise<YoutubeMeta | null> {
const url = `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${youtubeId}&format=json`
try {
const res = await fetch(url)
if (!res.ok) {
// 401/404 → vidéo privée, supprimée, ou embedding désactivé.
return null
}
const json = (await res.json()) as { title?: string; author_name?: string }
return {
title: json.title ?? "",
artist: json.author_name ?? "",
}
} catch {
return null
}
}

View file

@ -2,19 +2,13 @@
// pour ses effets de bord (registerRound). Ajouter un mode = une ligne ici. // pour ses effets de bord (registerRound). Ajouter un mode = une ligne ici.
import "./quiz" import "./quiz"
import "./blindtest"
import type { ServerRoom } from "../../rooms" import type { ServerRoom } from "../../rooms"
import { prepareQuizForRoom } from "./quiz/pool" import { prepareQuizForRoom } from "./quiz/pool"
import { prepareBlindtestForRoom } from "./blindtest/pool"
/** Précharge le contenu nécessaire avant de lancer la partie. */ /** Précharge le contenu nécessaire avant de lancer la partie (ex: pool quiz). */
export async function prepareRoom(room: ServerRoom): Promise<void> { export async function prepareRoom(room: ServerRoom): Promise<void> {
const types = new Set(room.settings.rounds.map((r) => r.type)) if (room.settings.rounds.some((r) => r.type === "quiz")) {
if (types.has("quiz")) {
await prepareQuizForRoom(room) await prepareQuizForRoom(room)
} }
if (types.has("blindtest")) {
prepareBlindtestForRoom(room)
}
} }

View file

@ -8,12 +8,8 @@ export interface QuizQuestion {
id: string id: string
format: QuizFormat format: QuizFormat
prompt: string prompt: string
/** mcq/truefalse uniquement. */ choices: string[]
choices?: string[] correctIndex: number
/** mcq/truefalse uniquement. */
correctIndex?: number
/** free : réponses acceptées (matching tolérant). */
acceptedAnswers?: string[]
category: string category: string
difficulty: number difficulty: number
} }
@ -111,45 +107,4 @@ export const QUIZ_QUESTIONS: QuizQuestion[] = [
category: "Pop culture", category: "Pop culture",
difficulty: 1, 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,
},
] ]

View file

@ -2,15 +2,11 @@ import { describe, expect, test } from "bun:test"
import { RoomManager } from "../../../rooms" import { RoomManager } from "../../../rooms"
import type { RoundContext } from "../../round" import type { RoundContext } from "../../round"
import { QuizRound } from "./quiz-round" import { QuizRound } from "./quiz-round"
import { QUIZ_QUESTIONS, type QuizQuestion } from "./questions" import { QUIZ_QUESTIONS } from "./questions"
const question = QUIZ_QUESTIONS[0] // "Link" → correctIndex 1 const question = QUIZ_QUESTIONS[0] // "Link" → correctIndex 1
function makeCtx(q: QuizQuestion = question): { function makeCtx(): { ctx: RoundContext; p1: string; p2: string } {
ctx: RoundContext
p1: string
p2: string
} {
const rooms = new RoomManager() const rooms = new RoomManager()
const { room, player: a } = rooms.create("Alice", "sa") const { room, player: a } = rooms.create("Alice", "sa")
const { player: b } = rooms.join(room.code, "Bob", "sb") const { player: b } = rooms.join(room.code, "Bob", "sb")
@ -20,7 +16,7 @@ function makeCtx(q: QuizQuestion = question): {
votes: new Map(), votes: new Map(),
startedAt: 0, startedAt: 0,
endsAt: 10_000, endsAt: 10_000,
data: { question: q, votedAt: new Map() }, data: { question, votedAt: new Map() },
} }
return { ctx, p1: a.id, p2: b.id } return { ctx, p1: a.id, p2: b.id }
} }
@ -60,24 +56,4 @@ describe("QuizRound", () => {
const deltas = round.score(ctx) const deltas = round.score(ctx)
expect(deltas).toEqual([{ playerId: p1, delta: 190 }]) // 100 + round(100 * 0.9) 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 }])
})
}) })

View file

@ -1,5 +1,5 @@
// Épreuve Quiz : une question = une manche. Pas de DJ, pas d'audio. // Épreuve Quiz : une question = une manche. Pas de DJ, pas d'audio.
// Formats : mcq, truefalse (choix), free (saisie libre, matching tolérant). // Implémente le contrat GameRound ; le moteur fait tout le reste.
import type { import type {
Answer, Answer,
@ -10,7 +10,6 @@ import type {
} from "@nerdware/shared" } from "@nerdware/shared"
import { hasDb } from "../../../db" import { hasDb } from "../../../db"
import { markQuestionPlayed } from "../../../db/quiz-repo" import { markQuestionPlayed } from "../../../db/quiz-repo"
import { fuzzyMatch } from "../../match"
import type { GameRound, RoundContext, RoundStart } from "../../round" import type { GameRound, RoundContext, RoundStart } from "../../round"
import type { ServerRoom } from "../../../rooms" import type { ServerRoom } from "../../../rooms"
import type { QuizQuestion } from "./questions" import type { QuizQuestion } from "./questions"
@ -26,29 +25,10 @@ interface QuizRoundData {
votedAt: Map<string, number> votedAt: Map<string, number>
} }
function asChoice(answer: Answer): number | null { function isQuizAnswer(answer: Answer): answer is { choiceIndex: number } {
const v = (answer as { choiceIndex?: unknown }).choiceIndex return (
return typeof v === "number" ? v : null typeof (answer as { choiceIndex?: unknown }).choiceIndex === "number"
} )
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 { export class QuizRound implements GameRound {
@ -65,12 +45,10 @@ export class QuizRound implements GameRound {
const payload: QuizQuestionPayload = { const payload: QuizQuestionPayload = {
format: question.format, format: question.format,
prompt: question.prompt, prompt: question.prompt,
choices: question.choices,
category: question.category, category: question.category,
difficulty: question.difficulty, difficulty: question.difficulty,
} }
if (question.format !== "free") {
payload.choices = question.choices
}
const data: QuizRoundData = { question, votedAt: new Map() } const data: QuizRoundData = { question, votedAt: new Map() }
return { djId: null, payload, data } return { djId: null, payload, data }
} }
@ -80,42 +58,29 @@ export class QuizRound implements GameRound {
if (ctx.votes.has(playerId)) { if (ctx.votes.has(playerId)) {
return return
} }
const { question, votedAt } = ctx.data as QuizRoundData if (!isQuizAnswer(answer)) {
if (question.format === "free") { return
const text = asText(answer)
if (text === null || text.trim().length === 0) {
return
}
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 })
} }
const { question, votedAt } = ctx.data as QuizRoundData
if (answer.choiceIndex < 0 || answer.choiceIndex >= question.choices.length) {
return
}
ctx.votes.set(playerId, { choiceIndex: answer.choiceIndex })
votedAt.set(playerId, this.now()) votedAt.set(playerId, this.now())
} }
reveal(ctx: RoundContext): { reveal(ctx: RoundContext): { truth: QuizRevealTruth; perPlayerResult: QuizPerPlayerResult } {
truth: QuizRevealTruth
perPlayerResult: QuizPerPlayerResult
} {
const { question } = ctx.data as QuizRoundData const { question } = ctx.data as QuizRoundData
const perPlayerResult: QuizPerPlayerResult = {} const perPlayerResult: QuizPerPlayerResult = {}
for (const player of ctx.room.players.values()) { for (const player of ctx.room.players.values()) {
const vote = ctx.votes.get(player.id) const vote = ctx.votes.get(player.id) as { choiceIndex: number } | undefined
const choiceIndex = vote ? vote.choiceIndex : null
perPlayerResult[player.id] = { perPlayerResult[player.id] = {
choiceIndex: vote ? asChoice(vote) : null, choiceIndex,
correct: isCorrect(question, vote), correct: choiceIndex === question.correctIndex,
} }
} }
const truth: QuizRevealTruth = return { truth: { correctIndex: question.correctIndex }, perPlayerResult }
question.format === "free"
? { answer: question.acceptedAnswers?.[0] ?? "" }
: { correctIndex: question.correctIndex }
return { truth, perPlayerResult }
} }
score(ctx: RoundContext): ScoreDelta[] { score(ctx: RoundContext): ScoreDelta[] {
@ -123,7 +88,7 @@ export class QuizRound implements GameRound {
const total = ctx.endsAt - ctx.startedAt const total = ctx.endsAt - ctx.startedAt
const deltas: ScoreDelta[] = [] const deltas: ScoreDelta[] = []
for (const [playerId, vote] of ctx.votes) { for (const [playerId, vote] of ctx.votes) {
if (!isCorrect(question, vote)) { if ((vote as { choiceIndex: number }).choiceIndex !== question.correctIndex) {
continue continue
} }
// Bonus rapidité : proportionnel au temps restant au moment du vote. // Bonus rapidité : proportionnel au temps restant au moment du vote.

View file

@ -1,13 +1,7 @@
// Contrat générique d'une épreuve. Le moteur ne connaît QUE cette interface : // Contrat générique d'une épreuve. Le moteur ne connaît QUE cette interface :
// brancher une nouvelle épreuve = écrire un module, pas retoucher la plomberie. // brancher une nouvelle épreuve = écrire un module, pas retoucher la plomberie.
import type { import type { Answer, RevealPayload, RoundType, ScoreDelta } from "@nerdware/shared"
Answer,
MediaControlPayload,
RevealPayload,
RoundType,
ScoreDelta,
} from "@nerdware/shared"
import type { ServerRoom } from "../rooms" import type { ServerRoom } from "../rooms"
/** Ce que `start()` renvoie : la partie publique + les données privées de la manche. */ /** Ce que `start()` renvoie : la partie publique + les données privées de la manche. */
@ -18,11 +12,6 @@ export interface RoundStart {
durationSec?: number durationSec?: number
/** Payload broadcasté aux clients, SANS la réponse. */ /** Payload broadcasté aux clients, SANS la réponse. */
payload: unknown payload: unknown
/**
* Joueur recevant un round:start privé avec `mine: true` et exclu des votants
* (blindtest : le contributeur du titre). Jamais révélé aux autres.
*/
secretPlayerId?: string
/** Données privées (ex: la bonne réponse), conservées jusqu'au reveal. Jamais diffusées. */ /** Données privées (ex: la bonne réponse), conservées jusqu'au reveal. Jamais diffusées. */
data?: unknown data?: unknown
} }
@ -63,6 +52,4 @@ export interface GameRound {
export interface RoomGameController { export interface RoomGameController {
run(): Promise<void> run(): Promise<void>
handleVote(playerId: string, answer: Answer): void handleVote(playerId: string, answer: Answer): void
/** Relais média du DJ (blindtest) : rebroadcast media:sync à toute la room. */
handleMediaControl(playerId: string, payload: MediaControlPayload): void
} }

View file

@ -10,14 +10,9 @@ import type {
} from "@nerdware/shared" } from "@nerdware/shared"
import type { IoServer } from "./socket" import type { IoServer } from "./socket"
import { RoomError, RoomManager, type ServerRoom } from "./rooms" import { RoomError, RoomManager, type ServerRoom } from "./rooms"
import { randomUUID } from "node:crypto"
import { GameEngine } from "./game/engine" import { GameEngine } from "./game/engine"
import { hasRound } from "./game/registry" import { hasRound } from "./game/registry"
import { prepareRoom } from "./game/modes" import { prepareRoom } from "./game/modes"
import {
extractYoutubeId,
fetchYoutubeMeta,
} from "./game/modes/blindtest/youtube"
type AppSocket = Socket< type AppSocket = Socket<
ClientToServerEvents, ClientToServerEvents,
@ -53,8 +48,11 @@ export function registerRoomHandlers(
} }
socket.on("room:create", (payload, ack) => { socket.on("room:create", (payload, ack) => {
// Le pseudo est choisi dans la room (player:setName) : on crée sans nom. const name = cleanName(payload?.playerName)
const name = cleanName(payload?.playerName) ?? "" if (!name) {
ack({ ok: false, error: { code: "INVALID_NAME", message: "Pseudo invalide." } })
return
}
try { try {
const { room, player } = rooms.create(name, socket.id) const { room, player } = rooms.create(name, socket.id)
socket.data.playerId = player.id socket.data.playerId = player.id
@ -68,8 +66,12 @@ export function registerRoomHandlers(
}) })
socket.on("room:join", (payload, ack) => { socket.on("room:join", (payload, ack) => {
const name = cleanName(payload?.playerName) ?? "" const name = cleanName(payload?.playerName)
const code = typeof payload?.roomCode === "string" ? payload.roomCode.trim().toUpperCase() : "" const code = typeof payload?.roomCode === "string" ? payload.roomCode.trim().toUpperCase() : ""
if (!name) {
ack({ ok: false, error: { code: "INVALID_NAME", message: "Pseudo invalide." } })
return
}
try { try {
const { room, player } = rooms.join(code, name, socket.id) const { room, player } = rooms.join(code, name, socket.id)
socket.data.playerId = player.id socket.data.playerId = player.id
@ -82,22 +84,6 @@ export function registerRoomHandlers(
} }
}) })
socket.on("player:setName", (payload) => {
const code = socket.data.roomCode
const playerId = socket.data.playerId
const room = code ? rooms.get(code) : undefined
const name = cleanName(payload?.name)
if (!room || !playerId || !name) {
return
}
const player = room.players.get(playerId)
if (!player) {
return
}
player.name = name
broadcastState(room)
})
socket.on("lobby:updateSettings", (payload) => { socket.on("lobby:updateSettings", (payload) => {
const code = socket.data.roomCode const code = socket.data.roomCode
const room = code ? rooms.get(code) : undefined const room = code ? rooms.get(code) : undefined
@ -105,10 +91,8 @@ export function registerRoomHandlers(
return return
} }
room.settings = { room.settings = {
gameType: payload.gameType,
blindtestMode: payload.blindtestMode, blindtestMode: payload.blindtestMode,
roundDuration: payload.roundDuration, roundDuration: payload.roundDuration,
tracksPerPlayer: payload.tracksPerPlayer,
rounds: payload.rounds, rounds: payload.rounds,
} }
broadcastState(room) broadcastState(room)
@ -137,39 +121,6 @@ export function registerRoomHandlers(
}) })
return return
} }
const hasBlindtest = room.settings.rounds.some((r) => r.type === "blindtest")
const connected = [...room.players.values()].filter(
(p) => p.connected && p.name !== ""
).length
if (hasBlindtest && connected < 3) {
ack({
ok: false,
error: {
code: "NEED_THREE",
message: "Le blindtest nécessite au moins 3 joueurs.",
},
})
return
}
if (hasBlindtest) {
const tpp = room.settings.tracksPerPlayer
const pending = [...room.players.values()].some(
(p) =>
p.connected &&
p.name !== "" &&
room.blindtestTracks.filter((t) => t.submittedBy === p.id).length < tpp
)
if (pending) {
ack({
ok: false,
error: {
code: "TRACKS_PENDING",
message: "Tous les joueurs n'ont pas encore soumis leurs titres.",
},
})
return
}
}
ack({ ok: true, data: null }) ack({ ok: true, data: null })
// Précharge le contenu (pool quiz depuis la DB), puis lance la boucle. // Précharge le contenu (pool quiz depuis la DB), puis lance la boucle.
// Fire-and-forget : la boucle de jeu broadcast son propre état. // Fire-and-forget : la boucle de jeu broadcast son propre état.
@ -182,88 +133,6 @@ export function registerRoomHandlers(
.catch((err) => console.error("[game] run failed", err)) .catch((err) => console.error("[game] run failed", err))
}) })
socket.on("blindtest:submitTrack", async (payload, ack) => {
const code = socket.data.roomCode
const playerId = socket.data.playerId
const room = code ? rooms.get(code) : undefined
if (!room || !playerId || room.status !== "lobby") {
ack({ accepted: false, reason: "Soumission impossible maintenant." })
return
}
const youtubeId = extractYoutubeId(payload?.youtubeUrl ?? "")
if (!youtubeId) {
ack({ accepted: false, reason: "Lien YouTube invalide." })
return
}
const mine = room.blindtestTracks.filter((t) => t.submittedBy === playerId)
if (mine.length >= room.settings.tracksPerPlayer) {
ack({ accepted: false, reason: "Quota de titres atteint." })
return
}
if (room.blindtestTracks.some((t) => t.youtubeId === youtubeId)) {
ack({ accepted: false, reason: "Titre déjà soumis." })
return
}
const meta = await fetchYoutubeMeta(youtubeId)
if (!meta) {
ack({
accepted: false,
reason: "Vidéo introuvable ou non intégrable.",
})
return
}
// Re-vérifie la room après l'await (le joueur a pu se déconnecter).
if (room.status !== "lobby" || !room.players.has(playerId)) {
ack({ accepted: false, reason: "Soumission impossible maintenant." })
return
}
const trackId = randomUUID()
room.blindtestTracks.push({
id: trackId,
youtubeId,
url: payload.youtubeUrl,
title: meta.title,
artist: meta.artist,
submittedBy: playerId,
})
ack({
accepted: true,
trackId,
youtubeId,
title: meta.title,
count: mine.length + 1,
})
broadcastState(room)
})
socket.on("blindtest:removeTrack", (payload, ack) => {
const code = socket.data.roomCode
const playerId = socket.data.playerId
const room = code ? rooms.get(code) : undefined
if (!room || !playerId || room.status !== "lobby") {
ack({ ok: false })
return
}
const before = room.blindtestTracks.length
room.blindtestTracks = room.blindtestTracks.filter(
(t) => !(t.id === payload.trackId && t.submittedBy === playerId)
)
const removed = room.blindtestTracks.length < before
ack({ ok: removed })
if (removed) {
broadcastState(room)
}
})
socket.on("media:control", (payload) => {
const code = socket.data.roomCode
const room = code ? rooms.get(code) : undefined
if (!room || !room.game || !socket.data.playerId) {
return
}
room.game.handleMediaControl(socket.data.playerId, payload)
})
socket.on("round:vote", (payload) => { socket.on("round:vote", (payload) => {
const code = socket.data.roomCode const code = socket.data.roomCode
const room = code ? rooms.get(code) : undefined const room = code ? rooms.get(code) : undefined
@ -273,22 +142,6 @@ export function registerRoomHandlers(
room.game.handleVote(socket.data.playerId, payload.answer) room.game.handleVote(socket.data.playerId, payload.answer)
}) })
socket.on("lobby:return", () => {
const code = socket.data.roomCode
const room = code ? rooms.get(code) : undefined
if (!room || room.hostId !== socket.data.playerId) {
return
}
// Rejouer : retour au lobby, scores remis à zéro, joueurs et titres conservés.
room.status = "lobby"
room.currentRound = -1
room.game = null
for (const playerId of room.scores.keys()) {
room.scores.set(playerId, 0)
}
broadcastState(room)
})
socket.on("disconnect", () => { socket.on("disconnect", () => {
const affected = rooms.handleDisconnect(socket.id) const affected = rooms.handleDisconnect(socket.id)
if (!affected) { if (!affected) {

View file

@ -18,16 +18,6 @@ export interface ServerPlayer {
socketId: string | null socketId: string | null
} }
/** Titre soumis pour le blindtest. `submittedBy` reste secret jusqu'au reveal. */
export interface BlindtestTrack {
id: string
youtubeId: string
url: string
title: string
artist: string
submittedBy: string
}
/** Room côté serveur : état runtime complet (le snapshot public en dérive). */ /** Room côté serveur : état runtime complet (le snapshot public en dérive). */
export interface ServerRoom { export interface ServerRoom {
code: string code: string
@ -37,8 +27,6 @@ export interface ServerRoom {
settings: RoomSettings settings: RoomSettings
scores: Map<string, number> scores: Map<string, number>
currentRound: number currentRound: number
/** Titres soumis pour le blindtest (mélangés au lancement). */
blindtestTracks: BlindtestTrack[]
/** Moteur de jeu actif (null tant qu'on est dans le lobby). */ /** Moteur de jeu actif (null tant qu'on est dans le lobby). */
game: RoomGameController | null game: RoomGameController | null
} }
@ -62,7 +50,6 @@ export class RoomManager {
settings: { ...DEFAULT_ROOM_SETTINGS }, settings: { ...DEFAULT_ROOM_SETTINGS },
scores: new Map([[player.id, 0]]), scores: new Map([[player.id, 0]]),
currentRound: -1, currentRound: -1,
blindtestTracks: [],
game: null, game: null,
} }
this.rooms.set(code, room) this.rooms.set(code, room)
@ -112,28 +99,22 @@ export class RoomManager {
/** Projette la room vers la vue publique diffusée aux clients (sans secrets). */ /** Projette la room vers la vue publique diffusée aux clients (sans secrets). */
toSnapshot(room: ServerRoom): RoomSnapshot { toSnapshot(room: ServerRoom): RoomSnapshot {
// Seuls les joueurs ayant choisi un pseudo sont visibles dans la room.
const named = [...room.players.values()].filter((p) => p.name !== "")
return { return {
code: room.code, code: room.code,
status: room.status, status: room.status,
hostId: room.hostId, hostId: room.hostId,
players: named.map((p) => ({ players: [...room.players.values()].map((p) => ({
id: p.id, id: p.id,
name: p.name, name: p.name,
connected: p.connected, connected: p.connected,
})), })),
settings: room.settings, settings: room.settings,
scores: named.map((p) => ({ scores: [...room.scores.entries()].map(([playerId, score]) => ({
playerId: p.id, playerId,
score: room.scores.get(p.id) ?? 0, score,
})), })),
currentRound: room.currentRound, currentRound: room.currentRound,
totalRounds: room.settings.rounds.length, totalRounds: room.settings.rounds.length,
submissions: named.map((p) => ({
playerId: p.id,
count: room.blindtestTracks.filter((t) => t.submittedBy === p.id).length,
})),
} }
} }

View file

@ -1,15 +1,11 @@
import { Route, Switch } from "wouter" import { Route, Switch } from "wouter"
import { HomePage } from "@/pages/home" import { HomePage } from "@/pages/home"
import { JoinPage } from "@/pages/join"
import { RoomPage } from "@/pages/room" import { RoomPage } from "@/pages/room"
export function App() { export function App() {
return ( return (
<Switch> <Switch>
<Route path="/" component={HomePage} /> <Route path="/" component={HomePage} />
<Route path="/join/:code">
{(params) => <JoinPage code={params.code.toUpperCase()} />}
</Route>
<Route path="/room/:code"> <Route path="/room/:code">
{(params) => <RoomPage code={params.code.toUpperCase()} />} {(params) => <RoomPage code={params.code.toUpperCase()} />}
</Route> </Route>

View file

@ -3,19 +3,9 @@
Dépose ici des fichiers d'animation pour les transitions entre manches : Dépose ici des fichiers d'animation pour les transitions entre manches :
formats supportés `*.gif`, `*.webp`, `*.apng`, `*.png`, `*.avif`. formats supportés `*.gif`, `*.webp`, `*.apng`, `*.png`, `*.avif`.
Rangement par mode (recommandé pour des transitions personnalisées) : - Ils sont détectés automatiquement au build (`import.meta.glob`).
- À chaque manche, un fichier est tiré **au hasard** parmi ceux présents.
- `quiz/` → jouées avant une manche de **quiz** - S'il n'y en a aucun, l'animation Framer par défaut (numéro + « PRÊT ?! ») est jouée.
- `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é 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 plein écran. Durée d'affichage ≈ 1,3 s (voir `VISIBLE_MS` dans

View file

@ -1,348 +0,0 @@
import { useEffect, useState } from "react"
import { motion } from "framer-motion"
import { Disc3, Pause, Play, Rewind } from "lucide-react"
import type {
BlindtestAnswer,
BlindtestMode,
BlindtestPerPlayerResult,
BlindtestRevealTruth,
BlindtestRoundPayload,
RoomSnapshot,
} from "@nerdware/shared"
import { Button } from "@workspace/ui/components/button"
import { useRoomStore } from "@/store/room"
import { useYoutube, type YoutubeApi } from "@/lib/youtube"
import { Countdown } from "@/components/countdown"
import { Avatar } from "@/components/avatar"
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"
function fmt(s: number): string {
const m = Math.floor(s / 60)
const sec = Math.floor(s % 60)
return `${m}:${sec.toString().padStart(2, "0")}`
}
export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) {
const round = useRoomStore((s) => s.round)
const reveal = useRoomStore((s) => s.reveal)
const mediaSync = useRoomStore((s) => s.mediaSync)
const playerId = useRoomStore((s) => s.playerId)
const hasVoted = useRoomStore((s) => s.hasVoted)
const voteBlindtest = useRoomStore((s) => s.voteBlindtest)
const mediaControl = useRoomStore((s) => s.mediaControl)
const track = round?.payload as BlindtestRoundPayload | undefined
const { hostRef, ready, api } = useYoutube(track?.youtubeId ?? "")
const isDj = !!playerId && round?.djId === playerId
const truth = reveal ? (reveal.truth as BlindtestRevealTruth) : null
const showReveal = truth !== null
// Non-DJ : on suit le DJ via media:sync (avec compensation de latence).
useEffect(() => {
if (isDj || !ready || !mediaSync) {
return
}
const elapsed = (Date.now() - mediaSync.atServerTs) / 1000
if (mediaSync.action === "play") {
api.seek(mediaSync.positionSec + Math.max(0, elapsed))
api.play()
} else if (mediaSync.action === "pause") {
api.seek(mediaSync.positionSec)
api.pause()
} else {
api.seek(mediaSync.positionSec)
}
}, [mediaSync, isDj, ready, api])
// Au reveal, on coupe le son.
useEffect(() => {
if (showReveal && ready) {
api.pause()
}
}, [showReveal, ready, api])
return (
<div className="flex w-full max-w-md flex-col gap-5">
<header className="flex items-center justify-between">
<span className="text-muted-foreground text-xs uppercase">
Blindtest {snapshot.currentRound + 1} / {snapshot.totalRounds}
</span>
{!showReveal && round && (
<Countdown
key={round.endsAt}
startsAt={round.startsAt}
endsAt={round.endsAt}
/>
)}
</header>
{/* Cadre 16:9 : le lecteur YouTube est caché derrière le disque pendant la
manche (son seulement), puis révélé (bannière visible) au reveal. */}
<div className="bg-card relative aspect-video w-full overflow-hidden rounded-2xl">
<div
ref={hostRef}
className="absolute inset-0 [&>iframe]:absolute [&>iframe]:inset-0 [&>iframe]:size-full"
/>
{!showReveal && (
<div className="bg-card absolute inset-0 flex items-center justify-center">
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 4, repeat: Infinity, ease: "linear" }}
>
<Disc3 className="text-muted-foreground size-24" />
</motion.div>
</div>
)}
</div>
{isDj && !showReveal && (
<DjControls ready={ready} api={api} mediaControl={mediaControl} />
)}
{!showReveal &&
(round?.mine ? (
<MisleadCard mode={snapshot.settings.blindtestMode} />
) : (
<VoteForm
mode={snapshot.settings.blindtestMode}
snapshot={snapshot}
playerId={playerId}
disabled={hasVoted}
onVote={voteBlindtest}
/>
))}
{showReveal && truth && (
<RevealCard
truth={truth}
result={
(reveal?.perPlayerResult as BlindtestPerPlayerResult)[playerId ?? ""]
}
/>
)}
</div>
)
}
function DjControls({
ready,
api,
mediaControl,
}: {
ready: boolean
api: YoutubeApi
mediaControl: (action: "play" | "pause" | "seek", positionSec: number) => void
}) {
const [pos, setPos] = useState(0)
const [dur, setDur] = useState(0)
useEffect(() => {
if (!ready) {
return
}
const id = setInterval(() => {
setPos(api.time())
setDur(api.duration())
}, 400)
return () => clearInterval(id)
}, [ready, api])
return (
<div className="flex flex-col gap-3">
<span className="text-muted-foreground text-center text-xs uppercase">
Tu es le DJ 🎧 pilote la lecture (et vote comme les autres)
</span>
<div className="flex justify-center gap-2">
<Button
size="sm"
disabled={!ready}
onClick={() => {
api.play()
mediaControl("play", api.time())
}}
>
<Play /> Play
</Button>
<Button
size="sm"
variant="secondary"
disabled={!ready}
onClick={() => {
api.pause()
mediaControl("pause", api.time())
}}
>
<Pause /> Pause
</Button>
<Button
size="sm"
variant="secondary"
disabled={!ready}
onClick={() => {
api.seek(0)
setPos(0)
mediaControl("seek", 0)
}}
>
<Rewind /> Début
</Button>
</div>
<div className="flex items-center gap-2 text-xs tabular-nums">
<span className="text-muted-foreground w-9 text-right">{fmt(pos)}</span>
<input
type="range"
min={0}
max={Math.max(dur, 1)}
step="any"
value={Math.min(pos, dur || 0)}
disabled={!ready}
onChange={(e) => {
const v = Number(e.target.value)
setPos(v)
api.seek(v)
mediaControl("seek", v)
}}
className="accent-primary flex-1"
/>
<span className="text-muted-foreground w-9">{fmt(dur)}</span>
</div>
</div>
)
}
function MisleadCard({ mode }: { mode: BlindtestMode }) {
return (
<div className="border-primary/40 bg-primary/5 flex flex-col items-center gap-2 rounded-xl border p-4 text-center">
<span className="text-2xl">🤫</span>
<p className="font-heading font-bold">C'est ton titre !</p>
<p className="text-muted-foreground text-sm">
{mode === "title_artist"
? "Tu ne votes pas. Savoure pendant que les autres cherchent."
: "Tu ne votes pas — ton but : que personne ne devine que c'est toi qui l'as ajouté. +50 par joueur trompé."}
</p>
</div>
)
}
function VoteForm({
mode,
snapshot,
playerId,
disabled,
onVote,
}: {
mode: BlindtestMode
snapshot: RoomSnapshot
playerId: string | null
disabled: boolean
onVote: (answer: BlindtestAnswer) => void
}) {
const [title, setTitle] = useState("")
const [artist, setArtist] = useState("")
const [guessed, setGuessed] = useState<string | null>(null)
const needsText = mode === "title_artist" || mode === "mixed"
const needsWho = mode === "who_added" || mode === "mixed"
const canSubmit =
!disabled &&
(!needsText || title.trim().length > 0) &&
(!needsWho || guessed !== null)
function submit() {
const answer: {
title?: string
artist?: string
guessedPlayerId?: string
} = {}
if (needsText) {
answer.title = title.trim()
answer.artist = artist.trim()
}
if (needsWho && guessed) {
answer.guessedPlayerId = guessed
}
onVote(answer as BlindtestAnswer)
}
if (disabled) {
return (
<p className="text-muted-foreground text-center text-xs">
Réponse envoyée en attente des autres
</p>
)
}
return (
<div className="flex flex-col gap-3">
{needsText && (
<>
<input
className={inputClass}
placeholder="Titre"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<input
className={inputClass}
placeholder="Artiste"
value={artist}
onChange={(e) => setArtist(e.target.value)}
/>
</>
)}
{needsWho && (
<div className="flex flex-wrap justify-center gap-2">
{snapshot.players
.filter((p) => p.id !== playerId)
.map((p) => (
<button
key={p.id}
onClick={() => setGuessed(p.id)}
className={`flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-sm ${
guessed === p.id
? "border-primary bg-primary/10"
: "border-input hover:bg-muted/60"
}`}
>
<Avatar seed={p.name} className="size-5" />
{p.name}
</button>
))}
</div>
)}
<Button disabled={!canSubmit} onClick={submit}>
Valider ma réponse
</Button>
</div>
)
}
function RevealCard({
truth,
result,
}: {
truth: BlindtestRevealTruth
result?: BlindtestPerPlayerResult[string]
}) {
return (
<div className="flex flex-col items-center gap-2 text-center">
<p className="font-heading text-lg font-bold text-balance">{truth.title}</p>
<p className="text-muted-foreground text-sm">{truth.artist}</p>
<p className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">Ajouté par</span>
<Avatar seed={truth.submittedByName} className="size-6" />
<span className="font-medium">{truth.submittedByName}</span>
</p>
{result && (
<p
className={`text-sm font-medium ${result.points > 0 ? "text-green-500" : "text-red-500"}`}
>
{result.points > 0 ? `+${result.points} points 🎉` : "Raté 💥"}
</p>
)}
</div>
)
}

View file

@ -1,6 +1,6 @@
import { Link } from "wouter" import { Link } from "wouter"
import { motion } from "framer-motion" import { motion } from "framer-motion"
import { Crown, ExternalLink, Music } from "lucide-react" import { Crown } from "lucide-react"
import type { PlayerScore, RoomSnapshot } from "@nerdware/shared" import type { PlayerScore, RoomSnapshot } from "@nerdware/shared"
import { Button } from "@workspace/ui/components/button" import { Button } from "@workspace/ui/components/button"
import { useRoomStore } from "@/store/room" import { useRoomStore } from "@/store/room"
@ -112,10 +112,7 @@ function PodiumColumn({
export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) { export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
const playerId = useRoomStore((s) => s.playerId) const playerId = useRoomStore((s) => s.playerId)
const finalScores = useRoomStore((s) => s.finalScores) const finalScores = useRoomStore((s) => s.finalScores)
const gameTracks = useRoomStore((s) => s.gameTracks)
const reset = useRoomStore((s) => s.reset) const reset = useRoomStore((s) => s.reset)
const returnToLobby = useRoomStore((s) => s.returnToLobby)
const isHost = snapshot.hostId === playerId
const scores: PlayerScore[] = finalScores ?? snapshot.scores const scores: PlayerScore[] = finalScores ?? snapshot.scores
const ranked = [...scores].sort((a, b) => b.score - a.score) const ranked = [...scores].sort((a, b) => b.score - a.score)
@ -197,62 +194,11 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
</ol> </ol>
)} )}
{gameTracks && gameTracks.length > 0 && ( <Link href="/">
<section className="flex flex-col gap-2 text-left"> <Button variant="secondary" className="w-full" onClick={reset}>
<h3 className="text-muted-foreground flex items-center gap-1.5 text-sm font-medium"> Retour à l'accueil
<Music className="size-4" /> Les musiques de la partie </Button>
</h3> </Link>
<ul className="flex flex-col gap-2">
{gameTracks.map((t) => (
<li
key={t.youtubeId}
className="bg-muted/40 flex items-center gap-3 rounded-lg p-2"
>
<img
src={`https://img.youtube.com/vi/${t.youtubeId}/mqdefault.jpg`}
alt=""
className="h-10 w-16 shrink-0 rounded object-cover"
/>
<span className="min-w-0 flex-1">
<span className="line-clamp-1 text-xs font-medium">
{t.title}
</span>
<span className="text-muted-foreground line-clamp-1 text-[10px]">
ajouté par {t.submittedByName}
</span>
</span>
<a
href={t.url}
target="_blank"
rel="noreferrer"
title="Ouvrir sur YouTube"
>
<Button size="icon-sm" variant="secondary">
<ExternalLink />
</Button>
</a>
</li>
))}
</ul>
</section>
)}
<div className="flex flex-col gap-2">
{isHost ? (
<Button className="w-full" onClick={returnToLobby}>
Rejouer
</Button>
) : (
<p className="text-muted-foreground text-xs">
En attente que l'hôte relance une partie
</p>
)}
<Link href="/">
<Button variant="secondary" className="w-full" onClick={reset}>
Quitter
</Button>
</Link>
</div>
</div> </div>
) )
} }

View file

@ -1,156 +1,25 @@
import { useState } from "react" import { useState } from "react"
import { import type { RoomSnapshot } from "@nerdware/shared"
Brain,
Headphones,
ListMusic,
Minus,
Music,
Play,
Plus,
Shuffle,
Trash2,
type LucideIcon,
} from "lucide-react"
import type {
BlindtestMode,
GameType,
RoomSnapshot,
RoundConfig,
} from "@nerdware/shared"
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"
const QUESTION_OPTIONS = [3, 5, 10] 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 MODE_LABELS: Record<BlindtestMode, string> = {
title_artist: "Titre & artiste",
who_added: "Qui l'a ajouté ?",
mixed: "Mixte",
}
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"
function Stepper({
value,
min = 1,
max = MAX_TRACKS,
onChange,
}: {
value: number
min?: number
max?: number
onChange: (v: number) => void
}) {
const clamp = (v: number) => Math.max(min, Math.min(max, v))
return (
<div className="flex items-center gap-1">
<Button
size="icon-sm"
variant="secondary"
disabled={value <= min}
onClick={() => onChange(clamp(value - 1))}
>
<Minus />
</Button>
<input
type="number"
min={min}
max={max}
value={value}
onChange={(e) => {
const n = parseInt(e.target.value, 10)
if (!Number.isNaN(n)) {
onChange(clamp(n))
}
}}
className="border-input bg-background h-8 w-14 rounded-md border text-center text-sm [appearance:textfield] focus-visible:outline-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<Button
size="icon-sm"
variant="secondary"
disabled={value >= max}
onClick={() => onChange(clamp(value + 1))}
>
<Plus />
</Button>
</div>
)
}
function shuffle<T>(items: T[]): T[] {
const arr = [...items]
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[arr[i], arr[j]] = [arr[j], arr[i]]
}
return arr
}
/** 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 : ordre mélangé pour qu'on ne sache pas ce qui vient ensuite.
const rounds: RoundConfig[] = [
...Array.from({ length: quizCount }, () => ({ type: "quiz" as const })),
...Array.from({ length: totalTracks }, () => ({ type: "blindtest" as const })),
]
return shuffle(rounds)
}
export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
const playerId = useRoomStore((s) => s.playerId) const playerId = useRoomStore((s) => s.playerId)
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 [count, setCount] = useState(5) const [count, setCount] = useState(5)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const totalTracks = snapshot.submissions.reduce((n, s) => n + s.count, 0) async function handleStart() {
const myCount =
snapshot.submissions.find((s) => s.playerId === playerId)?.count ?? 0
// 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"
const showBlindtest =
effectiveType === "blindtest" || effectiveType === "mixed"
const rounds = buildRounds(effectiveType, showQuiz ? count : 0, totalTracks)
// Blindtest : tout le monde doit avoir soumis son quota de titres.
const allSubmitted =
!showBlindtest ||
(snapshot.submissions.length > 0 &&
snapshot.submissions.every((s) => s.count >= tracksPerPlayer))
const canStart = rounds.length > 0 && allSubmitted
async function start() {
setError(null) setError(null)
setBusy(true) setBusy(true)
try { try {
await startGame(rounds) await startGame(count)
} catch (err) { } catch (err) {
setError((err as { message?: string }).message ?? "Erreur") setError((err as { message?: string }).message ?? "Erreur")
} finally { } finally {
@ -188,212 +57,35 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
</ul> </ul>
</section> </section>
{isHost && ( {isHost ? (
<div className="flex flex-col gap-1"> <section className="flex flex-col gap-3">
<div className="flex gap-2"> <div className="flex items-center justify-between">
{GAME_TYPES.map(({ value, label, Icon }) => { <span className="text-sm font-medium">Questions de quiz</span>
const needsThree = value !== "quiz" && !blindtestAvailable
return (
<Button
key={value}
className="flex-1"
variant={effectiveType === value ? "default" : "secondary"}
disabled={needsThree}
title={needsThree ? "Blindtest : 3 joueurs minimum" : undefined}
onClick={() => updateSettings({ gameType: value })}
>
<Icon /> {label}
</Button>
)
})}
</div>
{!blindtestAvailable && (
<p className="text-muted-foreground text-center text-xs">
Le blindtest se débloque à 3 joueurs.
</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">
<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
</span>
<div className="flex gap-1"> <div className="flex gap-1">
{(["title_artist", "who_added", "mixed"] as const).map((m) => ( {QUESTION_OPTIONS.map((n) => (
<Button <Button
key={m} key={n}
size="sm" size="sm"
className="flex-1" variant={count === n ? "default" : "secondary"}
variant={blindtestMode === m ? "default" : "secondary"} onClick={() => setCount(n)}
onClick={() => updateSettings({ blindtestMode: m })}
> >
{MODE_LABELS[m]} {n}
</Button> </Button>
))} ))}
</div> </div>
</div> </div>
<div className="flex items-center justify-between"> <Button disabled={busy} onClick={handleStart}>
<span className="flex items-center gap-1.5 text-sm font-medium"> {busy ? "Lancement…" : "Lancer la partie"}
<ListMusic className="size-4" /> Titres par joueur
</span>
<Stepper
value={tracksPerPlayer}
onChange={(v) => updateSettings({ tracksPerPlayer: v })}
/>
</div>
</div>
)}
{showBlindtest && (
<TrackSubmission tracksPerPlayer={tracksPerPlayer} myCount={myCount} />
)}
{isHost ? (
<div className="flex flex-col gap-1">
<Button disabled={busy || !canStart} onClick={start}>
<Play /> {busy ? "Lancement…" : `Lancer (${rounds.length} manches)`}
</Button> </Button>
{showBlindtest && !allSubmitted && ( {error && (
<p className="text-muted-foreground text-center text-xs"> <p className="text-destructive text-center text-sm">{error}</p>
En attente que tout le monde ait soumis ses {tracksPerPlayer}{" "}
titre(s).
</p>
)} )}
</div> </section>
) : ( ) : (
<p className="text-muted-foreground text-center text-xs"> <p className="text-muted-foreground text-center text-xs">
En attente du lancement par l'hôte En attente du lancement de la partie par l'hôte
{showBlindtest && ` (${totalTracks} titres soumis)`}
</p> </p>
)} )}
{error && <p className="text-destructive text-center text-sm">{error}</p>}
</div>
)
}
interface MyTrack {
trackId: string
title: string
youtubeId: string
}
function TrackSubmission({
tracksPerPlayer,
myCount,
}: {
tracksPerPlayer: number
myCount: number
}) {
const submitTrack = useRoomStore((s) => s.submitTrack)
const removeTrack = useRoomStore((s) => s.removeTrack)
const [url, setUrl] = useState("")
const [submitting, setSubmitting] = useState(false)
const [feedback, setFeedback] = useState<string | null>(null)
const [tracks, setTracks] = useState<MyTrack[]>([])
const quotaReached = myCount >= tracksPerPlayer
async function submit() {
if (!url.trim()) {
return
}
setSubmitting(true)
setFeedback(null)
const res = await submitTrack(url.trim())
if (res.accepted && res.trackId && res.youtubeId) {
setTracks((t) => [
...t,
{ trackId: res.trackId!, title: res.title ?? "", youtubeId: res.youtubeId! },
])
setUrl("")
} else {
setFeedback(res.reason ?? "Refusé")
}
setSubmitting(false)
}
async function remove(trackId: string) {
const ok = await removeTrack(trackId)
if (ok) {
setTracks((t) => t.filter((x) => x.trackId !== trackId))
}
}
return (
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">
Tes titres ({myCount}/{tracksPerPlayer})
</span>
{tracks.length > 0 && (
<ul className="flex flex-col gap-2">
{tracks.map((t) => (
<li
key={t.trackId}
className="bg-muted/40 flex items-center gap-3 rounded-lg p-2"
>
<img
src={`https://img.youtube.com/vi/${t.youtubeId}/mqdefault.jpg`}
alt=""
className="h-10 w-16 shrink-0 rounded object-cover"
/>
<span className="line-clamp-2 flex-1 text-xs">{t.title}</span>
<Button
size="icon-sm"
variant="secondary"
onClick={() => remove(t.trackId)}
title="Supprimer"
>
<Trash2 />
</Button>
</li>
))}
</ul>
)}
{!quotaReached && (
<div className="flex gap-2">
<input
className={inputClass}
placeholder="Lien YouTube"
value={url}
disabled={submitting}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && submit()}
/>
<Button
variant="secondary"
disabled={submitting || !url.trim()}
onClick={submit}
>
Ajouter
</Button>
</div>
)}
{feedback && <p className="text-destructive text-xs">{feedback}</p>}
</div> </div>
) )
} }

View file

@ -1,45 +0,0 @@
import { useState } from "react"
import { Button } from "@workspace/ui/components/button"
import { useRoomStore } from "@/store/room"
import { Avatar } from "@/components/avatar"
const inputClass =
"border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-center text-sm focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
/** Choix du pseudo dans la room, avec aperçu de l'avatar en temps réel. */
export function PseudoScreen({ code }: { code: string }) {
const setName = useRoomStore((s) => s.setName)
const [name, setName_] = useState("")
const canSubmit = name.trim().length > 0
return (
<div className="flex min-h-svh items-center justify-center p-6">
<div className="flex w-full max-w-xs flex-col items-center gap-5">
<p className="text-muted-foreground text-xs uppercase">
Room {code}
</p>
<Avatar
seed={name.trim() || "?"}
className="size-28 ring-2 ring-primary/30"
/>
<p className="font-heading text-lg font-bold">Choisis ton pseudo</p>
<input
className={inputClass}
placeholder="Ton pseudo"
value={name}
maxLength={24}
autoFocus
onChange={(e) => setName_(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && canSubmit && setName(name)}
/>
<Button
className="w-full"
disabled={!canSubmit}
onClick={() => setName(name)}
>
Entrer dans la room
</Button>
</div>
</div>
)
}

View file

@ -1,26 +1,19 @@
import { useState } from "react"
import type { import type {
QuizPerPlayerResult, QuizPerPlayerResult,
QuizQuestionPayload, QuizQuestionPayload,
QuizRevealTruth, QuizRevealTruth,
RoomSnapshot, RoomSnapshot,
} from "@nerdware/shared" } from "@nerdware/shared"
import { Button } from "@workspace/ui/components/button"
import { useRoomStore } from "@/store/room" import { useRoomStore } from "@/store/room"
import { Countdown } from "@/components/countdown" 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 }) { export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
const round = useRoomStore((s) => s.round) const round = useRoomStore((s) => s.round)
const reveal = useRoomStore((s) => s.reveal) const reveal = useRoomStore((s) => s.reveal)
const voteProgress = useRoomStore((s) => s.voteProgress) const voteProgress = useRoomStore((s) => s.voteProgress)
const myChoiceIndex = useRoomStore((s) => s.myChoiceIndex) const myChoiceIndex = useRoomStore((s) => s.myChoiceIndex)
const hasVoted = useRoomStore((s) => s.hasVoted)
const playerId = useRoomStore((s) => s.playerId) const playerId = useRoomStore((s) => s.playerId)
const vote = useRoomStore((s) => s.vote) const vote = useRoomStore((s) => s.vote)
const voteText = useRoomStore((s) => s.voteText)
if (!round) { if (!round) {
return ( return (
@ -31,7 +24,6 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
const question = round.payload as QuizQuestionPayload const question = round.payload as QuizQuestionPayload
const truth = reveal ? (reveal.truth as QuizRevealTruth) : null const truth = reveal ? (reveal.truth as QuizRevealTruth) : null
const showReveal = truth !== null const showReveal = truth !== null
const isFree = question.format === "free"
const myResult = reveal const myResult = reveal
? (reveal.perPlayerResult as QuizPerPlayerResult)[playerId ?? ""] ? (reveal.perPlayerResult as QuizPerPlayerResult)[playerId ?? ""]
: undefined : undefined
@ -74,28 +66,21 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
{question.prompt} {question.prompt}
</h2> </h2>
{isFree ? ( <div className="flex flex-col gap-2">
<FreeAnswer {question.choices.map((choice, index) => (
disabled={showReveal || hasVoted} <button
onSubmit={voteText} key={index}
/> className={choiceClass(index)}
) : ( disabled={showReveal || myChoiceIndex !== null}
<div className="flex flex-col gap-2"> onClick={() => vote(index)}
{(question.choices ?? []).map((choice, index) => ( >
<button {choice}
key={index} </button>
className={choiceClass(index)} ))}
disabled={showReveal || hasVoted} </div>
onClick={() => vote(index)}
>
{choice}
</button>
))}
</div>
)}
{!showReveal && {!showReveal &&
(hasVoted ? ( (myChoiceIndex !== null ? (
<p className="text-muted-foreground text-center text-xs"> <p className="text-muted-foreground text-center text-xs">
Réponse envoyée en attente des autres Réponse envoyée en attente des autres
{voteProgress ? ` (${voteProgress.count}/${voteProgress.total})` : ""} {voteProgress ? ` (${voteProgress.count}/${voteProgress.total})` : ""}
@ -109,49 +94,16 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
))} ))}
{showReveal && ( {showReveal && (
<div className="flex flex-col items-center gap-1"> <p
{isFree && ( className={`text-center text-sm font-medium ${myResult?.correct ? "text-green-500" : "text-red-500"}`}
<p className="text-sm"> >
<span className="text-muted-foreground">Réponse : </span> {myResult?.correct
<span className="font-medium">{truth?.answer}</span> ? "Bonne réponse ! 🎉"
</p> : myResult?.choiceIndex == null
)} ? "Pas de réponse 😴"
<p : "Raté 💥"}
className={`text-center text-sm font-medium ${myResult?.correct ? "text-green-500" : "text-red-500"}`} </p>
>
{myResult?.correct
? "Bonne réponse ! 🎉"
: !hasVoted
? "Pas de réponse 😴"
: "Raté 💥"}
</p>
</div>
)} )}
</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>
)
}

View file

@ -1,56 +1,39 @@
import { useState } from "react" import { useState } from "react"
import { Check, Copy, Link2 } from "lucide-react" import { Check, Copy } from "lucide-react"
import { Button } from "@workspace/ui/components/button"
type Copied = "code" | "link" | null /** Code de room cliquable : copie dans le presse-papier avec retour visuel. */
/** Code de room copiable + bouton pour copier le lien d'invitation. */
export function RoomCode({ code }: { code: string }) { export function RoomCode({ code }: { code: string }) {
const [copied, setCopied] = useState<Copied>(null) const [copied, setCopied] = useState(false)
async function copy(what: Copied, text: string) { async function copy() {
try { try {
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(code)
setCopied(what) setCopied(true)
setTimeout(() => setCopied(null), 1500) setTimeout(() => setCopied(false), 1500)
} catch { } catch {
// presse-papier indisponible (http non sécurisé) : on ignore silencieusement // presse-papier indisponible (http non sécurisé) : on ignore silencieusement
} }
} }
const link = `${window.location.origin}/join/${code}`
return ( return (
<div className="flex items-center gap-3"> <button
<button onClick={copy}
onClick={() => copy("code", code)} title="Copier le code"
title="Copier le code" className="group flex items-center gap-2 text-left"
className="group flex items-center gap-2 text-left" >
> <span>
<span> <span className="text-muted-foreground text-xs uppercase">
<span className="text-muted-foreground text-xs uppercase"> Code room
Code room
</span>
<span className="font-heading block text-2xl font-bold tracking-widest">
{code}
</span>
</span> </span>
{copied === "code" ? ( <span className="font-heading block text-2xl font-bold tracking-widest">
<Check className="size-4 text-green-500" /> {code}
) : ( </span>
<Copy className="text-muted-foreground group-hover:text-foreground size-4 transition-colors" /> </span>
)} {copied ? (
</button> <Check className="size-4 text-green-500" />
) : (
<Button <Copy className="text-muted-foreground group-hover:text-foreground size-4 transition-colors" />
size="sm" )}
variant="secondary" </button>
onClick={() => copy("link", link)}
title="Copier le lien d'invitation"
>
{copied === "link" ? <Check className="text-green-500" /> : <Link2 />}
{copied === "link" ? "Copié" : "Lien"}
</Button>
</div>
) )
} }

View file

@ -1,74 +1,30 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { createPortal } from "react-dom" import { createPortal } from "react-dom"
import { AnimatePresence, motion } from "framer-motion" 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) const VISIBLE_MS = 1300 // durée d'affichage avant le wipe de sortie (≈ leadMs serveur)
// Médias custom déposés par l'utilisateur, rangés par mode : // Animations custom déposées par l'utilisateur (gif/webp/apng/png/mp4...).
// src/assets/transitions/quiz/* → transitions de quiz // Glisse simplement des fichiers dans src/assets/transitions/ : ils sont
// src/assets/transitions/blindtest/* → transitions de blindtest // détectés au build et joués aléatoirement. Sinon, fallback animation Framer.
// src/assets/transitions/* → communs (fallback) const CUSTOM = Object.values(
// Détectés au build ; sinon, animation Framer par défaut. import.meta.glob("../assets/transitions/*.{gif,webp,apng,png,avif}", {
const ALL_MEDIA = import.meta.glob( eager: true,
"../assets/transitions/**/*.{gif,webp,apng,png,avif}", import: "default",
{ eager: true, import: "default" } })
) as Record<string, string> ) as 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" entre manches, thémée par mode. Annonce le mode quand * Transition "WarioWare" jouée entre les manches : wipe coloré plein écran,
* il change (quiz blindtest), sinon affiche un teaser léger de la manche. * média custom (si présent) ou sunburst + gros numéro qui rebondit, puis sortie.
* Montée avec une `key` qui change rejoue à chaque manche. * Montée avec une `key` qui change rejoue à chaque manche.
*/ */
export function RoundTransition({ export function RoundTransition({ index }: { index: number }) {
type,
index,
modeChanged,
}: {
type: RoundType
index: number
modeChanged: boolean
}) {
const [show, setShow] = useState(true) const [show, setShow] = useState(true)
const theme = THEME[type] // Choix stable d'un média custom pour ce montage (varie d'une manche à l'autre).
const [media] = useState(() => { const [media] = useState(() =>
const pool = mediaFor(type) CUSTOM.length ? CUSTOM[Math.floor(Math.random() * CUSTOM.length)] : null
return pool.length ? pool[Math.floor(Math.random() * pool.length)] : null )
})
useEffect(() => { useEffect(() => {
const t = setTimeout(() => setShow(false), VISIBLE_MS) const t = setTimeout(() => setShow(false), VISIBLE_MS)
@ -80,7 +36,7 @@ export function RoundTransition({
{show && ( {show && (
<motion.div <motion.div
key="round-transition" key="round-transition"
className={`pointer-events-auto fixed inset-0 z-50 flex items-center justify-center overflow-hidden bg-gradient-to-br ${theme.gradient}`} 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"
initial={{ clipPath: "inset(0 0 100% 0)" }} initial={{ clipPath: "inset(0 0 100% 0)" }}
animate={{ clipPath: "inset(0 0 0% 0)" }} animate={{ clipPath: "inset(0 0 0% 0)" }}
exit={{ exit={{
@ -89,74 +45,63 @@ export function RoundTransition({
}} }}
transition={{ duration: 0.25, ease: "easeOut" }} transition={{ duration: 0.25, ease: "easeOut" }}
> >
<motion.div
aria-hidden
className="absolute size-[220vmax] opacity-60"
style={{
backgroundImage:
"repeating-conic-gradient(rgba(255,255,255,0.12) 0deg 12deg, transparent 12deg 24deg)",
}}
animate={{ rotate: 360 }}
transition={{ duration: 7, repeat: Infinity, ease: "linear" }}
/>
{media ? ( {media ? (
<motion.img <motion.img
src={media} src={media}
alt="" alt=""
className="relative max-h-[70vh] max-w-[80vw] object-contain" className="max-h-[70vh] max-w-[80vw] object-contain"
initial={{ scale: 0.6, opacity: 0 }} initial={{ scale: 0.6, opacity: 0 }}
animate={{ scale: [0.6, 1.08, 1], opacity: 1 }} animate={{ scale: [0.6, 1.08, 1], opacity: 1 }}
transition={{ duration: 0.45, times: [0, 0.6, 1], ease: "easeOut" }} transition={{ duration: 0.45, times: [0, 0.6, 1], ease: "easeOut" }}
/> />
) : ( ) : (
<motion.div <>
className="relative flex flex-col items-center" <motion.div
initial={{ scale: 0, rotate: -12 }} aria-hidden
animate={{ scale: [0, 1.25, 1], rotate: [-12, 6, 0] }} className="absolute size-[220vmax] opacity-60"
transition={{ style={{
duration: 0.5, backgroundImage:
times: [0, 0.6, 1], "repeating-conic-gradient(rgba(255,255,255,0.12) 0deg 12deg, transparent 12deg 24deg)",
delay: 0.12, }}
ease: "easeOut", animate={{ rotate: 360 }}
}} transition={{ duration: 7, repeat: Infinity, ease: "linear" }}
> />
{modeChanged ? (
<> <motion.div
<span className="text-[18vmin] leading-none">{theme.emoji}</span> className="relative flex flex-col items-center"
<span initial={{ scale: 0, rotate: -12 }}
className={`font-heading text-[12vmin] leading-none font-black tracking-tight ${theme.accent} drop-shadow-[0_5px_0_rgba(0,0,0,0.25)]`} animate={{ scale: [0, 1.25, 1], rotate: [-12, 6, 0] }}
> transition={{
{theme.label} duration: 0.5,
</span> times: [0, 0.6, 1],
</> delay: 0.12,
) : ( ease: "easeOut",
<> }}
<span className="font-heading text-2xl font-black tracking-[0.35em] text-white uppercase drop-shadow">
{theme.kind}
</span>
<motion.span
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,
repeat: Infinity,
repeatType: "reverse",
}}
>
{index + 1}
</motion.span>
</>
)}
<motion.span
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="font-heading -rotate-3 text-3xl font-black text-white italic"
> >
PRÊT ?! <span className="font-heading text-2xl font-black tracking-[0.35em] text-white uppercase drop-shadow">
</motion.span> Question
</motion.div> </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)]"
animate={{ scale: [1, 1.07, 1] }}
transition={{
duration: 0.4,
repeat: Infinity,
repeatType: "reverse",
}}
>
{index + 1}
</motion.span>
<motion.span
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="font-heading -rotate-3 text-3xl font-black text-white italic"
>
PRÊT ?!
</motion.span>
</motion.div>
</>
)} )}
</motion.div> </motion.div>
)} )}

View file

@ -1,99 +0,0 @@
import { useEffect, useMemo, useRef, useState } from "react"
let apiPromise: Promise<void> | null = null
/** Charge l'API IFrame YouTube une seule fois. */
function loadYoutubeApi(): Promise<void> {
if (window.YT?.Player) {
return Promise.resolve()
}
if (apiPromise) {
return apiPromise
}
apiPromise = new Promise((resolve) => {
const previous = window.onYouTubeIframeAPIReady
window.onYouTubeIframeAPIReady = () => {
previous?.()
resolve()
}
const tag = document.createElement("script")
tag.src = "https://www.youtube.com/iframe_api"
document.head.appendChild(tag)
})
return apiPromise
}
export interface YoutubeApi {
play: () => void
pause: () => void
seek: (seconds: number) => void
time: () => number
duration: () => number
}
/**
* Crée un lecteur YouTube pour `youtubeId`. Renvoie un ref à poser sur un div
* hôte, l'état "prêt", et une API impérative (play/pause/seek/time).
*/
export function useYoutube(youtubeId: string) {
const hostRef = useRef<HTMLDivElement>(null)
const playerRef = useRef<YT.Player | null>(null)
const [ready, setReady] = useState(false)
useEffect(() => {
let cancelled = false
const host = hostRef.current
if (!host) {
return
}
setReady(false)
// YT remplace l'élément fourni par une iframe → on lui donne un enfant jetable
// pour que React ne gère jamais ce nœud.
const inner = document.createElement("div")
host.appendChild(inner)
loadYoutubeApi().then(() => {
if (cancelled || !window.YT) {
return
}
playerRef.current = new window.YT.Player(inner, {
videoId: youtubeId,
playerVars: {
autoplay: 0,
controls: 0,
disablekb: 1,
modestbranding: 1,
rel: 0,
playsinline: 1,
},
events: {
onReady: () => {
if (!cancelled) {
setReady(true)
}
},
},
})
})
return () => {
cancelled = true
playerRef.current?.destroy()
playerRef.current = null
host.replaceChildren()
}
}, [youtubeId])
const api = useMemo<YoutubeApi>(
() => ({
play: () => playerRef.current?.playVideo(),
pause: () => playerRef.current?.pauseVideo(),
seek: (seconds) => playerRef.current?.seekTo(seconds, true),
time: () => playerRef.current?.getCurrentTime() ?? 0,
duration: () => playerRef.current?.getDuration() ?? 0,
}),
[]
)
return { hostRef, ready, api }
}

View file

@ -1,38 +1,29 @@
import { useState } from "react" import { useState } from "react"
import { useLocation } from "wouter" import { useLocation } from "wouter"
import { motion } from "framer-motion"
import { LogIn, Sparkles } from "lucide-react"
import { Button } from "@workspace/ui/components/button" import { Button } from "@workspace/ui/components/button"
import { useRoomStore } from "@/store/room" import { useRoomStore } from "@/store/room"
const inputClass = const inputClass =
"border-input bg-background ring-offset-background focus-visible:ring-ring h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50" "border-input bg-background ring-offset-background focus-visible:ring-ring h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
// Emojis geek qui flottent en fond.
const FLOATERS = [
{ emoji: "🎮", x: "8%", y: "18%", delay: 0 },
{ emoji: "🎧", x: "82%", y: "22%", delay: 0.6 },
{ emoji: "🧠", x: "15%", y: "72%", delay: 1.2 },
{ emoji: "👾", x: "78%", y: "70%", delay: 0.3 },
{ emoji: "🕹️", x: "45%", y: "12%", delay: 0.9 },
{ emoji: "🎬", x: "60%", y: "82%", delay: 1.5 },
]
export function HomePage() { export function HomePage() {
const [, navigate] = useLocation() const [, navigate] = useLocation()
const createRoom = useRoomStore((s) => s.createRoom) const createRoom = useRoomStore((s) => s.createRoom)
const joinRoom = useRoomStore((s) => s.joinRoom) const joinRoom = useRoomStore((s) => s.joinRoom)
const connected = useRoomStore((s) => s.connected) const connected = useRoomStore((s) => s.connected)
const [name, setName] = useState("")
const [code, setCode] = useState("") const [code, setCode] = useState("")
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const canSubmit = name.trim().length > 0 && connected && !busy
async function handleCreate() { async function handleCreate() {
setError(null) setError(null)
setBusy(true) setBusy(true)
try { try {
const { roomCode } = await createRoom() const { roomCode } = await createRoom(name.trim())
navigate(`/room/${roomCode}`) navigate(`/room/${roomCode}`)
} catch (err) { } catch (err) {
setError((err as { message?: string }).message ?? "Erreur") setError((err as { message?: string }).message ?? "Erreur")
@ -45,7 +36,7 @@ export function HomePage() {
setError(null) setError(null)
setBusy(true) setBusy(true)
try { try {
const { roomCode } = await joinRoom(code.trim().toUpperCase()) const { roomCode } = await joinRoom(code.trim().toUpperCase(), name.trim())
navigate(`/room/${roomCode}`) navigate(`/room/${roomCode}`)
} catch (err) { } catch (err) {
setError((err as { message?: string }).message ?? "Erreur") setError((err as { message?: string }).message ?? "Erreur")
@ -55,63 +46,28 @@ export function HomePage() {
} }
return ( return (
<div className="relative flex min-h-svh items-center justify-center overflow-hidden p-6"> <div className="flex min-h-svh items-center justify-center p-6">
{/* Halo animé */} <div className="flex w-full max-w-sm flex-col gap-6">
<motion.div
aria-hidden
className="pointer-events-none absolute -z-10 size-[120vmin] rounded-full bg-gradient-to-br from-fuchsia-500/20 via-purple-500/10 to-cyan-400/20 blur-3xl"
animate={{ scale: [1, 1.15, 1], rotate: [0, 30, 0] }}
transition={{ duration: 18, repeat: Infinity, ease: "easeInOut" }}
/>
{/* Emojis flottants */}
{FLOATERS.map((f) => (
<motion.span
key={f.emoji}
aria-hidden
className="pointer-events-none absolute -z-10 text-4xl opacity-40 select-none sm:text-5xl"
style={{ left: f.x, top: f.y }}
animate={{ y: [0, -18, 0], rotate: [-8, 8, -8] }}
transition={{
duration: 5,
repeat: Infinity,
ease: "easeInOut",
delay: f.delay,
}}
>
{f.emoji}
</motion.span>
))}
<motion.div
className="flex w-full max-w-sm flex-col gap-6"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: "easeOut" }}
>
<header className="text-center"> <header className="text-center">
<motion.h1 <h1 className="font-heading text-3xl font-bold tracking-tight">
className="font-heading bg-gradient-to-r from-fuchsia-500 via-purple-500 to-cyan-400 bg-clip-text text-5xl font-black tracking-tight text-transparent"
initial={{ scale: 0.6, rotate: -6, opacity: 0 }}
animate={{ scale: 1, rotate: 0, opacity: 1 }}
transition={{ type: "spring", stiffness: 260, damping: 14 }}
>
NerdWare NerdWare
</motion.h1> </h1>
<p className="text-muted-foreground mt-1 flex items-center justify-center gap-1 text-sm"> <p className="text-muted-foreground text-sm">
<Sparkles className="size-3.5" /> Party game culture geek Party game culture geek
</p> </p>
</header> </header>
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}> <input
<Button className={inputClass}
size="lg" placeholder="Ton pseudo"
className="w-full" value={name}
disabled={!connected || busy} maxLength={24}
onClick={handleCreate} onChange={(e) => setName(e.target.value)}
> />
<Sparkles /> Créer une room
</Button> <Button disabled={!canSubmit} onClick={handleCreate}>
</motion.div> Créer une room
</Button>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="bg-border h-px flex-1" /> <div className="bg-border h-px flex-1" />
@ -121,19 +77,18 @@ export function HomePage() {
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
className={`${inputClass} text-center text-lg font-bold tracking-[0.3em] uppercase`} className={`${inputClass} uppercase`}
placeholder="CODE" placeholder="Code"
value={code} value={code}
maxLength={4} maxLength={4}
onChange={(e) => setCode(e.target.value)} onChange={(e) => setCode(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && code.trim() && handleJoin()}
/> />
<Button <Button
variant="secondary" variant="secondary"
disabled={!connected || busy || code.trim().length === 0} disabled={!canSubmit || code.trim().length === 0}
onClick={handleJoin} onClick={handleJoin}
> >
<LogIn /> Rejoindre Rejoindre
</Button> </Button>
</div> </div>
@ -145,7 +100,7 @@ export function HomePage() {
{error && ( {error && (
<p className="text-destructive text-center text-sm">{error}</p> <p className="text-destructive text-center text-sm">{error}</p>
)} )}
</motion.div> </div>
</div> </div>
) )
} }

View file

@ -1,42 +0,0 @@
import { useEffect, useRef, useState } from "react"
import { Link, useLocation } from "wouter"
import { Button } from "@workspace/ui/components/button"
import { useRoomStore } from "@/store/room"
/** Lien d'invitation /join/:code → rejoint la room puis redirige vers le lobby. */
export function JoinPage({ code }: { code: string }) {
const [, navigate] = useLocation()
const joinRoom = useRoomStore((s) => s.joinRoom)
const connected = useRoomStore((s) => s.connected)
const [error, setError] = useState<string | null>(null)
const tried = useRef(false)
useEffect(() => {
if (!connected || tried.current) {
return
}
tried.current = true
joinRoom(code)
.then(({ roomCode }) => navigate(`/room/${roomCode}`, { replace: true }))
.catch((err) =>
setError((err as { message?: string }).message ?? "Room introuvable.")
)
}, [connected, code, joinRoom, navigate])
return (
<div className="flex min-h-svh flex-col items-center justify-center gap-4 p-6 text-center">
{error ? (
<>
<p className="text-muted-foreground text-sm">{error}</p>
<Link href="/">
<Button variant="secondary">Retour à l'accueil</Button>
</Link>
</>
) : (
<p className="text-muted-foreground text-sm">
Connexion à la room {code}
</p>
)}
</div>
)
}

View file

@ -3,28 +3,22 @@ import { Button } from "@workspace/ui/components/button"
import { useRoomStore } from "@/store/room" import { useRoomStore } from "@/store/room"
import { LobbyView } from "@/components/lobby-view" import { LobbyView } from "@/components/lobby-view"
import { QuizView } from "@/components/quiz-view" import { QuizView } from "@/components/quiz-view"
import { BlindtestView } from "@/components/blindtest-view"
import { GameEndView } from "@/components/game-end-view" import { GameEndView } from "@/components/game-end-view"
import { PlayerCards } from "@/components/player-cards" import { PlayerCards } from "@/components/player-cards"
import { RoomCode } from "@/components/room-code" import { RoomCode } from "@/components/room-code"
import { Boom } from "@/components/boom" import { Boom } from "@/components/boom"
import { RoundTransition } from "@/components/round-transition" import { RoundTransition } from "@/components/round-transition"
import { PseudoScreen } from "@/components/pseudo-screen"
export function RoomPage({ code }: { code: string }) { export function RoomPage({ code }: { code: string }) {
const snapshot = useRoomStore((s) => s.snapshot) const snapshot = useRoomStore((s) => s.snapshot)
const roomCode = useRoomStore((s) => s.roomCode)
const playerId = useRoomStore((s) => s.playerId) const playerId = useRoomStore((s) => s.playerId)
const connected = useRoomStore((s) => s.connected) const connected = useRoomStore((s) => s.connected)
const pseudoSet = useRoomStore((s) => s.pseudoSet)
const boomKey = useRoomStore((s) => s.boomKey) const boomKey = useRoomStore((s) => s.boomKey)
const roundKey = useRoomStore((s) => s.roundKey) const roundKey = useRoomStore((s) => s.roundKey)
const voteProgress = useRoomStore((s) => s.voteProgress) const voteProgress = useRoomStore((s) => s.voteProgress)
const round = useRoomStore((s) => s.round)
const roundModeChanged = useRoomStore((s) => s.roundModeChanged)
// Pas dans cette room (accès direct sans rejoindre / refresh) : retour accueil. // Pas de snapshot pour ce code (accès direct / refresh) : retour à l'accueil.
if (roomCode !== code) { if (!snapshot || snapshot.code !== code) {
return ( return (
<div className="flex min-h-svh flex-col items-center justify-center gap-4 p-6 text-center"> <div className="flex min-h-svh flex-col items-center justify-center gap-4 p-6 text-center">
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
@ -37,20 +31,6 @@ export function RoomPage({ code }: { code: string }) {
) )
} }
// Pseudo pas encore choisi : on l'affiche d'abord.
if (!pseudoSet) {
return <PseudoScreen code={code} />
}
// En attente du premier snapshot.
if (!snapshot || snapshot.code !== code) {
return (
<div className="flex min-h-svh items-center justify-center p-6">
<p className="text-muted-foreground text-sm">Chargement</p>
</div>
)
}
// Cards de scores visibles en permanence pendant le jeu (suivi continu). // Cards de scores visibles en permanence pendant le jeu (suivi continu).
const inGame = const inGame =
snapshot.status === "in_round" || snapshot.status === "in_round" ||
@ -78,23 +58,13 @@ export function RoomPage({ code }: { code: string }) {
)} )}
{snapshot.status === "lobby" && <LobbyView snapshot={snapshot} />} {snapshot.status === "lobby" && <LobbyView snapshot={snapshot} />}
{inGame && {inGame && <QuizView snapshot={snapshot} />}
(round?.type === "blindtest" ? (
<BlindtestView snapshot={snapshot} />
) : (
<QuizView snapshot={snapshot} />
))}
{snapshot.status === "ended" && <GameEndView snapshot={snapshot} />} {snapshot.status === "ended" && <GameEndView snapshot={snapshot} />}
</div> </div>
{boomKey > 0 && <Boom key={boomKey} />} {boomKey > 0 && <Boom key={boomKey} />}
{roundKey > 0 && round && ( {roundKey > 0 && (
<RoundTransition <RoundTransition key={roundKey} index={snapshot.currentRound} />
key={roundKey}
type={round.type}
index={snapshot.currentRound}
modeChanged={roundModeChanged}
/>
)} )}
</div> </div>
) )

View file

@ -1,19 +1,11 @@
import { create } from "zustand" import { create } from "zustand"
import { import type {
DEFAULT_ROOM_SETTINGS, ErrorPayload,
type BlindtestAnswer, PlayerScore,
type BlindtestTrackInfo, RoomCreatedPayload,
type ErrorPayload, RoomSnapshot,
type MediaControlPayload, RoundStartPayload,
type MediaSyncPayload, VoteAckPayload,
type PlayerScore,
type RoomCreatedPayload,
type RoomSnapshot,
type RoundConfig,
type RoundStartPayload,
type SubmitOkPayload,
type UpdateSettingsPayload,
type VoteAckPayload,
} from "@nerdware/shared" } from "@nerdware/shared"
import { socket } from "@/lib/socket" import { socket } from "@/lib/socket"
@ -21,8 +13,6 @@ import { socket } from "@/lib/socket"
export interface ActiveRound { export interface ActiveRound {
type: RoundStartPayload["type"] type: RoundStartPayload["type"]
djId?: string djId?: string
/** Blindtest : true si c'est MON titre (je dois faire deviner, pas voter). */
mine?: boolean
startsAt: number startsAt: number
endsAt: number endsAt: number
payload: unknown payload: unknown
@ -36,10 +26,9 @@ export interface RoundReveal {
interface RoomState { interface RoomState {
connected: boolean connected: boolean
/** Identité locale, pour reconnaître "moi" dans le snapshot. */
playerId: string | null playerId: string | null
roomCode: string | null roomCode: string | null
/** Le joueur local a-t-il choisi son pseudo dans la room ? */
pseudoSet: boolean
snapshot: RoomSnapshot | null snapshot: RoomSnapshot | null
lastError: ErrorPayload | null lastError: ErrorPayload | null
@ -48,27 +37,16 @@ interface RoomState {
voteProgress: VoteAckPayload | null voteProgress: VoteAckPayload | null
reveal: RoundReveal | null reveal: RoundReveal | null
myChoiceIndex: number | null myChoiceIndex: number | null
hasVoted: boolean
finalScores: PlayerScore[] | null finalScores: PlayerScore[] | null
gameTracks: BlindtestTrackInfo[] | null /** Compteur d'explosions : incrémenté à chaque fin de manche au timer (boom à 0). */
mediaSync: MediaSyncPayload | null
boomKey: number boomKey: number
/** Compteur de manches : incrémenté à chaque round:start (transition WarioWare). */
roundKey: number roundKey: number
/** Le type d'épreuve a-t-il changé par rapport à la manche précédente ? */
roundModeChanged: boolean
createRoom: () => Promise<RoomCreatedPayload> createRoom: (playerName: string) => Promise<RoomCreatedPayload>
joinRoom: (roomCode: string) => Promise<RoomCreatedPayload> joinRoom: (roomCode: string, playerName: string) => Promise<RoomCreatedPayload>
setName: (name: string) => void startGame: (questionCount: number) => Promise<void>
updateSettings: (partial: Partial<UpdateSettingsPayload>) => void
startGame: (rounds: RoundConfig[]) => Promise<void>
submitTrack: (youtubeUrl: string) => Promise<SubmitOkPayload>
removeTrack: (trackId: string) => Promise<boolean>
returnToLobby: () => void
vote: (choiceIndex: number) => void vote: (choiceIndex: number) => void
voteText: (text: string) => void
voteBlindtest: (answer: BlindtestAnswer) => void
mediaControl: (action: MediaControlPayload["action"], positionSec: number) => void
boom: () => void boom: () => void
clearError: () => void clearError: () => void
reset: () => void reset: () => void
@ -78,30 +56,21 @@ export const useRoomStore = create<RoomState>((set, get) => ({
connected: socket.connected, connected: socket.connected,
playerId: null, playerId: null,
roomCode: null, roomCode: null,
pseudoSet: false,
snapshot: null, snapshot: null,
lastError: null, lastError: null,
round: null, round: null,
voteProgress: null, voteProgress: null,
reveal: null, reveal: null,
myChoiceIndex: null, myChoiceIndex: null,
hasVoted: false,
finalScores: null, finalScores: null,
gameTracks: null,
mediaSync: null,
boomKey: 0, boomKey: 0,
roundKey: 0, roundKey: 0,
roundModeChanged: false,
createRoom: () => createRoom: (playerName) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
socket.emit("room:create", {}, (res) => { socket.emit("room:create", { playerName }, (res) => {
if (res.ok) { if (res.ok) {
set({ set({ playerId: res.data.playerId, roomCode: res.data.roomCode })
playerId: res.data.playerId,
roomCode: res.data.roomCode,
pseudoSet: false,
})
resolve(res.data) resolve(res.data)
} else { } else {
set({ lastError: res.error }) set({ lastError: res.error })
@ -110,15 +79,11 @@ export const useRoomStore = create<RoomState>((set, get) => ({
}) })
}), }),
joinRoom: (roomCode) => joinRoom: (roomCode, playerName) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
socket.emit("room:join", { roomCode }, (res) => { socket.emit("room:join", { roomCode, playerName }, (res) => {
if (res.ok) { if (res.ok) {
set({ set({ playerId: res.data.playerId, roomCode: res.data.roomCode })
playerId: res.data.playerId,
roomCode: res.data.roomCode,
pseudoSet: false,
})
resolve(res.data) resolve(res.data)
} else { } else {
set({ lastError: res.error }) set({ lastError: res.error })
@ -127,28 +92,17 @@ export const useRoomStore = create<RoomState>((set, get) => ({
}) })
}), }),
setName: (name) => { startGame: (questionCount) =>
if (name.trim().length === 0) {
return
}
socket.emit("player:setName", { name: name.trim() })
set({ pseudoSet: true })
},
updateSettings: (partial) => {
const current = get().snapshot?.settings ?? DEFAULT_ROOM_SETTINGS
socket.emit("lobby:updateSettings", {
gameType: partial.gameType ?? current.gameType,
blindtestMode: partial.blindtestMode ?? current.blindtestMode,
roundDuration: partial.roundDuration ?? current.roundDuration,
tracksPerPlayer: partial.tracksPerPlayer ?? current.tracksPerPlayer,
rounds: partial.rounds ?? current.rounds,
})
},
startGame: (rounds) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
get().updateSettings({ rounds }) const rounds = Array.from({ length: questionCount }, () => ({
type: "quiz" as const,
}))
// On pousse la séquence de manches, puis on lance.
socket.emit("lobby:updateSettings", {
blindtestMode: get().snapshot?.settings.blindtestMode ?? "title_artist",
roundDuration: 20,
rounds,
})
socket.emit("game:start", (res) => { socket.emit("game:start", (res) => {
if (res.ok) { if (res.ok) {
resolve() resolve()
@ -159,91 +113,36 @@ export const useRoomStore = create<RoomState>((set, get) => ({
}) })
}), }),
submitTrack: (youtubeUrl) =>
new Promise((resolve) => {
socket.emit("blindtest:submitTrack", { youtubeUrl }, (res) => resolve(res))
}),
removeTrack: (trackId) =>
new Promise((resolve) => {
socket.emit("blindtest:removeTrack", { trackId }, (res) => resolve(res.ok))
}),
returnToLobby: () => {
socket.emit("lobby:return")
},
vote: (choiceIndex) => { vote: (choiceIndex) => {
if (get().hasVoted) { if (get().myChoiceIndex !== null) {
return return // vote déjà émis pour cette manche
} }
set({ myChoiceIndex: choiceIndex, hasVoted: true }) set({ myChoiceIndex: choiceIndex })
socket.emit("round:vote", { answer: { choiceIndex } }) 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
}
set({ hasVoted: true })
socket.emit("round:vote", { answer })
},
mediaControl: (action, positionSec) => {
socket.emit("media:control", { action, positionSec })
},
boom: () => set((s) => ({ boomKey: s.boomKey + 1 })), boom: () => set((s) => ({ boomKey: s.boomKey + 1 })),
clearError: () => set({ lastError: null }), clearError: () => set({ lastError: null }),
reset: () => reset: () =>
set({ set({
roomCode: null, roomCode: null,
pseudoSet: false,
snapshot: null, snapshot: null,
lastError: null, lastError: null,
round: null, round: null,
voteProgress: null, voteProgress: null,
reveal: null, reveal: null,
myChoiceIndex: null, myChoiceIndex: null,
hasVoted: false,
finalScores: null, finalScores: null,
gameTracks: null,
mediaSync: null,
boomKey: 0, boomKey: 0,
roundKey: 0, roundKey: 0,
roundModeChanged: false,
}), }),
})) }))
// Listeners socket → on pousse l'état serveur dans le store (source de vérité). // Listeners socket → on pousse l'état serveur dans le store (source de vérité).
socket.on("connect", () => useRoomStore.setState({ connected: true })) socket.on("connect", () => useRoomStore.setState({ connected: true }))
socket.on("disconnect", () => useRoomStore.setState({ connected: false })) socket.on("disconnect", () => useRoomStore.setState({ connected: false }))
socket.on("room:state", (snapshot) => socket.on("room:state", (snapshot) => useRoomStore.setState({ snapshot }))
useRoomStore.setState(
snapshot.status === "lobby"
? {
snapshot,
round: null,
reveal: null,
voteProgress: null,
myChoiceIndex: null,
hasVoted: false,
finalScores: null,
gameTracks: null,
mediaSync: null,
}
: { snapshot }
)
)
socket.on("error", (error) => useRoomStore.setState({ lastError: error })) socket.on("error", (error) => useRoomStore.setState({ lastError: error }))
socket.on("round:start", (payload) => socket.on("round:start", (payload) =>
@ -251,7 +150,6 @@ socket.on("round:start", (payload) =>
round: { round: {
type: payload.type, type: payload.type,
djId: payload.djId, djId: payload.djId,
mine: payload.mine,
startsAt: payload.startsAt, startsAt: payload.startsAt,
endsAt: payload.endsAt, endsAt: payload.endsAt,
payload: payload.payload, payload: payload.payload,
@ -259,17 +157,13 @@ socket.on("round:start", (payload) =>
voteProgress: null, voteProgress: null,
reveal: null, reveal: null,
myChoiceIndex: null, myChoiceIndex: null,
hasVoted: false,
mediaSync: null,
roundKey: s.roundKey + 1, roundKey: s.roundKey + 1,
roundModeChanged: (s.round?.type ?? null) !== payload.type,
})) }))
) )
socket.on("round:voteAck", (voteProgress) => socket.on("round:voteAck", (voteProgress) =>
useRoomStore.setState({ voteProgress }) useRoomStore.setState({ voteProgress })
) )
socket.on("round:reveal", (reveal) => useRoomStore.setState({ reveal })) socket.on("round:reveal", (reveal) => useRoomStore.setState({ reveal }))
socket.on("media:sync", (mediaSync) => useRoomStore.setState({ mediaSync })) socket.on("game:end", ({ finalScores }) =>
socket.on("game:end", ({ finalScores, tracks }) => useRoomStore.setState({ finalScores })
useRoomStore.setState({ finalScores, gameTracks: tracks ?? null })
) )

View file

@ -1,44 +0,0 @@
// Déclarations minimales de l'API YouTube IFrame Player (ce qu'on utilise).
export {}
declare global {
namespace YT {
interface PlayerEvent {
target: Player
data: number
}
interface PlayerOptions {
videoId?: string
playerVars?: Record<string, string | number>
events?: {
onReady?: (e: PlayerEvent) => void
onStateChange?: (e: PlayerEvent) => void
}
}
class Player {
constructor(el: HTMLElement | string, opts: PlayerOptions)
playVideo(): void
pauseVideo(): void
seekTo(seconds: number, allowSeekAhead: boolean): void
getCurrentTime(): number
getDuration(): number
getPlayerState(): number
loadVideoById(id: string): void
destroy(): void
}
const PlayerState: {
UNSTARTED: -1
ENDED: 0
PLAYING: 1
PAUSED: 2
BUFFERING: 3
CUED: 5
}
}
interface Window {
YT?: typeof YT
onYouTubeIframeAPIReady?: () => void
}
}

View file

@ -1,37 +0,0 @@
// Types concrets de l'épreuve Blindtest, partagés client/serveur.
// L'audio est joué localement par chaque client mais piloté par le DJ (media:sync).
/** Payload diffusé au lancement d'une manche blindtest (round:start), SANS la réponse. */
export interface BlindtestRoundPayload {
trackId: string
youtubeId: string
}
/** Vérité révélée à tous (round:reveal → truth), inclut qui a soumis le titre. */
export interface BlindtestRevealTruth {
title: string
artist: string
youtubeId: string
submittedBy: string
submittedByName: string
}
/** Résultat d'un joueur sur la manche (détail selon le mode). */
export interface BlindtestPlayerResult {
titleCorrect?: boolean
artistCorrect?: boolean
guessedCorrect?: boolean
points: number
}
/** round:reveal → perPlayerResult : playerId → résultat. */
export type BlindtestPerPlayerResult = Record<string, BlindtestPlayerResult>
/** Titre récapitulé en fin de partie (export / récupération des liens). */
export interface BlindtestTrackInfo {
title: string
artist: string
youtubeId: string
url: string
submittedByName: string
}

View file

@ -7,9 +7,6 @@ export type RoomStatus = "lobby" | "in_round" | "reveal" | "scores" | "ended"
/** Types d'épreuves disponibles. Étendre = ajouter une valeur + un module GameRound. */ /** Types d'épreuves disponibles. Étendre = ajouter une valeur + un module GameRound. */
export type RoundType = "blindtest" | "quiz" 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. */ /** 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"
@ -30,23 +27,17 @@ export interface RoundConfig {
/** Réglages de la room, modifiables dans le lobby par l'hôte. */ /** Réglages de la room, modifiables dans le lobby par l'hôte. */
export interface RoomSettings { export interface RoomSettings {
/** Type de partie choisi dans le lobby (pilote l'UI partagée). */
gameType: GameType
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
/** Nombre de titres que chaque joueur soumet (blindtest). */
tracksPerPlayer: number
/** Séquence d'épreuves de la partie. */ /** Séquence d'épreuves de la partie. */
rounds: RoundConfig[] rounds: RoundConfig[]
} }
/** 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",
blindtestMode: "title_artist", blindtestMode: "title_artist",
roundDuration: 60, roundDuration: 60,
tracksPerPlayer: 2,
rounds: [], rounds: [],
} }
@ -95,6 +86,4 @@ export interface RoomSnapshot {
currentRound: number currentRound: number
/** Nombre total de manches planifiées. */ /** Nombre total de manches planifiées. */
totalRounds: number totalRounds: number
/** Blindtest : nombre de titres soumis par joueur (comptes seulement, jamais les titres). */
submissions: { playerId: string; count: number }[]
} }

View file

@ -4,18 +4,16 @@
import type { import type {
Answer, Answer,
BlindtestMode, BlindtestMode,
GameType,
PlayerScore, PlayerScore,
RoomSnapshot, RoomSnapshot,
RoundConfig, RoundConfig,
RoundType, RoundType,
} from "./domain" } from "./domain"
import type { BlindtestTrackInfo } from "./blindtest"
// --- Payloads ------------------------------------------------------------ // --- Payloads ------------------------------------------------------------
export interface RoomCreatePayload { export interface RoomCreatePayload {
playerName?: string playerName: string
} }
export interface RoomCreatedPayload { export interface RoomCreatedPayload {
@ -25,18 +23,12 @@ export interface RoomCreatedPayload {
export interface RoomJoinPayload { export interface RoomJoinPayload {
roomCode: string roomCode: string
playerName?: string playerName: string
}
export interface SetNamePayload {
name: string
} }
export interface UpdateSettingsPayload { export interface UpdateSettingsPayload {
gameType: GameType
blindtestMode: BlindtestMode blindtestMode: BlindtestMode
roundDuration: number roundDuration: number
tracksPerPlayer: number
rounds: RoundConfig[] rounds: RoundConfig[]
} }
@ -51,17 +43,6 @@ export interface SubmitTrackPayload {
export interface SubmitOkPayload { export interface SubmitOkPayload {
accepted: boolean accepted: boolean
reason?: string reason?: string
/** Id du titre créé (pour pouvoir le supprimer). */
trackId?: string
youtubeId?: string
/** Titre récupéré (oEmbed) si accepté — pour confirmation côté client. */
title?: string
/** Nombre total de titres soumis par ce joueur après cet ajout. */
count?: number
}
export interface RemoveTrackPayload {
trackId: string
} }
export interface RoundStartPayload { export interface RoundStartPayload {
@ -71,8 +52,6 @@ export interface RoundStartPayload {
startsAt: number startsAt: number
/** Timestamp serveur de fin de manche (startsAt + roundDuration). */ /** Timestamp serveur de fin de manche (startsAt + roundDuration). */
endsAt: number endsAt: number
/** Envoyé uniquement au contributeur du titre (blindtest) : "c'est le tien". */
mine?: boolean
/** Payload spécifique au type d'épreuve, SANS la réponse. */ /** Payload spécifique au type d'épreuve, SANS la réponse. */
payload: unknown payload: unknown
} }
@ -114,8 +93,6 @@ export interface ScoreUpdatePayload {
export interface GameEndPayload { export interface GameEndPayload {
finalScores: PlayerScore[] finalScores: PlayerScore[]
/** Titres joués (si la partie comportait du blindtest) — pour récupérer les liens. */
tracks?: BlindtestTrackInfo[]
spotifyExport?: unknown spotifyExport?: unknown
} }
@ -133,18 +110,9 @@ export type Ack<T> = (result: { ok: true; data: T } | { ok: false; error: ErrorP
export interface ClientToServerEvents { export interface ClientToServerEvents {
"room:create": (payload: RoomCreatePayload, ack: Ack<RoomCreatedPayload>) => void "room:create": (payload: RoomCreatePayload, ack: Ack<RoomCreatedPayload>) => void
"room:join": (payload: RoomJoinPayload, ack: Ack<RoomCreatedPayload>) => void "room:join": (payload: RoomJoinPayload, ack: Ack<RoomCreatedPayload>) => void
"player:setName": (payload: SetNamePayload) => void
"lobby:updateSettings": (payload: UpdateSettingsPayload) => void "lobby:updateSettings": (payload: UpdateSettingsPayload) => void
"lobby:return": () => void
"game:start": (ack: Ack<null>) => void "game:start": (ack: Ack<null>) => void
"blindtest:submitTrack": ( "blindtest:submitTrack": (payload: SubmitTrackPayload, ack: Ack<SubmitOkPayload>) => void
payload: SubmitTrackPayload,
ack: (res: SubmitOkPayload) => void
) => void
"blindtest:removeTrack": (
payload: RemoveTrackPayload,
ack: (res: { ok: boolean }) => void
) => void
"media:control": (payload: MediaControlPayload) => void "media:control": (payload: MediaControlPayload) => void
"round:vote": (payload: VotePayload) => void "round:vote": (payload: VotePayload) => void
} }

View file

@ -1,4 +1,3 @@
export * from "./domain" export * from "./domain"
export * from "./events" export * from "./events"
export * from "./quiz" export * from "./quiz"
export * from "./blindtest"

View file

@ -8,8 +8,8 @@ import type { QuizFormat } from "./domain"
export interface QuizQuestionPayload { export interface QuizQuestionPayload {
format: QuizFormat format: QuizFormat
prompt: string prompt: string
/** Choix proposés (mcq/truefalse). Absent pour `free` (saisie libre). */ /** Choix proposés (truefalse = ["Vrai", "Faux"]). */
choices?: string[] choices: string[]
category?: string category?: string
/** 1 (facile) .. 3 (difficile). */ /** 1 (facile) .. 3 (difficile). */
difficulty?: number difficulty?: number
@ -17,10 +17,7 @@ export interface QuizQuestionPayload {
/** Vérité révélée à tous (round:reveal → truth). */ /** Vérité révélée à tous (round:reveal → truth). */
export interface QuizRevealTruth { export interface QuizRevealTruth {
/** Index de la bonne réponse (mcq/truefalse). */ correctIndex: number
correctIndex?: number
/** Réponse canonique affichée (free). */
answer?: string
} }
/** Résultat d'un joueur sur la manche. */ /** Résultat d'un joueur sur la manche. */