- image_reveal display: larger, object-contain (whole image up to 58vh), no crop - image prompt optional in back-office (defaults to "Qui est-ce ?"), overridable - mixed mode reworked: select "Mixte" then toggle sub-modes (Quiz/Images/ Blindtest). Blindtest sub-mode locked under 3 players; mixed itself no longer needs 3. Server quiz pool formats derived from gameType + mixedModes. shared: MixMode + RoomSettings.mixedModes (default quiz+image). - DB: prompt uniqueness is now a partial index (excludes image_reveal) + unique index on image_url; lets many images share the generic prompt, dedup by URL. - seed: db:seed:images pulls Pokémon (PokéAPI) as image_reveal questions with official artwork (remote URL) + FR/EN accepted answers; idempotent by image_url. Verified: migration applied, Pokémon seed inserts FR+EN, image mode serves only image_reveal, all checks green (23 tests). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
276 lines
7.4 KiB
TypeScript
276 lines
7.4 KiB
TypeScript
import { create } from "zustand"
|
|
import {
|
|
DEFAULT_ROOM_SETTINGS,
|
|
type BlindtestAnswer,
|
|
type BlindtestTrackInfo,
|
|
type ErrorPayload,
|
|
type MediaControlPayload,
|
|
type MediaSyncPayload,
|
|
type PlayerScore,
|
|
type RoomCreatedPayload,
|
|
type RoomSnapshot,
|
|
type RoundConfig,
|
|
type RoundStartPayload,
|
|
type SubmitOkPayload,
|
|
type UpdateSettingsPayload,
|
|
type VoteAckPayload,
|
|
} from "@nerdware/shared"
|
|
import { socket } from "@/lib/socket"
|
|
|
|
/** 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
|
|
|
|
// É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
|
|
mediaSync: MediaSyncPayload | null
|
|
boomKey: number
|
|
roundKey: number
|
|
/** Le type d'épreuve 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
|
|
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: null,
|
|
roomCode: null,
|
|
pseudoSet: false,
|
|
snapshot: null,
|
|
lastError: null,
|
|
round: null,
|
|
voteProgress: null,
|
|
reveal: null,
|
|
myChoiceIndex: null,
|
|
hasVoted: false,
|
|
finalScores: null,
|
|
gameTracks: null,
|
|
mediaSync: null,
|
|
boomKey: 0,
|
|
roundKey: 0,
|
|
roundModeChanged: false,
|
|
|
|
createRoom: () =>
|
|
new Promise((resolve, reject) => {
|
|
socket.emit("room:create", {}, (res) => {
|
|
if (res.ok) {
|
|
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) {
|
|
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() })
|
|
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,
|
|
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")
|
|
},
|
|
|
|
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: () =>
|
|
set({
|
|
roomCode: null,
|
|
pseudoSet: false,
|
|
snapshot: null,
|
|
lastError: null,
|
|
round: null,
|
|
voteProgress: null,
|
|
reveal: null,
|
|
myChoiceIndex: null,
|
|
hasVoted: false,
|
|
finalScores: null,
|
|
gameTracks: null,
|
|
mediaSync: null,
|
|
boomKey: 0,
|
|
roundKey: 0,
|
|
roundModeChanged: false,
|
|
}),
|
|
}))
|
|
|
|
// Listeners socket → on pousse l'état serveur dans le store (source de vérité).
|
|
socket.on("connect", () => useRoomStore.setState({ connected: true }))
|
|
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,
|
|
mediaSync: null,
|
|
}
|
|
: { snapshot }
|
|
)
|
|
)
|
|
socket.on("error", (error) => useRoomStore.setState({ lastError: error }))
|
|
|
|
socket.on("round:start", (payload) =>
|
|
useRoomStore.setState((s) => ({
|
|
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,
|
|
roundModeChanged: (s.round?.type ?? null) !== payload.type,
|
|
}))
|
|
)
|
|
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 }) =>
|
|
useRoomStore.setState({ finalScores, gameTracks: tracks ?? null })
|
|
)
|