From 6ed99ea8031f8d0aa660bc51a56d91d974d4b5cd Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Thu, 11 Jun 2026 17:26:22 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20reconnection=20=E2=80=94=20keep=20your?= =?UTF-8?q?=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