From 42a36fc442fd5e62febe30a9a7b5f289566ebcb1 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Thu, 11 Jun 2026 18:03:03 +0200 Subject: [PATCH] feat: blindtest needs 2 real players + bot difficulty setting - blindtest is only available with >=3 players AND >=2 humans (a bot can't be DJ): lobby gate + tooltips updated, and server game:start rejects otherwise - new botDifficulty setting (easy/normal/hard) drives how often bots answer correctly (quiz + blindtest who-added / title-artist); host picks it in the lobby when at least one bot is present Verified e2e: blindtest start rejected with 1 human + 2 bots (NEED_THREE). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/src/game/bot.ts | 13 +++++ .../game/modes/blindtest/blindtest-round.ts | 10 ++-- apps/server/src/game/modes/quiz/quiz-round.ts | 5 +- apps/server/src/handlers.ts | 11 ++-- apps/web/src/components/lobby-view.tsx | 51 ++++++++++++++++--- apps/web/src/store/room.ts | 1 + packages/shared/src/domain.ts | 6 +++ packages/shared/src/events.ts | 2 + 8 files changed, 83 insertions(+), 16 deletions(-) create mode 100644 apps/server/src/game/bot.ts diff --git a/apps/server/src/game/bot.ts b/apps/server/src/game/bot.ts new file mode 100644 index 0000000..fc15429 --- /dev/null +++ b/apps/server/src/game/bot.ts @@ -0,0 +1,13 @@ +// Réglage de difficulté des bots : probabilité de donner la bonne réponse. + +import type { BotDifficulty } from "@nerdware/shared" + +const CHANCE: Record = { + easy: 0.3, + normal: 0.55, + hard: 0.85, +} + +export function botCorrectChance(difficulty: BotDifficulty): number { + return CHANCE[difficulty] ?? CHANCE.normal +} diff --git a/apps/server/src/game/modes/blindtest/blindtest-round.ts b/apps/server/src/game/modes/blindtest/blindtest-round.ts index db3541e..53f86c9 100644 --- a/apps/server/src/game/modes/blindtest/blindtest-round.ts +++ b/apps/server/src/game/modes/blindtest/blindtest-round.ts @@ -19,6 +19,7 @@ import type { } from "../../round" import type { BlindtestTrack, ServerRoom } from "../../../rooms" import { fuzzyMatch } from "../../match" +import { botCorrectChance } from "../../bot" import { takeTrack } from "./pool" const TITLE_POINTS = 60 @@ -101,16 +102,19 @@ export class BlindtestRound implements GameRound { botAnswer(ctx: RoundContext, playerId: string): Answer { const { track } = ctx.data as BlindtestRoundData const mode = ctx.room.settings.blindtestMode - // Devine un joueur au hasard (parfois le bon contributeur). + const chance = botCorrectChance(ctx.room.settings.botDifficulty) + // Selon le niveau : devine le bon contributeur, sinon un joueur au hasard. const others = [...ctx.room.players.values()] .filter((p) => p.id !== playerId) .map((p) => p.id) const guessedPlayerId = - others[Math.floor(Math.random() * others.length)] ?? playerId + Math.random() < chance + ? track.submittedBy + : (others[Math.floor(Math.random() * others.length)] ?? playerId) if (mode === "who_added") { return { guessedPlayerId } } - const right = Math.random() < 0.4 + const right = Math.random() < chance const title = right ? track.title : "?" const artist = right ? track.artist : "?" if (mode === "title_artist") { diff --git a/apps/server/src/game/modes/quiz/quiz-round.ts b/apps/server/src/game/modes/quiz/quiz-round.ts index 73d3825..4b07bd2 100644 --- a/apps/server/src/game/modes/quiz/quiz-round.ts +++ b/apps/server/src/game/modes/quiz/quiz-round.ts @@ -9,6 +9,7 @@ import type { ScoreDelta, } from "@nerdware/shared" import { hasDb } from "../../../db" +import { botCorrectChance } from "../../bot" import { markQuestionPlayed } from "../../../db/quiz-repo" import { fuzzyMatch } from "../../match" import type { @@ -149,8 +150,8 @@ export class QuizRound implements GameRound { botAnswer(ctx: RoundContext): Answer { const { question } = ctx.data as QuizRoundData - // ~50% du temps le bot répond juste, sinon une réponse plausible mais fausse. - const rightish = Math.random() < 0.5 + // Le bot répond juste selon le niveau choisi, sinon une réponse plausible. + const rightish = Math.random() < botCorrectChance(ctx.room.settings.botDifficulty) if (isTextFormat(question)) { return { text: rightish ? (question.acceptedAnswers?.[0] ?? "?") : "euh…", diff --git a/apps/server/src/handlers.ts b/apps/server/src/handlers.ts index 0292d8d..da2fe57 100644 --- a/apps/server/src/handlers.ts +++ b/apps/server/src/handlers.ts @@ -144,6 +144,7 @@ export function registerRoomHandlers( roundDuration: payload.roundDuration, tracksPerPlayer: payload.tracksPerPlayer, categories: payload.categories, + botDifficulty: payload.botDifficulty, rounds: payload.rounds, } broadcastState(room) @@ -194,15 +195,17 @@ export function registerRoomHandlers( return } const hasBlindtest = room.settings.rounds.some((r) => r.type === "blindtest") - const connected = [...room.players.values()].filter( + const active = [...room.players.values()].filter( (p) => p.connected && p.name !== "" - ).length - if (hasBlindtest && connected < 3) { + ) + const connected = active.length + const humans = active.filter((p) => !p.isBot).length + if (hasBlindtest && (connected < 3 || humans < 2)) { ack({ ok: false, error: { code: "NEED_THREE", - message: "Le blindtest nécessite au moins 3 joueurs.", + message: "Le blindtest nécessite au moins 3 joueurs dont 2 réels.", }, }) return diff --git a/apps/web/src/components/lobby-view.tsx b/apps/web/src/components/lobby-view.tsx index c236db7..9b42c82 100644 --- a/apps/web/src/components/lobby-view.tsx +++ b/apps/web/src/components/lobby-view.tsx @@ -51,6 +51,12 @@ const MODE_LABELS: Record = { who_added: "Qui l'a ajouté ?", mixed: "Mixte", } +const BOT_DIFFICULTIES = ["easy", "normal", "hard"] as const +const BOT_DIFF_LABELS: Record<(typeof BOT_DIFFICULTIES)[number], string> = { + easy: "Facile", + normal: "Normal", + hard: "Difficile", +} 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" @@ -140,8 +146,14 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { const removeBot = useRoomStore((s) => s.removeBot) const isHost = snapshot.hostId === playerId const botCount = snapshot.players.filter((p) => p.bot).length - const { gameType, mixedModes, blindtestMode, tracksPerPlayer, categories } = - snapshot.settings + const { + gameType, + mixedModes, + blindtestMode, + tracksPerPlayer, + categories, + botDifficulty, + } = snapshot.settings const allCategories = useQuery({ queryKey: ["categories"], queryFn: fetchCategories }).data ?? [] const history = @@ -159,9 +171,13 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { const myCount = snapshot.submissions.find((s) => s.playerId === playerId)?.count ?? 0 - // Le blindtest exige au moins 3 joueurs connectés (DJ + 2 devineurs). + // Le blindtest exige 3 joueurs (DJ + 2 devineurs) DONT au moins 2 réels : + // un bot ne peut pas être DJ, donc il faut un humain DJ + un humain contributeur. const connectedCount = snapshot.players.filter((p) => p.connected).length - const blindtestAvailable = connectedCount >= 3 + const humanCount = snapshot.players.filter( + (p) => p.connected && !p.bot + ).length + const blindtestAvailable = connectedCount >= 3 && humanCount >= 2 // Seul le blindtest "seul" retombe sur du quiz à <3 ; le mixte reste mixte // (son sous-mode blindtest est juste désactivé tant qu'on n'est pas 3). const effectiveType: GameType = @@ -297,7 +313,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { + ))} + + + )} + {/* Réglages blindtest */} {showBlindtest && (
diff --git a/apps/web/src/store/room.ts b/apps/web/src/store/room.ts index b4b1b8a..241afc0 100644 --- a/apps/web/src/store/room.ts +++ b/apps/web/src/store/room.ts @@ -164,6 +164,7 @@ export const useRoomStore = create((set, get) => ({ 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, }) }, diff --git a/packages/shared/src/domain.ts b/packages/shared/src/domain.ts index 98508d9..6f85692 100644 --- a/packages/shared/src/domain.ts +++ b/packages/shared/src/domain.ts @@ -16,6 +16,9 @@ export type MixMode = "quiz" | "image" | "blindtest" /** Modes de blindtest, déterminent la forme du vote et le barème. */ export type BlindtestMode = "title_artist" | "who_added" | "mixed" +/** Niveau des bots : pilote leur probabilité de répondre juste. */ +export type BotDifficulty = "easy" | "normal" | "hard" + /** Formats de question quiz. */ export type QuizFormat = "mcq" | "truefalse" | "free" | "image_reveal" @@ -48,6 +51,8 @@ export interface RoomSettings { tracksPerPlayer: number /** Catégories de quiz autorisées (vide = toutes). */ categories: string[] + /** Niveau des bots ajoutés à la partie. */ + botDifficulty: BotDifficulty /** Séquence d'épreuves de la partie. */ rounds: RoundConfig[] } @@ -60,6 +65,7 @@ export const DEFAULT_ROOM_SETTINGS: RoomSettings = { roundDuration: 60, tracksPerPlayer: 2, categories: [], + botDifficulty: "normal", rounds: [], } diff --git a/packages/shared/src/events.ts b/packages/shared/src/events.ts index 8f71883..fb5090b 100644 --- a/packages/shared/src/events.ts +++ b/packages/shared/src/events.ts @@ -4,6 +4,7 @@ import type { Answer, BlindtestMode, + BotDifficulty, GameType, MixMode, PlayerScore, @@ -49,6 +50,7 @@ export interface UpdateSettingsPayload { roundDuration: number tracksPerPlayer: number categories: string[] + botDifficulty: BotDifficulty rounds: RoundConfig[] }