- 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>
78 lines
2.9 KiB
TypeScript
78 lines
2.9 KiB
TypeScript
// Contrat générique d'une épreuve. Le moteur ne connaît QUE cette interface :
|
|
// brancher une nouvelle épreuve = écrire un module, pas retoucher la plomberie.
|
|
|
|
import type {
|
|
Answer,
|
|
MediaControlPayload,
|
|
RevealPayload,
|
|
RoundRecap,
|
|
RoundType,
|
|
ScoreDelta,
|
|
} from "@nerdware/shared"
|
|
import type { ServerRoom } from "../rooms"
|
|
|
|
/** Partie du récap fournie par l'épreuve (le moteur complète index/type/scorers). */
|
|
export type RoundRecapInfo = Omit<RoundRecap, "index" | "type" | "scorers">
|
|
|
|
|
|
/** Ce que `start()` renvoie : la partie publique + les données privées de la manche. */
|
|
export interface RoundStart {
|
|
/** Joueur DJ (blindtest) ; null pour les épreuves sans DJ (quiz). */
|
|
djId?: string | null
|
|
/** Durée de la manche en secondes ; défaut = room.settings.roundDuration. */
|
|
durationSec?: number
|
|
/** Payload broadcasté aux clients, SANS la réponse. */
|
|
payload: unknown
|
|
/**
|
|
* Joueur recevant un round:start privé avec `mine: true` et exclu des votants
|
|
* (blindtest : le contributeur du titre). Jamais révélé aux autres.
|
|
*/
|
|
secretPlayerId?: string
|
|
/** Données privées (ex: la bonne réponse), conservées jusqu'au reveal. Jamais diffusées. */
|
|
data?: unknown
|
|
}
|
|
|
|
/** Contexte runtime d'une manche, fourni aux callbacks de l'épreuve. */
|
|
export interface RoundContext {
|
|
room: ServerRoom
|
|
djId: string | null
|
|
/** Votes reçus : playerId → réponse. Muté par submitAnswer. */
|
|
votes: Map<string, Answer>
|
|
startedAt: number
|
|
endsAt: number
|
|
/** Données privées renvoyées par start(). */
|
|
data: unknown
|
|
}
|
|
|
|
/**
|
|
* Une épreuve. Implémente le cycle start → submitAnswer → reveal → score.
|
|
* Le serveur est autoritaire : start/reveal/score ne s'exécutent que côté serveur.
|
|
*/
|
|
export interface GameRound {
|
|
type: RoundType
|
|
|
|
/** Prépare la manche (tire le DJ, choisit le contenu) et renvoie le payload public. */
|
|
start(room: ServerRoom): RoundStart
|
|
|
|
/** Enregistre/valide le vote d'un joueur dans ctx.votes. Doit être idempotent. */
|
|
submitAnswer(ctx: RoundContext, playerId: string, answer: Answer): void
|
|
|
|
/** Calcule la vérité + le détail révélé à tous. */
|
|
reveal(ctx: RoundContext): RevealPayload
|
|
|
|
/** Renvoie les variations de score à appliquer (le moteur les applique). */
|
|
score(ctx: RoundContext): ScoreDelta[]
|
|
|
|
/** Résumé de la manche pour le récap de fin de partie (intitulé + réponse + média). */
|
|
recap(ctx: RoundContext): RoundRecapInfo
|
|
}
|
|
|
|
/** Vue minimale du moteur exposée à la room (évite un cycle d'import). */
|
|
export interface RoomGameController {
|
|
run(): Promise<void>
|
|
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
|
|
}
|