From 56115b5a9f63f314e209cd1d7ff3b1f318718cf9 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Mon, 8 Jun 2026 14:55:52 +0200 Subject: [PATCH 01/51] feat(shared,server): scaffold @nerdware/shared types + Fastify/Socket.IO server - packages/shared (@nerdware/shared): domain model (Room, Player, Settings, Round, votes, scores) + fully typed Socket.IO events, consumed as source - apps/server (@nerdware/server): Fastify boot + /health, typed Socket.IO attached to the HTTP server, env config, graceful shutdown - gitignore: allow .env.example to be committed Roadmap V1 step 1. Room logic (room:create/join) comes next. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 1 + apps/server/.env.example | 4 + apps/server/eslint.config.js | 15 +++ apps/server/package.json | 30 +++++ apps/server/src/env.ts | 20 +++ apps/server/src/index.ts | 35 ++++++ apps/server/src/socket.ts | 39 ++++++ apps/server/tsconfig.json | 16 +++ bun.lock | 207 +++++++++++++++++++++++++++++-- packages/shared/eslint.config.js | 15 +++ packages/shared/package.json | 23 ++++ packages/shared/src/domain.ts | 89 +++++++++++++ packages/shared/src/events.ts | 138 +++++++++++++++++++++ packages/shared/src/index.ts | 2 + packages/shared/tsconfig.json | 14 +++ 15 files changed, 639 insertions(+), 9 deletions(-) create mode 100644 apps/server/.env.example create mode 100644 apps/server/eslint.config.js create mode 100644 apps/server/package.json create mode 100644 apps/server/src/env.ts create mode 100644 apps/server/src/index.ts create mode 100644 apps/server/src/socket.ts create mode 100644 apps/server/tsconfig.json create mode 100644 packages/shared/eslint.config.js create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/domain.ts create mode 100644 packages/shared/src/events.ts create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/tsconfig.json diff --git a/.gitignore b/.gitignore index 0877ace..19d9751 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ dist-ssr # env files (can opt-in for committing if needed) .env* +!.env.example # turbo .turbo diff --git a/apps/server/.env.example b/apps/server/.env.example new file mode 100644 index 0000000..4680059 --- /dev/null +++ b/apps/server/.env.example @@ -0,0 +1,4 @@ +NODE_ENV=development +HOST=0.0.0.0 +PORT=3001 +CORS_ORIGINS=http://localhost:5173 diff --git a/apps/server/eslint.config.js b/apps/server/eslint.config.js new file mode 100644 index 0000000..c414f33 --- /dev/null +++ b/apps/server/eslint.config.js @@ -0,0 +1,15 @@ +import js from "@eslint/js" +import globals from "globals" +import tseslint from "typescript-eslint" +import { defineConfig, globalIgnores } from "eslint/config" + +export default defineConfig([ + globalIgnores(["dist"]), + { + files: ["**/*.ts"], + extends: [js.configs.recommended, tseslint.configs.recommended], + languageOptions: { + globals: globals.node, + }, + }, +]) diff --git a/apps/server/package.json b/apps/server/package.json new file mode 100644 index 0000000..953e426 --- /dev/null +++ b/apps/server/package.json @@ -0,0 +1,30 @@ +{ + "name": "@nerdware/server", + "version": "0.0.1", + "type": "module", + "private": true, + "scripts": { + "dev": "bun --watch src/index.ts", + "start": "bun src/index.ts", + "build": "bun build ./src/index.ts --target bun --outdir dist", + "lint": "eslint", + "format": "prettier --write \"**/*.ts\"", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@fastify/cors": "^11", + "@nerdware/shared": "workspace:*", + "fastify": "^5", + "socket.io": "^4.8.1" + }, + "devDependencies": { + "@eslint/js": "^10", + "@types/bun": "latest", + "@types/node": "^24", + "eslint": "^10", + "globals": "^17", + "pino-pretty": "^13", + "typescript": "~6", + "typescript-eslint": "^8" + } +} diff --git a/apps/server/src/env.ts b/apps/server/src/env.ts new file mode 100644 index 0000000..5551908 --- /dev/null +++ b/apps/server/src/env.ts @@ -0,0 +1,20 @@ +// Configuration lue depuis l'environnement. Bun charge .env automatiquement. +// Secrets (YouTube, DB) viendront s'ajouter ici — jamais en dur. + +function num(value: string | undefined, fallback: number): number { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : fallback +} + +export const env = { + nodeEnv: process.env.NODE_ENV ?? "development", + host: process.env.HOST ?? "0.0.0.0", + port: num(process.env.PORT, 3001), + /** Origines autorisées pour CORS / Socket.IO (séparées par des virgules). */ + corsOrigins: (process.env.CORS_ORIGINS ?? "http://localhost:5173") + .split(",") + .map((o) => o.trim()) + .filter(Boolean), +} + +export const isDev = env.nodeEnv !== "production" diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts new file mode 100644 index 0000000..b613840 --- /dev/null +++ b/apps/server/src/index.ts @@ -0,0 +1,35 @@ +import Fastify from "fastify" +import cors from "@fastify/cors" +import { env, isDev } from "./env" +import { createSocketServer } from "./socket" + +const app = Fastify({ + logger: isDev + ? { transport: { target: "pino-pretty" } } + : true, +}) + +await app.register(cors, { origin: env.corsOrigins }) + +app.get("/health", async () => ({ status: "ok", uptime: process.uptime() })) + +// Socket.IO se greffe sur le serveur HTTP sous-jacent de Fastify. +const io = createSocketServer(app.server) + +try { + await app.listen({ host: env.host, port: env.port }) + app.log.info(`Socket.IO ready (${io.engine.clientsCount} clients)`) +} catch (err) { + app.log.error(err) + process.exit(1) +} + +// Arrêt propre : on ferme Socket.IO puis Fastify. +for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.on(signal, async () => { + app.log.info(`${signal} received, shutting down`) + await io.close() + await app.close() + process.exit(0) + }) +} diff --git a/apps/server/src/socket.ts b/apps/server/src/socket.ts new file mode 100644 index 0000000..a930c93 --- /dev/null +++ b/apps/server/src/socket.ts @@ -0,0 +1,39 @@ +import type { Server as HttpServer } from "node:http" +import { Server } from "socket.io" +import type { + ClientToServerEvents, + InterServerEvents, + ServerToClientEvents, + SocketData, +} from "@nerdware/shared" +import { env } from "./env" + +/** Serveur Socket.IO entièrement typé via les events partagés. */ +export type IoServer = Server< + ClientToServerEvents, + ServerToClientEvents, + InterServerEvents, + SocketData +> + +/** + * Crée et attache le serveur Socket.IO au serveur HTTP de Fastify. + * La logique des rooms (room:create / room:join) arrive à l'étape 2. + */ +export function createSocketServer(httpServer: HttpServer): IoServer { + const io: IoServer = new Server(httpServer, { + cors: { + origin: env.corsOrigins, + methods: ["GET", "POST"], + }, + }) + + io.on("connection", (socket) => { + console.log(`[ws] connect ${socket.id}`) + socket.on("disconnect", (reason) => { + console.log(`[ws] disconnect ${socket.id} (${reason})`) + }) + }) + + return io +} diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json new file mode 100644 index 0000000..330d21a --- /dev/null +++ b/apps/server/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["bun"], + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "verbatimModuleSyntax": true, + "resolveJsonModule": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/bun.lock b/bun.lock index 43ae77a..c176aca 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,26 @@ "typescript": "~6", }, }, + "apps/server": { + "name": "@nerdware/server", + "version": "0.0.1", + "dependencies": { + "@fastify/cors": "^11", + "@nerdware/shared": "workspace:*", + "fastify": "^5", + "socket.io": "^4.8.1", + }, + "devDependencies": { + "@eslint/js": "^10", + "@types/bun": "latest", + "@types/node": "^24", + "eslint": "^10", + "globals": "^17", + "pino-pretty": "^13", + "typescript": "~6", + "typescript-eslint": "^8", + }, + }, "apps/web": { "name": "web", "version": "0.0.1", @@ -36,6 +56,17 @@ "vite": "^8", }, }, + "packages/shared": { + "name": "@nerdware/shared", + "version": "0.0.1", + "devDependencies": { + "@eslint/js": "^10", + "eslint": "^10", + "globals": "^17", + "typescript": "~6", + "typescript-eslint": "^8", + }, + }, "packages/ui": { "name": "@workspace/ui", "version": "0.0.0", @@ -205,6 +236,20 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="], + "@fastify/ajv-compiler": ["@fastify/ajv-compiler@4.0.5", "", { "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0" } }, "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A=="], + + "@fastify/cors": ["@fastify/cors@11.2.0", "", { "dependencies": { "fastify-plugin": "^5.0.0", "toad-cache": "^3.7.0" } }, "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw=="], + + "@fastify/error": ["@fastify/error@4.2.0", "", {}, "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ=="], + + "@fastify/fast-json-stringify-compiler": ["@fastify/fast-json-stringify-compiler@5.0.3", "", { "dependencies": { "fast-json-stringify": "^6.0.0" } }, "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ=="], + + "@fastify/forwarded": ["@fastify/forwarded@3.0.1", "", {}, "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw=="], + + "@fastify/merge-json-schemas": ["@fastify/merge-json-schemas@0.2.1", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A=="], + + "@fastify/proxy-addr": ["@fastify/proxy-addr@5.1.0", "", { "dependencies": { "@fastify/forwarded": "^3.0.0", "ipaddr.js": "^2.1.0" } }, "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw=="], + "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], @@ -277,6 +322,10 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + "@nerdware/server": ["@nerdware/server@workspace:apps/server"], + + "@nerdware/shared": ["@nerdware/shared@workspace:packages/shared"], + "@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="], "@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], @@ -297,6 +346,8 @@ "@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="], + "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], + "@radix-ui/number": ["@radix-ui/number@1.1.2", "", {}, "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.4", "", {}, "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ=="], @@ -453,6 +504,8 @@ "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], + "@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="], + "@tailwindcss/node": ["@tailwindcss/node@4.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.0", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.0", "@tailwindcss/oxide-darwin-arm64": "4.3.0", "@tailwindcss/oxide-darwin-x64": "4.3.0", "@tailwindcss/oxide-freebsd-x64": "4.3.0", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", "@tailwindcss/oxide-linux-x64-musl": "4.3.0", "@tailwindcss/oxide-wasm32-wasi": "4.3.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg=="], @@ -501,6 +554,10 @@ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="], + "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], @@ -519,6 +576,8 @@ "@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="], + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.60.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.60.1", "@typescript-eslint/type-utils": "8.60.1", "@typescript-eslint/utils": "8.60.1", "@typescript-eslint/visitor-keys": "8.60.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.60.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.60.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.60.1", "@typescript-eslint/types": "8.60.1", "@typescript-eslint/typescript-estree": "8.60.1", "@typescript-eslint/visitor-keys": "8.60.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA=="], @@ -543,7 +602,9 @@ "@workspace/ui": ["@workspace/ui@workspace:packages/ui"], - "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + "abstract-logging": ["abstract-logging@2.0.1", "", {}, "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA=="], + + "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], @@ -567,8 +628,14 @@ "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], + "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], + + "avvio": ["avvio@9.2.0", "", { "dependencies": { "@fastify/error": "^4.0.0", "fastq": "^1.17.1" } }, "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ=="], + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "base64id": ["base64id@2.0.0", "", {}, "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.34", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw=="], "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], @@ -579,6 +646,8 @@ "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], @@ -613,6 +682,8 @@ "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], + "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], @@ -637,6 +708,8 @@ "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + "dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "dedent": ["dedent@1.7.2", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA=="], @@ -653,6 +726,8 @@ "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], @@ -673,6 +748,12 @@ "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + + "engine.io": ["engine.io@6.6.8", "", { "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", "@types/ws": "^8.5.12", "accepts": "~1.3.4", "base64id": "2.0.0", "cookie": "~0.7.2", "cors": "~2.8.5", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.20.1" } }, "sha512-2agL3ueZhqxoVrfmntO8yuVj+uNSlIOnhykYHk3Cq0ShYPdUjjUiSJrQvXjq01I9jAuI0Zl2YO8Evv5Mqytm5g=="], + + "engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="], + "enhanced-resolve": ["enhanced-resolve@5.23.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA=="], "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], @@ -729,14 +810,24 @@ "express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="], + "fast-copy": ["fast-copy@4.0.3", "", {}, "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw=="], + + "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + "fast-json-stringify": ["fast-json-stringify@6.4.0", "", { "dependencies": { "@fastify/merge-json-schemas": "^0.2.0", "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0", "json-schema-ref-resolver": "^3.0.0", "rfdc": "^1.2.0" } }, "sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ=="], + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + "fast-querystring": ["fast-querystring@1.1.2", "", { "dependencies": { "fast-decode-uri-component": "^1.0.1" } }, "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg=="], + + "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], + "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], @@ -745,6 +836,10 @@ "fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="], + "fastify": ["fastify@5.8.5", "", { "dependencies": { "@fastify/ajv-compiler": "^4.0.5", "@fastify/error": "^4.0.0", "@fastify/fast-json-stringify-compiler": "^5.0.0", "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", "avvio": "^9.0.0", "fast-json-stringify": "^6.0.0", "find-my-way": "^9.0.0", "light-my-request": "^6.0.0", "pino": "^9.14.0 || ^10.1.0", "process-warning": "^5.0.0", "rfdc": "^1.3.1", "secure-json-parse": "^4.0.0", "semver": "^7.6.0", "toad-cache": "^3.7.0" } }, "sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q=="], + + "fastify-plugin": ["fastify-plugin@5.1.0", "", {}, "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw=="], + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], @@ -759,6 +854,8 @@ "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + "find-my-way": ["find-my-way@9.6.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ=="], + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], @@ -811,6 +908,8 @@ "headers-polyfill": ["headers-polyfill@5.0.1", "", { "dependencies": { "@types/set-cookie-parser": "^2.4.10", "set-cookie-parser": "^3.0.1" } }, "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA=="], + "help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="], + "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], @@ -835,7 +934,7 @@ "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], - "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "ipaddr.js": ["ipaddr.js@2.4.0", "", {}, "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ=="], "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], @@ -877,6 +976,8 @@ "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], @@ -887,6 +988,8 @@ "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], + "json-schema-ref-resolver": ["json-schema-ref-resolver@3.0.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A=="], + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], @@ -903,6 +1006,8 @@ "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + "light-my-request": ["light-my-request@6.6.0", "", { "dependencies": { "cookie": "^1.0.1", "process-warning": "^4.0.0", "set-cookie-parser": "^2.6.0" } }, "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], @@ -951,9 +1056,9 @@ "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], - "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], @@ -973,7 +1078,7 @@ "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], - "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], @@ -989,6 +1094,8 @@ "object-treeify": ["object-treeify@1.1.33", "", {}, "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A=="], + "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], @@ -1027,6 +1134,14 @@ "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "pino": ["pino@10.3.1", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg=="], + + "pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="], + + "pino-pretty": ["pino-pretty@13.1.3", "", { "dependencies": { "colorette": "^2.0.7", "dateformat": "^4.6.3", "fast-copy": "^4.0.0", "fast-safe-stringify": "^2.1.1", "help-me": "^5.0.0", "joycon": "^3.1.1", "minimist": "^1.2.6", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pump": "^3.0.0", "secure-json-parse": "^4.0.0", "sonic-boom": "^4.0.1", "strip-json-comments": "^5.0.2" }, "bin": { "pino-pretty": "bin.js" } }, "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg=="], + + "pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="], + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], @@ -1043,16 +1158,22 @@ "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], + "process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], + "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], + "radix-ui": ["radix-ui@1.5.0", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-accessible-icon": "1.1.9", "@radix-ui/react-accordion": "1.2.13", "@radix-ui/react-alert-dialog": "1.1.16", "@radix-ui/react-arrow": "1.1.9", "@radix-ui/react-aspect-ratio": "1.1.9", "@radix-ui/react-avatar": "1.1.12", "@radix-ui/react-checkbox": "1.3.4", "@radix-ui/react-collapsible": "1.1.13", "@radix-ui/react-collection": "1.1.9", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-context-menu": "2.3.0", "@radix-ui/react-dialog": "1.1.16", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.12", "@radix-ui/react-dropdown-menu": "2.1.17", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.9", "@radix-ui/react-form": "0.1.9", "@radix-ui/react-hover-card": "1.1.16", "@radix-ui/react-label": "2.1.9", "@radix-ui/react-menu": "2.1.17", "@radix-ui/react-menubar": "1.1.17", "@radix-ui/react-navigation-menu": "1.2.15", "@radix-ui/react-one-time-password-field": "0.1.9", "@radix-ui/react-password-toggle-field": "0.1.4", "@radix-ui/react-popover": "1.1.16", "@radix-ui/react-popper": "1.3.0", "@radix-ui/react-portal": "1.1.11", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-progress": "1.1.9", "@radix-ui/react-radio-group": "1.4.0", "@radix-ui/react-roving-focus": "1.1.12", "@radix-ui/react-scroll-area": "1.2.11", "@radix-ui/react-select": "2.3.0", "@radix-ui/react-separator": "1.1.9", "@radix-ui/react-slider": "1.4.0", "@radix-ui/react-slot": "1.2.5", "@radix-ui/react-switch": "1.3.0", "@radix-ui/react-tabs": "1.1.14", "@radix-ui/react-toast": "1.2.16", "@radix-ui/react-toggle": "1.1.11", "@radix-ui/react-toggle-group": "1.1.12", "@radix-ui/react-toolbar": "1.1.12", "@radix-ui/react-tooltip": "1.2.9", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-effect-event": "0.0.3", "@radix-ui/react-use-escape-keydown": "1.1.2", "@radix-ui/react-use-is-hydrated": "0.1.1", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-size": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nzh2HNpClgB31FBHRqt2xG8XNUfVfQRpf34hACC5PNrXTd5JdXdqOXwLs3BL+D8CNYiNQiJiT8QGr5Q4vq+00w=="], "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], @@ -1069,6 +1190,8 @@ "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], + "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], @@ -1079,10 +1202,14 @@ "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], + "ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="], + "rettime": ["rettime@0.11.11", "", {}, "sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ=="], "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], + "rolldown": ["rolldown@1.0.3", "", { "dependencies": { "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-x64": "1.0.3", "@rolldown/binding-freebsd-x64": "1.0.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", "@rolldown/binding-linux-arm64-gnu": "1.0.3", "@rolldown/binding-linux-arm64-musl": "1.0.3", "@rolldown/binding-linux-ppc64-gnu": "1.0.3", "@rolldown/binding-linux-s390x-gnu": "1.0.3", "@rolldown/binding-linux-x64-gnu": "1.0.3", "@rolldown/binding-linux-x64-musl": "1.0.3", "@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.0.3", "@rolldown/binding-win32-x64-msvc": "1.0.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g=="], "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], @@ -1091,17 +1218,23 @@ "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + "safe-regex2": ["safe-regex2@5.1.1", "", { "dependencies": { "ret": "~0.5.0" }, "bin": { "safe-regex2": "bin/safe-regex2.js" } }, "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA=="], + + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="], + + "semver": ["semver@7.8.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ=="], "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], - "set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="], + "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], @@ -1123,10 +1256,20 @@ "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + "socket.io": ["socket.io@4.8.3", "", { "dependencies": { "accepts": "~1.3.4", "base64id": "~2.0.0", "cors": "~2.8.5", "debug": "~4.4.1", "engine.io": "~6.6.0", "socket.io-adapter": "~2.5.2", "socket.io-parser": "~4.2.4" } }, "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A=="], + + "socket.io-adapter": ["socket.io-adapter@2.5.7", "", { "dependencies": { "debug": "~4.4.1", "ws": "~8.20.1" } }, "sha512-e0LyK91f3cUxTmv95/KzoLg47+zF+s/sbxRGDNsyG4dmIP8ZSX8ax6byOxfJXeNNtS/8AZlfD+uP7gBeR7DLlg=="], + + "socket.io-parser": ["socket.io-parser@4.2.6", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg=="], + + "sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="], @@ -1143,6 +1286,8 @@ "strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="], + "strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], @@ -1151,6 +1296,8 @@ "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + "thread-stream": ["thread-stream@4.2.0", "", { "dependencies": { "real-require": "^1.0.0" } }, "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ=="], + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], @@ -1161,6 +1308,8 @@ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "toad-cache": ["toad-cache@3.7.1", "", {}, "sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ=="], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], "tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="], @@ -1225,6 +1374,8 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], + "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], @@ -1249,6 +1400,12 @@ "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], @@ -1257,6 +1414,8 @@ "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + "@fastify/ajv-compiler/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "@modelcontextprotocol/sdk/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "@mswjs/interceptors/@open-draft/deferred-promise": ["@open-draft/deferred-promise@2.2.0", "", {}, "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA=="], @@ -1275,8 +1434,6 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], - "@typescript-eslint/typescript-estree/semver": ["semver@7.8.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ=="], - "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -1285,12 +1442,24 @@ "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "engine.io/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + "enquirer/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + "express/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "fast-json-stringify/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "headers-polyfill/set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="], + + "light-my-request/process-warning": ["process-warning@4.0.1", "", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="], + "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], @@ -1301,14 +1470,22 @@ "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + "proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], "router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + "send/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "shadcn/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="], + "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "type-is/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -1329,6 +1506,8 @@ "@dotenvx/dotenvx/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], + "@fastify/ajv-compiler/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], @@ -1339,10 +1518,20 @@ "enquirer/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "express/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "fast-json-stringify/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "msw/@inquirer/confirm/@inquirer/core": ["@inquirer/core@11.2.1", "", { "dependencies": { "@inquirer/ansi": "^2.0.7", "@inquirer/figures": "^2.0.7", "@inquirer/type": "^4.0.7", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA=="], "msw/@inquirer/confirm/@inquirer/type": ["@inquirer/type@4.0.7", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g=="], + "send/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "type-is/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + "wrap-ansi/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], diff --git a/packages/shared/eslint.config.js b/packages/shared/eslint.config.js new file mode 100644 index 0000000..c414f33 --- /dev/null +++ b/packages/shared/eslint.config.js @@ -0,0 +1,15 @@ +import js from "@eslint/js" +import globals from "globals" +import tseslint from "typescript-eslint" +import { defineConfig, globalIgnores } from "eslint/config" + +export default defineConfig([ + globalIgnores(["dist"]), + { + files: ["**/*.ts"], + extends: [js.configs.recommended, tseslint.configs.recommended], + languageOptions: { + globals: globals.node, + }, + }, +]) diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..c80f8da --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,23 @@ +{ + "name": "@nerdware/shared", + "version": "0.0.1", + "type": "module", + "private": true, + "scripts": { + "lint": "eslint", + "format": "prettier --write \"**/*.ts\"", + "typecheck": "tsc --noEmit" + }, + "exports": { + ".": "./src/index.ts", + "./domain": "./src/domain.ts", + "./events": "./src/events.ts" + }, + "devDependencies": { + "@eslint/js": "^10", + "eslint": "^10", + "globals": "^17", + "typescript": "~6", + "typescript-eslint": "^8" + } +} diff --git a/packages/shared/src/domain.ts b/packages/shared/src/domain.ts new file mode 100644 index 0000000..fcfa8e0 --- /dev/null +++ b/packages/shared/src/domain.ts @@ -0,0 +1,89 @@ +// Modèle de domaine partagé entre client et serveur. +// Le serveur est la source de vérité ; le client n'affiche qu'un état reçu. + +/** Statut courant d'une room (machine à états du jeu). */ +export type RoomStatus = "lobby" | "in_round" | "reveal" | "scores" | "ended" + +/** Types d'épreuves disponibles. Étendre = ajouter une valeur + un module GameRound. */ +export type RoundType = "blindtest" | "quiz" + +/** Modes de blindtest, déterminent la forme du vote et le barème. */ +export type BlindtestMode = "title_artist" | "who_added" | "mixed" + +/** Formats de question quiz. */ +export type QuizFormat = "mcq" | "truefalse" | "free" | "image_reveal" + +/** Joueur connecté à une room. */ +export interface Player { + id: string + name: string + connected: boolean +} + +/** Configuration d'une épreuve planifiée dans la séquence de la partie. */ +export interface RoundConfig { + type: RoundType +} + +/** Réglages de la room, modifiables dans le lobby par l'hôte. */ +export interface RoomSettings { + blindtestMode: BlindtestMode + /** Durée d'une manche en secondes (def. 60). */ + roundDuration: number + /** Séquence d'épreuves de la partie. */ + rounds: RoundConfig[] +} + +/** Réglages par défaut d'une nouvelle room. */ +export const DEFAULT_ROOM_SETTINGS: RoomSettings = { + blindtestMode: "title_artist", + roundDuration: 60, + rounds: [], +} + +// --- Votes --------------------------------------------------------------- + +/** Vote blindtest, forme selon RoomSettings.blindtestMode. */ +export type BlindtestAnswer = + | { title: string; artist: string } + | { guessedPlayerId: string } + | { title: string; artist: string; guessedPlayerId: string } + +/** Vote quiz, forme selon le format de la question. */ +export type QuizAnswer = { choiceIndex: number } | { text: string } + +/** Réponse soumise par un joueur pour la manche en cours. */ +export type Answer = BlindtestAnswer | QuizAnswer + +// --- Scores -------------------------------------------------------------- + +/** Score d'un joueur. */ +export interface PlayerScore { + playerId: string + score: number +} + +/** Variation de score appliquée à l'issue d'une manche. */ +export interface ScoreDelta { + playerId: string + delta: number +} + +// --- Snapshot diffusé au client (room:state) ----------------------------- + +/** + * Vue publique de la room poussée à tous les clients. + * Ne contient JAMAIS de réponse non révélée ni d'info secrète (ex: submittedBy). + */ +export interface RoomSnapshot { + code: string + status: RoomStatus + hostId: string + players: Player[] + settings: RoomSettings + scores: PlayerScore[] + /** Index de la manche en cours, -1 dans le lobby. */ + currentRound: number + /** Nombre total de manches planifiées. */ + totalRounds: number +} diff --git a/packages/shared/src/events.ts b/packages/shared/src/events.ts new file mode 100644 index 0000000..2a653cf --- /dev/null +++ b/packages/shared/src/events.ts @@ -0,0 +1,138 @@ +// Events Socket.IO typés, partagés par le serveur et le client. +// Convention : `CLIENT → SERVER` et `SERVER → CLIENT`. Tout timestamp est serveur. + +import type { + Answer, + BlindtestMode, + PlayerScore, + RoomSnapshot, + RoundConfig, + RoundType, +} from "./domain" + +// --- Payloads ------------------------------------------------------------ + +export interface RoomCreatePayload { + playerName: string +} + +export interface RoomCreatedPayload { + roomCode: string + playerId: string +} + +export interface RoomJoinPayload { + roomCode: string + playerName: string +} + +export interface UpdateSettingsPayload { + blindtestMode: BlindtestMode + roundDuration: number + rounds: RoundConfig[] +} + +export interface PlayerRefPayload { + playerId: string +} + +export interface SubmitTrackPayload { + youtubeUrl: string +} + +export interface SubmitOkPayload { + accepted: boolean + reason?: string +} + +export interface RoundStartPayload { + type: RoundType + djId?: string + /** Timestamp serveur de fin de manche (startedAt + roundDuration). */ + endsAt: number + /** Payload spécifique au type d'épreuve, SANS la réponse. */ + payload: unknown +} + +export type MediaAction = "play" | "pause" | "seek" + +/** Contrôle média émis par le DJ uniquement. */ +export interface MediaControlPayload { + action: MediaAction + positionSec: number +} + +/** Diffusion de recalage à tous les clients. */ +export interface MediaSyncPayload { + action: MediaAction + positionSec: number + atServerTs: number +} + +export interface VotePayload { + answer: Answer +} + +/** Progression anonyme des votes. */ +export interface VoteAckPayload { + count: number + total: number +} + +export interface RevealPayload { + truth: unknown + perPlayerResult: unknown +} + +export interface ScoreUpdatePayload { + scores: PlayerScore[] +} + +export interface GameEndPayload { + finalScores: PlayerScore[] + spotifyExport?: unknown +} + +export interface ErrorPayload { + code: string + message: string +} + +// --- Maps d'events Socket.IO --------------------------------------------- + +/** Ack standard renvoyé par le serveur sur les commandes critiques. */ +export type Ack = (result: { ok: true; data: T } | { ok: false; error: ErrorPayload }) => void + +/** CLIENT → SERVER */ +export interface ClientToServerEvents { + "room:create": (payload: RoomCreatePayload, ack: Ack) => void + "room:join": (payload: RoomJoinPayload, ack: Ack) => void + "lobby:updateSettings": (payload: UpdateSettingsPayload) => void + "blindtest:submitTrack": (payload: SubmitTrackPayload, ack: Ack) => void + "media:control": (payload: MediaControlPayload) => void + "round:vote": (payload: VotePayload) => void +} + +/** SERVER → CLIENT */ +export interface ServerToClientEvents { + "room:state": (payload: RoomSnapshot) => void + "player:left": (payload: PlayerRefPayload) => void + "player:rejoined": (payload: PlayerRefPayload) => void + "blindtest:submitOk": (payload: SubmitOkPayload) => void + "round:start": (payload: RoundStartPayload) => void + "media:sync": (payload: MediaSyncPayload) => void + "round:voteAck": (payload: VoteAckPayload) => void + "round:reveal": (payload: RevealPayload) => void + "score:update": (payload: ScoreUpdatePayload) => void + "game:end": (payload: GameEndPayload) => void + "error": (payload: ErrorPayload) => void +} + +/** Events serveur ↔ serveur (inutilisés en V1, pas de Redis adapter). */ +export type InterServerEvents = Record + +/** Données attachées à chaque socket côté serveur. */ +export interface SocketData { + playerId?: string + roomCode?: string +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 0000000..313fded --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,2 @@ +export * from "./domain" +export * from "./events" diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..d70c92e --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "bundler", + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "verbatimModuleSyntax": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} -- 2.45.3 From 29faa18731bfc021577c47ba2a4e594791df8b64 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Mon, 8 Jun 2026 15:16:26 +0200 Subject: [PATCH 02/51] =?UTF-8?q?feat(server,web):=20first=20real-time=20c?= =?UTF-8?q?ycle=20=E2=80=94=20room=20create/join=20+=20lobby?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server (@nerdware/server): - RoomManager: in-memory rooms (Map), code generation, create/join, disconnect handling, public snapshot projection (no secrets) - Socket.IO handlers: room:create / room:join with typed acks, room:state broadcast on every change, lobby:updateSettings (host-only), player:left on disconnect Web (web): - typed Socket.IO client + Zustand store mirroring room:state - wouter routing: home (create/join) + minimal lobby (live player list) - @nerdware/shared wired into the client Roadmap V1 step 2. Verified end-to-end (create/join/broadcast/disconnect). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/src/handlers.ts | 106 +++++++++++++++++++++++++ apps/server/src/rooms.ts | 145 +++++++++++++++++++++++++++++++++++ apps/server/src/socket.ts | 10 ++- apps/web/.env.example | 1 + apps/web/package.json | 6 +- apps/web/src/App.tsx | 26 +++---- apps/web/src/lib/socket.ts | 13 ++++ apps/web/src/pages/home.tsx | 106 +++++++++++++++++++++++++ apps/web/src/pages/lobby.tsx | 71 +++++++++++++++++ apps/web/src/store/room.ts | 63 +++++++++++++++ bun.lock | 20 +++++ 11 files changed, 549 insertions(+), 18 deletions(-) create mode 100644 apps/server/src/handlers.ts create mode 100644 apps/server/src/rooms.ts create mode 100644 apps/web/.env.example create mode 100644 apps/web/src/lib/socket.ts create mode 100644 apps/web/src/pages/home.tsx create mode 100644 apps/web/src/pages/lobby.tsx create mode 100644 apps/web/src/store/room.ts diff --git a/apps/server/src/handlers.ts b/apps/server/src/handlers.ts new file mode 100644 index 0000000..8526002 --- /dev/null +++ b/apps/server/src/handlers.ts @@ -0,0 +1,106 @@ +// Handlers Socket.IO du cycle lobby. Enregistrés par socket à la connexion. + +import type { Socket } from "socket.io" +import type { + ClientToServerEvents, + ErrorPayload, + InterServerEvents, + ServerToClientEvents, + SocketData, +} from "@nerdware/shared" +import type { IoServer } from "./socket" +import { RoomError, RoomManager, type ServerRoom } from "./rooms" + +type AppSocket = Socket< + ClientToServerEvents, + ServerToClientEvents, + InterServerEvents, + SocketData +> + +const MAX_NAME_LENGTH = 24 + +function cleanName(raw: unknown): string | null { + if (typeof raw !== "string") { + return null + } + const name = raw.trim().slice(0, MAX_NAME_LENGTH) + return name.length > 0 ? name : null +} + +function toErrorPayload(err: unknown): ErrorPayload { + if (err instanceof RoomError) { + return { code: err.code, message: err.message } + } + return { code: "INTERNAL", message: "Une erreur est survenue." } +} + +export function registerRoomHandlers( + io: IoServer, + socket: AppSocket, + rooms: RoomManager +): void { + const broadcastState = (room: ServerRoom) => { + io.to(room.code).emit("room:state", rooms.toSnapshot(room)) + } + + socket.on("room:create", (payload, ack) => { + const name = cleanName(payload?.playerName) + if (!name) { + ack({ ok: false, error: { code: "INVALID_NAME", message: "Pseudo invalide." } }) + return + } + try { + const { room, player } = rooms.create(name, socket.id) + socket.data.playerId = player.id + socket.data.roomCode = room.code + socket.join(room.code) + ack({ ok: true, data: { roomCode: room.code, playerId: player.id } }) + broadcastState(room) + } catch (err) { + ack({ ok: false, error: toErrorPayload(err) }) + } + }) + + socket.on("room:join", (payload, ack) => { + const name = cleanName(payload?.playerName) + const code = typeof payload?.roomCode === "string" ? payload.roomCode.trim().toUpperCase() : "" + if (!name) { + ack({ ok: false, error: { code: "INVALID_NAME", message: "Pseudo invalide." } }) + return + } + try { + const { room, player } = rooms.join(code, name, socket.id) + socket.data.playerId = player.id + socket.data.roomCode = room.code + socket.join(room.code) + ack({ ok: true, data: { roomCode: room.code, playerId: player.id } }) + broadcastState(room) + } catch (err) { + ack({ ok: false, error: toErrorPayload(err) }) + } + }) + + socket.on("lobby:updateSettings", (payload) => { + const code = socket.data.roomCode + const room = code ? rooms.get(code) : undefined + if (!room || room.hostId !== socket.data.playerId) { + return + } + room.settings = { + blindtestMode: payload.blindtestMode, + roundDuration: payload.roundDuration, + rounds: payload.rounds, + } + broadcastState(room) + }) + + socket.on("disconnect", () => { + const affected = rooms.handleDisconnect(socket.id) + if (!affected) { + return + } + io.to(affected.room.code).emit("player:left", { playerId: affected.playerId }) + broadcastState(affected.room) + }) +} diff --git a/apps/server/src/rooms.ts b/apps/server/src/rooms.ts new file mode 100644 index 0000000..c5dd2fd --- /dev/null +++ b/apps/server/src/rooms.ts @@ -0,0 +1,145 @@ +// Gestion des rooms en mémoire. Source de vérité côté serveur. +// Pas de Redis en V1 : une simple Map suffit pour jouer entre potes. + +import { randomUUID } from "node:crypto" +import { + DEFAULT_ROOM_SETTINGS, + type RoomSettings, + type RoomSnapshot, + type RoomStatus, +} from "@nerdware/shared" + +/** Joueur côté serveur : porte le socketId, jamais exposé au client. */ +export interface ServerPlayer { + id: string + name: string + connected: boolean + socketId: string | null +} + +/** Room côté serveur : état runtime complet (le snapshot public en dérive). */ +export interface ServerRoom { + code: string + status: RoomStatus + hostId: string + players: Map + settings: RoomSettings + scores: Map + currentRound: number +} + +/** Codes lisibles, sans caractères ambigus (0/O, 1/I/L). */ +const CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" +const CODE_LENGTH = 4 + +export class RoomManager { + private rooms = new Map() + + /** Crée une room et y place le créateur comme hôte. */ + create(playerName: string, socketId: string): { room: ServerRoom; player: ServerPlayer } { + const code = this.generateCode() + const player = this.makePlayer(playerName, socketId) + const room: ServerRoom = { + code, + status: "lobby", + hostId: player.id, + players: new Map([[player.id, player]]), + settings: { ...DEFAULT_ROOM_SETTINGS }, + scores: new Map([[player.id, 0]]), + currentRound: -1, + } + this.rooms.set(code, room) + return { room, player } + } + + /** Ajoute un joueur à une room existante. Lève une erreur si introuvable ou fermée. */ + join( + code: string, + playerName: string, + socketId: string + ): { room: ServerRoom; player: ServerPlayer } { + const room = this.rooms.get(code) + if (!room) { + throw new RoomError("ROOM_NOT_FOUND", "Cette room n'existe pas.") + } + if (room.status !== "lobby") { + throw new RoomError("ROOM_IN_PROGRESS", "La partie a déjà commencé.") + } + const player = this.makePlayer(playerName, socketId) + room.players.set(player.id, player) + room.scores.set(player.id, 0) + return { room, player } + } + + get(code: string): ServerRoom | undefined { + return this.rooms.get(code) + } + + /** + * Marque le joueur d'un socket comme déconnecté. + * Retourne la room affectée et le playerId, ou null si non rattaché. + * La room et le joueur restent en mémoire pour permettre un futur rejoin. + */ + handleDisconnect(socketId: string): { room: ServerRoom; playerId: string } | null { + for (const room of this.rooms.values()) { + for (const player of room.players.values()) { + if (player.socketId === socketId) { + player.connected = false + player.socketId = null + return { room, playerId: player.id } + } + } + } + return null + } + + /** Projette la room vers la vue publique diffusée aux clients (sans secrets). */ + toSnapshot(room: ServerRoom): RoomSnapshot { + return { + code: room.code, + status: room.status, + hostId: room.hostId, + players: [...room.players.values()].map((p) => ({ + id: p.id, + name: p.name, + connected: p.connected, + })), + settings: room.settings, + scores: [...room.scores.entries()].map(([playerId, score]) => ({ + playerId, + score, + })), + currentRound: room.currentRound, + totalRounds: room.settings.rounds.length, + } + } + + private makePlayer(name: string, socketId: string): ServerPlayer { + return { id: randomUUID(), name, connected: true, socketId } + } + + private generateCode(): string { + // Très peu de collisions à cette échelle ; on retire quand même par sûreté. + for (let attempt = 0; attempt < 100; attempt++) { + let code = "" + for (let i = 0; i < CODE_LENGTH; i++) { + code += CODE_ALPHABET[Math.floor(Math.random() * CODE_ALPHABET.length)] + } + if (!this.rooms.has(code)) { + return code + } + } + throw new RoomError("CODE_EXHAUSTED", "Impossible de générer un code de room.") + } +} + +/** Erreur métier portant un code stable pour le client. */ +export class RoomError extends Error { + constructor( + public code: string, + message: string + ) { + super(message) + this.name = "RoomError" + } +} diff --git a/apps/server/src/socket.ts b/apps/server/src/socket.ts index a930c93..e03df05 100644 --- a/apps/server/src/socket.ts +++ b/apps/server/src/socket.ts @@ -7,6 +7,8 @@ import type { SocketData, } from "@nerdware/shared" import { env } from "./env" +import { registerRoomHandlers } from "./handlers" +import { RoomManager } from "./rooms" /** Serveur Socket.IO entièrement typé via les events partagés. */ export type IoServer = Server< @@ -16,10 +18,7 @@ export type IoServer = Server< SocketData > -/** - * Crée et attache le serveur Socket.IO au serveur HTTP de Fastify. - * La logique des rooms (room:create / room:join) arrive à l'étape 2. - */ +/** Crée et attache le serveur Socket.IO au serveur HTTP de Fastify. */ export function createSocketServer(httpServer: HttpServer): IoServer { const io: IoServer = new Server(httpServer, { cors: { @@ -28,8 +27,11 @@ export function createSocketServer(httpServer: HttpServer): IoServer { }, }) + const rooms = new RoomManager() + io.on("connection", (socket) => { console.log(`[ws] connect ${socket.id}`) + registerRoomHandlers(io, socket, rooms) socket.on("disconnect", (reason) => { console.log(`[ws] disconnect ${socket.id} (${reason})`) }) diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..26e6ed8 --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1 @@ +VITE_SERVER_URL=http://localhost:3001 diff --git a/apps/web/package.json b/apps/web/package.json index c62537d..2c7c96e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,10 +12,14 @@ "preview": "vite preview" }, "dependencies": { + "@nerdware/shared": "workspace:*", "@workspace/ui": "workspace:*", "lucide-react": "^1.17.0", "react": "^19.2.6", - "react-dom": "^19.2.6" + "react-dom": "^19.2.6", + "socket.io-client": "^4.8.1", + "wouter": "^3.7.1", + "zustand": "^5.0.8" }, "devDependencies": { "@eslint/js": "^10", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 55a08a9..dce07e3 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,19 +1,19 @@ -import { Button } from "@workspace/ui/components/button" +import { Route, Switch } from "wouter" +import { HomePage } from "@/pages/home" +import { LobbyPage } from "@/pages/lobby" export function App() { return ( -
-
-
-

