release/0.1.0 #18

Merged
ayoub merged 68 commits from release/0.1.0 into main 2026-06-11 18:36:42 +00:00
9 changed files with 229 additions and 49 deletions
Showing only changes of commit 6251146c19 - Show all commits

View file

@ -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<string, Answer>
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<void>((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)
}

View file

@ -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 {

View file

@ -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
}

View file

@ -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) {

View file

@ -102,7 +102,10 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) {
<DjControls ready={ready} api={api} mediaControl={mediaControl} />
)}
{!showReveal && (
{!showReveal &&
(round?.mine ? (
<MisleadCard mode={snapshot.settings.blindtestMode} />
) : (
<VoteForm
mode={snapshot.settings.blindtestMode}
snapshot={snapshot}
@ -110,7 +113,7 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) {
disabled={hasVoted}
onVote={voteBlindtest}
/>
)}
))}
{showReveal && truth && (
<RevealCard
@ -210,6 +213,20 @@ function DjControls({
)
}
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,

View file

@ -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 }) {
</ol>
)}
<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}>
Retour à l'accueil
Quitter
</Button>
</Link>
</div>
</div>
)
}

View file

@ -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<string | null>(null)
const [accepted, setAccepted] = useState<string[]>([])
const [tracks, setTracks] = useState<MyTrack[]>([])
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 (
<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={quotaReached || submitting}
disabled={submitting}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && submit()}
/>
<Button
variant="secondary"
disabled={quotaReached || submitting || !url.trim()}
disabled={submitting || !url.trim()}
onClick={submit}
>
Ajouter
</Button>
</div>
{feedback && <p className="text-destructive text-xs">{feedback}</p>}
{accepted.length > 0 && (
<ul className="text-muted-foreground flex flex-col gap-0.5 text-xs">
{accepted.map((t, i) => (
<li key={i} className="truncate">
{t}
</li>
))}
</ul>
)}
{feedback && <p className="text-destructive text-xs">{feedback}</p>}
</div>
)
}

View file

@ -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<UpdateSettingsPayload>) => void
startGame: (rounds: RoundConfig[]) => Promise<void>
submitTrack: (youtubeUrl: string) => Promise<SubmitOkPayload>
removeTrack: (trackId: string) => Promise<boolean>
returnToLobby: () => void
vote: (choiceIndex: number) => void
voteText: (text: string) => void
voteBlindtest: (answer: BlindtestAnswer) => void
@ -137,6 +141,15 @@ export const useRoomStore = create<RoomState>((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<RoomState>((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,

View file

@ -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<RoomCreatedPayload>) => void
"room:join": (payload: RoomJoinPayload, ack: Ack<RoomCreatedPayload>) => void
"lobby:updateSettings": (payload: UpdateSettingsPayload) => void
"lobby:return": () => void
"game:start": (ack: Ack<null>) => 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
}