diff --git a/apps/server/src/game/engine.ts b/apps/server/src/game/engine.ts index 2aedfce..a34be3a 100644 --- a/apps/server/src/game/engine.ts +++ b/apps/server/src/game/engine.ts @@ -16,6 +16,8 @@ import type { GameRound, RoomGameController, RoundContext } from "./round" interface RoundRuntime { round: GameRound djId: string | null + /** Contributeur (blindtest) : reçoit round:start privé et ne vote pas. */ + secretPlayerId: string | null votes: Map startedAt: number endsAt: number @@ -120,6 +122,7 @@ export class GameEngine implements RoomGameController { this.runtime = { round, djId: start.djId ?? null, + secretPlayerId: start.secretPlayerId ?? null, votes: new Map(), startedAt, endsAt, @@ -127,13 +130,24 @@ export class GameEngine implements RoomGameController { } this.room.status = "in_round" this.broadcastState() - this.io.to(this.room.code).emit("round:start", { + const base = { type: round.type, djId: this.runtime.djId ?? undefined, startsAt: startedAt, endsAt, 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. await new Promise((resolve) => { @@ -177,10 +191,14 @@ export class GameEngine implements RoomGameController { } } - /** Votants éligibles : tous les joueurs connectés (le DJ neutre vote aussi). */ + /** + * Votants éligibles : joueurs connectés (DJ neutre inclus), hors contributeur + * (blindtest) qui n'a rien à saisir. + */ private eligibleVoters(): string[] { + const secret = this.runtime?.secretPlayerId return [...this.room.players.values()] - .filter((p) => p.connected) + .filter((p) => p.connected && p.id !== secret) .map((p) => p.id) } diff --git a/apps/server/src/game/modes/blindtest/blindtest-round.ts b/apps/server/src/game/modes/blindtest/blindtest-round.ts index 14b61ef..0fe58eb 100644 --- a/apps/server/src/game/modes/blindtest/blindtest-round.ts +++ b/apps/server/src/game/modes/blindtest/blindtest-round.ts @@ -76,7 +76,12 @@ export class BlindtestRound implements GameRound { const payload: BlindtestRoundPayload | null = track ? { trackId: track.id, youtubeId: track.youtubeId } : null - return { djId, payload, data: track ? { track } : null } + return { + djId, + payload, + secretPlayerId: track?.submittedBy, + data: track ? { track } : null, + } } submitAnswer(ctx: RoundContext, playerId: string, answer: Answer): void { diff --git a/apps/server/src/game/round.ts b/apps/server/src/game/round.ts index 7bfc21a..9836f36 100644 --- a/apps/server/src/game/round.ts +++ b/apps/server/src/game/round.ts @@ -18,6 +18,11 @@ export interface RoundStart { durationSec?: number /** Payload broadcasté aux clients, SANS la réponse. */ 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. */ data?: unknown } diff --git a/apps/server/src/handlers.ts b/apps/server/src/handlers.ts index 60ea318..05657c9 100644 --- a/apps/server/src/handlers.ts +++ b/apps/server/src/handlers.ts @@ -187,19 +187,44 @@ export function registerRoomHandlers( ack({ accepted: false, reason: "Soumission impossible maintenant." }) return } + const trackId = randomUUID() room.blindtestTracks.push({ - id: randomUUID(), + id: trackId, youtubeId, url: payload.youtubeUrl, title: meta.title, artist: meta.artist, submittedBy: playerId, }) - const count = mine.length + 1 - ack({ accepted: true, title: meta.title, count }) + 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 @@ -218,6 +243,22 @@ export function registerRoomHandlers( 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", () => { const affected = rooms.handleDisconnect(socket.id) if (!affected) { diff --git a/apps/web/src/components/blindtest-view.tsx b/apps/web/src/components/blindtest-view.tsx index 5354eab..739ea5f 100644 --- a/apps/web/src/components/blindtest-view.tsx +++ b/apps/web/src/components/blindtest-view.tsx @@ -102,15 +102,18 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) { )} - {!showReveal && ( - - )} + {!showReveal && + (round?.mine ? ( + + ) : ( + + ))} {showReveal && truth && ( + 🤫 +

C'est ton titre !

+

+ {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é."} +

+ + ) +} + function VoteForm({ mode, snapshot, diff --git a/apps/web/src/components/game-end-view.tsx b/apps/web/src/components/game-end-view.tsx index 28c1ae9..b4d47f4 100644 --- a/apps/web/src/components/game-end-view.tsx +++ b/apps/web/src/components/game-end-view.tsx @@ -113,6 +113,8 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) { const playerId = useRoomStore((s) => s.playerId) const finalScores = useRoomStore((s) => s.finalScores) const reset = useRoomStore((s) => s.reset) + const returnToLobby = useRoomStore((s) => s.returnToLobby) + const isHost = snapshot.hostId === playerId const scores: PlayerScore[] = finalScores ?? snapshot.scores const ranked = [...scores].sort((a, b) => b.score - a.score) @@ -194,11 +196,22 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) { )} - - - +
+ {isHost ? ( + + ) : ( +

+ En attente que l'hôte relance une partie… +

+ )} + + + +
) } diff --git a/apps/web/src/components/lobby-view.tsx b/apps/web/src/components/lobby-view.tsx index 0197898..c8b7124 100644 --- a/apps/web/src/components/lobby-view.tsx +++ b/apps/web/src/components/lobby-view.tsx @@ -1,4 +1,5 @@ import { useState } from "react" +import { Trash2 } from "lucide-react" import type { BlindtestMode, GameType, @@ -225,6 +226,12 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { ) } +interface MyTrack { + trackId: string + title: string + youtubeId: string +} + function TrackSubmission({ tracksPerPlayer, myCount, @@ -233,10 +240,11 @@ function TrackSubmission({ 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(null) - const [accepted, setAccepted] = useState([]) + const [tracks, setTracks] = useState([]) const quotaReached = myCount >= tracksPerPlayer async function submit() { @@ -246,8 +254,11 @@ function TrackSubmission({ setSubmitting(true) setFeedback(null) const res = await submitTrack(url.trim()) - if (res.accepted) { - setAccepted((a) => [...a, res.title ?? 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é") @@ -255,38 +266,65 @@ function TrackSubmission({ setSubmitting(false) } + async function remove(trackId: string) { + const ok = await removeTrack(trackId) + if (ok) { + setTracks((t) => t.filter((x) => x.trackId !== trackId)) + } + } + return (
Tes titres ({myCount}/{tracksPerPlayer}) -
- setUrl(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && submit()} - /> - -
- {feedback &&

{feedback}

} - {accepted.length > 0 && ( -
    - {accepted.map((t, i) => ( -
  • - ✓ {t} + + {tracks.length > 0 && ( +
      + {tracks.map((t) => ( +
    • + + {t.title} +
    • ))}
    )} + + {!quotaReached && ( +
    + setUrl(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && submit()} + /> + +
    + )} + {feedback &&

    {feedback}

    }
) } diff --git a/apps/web/src/store/room.ts b/apps/web/src/store/room.ts index 55024e8..6955ae1 100644 --- a/apps/web/src/store/room.ts +++ b/apps/web/src/store/room.ts @@ -20,6 +20,8 @@ import { socket } from "@/lib/socket" export interface ActiveRound { type: RoundStartPayload["type"] djId?: string + /** Blindtest : true si c'est MON titre (je dois faire deviner, pas voter). */ + mine?: boolean startsAt: number endsAt: number payload: unknown @@ -56,6 +58,8 @@ interface RoomState { updateSettings: (partial: Partial) => void startGame: (rounds: RoundConfig[]) => Promise submitTrack: (youtubeUrl: string) => Promise + removeTrack: (trackId: string) => Promise + returnToLobby: () => void vote: (choiceIndex: number) => void voteText: (text: string) => void voteBlindtest: (answer: BlindtestAnswer) => void @@ -137,6 +141,15 @@ export const useRoomStore = create((set, get) => ({ 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) => { if (get().hasVoted) { return @@ -189,7 +202,22 @@ export const useRoomStore = create((set, get) => ({ // Listeners socket → on pousse l'état serveur dans le store (source de vérité). socket.on("connect", () => useRoomStore.setState({ connected: true })) socket.on("disconnect", () => useRoomStore.setState({ connected: false })) -socket.on("room:state", (snapshot) => useRoomStore.setState({ snapshot })) +socket.on("room:state", (snapshot) => + useRoomStore.setState( + snapshot.status === "lobby" + ? { + snapshot, + round: null, + reveal: null, + voteProgress: null, + myChoiceIndex: null, + hasVoted: false, + finalScores: null, + mediaSync: null, + } + : { snapshot } + ) +) socket.on("error", (error) => useRoomStore.setState({ lastError: error })) socket.on("round:start", (payload) => @@ -197,6 +225,7 @@ socket.on("round:start", (payload) => round: { type: payload.type, djId: payload.djId, + mine: payload.mine, startsAt: payload.startsAt, endsAt: payload.endsAt, payload: payload.payload, diff --git a/packages/shared/src/events.ts b/packages/shared/src/events.ts index 8b9a7b1..b3f35fe 100644 --- a/packages/shared/src/events.ts +++ b/packages/shared/src/events.ts @@ -46,12 +46,19 @@ export interface SubmitTrackPayload { export interface SubmitOkPayload { accepted: boolean 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 { type: RoundType djId?: string @@ -59,6 +66,8 @@ export interface RoundStartPayload { startsAt: number /** Timestamp serveur de fin de manche (startsAt + roundDuration). */ 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: unknown } @@ -118,11 +127,16 @@ export interface ClientToServerEvents { "room:create": (payload: RoomCreatePayload, ack: Ack) => void "room:join": (payload: RoomJoinPayload, ack: Ack) => void "lobby:updateSettings": (payload: UpdateSettingsPayload) => void + "lobby:return": () => void "game:start": (ack: Ack) => void "blindtest:submitTrack": ( payload: SubmitTrackPayload, ack: (res: SubmitOkPayload) => void ) => void + "blindtest:removeTrack": ( + payload: RemoveTrackPayload, + ack: (res: { ok: boolean }) => void + ) => void "media:control": (payload: MediaControlPayload) => void "round:vote": (payload: VotePayload) => void }