release/0.1.0 #18

Merged
ayoub merged 68 commits from release/0.1.0 into main 2026-06-11 18:36:42 +00:00
8 changed files with 83 additions and 16 deletions
Showing only changes of commit 42a36fc442 - Show all commits

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

View file

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

View file

@ -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…",

View file

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

View file

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

View file

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

View file

@ -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: [],
} }

View file

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