diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b71b62e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +**/node_modules +**/dist +**/.turbo +**/uploads +.git +.github +**/*.log +**/.env +**/.env.* +!**/.env.example +**/.DS_Store diff --git a/.gitignore b/.gitignore index 0877ace..1fbeef7 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 @@ -31,3 +32,6 @@ dist-ssr # typescript *.tsbuildinfo + +# uploads (image_reveal) servies en statique +uploads/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6286014 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,34 @@ +# Changelog + +Toutes les évolutions notables de NerdWare. Format basé sur +[Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), versionné en +[SemVer](https://semver.org/lang/fr/). + +## [0.1.0] — 2026-06-11 + +Première version déployable. Cycle de jeu temps réel complet (lobby → manches → +fin), trois modes, back-office et internationalisation. + +### Ajouté + +- **Temps réel** : rooms en mémoire, synchronisation Socket.IO, serveur + autoritaire (timer, scores, ordre des manches), moteur de jeu générique. +- **Modes de jeu** : Quiz (QCM / vrai-faux / réponse libre), Images (révélation + progressive floutée), Blindtest (YouTube, DJ neutre, modes titre & artiste / + qui l'a ajouté / mixte), et mode Mixte avec ordre mélangé. +- **Réglages** : compteur par sous-mode, filtre de catégories, durée, titres par + joueur, difficulté des bots. +- **Bots** : joueurs virtuels qui votent automatiquement (jamais DJ). +- **Fin de partie** : podium animé, récompenses, récapitulatif des manches, + export playlist (Spotify / YouTube), copie des résultats. +- **Reconnexion** : reprise de sa place après un refresh ou une coupure réseau ; + bouton « Quitter ». +- **Back-office** quiz (token) : CRUD des questions, upload d'images, langue, + activation/désactivation. +- **Historique** des parties par room. +- **i18n** FR / EN de l'interface (détection navigateur + sélecteur). +- **Persistance** : PostgreSQL + Drizzle (contenu quiz + historique). +- **Déploiement** : Dockerfiles (serveur Bun, client nginx) + `docker-compose.prod.yml` + prêt pour Dokploy. + +[0.1.0]: https://git.ayoubbenziza.dev/ayoub/nerdware/src/tag/v0.1.0 diff --git a/README.md b/README.md index f635370..898fb46 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,139 @@ -# 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 +**Quiz culture · Images (révélation) · Blindtest (YouTube)**, mélangeables dans +une même partie. Interface FR / EN, reconnexion après refresh, bots pour jouer à +plusieurs tout seul. -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 +bun run db:seed:images # (optionnel) seed images via PokéAPI (mode Images) +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. +- **Images** — une image se dé-floute progressivement, on devine le sujet + (réponse libre). Seed PokéAPI ou images uploadées dans le 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 **dont 2 réels** (un bot ne peut pas être DJ). Récap des + titres + liens en fin de partie. +- **Mixte** (défaut) — séquence mélangée de manches, par sous-modes au choix. + +Filtre par catégories, durée et nombre de manches réglables, et **bots** (niveau +réglable) pour compléter une partie. + +## Back-office quiz + +Page protégée `/admin` pour écrire des questions manuelles (QCM, vrai/faux, +réponse libre, image), choisir leur langue et les activer/désactiver. Définir +`ADMIN_TOKEN` dans `apps/server/.env` et une `DATABASE_URL` valide. Le jeton est +demandé à la connexion sur `/admin`. + +## Déploiement (Dokploy) + +La stack de prod est décrite dans +[`docker-compose.prod.yml`](./docker-compose.prod.yml) : `postgres`, `server` +(Bun) et `web` (build statique servi par nginx). Voir +[`.env.production.example`](./.env.production.example) pour les variables. + +1. **Dokploy → Create → Compose**, pointant sur ce repo et `docker-compose.prod.yml`. +2. Renseigner les variables d'environnement : `POSTGRES_PASSWORD`, `ADMIN_TOKEN`, + `CORS_ORIGINS` (domaine du client), `VITE_SERVER_URL` (URL publique du serveur). +3. Assigner les **domaines** via Traefik : le service `web` (port 80) sur le + domaine principal, le service `server` (port 3001) sur le sous-domaine d'API. + `VITE_SERVER_URL` doit pointer vers ce domaine d'API, et `CORS_ORIGINS` vers + celui du client. +4. Déployer. Les **migrations** s'appliquent automatiquement au démarrage du + serveur. Pour peupler le contenu (one-shot) : + `docker compose -f docker-compose.prod.yml exec server bun run db:seed`. + +Les images uploadées sont persistées dans le volume `uploads`, la base dans +`pgdata`. + +> Test local de la stack de prod : +> `docker compose -f docker-compose.prod.yml up --build` (après avoir copié +> `.env.production.example` en `.env`). + +## Versionnement & gitflow + +- Branches : `main` (production, taguée), `dev` (intégration), `feature/*`, + `release/*`. +- Une **release** se coupe depuis `dev` (`release/x.y.z`), y reçoit le bump de + version + le CHANGELOG, puis fusionne dans `main` (taguée `vX.Y.Z`) et revient + dans `dev`. Voir [`CHANGELOG.md`](./CHANGELOG.md). Versionnage [SemVer](https://semver.org). diff --git a/apps/server/.env.example b/apps/server/.env.example new file mode 100644 index 0000000..7dc6e6f --- /dev/null +++ b/apps/server/.env.example @@ -0,0 +1,10 @@ +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 +# Jeton du back-office quiz (/admin). Laisser vide pour le désactiver. +ADMIN_TOKEN=change-me +# Dossier de stockage des images (image_reveal), servi sur /uploads. +UPLOADS_DIR=./uploads diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile new file mode 100644 index 0000000..0fdac43 --- /dev/null +++ b/apps/server/Dockerfile @@ -0,0 +1,23 @@ +# Serveur Fastify + Socket.IO (Bun, monorepo). Contexte de build = racine du repo. +FROM oven/bun:1.3.14-alpine + +WORKDIR /app + +# Manifests d'abord (cache des dépendances). +COPY package.json bun.lock turbo.json ./ +COPY apps/server/package.json apps/server/package.json +COPY apps/web/package.json apps/web/package.json +COPY packages/shared/package.json packages/shared/package.json +COPY packages/ui/package.json packages/ui/package.json +RUN bun install --frozen-lockfile + +# Sources nécessaires au serveur (le client n'est pas requis ici). +COPY packages/shared packages/shared +COPY apps/server apps/server + +WORKDIR /app/apps/server +ENV NODE_ENV=production +EXPOSE 3001 + +# Applique les migrations (idempotent) puis démarre le serveur. +CMD ["sh", "-c", "bun src/db/migrate.ts && bun src/index.ts"] 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/0001_fancy_captain_stacy.sql b/apps/server/drizzle/0001_fancy_captain_stacy.sql new file mode 100644 index 0000000..63bef25 --- /dev/null +++ b/apps/server/drizzle/0001_fancy_captain_stacy.sql @@ -0,0 +1,3 @@ +ALTER TABLE "quiz_question" DROP CONSTRAINT "quiz_question_prompt_unique";--> statement-breakpoint +CREATE UNIQUE INDEX "quiz_question_prompt_unique" ON "quiz_question" USING btree ("prompt") WHERE "quiz_question"."format" <> 'image_reveal';--> statement-breakpoint +CREATE UNIQUE INDEX "quiz_question_image_unique" ON "quiz_question" USING btree ("image_url") WHERE "quiz_question"."image_url" is not null; \ No newline at end of file diff --git a/apps/server/drizzle/0002_lowly_tenebrous.sql b/apps/server/drizzle/0002_lowly_tenebrous.sql new file mode 100644 index 0000000..0a04f99 --- /dev/null +++ b/apps/server/drizzle/0002_lowly_tenebrous.sql @@ -0,0 +1,2 @@ +ALTER TABLE "quiz_question" ADD COLUMN "lang" text DEFAULT 'fr' NOT NULL;--> statement-breakpoint +ALTER TABLE "quiz_question" ADD COLUMN "active" boolean DEFAULT true NOT NULL; \ 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/0001_snapshot.json b/apps/server/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..a125720 --- /dev/null +++ b/apps/server/drizzle/meta/0001_snapshot.json @@ -0,0 +1,288 @@ +{ + "id": "6de8c2ee-3634-455b-ba4b-372368fc2e66", + "prevId": "c68e2f59-3d1b-4fca-8443-17fe243e2919", + "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": { + "quiz_question_prompt_unique": { + "name": "quiz_question_prompt_unique", + "columns": [ + { + "expression": "prompt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"quiz_question\".\"format\" <> 'image_reveal'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "quiz_question_image_unique": { + "name": "quiz_question_image_unique", + "columns": [ + { + "expression": "image_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"quiz_question\".\"image_url\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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": {}, + "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/0002_snapshot.json b/apps/server/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..01d2ac0 --- /dev/null +++ b/apps/server/drizzle/meta/0002_snapshot.json @@ -0,0 +1,302 @@ +{ + "id": "7df3d3d1-dbf0-445e-a5c1-5ef4872a80de", + "prevId": "6de8c2ee-3634-455b-ba4b-372368fc2e66", + "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 + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'fr'" + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": { + "quiz_question_prompt_unique": { + "name": "quiz_question_prompt_unique", + "columns": [ + { + "expression": "prompt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"quiz_question\".\"format\" <> 'image_reveal'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "quiz_question_image_unique": { + "name": "quiz_question_image_unique", + "columns": [ + { + "expression": "image_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"quiz_question\".\"image_url\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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": {}, + "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..7f8f078 --- /dev/null +++ b/apps/server/drizzle/meta/_journal.json @@ -0,0 +1,27 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1781085893179, + "tag": "0000_loose_doctor_spectrum", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1781129455626, + "tag": "0001_fancy_captain_stacy", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1781185531830, + "tag": "0002_lowly_tenebrous", + "breakpoints": true + } + ] +} \ No newline at end of file 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..a50afb6 --- /dev/null +++ b/apps/server/package.json @@ -0,0 +1,41 @@ +{ + "name": "@nerdware/server", + "version": "0.1.0", + "type": "module", + "private": true, + "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\"", + "typecheck": "tsc --noEmit", + "db:generate": "drizzle-kit generate", + "db:migrate": "bun src/db/migrate.ts", + "db:seed": "bun src/db/seed.ts", + "db:seed:images": "bun src/db/seed-images.ts", + "db:studio": "drizzle-kit studio" + }, + "dependencies": { + "@fastify/cors": "^11", + "@fastify/multipart": "^10.0.0", + "@fastify/static": "^9.1.3", + "@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", + "typescript": "~6", + "typescript-eslint": "^8" + } +} diff --git a/apps/server/src/admin/routes.ts b/apps/server/src/admin/routes.ts new file mode 100644 index 0000000..8b69f33 --- /dev/null +++ b/apps/server/src/admin/routes.ts @@ -0,0 +1,216 @@ +// Back-office quiz (HTTP, hors temps réel). Protégé par ADMIN_TOKEN. +// CRUD des questions manuelles écrites en base (source 'manual'). + +import { randomUUID } from "node:crypto" +import { mkdir, writeFile } from "node:fs/promises" +import { extname, resolve } from "node:path" +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", "image_reveal"] as const +type AdminFormat = (typeof FORMATS)[number] +const IMAGE_EXT = [".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif"] + +interface CreateBody { + format?: string + prompt?: string + category?: string + difficulty?: number + lang?: string + choices?: string[] + correctIndex?: number + acceptedAnswers?: string[] + imageUrl?: 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." } + } + // L'intitulé est optionnel pour image_reveal (défaut générique). + const prompt = + (body.prompt ?? "").trim() || + (format === "image_reveal" ? "Qui est-ce ?" : "") + 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", + lang: body.lang === "en" ? "en" : "fr", + } + + if (format === "free" || format === "image_reveal") { + 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." } + } + if (format === "image_reveal" && !body.imageUrl) { + return { error: "Une image est requise." } + } + return { + value: { + ...base, + acceptedAnswers: answers, + imageUrl: format === "image_reveal" ? body.imageUrl : undefined, + }, + } + } + + // 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.post("/upload", async (req, reply) => { + const file = await req.file() + if (!file) { + return reply.code(400).send({ error: "Aucun fichier reçu." }) + } + if (!file.mimetype.startsWith("image/")) { + return reply.code(400).send({ error: "Le fichier doit être une image." }) + } + const ext = extname(file.filename ?? "").toLowerCase() + const safeExt = IMAGE_EXT.includes(ext) ? ext : ".jpg" + const name = `${randomUUID()}${safeExt}` + const buffer = await file.toBuffer() + // Crée le dossier si besoin (résilient s'il a été supprimé après le démarrage). + await mkdir(resolve(env.uploadsDir), { recursive: true }) + await writeFile(resolve(env.uploadsDir, name), buffer) + return { url: `/uploads/${name}` } + }) + + 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, + imageUrl: quizQuestion.imageUrl, + lang: quizQuestion.lang, + active: quizQuestion.active, + 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.patch<{ Params: { id: string }; Body: { active?: boolean } }>( + "/questions/:id", + async (req, reply) => { + const active = req.body?.active + if (typeof active !== "boolean") { + return reply.code(400).send({ error: "Champ 'active' requis." }) + } + const updated = await db! + .update(quizQuestion) + .set({ active }) + .where(eq(quizQuestion.id, req.params.id)) + .returning({ id: quizQuestion.id }) + if (updated.length === 0) { + return reply.code(404).send({ error: "Question introuvable." }) + } + return { ok: true } + } + ) + + 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/db/history-repo.ts b/apps/server/src/db/history-repo.ts new file mode 100644 index 0000000..5495e42 --- /dev/null +++ b/apps/server/src/db/history-repo.ts @@ -0,0 +1,55 @@ +// Historique de parties (table game_history). No-op si pas de DB. + +import { desc, eq } from "drizzle-orm" +import { db } from "./index" +import { gameHistory } from "./schema" + +export interface GameResultEntry { + name: string + score: number +} + +export async function saveGameHistory( + roomCode: string, + modes: string[], + results: GameResultEntry[] +): Promise { + if (!db) { + return + } + await db.insert(gameHistory).values({ roomCode, modes, results }) +} + +export interface GameHistoryRow { + id: string + playedAt: string + modes: string[] + results: GameResultEntry[] +} + +export async function listGameHistory( + roomCode: string, + limit = 10 +): Promise { + if (!db) { + return [] + } + const rows = await db + .select({ + id: gameHistory.id, + playedAt: gameHistory.playedAt, + modes: gameHistory.modes, + results: gameHistory.results, + }) + .from(gameHistory) + .where(eq(gameHistory.roomCode, roomCode)) + .orderBy(desc(gameHistory.playedAt)) + .limit(limit) + return rows.map((r) => ({ + id: r.id, + playedAt: + r.playedAt instanceof Date ? r.playedAt.toISOString() : String(r.playedAt), + modes: (r.modes as string[]) ?? [], + results: (r.results as GameResultEntry[]) ?? [], + })) +} 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..38fc639 --- /dev/null +++ b/apps/server/src/db/quiz-repo.ts @@ -0,0 +1,100 @@ +// Accès aux questions de quiz en base. No-op si pas de DB (db === null). + +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" + +/** + * 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. + */ +/** Catégories existantes (pour le filtre du lobby). */ +export async function listCategories(): Promise { + if (!db) { + return [] + } + const rows = await db + .select({ name: quizCategory.name }) + .from(quizCategory) + .orderBy(quizCategory.name) + return rows.map((r) => r.name) +} + +export async function loadQuizPool( + roomCode: string, + limit: number, + formats: string[], + categories: string[] = [] +): Promise { + if (!db || formats.length === 0) { + 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, + acceptedAnswers: quizQuestion.acceptedAnswers, + imageUrl: quizQuestion.imageUrl, + difficulty: quizQuestion.difficulty, + category: quizCategory.name, + }) + .from(quizQuestion) + .leftJoin(quizCategory, eq(quizQuestion.categoryId, quizCategory.id)) + .where( + and( + eq(quizQuestion.active, true), + inArray(quizQuestion.format, formats), + categories.length > 0 + ? inArray(quizCategory.name, categories) + : undefined, + notInArray(quizQuestion.id, alreadyPlayed) + ) + ) + .orderBy(sql`random()`) + .limit(limit) + + return rows + .filter((r) => { + if (r.format === "free") { + return (r.acceptedAnswers?.length ?? 0) > 0 + } + if (r.format === "image_reveal") { + return !!r.imageUrl && (r.acceptedAnswers?.length ?? 0) > 0 + } + return r.choices && r.correctIndex !== null + }) + .map((r) => ({ + id: r.id, + format: r.format as QuizQuestion["format"], + prompt: r.prompt, + choices: r.choices ?? undefined, + correctIndex: r.correctIndex ?? undefined, + acceptedAnswers: r.acceptedAnswers ?? undefined, + imageUrl: r.imageUrl ?? undefined, + 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..12d07ca --- /dev/null +++ b/apps/server/src/db/schema.ts @@ -0,0 +1,90 @@ +// 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 { + boolean, + integer, + jsonb, + pgTable, + primaryKey, + text, + timestamp, + uniqueIndex, + 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(), + prompt: text("prompt").notNull(), + /** 1 (facile) .. 3 (difficile). */ + difficulty: integer("difficulty").notNull().default(1), + /** 'opentdb' | 'manual' | 'pokeapi' */ + source: text("source").notNull(), + /** Langue de la question ('fr' | 'en'). */ + lang: text("lang").notNull().default("fr"), + /** Désactivée (back-office) : exclue des parties sans être supprimée. */ + active: boolean("active").notNull().default(true), + // 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()`), + }, + // Unique sur l'intitulé SAUF pour les images (même intitulé "Qui est-ce ?" + // pour des images différentes). Les images se dédupliquent par image_url. + (t) => [ + uniqueIndex("quiz_question_prompt_unique") + .on(t.prompt) + .where(sql`${t.format} <> 'image_reveal'`), + uniqueIndex("quiz_question_image_unique") + .on(t.imageUrl) + .where(sql`${t.imageUrl} is not null`), + ] +) + +/** 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-images.ts b/apps/server/src/db/seed-images.ts new file mode 100644 index 0000000..7c642b4 --- /dev/null +++ b/apps/server/src/db/seed-images.ts @@ -0,0 +1,95 @@ +// Seed du mode Images depuis PokéAPI : "devine le Pokémon" à partir de +// l'artwork officiel (URL distante, pas d'upload). Réponses acceptées FR + EN. +// bun run db:seed:images (gen 1, 151 Pokémon) +// IMAGE_SEED_COUNT=50 bun run db:seed:images +// Idempotent : déduplication par image_url (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" + +if (!env.databaseUrl) { + console.error("DATABASE_URL manquant — impossible de seeder.") + process.exit(1) +} + +const COUNT = Number(process.env.IMAGE_SEED_COUNT ?? 151) +const CATEGORY = "Pokémon" +const PROMPT = "Quel est ce Pokémon ?" +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) + +const client = postgres(env.databaseUrl, { max: 1 }) +const db = drizzle(client, { schema: { quizCategory, quizQuestion } }) + +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 +} + +interface Pokemon { + name: string + sprites: { other?: { ["official-artwork"]?: { front_default: string | null } } } +} +interface Species { + names: { name: string; language: { name: string } }[] +} + +async function fetchPokemon(id: number): Promise { + try { + const [pRes, sRes] = await Promise.all([ + fetch(`https://pokeapi.co/api/v2/pokemon/${id}`), + fetch(`https://pokeapi.co/api/v2/pokemon-species/${id}`), + ]) + if (!pRes.ok || !sRes.ok) { + return null + } + const p = (await pRes.json()) as Pokemon + const s = (await sRes.json()) as Species + const art = p.sprites.other?.["official-artwork"]?.front_default + if (!art) { + return null + } + const fr = s.names.find((n) => n.language.name === "fr")?.name + const en = s.names.find((n) => n.language.name === "en")?.name ?? p.name + const answers = [...new Set([fr, en].filter(Boolean) as string[])] + return { + categoryId: "", // rempli plus bas + format: "image_reveal", + prompt: PROMPT, + difficulty: 2, + source: "pokeapi", + lang: "fr", + acceptedAnswers: answers, + imageUrl: art, + } + } catch { + return null + } +} + +console.log(`Seed Images depuis PokéAPI (${COUNT} Pokémon)…`) +const categoryId = await upsertCategory(CATEGORY) +let inserted = 0 +for (let id = 1; id <= COUNT; id++) { + const q = await fetchPokemon(id) + if (q) { + const res = await db + .insert(quizQuestion) + .values({ ...q, categoryId }) + .onConflictDoNothing() + .returning({ id: quizQuestion.id }) + inserted += res.length + } + if (id % 25 === 0) { + console.log(` ${id}/${COUNT}…`) + } + await sleep(40) // courtoisie envers l'API +} +console.log(`Terminé : ${inserted} Pokémon ajoutés (mode Images).`) +await client.end() diff --git a/apps/server/src/db/seed.ts b/apps/server/src/db/seed.ts new file mode 100644 index 0000000..6af9d3c --- /dev/null +++ b/apps/server/src/db/seed.ts @@ -0,0 +1,197 @@ +// 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", + lang: "en", + 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", + lang: "en", + 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", + lang: "fr", + choices: q.choices, + correctIndex: q.correctIndex, + acceptedAnswers: q.acceptedAnswers, + }, + ]) + } + 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 new file mode 100644 index 0000000..f138043 --- /dev/null +++ b/apps/server/src/env.ts @@ -0,0 +1,26 @@ +// 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), + /** 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 ?? "", + /** Dossier de stockage des images (image_reveal), servi en statique sur /uploads. */ + uploadsDir: process.env.UPLOADS_DIR ?? "./uploads", + /** 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/game/bot.ts b/apps/server/src/game/bot.ts new file mode 100644 index 0000000..fc15429 --- /dev/null +++ b/apps/server/src/game/bot.ts @@ -0,0 +1,13 @@ +// Réglage de difficulté des bots : probabilité de donner la bonne réponse. + +import type { BotDifficulty } from "@nerdware/shared" + +const CHANCE: Record = { + easy: 0.3, + normal: 0.55, + hard: 0.85, +} + +export function botCorrectChance(difficulty: BotDifficulty): number { + return CHANCE[difficulty] ?? CHANCE.normal +} diff --git a/apps/server/src/game/engine.test.ts b/apps/server/src/game/engine.test.ts new file mode 100644 index 0000000..dfebac3 --- /dev/null +++ b/apps/server/src/game/engine.test.ts @@ -0,0 +1,137 @@ +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 + }, + recap: () => ({ label: "2+2 ?", answer: "4", answers: [] }), + botAnswer: () => ({ choiceIndex: 0 }), +} + +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, + leadMs: 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 + voted: string[] + } + expect(ack).toEqual({ count: 1, total: 2, voted: [p1] }) + + 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..3e6aaf2 --- /dev/null +++ b/apps/server/src/game/engine.ts @@ -0,0 +1,314 @@ +// 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, + MediaControlPayload, + PlayerScore, + RoundConfig, + RoundRecap, +} from "@nerdware/shared" +import type { IoServer } from "../socket" +import type { RoomManager, ServerRoom } from "../rooms" +import { saveGameHistory } from "../db/history-repo" +import { createRound as defaultCreateRound, type RoundFactory } from "./registry" +import type { GameRound, RoomGameController, RoundContext } from "./round" + +interface RoundRuntime { + round: GameRound + djId: string | null + /** Contributeur (blindtest) : reçoit round:start privé et ne vote pas. */ + secretPlayerId: string | null + votes: Map + startedAt: number + endsAt: number + data: unknown + /** Payload public de la manche (pour la reconnexion). */ + payload: 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 + /** 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 + private timer: ReturnType | null = null + private resolveRoundEnd: (() => void) | null = null + private readonly history: RoundRecap[] = [] + private readonly createRound: (type: RoundConfig["type"]) => GameRound + private readonly revealPauseMs: number + private readonly leadMs: 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.leadMs = options.leadMs ?? DEFAULT_LEAD_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() + // 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, + recap: this.history.length > 0 ? this.history : undefined, + }) + + // Historique (fire-and-forget, DB optionnelle). + const modes = [...new Set(this.room.settings.rounds.map((r) => r.type))] + const results = this.scoreboard() + .map((s) => ({ + name: this.room.players.get(s.playerId)?.name ?? "?", + score: s.score, + })) + .sort((a, b) => b.score - a.score) + void saveGameHistory(this.room.code, modes, results).catch((err) => + console.error("[history] save failed", err) + ) + } + + /** 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, + voted: [...this.runtime.votes.keys()], + }) + // Fin anticipée : tous les éligibles ont voté. + if (total > 0 && this.runtime.votes.size >= total) { + this.endRound() + } + } + + /** 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(), + }) + } + + /** Reconnexion : re-pousse la manche en cours au socket d'un joueur. */ + resync(socketId: string, playerId: string): void { + if (!this.runtime || this.room.status !== "in_round") { + return + } + const r = this.runtime + this.io.to(socketId).emit("round:start", { + type: r.round.type, + djId: r.djId === playerId ? r.djId : undefined, + mine: r.secretPlayerId === playerId ? true : undefined, + startsAt: r.startedAt, + endsAt: r.endsAt, + payload: r.payload, + }) + } + + 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 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 = { + round, + djId: start.djId ?? null, + secretPlayerId: start.secretPlayerId ?? null, + votes: new Map(), + startedAt, + endsAt, + data: start.data, + payload: start.payload, + } + this.room.status = "in_round" + this.broadcastState() + const base = { + type: round.type, + djId: this.runtime.djId ?? undefined, + startsAt: startedAt, + endsAt, + payload: start.payload, + } + // Le contributeur reçoit un round:start privé (mine: true) ; il reste secret + // pour les autres (qui ne reçoivent que `base`). + const secretSocketId = this.runtime.secretPlayerId + ? this.room.players.get(this.runtime.secretPlayerId)?.socketId + : null + if (secretSocketId) { + this.io.to(secretSocketId).emit("round:start", { ...base, mine: true }) + this.io.to(this.room.code).except(secretSocketId).emit("round:start", base) + } else { + this.io.to(this.room.code).emit("round:start", base) + } + + this.scheduleBotVotes() + + // Attend la première condition de fin : timer écoulé OU tous votés. + await new Promise((resolve) => { + this.resolveRoundEnd = resolve + this.timer = setTimeout(() => this.endRound(), endsAt - now) + }) + + const ctx = this.context(this.runtime) + this.room.status = "reveal" + this.broadcastState() + this.io.to(this.room.code).emit("round:reveal", round.reveal(ctx)) + + const deltas = round.score(ctx) + for (const { playerId, delta } of deltas) { + this.room.scores.set(playerId, (this.room.scores.get(playerId) ?? 0) + delta) + } + this.history.push({ + index: this.room.currentRound, + type: round.type, + ...round.recap(ctx), + scorers: deltas.map((d) => ({ playerId: d.playerId, points: d.delta })), + }) + this.room.status = "scores" + this.io.to(this.room.code).emit("score:update", { scores: this.scoreboard() }) + this.broadcastState() + this.runtime = null + } + + /** Programme le vote automatique des bots (jamais DJ ni contributeur). */ + private scheduleBotVotes(): void { + if (!this.runtime) { + return + } + const { djId, secretPlayerId, endsAt, startedAt } = this.runtime + const window = Math.max(0, endsAt - startedAt) + const bots = [...this.room.players.values()].filter( + (p) => p.isBot && p.id !== djId && p.id !== secretPlayerId + ) + for (const bot of bots) { + // Vote après l'ouverture des réponses, réparti dans la première moitié. + const delay = this.leadMs + 700 + Math.random() * Math.min(window * 0.5, 3500) + setTimeout(() => this.botVote(bot.id), delay) + } + } + + /** Exécute le vote d'un bot (no-op si la manche est finie ou s'il a déjà voté). */ + private botVote(botId: string): void { + if (!this.runtime || this.room.status !== "in_round") { + return + } + if (this.runtime.votes.has(botId)) { + return + } + const ctx = this.context(this.runtime) + this.runtime.round.submitAnswer(ctx, botId, this.runtime.round.botAnswer(ctx, botId)) + const total = this.eligibleVoters().length + this.io.to(this.room.code).emit("round:voteAck", { + count: this.runtime.votes.size, + total, + voted: [...this.runtime.votes.keys()], + }) + if (total > 0 && this.runtime.votes.size >= total) { + this.endRound() + } + } + + /** Termine la manche en cours (idempotent : timer et "tous votés" peuvent se croiser). */ + 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 : joueurs connectés (DJ neutre inclus), hors contributeur + * (blindtest) qui n'a rien à saisir. + */ + private eligibleVoters(): string[] { + const secret = this.runtime?.secretPlayerId + return [...this.room.players.values()] + .filter((p) => p.connected && p.name !== "" && p.id !== secret) + .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/match.ts b/apps/server/src/game/match.ts new file mode 100644 index 0000000..235cd99 --- /dev/null +++ b/apps/server/src/game/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/blindtest-round.test.ts b/apps/server/src/game/modes/blindtest/blindtest-round.test.ts new file mode 100644 index 0000000..cdeb1ee --- /dev/null +++ b/apps/server/src/game/modes/blindtest/blindtest-round.test.ts @@ -0,0 +1,128 @@ +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 : le DJ est 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) // Bob (pas Alice, la contributrice) + 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 }) // 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 }) + 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") + 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..53f86c9 --- /dev/null +++ b/apps/server/src/game/modes/blindtest/blindtest-round.ts @@ -0,0 +1,227 @@ +// É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, + BlindtestMode, + BlindtestPerPlayerResult, + BlindtestPlayerResult, + BlindtestRevealTruth, + BlindtestRoundPayload, + ScoreDelta, +} from "@nerdware/shared" +import type { + GameRound, + RoundContext, + RoundRecapInfo, + RoundStart, +} from "../../round" +import type { BlindtestTrack, ServerRoom } from "../../../rooms" +import { fuzzyMatch } from "../../match" +import { botCorrectChance } from "../../bot" +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 humain 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.isBot && 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, + 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) + // 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 + return { + djId, + payload, + secretPlayerId: track?.submittedBy, + data: track ? { track } : null, + } + } + + submitAnswer(ctx: RoundContext, playerId: string, answer: Answer): void { + // 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) + } + + botAnswer(ctx: RoundContext, playerId: string): Answer { + const { track } = ctx.data as BlindtestRoundData + const mode = ctx.room.settings.blindtestMode + const chance = botCorrectChance(ctx.room.settings.botDifficulty) + // Selon le niveau : devine le bon contributeur, sinon un joueur au hasard. + const others = [...ctx.room.players.values()] + .filter((p) => p.id !== playerId) + .map((p) => p.id) + const guessedPlayerId = + Math.random() < chance + ? track.submittedBy + : (others[Math.floor(Math.random() * others.length)] ?? playerId) + if (mode === "who_added") { + return { guessedPlayerId } + } + const right = Math.random() < chance + const title = right ? track.title : "?" + const artist = right ? track.artist : "?" + if (mode === "title_artist") { + return { title, artist } + } + return { title, artist, guessedPlayerId } + } + + /** Résultat par joueur (votants + bonus de tromperie du contributeur). */ + 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 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: this.buildResults(ctx), + } + } + + score(ctx: RoundContext): ScoreDelta[] { + const perPlayer = this.buildResults(ctx) + const deltas: ScoreDelta[] = [] + for (const [playerId, result] of Object.entries(perPlayer)) { + if (result.points > 0) { + deltas.push({ playerId, delta: result.points }) + } + } + return deltas + } + + recap(ctx: RoundContext): RoundRecapInfo { + const { track } = ctx.data as BlindtestRoundData + const mode = ctx.room.settings.blindtestMode + const submitter = ctx.room.players.get(track.submittedBy) + const nameOf = (id?: string) => + (id && ctx.room.players.get(id)?.name) || "?" + + const answers = [] + for (const [playerId, vote] of ctx.votes) { + if (playerId === track.submittedBy) { + continue + } + const a = vote as { + title?: string + artist?: string + guessedPlayerId?: string + } + let text: string + if (mode === "who_added") { + text = nameOf(a.guessedPlayerId) + } else if (mode === "title_artist") { + text = `${a.title ?? ""} / ${a.artist ?? ""}` + } else { + text = `${a.title ?? ""} / ${a.artist ?? ""} → ${nameOf(a.guessedPlayerId)}` + } + answers.push({ + playerId, + answer: text, + correct: voterResult(mode, vote, track).points > 0, + }) + } + + return { + label: "Blindtest", + answer: `${track.title} — ${track.artist}`, + youtubeId: track.youtubeId, + url: track.url, + addedBy: submitter?.name ?? "?", + answers, + } + } +} 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/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 new file mode 100644 index 0000000..29146d8 --- /dev/null +++ b/apps/server/src/game/modes/index.ts @@ -0,0 +1,20 @@ +// 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" +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. */ +export async function prepareRoom(room: ServerRoom): Promise { + 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/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/pool.ts b/apps/server/src/game/modes/quiz/pool.ts new file mode 100644 index 0000000..ccefa1f --- /dev/null +++ b/apps/server/src/game/modes/quiz/pool.ts @@ -0,0 +1,100 @@ +// File d'attente de questions par room. Préchargée au lancement : on respecte +// le sous-pool de chaque manche quiz (quiz "classique" vs images), dans l'ordre +// de la séquence. DB si dispo, sinon banque en dur (pas d'images en dur). + +import type { QuizFormat } from "@nerdware/shared" +import { hasDb } from "../../../db" +import { loadQuizPool } from "../../../db/quiz-repo" +import type { ServerRoom } from "../../../rooms" +import { QUIZ_QUESTIONS, type QuizQuestion } from "./questions" + +interface QueueItem { + question: QuizQuestion + fromDb: boolean +} + +const poolByRoom = new WeakMap() + +const QUIZ_FORMATS: QuizFormat[] = ["mcq", "truefalse", "free"] +const IMAGE_FORMATS: QuizFormat[] = ["image_reveal"] + +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 +} + +/** Charge un sous-pool (DB puis complément banque en dur) pour `need` manches. */ +async function loadGroup( + room: ServerRoom, + need: number, + formats: QuizFormat[], + isImage: boolean, + categories: string[] +): Promise { + if (need <= 0) { + return [] + } + let items: QueueItem[] = [] + if (hasDb) { + try { + const rows = await loadQuizPool(room.code, need, formats, categories) + items = rows.map((question) => ({ question, fromDb: true })) + } catch (err) { + console.error("[quiz] chargement DB échoué, fallback banque en dur", err) + } + } + if (items.length < need && !isImage) { + // Complément depuis la banque en dur (uniquement le quiz, pas d'images en dur). + const bank = shuffle( + QUIZ_QUESTIONS.filter( + (q) => + formats.includes(q.format) && + (categories.length === 0 || categories.includes(q.category)) + ) + ) + for (const question of bank) { + if (items.length >= need) break + items.push({ question, fromDb: false }) + } + } + return items +} + +/** Précharge les questions de la partie. À appeler avant de lancer le moteur. */ +export async function prepareQuizForRoom(room: ServerRoom): Promise { + const quizRounds = room.settings.rounds.filter((r) => r.type === "quiz") + const needImage = quizRounds.filter((r) => r.pool === "image").length + const needQuiz = quizRounds.length - needImage + + const cats = room.settings.categories + const quizGroup = await loadGroup(room, needQuiz, QUIZ_FORMATS, false, cats) + const imageGroup = await loadGroup(room, needImage, IMAGE_FORMATS, true, cats) + + // On assemble la file dans l'ordre des manches (chacune pioche dans son sous-pool). + const queue: QueueItem[] = [] + for (const r of quizRounds) { + const group = r.pool === "image" ? imageGroup : quizGroup + const item = group.shift() + if (item) { + queue.push(item) + } + } + poolByRoom.set(room, queue) +} + +/** Pioche la prochaine question. Fallback aléatoire (banque en dur) si vide. */ +export function takeQuestion(room: ServerRoom): { + question: QuizQuestion + fromDb: boolean +} { + const queue = poolByRoom.get(room) + if (queue && queue.length > 0) { + return queue.shift()! + } + const q = QUIZ_QUESTIONS[Math.floor(Math.random() * QUIZ_QUESTIONS.length)] + return { question: q, fromDb: false } +} 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..4887f25 --- /dev/null +++ b/apps/server/src/game/modes/quiz/questions.ts @@ -0,0 +1,157 @@ +// 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 + /** mcq/truefalse uniquement. */ + choices?: string[] + /** mcq/truefalse uniquement. */ + correctIndex?: number + /** free / image_reveal : réponses acceptées (matching tolérant). */ + acceptedAnswers?: string[] + /** image_reveal : image à dévoiler (chemin relatif au serveur). */ + imageUrl?: string + 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, + }, + // 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 new file mode 100644 index 0000000..399269a --- /dev/null +++ b/apps/server/src/game/modes/quiz/quiz-round.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "bun:test" +import { RoomManager } from "../../../rooms" +import type { RoundContext } from "../../round" +import { QuizRound } from "./quiz-round" +import { QUIZ_QUESTIONS, type QuizQuestion } from "./questions" + +const question = QUIZ_QUESTIONS[0] // "Link" → correctIndex 1 + +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") + const ctx: RoundContext = { + room, + djId: null, + votes: new Map(), + startedAt: 0, + endsAt: 10_000, + data: { question: q, 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) + }) + + 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 new file mode 100644 index 0000000..4b07bd2 --- /dev/null +++ b/apps/server/src/game/modes/quiz/quiz-round.ts @@ -0,0 +1,196 @@ +// Épreuve Quiz : une question = une manche. Pas de DJ, pas d'audio. +// Formats : mcq, truefalse (choix), free (saisie libre, matching tolérant). + +import type { + Answer, + QuizPerPlayerResult, + QuizQuestionPayload, + QuizRevealTruth, + ScoreDelta, +} from "@nerdware/shared" +import { hasDb } from "../../../db" +import { botCorrectChance } from "../../bot" +import { markQuestionPlayed } from "../../../db/quiz-repo" +import { fuzzyMatch } from "../../match" +import type { + GameRound, + RoundContext, + RoundRecapInfo, + RoundStart, +} from "../../round" +import type { ServerRoom } from "../../../rooms" +import type { QuizQuestion } from "./questions" +import { takeQuestion } from "./pool" + +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 +} + +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 +} + +/** Formats à réponse libre (saisie texte + matching tolérant). */ +function isTextFormat(question: QuizQuestion): boolean { + return question.format === "free" || question.format === "image_reveal" +} + +/** Réponse correcte ? Selon le format de la question. */ +function isCorrect(question: QuizQuestion, answer: Answer | undefined): boolean { + if (!answer) { + return false + } + if (isTextFormat(question)) { + 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 { + readonly type = "quiz" as const + + constructor(private readonly now: () => number = Date.now) {} + + start(room: ServerRoom): RoundStart { + 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, + category: question.category, + difficulty: question.difficulty, + } + if (question.format === "mcq" || question.format === "truefalse") { + payload.choices = question.choices + } + if (question.format === "image_reveal") { + payload.imageUrl = question.imageUrl + } + 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 + } + const { question, votedAt } = ctx.data as QuizRoundData + if (isTextFormat(question)) { + 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 }) + } + 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) + perPlayerResult[player.id] = { + choiceIndex: vote ? asChoice(vote) : null, + correct: isCorrect(question, vote), + } + } + const truth: QuizRevealTruth = isTextFormat(question) + ? { answer: question.acceptedAnswers?.[0] ?? "" } + : { correctIndex: question.correctIndex } + return { truth, 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 (!isCorrect(question, vote)) { + 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 + } + + botAnswer(ctx: RoundContext): Answer { + const { question } = ctx.data as QuizRoundData + // Le bot répond juste selon le niveau choisi, sinon une réponse plausible. + const rightish = Math.random() < botCorrectChance(ctx.room.settings.botDifficulty) + if (isTextFormat(question)) { + return { + text: rightish ? (question.acceptedAnswers?.[0] ?? "?") : "euh…", + } + } + const count = question.choices?.length ?? 1 + if (rightish && typeof question.correctIndex === "number") { + return { choiceIndex: question.correctIndex } + } + return { choiceIndex: Math.floor(Math.random() * count) } + } + + recap(ctx: RoundContext): RoundRecapInfo { + const { question } = ctx.data as QuizRoundData + const answer = isTextFormat(question) + ? (question.acceptedAnswers?.[0] ?? "") + : (question.choices?.[question.correctIndex ?? 0] ?? "") + const answers = [] + for (const player of ctx.room.players.values()) { + const vote = ctx.votes.get(player.id) + if (!vote) { + continue + } + const text = isTextFormat(question) + ? (asText(vote) ?? "") + : (question.choices?.[asChoice(vote) ?? -1] ?? "?") + answers.push({ + playerId: player.id, + answer: text, + correct: isCorrect(question, vote), + }) + } + return { + label: question.prompt, + answer, + category: question.category, + imageUrl: + question.format === "image_reveal" ? question.imageUrl : undefined, + answers, + } + } +} 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..ce5e04f --- /dev/null +++ b/apps/server/src/game/round.ts @@ -0,0 +1,81 @@ +// 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, + MediaControlPayload, + RevealPayload, + RoundRecap, + RoundType, + ScoreDelta, +} from "@nerdware/shared" +import type { ServerRoom } from "../rooms" + +/** Partie du récap fournie par l'épreuve (le moteur complète index/type/scorers). */ +export type RoundRecapInfo = Omit + + +/** 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 + /** + * Joueur recevant un round:start privé avec `mine: true` et exclu des votants + * (blindtest : le contributeur du titre). Jamais révélé aux autres. + */ + secretPlayerId?: string + /** 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[] + + /** Réponse automatique d'un bot (vote plausible, parfois juste). */ + botAnswer(ctx: RoundContext, playerId: string): Answer + + /** Résumé de la manche pour le récap de fin de partie (intitulé + réponse + média). */ + recap(ctx: RoundContext): RoundRecapInfo +} + +/** Vue minimale du moteur exposée à la room (évite un cycle d'import). */ +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 + /** Reconnexion : re-pousse la manche en cours au socket d'un joueur. */ + resync(socketId: string, playerId: string): void +} diff --git a/apps/server/src/handlers.ts b/apps/server/src/handlers.ts new file mode 100644 index 0000000..da2fe57 --- /dev/null +++ b/apps/server/src/handlers.ts @@ -0,0 +1,376 @@ +// 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" +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, + 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) => { + // 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 + socket.data.roomCode = room.code + socket.join(room.code) + ack({ ok: true, data: { roomCode: room.code, playerId: player.id, token: player.reconnectToken } }) + 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() : "" + 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, token: player.reconnectToken } }) + broadcastState(room) + } catch (err) { + ack({ ok: false, error: toErrorPayload(err) }) + } + }) + + socket.on("room:rejoin", (payload, ack) => { + const code = + typeof payload?.roomCode === "string" + ? payload.roomCode.trim().toUpperCase() + : "" + const room = rooms.get(code) + const player = room?.players.get(payload?.playerId ?? "") + if (!room || !player || player.reconnectToken !== payload?.token) { + ack({ + ok: false, + error: { code: "REJOIN_FAILED", message: "Reconnexion impossible." }, + }) + return + } + player.connected = true + player.socketId = 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, + token: player.reconnectToken, + }, + }) + io.to(room.code).emit("player:rejoined", { playerId: player.id }) + broadcastState(room) + // Re-pousse la manche en cours au socket reconnecté (si partie en cours). + room.game?.resync(socket.id, player.id) + }) + + 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 + if (!room || room.hostId !== socket.data.playerId) { + return + } + room.settings = { + gameType: payload.gameType, + mixedModes: payload.mixedModes, + blindtestMode: payload.blindtestMode, + roundDuration: payload.roundDuration, + tracksPerPlayer: payload.tracksPerPlayer, + categories: payload.categories, + botDifficulty: payload.botDifficulty, + rounds: payload.rounds, + } + broadcastState(room) + }) + + socket.on("lobby:addBot", () => { + const code = socket.data.roomCode + const room = code ? rooms.get(code) : undefined + if (!room || room.hostId !== socket.data.playerId || room.status !== "lobby") { + return + } + if (rooms.addBot(room)) { + broadcastState(room) + } + }) + + socket.on("lobby:removeBot", () => { + const code = socket.data.roomCode + const room = code ? rooms.get(code) : undefined + if (!room || room.hostId !== socket.data.playerId) { + return + } + rooms.removeBot(room) + broadcastState(room) + }) + + socket.on("game:start", (ack) => { + 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 + } + const hasBlindtest = room.settings.rounds.some((r) => r.type === "blindtest") + const active = [...room.players.values()].filter( + (p) => p.connected && p.name !== "" + ) + const connected = active.length + const humans = active.filter((p) => !p.isBot).length + if (hasBlindtest && (connected < 3 || humans < 2)) { + ack({ + ok: false, + error: { + code: "NEED_THREE", + message: "Le blindtest nécessite au moins 3 joueurs dont 2 réels.", + }, + }) + return + } + if (hasBlindtest) { + const tpp = room.settings.tracksPerPlayer + const pending = [...room.players.values()].some( + (p) => + p.connected && + p.name !== "" && + !p.isBot && + 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. + 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("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 + } + const trackId = randomUUID() + room.blindtestTracks.push({ + id: trackId, + youtubeId, + url: payload.youtubeUrl, + title: meta.title, + artist: meta.artist, + submittedBy: playerId, + }) + ack({ + accepted: true, + trackId, + youtubeId, + title: meta.title, + count: mine.length + 1, + }) + broadcastState(room) + }) + + socket.on("blindtest:removeTrack", (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({ ok: false }) + return + } + const before = room.blindtestTracks.length + room.blindtestTracks = room.blindtestTracks.filter( + (t) => !(t.id === payload.trackId && t.submittedBy === playerId) + ) + const removed = room.blindtestTracks.length < before + ack({ ok: removed }) + if (removed) { + 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 + if (!room || !room.game || !socket.data.playerId) { + return + } + room.game.handleVote(socket.data.playerId, payload.answer) + }) + + socket.on("lobby:return", () => { + const code = socket.data.roomCode + const room = code ? rooms.get(code) : undefined + if (!room || room.hostId !== socket.data.playerId) { + return + } + // Rejouer : retour au lobby, scores remis à zéro, joueurs et titres conservés. + room.status = "lobby" + room.currentRound = -1 + room.game = null + for (const playerId of room.scores.keys()) { + room.scores.set(playerId, 0) + } + broadcastState(room) + }) + + socket.on("room:leave", () => { + const code = socket.data.roomCode + const playerId = socket.data.playerId + if (!code || !playerId) { + return + } + socket.leave(code) + socket.data.roomCode = undefined + socket.data.playerId = undefined + const room = rooms.leave(code, playerId) + if (room) { + io.to(room.code).emit("player:left", { playerId }) + 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/index.ts b/apps/server/src/index.ts new file mode 100644 index 0000000..f2a03ca --- /dev/null +++ b/apps/server/src/index.ts @@ -0,0 +1,59 @@ +import { mkdirSync } from "node:fs" +import { resolve } from "node:path" +import Fastify from "fastify" +import cors from "@fastify/cors" +import multipart from "@fastify/multipart" +import fastifyStatic from "@fastify/static" +import { env, isDev } from "./env" +import { createSocketServer } from "./socket" +import { adminRoutes } from "./admin/routes" +import { listCategories } from "./db/quiz-repo" +import { listGameHistory } from "./db/history-repo" +import "./game/modes" // enregistre les épreuves (registerRound) + +const app = Fastify({ + logger: isDev + ? { transport: { target: "pino-pretty" } } + : true, +}) + +await app.register(cors, { origin: env.corsOrigins }) + +// Images (image_reveal) : dossier servi en statique sur /uploads. +const uploadsRoot = resolve(env.uploadsDir) +mkdirSync(uploadsRoot, { recursive: true }) +await app.register(multipart, { limits: { fileSize: 8 * 1024 * 1024 } }) +await app.register(fastifyStatic, { root: uploadsRoot, prefix: "/uploads/" }) + +app.get("/health", async () => ({ status: "ok", uptime: process.uptime() })) + +// Catégories de quiz disponibles (public, pour le filtre du lobby). +app.get("/api/categories", async () => listCategories()) + +// Historique des parties d'une room (public). +app.get<{ Params: { code: string } }>("/api/history/:code", async (req) => + listGameHistory(req.params.code.toUpperCase()) +) + +await app.register(adminRoutes, { prefix: "/api/admin" }) + +// 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/rooms.ts b/apps/server/src/rooms.ts new file mode 100644 index 0000000..2c8984a --- /dev/null +++ b/apps/server/src/rooms.ts @@ -0,0 +1,245 @@ +// 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" +import type { RoomGameController } from "./game/round" + +/** Joueur côté serveur : porte le socketId, jamais exposé au client. */ +export interface ServerPlayer { + id: string + name: string + connected: boolean + socketId: string | null + /** Secret de reconnexion (refresh/coupure). */ + reconnectToken: string + /** Joueur virtuel (vote automatiquement, jamais DJ). */ + isBot: boolean +} + +const BOT_NAMES = [ + "Bot BMO", + "Bot R2-D2", + "Bot GLaDOS", + "Bot Clank", + "Bot Wall-E", + "Bot K-2SO", + "Bot HAL", + "Bot Bender", +] +const MAX_PLAYERS = 8 + +/** Titre soumis pour le blindtest. `submittedBy` reste secret jusqu'au reveal. */ +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 + status: RoomStatus + hostId: string + players: Map + 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 +} + +/** 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, + blindtestTracks: [], + game: null, + } + 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) + } + + /** Ajoute un bot (joueur virtuel) à la room. Renvoie false si plein. */ + addBot(room: ServerRoom): boolean { + if (room.players.size >= MAX_PLAYERS) { + return false + } + const used = new Set([...room.players.values()].map((p) => p.name)) + const name = + BOT_NAMES.find((n) => !used.has(n)) ?? `Bot ${room.players.size + 1}` + const bot: ServerPlayer = { + id: randomUUID(), + name, + connected: true, + socketId: null, + reconnectToken: "", + isBot: true, + } + room.players.set(bot.id, bot) + room.scores.set(bot.id, 0) + return true + } + + /** Retire un bot (le dernier ajouté). */ + removeBot(room: ServerRoom): void { + const bots = [...room.players.values()].filter((p) => p.isBot) + const last = bots[bots.length - 1] + if (last) { + room.players.delete(last.id) + room.scores.delete(last.id) + } + } + + /** + * Retire un joueur d'une room (départ volontaire). Réassigne l'hôte si besoin, + * supprime la room si plus personne. Renvoie la room (ou null si supprimée). + */ + leave(code: string, playerId: string): ServerRoom | null { + const room = this.rooms.get(code) + if (!room) { + return null + } + room.players.delete(playerId) + room.scores.delete(playerId) + if (room.players.size === 0) { + this.rooms.delete(code) + return null + } + if (room.hostId === playerId) { + const named = [...room.players.values()].find((p) => p.name !== "") + room.hostId = (named ?? [...room.players.values()][0]).id + } + return room + } + + /** + * 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 { + // 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: named.map((p) => ({ + id: p.id, + name: p.name, + connected: p.connected, + bot: p.isBot, + })), + settings: room.settings, + scores: named.map((p) => ({ + playerId: p.id, + score: room.scores.get(p.id) ?? 0, + })), + currentRound: room.currentRound, + totalRounds: room.settings.rounds.length, + submissions: named.map((p) => ({ + playerId: p.id, + count: room.blindtestTracks.filter((t) => t.submittedBy === p.id).length, + })), + } + } + + private makePlayer(name: string, socketId: string): ServerPlayer { + return { + id: randomUUID(), + name, + connected: true, + socketId, + reconnectToken: randomUUID(), + isBot: false, + } + } + + 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 new file mode 100644 index 0000000..e03df05 --- /dev/null +++ b/apps/server/src/socket.ts @@ -0,0 +1,41 @@ +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" +import { registerRoomHandlers } from "./handlers" +import { RoomManager } from "./rooms" + +/** 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. */ +export function createSocketServer(httpServer: HttpServer): IoServer { + const io: IoServer = new Server(httpServer, { + cors: { + origin: env.corsOrigins, + methods: ["GET", "POST"], + }, + }) + + 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})`) + }) + }) + + 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/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/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 0000000..ba9a281 --- /dev/null +++ b/apps/web/Dockerfile @@ -0,0 +1,23 @@ +# Client Vite/React : build statique puis service via nginx. +# Contexte de build = racine du repo. VITE_SERVER_URL est figé au build. +FROM oven/bun:1.3.14-alpine AS build + +WORKDIR /app +COPY package.json bun.lock turbo.json ./ +COPY apps/web/package.json apps/web/package.json +COPY apps/server/package.json apps/server/package.json +COPY packages/shared/package.json packages/shared/package.json +COPY packages/ui/package.json packages/ui/package.json +RUN bun install --frozen-lockfile + +COPY packages packages +COPY apps/web apps/web + +ARG VITE_SERVER_URL +ENV VITE_SERVER_URL=$VITE_SERVER_URL +RUN bun run --filter web build + +FROM nginx:1.27-alpine AS runner +COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/apps/web/dist /usr/share/nginx/html +EXPOSE 80 diff --git a/apps/web/index.html b/apps/web/index.html index de8cc51..4c20e8b 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -1,10 +1,14 @@ - + - vite-monorepo + NerdWare — Party game culture geek +
diff --git a/apps/web/nginx.conf b/apps/web/nginx.conf new file mode 100644 index 0000000..9628322 --- /dev/null +++ b/apps/web/nginx.conf @@ -0,0 +1,17 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # SPA : toutes les routes (/room/:code, /join/:code, /admin) → index.html + location / { + try_files $uri $uri/ /index.html; + } + + # Cache long pour les assets fingerprintés. + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} diff --git a/apps/web/package.json b/apps/web/package.json index c62537d..4df7ac1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "web", - "version": "0.0.1", + "version": "0.1.0", "type": "module", "private": true, "scripts": { @@ -12,14 +12,25 @@ "preview": "vite preview" }, "dependencies": { + "@dicebear/core": "^10", + "@dicebear/styles": "^10", + "@icons-pack/react-simple-icons": "^13", + "@nerdware/shared": "workspace:*", + "@tanstack/react-query": "^5.101.0", "@workspace/ui": "workspace:*", + "canvas-confetti": "^1.9.4", + "framer-motion": "^11", "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", "@tailwindcss/vite": "^4", + "@types/canvas-confetti": "^1.9.0", "@types/node": "^24", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 55a08a9..e3b02f1 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,19 +1,25 @@ -import { Button } from "@workspace/ui/components/button" +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 ( -
-
-
-

