From 8eac8d012315debf797e5581fda4e949e2232490 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Thu, 11 Jun 2026 17:48:33 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20bots=20=E2=80=94=20virtual=20player?= =?UTF-8?q?s=20to=20fill=20a=20game?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - server-side bot players (isBot): host adds/removes them in the lobby (lobby:addBot / lobby:removeBot), capped at 8 players - bots auto-vote each round: GameRound.botAnswer generates a plausible answer (quiz ~50% right, blindtest guesses a random player / title); engine schedules the votes within the answer window and triggers early end when all have voted - bots are never DJ (pickDj excludes them) and are excluded from the blindtest track-submission gate (they don't submit) - snapshot players carry `bot`; lobby shows a 🤖 marker + add/remove controls Verified e2e: 2 bots added, they vote, game ends with all 3 players scored and rounds end early. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/src/game/engine.test.ts | 1 + apps/server/src/game/engine.ts | 40 ++++++++++++++++ .../game/modes/blindtest/blindtest-round.ts | 25 +++++++++- apps/server/src/game/modes/quiz/quiz-round.ts | 16 +++++++ apps/server/src/game/round.ts | 3 ++ apps/server/src/handlers.ts | 22 +++++++++ apps/server/src/rooms.ts | 47 +++++++++++++++++++ apps/web/src/components/lobby-view.tsx | 40 ++++++++++++++-- apps/web/src/store/room.ts | 5 ++ packages/shared/src/domain.ts | 2 + packages/shared/src/events.ts | 2 + 11 files changed, 196 insertions(+), 7 deletions(-) diff --git a/apps/server/src/game/engine.test.ts b/apps/server/src/game/engine.test.ts index 991b29d..dfebac3 100644 --- a/apps/server/src/game/engine.test.ts +++ b/apps/server/src/game/engine.test.ts @@ -47,6 +47,7 @@ const dummyRound: GameRound = { return deltas }, recap: () => ({ label: "2+2 ?", answer: "4", answers: [] }), + botAnswer: () => ({ choiceIndex: 0 }), } function setup(roundDurationSec: number): { diff --git a/apps/server/src/game/engine.ts b/apps/server/src/game/engine.ts index 66137f8..3e6aaf2 100644 --- a/apps/server/src/game/engine.ts +++ b/apps/server/src/game/engine.ts @@ -195,6 +195,8 @@ export class GameEngine implements RoomGameController { this.io.to(this.room.code).emit("round:start", base) } + this.scheduleBotVotes() + // Attend la première condition de fin : timer écoulé OU tous votés. await new Promise((resolve) => { this.resolveRoundEnd = resolve @@ -222,6 +224,44 @@ export class GameEngine implements RoomGameController { this.runtime = null } + /** Programme le vote automatique des bots (jamais DJ ni contributeur). */ + private scheduleBotVotes(): void { + if (!this.runtime) { + return + } + const { djId, secretPlayerId, endsAt, startedAt } = this.runtime + const window = Math.max(0, endsAt - startedAt) + const bots = [...this.room.players.values()].filter( + (p) => p.isBot && p.id !== djId && p.id !== secretPlayerId + ) + for (const bot of bots) { + // Vote après l'ouverture des réponses, réparti dans la première moitié. + const delay = this.leadMs + 700 + Math.random() * Math.min(window * 0.5, 3500) + setTimeout(() => this.botVote(bot.id), delay) + } + } + + /** Exécute le vote d'un bot (no-op si la manche est finie ou s'il a déjà voté). */ + private botVote(botId: string): void { + if (!this.runtime || this.room.status !== "in_round") { + return + } + if (this.runtime.votes.has(botId)) { + return + } + const ctx = this.context(this.runtime) + this.runtime.round.submitAnswer(ctx, botId, this.runtime.round.botAnswer(ctx, botId)) + const total = this.eligibleVoters().length + this.io.to(this.room.code).emit("round:voteAck", { + count: this.runtime.votes.size, + total, + voted: [...this.runtime.votes.keys()], + }) + if (total > 0 && this.runtime.votes.size >= total) { + this.endRound() + } + } + /** Termine la manche en cours (idempotent : timer et "tous votés" peuvent se croiser). */ private endRound(): void { if (this.timer) { diff --git a/apps/server/src/game/modes/blindtest/blindtest-round.ts b/apps/server/src/game/modes/blindtest/blindtest-round.ts index c78ae68..db3541e 100644 --- a/apps/server/src/game/modes/blindtest/blindtest-round.ts +++ b/apps/server/src/game/modes/blindtest/blindtest-round.ts @@ -31,10 +31,10 @@ interface BlindtestRoundData { track: BlindtestTrack } -/** Tire un DJ neutre : un joueur connecté qui n'a pas soumis ce titre. */ +/** Tire un DJ neutre : un humain connecté qui n'a pas soumis ce titre. */ function pickDj(room: ServerRoom, submittedBy: string): string | null { const eligible = [...room.players.values()].filter( - (p) => p.connected && p.id !== submittedBy + (p) => p.connected && !p.isBot && p.id !== submittedBy ) if (eligible.length === 0) { return null @@ -98,6 +98,27 @@ export class BlindtestRound implements GameRound { ctx.votes.set(playerId, answer) } + 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 others = [...ctx.room.players.values()] + .filter((p) => p.id !== playerId) + .map((p) => p.id) + const guessedPlayerId = + others[Math.floor(Math.random() * others.length)] ?? playerId + if (mode === "who_added") { + return { guessedPlayerId } + } + const right = Math.random() < 0.4 + const title = right ? track.title : "?" + const artist = right ? track.artist : "?" + if (mode === "title_artist") { + return { title, artist } + } + return { title, artist, guessedPlayerId } + } + /** Résultat par joueur (votants + bonus de tromperie du contributeur). */ private buildResults(ctx: RoundContext): BlindtestPerPlayerResult { const { track } = ctx.data as BlindtestRoundData diff --git a/apps/server/src/game/modes/quiz/quiz-round.ts b/apps/server/src/game/modes/quiz/quiz-round.ts index 54250a1..73d3825 100644 --- a/apps/server/src/game/modes/quiz/quiz-round.ts +++ b/apps/server/src/game/modes/quiz/quiz-round.ts @@ -147,6 +147,22 @@ export class QuizRound implements GameRound { return deltas } + 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 + if (isTextFormat(question)) { + return { + text: rightish ? (question.acceptedAnswers?.[0] ?? "?") : "euh…", + } + } + const count = question.choices?.length ?? 1 + if (rightish && typeof question.correctIndex === "number") { + return { choiceIndex: question.correctIndex } + } + return { choiceIndex: Math.floor(Math.random() * count) } + } + recap(ctx: RoundContext): RoundRecapInfo { const { question } = ctx.data as QuizRoundData const answer = isTextFormat(question) diff --git a/apps/server/src/game/round.ts b/apps/server/src/game/round.ts index e9a59a6..ce5e04f 100644 --- a/apps/server/src/game/round.ts +++ b/apps/server/src/game/round.ts @@ -63,6 +63,9 @@ export interface GameRound { /** Renvoie les variations de score à appliquer (le moteur les applique). */ score(ctx: RoundContext): ScoreDelta[] + /** Réponse automatique d'un bot (vote plausible, parfois juste). */ + botAnswer(ctx: RoundContext, playerId: string): Answer + /** Résumé de la manche pour le récap de fin de partie (intitulé + réponse + média). */ recap(ctx: RoundContext): RoundRecapInfo } diff --git a/apps/server/src/handlers.ts b/apps/server/src/handlers.ts index 8ab1faf..0292d8d 100644 --- a/apps/server/src/handlers.ts +++ b/apps/server/src/handlers.ts @@ -149,6 +149,27 @@ export function registerRoomHandlers( broadcastState(room) }) + socket.on("lobby:addBot", () => { + const code = socket.data.roomCode + const room = code ? rooms.get(code) : undefined + if (!room || room.hostId !== socket.data.playerId || room.status !== "lobby") { + return + } + if (rooms.addBot(room)) { + broadcastState(room) + } + }) + + socket.on("lobby:removeBot", () => { + const code = socket.data.roomCode + const room = code ? rooms.get(code) : undefined + if (!room || room.hostId !== socket.data.playerId) { + return + } + rooms.removeBot(room) + broadcastState(room) + }) + socket.on("game:start", (ack) => { const code = socket.data.roomCode const room = code ? rooms.get(code) : undefined @@ -192,6 +213,7 @@ export function registerRoomHandlers( (p) => p.connected && p.name !== "" && + !p.isBot && room.blindtestTracks.filter((t) => t.submittedBy === p.id).length < tpp ) if (pending) { diff --git a/apps/server/src/rooms.ts b/apps/server/src/rooms.ts index d32dd67..2c8984a 100644 --- a/apps/server/src/rooms.ts +++ b/apps/server/src/rooms.ts @@ -18,8 +18,22 @@ export interface ServerPlayer { socketId: string | null /** Secret de reconnexion (refresh/coupure). */ reconnectToken: string + /** Joueur virtuel (vote automatiquement, jamais DJ). */ + isBot: boolean } +const BOT_NAMES = [ + "Bot BMO", + "Bot R2-D2", + "Bot GLaDOS", + "Bot Clank", + "Bot Wall-E", + "Bot K-2SO", + "Bot HAL", + "Bot Bender", +] +const MAX_PLAYERS = 8 + /** Titre soumis pour le blindtest. `submittedBy` reste secret jusqu'au reveal. */ export interface BlindtestTrack { id: string @@ -94,6 +108,37 @@ export class RoomManager { return this.rooms.get(code) } + /** Ajoute un bot (joueur virtuel) à la room. Renvoie false si plein. */ + addBot(room: ServerRoom): boolean { + if (room.players.size >= MAX_PLAYERS) { + return false + } + const used = new Set([...room.players.values()].map((p) => p.name)) + const name = + BOT_NAMES.find((n) => !used.has(n)) ?? `Bot ${room.players.size + 1}` + const bot: ServerPlayer = { + id: randomUUID(), + name, + connected: true, + socketId: null, + reconnectToken: "", + isBot: true, + } + room.players.set(bot.id, bot) + room.scores.set(bot.id, 0) + return true + } + + /** Retire un bot (le dernier ajouté). */ + removeBot(room: ServerRoom): void { + const bots = [...room.players.values()].filter((p) => p.isBot) + const last = bots[bots.length - 1] + if (last) { + room.players.delete(last.id) + room.scores.delete(last.id) + } + } + /** * Retire un joueur d'une room (départ volontaire). Réassigne l'hôte si besoin, * supprime la room si plus personne. Renvoie la room (ou null si supprimée). @@ -146,6 +191,7 @@ export class RoomManager { id: p.id, name: p.name, connected: p.connected, + bot: p.isBot, })), settings: room.settings, scores: named.map((p) => ({ @@ -168,6 +214,7 @@ export class RoomManager { connected: true, socketId, reconnectToken: randomUUID(), + isBot: false, } } diff --git a/apps/web/src/components/lobby-view.tsx b/apps/web/src/components/lobby-view.tsx index ac9fd09..c236db7 100644 --- a/apps/web/src/components/lobby-view.tsx +++ b/apps/web/src/components/lobby-view.tsx @@ -136,7 +136,10 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { const playerId = useRoomStore((s) => s.playerId) const updateSettings = useRoomStore((s) => s.updateSettings) const startGame = useRoomStore((s) => s.startGame) + const addBot = useRoomStore((s) => s.addBot) + 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 allCategories = @@ -180,11 +183,13 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { tracks: showBlindtest ? totalTracks : 0, shuffleOrder: isMixed, }) - // Blindtest : tout le monde doit avoir soumis son quota de titres. + // Blindtest : tous les HUMAINS doivent avoir soumis leur quota (les bots non). + const botIds = new Set(snapshot.players.filter((p) => p.bot).map((p) => p.id)) + const humanSubs = snapshot.submissions.filter((s) => !botIds.has(s.playerId)) const allSubmitted = !showBlindtest || - (snapshot.submissions.length > 0 && - snapshot.submissions.every((s) => s.count >= tracksPerPlayer)) + (humanSubs.length > 0 && + humanSubs.every((s) => s.count >= tracksPerPlayer)) const canStart = rounds.length > 0 && allSubmitted function toggleMix(mode: MixMode) { @@ -241,12 +246,37 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { )} - {p.id === snapshot.hostId ? "Hôte" : ""} - {!p.connected && " · hors ligne"} + {p.bot ? "🤖 Bot" : p.id === snapshot.hostId ? "Hôte" : ""} + {!p.bot && !p.connected && " · hors ligne"} ))} + {isHost && ( +
+ + Bots : {botCount} + +
+ + +
+
+ )}
diff --git a/apps/web/src/store/room.ts b/apps/web/src/store/room.ts index 5b72134..b4b1b8a 100644 --- a/apps/web/src/store/room.ts +++ b/apps/web/src/store/room.ts @@ -78,6 +78,8 @@ interface RoomState { removeTrack: (trackId: string) => Promise returnToLobby: () => void leaveRoom: () => void + addBot: () => void + removeBot: () => void vote: (choiceIndex: number) => void voteText: (text: string) => void voteBlindtest: (answer: BlindtestAnswer) => void @@ -198,6 +200,9 @@ export const useRoomStore = create((set, get) => ({ get().reset() }, + addBot: () => socket.emit("lobby:addBot"), + removeBot: () => socket.emit("lobby:removeBot"), + vote: (choiceIndex) => { if (get().hasVoted) { return diff --git a/packages/shared/src/domain.ts b/packages/shared/src/domain.ts index 8c98ea2..98508d9 100644 --- a/packages/shared/src/domain.ts +++ b/packages/shared/src/domain.ts @@ -24,6 +24,8 @@ export interface Player { id: string name: string connected: boolean + /** Joueur virtuel (bot) géré par le serveur. */ + bot?: boolean } /** Configuration d'une épreuve planifiée dans la séquence de la partie. */ diff --git a/packages/shared/src/events.ts b/packages/shared/src/events.ts index f0dfe38..8f71883 100644 --- a/packages/shared/src/events.ts +++ b/packages/shared/src/events.ts @@ -150,6 +150,8 @@ export interface ClientToServerEvents { "room:rejoin": (payload: RejoinPayload, ack: Ack) => void "player:setName": (payload: SetNamePayload) => void "room:leave": () => void + "lobby:addBot": () => void + "lobby:removeBot": () => void "lobby:updateSettings": (payload: UpdateSettingsPayload) => void "lobby:return": () => void "game:start": (ack: Ack) => void -- 2.45.3 From 42a36fc442fd5e62febe30a9a7b5f289566ebcb1 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Thu, 11 Jun 2026 18:03:03 +0200 Subject: [PATCH 2/3] 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 }) {
{!blindtestAvailable && (

- Le blindtest se débloque à 3 joueurs. + Le blindtest se débloque à 3 joueurs dont 2 réels (un bot ne peut + pas être DJ).

)} @@ -330,7 +347,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[] } -- 2.45.3 From 47e5c2854ec5f95722a18aef09333dffb17aba62 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Thu, 11 Jun 2026 18:17:47 +0200 Subject: [PATCH 3/3] fix(web): center end-screen recap + shadcn tooltips - results page: when there are no tracks (quiz-only game), the recap was sitting in the left column of a 2-col grid; now a single section is centered (2 columns only when both tracks and recap exist) - add a shadcn Tooltip component (@workspace/ui, via radix-ui) and replace native browser title= tooltips: connection dot, back-office active toggle, and the locked blindtest tiles (now aria-disabled so the tooltip still triggers) Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/components/game-end-view.tsx | 24 +++++---- apps/web/src/components/lobby-view.tsx | 47 ++++++++++++++---- apps/web/src/pages/admin.tsx | 21 +++++--- apps/web/src/pages/room.tsx | 19 ++++++-- packages/ui/src/components/tooltip.tsx | 59 +++++++++++++++++++++++ 5 files changed, 139 insertions(+), 31 deletions(-) create mode 100644 packages/ui/src/components/tooltip.tsx diff --git a/apps/web/src/components/game-end-view.tsx b/apps/web/src/components/game-end-view.tsx index ce64d4d..cb18a90 100644 --- a/apps/web/src/components/game-end-view.tsx +++ b/apps/web/src/components/game-end-view.tsx @@ -178,6 +178,8 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) { const [first, second, third] = ranked const rest = ranked.slice(3) + const hasTracks = !!gameTracks && gameTracks.length > 0 + const hasRecap = !!gameRecap && gameRecap.length > 0 return (
@@ -256,15 +258,19 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) { )}
- {/* Détails (musiques + récap) côte à côte sur desktop. */} - {((gameTracks && gameTracks.length > 0) || - (gameRecap && gameRecap.length > 0)) && ( -
- {gameTracks && gameTracks.length > 0 && ( - - )} - {gameRecap && gameRecap.length > 0 && ( - + {/* Détails : musiques + récap côte à côte sur desktop s'il y a les deux, + sinon une seule colonne centrée (sinon le récap se calait à gauche). */} + {(hasTracks || hasRecap) && ( +
+ {hasTracks && } + {hasRecap && ( + )}
)} diff --git a/apps/web/src/components/lobby-view.tsx b/apps/web/src/components/lobby-view.tsx index 9b42c82..c04740f 100644 --- a/apps/web/src/components/lobby-view.tsx +++ b/apps/web/src/components/lobby-view.tsx @@ -31,9 +31,16 @@ const MIX_LABELS: Record = { blindtest: "Blindtest", } import { Button } from "@workspace/ui/components/button" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@workspace/ui/components/tooltip" import { useRoomStore } from "@/store/room" import { Avatar } from "@/components/avatar" +const BLINDTEST_LOCK_HINT = "3 joueurs dont 2 réels (un bot ne peut pas être DJ)" + const MAX_TRACKS = 10 const GAME_TYPES: { value: GameType @@ -309,13 +316,15 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { {GAME_TYPES.map(({ value, label, Icon, needsThree }) => { const locked = needsThree && !blindtestAvailable const active = effectiveType === value - return ( + const tile = ( ) + return locked ? ( + + {tile} + {BLINDTEST_LOCK_HINT} + + ) : ( + tile + ) })}
{!blindtestAvailable && ( @@ -343,13 +360,15 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { {(["quiz", "image", "blindtest"] as const).map((m) => { const locked = m === "blindtest" && !blindtestAvailable const on = mixedModes.includes(m) && !locked - return ( + const tile = ( ) + return locked ? ( + + {tile} + {BLINDTEST_LOCK_HINT} + + ) : ( + tile + ) })}
diff --git a/apps/web/src/pages/admin.tsx b/apps/web/src/pages/admin.tsx index 6e19c64..8550948 100644 --- a/apps/web/src/pages/admin.tsx +++ b/apps/web/src/pages/admin.tsx @@ -4,6 +4,11 @@ import { Link } from "wouter" import { Eye, EyeOff, Trash2, Upload } from "lucide-react" import type { QuizFormat } from "@nerdware/shared" import { Button } from "@workspace/ui/components/button" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@workspace/ui/components/tooltip" import { adminApi, assetUrl, @@ -187,14 +192,14 @@ function QuestionRow({

)} - + + + + + {q.active ? "Désactiver" : "Activer"} +