From 8eac8d012315debf797e5581fda4e949e2232490 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Thu, 11 Jun 2026 17:48:33 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20bots=20=E2=80=94=20virtual=20players=20?= =?UTF-8?q?to=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