feat: replay from lobby, contributor mislead card, editable track cards
Return to lobby (replay without recreating the room): - lobby:return (host): reset to lobby, scores to 0, keep players + tracks - client: end screen has "Rejouer" (host) + "Quitter"; store clears transient game state whenever the room goes back to lobby Contributor has nothing to input: - round:start carries a private `mine: true` to the contributor only (via RoundStart.secretPlayerId targeted emit); they're excluded from eligible voters. Client shows a "fais-les se tromper" card instead of the vote form. Editable track submission: - submitTrack ack returns trackId + youtubeId; new blindtest:removeTrack - lobby shows submitted tracks as cards (YouTube thumbnail + title) with delete Verified e2e (3 players): add/remove, private mine flag, return-to-lobby reset. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a2dc687ac0
commit
6251146c19
9 changed files with 229 additions and 49 deletions
|
|
@ -16,6 +16,8 @@ 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
|
||||||
|
|
@ -120,6 +122,7 @@ 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,
|
||||||
|
|
@ -127,13 +130,24 @@ export class GameEngine implements RoomGameController {
|
||||||
}
|
}
|
||||||
this.room.status = "in_round"
|
this.room.status = "in_round"
|
||||||
this.broadcastState()
|
this.broadcastState()
|
||||||
this.io.to(this.room.code).emit("round:start", {
|
const base = {
|
||||||
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) => {
|
||||||
|
|
@ -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[] {
|
private eligibleVoters(): string[] {
|
||||||
|
const secret = this.runtime?.secretPlayerId
|
||||||
return [...this.room.players.values()]
|
return [...this.room.players.values()]
|
||||||
.filter((p) => p.connected)
|
.filter((p) => p.connected && p.id !== secret)
|
||||||
.map((p) => p.id)
|
.map((p) => p.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,12 @@ export class BlindtestRound implements GameRound {
|
||||||
const payload: BlindtestRoundPayload | null = track
|
const payload: BlindtestRoundPayload | null = track
|
||||||
? { trackId: track.id, youtubeId: track.youtubeId }
|
? { trackId: track.id, youtubeId: track.youtubeId }
|
||||||
: null
|
: 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 {
|
submitAnswer(ctx: RoundContext, playerId: string, answer: Answer): void {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,11 @@ 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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -187,19 +187,44 @@ export function registerRoomHandlers(
|
||||||
ack({ accepted: false, reason: "Soumission impossible maintenant." })
|
ack({ accepted: false, reason: "Soumission impossible maintenant." })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const trackId = randomUUID()
|
||||||
room.blindtestTracks.push({
|
room.blindtestTracks.push({
|
||||||
id: randomUUID(),
|
id: trackId,
|
||||||
youtubeId,
|
youtubeId,
|
||||||
url: payload.youtubeUrl,
|
url: payload.youtubeUrl,
|
||||||
title: meta.title,
|
title: meta.title,
|
||||||
artist: meta.artist,
|
artist: meta.artist,
|
||||||
submittedBy: playerId,
|
submittedBy: playerId,
|
||||||
})
|
})
|
||||||
const count = mine.length + 1
|
ack({
|
||||||
ack({ accepted: true, title: meta.title, count })
|
accepted: true,
|
||||||
|
trackId,
|
||||||
|
youtubeId,
|
||||||
|
title: meta.title,
|
||||||
|
count: mine.length + 1,
|
||||||
|
})
|
||||||
broadcastState(room)
|
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) => {
|
socket.on("media:control", (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
|
||||||
|
|
@ -218,6 +243,22 @@ 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) {
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,10 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
<DjControls ready={ready} api={api} mediaControl={mediaControl} />
|
<DjControls ready={ready} api={api} mediaControl={mediaControl} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!showReveal && (
|
{!showReveal &&
|
||||||
|
(round?.mine ? (
|
||||||
|
<MisleadCard mode={snapshot.settings.blindtestMode} />
|
||||||
|
) : (
|
||||||
<VoteForm
|
<VoteForm
|
||||||
mode={snapshot.settings.blindtestMode}
|
mode={snapshot.settings.blindtestMode}
|
||||||
snapshot={snapshot}
|
snapshot={snapshot}
|
||||||
|
|
@ -110,7 +113,7 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
disabled={hasVoted}
|
disabled={hasVoted}
|
||||||
onVote={voteBlindtest}
|
onVote={voteBlindtest}
|
||||||
/>
|
/>
|
||||||
)}
|
))}
|
||||||
|
|
||||||
{showReveal && truth && (
|
{showReveal && truth && (
|
||||||
<RevealCard
|
<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({
|
function VoteForm({
|
||||||
mode,
|
mode,
|
||||||
snapshot,
|
snapshot,
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,8 @@ 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 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)
|
||||||
|
|
@ -194,11 +196,22 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
</ol>
|
</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="/">
|
<Link href="/">
|
||||||
<Button variant="secondary" className="w-full" onClick={reset}>
|
<Button variant="secondary" className="w-full" onClick={reset}>
|
||||||
Retour à l'accueil
|
Quitter
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
import { Trash2 } from "lucide-react"
|
||||||
import type {
|
import type {
|
||||||
BlindtestMode,
|
BlindtestMode,
|
||||||
GameType,
|
GameType,
|
||||||
|
|
@ -225,6 +226,12 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MyTrack {
|
||||||
|
trackId: string
|
||||||
|
title: string
|
||||||
|
youtubeId: string
|
||||||
|
}
|
||||||
|
|
||||||
function TrackSubmission({
|
function TrackSubmission({
|
||||||
tracksPerPlayer,
|
tracksPerPlayer,
|
||||||
myCount,
|
myCount,
|
||||||
|
|
@ -233,10 +240,11 @@ function TrackSubmission({
|
||||||
myCount: number
|
myCount: number
|
||||||
}) {
|
}) {
|
||||||
const submitTrack = useRoomStore((s) => s.submitTrack)
|
const submitTrack = useRoomStore((s) => s.submitTrack)
|
||||||
|
const removeTrack = useRoomStore((s) => s.removeTrack)
|
||||||
const [url, setUrl] = useState("")
|
const [url, setUrl] = useState("")
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [feedback, setFeedback] = useState<string | null>(null)
|
const [feedback, setFeedback] = useState<string | null>(null)
|
||||||
const [accepted, setAccepted] = useState<string[]>([])
|
const [tracks, setTracks] = useState<MyTrack[]>([])
|
||||||
const quotaReached = myCount >= tracksPerPlayer
|
const quotaReached = myCount >= tracksPerPlayer
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
|
|
@ -246,8 +254,11 @@ function TrackSubmission({
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
setFeedback(null)
|
setFeedback(null)
|
||||||
const res = await submitTrack(url.trim())
|
const res = await submitTrack(url.trim())
|
||||||
if (res.accepted) {
|
if (res.accepted && res.trackId && res.youtubeId) {
|
||||||
setAccepted((a) => [...a, res.title ?? url.trim()])
|
setTracks((t) => [
|
||||||
|
...t,
|
||||||
|
{ trackId: res.trackId!, title: res.title ?? "", youtubeId: res.youtubeId! },
|
||||||
|
])
|
||||||
setUrl("")
|
setUrl("")
|
||||||
} else {
|
} else {
|
||||||
setFeedback(res.reason ?? "Refusé")
|
setFeedback(res.reason ?? "Refusé")
|
||||||
|
|
@ -255,38 +266,65 @@ function TrackSubmission({
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function remove(trackId: string) {
|
||||||
|
const ok = await removeTrack(trackId)
|
||||||
|
if (ok) {
|
||||||
|
setTracks((t) => t.filter((x) => x.trackId !== trackId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
Tes titres ({myCount}/{tracksPerPlayer})
|
Tes titres ({myCount}/{tracksPerPlayer})
|
||||||
</span>
|
</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">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
placeholder="Lien YouTube"
|
placeholder="Lien YouTube"
|
||||||
value={url}
|
value={url}
|
||||||
disabled={quotaReached || submitting}
|
disabled={submitting}
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && submit()}
|
onKeyDown={(e) => e.key === "Enter" && submit()}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
disabled={quotaReached || submitting || !url.trim()}
|
disabled={submitting || !url.trim()}
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
>
|
>
|
||||||
Ajouter
|
Ajouter
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ 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
|
||||||
|
|
@ -56,6 +58,8 @@ interface RoomState {
|
||||||
updateSettings: (partial: Partial<UpdateSettingsPayload>) => void
|
updateSettings: (partial: Partial<UpdateSettingsPayload>) => void
|
||||||
startGame: (rounds: RoundConfig[]) => Promise<void>
|
startGame: (rounds: RoundConfig[]) => Promise<void>
|
||||||
submitTrack: (youtubeUrl: string) => Promise<SubmitOkPayload>
|
submitTrack: (youtubeUrl: string) => Promise<SubmitOkPayload>
|
||||||
|
removeTrack: (trackId: string) => Promise<boolean>
|
||||||
|
returnToLobby: () => void
|
||||||
vote: (choiceIndex: number) => void
|
vote: (choiceIndex: number) => void
|
||||||
voteText: (text: string) => void
|
voteText: (text: string) => void
|
||||||
voteBlindtest: (answer: BlindtestAnswer) => void
|
voteBlindtest: (answer: BlindtestAnswer) => void
|
||||||
|
|
@ -137,6 +141,15 @@ export const useRoomStore = create<RoomState>((set, get) => ({
|
||||||
socket.emit("blindtest:submitTrack", { youtubeUrl }, (res) => resolve(res))
|
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().hasVoted) {
|
||||||
return
|
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é).
|
// 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) => 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("error", (error) => useRoomStore.setState({ lastError: error }))
|
||||||
|
|
||||||
socket.on("round:start", (payload) =>
|
socket.on("round:start", (payload) =>
|
||||||
|
|
@ -197,6 +225,7 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -46,12 +46,19 @@ 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. */
|
/** Titre récupéré (oEmbed) si accepté — pour confirmation côté client. */
|
||||||
title?: string
|
title?: string
|
||||||
/** Nombre total de titres soumis par ce joueur après cet ajout. */
|
/** Nombre total de titres soumis par ce joueur après cet ajout. */
|
||||||
count?: number
|
count?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RemoveTrackPayload {
|
||||||
|
trackId: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface RoundStartPayload {
|
export interface RoundStartPayload {
|
||||||
type: RoundType
|
type: RoundType
|
||||||
djId?: string
|
djId?: string
|
||||||
|
|
@ -59,6 +66,8 @@ 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
|
||||||
}
|
}
|
||||||
|
|
@ -118,11 +127,16 @@ 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
|
||||||
"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,
|
payload: SubmitTrackPayload,
|
||||||
ack: (res: SubmitOkPayload) => void
|
ack: (res: SubmitOkPayload) => void
|
||||||
) => 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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue