feature/bots #15
16 changed files with 411 additions and 47 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
|
||||||
|
}
|
||||||
|
|
@ -47,6 +47,7 @@ const dummyRound: GameRound = {
|
||||||
return deltas
|
return deltas
|
||||||
},
|
},
|
||||||
recap: () => ({ label: "2+2 ?", answer: "4", answers: [] }),
|
recap: () => ({ label: "2+2 ?", answer: "4", answers: [] }),
|
||||||
|
botAnswer: () => ({ choiceIndex: 0 }),
|
||||||
}
|
}
|
||||||
|
|
||||||
function setup(roundDurationSec: number): {
|
function setup(roundDurationSec: number): {
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,8 @@ export class GameEngine implements RoomGameController {
|
||||||
this.io.to(this.room.code).emit("round:start", base)
|
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.
|
// Attend la première condition de fin : timer écoulé OU tous votés.
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
this.resolveRoundEnd = resolve
|
this.resolveRoundEnd = resolve
|
||||||
|
|
@ -222,6 +224,44 @@ export class GameEngine implements RoomGameController {
|
||||||
this.runtime = null
|
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). */
|
/** Termine la manche en cours (idempotent : timer et "tous votés" peuvent se croiser). */
|
||||||
private endRound(): void {
|
private endRound(): void {
|
||||||
if (this.timer) {
|
if (this.timer) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -31,10 +32,10 @@ interface BlindtestRoundData {
|
||||||
track: BlindtestTrack
|
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 {
|
function pickDj(room: ServerRoom, submittedBy: string): string | null {
|
||||||
const eligible = [...room.players.values()].filter(
|
const eligible = [...room.players.values()].filter(
|
||||||
(p) => p.connected && p.id !== submittedBy
|
(p) => p.connected && !p.isBot && p.id !== submittedBy
|
||||||
)
|
)
|
||||||
if (eligible.length === 0) {
|
if (eligible.length === 0) {
|
||||||
return null
|
return null
|
||||||
|
|
@ -98,6 +99,30 @@ export class BlindtestRound implements GameRound {
|
||||||
ctx.votes.set(playerId, answer)
|
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). */
|
/** Résultat par joueur (votants + bonus de tromperie du contributeur). */
|
||||||
private buildResults(ctx: RoundContext): BlindtestPerPlayerResult {
|
private buildResults(ctx: RoundContext): BlindtestPerPlayerResult {
|
||||||
const { track } = ctx.data as BlindtestRoundData
|
const { track } = ctx.data as BlindtestRoundData
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -147,6 +148,22 @@ export class QuizRound implements GameRound {
|
||||||
return deltas
|
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 {
|
recap(ctx: RoundContext): RoundRecapInfo {
|
||||||
const { question } = ctx.data as QuizRoundData
|
const { question } = ctx.data as QuizRoundData
|
||||||
const answer = isTextFormat(question)
|
const answer = isTextFormat(question)
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,9 @@ export interface GameRound {
|
||||||
/** Renvoie les variations de score à appliquer (le moteur les applique). */
|
/** Renvoie les variations de score à appliquer (le moteur les applique). */
|
||||||
score(ctx: RoundContext): ScoreDelta[]
|
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). */
|
/** Résumé de la manche pour le récap de fin de partie (intitulé + réponse + média). */
|
||||||
recap(ctx: RoundContext): RoundRecapInfo
|
recap(ctx: RoundContext): RoundRecapInfo
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -144,11 +144,33 @@ 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)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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) => {
|
socket.on("game:start", (ack) => {
|
||||||
const code = socket.data.roomCode
|
const code = socket.data.roomCode
|
||||||
const room = code ? rooms.get(code) : undefined
|
const room = code ? rooms.get(code) : undefined
|
||||||
|
|
@ -173,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
|
||||||
|
|
@ -192,6 +216,7 @@ export function registerRoomHandlers(
|
||||||
(p) =>
|
(p) =>
|
||||||
p.connected &&
|
p.connected &&
|
||||||
p.name !== "" &&
|
p.name !== "" &&
|
||||||
|
!p.isBot &&
|
||||||
room.blindtestTracks.filter((t) => t.submittedBy === p.id).length < tpp
|
room.blindtestTracks.filter((t) => t.submittedBy === p.id).length < tpp
|
||||||
)
|
)
|
||||||
if (pending) {
|
if (pending) {
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,22 @@ export interface ServerPlayer {
|
||||||
socketId: string | null
|
socketId: string | null
|
||||||
/** Secret de reconnexion (refresh/coupure). */
|
/** Secret de reconnexion (refresh/coupure). */
|
||||||
reconnectToken: string
|
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. */
|
/** Titre soumis pour le blindtest. `submittedBy` reste secret jusqu'au reveal. */
|
||||||
export interface BlindtestTrack {
|
export interface BlindtestTrack {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -94,6 +108,37 @@ export class RoomManager {
|
||||||
return this.rooms.get(code)
|
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,
|
* 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).
|
* supprime la room si plus personne. Renvoie la room (ou null si supprimée).
|
||||||
|
|
@ -146,6 +191,7 @@ export class RoomManager {
|
||||||
id: p.id,
|
id: p.id,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
connected: p.connected,
|
connected: p.connected,
|
||||||
|
bot: p.isBot,
|
||||||
})),
|
})),
|
||||||
settings: room.settings,
|
settings: room.settings,
|
||||||
scores: named.map((p) => ({
|
scores: named.map((p) => ({
|
||||||
|
|
@ -168,6 +214,7 @@ export class RoomManager {
|
||||||
connected: true,
|
connected: true,
|
||||||
socketId,
|
socketId,
|
||||||
reconnectToken: randomUUID(),
|
reconnectToken: randomUUID(),
|
||||||
|
isBot: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,8 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
|
|
||||||
const [first, second, third] = ranked
|
const [first, second, third] = ranked
|
||||||
const rest = ranked.slice(3)
|
const rest = ranked.slice(3)
|
||||||
|
const hasTracks = !!gameTracks && gameTracks.length > 0
|
||||||
|
const hasRecap = !!gameRecap && gameRecap.length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col items-center gap-6">
|
<div className="flex w-full flex-col items-center gap-6">
|
||||||
|
|
@ -256,15 +258,19 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Détails (musiques + récap) côte à côte sur desktop. */}
|
{/* Détails : musiques + récap côte à côte sur desktop s'il y a les deux,
|
||||||
{((gameTracks && gameTracks.length > 0) ||
|
sinon une seule colonne centrée (sinon le récap se calait à gauche). */}
|
||||||
(gameRecap && gameRecap.length > 0)) && (
|
{(hasTracks || hasRecap) && (
|
||||||
<div className="grid w-full gap-6 md:grid-cols-2 md:items-start">
|
<div
|
||||||
{gameTracks && gameTracks.length > 0 && (
|
className={
|
||||||
<TracksRecap tracks={gameTracks} />
|
hasTracks && hasRecap
|
||||||
)}
|
? "grid w-full gap-6 md:grid-cols-2 md:items-start"
|
||||||
{gameRecap && gameRecap.length > 0 && (
|
: "flex w-full max-w-xl flex-col gap-6"
|
||||||
<RoundsRecap recap={gameRecap} nameOf={nameOf} playerId={playerId} />
|
}
|
||||||
|
>
|
||||||
|
{hasTracks && <TracksRecap tracks={gameTracks!} />}
|
||||||
|
{hasRecap && (
|
||||||
|
<RoundsRecap recap={gameRecap!} nameOf={nameOf} playerId={playerId} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -31,9 +31,16 @@ const MIX_LABELS: Record<MixMode, string> = {
|
||||||
blindtest: "Blindtest",
|
blindtest: "Blindtest",
|
||||||
}
|
}
|
||||||
import { Button } from "@workspace/ui/components/button"
|
import { Button } from "@workspace/ui/components/button"
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@workspace/ui/components/tooltip"
|
||||||
import { useRoomStore } from "@/store/room"
|
import { useRoomStore } from "@/store/room"
|
||||||
import { Avatar } from "@/components/avatar"
|
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 MAX_TRACKS = 10
|
||||||
const GAME_TYPES: {
|
const GAME_TYPES: {
|
||||||
value: GameType
|
value: GameType
|
||||||
|
|
@ -51,6 +58,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"
|
||||||
|
|
@ -136,9 +149,18 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
const playerId = useRoomStore((s) => s.playerId)
|
const playerId = useRoomStore((s) => s.playerId)
|
||||||
const updateSettings = useRoomStore((s) => s.updateSettings)
|
const updateSettings = useRoomStore((s) => s.updateSettings)
|
||||||
const startGame = useRoomStore((s) => s.startGame)
|
const startGame = useRoomStore((s) => s.startGame)
|
||||||
|
const addBot = useRoomStore((s) => s.addBot)
|
||||||
|
const removeBot = useRoomStore((s) => s.removeBot)
|
||||||
const isHost = snapshot.hostId === playerId
|
const isHost = snapshot.hostId === playerId
|
||||||
const { gameType, mixedModes, blindtestMode, tracksPerPlayer, categories } =
|
const botCount = snapshot.players.filter((p) => p.bot).length
|
||||||
snapshot.settings
|
const {
|
||||||
|
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 =
|
||||||
|
|
@ -156,9 +178,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 =
|
||||||
|
|
@ -180,11 +206,13 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
tracks: showBlindtest ? totalTracks : 0,
|
tracks: showBlindtest ? totalTracks : 0,
|
||||||
shuffleOrder: isMixed,
|
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 =
|
const allSubmitted =
|
||||||
!showBlindtest ||
|
!showBlindtest ||
|
||||||
(snapshot.submissions.length > 0 &&
|
(humanSubs.length > 0 &&
|
||||||
snapshot.submissions.every((s) => s.count >= tracksPerPlayer))
|
humanSubs.every((s) => s.count >= tracksPerPlayer))
|
||||||
const canStart = rounds.length > 0 && allSubmitted
|
const canStart = rounds.length > 0 && allSubmitted
|
||||||
|
|
||||||
function toggleMix(mode: MixMode) {
|
function toggleMix(mode: MixMode) {
|
||||||
|
|
@ -241,12 +269,37 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground text-xs">
|
<span className="text-muted-foreground text-xs">
|
||||||
{p.id === snapshot.hostId ? "Hôte" : ""}
|
{p.bot ? "🤖 Bot" : p.id === snapshot.hostId ? "Hôte" : ""}
|
||||||
{!p.connected && " · hors ligne"}
|
{!p.bot && !p.connected && " · hors ligne"}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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>
|
</section>
|
||||||
|
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
|
|
@ -263,13 +316,15 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
{GAME_TYPES.map(({ value, label, Icon, needsThree }) => {
|
{GAME_TYPES.map(({ value, label, Icon, needsThree }) => {
|
||||||
const locked = needsThree && !blindtestAvailable
|
const locked = needsThree && !blindtestAvailable
|
||||||
const active = effectiveType === value
|
const active = effectiveType === value
|
||||||
return (
|
const tile = (
|
||||||
<button
|
<button
|
||||||
key={value}
|
key={value}
|
||||||
disabled={locked}
|
aria-disabled={locked}
|
||||||
title={locked ? "3 joueurs minimum" : undefined}
|
onClick={() => {
|
||||||
onClick={() => updateSettings({ gameType: value })}
|
if (locked) return
|
||||||
className={`flex flex-col items-center gap-1.5 rounded-lg border p-2.5 text-xs font-medium transition-colors disabled:opacity-40 ${
|
updateSettings({ gameType: value })
|
||||||
|
}}
|
||||||
|
className={`flex flex-col items-center gap-1.5 rounded-lg border p-2.5 text-xs font-medium transition-colors aria-disabled:cursor-not-allowed aria-disabled:opacity-40 ${
|
||||||
active
|
active
|
||||||
? "border-primary bg-primary/10"
|
? "border-primary bg-primary/10"
|
||||||
: "border-input hover:bg-muted/60"
|
: "border-input hover:bg-muted/60"
|
||||||
|
|
@ -279,11 +334,20 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
return locked ? (
|
||||||
|
<Tooltip key={value}>
|
||||||
|
<TooltipTrigger asChild>{tile}</TooltipTrigger>
|
||||||
|
<TooltipContent>{BLINDTEST_LOCK_HINT}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
tile
|
||||||
|
)
|
||||||
})}
|
})}
|
||||||
</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>
|
||||||
|
|
@ -296,13 +360,15 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
{(["quiz", "image", "blindtest"] as const).map((m) => {
|
{(["quiz", "image", "blindtest"] as const).map((m) => {
|
||||||
const locked = m === "blindtest" && !blindtestAvailable
|
const locked = m === "blindtest" && !blindtestAvailable
|
||||||
const on = mixedModes.includes(m) && !locked
|
const on = mixedModes.includes(m) && !locked
|
||||||
return (
|
const tile = (
|
||||||
<button
|
<button
|
||||||
key={m}
|
key={m}
|
||||||
disabled={locked}
|
aria-disabled={locked}
|
||||||
title={locked ? "3 joueurs minimum" : undefined}
|
onClick={() => {
|
||||||
onClick={() => toggleMix(m)}
|
if (locked) return
|
||||||
className={`flex-1 rounded-lg border px-2 py-1.5 text-xs font-medium transition-colors disabled:opacity-40 ${
|
toggleMix(m)
|
||||||
|
}}
|
||||||
|
className={`flex-1 rounded-lg border px-2 py-1.5 text-xs font-medium transition-colors aria-disabled:cursor-not-allowed aria-disabled:opacity-40 ${
|
||||||
on
|
on
|
||||||
? "border-primary bg-primary/10"
|
? "border-primary bg-primary/10"
|
||||||
: "border-input hover:bg-muted/60"
|
: "border-input hover:bg-muted/60"
|
||||||
|
|
@ -311,6 +377,14 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
{MIX_LABELS[m]}
|
{MIX_LABELS[m]}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
return locked ? (
|
||||||
|
<Tooltip key={m}>
|
||||||
|
<TooltipTrigger asChild>{tile}</TooltipTrigger>
|
||||||
|
<TooltipContent>{BLINDTEST_LOCK_HINT}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
tile
|
||||||
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -380,6 +454,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">
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,11 @@ import { Link } from "wouter"
|
||||||
import { Eye, EyeOff, Trash2, Upload } from "lucide-react"
|
import { Eye, EyeOff, Trash2, Upload } from "lucide-react"
|
||||||
import type { QuizFormat } from "@nerdware/shared"
|
import type { QuizFormat } from "@nerdware/shared"
|
||||||
import { Button } from "@workspace/ui/components/button"
|
import { Button } from "@workspace/ui/components/button"
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@workspace/ui/components/tooltip"
|
||||||
import {
|
import {
|
||||||
adminApi,
|
adminApi,
|
||||||
assetUrl,
|
assetUrl,
|
||||||
|
|
@ -187,14 +192,14 @@ function QuestionRow({
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Tooltip>
|
||||||
size="icon-sm"
|
<TooltipTrigger asChild>
|
||||||
variant="secondary"
|
<Button size="icon-sm" variant="secondary" onClick={onToggle}>
|
||||||
onClick={onToggle}
|
{q.active ? <Eye /> : <EyeOff />}
|
||||||
title={q.active ? "Désactiver" : "Activer"}
|
</Button>
|
||||||
>
|
</TooltipTrigger>
|
||||||
{q.active ? <Eye /> : <EyeOff />}
|
<TooltipContent>{q.active ? "Désactiver" : "Activer"}</TooltipContent>
|
||||||
</Button>
|
</Tooltip>
|
||||||
<Button
|
<Button
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,11 @@ import { Link, useLocation } from "wouter"
|
||||||
import { AnimatePresence, motion } from "framer-motion"
|
import { AnimatePresence, motion } from "framer-motion"
|
||||||
import { LogOut } from "lucide-react"
|
import { LogOut } from "lucide-react"
|
||||||
import { Button } from "@workspace/ui/components/button"
|
import { Button } from "@workspace/ui/components/button"
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@workspace/ui/components/tooltip"
|
||||||
import { useRoomStore } from "@/store/room"
|
import { useRoomStore } from "@/store/room"
|
||||||
import { LobbyView } from "@/components/lobby-view"
|
import { LobbyView } from "@/components/lobby-view"
|
||||||
import { QuizView } from "@/components/quiz-view"
|
import { QuizView } from "@/components/quiz-view"
|
||||||
|
|
@ -84,10 +89,16 @@ export function RoomPage({ code }: { code: string }) {
|
||||||
<header className="flex items-center justify-between">
|
<header className="flex items-center justify-between">
|
||||||
<RoomCode code={snapshot.code} />
|
<RoomCode code={snapshot.code} />
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span
|
<Tooltip>
|
||||||
className={`size-2.5 rounded-full ${connected ? "bg-green-500" : "bg-red-500"}`}
|
<TooltipTrigger asChild>
|
||||||
title={connected ? "Connecté" : "Déconnecté"}
|
<span
|
||||||
/>
|
className={`size-2.5 rounded-full ${connected ? "bg-green-500" : "bg-red-500"}`}
|
||||||
|
/>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{connected ? "Connecté" : "Déconnecté"}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,8 @@ interface RoomState {
|
||||||
removeTrack: (trackId: string) => Promise<boolean>
|
removeTrack: (trackId: string) => Promise<boolean>
|
||||||
returnToLobby: () => void
|
returnToLobby: () => void
|
||||||
leaveRoom: () => void
|
leaveRoom: () => void
|
||||||
|
addBot: () => void
|
||||||
|
removeBot: () => void
|
||||||
vote: (choiceIndex: number) => void
|
vote: (choiceIndex: number) => void
|
||||||
voteText: (text: string) => void
|
voteText: (text: string) => void
|
||||||
voteBlindtest: (answer: BlindtestAnswer) => void
|
voteBlindtest: (answer: BlindtestAnswer) => void
|
||||||
|
|
@ -162,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,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
@ -198,6 +201,9 @@ export const useRoomStore = create<RoomState>((set, get) => ({
|
||||||
get().reset()
|
get().reset()
|
||||||
},
|
},
|
||||||
|
|
||||||
|
addBot: () => socket.emit("lobby:addBot"),
|
||||||
|
removeBot: () => socket.emit("lobby:removeBot"),
|
||||||
|
|
||||||
vote: (choiceIndex) => {
|
vote: (choiceIndex) => {
|
||||||
if (get().hasVoted) {
|
if (get().hasVoted) {
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
||||||
|
|
@ -24,6 +27,8 @@ export interface Player {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
connected: boolean
|
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. */
|
/** Configuration d'une épreuve planifiée dans la séquence de la partie. */
|
||||||
|
|
@ -46,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[]
|
||||||
}
|
}
|
||||||
|
|
@ -58,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[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -150,6 +152,8 @@ export interface ClientToServerEvents {
|
||||||
"room:rejoin": (payload: RejoinPayload, ack: Ack<RoomCreatedPayload>) => void
|
"room:rejoin": (payload: RejoinPayload, ack: Ack<RoomCreatedPayload>) => void
|
||||||
"player:setName": (payload: SetNamePayload) => void
|
"player:setName": (payload: SetNamePayload) => void
|
||||||
"room:leave": () => void
|
"room:leave": () => void
|
||||||
|
"lobby:addBot": () => void
|
||||||
|
"lobby:removeBot": () => void
|
||||||
"lobby:updateSettings": (payload: UpdateSettingsPayload) => void
|
"lobby:updateSettings": (payload: UpdateSettingsPayload) => void
|
||||||
"lobby:return": () => void
|
"lobby:return": () => void
|
||||||
"game:start": (ack: Ack<null>) => void
|
"game:start": (ack: Ack<null>) => void
|
||||||
|
|
|
||||||
59
packages/ui/src/components/tooltip.tsx
Normal file
59
packages/ui/src/components/tooltip.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@workspace/ui/lib/utils"
|
||||||
|
|
||||||
|
function TooltipProvider({
|
||||||
|
delayDuration = 0,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Provider
|
||||||
|
data-slot="tooltip-provider"
|
||||||
|
delayDuration={delayDuration}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||||
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 0,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||||
|
</TooltipPrimitive.Content>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
Loading…
Add table
Reference in a new issue