feature/bots #15
8 changed files with 83 additions and 16 deletions
13
apps/server/src/game/bot.ts
Normal file
13
apps/server/src/game/bot.ts
Normal file
|
|
@ -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<BotDifficulty, number> = {
|
||||||
|
easy: 0.3,
|
||||||
|
normal: 0.55,
|
||||||
|
hard: 0.85,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function botCorrectChance(difficulty: BotDifficulty): number {
|
||||||
|
return CHANCE[difficulty] ?? CHANCE.normal
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ import type {
|
||||||
} from "../../round"
|
} from "../../round"
|
||||||
import type { BlindtestTrack, ServerRoom } from "../../../rooms"
|
import type { BlindtestTrack, ServerRoom } from "../../../rooms"
|
||||||
import { fuzzyMatch } from "../../match"
|
import { fuzzyMatch } from "../../match"
|
||||||
|
import { botCorrectChance } from "../../bot"
|
||||||
import { takeTrack } from "./pool"
|
import { takeTrack } from "./pool"
|
||||||
|
|
||||||
const TITLE_POINTS = 60
|
const TITLE_POINTS = 60
|
||||||
|
|
@ -101,16 +102,19 @@ export class BlindtestRound implements GameRound {
|
||||||
botAnswer(ctx: RoundContext, playerId: string): Answer {
|
botAnswer(ctx: RoundContext, playerId: string): Answer {
|
||||||
const { track } = ctx.data as BlindtestRoundData
|
const { track } = ctx.data as BlindtestRoundData
|
||||||
const mode = ctx.room.settings.blindtestMode
|
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()]
|
const others = [...ctx.room.players.values()]
|
||||||
.filter((p) => p.id !== playerId)
|
.filter((p) => p.id !== playerId)
|
||||||
.map((p) => p.id)
|
.map((p) => p.id)
|
||||||
const guessedPlayerId =
|
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") {
|
if (mode === "who_added") {
|
||||||
return { guessedPlayerId }
|
return { guessedPlayerId }
|
||||||
}
|
}
|
||||||
const right = Math.random() < 0.4
|
const right = Math.random() < chance
|
||||||
const title = right ? track.title : "?"
|
const title = right ? track.title : "?"
|
||||||
const artist = right ? track.artist : "?"
|
const artist = right ? track.artist : "?"
|
||||||
if (mode === "title_artist") {
|
if (mode === "title_artist") {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import type {
|
||||||
ScoreDelta,
|
ScoreDelta,
|
||||||
} from "@nerdware/shared"
|
} from "@nerdware/shared"
|
||||||
import { hasDb } from "../../../db"
|
import { hasDb } from "../../../db"
|
||||||
|
import { botCorrectChance } from "../../bot"
|
||||||
import { markQuestionPlayed } from "../../../db/quiz-repo"
|
import { markQuestionPlayed } from "../../../db/quiz-repo"
|
||||||
import { fuzzyMatch } from "../../match"
|
import { fuzzyMatch } from "../../match"
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -149,8 +150,8 @@ export class QuizRound implements GameRound {
|
||||||
|
|
||||||
botAnswer(ctx: RoundContext): Answer {
|
botAnswer(ctx: RoundContext): Answer {
|
||||||
const { question } = ctx.data as QuizRoundData
|
const { question } = ctx.data as QuizRoundData
|
||||||
// ~50% du temps le bot répond juste, sinon une réponse plausible mais fausse.
|
// Le bot répond juste selon le niveau choisi, sinon une réponse plausible.
|
||||||
const rightish = Math.random() < 0.5
|
const rightish = Math.random() < botCorrectChance(ctx.room.settings.botDifficulty)
|
||||||
if (isTextFormat(question)) {
|
if (isTextFormat(question)) {
|
||||||
return {
|
return {
|
||||||
text: rightish ? (question.acceptedAnswers?.[0] ?? "?") : "euh…",
|
text: rightish ? (question.acceptedAnswers?.[0] ?? "?") : "euh…",
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,7 @@ export function registerRoomHandlers(
|
||||||
roundDuration: payload.roundDuration,
|
roundDuration: payload.roundDuration,
|
||||||
tracksPerPlayer: payload.tracksPerPlayer,
|
tracksPerPlayer: payload.tracksPerPlayer,
|
||||||
categories: payload.categories,
|
categories: payload.categories,
|
||||||
|
botDifficulty: payload.botDifficulty,
|
||||||
rounds: payload.rounds,
|
rounds: payload.rounds,
|
||||||
}
|
}
|
||||||
broadcastState(room)
|
broadcastState(room)
|
||||||
|
|
@ -194,15 +195,17 @@ export function registerRoomHandlers(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const hasBlindtest = room.settings.rounds.some((r) => r.type === "blindtest")
|
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 !== ""
|
(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({
|
ack({
|
||||||
ok: false,
|
ok: false,
|
||||||
error: {
|
error: {
|
||||||
code: "NEED_THREE",
|
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
|
return
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,12 @@ const MODE_LABELS: Record<BlindtestMode, string> = {
|
||||||
who_added: "Qui l'a ajouté ?",
|
who_added: "Qui l'a ajouté ?",
|
||||||
mixed: "Mixte",
|
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 =
|
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"
|
"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 removeBot = useRoomStore((s) => s.removeBot)
|
||||||
const isHost = snapshot.hostId === playerId
|
const isHost = snapshot.hostId === playerId
|
||||||
const botCount = snapshot.players.filter((p) => p.bot).length
|
const botCount = snapshot.players.filter((p) => p.bot).length
|
||||||
const { gameType, mixedModes, blindtestMode, tracksPerPlayer, categories } =
|
const {
|
||||||
snapshot.settings
|
gameType,
|
||||||
|
mixedModes,
|
||||||
|
blindtestMode,
|
||||||
|
tracksPerPlayer,
|
||||||
|
categories,
|
||||||
|
botDifficulty,
|
||||||
|
} = snapshot.settings
|
||||||
const allCategories =
|
const allCategories =
|
||||||
useQuery({ queryKey: ["categories"], queryFn: fetchCategories }).data ?? []
|
useQuery({ queryKey: ["categories"], queryFn: fetchCategories }).data ?? []
|
||||||
const history =
|
const history =
|
||||||
|
|
@ -159,9 +171,13 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
const myCount =
|
const myCount =
|
||||||
snapshot.submissions.find((s) => s.playerId === playerId)?.count ?? 0
|
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 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
|
// 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).
|
// (son sous-mode blindtest est juste désactivé tant qu'on n'est pas 3).
|
||||||
const effectiveType: GameType =
|
const effectiveType: GameType =
|
||||||
|
|
@ -297,7 +313,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
<button
|
<button
|
||||||
key={value}
|
key={value}
|
||||||
disabled={locked}
|
disabled={locked}
|
||||||
title={locked ? "3 joueurs minimum" : undefined}
|
title={locked ? "3 joueurs dont 2 réels" : undefined}
|
||||||
onClick={() => updateSettings({ gameType: value })}
|
onClick={() => updateSettings({ gameType: value })}
|
||||||
className={`flex flex-col items-center gap-1.5 rounded-lg border p-2.5 text-xs font-medium transition-colors disabled:opacity-40 ${
|
className={`flex flex-col items-center gap-1.5 rounded-lg border p-2.5 text-xs font-medium transition-colors disabled:opacity-40 ${
|
||||||
active
|
active
|
||||||
|
|
@ -313,7 +329,8 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
</div>
|
</div>
|
||||||
{!blindtestAvailable && (
|
{!blindtestAvailable && (
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
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).
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -330,7 +347,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
<button
|
<button
|
||||||
key={m}
|
key={m}
|
||||||
disabled={locked}
|
disabled={locked}
|
||||||
title={locked ? "3 joueurs minimum" : undefined}
|
title={locked ? "3 joueurs dont 2 réels" : undefined}
|
||||||
onClick={() => toggleMix(m)}
|
onClick={() => toggleMix(m)}
|
||||||
className={`flex-1 rounded-lg border px-2 py-1.5 text-xs font-medium transition-colors disabled:opacity-40 ${
|
className={`flex-1 rounded-lg border px-2 py-1.5 text-xs font-medium transition-colors disabled:opacity-40 ${
|
||||||
on
|
on
|
||||||
|
|
@ -410,6 +427,26 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Niveau des bots (si au moins un bot dans la partie) */}
|
||||||
|
{botCount > 0 && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-sm font-medium">🤖 Niveau des bots</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{BOT_DIFFICULTIES.map((d) => (
|
||||||
|
<Button
|
||||||
|
key={d}
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
variant={botDifficulty === d ? "default" : "secondary"}
|
||||||
|
onClick={() => updateSettings({ botDifficulty: d })}
|
||||||
|
>
|
||||||
|
{BOT_DIFF_LABELS[d]}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Réglages blindtest */}
|
{/* Réglages blindtest */}
|
||||||
{showBlindtest && (
|
{showBlindtest && (
|
||||||
<div className="flex flex-col gap-3 border-t pt-3">
|
<div className="flex flex-col gap-3 border-t pt-3">
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
|
||||||
roundDuration: partial.roundDuration ?? current.roundDuration,
|
roundDuration: partial.roundDuration ?? current.roundDuration,
|
||||||
tracksPerPlayer: partial.tracksPerPlayer ?? current.tracksPerPlayer,
|
tracksPerPlayer: partial.tracksPerPlayer ?? current.tracksPerPlayer,
|
||||||
categories: partial.categories ?? current.categories,
|
categories: partial.categories ?? current.categories,
|
||||||
|
botDifficulty: partial.botDifficulty ?? current.botDifficulty,
|
||||||
rounds: partial.rounds ?? current.rounds,
|
rounds: partial.rounds ?? current.rounds,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@ export type MixMode = "quiz" | "image" | "blindtest"
|
||||||
/** Modes de blindtest, déterminent la forme du vote et le barème. */
|
/** Modes de blindtest, déterminent la forme du vote et le barème. */
|
||||||
export type BlindtestMode = "title_artist" | "who_added" | "mixed"
|
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. */
|
/** Formats de question quiz. */
|
||||||
export type QuizFormat = "mcq" | "truefalse" | "free" | "image_reveal"
|
export type QuizFormat = "mcq" | "truefalse" | "free" | "image_reveal"
|
||||||
|
|
||||||
|
|
@ -48,6 +51,8 @@ export interface RoomSettings {
|
||||||
tracksPerPlayer: number
|
tracksPerPlayer: number
|
||||||
/** Catégories de quiz autorisées (vide = toutes). */
|
/** Catégories de quiz autorisées (vide = toutes). */
|
||||||
categories: string[]
|
categories: string[]
|
||||||
|
/** Niveau des bots ajoutés à la partie. */
|
||||||
|
botDifficulty: BotDifficulty
|
||||||
/** Séquence d'épreuves de la partie. */
|
/** Séquence d'épreuves de la partie. */
|
||||||
rounds: RoundConfig[]
|
rounds: RoundConfig[]
|
||||||
}
|
}
|
||||||
|
|
@ -60,6 +65,7 @@ export const DEFAULT_ROOM_SETTINGS: RoomSettings = {
|
||||||
roundDuration: 60,
|
roundDuration: 60,
|
||||||
tracksPerPlayer: 2,
|
tracksPerPlayer: 2,
|
||||||
categories: [],
|
categories: [],
|
||||||
|
botDifficulty: "normal",
|
||||||
rounds: [],
|
rounds: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
import type {
|
import type {
|
||||||
Answer,
|
Answer,
|
||||||
BlindtestMode,
|
BlindtestMode,
|
||||||
|
BotDifficulty,
|
||||||
GameType,
|
GameType,
|
||||||
MixMode,
|
MixMode,
|
||||||
PlayerScore,
|
PlayerScore,
|
||||||
|
|
@ -49,6 +50,7 @@ export interface UpdateSettingsPayload {
|
||||||
roundDuration: number
|
roundDuration: number
|
||||||
tracksPerPlayer: number
|
tracksPerPlayer: number
|
||||||
categories: string[]
|
categories: string[]
|
||||||
|
botDifficulty: BotDifficulty
|
||||||
rounds: RoundConfig[]
|
rounds: RoundConfig[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue