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/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..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 @@ -31,10 +32,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 +99,30 @@ 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 + 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 = + Math.random() < chance + ? track.submittedBy + : (others[Math.floor(Math.random() * others.length)] ?? playerId) + if (mode === "who_added") { + return { guessedPlayerId } + } + const right = Math.random() < chance + 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..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 { @@ -147,6 +148,22 @@ export class QuizRound implements GameRound { return deltas } + botAnswer(ctx: RoundContext): Answer { + const { question } = ctx.data as QuizRoundData + // 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…", + } + } + 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..da2fe57 100644 --- a/apps/server/src/handlers.ts +++ b/apps/server/src/handlers.ts @@ -144,11 +144,33 @@ export function registerRoomHandlers( roundDuration: payload.roundDuration, tracksPerPlayer: payload.tracksPerPlayer, categories: payload.categories, + botDifficulty: payload.botDifficulty, rounds: payload.rounds, } 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 @@ -173,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 @@ -192,6 +216,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/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 ac9fd09..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 @@ -51,6 +58,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" @@ -136,9 +149,18 @@ 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 { gameType, mixedModes, blindtestMode, tracksPerPlayer, categories } = - snapshot.settings + const botCount = snapshot.players.filter((p) => p.bot).length + const { + gameType, + mixedModes, + blindtestMode, + tracksPerPlayer, + categories, + botDifficulty, + } = snapshot.settings const allCategories = useQuery({ queryKey: ["categories"], queryFn: fetchCategories }).data ?? [] const history = @@ -156,9 +178,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 = @@ -180,11 +206,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 +269,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} + +
+ + +
+
+ )}
@@ -263,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 && (

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

)}
@@ -296,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 + ) })} @@ -380,6 +454,26 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { )} + {/* Niveau des bots (si au moins un bot dans la partie) */} + {botCount > 0 && ( +
+ 🤖 Niveau des bots +
+ {BOT_DIFFICULTIES.map((d) => ( + + ))} +
+
+ )} + {/* Réglages blindtest */} {showBlindtest && (
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"} +