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>
358 lines
9.5 KiB
TypeScript
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,
|
|
})
|
|
)
|