Project ready!

-

You may now add components and start building.

-

We've already added the button component for you.

- + + + + {(params) => } + + +
+

Page introuvable.

-
- (Press d to toggle dark mode) -
-
-
+ + ) } diff --git a/apps/web/src/lib/socket.ts b/apps/web/src/lib/socket.ts new file mode 100644 index 0000000..354fed9 --- /dev/null +++ b/apps/web/src/lib/socket.ts @@ -0,0 +1,13 @@ +import { io, type Socket } from "socket.io-client" +import type { + ClientToServerEvents, + ServerToClientEvents, +} from "@nerdware/shared" + +/** Socket typé : . Désync = erreur de compile. */ +export type AppSocket = Socket + +const SERVER_URL = import.meta.env.VITE_SERVER_URL ?? "http://localhost:3001" + +// Une seule connexion partagée pour toute l'app (reconnexion auto par défaut). +export const socket: AppSocket = io(SERVER_URL, { autoConnect: true }) diff --git a/apps/web/src/pages/home.tsx b/apps/web/src/pages/home.tsx new file mode 100644 index 0000000..c9d305c --- /dev/null +++ b/apps/web/src/pages/home.tsx @@ -0,0 +1,106 @@ +import { useState } from "react" +import { useLocation } from "wouter" +import { Button } from "@workspace/ui/components/button" +import { useRoomStore } from "@/store/room" + +const inputClass = + "border-input bg-background ring-offset-background focus-visible:ring-ring h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50" + +export function HomePage() { + const [, navigate] = useLocation() + const createRoom = useRoomStore((s) => s.createRoom) + const joinRoom = useRoomStore((s) => s.joinRoom) + const connected = useRoomStore((s) => s.connected) + + const [name, setName] = useState("") + const [code, setCode] = useState("") + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + + const canSubmit = name.trim().length > 0 && connected && !busy + + async function handleCreate() { + setError(null) + setBusy(true) + try { + const { roomCode } = await createRoom(name.trim()) + navigate(`/room/${roomCode}`) + } catch (err) { + setError((err as { message?: string }).message ?? "Erreur") + } finally { + setBusy(false) + } + } + + async function handleJoin() { + setError(null) + setBusy(true) + try { + const { roomCode } = await joinRoom(code.trim().toUpperCase(), name.trim()) + navigate(`/room/${roomCode}`) + } catch (err) { + setError((err as { message?: string }).message ?? "Erreur") + } finally { + setBusy(false) + } + } + + return ( +
+
+
+

+ NerdWare +

+

+ Party game culture geek +

