From 6ed99ea8031f8d0aa660bc51a56d91d974d4b5cd Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Thu, 11 Jun 2026 17:26:22 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20reconnection=20=E2=80=94=20keep=20y?= =?UTF-8?q?our=20place=20across=20refresh=20/=20network=20drop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - each player gets a reconnectToken; room:create/join return it - new room:rejoin event: re-attaches the existing player (by id+token) to the new socket, broadcasts player:rejoined + room:state, and resyncs the current round to that socket (engine.resync re-emits round:start with their view) - client persists the session (roomCode/playerId/token/name) in localStorage; bootstraps the store from it and auto-rejoins on every socket connect; clears it on reset/quit or failed rejoin Verified e2e: wrong token rejected, correct token re-attaches (player present + connected). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/src/game/engine.ts | 19 ++++++++++++++ apps/server/src/game/round.ts | 2 ++ apps/server/src/handlers.ts | 37 ++++++++++++++++++++++++-- apps/server/src/rooms.ts | 10 +++++++- apps/web/src/lib/session.ts | 34 ++++++++++++++++++++++++ apps/web/src/store/room.ts | 47 +++++++++++++++++++++++++++++----- packages/shared/src/events.ts | 9 +++++++ 7 files changed, 149 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/lib/session.ts diff --git a/apps/server/src/game/engine.ts b/apps/server/src/game/engine.ts index c0097b0..66137f8 100644 --- a/apps/server/src/game/engine.ts +++ b/apps/server/src/game/engine.ts @@ -24,6 +24,8 @@ interface RoundRuntime { startedAt: number endsAt: number data: unknown + /** Payload public de la manche (pour la reconnexion). */ + payload: unknown } export interface GameEngineOptions { @@ -137,6 +139,22 @@ export class GameEngine implements RoomGameController { }) } + /** Reconnexion : re-pousse la manche en cours au socket d'un joueur. */ + resync(socketId: string, playerId: string): void { + if (!this.runtime || this.room.status !== "in_round") { + return + } + const r = this.runtime + this.io.to(socketId).emit("round:start", { + type: r.round.type, + djId: r.djId === playerId ? r.djId : undefined, + mine: r.secretPlayerId === playerId ? true : undefined, + startsAt: r.startedAt, + endsAt: r.endsAt, + payload: r.payload, + }) + } + private async playRound(config: RoundConfig): Promise { const round = this.createRound(config.type) const start = round.start(this.room) @@ -154,6 +172,7 @@ export class GameEngine implements RoomGameController { startedAt, endsAt, data: start.data, + payload: start.payload, } this.room.status = "in_round" this.broadcastState() diff --git a/apps/server/src/game/round.ts b/apps/server/src/game/round.ts index 46be5e9..e9a59a6 100644 --- a/apps/server/src/game/round.ts +++ b/apps/server/src/game/round.ts @@ -73,4 +73,6 @@ export interface RoomGameController { handleVote(playerId: string, answer: Answer): void /** Relais média du DJ (blindtest) : rebroadcast media:sync à toute la room. */ handleMediaControl(playerId: string, payload: MediaControlPayload): void + /** Reconnexion : re-pousse la manche en cours au socket d'un joueur. */ + resync(socketId: string, playerId: string): void } diff --git a/apps/server/src/handlers.ts b/apps/server/src/handlers.ts index 2a39854..8aa2cd4 100644 --- a/apps/server/src/handlers.ts +++ b/apps/server/src/handlers.ts @@ -60,7 +60,7 @@ export function registerRoomHandlers( socket.data.playerId = player.id socket.data.roomCode = room.code socket.join(room.code) - ack({ ok: true, data: { roomCode: room.code, playerId: player.id } }) + ack({ ok: true, data: { roomCode: room.code, playerId: player.id, token: player.reconnectToken } }) broadcastState(room) } catch (err) { ack({ ok: false, error: toErrorPayload(err) }) @@ -75,13 +75,46 @@ export function registerRoomHandlers( socket.data.playerId = player.id socket.data.roomCode = room.code socket.join(room.code) - ack({ ok: true, data: { roomCode: room.code, playerId: player.id } }) + ack({ ok: true, data: { roomCode: room.code, playerId: player.id, token: player.reconnectToken } }) broadcastState(room) } catch (err) { ack({ ok: false, error: toErrorPayload(err) }) } }) + socket.on("room:rejoin", (payload, ack) => { + const code = + typeof payload?.roomCode === "string" + ? payload.roomCode.trim().toUpperCase() + : "" + const room = rooms.get(code) + const player = room?.players.get(payload?.playerId ?? "") + if (!room || !player || player.reconnectToken !== payload?.token) { + ack({ + ok: false, + error: { code: "REJOIN_FAILED", message: "Reconnexion impossible." }, + }) + return + } + player.connected = true + player.socketId = socket.id + socket.data.playerId = player.id + socket.data.roomCode = room.code + socket.join(room.code) + ack({ + ok: true, + data: { + roomCode: room.code, + playerId: player.id, + token: player.reconnectToken, + }, + }) + io.to(room.code).emit("player:rejoined", { playerId: player.id }) + broadcastState(room) + // Re-pousse la manche en cours au socket reconnecté (si partie en cours). + room.game?.resync(socket.id, player.id) + }) + socket.on("player:setName", (payload) => { const code = socket.data.roomCode const playerId = socket.data.playerId diff --git a/apps/server/src/rooms.ts b/apps/server/src/rooms.ts index 96be50b..1aa79f3 100644 --- a/apps/server/src/rooms.ts +++ b/apps/server/src/rooms.ts @@ -16,6 +16,8 @@ export interface ServerPlayer { name: string connected: boolean socketId: string | null + /** Secret de reconnexion (refresh/coupure). */ + reconnectToken: string } /** Titre soumis pour le blindtest. `submittedBy` reste secret jusqu'au reveal. */ @@ -138,7 +140,13 @@ export class RoomManager { } private makePlayer(name: string, socketId: string): ServerPlayer { - return { id: randomUUID(), name, connected: true, socketId } + return { + id: randomUUID(), + name, + connected: true, + socketId, + reconnectToken: randomUUID(), + } } private generateCode(): string { diff --git a/apps/web/src/lib/session.ts b/apps/web/src/lib/session.ts new file mode 100644 index 0000000..f707914 --- /dev/null +++ b/apps/web/src/lib/session.ts @@ -0,0 +1,34 @@ +// Session locale pour la reconnexion (refresh / coupure réseau). + +const KEY = "nerdware-session" + +export interface Session { + roomCode: string + playerId: string + token: string + name: string +} + +export function getSession(): Session | null { + try { + const raw = localStorage.getItem(KEY) + return raw ? (JSON.parse(raw) as Session) : null + } catch { + return null + } +} + +export function setSession(session: Session): void { + localStorage.setItem(KEY, JSON.stringify(session)) +} + +export function patchSession(patch: Partial): void { + const current = getSession() + if (current) { + setSession({ ...current, ...patch }) + } +} + +export function clearSession(): void { + localStorage.removeItem(KEY) +} diff --git a/apps/web/src/store/room.ts b/apps/web/src/store/room.ts index c8ecbfe..5cb55f0 100644 --- a/apps/web/src/store/room.ts +++ b/apps/web/src/store/room.ts @@ -17,6 +17,14 @@ import { type VoteAckPayload, } from "@nerdware/shared" import { socket } from "@/lib/socket" +import { + clearSession, + getSession, + patchSession, + setSession, +} from "@/lib/session" + +const saved = getSession() /** Manche en cours côté client (depuis round:start). */ export interface ActiveRound { @@ -80,9 +88,9 @@ interface RoomState { export const useRoomStore = create((set, get) => ({ connected: socket.connected, - playerId: null, - roomCode: null, - pseudoSet: false, + playerId: saved?.playerId ?? null, + roomCode: saved?.roomCode ?? null, + pseudoSet: !!saved?.name, snapshot: null, lastError: null, round: null, @@ -103,6 +111,7 @@ export const useRoomStore = create((set, get) => ({ new Promise((resolve, reject) => { socket.emit("room:create", {}, (res) => { if (res.ok) { + setSession({ ...res.data, name: "" }) set({ playerId: res.data.playerId, roomCode: res.data.roomCode, @@ -120,6 +129,7 @@ export const useRoomStore = create((set, get) => ({ new Promise((resolve, reject) => { socket.emit("room:join", { roomCode }, (res) => { if (res.ok) { + setSession({ ...res.data, name: "" }) set({ playerId: res.data.playerId, roomCode: res.data.roomCode, @@ -138,6 +148,7 @@ export const useRoomStore = create((set, get) => ({ return } socket.emit("player:setName", { name: name.trim() }) + patchSession({ name: name.trim() }) set({ pseudoSet: true }) }, @@ -212,7 +223,8 @@ export const useRoomStore = create((set, get) => ({ boom: () => set((s) => ({ boomKey: s.boomKey + 1 })), clearError: () => set({ lastError: null }), - reset: () => + reset: () => { + clearSession() set({ roomCode: null, pseudoSet: false, @@ -231,11 +243,34 @@ export const useRoomStore = create((set, get) => ({ roundKey: 0, roundKind: "quiz", roundModeChanged: false, - }), + }) + }, })) // 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 }) + // Reconnexion : on retente de réintégrer la room mémorisée (refresh/coupure). + const s = getSession() + if (!s) { + return + } + socket.emit( + "room:rejoin", + { roomCode: s.roomCode, playerId: s.playerId, token: s.token }, + (res) => { + if (res.ok) { + useRoomStore.setState({ + roomCode: res.data.roomCode, + playerId: res.data.playerId, + pseudoSet: !!s.name, + }) + } else { + useRoomStore.getState().reset() + } + } + ) +}) socket.on("disconnect", () => useRoomStore.setState({ connected: false })) socket.on("room:state", (snapshot) => useRoomStore.setState( diff --git a/packages/shared/src/events.ts b/packages/shared/src/events.ts index 62cd0df..eabf5d3 100644 --- a/packages/shared/src/events.ts +++ b/packages/shared/src/events.ts @@ -23,6 +23,14 @@ export interface RoomCreatePayload { export interface RoomCreatedPayload { roomCode: string playerId: string + /** Jeton secret pour se reconnecter après un refresh/coupure. */ + token: string +} + +export interface RejoinPayload { + roomCode: string + playerId: string + token: string } export interface RoomJoinPayload { @@ -139,6 +147,7 @@ export type Ack = (result: { ok: true; data: T } | { ok: false; error: ErrorP export interface ClientToServerEvents { "room:create": (payload: RoomCreatePayload, ack: Ack) => void "room:join": (payload: RoomJoinPayload, ack: Ack) => void + "room:rejoin": (payload: RejoinPayload, ack: Ack) => void "player:setName": (payload: SetNamePayload) => void "lobby:updateSettings": (payload: UpdateSettingsPayload) => void "lobby:return": () => void From f0c327617b120b676ff7b3a9cf437e60c3827dfa Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Thu, 11 Jun 2026 17:33:10 +0200 Subject: [PATCH 2/2] 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) --- apps/server/src/handlers.ts | 16 +++++++++++++++ apps/server/src/rooms.ts | 22 ++++++++++++++++++++ apps/web/src/components/game-end-view.tsx | 4 ++-- apps/web/src/pages/room.tsx | 25 ++++++++++++++++++----- apps/web/src/store/room.ts | 6 ++++++ packages/shared/src/events.ts | 1 + 6 files changed, 67 insertions(+), 7 deletions(-) diff --git a/apps/server/src/handlers.ts b/apps/server/src/handlers.ts index 8aa2cd4..8ab1faf 100644 --- a/apps/server/src/handlers.ts +++ b/apps/server/src/handlers.ts @@ -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) { diff --git a/apps/server/src/rooms.ts b/apps/server/src/rooms.ts index 1aa79f3..d32dd67 100644 --- a/apps/server/src/rooms.ts +++ b/apps/server/src/rooms.ts @@ -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é. diff --git a/apps/web/src/components/game-end-view.tsx b/apps/web/src/components/game-end-view.tsx index 5e5bd39..ce64d4d 100644 --- a/apps/web/src/components/game-end-view.tsx +++ b/apps/web/src/components/game-end-view.tsx @@ -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 }) {

)} - diff --git a/apps/web/src/pages/room.tsx b/apps/web/src/pages/room.tsx index 81bfb56..12e82a4 100644 --- a/apps/web/src/pages/room.tsx +++ b/apps/web/src/pages/room.tsx @@ -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 }) { >
- +
+ + +
diff --git a/apps/web/src/store/room.ts b/apps/web/src/store/room.ts index 5cb55f0..5b72134 100644 --- a/apps/web/src/store/room.ts +++ b/apps/web/src/store/room.ts @@ -77,6 +77,7 @@ interface RoomState { submitTrack: (youtubeUrl: string) => Promise removeTrack: (trackId: string) => Promise returnToLobby: () => void + leaveRoom: () => void vote: (choiceIndex: number) => void voteText: (text: string) => void voteBlindtest: (answer: BlindtestAnswer) => void @@ -192,6 +193,11 @@ export const useRoomStore = create((set, get) => ({ socket.emit("lobby:return") }, + leaveRoom: () => { + socket.emit("room:leave") + get().reset() + }, + vote: (choiceIndex) => { if (get().hasVoted) { return diff --git a/packages/shared/src/events.ts b/packages/shared/src/events.ts index eabf5d3..f0dfe38 100644 --- a/packages/shared/src/events.ts +++ b/packages/shared/src/events.ts @@ -149,6 +149,7 @@ export interface ClientToServerEvents { "room:join": (payload: RoomJoinPayload, ack: Ack) => void "room:rejoin": (payload: RejoinPayload, ack: Ack) => void "player:setName": (payload: SetNamePayload) => void + "room:leave": () => void "lobby:updateSettings": (payload: UpdateSettingsPayload) => void "lobby:return": () => void "game:start": (ack: Ack) => void