feat(lobby): block start until everyone submitted; tracks count stepper
- blindtest/mixed: the host can't start until every connected player has submitted their track quota — client disables the button with a hint, and game:start rejects with TRACKS_PENDING (authoritative) - tracks-per-player is now a number stepper (− / + buttons + free typing, clamped 1..10) instead of fixed 1/2/3 buttons Verified e2e: start rejected while a player hasn't submitted, accepted once all did. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3f0cab8f5f
commit
ff84dc4f6b
2 changed files with 90 additions and 17 deletions
|
|
@ -151,6 +151,25 @@ export function registerRoomHandlers(
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (hasBlindtest) {
|
||||||
|
const tpp = room.settings.tracksPerPlayer
|
||||||
|
const pending = [...room.players.values()].some(
|
||||||
|
(p) =>
|
||||||
|
p.connected &&
|
||||||
|
p.name !== "" &&
|
||||||
|
room.blindtestTracks.filter((t) => t.submittedBy === p.id).length < tpp
|
||||||
|
)
|
||||||
|
if (pending) {
|
||||||
|
ack({
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: "TRACKS_PENDING",
|
||||||
|
message: "Tous les joueurs n'ont pas encore soumis leurs titres.",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
ack({ ok: true, data: null })
|
ack({ ok: true, data: null })
|
||||||
// Précharge le contenu (pool quiz depuis la DB), puis lance la boucle.
|
// Précharge le contenu (pool quiz depuis la DB), puis lance la boucle.
|
||||||
// Fire-and-forget : la boucle de jeu broadcast son propre état.
|
// Fire-and-forget : la boucle de jeu broadcast son propre état.
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@ import {
|
||||||
Brain,
|
Brain,
|
||||||
Headphones,
|
Headphones,
|
||||||
ListMusic,
|
ListMusic,
|
||||||
|
Minus,
|
||||||
Music,
|
Music,
|
||||||
Play,
|
Play,
|
||||||
|
Plus,
|
||||||
Shuffle,
|
Shuffle,
|
||||||
Trash2,
|
Trash2,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
|
|
@ -20,7 +22,7 @@ import { useRoomStore } from "@/store/room"
|
||||||
import { Avatar } from "@/components/avatar"
|
import { Avatar } from "@/components/avatar"
|
||||||
|
|
||||||
const QUESTION_OPTIONS = [3, 5, 10]
|
const QUESTION_OPTIONS = [3, 5, 10]
|
||||||
const TRACKS_OPTIONS = [1, 2, 3]
|
const MAX_TRACKS = 10
|
||||||
const GAME_TYPES: { value: GameType; label: string; Icon: LucideIcon }[] = [
|
const GAME_TYPES: { value: GameType; label: string; Icon: LucideIcon }[] = [
|
||||||
{ value: "mixed", label: "Mixte", Icon: Shuffle },
|
{ value: "mixed", label: "Mixte", Icon: Shuffle },
|
||||||
{ value: "quiz", label: "Quiz", Icon: Brain },
|
{ value: "quiz", label: "Quiz", Icon: Brain },
|
||||||
|
|
@ -35,6 +37,53 @@ const MODE_LABELS: Record<BlindtestMode, string> = {
|
||||||
const inputClass =
|
const inputClass =
|
||||||
"border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
|
"border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
|
||||||
|
|
||||||
|
function Stepper({
|
||||||
|
value,
|
||||||
|
min = 1,
|
||||||
|
max = MAX_TRACKS,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: number
|
||||||
|
min?: number
|
||||||
|
max?: number
|
||||||
|
onChange: (v: number) => void
|
||||||
|
}) {
|
||||||
|
const clamp = (v: number) => Math.max(min, Math.min(max, v))
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={value <= min}
|
||||||
|
onClick={() => onChange(clamp(value - 1))}
|
||||||
|
>
|
||||||
|
<Minus />
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const n = parseInt(e.target.value, 10)
|
||||||
|
if (!Number.isNaN(n)) {
|
||||||
|
onChange(clamp(n))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="border-input bg-background h-8 w-14 rounded-md border text-center text-sm [appearance:textfield] focus-visible:outline-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={value >= max}
|
||||||
|
onClick={() => onChange(clamp(value + 1))}
|
||||||
|
>
|
||||||
|
<Plus />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function shuffle<T>(items: T[]): T[] {
|
function shuffle<T>(items: T[]): T[] {
|
||||||
const arr = [...items]
|
const arr = [...items]
|
||||||
for (let i = arr.length - 1; i > 0; i--) {
|
for (let i = arr.length - 1; i > 0; i--) {
|
||||||
|
|
@ -90,7 +139,12 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
effectiveType === "blindtest" || effectiveType === "mixed"
|
effectiveType === "blindtest" || effectiveType === "mixed"
|
||||||
|
|
||||||
const rounds = buildRounds(effectiveType, showQuiz ? count : 0, totalTracks)
|
const rounds = buildRounds(effectiveType, showQuiz ? count : 0, totalTracks)
|
||||||
const canStart = rounds.length > 0
|
// Blindtest : tout le monde doit avoir soumis son quota de titres.
|
||||||
|
const allSubmitted =
|
||||||
|
!showBlindtest ||
|
||||||
|
(snapshot.submissions.length > 0 &&
|
||||||
|
snapshot.submissions.every((s) => s.count >= tracksPerPlayer))
|
||||||
|
const canStart = rounds.length > 0 && allSubmitted
|
||||||
|
|
||||||
async function start() {
|
async function start() {
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
@ -205,18 +259,10 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
<span className="flex items-center gap-1.5 text-sm font-medium">
|
<span className="flex items-center gap-1.5 text-sm font-medium">
|
||||||
<ListMusic className="size-4" /> Titres par joueur
|
<ListMusic className="size-4" /> Titres par joueur
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-1">
|
<Stepper
|
||||||
{TRACKS_OPTIONS.map((n) => (
|
value={tracksPerPlayer}
|
||||||
<Button
|
onChange={(v) => updateSettings({ tracksPerPlayer: v })}
|
||||||
key={n}
|
/>
|
||||||
size="sm"
|
|
||||||
variant={tracksPerPlayer === n ? "default" : "secondary"}
|
|
||||||
onClick={() => updateSettings({ tracksPerPlayer: n })}
|
|
||||||
>
|
|
||||||
{n}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -226,9 +272,17 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isHost ? (
|
{isHost ? (
|
||||||
<Button disabled={busy || !canStart} onClick={start}>
|
<div className="flex flex-col gap-1">
|
||||||
<Play /> {busy ? "Lancement…" : `Lancer (${rounds.length} manches)`}
|
<Button disabled={busy || !canStart} onClick={start}>
|
||||||
</Button>
|
<Play /> {busy ? "Lancement…" : `Lancer (${rounds.length} manches)`}
|
||||||
|
</Button>
|
||||||
|
{showBlindtest && !allSubmitted && (
|
||||||
|
<p className="text-muted-foreground text-center text-xs">
|
||||||
|
En attente que tout le monde ait soumis ses {tracksPerPlayer}{" "}
|
||||||
|
titre(s).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-muted-foreground text-center text-xs">
|
<p className="text-muted-foreground text-center text-xs">
|
||||||
En attente du lancement par l'hôte…
|
En attente du lancement par l'hôte…
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue