nerdware/apps/web/src/store/room.ts
AyoubBenziza 8bc184fab7 feat: bigger image_reveal, optional image prompt, mixed sub-mode toggles, Pokémon seed
- 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>
2026-06-11 00:20:28 +02:00

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 })
)