Project ready!

-

You may now add components and start building.

-

We've already added the button component for you.

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

Page introuvable.

-
- (Press d to toggle dark mode) -
-
-
+ + ) } diff --git a/apps/web/src/assets/sounds/README.md b/apps/web/src/assets/sounds/README.md new file mode 100644 index 0000000..771c890 --- /dev/null +++ b/apps/web/src/assets/sounds/README.md @@ -0,0 +1,10 @@ +# Sons du jeu + +Dépose ici les effets sonores (détectés automatiquement au build). + +- **`victory.mp3`** → jingle joué sur l'écran de fin de partie. + (formats acceptés : `.mp3`, `.ogg`, `.wav` — nomme le fichier `victory.*`) + +Si aucun fichier n'est présent, le son est simplement ignoré (pas d'erreur). + +> ⚠️ N'utilise que des sons dont tu as les droits (libres / CC0). diff --git a/apps/web/src/assets/sounds/victory.mp3 b/apps/web/src/assets/sounds/victory.mp3 new file mode 100644 index 0000000..a96779f Binary files /dev/null and b/apps/web/src/assets/sounds/victory.mp3 differ diff --git a/apps/web/src/assets/transitions/README.md b/apps/web/src/assets/transitions/README.md new file mode 100644 index 0000000..8240354 --- /dev/null +++ b/apps/web/src/assets/transitions/README.md @@ -0,0 +1,26 @@ +# 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`. + +Rangement par mode (recommandé pour des transitions personnalisées) : + +- `quiz/` → jouées avant une manche de **quiz** +- `image/` → jouées avant une manche du mode **images** +- `blindtest/` → jouées avant une manche de **blindtest** +- racine (`./`) → **communs**, fallback pour tous les 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 +`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/avatar.tsx b/apps/web/src/components/avatar.tsx new file mode 100644 index 0000000..3a876c9 --- /dev/null +++ b/apps/web/src/components/avatar.tsx @@ -0,0 +1,28 @@ +import { Avatar as DicebearAvatar, Style } from "@dicebear/core" +import loreleiDefinition from "@dicebear/styles/lorelei.json" + +// Génération locale (pas d'appel à l'API DiceBear) : self-hosted, offline, +// aucun pseudo envoyé à un tiers. Lorelei par Lisa Wischofsky — licence CC0 1.0. +const style = new Style(loreleiDefinition) +const cache = new Map() + +/** Data URI de l'avatar pour un seed donné, mémoïsé. */ +function avatarUri(seed: string): string { + let uri = cache.get(seed) + if (!uri) { + uri = new DicebearAvatar(style, { seed }).toDataUri() + cache.set(seed, uri) + } + return uri +} + +/** Avatar DiceBear (style lorelei), seedé par le pseudo → stable et reconnaissable. */ +export function Avatar({ seed, className }: { seed: string; className?: string }) { + return ( + {`Avatar + ) +} diff --git a/apps/web/src/components/blindtest-view.tsx b/apps/web/src/components/blindtest-view.tsx new file mode 100644 index 0000000..af3eacf --- /dev/null +++ b/apps/web/src/components/blindtest-view.tsx @@ -0,0 +1,354 @@ +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, type YoutubeApi } from "@/lib/youtube" +import { Countdown } from "@/components/countdown" +import { Avatar } from "@/components/avatar" +import { useI18n } from "@/i18n/context" + +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) + 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 { t } = useI18n() + 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]) + + return ( +
+
+ + {t.game.blindtest(snapshot.currentRound + 1, snapshot.totalRounds)} + + {!showReveal && round && ( + + )} +
+ + {/* 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 && ( + + )} + + {!showReveal && + (round?.mine ? ( + + ) : ( + + ))} + + {showReveal && truth && ( + + )} +
+ ) +} + +function DjControls({ + ready, + api, + mediaControl, +}: { + ready: boolean + api: YoutubeApi + mediaControl: (action: "play" | "pause" | "seek", positionSec: number) => void +}) { + const { t } = useI18n() + 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 ( +
+ + {t.blindtest.djHint} + +
+ + + +
+
+ {fmt(pos)} + { + const v = Number(e.target.value) + setPos(v) + api.seek(v) + mediaControl("seek", v) + }} + className="accent-primary flex-1" + /> + {fmt(dur)} +
+
+ ) +} + +function MisleadCard({ mode }: { mode: BlindtestMode }) { + const { t } = useI18n() + return ( +
+ 🤫 +

{t.blindtest.yourTrack}

+

+ {mode === "title_artist" + ? t.blindtest.yourTrackTitleArtist + : t.blindtest.yourTrackWhoAdded} +

+
+ ) +} + +function VoteForm({ + mode, + snapshot, + playerId, + disabled, + onVote, +}: { + mode: BlindtestMode + snapshot: RoomSnapshot + playerId: string | null + disabled: boolean + onVote: (answer: BlindtestAnswer) => void +}) { + const { t } = useI18n() + 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 ( +

+ {t.game.answerSent} +

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

{truth.title}

+

{truth.artist}

+

+ {t.blindtest.revealAddedBy} + + {truth.submittedByName} +

+ {result && ( +

0 ? "text-green-500" : "text-red-500"}`} + > + {result.points > 0 ? t.blindtest.points(result.points) : t.game.wrong} +

+ )} +
+ ) +} 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 new file mode 100644 index 0000000..e50e4b1 --- /dev/null +++ b/apps/web/src/components/countdown.tsx @@ -0,0 +1,87 @@ +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" + +/** + * 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 { + 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({ + 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(startsAt, endsAt)), 250) + return () => clearInterval(id) + }, [startsAt, 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 + + // 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 ( + + {danger && 💣} + {exploded ? "💥" : 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..fb1bcf4 --- /dev/null +++ b/apps/web/src/components/game-end-view.tsx @@ -0,0 +1,616 @@ +import { useEffect, useRef, useState } from "react" +import { Link } from "wouter" +import { motion } from "framer-motion" +import { Check, ClipboardList, Crown, ListChecks, Music } from "lucide-react" +import { SiSpotify, SiYoutube } from "@icons-pack/react-simple-icons" +import type { + BlindtestTrackInfo, + PlayerScore, + RoomSnapshot, + RoundRecap, +} from "@nerdware/shared" +import { Button } from "@workspace/ui/components/button" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@workspace/ui/components/tooltip" + +const SERVER_URL = import.meta.env.VITE_SERVER_URL ?? "http://localhost:3001" +const recapImage = (url: string) => + url.startsWith("http") ? url : `${SERVER_URL}${url}` +import { useRoomStore } from "@/store/room" +import { Avatar } from "@/components/avatar" +import { celebrate } from "@/lib/confetti" +import { playVictory } from "@/lib/sound" +import { useI18n, type Dict } from "@/i18n/context" + +/** Lien de recherche Spotify (le titre vidéo contient en général artiste + chanson). */ +function spotifySearch(track: BlindtestTrackInfo): string { + const query = `${track.title} ${track.artist}`.trim() + return `https://open.spotify.com/search/${encodeURIComponent(query)}` +} + +type Place = 1 | 2 | 3 + +// Hauteur de marche, couleurs (or / argent / bronze) et taille d'avatar par place. +const PODIUM: Record = { + 1: { + bar: "h-24", + pedestal: "border-amber-400 bg-gradient-to-b from-amber-400/30 to-amber-400/5", + text: "text-amber-400", + ring: "ring-amber-400", + avatar: "size-20", + }, + 2: { + bar: "h-16", + pedestal: "border-zinc-300/70 bg-gradient-to-b from-zinc-300/20 to-zinc-300/5", + text: "text-zinc-300", + ring: "ring-zinc-300", + avatar: "size-16", + }, + 3: { + bar: "h-12", + pedestal: "border-amber-700/70 bg-gradient-to-b from-amber-700/25 to-amber-700/5", + text: "text-amber-600", + ring: "ring-amber-700", + avatar: "size-16", + }, +} + +function PodiumColumn({ + entry, + place, + name, + isMe, + delay, +}: { + entry: PlayerScore + place: Place + name: string + isMe: boolean + delay: number +}) { + const cfg = PODIUM[place] + return ( + +
+ {place === 1 && ( + + + + )} + + + +
+ + + {name} + {isMe && (toi)} + + + {entry.score} + + +
+ + {place} + +
+
+ ) +} + +export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) { + const playerId = useRoomStore((s) => s.playerId) + const finalScores = useRoomStore((s) => s.finalScores) + const gameTracks = useRoomStore((s) => s.gameTracks) + const gameRecap = useRoomStore((s) => s.gameRecap) + const leaveRoom = useRoomStore((s) => s.leaveRoom) + const returnToLobby = useRoomStore((s) => s.returnToLobby) + const { t } = useI18n() + const isHost = snapshot.hostId === playerId + + // Confettis de victoire (une fois à l'arrivée sur l'écran de fin). + const celebrated = useRef(false) + useEffect(() => { + if (!celebrated.current) { + celebrated.current = true + celebrate() + playVictory() + } + }, []) + + const [copied, setCopied] = useState(false) + async function copyResults() { + const lines = [t.end.resultsTitle, "", t.end.ranking] + ranked.forEach((s, i) => + lines.push(`${i + 1}. ${nameOf(s.playerId)} — ${s.score}`) + ) + if (gameRecap && gameRecap.length > 0) { + lines.push("", t.end.rounds) + gameRecap.forEach((r) => + lines.push( + `${r.index + 1}. ${r.type === "blindtest" ? "🎧" : "🧠"} ${r.answer}` + ) + ) + } + try { + await navigator.clipboard.writeText(lines.join("\n")) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } catch { + // presse-papier indisponible : on ignore + } + } + + const scores: PlayerScore[] = finalScores ?? snapshot.scores + const ranked = [...scores].sort((a, b) => b.score - a.score) + const nameOf = (id: string) => + snapshot.players.find((p) => p.id === id)?.name ?? "?" + const isMe = (id: string) => id === playerId + + const [first, second, third] = ranked + const rest = ranked.slice(3) + const hasTracks = !!gameTracks && gameTracks.length > 0 + const hasRecap = !!gameRecap && gameRecap.length > 0 + + return ( +
+
+
+

{t.end.over}

+

+ {t.end.winner(first ? nameOf(first.playerId) : "?")} +

+
+ + {/* Podium : 2e à gauche, champion au centre, 3e à droite. */} +
+ {second && ( + + )} + {first && ( + + )} + {third && ( + + )} +
+ + {rest.length > 0 && ( +
    + {rest.map((s, i) => ( +
  1. + + + {i + 4} + + + + {nameOf(s.playerId)} + {isMe(s.playerId) && ( + (toi) + )} + + + + {s.score} + +
  2. + ))} +
+ )} + + {gameRecap && gameRecap.length > 0 && ( + + )} +
+ + {/* Détails : musiques + récap côte à côte sur desktop s'il y a les deux, + sinon une seule colonne centrée (sinon le récap se calait à gauche). */} + {(hasTracks || hasRecap) && ( +
+ {hasTracks && } + {hasRecap && ( + + )} +
+ )} + +
+ + {isHost ? ( + + ) : ( +

+ {t.end.waitHostReplay} +

+ )} + + + +
+
+ ) +} + +function TracksRecap({ tracks, t }: { tracks: BlindtestTrackInfo[]; t: Dict }) { + const [copied, setCopied] = useState(false) + + async function copyPlaylist() { + const text = tracks.map((t) => `${t.title} — ${t.artist}`).join("\n") + try { + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } catch { + // presse-papier indisponible : on ignore + } + } + + return ( +
+
+

+ {t.end.tracksTitle} +

+ +
+
    + {tracks.map((track) => ( +
  • + + + + {track.title} + + + {t.end.addedBy(track.submittedByName)} + + + + + + + + + {t.end.searchSpotify} + + + + + + + + {t.end.openYoutube} + +
  • + ))} +
+
+ ) +} + +interface PlayerStat { + correct: number + answered: number + decoy: number + speed: number +} + +// Points de base d'une bonne réponse quiz/image ; le surplus = bonus de vitesse. +const QUIZ_BASE_POINTS = 100 + +function FunStats({ + recap, + snapshot, + t, +}: { + recap: RoundRecap[] + snapshot: RoomSnapshot + t: Dict +}) { + const stats = new Map( + snapshot.players.map((p) => [ + p.id, + { correct: 0, answered: 0, decoy: 0, speed: 0 }, + ]) + ) + for (const r of recap) { + const answered = new Set(r.answers.map((a) => a.playerId)) + const pointsBy = new Map(r.scorers.map((sc) => [sc.playerId, sc.points])) + for (const a of r.answers) { + const s = stats.get(a.playerId) + if (s) { + s.answered++ + if (a.correct) { + s.correct++ + // Bonus de vitesse = points au-dessus de la base (quiz/images). + if (r.type !== "blindtest") { + s.speed += Math.max(0, (pointsBy.get(a.playerId) ?? 0) - QUIZ_BASE_POINTS) + } + } + } + } + if (r.type === "blindtest") { + for (const sc of r.scorers) { + if (!answered.has(sc.playerId)) { + const s = stats.get(sc.playerId) + if (s) s.decoy += sc.points + } + } + } + } + + const players = snapshot.players + const total = recap.length + const nameOf = (id: string) => players.find((p) => p.id === id)?.name ?? "?" + const stat = (id: string) => + stats.get(id) ?? { correct: 0, answered: 0, decoy: 0, speed: 0 } + const winners = (pick: (s: PlayerStat) => number, min = 1) => { + const max = Math.max(0, ...players.map((p) => pick(stat(p.id)))) + return max >= min ? players.filter((p) => pick(stat(p.id)) === max) : [] + } + + const awards: { emoji: string; label: string; ids: string[] }[] = [] + const perfect = players.filter( + (p) => total > 0 && stat(p.id).correct === total + ) + if (perfect.length) { + awards.push({ emoji: "🎯", label: t.end.awardPerfect, ids: perfect.map((p) => p.id) }) + } + const brains = winners((s) => s.correct) + if (brains.length && brains.length < players.length) { + awards.push({ emoji: "🧠", label: t.end.awardBrain, ids: brains.map((p) => p.id) }) + } + const fastest = winners((s) => s.speed) + if (fastest.length && fastest.length < players.length) { + awards.push({ + emoji: "⚡", + label: t.end.awardFastest, + ids: fastest.map((p) => p.id), + }) + } + const decoy = winners((s) => s.decoy) + if (decoy.length) { + awards.push({ + emoji: "🦹", + label: t.end.awardDecoy, + ids: decoy.map((p) => p.id), + }) + } + const boulets = players.filter( + (p) => stat(p.id).answered > 0 && stat(p.id).correct === 0 + ) + if (boulets.length && total >= 3) { + awards.push({ emoji: "🪨", label: t.end.awardBoulet, ids: boulets.map((p) => p.id) }) + } + + if (awards.length === 0) { + return null + } + + return ( +
+

{t.end.awards}

+
+ {awards.map((a) => ( +
+ {a.emoji} + {a.label} +
+ {a.ids.map((id) => ( + + + {nameOf(id)} + + ))} +
+
+ ))} +
+
+ ) +} + +function RoundsRecap({ + recap, + nameOf, + playerId, + t, +}: { + recap: RoundRecap[] + nameOf: (id: string) => string + playerId: string | null + t: Dict +}) { + return ( +
+ + {t.end.recapTitle(recap.length)} + +
    + {recap.map((r) => { + const pointsBy = new Map(r.scorers.map((s) => [s.playerId, s.points])) + const found = r.answers.filter((a) => a.correct).length + // Tri : plus de points d'abord, puis bonnes réponses. + const answers = [...r.answers].sort( + (a, b) => + (pointsBy.get(b.playerId) ?? 0) - (pointsBy.get(a.playerId) ?? 0) || + Number(b.correct) - Number(a.correct) + ) + return ( +
  • + {r.youtubeId ? ( + + ) : r.imageUrl ? ( + + ) : null} +
    +

    + {r.index + 1} ·{" "} + {r.category ?? + (r.type === "blindtest" + ? t.gameTypes.blindtest + : t.gameTypes.quiz)} + {" · "} + ✓ {found} +

    + {r.type !== "blindtest" && ( +

    {r.label}

    + )} +

    + {r.answer} + {r.addedBy && ( + + {" "} + · {r.addedBy} + + )} +

    +
      + {answers.length === 0 ? ( +
    • + {t.end.noAnswer} +
    • + ) : ( + answers.map((a) => ( +
    • + + + {nameOf(a.playerId)} + {a.playerId === playerId && t.end.you} : + + + {a.answer || "—"} + + {pointsBy.get(a.playerId) ? ( + + +{pointsBy.get(a.playerId)} + + ) : null} +
    • + )) + )} +
    +
    +
  • + ) + })} +
+
+ ) +} diff --git a/apps/web/src/components/lobby-view.tsx b/apps/web/src/components/lobby-view.tsx new file mode 100644 index 0000000..fc6a6c6 --- /dev/null +++ b/apps/web/src/components/lobby-view.tsx @@ -0,0 +1,673 @@ +import { useState } from "react" +import { useQuery } from "@tanstack/react-query" +import { + Brain, + Filter, + Headphones, + Image as ImageIcon, + ListMusic, + Minus, + Music, + Play, + Plus, + Shuffle, + Trash2, + type LucideIcon, +} from "lucide-react" +import { SiYoutube } from "@icons-pack/react-simple-icons" +import { fetchCategories } from "@/lib/categories" +import { fetchHistory } from "@/lib/history" +import type { + GameType, + MixMode, + RoomSnapshot, + RoundConfig, +} from "@nerdware/shared" +import { Button } from "@workspace/ui/components/button" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@workspace/ui/components/tooltip" +import { useRoomStore } from "@/store/room" +import { Avatar } from "@/components/avatar" +import { useI18n } from "@/i18n/context" +import { errorMessage } from "@/i18n/error" + +const MAX_TRACKS = 10 +const GAME_TYPES: { + value: GameType + Icon: LucideIcon + needsThree: boolean +}[] = [ + { value: "mixed", Icon: Shuffle, needsThree: false }, + { value: "quiz", Icon: Brain, needsThree: false }, + { value: "image", Icon: ImageIcon, needsThree: false }, + { value: "blindtest", Icon: Headphones, needsThree: true }, +] +const BOT_DIFFICULTIES = ["easy", "normal", "hard"] as const + +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--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[arr[i], arr[j]] = [arr[j], arr[i]] + } + return arr +} + +/** Construit la séquence de manches (chaque manche quiz porte son sous-pool). */ +function buildRounds(opts: { + quiz: number + image: number + tracks: number + shuffleOrder: boolean +}): RoundConfig[] { + const rounds: RoundConfig[] = [ + ...Array.from({ length: opts.quiz }, () => ({ + type: "quiz" as const, + pool: "quiz" as const, + })), + ...Array.from({ length: opts.image }, () => ({ + type: "quiz" as const, + pool: "image" as const, + })), + ...Array.from({ length: opts.tracks }, () => ({ type: "blindtest" as const })), + ] + return opts.shuffleOrder ? shuffle(rounds) : rounds +} + +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 addBot = useRoomStore((s) => s.addBot) + const removeBot = useRoomStore((s) => s.removeBot) + const { t, lang } = useI18n() + const isHost = snapshot.hostId === playerId + const botCount = snapshot.players.filter((p) => p.bot).length + const { + gameType, + mixedModes, + blindtestMode, + tracksPerPlayer, + categories, + botDifficulty, + } = snapshot.settings + const allCategories = + useQuery({ queryKey: ["categories"], queryFn: fetchCategories }).data ?? [] + const history = + useQuery({ + queryKey: ["history", snapshot.code], + queryFn: () => fetchHistory(snapshot.code), + }).data ?? [] + + const [quizCount, setQuizCount] = useState(5) + const [imageCount, setImageCount] = useState(5) + const [busy, setBusy] = useState(false) + 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 + + // Le blindtest exige 3 joueurs (DJ + 2 devineurs) DONT au moins 2 réels : + // un bot ne peut pas être DJ, donc il faut un humain DJ + un humain contributeur. + const connectedCount = snapshot.players.filter((p) => p.connected).length + const humanCount = snapshot.players.filter( + (p) => p.connected && !p.bot + ).length + const blindtestAvailable = connectedCount >= 3 && humanCount >= 2 + // Seul le blindtest "seul" retombe sur du quiz à <3 ; le mixte reste mixte + // (son sous-mode blindtest est juste désactivé tant qu'on n'est pas 3). + const effectiveType: GameType = + gameType === "blindtest" && !blindtestAvailable ? "quiz" : gameType + const isMixed = effectiveType === "mixed" + + // Sous-modes effectivement actifs (un réglage par sous-mode). + const showQuiz = + effectiveType === "quiz" || (isMixed && mixedModes.includes("quiz")) + const showImage = + effectiveType === "image" || (isMixed && mixedModes.includes("image")) + const showBlindtest = + effectiveType === "blindtest" || + (isMixed && mixedModes.includes("blindtest") && blindtestAvailable) + + const rounds = buildRounds({ + quiz: showQuiz ? quizCount : 0, + image: showImage ? imageCount : 0, + tracks: showBlindtest ? totalTracks : 0, + shuffleOrder: isMixed, + }) + // Blindtest : tous les HUMAINS doivent avoir soumis leur quota (les bots non). + const botIds = new Set(snapshot.players.filter((p) => p.bot).map((p) => p.id)) + const humanSubs = snapshot.submissions.filter((s) => !botIds.has(s.playerId)) + const allSubmitted = + !showBlindtest || + (humanSubs.length > 0 && + humanSubs.every((s) => s.count >= tracksPerPlayer)) + const canStart = rounds.length > 0 && allSubmitted + + function toggleMix(mode: MixMode) { + const set = new Set(mixedModes) + if (set.has(mode)) { + set.delete(mode) + } else { + set.add(mode) + } + updateSettings({ mixedModes: [...set] }) + } + + function toggleCategory(name: string) { + const set = new Set(categories) + if (set.has(name)) { + set.delete(name) + } else { + set.add(name) + } + updateSettings({ categories: [...set] }) + } + + async function start() { + setError(null) + setBusy(true) + try { + await startGame(rounds) + } catch (err) { + setError(errorMessage(t, err)) + } finally { + setBusy(false) + } + } + + return ( +
+
+