+
+ + setName(e.target.value)} + /> + + + +
+
+ ou rejoindre +
+
+ +
+ setCode(e.target.value)} + /> + +
+ + {!connected && ( +

+ Connexion au serveur… +

+ )} + {error && ( +

{error}

+ )} +
+
+ ) +} diff --git a/apps/web/src/pages/lobby.tsx b/apps/web/src/pages/lobby.tsx new file mode 100644 index 0000000..4e763ba --- /dev/null +++ b/apps/web/src/pages/lobby.tsx @@ -0,0 +1,71 @@ +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 ( +
+

+ Room introuvable ou session perdue. +

+ + + +
+ ) + } + + return ( +
+
+
+
+

Code room

+

+ {snapshot.code} +

+
+ +
+ +
+

+ Joueurs ({snapshot.players.length}) +

+
    + {snapshot.players.map((p) => ( +
  • + + {p.name} + {p.id === playerId && ( + (toi) + )} + + + {p.id === snapshot.hostId ? "Hôte" : ""} + {!p.connected && " · hors ligne"} + +
  • + ))} +
+
+ +

+ En attente du lancement de la partie par l'hôte… +

+
+
+ ) +} diff --git a/apps/web/src/store/room.ts b/apps/web/src/store/room.ts new file mode 100644 index 0000000..09ba400 --- /dev/null +++ b/apps/web/src/store/room.ts @@ -0,0 +1,63 @@ +import { create } from "zustand" +import type { + ErrorPayload, + RoomCreatedPayload, + RoomSnapshot, +} from "@nerdware/shared" +import { socket } from "@/lib/socket" + +interface RoomState { + connected: boolean + /** Identité locale, persistée pour reconnaître "moi" dans le snapshot. */ + playerId: string | null + roomCode: string | null + snapshot: RoomSnapshot | null + lastError: ErrorPayload | null + createRoom: (playerName: string) => Promise + joinRoom: (roomCode: string, playerName: string) => Promise + clearError: () => void + reset: () => void +} + +export const useRoomStore = create((set) => ({ + connected: socket.connected, + playerId: null, + roomCode: null, + snapshot: null, + lastError: null, + + createRoom: (playerName) => + new Promise((resolve, reject) => { + socket.emit("room:create", { playerName }, (res) => { + if (res.ok) { + set({ playerId: res.data.playerId, roomCode: res.data.roomCode }) + resolve(res.data) + } else { + set({ lastError: res.error }) + reject(res.error) + } + }) + }), + + joinRoom: (roomCode, playerName) => + new Promise((resolve, reject) => { + socket.emit("room:join", { roomCode, playerName }, (res) => { + if (res.ok) { + set({ playerId: res.data.playerId, roomCode: res.data.roomCode }) + resolve(res.data) + } else { + set({ lastError: res.error }) + reject(res.error) + } + }) + }), + + clearError: () => set({ lastError: null }), + reset: () => set({ roomCode: null, snapshot: null, lastError: null }), +})) + +// Listeners socket → on pousse l'état serveur dans le store (source de vérité). +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 })) diff --git a/bun.lock b/bun.lock index c176aca..515f883 100644 --- a/bun.lock +++ b/bun.lock @@ -35,10 +35,14 @@ "name": "web", "version": "0.0.1", "dependencies": { + "@nerdware/shared": "workspace:*", "@workspace/ui": "workspace:*", "lucide-react": "^1.17.0", "react": "^19.2.6", "react-dom": "^19.2.6", + "socket.io-client": "^4.8.1", + "wouter": "^3.7.1", + "zustand": "^5.0.8", }, "devDependencies": { "@eslint/js": "^10", @@ -752,6 +756,8 @@ "engine.io": ["engine.io@6.6.8", "", { "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", "@types/ws": "^8.5.12", "accepts": "~1.3.4", "base64id": "2.0.0", "cookie": "~0.7.2", "cors": "~2.8.5", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.20.1" } }, "sha512-2agL3ueZhqxoVrfmntO8yuVj+uNSlIOnhykYHk3Cq0ShYPdUjjUiSJrQvXjq01I9jAuI0Zl2YO8Evv5Mqytm5g=="], + "engine.io-client": ["engine.io-client@6.6.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.20.1", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-QCwxUDULPlXv8F6tqMMKx5dNkTe6OaBYRMPYeXKBlyOoKvAmE0ac6pW7fFhSscJ/5SI7666/U/B+MElbsrJlIg=="], + "engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="], "enhanced-resolve": ["enhanced-resolve@5.23.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA=="], @@ -1068,6 +1074,8 @@ "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "msw": ["msw@2.14.6", "", { "dependencies": { "@inquirer/confirm": "^6.0.11", "@mswjs/interceptors": "^0.41.3", "@open-draft/deferred-promise": "^3.0.0", "@types/statuses": "^2.0.6", "cookie": "^1.1.1", "graphql": "^16.13.2", "headers-polyfill": "^5.0.1", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.11.11", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.1", "type-fest": "^5.5.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg=="], @@ -1194,6 +1202,8 @@ "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], + "regexparam": ["regexparam@3.0.0", "", {}, "sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -1260,6 +1270,8 @@ "socket.io-adapter": ["socket.io-adapter@2.5.7", "", { "dependencies": { "debug": "~4.4.1", "ws": "~8.20.1" } }, "sha512-e0LyK91f3cUxTmv95/KzoLg47+zF+s/sbxRGDNsyG4dmIP8ZSX8ax6byOxfJXeNNtS/8AZlfD+uP7gBeR7DLlg=="], + "socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="], + "socket.io-parser": ["socket.io-parser@4.2.6", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg=="], "sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], @@ -1354,6 +1366,8 @@ "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="], @@ -1370,6 +1384,8 @@ "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + "wouter": ["wouter@3.10.0", "", { "dependencies": { "mitt": "^3.0.1", "regexparam": "^3.0.0", "use-sync-external-store": "^1.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-zTfddD80zc2/J5l8JKcdvzOK6AwP0kpyHEI3DxRN2bn8U1oJPnrSVm8v+X3WwDamvLAOxTO7ZvkxkpRWlyeJ1Q=="], + "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], @@ -1378,6 +1394,8 @@ "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], + "xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="], + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], @@ -1400,6 +1418,8 @@ "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], + "zustand": ["zustand@5.0.14", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], -- 2.45.3 From 9ed3b8a4cbf0c134013b64a6e815839330639272 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Mon, 8 Jun 2026 15:29:49 +0200 Subject: [PATCH 03/51] =?UTF-8?q?feat(server):=20generic=20game=20engine?= =?UTF-8?q?=20=E2=80=94=20GameRound=20interface=20+=20orchestration=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - game/round.ts: GameRound contract (start/submitAnswer/reveal/score) + RoundContext + RoomGameController (decouples room from engine) - game/registry.ts: register/createRound — adding a mode = one registerRound - game/engine.ts: server-authoritative orchestration. Per round: start → broadcast round:start → wait (timer OR all eligible voted) → reveal → apply score deltas → score:update; then game:end. DJ counts as eligible voter; round ends on first condition met. - handlers: game:start (host-only, validates rounds/modes) + round:vote wired to the engine - shared: add game:start client event - engine.test.ts: covers both end conditions + voteAck (bun test) - turbo/root: add test task Roadmap V1 step 3. No UI yet — concrete quiz mode + client come in step 4. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/package.json | 1 + apps/server/src/game/engine.test.ts | 133 +++++++++++++++++++++ apps/server/src/game/engine.ts | 172 ++++++++++++++++++++++++++++ apps/server/src/game/registry.ts | 25 ++++ apps/server/src/game/round.ts | 55 +++++++++ apps/server/src/handlers.ts | 41 +++++++ apps/server/src/rooms.ts | 4 + package.json | 3 +- packages/shared/src/events.ts | 1 + turbo.json | 3 + 10 files changed, 437 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/game/engine.test.ts create mode 100644 apps/server/src/game/engine.ts create mode 100644 apps/server/src/game/registry.ts create mode 100644 apps/server/src/game/round.ts diff --git a/apps/server/package.json b/apps/server/package.json index 953e426..34534ed 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "bun --watch src/index.ts", "start": "bun src/index.ts", + "test": "bun test", "build": "bun build ./src/index.ts --target bun --outdir dist", "lint": "eslint", "format": "prettier --write \"**/*.ts\"", diff --git a/apps/server/src/game/engine.test.ts b/apps/server/src/game/engine.test.ts new file mode 100644 index 0000000..a8638d2 --- /dev/null +++ b/apps/server/src/game/engine.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test } from "bun:test" +import type { IoServer } from "../socket" +import { RoomManager, type ServerRoom } from "../rooms" +import { GameEngine, type GameEngineOptions } from "./engine" +import type { GameRound } from "./round" + +interface Emit { + event: string + payload: unknown +} + +/** io stub : capture tous les emits, ignore le ciblage de room. */ +function fakeIo(): { io: IoServer; emits: Emit[] } { + const emits: Emit[] = [] + const io = { + to: () => ({ + emit: (event: string, payload: unknown) => { + emits.push({ event, payload }) + }, + }), + } + return { io: io as unknown as IoServer, emits } +} + +/** Épreuve factice : +10 à qui choisit l'index correct (0). */ +const dummyRound: GameRound = { + type: "quiz", + start: () => ({ + payload: { prompt: "2+2 ?", choices: ["4", "5"] }, + data: { correct: 0 }, + }), + submitAnswer: (ctx, playerId, answer) => { + ctx.votes.set(playerId, answer) + }, + reveal: (ctx) => ({ + truth: { correctIndex: (ctx.data as { correct: number }).correct }, + perPlayerResult: {}, + }), + score: (ctx) => { + const correct = (ctx.data as { correct: number }).correct + const deltas: { playerId: string; delta: number }[] = [] + for (const [playerId, answer] of ctx.votes) { + if ((answer as { choiceIndex: number }).choiceIndex === correct) { + deltas.push({ playerId, delta: 10 }) + } + } + return deltas + }, +} + +function setup(roundDurationSec: number): { + io: IoServer + emits: Emit[] + rooms: RoomManager + room: ServerRoom + p1: string + p2: string + options: GameEngineOptions +} { + const { io, emits } = fakeIo() + const rooms = new RoomManager() + const { room, player: a } = rooms.create("Alice", "sock-a") + const { player: b } = rooms.join(room.code, "Bob", "sock-b") + room.settings.roundDuration = roundDurationSec + room.settings.rounds = [{ type: "quiz" }] + const options: GameEngineOptions = { + createRound: () => dummyRound, + revealPauseMs: 0, + } + return { io, emits, rooms, room, p1: a.id, p2: b.id, options } +} + +const eventsOf = (emits: Emit[]) => emits.map((e) => e.event) + +describe("GameEngine", () => { + test("finit la manche dès que tous les éligibles ont voté (avant le timer)", async () => { + const { io, emits, rooms, room, p1, p2, options } = setup(100) + const engine = new GameEngine(io, rooms, room, options) + + const run = engine.run() + // run() s'exécute jusqu'à round:start de façon synchrone, le runtime est prêt. + expect(eventsOf(emits)).toContain("round:start") + + engine.handleVote(p1, { choiceIndex: 0 }) // juste + engine.handleVote(p2, { choiceIndex: 1 }) // faux → 2/2 votes → fin anticipée + await run + + const events = eventsOf(emits) + expect(events).toContain("round:reveal") + expect(events).toContain("score:update") + expect(events).toContain("game:end") + + expect(room.status).toBe("ended") + expect(room.scores.get(p1)).toBe(10) + expect(room.scores.get(p2)).toBe(0) + + const end = emits.find((e) => e.event === "game:end")!.payload as { + finalScores: { playerId: string; score: number }[] + } + expect(end.finalScores).toHaveLength(2) + }) + + test("émet round:voteAck avec count/total à chaque vote", async () => { + const { io, emits, rooms, room, p1, p2, options } = setup(100) + const engine = new GameEngine(io, rooms, room, options) + const run = engine.run() + + engine.handleVote(p1, { choiceIndex: 0 }) + const ack = emits.find((e) => e.event === "round:voteAck")!.payload as { + count: number + total: number + } + expect(ack).toEqual({ count: 1, total: 2 }) + + engine.handleVote(p2, { choiceIndex: 0 }) + await run + expect(room.scores.get(p1)).toBe(10) + expect(room.scores.get(p2)).toBe(10) + }) + + test("finit la manche au timer si tout le monde n'a pas voté", async () => { + const { io, emits, rooms, room, p1, options } = setup(0.05) // 50 ms + const engine = new GameEngine(io, rooms, room, options) + + const run = engine.run() + engine.handleVote(p1, { choiceIndex: 0 }) // un seul vote → on attend le timer + await run + + expect(eventsOf(emits)).toContain("game:end") + expect(room.status).toBe("ended") + expect(room.scores.get(p1)).toBe(10) + }) +}) diff --git a/apps/server/src/game/engine.ts b/apps/server/src/game/engine.ts new file mode 100644 index 0000000..f4a8df9 --- /dev/null +++ b/apps/server/src/game/engine.ts @@ -0,0 +1,172 @@ +// Moteur de jeu générique. Orchestre la séquence de manches d'une room : +// pour chaque manche start → (votes | timer) → reveal → score, puis game:end. +// Tout est tranché serveur ; les clients n'affichent que ce qui est broadcasté. + +import type { Answer, PlayerScore, RoundConfig } from "@nerdware/shared" +import type { IoServer } from "../socket" +import type { RoomManager, ServerRoom } from "../rooms" +import { createRound as defaultCreateRound, type RoundFactory } from "./registry" +import type { GameRound, RoomGameController, RoundContext } from "./round" + +interface RoundRuntime { + round: GameRound + djId: string | null + votes: Map + startedAt: number + endsAt: number + data: unknown +} + +export interface GameEngineOptions { + /** Injection de l'instanciation d'épreuve (tests). Défaut = registre global. */ + createRound?: (type: RoundConfig["type"]) => GameRound + /** Pause d'affichage du reveal/scores entre deux manches (ms). */ + revealPauseMs?: number + /** Permet de patcher l'horloge en test ; défaut = Date.now. */ + now?: () => number +} + +const DEFAULT_REVEAL_PAUSE_MS = 5000 + +export class GameEngine implements RoomGameController { + private runtime: RoundRuntime | null = null + private timer: ReturnType | null = null + private resolveRoundEnd: (() => void) | null = null + private readonly createRound: (type: RoundConfig["type"]) => GameRound + private readonly revealPauseMs: number + private readonly now: () => number + + constructor( + private readonly io: IoServer, + private readonly rooms: RoomManager, + private readonly room: ServerRoom, + options: GameEngineOptions = {} + ) { + this.createRound = options.createRound ?? defaultCreateRound + this.revealPauseMs = options.revealPauseMs ?? DEFAULT_REVEAL_PAUSE_MS + this.now = options.now ?? Date.now + } + + /** Joue toute la partie, manche par manche, puis émet game:end. */ + async run(): Promise { + const { rounds } = this.room.settings + for (let i = 0; i < rounds.length; i++) { + this.room.currentRound = i + await this.playRound(rounds[i]) + if (i < rounds.length - 1) { + await this.sleep(this.revealPauseMs) + } + } + this.room.status = "ended" + this.broadcastState() + this.io.to(this.room.code).emit("game:end", { finalScores: this.scoreboard() }) + } + + /** Vote d'un joueur pendant une manche. Délègue à l'épreuve, puis check fin anticipée. */ + handleVote(playerId: string, answer: Answer): void { + if (!this.runtime || this.room.status !== "in_round") { + return + } + const ctx = this.context(this.runtime) + this.runtime.round.submitAnswer(ctx, playerId, answer) + const total = this.eligibleVoters().length + this.io.to(this.room.code).emit("round:voteAck", { + count: this.runtime.votes.size, + total, + }) + // Fin anticipée : tous les éligibles ont voté. + if (total > 0 && this.runtime.votes.size >= total) { + this.endRound() + } + } + + private async playRound(config: RoundConfig): Promise { + const round = this.createRound(config.type) + const start = round.start(this.room) + const durationSec = start.durationSec ?? this.room.settings.roundDuration + const startedAt = this.now() + const endsAt = startedAt + durationSec * 1000 + + this.runtime = { + round, + djId: start.djId ?? null, + votes: new Map(), + startedAt, + endsAt, + data: start.data, + } + this.room.status = "in_round" + this.broadcastState() + this.io.to(this.room.code).emit("round:start", { + type: round.type, + djId: this.runtime.djId ?? undefined, + endsAt, + payload: start.payload, + }) + + // Attend la première condition de fin : timer écoulé OU tous votés. + await new Promise((resolve) => { + this.resolveRoundEnd = resolve + this.timer = setTimeout(() => this.endRound(), durationSec * 1000) + }) + + const ctx = this.context(this.runtime) + this.room.status = "reveal" + this.broadcastState() + this.io.to(this.room.code).emit("round:reveal", round.reveal(ctx)) + + for (const { playerId, delta } of round.score(ctx)) { + this.room.scores.set(playerId, (this.room.scores.get(playerId) ?? 0) + delta) + } + this.room.status = "scores" + this.io.to(this.room.code).emit("score:update", { scores: this.scoreboard() }) + this.broadcastState() + this.runtime = null + } + + /** Termine la manche en cours (idempotent : timer et "tous votés" peuvent se croiser). */ + private endRound(): void { + if (this.timer) { + clearTimeout(this.timer) + this.timer = null + } + const resolve = this.resolveRoundEnd + this.resolveRoundEnd = null + resolve?.() + } + + private context(runtime: RoundRuntime): RoundContext { + return { + room: this.room, + djId: runtime.djId, + votes: runtime.votes, + startedAt: runtime.startedAt, + endsAt: runtime.endsAt, + data: runtime.data, + } + } + + /** Votants éligibles : tous les joueurs connectés (le DJ inclus). */ + private eligibleVoters(): string[] { + return [...this.room.players.values()] + .filter((p) => p.connected) + .map((p) => p.id) + } + + private scoreboard(): PlayerScore[] { + return [...this.room.scores.entries()].map(([playerId, score]) => ({ + playerId, + score, + })) + } + + private broadcastState(): void { + this.io.to(this.room.code).emit("room:state", this.rooms.toSnapshot(this.room)) + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) + } +} + +export type { RoundFactory } diff --git a/apps/server/src/game/registry.ts b/apps/server/src/game/registry.ts new file mode 100644 index 0000000..27be9e5 --- /dev/null +++ b/apps/server/src/game/registry.ts @@ -0,0 +1,25 @@ +// Registre des épreuves. Chaque mode s'enregistre via registerRound() ; +// le moteur instancie une manche fraîche par round via createRound(). + +import type { RoundType } from "@nerdware/shared" +import type { GameRound } from "./round" + +export type RoundFactory = () => GameRound + +const factories = new Map() + +export function registerRound(type: RoundType, factory: RoundFactory): void { + factories.set(type, factory) +} + +export function createRound(type: RoundType): GameRound { + const factory = factories.get(type) + if (!factory) { + throw new Error(`Aucune épreuve enregistrée pour le type "${type}"`) + } + return factory() +} + +export function hasRound(type: RoundType): boolean { + return factories.has(type) +} diff --git a/apps/server/src/game/round.ts b/apps/server/src/game/round.ts new file mode 100644 index 0000000..8f3ca62 --- /dev/null +++ b/apps/server/src/game/round.ts @@ -0,0 +1,55 @@ +// Contrat générique d'une épreuve. Le moteur ne connaît QUE cette interface : +// brancher une nouvelle épreuve = écrire un module, pas retoucher la plomberie. + +import type { Answer, RevealPayload, RoundType, ScoreDelta } from "@nerdware/shared" +import type { ServerRoom } from "../rooms" + +/** Ce que `start()` renvoie : la partie publique + les données privées de la manche. */ +export interface RoundStart { + /** Joueur DJ (blindtest) ; null pour les épreuves sans DJ (quiz). */ + djId?: string | null + /** Durée de la manche en secondes ; défaut = room.settings.roundDuration. */ + durationSec?: number + /** Payload broadcasté aux clients, SANS la réponse. */ + payload: unknown + /** Données privées (ex: la bonne réponse), conservées jusqu'au reveal. Jamais diffusées. */ + data?: unknown +} + +/** Contexte runtime d'une manche, fourni aux callbacks de l'épreuve. */ +export interface RoundContext { + room: ServerRoom + djId: string | null + /** Votes reçus : playerId → réponse. Muté par submitAnswer. */ + votes: Map + startedAt: number + endsAt: number + /** Données privées renvoyées par start(). */ + data: unknown +} + +/** + * Une épreuve. Implémente le cycle start → submitAnswer → reveal → score. + * Le serveur est autoritaire : start/reveal/score ne s'exécutent que côté serveur. + */ +export interface GameRound { + type: RoundType + + /** Prépare la manche (tire le DJ, choisit le contenu) et renvoie le payload public. */ + start(room: ServerRoom): RoundStart + + /** Enregistre/valide le vote d'un joueur dans ctx.votes. Doit être idempotent. */ + submitAnswer(ctx: RoundContext, playerId: string, answer: Answer): void + + /** Calcule la vérité + le détail révélé à tous. */ + reveal(ctx: RoundContext): RevealPayload + + /** Renvoie les variations de score à appliquer (le moteur les applique). */ + score(ctx: RoundContext): ScoreDelta[] +} + +/** Vue minimale du moteur exposée à la room (évite un cycle d'import). */ +export interface RoomGameController { + run(): Promise + handleVote(playerId: string, answer: Answer): void +} diff --git a/apps/server/src/handlers.ts b/apps/server/src/handlers.ts index 8526002..aebdf99 100644 --- a/apps/server/src/handlers.ts +++ b/apps/server/src/handlers.ts @@ -10,6 +10,8 @@ import type { } from "@nerdware/shared" import type { IoServer } from "./socket" import { RoomError, RoomManager, type ServerRoom } from "./rooms" +import { GameEngine } from "./game/engine" +import { hasRound } from "./game/registry" type AppSocket = Socket< ClientToServerEvents, @@ -95,6 +97,45 @@ export function registerRoomHandlers( broadcastState(room) }) + socket.on("game:start", (ack) => { + const code = socket.data.roomCode + const room = code ? rooms.get(code) : undefined + if (!room || room.hostId !== socket.data.playerId) { + ack({ ok: false, error: { code: "NOT_HOST", message: "Seul l'hôte peut lancer." } }) + return + } + if (room.status !== "lobby") { + ack({ ok: false, error: { code: "ALREADY_STARTED", message: "Partie déjà lancée." } }) + return + } + if (room.settings.rounds.length === 0) { + ack({ ok: false, error: { code: "NO_ROUNDS", message: "Aucune épreuve configurée." } }) + return + } + const unknown = room.settings.rounds.find((r) => !hasRound(r.type)) + if (unknown) { + ack({ + ok: false, + error: { code: "UNKNOWN_MODE", message: `Épreuve indisponible : ${unknown.type}.` }, + }) + return + } + ack({ ok: true, data: null }) + const engine = new GameEngine(io, rooms, room) + room.game = engine + // Fire-and-forget : la boucle de jeu broadcast son propre état. + void engine.run().catch((err) => console.error("[game] run failed", err)) + }) + + socket.on("round:vote", (payload) => { + const code = socket.data.roomCode + const room = code ? rooms.get(code) : undefined + if (!room || !room.game || !socket.data.playerId) { + return + } + room.game.handleVote(socket.data.playerId, payload.answer) + }) + socket.on("disconnect", () => { const affected = rooms.handleDisconnect(socket.id) if (!affected) { diff --git a/apps/server/src/rooms.ts b/apps/server/src/rooms.ts index c5dd2fd..fadc8f7 100644 --- a/apps/server/src/rooms.ts +++ b/apps/server/src/rooms.ts @@ -8,6 +8,7 @@ import { type RoomSnapshot, type RoomStatus, } from "@nerdware/shared" +import type { RoomGameController } from "./game/round" /** Joueur côté serveur : porte le socketId, jamais exposé au client. */ export interface ServerPlayer { @@ -26,6 +27,8 @@ export interface ServerRoom { settings: RoomSettings scores: Map currentRound: number + /** Moteur de jeu actif (null tant qu'on est dans le lobby). */ + game: RoomGameController | null } /** Codes lisibles, sans caractères ambigus (0/O, 1/I/L). */ @@ -47,6 +50,7 @@ export class RoomManager { settings: { ...DEFAULT_ROOM_SETTINGS }, scores: new Map([[player.id, 0]]), currentRound: -1, + game: null, } this.rooms.set(code, room) return { room, player } diff --git a/package.json b/package.json index 8198027..51e36c8 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "dev": "turbo dev", "lint": "turbo lint", "format": "turbo format", - "typecheck": "turbo typecheck" + "typecheck": "turbo typecheck", + "test": "turbo test" }, "devDependencies": { "prettier": "^3.8.3", diff --git a/packages/shared/src/events.ts b/packages/shared/src/events.ts index 2a653cf..cc3a8b6 100644 --- a/packages/shared/src/events.ts +++ b/packages/shared/src/events.ts @@ -108,6 +108,7 @@ export interface ClientToServerEvents { "room:create": (payload: RoomCreatePayload, ack: Ack) => void "room:join": (payload: RoomJoinPayload, ack: Ack) => void "lobby:updateSettings": (payload: UpdateSettingsPayload) => void + "game:start": (ack: Ack) => void "blindtest:submitTrack": (payload: SubmitTrackPayload, ack: Ack) => void "media:control": (payload: MediaControlPayload) => void "round:vote": (payload: VotePayload) => void diff --git a/turbo.json b/turbo.json index c5398f5..b2332be 100644 --- a/turbo.json +++ b/turbo.json @@ -16,6 +16,9 @@ "typecheck": { "dependsOn": ["^typecheck"] }, + "test": { + "dependsOn": ["^build"] + }, "dev": { "cache": false, "persistent": true -- 2.45.3 From 5cc9113b8c6ba0f2b268d16e1decd1c9e9fa0d20 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Mon, 8 Jun 2026 15:43:17 +0200 Subject: [PATCH 04/51] feat(quiz): quiz game mode end-to-end (server mode + playable client UI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server: - quiz mode (GameRound): in-memory FR question bank (mcq + truefalse), per-room anti-repeat, first-vote lock, score = base + speed bonus ∝ time left; registered via registerRound, loaded at startup - shared: typed quiz payloads (QuizQuestionPayload / reveal truth / result) - tests: quiz scoring, vote lock, bounds, reveal (bun test) Client: - room store handles round:start / voteAck / reveal / game:end - RoomPage dispatches by status: lobby (host start controls) → quiz view (question, countdown, vote, reveal + scoreboard) → game-end view - replaces standalone lobby page Roadmap V1 step 4. Verified end-to-end over the wire (start→vote→reveal→ score→next round→game:end, no answer leaked, anti-repeat works). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/src/game/modes/index.ts | 4 + apps/server/src/game/modes/quiz/index.ts | 7 ++ apps/server/src/game/modes/quiz/questions.ts | 110 +++++++++++++++++ .../src/game/modes/quiz/quiz-round.test.ts | 59 +++++++++ apps/server/src/game/modes/quiz/quiz-round.ts | 115 ++++++++++++++++++ apps/server/src/index.ts | 1 + apps/web/src/App.tsx | 4 +- apps/web/src/components/countdown.tsx | 19 +++ apps/web/src/components/game-end-view.tsx | 37 ++++++ apps/web/src/components/lobby-view.tsx | 87 +++++++++++++ apps/web/src/components/quiz-view.tsx | 113 +++++++++++++++++ apps/web/src/components/scoreboard.tsx | 37 ++++++ apps/web/src/pages/lobby.tsx | 71 ----------- apps/web/src/pages/room.tsx | 50 ++++++++ apps/web/src/store/room.ts | 98 ++++++++++++++- packages/shared/src/index.ts | 1 + packages/shared/src/quiz.ts | 30 +++++ 17 files changed, 767 insertions(+), 76 deletions(-) create mode 100644 apps/server/src/game/modes/index.ts create mode 100644 apps/server/src/game/modes/quiz/index.ts create mode 100644 apps/server/src/game/modes/quiz/questions.ts create mode 100644 apps/server/src/game/modes/quiz/quiz-round.test.ts create mode 100644 apps/server/src/game/modes/quiz/quiz-round.ts create mode 100644 apps/web/src/components/countdown.tsx create mode 100644 apps/web/src/components/game-end-view.tsx create mode 100644 apps/web/src/components/lobby-view.tsx create mode 100644 apps/web/src/components/quiz-view.tsx create mode 100644 apps/web/src/components/scoreboard.tsx delete mode 100644 apps/web/src/pages/lobby.tsx create mode 100644 apps/web/src/pages/room.tsx create mode 100644 packages/shared/src/quiz.ts diff --git a/apps/server/src/game/modes/index.ts b/apps/server/src/game/modes/index.ts new file mode 100644 index 0000000..d413056 --- /dev/null +++ b/apps/server/src/game/modes/index.ts @@ -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" diff --git a/apps/server/src/game/modes/quiz/index.ts b/apps/server/src/game/modes/quiz/index.ts new file mode 100644 index 0000000..4c45f97 --- /dev/null +++ b/apps/server/src/game/modes/quiz/index.ts @@ -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 } diff --git a/apps/server/src/game/modes/quiz/questions.ts b/apps/server/src/game/modes/quiz/questions.ts new file mode 100644 index 0000000..3c4af08 --- /dev/null +++ b/apps/server/src/game/modes/quiz/questions.ts @@ -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, + }, +] diff --git a/apps/server/src/game/modes/quiz/quiz-round.test.ts b/apps/server/src/game/modes/quiz/quiz-round.test.ts new file mode 100644 index 0000000..6d5bbe6 --- /dev/null +++ b/apps/server/src/game/modes/quiz/quiz-round.test.ts @@ -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) + }) +}) diff --git a/apps/server/src/game/modes/quiz/quiz-round.ts b/apps/server/src/game/modes/quiz/quiz-round.ts new file mode 100644 index 0000000..55116e8 --- /dev/null +++ b/apps/server/src/game/modes/quiz/quiz-round.ts @@ -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 +} + +// Anti-répétition à l'échelle d'une partie : questions déjà jouées par room. +const playedByRoom = new WeakMap>() + +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 + } +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index b613840..5990a3f 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -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 diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index dce07e3..3aca4dd 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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 ( - {(params) => } + {(params) => }
diff --git a/apps/web/src/components/countdown.tsx b/apps/web/src/components/countdown.tsx new file mode 100644 index 0000000..fe53b7f --- /dev/null +++ b/apps/web/src/components/countdown.tsx @@ -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 ( + {left} + ) +} diff --git a/apps/web/src/components/game-end-view.tsx b/apps/web/src/components/game-end-view.tsx new file mode 100644 index 0000000..0d8c8fe --- /dev/null +++ b/apps/web/src/components/game-end-view.tsx @@ -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 ( +
+
+

+ Partie terminée +

+

+ 🏆 {winnerName} l'emporte ! +

+
+ + + + + + +
+ ) +} diff --git a/apps/web/src/components/lobby-view.tsx b/apps/web/src/components/lobby-view.tsx new file mode 100644 index 0000000..d4c5eab --- /dev/null +++ b/apps/web/src/components/lobby-view.tsx @@ -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(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 ( +
+
+

+ Joueurs ({snapshot.players.length}) +

+
    + {snapshot.players.map((p) => ( +
  • + + {p.name} + {p.id === playerId && ( + (toi) + )} + + + {p.id === snapshot.hostId ? "Hôte" : ""} + {!p.connected && " · hors ligne"} + +
  • + ))} +
+
+ + {isHost ? ( +
+
+ Questions de quiz +
+ {QUESTION_OPTIONS.map((n) => ( + + ))} +
+
+ + {error && ( +

{error}

+ )} +
+ ) : ( +

+ En attente du lancement de la partie par l'hôte… +

+ )} +
+ ) +} diff --git a/apps/web/src/components/quiz-view.tsx b/apps/web/src/components/quiz-view.tsx new file mode 100644 index 0000000..d255a3b --- /dev/null +++ b/apps/web/src/components/quiz-view.tsx @@ -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 ( +

Préparation de la manche…

+ ) + } + + 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 ( +
+
+ + Question {snapshot.currentRound + 1} / {snapshot.totalRounds} + {question.category ? ` · ${question.category}` : ""} + + {!showReveal && ( + + )} +
+ +

+ {question.prompt} +

+ +
+ {question.choices.map((choice, index) => ( + + ))} +
+ + {!showReveal && + (myChoiceIndex !== null ? ( +

+ Réponse envoyée — en attente des autres + {voteProgress ? ` (${voteProgress.count}/${voteProgress.total})` : ""} +

+ ) : ( + voteProgress && ( +

+ {voteProgress.count}/{voteProgress.total} ont répondu +

+ ) + ))} + + {showReveal && ( +
+

+ {myResult?.correct + ? "Bonne réponse ! 🎉" + : myResult?.choiceIndex == null + ? "Pas de réponse 😴" + : "Raté 💥"} +

+ +
+ )} +
+ ) +} diff --git a/apps/web/src/components/scoreboard.tsx b/apps/web/src/components/scoreboard.tsx new file mode 100644 index 0000000..7758bb0 --- /dev/null +++ b/apps/web/src/components/scoreboard.tsx @@ -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 ( +
    + {ranked.map((s, i) => ( +
  1. + + + {i + 1} + + + {nameOf(s.playerId)} + {s.playerId === playerId && ( + (toi) + )} + + + {s.score} +
  2. + ))} +
+ ) +} diff --git a/apps/web/src/pages/lobby.tsx b/apps/web/src/pages/lobby.tsx deleted file mode 100644 index 4e763ba..0000000 --- a/apps/web/src/pages/lobby.tsx +++ /dev/null @@ -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 ( -
-

- Room introuvable ou session perdue. -

- - - -
- ) - } - - return ( -
-
-
-
-

Code room

-

- {snapshot.code} -

-
- -
- -
-

- Joueurs ({snapshot.players.length}) -

-
    - {snapshot.players.map((p) => ( -
  • - - {p.name} - {p.id === playerId && ( - (toi) - )} - - - {p.id === snapshot.hostId ? "Hôte" : ""} - {!p.connected && " · hors ligne"} - -
  • - ))} -
-
- -

- En attente du lancement de la partie par l'hôte… -

-
-
- ) -} diff --git a/apps/web/src/pages/room.tsx b/apps/web/src/pages/room.tsx new file mode 100644 index 0000000..949bd77 --- /dev/null +++ b/apps/web/src/pages/room.tsx @@ -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 ( +
+

+ Room introuvable ou session perdue. +

+ + + +
+ ) + } + + return ( +
+
+
+
+

Code room

+

+ {snapshot.code} +

+
+ +
+ + {snapshot.status === "lobby" && } + {(snapshot.status === "in_round" || + snapshot.status === "reveal" || + snapshot.status === "scores") && } + {snapshot.status === "ended" && } +
+
+ ) +} diff --git a/apps/web/src/store/room.ts b/apps/web/src/store/room.ts index 09ba400..ca91a02 100644 --- a/apps/web/src/store/room.ts +++ b/apps/web/src/store/room.ts @@ -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 joinRoom: (roomCode: string, playerName: string) => Promise + startGame: (questionCount: number) => Promise + vote: (choiceIndex: number) => void clearError: () => void reset: () => void } -export const useRoomStore = create((set) => ({ +export const useRoomStore = create((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((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 }) +) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 313fded..dece734 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,2 +1,3 @@ export * from "./domain" export * from "./events" +export * from "./quiz" diff --git a/packages/shared/src/quiz.ts b/packages/shared/src/quiz.ts new file mode 100644 index 0000000..a2156a6 --- /dev/null +++ b/packages/shared/src/quiz.ts @@ -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 -- 2.45.3 From f82c7d97003da5cbd3476d38a75b9f8ba787ff31 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Mon, 8 Jun 2026 15:56:57 +0200 Subject: [PATCH 05/51] feat(web): bomb countdown, copyable room code, persistent score cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Countdown: under 10s it ramps into a "bomb" — white→red color and an escalating scale/wiggle pulse (faster & wider as it nears 0) via Framer Motion - RoomCode: click-to-copy the room code with visual feedback - PlayerCards: always-on score HUD during the game (cards per player, animated reorder, crown on the leader) so scores are tracked continuously instead of only appearing at reveal - quiz view: drop the reveal-only scoreboard (now covered by PlayerCards) - add framer-motion dependency Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/package.json | 1 + apps/web/src/components/countdown.tsx | 45 +++++++++++++++- apps/web/src/components/player-cards.tsx | 69 ++++++++++++++++++++++++ apps/web/src/components/quiz-view.tsx | 26 ++++----- apps/web/src/components/room-code.tsx | 39 ++++++++++++++ apps/web/src/pages/room.tsx | 22 ++++---- bun.lock | 7 +++ 7 files changed, 182 insertions(+), 27 deletions(-) create mode 100644 apps/web/src/components/player-cards.tsx create mode 100644 apps/web/src/components/room-code.tsx diff --git a/apps/web/package.json b/apps/web/package.json index 2c7c96e..5d5458d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,6 +14,7 @@ "dependencies": { "@nerdware/shared": "workspace:*", "@workspace/ui": "workspace:*", + "framer-motion": "^11", "lucide-react": "^1.17.0", "react": "^19.2.6", "react-dom": "^19.2.6", diff --git a/apps/web/src/components/countdown.tsx b/apps/web/src/components/countdown.tsx index fe53b7f..9f2012a 100644 --- a/apps/web/src/components/countdown.tsx +++ b/apps/web/src/components/countdown.tsx @@ -1,10 +1,25 @@ import { useEffect, useState } from "react" +import { motion } from "framer-motion" + +const DANGER_FROM = 10 // secondes : seuil d'entrée en mode "bombe" /** Secondes restantes jusqu'à `endsAt` (timestamp serveur). */ function secondsLeft(endsAt: number): number { return Math.max(0, Math.ceil((endsAt - Date.now()) / 1000)) } +function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t +} + +/** Blanc → rouge sur les DANGER_FROM dernières secondes. */ +function dangerColor(t: number): string { + const r = Math.round(lerp(255, 239, t)) + const g = Math.round(lerp(255, 68, t)) + const b = Math.round(lerp(255, 68, t)) + return `rgb(${r}, ${g}, ${b})` +} + export function Countdown({ endsAt }: { endsAt: number }) { const [left, setLeft] = useState(() => secondsLeft(endsAt)) @@ -13,7 +28,35 @@ export function Countdown({ endsAt }: { endsAt: number }) { return () => clearInterval(id) }, [endsAt]) + const danger = left <= DANGER_FROM && left > 0 + // 0 (début du danger) → 1 (explosion imminente) + const intensity = danger ? Math.min(1, (DANGER_FROM - left) / DANGER_FROM) : 0 + + // Plus on approche de 0, plus le "battement" est ample et rapide. + const peak = 1.15 + intensity * 0.5 + const wiggle = 4 + intensity * 12 + const duration = 0.7 - intensity * 0.46 + return ( - {left} + + {danger && 💣} + {left} + ) } diff --git a/apps/web/src/components/player-cards.tsx b/apps/web/src/components/player-cards.tsx new file mode 100644 index 0000000..353116f --- /dev/null +++ b/apps/web/src/components/player-cards.tsx @@ -0,0 +1,69 @@ +import { AnimatePresence, motion } from "framer-motion" +import { Crown } from "lucide-react" +import type { RoomSnapshot } from "@nerdware/shared" + +interface PlayerCardsProps { + snapshot: RoomSnapshot + playerId: string | null +} + +/** HUD persistant : une card par joueur avec son score, couronne au leader. */ +export function PlayerCards({ snapshot, playerId }: PlayerCardsProps) { + const scoreOf = (id: string) => + snapshot.scores.find((s) => s.playerId === id)?.score ?? 0 + + const max = Math.max(0, ...snapshot.players.map((p) => scoreOf(p.id))) + const ranked = [...snapshot.players].sort( + (a, b) => scoreOf(b.id) - scoreOf(a.id) + ) + + return ( +
+ + {ranked.map((p) => { + const score = scoreOf(p.id) + const isLeader = max > 0 && score === max + const isMe = p.id === playerId + return ( + + {isLeader && ( + + + + )} + + {p.name} + {isMe && (toi)} + + + {score} + + + ) + })} + +
+ ) +} diff --git a/apps/web/src/components/quiz-view.tsx b/apps/web/src/components/quiz-view.tsx index d255a3b..cbc231a 100644 --- a/apps/web/src/components/quiz-view.tsx +++ b/apps/web/src/components/quiz-view.tsx @@ -6,7 +6,6 @@ import type { } 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) @@ -91,22 +90,15 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) { ))} {showReveal && ( -
-

- {myResult?.correct - ? "Bonne réponse ! 🎉" - : myResult?.choiceIndex == null - ? "Pas de réponse 😴" - : "Raté 💥"} -

- -
+

+ {myResult?.correct + ? "Bonne réponse ! 🎉" + : myResult?.choiceIndex == null + ? "Pas de réponse 😴" + : "Raté 💥"} +

)}
) diff --git a/apps/web/src/components/room-code.tsx b/apps/web/src/components/room-code.tsx new file mode 100644 index 0000000..58142df --- /dev/null +++ b/apps/web/src/components/room-code.tsx @@ -0,0 +1,39 @@ +import { useState } from "react" +import { Check, Copy } from "lucide-react" + +/** Code de room cliquable : copie dans le presse-papier avec retour visuel. */ +export function RoomCode({ code }: { code: string }) { + const [copied, setCopied] = useState(false) + + async function copy() { + try { + await navigator.clipboard.writeText(code) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } catch { + // presse-papier indisponible (http non sécurisé) : on ignore silencieusement + } + } + + return ( + + ) +} diff --git a/apps/web/src/pages/room.tsx b/apps/web/src/pages/room.tsx index 949bd77..bb975dd 100644 --- a/apps/web/src/pages/room.tsx +++ b/apps/web/src/pages/room.tsx @@ -4,9 +4,12 @@ import { useRoomStore } from "@/store/room" import { LobbyView } from "@/components/lobby-view" import { QuizView } from "@/components/quiz-view" import { GameEndView } from "@/components/game-end-view" +import { PlayerCards } from "@/components/player-cards" +import { RoomCode } from "@/components/room-code" export function RoomPage({ 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) : retour à l'accueil. @@ -23,26 +26,27 @@ export function RoomPage({ code }: { code: string }) { ) } + // Cards de scores visibles en permanence pendant le jeu (suivi continu). + const inGame = + snapshot.status === "in_round" || + snapshot.status === "reveal" || + snapshot.status === "scores" + return (
-
-

Code room

-

- {snapshot.code} -

-
+
+ {inGame && } + {snapshot.status === "lobby" && } - {(snapshot.status === "in_round" || - snapshot.status === "reveal" || - snapshot.status === "scores") && } + {inGame && } {snapshot.status === "ended" && }
diff --git a/bun.lock b/bun.lock index 515f883..8ff9b27 100644 --- a/bun.lock +++ b/bun.lock @@ -37,6 +37,7 @@ "dependencies": { "@nerdware/shared": "workspace:*", "@workspace/ui": "workspace:*", + "framer-motion": "^11", "lucide-react": "^1.17.0", "react": "^19.2.6", "react-dom": "^19.2.6", @@ -872,6 +873,8 @@ "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + "framer-motion": ["framer-motion@11.18.2", "", { "dependencies": { "motion-dom": "^11.18.1", "motion-utils": "^11.18.1", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w=="], + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], "fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="], @@ -1076,6 +1079,10 @@ "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], + "motion-dom": ["motion-dom@11.18.1", "", { "dependencies": { "motion-utils": "^11.18.1" } }, "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw=="], + + "motion-utils": ["motion-utils@11.18.1", "", {}, "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "msw": ["msw@2.14.6", "", { "dependencies": { "@inquirer/confirm": "^6.0.11", "@mswjs/interceptors": "^0.41.3", "@open-draft/deferred-promise": "^3.0.0", "@types/statuses": "^2.0.6", "cookie": "^1.1.1", "graphql": "^16.13.2", "headers-polyfill": "^5.0.1", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.11.11", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.1", "type-fest": "^5.5.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg=="], -- 2.45.3 From c9f319730012a48ebc7a91dde5c27d481d0be0ee Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Mon, 8 Jun 2026 16:05:42 +0200 Subject: [PATCH 06/51] =?UTF-8?q?feat(web):=20real=20explosion=20at=200=20?= =?UTF-8?q?=E2=80=94=20fullscreen=20flash=20+=20giant=20=F0=9F=92=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - store: boomKey counter + boom() action - Countdown fires boom() once when it reaches 0 (timer expiry only) - Boom component (portal to body) plays a fullscreen white flash + giant 💥 - rendered at RoomPage level, keyed by boomKey so it plays fully (independent of the countdown unmounting on reveal) and replays each round Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/components/boom.tsx | 35 +++++++++++++++++++++++++++ apps/web/src/components/countdown.tsx | 16 ++++++++++-- apps/web/src/pages/room.tsx | 4 +++ apps/web/src/store/room.ts | 7 ++++++ 4 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/boom.tsx diff --git a/apps/web/src/components/boom.tsx b/apps/web/src/components/boom.tsx new file mode 100644 index 0000000..6670893 --- /dev/null +++ b/apps/web/src/components/boom.tsx @@ -0,0 +1,35 @@ +import { createPortal } from "react-dom" +import { motion } from "framer-motion" + +/** + * Explosion plein écran : flash blanc + 💥 géant. + * Rendue dans document.body via portal pour échapper aux transforms parents, + * et montée avec une `key` qui change → l'animation rejoue à chaque boom. + */ +export function Boom() { + return createPortal( +
+ + + 💥 + +
, + document.body + ) +} diff --git a/apps/web/src/components/countdown.tsx b/apps/web/src/components/countdown.tsx index 9f2012a..7847867 100644 --- a/apps/web/src/components/countdown.tsx +++ b/apps/web/src/components/countdown.tsx @@ -1,5 +1,6 @@ -import { useEffect, useState } from "react" +import { useEffect, useRef, useState } from "react" import { motion } from "framer-motion" +import { useRoomStore } from "@/store/room" const DANGER_FROM = 10 // secondes : seuil d'entrée en mode "bombe" @@ -22,12 +23,23 @@ function dangerColor(t: number): string { export function Countdown({ endsAt }: { endsAt: number }) { const [left, setLeft] = useState(() => secondsLeft(endsAt)) + const boom = useRoomStore((s) => s.boom) + const boomed = useRef(false) useEffect(() => { const id = setInterval(() => setLeft(secondsLeft(endsAt)), 250) return () => clearInterval(id) }, [endsAt]) + // Déclenche l'explosion une seule fois au passage à 0 (fin au timer). + useEffect(() => { + if (left === 0 && !boomed.current) { + boomed.current = true + boom() + } + }, [left, boom]) + + const exploded = left === 0 const danger = left <= DANGER_FROM && left > 0 // 0 (début du danger) → 1 (explosion imminente) const intensity = danger ? Math.min(1, (DANGER_FROM - left) / DANGER_FROM) : 0 @@ -56,7 +68,7 @@ export function Countdown({ endsAt }: { endsAt: number }) { } > {danger && 💣} - {left} + {exploded ? "💥" : left} ) } diff --git a/apps/web/src/pages/room.tsx b/apps/web/src/pages/room.tsx index bb975dd..f0dc0b5 100644 --- a/apps/web/src/pages/room.tsx +++ b/apps/web/src/pages/room.tsx @@ -6,11 +6,13 @@ import { QuizView } from "@/components/quiz-view" import { GameEndView } from "@/components/game-end-view" import { PlayerCards } from "@/components/player-cards" import { RoomCode } from "@/components/room-code" +import { Boom } from "@/components/boom" export function RoomPage({ code }: { code: string }) { const snapshot = useRoomStore((s) => s.snapshot) const playerId = useRoomStore((s) => s.playerId) const connected = useRoomStore((s) => s.connected) + const boomKey = useRoomStore((s) => s.boomKey) // Pas de snapshot pour ce code (accès direct / refresh) : retour à l'accueil. if (!snapshot || snapshot.code !== code) { @@ -49,6 +51,8 @@ export function RoomPage({ code }: { code: string }) { {inGame && } {snapshot.status === "ended" && }
+ + {boomKey > 0 && }
) } diff --git a/apps/web/src/store/room.ts b/apps/web/src/store/room.ts index ca91a02..b1ca21e 100644 --- a/apps/web/src/store/room.ts +++ b/apps/web/src/store/room.ts @@ -37,11 +37,14 @@ interface RoomState { reveal: RoundReveal | null myChoiceIndex: number | null finalScores: PlayerScore[] | null + /** Compteur d'explosions : incrémenté à chaque fin de manche au timer (boom à 0). */ + boomKey: number createRoom: (playerName: string) => Promise joinRoom: (roomCode: string, playerName: string) => Promise startGame: (questionCount: number) => Promise vote: (choiceIndex: number) => void + boom: () => void clearError: () => void reset: () => void } @@ -57,6 +60,7 @@ export const useRoomStore = create((set, get) => ({ reveal: null, myChoiceIndex: null, finalScores: null, + boomKey: 0, createRoom: (playerName) => new Promise((resolve, reject) => { @@ -113,6 +117,8 @@ export const useRoomStore = create((set, get) => ({ socket.emit("round:vote", { answer: { choiceIndex } }) }, + boom: () => set((s) => ({ boomKey: s.boomKey + 1 })), + clearError: () => set({ lastError: null }), reset: () => set({ @@ -124,6 +130,7 @@ export const useRoomStore = create((set, get) => ({ reveal: null, myChoiceIndex: null, finalScores: null, + boomKey: 0, }), })) -- 2.45.3 From 0c9eaca31295e3ada3a085d1170194d9275b4d13 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Tue, 9 Jun 2026 10:25:28 +0200 Subject: [PATCH 07/51] feat(web): glorify the winner on the final leaderboard - dedicated winner card: gold border, gold gradient, pulsing gold glow, spring scale-in, and a Crown that drops & bounces in (Framer Motion) - champion label + big gold score; runners-up listed below from rank 2 - drop the generic Scoreboard component (now unused) Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/components/game-end-view.tsx | 86 +++++++++++++++++++++-- apps/web/src/components/scoreboard.tsx | 37 ---------- 2 files changed, 80 insertions(+), 43 deletions(-) delete mode 100644 apps/web/src/components/scoreboard.tsx diff --git a/apps/web/src/components/game-end-view.tsx b/apps/web/src/components/game-end-view.tsx index 0d8c8fe..fb85092 100644 --- a/apps/web/src/components/game-end-view.tsx +++ b/apps/web/src/components/game-end-view.tsx @@ -1,8 +1,9 @@ import { Link } from "wouter" +import { motion } from "framer-motion" +import { Crown } from "lucide-react" 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) @@ -10,9 +11,13 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) { 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 ?? "?" + const ranked = [...scores].sort((a, b) => b.score - a.score) + const nameOf = (id: string) => + snapshot.players.find((p) => p.id === id)?.name ?? "?" + + const winner = ranked[0] + const rest = ranked.slice(1) + const isMe = (id: string) => id === playerId return (
@@ -21,11 +26,80 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) { Partie terminée

- 🏆 {winnerName} l'emporte ! + 🏆 {winner ? nameOf(winner.playerId) : "?"} l'emporte !

- + {winner && ( + + + + + + + {nameOf(winner.playerId)} + {isMe(winner.playerId) && ( + (toi) + )} + + + {winner.score} + + + Champion·ne + + + )} + + {rest.length > 0 && ( +
    + {rest.map((s, i) => ( +
  1. + + + {i + 2} + + + {nameOf(s.playerId)} + {isMe(s.playerId) && ( + (toi) + )} + + + + {s.score} + +
  2. + ))} +
+ )}
) } diff --git a/apps/web/src/store/room.ts b/apps/web/src/store/room.ts index b1ca21e..70c7cb1 100644 --- a/apps/web/src/store/room.ts +++ b/apps/web/src/store/room.ts @@ -39,6 +39,8 @@ interface RoomState { finalScores: PlayerScore[] | null /** Compteur d'explosions : incrémenté à chaque fin de manche au timer (boom à 0). */ boomKey: number + /** Compteur de manches : incrémenté à chaque round:start (transition WarioWare). */ + roundKey: number createRoom: (playerName: string) => Promise joinRoom: (roomCode: string, playerName: string) => Promise @@ -61,6 +63,7 @@ export const useRoomStore = create((set, get) => ({ myChoiceIndex: null, finalScores: null, boomKey: 0, + roundKey: 0, createRoom: (playerName) => new Promise((resolve, reject) => { @@ -131,6 +134,7 @@ export const useRoomStore = create((set, get) => ({ myChoiceIndex: null, finalScores: null, boomKey: 0, + roundKey: 0, }), })) @@ -141,7 +145,7 @@ socket.on("room:state", (snapshot) => useRoomStore.setState({ snapshot })) socket.on("error", (error) => useRoomStore.setState({ lastError: error })) socket.on("round:start", (payload) => - useRoomStore.setState({ + useRoomStore.setState((s) => ({ round: { type: payload.type, djId: payload.djId, @@ -151,7 +155,8 @@ socket.on("round:start", (payload) => voteProgress: null, reveal: null, myChoiceIndex: null, - }) + roundKey: s.roundKey + 1, + })) ) socket.on("round:voteAck", (voteProgress) => useRoomStore.setState({ voteProgress }) -- 2.45.3 From 7dda00c65c39bd558c3c982777842ea03de09938 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Tue, 9 Jun 2026 11:25:17 +0200 Subject: [PATCH 13/51] feat: lead-in timer compensation + GIF support for round transitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server (option 2 — don't eat answer time): - engine: leadMs prep delay (default 1600ms) before the clock starts; the round timer now fires at lead + duration, and scoring uses the post-lead start so the speed bonus spans the full answer window - shared: round:start carries startsAt (answers begin) alongside endsAt - engine test passes leadMs:0 Client: - Countdown shows the full duration (no ticking) until startsAt, then counts down — so the WarioWare transition plays in full without stealing time - store/ActiveRound carry startsAt Custom transition media: - RoundTransition auto-loads any gif/webp/apng/png/avif dropped in src/assets/transitions/ (import.meta.glob), plays one at random per round, falls back to the Framer animation when none are present - README documenting how to add animations Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/src/game/engine.test.ts | 1 + apps/server/src/game/engine.ts | 13 ++- apps/web/src/assets/transitions/README.md | 15 +++ apps/web/src/components/countdown.tsx | 27 +++-- apps/web/src/components/quiz-view.tsx | 6 +- apps/web/src/components/round-transition.tsx | 115 ++++++++++++------- apps/web/src/store/room.ts | 2 + packages/shared/src/events.ts | 4 +- 8 files changed, 130 insertions(+), 53 deletions(-) create mode 100644 apps/web/src/assets/transitions/README.md diff --git a/apps/server/src/game/engine.test.ts b/apps/server/src/game/engine.test.ts index 7cbf67c..73f8fa7 100644 --- a/apps/server/src/game/engine.test.ts +++ b/apps/server/src/game/engine.test.ts @@ -66,6 +66,7 @@ function setup(roundDurationSec: number): { const options: GameEngineOptions = { createRound: () => dummyRound, revealPauseMs: 0, + leadMs: 0, } return { io, emits, rooms, room, p1: a.id, p2: b.id, options } } diff --git a/apps/server/src/game/engine.ts b/apps/server/src/game/engine.ts index 6c56f59..3bef317 100644 --- a/apps/server/src/game/engine.ts +++ b/apps/server/src/game/engine.ts @@ -22,11 +22,15 @@ export interface GameEngineOptions { createRound?: (type: RoundConfig["type"]) => GameRound /** Pause d'affichage du reveal/scores entre deux manches (ms). */ revealPauseMs?: number + /** Délai de préparation avant le démarrage du chrono (transition WarioWare, ms). */ + leadMs?: number /** Permet de patcher l'horloge en test ; défaut = Date.now. */ now?: () => number } const DEFAULT_REVEAL_PAUSE_MS = 5000 +// Le chrono ne démarre qu'après ce délai, le temps que la transition s'affiche. +const DEFAULT_LEAD_MS = 1600 export class GameEngine implements RoomGameController { private runtime: RoundRuntime | null = null @@ -34,6 +38,7 @@ export class GameEngine implements RoomGameController { private resolveRoundEnd: (() => void) | null = null private readonly createRound: (type: RoundConfig["type"]) => GameRound private readonly revealPauseMs: number + private readonly leadMs: number private readonly now: () => number constructor( @@ -44,6 +49,7 @@ export class GameEngine implements RoomGameController { ) { this.createRound = options.createRound ?? defaultCreateRound this.revealPauseMs = options.revealPauseMs ?? DEFAULT_REVEAL_PAUSE_MS + this.leadMs = options.leadMs ?? DEFAULT_LEAD_MS this.now = options.now ?? Date.now } @@ -85,7 +91,9 @@ export class GameEngine implements RoomGameController { const round = this.createRound(config.type) const start = round.start(this.room) const durationSec = start.durationSec ?? this.room.settings.roundDuration - const startedAt = this.now() + const now = this.now() + // Les réponses ne comptent qu'après le délai de préparation (transition). + const startedAt = now + this.leadMs const endsAt = startedAt + durationSec * 1000 this.runtime = { @@ -101,6 +109,7 @@ export class GameEngine implements RoomGameController { this.io.to(this.room.code).emit("round:start", { type: round.type, djId: this.runtime.djId ?? undefined, + startsAt: startedAt, endsAt, payload: start.payload, }) @@ -108,7 +117,7 @@ export class GameEngine implements RoomGameController { // Attend la première condition de fin : timer écoulé OU tous votés. await new Promise((resolve) => { this.resolveRoundEnd = resolve - this.timer = setTimeout(() => this.endRound(), durationSec * 1000) + this.timer = setTimeout(() => this.endRound(), endsAt - now) }) const ctx = this.context(this.runtime) diff --git a/apps/web/src/assets/transitions/README.md b/apps/web/src/assets/transitions/README.md new file mode 100644 index 0000000..eb8792a --- /dev/null +++ b/apps/web/src/assets/transitions/README.md @@ -0,0 +1,15 @@ +# Animations de transition (style WarioWare) + +Dépose ici des fichiers d'animation pour les transitions entre manches : +formats supportés `*.gif`, `*.webp`, `*.apng`, `*.png`, `*.avif`. + +- Ils sont détectés automatiquement au build (`import.meta.glob`). +- À chaque manche, un fichier est tiré **au hasard** parmi ceux présents. +- S'il n'y en a aucun, l'animation Framer par défaut (numéro + « PRÊT ?! ») est jouée. + +Le média est centré, limité à 70vh / 80vw, et joué par-dessus le wipe coloré +plein écran. Durée d'affichage ≈ 1,3 s (voir `VISIBLE_MS` dans +`src/components/round-transition.tsx`), calée sur le délai de préparation +serveur (`leadMs`) pour ne pas rogner le temps de réponse. + +> ⚠️ N'utilise que des médias dont tu as les droits (pas d'assets Nintendo). diff --git a/apps/web/src/components/countdown.tsx b/apps/web/src/components/countdown.tsx index 7847867..e50e4b1 100644 --- a/apps/web/src/components/countdown.tsx +++ b/apps/web/src/components/countdown.tsx @@ -4,9 +4,16 @@ import { useRoomStore } from "@/store/room" const DANGER_FROM = 10 // secondes : seuil d'entrée en mode "bombe" -/** Secondes restantes jusqu'à `endsAt` (timestamp serveur). */ -function secondsLeft(endsAt: number): number { - return Math.max(0, Math.ceil((endsAt - Date.now()) / 1000)) +/** + * Secondes restantes. Avant `startsAt` (phase de préparation / transition), + * on affiche la durée pleine sans décompter. + */ +function secondsLeft(startsAt: number, endsAt: number): number { + const now = Date.now() + if (now < startsAt) { + return Math.ceil((endsAt - startsAt) / 1000) + } + return Math.max(0, Math.ceil((endsAt - now) / 1000)) } function lerp(a: number, b: number, t: number): number { @@ -21,15 +28,21 @@ function dangerColor(t: number): string { return `rgb(${r}, ${g}, ${b})` } -export function Countdown({ endsAt }: { endsAt: number }) { - const [left, setLeft] = useState(() => secondsLeft(endsAt)) +export function Countdown({ + startsAt, + endsAt, +}: { + startsAt: number + endsAt: number +}) { + const [left, setLeft] = useState(() => secondsLeft(startsAt, endsAt)) const boom = useRoomStore((s) => s.boom) const boomed = useRef(false) useEffect(() => { - const id = setInterval(() => setLeft(secondsLeft(endsAt)), 250) + const id = setInterval(() => setLeft(secondsLeft(startsAt, endsAt)), 250) return () => clearInterval(id) - }, [endsAt]) + }, [startsAt, endsAt]) // Déclenche l'explosion une seule fois au passage à 0 (fin au timer). useEffect(() => { diff --git a/apps/web/src/components/quiz-view.tsx b/apps/web/src/components/quiz-view.tsx index cbc231a..c4035da 100644 --- a/apps/web/src/components/quiz-view.tsx +++ b/apps/web/src/components/quiz-view.tsx @@ -54,7 +54,11 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) { {question.category ? ` · ${question.category}` : ""} {!showReveal && ( - + )} diff --git a/apps/web/src/components/round-transition.tsx b/apps/web/src/components/round-transition.tsx index 3e4bef9..7d33241 100644 --- a/apps/web/src/components/round-transition.tsx +++ b/apps/web/src/components/round-transition.tsx @@ -2,15 +2,29 @@ import { useEffect, useState } from "react" import { createPortal } from "react-dom" import { AnimatePresence, motion } from "framer-motion" -const VISIBLE_MS = 1100 // durée d'affichage avant le wipe de sortie +const VISIBLE_MS = 1300 // durée d'affichage avant le wipe de sortie (≈ leadMs serveur) + +// Animations custom déposées par l'utilisateur (gif/webp/apng/png/mp4...). +// Glisse simplement des fichiers dans src/assets/transitions/ : ils sont +// détectés au build et joués aléatoirement. Sinon, fallback animation Framer. +const CUSTOM = Object.values( + import.meta.glob("../assets/transitions/*.{gif,webp,apng,png,avif}", { + eager: true, + import: "default", + }) +) as string[] /** * Transition "WarioWare" jouée entre les manches : wipe coloré plein écran, - * sunburst qui tourne, gros numéro de question qui rebondit, puis sortie. + * média custom (si présent) ou sunburst + gros numéro qui rebondit, puis sortie. * Montée avec une `key` qui change → rejoue à chaque manche. */ export function RoundTransition({ index }: { index: number }) { const [show, setShow] = useState(true) + // Choix stable d'un média custom pour ce montage (varie d'une manche à l'autre). + const [media] = useState(() => + CUSTOM.length ? CUSTOM[Math.floor(Math.random() * CUSTOM.length)] : null + ) useEffect(() => { const t = setTimeout(() => setShow(false), VISIBLE_MS) @@ -31,47 +45,64 @@ export function RoundTransition({ index }: { index: number }) { }} transition={{ duration: 0.25, ease: "easeOut" }} > - + {media ? ( + + ) : ( + <> + - - - Question - - - {index + 1} - - - PRÊT ?! - - + + + Question + + + {index + 1} + + + PRÊT ?! + + + + )} )} , diff --git a/apps/web/src/store/room.ts b/apps/web/src/store/room.ts index 70c7cb1..bf8d558 100644 --- a/apps/web/src/store/room.ts +++ b/apps/web/src/store/room.ts @@ -13,6 +13,7 @@ import { socket } from "@/lib/socket" export interface ActiveRound { type: RoundStartPayload["type"] djId?: string + startsAt: number endsAt: number payload: unknown } @@ -149,6 +150,7 @@ socket.on("round:start", (payload) => round: { type: payload.type, djId: payload.djId, + startsAt: payload.startsAt, endsAt: payload.endsAt, payload: payload.payload, }, diff --git a/packages/shared/src/events.ts b/packages/shared/src/events.ts index d8d2ee4..d18b5b2 100644 --- a/packages/shared/src/events.ts +++ b/packages/shared/src/events.ts @@ -48,7 +48,9 @@ export interface SubmitOkPayload { export interface RoundStartPayload { type: RoundType djId?: string - /** Timestamp serveur de fin de manche (startedAt + roundDuration). */ + /** Timestamp serveur de début des réponses (après le délai de préparation / transition). */ + startsAt: number + /** Timestamp serveur de fin de manche (startsAt + roundDuration). */ endsAt: number /** Payload spécifique au type d'épreuve, SANS la réponse. */ payload: unknown -- 2.45.3 From 9fe30feec575e5e7b213fd9769eb35222656ae2e Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Wed, 10 Jun 2026 12:24:17 +0200 Subject: [PATCH 14/51] feat(server): Drizzle + PostgreSQL schema + Open Trivia DB seed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drizzle (postgres-js) setup: schema (quiz_category, quiz_question, quiz_played, game_history), client (nullable — no DATABASE_URL → null), drizzle.config, initial migration, migrate script - Open Trivia DB seed: curated geek categories, mcq + truefalse, base64 decode (incl. type/difficulty), 1 req/5s rate limit, idempotent (prompt unique); also seeds the FR in-code bank as source 'manual' - quiz wired to the DB: per-room pool preloaded at game start (loadQuizPool excludes already-played), records quiz_played for cross-session anti-repeat; falls back to the in-code bank when no DB - docker-compose for local Postgres; db:* scripts; DATABASE_URL in env Roadmap V1 step 5. Verified against a real Postgres: migrate + seed (270 OpenTDB + 10 manual), DB-backed game serves OpenTDB questions and fills quiz_played; no-DB fallback still serves the in-code bank. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/.env.example | 2 + apps/server/drizzle.config.ts | 10 + .../drizzle/0000_loose_doctor_spectrum.sql | 39 +++ apps/server/drizzle/meta/0000_snapshot.json | 263 ++++++++++++++++++ apps/server/drizzle/meta/_journal.json | 13 + apps/server/package.json | 9 +- apps/server/src/db/index.ts | 21 ++ apps/server/src/db/migrate.ts | 18 ++ apps/server/src/db/quiz-repo.ts | 71 +++++ apps/server/src/db/schema.ts | 72 +++++ apps/server/src/db/seed.ts | 193 +++++++++++++ apps/server/src/env.ts | 2 + apps/server/src/game/modes/index.ts | 10 + apps/server/src/game/modes/quiz/pool.ts | 61 ++++ apps/server/src/game/modes/quiz/quiz-round.ts | 31 +-- apps/server/src/handlers.ts | 12 +- bun.lock | 125 +++++++++ docker-compose.yml | 24 ++ 18 files changed, 950 insertions(+), 26 deletions(-) create mode 100644 apps/server/drizzle.config.ts create mode 100644 apps/server/drizzle/0000_loose_doctor_spectrum.sql create mode 100644 apps/server/drizzle/meta/0000_snapshot.json create mode 100644 apps/server/drizzle/meta/_journal.json create mode 100644 apps/server/src/db/index.ts create mode 100644 apps/server/src/db/migrate.ts create mode 100644 apps/server/src/db/quiz-repo.ts create mode 100644 apps/server/src/db/schema.ts create mode 100644 apps/server/src/db/seed.ts create mode 100644 apps/server/src/game/modes/quiz/pool.ts create mode 100644 docker-compose.yml diff --git a/apps/server/.env.example b/apps/server/.env.example index 4680059..e928b54 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -2,3 +2,5 @@ NODE_ENV=development HOST=0.0.0.0 PORT=3001 CORS_ORIGINS=http://localhost:5173 +# Laisser vide pour jouer sans DB (banque de questions en dur). +DATABASE_URL=postgres://nerdware:nerdware@localhost:5432/nerdware diff --git a/apps/server/drizzle.config.ts b/apps/server/drizzle.config.ts new file mode 100644 index 0000000..1ed9364 --- /dev/null +++ b/apps/server/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "drizzle-kit" + +export default defineConfig({ + dialect: "postgresql", + schema: "./src/db/schema.ts", + out: "./drizzle", + dbCredentials: { + url: process.env.DATABASE_URL ?? "", + }, +}) diff --git a/apps/server/drizzle/0000_loose_doctor_spectrum.sql b/apps/server/drizzle/0000_loose_doctor_spectrum.sql new file mode 100644 index 0000000..ec13ca3 --- /dev/null +++ b/apps/server/drizzle/0000_loose_doctor_spectrum.sql @@ -0,0 +1,39 @@ +CREATE TABLE "game_history" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "room_code" text NOT NULL, + "played_at" timestamp with time zone DEFAULT now() NOT NULL, + "modes" jsonb, + "results" jsonb +); +--> statement-breakpoint +CREATE TABLE "quiz_category" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "quiz_category_name_unique" UNIQUE("name") +); +--> statement-breakpoint +CREATE TABLE "quiz_played" ( + "question_id" uuid NOT NULL, + "room_code" text NOT NULL, + "played_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "quiz_played_question_id_room_code_pk" PRIMARY KEY("question_id","room_code") +); +--> statement-breakpoint +CREATE TABLE "quiz_question" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "category_id" uuid, + "format" text NOT NULL, + "prompt" text NOT NULL, + "difficulty" integer DEFAULT 1 NOT NULL, + "source" text NOT NULL, + "choices" jsonb, + "correct_index" integer, + "accepted_answers" jsonb, + "image_url" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "quiz_question_prompt_unique" UNIQUE("prompt") +); +--> statement-breakpoint +ALTER TABLE "quiz_played" ADD CONSTRAINT "quiz_played_question_id_quiz_question_id_fk" FOREIGN KEY ("question_id") REFERENCES "public"."quiz_question"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "quiz_question" ADD CONSTRAINT "quiz_question_category_id_quiz_category_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."quiz_category"("id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/apps/server/drizzle/meta/0000_snapshot.json b/apps/server/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..c5eb80f --- /dev/null +++ b/apps/server/drizzle/meta/0000_snapshot.json @@ -0,0 +1,263 @@ +{ + "id": "c68e2f59-3d1b-4fca-8443-17fe243e2919", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.game_history": { + "name": "game_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "played_at": { + "name": "played_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "modes": { + "name": "modes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "results": { + "name": "results", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_category": { + "name": "quiz_category", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "quiz_category_name_unique": { + "name": "quiz_category_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_played": { + "name": "quiz_played", + "schema": "", + "columns": { + "question_id": { + "name": "question_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "played_at": { + "name": "played_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "quiz_played_question_id_quiz_question_id_fk": { + "name": "quiz_played_question_id_quiz_question_id_fk", + "tableFrom": "quiz_played", + "tableTo": "quiz_question", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "quiz_played_question_id_room_code_pk": { + "name": "quiz_played_question_id_room_code_pk", + "columns": [ + "question_id", + "room_code" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_question": { + "name": "quiz_question", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "difficulty": { + "name": "difficulty", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "choices": { + "name": "choices", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "correct_index": { + "name": "correct_index", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "accepted_answers": { + "name": "accepted_answers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "quiz_question_category_id_quiz_category_id_fk": { + "name": "quiz_question_category_id_quiz_category_id_fk", + "tableFrom": "quiz_question", + "tableTo": "quiz_category", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "quiz_question_prompt_unique": { + "name": "quiz_question_prompt_unique", + "nullsNotDistinct": false, + "columns": [ + "prompt" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json new file mode 100644 index 0000000..4a441f0 --- /dev/null +++ b/apps/server/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1781085893179, + "tag": "0000_loose_doctor_spectrum", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/apps/server/package.json b/apps/server/package.json index 34534ed..31db470 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -10,18 +10,25 @@ "build": "bun build ./src/index.ts --target bun --outdir dist", "lint": "eslint", "format": "prettier --write \"**/*.ts\"", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "db:generate": "drizzle-kit generate", + "db:migrate": "bun src/db/migrate.ts", + "db:seed": "bun src/db/seed.ts", + "db:studio": "drizzle-kit studio" }, "dependencies": { "@fastify/cors": "^11", "@nerdware/shared": "workspace:*", + "drizzle-orm": "^0.45", "fastify": "^5", + "postgres": "^3.4", "socket.io": "^4.8.1" }, "devDependencies": { "@eslint/js": "^10", "@types/bun": "latest", "@types/node": "^24", + "drizzle-kit": "^0.31", "eslint": "^10", "globals": "^17", "pino-pretty": "^13", diff --git a/apps/server/src/db/index.ts b/apps/server/src/db/index.ts new file mode 100644 index 0000000..a6712e1 --- /dev/null +++ b/apps/server/src/db/index.ts @@ -0,0 +1,21 @@ +// Client Drizzle/PostgreSQL. Optionnel : sans DATABASE_URL, `db` vaut null et +// le jeu retombe sur la banque de questions en dur (dev sans infra). + +import { drizzle } from "drizzle-orm/postgres-js" +import postgres from "postgres" +import { env } from "../env" +import * as schema from "./schema" + +export type Database = ReturnType> + +function createDb(): Database | null { + if (!env.databaseUrl) { + return null + } + const client = postgres(env.databaseUrl) + return drizzle(client, { schema }) +} + +export const db = createDb() +export const hasDb = db !== null +export { schema } diff --git a/apps/server/src/db/migrate.ts b/apps/server/src/db/migrate.ts new file mode 100644 index 0000000..6f708c4 --- /dev/null +++ b/apps/server/src/db/migrate.ts @@ -0,0 +1,18 @@ +// Applique les migrations générées (drizzle/*.sql) à la base. +// bun run db:migrate +import { drizzle } from "drizzle-orm/postgres-js" +import { migrate } from "drizzle-orm/postgres-js/migrator" +import postgres from "postgres" +import { env } from "../env" + +if (!env.databaseUrl) { + console.error("DATABASE_URL manquant — rien à migrer.") + process.exit(1) +} + +const client = postgres(env.databaseUrl, { max: 1 }) +const db = drizzle(client) + +await migrate(db, { migrationsFolder: "./drizzle" }) +console.log("Migrations appliquées.") +await client.end() diff --git a/apps/server/src/db/quiz-repo.ts b/apps/server/src/db/quiz-repo.ts new file mode 100644 index 0000000..92eec3d --- /dev/null +++ b/apps/server/src/db/quiz-repo.ts @@ -0,0 +1,71 @@ +// Accès aux questions de quiz en base. No-op si pas de DB (db === null). + +import { and, eq, inArray, isNotNull, notInArray, sql } from "drizzle-orm" +import { db } from "./index" +import { quizCategory, quizPlayed, quizQuestion } from "./schema" +import type { QuizQuestion } from "../game/modes/quiz/questions" + +/** + * Pool de questions mcq/truefalse non encore jouées par cette room, + * mélangées et limitées. Tableau vide si pas de DB ou rien à servir. + */ +export async function loadQuizPool( + roomCode: string, + limit: number +): Promise { + if (!db) { + return [] + } + const alreadyPlayed = db + .select({ id: quizPlayed.questionId }) + .from(quizPlayed) + .where(eq(quizPlayed.roomCode, roomCode)) + + const rows = await db + .select({ + id: quizQuestion.id, + format: quizQuestion.format, + prompt: quizQuestion.prompt, + choices: quizQuestion.choices, + correctIndex: quizQuestion.correctIndex, + difficulty: quizQuestion.difficulty, + category: quizCategory.name, + }) + .from(quizQuestion) + .leftJoin(quizCategory, eq(quizQuestion.categoryId, quizCategory.id)) + .where( + and( + inArray(quizQuestion.format, ["mcq", "truefalse"]), + isNotNull(quizQuestion.correctIndex), + notInArray(quizQuestion.id, alreadyPlayed) + ) + ) + .orderBy(sql`random()`) + .limit(limit) + + return rows + .filter((r) => r.choices && r.correctIndex !== null) + .map((r) => ({ + id: r.id, + format: r.format as QuizQuestion["format"], + prompt: r.prompt, + choices: r.choices as string[], + correctIndex: r.correctIndex as number, + category: r.category ?? "Quiz", + difficulty: r.difficulty, + })) +} + +/** Marque une question comme jouée par cette room (anti-répétition). */ +export async function markQuestionPlayed( + roomCode: string, + questionId: string +): Promise { + if (!db) { + return + } + await db + .insert(quizPlayed) + .values({ roomCode, questionId }) + .onConflictDoNothing() +} diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts new file mode 100644 index 0000000..668257f --- /dev/null +++ b/apps/server/src/db/schema.ts @@ -0,0 +1,72 @@ +// Schéma persistant (PostgreSQL via Drizzle). Ne stocke que le contenu quiz +// et l'historique — les rooms vivent en mémoire (voir ARCHITECTURE §3). + +import { sql } from "drizzle-orm" +import { + integer, + jsonb, + pgTable, + primaryKey, + text, + timestamp, + uuid, +} from "drizzle-orm/pg-core" + +export const quizCategory = pgTable("quiz_category", { + id: uuid("id").primaryKey().defaultRandom(), + name: text("name").notNull().unique(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .default(sql`now()`), +}) + +export const quizQuestion = pgTable("quiz_question", { + id: uuid("id").primaryKey().defaultRandom(), + categoryId: uuid("category_id").references(() => quizCategory.id), + /** 'mcq' | 'truefalse' | 'free' | 'image_reveal' */ + format: text("format").notNull(), + /** Unique → seed idempotent (onConflictDoNothing). */ + prompt: text("prompt").notNull().unique(), + /** 1 (facile) .. 3 (difficile). */ + difficulty: integer("difficulty").notNull().default(1), + /** 'opentdb' | 'manual' */ + source: text("source").notNull(), + // Champs selon le format (NULL si non applicable) : + choices: jsonb("choices").$type(), + correctIndex: integer("correct_index"), + acceptedAnswers: jsonb("accepted_answers").$type(), + imageUrl: text("image_url"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .default(sql`now()`), +}) + +/** Anti-répétition : ce qu'un groupe (room_code) a déjà joué. */ +export const quizPlayed = pgTable( + "quiz_played", + { + questionId: uuid("question_id") + .notNull() + .references(() => quizQuestion.id), + roomCode: text("room_code").notNull(), + playedAt: timestamp("played_at", { withTimezone: true }) + .notNull() + .default(sql`now()`), + }, + (t) => [primaryKey({ columns: [t.questionId, t.roomCode] })] +) + +/** Historique de parties (stats / portfolio). */ +export const gameHistory = pgTable("game_history", { + id: uuid("id").primaryKey().defaultRandom(), + roomCode: text("room_code").notNull(), + playedAt: timestamp("played_at", { withTimezone: true }) + .notNull() + .default(sql`now()`), + modes: jsonb("modes").$type(), + results: jsonb("results"), +}) + +export type QuizQuestionRow = typeof quizQuestion.$inferSelect +export type NewQuizQuestion = typeof quizQuestion.$inferInsert +export type NewQuizCategory = typeof quizCategory.$inferInsert diff --git a/apps/server/src/db/seed.ts b/apps/server/src/db/seed.ts new file mode 100644 index 0000000..019a499 --- /dev/null +++ b/apps/server/src/db/seed.ts @@ -0,0 +1,193 @@ +// Seed du contenu quiz. One-shot au déploiement : bun run db:seed +// - Open Trivia DB : catégories geek (mcq + truefalse), respect 1 req / 5 s. +// - Banque FR en dur : insérée comme source 'manual'. +// Idempotent : prompt unique → onConflictDoNothing. + +import { eq } from "drizzle-orm" +import { drizzle } from "drizzle-orm/postgres-js" +import postgres from "postgres" +import { env } from "../env" +import { + quizCategory, + quizQuestion, + type NewQuizQuestion, +} from "./schema" +import { QUIZ_QUESTIONS } from "../game/modes/quiz/questions" + +if (!env.databaseUrl) { + console.error("DATABASE_URL manquant — impossible de seeder.") + process.exit(1) +} + +const client = postgres(env.databaseUrl, { max: 1 }) +const db = drizzle(client, { schema: { quizCategory, quizQuestion } }) + +// Catégories Open Trivia DB pertinentes (id OpenTDB → nom affiché). +const OPENTDB_CATEGORIES = [ + { id: 15, name: "Jeux vidéo" }, + { id: 31, name: "Anime & Manga" }, + { id: 29, name: "Comics" }, + { id: 11, name: "Cinéma" }, + { id: 14, name: "Télévision" }, + { id: 18, name: "Informatique" }, +] +const AMOUNT = 30 +const RATE_LIMIT_MS = 5200 // OpenTDB : 1 requête / 5 s par IP + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) +const decode = (s: string) => Buffer.from(s, "base64").toString("utf8") +const difficultyOf = (d: string) => (d === "hard" ? 3 : d === "medium" ? 2 : 1) + +// Tous les champs string sont encodés en base64 (encode=base64). +interface OtdbQuestion { + type: string + difficulty: string + question: string + correct_answer: string + incorrect_answers: string[] +} + +function shuffle(items: T[]): T[] { + const arr = [...items] + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[arr[i], arr[j]] = [arr[j], arr[i]] + } + return arr +} + +async function getToken(): Promise { + try { + const res = await fetch("https://opentdb.com/api_token.php?command=request") + const json = (await res.json()) as { token?: string } + return json.token + } catch { + return undefined + } +} + +async function fetchBatch( + categoryId: number, + type: "multiple" | "boolean", + token: string | undefined +): Promise { + const url = + `https://opentdb.com/api.php?amount=${AMOUNT}&category=${categoryId}` + + `&type=${type}&encode=base64${token ? `&token=${token}` : ""}` + const res = await fetch(url) + const json = (await res.json()) as { + response_code: number + results: OtdbQuestion[] + } + if (json.response_code === 5) { + // Rate limit : on attend et on réessaie une fois. + await sleep(RATE_LIMIT_MS) + return fetchBatch(categoryId, type, token) + } + return json.response_code === 0 ? json.results : [] +} + +/** Upsert une catégorie et renvoie son id. */ +async function upsertCategory(name: string): Promise { + await db.insert(quizCategory).values({ name }).onConflictDoNothing() + const [row] = await db + .select({ id: quizCategory.id }) + .from(quizCategory) + .where(eq(quizCategory.name, name)) + return row.id +} + +function toQuestion( + q: OtdbQuestion, + categoryId: string +): NewQuizQuestion { + // Avec encode=base64, TOUS les champs sont encodés (type/difficulty inclus). + const prompt = decode(q.question) + const type = decode(q.type) + const difficulty = difficultyOf(decode(q.difficulty)) + if (type === "boolean") { + const correct = decode(q.correct_answer) // "True" | "False" + return { + categoryId, + format: "truefalse", + prompt, + difficulty, + source: "opentdb", + choices: ["True", "False"], + correctIndex: correct === "True" ? 0 : 1, + } + } + const correct = decode(q.correct_answer) + const choices = shuffle([ + correct, + ...q.incorrect_answers.map(decode), + ]) + return { + categoryId, + format: "mcq", + prompt, + difficulty, + source: "opentdb", + choices, + correctIndex: choices.indexOf(correct), + } +} + +async function insertQuestions(rows: NewQuizQuestion[]): Promise { + if (rows.length === 0) { + return 0 + } + const inserted = await db + .insert(quizQuestion) + .values(rows) + .onConflictDoNothing() + .returning({ id: quizQuestion.id }) + return inserted.length +} + +async function seedOpenTdb(): Promise { + const token = await getToken() + let total = 0 + let first = true + for (const cat of OPENTDB_CATEGORIES) { + const categoryId = await upsertCategory(cat.name) + for (const type of ["multiple", "boolean"] as const) { + if (!first) { + await sleep(RATE_LIMIT_MS) + } + first = false + const batch = await fetchBatch(cat.id, type, token) + const rows = batch.map((q) => toQuestion(q, categoryId)) + const n = await insertQuestions(rows) + total += n + console.log(` ${cat.name} (${type}): +${n}`) + } + } + return total +} + +async function seedManual(): Promise { + let total = 0 + for (const q of QUIZ_QUESTIONS) { + const categoryId = await upsertCategory(q.category) + total += await insertQuestions([ + { + categoryId, + format: q.format, + prompt: q.prompt, + difficulty: q.difficulty, + source: "manual", + choices: q.choices, + correctIndex: q.correctIndex, + }, + ]) + } + return total +} + +console.log("Seed Open Trivia DB (respect du 1 req / 5 s, patientez)…") +const otdb = await seedOpenTdb() +console.log("Seed banque FR (manual)…") +const manual = await seedManual() +console.log(`Terminé : ${otdb} questions OpenTDB + ${manual} manuelles.`) +await client.end() diff --git a/apps/server/src/env.ts b/apps/server/src/env.ts index 5551908..169dbfa 100644 --- a/apps/server/src/env.ts +++ b/apps/server/src/env.ts @@ -10,6 +10,8 @@ export const env = { nodeEnv: process.env.NODE_ENV ?? "development", host: process.env.HOST ?? "0.0.0.0", port: num(process.env.PORT, 3001), + /** Connexion PostgreSQL. Vide → pas de DB, fallback banque en dur. */ + databaseUrl: process.env.DATABASE_URL ?? "", /** Origines autorisées pour CORS / Socket.IO (séparées par des virgules). */ corsOrigins: (process.env.CORS_ORIGINS ?? "http://localhost:5173") .split(",") diff --git a/apps/server/src/game/modes/index.ts b/apps/server/src/game/modes/index.ts index d413056..684f1f1 100644 --- a/apps/server/src/game/modes/index.ts +++ b/apps/server/src/game/modes/index.ts @@ -2,3 +2,13 @@ // pour ses effets de bord (registerRound). Ajouter un mode = une ligne ici. import "./quiz" + +import type { ServerRoom } from "../../rooms" +import { prepareQuizForRoom } from "./quiz/pool" + +/** Précharge le contenu nécessaire avant de lancer la partie (ex: pool quiz). */ +export async function prepareRoom(room: ServerRoom): Promise { + if (room.settings.rounds.some((r) => r.type === "quiz")) { + await prepareQuizForRoom(room) + } +} diff --git a/apps/server/src/game/modes/quiz/pool.ts b/apps/server/src/game/modes/quiz/pool.ts new file mode 100644 index 0000000..5a065fc --- /dev/null +++ b/apps/server/src/game/modes/quiz/pool.ts @@ -0,0 +1,61 @@ +// File d'attente de questions par room. Préchargée au lancement de la partie +// depuis la DB (si dispo), sinon depuis la banque en dur. Le QuizRound y pioche. + +import { hasDb } from "../../../db" +import { loadQuizPool } from "../../../db/quiz-repo" +import type { ServerRoom } from "../../../rooms" +import { QUIZ_QUESTIONS, type QuizQuestion } from "./questions" + +interface RoomPool { + queue: QuizQuestion[] + /** true si les questions viennent de la DB (→ marquer jouées). */ + fromDb: boolean +} + +const poolByRoom = new WeakMap() + +function shuffle(items: T[]): T[] { + const arr = [...items] + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[arr[i], arr[j]] = [arr[j], arr[i]] + } + return arr +} + +/** Précharge les questions de la partie. À appeler avant de lancer le moteur. */ +export async function prepareQuizForRoom(room: ServerRoom): Promise { + const needed = room.settings.rounds.filter((r) => r.type === "quiz").length + let queue: QuizQuestion[] = [] + let fromDb = false + + if (hasDb) { + try { + queue = await loadQuizPool(room.code, Math.max(needed, 1)) + fromDb = queue.length > 0 + } catch (err) { + console.error("[quiz] chargement DB échoué, fallback banque en dur", err) + } + } + + if (queue.length === 0) { + queue = shuffle(QUIZ_QUESTIONS) + fromDb = false + } + + poolByRoom.set(room, { queue, fromDb }) +} + +/** Pioche la prochaine question. Fallback aléatoire si la file est vide. */ +export function takeQuestion(room: ServerRoom): { + question: QuizQuestion + fromDb: boolean +} { + const pool = poolByRoom.get(room) + if (pool && pool.queue.length > 0) { + return { question: pool.queue.shift()!, fromDb: pool.fromDb } + } + const question = + QUIZ_QUESTIONS[Math.floor(Math.random() * QUIZ_QUESTIONS.length)] + return { question, fromDb: false } +} diff --git a/apps/server/src/game/modes/quiz/quiz-round.ts b/apps/server/src/game/modes/quiz/quiz-round.ts index 55116e8..e8f121e 100644 --- a/apps/server/src/game/modes/quiz/quiz-round.ts +++ b/apps/server/src/game/modes/quiz/quiz-round.ts @@ -8,9 +8,12 @@ import type { QuizRevealTruth, ScoreDelta, } from "@nerdware/shared" +import { hasDb } from "../../../db" +import { markQuestionPlayed } from "../../../db/quiz-repo" import type { GameRound, RoundContext, RoundStart } from "../../round" import type { ServerRoom } from "../../../rooms" -import { QUIZ_QUESTIONS, type QuizQuestion } from "./questions" +import type { QuizQuestion } from "./questions" +import { takeQuestion } from "./pool" const BASE_POINTS = 100 const SPEED_BONUS_MAX = 100 @@ -22,26 +25,6 @@ interface QuizRoundData { votedAt: Map } -// Anti-répétition à l'échelle d'une partie : questions déjà jouées par room. -const playedByRoom = new WeakMap>() - -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" @@ -54,7 +37,11 @@ export class QuizRound implements GameRound { constructor(private readonly now: () => number = Date.now) {} start(room: ServerRoom): RoundStart { - const question = pickQuestion(room) + const { question, fromDb } = takeQuestion(room) + // Anti-répétition cross-session : on enregistre la question jouée (DB only). + if (fromDb && hasDb) { + void markQuestionPlayed(room.code, question.id) + } const payload: QuizQuestionPayload = { format: question.format, prompt: question.prompt, diff --git a/apps/server/src/handlers.ts b/apps/server/src/handlers.ts index aebdf99..c52c227 100644 --- a/apps/server/src/handlers.ts +++ b/apps/server/src/handlers.ts @@ -12,6 +12,7 @@ import type { IoServer } from "./socket" import { RoomError, RoomManager, type ServerRoom } from "./rooms" import { GameEngine } from "./game/engine" import { hasRound } from "./game/registry" +import { prepareRoom } from "./game/modes" type AppSocket = Socket< ClientToServerEvents, @@ -121,10 +122,15 @@ export function registerRoomHandlers( return } ack({ ok: true, data: null }) - const engine = new GameEngine(io, rooms, room) - room.game = engine + // Précharge le contenu (pool quiz depuis la DB), puis lance la boucle. // Fire-and-forget : la boucle de jeu broadcast son propre état. - void engine.run().catch((err) => console.error("[game] run failed", err)) + void prepareRoom(room) + .then(() => { + const engine = new GameEngine(io, rooms, room) + room.game = engine + return engine.run() + }) + .catch((err) => console.error("[game] run failed", err)) }) socket.on("round:vote", (payload) => { diff --git a/bun.lock b/bun.lock index 5810c19..85c5245 100644 --- a/bun.lock +++ b/bun.lock @@ -17,13 +17,16 @@ "dependencies": { "@fastify/cors": "^11", "@nerdware/shared": "workspace:*", + "drizzle-orm": "^0.45", "fastify": "^5", + "postgres": "^3.4", "socket.io": "^4.8.1", }, "devDependencies": { "@eslint/js": "^10", "@types/bun": "latest", "@types/node": "^24", + "drizzle-kit": "^0.31", "eslint": "^10", "globals": "^17", "pino-pretty": "^13", @@ -171,6 +174,8 @@ "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.71.0", "", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "enquirer": "^2.4.1", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.4", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-KEUw/mGu+EDRhYWRTNGHIimVCs9NvMFaIXOGrHSXoCteKLE5EsJnmPjOPpYorjXVg/0xG0fbdVw720azw1z4ag=="], + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], + "@ecies/ciphers": ["@ecies/ciphers@0.2.6", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g=="], "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], @@ -179,6 +184,10 @@ "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], + + "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], @@ -657,6 +666,8 @@ "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], @@ -747,6 +758,10 @@ "dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], + "drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="], + + "drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "eciesjs": ["eciesjs@0.4.18", "", { "dependencies": { "@ecies/ciphers": "^0.2.5", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.7", "@noble/hashes": "^1.8.0" } }, "sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ=="], @@ -907,6 +922,8 @@ "get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], + "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "globals": ["globals@17.6.0", "", {}, "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA=="], @@ -1169,6 +1186,8 @@ "postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="], + "postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="], + "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], @@ -1223,6 +1242,8 @@ "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], "ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="], @@ -1293,6 +1314,8 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], @@ -1347,6 +1370,8 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tsx": ["tsx@4.22.4", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg=="], + "turbo": ["turbo@2.9.16", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.16", "@turbo/darwin-arm64": "2.9.16", "@turbo/linux-64": "2.9.16", "@turbo/linux-arm64": "2.9.16", "@turbo/windows-64": "2.9.16", "@turbo/windows-arm64": "2.9.16" }, "bin": { "turbo": "bin/turbo" } }, "sha512-NqgRQy6j6dPYcdSdv0q1g9QsZg7SWg87RERM8otw/1AtKU2yTFVClOM7cbwKzOonZr/Ek1blTBucw64L9H0Bwg=="], "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], @@ -1445,6 +1470,8 @@ "@dotenvx/dotenvx/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@fastify/ajv-compiler/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], @@ -1515,6 +1542,8 @@ "thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="], + "tsx/esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="], + "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], "type-is/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], @@ -1539,6 +1568,50 @@ "@dotenvx/dotenvx/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], + "@fastify/ajv-compiler/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], @@ -1563,6 +1636,58 @@ "send/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="], + + "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="], + + "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.0", "", { "os": "android", "cpu": "arm64" }, "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw=="], + + "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.0", "", { "os": "android", "cpu": "x64" }, "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA=="], + + "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q=="], + + "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ=="], + + "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q=="], + + "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw=="], + + "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw=="], + + "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A=="], + + "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ=="], + + "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg=="], + + "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w=="], + + "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg=="], + + "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ=="], + + "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q=="], + + "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.0", "", { "os": "linux", "cpu": "x64" }, "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ=="], + + "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw=="], + + "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.0", "", { "os": "none", "cpu": "x64" }, "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw=="], + + "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g=="], + + "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA=="], + + "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w=="], + + "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw=="], + + "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA=="], + + "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA=="], + + "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw=="], + "type-is/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], "wrap-ansi/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..08647a3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +# Postgres de développement local pour NerdWare. +# docker compose up -d +# Puis depuis apps/server : bun run db:migrate && bun run db:seed +services: + postgres: + image: postgres:17-alpine + container_name: nerdware-postgres + restart: unless-stopped + environment: + POSTGRES_USER: nerdware + POSTGRES_PASSWORD: nerdware + POSTGRES_DB: nerdware + ports: + - "5432:5432" + volumes: + - nerdware-pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U nerdware -d nerdware"] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + nerdware-pgdata: -- 2.45.3 From 98e06b8206d3a43691a15331f1bee2a8bfc4ec50 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Wed, 10 Jun 2026 13:02:28 +0200 Subject: [PATCH 15/51] feat(blindtest): YouTube blindtest mode with neutral DJ + synced playback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server: - BlindtestRound (GameRound): neutral DJ pick (never the contributor), per-mode scoring (title_artist fuzzy / who_added / mixed), reveal exposes title/artist/submittedBy; contributor doesn't score on own track - track submission: blindtest:submitTrack validates via YouTube oEmbed (exists + embeddable) and captures title/artist; per-player quota; dedup - DJ media relay: media:control (DJ only) → media:sync broadcast (engine) - per-room shuffled track pool; submissions count in room snapshot - shared: blindtest payloads, gameType/tracksPerPlayer in settings - tests: DJ neutrality, scoring per mode, fuzzy match, youtube id extraction Client: - YouTube IFrame player (hidden behind a spinning disc — audio only, no title leak), DJ transport (play/pause/restart), non-DJ follows media:sync with latency compensation - lobby: game-type switch (Quiz/Blindtest), blindtest config + per-player track submission UI - vote forms per mode; reveal card with title/artist/who-added Roadmap V1 step 6 (highest technical risk). Server flow verified end-to-end over the wire (submit→start→DJ relay→vote→reveal→score). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/src/game/engine.ts | 23 +- .../modes/blindtest/blindtest-round.test.ts | 110 ++++++++ .../game/modes/blindtest/blindtest-round.ts | 125 +++++++++ apps/server/src/game/modes/blindtest/index.ts | 6 + apps/server/src/game/modes/blindtest/match.ts | 54 ++++ apps/server/src/game/modes/blindtest/pool.ts | 26 ++ .../src/game/modes/blindtest/youtube.test.ts | 20 ++ .../src/game/modes/blindtest/youtube.ts | 53 ++++ apps/server/src/game/modes/index.ts | 10 +- apps/server/src/game/round.ts | 10 +- apps/server/src/handlers.ts | 64 +++++ apps/server/src/rooms.ts | 17 ++ apps/web/src/components/blindtest-view.tsx | 264 ++++++++++++++++++ apps/web/src/components/lobby-view.tsx | 221 +++++++++++++-- apps/web/src/lib/youtube.ts | 97 +++++++ apps/web/src/pages/room.tsx | 9 +- apps/web/src/store/room.ts | 85 ++++-- apps/web/src/types/youtube.d.ts | 44 +++ packages/shared/src/blindtest.ts | 28 ++ packages/shared/src/domain.ts | 8 + packages/shared/src/events.ts | 11 +- packages/shared/src/index.ts | 1 + 22 files changed, 1238 insertions(+), 48 deletions(-) create mode 100644 apps/server/src/game/modes/blindtest/blindtest-round.test.ts create mode 100644 apps/server/src/game/modes/blindtest/blindtest-round.ts create mode 100644 apps/server/src/game/modes/blindtest/index.ts create mode 100644 apps/server/src/game/modes/blindtest/match.ts create mode 100644 apps/server/src/game/modes/blindtest/pool.ts create mode 100644 apps/server/src/game/modes/blindtest/youtube.test.ts create mode 100644 apps/server/src/game/modes/blindtest/youtube.ts create mode 100644 apps/web/src/components/blindtest-view.tsx create mode 100644 apps/web/src/lib/youtube.ts create mode 100644 apps/web/src/types/youtube.d.ts create mode 100644 packages/shared/src/blindtest.ts diff --git a/apps/server/src/game/engine.ts b/apps/server/src/game/engine.ts index 3bef317..7df50c9 100644 --- a/apps/server/src/game/engine.ts +++ b/apps/server/src/game/engine.ts @@ -2,7 +2,12 @@ // pour chaque manche start → (votes | timer) → reveal → score, puis game:end. // Tout est tranché serveur ; les clients n'affichent que ce qui est broadcasté. -import type { Answer, PlayerScore, RoundConfig } from "@nerdware/shared" +import type { + Answer, + MediaControlPayload, + PlayerScore, + RoundConfig, +} from "@nerdware/shared" import type { IoServer } from "../socket" import type { RoomManager, ServerRoom } from "../rooms" import { createRound as defaultCreateRound, type RoundFactory } from "./registry" @@ -87,6 +92,22 @@ export class GameEngine implements RoomGameController { } } + /** Relais média : seul le DJ de la manche peut piloter la lecture. */ + handleMediaControl(playerId: string, payload: MediaControlPayload): void { + if ( + !this.runtime || + this.room.status !== "in_round" || + playerId !== this.runtime.djId + ) { + return + } + this.io.to(this.room.code).emit("media:sync", { + action: payload.action, + positionSec: payload.positionSec, + atServerTs: this.now(), + }) + } + private async playRound(config: RoundConfig): Promise { const round = this.createRound(config.type) const start = round.start(this.room) diff --git a/apps/server/src/game/modes/blindtest/blindtest-round.test.ts b/apps/server/src/game/modes/blindtest/blindtest-round.test.ts new file mode 100644 index 0000000..bcfb726 --- /dev/null +++ b/apps/server/src/game/modes/blindtest/blindtest-round.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from "bun:test" +import { RoomManager, type BlindtestTrack } from "../../../rooms" +import type { RoundContext } from "../../round" +import { BlindtestRound } from "./blindtest-round" +import { prepareBlindtestForRoom } from "./pool" +import { fuzzyMatch, normalize } from "./match" + +function track(submittedBy: string): BlindtestTrack { + return { + id: "t1", + youtubeId: "abc12345678", + url: "https://youtu.be/abc12345678", + title: "The Legend of Zelda Main Theme", + artist: "Koji Kondo", + submittedBy, + } +} + +function ctxFor( + room: ReturnType["room"], + t: BlindtestTrack, + mode: "title_artist" | "who_added" | "mixed" +): RoundContext { + room.settings.blindtestMode = mode + return { + room, + djId: null, + votes: new Map(), + startedAt: 0, + endsAt: 10_000, + data: { track: t }, + } +} + +describe("match", () => { + test("normalize retire accents/ponctuation/casse", () => { + expect(normalize("Pokémon: Rouge & Bleu!")).toBe("pokemon rouge bleu") + }) + test("fuzzyMatch tolère fautes et réponses partielles", () => { + expect(fuzzyMatch("zelda", "The Legend of Zelda")).toBe(true) + expect(fuzzyMatch("koji kondo", "Koji Kondo")).toBe(true) + expect(fuzzyMatch("mario", "The Legend of Zelda")).toBe(false) + }) +}) + +describe("BlindtestRound", () => { + test("start tire un DJ neutre (jamais le contributeur)", () => { + const rooms = new RoomManager() + const { room, player: a } = rooms.create("Alice", "sa") + const { player: b } = rooms.join(room.code, "Bob", "sb") + room.blindtestTracks = [track(a.id)] + prepareBlindtestForRoom(room) + + const round = new BlindtestRound() + const start = round.start(room) + expect(start.djId).toBe(b.id) // jamais Alice (contributrice) + expect(start.payload).toEqual({ trackId: "t1", youtubeId: "abc12345678" }) + }) + + test("who_added : points si on devine le bon contributeur", () => { + const rooms = new RoomManager() + const { room, player: a } = rooms.create("Alice", "sa") + const { player: b } = rooms.join(room.code, "Bob", "sb") + const t = track(a.id) + const round = new BlindtestRound() + const ctx = ctxFor(room, t, "who_added") + round.submitAnswer(ctx, b.id, { guessedPlayerId: a.id }) // juste + const deltas = round.score(ctx) + expect(deltas).toEqual([{ playerId: b.id, delta: 100 }]) + }) + + test("title_artist : 60 titre + 40 artiste, fuzzy", () => { + const rooms = new RoomManager() + const { room, player: a } = rooms.create("Alice", "sa") + const { player: b } = rooms.join(room.code, "Bob", "sb") + const t = track(a.id) + const round = new BlindtestRound() + const ctx = ctxFor(room, t, "title_artist") + round.submitAnswer(ctx, b.id, { title: "zelda", artist: "koji kondo" }) + const { perPlayerResult } = round.reveal(ctx) + expect(perPlayerResult[b.id]).toMatchObject({ + titleCorrect: true, + artistCorrect: true, + points: 100, + }) + expect(round.score(ctx)).toEqual([{ playerId: b.id, delta: 100 }]) + }) + + test("le contributeur ne marque pas sur son propre titre", () => { + const rooms = new RoomManager() + const { room, player: a } = rooms.create("Alice", "sa") + const t = track(a.id) + const round = new BlindtestRound() + const ctx = ctxFor(room, t, "who_added") + round.submitAnswer(ctx, a.id, { guessedPlayerId: a.id }) + expect(round.score(ctx)).toEqual([]) + }) + + test("reveal expose le titre, l'artiste et le contributeur", () => { + const rooms = new RoomManager() + const { room, player: a } = rooms.create("Alice", "sa") + const t = track(a.id) + const round = new BlindtestRound() + const ctx = ctxFor(room, t, "title_artist") + const { truth } = round.reveal(ctx) + expect(truth.submittedBy).toBe(a.id) + expect(truth.submittedByName).toBe("Alice") + expect(truth.title).toBe("The Legend of Zelda Main Theme") + }) +}) diff --git a/apps/server/src/game/modes/blindtest/blindtest-round.ts b/apps/server/src/game/modes/blindtest/blindtest-round.ts new file mode 100644 index 0000000..d7dbbe1 --- /dev/null +++ b/apps/server/src/game/modes/blindtest/blindtest-round.ts @@ -0,0 +1,125 @@ +// Épreuve Blindtest : un titre = une manche. Un DJ neutre (jamais le +// contributeur) pilote la lecture ; tout le monde vote à l'aveugle. + +import type { + Answer, + BlindtestMode, + BlindtestPerPlayerResult, + BlindtestPlayerResult, + BlindtestRevealTruth, + BlindtestRoundPayload, + ScoreDelta, +} from "@nerdware/shared" +import type { GameRound, RoundContext, RoundStart } from "../../round" +import type { BlindtestTrack, ServerRoom } from "../../../rooms" +import { fuzzyMatch } from "./match" +import { takeTrack } from "./pool" + +const TITLE_POINTS = 60 +const ARTIST_POINTS = 40 +const WHO_POINTS = 100 + +interface BlindtestRoundData { + track: BlindtestTrack +} + +/** Tire un DJ neutre : un joueur connecté qui n'a pas soumis ce titre. */ +function pickDj(room: ServerRoom, submittedBy: string): string | null { + const eligible = [...room.players.values()].filter( + (p) => p.connected && p.id !== submittedBy + ) + if (eligible.length === 0) { + return null + } + return eligible[Math.floor(Math.random() * eligible.length)].id +} + +/** Calcule le résultat d'un vote selon le mode. */ +function resultFor( + mode: BlindtestMode, + answer: Answer | undefined, + track: BlindtestTrack +): BlindtestPlayerResult { + const a = (answer ?? {}) as { + title?: string + artist?: string + guessedPlayerId?: string + } + let points = 0 + const result: BlindtestPlayerResult = { points: 0 } + + if (mode === "title_artist" || mode === "mixed") { + result.titleCorrect = a.title ? fuzzyMatch(a.title, track.title) : false + result.artistCorrect = a.artist ? fuzzyMatch(a.artist, track.artist) : false + if (result.titleCorrect) points += TITLE_POINTS + if (result.artistCorrect) points += ARTIST_POINTS + } + if (mode === "who_added" || mode === "mixed") { + result.guessedCorrect = a.guessedPlayerId === track.submittedBy + if (result.guessedCorrect) points += WHO_POINTS + } + + result.points = points + return result +} + +export class BlindtestRound implements GameRound { + readonly type = "blindtest" as const + + start(room: ServerRoom): RoundStart { + const track = takeTrack(room) + const djId = track ? pickDj(room, track.submittedBy) : null + const payload: BlindtestRoundPayload | null = track + ? { trackId: track.id, youtubeId: track.youtubeId } + : null + return { djId, payload, data: track ? { track } : null } + } + + submitAnswer(ctx: RoundContext, playerId: string, answer: Answer): void { + // Premier vote verrouillé (idempotent). + if (ctx.votes.has(playerId)) { + return + } + ctx.votes.set(playerId, answer) + } + + reveal(ctx: RoundContext): { + truth: BlindtestRevealTruth + perPlayerResult: BlindtestPerPlayerResult + } { + const { track } = ctx.data as BlindtestRoundData + const mode = ctx.room.settings.blindtestMode + const perPlayerResult: BlindtestPerPlayerResult = {} + for (const player of ctx.room.players.values()) { + perPlayerResult[player.id] = resultFor(mode, ctx.votes.get(player.id), track) + } + const submitter = ctx.room.players.get(track.submittedBy) + return { + truth: { + title: track.title, + artist: track.artist, + youtubeId: track.youtubeId, + submittedBy: track.submittedBy, + submittedByName: submitter?.name ?? "?", + }, + perPlayerResult, + } + } + + score(ctx: RoundContext): ScoreDelta[] { + const { track } = ctx.data as BlindtestRoundData + const mode = ctx.room.settings.blindtestMode + const deltas: ScoreDelta[] = [] + for (const [playerId, answer] of ctx.votes) { + // Le contributeur ne marque pas sur son propre titre. + if (playerId === track.submittedBy) { + continue + } + const { points } = resultFor(mode, answer, track) + if (points > 0) { + deltas.push({ playerId, delta: points }) + } + } + return deltas + } +} diff --git a/apps/server/src/game/modes/blindtest/index.ts b/apps/server/src/game/modes/blindtest/index.ts new file mode 100644 index 0000000..ddf4437 --- /dev/null +++ b/apps/server/src/game/modes/blindtest/index.ts @@ -0,0 +1,6 @@ +import { registerRound } from "../../registry" +import { BlindtestRound } from "./blindtest-round" + +registerRound("blindtest", () => new BlindtestRound()) + +export { BlindtestRound } diff --git a/apps/server/src/game/modes/blindtest/match.ts b/apps/server/src/game/modes/blindtest/match.ts new file mode 100644 index 0000000..235cd99 --- /dev/null +++ b/apps/server/src/game/modes/blindtest/match.ts @@ -0,0 +1,54 @@ +// Matching tolérant pour les réponses libres (titre/artiste). +// Normalisation (minuscules, accents, ponctuation) + distance de Levenshtein. + +/** Minuscule, sans accents, sans ponctuation, espaces compactés. */ +export function normalize(text: string): string { + return text + .toLowerCase() + .normalize("NFD") + .replace(/[̀-ͯ]/g, "") // accents + .replace(/\(.*?\)|\[.*?\]/g, " ") // contenu entre parenthèses/crochets + .replace(/[^a-z0-9\s]/g, " ") // ponctuation + .replace(/\s+/g, " ") + .trim() +} + +/** Distance d'édition de Levenshtein. */ +export function levenshtein(a: string, b: string): number { + if (a === b) return 0 + if (a.length === 0) return b.length + if (b.length === 0) return a.length + let prev = Array.from({ length: b.length + 1 }, (_, i) => i) + for (let i = 1; i <= a.length; i++) { + const curr = [i] + for (let j = 1; j <= b.length; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1 + curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost) + } + prev = curr + } + return prev[b.length] +} + +/** + * Vrai si `guess` correspond à `truth` avec tolérance : + * - égalité après normalisation, ou + * - une normalisation contient l'autre (réponse partielle), ou + * - distance de Levenshtein ≤ ~20% de la longueur (fautes de frappe). + */ +export function fuzzyMatch(guess: string, truth: string): boolean { + const g = normalize(guess) + const t = normalize(truth) + if (!g || !t) { + return false + } + if (g === t) { + return true + } + // Réponse contenue (ex: "zelda" pour "the legend of zelda"). + if (g.length >= 3 && (t.includes(g) || g.includes(t))) { + return true + } + const tolerance = Math.floor(Math.max(g.length, t.length) * 0.2) + return levenshtein(g, t) <= tolerance +} diff --git a/apps/server/src/game/modes/blindtest/pool.ts b/apps/server/src/game/modes/blindtest/pool.ts new file mode 100644 index 0000000..a86dd96 --- /dev/null +++ b/apps/server/src/game/modes/blindtest/pool.ts @@ -0,0 +1,26 @@ +// File d'attente des titres blindtest par room, préchargée (mélangée) au +// lancement de la partie. Le BlindtestRound y pioche un titre par manche. + +import type { BlindtestTrack, ServerRoom } from "../../../rooms" + +const queueByRoom = new WeakMap() + +function shuffle(items: T[]): T[] { + const arr = [...items] + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[arr[i], arr[j]] = [arr[j], arr[i]] + } + return arr +} + +/** Mélange les titres soumis dans une file pour la partie. */ +export function prepareBlindtestForRoom(room: ServerRoom): void { + queueByRoom.set(room, shuffle(room.blindtestTracks)) +} + +/** Pioche le prochain titre (ou null si la file est vide). */ +export function takeTrack(room: ServerRoom): BlindtestTrack | null { + const queue = queueByRoom.get(room) + return queue && queue.length > 0 ? (queue.shift() ?? null) : null +} diff --git a/apps/server/src/game/modes/blindtest/youtube.test.ts b/apps/server/src/game/modes/blindtest/youtube.test.ts new file mode 100644 index 0000000..77ed095 --- /dev/null +++ b/apps/server/src/game/modes/blindtest/youtube.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "bun:test" +import { extractYoutubeId } from "./youtube" + +describe("extractYoutubeId", () => { + test.each([ + ["https://www.youtube.com/watch?v=dQw4w9WgXcQ", "dQw4w9WgXcQ"], + ["https://youtu.be/dQw4w9WgXcQ", "dQw4w9WgXcQ"], + ["https://www.youtube.com/shorts/dQw4w9WgXcQ", "dQw4w9WgXcQ"], + ["https://www.youtube.com/embed/dQw4w9WgXcQ", "dQw4w9WgXcQ"], + ["https://youtube.com/watch?v=dQw4w9WgXcQ&t=42s", "dQw4w9WgXcQ"], + ["dQw4w9WgXcQ", "dQw4w9WgXcQ"], + ])("extrait l'ID de %s", (url, expected) => { + expect(extractYoutubeId(url)).toBe(expected) + }) + + test("renvoie null pour une URL non YouTube", () => { + expect(extractYoutubeId("https://example.com")).toBeNull() + expect(extractYoutubeId("")).toBeNull() + }) +}) diff --git a/apps/server/src/game/modes/blindtest/youtube.ts b/apps/server/src/game/modes/blindtest/youtube.ts new file mode 100644 index 0000000..dfdd65c --- /dev/null +++ b/apps/server/src/game/modes/blindtest/youtube.ts @@ -0,0 +1,53 @@ +// Extraction d'ID YouTube + vérification via oEmbed (existe + embeddable), +// qui fournit aussi titre et auteur pour le reveal. + +/** Extrait l'ID vidéo (11 chars) d'une URL YouTube (watch, youtu.be, shorts, embed). */ +export function extractYoutubeId(input: string): string | null { + const url = input.trim() + // ID brut directement collé. + if (/^[\w-]{11}$/.test(url)) { + return url + } + const patterns = [ + /[?&]v=([\w-]{11})/, // watch?v=ID + /youtu\.be\/([\w-]{11})/, // youtu.be/ID + /\/shorts\/([\w-]{11})/, // /shorts/ID + /\/embed\/([\w-]{11})/, // /embed/ID + ] + for (const re of patterns) { + const m = url.match(re) + if (m) { + return m[1] + } + } + return null +} + +export interface YoutubeMeta { + title: string + artist: string +} + +/** + * Vérifie via oEmbed que la vidéo existe et est intégrable, et renvoie + * { title, artist }. null si introuvable/privée/embedding désactivé. + */ +export async function fetchYoutubeMeta( + youtubeId: string +): Promise { + const url = `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${youtubeId}&format=json` + try { + const res = await fetch(url) + if (!res.ok) { + // 401/404 → vidéo privée, supprimée, ou embedding désactivé. + return null + } + const json = (await res.json()) as { title?: string; author_name?: string } + return { + title: json.title ?? "", + artist: json.author_name ?? "", + } + } catch { + return null + } +} diff --git a/apps/server/src/game/modes/index.ts b/apps/server/src/game/modes/index.ts index 684f1f1..29146d8 100644 --- a/apps/server/src/game/modes/index.ts +++ b/apps/server/src/game/modes/index.ts @@ -2,13 +2,19 @@ // pour ses effets de bord (registerRound). Ajouter un mode = une ligne ici. import "./quiz" +import "./blindtest" import type { ServerRoom } from "../../rooms" import { prepareQuizForRoom } from "./quiz/pool" +import { prepareBlindtestForRoom } from "./blindtest/pool" -/** Précharge le contenu nécessaire avant de lancer la partie (ex: pool quiz). */ +/** Précharge le contenu nécessaire avant de lancer la partie. */ export async function prepareRoom(room: ServerRoom): Promise { - if (room.settings.rounds.some((r) => r.type === "quiz")) { + const types = new Set(room.settings.rounds.map((r) => r.type)) + if (types.has("quiz")) { await prepareQuizForRoom(room) } + if (types.has("blindtest")) { + prepareBlindtestForRoom(room) + } } diff --git a/apps/server/src/game/round.ts b/apps/server/src/game/round.ts index 8f3ca62..7bfc21a 100644 --- a/apps/server/src/game/round.ts +++ b/apps/server/src/game/round.ts @@ -1,7 +1,13 @@ // Contrat générique d'une épreuve. Le moteur ne connaît QUE cette interface : // brancher une nouvelle épreuve = écrire un module, pas retoucher la plomberie. -import type { Answer, RevealPayload, RoundType, ScoreDelta } from "@nerdware/shared" +import type { + Answer, + MediaControlPayload, + RevealPayload, + RoundType, + ScoreDelta, +} from "@nerdware/shared" import type { ServerRoom } from "../rooms" /** Ce que `start()` renvoie : la partie publique + les données privées de la manche. */ @@ -52,4 +58,6 @@ export interface GameRound { export interface RoomGameController { run(): Promise handleVote(playerId: string, answer: Answer): void + /** Relais média du DJ (blindtest) : rebroadcast media:sync à toute la room. */ + handleMediaControl(playerId: string, payload: MediaControlPayload): void } diff --git a/apps/server/src/handlers.ts b/apps/server/src/handlers.ts index c52c227..0fd0e96 100644 --- a/apps/server/src/handlers.ts +++ b/apps/server/src/handlers.ts @@ -10,9 +10,14 @@ import type { } from "@nerdware/shared" import type { IoServer } from "./socket" import { RoomError, RoomManager, type ServerRoom } from "./rooms" +import { randomUUID } from "node:crypto" import { GameEngine } from "./game/engine" import { hasRound } from "./game/registry" import { prepareRoom } from "./game/modes" +import { + extractYoutubeId, + fetchYoutubeMeta, +} from "./game/modes/blindtest/youtube" type AppSocket = Socket< ClientToServerEvents, @@ -91,8 +96,10 @@ export function registerRoomHandlers( return } room.settings = { + gameType: payload.gameType, blindtestMode: payload.blindtestMode, roundDuration: payload.roundDuration, + tracksPerPlayer: payload.tracksPerPlayer, rounds: payload.rounds, } broadcastState(room) @@ -133,6 +140,63 @@ export function registerRoomHandlers( .catch((err) => console.error("[game] run failed", err)) }) + socket.on("blindtest:submitTrack", async (payload, ack) => { + const code = socket.data.roomCode + const playerId = socket.data.playerId + const room = code ? rooms.get(code) : undefined + if (!room || !playerId || room.status !== "lobby") { + ack({ accepted: false, reason: "Soumission impossible maintenant." }) + return + } + const youtubeId = extractYoutubeId(payload?.youtubeUrl ?? "") + if (!youtubeId) { + ack({ accepted: false, reason: "Lien YouTube invalide." }) + return + } + const mine = room.blindtestTracks.filter((t) => t.submittedBy === playerId) + if (mine.length >= room.settings.tracksPerPlayer) { + ack({ accepted: false, reason: "Quota de titres atteint." }) + return + } + if (room.blindtestTracks.some((t) => t.youtubeId === youtubeId)) { + ack({ accepted: false, reason: "Titre déjà soumis." }) + return + } + const meta = await fetchYoutubeMeta(youtubeId) + if (!meta) { + ack({ + accepted: false, + reason: "Vidéo introuvable ou non intégrable.", + }) + return + } + // Re-vérifie la room après l'await (le joueur a pu se déconnecter). + if (room.status !== "lobby" || !room.players.has(playerId)) { + ack({ accepted: false, reason: "Soumission impossible maintenant." }) + return + } + room.blindtestTracks.push({ + id: randomUUID(), + youtubeId, + url: payload.youtubeUrl, + title: meta.title, + artist: meta.artist, + submittedBy: playerId, + }) + const count = mine.length + 1 + ack({ accepted: true, title: meta.title, count }) + broadcastState(room) + }) + + socket.on("media:control", (payload) => { + const code = socket.data.roomCode + const room = code ? rooms.get(code) : undefined + if (!room || !room.game || !socket.data.playerId) { + return + } + room.game.handleMediaControl(socket.data.playerId, payload) + }) + socket.on("round:vote", (payload) => { const code = socket.data.roomCode const room = code ? rooms.get(code) : undefined diff --git a/apps/server/src/rooms.ts b/apps/server/src/rooms.ts index fadc8f7..bffbd5c 100644 --- a/apps/server/src/rooms.ts +++ b/apps/server/src/rooms.ts @@ -18,6 +18,16 @@ export interface ServerPlayer { socketId: string | null } +/** Titre soumis pour le blindtest. `submittedBy` reste secret jusqu'au reveal. */ +export interface BlindtestTrack { + id: string + youtubeId: string + url: string + title: string + artist: string + submittedBy: string +} + /** Room côté serveur : état runtime complet (le snapshot public en dérive). */ export interface ServerRoom { code: string @@ -27,6 +37,8 @@ export interface ServerRoom { settings: RoomSettings scores: Map currentRound: number + /** Titres soumis pour le blindtest (mélangés au lancement). */ + blindtestTracks: BlindtestTrack[] /** Moteur de jeu actif (null tant qu'on est dans le lobby). */ game: RoomGameController | null } @@ -50,6 +62,7 @@ export class RoomManager { settings: { ...DEFAULT_ROOM_SETTINGS }, scores: new Map([[player.id, 0]]), currentRound: -1, + blindtestTracks: [], game: null, } this.rooms.set(code, room) @@ -115,6 +128,10 @@ export class RoomManager { })), currentRound: room.currentRound, totalRounds: room.settings.rounds.length, + submissions: [...room.players.values()].map((p) => ({ + playerId: p.id, + count: room.blindtestTracks.filter((t) => t.submittedBy === p.id).length, + })), } } diff --git a/apps/web/src/components/blindtest-view.tsx b/apps/web/src/components/blindtest-view.tsx new file mode 100644 index 0000000..e6b9596 --- /dev/null +++ b/apps/web/src/components/blindtest-view.tsx @@ -0,0 +1,264 @@ +import { useEffect, useState } from "react" +import { motion } from "framer-motion" +import { Disc3, Pause, Play, Rewind } from "lucide-react" +import type { + BlindtestAnswer, + BlindtestMode, + BlindtestPerPlayerResult, + BlindtestRevealTruth, + BlindtestRoundPayload, + RoomSnapshot, +} from "@nerdware/shared" +import { Button } from "@workspace/ui/components/button" +import { useRoomStore } from "@/store/room" +import { useYoutube } from "@/lib/youtube" +import { Countdown } from "@/components/countdown" +import { Avatar } from "@/components/avatar" + +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" + +export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) { + const round = useRoomStore((s) => s.round) + const reveal = useRoomStore((s) => s.reveal) + const mediaSync = useRoomStore((s) => s.mediaSync) + const playerId = useRoomStore((s) => s.playerId) + const hasVoted = useRoomStore((s) => s.hasVoted) + const voteBlindtest = useRoomStore((s) => s.voteBlindtest) + const mediaControl = useRoomStore((s) => s.mediaControl) + + const track = round?.payload as BlindtestRoundPayload | undefined + const { hostRef, ready, api } = useYoutube(track?.youtubeId ?? "") + + const isDj = !!playerId && round?.djId === playerId + const truth = reveal ? (reveal.truth as BlindtestRevealTruth) : null + const showReveal = truth !== null + + // Non-DJ : on suit le DJ via media:sync (avec compensation de latence). + useEffect(() => { + if (isDj || !ready || !mediaSync) { + return + } + const elapsed = (Date.now() - mediaSync.atServerTs) / 1000 + if (mediaSync.action === "play") { + api.seek(mediaSync.positionSec + Math.max(0, elapsed)) + api.play() + } else if (mediaSync.action === "pause") { + api.seek(mediaSync.positionSec) + api.pause() + } else { + api.seek(mediaSync.positionSec) + } + }, [mediaSync, isDj, ready, api]) + + // Au reveal, on coupe le son. + useEffect(() => { + if (showReveal && ready) { + api.pause() + } + }, [showReveal, ready, api]) + + function djPlay() { + api.play() + mediaControl("play", api.time()) + } + function djPause() { + api.pause() + mediaControl("pause", api.time()) + } + function djRestart() { + api.seek(0) + mediaControl("seek", 0) + } + + return ( +
+
+ + Blindtest {snapshot.currentRound + 1} / {snapshot.totalRounds} + + {!showReveal && round && ( + + )} +
+ + {/* Pochette : le lecteur YouTube est caché derrière (son seulement). */} +
+
+ {!showReveal && ( +
+ + + +
+ )} +
+ + {isDj && !showReveal && ( +
+ + Tu es le DJ 🎧 + +
+ + + +
+
+ )} + + {!showReveal && ( + + )} + + {showReveal && truth && ( + + )} +
+ ) +} + +function VoteForm({ + mode, + snapshot, + playerId, + disabled, + onVote, +}: { + mode: BlindtestMode + snapshot: RoomSnapshot + playerId: string | null + disabled: boolean + onVote: (answer: BlindtestAnswer) => void +}) { + const [title, setTitle] = useState("") + const [artist, setArtist] = useState("") + const [guessed, setGuessed] = useState(null) + + const needsText = mode === "title_artist" || mode === "mixed" + const needsWho = mode === "who_added" || mode === "mixed" + const canSubmit = + !disabled && + (!needsText || title.trim().length > 0) && + (!needsWho || guessed !== null) + + function submit() { + const answer: { + title?: string + artist?: string + guessedPlayerId?: string + } = {} + if (needsText) { + answer.title = title.trim() + answer.artist = artist.trim() + } + if (needsWho && guessed) { + answer.guessedPlayerId = guessed + } + onVote(answer as BlindtestAnswer) + } + + if (disabled) { + return ( +

+ Réponse envoyée — en attente des autres +

+ ) + } + + return ( +
+ {needsText && ( + <> + setTitle(e.target.value)} + /> + setArtist(e.target.value)} + /> + + )} + {needsWho && ( +
+ {snapshot.players.map((p) => ( + + ))} +
+ )} + +
+ ) +} + +function RevealCard({ + truth, + result, +}: { + truth: BlindtestRevealTruth + result?: BlindtestPerPlayerResult[string] +}) { + return ( +
+

{truth.title}

+

{truth.artist}

+

+ Ajouté par + + {truth.submittedByName} +

+ {result && ( +

0 ? "text-green-500" : "text-red-500"}`} + > + {result.points > 0 ? `+${result.points} points 🎉` : "Raté 💥"} +

+ )} +
+ ) +} diff --git a/apps/web/src/components/lobby-view.tsx b/apps/web/src/components/lobby-view.tsx index b315a3f..4713f3d 100644 --- a/apps/web/src/components/lobby-view.tsx +++ b/apps/web/src/components/lobby-view.tsx @@ -1,25 +1,38 @@ import { useState } from "react" -import type { RoomSnapshot } from "@nerdware/shared" +import type { BlindtestMode, RoomSnapshot, RoundConfig } from "@nerdware/shared" import { Button } from "@workspace/ui/components/button" import { useRoomStore } from "@/store/room" import { Avatar } from "@/components/avatar" const QUESTION_OPTIONS = [3, 5, 10] +const TRACKS_OPTIONS = [1, 2, 3] +const MODE_LABELS: Record = { + title_artist: "Titre & artiste", + who_added: "Qui l'a ajouté ?", + mixed: "Mixte", +} + +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" export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { const playerId = useRoomStore((s) => s.playerId) + const updateSettings = useRoomStore((s) => s.updateSettings) const startGame = useRoomStore((s) => s.startGame) const isHost = snapshot.hostId === playerId + const { gameType, blindtestMode, tracksPerPlayer } = snapshot.settings const [count, setCount] = useState(5) const [busy, setBusy] = useState(false) const [error, setError] = useState(null) - async function handleStart() { + const totalTracks = snapshot.submissions.reduce((n, s) => n + s.count, 0) + + async function start(rounds: RoundConfig[]) { setError(null) setBusy(true) try { - await startGame(count) + await startGame(rounds) } catch (err) { setError((err as { message?: string }).message ?? "Erreur") } finally { @@ -57,35 +70,207 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { - {isHost ? ( -
-
- Questions de quiz + {/* Choix du type de partie (hôte). */} + {isHost && ( +
+ {(["quiz", "blindtest"] as const).map((type) => ( + + ))} +
+ )} + + {gameType === "quiz" ? ( + isHost ? ( +
+
+ Questions de quiz +
+ {QUESTION_OPTIONS.map((n) => ( + + ))} +
+
+ +
+ ) : ( +

+ En attente du lancement par l'hôte… +

+ ) + ) : ( + s.playerId === playerId)?.count ?? 0 + } + totalTracks={totalTracks} + busy={busy} + onConfig={updateSettings} + onStart={() => + start( + Array.from({ length: totalTracks }, () => ({ type: "blindtest" })) + ) + } + /> + )} + + {error &&

{error}

} +
+ ) +} + +function BlindtestLobby({ + isHost, + mode, + tracksPerPlayer, + myCount, + totalTracks, + busy, + onConfig, + onStart, +}: { + snapshot: RoomSnapshot + isHost: boolean + mode: BlindtestMode + tracksPerPlayer: number + myCount: number + totalTracks: number + busy: boolean + onConfig: (partial: { + blindtestMode?: BlindtestMode + tracksPerPlayer?: number + }) => void + onStart: () => void +}) { + const submitTrack = useRoomStore((s) => s.submitTrack) + const [url, setUrl] = useState("") + const [submitting, setSubmitting] = useState(false) + const [feedback, setFeedback] = useState(null) + const [accepted, setAccepted] = useState([]) + + const quotaReached = myCount >= tracksPerPlayer + + async function submit() { + if (!url.trim()) { + return + } + setSubmitting(true) + setFeedback(null) + const res = await submitTrack(url.trim()) + if (res.accepted) { + setAccepted((a) => [...a, res.title ?? url.trim()]) + setUrl("") + setFeedback(null) + } else { + setFeedback(res.reason ?? "Refusé") + } + setSubmitting(false) + } + + return ( +
+ {isHost && ( +
+
+ Mode
- {QUESTION_OPTIONS.map((n) => ( + {(["title_artist", "who_added", "mixed"] as const).map((m) => ( + + ))} +
+
+
+ Titres par joueur +
+ {TRACKS_OPTIONS.map((n) => ( ))}
-
+ )} + +
+ + Tes titres ({myCount}/{tracksPerPlayer}) + +
+ setUrl(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && submit()} + /> + - {error && ( -

{error}

- )} -
+
+ {feedback &&

{feedback}

} + {accepted.length > 0 && ( +
    + {accepted.map((t, i) => ( +
  • + ✓ {t} +
  • + ))} +
+ )} +
+ + {isHost ? ( + ) : (

- En attente du lancement de la partie par l'hôte… + En attente du lancement par l'hôte… ({totalTracks} titres soumis)

)} - + ) } diff --git a/apps/web/src/lib/youtube.ts b/apps/web/src/lib/youtube.ts new file mode 100644 index 0000000..b4308c4 --- /dev/null +++ b/apps/web/src/lib/youtube.ts @@ -0,0 +1,97 @@ +import { useEffect, useMemo, useRef, useState } from "react" + +let apiPromise: Promise | null = null + +/** Charge l'API IFrame YouTube une seule fois. */ +function loadYoutubeApi(): Promise { + if (window.YT?.Player) { + return Promise.resolve() + } + if (apiPromise) { + return apiPromise + } + apiPromise = new Promise((resolve) => { + const previous = window.onYouTubeIframeAPIReady + window.onYouTubeIframeAPIReady = () => { + previous?.() + resolve() + } + const tag = document.createElement("script") + tag.src = "https://www.youtube.com/iframe_api" + document.head.appendChild(tag) + }) + return apiPromise +} + +export interface YoutubeApi { + play: () => void + pause: () => void + seek: (seconds: number) => void + time: () => number +} + +/** + * Crée un lecteur YouTube pour `youtubeId`. Renvoie un ref à poser sur un div + * hôte, l'état "prêt", et une API impérative (play/pause/seek/time). + */ +export function useYoutube(youtubeId: string) { + const hostRef = useRef(null) + const playerRef = useRef(null) + const [ready, setReady] = useState(false) + + useEffect(() => { + let cancelled = false + const host = hostRef.current + if (!host) { + return + } + setReady(false) + // YT remplace l'élément fourni par une iframe → on lui donne un enfant jetable + // pour que React ne gère jamais ce nœud. + const inner = document.createElement("div") + host.appendChild(inner) + + loadYoutubeApi().then(() => { + if (cancelled || !window.YT) { + return + } + playerRef.current = new window.YT.Player(inner, { + videoId: youtubeId, + playerVars: { + autoplay: 0, + controls: 0, + disablekb: 1, + modestbranding: 1, + rel: 0, + playsinline: 1, + }, + events: { + onReady: () => { + if (!cancelled) { + setReady(true) + } + }, + }, + }) + }) + + return () => { + cancelled = true + playerRef.current?.destroy() + playerRef.current = null + host.replaceChildren() + } + }, [youtubeId]) + + const api = useMemo( + () => ({ + play: () => playerRef.current?.playVideo(), + pause: () => playerRef.current?.pauseVideo(), + seek: (seconds) => playerRef.current?.seekTo(seconds, true), + time: () => playerRef.current?.getCurrentTime() ?? 0, + }), + [] + ) + + return { hostRef, ready, api } +} diff --git a/apps/web/src/pages/room.tsx b/apps/web/src/pages/room.tsx index 79e8212..52e7957 100644 --- a/apps/web/src/pages/room.tsx +++ b/apps/web/src/pages/room.tsx @@ -3,6 +3,7 @@ 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 { BlindtestView } from "@/components/blindtest-view" import { GameEndView } from "@/components/game-end-view" import { PlayerCards } from "@/components/player-cards" import { RoomCode } from "@/components/room-code" @@ -16,6 +17,7 @@ export function RoomPage({ code }: { code: string }) { const boomKey = useRoomStore((s) => s.boomKey) const roundKey = useRoomStore((s) => s.roundKey) const voteProgress = useRoomStore((s) => s.voteProgress) + const round = useRoomStore((s) => s.round) // Pas de snapshot pour ce code (accès direct / refresh) : retour à l'accueil. if (!snapshot || snapshot.code !== code) { @@ -58,7 +60,12 @@ export function RoomPage({ code }: { code: string }) { )} {snapshot.status === "lobby" && } - {inGame && } + {inGame && + (round?.type === "blindtest" ? ( + + ) : ( + + ))} {snapshot.status === "ended" && } diff --git a/apps/web/src/store/room.ts b/apps/web/src/store/room.ts index bf8d558..7a30cea 100644 --- a/apps/web/src/store/room.ts +++ b/apps/web/src/store/room.ts @@ -1,11 +1,18 @@ import { create } from "zustand" -import type { - ErrorPayload, - PlayerScore, - RoomCreatedPayload, - RoomSnapshot, - RoundStartPayload, - VoteAckPayload, +import { + DEFAULT_ROOM_SETTINGS, + type BlindtestAnswer, + type ErrorPayload, + type MediaControlPayload, + type MediaSyncPayload, + type PlayerScore, + type RoomCreatedPayload, + type RoomSnapshot, + type RoundConfig, + type RoundStartPayload, + type SubmitOkPayload, + type UpdateSettingsPayload, + type VoteAckPayload, } from "@nerdware/shared" import { socket } from "@/lib/socket" @@ -26,7 +33,6 @@ export interface RoundReveal { interface RoomState { connected: boolean - /** Identité locale, pour reconnaître "moi" dans le snapshot. */ playerId: string | null roomCode: string | null snapshot: RoomSnapshot | null @@ -37,16 +43,20 @@ interface RoomState { voteProgress: VoteAckPayload | null reveal: RoundReveal | null myChoiceIndex: number | null + hasVoted: boolean finalScores: PlayerScore[] | null - /** Compteur d'explosions : incrémenté à chaque fin de manche au timer (boom à 0). */ + mediaSync: MediaSyncPayload | null boomKey: number - /** Compteur de manches : incrémenté à chaque round:start (transition WarioWare). */ roundKey: number createRoom: (playerName: string) => Promise joinRoom: (roomCode: string, playerName: string) => Promise - startGame: (questionCount: number) => Promise + updateSettings: (partial: Partial) => void + startGame: (rounds: RoundConfig[]) => Promise + submitTrack: (youtubeUrl: string) => Promise vote: (choiceIndex: number) => void + voteBlindtest: (answer: BlindtestAnswer) => void + mediaControl: (action: MediaControlPayload["action"], positionSec: number) => void boom: () => void clearError: () => void reset: () => void @@ -62,7 +72,9 @@ export const useRoomStore = create((set, get) => ({ voteProgress: null, reveal: null, myChoiceIndex: null, + hasVoted: false, finalScores: null, + mediaSync: null, boomKey: 0, roundKey: 0, @@ -92,17 +104,20 @@ export const useRoomStore = create((set, get) => ({ }) }), - startGame: (questionCount) => + updateSettings: (partial) => { + const current = get().snapshot?.settings ?? DEFAULT_ROOM_SETTINGS + socket.emit("lobby:updateSettings", { + gameType: partial.gameType ?? current.gameType, + blindtestMode: partial.blindtestMode ?? current.blindtestMode, + roundDuration: partial.roundDuration ?? current.roundDuration, + tracksPerPlayer: partial.tracksPerPlayer ?? current.tracksPerPlayer, + rounds: partial.rounds ?? current.rounds, + }) + }, + + startGame: (rounds) => 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, - }) + get().updateSettings({ rounds }) socket.emit("game:start", (res) => { if (res.ok) { resolve() @@ -113,14 +128,31 @@ export const useRoomStore = create((set, get) => ({ }) }), + submitTrack: (youtubeUrl) => + new Promise((resolve) => { + socket.emit("blindtest:submitTrack", { youtubeUrl }, (res) => resolve(res)) + }), + vote: (choiceIndex) => { - if (get().myChoiceIndex !== null) { - return // vote déjà émis pour cette manche + if (get().hasVoted) { + return } - set({ myChoiceIndex: choiceIndex }) + set({ myChoiceIndex: choiceIndex, hasVoted: true }) socket.emit("round:vote", { answer: { choiceIndex } }) }, + voteBlindtest: (answer) => { + if (get().hasVoted) { + return + } + set({ hasVoted: true }) + socket.emit("round:vote", { answer }) + }, + + mediaControl: (action, positionSec) => { + socket.emit("media:control", { action, positionSec }) + }, + boom: () => set((s) => ({ boomKey: s.boomKey + 1 })), clearError: () => set({ lastError: null }), @@ -133,7 +165,9 @@ export const useRoomStore = create((set, get) => ({ voteProgress: null, reveal: null, myChoiceIndex: null, + hasVoted: false, finalScores: null, + mediaSync: null, boomKey: 0, roundKey: 0, }), @@ -157,6 +191,8 @@ socket.on("round:start", (payload) => voteProgress: null, reveal: null, myChoiceIndex: null, + hasVoted: false, + mediaSync: null, roundKey: s.roundKey + 1, })) ) @@ -164,6 +200,7 @@ socket.on("round:voteAck", (voteProgress) => useRoomStore.setState({ voteProgress }) ) socket.on("round:reveal", (reveal) => useRoomStore.setState({ reveal })) +socket.on("media:sync", (mediaSync) => useRoomStore.setState({ mediaSync })) socket.on("game:end", ({ finalScores }) => useRoomStore.setState({ finalScores }) ) diff --git a/apps/web/src/types/youtube.d.ts b/apps/web/src/types/youtube.d.ts new file mode 100644 index 0000000..bc6f8a5 --- /dev/null +++ b/apps/web/src/types/youtube.d.ts @@ -0,0 +1,44 @@ +// Déclarations minimales de l'API YouTube IFrame Player (ce qu'on utilise). + +export {} + +declare global { + namespace YT { + interface PlayerEvent { + target: Player + data: number + } + interface PlayerOptions { + videoId?: string + playerVars?: Record + events?: { + onReady?: (e: PlayerEvent) => void + onStateChange?: (e: PlayerEvent) => void + } + } + class Player { + constructor(el: HTMLElement | string, opts: PlayerOptions) + playVideo(): void + pauseVideo(): void + seekTo(seconds: number, allowSeekAhead: boolean): void + getCurrentTime(): number + getDuration(): number + getPlayerState(): number + loadVideoById(id: string): void + destroy(): void + } + const PlayerState: { + UNSTARTED: -1 + ENDED: 0 + PLAYING: 1 + PAUSED: 2 + BUFFERING: 3 + CUED: 5 + } + } + + interface Window { + YT?: typeof YT + onYouTubeIframeAPIReady?: () => void + } +} diff --git a/packages/shared/src/blindtest.ts b/packages/shared/src/blindtest.ts new file mode 100644 index 0000000..9c37acf --- /dev/null +++ b/packages/shared/src/blindtest.ts @@ -0,0 +1,28 @@ +// Types concrets de l'épreuve Blindtest, partagés client/serveur. +// L'audio est joué localement par chaque client mais piloté par le DJ (media:sync). + +/** Payload diffusé au lancement d'une manche blindtest (round:start), SANS la réponse. */ +export interface BlindtestRoundPayload { + trackId: string + youtubeId: string +} + +/** Vérité révélée à tous (round:reveal → truth), inclut qui a soumis le titre. */ +export interface BlindtestRevealTruth { + title: string + artist: string + youtubeId: string + submittedBy: string + submittedByName: string +} + +/** Résultat d'un joueur sur la manche (détail selon le mode). */ +export interface BlindtestPlayerResult { + titleCorrect?: boolean + artistCorrect?: boolean + guessedCorrect?: boolean + points: number +} + +/** round:reveal → perPlayerResult : playerId → résultat. */ +export type BlindtestPerPlayerResult = Record diff --git a/packages/shared/src/domain.ts b/packages/shared/src/domain.ts index fcfa8e0..a56a6f6 100644 --- a/packages/shared/src/domain.ts +++ b/packages/shared/src/domain.ts @@ -27,17 +27,23 @@ export interface RoundConfig { /** Réglages de la room, modifiables dans le lobby par l'hôte. */ export interface RoomSettings { + /** Type de partie choisi dans le lobby (pilote l'UI partagée). */ + gameType: RoundType blindtestMode: BlindtestMode /** Durée d'une manche en secondes (def. 60). */ roundDuration: number + /** Nombre de titres que chaque joueur soumet (blindtest). */ + tracksPerPlayer: number /** Séquence d'épreuves de la partie. */ rounds: RoundConfig[] } /** Réglages par défaut d'une nouvelle room. */ export const DEFAULT_ROOM_SETTINGS: RoomSettings = { + gameType: "quiz", blindtestMode: "title_artist", roundDuration: 60, + tracksPerPlayer: 2, rounds: [], } @@ -86,4 +92,6 @@ export interface RoomSnapshot { currentRound: number /** Nombre total de manches planifiées. */ totalRounds: number + /** Blindtest : nombre de titres soumis par joueur (comptes seulement, jamais les titres). */ + submissions: { playerId: string; count: number }[] } diff --git a/packages/shared/src/events.ts b/packages/shared/src/events.ts index d18b5b2..e03de62 100644 --- a/packages/shared/src/events.ts +++ b/packages/shared/src/events.ts @@ -27,8 +27,10 @@ export interface RoomJoinPayload { } export interface UpdateSettingsPayload { + gameType: RoundType blindtestMode: BlindtestMode roundDuration: number + tracksPerPlayer: number rounds: RoundConfig[] } @@ -43,6 +45,10 @@ export interface SubmitTrackPayload { export interface SubmitOkPayload { accepted: boolean reason?: string + /** Titre récupéré (oEmbed) si accepté — pour confirmation côté client. */ + title?: string + /** Nombre total de titres soumis par ce joueur après cet ajout. */ + count?: number } export interface RoundStartPayload { @@ -112,7 +118,10 @@ export interface ClientToServerEvents { "room:join": (payload: RoomJoinPayload, ack: Ack) => void "lobby:updateSettings": (payload: UpdateSettingsPayload) => void "game:start": (ack: Ack) => void - "blindtest:submitTrack": (payload: SubmitTrackPayload, ack: Ack) => void + "blindtest:submitTrack": ( + payload: SubmitTrackPayload, + ack: (res: SubmitOkPayload) => void + ) => void "media:control": (payload: MediaControlPayload) => void "round:vote": (payload: VotePayload) => void } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index dece734..27085c7 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,4 @@ export * from "./domain" export * from "./events" export * from "./quiz" +export * from "./blindtest" -- 2.45.3 From e3315d3b3507030e76c02ad756daadd1677d2945 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Wed, 10 Jun 2026 13:41:29 +0200 Subject: [PATCH 16/51] feat: mixed games by default, free-text quiz, mode-aware transitions Mixed mode (default): - gameType is now mixed | quiz | blindtest, default "mixed"; the lobby interleaves quiz + blindtest rounds so modes alternate - lobby reworked: 3-way game-type switch, quiz count and/or blindtest config + track submission shown per selected type Free-text quiz questions: - quiz supports the `free` format (e.g. maths) alongside mcq/truefalse: payload without choices, {text} vote, tolerant matching on acceptedAnswers - shared QuizQuestionPayload.choices optional; reveal truth carries answer - match util moved to game/match.ts (shared by quiz + blindtest) - repo/seed include free + acceptedAnswers; in-code bank gains free questions - client quiz view renders a text input for free questions Mode-aware transitions: - RoundTransition themed per mode (colors/label/emoji); announces the mode on change (quiz <-> blindtest), lighter teaser within the same mode - custom media buckets per mode: assets/transitions/{quiz,blindtest}/ + shared - store tracks roundModeChanged Verified e2e: mixed game alternates quiz/blindtest to completion; free-text scoring covered by tests (22 pass). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/src/db/quiz-repo.ts | 17 +- apps/server/src/db/seed.ts | 1 + .../src/game/{modes/blindtest => }/match.ts | 0 .../modes/blindtest/blindtest-round.test.ts | 2 +- .../game/modes/blindtest/blindtest-round.ts | 2 +- apps/server/src/game/modes/quiz/questions.ts | 49 ++- .../src/game/modes/quiz/quiz-round.test.ts | 30 +- apps/server/src/game/modes/quiz/quiz-round.ts | 73 +++-- apps/web/src/assets/transitions/README.md | 16 +- apps/web/src/components/lobby-view.tsx | 304 +++++++++--------- apps/web/src/components/quiz-view.tsx | 92 ++++-- apps/web/src/components/round-transition.tsx | 183 +++++++---- apps/web/src/pages/room.tsx | 10 +- apps/web/src/store/room.ts | 14 + packages/shared/src/domain.ts | 7 +- packages/shared/src/events.ts | 3 +- packages/shared/src/quiz.ts | 9 +- 17 files changed, 529 insertions(+), 283 deletions(-) rename apps/server/src/game/{modes/blindtest => }/match.ts (100%) diff --git a/apps/server/src/db/quiz-repo.ts b/apps/server/src/db/quiz-repo.ts index 92eec3d..b9512f2 100644 --- a/apps/server/src/db/quiz-repo.ts +++ b/apps/server/src/db/quiz-repo.ts @@ -1,6 +1,6 @@ // Accès aux questions de quiz en base. No-op si pas de DB (db === null). -import { and, eq, inArray, isNotNull, notInArray, sql } from "drizzle-orm" +import { and, eq, inArray, notInArray, sql } from "drizzle-orm" import { db } from "./index" import { quizCategory, quizPlayed, quizQuestion } from "./schema" import type { QuizQuestion } from "../game/modes/quiz/questions" @@ -28,6 +28,7 @@ export async function loadQuizPool( prompt: quizQuestion.prompt, choices: quizQuestion.choices, correctIndex: quizQuestion.correctIndex, + acceptedAnswers: quizQuestion.acceptedAnswers, difficulty: quizQuestion.difficulty, category: quizCategory.name, }) @@ -35,8 +36,7 @@ export async function loadQuizPool( .leftJoin(quizCategory, eq(quizQuestion.categoryId, quizCategory.id)) .where( and( - inArray(quizQuestion.format, ["mcq", "truefalse"]), - isNotNull(quizQuestion.correctIndex), + inArray(quizQuestion.format, ["mcq", "truefalse", "free"]), notInArray(quizQuestion.id, alreadyPlayed) ) ) @@ -44,13 +44,18 @@ export async function loadQuizPool( .limit(limit) return rows - .filter((r) => r.choices && r.correctIndex !== null) + .filter((r) => + r.format === "free" + ? (r.acceptedAnswers?.length ?? 0) > 0 + : r.choices && r.correctIndex !== null + ) .map((r) => ({ id: r.id, format: r.format as QuizQuestion["format"], prompt: r.prompt, - choices: r.choices as string[], - correctIndex: r.correctIndex as number, + choices: r.choices ?? undefined, + correctIndex: r.correctIndex ?? undefined, + acceptedAnswers: r.acceptedAnswers ?? undefined, category: r.category ?? "Quiz", difficulty: r.difficulty, })) diff --git a/apps/server/src/db/seed.ts b/apps/server/src/db/seed.ts index 019a499..e1f9579 100644 --- a/apps/server/src/db/seed.ts +++ b/apps/server/src/db/seed.ts @@ -179,6 +179,7 @@ async function seedManual(): Promise { source: "manual", choices: q.choices, correctIndex: q.correctIndex, + acceptedAnswers: q.acceptedAnswers, }, ]) } diff --git a/apps/server/src/game/modes/blindtest/match.ts b/apps/server/src/game/match.ts similarity index 100% rename from apps/server/src/game/modes/blindtest/match.ts rename to apps/server/src/game/match.ts diff --git a/apps/server/src/game/modes/blindtest/blindtest-round.test.ts b/apps/server/src/game/modes/blindtest/blindtest-round.test.ts index bcfb726..30a8eb1 100644 --- a/apps/server/src/game/modes/blindtest/blindtest-round.test.ts +++ b/apps/server/src/game/modes/blindtest/blindtest-round.test.ts @@ -3,7 +3,7 @@ import { RoomManager, type BlindtestTrack } from "../../../rooms" import type { RoundContext } from "../../round" import { BlindtestRound } from "./blindtest-round" import { prepareBlindtestForRoom } from "./pool" -import { fuzzyMatch, normalize } from "./match" +import { fuzzyMatch, normalize } from "../../match" function track(submittedBy: string): BlindtestTrack { return { diff --git a/apps/server/src/game/modes/blindtest/blindtest-round.ts b/apps/server/src/game/modes/blindtest/blindtest-round.ts index d7dbbe1..59199cf 100644 --- a/apps/server/src/game/modes/blindtest/blindtest-round.ts +++ b/apps/server/src/game/modes/blindtest/blindtest-round.ts @@ -12,7 +12,7 @@ import type { } from "@nerdware/shared" import type { GameRound, RoundContext, RoundStart } from "../../round" import type { BlindtestTrack, ServerRoom } from "../../../rooms" -import { fuzzyMatch } from "./match" +import { fuzzyMatch } from "../../match" import { takeTrack } from "./pool" const TITLE_POINTS = 60 diff --git a/apps/server/src/game/modes/quiz/questions.ts b/apps/server/src/game/modes/quiz/questions.ts index 3c4af08..271157e 100644 --- a/apps/server/src/game/modes/quiz/questions.ts +++ b/apps/server/src/game/modes/quiz/questions.ts @@ -8,8 +8,12 @@ export interface QuizQuestion { id: string format: QuizFormat prompt: string - choices: string[] - correctIndex: number + /** mcq/truefalse uniquement. */ + choices?: string[] + /** mcq/truefalse uniquement. */ + correctIndex?: number + /** free : réponses acceptées (matching tolérant). */ + acceptedAnswers?: string[] category: string difficulty: number } @@ -107,4 +111,45 @@ export const QUIZ_QUESTIONS: QuizQuestion[] = [ category: "Pop culture", difficulty: 1, }, + // Saisie libre (matching tolérant sur acceptedAnswers). + { + id: "q-free-math-1", + format: "free", + prompt: "Combien font 7 × 8 ?", + acceptedAnswers: ["56"], + category: "Maths", + difficulty: 1, + }, + { + id: "q-free-math-2", + format: "free", + prompt: "Résous : 12² − 44 = ?", + acceptedAnswers: ["100", "cent"], + category: "Maths", + difficulty: 2, + }, + { + id: "q-free-zelda-princess", + format: "free", + prompt: "Comment s'appelle la princesse dans The Legend of Zelda ?", + acceptedAnswers: ["Zelda"], + category: "Jeux vidéo", + difficulty: 1, + }, + { + id: "q-free-pi", + format: "free", + prompt: "Donne les 3 premières décimales de Pi (après la virgule).", + acceptedAnswers: ["141"], + category: "Maths", + difficulty: 2, + }, + { + id: "q-free-onepiece", + format: "free", + prompt: "Quel est le nom du bateau de l'équipage de Luffy (2e navire) ?", + acceptedAnswers: ["Thousand Sunny", "Sunny", "le Thousand Sunny"], + category: "Manga", + difficulty: 3, + }, ] diff --git a/apps/server/src/game/modes/quiz/quiz-round.test.ts b/apps/server/src/game/modes/quiz/quiz-round.test.ts index 6d5bbe6..399269a 100644 --- a/apps/server/src/game/modes/quiz/quiz-round.test.ts +++ b/apps/server/src/game/modes/quiz/quiz-round.test.ts @@ -2,11 +2,15 @@ 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" +import { QUIZ_QUESTIONS, type QuizQuestion } from "./questions" const question = QUIZ_QUESTIONS[0] // "Link" → correctIndex 1 -function makeCtx(): { ctx: RoundContext; p1: string; p2: string } { +function makeCtx(q: QuizQuestion = question): { + 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") @@ -16,7 +20,7 @@ function makeCtx(): { ctx: RoundContext; p1: string; p2: string } { votes: new Map(), startedAt: 0, endsAt: 10_000, - data: { question, votedAt: new Map() }, + data: { question: q, votedAt: new Map() }, } return { ctx, p1: a.id, p2: b.id } } @@ -56,4 +60,24 @@ describe("QuizRound", () => { const deltas = round.score(ctx) expect(deltas).toEqual([{ playerId: p1, delta: 190 }]) // 100 + round(100 * 0.9) }) + + test("free : matching tolérant sur acceptedAnswers", () => { + const free: QuizQuestion = { + id: "f1", + format: "free", + prompt: "7 × 8 ?", + acceptedAnswers: ["56"], + category: "Maths", + difficulty: 1, + } + const round = new QuizRound(() => 1000) + const { ctx, p1, p2 } = makeCtx(free) + round.submitAnswer(ctx, p1, { text: " 56 " }) // juste (espaces tolérés) + round.submitAnswer(ctx, p2, { text: "57" }) // faux + const { truth, perPlayerResult } = round.reveal(ctx) + expect(truth).toEqual({ answer: "56" }) + expect(perPlayerResult[p1].correct).toBe(true) + expect(perPlayerResult[p2].correct).toBe(false) + expect(round.score(ctx)).toEqual([{ playerId: p1, delta: 190 }]) + }) }) diff --git a/apps/server/src/game/modes/quiz/quiz-round.ts b/apps/server/src/game/modes/quiz/quiz-round.ts index e8f121e..310b046 100644 --- a/apps/server/src/game/modes/quiz/quiz-round.ts +++ b/apps/server/src/game/modes/quiz/quiz-round.ts @@ -1,5 +1,5 @@ // Épreuve Quiz : une question = une manche. Pas de DJ, pas d'audio. -// Implémente le contrat GameRound ; le moteur fait tout le reste. +// Formats : mcq, truefalse (choix), free (saisie libre, matching tolérant). import type { Answer, @@ -10,6 +10,7 @@ import type { } from "@nerdware/shared" import { hasDb } from "../../../db" import { markQuestionPlayed } from "../../../db/quiz-repo" +import { fuzzyMatch } from "../../match" import type { GameRound, RoundContext, RoundStart } from "../../round" import type { ServerRoom } from "../../../rooms" import type { QuizQuestion } from "./questions" @@ -25,10 +26,29 @@ interface QuizRoundData { votedAt: Map } -function isQuizAnswer(answer: Answer): answer is { choiceIndex: number } { - return ( - typeof (answer as { choiceIndex?: unknown }).choiceIndex === "number" - ) +function asChoice(answer: Answer): number | null { + const v = (answer as { choiceIndex?: unknown }).choiceIndex + return typeof v === "number" ? v : null +} + +function asText(answer: Answer): string | null { + const v = (answer as { text?: unknown }).text + return typeof v === "string" ? v : null +} + +/** Réponse correcte ? Selon le format de la question. */ +function isCorrect(question: QuizQuestion, answer: Answer | undefined): boolean { + if (!answer) { + return false + } + if (question.format === "free") { + const text = asText(answer) + if (!text) { + return false + } + return (question.acceptedAnswers ?? []).some((a) => fuzzyMatch(text, a)) + } + return asChoice(answer) === question.correctIndex } export class QuizRound implements GameRound { @@ -45,10 +65,12 @@ export class QuizRound implements GameRound { const payload: QuizQuestionPayload = { format: question.format, prompt: question.prompt, - choices: question.choices, category: question.category, difficulty: question.difficulty, } + if (question.format !== "free") { + payload.choices = question.choices + } const data: QuizRoundData = { question, votedAt: new Map() } return { djId: null, payload, data } } @@ -58,29 +80,42 @@ export class QuizRound implements GameRound { 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 + if (question.format === "free") { + const text = asText(answer) + if (text === null || text.trim().length === 0) { + return + } + ctx.votes.set(playerId, { text }) + } else { + const choiceIndex = asChoice(answer) + const count = question.choices?.length ?? 0 + if (choiceIndex === null || choiceIndex < 0 || choiceIndex >= count) { + return + } + ctx.votes.set(playerId, { choiceIndex }) } - ctx.votes.set(playerId, { choiceIndex: answer.choiceIndex }) votedAt.set(playerId, this.now()) } - reveal(ctx: RoundContext): { truth: QuizRevealTruth; perPlayerResult: QuizPerPlayerResult } { + 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 + const vote = ctx.votes.get(player.id) perPlayerResult[player.id] = { - choiceIndex, - correct: choiceIndex === question.correctIndex, + choiceIndex: vote ? asChoice(vote) : null, + correct: isCorrect(question, vote), } } - return { truth: { correctIndex: question.correctIndex }, perPlayerResult } + const truth: QuizRevealTruth = + question.format === "free" + ? { answer: question.acceptedAnswers?.[0] ?? "" } + : { correctIndex: question.correctIndex } + return { truth, perPlayerResult } } score(ctx: RoundContext): ScoreDelta[] { @@ -88,7 +123,7 @@ export class QuizRound implements GameRound { const total = ctx.endsAt - ctx.startedAt const deltas: ScoreDelta[] = [] for (const [playerId, vote] of ctx.votes) { - if ((vote as { choiceIndex: number }).choiceIndex !== question.correctIndex) { + if (!isCorrect(question, vote)) { continue } // Bonus rapidité : proportionnel au temps restant au moment du vote. diff --git a/apps/web/src/assets/transitions/README.md b/apps/web/src/assets/transitions/README.md index eb8792a..a1675d9 100644 --- a/apps/web/src/assets/transitions/README.md +++ b/apps/web/src/assets/transitions/README.md @@ -3,9 +3,19 @@ Dépose ici des fichiers d'animation pour les transitions entre manches : formats supportés `*.gif`, `*.webp`, `*.apng`, `*.png`, `*.avif`. -- Ils sont détectés automatiquement au build (`import.meta.glob`). -- À chaque manche, un fichier est tiré **au hasard** parmi ceux présents. -- S'il n'y en a aucun, l'animation Framer par défaut (numéro + « PRÊT ?! ») est jouée. +Rangement par mode (recommandé pour des transitions personnalisées) : + +- `quiz/` → jouées avant une manche de **quiz** +- `blindtest/` → jouées avant une manche de **blindtest** +- racine (`./`) → **communs**, fallback pour les deux modes + +Règles : + +- Détectés automatiquement au build (`import.meta.glob`). +- À chaque manche, un fichier est tiré **au hasard** dans le pool du mode + (dossier du mode + communs). +- S'il n'y en a aucun, l'animation Framer par défaut est jouée : annonce du + mode (« QUIZ » / « BLINDTEST ») quand on change de mode, sinon « Question/Titre N ». Le média est centré, limité à 70vh / 80vw, et joué par-dessus le wipe coloré plein écran. Durée d'affichage ≈ 1,3 s (voir `VISIBLE_MS` dans diff --git a/apps/web/src/components/lobby-view.tsx b/apps/web/src/components/lobby-view.tsx index 4713f3d..8eb8375 100644 --- a/apps/web/src/components/lobby-view.tsx +++ b/apps/web/src/components/lobby-view.tsx @@ -1,11 +1,21 @@ import { useState } from "react" -import type { BlindtestMode, RoomSnapshot, RoundConfig } from "@nerdware/shared" +import type { + BlindtestMode, + GameType, + RoomSnapshot, + RoundConfig, +} from "@nerdware/shared" import { Button } from "@workspace/ui/components/button" import { useRoomStore } from "@/store/room" import { Avatar } from "@/components/avatar" const QUESTION_OPTIONS = [3, 5, 10] const TRACKS_OPTIONS = [1, 2, 3] +const GAME_TYPES: { value: GameType; label: string }[] = [ + { value: "mixed", label: "Mixte" }, + { value: "quiz", label: "Quiz" }, + { value: "blindtest", label: "Blindtest" }, +] const MODE_LABELS: Record = { title_artist: "Titre & artiste", who_added: "Qui l'a ajouté ?", @@ -15,6 +25,35 @@ const MODE_LABELS: Record = { 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" +/** Construit la séquence de manches selon le type de partie. */ +function buildRounds( + gameType: GameType, + quizCount: number, + totalTracks: number +): RoundConfig[] { + if (gameType === "quiz") { + return Array.from({ length: quizCount }, () => ({ type: "quiz" })) + } + if (gameType === "blindtest") { + return Array.from({ length: totalTracks }, () => ({ type: "blindtest" })) + } + // Mixte : on alterne quiz / blindtest pour multiplier les changements de mode. + const rounds: RoundConfig[] = [] + let q = quizCount + let b = totalTracks + while (q > 0 || b > 0) { + if (q > 0) { + rounds.push({ type: "quiz" }) + q-- + } + if (b > 0) { + rounds.push({ type: "blindtest" }) + b-- + } + } + return rounds +} + export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { const playerId = useRoomStore((s) => s.playerId) const updateSettings = useRoomStore((s) => s.updateSettings) @@ -27,8 +66,15 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { const [error, setError] = useState(null) const totalTracks = snapshot.submissions.reduce((n, s) => n + s.count, 0) + const myCount = + snapshot.submissions.find((s) => s.playerId === playerId)?.count ?? 0 + const showQuiz = gameType === "quiz" || gameType === "mixed" + const showBlindtest = gameType === "blindtest" || gameType === "mixed" - async function start(rounds: RoundConfig[]) { + const rounds = buildRounds(gameType, showQuiz ? count : 0, totalTracks) + const canStart = rounds.length > 0 + + async function start() { setError(null) setBusy(true) try { @@ -70,141 +116,51 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { - {/* Choix du type de partie (hôte). */} {isHost && (
- {(["quiz", "blindtest"] as const).map((type) => ( + {GAME_TYPES.map((t) => ( ))}
)} - {gameType === "quiz" ? ( - isHost ? ( -
-
- Questions de quiz -
- {QUESTION_OPTIONS.map((n) => ( - - ))} -
-
- -
- ) : ( -

- En attente du lancement par l'hôte… -

- ) - ) : ( - s.playerId === playerId)?.count ?? 0 - } - totalTracks={totalTracks} - busy={busy} - onConfig={updateSettings} - onStart={() => - start( - Array.from({ length: totalTracks }, () => ({ type: "blindtest" })) - ) - } - /> + {isHost && showQuiz && ( +
+ Questions de quiz +
+ {QUESTION_OPTIONS.map((n) => ( + + ))} +
+
)} - {error &&

{error}

} - - ) -} - -function BlindtestLobby({ - isHost, - mode, - tracksPerPlayer, - myCount, - totalTracks, - busy, - onConfig, - onStart, -}: { - snapshot: RoomSnapshot - isHost: boolean - mode: BlindtestMode - tracksPerPlayer: number - myCount: number - totalTracks: number - busy: boolean - onConfig: (partial: { - blindtestMode?: BlindtestMode - tracksPerPlayer?: number - }) => void - onStart: () => void -}) { - const submitTrack = useRoomStore((s) => s.submitTrack) - const [url, setUrl] = useState("") - const [submitting, setSubmitting] = useState(false) - const [feedback, setFeedback] = useState(null) - const [accepted, setAccepted] = useState([]) - - const quotaReached = myCount >= tracksPerPlayer - - async function submit() { - if (!url.trim()) { - return - } - setSubmitting(true) - setFeedback(null) - const res = await submitTrack(url.trim()) - if (res.accepted) { - setAccepted((a) => [...a, res.title ?? url.trim()]) - setUrl("") - setFeedback(null) - } else { - setFeedback(res.reason ?? "Refusé") - } - setSubmitting(false) - } - - return ( -
- {isHost && ( + {isHost && showBlindtest && (
- Mode + Mode blindtest
{(["title_artist", "who_added", "mixed"] as const).map((m) => ( @@ -219,7 +175,7 @@ function BlindtestLobby({ key={n} size="sm" variant={tracksPerPlayer === n ? "default" : "secondary"} - onClick={() => onConfig({ tracksPerPlayer: n })} + onClick={() => updateSettings({ tracksPerPlayer: n })} > {n} @@ -229,48 +185,88 @@ function BlindtestLobby({
)} -
- - Tes titres ({myCount}/{tracksPerPlayer}) - -
- setUrl(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && submit()} - /> - -
- {feedback &&

{feedback}

} - {accepted.length > 0 && ( -
    - {accepted.map((t, i) => ( -
  • - ✓ {t} -
  • - ))} -
- )} -
+ {showBlindtest && ( + + )} {isHost ? ( - ) : (

- En attente du lancement par l'hôte… ({totalTracks} titres soumis) + En attente du lancement par l'hôte… + {showBlindtest && ` (${totalTracks} titres soumis)`}

)} -
+ + {error &&

{error}

} + + ) +} + +function TrackSubmission({ + tracksPerPlayer, + myCount, +}: { + tracksPerPlayer: number + myCount: number +}) { + const submitTrack = useRoomStore((s) => s.submitTrack) + const [url, setUrl] = useState("") + const [submitting, setSubmitting] = useState(false) + const [feedback, setFeedback] = useState(null) + const [accepted, setAccepted] = useState([]) + const quotaReached = myCount >= tracksPerPlayer + + async function submit() { + if (!url.trim()) { + return + } + setSubmitting(true) + setFeedback(null) + const res = await submitTrack(url.trim()) + if (res.accepted) { + setAccepted((a) => [...a, res.title ?? url.trim()]) + setUrl("") + } else { + setFeedback(res.reason ?? "Refusé") + } + setSubmitting(false) + } + + return ( +
+ + Tes titres ({myCount}/{tracksPerPlayer}) + +
+ setUrl(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && submit()} + /> + +
+ {feedback &&

{feedback}

} + {accepted.length > 0 && ( +
    + {accepted.map((t, i) => ( +
  • + ✓ {t} +
  • + ))} +
+ )} +
) } diff --git a/apps/web/src/components/quiz-view.tsx b/apps/web/src/components/quiz-view.tsx index c4035da..ddc401a 100644 --- a/apps/web/src/components/quiz-view.tsx +++ b/apps/web/src/components/quiz-view.tsx @@ -1,19 +1,26 @@ +import { useState } from "react" import type { QuizPerPlayerResult, QuizQuestionPayload, QuizRevealTruth, RoomSnapshot, } from "@nerdware/shared" +import { Button } from "@workspace/ui/components/button" import { useRoomStore } from "@/store/room" import { Countdown } from "@/components/countdown" +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" + 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 hasVoted = useRoomStore((s) => s.hasVoted) const playerId = useRoomStore((s) => s.playerId) const vote = useRoomStore((s) => s.vote) + const voteText = useRoomStore((s) => s.voteText) if (!round) { return ( @@ -24,6 +31,7 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) { const question = round.payload as QuizQuestionPayload const truth = reveal ? (reveal.truth as QuizRevealTruth) : null const showReveal = truth !== null + const isFree = question.format === "free" const myResult = reveal ? (reveal.perPlayerResult as QuizPerPlayerResult)[playerId ?? ""] : undefined @@ -66,21 +74,28 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) { {question.prompt} -
- {question.choices.map((choice, index) => ( - - ))} -
+ {isFree ? ( + + ) : ( +
+ {(question.choices ?? []).map((choice, index) => ( + + ))} +
+ )} {!showReveal && - (myChoiceIndex !== null ? ( + (hasVoted ? (

Réponse envoyée — en attente des autres {voteProgress ? ` (${voteProgress.count}/${voteProgress.total})` : ""} @@ -94,16 +109,49 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) { ))} {showReveal && ( -

- {myResult?.correct - ? "Bonne réponse ! 🎉" - : myResult?.choiceIndex == null - ? "Pas de réponse 😴" - : "Raté 💥"} -

+
+ {isFree && ( +

+ Réponse : + {truth?.answer} +

+ )} +

+ {myResult?.correct + ? "Bonne réponse ! 🎉" + : !hasVoted + ? "Pas de réponse 😴" + : "Raté 💥"} +

+
)} ) } + +function FreeAnswer({ + disabled, + onSubmit, +}: { + disabled: boolean + onSubmit: (text: string) => void +}) { + const [text, setText] = useState("") + return ( +
+ setText(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && text.trim() && onSubmit(text)} + /> + +
+ ) +} diff --git a/apps/web/src/components/round-transition.tsx b/apps/web/src/components/round-transition.tsx index 7d33241..937123c 100644 --- a/apps/web/src/components/round-transition.tsx +++ b/apps/web/src/components/round-transition.tsx @@ -1,30 +1,74 @@ import { useEffect, useState } from "react" import { createPortal } from "react-dom" import { AnimatePresence, motion } from "framer-motion" +import type { RoundType } from "@nerdware/shared" const VISIBLE_MS = 1300 // durée d'affichage avant le wipe de sortie (≈ leadMs serveur) -// Animations custom déposées par l'utilisateur (gif/webp/apng/png/mp4...). -// Glisse simplement des fichiers dans src/assets/transitions/ : ils sont -// détectés au build et joués aléatoirement. Sinon, fallback animation Framer. -const CUSTOM = Object.values( - import.meta.glob("../assets/transitions/*.{gif,webp,apng,png,avif}", { - eager: true, - import: "default", - }) -) as string[] +// Médias custom déposés par l'utilisateur, rangés par mode : +// src/assets/transitions/quiz/* → transitions de quiz +// src/assets/transitions/blindtest/* → transitions de blindtest +// src/assets/transitions/* → communs (fallback) +// Détectés au build ; sinon, animation Framer par défaut. +const ALL_MEDIA = import.meta.glob( + "../assets/transitions/**/*.{gif,webp,apng,png,avif}", + { eager: true, import: "default" } +) as Record + +const QUIZ_MEDIA: string[] = [] +const BLINDTEST_MEDIA: string[] = [] +const SHARED_MEDIA: string[] = [] +for (const [path, url] of Object.entries(ALL_MEDIA)) { + if (path.includes("/transitions/quiz/")) QUIZ_MEDIA.push(url) + else if (path.includes("/transitions/blindtest/")) BLINDTEST_MEDIA.push(url) + else SHARED_MEDIA.push(url) +} + +const THEME: Record< + RoundType, + { gradient: string; accent: string; label: string; kind: string; emoji: string } +> = { + quiz: { + gradient: "from-indigo-500 via-blue-600 to-cyan-600", + accent: "text-cyan-300", + label: "QUIZ", + kind: "Question", + emoji: "🧠", + }, + blindtest: { + gradient: "from-fuchsia-500 via-purple-600 to-indigo-700", + accent: "text-yellow-300", + label: "BLINDTEST", + kind: "Titre", + emoji: "🎧", + }, +} + +function mediaFor(type: RoundType): string[] { + const own = type === "quiz" ? QUIZ_MEDIA : BLINDTEST_MEDIA + return [...own, ...SHARED_MEDIA] +} /** - * Transition "WarioWare" jouée entre les manches : wipe coloré plein écran, - * média custom (si présent) ou sunburst + gros numéro qui rebondit, puis sortie. + * Transition "WarioWare" entre manches, thémée par mode. Annonce le mode quand + * il change (quiz ↔ blindtest), sinon affiche un teaser léger de la manche. * Montée avec une `key` qui change → rejoue à chaque manche. */ -export function RoundTransition({ index }: { index: number }) { +export function RoundTransition({ + type, + index, + modeChanged, +}: { + type: RoundType + index: number + modeChanged: boolean +}) { const [show, setShow] = useState(true) - // Choix stable d'un média custom pour ce montage (varie d'une manche à l'autre). - const [media] = useState(() => - CUSTOM.length ? CUSTOM[Math.floor(Math.random() * CUSTOM.length)] : null - ) + const theme = THEME[type] + const [media] = useState(() => { + const pool = mediaFor(type) + return pool.length ? pool[Math.floor(Math.random() * pool.length)] : null + }) useEffect(() => { const t = setTimeout(() => setShow(false), VISIBLE_MS) @@ -36,7 +80,7 @@ export function RoundTransition({ index }: { index: number }) { {show && ( + + {media ? ( ) : ( - <> - - - + {modeChanged ? ( + <> + {theme.emoji} + + {theme.label} + + + ) : ( + <> + + {theme.kind} + + + {index + 1} + + + )} + - - Question - - - {index + 1} - - - PRÊT ?! - - - + PRÊT ?! + + )} )} diff --git a/apps/web/src/pages/room.tsx b/apps/web/src/pages/room.tsx index 52e7957..ee937fc 100644 --- a/apps/web/src/pages/room.tsx +++ b/apps/web/src/pages/room.tsx @@ -18,6 +18,7 @@ export function RoomPage({ code }: { code: string }) { const roundKey = useRoomStore((s) => s.roundKey) const voteProgress = useRoomStore((s) => s.voteProgress) const round = useRoomStore((s) => s.round) + const roundModeChanged = useRoomStore((s) => s.roundModeChanged) // Pas de snapshot pour ce code (accès direct / refresh) : retour à l'accueil. if (!snapshot || snapshot.code !== code) { @@ -70,8 +71,13 @@ export function RoomPage({ code }: { code: string }) { {boomKey > 0 && } - {roundKey > 0 && ( - + {roundKey > 0 && round && ( + )} ) diff --git a/apps/web/src/store/room.ts b/apps/web/src/store/room.ts index 7a30cea..55024e8 100644 --- a/apps/web/src/store/room.ts +++ b/apps/web/src/store/room.ts @@ -48,6 +48,8 @@ interface RoomState { mediaSync: MediaSyncPayload | null boomKey: number roundKey: number + /** Le type d'épreuve a-t-il changé par rapport à la manche précédente ? */ + roundModeChanged: boolean createRoom: (playerName: string) => Promise joinRoom: (roomCode: string, playerName: string) => Promise @@ -55,6 +57,7 @@ interface RoomState { startGame: (rounds: RoundConfig[]) => Promise submitTrack: (youtubeUrl: string) => Promise vote: (choiceIndex: number) => void + voteText: (text: string) => void voteBlindtest: (answer: BlindtestAnswer) => void mediaControl: (action: MediaControlPayload["action"], positionSec: number) => void boom: () => void @@ -77,6 +80,7 @@ export const useRoomStore = create((set, get) => ({ mediaSync: null, boomKey: 0, roundKey: 0, + roundModeChanged: false, createRoom: (playerName) => new Promise((resolve, reject) => { @@ -141,6 +145,14 @@ export const useRoomStore = create((set, get) => ({ socket.emit("round:vote", { answer: { choiceIndex } }) }, + voteText: (text) => { + if (get().hasVoted || text.trim().length === 0) { + return + } + set({ hasVoted: true }) + socket.emit("round:vote", { answer: { text: text.trim() } }) + }, + voteBlindtest: (answer) => { if (get().hasVoted) { return @@ -170,6 +182,7 @@ export const useRoomStore = create((set, get) => ({ mediaSync: null, boomKey: 0, roundKey: 0, + roundModeChanged: false, }), })) @@ -194,6 +207,7 @@ socket.on("round:start", (payload) => hasVoted: false, mediaSync: null, roundKey: s.roundKey + 1, + roundModeChanged: (s.round?.type ?? null) !== payload.type, })) ) socket.on("round:voteAck", (voteProgress) => diff --git a/packages/shared/src/domain.ts b/packages/shared/src/domain.ts index a56a6f6..d7ee165 100644 --- a/packages/shared/src/domain.ts +++ b/packages/shared/src/domain.ts @@ -7,6 +7,9 @@ export type RoomStatus = "lobby" | "in_round" | "reveal" | "scores" | "ended" /** Types d'épreuves disponibles. Étendre = ajouter une valeur + un module GameRound. */ export type RoundType = "blindtest" | "quiz" +/** Type de partie choisi au lobby : mélange par défaut, ou un seul mode. */ +export type GameType = "mixed" | "quiz" | "blindtest" + /** Modes de blindtest, déterminent la forme du vote et le barème. */ export type BlindtestMode = "title_artist" | "who_added" | "mixed" @@ -28,7 +31,7 @@ export interface RoundConfig { /** Réglages de la room, modifiables dans le lobby par l'hôte. */ export interface RoomSettings { /** Type de partie choisi dans le lobby (pilote l'UI partagée). */ - gameType: RoundType + gameType: GameType blindtestMode: BlindtestMode /** Durée d'une manche en secondes (def. 60). */ roundDuration: number @@ -40,7 +43,7 @@ export interface RoomSettings { /** Réglages par défaut d'une nouvelle room. */ export const DEFAULT_ROOM_SETTINGS: RoomSettings = { - gameType: "quiz", + gameType: "mixed", blindtestMode: "title_artist", roundDuration: 60, tracksPerPlayer: 2, diff --git a/packages/shared/src/events.ts b/packages/shared/src/events.ts index e03de62..8b9a7b1 100644 --- a/packages/shared/src/events.ts +++ b/packages/shared/src/events.ts @@ -4,6 +4,7 @@ import type { Answer, BlindtestMode, + GameType, PlayerScore, RoomSnapshot, RoundConfig, @@ -27,7 +28,7 @@ export interface RoomJoinPayload { } export interface UpdateSettingsPayload { - gameType: RoundType + gameType: GameType blindtestMode: BlindtestMode roundDuration: number tracksPerPlayer: number diff --git a/packages/shared/src/quiz.ts b/packages/shared/src/quiz.ts index a2156a6..d61bd3c 100644 --- a/packages/shared/src/quiz.ts +++ b/packages/shared/src/quiz.ts @@ -8,8 +8,8 @@ import type { QuizFormat } from "./domain" export interface QuizQuestionPayload { format: QuizFormat prompt: string - /** Choix proposés (truefalse = ["Vrai", "Faux"]). */ - choices: string[] + /** Choix proposés (mcq/truefalse). Absent pour `free` (saisie libre). */ + choices?: string[] category?: string /** 1 (facile) .. 3 (difficile). */ difficulty?: number @@ -17,7 +17,10 @@ export interface QuizQuestionPayload { /** Vérité révélée à tous (round:reveal → truth). */ export interface QuizRevealTruth { - correctIndex: number + /** Index de la bonne réponse (mcq/truefalse). */ + correctIndex?: number + /** Réponse canonique affichée (free). */ + answer?: string } /** Résultat d'un joueur sur la manche. */ -- 2.45.3 From a9eb6098f261e6a60f44019bd17a0b6e0209d669 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Wed, 10 Jun 2026 14:30:47 +0200 Subject: [PATCH 17/51] feat(blindtest): contributor-as-DJ scoring, 3-player gate, 16:9 player + seek MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DJ is now the track's contributor (controls their own song); their identity is sent only to them so "who added" stays non-trivial. The contributor doesn't vote and is excluded from the eligible-voters count. - Scoring: the contributor earns nothing except in who_added/mixed, where they get a misdirection bonus (50) per player who guesses the wrong adder. - Blindtest (and mixed) require >= 3 connected players: lobby disables the options with a hint, and game:start rejects with NEED_THREE. - Player card is now 16:9 and full width — the video banner shows at reveal; DJ gets a seek bar (current/duration) to jump anywhere, plus play/pause/restart. - Mixed game order is shuffled (no predictable quiz/blindtest alternation). Verified e2e (3 players): guard rejects at 2, DJ id hidden from others, misdirection scoring correct. 23 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/src/game/engine.ts | 24 ++- .../modes/blindtest/blindtest-round.test.ts | 24 ++- .../game/modes/blindtest/blindtest-round.ts | 82 ++++++---- apps/server/src/handlers.ts | 12 ++ apps/web/src/components/blindtest-view.tsx | 145 +++++++++++++----- apps/web/src/components/lobby-view.tsx | 71 +++++---- apps/web/src/lib/youtube.ts | 2 + 7 files changed, 255 insertions(+), 105 deletions(-) diff --git a/apps/server/src/game/engine.ts b/apps/server/src/game/engine.ts index 7df50c9..7b9771f 100644 --- a/apps/server/src/game/engine.ts +++ b/apps/server/src/game/engine.ts @@ -127,13 +127,26 @@ export class GameEngine implements RoomGameController { } this.room.status = "in_round" this.broadcastState() - this.io.to(this.room.code).emit("round:start", { + // L'identité du DJ (= contributeur du titre) est privée : seul le DJ la + // reçoit, sinon "qui l'a ajouté" deviendrait trivial. + const base = { type: round.type, - djId: this.runtime.djId ?? undefined, startsAt: startedAt, endsAt, payload: start.payload, - }) + } + const djSocketId = this.runtime.djId + ? this.room.players.get(this.runtime.djId)?.socketId + : null + if (djSocketId) { + this.io.to(djSocketId).emit("round:start", { + ...base, + djId: this.runtime.djId ?? undefined, + }) + this.io.to(this.room.code).except(djSocketId).emit("round:start", base) + } else { + this.io.to(this.room.code).emit("round:start", base) + } // Attend la première condition de fin : timer écoulé OU tous votés. await new Promise((resolve) => { @@ -177,10 +190,11 @@ export class GameEngine implements RoomGameController { } } - /** Votants éligibles : tous les joueurs connectés (le DJ inclus). */ + /** Votants éligibles : joueurs connectés, hors DJ (= contributeur, ne vote pas). */ private eligibleVoters(): string[] { + const djId = this.runtime?.djId return [...this.room.players.values()] - .filter((p) => p.connected) + .filter((p) => p.connected && p.id !== djId) .map((p) => p.id) } diff --git a/apps/server/src/game/modes/blindtest/blindtest-round.test.ts b/apps/server/src/game/modes/blindtest/blindtest-round.test.ts index 30a8eb1..ef6f599 100644 --- a/apps/server/src/game/modes/blindtest/blindtest-round.test.ts +++ b/apps/server/src/game/modes/blindtest/blindtest-round.test.ts @@ -44,19 +44,37 @@ describe("match", () => { }) describe("BlindtestRound", () => { - test("start tire un DJ neutre (jamais le contributeur)", () => { + test("start : le DJ est le contributeur du titre", () => { const rooms = new RoomManager() const { room, player: a } = rooms.create("Alice", "sa") - const { player: b } = rooms.join(room.code, "Bob", "sb") + rooms.join(room.code, "Bob", "sb") room.blindtestTracks = [track(a.id)] prepareBlindtestForRoom(room) const round = new BlindtestRound() const start = round.start(room) - expect(start.djId).toBe(b.id) // jamais Alice (contributrice) + expect(start.djId).toBe(a.id) // Alice a ajouté le titre → elle est DJ expect(start.payload).toEqual({ trackId: "t1", youtubeId: "abc12345678" }) }) + test("who_added : le contributeur gagne par tromperie (mauvaises devinettes)", () => { + const rooms = new RoomManager() + const { room, player: a } = rooms.create("Alice", "sa") + const { player: b } = rooms.join(room.code, "Bob", "sb") + const { player: c } = rooms.join(room.code, "Carol", "sc") + const t = track(a.id) // Alice = contributrice/DJ + const round = new BlindtestRound() + const ctx = ctxFor(room, t, "who_added") + round.submitAnswer(ctx, b.id, { guessedPlayerId: c.id }) // faux + round.submitAnswer(ctx, c.id, { guessedPlayerId: a.id }) // juste + round.submitAnswer(ctx, a.id, { guessedPlayerId: b.id }) // ignoré (contributrice) + const deltas = round.score(ctx) + // Carol devine juste → 100 ; Alice trompe Bob (1 mauvaise devinette) → 50 + expect(deltas).toContainEqual({ playerId: c.id, delta: 100 }) + expect(deltas).toContainEqual({ playerId: a.id, delta: 50 }) + expect(deltas.find((d) => d.playerId === b.id)).toBeUndefined() + }) + test("who_added : points si on devine le bon contributeur", () => { const rooms = new RoomManager() const { room, player: a } = rooms.create("Alice", "sa") diff --git a/apps/server/src/game/modes/blindtest/blindtest-round.ts b/apps/server/src/game/modes/blindtest/blindtest-round.ts index 59199cf..17e504f 100644 --- a/apps/server/src/game/modes/blindtest/blindtest-round.ts +++ b/apps/server/src/game/modes/blindtest/blindtest-round.ts @@ -1,5 +1,6 @@ -// Épreuve Blindtest : un titre = une manche. Un DJ neutre (jamais le -// contributeur) pilote la lecture ; tout le monde vote à l'aveugle. +// Épreuve Blindtest : un titre = une manche. Le contributeur du titre est le DJ +// (il pilote la lecture, son identité reste cachée aux autres). Il ne marque pas +// de points, SAUF en "qui l'a ajouté" : il gagne quand un joueur le devine mal. import type { Answer, @@ -18,24 +19,15 @@ import { takeTrack } from "./pool" const TITLE_POINTS = 60 const ARTIST_POINTS = 40 const WHO_POINTS = 100 +/** Points gagnés par le contributeur pour chaque joueur qui le devine mal. */ +const MISDIRECTION_POINTS = 50 interface BlindtestRoundData { track: BlindtestTrack } -/** Tire un DJ neutre : un joueur connecté qui n'a pas soumis ce titre. */ -function pickDj(room: ServerRoom, submittedBy: string): string | null { - const eligible = [...room.players.values()].filter( - (p) => p.connected && p.id !== submittedBy - ) - if (eligible.length === 0) { - return null - } - return eligible[Math.floor(Math.random() * eligible.length)].id -} - -/** Calcule le résultat d'un vote selon le mode. */ -function resultFor( +/** Résultat d'un joueur VOTANT (≠ contributeur) selon le mode. */ +function voterResult( mode: BlindtestMode, answer: Answer | undefined, track: BlindtestTrack @@ -68,7 +60,8 @@ export class BlindtestRound implements GameRound { start(room: ServerRoom): RoundStart { const track = takeTrack(room) - const djId = track ? pickDj(room, track.submittedBy) : null + // Le DJ EST le contributeur : il lance/pilote son propre titre. + const djId = track ? track.submittedBy : null const payload: BlindtestRoundPayload | null = track ? { trackId: track.id, youtubeId: track.youtubeId } : null @@ -76,23 +69,50 @@ export class BlindtestRound implements GameRound { } submitAnswer(ctx: RoundContext, playerId: string, answer: Answer): void { - // Premier vote verrouillé (idempotent). - if (ctx.votes.has(playerId)) { + const { track } = ctx.data as BlindtestRoundData + // Le contributeur (DJ) ne vote pas ; premier vote verrouillé pour les autres. + if (playerId === track.submittedBy || ctx.votes.has(playerId)) { return } ctx.votes.set(playerId, answer) } + /** Résultat par joueur (votants + bonus de tromperie du contributeur). */ + private buildResults(ctx: RoundContext): BlindtestPerPlayerResult { + const { track } = ctx.data as BlindtestRoundData + const mode = ctx.room.settings.blindtestMode + const perPlayer: BlindtestPerPlayerResult = {} + + let wrongGuessers = 0 + for (const player of ctx.room.players.values()) { + if (player.id === track.submittedBy) { + continue + } + const result = voterResult(mode, ctx.votes.get(player.id), track) + perPlayer[player.id] = result + if ( + (mode === "who_added" || mode === "mixed") && + result.guessedCorrect === false + ) { + wrongGuessers++ + } + } + + // Contributeur : pas de points hors who_added/mixed ; sinon tromperie. + const misdirection = + mode === "who_added" || mode === "mixed" + ? wrongGuessers * MISDIRECTION_POINTS + : 0 + perPlayer[track.submittedBy] = { points: misdirection } + + return perPlayer + } + reveal(ctx: RoundContext): { truth: BlindtestRevealTruth perPlayerResult: BlindtestPerPlayerResult } { const { track } = ctx.data as BlindtestRoundData - const mode = ctx.room.settings.blindtestMode - const perPlayerResult: BlindtestPerPlayerResult = {} - for (const player of ctx.room.players.values()) { - perPlayerResult[player.id] = resultFor(mode, ctx.votes.get(player.id), track) - } const submitter = ctx.room.players.get(track.submittedBy) return { truth: { @@ -102,22 +122,16 @@ export class BlindtestRound implements GameRound { submittedBy: track.submittedBy, submittedByName: submitter?.name ?? "?", }, - perPlayerResult, + perPlayerResult: this.buildResults(ctx), } } score(ctx: RoundContext): ScoreDelta[] { - const { track } = ctx.data as BlindtestRoundData - const mode = ctx.room.settings.blindtestMode + const perPlayer = this.buildResults(ctx) const deltas: ScoreDelta[] = [] - for (const [playerId, answer] of ctx.votes) { - // Le contributeur ne marque pas sur son propre titre. - if (playerId === track.submittedBy) { - continue - } - const { points } = resultFor(mode, answer, track) - if (points > 0) { - deltas.push({ playerId, delta: points }) + for (const [playerId, result] of Object.entries(perPlayer)) { + if (result.points > 0) { + deltas.push({ playerId, delta: result.points }) } } return deltas diff --git a/apps/server/src/handlers.ts b/apps/server/src/handlers.ts index 0fd0e96..60ea318 100644 --- a/apps/server/src/handlers.ts +++ b/apps/server/src/handlers.ts @@ -128,6 +128,18 @@ export function registerRoomHandlers( }) return } + const hasBlindtest = room.settings.rounds.some((r) => r.type === "blindtest") + const connected = [...room.players.values()].filter((p) => p.connected).length + if (hasBlindtest && connected < 3) { + ack({ + ok: false, + error: { + code: "NEED_THREE", + message: "Le blindtest nécessite au moins 3 joueurs.", + }, + }) + return + } ack({ ok: true, data: null }) // Précharge le contenu (pool quiz depuis la DB), puis lance la boucle. // Fire-and-forget : la boucle de jeu broadcast son propre état. diff --git a/apps/web/src/components/blindtest-view.tsx b/apps/web/src/components/blindtest-view.tsx index e6b9596..8925185 100644 --- a/apps/web/src/components/blindtest-view.tsx +++ b/apps/web/src/components/blindtest-view.tsx @@ -11,13 +11,19 @@ import type { } from "@nerdware/shared" import { Button } from "@workspace/ui/components/button" import { useRoomStore } from "@/store/room" -import { useYoutube } from "@/lib/youtube" +import { useYoutube, type YoutubeApi } from "@/lib/youtube" import { Countdown } from "@/components/countdown" import { Avatar } from "@/components/avatar" 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" +function fmt(s: number): string { + const m = Math.floor(s / 60) + const sec = Math.floor(s % 60) + return `${m}:${sec.toString().padStart(2, "0")}` +} + export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) { const round = useRoomStore((s) => s.round) const reveal = useRoomStore((s) => s.reveal) @@ -58,19 +64,6 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) { } }, [showReveal, ready, api]) - function djPlay() { - api.play() - mediaControl("play", api.time()) - } - function djPause() { - api.pause() - mediaControl("pause", api.time()) - } - function djRestart() { - api.seek(0) - mediaControl("seek", 0) - } - return (
@@ -86,9 +79,13 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) { )}
- {/* Pochette : le lecteur YouTube est caché derrière (son seulement). */} -
-
+ {/* Cadre 16:9 : le lecteur YouTube est caché derrière le disque pendant la + manche (son seulement), puis révélé (bannière visible) au reveal. */} +
+
{!showReveal && (
{isDj && !showReveal && ( -
- - Tu es le DJ 🎧 - -
- - - -
-
+ )} - {!showReveal && ( + {!isDj && !showReveal && ( void +}) { + const [pos, setPos] = useState(0) + const [dur, setDur] = useState(0) + + useEffect(() => { + if (!ready) { + return + } + const id = setInterval(() => { + setPos(api.time()) + setDur(api.duration()) + }, 400) + return () => clearInterval(id) + }, [ready, api]) + + return ( +
+ + Tu es le DJ 🎧 — c'est ton titre, fais-le deviner 🤫 + +
+ + + +
+
+ {fmt(pos)} + { + const v = Number(e.target.value) + setPos(v) + api.seek(v) + mediaControl("seek", v) + }} + className="accent-primary flex-1" + /> + {fmt(dur)} +
+
+ ) +} + function VoteForm({ mode, snapshot, @@ -238,9 +307,11 @@ function VoteForm({ function RevealCard({ truth, + isDj, result, }: { truth: BlindtestRevealTruth + isDj: boolean result?: BlindtestPerPlayerResult[string] }) { return ( @@ -252,11 +323,13 @@ function RevealCard({ {truth.submittedByName}

- {result && ( + {result && (result.points > 0 || !isDj) && (

0 ? "text-green-500" : "text-red-500"}`} > - {result.points > 0 ? `+${result.points} points 🎉` : "Raté 💥"} + {result.points > 0 + ? `+${result.points} points ${isDj ? "🤫" : "🎉"}` + : "Raté 💥"}

)}
diff --git a/apps/web/src/components/lobby-view.tsx b/apps/web/src/components/lobby-view.tsx index 8eb8375..6e948f8 100644 --- a/apps/web/src/components/lobby-view.tsx +++ b/apps/web/src/components/lobby-view.tsx @@ -25,6 +25,15 @@ const MODE_LABELS: Record = { 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" +function shuffle(items: T[]): T[] { + const arr = [...items] + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[arr[i], arr[j]] = [arr[j], arr[i]] + } + return arr +} + /** Construit la séquence de manches selon le type de partie. */ function buildRounds( gameType: GameType, @@ -37,21 +46,12 @@ function buildRounds( if (gameType === "blindtest") { return Array.from({ length: totalTracks }, () => ({ type: "blindtest" })) } - // Mixte : on alterne quiz / blindtest pour multiplier les changements de mode. - const rounds: RoundConfig[] = [] - let q = quizCount - let b = totalTracks - while (q > 0 || b > 0) { - if (q > 0) { - rounds.push({ type: "quiz" }) - q-- - } - if (b > 0) { - rounds.push({ type: "blindtest" }) - b-- - } - } - return rounds + // Mixte : ordre mélangé pour qu'on ne sache pas ce qui vient ensuite. + const rounds: RoundConfig[] = [ + ...Array.from({ length: quizCount }, () => ({ type: "quiz" as const })), + ...Array.from({ length: totalTracks }, () => ({ type: "blindtest" as const })), + ] + return shuffle(rounds) } export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { @@ -71,8 +71,13 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { const showQuiz = gameType === "quiz" || gameType === "mixed" const showBlindtest = gameType === "blindtest" || gameType === "mixed" + // Le blindtest exige au moins 3 joueurs connectés (DJ + 2 devineurs). + const connectedCount = snapshot.players.filter((p) => p.connected).length + const blindtestAvailable = connectedCount >= 3 + const rounds = buildRounds(gameType, showQuiz ? count : 0, totalTracks) - const canStart = rounds.length > 0 + const canStart = + rounds.length > 0 && (!showBlindtest || blindtestAvailable) async function start() { setError(null) @@ -117,17 +122,29 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { {isHost && ( -
- {GAME_TYPES.map((t) => ( - - ))} +
+
+ {GAME_TYPES.map((t) => { + const needsThree = t.value !== "quiz" && !blindtestAvailable + return ( + + ) + })} +
+ {!blindtestAvailable && ( +

+ Le blindtest se débloque à 3 joueurs. +

+ )}
)} diff --git a/apps/web/src/lib/youtube.ts b/apps/web/src/lib/youtube.ts index b4308c4..13f47a0 100644 --- a/apps/web/src/lib/youtube.ts +++ b/apps/web/src/lib/youtube.ts @@ -28,6 +28,7 @@ export interface YoutubeApi { pause: () => void seek: (seconds: number) => void time: () => number + duration: () => number } /** @@ -89,6 +90,7 @@ export function useYoutube(youtubeId: string) { pause: () => playerRef.current?.pauseVideo(), seek: (seconds) => playerRef.current?.seekTo(seconds, true), time: () => playerRef.current?.getCurrentTime() ?? 0, + duration: () => playerRef.current?.getDuration() ?? 0, }), [] ) -- 2.45.3 From a2e3fae858bc61c6f3fbd04d6ca65605f74d205e Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Wed, 10 Jun 2026 14:40:02 +0200 Subject: [PATCH 18/51] fix(blindtest): keep neutral DJ; only the contributor is misdirection-scored Revert the DJ change: the DJ is again a neutral player (never the track's contributor), broadcast to all and counted as an eligible voter (votes blind). The contributor is a normal voter whose vote never scores on their own track; they only earn the who_added/mixed misdirection bonus (+50 per wrong guess). - engine: restore single round:start broadcast + DJ-included eligible voters - blindtest-round: restore neutral pickDj; contributor may vote (ignored in scoring) so the round can still end on "all voted" - client: DJ controls + everyone (DJ included) votes; reveal message simplified Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/src/game/engine.ts | 24 ++++--------------- .../modes/blindtest/blindtest-round.test.ts | 8 +++---- .../game/modes/blindtest/blindtest-round.ts | 21 ++++++++++++---- apps/web/src/components/blindtest-view.tsx | 13 ++++------ 4 files changed, 29 insertions(+), 37 deletions(-) diff --git a/apps/server/src/game/engine.ts b/apps/server/src/game/engine.ts index 7b9771f..2aedfce 100644 --- a/apps/server/src/game/engine.ts +++ b/apps/server/src/game/engine.ts @@ -127,26 +127,13 @@ export class GameEngine implements RoomGameController { } this.room.status = "in_round" this.broadcastState() - // L'identité du DJ (= contributeur du titre) est privée : seul le DJ la - // reçoit, sinon "qui l'a ajouté" deviendrait trivial. - const base = { + this.io.to(this.room.code).emit("round:start", { type: round.type, + djId: this.runtime.djId ?? undefined, startsAt: startedAt, endsAt, payload: start.payload, - } - const djSocketId = this.runtime.djId - ? this.room.players.get(this.runtime.djId)?.socketId - : null - if (djSocketId) { - this.io.to(djSocketId).emit("round:start", { - ...base, - djId: this.runtime.djId ?? undefined, - }) - this.io.to(this.room.code).except(djSocketId).emit("round:start", base) - } else { - this.io.to(this.room.code).emit("round:start", base) - } + }) // Attend la première condition de fin : timer écoulé OU tous votés. await new Promise((resolve) => { @@ -190,11 +177,10 @@ export class GameEngine implements RoomGameController { } } - /** Votants éligibles : joueurs connectés, hors DJ (= contributeur, ne vote pas). */ + /** Votants éligibles : tous les joueurs connectés (le DJ neutre vote aussi). */ private eligibleVoters(): string[] { - const djId = this.runtime?.djId return [...this.room.players.values()] - .filter((p) => p.connected && p.id !== djId) + .filter((p) => p.connected) .map((p) => p.id) } diff --git a/apps/server/src/game/modes/blindtest/blindtest-round.test.ts b/apps/server/src/game/modes/blindtest/blindtest-round.test.ts index ef6f599..cdeb1ee 100644 --- a/apps/server/src/game/modes/blindtest/blindtest-round.test.ts +++ b/apps/server/src/game/modes/blindtest/blindtest-round.test.ts @@ -44,16 +44,16 @@ describe("match", () => { }) describe("BlindtestRound", () => { - test("start : le DJ est le contributeur du titre", () => { + test("start : le DJ est neutre (jamais le contributeur)", () => { const rooms = new RoomManager() const { room, player: a } = rooms.create("Alice", "sa") - rooms.join(room.code, "Bob", "sb") + const { player: b } = rooms.join(room.code, "Bob", "sb") room.blindtestTracks = [track(a.id)] prepareBlindtestForRoom(room) const round = new BlindtestRound() const start = round.start(room) - expect(start.djId).toBe(a.id) // Alice a ajouté le titre → elle est DJ + expect(start.djId).toBe(b.id) // Bob (pas Alice, la contributrice) expect(start.payload).toEqual({ trackId: "t1", youtubeId: "abc12345678" }) }) @@ -67,7 +67,7 @@ describe("BlindtestRound", () => { const ctx = ctxFor(room, t, "who_added") round.submitAnswer(ctx, b.id, { guessedPlayerId: c.id }) // faux round.submitAnswer(ctx, c.id, { guessedPlayerId: a.id }) // juste - round.submitAnswer(ctx, a.id, { guessedPlayerId: b.id }) // ignoré (contributrice) + round.submitAnswer(ctx, a.id, { guessedPlayerId: b.id }) // contributrice : non scorée const deltas = round.score(ctx) // Carol devine juste → 100 ; Alice trompe Bob (1 mauvaise devinette) → 50 expect(deltas).toContainEqual({ playerId: c.id, delta: 100 }) diff --git a/apps/server/src/game/modes/blindtest/blindtest-round.ts b/apps/server/src/game/modes/blindtest/blindtest-round.ts index 17e504f..14b61ef 100644 --- a/apps/server/src/game/modes/blindtest/blindtest-round.ts +++ b/apps/server/src/game/modes/blindtest/blindtest-round.ts @@ -26,6 +26,17 @@ interface BlindtestRoundData { track: BlindtestTrack } +/** Tire un DJ neutre : un joueur connecté qui n'a pas soumis ce titre. */ +function pickDj(room: ServerRoom, submittedBy: string): string | null { + const eligible = [...room.players.values()].filter( + (p) => p.connected && p.id !== submittedBy + ) + if (eligible.length === 0) { + return null + } + return eligible[Math.floor(Math.random() * eligible.length)].id +} + /** Résultat d'un joueur VOTANT (≠ contributeur) selon le mode. */ function voterResult( mode: BlindtestMode, @@ -60,8 +71,8 @@ export class BlindtestRound implements GameRound { start(room: ServerRoom): RoundStart { const track = takeTrack(room) - // Le DJ EST le contributeur : il lance/pilote son propre titre. - const djId = track ? track.submittedBy : null + // DJ neutre (jamais le contributeur) : il pilote la lecture et vote à l'aveugle. + const djId = track ? pickDj(room, track.submittedBy) : null const payload: BlindtestRoundPayload | null = track ? { trackId: track.id, youtubeId: track.youtubeId } : null @@ -69,9 +80,9 @@ export class BlindtestRound implements GameRound { } submitAnswer(ctx: RoundContext, playerId: string, answer: Answer): void { - const { track } = ctx.data as BlindtestRoundData - // Le contributeur (DJ) ne vote pas ; premier vote verrouillé pour les autres. - if (playerId === track.submittedBy || ctx.votes.has(playerId)) { + // Premier vote verrouillé (idempotent). Le contributeur peut voter mais son + // vote ne lui rapporte rien (voir buildResults) ; ça évite de bloquer la fin. + if (ctx.votes.has(playerId)) { return } ctx.votes.set(playerId, answer) diff --git a/apps/web/src/components/blindtest-view.tsx b/apps/web/src/components/blindtest-view.tsx index 8925185..5354eab 100644 --- a/apps/web/src/components/blindtest-view.tsx +++ b/apps/web/src/components/blindtest-view.tsx @@ -102,7 +102,7 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) { )} - {!isDj && !showReveal && ( + {!showReveal && ( - Tu es le DJ 🎧 — c'est ton titre, fais-le deviner 🤫 + Tu es le DJ 🎧 — pilote la lecture (et vote comme les autres)
-- 2.45.3 From a2dc687ac0f359116d85acdac5d64838a7dd3049 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Wed, 10 Jun 2026 14:51:19 +0200 Subject: [PATCH 19/51] fix(web): lobby falls back to quiz UI below 3 players MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Default game type stays "mixed", but while fewer than 3 players are connected the lobby renders the quiz config only (Quiz highlighted, Mixed/Blindtest disabled) instead of showing blindtest settings to a lone host. It switches back to the stored type automatically once 3 players are present — no manual click needed. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/components/lobby-view.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/lobby-view.tsx b/apps/web/src/components/lobby-view.tsx index 6e948f8..0197898 100644 --- a/apps/web/src/components/lobby-view.tsx +++ b/apps/web/src/components/lobby-view.tsx @@ -68,16 +68,19 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { const totalTracks = snapshot.submissions.reduce((n, s) => n + s.count, 0) const myCount = snapshot.submissions.find((s) => s.playerId === playerId)?.count ?? 0 - const showQuiz = gameType === "quiz" || gameType === "mixed" - const showBlindtest = gameType === "blindtest" || gameType === "mixed" // Le blindtest exige au moins 3 joueurs connectés (DJ + 2 devineurs). const connectedCount = snapshot.players.filter((p) => p.connected).length const blindtestAvailable = connectedCount >= 3 + // Tant que le blindtest est indisponible, on retombe sur du quiz (sans + // toucher au réglage stocké) : repasse en mixte automatiquement à 3 joueurs. + const effectiveType: GameType = blindtestAvailable ? gameType : "quiz" + const showQuiz = effectiveType === "quiz" || effectiveType === "mixed" + const showBlindtest = + effectiveType === "blindtest" || effectiveType === "mixed" - const rounds = buildRounds(gameType, showQuiz ? count : 0, totalTracks) - const canStart = - rounds.length > 0 && (!showBlindtest || blindtestAvailable) + const rounds = buildRounds(effectiveType, showQuiz ? count : 0, totalTracks) + const canStart = rounds.length > 0 async function start() { setError(null) @@ -130,7 +133,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
+ ) +} + function VoteForm({ mode, snapshot, diff --git a/apps/web/src/components/game-end-view.tsx b/apps/web/src/components/game-end-view.tsx index 28c1ae9..b4d47f4 100644 --- a/apps/web/src/components/game-end-view.tsx +++ b/apps/web/src/components/game-end-view.tsx @@ -113,6 +113,8 @@ 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 returnToLobby = useRoomStore((s) => s.returnToLobby) + const isHost = snapshot.hostId === playerId const scores: PlayerScore[] = finalScores ?? snapshot.scores const ranked = [...scores].sort((a, b) => b.score - a.score) @@ -194,11 +196,22 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) { )} - - - +
+ {isHost ? ( + + ) : ( +

+ En attente que l'hôte relance une partie… +

+ )} + + + +
) } diff --git a/apps/web/src/components/lobby-view.tsx b/apps/web/src/components/lobby-view.tsx index 0197898..c8b7124 100644 --- a/apps/web/src/components/lobby-view.tsx +++ b/apps/web/src/components/lobby-view.tsx @@ -1,4 +1,5 @@ import { useState } from "react" +import { Trash2 } from "lucide-react" import type { BlindtestMode, GameType, @@ -225,6 +226,12 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { ) } +interface MyTrack { + trackId: string + title: string + youtubeId: string +} + function TrackSubmission({ tracksPerPlayer, myCount, @@ -233,10 +240,11 @@ function TrackSubmission({ myCount: number }) { const submitTrack = useRoomStore((s) => s.submitTrack) + const removeTrack = useRoomStore((s) => s.removeTrack) const [url, setUrl] = useState("") const [submitting, setSubmitting] = useState(false) const [feedback, setFeedback] = useState(null) - const [accepted, setAccepted] = useState([]) + const [tracks, setTracks] = useState([]) const quotaReached = myCount >= tracksPerPlayer async function submit() { @@ -246,8 +254,11 @@ function TrackSubmission({ setSubmitting(true) setFeedback(null) const res = await submitTrack(url.trim()) - if (res.accepted) { - setAccepted((a) => [...a, res.title ?? url.trim()]) + if (res.accepted && res.trackId && res.youtubeId) { + setTracks((t) => [ + ...t, + { trackId: res.trackId!, title: res.title ?? "", youtubeId: res.youtubeId! }, + ]) setUrl("") } else { setFeedback(res.reason ?? "Refusé") @@ -255,38 +266,65 @@ function TrackSubmission({ setSubmitting(false) } + async function remove(trackId: string) { + const ok = await removeTrack(trackId) + if (ok) { + setTracks((t) => t.filter((x) => x.trackId !== trackId)) + } + } + return (
Tes titres ({myCount}/{tracksPerPlayer}) -
- setUrl(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && submit()} - /> - -
- {feedback &&

{feedback}

} - {accepted.length > 0 && ( -
    - {accepted.map((t, i) => ( -
  • - ✓ {t} + + {tracks.length > 0 && ( +
      + {tracks.map((t) => ( +
    • + + {t.title} +
    • ))}
    )} + + {!quotaReached && ( +
    + setUrl(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && submit()} + /> + +
    + )} + {feedback &&

    {feedback}

    }
) } diff --git a/apps/web/src/store/room.ts b/apps/web/src/store/room.ts index 55024e8..6955ae1 100644 --- a/apps/web/src/store/room.ts +++ b/apps/web/src/store/room.ts @@ -20,6 +20,8 @@ import { socket } from "@/lib/socket" export interface ActiveRound { type: RoundStartPayload["type"] djId?: string + /** Blindtest : true si c'est MON titre (je dois faire deviner, pas voter). */ + mine?: boolean startsAt: number endsAt: number payload: unknown @@ -56,6 +58,8 @@ interface RoomState { updateSettings: (partial: Partial) => void startGame: (rounds: RoundConfig[]) => Promise submitTrack: (youtubeUrl: string) => Promise + removeTrack: (trackId: string) => Promise + returnToLobby: () => void vote: (choiceIndex: number) => void voteText: (text: string) => void voteBlindtest: (answer: BlindtestAnswer) => void @@ -137,6 +141,15 @@ export const useRoomStore = create((set, get) => ({ socket.emit("blindtest:submitTrack", { youtubeUrl }, (res) => resolve(res)) }), + removeTrack: (trackId) => + new Promise((resolve) => { + socket.emit("blindtest:removeTrack", { trackId }, (res) => resolve(res.ok)) + }), + + returnToLobby: () => { + socket.emit("lobby:return") + }, + vote: (choiceIndex) => { if (get().hasVoted) { return @@ -189,7 +202,22 @@ export const useRoomStore = create((set, get) => ({ // Listeners socket → on pousse l'état serveur dans le store (source de vérité). socket.on("connect", () => useRoomStore.setState({ connected: true })) socket.on("disconnect", () => useRoomStore.setState({ connected: false })) -socket.on("room:state", (snapshot) => useRoomStore.setState({ snapshot })) +socket.on("room:state", (snapshot) => + useRoomStore.setState( + snapshot.status === "lobby" + ? { + snapshot, + round: null, + reveal: null, + voteProgress: null, + myChoiceIndex: null, + hasVoted: false, + finalScores: null, + mediaSync: null, + } + : { snapshot } + ) +) socket.on("error", (error) => useRoomStore.setState({ lastError: error })) socket.on("round:start", (payload) => @@ -197,6 +225,7 @@ socket.on("round:start", (payload) => round: { type: payload.type, djId: payload.djId, + mine: payload.mine, startsAt: payload.startsAt, endsAt: payload.endsAt, payload: payload.payload, diff --git a/packages/shared/src/events.ts b/packages/shared/src/events.ts index 8b9a7b1..b3f35fe 100644 --- a/packages/shared/src/events.ts +++ b/packages/shared/src/events.ts @@ -46,12 +46,19 @@ export interface SubmitTrackPayload { export interface SubmitOkPayload { accepted: boolean reason?: string + /** Id du titre créé (pour pouvoir le supprimer). */ + trackId?: string + youtubeId?: string /** Titre récupéré (oEmbed) si accepté — pour confirmation côté client. */ title?: string /** Nombre total de titres soumis par ce joueur après cet ajout. */ count?: number } +export interface RemoveTrackPayload { + trackId: string +} + export interface RoundStartPayload { type: RoundType djId?: string @@ -59,6 +66,8 @@ export interface RoundStartPayload { startsAt: number /** Timestamp serveur de fin de manche (startsAt + roundDuration). */ endsAt: number + /** Envoyé uniquement au contributeur du titre (blindtest) : "c'est le tien". */ + mine?: boolean /** Payload spécifique au type d'épreuve, SANS la réponse. */ payload: unknown } @@ -118,11 +127,16 @@ export interface ClientToServerEvents { "room:create": (payload: RoomCreatePayload, ack: Ack) => void "room:join": (payload: RoomJoinPayload, ack: Ack) => void "lobby:updateSettings": (payload: UpdateSettingsPayload) => void + "lobby:return": () => void "game:start": (ack: Ack) => void "blindtest:submitTrack": ( payload: SubmitTrackPayload, ack: (res: SubmitOkPayload) => void ) => void + "blindtest:removeTrack": ( + payload: RemoveTrackPayload, + ack: (res: { ok: boolean }) => void + ) => void "media:control": (payload: MediaControlPayload) => void "round:vote": (payload: VotePayload) => void } -- 2.45.3 From 1071a59b681fd42f0206603edacd6231abfdf511 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Wed, 10 Jun 2026 15:32:24 +0200 Subject: [PATCH 21/51] feat: pick pseudo inside the room + shareable invite link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure onboarding (option B): - home is just Create / Join (code) — no name field - name is chosen in the room: room:create/join no longer require a name; new player:setName event. Nameless players are hidden from the snapshot (players/scores/submissions) and excluded from eligible voters and the 3-player blindtest gate until they pick a pseudo. - PseudoScreen with live DiceBear avatar preview gates RoomPage until set - /join/:code auto-joins then redirects to the room (seamless invite links) - RoomCode gains a "copier le lien" button (origin/join/CODE) Verified e2e: nameless players excluded from the room until setName. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/src/game/engine.ts | 2 +- apps/server/src/handlers.ts | 33 +++++++---- apps/server/src/rooms.ts | 12 ++-- apps/web/src/App.tsx | 4 ++ apps/web/src/components/pseudo-screen.tsx | 45 +++++++++++++++ apps/web/src/components/room-code.tsx | 67 ++++++++++++++--------- apps/web/src/pages/home.tsx | 20 ++----- apps/web/src/pages/join.tsx | 42 ++++++++++++++ apps/web/src/pages/room.tsx | 21 ++++++- apps/web/src/store/room.ts | 37 ++++++++++--- packages/shared/src/events.ts | 9 ++- 11 files changed, 223 insertions(+), 69 deletions(-) create mode 100644 apps/web/src/components/pseudo-screen.tsx create mode 100644 apps/web/src/pages/join.tsx diff --git a/apps/server/src/game/engine.ts b/apps/server/src/game/engine.ts index a34be3a..9b11409 100644 --- a/apps/server/src/game/engine.ts +++ b/apps/server/src/game/engine.ts @@ -198,7 +198,7 @@ export class GameEngine implements RoomGameController { private eligibleVoters(): string[] { const secret = this.runtime?.secretPlayerId return [...this.room.players.values()] - .filter((p) => p.connected && p.id !== secret) + .filter((p) => p.connected && p.name !== "" && p.id !== secret) .map((p) => p.id) } diff --git a/apps/server/src/handlers.ts b/apps/server/src/handlers.ts index 05657c9..2c5bf82 100644 --- a/apps/server/src/handlers.ts +++ b/apps/server/src/handlers.ts @@ -53,11 +53,8 @@ export function registerRoomHandlers( } socket.on("room:create", (payload, ack) => { - const name = cleanName(payload?.playerName) - if (!name) { - ack({ ok: false, error: { code: "INVALID_NAME", message: "Pseudo invalide." } }) - return - } + // Le pseudo est choisi dans la room (player:setName) : on crée sans nom. + const name = cleanName(payload?.playerName) ?? "" try { const { room, player } = rooms.create(name, socket.id) socket.data.playerId = player.id @@ -71,12 +68,8 @@ export function registerRoomHandlers( }) socket.on("room:join", (payload, ack) => { - const name = cleanName(payload?.playerName) + const name = cleanName(payload?.playerName) ?? "" const code = typeof payload?.roomCode === "string" ? payload.roomCode.trim().toUpperCase() : "" - if (!name) { - ack({ ok: false, error: { code: "INVALID_NAME", message: "Pseudo invalide." } }) - return - } try { const { room, player } = rooms.join(code, name, socket.id) socket.data.playerId = player.id @@ -89,6 +82,22 @@ export function registerRoomHandlers( } }) + socket.on("player:setName", (payload) => { + const code = socket.data.roomCode + const playerId = socket.data.playerId + const room = code ? rooms.get(code) : undefined + const name = cleanName(payload?.name) + if (!room || !playerId || !name) { + return + } + const player = room.players.get(playerId) + if (!player) { + return + } + player.name = name + broadcastState(room) + }) + socket.on("lobby:updateSettings", (payload) => { const code = socket.data.roomCode const room = code ? rooms.get(code) : undefined @@ -129,7 +138,9 @@ export function registerRoomHandlers( return } const hasBlindtest = room.settings.rounds.some((r) => r.type === "blindtest") - const connected = [...room.players.values()].filter((p) => p.connected).length + const connected = [...room.players.values()].filter( + (p) => p.connected && p.name !== "" + ).length if (hasBlindtest && connected < 3) { ack({ ok: false, diff --git a/apps/server/src/rooms.ts b/apps/server/src/rooms.ts index bffbd5c..96be50b 100644 --- a/apps/server/src/rooms.ts +++ b/apps/server/src/rooms.ts @@ -112,23 +112,25 @@ export class RoomManager { /** Projette la room vers la vue publique diffusée aux clients (sans secrets). */ toSnapshot(room: ServerRoom): RoomSnapshot { + // Seuls les joueurs ayant choisi un pseudo sont visibles dans la room. + const named = [...room.players.values()].filter((p) => p.name !== "") return { code: room.code, status: room.status, hostId: room.hostId, - players: [...room.players.values()].map((p) => ({ + players: named.map((p) => ({ id: p.id, name: p.name, connected: p.connected, })), settings: room.settings, - scores: [...room.scores.entries()].map(([playerId, score]) => ({ - playerId, - score, + scores: named.map((p) => ({ + playerId: p.id, + score: room.scores.get(p.id) ?? 0, })), currentRound: room.currentRound, totalRounds: room.settings.rounds.length, - submissions: [...room.players.values()].map((p) => ({ + submissions: named.map((p) => ({ playerId: p.id, count: room.blindtestTracks.filter((t) => t.submittedBy === p.id).length, })), diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 3aca4dd..6a275ee 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,11 +1,15 @@ import { Route, Switch } from "wouter" import { HomePage } from "@/pages/home" +import { JoinPage } from "@/pages/join" import { RoomPage } from "@/pages/room" export function App() { return ( + + {(params) => } + {(params) => } diff --git a/apps/web/src/components/pseudo-screen.tsx b/apps/web/src/components/pseudo-screen.tsx new file mode 100644 index 0000000..5a11d8c --- /dev/null +++ b/apps/web/src/components/pseudo-screen.tsx @@ -0,0 +1,45 @@ +import { useState } from "react" +import { Button } from "@workspace/ui/components/button" +import { useRoomStore } from "@/store/room" +import { Avatar } from "@/components/avatar" + +const inputClass = + "border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-center text-sm focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none" + +/** Choix du pseudo dans la room, avec aperçu de l'avatar en temps réel. */ +export function PseudoScreen({ code }: { code: string }) { + const setName = useRoomStore((s) => s.setName) + const [name, setName_] = useState("") + const canSubmit = name.trim().length > 0 + + return ( +
+
+

+ Room {code} +

+ +

Choisis ton pseudo

+ setName_(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && canSubmit && setName(name)} + /> + +
+
+ ) +} diff --git a/apps/web/src/components/room-code.tsx b/apps/web/src/components/room-code.tsx index 58142df..ccd599d 100644 --- a/apps/web/src/components/room-code.tsx +++ b/apps/web/src/components/room-code.tsx @@ -1,39 +1,56 @@ import { useState } from "react" -import { Check, Copy } from "lucide-react" +import { Check, Copy, Link2 } from "lucide-react" +import { Button } from "@workspace/ui/components/button" -/** Code de room cliquable : copie dans le presse-papier avec retour visuel. */ +type Copied = "code" | "link" | null + +/** Code de room copiable + bouton pour copier le lien d'invitation. */ export function RoomCode({ code }: { code: string }) { - const [copied, setCopied] = useState(false) + const [copied, setCopied] = useState(null) - async function copy() { + async function copy(what: Copied, text: string) { try { - await navigator.clipboard.writeText(code) - setCopied(true) - setTimeout(() => setCopied(false), 1500) + await navigator.clipboard.writeText(text) + setCopied(what) + setTimeout(() => setCopied(null), 1500) } catch { // presse-papier indisponible (http non sécurisé) : on ignore silencieusement } } + const link = `${window.location.origin}/join/${code}` + return ( - + {copied === "code" ? ( + + ) : ( + + )} + + + +
) } diff --git a/apps/web/src/pages/home.tsx b/apps/web/src/pages/home.tsx index c9d305c..6aebc56 100644 --- a/apps/web/src/pages/home.tsx +++ b/apps/web/src/pages/home.tsx @@ -12,18 +12,15 @@ export function HomePage() { const joinRoom = useRoomStore((s) => s.joinRoom) const connected = useRoomStore((s) => s.connected) - const [name, setName] = useState("") const [code, setCode] = useState("") const [error, setError] = useState(null) const [busy, setBusy] = useState(false) - const canSubmit = name.trim().length > 0 && connected && !busy - async function handleCreate() { setError(null) setBusy(true) try { - const { roomCode } = await createRoom(name.trim()) + const { roomCode } = await createRoom() navigate(`/room/${roomCode}`) } catch (err) { setError((err as { message?: string }).message ?? "Erreur") @@ -36,7 +33,7 @@ export function HomePage() { setError(null) setBusy(true) try { - const { roomCode } = await joinRoom(code.trim().toUpperCase(), name.trim()) + const { roomCode } = await joinRoom(code.trim().toUpperCase()) navigate(`/room/${roomCode}`) } catch (err) { setError((err as { message?: string }).message ?? "Erreur") @@ -57,15 +54,7 @@ export function HomePage() {

- setName(e.target.value)} - /> - - @@ -82,10 +71,11 @@ export function HomePage() { value={code} maxLength={4} onChange={(e) => setCode(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && code.trim() && handleJoin()} /> + + + ) : ( +

+ Connexion à la room {code}… +

+ )} +
+ ) +} diff --git a/apps/web/src/pages/room.tsx b/apps/web/src/pages/room.tsx index ee937fc..5c00307 100644 --- a/apps/web/src/pages/room.tsx +++ b/apps/web/src/pages/room.tsx @@ -9,19 +9,22 @@ import { PlayerCards } from "@/components/player-cards" import { RoomCode } from "@/components/room-code" import { Boom } from "@/components/boom" import { RoundTransition } from "@/components/round-transition" +import { PseudoScreen } from "@/components/pseudo-screen" export function RoomPage({ code }: { code: string }) { const snapshot = useRoomStore((s) => s.snapshot) + const roomCode = useRoomStore((s) => s.roomCode) const playerId = useRoomStore((s) => s.playerId) const connected = useRoomStore((s) => s.connected) + const pseudoSet = useRoomStore((s) => s.pseudoSet) const boomKey = useRoomStore((s) => s.boomKey) const roundKey = useRoomStore((s) => s.roundKey) const voteProgress = useRoomStore((s) => s.voteProgress) const round = useRoomStore((s) => s.round) const roundModeChanged = useRoomStore((s) => s.roundModeChanged) - // Pas de snapshot pour ce code (accès direct / refresh) : retour à l'accueil. - if (!snapshot || snapshot.code !== code) { + // Pas dans cette room (accès direct sans rejoindre / refresh) : retour accueil. + if (roomCode !== code) { return (

@@ -34,6 +37,20 @@ export function RoomPage({ code }: { code: string }) { ) } + // Pseudo pas encore choisi : on l'affiche d'abord. + if (!pseudoSet) { + return + } + + // En attente du premier snapshot. + if (!snapshot || snapshot.code !== code) { + return ( +

+

Chargement…

+
+ ) + } + // Cards de scores visibles en permanence pendant le jeu (suivi continu). const inGame = snapshot.status === "in_round" || diff --git a/apps/web/src/store/room.ts b/apps/web/src/store/room.ts index 6955ae1..94f018c 100644 --- a/apps/web/src/store/room.ts +++ b/apps/web/src/store/room.ts @@ -37,6 +37,8 @@ interface RoomState { connected: boolean playerId: string | null roomCode: string | null + /** Le joueur local a-t-il choisi son pseudo dans la room ? */ + pseudoSet: boolean snapshot: RoomSnapshot | null lastError: ErrorPayload | null @@ -53,8 +55,9 @@ interface RoomState { /** Le type d'épreuve a-t-il changé par rapport à la manche précédente ? */ roundModeChanged: boolean - createRoom: (playerName: string) => Promise - joinRoom: (roomCode: string, playerName: string) => Promise + createRoom: () => Promise + joinRoom: (roomCode: string) => Promise + setName: (name: string) => void updateSettings: (partial: Partial) => void startGame: (rounds: RoundConfig[]) => Promise submitTrack: (youtubeUrl: string) => Promise @@ -73,6 +76,7 @@ export const useRoomStore = create((set, get) => ({ connected: socket.connected, playerId: null, roomCode: null, + pseudoSet: false, snapshot: null, lastError: null, round: null, @@ -86,11 +90,15 @@ export const useRoomStore = create((set, get) => ({ roundKey: 0, roundModeChanged: false, - createRoom: (playerName) => + createRoom: () => new Promise((resolve, reject) => { - socket.emit("room:create", { playerName }, (res) => { + socket.emit("room:create", {}, (res) => { if (res.ok) { - set({ playerId: res.data.playerId, roomCode: res.data.roomCode }) + set({ + playerId: res.data.playerId, + roomCode: res.data.roomCode, + pseudoSet: false, + }) resolve(res.data) } else { set({ lastError: res.error }) @@ -99,11 +107,15 @@ export const useRoomStore = create((set, get) => ({ }) }), - joinRoom: (roomCode, playerName) => + joinRoom: (roomCode) => new Promise((resolve, reject) => { - socket.emit("room:join", { roomCode, playerName }, (res) => { + socket.emit("room:join", { roomCode }, (res) => { if (res.ok) { - set({ playerId: res.data.playerId, roomCode: res.data.roomCode }) + set({ + playerId: res.data.playerId, + roomCode: res.data.roomCode, + pseudoSet: false, + }) resolve(res.data) } else { set({ lastError: res.error }) @@ -112,6 +124,14 @@ export const useRoomStore = create((set, get) => ({ }) }), + setName: (name) => { + if (name.trim().length === 0) { + return + } + socket.emit("player:setName", { name: name.trim() }) + set({ pseudoSet: true }) + }, + updateSettings: (partial) => { const current = get().snapshot?.settings ?? DEFAULT_ROOM_SETTINGS socket.emit("lobby:updateSettings", { @@ -184,6 +204,7 @@ export const useRoomStore = create((set, get) => ({ reset: () => set({ roomCode: null, + pseudoSet: false, snapshot: null, lastError: null, round: null, diff --git a/packages/shared/src/events.ts b/packages/shared/src/events.ts index b3f35fe..747f6ff 100644 --- a/packages/shared/src/events.ts +++ b/packages/shared/src/events.ts @@ -14,7 +14,7 @@ import type { // --- Payloads ------------------------------------------------------------ export interface RoomCreatePayload { - playerName: string + playerName?: string } export interface RoomCreatedPayload { @@ -24,7 +24,11 @@ export interface RoomCreatedPayload { export interface RoomJoinPayload { roomCode: string - playerName: string + playerName?: string +} + +export interface SetNamePayload { + name: string } export interface UpdateSettingsPayload { @@ -126,6 +130,7 @@ export type Ack = (result: { ok: true; data: T } | { ok: false; error: ErrorP export interface ClientToServerEvents { "room:create": (payload: RoomCreatePayload, ack: Ack) => void "room:join": (payload: RoomJoinPayload, ack: Ack) => void + "player:setName": (payload: SetNamePayload) => void "lobby:updateSettings": (payload: UpdateSettingsPayload) => void "lobby:return": () => void "game:start": (ack: Ack) => void -- 2.45.3 From 3f0cab8f5fa69ca018885321056dc19457249674 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Wed, 10 Jun 2026 16:04:14 +0200 Subject: [PATCH 22/51] feat: end-of-game track recap, no self-vote in who_added, festive home + icons - game:end now carries the played blindtest tracks (title/artist/youtubeId/ url/submittedByName); the end screen lists them as cards with a YouTube open link so players can recover/export the songs - who_added vote no longer lets you pick yourself (filtered out) - home page: animated halo, floating geek emojis, springy gradient title, hover/tap on buttons (Framer Motion) + Lucide icons - lobby settings get Lucide icons (game type, quiz, blindtest mode, tracks, start) Verified e2e: game:end includes the track list with links. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/src/game/engine.ts | 13 +++- apps/web/src/components/blindtest-view.tsx | 33 +++++---- apps/web/src/components/game-end-view.tsx | 43 +++++++++++- apps/web/src/components/lobby-view.tsx | 45 ++++++++---- apps/web/src/pages/home.tsx | 81 ++++++++++++++++++---- apps/web/src/store/room.ts | 9 ++- packages/shared/src/blindtest.ts | 9 +++ packages/shared/src/events.ts | 3 + 8 files changed, 187 insertions(+), 49 deletions(-) diff --git a/apps/server/src/game/engine.ts b/apps/server/src/game/engine.ts index 9b11409..8fe2b74 100644 --- a/apps/server/src/game/engine.ts +++ b/apps/server/src/game/engine.ts @@ -72,7 +72,18 @@ export class GameEngine implements RoomGameController { } this.room.status = "ended" this.broadcastState() - this.io.to(this.room.code).emit("game:end", { finalScores: this.scoreboard() }) + // Récap des titres blindtest joués (pour récupérer les liens YouTube). + const tracks = this.room.blindtestTracks.map((t) => ({ + title: t.title, + artist: t.artist, + youtubeId: t.youtubeId, + url: t.url, + submittedByName: this.room.players.get(t.submittedBy)?.name ?? "?", + })) + this.io.to(this.room.code).emit("game:end", { + finalScores: this.scoreboard(), + tracks: tracks.length > 0 ? tracks : undefined, + }) } /** Vote d'un joueur pendant une manche. Délègue à l'épreuve, puis check fin anticipée. */ diff --git a/apps/web/src/components/blindtest-view.tsx b/apps/web/src/components/blindtest-view.tsx index 739ea5f..a96ea40 100644 --- a/apps/web/src/components/blindtest-view.tsx +++ b/apps/web/src/components/blindtest-view.tsx @@ -295,23 +295,22 @@ function VoteForm({ )} {needsWho && (
- {snapshot.players.map((p) => ( - - ))} + {snapshot.players + .filter((p) => p.id !== playerId) + .map((p) => ( + + ))}
)} + + + ))} + + + )} +
{isHost ? ( ) })} @@ -154,7 +163,9 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { {isHost && showQuiz && (
- Questions de quiz + + Questions de quiz +
{QUESTION_OPTIONS.map((n) => (
- Titres par joueur + + Titres par joueur +
{TRACKS_OPTIONS.map((n) => ( ) : (

diff --git a/apps/web/src/pages/home.tsx b/apps/web/src/pages/home.tsx index 6aebc56..c124839 100644 --- a/apps/web/src/pages/home.tsx +++ b/apps/web/src/pages/home.tsx @@ -1,11 +1,23 @@ import { useState } from "react" import { useLocation } from "wouter" +import { motion } from "framer-motion" +import { LogIn, Sparkles } from "lucide-react" import { Button } from "@workspace/ui/components/button" import { useRoomStore } from "@/store/room" const inputClass = "border-input bg-background ring-offset-background focus-visible:ring-ring h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50" +// Emojis geek qui flottent en fond. +const FLOATERS = [ + { emoji: "🎮", x: "8%", y: "18%", delay: 0 }, + { emoji: "🎧", x: "82%", y: "22%", delay: 0.6 }, + { emoji: "🧠", x: "15%", y: "72%", delay: 1.2 }, + { emoji: "👾", x: "78%", y: "70%", delay: 0.3 }, + { emoji: "🕹️", x: "45%", y: "12%", delay: 0.9 }, + { emoji: "🎬", x: "60%", y: "82%", delay: 1.5 }, +] + export function HomePage() { const [, navigate] = useLocation() const createRoom = useRoomStore((s) => s.createRoom) @@ -43,20 +55,63 @@ export function HomePage() { } return ( -

-
+
+ {/* Halo animé */} + + {/* Emojis flottants */} + {FLOATERS.map((f) => ( + + {f.emoji} + + ))} + +
-

+ NerdWare -

-

- Party game culture geek + +

+ Party game culture geek

- + + +
@@ -66,8 +121,8 @@ export function HomePage() {
setCode(e.target.value)} @@ -78,7 +133,7 @@ export function HomePage() { disabled={!connected || busy || code.trim().length === 0} onClick={handleJoin} > - Rejoindre + Rejoindre
@@ -90,7 +145,7 @@ export function HomePage() { {error && (

{error}

)} -
+
) } diff --git a/apps/web/src/store/room.ts b/apps/web/src/store/room.ts index 94f018c..b134866 100644 --- a/apps/web/src/store/room.ts +++ b/apps/web/src/store/room.ts @@ -2,6 +2,7 @@ import { create } from "zustand" import { DEFAULT_ROOM_SETTINGS, type BlindtestAnswer, + type BlindtestTrackInfo, type ErrorPayload, type MediaControlPayload, type MediaSyncPayload, @@ -49,6 +50,7 @@ interface RoomState { myChoiceIndex: number | null hasVoted: boolean finalScores: PlayerScore[] | null + gameTracks: BlindtestTrackInfo[] | null mediaSync: MediaSyncPayload | null boomKey: number roundKey: number @@ -85,6 +87,7 @@ export const useRoomStore = create((set, get) => ({ myChoiceIndex: null, hasVoted: false, finalScores: null, + gameTracks: null, mediaSync: null, boomKey: 0, roundKey: 0, @@ -213,6 +216,7 @@ export const useRoomStore = create((set, get) => ({ myChoiceIndex: null, hasVoted: false, finalScores: null, + gameTracks: null, mediaSync: null, boomKey: 0, roundKey: 0, @@ -234,6 +238,7 @@ socket.on("room:state", (snapshot) => myChoiceIndex: null, hasVoted: false, finalScores: null, + gameTracks: null, mediaSync: null, } : { snapshot } @@ -265,6 +270,6 @@ socket.on("round:voteAck", (voteProgress) => ) socket.on("round:reveal", (reveal) => useRoomStore.setState({ reveal })) socket.on("media:sync", (mediaSync) => useRoomStore.setState({ mediaSync })) -socket.on("game:end", ({ finalScores }) => - useRoomStore.setState({ finalScores }) +socket.on("game:end", ({ finalScores, tracks }) => + useRoomStore.setState({ finalScores, gameTracks: tracks ?? null }) ) diff --git a/packages/shared/src/blindtest.ts b/packages/shared/src/blindtest.ts index 9c37acf..5382d15 100644 --- a/packages/shared/src/blindtest.ts +++ b/packages/shared/src/blindtest.ts @@ -26,3 +26,12 @@ export interface BlindtestPlayerResult { /** round:reveal → perPlayerResult : playerId → résultat. */ export type BlindtestPerPlayerResult = Record + +/** Titre récapitulé en fin de partie (export / récupération des liens). */ +export interface BlindtestTrackInfo { + title: string + artist: string + youtubeId: string + url: string + submittedByName: string +} diff --git a/packages/shared/src/events.ts b/packages/shared/src/events.ts index 747f6ff..2c1f023 100644 --- a/packages/shared/src/events.ts +++ b/packages/shared/src/events.ts @@ -10,6 +10,7 @@ import type { RoundConfig, RoundType, } from "./domain" +import type { BlindtestTrackInfo } from "./blindtest" // --- Payloads ------------------------------------------------------------ @@ -113,6 +114,8 @@ export interface ScoreUpdatePayload { export interface GameEndPayload { finalScores: PlayerScore[] + /** Titres joués (si la partie comportait du blindtest) — pour récupérer les liens. */ + tracks?: BlindtestTrackInfo[] spotifyExport?: unknown } -- 2.45.3 From ff84dc4f6b3a12d334608d643ef7d538af297aae Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Wed, 10 Jun 2026 16:19:31 +0200 Subject: [PATCH 23/51] feat(lobby): block start until everyone submitted; tracks count stepper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - blindtest/mixed: the host can't start until every connected player has submitted their track quota — client disables the button with a hint, and game:start rejects with TRACKS_PENDING (authoritative) - tracks-per-player is now a number stepper (− / + buttons + free typing, clamped 1..10) instead of fixed 1/2/3 buttons Verified e2e: start rejected while a player hasn't submitted, accepted once all did. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/src/handlers.ts | 19 ++++++ apps/web/src/components/lobby-view.tsx | 88 +++++++++++++++++++++----- 2 files changed, 90 insertions(+), 17 deletions(-) diff --git a/apps/server/src/handlers.ts b/apps/server/src/handlers.ts index 2c5bf82..4b15c83 100644 --- a/apps/server/src/handlers.ts +++ b/apps/server/src/handlers.ts @@ -151,6 +151,25 @@ export function registerRoomHandlers( }) return } + if (hasBlindtest) { + const tpp = room.settings.tracksPerPlayer + const pending = [...room.players.values()].some( + (p) => + p.connected && + p.name !== "" && + room.blindtestTracks.filter((t) => t.submittedBy === p.id).length < tpp + ) + if (pending) { + ack({ + ok: false, + error: { + code: "TRACKS_PENDING", + message: "Tous les joueurs n'ont pas encore soumis leurs titres.", + }, + }) + return + } + } ack({ ok: true, data: null }) // Précharge le contenu (pool quiz depuis la DB), puis lance la boucle. // Fire-and-forget : la boucle de jeu broadcast son propre état. diff --git a/apps/web/src/components/lobby-view.tsx b/apps/web/src/components/lobby-view.tsx index cd449b3..a34afb0 100644 --- a/apps/web/src/components/lobby-view.tsx +++ b/apps/web/src/components/lobby-view.tsx @@ -3,8 +3,10 @@ import { Brain, Headphones, ListMusic, + Minus, Music, Play, + Plus, Shuffle, Trash2, type LucideIcon, @@ -20,7 +22,7 @@ import { useRoomStore } from "@/store/room" import { Avatar } from "@/components/avatar" const QUESTION_OPTIONS = [3, 5, 10] -const TRACKS_OPTIONS = [1, 2, 3] +const MAX_TRACKS = 10 const GAME_TYPES: { value: GameType; label: string; Icon: LucideIcon }[] = [ { value: "mixed", label: "Mixte", Icon: Shuffle }, { value: "quiz", label: "Quiz", Icon: Brain }, @@ -35,6 +37,53 @@ const MODE_LABELS: Record = { 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" +function Stepper({ + value, + min = 1, + max = MAX_TRACKS, + onChange, +}: { + value: number + min?: number + max?: number + onChange: (v: number) => void +}) { + const clamp = (v: number) => Math.max(min, Math.min(max, v)) + return ( +
+ + { + const n = parseInt(e.target.value, 10) + if (!Number.isNaN(n)) { + onChange(clamp(n)) + } + }} + className="border-input bg-background h-8 w-14 rounded-md border text-center text-sm [appearance:textfield] focus-visible:outline-none [&::-webkit-inner-spin-button]:appearance-none" + /> + +
+ ) +} + function shuffle(items: T[]): T[] { const arr = [...items] for (let i = arr.length - 1; i > 0; i--) { @@ -90,7 +139,12 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { effectiveType === "blindtest" || effectiveType === "mixed" const rounds = buildRounds(effectiveType, showQuiz ? count : 0, totalTracks) - const canStart = rounds.length > 0 + // Blindtest : tout le monde doit avoir soumis son quota de titres. + const allSubmitted = + !showBlindtest || + (snapshot.submissions.length > 0 && + snapshot.submissions.every((s) => s.count >= tracksPerPlayer)) + const canStart = rounds.length > 0 && allSubmitted async function start() { setError(null) @@ -205,18 +259,10 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { Titres par joueur -
- {TRACKS_OPTIONS.map((n) => ( - - ))} -
+ updateSettings({ tracksPerPlayer: v })} + />
)} @@ -226,9 +272,17 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { )} {isHost ? ( - +
+ + {showBlindtest && !allSubmitted && ( +

+ En attente que tout le monde ait soumis ses {tracksPerPlayer}{" "} + titre(s). +

+ )} +
) : (

En attente du lancement par l'hôte… -- 2.45.3 From d3ce52b60a93cd36979c85b4cadd0cb53394c988 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Wed, 10 Jun 2026 16:43:19 +0200 Subject: [PATCH 24/51] feat(admin): manual quiz back-office + rewrite README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Back-office (roadmap V1 step 7): - server: token-protected HTTP API under /api/admin (ADMIN_TOKEN), Drizzle-backed CRUD for manual questions — list, create (mcq/truefalse/free, category upsert, dedup on prompt, validation), delete; 401/503 when unauthorized/unconfigured - client: /admin page with token login + TanStack Query (list/create/delete), format-aware form (QCM / vrai-faux / réponse libre) - env: ADMIN_TOKEN README: replace the shadcn template with a real NerdWare overview (stack, structure, getting started, scripts, game modes, back-office). Verified against real Postgres: auth (401), create (201), dedup (409), validation (400), list, delete (200/404). Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 95 +++++++++- apps/server/.env.example | 2 + apps/server/src/admin/routes.ts | 151 +++++++++++++++ apps/server/src/env.ts | 2 + apps/server/src/index.ts | 3 + apps/web/package.json | 1 + apps/web/src/App.tsx | 2 + apps/web/src/lib/admin-api.ts | 65 +++++++ apps/web/src/main.tsx | 11 +- apps/web/src/pages/admin.tsx | 324 ++++++++++++++++++++++++++++++++ bun.lock | 5 + 11 files changed, 648 insertions(+), 13 deletions(-) create mode 100644 apps/server/src/admin/routes.ts create mode 100644 apps/web/src/lib/admin-api.ts create mode 100644 apps/web/src/pages/admin.tsx diff --git a/README.md b/README.md index f635370..cf72be1 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,96 @@ -# shadcn/ui monorepo template +# NerdWare -This is a Vite monorepo template with shadcn/ui. +Party game web multi-épreuves, ambiance WarioWare, centré culture geek (jeux +vidéo, manga, pop culture). Chaque joueur joue sur son propre PC/téléphone, les +rooms sont synchronisées en temps réel via Socket.IO. -## Adding components +**V1 : Quiz culture + Blindtest (YouTube)**, mélangeables dans une même partie. -To add components to your app, run the following command at the root of your `web` app: +> Architecture détaillée : [`ARCHITECTURE.md`](./ARCHITECTURE.md) · consignes de +> dev : [`CLAUDE.md`](./CLAUDE.md). + +## Stack + +| Couche | Choix | +|---|---| +| Langage | TypeScript (strict, front + back) | +| Runtime / package manager | **Bun** | +| Monorepo | Turborepo | +| Backend | Fastify + Socket.IO | +| DB | PostgreSQL + Drizzle ORM (contenu quiz + historique ; rooms en mémoire) | +| Frontend | Vite + React, wouter, Zustand, TanStack Query (back-office) | +| UI | Tailwind v4 + shadcn/ui, Framer Motion, Lucide | +| Audio | YouTube IFrame Player API | +| Avatars | DiceBear (style lorelei), générés en local | + +## Structure + +``` +apps/ + web/ Client Vite + React (jeu + back-office) + server/ Fastify + Socket.IO + Drizzle (moteur de jeu autoritaire) +packages/ + shared/ Types de domaine + events WS partagés (@nerdware/shared) + ui/ Composants shadcn/ui (@workspace/ui) +``` + +Le serveur est **autoritaire** : timer, scores, ordre des manches et tirage du +DJ sont tranchés côté serveur ; le client n'affiche que l'état reçu. Chaque +épreuve implémente l'interface `GameRound` (`start / submitAnswer / reveal / +score`) — ajouter un mode = un module, sans toucher à la plomberie. + +## Démarrer + +Prérequis : [Bun](https://bun.sh) ≥ 1.3, et [Docker](https://www.docker.com) +pour la base (optionnel — sans DB, le quiz tourne sur une banque en dur). ```bash -pnpm dlx shadcn@latest add button -c apps/web +bun install + +# (optionnel) base de données +docker compose up -d +cd apps/server && cp .env.example .env +bun run db:migrate # applique les migrations +bun run db:seed # seed Open Trivia DB (~1 req/5s) + banque FR +cd ../.. + +# config client +cd apps/web && cp .env.example .env && cd ../.. + +# tout lancer (Turborepo) +bun run dev ``` -This will place the ui components in the `packages/ui/src/components` directory. +- Client : http://localhost:5173 +- Serveur : http://localhost:3001 -## Using components +Crée une room, choisis ton pseudo, partage le **lien d'invitation**, et lancez +une partie (quiz, blindtest ou mixte). -To use the components in your app, import them from the `ui` package. +## Scripts (racine) -```tsx -import { Button } from "@workspace/ui/components/button"; +```bash +bun run dev # tout en dev +bun run build # build complet +bun run lint # lint +bun run typecheck # type-check +bun run test # tests (bun test) +bun run format # prettier ``` + +Serveur (`apps/server`) : `db:generate`, `db:migrate`, `db:seed`, `db:studio`. + +## Modes de jeu + +- **Quiz** — QCM, vrai/faux, et réponse libre (matching tolérant). Bonus de + rapidité. Questions issues d'Open Trivia DB (seed) ou du back-office. +- **Blindtest** — chaque joueur soumet des liens YouTube ; un DJ neutre pilote + la lecture, les autres devinent (titre/artiste, qui l'a ajouté, ou mixte). + Nécessite ≥ 3 joueurs. Récap des titres + liens en fin de partie. +- **Mixte** (défaut) — séquence mélangée de manches quiz et blindtest. + +## Back-office quiz + +Page protégée `/admin` pour écrire des questions manuelles (QCM, vrai/faux, +réponse libre). Définir `ADMIN_TOKEN` dans `apps/server/.env` et une `DATABASE_URL` +valide. Le jeton est demandé à la connexion sur `/admin`. diff --git a/apps/server/.env.example b/apps/server/.env.example index e928b54..f7af472 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -4,3 +4,5 @@ PORT=3001 CORS_ORIGINS=http://localhost:5173 # Laisser vide pour jouer sans DB (banque de questions en dur). DATABASE_URL=postgres://nerdware:nerdware@localhost:5432/nerdware +# Jeton du back-office quiz (/admin). Laisser vide pour le désactiver. +ADMIN_TOKEN=change-me diff --git a/apps/server/src/admin/routes.ts b/apps/server/src/admin/routes.ts new file mode 100644 index 0000000..32491d0 --- /dev/null +++ b/apps/server/src/admin/routes.ts @@ -0,0 +1,151 @@ +// Back-office quiz (HTTP, hors temps réel). Protégé par ADMIN_TOKEN. +// CRUD des questions manuelles écrites en base (source 'manual'). + +import type { FastifyInstance } from "fastify" +import { desc, eq } from "drizzle-orm" +import { env } from "../env" +import { db } from "../db" +import { quizCategory, quizQuestion, type NewQuizQuestion } from "../db/schema" + +const FORMATS = ["mcq", "truefalse", "free"] as const +type AdminFormat = (typeof FORMATS)[number] + +interface CreateBody { + format?: string + prompt?: string + category?: string + difficulty?: number + choices?: string[] + correctIndex?: number + acceptedAnswers?: string[] +} + +/** Valide le corps et renvoie soit une erreur, soit la ligne à insérer. */ +function validate( + body: CreateBody, + categoryId: string +): { error: string } | { value: NewQuizQuestion } { + const format = body.format as AdminFormat + if (!FORMATS.includes(format)) { + return { error: "Format invalide." } + } + const prompt = (body.prompt ?? "").trim() + if (prompt.length === 0) { + return { error: "L'intitulé est obligatoire." } + } + const difficulty = Number(body.difficulty) + if (![1, 2, 3].includes(difficulty)) { + return { error: "Difficulté invalide (1 à 3)." } + } + + const base = { categoryId, format, prompt, difficulty, source: "manual" } + + if (format === "free") { + const answers = (body.acceptedAnswers ?? []) + .map((a) => a.trim()) + .filter(Boolean) + if (answers.length === 0) { + return { error: "Au moins une réponse acceptée est requise." } + } + return { value: { ...base, acceptedAnswers: answers } } + } + + // mcq / truefalse + const choices = (body.choices ?? []).map((c) => c.trim()).filter(Boolean) + const min = format === "truefalse" ? 2 : 2 + if (choices.length < min) { + return { error: "Au moins deux choix sont requis." } + } + const correctIndex = Number(body.correctIndex) + if ( + !Number.isInteger(correctIndex) || + correctIndex < 0 || + correctIndex >= choices.length + ) { + return { error: "La bonne réponse est invalide." } + } + return { value: { ...base, choices, correctIndex } } +} + +export async function adminRoutes(app: FastifyInstance) { + // Auth (scopée à ce plugin) : ADMIN_TOKEN configuré + Bearer valide + DB up. + app.addHook("preHandler", async (req, reply) => { + if (!env.adminToken) { + return reply.code(503).send({ error: "Back-office désactivé (ADMIN_TOKEN manquant)." }) + } + const header = req.headers.authorization ?? "" + const token = header.startsWith("Bearer ") ? header.slice(7) : "" + if (token !== env.adminToken) { + return reply.code(401).send({ error: "Non autorisé." }) + } + if (!db) { + return reply.code(503).send({ error: "Base de données indisponible." }) + } + }) + + app.get("/categories", async () => { + const rows = await db! + .select({ id: quizCategory.id, name: quizCategory.name }) + .from(quizCategory) + .orderBy(quizCategory.name) + return rows + }) + + app.get("/questions", async () => { + const rows = await db! + .select({ + id: quizQuestion.id, + format: quizQuestion.format, + prompt: quizQuestion.prompt, + difficulty: quizQuestion.difficulty, + source: quizQuestion.source, + choices: quizQuestion.choices, + correctIndex: quizQuestion.correctIndex, + acceptedAnswers: quizQuestion.acceptedAnswers, + category: quizCategory.name, + }) + .from(quizQuestion) + .leftJoin(quizCategory, eq(quizQuestion.categoryId, quizCategory.id)) + .orderBy(desc(quizQuestion.createdAt)) + .limit(500) + return rows + }) + + app.post("/questions", async (req, reply) => { + const body = (req.body ?? {}) as CreateBody + const categoryName = (body.category ?? "").trim() + if (categoryName.length === 0) { + return reply.code(400).send({ error: "La catégorie est obligatoire." }) + } + await db!.insert(quizCategory).values({ name: categoryName }).onConflictDoNothing() + const [cat] = await db! + .select({ id: quizCategory.id }) + .from(quizCategory) + .where(eq(quizCategory.name, categoryName)) + + const result = validate(body, cat.id) + if ("error" in result) { + return reply.code(400).send({ error: result.error }) + } + const inserted = await db! + .insert(quizQuestion) + .values(result.value) + .onConflictDoNothing() + .returning({ id: quizQuestion.id }) + if (inserted.length === 0) { + return reply.code(409).send({ error: "Une question avec cet intitulé existe déjà." }) + } + return reply.code(201).send({ id: inserted[0].id }) + }) + + app.delete<{ Params: { id: string } }>("/questions/:id", async (req, reply) => { + const deleted = await db! + .delete(quizQuestion) + .where(eq(quizQuestion.id, req.params.id)) + .returning({ id: quizQuestion.id }) + if (deleted.length === 0) { + return reply.code(404).send({ error: "Question introuvable." }) + } + return { ok: true } + }) +} diff --git a/apps/server/src/env.ts b/apps/server/src/env.ts index 169dbfa..e7e1220 100644 --- a/apps/server/src/env.ts +++ b/apps/server/src/env.ts @@ -12,6 +12,8 @@ export const env = { port: num(process.env.PORT, 3001), /** Connexion PostgreSQL. Vide → pas de DB, fallback banque en dur. */ databaseUrl: process.env.DATABASE_URL ?? "", + /** Jeton du back-office quiz. Vide → back-office désactivé. */ + adminToken: process.env.ADMIN_TOKEN ?? "", /** Origines autorisées pour CORS / Socket.IO (séparées par des virgules). */ corsOrigins: (process.env.CORS_ORIGINS ?? "http://localhost:5173") .split(",") diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 5990a3f..35b9fc7 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -2,6 +2,7 @@ import Fastify from "fastify" import cors from "@fastify/cors" import { env, isDev } from "./env" import { createSocketServer } from "./socket" +import { adminRoutes } from "./admin/routes" import "./game/modes" // enregistre les épreuves (registerRound) const app = Fastify({ @@ -14,6 +15,8 @@ await app.register(cors, { origin: env.corsOrigins }) app.get("/health", async () => ({ status: "ok", uptime: process.uptime() })) +await app.register(adminRoutes, { prefix: "/api/admin" }) + // Socket.IO se greffe sur le serveur HTTP sous-jacent de Fastify. const io = createSocketServer(app.server) diff --git a/apps/web/package.json b/apps/web/package.json index ce1306e..7a975fc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,6 +15,7 @@ "@dicebear/core": "^10", "@dicebear/styles": "^10", "@nerdware/shared": "workspace:*", + "@tanstack/react-query": "^5.101.0", "@workspace/ui": "workspace:*", "framer-motion": "^11", "lucide-react": "^1.17.0", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 6a275ee..e3b02f1 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -2,6 +2,7 @@ import { Route, Switch } from "wouter" import { HomePage } from "@/pages/home" import { JoinPage } from "@/pages/join" import { RoomPage } from "@/pages/room" +import { AdminPage } from "@/pages/admin" export function App() { return ( @@ -13,6 +14,7 @@ export function App() { {(params) => } +

Page introuvable.

diff --git a/apps/web/src/lib/admin-api.ts b/apps/web/src/lib/admin-api.ts new file mode 100644 index 0000000..73735b8 --- /dev/null +++ b/apps/web/src/lib/admin-api.ts @@ -0,0 +1,65 @@ +// Accès HTTP au back-office quiz (TanStack Query l'enrobe côté composant). + +import type { QuizFormat } from "@nerdware/shared" + +const SERVER_URL = import.meta.env.VITE_SERVER_URL ?? "http://localhost:3001" +const TOKEN_KEY = "nerdware-admin-token" + +export function getAdminToken(): string { + return localStorage.getItem(TOKEN_KEY) ?? "" +} +export function setAdminToken(token: string): void { + localStorage.setItem(TOKEN_KEY, token) +} +export function clearAdminToken(): void { + localStorage.removeItem(TOKEN_KEY) +} + +export interface AdminQuestion { + id: string + format: QuizFormat + prompt: string + difficulty: number + source: string + choices: string[] | null + correctIndex: number | null + acceptedAnswers: string[] | null + category: string | null +} + +export interface NewQuestionInput { + format: QuizFormat + prompt: string + category: string + difficulty: number + choices?: string[] + correctIndex?: number + acceptedAnswers?: string[] +} + +async function request(path: string, init?: RequestInit): Promise { + const res = await fetch(`${SERVER_URL}/api/admin${path}`, { + ...init, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${getAdminToken()}`, + ...init?.headers, + }, + }) + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string } + throw new Error(body.error ?? `Erreur ${res.status}`) + } + return res.status === 204 ? (undefined as T) : ((await res.json()) as T) +} + +export const adminApi = { + listQuestions: () => request("/questions"), + createQuestion: (input: NewQuestionInput) => + request<{ id: string }>("/questions", { + method: "POST", + body: JSON.stringify(input), + }), + deleteQuestion: (id: string) => + request<{ ok: true }>(`/questions/${id}`, { method: "DELETE" }), +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 242ad82..6d99f3c 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,14 +1,19 @@ import { StrictMode } from "react" import { createRoot } from "react-dom/client" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import "@workspace/ui/globals.css" import { App } from "./App.tsx" import { ThemeProvider } from "@/components/theme-provider.tsx" +const queryClient = new QueryClient() + createRoot(document.getElementById("root")!).render( - - - + + + + + ) diff --git a/apps/web/src/pages/admin.tsx b/apps/web/src/pages/admin.tsx new file mode 100644 index 0000000..91523ea --- /dev/null +++ b/apps/web/src/pages/admin.tsx @@ -0,0 +1,324 @@ +import { useState } from "react" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { Link } from "wouter" +import { Trash2 } from "lucide-react" +import type { QuizFormat } from "@nerdware/shared" +import { Button } from "@workspace/ui/components/button" +import { + adminApi, + clearAdminToken, + getAdminToken, + setAdminToken, + type AdminQuestion, + type NewQuestionInput, +} from "@/lib/admin-api" + +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" + +export function AdminPage() { + const [token, setToken] = useState(getAdminToken()) + + if (!token) { + return + } + return ( + { + clearAdminToken() + setToken("") + }} + /> + ) +} + +function Login({ onAuth }: { onAuth: (token: string) => void }) { + const [value, setValue] = useState("") + return ( +
+
+

+ Back-office quiz +

+ setValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && value.trim()) { + setAdminToken(value.trim()) + onAuth(value.trim()) + } + }} + /> + + + + +
+
+ ) +} + +function AdminPanel({ onLogout }: { onLogout: () => void }) { + const qc = useQueryClient() + const questions = useQuery({ + queryKey: ["admin-questions"], + queryFn: adminApi.listQuestions, + retry: false, + }) + + const remove = useMutation({ + mutationFn: adminApi.deleteQuestion, + onSuccess: () => qc.invalidateQueries({ queryKey: ["admin-questions"] }), + }) + + const unauthorized = + questions.isError && /401|autoris/i.test(String(questions.error)) + + return ( +
+
+

Back-office quiz

+ +
+ + {unauthorized ? ( +

+ Jeton invalide ou back-office indisponible.{" "} + +

+ ) : ( + <> + + qc.invalidateQueries({ queryKey: ["admin-questions"] }) + } + /> + +
+

+ Questions ({questions.data?.length ?? 0}) +

+ {questions.isLoading && ( +

Chargement…

+ )} +
    + {questions.data?.map((q) => ( + remove.mutate(q.id)} + deleting={remove.isPending} + /> + ))} +
+
+ + )} +
+ ) +} + +function QuestionRow({ + q, + onDelete, + deleting, +}: { + q: AdminQuestion + onDelete: () => void + deleting: boolean +}) { + return ( +
  • +
    +

    {q.prompt}

    +

    + {q.format} · {q.category ?? "—"} · diff. {q.difficulty} · {q.source} +

    + {q.format === "free" ? ( +

    + ✔ {q.acceptedAnswers?.join(", ")} +

    + ) : ( +

    + {q.choices + ?.map((c, i) => (i === q.correctIndex ? `✔ ${c}` : c)) + .join(" · ")} +

    + )} +
    + +
  • + ) +} + +const EMPTY_CHOICES = ["", "", "", ""] + +function QuestionForm({ onCreated }: { onCreated: () => void }) { + const [format, setFormat] = useState("mcq") + const [prompt, setPrompt] = useState("") + const [category, setCategory] = useState("") + const [difficulty, setDifficulty] = useState(1) + const [choices, setChoices] = useState(EMPTY_CHOICES) + const [correctIndex, setCorrectIndex] = useState(0) + const [answers, setAnswers] = useState("") + + const create = useMutation({ + mutationFn: (input: NewQuestionInput) => adminApi.createQuestion(input), + onSuccess: () => { + setPrompt("") + setChoices(EMPTY_CHOICES) + setCorrectIndex(0) + setAnswers("") + onCreated() + }, + }) + + function submit() { + const input: NewQuestionInput = { format, prompt, category, difficulty } + if (format === "free") { + input.acceptedAnswers = answers + .split("\n") + .map((a) => a.trim()) + .filter(Boolean) + } else if (format === "truefalse") { + input.choices = ["Vrai", "Faux"] + input.correctIndex = correctIndex + } else { + input.choices = choices.map((c) => c.trim()).filter(Boolean) + input.correctIndex = correctIndex + } + create.mutate(input) + } + + return ( +
    +

    Nouvelle question

    + +
    + + +
    + + setCategory(e.target.value)} + /> + setPrompt(e.target.value)} + /> + + {format === "mcq" && ( +
    + {choices.map((choice, i) => ( + + ))} +

    + Coche la bonne réponse. Les choix vides sont ignorés (min. 2). +

    +
    + )} + + {format === "truefalse" && ( +
    + {["Vrai", "Faux"].map((label, i) => ( + + ))} +
    + )} + + {format === "free" && ( +