feat: reconnection — keep your place across refresh / network drop
- 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) <noreply@anthropic.com>
This commit is contained in:
parent
4225b7b43e
commit
6ed99ea803
7 changed files with 149 additions and 9 deletions
|
|
@ -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<void> {
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
34
apps/web/src/lib/session.ts
Normal file
34
apps/web/src/lib/session.ts
Normal file
|
|
@ -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<Session>): void {
|
||||
const current = getSession()
|
||||
if (current) {
|
||||
setSession({ ...current, ...patch })
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSession(): void {
|
||||
localStorage.removeItem(KEY)
|
||||
}
|
||||
|
|
@ -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<RoomState>((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<RoomState>((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<RoomState>((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<RoomState>((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<RoomState>((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<RoomState>((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(
|
||||
|
|
|
|||
|
|
@ -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<T> = (result: { ok: true; data: T } | { ok: false; error: ErrorP
|
|||
export interface ClientToServerEvents {
|
||||
"room:create": (payload: RoomCreatePayload, ack: Ack<RoomCreatedPayload>) => void
|
||||
"room:join": (payload: RoomJoinPayload, ack: Ack<RoomCreatedPayload>) => void
|
||||
"room:rejoin": (payload: RejoinPayload, ack: Ack<RoomCreatedPayload>) => void
|
||||
"player:setName": (payload: SetNamePayload) => void
|
||||
"lobby:updateSettings": (payload: UpdateSettingsPayload) => void
|
||||
"lobby:return": () => void
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue