nerdware/apps/web/src/store/room.ts
AyoubBenziza 1727b84644 feat: rebus game mode
New "Rébus" mode: guess the geek work from an emoji puzzle (free-text answer,
tolerant matching). Implemented as a quiz format `rebus` — reuses the whole quiz
machinery (pool, anti-repeat, speed scoring, recap, bots, back-office):

- shared: QuizFormat/GameType/MixMode + RoundConfig.pool gain "rebus"
- server: rebus is a text format; pool composes a rebus sub-pool (not filtered by
  category); ~9 seeded emoji rebus in the in-code bank; admin accepts the format
- client: lobby tile + sub-mode + count; big centered emoji display in the round;
  dedicated transition theme (🧩 RÉBUS); FR/EN strings; back-office form option

Verified e2e: a rebus game serves only rebus rounds with emoji prompts and the
recap shows the right answers. typecheck/lint/build/24 tests green.

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

358 lines
9.5 KiB
TypeScript

import { create } from "zustand"
import {
DEFAULT_ROOM_SETTINGS,
type BlindtestAnswer,
type BlindtestTrackInfo,
type ErrorPayload,
type MediaControlPayload,
type MediaSyncPayload,
type MyTrackInfo,
type PlayerScore,
type RoomCreatedPayload,
type RoomSnapshot,
type RoundConfig,
type RoundRecap,
type RoundStartPayload,
type SubmitOkPayload,
type UpdateSettingsPayload,
type VoteAckPayload,
} from "@nerdware/shared"
import { socket } from "@/lib/socket"
import "@/lib/clock" // démarre la synchro d'horloge avec le serveur
import {
clearSession,
getSession,
patchSession,
setSession,
} from "@/lib/session"
const saved = getSession()
/** Manche en cours côté client (depuis round:start). */
export interface ActiveRound {
type: RoundStartPayload["type"]
djId?: string
/** Blindtest : true si c'est MON titre (je dois faire deviner, pas voter). */
mine?: boolean
startsAt: number
endsAt: number
payload: unknown
}
/** Reveal reçu (round:reveal), payloads typés par mode au moment de l'affichage. */
export interface RoundReveal {
truth: unknown
perPlayerResult: unknown
}
interface RoomState {
connected: boolean
playerId: string | null
roomCode: string | null
/** Le joueur local a-t-il choisi son pseudo dans la room ? */
pseudoSet: boolean
snapshot: RoomSnapshot | null
lastError: ErrorPayload | null
/** Mes titres blindtest (renvoyés par le serveur, source de vérité). */
myTracks: MyTrackInfo[]
// État de jeu (poussé par le moteur serveur).
round: ActiveRound | null
voteProgress: VoteAckPayload | null
reveal: RoundReveal | null
myChoiceIndex: number | null
hasVoted: boolean
finalScores: PlayerScore[] | null
gameTracks: BlindtestTrackInfo[] | null
gameRecap: RoundRecap[] | null
mediaSync: MediaSyncPayload | null
boomKey: number
roundKey: number
/** Catégorie visuelle de la manche (image_reveal / rébus distincts du quiz). */
roundKind: "quiz" | "image" | "rebus" | "blindtest"
/** Le mode a-t-il changé par rapport à la manche précédente ? */
roundModeChanged: boolean
createRoom: () => Promise<RoomCreatedPayload>
joinRoom: (roomCode: string) => Promise<RoomCreatedPayload>
setName: (name: string) => void
updateSettings: (partial: Partial<UpdateSettingsPayload>) => void
startGame: (rounds: RoundConfig[]) => Promise<void>
submitTrack: (youtubeUrl: string) => Promise<SubmitOkPayload>
removeTrack: (trackId: string) => Promise<boolean>
returnToLobby: () => void
leaveRoom: () => void
addBot: () => void
removeBot: () => void
vote: (choiceIndex: number) => void
voteText: (text: string) => void
voteBlindtest: (answer: BlindtestAnswer) => void
mediaControl: (action: MediaControlPayload["action"], positionSec: number) => void
boom: () => void
clearError: () => void
reset: () => void
}
export const useRoomStore = create<RoomState>((set, get) => ({
connected: socket.connected,
playerId: saved?.playerId ?? null,
roomCode: saved?.roomCode ?? null,
pseudoSet: !!saved?.name,
snapshot: null,
lastError: null,
myTracks: [],
round: null,
voteProgress: null,
reveal: null,
myChoiceIndex: null,
hasVoted: false,
finalScores: null,
gameTracks: null,
gameRecap: null,
mediaSync: null,
boomKey: 0,
roundKey: 0,
roundKind: "quiz",
roundModeChanged: false,
createRoom: () =>
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,
pseudoSet: false,
})
resolve(res.data)
} else {
set({ lastError: res.error })
reject(res.error)
}
})
}),
joinRoom: (roomCode) =>
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,
pseudoSet: false,
})
resolve(res.data)
} else {
set({ lastError: res.error })
reject(res.error)
}
})
}),
setName: (name) => {
if (name.trim().length === 0) {
return
}
socket.emit("player:setName", { name: name.trim() })
patchSession({ name: name.trim() })
set({ pseudoSet: true })
},
updateSettings: (partial) => {
const current = get().snapshot?.settings ?? DEFAULT_ROOM_SETTINGS
socket.emit("lobby:updateSettings", {
gameType: partial.gameType ?? current.gameType,
mixedModes: partial.mixedModes ?? current.mixedModes,
blindtestMode: partial.blindtestMode ?? current.blindtestMode,
roundDuration: partial.roundDuration ?? current.roundDuration,
tracksPerPlayer: partial.tracksPerPlayer ?? current.tracksPerPlayer,
categories: partial.categories ?? current.categories,
botDifficulty: partial.botDifficulty ?? current.botDifficulty,
rounds: partial.rounds ?? current.rounds,
})
},
startGame: (rounds) =>
new Promise((resolve, reject) => {
get().updateSettings({ rounds })
socket.emit("game:start", (res) => {
if (res.ok) {
resolve()
} else {
set({ lastError: res.error })
reject(res.error)
}
})
}),
submitTrack: (youtubeUrl) =>
new Promise((resolve) => {
socket.emit("blindtest:submitTrack", { youtubeUrl }, (res) => resolve(res))
}),
removeTrack: (trackId) =>
new Promise((resolve) => {
socket.emit("blindtest:removeTrack", { trackId }, (res) => resolve(res.ok))
}),
returnToLobby: () => {
socket.emit("lobby:return")
},
leaveRoom: () => {
socket.emit("room:leave")
get().reset()
},
addBot: () => socket.emit("lobby:addBot"),
removeBot: () => socket.emit("lobby:removeBot"),
vote: (choiceIndex) => {
if (get().hasVoted) {
return
}
set({ myChoiceIndex: choiceIndex, hasVoted: true })
socket.emit("round:vote", { answer: { choiceIndex } })
},
voteText: (text) => {
if (get().hasVoted || text.trim().length === 0) {
return
}
set({ hasVoted: true })
socket.emit("round:vote", { answer: { text: text.trim() } })
},
voteBlindtest: (answer) => {
if (get().hasVoted) {
return
}
set({ hasVoted: true })
socket.emit("round:vote", { answer })
},
mediaControl: (action, positionSec) => {
socket.emit("media:control", { action, positionSec })
},
boom: () => set((s) => ({ boomKey: s.boomKey + 1 })),
clearError: () => set({ lastError: null }),
reset: () => {
clearSession()
set({
roomCode: null,
pseudoSet: false,
snapshot: null,
lastError: null,
myTracks: [],
round: null,
voteProgress: null,
reveal: null,
myChoiceIndex: null,
hasVoted: false,
finalScores: null,
gameTracks: null,
gameRecap: null,
mediaSync: null,
boomKey: 0,
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 })
// 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(
snapshot.status === "lobby"
? {
snapshot,
round: null,
reveal: null,
voteProgress: null,
myChoiceIndex: null,
hasVoted: false,
finalScores: null,
gameTracks: null,
gameRecap: null,
mediaSync: null,
}
: { snapshot }
)
)
socket.on("error", (error) => useRoomStore.setState({ lastError: error }))
socket.on("blindtest:myTracks", ({ tracks }) =>
useRoomStore.setState({ myTracks: tracks })
)
socket.on("round:start", (payload) =>
useRoomStore.setState((s) => {
const fmt = (payload.payload as { format?: string } | null)?.format
const kind =
payload.type === "blindtest"
? "blindtest"
: fmt === "image_reveal"
? "image"
: fmt === "rebus"
? "rebus"
: "quiz"
return {
round: {
type: payload.type,
djId: payload.djId,
mine: payload.mine,
startsAt: payload.startsAt,
endsAt: payload.endsAt,
payload: payload.payload,
},
voteProgress: null,
reveal: null,
myChoiceIndex: null,
hasVoted: false,
mediaSync: null,
roundKey: s.roundKey + 1,
roundKind: kind,
roundModeChanged: s.roundKind !== kind,
}
})
)
socket.on("round:voteAck", (voteProgress) =>
useRoomStore.setState({ voteProgress })
)
socket.on("round:reveal", (reveal) => useRoomStore.setState({ reveal }))
socket.on("media:sync", (mediaSync) => useRoomStore.setState({ mediaSync }))
socket.on("game:end", ({ finalScores, tracks, recap }) =>
useRoomStore.setState({
finalScores,
gameTracks: tracks ?? null,
gameRecap: recap ?? null,
})
)