feat: leave-room button

- room:leave event: removes the player from the room (not just disconnected),
  reassigns host if the host leaves, deletes the room when empty
- store leaveRoom() emits it and clears the session (reset)
- room header gets a "Quitter" button (always available); end-screen "Quitter"
  now also leaves cleanly

Verified e2e: leaver removed from snapshot, host reassigned to the next player.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
AyoubBenziza 2026-06-11 17:33:10 +02:00
parent 6ed99ea803
commit f0c327617b
6 changed files with 67 additions and 7 deletions

View file

@ -324,6 +324,22 @@ export function registerRoomHandlers(
broadcastState(room)
})
socket.on("room:leave", () => {
const code = socket.data.roomCode
const playerId = socket.data.playerId
if (!code || !playerId) {
return
}
socket.leave(code)
socket.data.roomCode = undefined
socket.data.playerId = undefined
const room = rooms.leave(code, playerId)
if (room) {
io.to(room.code).emit("player:left", { playerId })
broadcastState(room)
}
})
socket.on("disconnect", () => {
const affected = rooms.handleDisconnect(socket.id)
if (!affected) {

View file

@ -94,6 +94,28 @@ export class RoomManager {
return this.rooms.get(code)
}
/**
* Retire un joueur d'une room (départ volontaire). Réassigne l'hôte si besoin,
* supprime la room si plus personne. Renvoie la room (ou null si supprimée).
*/
leave(code: string, playerId: string): ServerRoom | null {
const room = this.rooms.get(code)
if (!room) {
return null
}
room.players.delete(playerId)
room.scores.delete(playerId)
if (room.players.size === 0) {
this.rooms.delete(code)
return null
}
if (room.hostId === playerId) {
const named = [...room.players.values()].find((p) => p.name !== "")
room.hostId = (named ?? [...room.players.values()][0]).id
}
return room
}
/**
* Marque le joueur d'un socket comme déconnecté.
* Retourne la room affectée et le playerId, ou null si non rattaché.

View file

@ -133,7 +133,7 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
const finalScores = useRoomStore((s) => s.finalScores)
const gameTracks = useRoomStore((s) => s.gameTracks)
const gameRecap = useRoomStore((s) => s.gameRecap)
const reset = useRoomStore((s) => s.reset)
const leaveRoom = useRoomStore((s) => s.leaveRoom)
const returnToLobby = useRoomStore((s) => s.returnToLobby)
const isHost = snapshot.hostId === playerId
@ -284,7 +284,7 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
</p>
)}
<Link href="/">
<Button variant="secondary" className="w-full" onClick={reset}>
<Button variant="secondary" className="w-full" onClick={leaveRoom}>
Quitter
</Button>
</Link>

View file

@ -1,5 +1,6 @@
import { Link } from "wouter"
import { Link, useLocation } from "wouter"
import { AnimatePresence, motion } from "framer-motion"
import { LogOut } from "lucide-react"
import { Button } from "@workspace/ui/components/button"
import { useRoomStore } from "@/store/room"
import { LobbyView } from "@/components/lobby-view"
@ -13,8 +14,10 @@ import { RoundTransition } from "@/components/round-transition"
import { PseudoScreen } from "@/components/pseudo-screen"
export function RoomPage({ code }: { code: string }) {
const [, navigate] = useLocation()
const snapshot = useRoomStore((s) => s.snapshot)
const roomCode = useRoomStore((s) => s.roomCode)
const leaveRoom = useRoomStore((s) => s.leaveRoom)
const playerId = useRoomStore((s) => s.playerId)
const connected = useRoomStore((s) => s.connected)
const pseudoSet = useRoomStore((s) => s.pseudoSet)
@ -80,10 +83,22 @@ export function RoomPage({ code }: { code: string }) {
>
<header className="flex items-center justify-between">
<RoomCode code={snapshot.code} />
<div className="flex items-center gap-3">
<span
className={`size-2.5 rounded-full ${connected ? "bg-green-500" : "bg-red-500"}`}
title={connected ? "Connecté" : "Déconnecté"}
/>
<Button
size="sm"
variant="secondary"
onClick={() => {
leaveRoom()
navigate("/")
}}
>
<LogOut /> Quitter
</Button>
</div>
</header>
<AnimatePresence mode="wait">

View file

@ -77,6 +77,7 @@ interface RoomState {
submitTrack: (youtubeUrl: string) => Promise<SubmitOkPayload>
removeTrack: (trackId: string) => Promise<boolean>
returnToLobby: () => void
leaveRoom: () => void
vote: (choiceIndex: number) => void
voteText: (text: string) => void
voteBlindtest: (answer: BlindtestAnswer) => void
@ -192,6 +193,11 @@ export const useRoomStore = create<RoomState>((set, get) => ({
socket.emit("lobby:return")
},
leaveRoom: () => {
socket.emit("room:leave")
get().reset()
},
vote: (choiceIndex) => {
if (get().hasVoted) {
return

View file

@ -149,6 +149,7 @@ export interface ClientToServerEvents {
"room:join": (payload: RoomJoinPayload, ack: Ack<RoomCreatedPayload>) => void
"room:rejoin": (payload: RejoinPayload, ack: Ack<RoomCreatedPayload>) => void
"player:setName": (payload: SetNamePayload) => void
"room:leave": () => void
"lobby:updateSettings": (payload: UpdateSettingsPayload) => void
"lobby:return": () => void
"game:start": (ack: Ack<null>) => void