+ {t.lobby.players(snapshot.players.length)} +

+
    + {snapshot.players.map((p) => ( +
  • + + + {p.name} + {p.id === playerId && ( + {t.common.you} + )} + + + {p.bot ? t.lobby.bot : p.id === snapshot.hostId ? t.lobby.host : ""} + {!p.bot && !p.connected && ` · ${t.lobby.offline}`} + +
  • + ))} +
+ {isHost && ( +
+ + {t.lobby.bots} : {botCount} + +
+ + +
+
+ )} +
+ +
+ {isHost && ( +
+

+ {t.lobby.settings} +

+ + {/* Mode de jeu */} +
+ {t.lobby.gameMode} +
+ {GAME_TYPES.map(({ value, Icon, needsThree }) => { + const locked = needsThree && !blindtestAvailable + const active = effectiveType === value + const tile = ( + + ) + return locked ? ( + + {tile} + {t.lobby.blindtestLockHint} + + ) : ( + tile + ) + })} +
+ {!blindtestAvailable && ( +

+ {t.lobby.blindtestUnlock} +

+ )} +
+ + {/* Sous-modes du mixte (toggles) */} + {isMixed && ( +
+ {t.lobby.subModes} +
+ {(["quiz", "image", "blindtest"] as const).map((m) => { + const locked = m === "blindtest" && !blindtestAvailable + const on = mixedModes.includes(m) && !locked + const tile = ( + + ) + return locked ? ( + + {tile} + {t.lobby.blindtestLockHint} + + ) : ( + tile + ) + })} +
+
+ )} + + {/* Nombre de questions de quiz */} + {showQuiz && ( +
+ + {t.lobby.quizCount} + + +
+ )} + + {/* Nombre d'images */} + {showImage && ( +
+
+ + {t.lobby.imageCount} + + +
+

{t.lobby.imageHint}

+
+ )} + + {/* Filtre de catégories (quiz / images) */} + {(showQuiz || showImage) && allCategories.length > 0 && ( +
+ + {t.lobby.categories} + +
+ + {allCategories.map((c) => ( + + ))} +
+
+ )} + + {/* Niveau des bots (si au moins un bot dans la partie) */} + {botCount > 0 && ( +
+ {t.lobby.botLevel} +
+ {BOT_DIFFICULTIES.map((d) => ( + + ))} +
+
+ )} + + {/* Réglages blindtest */} + {showBlindtest && ( +
+
+ + {t.lobby.blindtestMode} + +
+ {(["title_artist", "who_added", "mixed"] as const).map((m) => ( + + ))} +
+
+
+ + {t.lobby.tracksPerPlayer} + + updateSettings({ tracksPerPlayer: v })} + /> +
+
+ )} +
+ )} + + {showBlindtest && ( + + )} + + {isHost ? ( +
+ + {showBlindtest && !allSubmitted && ( +

+ {t.lobby.waitTracks(tracksPerPlayer)} +

+ )} +
+ ) : ( +

+ {t.lobby.waitHost} + {showBlindtest && t.lobby.tracksSubmitted(totalTracks)} +

+ )} + + {error &&

{error}

} + + {history.length > 0 && ( +
+ + {t.lobby.prevGames(history.length)} + +
    + {history.map((g) => { + const top = [...g.results].sort((a, b) => b.score - a.score) + return ( +
  • +

    + {new Date(g.playedAt).toLocaleString( + lang === "fr" ? "fr-FR" : "en-US", + { dateStyle: "short", timeStyle: "short" } + )} + {" · "} + {g.modes.join(", ")} +

    +

    + 🏆 {top[0]?.name ?? "?"} + + {" "} + — {top.map((r) => `${r.name} ${r.score}`).join(" · ")} + +

    +
  • + ) + })} +
+
+ )} +
+
+ ) +} + +interface MyTrack { + trackId: string + title: string + youtubeId: string +} + +function TrackSubmission({ + tracksPerPlayer, + myCount, +}: { + tracksPerPlayer: number + myCount: number +}) { + const submitTrack = useRoomStore((s) => s.submitTrack) + const removeTrack = useRoomStore((s) => s.removeTrack) + const { t } = useI18n() + const [url, setUrl] = useState("") + const [submitting, setSubmitting] = useState(false) + const [feedback, setFeedback] = useState(null) + const [tracks, setTracks] = 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 && res.trackId && res.youtubeId) { + setTracks((t) => [ + ...t, + { trackId: res.trackId!, title: res.title ?? "", youtubeId: res.youtubeId! }, + ]) + setUrl("") + } else { + setFeedback(res.reason ?? t.lobby.rejected) + } + setSubmitting(false) + } + + async function remove(trackId: string) { + const ok = await removeTrack(trackId) + if (ok) { + setTracks((t) => t.filter((x) => x.trackId !== trackId)) + } + } + + return ( +
+ + {t.lobby.myTracks(myCount, tracksPerPlayer)} + + + {tracks.length > 0 && ( +
    + {tracks.map((track) => ( +
  • + + {track.title} + + + + + + + {t.lobby.openYoutube} + + + + + + {t.lobby.remove} + +
  • + ))} +
+ )} + + {!quotaReached && ( +
+ setUrl(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && submit()} + /> + +
+ )} + {feedback &&

{feedback}

} +
+ ) +} diff --git a/apps/web/src/components/player-cards.tsx b/apps/web/src/components/player-cards.tsx new file mode 100644 index 0000000..ddb322f --- /dev/null +++ b/apps/web/src/components/player-cards.tsx @@ -0,0 +1,127 @@ +import { useEffect, useRef } from "react" +import { AnimatePresence, motion } from "framer-motion" +import { Check, Crown } from "lucide-react" +import type { RoomSnapshot } from "@nerdware/shared" +import { Avatar } from "@/components/avatar" +import { celebrateLead } from "@/lib/confetti" +import { useI18n } from "@/i18n/context" + +interface PlayerCardsProps { + snapshot: RoomSnapshot + playerId: string | null + /** Affiche l'état de vote (manche en cours uniquement). */ + showVoteStatus?: boolean + /** playerIds ayant déjà validé leur réponse. */ + votedIds?: string[] +} + +/** HUD persistant : une card par joueur avec son score, couronne au leader. */ +export function PlayerCards({ + snapshot, + playerId, + showVoteStatus = false, + votedIds = [], +}: PlayerCardsProps) { + const { t } = useI18n() + 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) + ) + const voted = new Set(votedIds) + const leaderId = max > 0 ? ranked[0].id : null + + // Confettis quand un joueur DÉPASSE pour prendre la tête (pas à la 1re prise). + const prevLeader = useRef(null) + useEffect(() => { + if (leaderId && leaderId !== prevLeader.current) { + if (prevLeader.current !== null) { + celebrateLead() + } + prevLeader.current = leaderId + } + }, [leaderId]) + + return ( +
+ + {ranked.map((p) => { + const score = scoreOf(p.id) + const isLeader = max > 0 && score === max + const isMe = p.id === playerId + const hasVoted = voted.has(p.id) + return ( + + {isLeader && ( + + + + )} + + + {p.name} + {isMe && ( + {t.common.you} + )} + + + + {score} + + + {showVoteStatus && + (hasVoted ? ( + + {t.playerCards.ready} + + ) : ( + + + + {t.playerCards.waiting} + + + ))} + + ) + })} + +
+ ) +} diff --git a/apps/web/src/components/pseudo-screen.tsx b/apps/web/src/components/pseudo-screen.tsx new file mode 100644 index 0000000..39b8fe6 --- /dev/null +++ b/apps/web/src/components/pseudo-screen.tsx @@ -0,0 +1,47 @@ +import { useState } from "react" +import { Button } from "@workspace/ui/components/button" +import { useRoomStore } from "@/store/room" +import { Avatar } from "@/components/avatar" +import { useI18n } from "@/i18n/context" + +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 { t } = useI18n() + const [name, setName_] = useState("") + const canSubmit = name.trim().length > 0 + + return ( +
+
+

+ {t.pseudo.room(code)} +

+ +

{t.pseudo.choose}

+ setName_(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && canSubmit && setName(name)} + /> + +
+
+ ) +} diff --git a/apps/web/src/components/quiz-view.tsx b/apps/web/src/components/quiz-view.tsx new file mode 100644 index 0000000..d5fa2dd --- /dev/null +++ b/apps/web/src/components/quiz-view.tsx @@ -0,0 +1,215 @@ +import { useEffect, 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" +import { useI18n, type Dict } from "@/i18n/context" + +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" + +const SERVER_URL = import.meta.env.VITE_SERVER_URL ?? "http://localhost:3001" +const MAX_BLUR = 22 + +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) + const { t } = useI18n() + + if (!round) { + return

{t.common.loading}

+ } + + const question = round.payload as QuizQuestionPayload + const truth = reveal ? (reveal.truth as QuizRevealTruth) : null + const showReveal = truth !== null + const isImage = question.format === "image_reveal" + const isText = question.format === "free" || isImage + 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 ( +
+
+ + {t.game.question(snapshot.currentRound + 1, snapshot.totalRounds)} + {question.category ? ` · ${question.category}` : ""} + + {!showReveal && ( + + )} +
+ +

+ {question.prompt} +

+ + {isImage && question.imageUrl && ( + + )} + + {isText ? ( + + ) : ( +
+ {(question.choices ?? []).map((choice, index) => ( + + ))} +
+ )} + + {!showReveal && + (hasVoted ? ( +

+ {t.game.answerSent} + {voteProgress ? ` (${voteProgress.count}/${voteProgress.total})` : ""} +

+ ) : ( + voteProgress && ( +

+ {t.game.answeredCount(voteProgress.count, voteProgress.total)} +

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

+ {t.game.answer} + {truth?.answer} +

+ )} +

+ {myResult?.correct + ? t.game.correct + : !hasVoted + ? t.game.noAnswer + : t.game.wrong} +

+
+ )} +
+ ) +} + +function ImageReveal({ + src, + startsAt, + endsAt, + revealed, +}: { + src: string + startsAt: number + endsAt: number + revealed: boolean +}) { + // Le flou décroît avec le temps (cadencé par les timestamps serveur) : tous + // les clients voient le même niveau de flou au même instant. + const [blur, setBlur] = useState(MAX_BLUR) + + useEffect(() => { + if (revealed) { + return + } + const id = setInterval(() => { + const now = Date.now() + const p = + now < startsAt ? 0 : Math.min(1, (now - startsAt) / (endsAt - startsAt)) + setBlur(MAX_BLUR * (1 - p)) + }, 200) + return () => clearInterval(id) + }, [revealed, startsAt, endsAt]) + + const displayBlur = revealed ? 0 : blur + + return ( +
+ +
+ ) +} + +function FreeAnswer({ + disabled, + onSubmit, + t, +}: { + disabled: boolean + onSubmit: (text: string) => void + t: Dict +}) { + 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/room-code.tsx b/apps/web/src/components/room-code.tsx new file mode 100644 index 0000000..b4dc669 --- /dev/null +++ b/apps/web/src/components/room-code.tsx @@ -0,0 +1,71 @@ +import { useState } from "react" +import { Check, Copy, Link2 } from "lucide-react" +import { Button } from "@workspace/ui/components/button" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@workspace/ui/components/tooltip" +import { useI18n } from "@/i18n/context" + +type Copied = "code" | "link" | null + +/** Code de room copiable + bouton pour copier le lien d'invitation. */ +export function RoomCode({ code }: { code: string }) { + const { t } = useI18n() + const [copied, setCopied] = useState(null) + + async function copy(what: Copied, text: string) { + try { + 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 ( +
+ + + + + {t.roomCode.copyCode} + + + + + + + {t.roomCode.copyLink} + +
+ ) +} diff --git a/apps/web/src/components/round-transition.tsx b/apps/web/src/components/round-transition.tsx new file mode 100644 index 0000000..c8e947e --- /dev/null +++ b/apps/web/src/components/round-transition.tsx @@ -0,0 +1,177 @@ +import { useEffect, useState } from "react" +import { createPortal } from "react-dom" +import { AnimatePresence, motion } from "framer-motion" +import { useI18n } from "@/i18n/context" + +/** Catégorie visuelle de la transition (image_reveal est distinct du quiz). */ +export type TransitionKind = "quiz" | "image" | "blindtest" + +const VISIBLE_MS = 1300 // durée d'affichage avant le wipe de sortie (≈ leadMs serveur) + +// Médias custom déposés par l'utilisateur, rangés par mode : +// src/assets/transitions/quiz/* → transitions de quiz +// src/assets/transitions/image/* → transitions du mode images +// 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 MEDIA: Record = { quiz: [], image: [], blindtest: [] } +const SHARED_MEDIA: string[] = [] +for (const [path, url] of Object.entries(ALL_MEDIA)) { + if (path.includes("/transitions/quiz/")) MEDIA.quiz.push(url) + else if (path.includes("/transitions/image/")) MEDIA.image.push(url) + else if (path.includes("/transitions/blindtest/")) MEDIA.blindtest.push(url) + else SHARED_MEDIA.push(url) +} + +const THEME: Record< + TransitionKind, + { 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: "🧠", + }, + image: { + gradient: "from-emerald-500 via-teal-600 to-cyan-700", + accent: "text-lime-300", + label: "IMAGE", + kind: "Image", + emoji: "🖼️", + }, + blindtest: { + gradient: "from-fuchsia-500 via-purple-600 to-indigo-700", + accent: "text-yellow-300", + label: "BLINDTEST", + kind: "Titre", + emoji: "🎧", + }, +} + +function mediaFor(kind: TransitionKind): string[] { + return [...MEDIA[kind], ...SHARED_MEDIA] +} + +/** + * Transition "WarioWare" entre manches, thémée par mode (quiz / image / blindtest). + * Annonce le mode quand il change, sinon affiche un teaser léger de la manche. + * Montée avec une `key` qui change → rejoue à chaque manche. + */ +export function RoundTransition({ + kind, + index, + modeChanged, +}: { + kind: TransitionKind + index: number + modeChanged: boolean +}) { + const { t } = useI18n() + const [show, setShow] = useState(true) + const theme = THEME[kind] + const [media] = useState(() => { + const pool = mediaFor(kind) + return pool.length ? pool[Math.floor(Math.random() * pool.length)] : null + }) + + useEffect(() => { + const t = setTimeout(() => setShow(false), VISIBLE_MS) + return () => clearTimeout(t) + }, []) + + return createPortal( + + {show && ( + + + + {media ? ( + + ) : ( + + {modeChanged ? ( + <> + {theme.emoji} + + {theme.label} + + + ) : ( + <> + + {t.transition.kind[kind]} + + + {index + 1} + + + )} + + {t.transition.ready} + + + )} + + )} + , + document.body + ) +} diff --git a/apps/web/src/i18n/context.ts b/apps/web/src/i18n/context.ts new file mode 100644 index 0000000..d35146b --- /dev/null +++ b/apps/web/src/i18n/context.ts @@ -0,0 +1,36 @@ +import { createContext, useContext } from "react" +import { fr } from "./fr" + +export type Lang = "fr" | "en" +/** Forme du dictionnaire, dérivée du FR (source de vérité). */ +export type Dict = typeof fr + +export const LANG_KEY = "nerdware-lang" + +export function detectLang(): Lang { + try { + const stored = localStorage.getItem(LANG_KEY) + if (stored === "fr" || stored === "en") { + return stored + } + } catch { + // localStorage indisponible : on retombe sur la langue du navigateur + } + return navigator.language?.toLowerCase().startsWith("fr") ? "fr" : "en" +} + +export interface I18nContextValue { + lang: Lang + t: Dict + setLang: (lang: Lang) => void +} + +export const I18nContext = createContext(null) + +export function useI18n(): I18nContextValue { + const ctx = useContext(I18nContext) + if (!ctx) { + throw new Error("useI18n doit être utilisé dans ") + } + return ctx +} diff --git a/apps/web/src/i18n/en.ts b/apps/web/src/i18n/en.ts new file mode 100644 index 0000000..785fbaa --- /dev/null +++ b/apps/web/src/i18n/en.ts @@ -0,0 +1,180 @@ +// Dictionnaire EN. Doit respecter la forme de `fr` (Dict) — sinon erreur TS. + +import type { Dict } from "./context" + +export const en: Dict = { + common: { + you: "(you)", + back: "Back to home", + backShort: "Back", + loading: "Loading…", + error: "Error", + }, + home: { + tagline: "Geek culture party game", + create: "Create a room", + orJoin: "or join", + codePlaceholder: "CODE", + join: "Join", + connecting: "Connecting to server…", + }, + pseudo: { + room: (code: string) => `Room ${code}`, + choose: "Choose your nickname", + placeholder: "Your nickname", + enter: "Enter the room", + }, + joinPage: { + connecting: (code: string) => `Joining room ${code}…`, + notFound: "Room not found.", + }, + room: { + notFound: "Room not found or session lost.", + connected: "Connected", + disconnected: "Disconnected", + leave: "Leave", + }, + roomCode: { + label: "Room code", + copyCode: "Copy code", + copyLink: "Copy invite link", + link: "Link", + copied: "Copied", + }, + lobby: { + players: (n: number) => `Players (${n})`, + host: "Host", + bot: "🤖 Bot", + offline: "offline", + bots: "Bots", + settings: "Game settings", + gameMode: "Game mode", + blindtestLockHint: "3 players incl. 2 real ones (a bot can't DJ)", + blindtestUnlock: + "Blindtest unlocks at 3 players incl. 2 real ones (a bot can't DJ).", + subModes: "Included sub-modes", + quizCount: "Quiz questions", + imageCount: "Images to guess", + imageHint: "Requires “Image” questions created in the back-office.", + categories: "Categories", + allCategories: "All", + botLevel: "🤖 Bot difficulty", + blindtestMode: "Blindtest mode", + tracksPerPlayer: "Tracks per player", + launch: (n: number) => `Start (${n} rounds)`, + launching: "Starting…", + waitTracks: (n: number) => + `Waiting for everyone to submit their ${n} track(s).`, + waitHost: "Waiting for the host to start…", + tracksSubmitted: (n: number) => ` (${n} tracks submitted)`, + prevGames: (n: number) => `Previous games (${n})`, + myTracks: (count: number, max: number) => `Your tracks (${count}/${max})`, + youtubePlaceholder: "YouTube link", + add: "Add", + openYoutube: "Open on YouTube", + remove: "Remove", + rejected: "Rejected", + }, + gameTypes: { + mixed: "Mixed", + quiz: "Quiz", + image: "Images", + blindtest: "Blindtest", + }, + mixModes: { + quiz: "Quiz", + image: "Images", + blindtest: "Blindtest", + }, + blindtestModes: { + title_artist: "Title & artist", + who_added: "Who added it?", + mixed: "Mixed", + }, + botDifficulty: { + easy: "Easy", + normal: "Normal", + hard: "Hard", + }, + game: { + question: (i: number, n: number) => `Question ${i} / ${n}`, + blindtest: (i: number, n: number) => `Blindtest ${i} / ${n}`, + answerSent: "Answer sent — waiting for the others", + answeredCount: (count: number, total: number) => `${count}/${total} answered`, + answer: "Answer:", + correct: "Correct! 🎉", + noAnswer: "No answer 😴", + wrong: "Wrong 💥", + yourAnswer: "Your answer", + validate: "Submit", + }, + blindtest: { + djHint: "You're the DJ 🎧 — control playback (and vote like everyone)", + play: "Play", + pause: "Pause", + restart: "Start", + yourTrack: "It's your track!", + yourTrackTitleArtist: "You don't vote. Enjoy while the others search.", + yourTrackWhoAdded: + "You don't vote — your goal: nobody guesses you added it. +50 per fooled player.", + titlePlaceholder: "Title", + artistPlaceholder: "Artist", + whoAdded: "Who added it?", + vote: "Vote", + pickPlayer: "Pick a player", + revealTitle: "Title", + revealArtist: "Artist", + revealAddedBy: "Added by", + points: (n: number) => `+${n} points 🎉`, + submit: "Submit my answer", + }, + playerCards: { + ready: "Ready", + waiting: "waiting", + }, + transition: { + kind: { + quiz: "Question", + image: "Image", + blindtest: "Track", + }, + ready: "READY ?!", + }, + end: { + over: "Game over", + winner: (name: string) => `🏆 ${name} wins!`, + copy: "Copy results", + copied: "Copied!", + replay: "Play again", + waitHostReplay: "Waiting for the host to start a new game…", + leave: "Leave", + resultsTitle: "NerdWare — Results", + ranking: "🏆 Ranking", + rounds: "Rounds", + tracksTitle: "Tracks from the game", + playlist: "Playlist", + addedBy: (name: string) => `added by ${name}`, + searchSpotify: "Search on Spotify", + openYoutube: "Open on YouTube", + awards: "Awards", + awardPerfect: "Flawless", + awardBrain: "The brain", + awardFastest: "The fastest", + awardDecoy: "Master of deception", + awardBoulet: "The dud", + recapTitle: (n: number) => `Rounds recap (${n})`, + noAnswer: "No answer", + you: " (you)", + }, + errors: { + ROOM_NOT_FOUND: "This room doesn't exist.", + ROOM_IN_PROGRESS: "The game has already started.", + ALREADY_STARTED: "Game already started.", + NO_ROUNDS: "No round configured.", + NEED_THREE: "Blindtest needs at least 3 players incl. 2 real ones.", + TRACKS_PENDING: "Not all players have submitted their tracks yet.", + CODE_EXHAUSTED: "Couldn't generate a room code.", + REJOIN_FAILED: "Reconnection failed.", + UNKNOWN_MODE: "Round unavailable.", + }, +} diff --git a/apps/web/src/i18n/error.ts b/apps/web/src/i18n/error.ts new file mode 100644 index 0000000..e1a0580 --- /dev/null +++ b/apps/web/src/i18n/error.ts @@ -0,0 +1,10 @@ +import type { Dict } from "./context" + +/** Traduit une erreur serveur via son code, sinon retombe sur son message. */ +export function errorMessage(t: Dict, err: unknown): string { + const e = err as { code?: string; message?: string } | null + if (e?.code && e.code in t.errors) { + return t.errors[e.code as keyof typeof t.errors] + } + return e?.message ?? t.common.error +} diff --git a/apps/web/src/i18n/fr.ts b/apps/web/src/i18n/fr.ts new file mode 100644 index 0000000..ce0220f --- /dev/null +++ b/apps/web/src/i18n/fr.ts @@ -0,0 +1,179 @@ +// Dictionnaire FR — source de vérité de la forme du dictionnaire (voir en.ts). + +export const fr = { + common: { + you: "(toi)", + back: "Retour à l'accueil", + backShort: "Retour", + loading: "Chargement…", + error: "Erreur", + }, + home: { + tagline: "Party game culture geek", + create: "Créer une room", + orJoin: "ou rejoindre", + codePlaceholder: "CODE", + join: "Rejoindre", + connecting: "Connexion au serveur…", + }, + pseudo: { + room: (code: string) => `Room ${code}`, + choose: "Choisis ton pseudo", + placeholder: "Ton pseudo", + enter: "Entrer dans la room", + }, + joinPage: { + connecting: (code: string) => `Connexion à la room ${code}…`, + notFound: "Room introuvable.", + }, + room: { + notFound: "Room introuvable ou session perdue.", + connected: "Connecté", + disconnected: "Déconnecté", + leave: "Quitter", + }, + roomCode: { + label: "Code room", + copyCode: "Copier le code", + copyLink: "Copier le lien d'invitation", + link: "Lien", + copied: "Copié", + }, + lobby: { + players: (n: number) => `Joueurs (${n})`, + host: "Hôte", + bot: "🤖 Bot", + offline: "hors ligne", + bots: "Bots", + settings: "Réglages de la partie", + gameMode: "Mode de jeu", + blindtestLockHint: "3 joueurs dont 2 réels (un bot ne peut pas être DJ)", + blindtestUnlock: + "Le blindtest se débloque à 3 joueurs dont 2 réels (un bot ne peut pas être DJ).", + subModes: "Sous-modes inclus", + quizCount: "Questions de quiz", + imageCount: "Images à deviner", + imageHint: "Nécessite des questions « Image » créées dans le back-office.", + categories: "Catégories", + allCategories: "Toutes", + botLevel: "🤖 Niveau des bots", + blindtestMode: "Mode blindtest", + tracksPerPlayer: "Titres par joueur", + launch: (n: number) => `Lancer (${n} manches)`, + launching: "Lancement…", + waitTracks: (n: number) => + `En attente que tout le monde ait soumis ses ${n} titre(s).`, + waitHost: "En attente du lancement par l'hôte…", + tracksSubmitted: (n: number) => ` (${n} titres soumis)`, + prevGames: (n: number) => `Parties précédentes (${n})`, + myTracks: (count: number, max: number) => `Tes titres (${count}/${max})`, + youtubePlaceholder: "Lien YouTube", + add: "Ajouter", + openYoutube: "Ouvrir sur YouTube", + remove: "Supprimer", + rejected: "Refusé", + }, + gameTypes: { + mixed: "Mixte", + quiz: "Quiz", + image: "Images", + blindtest: "Blindtest", + }, + mixModes: { + quiz: "Quiz", + image: "Images", + blindtest: "Blindtest", + }, + blindtestModes: { + title_artist: "Titre & artiste", + who_added: "Qui l'a ajouté ?", + mixed: "Mixte", + }, + botDifficulty: { + easy: "Facile", + normal: "Normal", + hard: "Difficile", + }, + game: { + question: (i: number, n: number) => `Question ${i} / ${n}`, + blindtest: (i: number, n: number) => `Blindtest ${i} / ${n}`, + answerSent: "Réponse envoyée — en attente des autres", + answeredCount: (count: number, total: number) => `${count}/${total} ont répondu`, + answer: "Réponse :", + correct: "Bonne réponse ! 🎉", + noAnswer: "Pas de réponse 😴", + wrong: "Raté 💥", + yourAnswer: "Ta réponse", + validate: "Valider", + }, + blindtest: { + djHint: "Tu es le DJ 🎧 — pilote la lecture (et vote comme les autres)", + play: "Play", + pause: "Pause", + restart: "Début", + yourTrack: "C'est ton titre !", + yourTrackTitleArtist: + "Tu ne votes pas. Savoure pendant que les autres cherchent.", + yourTrackWhoAdded: + "Tu ne votes pas — ton but : que personne ne devine que c'est toi qui l'as ajouté. +50 par joueur trompé.", + titlePlaceholder: "Titre", + artistPlaceholder: "Artiste", + whoAdded: "Qui l'a ajouté ?", + vote: "Voter", + pickPlayer: "Choisis un joueur", + revealTitle: "Titre", + revealArtist: "Artiste", + revealAddedBy: "Ajouté par", + points: (n: number) => `+${n} points 🎉`, + submit: "Valider ma réponse", + }, + playerCards: { + ready: "Prêt", + waiting: "en attente", + }, + transition: { + kind: { + quiz: "Question", + image: "Image", + blindtest: "Titre", + }, + ready: "PRÊT ?!", + }, + end: { + over: "Partie terminée", + winner: (name: string) => `🏆 ${name} l'emporte !`, + copy: "Copier les résultats", + copied: "Copié !", + replay: "Rejouer", + waitHostReplay: "En attente que l'hôte relance une partie…", + leave: "Quitter", + resultsTitle: "NerdWare — Résultats", + ranking: "🏆 Classement", + rounds: "Manches", + tracksTitle: "Les musiques de la partie", + playlist: "Playlist", + addedBy: (name: string) => `ajouté par ${name}`, + searchSpotify: "Chercher sur Spotify", + openYoutube: "Ouvrir sur YouTube", + awards: "Récompenses", + awardPerfect: "Sans-faute", + awardBrain: "Le cerveau", + awardFastest: "Le plus rapide", + awardDecoy: "Roi de la tromperie", + awardBoulet: "Le boulet", + recapTitle: (n: number) => `Récapitulatif des manches (${n})`, + noAnswer: "Aucune réponse", + you: " (toi)", + }, + errors: { + ROOM_NOT_FOUND: "Cette room n'existe pas.", + ROOM_IN_PROGRESS: "La partie a déjà commencé.", + ALREADY_STARTED: "Partie déjà lancée.", + NO_ROUNDS: "Aucune épreuve configurée.", + NEED_THREE: "Le blindtest nécessite au moins 3 joueurs dont 2 réels.", + TRACKS_PENDING: "Tous les joueurs n'ont pas encore soumis leurs titres.", + CODE_EXHAUSTED: "Impossible de générer un code de room.", + REJOIN_FAILED: "Reconnexion impossible.", + UNKNOWN_MODE: "Épreuve indisponible.", + }, +} diff --git a/apps/web/src/i18n/language-toggle.tsx b/apps/web/src/i18n/language-toggle.tsx new file mode 100644 index 0000000..29f4c57 --- /dev/null +++ b/apps/web/src/i18n/language-toggle.tsx @@ -0,0 +1,25 @@ +import { useI18n, type Lang } from "./context" + +const LANGS: Lang[] = ["fr", "en"] + +/** Petit sélecteur FR / EN. */ +export function LanguageToggle({ className = "" }: { className?: string }) { + const { lang, setLang } = useI18n() + return ( +
+ {LANGS.map((l) => ( + + ))} +
+ ) +} diff --git a/apps/web/src/i18n/provider.tsx b/apps/web/src/i18n/provider.tsx new file mode 100644 index 0000000..190ef8f --- /dev/null +++ b/apps/web/src/i18n/provider.tsx @@ -0,0 +1,30 @@ +import { useState, type ReactNode } from "react" +import { fr } from "./fr" +import { en } from "./en" +import { + I18nContext, + LANG_KEY, + detectLang, + type Dict, + type Lang, +} from "./context" + +export function I18nProvider({ children }: { children: ReactNode }) { + const [lang, setLangState] = useState(detectLang) + const dicts: Record = { fr, en } + + function setLang(next: Lang) { + try { + localStorage.setItem(LANG_KEY, next) + } catch { + // ignore + } + setLangState(next) + } + + return ( + + {children} + + ) +} diff --git a/apps/web/src/lib/admin-api.ts b/apps/web/src/lib/admin-api.ts new file mode 100644 index 0000000..63e6f1d --- /dev/null +++ b/apps/web/src/lib/admin-api.ts @@ -0,0 +1,96 @@ +// 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 + imageUrl: string | null + lang: string + active: boolean + category: string | null +} + +export interface NewQuestionInput { + format: QuizFormat + prompt: string + category: string + difficulty: number + lang?: string + choices?: string[] + correctIndex?: number + acceptedAnswers?: string[] + imageUrl?: string +} + +/** Construit l'URL absolue d'un asset servi par le serveur (ex: /uploads/x.jpg). */ +export function assetUrl(path: string): string { + return path.startsWith("http") ? path : `${SERVER_URL}${path}` +} + +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) +} + +async function uploadImage(file: File): Promise<{ url: string }> { + const form = new FormData() + form.append("file", file) + const res = await fetch(`${SERVER_URL}/api/admin/upload`, { + method: "POST", + headers: { Authorization: `Bearer ${getAdminToken()}` }, + body: form, + }) + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string } + throw new Error(body.error ?? `Erreur ${res.status}`) + } + return (await res.json()) as { url: string } +} + +export const adminApi = { + listQuestions: () => request("/questions"), + uploadImage, + createQuestion: (input: NewQuestionInput) => + request<{ id: string }>("/questions", { + method: "POST", + body: JSON.stringify(input), + }), + setActive: (id: string, active: boolean) => + request<{ ok: true }>(`/questions/${id}`, { + method: "PATCH", + body: JSON.stringify({ active }), + }), + deleteQuestion: (id: string) => + request<{ ok: true }>(`/questions/${id}`, { method: "DELETE" }), +} diff --git a/apps/web/src/lib/categories.ts b/apps/web/src/lib/categories.ts new file mode 100644 index 0000000..69913d4 --- /dev/null +++ b/apps/web/src/lib/categories.ts @@ -0,0 +1,15 @@ +// Liste publique des catégories de quiz (pour le filtre du lobby). + +const SERVER_URL = import.meta.env.VITE_SERVER_URL ?? "http://localhost:3001" + +export async function fetchCategories(): Promise { + try { + const res = await fetch(`${SERVER_URL}/api/categories`) + if (!res.ok) { + return [] + } + return (await res.json()) as string[] + } catch { + return [] + } +} diff --git a/apps/web/src/lib/confetti.ts b/apps/web/src/lib/confetti.ts new file mode 100644 index 0000000..7f20fcb --- /dev/null +++ b/apps/web/src/lib/confetti.ts @@ -0,0 +1,48 @@ +import confetti from "canvas-confetti" + +const COLORS = ["#a855f7", "#ec4899", "#22d3ee", "#fbbf24", "#f97316"] + +/** Salve de confettis de victoire : gros pop central + jets latéraux ~1s. */ +export function celebrate(): void { + confetti({ + particleCount: 140, + spread: 100, + startVelocity: 45, + origin: { y: 0.6 }, + colors: COLORS, + }) + + const end = Date.now() + 900 + const frame = () => { + confetti({ + particleCount: 5, + angle: 60, + spread: 55, + origin: { x: 0 }, + colors: COLORS, + }) + confetti({ + particleCount: 5, + angle: 120, + spread: 55, + origin: { x: 1 }, + colors: COLORS, + }) + if (Date.now() < end) { + requestAnimationFrame(frame) + } + } + frame() +} + +/** Petite salve quand un joueur prend la tête en cours de partie. */ +export function celebrateLead(): void { + confetti({ + particleCount: 45, + spread: 70, + startVelocity: 30, + scalar: 0.8, + origin: { y: 0.25 }, + colors: COLORS, + }) +} diff --git a/apps/web/src/lib/history.ts b/apps/web/src/lib/history.ts new file mode 100644 index 0000000..64f2517 --- /dev/null +++ b/apps/web/src/lib/history.ts @@ -0,0 +1,22 @@ +// Historique public des parties d'une room. + +const SERVER_URL = import.meta.env.VITE_SERVER_URL ?? "http://localhost:3001" + +export interface GameHistoryEntry { + id: string + playedAt: string + modes: string[] + results: { name: string; score: number }[] +} + +export async function fetchHistory(code: string): Promise { + try { + const res = await fetch(`${SERVER_URL}/api/history/${code}`) + if (!res.ok) { + return [] + } + return (await res.json()) as GameHistoryEntry[] + } catch { + return [] + } +} diff --git a/apps/web/src/lib/session.ts b/apps/web/src/lib/session.ts new file mode 100644 index 0000000..f707914 --- /dev/null +++ b/apps/web/src/lib/session.ts @@ -0,0 +1,34 @@ +// Session locale pour la reconnexion (refresh / coupure réseau). + +const KEY = "nerdware-session" + +export interface Session { + roomCode: string + playerId: string + token: string + name: string +} + +export function getSession(): Session | null { + try { + const raw = localStorage.getItem(KEY) + return raw ? (JSON.parse(raw) as Session) : null + } catch { + return null + } +} + +export function setSession(session: Session): void { + localStorage.setItem(KEY, JSON.stringify(session)) +} + +export function patchSession(patch: Partial): void { + const current = getSession() + if (current) { + setSession({ ...current, ...patch }) + } +} + +export function clearSession(): void { + localStorage.removeItem(KEY) +} 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/lib/sound.ts b/apps/web/src/lib/sound.ts new file mode 100644 index 0000000..f0b4763 --- /dev/null +++ b/apps/web/src/lib/sound.ts @@ -0,0 +1,24 @@ +// Sons du jeu. Dépose les fichiers dans src/assets/sounds/ (voir le README). +// Tout est optionnel : si le fichier est absent, la lecture est ignorée. + +const victorySources = Object.values( + import.meta.glob("../assets/sounds/victory.{mp3,ogg,wav}", { + eager: true, + import: "default", + }) +) as string[] + +const victoryUrl: string | null = victorySources[0] ?? null +let victoryAudio: HTMLAudioElement | null = null + +/** Joue le jingle de victoire (si présent). Nécessite une interaction préalable. */ +export function playVictory(volume = 0.6): void { + if (!victoryUrl) { + return + } + victoryAudio ??= new Audio(victoryUrl) + victoryAudio.volume = volume + victoryAudio.currentTime = 0 + // L'autoplay peut être bloqué tant qu'aucun geste utilisateur n'a eu lieu. + void victoryAudio.play().catch(() => {}) +} diff --git a/apps/web/src/lib/youtube.ts b/apps/web/src/lib/youtube.ts new file mode 100644 index 0000000..13f47a0 --- /dev/null +++ b/apps/web/src/lib/youtube.ts @@ -0,0 +1,99 @@ +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 + duration: () => 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, + duration: () => playerRef.current?.getDuration() ?? 0, + }), + [] + ) + + return { hostRef, ready, api } +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 242ad82..e611d29 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,14 +1,22 @@ 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" +import { I18nProvider } from "@/i18n/provider" + +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..8550948 --- /dev/null +++ b/apps/web/src/pages/admin.tsx @@ -0,0 +1,442 @@ +import { useRef, useState } from "react" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { Link } from "wouter" +import { Eye, EyeOff, Trash2, Upload } from "lucide-react" +import type { QuizFormat } from "@nerdware/shared" +import { Button } from "@workspace/ui/components/button" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@workspace/ui/components/tooltip" +import { + adminApi, + assetUrl, + 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 toggle = useMutation({ + mutationFn: ({ id, active }: { id: string; active: boolean }) => + adminApi.setActive(id, active), + 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)} + onToggle={() => toggle.mutate({ id: q.id, active: !q.active })} + deleting={remove.isPending} + /> + ))} +
+
+
+ )} +
+ ) +} + +function QuestionRow({ + q, + onDelete, + onToggle, + deleting, +}: { + q: AdminQuestion + onDelete: () => void + onToggle: () => void + deleting: boolean +}) { + return ( +
  • + {q.format === "image_reveal" && q.imageUrl && ( + + )} +
    +

    {q.prompt}

    +

    + {q.format} · {q.category ?? "—"} · diff. {q.difficulty} ·{" "} + {q.lang.toUpperCase()} · {q.source} + {!q.active && " · désactivée"} +

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

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

    + ) : ( +

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

    + )} +
    + + + + + {q.active ? "Désactiver" : "Activer"} + + +
  • + ) +} + +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 [lang, setLang] = useState("fr") + const [choices, setChoices] = useState(EMPTY_CHOICES) + const [correctIndex, setCorrectIndex] = useState(0) + const [answers, setAnswers] = useState("") + const [imageUrl, setImageUrl] = useState(null) + const [uploading, setUploading] = useState(false) + const [uploadError, setUploadError] = useState(null) + const fileInputRef = useRef(null) + + const create = useMutation({ + mutationFn: (input: NewQuestionInput) => adminApi.createQuestion(input), + onSuccess: () => { + setPrompt("") + setChoices(EMPTY_CHOICES) + setCorrectIndex(0) + setAnswers("") + setImageUrl(null) + onCreated() + }, + }) + + async function onPickImage(file: File | undefined) { + if (!file) { + return + } + setUploading(true) + setUploadError(null) + try { + const { url } = await adminApi.uploadImage(file) + setImageUrl(url) + } catch (err) { + setUploadError((err as { message?: string }).message ?? "Échec de l'upload") + } finally { + setUploading(false) + } + } + + function submit() { + const input: NewQuestionInput = { format, prompt, category, difficulty, lang } + if (format === "free") { + input.acceptedAnswers = answers + .split("\n") + .map((a) => a.trim()) + .filter(Boolean) + } else if (format === "image_reveal") { + input.acceptedAnswers = answers + .split("\n") + .map((a) => a.trim()) + .filter(Boolean) + input.imageUrl = imageUrl ?? undefined + } 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 === "image_reveal" && ( +
    + onPickImage(e.target.files?.[0])} + /> + + {uploadError && ( +

    {uploadError}

    + )} + {imageUrl && ( + + )} +
    + )} + + {(format === "free" || format === "image_reveal") && ( +