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..8ab1faf 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
@@ -291,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 96be50b..d32dd67 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. */
@@ -92,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é.
@@ -138,7 +162,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/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 }) {
)}
-