feature/bots #15

Merged
ayoub merged 3 commits from feature/bots into dev 2026-06-11 16:22:00 +00:00
11 changed files with 196 additions and 7 deletions
Showing only changes of commit 8eac8d0123 - Show all commits

View file

@ -47,6 +47,7 @@ const dummyRound: GameRound = {
return deltas
},
recap: () => ({ label: "2+2 ?", answer: "4", answers: [] }),
botAnswer: () => ({ choiceIndex: 0 }),
}
function setup(roundDurationSec: number): {

View file

@ -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<void>((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) {

View file

@ -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

View file

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

View file

@ -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
}

View file

@ -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) {

View file

@ -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,
}
}

View file

@ -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 }) {
)}
</span>
<span className="text-muted-foreground text-xs">
{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"}
</span>
</li>
))}
</ul>
{isHost && (
<div className="flex items-center justify-between gap-2 pt-1">
<span className="text-muted-foreground text-xs">
Bots : {botCount}
</span>
<div className="flex gap-2">
<Button
size="sm"
variant="secondary"
disabled={botCount === 0}
onClick={removeBot}
>
<Minus /> Bot
</Button>
<Button
size="sm"
variant="secondary"
disabled={snapshot.players.length >= 8}
onClick={addBot}
>
<Plus /> Bot
</Button>
</div>
</div>
)}
</section>
<div className="flex flex-col gap-6">

View file

@ -78,6 +78,8 @@ interface RoomState {
removeTrack: (trackId: string) => Promise<boolean>
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<RoomState>((set, get) => ({
get().reset()
},
addBot: () => socket.emit("lobby:addBot"),
removeBot: () => socket.emit("lobby:removeBot"),
vote: (choiceIndex) => {
if (get().hasVoted) {
return

View file

@ -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. */

View file

@ -150,6 +150,8 @@ export interface ClientToServerEvents {
"room:rejoin": (payload: RejoinPayload, ack: Ack<RoomCreatedPayload>) => 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<null>) => void