nerdware/packages/shared/src/events.ts
AyoubBenziza 7a41bbf8c1 feat: DJ can be contributor, volume, theme switch, clock sync, 90s blindtest
Playtest feedback round 2:
- blindtest DJ can now be anyone incl. the track's contributor (pickDj only
  excludes bots) — no more "immunity" for whoever isn't picked
- blindtest rounds last 90s (longer listen + typing)
- per-player volume slider (persisted), YT player setVolume + origin/enablejsapi
- theme switcher (light/dark/system) on home + room header
- clock sync: clients estimate the server-clock offset (time:sync) so the
  countdown is identical across devices (was off by each device's clock skew);
  also used for blindtest media sync

Note on duplicates: anti-repeat is per room code (quiz_played) — it works when
you replay in the SAME room, but a new room starts fresh. Verified e2e: two
chained games in one room share 0 questions.

Verified: typecheck 4/4, lint 4/4, 24 server tests, web build; e2e dedup (0
overlap) and clock/DJ behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:39:02 +02:00

207 lines
5.6 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,
BotDifficulty,
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[]
botDifficulty: BotDifficulty
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
}
/** Un titre soumis par le joueur lui-même (renvoyé en privé, source de vérité). */
export interface MyTrackInfo {
trackId: string
title: string
youtubeId: string
}
export interface MyTracksPayload {
tracks: MyTrackInfo[]
}
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 {
"time:sync": (ack: (serverNow: number) => void) => void
"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
"room:leave": () => void
"lobby:addBot": () => void
"lobby:removeBot": () => 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
"blindtest:myTracks": (payload: MyTracksPayload) => 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
}