release/0.1.0 #18
17 changed files with 767 additions and 76 deletions
4
apps/server/src/game/modes/index.ts
Normal file
4
apps/server/src/game/modes/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
// Point d'enregistrement de toutes les épreuves. Importé une fois au démarrage
|
||||
// pour ses effets de bord (registerRound). Ajouter un mode = une ligne ici.
|
||||
|
||||
import "./quiz"
|
||||
7
apps/server/src/game/modes/quiz/index.ts
Normal file
7
apps/server/src/game/modes/quiz/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { registerRound } from "../../registry"
|
||||
import { QuizRound } from "./quiz-round"
|
||||
|
||||
// Brancher l'épreuve = un seul registerRound, aucune plomberie à toucher.
|
||||
registerRound("quiz", () => new QuizRound())
|
||||
|
||||
export { QuizRound }
|
||||
110
apps/server/src/game/modes/quiz/questions.ts
Normal file
110
apps/server/src/game/modes/quiz/questions.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
// Banque de questions en dur pour la V1 (mode quiz culture geek).
|
||||
// Le seed Open Trivia DB + le back-office manuel arrivent aux étapes 5 et 7 ;
|
||||
// d'ici là, ce petit jeu suffit à jouer une partie complète de bout en bout.
|
||||
|
||||
import type { QuizFormat } from "@nerdware/shared"
|
||||
|
||||
export interface QuizQuestion {
|
||||
id: string
|
||||
format: QuizFormat
|
||||
prompt: string
|
||||
choices: string[]
|
||||
correctIndex: number
|
||||
category: string
|
||||
difficulty: number
|
||||
}
|
||||
|
||||
const TRUE_FALSE = ["Vrai", "Faux"]
|
||||
|
||||
export const QUIZ_QUESTIONS: QuizQuestion[] = [
|
||||
{
|
||||
id: "q-zelda-princess",
|
||||
format: "mcq",
|
||||
prompt: "Dans la saga The Legend of Zelda, quel est le nom du héros ?",
|
||||
choices: ["Zelda", "Link", "Ganon", "Navi"],
|
||||
correctIndex: 1,
|
||||
category: "Jeux vidéo",
|
||||
difficulty: 1,
|
||||
},
|
||||
{
|
||||
id: "q-mario-plumber",
|
||||
format: "truefalse",
|
||||
prompt: "Mario est plombier de profession.",
|
||||
choices: TRUE_FALSE,
|
||||
correctIndex: 0,
|
||||
category: "Jeux vidéo",
|
||||
difficulty: 1,
|
||||
},
|
||||
{
|
||||
id: "q-onepiece-captain",
|
||||
format: "mcq",
|
||||
prompt: "Qui est le capitaine de l'équipage du Chapeau de paille dans One Piece ?",
|
||||
choices: ["Zoro", "Sanji", "Luffy", "Usopp"],
|
||||
correctIndex: 2,
|
||||
category: "Manga",
|
||||
difficulty: 1,
|
||||
},
|
||||
{
|
||||
id: "q-pokemon-first",
|
||||
format: "mcq",
|
||||
prompt: "Quel Pokémon porte le numéro 001 dans le Pokédex national ?",
|
||||
choices: ["Salamèche", "Carapuce", "Pikachu", "Bulbizarre"],
|
||||
correctIndex: 3,
|
||||
category: "Jeux vidéo",
|
||||
difficulty: 2,
|
||||
},
|
||||
{
|
||||
id: "q-naruto-village",
|
||||
format: "mcq",
|
||||
prompt: "De quel village ninja Naruto est-il originaire ?",
|
||||
choices: ["Konoha", "Suna", "Kiri", "Iwa"],
|
||||
correctIndex: 0,
|
||||
category: "Manga",
|
||||
difficulty: 2,
|
||||
},
|
||||
{
|
||||
id: "q-minecraft-creeper",
|
||||
format: "truefalse",
|
||||
prompt: "Dans Minecraft, le Creeper explose au contact du joueur.",
|
||||
choices: TRUE_FALSE,
|
||||
correctIndex: 0,
|
||||
category: "Jeux vidéo",
|
||||
difficulty: 1,
|
||||
},
|
||||
{
|
||||
id: "q-starwars-vador",
|
||||
format: "mcq",
|
||||
prompt: "Dans Star Wars, qui est le père de Luke Skywalker ?",
|
||||
choices: ["Obi-Wan Kenobi", "Dark Vador", "Yoda", "Palpatine"],
|
||||
correctIndex: 1,
|
||||
category: "Pop culture",
|
||||
difficulty: 1,
|
||||
},
|
||||
{
|
||||
id: "q-dbz-kamehameha",
|
||||
format: "mcq",
|
||||
prompt: "Dans Dragon Ball, quelle attaque emblématique Sangoku utilise-t-il ?",
|
||||
choices: ["Genkidama", "Kamehameha", "Final Flash", "Makankosappo"],
|
||||
correctIndex: 1,
|
||||
category: "Manga",
|
||||
difficulty: 1,
|
||||
},
|
||||
{
|
||||
id: "q-tetris-origin",
|
||||
format: "truefalse",
|
||||
prompt: "Le jeu Tetris a été créé par un développeur soviétique.",
|
||||
choices: TRUE_FALSE,
|
||||
correctIndex: 0,
|
||||
category: "Jeux vidéo",
|
||||
difficulty: 2,
|
||||
},
|
||||
{
|
||||
id: "q-matrix-pill",
|
||||
format: "mcq",
|
||||
prompt: "Dans Matrix, quelle pilule Neo doit-il prendre pour découvrir la vérité ?",
|
||||
choices: ["La bleue", "La rouge", "La verte", "La jaune"],
|
||||
correctIndex: 1,
|
||||
category: "Pop culture",
|
||||
difficulty: 1,
|
||||
},
|
||||
]
|
||||
59
apps/server/src/game/modes/quiz/quiz-round.test.ts
Normal file
59
apps/server/src/game/modes/quiz/quiz-round.test.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { RoomManager } from "../../../rooms"
|
||||
import type { RoundContext } from "../../round"
|
||||
import { QuizRound } from "./quiz-round"
|
||||
import { QUIZ_QUESTIONS } from "./questions"
|
||||
|
||||
const question = QUIZ_QUESTIONS[0] // "Link" → correctIndex 1
|
||||
|
||||
function makeCtx(): { ctx: RoundContext; p1: string; p2: string } {
|
||||
const rooms = new RoomManager()
|
||||
const { room, player: a } = rooms.create("Alice", "sa")
|
||||
const { player: b } = rooms.join(room.code, "Bob", "sb")
|
||||
const ctx: RoundContext = {
|
||||
room,
|
||||
djId: null,
|
||||
votes: new Map(),
|
||||
startedAt: 0,
|
||||
endsAt: 10_000,
|
||||
data: { question, votedAt: new Map() },
|
||||
}
|
||||
return { ctx, p1: a.id, p2: b.id }
|
||||
}
|
||||
|
||||
describe("QuizRound", () => {
|
||||
test("verrouille le premier vote (idempotent)", () => {
|
||||
const round = new QuizRound(() => 1000)
|
||||
const { ctx, p1 } = makeCtx()
|
||||
round.submitAnswer(ctx, p1, { choiceIndex: 1 })
|
||||
round.submitAnswer(ctx, p1, { choiceIndex: 0 }) // ignoré
|
||||
expect(ctx.votes.get(p1)).toEqual({ choiceIndex: 1 })
|
||||
})
|
||||
|
||||
test("ignore un index hors bornes", () => {
|
||||
const round = new QuizRound(() => 1000)
|
||||
const { ctx, p1 } = makeCtx()
|
||||
round.submitAnswer(ctx, p1, { choiceIndex: 99 })
|
||||
expect(ctx.votes.has(p1)).toBe(false)
|
||||
})
|
||||
|
||||
test("reveal expose la vérité + le résultat par joueur", () => {
|
||||
const round = new QuizRound(() => 1000)
|
||||
const { ctx, p1, p2 } = makeCtx()
|
||||
round.submitAnswer(ctx, p1, { choiceIndex: 1 }) // juste
|
||||
round.submitAnswer(ctx, p2, { choiceIndex: 0 }) // faux
|
||||
const { truth, perPlayerResult } = round.reveal(ctx)
|
||||
expect(truth.correctIndex).toBe(question.correctIndex)
|
||||
expect(perPlayerResult[p1]).toEqual({ choiceIndex: 1, correct: true })
|
||||
expect(perPlayerResult[p2]).toEqual({ choiceIndex: 0, correct: false })
|
||||
})
|
||||
|
||||
test("score = base + bonus rapidité pour les bonnes réponses uniquement", () => {
|
||||
const round = new QuizRound(() => 1000) // vote à t=1000 sur 10000 → reste 90%
|
||||
const { ctx, p1, p2 } = makeCtx()
|
||||
round.submitAnswer(ctx, p1, { choiceIndex: 1 }) // juste, rapide
|
||||
round.submitAnswer(ctx, p2, { choiceIndex: 0 }) // faux
|
||||
const deltas = round.score(ctx)
|
||||
expect(deltas).toEqual([{ playerId: p1, delta: 190 }]) // 100 + round(100 * 0.9)
|
||||
})
|
||||
})
|
||||
115
apps/server/src/game/modes/quiz/quiz-round.ts
Normal file
115
apps/server/src/game/modes/quiz/quiz-round.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
// Épreuve Quiz : une question = une manche. Pas de DJ, pas d'audio.
|
||||
// Implémente le contrat GameRound ; le moteur fait tout le reste.
|
||||
|
||||
import type {
|
||||
Answer,
|
||||
QuizPerPlayerResult,
|
||||
QuizQuestionPayload,
|
||||
QuizRevealTruth,
|
||||
ScoreDelta,
|
||||
} from "@nerdware/shared"
|
||||
import type { GameRound, RoundContext, RoundStart } from "../../round"
|
||||
import type { ServerRoom } from "../../../rooms"
|
||||
import { QUIZ_QUESTIONS, type QuizQuestion } from "./questions"
|
||||
|
||||
const BASE_POINTS = 100
|
||||
const SPEED_BONUS_MAX = 100
|
||||
|
||||
/** Données privées de la manche (jamais diffusées). */
|
||||
interface QuizRoundData {
|
||||
question: QuizQuestion
|
||||
/** Premier instant de vote par joueur, pour le bonus rapidité. */
|
||||
votedAt: Map<string, number>
|
||||
}
|
||||
|
||||
// Anti-répétition à l'échelle d'une partie : questions déjà jouées par room.
|
||||
const playedByRoom = new WeakMap<ServerRoom, Set<string>>()
|
||||
|
||||
function pickQuestion(room: ServerRoom): QuizQuestion {
|
||||
let played = playedByRoom.get(room)
|
||||
if (!played) {
|
||||
played = new Set()
|
||||
playedByRoom.set(room, played)
|
||||
}
|
||||
let pool = QUIZ_QUESTIONS.filter((q) => !played.has(q.id))
|
||||
if (pool.length === 0) {
|
||||
// Banque épuisée sur cette partie : on autorise à nouveau les répétitions.
|
||||
played.clear()
|
||||
pool = QUIZ_QUESTIONS
|
||||
}
|
||||
const question = pool[Math.floor(Math.random() * pool.length)]
|
||||
played.add(question.id)
|
||||
return question
|
||||
}
|
||||
|
||||
function isQuizAnswer(answer: Answer): answer is { choiceIndex: number } {
|
||||
return (
|
||||
typeof (answer as { choiceIndex?: unknown }).choiceIndex === "number"
|
||||
)
|
||||
}
|
||||
|
||||
export class QuizRound implements GameRound {
|
||||
readonly type = "quiz" as const
|
||||
|
||||
constructor(private readonly now: () => number = Date.now) {}
|
||||
|
||||
start(room: ServerRoom): RoundStart {
|
||||
const question = pickQuestion(room)
|
||||
const payload: QuizQuestionPayload = {
|
||||
format: question.format,
|
||||
prompt: question.prompt,
|
||||
choices: question.choices,
|
||||
category: question.category,
|
||||
difficulty: question.difficulty,
|
||||
}
|
||||
const data: QuizRoundData = { question, votedAt: new Map() }
|
||||
return { djId: null, payload, data }
|
||||
}
|
||||
|
||||
submitAnswer(ctx: RoundContext, playerId: string, answer: Answer): void {
|
||||
// Premier vote verrouillé (idempotent) : on ignore les votes suivants.
|
||||
if (ctx.votes.has(playerId)) {
|
||||
return
|
||||
}
|
||||
if (!isQuizAnswer(answer)) {
|
||||
return
|
||||
}
|
||||
const { question, votedAt } = ctx.data as QuizRoundData
|
||||
if (answer.choiceIndex < 0 || answer.choiceIndex >= question.choices.length) {
|
||||
return
|
||||
}
|
||||
ctx.votes.set(playerId, { choiceIndex: answer.choiceIndex })
|
||||
votedAt.set(playerId, this.now())
|
||||
}
|
||||
|
||||
reveal(ctx: RoundContext): { truth: QuizRevealTruth; perPlayerResult: QuizPerPlayerResult } {
|
||||
const { question } = ctx.data as QuizRoundData
|
||||
const perPlayerResult: QuizPerPlayerResult = {}
|
||||
for (const player of ctx.room.players.values()) {
|
||||
const vote = ctx.votes.get(player.id) as { choiceIndex: number } | undefined
|
||||
const choiceIndex = vote ? vote.choiceIndex : null
|
||||
perPlayerResult[player.id] = {
|
||||
choiceIndex,
|
||||
correct: choiceIndex === question.correctIndex,
|
||||
}
|
||||
}
|
||||
return { truth: { correctIndex: question.correctIndex }, perPlayerResult }
|
||||
}
|
||||
|
||||
score(ctx: RoundContext): ScoreDelta[] {
|
||||
const { question, votedAt } = ctx.data as QuizRoundData
|
||||
const total = ctx.endsAt - ctx.startedAt
|
||||
const deltas: ScoreDelta[] = []
|
||||
for (const [playerId, vote] of ctx.votes) {
|
||||
if ((vote as { choiceIndex: number }).choiceIndex !== question.correctIndex) {
|
||||
continue
|
||||
}
|
||||
// Bonus rapidité : proportionnel au temps restant au moment du vote.
|
||||
const at = votedAt.get(playerId) ?? ctx.endsAt
|
||||
const remaining = Math.max(0, Math.min(1, (ctx.endsAt - at) / total))
|
||||
const bonus = Math.round(SPEED_BONUS_MAX * remaining)
|
||||
deltas.push({ playerId, delta: BASE_POINTS + bonus })
|
||||
}
|
||||
return deltas
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import Fastify from "fastify"
|
|||
import cors from "@fastify/cors"
|
||||
import { env, isDev } from "./env"
|
||||
import { createSocketServer } from "./socket"
|
||||
import "./game/modes" // enregistre les épreuves (registerRound)
|
||||
|
||||
const app = Fastify({
|
||||
logger: isDev
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { Route, Switch } from "wouter"
|
||||
import { HomePage } from "@/pages/home"
|
||||
import { LobbyPage } from "@/pages/lobby"
|
||||
import { RoomPage } from "@/pages/room"
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<Switch>
|
||||
<Route path="/" component={HomePage} />
|
||||
<Route path="/room/:code">
|
||||
{(params) => <LobbyPage code={params.code.toUpperCase()} />}
|
||||
{(params) => <RoomPage code={params.code.toUpperCase()} />}
|
||||
</Route>
|
||||
<Route>
|
||||
<div className="flex min-h-svh items-center justify-center p-6">
|
||||
|
|
|
|||
19
apps/web/src/components/countdown.tsx
Normal file
19
apps/web/src/components/countdown.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { useEffect, useState } from "react"
|
||||
|
||||
/** Secondes restantes jusqu'à `endsAt` (timestamp serveur). */
|
||||
function secondsLeft(endsAt: number): number {
|
||||
return Math.max(0, Math.ceil((endsAt - Date.now()) / 1000))
|
||||
}
|
||||
|
||||
export function Countdown({ endsAt }: { endsAt: number }) {
|
||||
const [left, setLeft] = useState(() => secondsLeft(endsAt))
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setLeft(secondsLeft(endsAt)), 250)
|
||||
return () => clearInterval(id)
|
||||
}, [endsAt])
|
||||
|
||||
return (
|
||||
<span className="font-heading text-2xl font-bold tabular-nums">{left}</span>
|
||||
)
|
||||
}
|
||||
37
apps/web/src/components/game-end-view.tsx
Normal file
37
apps/web/src/components/game-end-view.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { Link } from "wouter"
|
||||
import type { PlayerScore, RoomSnapshot } from "@nerdware/shared"
|
||||
import { Button } from "@workspace/ui/components/button"
|
||||
import { useRoomStore } from "@/store/room"
|
||||
import { Scoreboard } from "@/components/scoreboard"
|
||||
|
||||
export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||
const playerId = useRoomStore((s) => s.playerId)
|
||||
const finalScores = useRoomStore((s) => s.finalScores)
|
||||
const reset = useRoomStore((s) => s.reset)
|
||||
|
||||
const scores: PlayerScore[] = finalScores ?? snapshot.scores
|
||||
const winner = [...scores].sort((a, b) => b.score - a.score)[0]
|
||||
const winnerName =
|
||||
snapshot.players.find((p) => p.id === winner?.playerId)?.name ?? "?"
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-md flex-col gap-6 text-center">
|
||||
<header>
|
||||
<p className="text-muted-foreground text-xs uppercase">
|
||||
Partie terminée
|
||||
</p>
|
||||
<h2 className="font-heading text-2xl font-bold">
|
||||
🏆 {winnerName} l'emporte !
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<Scoreboard scores={scores} snapshot={snapshot} playerId={playerId} />
|
||||
|
||||
<Link href="/">
|
||||
<Button variant="secondary" className="w-full" onClick={reset}>
|
||||
Retour à l'accueil
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
87
apps/web/src/components/lobby-view.tsx
Normal file
87
apps/web/src/components/lobby-view.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { useState } from "react"
|
||||
import type { RoomSnapshot } from "@nerdware/shared"
|
||||
import { Button } from "@workspace/ui/components/button"
|
||||
import { useRoomStore } from "@/store/room"
|
||||
|
||||
const QUESTION_OPTIONS = [3, 5, 10]
|
||||
|
||||
export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||
const playerId = useRoomStore((s) => s.playerId)
|
||||
const startGame = useRoomStore((s) => s.startGame)
|
||||
const isHost = snapshot.hostId === playerId
|
||||
|
||||
const [count, setCount] = useState(5)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function handleStart() {
|
||||
setError(null)
|
||||
setBusy(true)
|
||||
try {
|
||||
await startGame(count)
|
||||
} catch (err) {
|
||||
setError((err as { message?: string }).message ?? "Erreur")
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-md flex-col gap-6">
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="text-muted-foreground text-sm font-medium">
|
||||
Joueurs ({snapshot.players.length})
|
||||
</h2>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{snapshot.players.map((p) => (
|
||||
<li
|
||||
key={p.id}
|
||||
className="bg-muted/40 flex items-center justify-between rounded-md px-3 py-2 text-sm"
|
||||
>
|
||||
<span className={p.connected ? "" : "opacity-40"}>
|
||||
{p.name}
|
||||
{p.id === playerId && (
|
||||
<span className="text-muted-foreground"> (toi)</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{p.id === snapshot.hostId ? "Hôte" : ""}
|
||||
{!p.connected && " · hors ligne"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{isHost ? (
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Questions de quiz</span>
|
||||
<div className="flex gap-1">
|
||||
{QUESTION_OPTIONS.map((n) => (
|
||||
<Button
|
||||
key={n}
|
||||
size="sm"
|
||||
variant={count === n ? "default" : "secondary"}
|
||||
onClick={() => setCount(n)}
|
||||
>
|
||||
{n}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Button disabled={busy} onClick={handleStart}>
|
||||
{busy ? "Lancement…" : "Lancer la partie"}
|
||||
</Button>
|
||||
{error && (
|
||||
<p className="text-destructive text-center text-sm">{error}</p>
|
||||
)}
|
||||
</section>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-center text-xs">
|
||||
En attente du lancement de la partie par l'hôte…
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
113
apps/web/src/components/quiz-view.tsx
Normal file
113
apps/web/src/components/quiz-view.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import type {
|
||||
QuizPerPlayerResult,
|
||||
QuizQuestionPayload,
|
||||
QuizRevealTruth,
|
||||
RoomSnapshot,
|
||||
} from "@nerdware/shared"
|
||||
import { useRoomStore } from "@/store/room"
|
||||
import { Countdown } from "@/components/countdown"
|
||||
import { Scoreboard } from "@/components/scoreboard"
|
||||
|
||||
export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||
const round = useRoomStore((s) => s.round)
|
||||
const reveal = useRoomStore((s) => s.reveal)
|
||||
const voteProgress = useRoomStore((s) => s.voteProgress)
|
||||
const myChoiceIndex = useRoomStore((s) => s.myChoiceIndex)
|
||||
const playerId = useRoomStore((s) => s.playerId)
|
||||
const vote = useRoomStore((s) => s.vote)
|
||||
|
||||
if (!round) {
|
||||
return (
|
||||
<p className="text-muted-foreground text-sm">Préparation de la manche…</p>
|
||||
)
|
||||
}
|
||||
|
||||
const question = round.payload as QuizQuestionPayload
|
||||
const truth = reveal ? (reveal.truth as QuizRevealTruth) : null
|
||||
const showReveal = truth !== null
|
||||
const myResult = reveal
|
||||
? (reveal.perPlayerResult as QuizPerPlayerResult)[playerId ?? ""]
|
||||
: undefined
|
||||
|
||||
function choiceClass(index: number): string {
|
||||
const base =
|
||||
"w-full rounded-lg border px-4 py-3 text-left text-sm transition-colors"
|
||||
if (showReveal) {
|
||||
if (index === truth?.correctIndex) {
|
||||
return `${base} border-green-500 bg-green-500/15 font-medium`
|
||||
}
|
||||
if (index === myChoiceIndex) {
|
||||
return `${base} border-red-500 bg-red-500/10`
|
||||
}
|
||||
return `${base} border-input opacity-60`
|
||||
}
|
||||
if (index === myChoiceIndex) {
|
||||
return `${base} border-primary bg-primary/10 font-medium`
|
||||
}
|
||||
return `${base} border-input hover:bg-muted/60`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-md flex-col gap-5">
|
||||
<header className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs uppercase">
|
||||
Question {snapshot.currentRound + 1} / {snapshot.totalRounds}
|
||||
{question.category ? ` · ${question.category}` : ""}
|
||||
</span>
|
||||
{!showReveal && (
|
||||
<Countdown key={round.endsAt} endsAt={round.endsAt} />
|
||||
)}
|
||||
</header>
|
||||
|
||||
<h2 className="font-heading text-xl font-semibold text-balance">
|
||||
{question.prompt}
|
||||
</h2>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{question.choices.map((choice, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className={choiceClass(index)}
|
||||
disabled={showReveal || myChoiceIndex !== null}
|
||||
onClick={() => vote(index)}
|
||||
>
|
||||
{choice}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!showReveal &&
|
||||
(myChoiceIndex !== null ? (
|
||||
<p className="text-muted-foreground text-center text-xs">
|
||||
Réponse envoyée — en attente des autres
|
||||
{voteProgress ? ` (${voteProgress.count}/${voteProgress.total})` : ""}
|
||||
</p>
|
||||
) : (
|
||||
voteProgress && (
|
||||
<p className="text-muted-foreground text-center text-xs">
|
||||
{voteProgress.count}/{voteProgress.total} ont répondu
|
||||
</p>
|
||||
)
|
||||
))}
|
||||
|
||||
{showReveal && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p
|
||||
className={`text-center text-sm font-medium ${myResult?.correct ? "text-green-500" : "text-red-500"}`}
|
||||
>
|
||||
{myResult?.correct
|
||||
? "Bonne réponse ! 🎉"
|
||||
: myResult?.choiceIndex == null
|
||||
? "Pas de réponse 😴"
|
||||
: "Raté 💥"}
|
||||
</p>
|
||||
<Scoreboard
|
||||
scores={snapshot.scores}
|
||||
snapshot={snapshot}
|
||||
playerId={playerId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
37
apps/web/src/components/scoreboard.tsx
Normal file
37
apps/web/src/components/scoreboard.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import type { PlayerScore, RoomSnapshot } from "@nerdware/shared"
|
||||
|
||||
interface ScoreboardProps {
|
||||
scores: PlayerScore[]
|
||||
snapshot: RoomSnapshot
|
||||
playerId: string | null
|
||||
}
|
||||
|
||||
export function Scoreboard({ scores, snapshot, playerId }: ScoreboardProps) {
|
||||
const nameOf = (id: string) =>
|
||||
snapshot.players.find((p) => p.id === id)?.name ?? "?"
|
||||
const ranked = [...scores].sort((a, b) => b.score - a.score)
|
||||
|
||||
return (
|
||||
<ol className="flex flex-col gap-1">
|
||||
{ranked.map((s, i) => (
|
||||
<li
|
||||
key={s.playerId}
|
||||
className="bg-muted/40 flex items-center justify-between rounded-md px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground w-5 text-right tabular-nums">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span>
|
||||
{nameOf(s.playerId)}
|
||||
{s.playerId === playerId && (
|
||||
<span className="text-muted-foreground"> (toi)</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="font-heading font-bold tabular-nums">{s.score}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
import { Link } from "wouter"
|
||||
import { Button } from "@workspace/ui/components/button"
|
||||
import { useRoomStore } from "@/store/room"
|
||||
|
||||
export function LobbyPage({ code }: { code: string }) {
|
||||
const snapshot = useRoomStore((s) => s.snapshot)
|
||||
const playerId = useRoomStore((s) => s.playerId)
|
||||
const connected = useRoomStore((s) => s.connected)
|
||||
|
||||
// Pas de snapshot pour ce code (accès direct / refresh) : on renvoie à l'accueil.
|
||||
if (!snapshot || snapshot.code !== code) {
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col items-center justify-center gap-4 p-6 text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Room introuvable ou session perdue.
|
||||
</p>
|
||||
<Link href="/">
|
||||
<Button variant="secondary">Retour à l'accueil</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-svh items-center justify-center p-6">
|
||||
<div className="flex w-full max-w-md flex-col gap-6">
|
||||
<header className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs uppercase">Code room</p>
|
||||
<p className="font-heading text-3xl font-bold tracking-widest">
|
||||
{snapshot.code}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`size-2.5 rounded-full ${connected ? "bg-green-500" : "bg-red-500"}`}
|
||||
title={connected ? "Connecté" : "Déconnecté"}
|
||||
/>
|
||||
</header>
|
||||
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="text-muted-foreground text-sm font-medium">
|
||||
Joueurs ({snapshot.players.length})
|
||||
</h2>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{snapshot.players.map((p) => (
|
||||
<li
|
||||
key={p.id}
|
||||
className="bg-muted/40 flex items-center justify-between rounded-md px-3 py-2 text-sm"
|
||||
>
|
||||
<span className={p.connected ? "" : "opacity-40"}>
|
||||
{p.name}
|
||||
{p.id === playerId && (
|
||||
<span className="text-muted-foreground"> (toi)</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{p.id === snapshot.hostId ? "Hôte" : ""}
|
||||
{!p.connected && " · hors ligne"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<p className="text-muted-foreground text-center text-xs">
|
||||
En attente du lancement de la partie par l'hôte…
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
50
apps/web/src/pages/room.tsx
Normal file
50
apps/web/src/pages/room.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { Link } from "wouter"
|
||||
import { Button } from "@workspace/ui/components/button"
|
||||
import { useRoomStore } from "@/store/room"
|
||||
import { LobbyView } from "@/components/lobby-view"
|
||||
import { QuizView } from "@/components/quiz-view"
|
||||
import { GameEndView } from "@/components/game-end-view"
|
||||
|
||||
export function RoomPage({ code }: { code: string }) {
|
||||
const snapshot = useRoomStore((s) => s.snapshot)
|
||||
const connected = useRoomStore((s) => s.connected)
|
||||
|
||||
// Pas de snapshot pour ce code (accès direct / refresh) : retour à l'accueil.
|
||||
if (!snapshot || snapshot.code !== code) {
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col items-center justify-center gap-4 p-6 text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Room introuvable ou session perdue.
|
||||
</p>
|
||||
<Link href="/">
|
||||
<Button variant="secondary">Retour à l'accueil</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-svh items-start justify-center p-6 pt-12">
|
||||
<div className="flex w-full max-w-md flex-col gap-6">
|
||||
<header className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs uppercase">Code room</p>
|
||||
<p className="font-heading text-2xl font-bold tracking-widest">
|
||||
{snapshot.code}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`size-2.5 rounded-full ${connected ? "bg-green-500" : "bg-red-500"}`}
|
||||
title={connected ? "Connecté" : "Déconnecté"}
|
||||
/>
|
||||
</header>
|
||||
|
||||
{snapshot.status === "lobby" && <LobbyView snapshot={snapshot} />}
|
||||
{(snapshot.status === "in_round" ||
|
||||
snapshot.status === "reveal" ||
|
||||
snapshot.status === "scores") && <QuizView snapshot={snapshot} />}
|
||||
{snapshot.status === "ended" && <GameEndView snapshot={snapshot} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,30 +1,62 @@
|
|||
import { create } from "zustand"
|
||||
import type {
|
||||
ErrorPayload,
|
||||
PlayerScore,
|
||||
RoomCreatedPayload,
|
||||
RoomSnapshot,
|
||||
RoundStartPayload,
|
||||
VoteAckPayload,
|
||||
} from "@nerdware/shared"
|
||||
import { socket } from "@/lib/socket"
|
||||
|
||||
/** Manche en cours côté client (depuis round:start). */
|
||||
export interface ActiveRound {
|
||||
type: RoundStartPayload["type"]
|
||||
djId?: string
|
||||
endsAt: number
|
||||
payload: unknown
|
||||
}
|
||||
|
||||
/** Reveal reçu (round:reveal), payloads typés par mode au moment de l'affichage. */
|
||||
export interface RoundReveal {
|
||||
truth: unknown
|
||||
perPlayerResult: unknown
|
||||
}
|
||||
|
||||
interface RoomState {
|
||||
connected: boolean
|
||||
/** Identité locale, persistée pour reconnaître "moi" dans le snapshot. */
|
||||
/** Identité locale, pour reconnaître "moi" dans le snapshot. */
|
||||
playerId: string | null
|
||||
roomCode: string | null
|
||||
snapshot: RoomSnapshot | null
|
||||
lastError: ErrorPayload | null
|
||||
|
||||
// État de jeu (poussé par le moteur serveur).
|
||||
round: ActiveRound | null
|
||||
voteProgress: VoteAckPayload | null
|
||||
reveal: RoundReveal | null
|
||||
myChoiceIndex: number | null
|
||||
finalScores: PlayerScore[] | null
|
||||
|
||||
createRoom: (playerName: string) => Promise<RoomCreatedPayload>
|
||||
joinRoom: (roomCode: string, playerName: string) => Promise<RoomCreatedPayload>
|
||||
startGame: (questionCount: number) => Promise<void>
|
||||
vote: (choiceIndex: number) => void
|
||||
clearError: () => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export const useRoomStore = create<RoomState>((set) => ({
|
||||
export const useRoomStore = create<RoomState>((set, get) => ({
|
||||
connected: socket.connected,
|
||||
playerId: null,
|
||||
roomCode: null,
|
||||
snapshot: null,
|
||||
lastError: null,
|
||||
round: null,
|
||||
voteProgress: null,
|
||||
reveal: null,
|
||||
myChoiceIndex: null,
|
||||
finalScores: null,
|
||||
|
||||
createRoom: (playerName) =>
|
||||
new Promise((resolve, reject) => {
|
||||
|
|
@ -52,8 +84,47 @@ export const useRoomStore = create<RoomState>((set) => ({
|
|||
})
|
||||
}),
|
||||
|
||||
startGame: (questionCount) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const rounds = Array.from({ length: questionCount }, () => ({
|
||||
type: "quiz" as const,
|
||||
}))
|
||||
// On pousse la séquence de manches, puis on lance.
|
||||
socket.emit("lobby:updateSettings", {
|
||||
blindtestMode: get().snapshot?.settings.blindtestMode ?? "title_artist",
|
||||
roundDuration: 20,
|
||||
rounds,
|
||||
})
|
||||
socket.emit("game:start", (res) => {
|
||||
if (res.ok) {
|
||||
resolve()
|
||||
} else {
|
||||
set({ lastError: res.error })
|
||||
reject(res.error)
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
||||
vote: (choiceIndex) => {
|
||||
if (get().myChoiceIndex !== null) {
|
||||
return // vote déjà émis pour cette manche
|
||||
}
|
||||
set({ myChoiceIndex: choiceIndex })
|
||||
socket.emit("round:vote", { answer: { choiceIndex } })
|
||||
},
|
||||
|
||||
clearError: () => set({ lastError: null }),
|
||||
reset: () => set({ roomCode: null, snapshot: null, lastError: null }),
|
||||
reset: () =>
|
||||
set({
|
||||
roomCode: null,
|
||||
snapshot: null,
|
||||
lastError: null,
|
||||
round: null,
|
||||
voteProgress: null,
|
||||
reveal: null,
|
||||
myChoiceIndex: null,
|
||||
finalScores: null,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Listeners socket → on pousse l'état serveur dans le store (source de vérité).
|
||||
|
|
@ -61,3 +132,24 @@ socket.on("connect", () => useRoomStore.setState({ connected: true }))
|
|||
socket.on("disconnect", () => useRoomStore.setState({ connected: false }))
|
||||
socket.on("room:state", (snapshot) => useRoomStore.setState({ snapshot }))
|
||||
socket.on("error", (error) => useRoomStore.setState({ lastError: error }))
|
||||
|
||||
socket.on("round:start", (payload) =>
|
||||
useRoomStore.setState({
|
||||
round: {
|
||||
type: payload.type,
|
||||
djId: payload.djId,
|
||||
endsAt: payload.endsAt,
|
||||
payload: payload.payload,
|
||||
},
|
||||
voteProgress: null,
|
||||
reveal: null,
|
||||
myChoiceIndex: null,
|
||||
})
|
||||
)
|
||||
socket.on("round:voteAck", (voteProgress) =>
|
||||
useRoomStore.setState({ voteProgress })
|
||||
)
|
||||
socket.on("round:reveal", (reveal) => useRoomStore.setState({ reveal }))
|
||||
socket.on("game:end", ({ finalScores }) =>
|
||||
useRoomStore.setState({ finalScores })
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
export * from "./domain"
|
||||
export * from "./events"
|
||||
export * from "./quiz"
|
||||
|
|
|
|||
30
packages/shared/src/quiz.ts
Normal file
30
packages/shared/src/quiz.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// Types concrets de l'épreuve Quiz, partagés client/serveur.
|
||||
// Le payload générique du moteur est `unknown` ; le client le narrow via type === "quiz".
|
||||
// V1 : formats mcq + truefalse (free / image_reveal arrivent avec la DB).
|
||||
|
||||
import type { QuizFormat } from "./domain"
|
||||
|
||||
/** Payload diffusé au lancement d'une manche quiz (round:start), SANS la réponse. */
|
||||
export interface QuizQuestionPayload {
|
||||
format: QuizFormat
|
||||
prompt: string
|
||||
/** Choix proposés (truefalse = ["Vrai", "Faux"]). */
|
||||
choices: string[]
|
||||
category?: string
|
||||
/** 1 (facile) .. 3 (difficile). */
|
||||
difficulty?: number
|
||||
}
|
||||
|
||||
/** Vérité révélée à tous (round:reveal → truth). */
|
||||
export interface QuizRevealTruth {
|
||||
correctIndex: number
|
||||
}
|
||||
|
||||
/** Résultat d'un joueur sur la manche. */
|
||||
export interface QuizPlayerResult {
|
||||
choiceIndex: number | null
|
||||
correct: boolean
|
||||
}
|
||||
|
||||
/** round:reveal → perPlayerResult : playerId → résultat. */
|
||||
export type QuizPerPlayerResult = Record<string, QuizPlayerResult>
|
||||
Loading…
Add table
Reference in a new issue