- 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>
189 lines
5.1 KiB
TypeScript
189 lines
5.1 KiB
TypeScript
// Events Socket.IO typés, partagés par le serveur et le client.
|
|
// Convention : `CLIENT → SERVER` et `SERVER → CLIENT`. Tout timestamp est serveur.
|
|
|
|
import type {
|
|
Answer,
|
|
BlindtestMode,
|
|
GameType,
|
|
MixMode,
|
|
PlayerScore,
|
|
RoomSnapshot,
|
|
RoundConfig,
|
|
RoundRecap,
|
|
RoundType,
|
|
} from "./domain"
|
|
import type { BlindtestTrackInfo } from "./blindtest"
|
|
|
|
// --- Payloads ------------------------------------------------------------
|
|
|
|
export interface RoomCreatePayload {
|
|
playerName?: string
|
|
}
|
|
|
|
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 {
|
|
roomCode: string
|
|
playerName?: string
|
|
}
|
|
|
|
export interface SetNamePayload {
|
|
name: string
|
|
}
|
|
|
|
export interface UpdateSettingsPayload {
|
|
gameType: GameType
|
|
mixedModes: MixMode[]
|
|
blindtestMode: BlindtestMode
|
|
roundDuration: number
|
|
tracksPerPlayer: number
|
|
categories: string[]
|
|
rounds: RoundConfig[]
|
|
}
|
|
|
|
export interface PlayerRefPayload {
|
|
playerId: string
|
|
}
|
|
|
|
export interface SubmitTrackPayload {
|
|
youtubeUrl: string
|
|
}
|
|
|
|
export interface SubmitOkPayload {
|
|
accepted: boolean
|
|
reason?: string
|
|
/** Id du titre créé (pour pouvoir le supprimer). */
|
|
trackId?: string
|
|
youtubeId?: string
|
|
/** Titre récupéré (oEmbed) si accepté — pour confirmation côté client. */
|
|
title?: string
|
|
/** Nombre total de titres soumis par ce joueur après cet ajout. */
|
|
count?: number
|
|
}
|
|
|
|
export interface RemoveTrackPayload {
|
|
trackId: string
|
|
}
|
|
|
|
export interface RoundStartPayload {
|
|
type: RoundType
|
|
djId?: string
|
|
/** Timestamp serveur de début des réponses (après le délai de préparation / transition). */
|
|
startsAt: number
|
|
/** Timestamp serveur de fin de manche (startsAt + roundDuration). */
|
|
endsAt: number
|
|
/** Envoyé uniquement au contributeur du titre (blindtest) : "c'est le tien". */
|
|
mine?: boolean
|
|
/** Payload spécifique au type d'épreuve, SANS la réponse. */
|
|
payload: unknown
|
|
}
|
|
|
|
export type MediaAction = "play" | "pause" | "seek"
|
|
|
|
/** Contrôle média émis par le DJ uniquement. */
|
|
export interface MediaControlPayload {
|
|
action: MediaAction
|
|
positionSec: number
|
|
}
|
|
|
|
/** Diffusion de recalage à tous les clients. */
|
|
export interface MediaSyncPayload {
|
|
action: MediaAction
|
|
positionSec: number
|
|
atServerTs: number
|
|
}
|
|
|
|
export interface VotePayload {
|
|
answer: Answer
|
|
}
|
|
|
|
/** Progression des votes. `voted` = playerIds ayant validé (jamais leur réponse). */
|
|
export interface VoteAckPayload {
|
|
count: number
|
|
total: number
|
|
voted: string[]
|
|
}
|
|
|
|
export interface RevealPayload {
|
|
truth: unknown
|
|
perPlayerResult: unknown
|
|
}
|
|
|
|
export interface ScoreUpdatePayload {
|
|
scores: PlayerScore[]
|
|
}
|
|
|
|
export interface GameEndPayload {
|
|
finalScores: PlayerScore[]
|
|
/** Titres joués (si la partie comportait du blindtest) — pour récupérer les liens. */
|
|
tracks?: BlindtestTrackInfo[]
|
|
/** Récapitulatif de toutes les manches (question + réponse + qui a marqué). */
|
|
recap?: RoundRecap[]
|
|
spotifyExport?: unknown
|
|
}
|
|
|
|
export interface ErrorPayload {
|
|
code: string
|
|
message: string
|
|
}
|
|
|
|
// --- Maps d'events Socket.IO ---------------------------------------------
|
|
|
|
/** Ack standard renvoyé par le serveur sur les commandes critiques. */
|
|
export type Ack<T> = (result: { ok: true; data: T } | { ok: false; error: ErrorPayload }) => void
|
|
|
|
/** CLIENT → SERVER */
|
|
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
|
|
"game:start": (ack: Ack<null>) => void
|
|
"blindtest:submitTrack": (
|
|
payload: SubmitTrackPayload,
|
|
ack: (res: SubmitOkPayload) => void
|
|
) => void
|
|
"blindtest:removeTrack": (
|
|
payload: RemoveTrackPayload,
|
|
ack: (res: { ok: boolean }) => void
|
|
) => void
|
|
"media:control": (payload: MediaControlPayload) => void
|
|
"round:vote": (payload: VotePayload) => void
|
|
}
|
|
|
|
/** SERVER → CLIENT */
|
|
export interface ServerToClientEvents {
|
|
"room:state": (payload: RoomSnapshot) => void
|
|
"player:left": (payload: PlayerRefPayload) => void
|
|
"player:rejoined": (payload: PlayerRefPayload) => void
|
|
"blindtest:submitOk": (payload: SubmitOkPayload) => void
|
|
"round:start": (payload: RoundStartPayload) => void
|
|
"media:sync": (payload: MediaSyncPayload) => void
|
|
"round:voteAck": (payload: VoteAckPayload) => void
|
|
"round:reveal": (payload: RevealPayload) => void
|
|
"score:update": (payload: ScoreUpdatePayload) => void
|
|
"game:end": (payload: GameEndPayload) => void
|
|
"error": (payload: ErrorPayload) => void
|
|
}
|
|
|
|
/** Events serveur ↔ serveur (inutilisés en V1, pas de Redis adapter). */
|
|
export type InterServerEvents = Record<string, never>
|
|
|
|
/** Données attachées à chaque socket côté serveur. */
|
|
export interface SocketData {
|
|
playerId?: string
|
|
roomCode?: string
|
|
}
|