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 {
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue