clementpep commited on
Commit
e6cd1dc
·
2 Parent(s): cc01868 5bfcf2b

Merge branch 'cp/change_app_architecture'

Browse files
.gitignore CHANGED
@@ -23,6 +23,10 @@ wheels/
23
  .installed.cfg
24
  *.egg
25
 
 
 
 
 
26
  # Environment
27
  .env
28
  .venv
 
23
  .installed.cfg
24
  *.egg
25
 
26
+ # Node
27
+ node_modules/
28
+ package-lock.json
29
+
30
  # Environment
31
  .env
32
  .venv
CHANGELOG.md ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Changelog
2
+
3
+ ## [2.0.0] - 2025-10-05
4
+
5
+ ### ✨ Nouvelles Fonctionnalités
6
+
7
+ #### Interface de Jeu
8
+ - ✅ **Grille d'enquête interactive** - Système de notation avec statuts (✅ Mes cartes, ❌ Éliminé, ❓ Peut-être, ⬜ Inconnu)
9
+ - ✅ **Plateau de jeu visuel** - Affichage graphique du plateau avec positions des joueurs
10
+ - ✅ **Système de dés** - Lancer de dés et déplacement circulaire sur le plateau
11
+ - ✅ **Narrateur IA Desland** - Commentaires sarcastiques en temps réel sur les actions des joueurs
12
+
13
+ #### Architecture
14
+ - ✅ **Plateau personnalisable** - Modèle BoardLayout avec disposition des salles sur grille
15
+ - ✅ **Support React complet** - Migration vers React + Vite + TailwindCSS
16
+ - ✅ **Build Docker multi-stage** - Frontend build + Backend Python optimisé
17
+
18
+ #### Composants Frontend
19
+ - `InvestigationGrid.jsx` - Grille interactive pour noter les déductions
20
+ - `GameBoard.jsx` - Affichage visuel du plateau de jeu
21
+ - `AINavigator.jsx` - Panneau du narrateur IA avec historique
22
+
23
+ #### Backend
24
+ - Support plateau de jeu dans modèles (BoardLayout, RoomPosition)
25
+ - Intégration IA dans endpoints `/suggest` et `/accuse`
26
+ - Génération automatique layout par défaut
27
+
28
+ ### 🎨 Améliorations UI/UX
29
+ - Thème hanté avec animations et effets de brouillard
30
+ - Interface immersive avec palette de couleurs cohérente
31
+ - Affichage temps réel des positions des joueurs
32
+ - Historique des actions visible
33
+
34
+ ### 🤖 Desland - Narrateur IA
35
+
36
+ #### Personnalité
37
+ - Sarcastique et incisif
38
+ - Se moque des théories absurdes
39
+ - Confusion récurrente sur son nom (Leland → Desland)
40
+ - Commentaires courts et mémorables
41
+
42
+ #### Exemples de commentaires
43
+ ```
44
+ "Et toi ça te semble logique que Pierre ait tué Daniel avec une clé USB
45
+ à côté de l'étendoir ?? Sans surprise c'est pas la bonne réponse..."
46
+
47
+ "Une capsule de café ? Brillant. Parce que évidemment, on commet des
48
+ meurtres avec du Nespresso maintenant."
49
+ ```
50
+
51
+ #### Configuration
52
+ - gpt-5-nano avec température 0.9
53
+ - Timeout 3 secondes
54
+ - Fallback gracieux si indisponible
55
+
56
+ ### 🔧 Technique
57
+
58
+ #### Stack
59
+ - **Frontend**: React 18, Vite, TailwindCSS
60
+ - **Backend**: FastAPI, Python 3.11
61
+ - **IA**: OpenAI gpt-5-nano (optionnel)
62
+ - **Build**: Docker multi-stage
63
+
64
+ #### Endpoints ajoutés
65
+ - Board layout dans game state
66
+ - AI comments dans suggestions/accusations
67
+
68
+ ### 🐛 Corrections
69
+ - Fix imports backend pour Docker
70
+ - Amélioration état du jeu (current_turn)
71
+ - Correction affichage plateau
72
+
73
+ ### 📝 Documentation
74
+ - README complet en français
75
+ - Guide de déploiement Docker
76
+ - Documentation API endpoints
77
+ - Exemples Desland
78
+
79
+ ---
80
+
81
+ ## [1.0.0] - Initial Release
82
+
83
+ - Création de partie basique
84
+ - Système de suggestions/accusations
85
+ - Multi-joueurs (3-8 joueurs)
86
+ - Thèmes prédéfinis (Classique, Bureau, Fantastique)
Dockerfile CHANGED
@@ -1,27 +1,27 @@
1
- # Use Python 3.11 slim image for smaller size
2
- FROM python:3.11-slim
 
 
 
 
 
3
 
4
- # Set working directory
 
5
  WORKDIR /app
6
 
7
- # Set environment variables
8
- ENV PYTHONUNBUFFERED=1 \
9
- PYTHONDONTWRITEBYTECODE=1 \
10
- PORT=7860
11
-
12
- # Install dependencies
13
- COPY requirements.txt .
14
  RUN pip install --no-cache-dir -r requirements.txt
15
 
16
- # Copy application files
17
- COPY . .
18
 
19
- # Expose port
20
- EXPOSE 7860
21
 
22
- # Health check
23
- HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
24
- CMD python -c "import requests; requests.get('http://localhost:7860/')"
25
 
26
- # Run the application
27
- CMD ["python", "app.py"]
 
1
+ # Stage 1: Build frontend
2
+ FROM node:18-alpine AS frontend-build
3
+ WORKDIR /app/frontend
4
+ COPY frontend/package*.json ./
5
+ RUN npm install
6
+ COPY frontend/ ./
7
+ RUN npm run build
8
 
9
+ # Stage 2: Python backend
10
+ FROM python:3.11-slim
11
  WORKDIR /app
12
 
13
+ # Install backend deps
14
+ COPY backend/requirements.txt ./
 
 
 
 
 
15
  RUN pip install --no-cache-dir -r requirements.txt
16
 
17
+ # Copy backend
18
+ COPY backend/ ./backend/
19
 
20
+ # Copy built frontend
21
+ COPY --from=frontend-build /app/frontend/dist ./frontend/dist
22
 
23
+ # Expose port for Hugging Face
24
+ EXPOSE 7860
 
25
 
26
+ # Start server
27
+ CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -18,290 +18,235 @@ tags:
18
  - openai
19
  ---
20
 
21
- # Cluedo Custom
22
 
23
- A web-based custom Cluedo (Clue) game that transforms real-world locations into interactive murder mystery games. Players can create games with custom room names matching their physical environment and play together in real-time.
24
 
25
- ## Features
26
 
27
- - **Custom Room Setup**: Define your own rooms based on your real-world location (office, house, school, etc.)
28
- - **Multi-player Support**: Up to 8 players per game
29
- - **Real-time Gameplay**: Turn-based system with suggestions and accusations
30
- - **AI-Enhanced Narration** (Optional): Generate atmospheric scenarios and narration using OpenAI
31
- - **Mobile-First Interface**: Responsive design optimized for smartphone gameplay
32
- - **Easy Deployment**: Docker-ready and compatible with Hugging Face Spaces
 
 
33
 
34
- ## Quick Start
35
 
36
- ### Local Development
37
 
38
- 1. **Clone the repository**
39
- ```bash
40
- git clone <repository-url>
41
- cd custom-cluedo
42
- ```
43
-
44
- 2. **Install dependencies**
45
- ```bash
46
- pip install -r requirements.txt
47
- ```
48
-
49
- 3. **Configure environment** (optional)
50
- ```bash
51
- cp .env.example .env
52
- # Edit .env to configure settings
53
- ```
54
-
55
- 4. **Run the application**
56
- ```bash
57
- python app.py
58
- ```
59
-
60
- 5. **Access the interface**
61
- Open your browser at `http://localhost:7860`
62
 
63
- ### Docker Deployment
 
64
 
65
- 1. **Build the Docker image**
66
- ```bash
67
- docker build -t cluedo-custom .
68
- ```
 
69
 
70
- 2. **Run the container**
71
- ```bash
72
- docker run -p 7860:7860 cluedo-custom
73
- ```
74
 
75
- Or with environment variables:
76
- ```bash
77
- docker run -p 7860:7860 \
78
- -e USE_OPENAI=true \
79
- -e OPENAI_API_KEY=your_key_here \
80
- cluedo-custom
81
- ```
82
 
83
- ### Hugging Face Spaces Deployment
 
 
 
 
 
84
 
85
- 1. **Create a new Space**
86
- - Go to [Hugging Face Spaces](https://huggingface.co/spaces)
87
- - Click "Create new Space"
88
- - Choose "Gradio" as the SDK
 
 
 
89
 
90
- 2. **Upload files**
91
- - Upload all project files to your Space
92
- - Ensure `app.py` is the main entry point
93
 
94
- 3. **Configure secrets** (for AI mode)
95
- - Go to Settings → Repository secrets
96
- - Add `OPENAI_API_KEY` if using AI features
97
 
98
- 4. **Set environment variables** in Space settings:
99
  ```
100
  USE_OPENAI=true
101
- APP_NAME=Cluedo Custom
102
- MAX_PLAYERS=8
103
  ```
104
 
105
- ## Environment Variables
 
 
 
106
 
107
- | Variable | Description | Default |
108
- |----------|-------------|---------|
109
- | `APP_NAME` | Application name displayed in the interface | `Cluedo Custom` |
110
- | `MAX_PLAYERS` | Maximum number of players per game | `8` |
111
- | `USE_OPENAI` | Enable AI-generated content (true/false) | `false` |
112
- | `OPENAI_API_KEY` | OpenAI API key (required if USE_OPENAI=true) | `""` |
113
 
114
- ## How to Play
 
 
 
 
 
115
 
116
- ### 1. Create a Game
117
 
118
- - Navigate to the "Create Game" tab
119
- - Enter a game name
120
- - List 6-12 room names that match your real-world location
121
- - Example: `Kitchen, Living Room, Bedroom, Office, Garage, Garden`
122
- - Optionally enable AI narration
123
- - Click "Create Game" and share the Game ID with other players
124
 
125
- ### 2. Join a Game
 
 
 
126
 
127
- - Navigate to the "Join Game" tab
128
- - Enter the Game ID provided by the game creator
129
- - Enter your player name
130
- - Click "Join Game"
131
 
132
- ### 3. Start the Game
 
 
 
133
 
134
- - Once all players have joined (minimum 3 players)
135
- - The game creator clicks "Start Game"
136
- - Cards are automatically distributed to all players
137
 
138
- ### 4. Play Your Turn
 
 
139
 
140
- - Navigate to the "Play" tab
141
- - Click "Refresh Game State" to see current status
142
- - When it's your turn:
143
- - **Make a Suggestion**: Choose a character, weapon, and room. Other players will try to disprove it.
144
- - **Make an Accusation**: If you think you know the solution. Warning: wrong accusations eliminate you!
145
- - **Pass Turn**: Skip to the next player
146
 
147
- ### 5. Win the Game
148
 
149
- - The first player to make a correct accusation wins
150
- - Or be the last player standing if others are eliminated
 
 
 
 
151
 
152
- ## Game Rules
153
 
154
- ### Setup
 
155
 
156
- - Each game has:
157
- - 6 default characters (Miss Scarlett, Colonel Mustard, Mrs. White, etc.)
158
- - 6 default weapons (Candlestick, Knife, Lead Pipe, etc.)
159
- - Custom rooms defined by the players
160
 
161
- ### Solution
162
 
163
- - At the start, one character, one weapon, and one room are randomly selected as the secret solution
164
- - All other cards are distributed evenly among players
165
 
166
- ### Gameplay
167
 
168
- 1. **Suggestions**:
169
- - Player suggests a character, weapon, and room combination
170
- - Other players (clockwise) try to disprove by showing one matching card
171
- - Only the suggesting player sees the card shown
172
 
173
- 2. **Accusations**:
174
- - Player makes a final accusation of the solution
175
- - If correct: player wins immediately
176
- - If incorrect: player is eliminated and cannot act further
177
 
178
- 3. **Victory**:
179
- - First player with correct accusation wins
180
- - If all others are eliminated, the last remaining player wins
181
 
182
- ## AI Mode
 
183
 
184
- When enabled (`USE_OPENAI=true`), the application generates:
 
 
185
 
186
- - **Scenario Introduction**: Atmospheric setup describing the mystery in your chosen location
187
- - **Turn Narration** (optional): Brief narrative elements during gameplay
188
 
189
- AI features use GPT-3.5-turbo with:
190
- - Low temperature for consistency
191
- - 3-second timeout per request
192
- - Graceful fallback if unavailable
193
 
194
- ## Project Structure
195
 
196
  ```
197
  custom-cluedo/
198
- ├── app.py # Main application (Gradio interface)
199
- ├── api.py # FastAPI backend routes
200
- ├── config.py # Configuration and settings
201
- ├── models.py # Data models (Pydantic)
202
- ├── game_engine.py # Core game logic
203
- ├── game_manager.py # Game state management
204
- ├── ai_service.py # OpenAI integration
205
- ├── requirements.txt # Python dependencies
206
- ├── Dockerfile # Docker configuration
207
- ├── .env.example # Environment variables template
208
- └── README.md # This file
209
- ```
210
-
211
- ## Technical Details
212
-
213
- ### Backend
214
-
215
- - **FastAPI**: REST API for game management
216
- - **In-memory storage**: Games stored in memory with JSON persistence
217
- - **Pydantic models**: Type-safe data validation
218
-
219
- ### Frontend
220
-
221
- - **Gradio**: Interactive web interface
222
- - **Mobile-optimized**: Responsive design with large touch targets
223
- - **Real-time updates**: Manual refresh for game state (polling-based)
224
-
225
- ### Storage
226
-
227
- - Games are stored in `games.json` for persistence
228
- - Data is lost when container restarts (suitable for casual play)
229
- - No database required
230
-
231
- ## API Endpoints
232
-
233
- - `GET /` - Health check
234
- - `POST /games/create` - Create new game
235
- - `POST /games/join` - Join existing game
236
- - `POST /games/{game_id}/start` - Start game
237
- - `GET /games/{game_id}` - Get full game state
238
- - `GET /games/{game_id}/player/{player_id}` - Get player-specific view
239
- - `POST /games/{game_id}/action` - Perform game action
240
- - `GET /games/list` - List active games
241
- - `DELETE /games/{game_id}` - Delete game
242
-
243
- ## Limitations
244
-
245
- - Maximum 8 players per game (configurable)
246
- - Minimum 3 players to start
247
- - 6-12 custom rooms required
248
- - No persistent database (games reset on restart)
249
- - AI features require OpenAI API key and have rate limits
250
-
251
- ## Development
252
-
253
- ### Running Tests
254
-
255
- ```bash
256
- # Install test dependencies
257
- pip install pytest pytest-asyncio
258
-
259
- # Run tests (when implemented)
260
- pytest
261
  ```
262
 
263
- ### Code Style
264
 
265
- - Code documented in English
266
- - Type hints using Pydantic models
267
- - Follows PEP 8 guidelines
 
 
 
268
 
269
- ## Troubleshooting
 
 
 
 
270
 
271
- ### Port 7860 already in use
272
-
273
- Change the port in `config.py` or use environment variable:
274
- ```bash
275
- PORT=8000 python app.py
276
- ```
277
 
278
- ### AI features not working
279
 
280
- - Verify `USE_OPENAI=true` is set
281
- - Check `OPENAI_API_KEY` is valid
282
- - Ensure OpenAI API is accessible
283
- - Check API rate limits
 
284
 
285
- ### Game state not updating
286
 
287
- - Click "Refresh Game State" button
288
- - Check network connection to API
289
- - Verify game ID and player ID are correct
 
290
 
291
- ## License
 
 
 
292
 
293
- This project is provided as-is for educational and recreational purposes.
 
 
 
294
 
295
- ## Contributing
296
 
297
- Contributions welcome! Please:
298
- 1. Fork the repository
299
- 2. Create a feature branch
300
- 3. Submit a pull request
301
 
302
- ## Support
303
 
304
- For issues and questions:
305
- - Create an issue on GitHub
306
- - Check existing documentation
307
- - Review API endpoint responses for error details
 
18
  - openai
19
  ---
20
 
21
+ # 🕯️ Cluedo Custom - Jeu de Mystère Personnalisable
22
 
23
+ Application web de Cluedo personnalisable avec narrateur IA sarcastique (Desland, le vieux jardinier). Transformez votre environnement réel en plateau de jeu interactif !
24
 
25
+ ## ✨ Fonctionnalités
26
 
27
+ - **Plateau de jeu personnalisable** - Disposez vos salles sur une grille avec drag & drop
28
+ - **Grille d'enquête interactive** - Cochez les possibilités éliminées (✅ Mes cartes, ❌ Éliminé, ❓ Peut-être)
29
+ - **Système de dés et déplacement** - Déplacez-vous sur le plateau circulaire
30
+ - **Suggestions et accusations** - Mécaniques de jeu Cluedo complètes
31
+ - **Narrateur IA Desland** - Commentaires sarcastiques en temps réel sur vos actions
32
+ - **Interface immersive** - Thème hanté avec animations et effets visuels
33
+ - ✅ **Multi-joueurs** - 3-8 joueurs, synchronisation en temps réel
34
+ - ✅ **Thèmes prédéfinis** - Classique (Manoir), Bureau, Fantastique
35
 
36
+ ## 🚀 Démarrage Rapide
37
 
38
+ ### Avec Docker (Recommandé)
39
 
40
+ ```bash
41
+ # Build l'image
42
+ docker build -t custom-cluedo .
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
+ # Lance l'application
45
+ docker run -p 7860:7860 custom-cluedo
46
 
47
+ # Avec IA Desland activée
48
+ docker run -p 7860:7860 \
49
+ -e USE_OPENAI=true \
50
+ -e OPENAI_API_KEY=your_key_here \
51
+ custom-cluedo
52
 
53
+ # Accéder à l'application
54
+ open http://localhost:7860
55
+ ```
 
56
 
57
+ ### Développement Local
 
 
 
 
 
 
58
 
59
+ #### Backend
60
+ ```bash
61
+ cd backend
62
+ pip install -r requirements.txt
63
+ uvicorn backend.main:app --reload --port 8000
64
+ ```
65
 
66
+ #### Frontend
67
+ ```bash
68
+ cd frontend
69
+ npm install
70
+ npm run dev # Dev server on http://localhost:5173
71
+ npm run build # Build for production
72
+ ```
73
 
74
+ ### Déploiement Hugging Face Spaces
 
 
75
 
76
+ 1. **Créer un nouveau Space**
77
+ - SDK: **Docker**
78
+ - Port: **7860**
79
 
80
+ 2. **Variables d'environnement** (Settings Variables):
81
  ```
82
  USE_OPENAI=true
83
+ OPENAI_API_KEY=<votre_clé>
 
84
  ```
85
 
86
+ 3. **Push le code**
87
+ ```bash
88
+ git push
89
+ ```
90
 
91
+ ## ⚙️ Configuration
 
 
 
 
 
92
 
93
+ | Variable | Description | Défaut |
94
+ |----------|-------------|--------|
95
+ | `USE_OPENAI` | Active le narrateur IA Desland | `false` |
96
+ | `OPENAI_API_KEY` | Clé API OpenAI (si USE_OPENAI=true) | `""` |
97
+ | `MAX_PLAYERS` | Nombre max de joueurs | `8` |
98
+ | `MIN_PLAYERS` | Nombre min de joueurs | `3` |
99
 
100
+ ## 🎮 Comment Jouer
101
 
102
+ ### 1. Créer une Partie
 
 
 
 
 
103
 
104
+ 1. Entrez votre nom
105
+ 2. Cliquez sur **"🚪 Entrer dans le Manoir"**
106
+ 3. Un code de partie est généré (ex: `CBSB`)
107
+ 4. Partagez ce code avec vos amis
108
 
109
+ ### 2. Rejoindre une Partie
 
 
 
110
 
111
+ 1. Cliquez sur **"👻 Rejoindre une partie existante"**
112
+ 2. Entrez le code de partie
113
+ 3. Entrez votre nom
114
+ 4. Rejoignez !
115
 
116
+ ### 3. Démarrer le Jeu
 
 
117
 
118
+ - Minimum **3 joueurs** requis
119
+ - Le créateur clique sur **"🚀 Commencer l'enquête"**
120
+ - Les cartes sont distribuées automatiquement
121
 
122
+ ### 4. Jouer Votre Tour
 
 
 
 
 
123
 
124
+ Quand c'est votre tour :
125
 
126
+ 1. **🎲 Lancer les dés** - Déplacez-vous sur le plateau
127
+ 2. **💬 Suggérer** - Proposez un suspect + arme + salle
128
+ - Les autres joueurs tentent de réfuter
129
+ 3. **⚠️ Accuser** - Accusation finale (éliminé si faux !)
130
+ 4. **📋 Grille d'enquête** - Notez vos déductions
131
+ - Cliquez pour marquer : ✅ → ❌ → ❓ → ⬜
132
 
133
+ ### 5. Gagner
134
 
135
+ - Premier à faire une accusation correcte gagne
136
+ - Ou dernier joueur non-éliminé
137
 
138
+ ## 🤖 Narrateur IA : Desland
 
 
 
139
 
140
+ Activez `USE_OPENAI=true` pour les commentaires sarcastiques de Desland !
141
 
142
+ ### Personnalité de Desland
 
143
 
144
+ > *"Je suis Leland... euh non, Desland. Le vieux jardinier de ce manoir maudit."*
145
 
146
+ - **Sarcastique** - Se moque des théories absurdes
147
+ - **Incisif** - Commentaires tranchants et condescendants
148
+ - **Suspicieux** - Semble en savoir plus qu'il ne dit
149
+ - **Confus** - Se trompe souvent de nom (Leland → Desland)
150
 
151
+ ### Exemples de Commentaires
 
 
 
152
 
153
+ ```
154
+ "Et toi ça te semble logique que Pierre ait tué Daniel avec une clé USB
155
+ à côté de l'étendoir ?? Sans surprise c'est pas la bonne réponse..."
156
 
157
+ "Une capsule de café ? Brillant. Parce que évidemment, on commet des
158
+ meurtres avec du Nespresso maintenant."
159
 
160
+ "Ah oui, excellente déduction Sherlock. Prochaine étape : accuser le
161
+ chat du voisin."
162
+ ```
163
 
164
+ ### Configuration IA
 
165
 
166
+ - Modèle: gpt-5-nano
167
+ - Température: 0.9 (créativité élevée)
168
+ - Timeout: 3 secondes max
169
+ - Fallback gracieux si indisponible
170
 
171
+ ## 📁 Structure du Projet
172
 
173
  ```
174
  custom-cluedo/
175
+ ├── backend/
176
+ ├── main.py # API FastAPI + Serving frontend
177
+ ├── models.py # Modèles Pydantic (Game, Player, Cards...)
178
+ ├── game_engine.py # Logique du jeu (règles, vérifications)
179
+ ├── game_manager.py # Gestion des parties (CRUD)
180
+ ├── defaults.py # Thèmes prédéfinis
181
+ ├── config.py # Configuration
182
+ │ └── requirements.txt # Dépendances Python
183
+ ├── frontend/
184
+ ├── src/
185
+ │ │ ├── pages/
186
+ │ │ │ ├── Home.jsx # Accueil + création partie
187
+ │ │ │ ├── Join.jsx # Rejoindre partie
188
+ │ │ │ └── Game.jsx # Interface de jeu
189
+ │ │ ├── components/
190
+ │ │ │ ├── GameBoard.jsx # Plateau de jeu
191
+ │ │ │ ├── InvestigationGrid.jsx # Grille d'enquête
192
+ │ │ │ └── AINavigator.jsx # Narrateur Desland
193
+ │ │ └── api.js # Client API
194
+ │ ├── package.json
195
+ │ └── tailwind.config.js
196
+ ├── ai_service.py # Service IA (Desland)
197
+ ├── Dockerfile # Build multi-stage
198
+ └── README.md
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  ```
200
 
201
+ ## 🔌 API Endpoints
202
 
203
+ ### Parties
204
+ - `GET /api/health` - Santé de l'API
205
+ - `POST /api/games/quick-create` - Créer partie rapide
206
+ - `POST /api/games/join` - Rejoindre partie
207
+ - `POST /api/games/{game_id}/start` - Démarrer
208
+ - `GET /api/games/{game_id}/state/{player_id}` - État du jeu
209
 
210
+ ### Actions
211
+ - `POST /api/games/{game_id}/roll` - Lancer dés
212
+ - `POST /api/games/{game_id}/suggest` - Suggestion
213
+ - `POST /api/games/{game_id}/accuse` - Accusation
214
+ - `POST /api/games/{game_id}/pass` - Passer tour
215
 
216
+ ### Autres
217
+ - `GET /api/themes` - Thèmes disponibles
 
 
 
 
218
 
219
+ ## 🛠️ Technologies
220
 
221
+ - **Backend** : FastAPI, Python 3.11, Pydantic
222
+ - **Frontend** : React 18, Vite, TailwindCSS
223
+ - **IA** : OpenAI gpt-5-nano (optionnel)
224
+ - **Stockage** : JSON (games.json)
225
+ - **Déploiement** : Docker, Hugging Face Spaces
226
 
227
+ ## 🎨 Thèmes Disponibles
228
 
229
+ ### Classique - Meurtre au Manoir 🏰
230
+ - **Suspects** : Mme Leblanc, Col. Moutarde, Mlle Rose, Prof. Violet, Mme Pervenche, M. Olive
231
+ - **Armes** : Poignard, Revolver, Corde, Chandelier, Clé anglaise, Poison
232
+ - **Salles** : Cuisine, Salon, Bureau, Chambre, Garage, Jardin
233
 
234
+ ### Bureau - Meurtre au Bureau 💼
235
+ - **Suspects** : Claire, Pierre, Daniel, Marie, Thomas, Sophie
236
+ - **Armes** : Clé USB, Agrafeuse, Câble HDMI, Capsule de café, Souris, Plante verte
237
+ - **Salles** : Open Space, Salle de réunion, Cafétéria, Bureau CEO, Toilettes, Parking
238
 
239
+ ### Fantastique - Meurtre au Château 🧙
240
+ - **Suspects** : Merlin le Sage, Dame Morgane, Chevalier Lancelot, Elfe Aranelle, Nain Thorin, Sorcière Malva
241
+ - **Armes** : Épée enchantée, Potion empoisonnée, Grimoire maudit, Dague runique, Bâton magique, Amulette sombre
242
+ - **Salles** : Grande Salle, Tour des Mages, Donjon, Bibliothèque, Armurerie, Crypte
243
 
244
+ ## 📝 Licence
245
 
246
+ Projet personnel - Usage libre pour l'éducation et le divertissement
 
 
 
247
 
248
+ ## 🎯 Crédits
249
 
250
+ - Jeu basé sur le Cluedo classique
251
+ - Interface immersive avec thème hanté
252
+ - Narrateur IA Desland créé avec amour et sarcasme 👻
 
api.py DELETED
@@ -1,304 +0,0 @@
1
- """
2
- FastAPI backend for Cluedo Custom game.
3
- Provides REST API endpoints for game management and actions.
4
- """
5
-
6
- from fastapi import FastAPI, HTTPException
7
- from fastapi.middleware.cors import CORSMiddleware
8
- from pydantic import BaseModel
9
- from typing import List, Optional
10
-
11
- from models import (
12
- CreateGameRequest,
13
- JoinGameRequest,
14
- GameAction,
15
- Game,
16
- Player,
17
- GameStatus
18
- )
19
- from game_manager import game_manager
20
- from game_engine import GameEngine
21
- from ai_service import ai_service
22
- from config import settings
23
-
24
-
25
- app = FastAPI(title=settings.APP_NAME)
26
-
27
- # CORS disabled - not needed when API and Gradio are on same host (localhost)
28
-
29
-
30
- class GameResponse(BaseModel):
31
- """Response when creating or joining a game."""
32
- game_id: str
33
- player_id: Optional[str] = None
34
- message: str
35
-
36
-
37
- class ActionResponse(BaseModel):
38
- """Response for game actions."""
39
- success: bool
40
- message: str
41
- data: Optional[dict] = None
42
-
43
-
44
- @app.get("/")
45
- async def root():
46
- """Health check endpoint."""
47
- return {
48
- "app": settings.APP_NAME,
49
- "status": "running",
50
- "ai_enabled": settings.USE_OPENAI
51
- }
52
-
53
-
54
- @app.post("/games/create", response_model=GameResponse)
55
- async def create_game(request: CreateGameRequest):
56
- """
57
- Create a new game.
58
- Returns the game ID.
59
- """
60
- # Validate room count
61
- if len(request.rooms) < settings.MIN_ROOMS:
62
- raise HTTPException(
63
- status_code=400,
64
- detail=f"At least {settings.MIN_ROOMS} rooms are required"
65
- )
66
-
67
- if len(request.rooms) > settings.MAX_ROOMS:
68
- raise HTTPException(
69
- status_code=400,
70
- detail=f"Maximum {settings.MAX_ROOMS} rooms allowed"
71
- )
72
-
73
- # Create game
74
- game = game_manager.create_game(request)
75
-
76
- # Generate AI scenario if enabled
77
- if game.use_ai and settings.USE_OPENAI:
78
- from game_engine import DEFAULT_CHARACTERS
79
- scenario = await ai_service.generate_scenario(
80
- game.rooms,
81
- DEFAULT_CHARACTERS
82
- )
83
- if scenario:
84
- game.scenario = scenario
85
- game_manager.save_games()
86
-
87
- return GameResponse(
88
- game_id=game.game_id,
89
- message=f"Game '{game.name}' created successfully"
90
- )
91
-
92
-
93
- @app.post("/games/join", response_model=GameResponse)
94
- async def join_game(request: JoinGameRequest):
95
- """
96
- Join an existing game.
97
- Returns the player ID.
98
- """
99
- game = game_manager.get_game(request.game_id)
100
-
101
- if not game:
102
- raise HTTPException(status_code=404, detail="Game not found")
103
-
104
- if game.status != GameStatus.WAITING:
105
- raise HTTPException(status_code=400, detail="Game has already started")
106
-
107
- if game.is_full():
108
- raise HTTPException(status_code=400, detail="Game is full")
109
-
110
- player = game_manager.join_game(request.game_id, request.player_name)
111
-
112
- if not player:
113
- raise HTTPException(status_code=400, detail="Could not join game")
114
-
115
- return GameResponse(
116
- game_id=game.game_id,
117
- player_id=player.id,
118
- message=f"{request.player_name} joined the game"
119
- )
120
-
121
-
122
- @app.post("/games/{game_id}/start", response_model=ActionResponse)
123
- async def start_game(game_id: str):
124
- """
125
- Start a game (initialize cards and begin play).
126
- """
127
- success = game_manager.start_game(game_id)
128
-
129
- if not success:
130
- raise HTTPException(
131
- status_code=400,
132
- detail="Could not start game. Check player count and game status."
133
- )
134
-
135
- return ActionResponse(
136
- success=True,
137
- message="Game started successfully"
138
- )
139
-
140
-
141
- @app.get("/games/{game_id}", response_model=Game)
142
- async def get_game(game_id: str):
143
- """
144
- Get full game state.
145
- """
146
- game = game_manager.get_game(game_id)
147
-
148
- if not game:
149
- raise HTTPException(status_code=404, detail="Game not found")
150
-
151
- return game
152
-
153
-
154
- @app.get("/games/{game_id}/player/{player_id}")
155
- async def get_player_view(game_id: str, player_id: str):
156
- """
157
- Get game state from a specific player's perspective.
158
- Hides other players' cards and the solution.
159
- """
160
- game = game_manager.get_game(game_id)
161
-
162
- if not game:
163
- raise HTTPException(status_code=404, detail="Game not found")
164
-
165
- player = next((p for p in game.players if p.id == player_id), None)
166
-
167
- if not player:
168
- raise HTTPException(status_code=404, detail="Player not found")
169
-
170
- # Build safe view
171
- other_players = [
172
- {
173
- "name": p.name,
174
- "is_active": p.is_active,
175
- "card_count": len(p.cards)
176
- }
177
- for p in game.players if p.id != player_id
178
- ]
179
-
180
- current_player = game.get_current_player()
181
-
182
- return {
183
- "game_id": game.game_id,
184
- "game_name": game.name,
185
- "status": game.status,
186
- "scenario": game.scenario,
187
- "rooms": game.rooms,
188
- "characters": [c.name for c in game.characters],
189
- "weapons": [w.name for w in game.weapons],
190
- "my_cards": [c.name for c in player.cards],
191
- "other_players": other_players,
192
- "current_turn": current_player.name if current_player else None,
193
- "is_my_turn": current_player.id == player_id if current_player else False,
194
- "recent_turns": game.turns[-5:] if game.turns else [],
195
- "winner": game.winner
196
- }
197
-
198
-
199
- @app.post("/games/{game_id}/action", response_model=ActionResponse)
200
- async def perform_action(game_id: str, action: GameAction):
201
- """
202
- Perform a game action (suggest, accuse, pass).
203
- """
204
- game = game_manager.get_game(game_id)
205
-
206
- if not game:
207
- raise HTTPException(status_code=404, detail="Game not found")
208
-
209
- # Verify it's the player's turn
210
- if not GameEngine.can_player_act(game, action.player_id):
211
- raise HTTPException(status_code=403, detail="Not your turn or game not in progress")
212
-
213
- player = next((p for p in game.players if p.id == action.player_id), None)
214
- if not player:
215
- raise HTTPException(status_code=404, detail="Player not found")
216
-
217
- result_message = ""
218
- result_data = {}
219
-
220
- if action.action_type == "pass":
221
- # Pass turn
222
- GameEngine.add_turn_record(game, action.player_id, "pass", "Passed turn")
223
- game.next_turn()
224
- result_message = f"{player.name} passed their turn"
225
-
226
- elif action.action_type == "suggest":
227
- # Make a suggestion
228
- if not all([action.character, action.weapon, action.room]):
229
- raise HTTPException(status_code=400, detail="Suggestion requires character, weapon, and room")
230
-
231
- can_disprove, disprover, card = GameEngine.check_suggestion(
232
- game,
233
- action.player_id,
234
- action.character,
235
- action.weapon,
236
- action.room
237
- )
238
-
239
- suggestion_text = f"Suggested: {action.character} with {action.weapon} in {action.room}"
240
-
241
- if can_disprove and disprover and card:
242
- result_message = f"{disprover} disproved the suggestion by showing: {card.name}"
243
- result_data = {"disproved_by": disprover, "card_shown": card.name}
244
- else:
245
- result_message = "No one could disprove the suggestion!"
246
- result_data = {"disproved_by": None}
247
-
248
- GameEngine.add_turn_record(game, action.player_id, "suggest", suggestion_text)
249
- game.next_turn()
250
-
251
- elif action.action_type == "accuse":
252
- # Make an accusation
253
- if not all([action.character, action.weapon, action.room]):
254
- raise HTTPException(status_code=400, detail="Accusation requires character, weapon, and room")
255
-
256
- accusation_text = f"Accused: {action.character} with {action.weapon} in {action.room}"
257
-
258
- is_correct, message = GameEngine.process_accusation(
259
- game,
260
- action.player_id,
261
- action.character,
262
- action.weapon,
263
- action.room
264
- )
265
-
266
- GameEngine.add_turn_record(game, action.player_id, "accuse", accusation_text)
267
-
268
- if not is_correct and game.status == GameStatus.IN_PROGRESS:
269
- game.next_turn()
270
-
271
- result_message = message
272
- result_data = {"correct": is_correct, "game_finished": game.status == GameStatus.FINISHED}
273
-
274
- else:
275
- raise HTTPException(status_code=400, detail="Invalid action type")
276
-
277
- game_manager.save_games()
278
-
279
- return ActionResponse(
280
- success=True,
281
- message=result_message,
282
- data=result_data
283
- )
284
-
285
-
286
- @app.get("/games/list")
287
- async def list_games():
288
- """
289
- List all active games.
290
- """
291
- return {"games": game_manager.list_active_games()}
292
-
293
-
294
- @app.delete("/games/{game_id}")
295
- async def delete_game(game_id: str):
296
- """
297
- Delete a game.
298
- """
299
- success = game_manager.delete_game(game_id)
300
-
301
- if not success:
302
- raise HTTPException(status_code=404, detail="Game not found")
303
-
304
- return {"message": "Game deleted successfully"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app.py DELETED
@@ -1,1087 +0,0 @@
1
- """
2
- Main application file for Cluedo Custom.
3
- Integrates FastAPI backend with Gradio frontend interface.
4
- """
5
-
6
- import gradio as gr
7
- import requests
8
- import json
9
- from typing import Optional, List
10
- from config import settings
11
- import uvicorn
12
- import threading
13
- import time
14
-
15
-
16
- # Determine runtime environment
17
- import os
18
-
19
- IS_HUGGINGFACE = os.getenv("SPACE_ID") is not None
20
-
21
- # API base URL (only used in local mode)
22
- API_BASE = "http://localhost:8000"
23
-
24
-
25
- class GameState:
26
- """Client-side game state management."""
27
-
28
- def __init__(self):
29
- self.game_id: Optional[str] = None
30
- self.player_id: Optional[str] = None
31
- self.player_name: Optional[str] = None
32
-
33
-
34
- # Global state
35
- state = GameState()
36
-
37
-
38
- def create_game(game_name: str, rooms_text: str, use_ai: bool):
39
- """
40
- Create a new game.
41
- """
42
- if not game_name or not rooms_text:
43
- return "❌ Ekelesbikes ! Fournissez un nom d'enquête et une liste de pièces", ""
44
-
45
- # Parse rooms (comma or newline separated)
46
- rooms = [r.strip() for r in rooms_text.replace("\n", ",").split(",") if r.strip()]
47
-
48
- if len(rooms) < settings.MIN_ROOMS:
49
- return (
50
- f"❌ Koikoubaiseyyyyy ! Il faut au moins {settings.MIN_ROOMS} pièces",
51
- "",
52
- )
53
-
54
- if len(rooms) > settings.MAX_ROOMS:
55
- return (
56
- f"❌ Triple monstre coucouuuuu ! Maximum {settings.MAX_ROOMS} pièces autorisées",
57
- "",
58
- )
59
-
60
- try:
61
- if IS_HUGGINGFACE:
62
- # Direct backend call
63
- from game_manager import game_manager
64
- from models import CreateGameRequest
65
-
66
- request = CreateGameRequest(game_name=game_name, rooms=rooms, use_ai=use_ai)
67
- game = game_manager.create_game(request)
68
-
69
- # Generate AI scenario if enabled
70
- if game.use_ai and settings.USE_OPENAI:
71
- from game_engine import DEFAULT_CHARACTERS
72
- from ai_service import ai_service
73
- import asyncio
74
-
75
- try:
76
- scenario = asyncio.run(
77
- ai_service.generate_scenario(game.rooms, DEFAULT_CHARACTERS)
78
- )
79
- if scenario:
80
- game.scenario = scenario
81
- game_manager.save_games()
82
- except:
83
- pass # AI is optional
84
-
85
- state.game_id = game.game_id
86
- return (
87
- f"✅ Enquête créée avec succès ! En alicrampté, les coicoubaca sont de sortie...\n\n"
88
- f"🔑 Code d'Enquête : {game.game_id}\n\n"
89
- f"📤 Partagez ce code avec les autres poupouilles masquées pour qu'elles puissent vous rejoindre en alicrampté.\n\n"
90
- f"ℹ️ Minimum {settings.MIN_PLAYERS} péchailloux requis pour démarrer (sinon c'est les fourlestourtes et les bourbillats).",
91
- game.game_id,
92
- )
93
- else:
94
- # HTTP API call (local mode)
95
- response = requests.post(
96
- f"{API_BASE}/games/create",
97
- json={"game_name": game_name, "rooms": rooms, "use_ai": use_ai},
98
- timeout=5,
99
- )
100
-
101
- if response.status_code == 200:
102
- data = response.json()
103
- state.game_id = data["game_id"]
104
- return (
105
- f"✅ Enquête créée avec succès ! En alicrampté, les coicoubaca sont de sortie...\n\n"
106
- f"🔑 Code d'Enquête : {data['game_id']}\n\n"
107
- f"📤 Partagez ce code avec les autres joueurs pour qu'ils puissent rejoindre.\n\n"
108
- f"ℹ️ Minimum {settings.MIN_PLAYERS} joueurs requis pour démarrer.",
109
- data["game_id"],
110
- )
111
- else:
112
- return (
113
- f"❌ All RS5, erreur réseau : {response.json().get('detail', 'Erreur inconnue')}",
114
- "",
115
- )
116
-
117
- except Exception as e:
118
- return f"❌ Yamete coudasai ! Erreur lors de la création : {str(e)}", ""
119
-
120
-
121
- def join_game(game_id: str, player_name: str):
122
- """
123
- Join an existing game.
124
- """
125
- if not game_id or not player_name:
126
- return "❌ Yamete coudasai ! Fournissez le code d'enquête et votre nom de tchoupinoux masqué !"
127
-
128
- try:
129
- game_id = game_id.strip().upper()
130
- player_name = player_name.strip()
131
-
132
- if IS_HUGGINGFACE:
133
- # Direct backend call
134
- from game_manager import game_manager
135
- from models import GameStatus
136
-
137
- game = game_manager.get_game(game_id)
138
- if not game:
139
- return "❌ Erreur réseau ! Enquête introuvable... C'est Leland (non c'est Desland)"
140
-
141
- if game.status != GameStatus.WAITING:
142
- return "❌ Armankaboul ! Les chnawax masqués jouent déjà !"
143
-
144
- if game.is_full():
145
- return "❌ Chat 4, 3 entre chat 4 et 1 brisé ! Trop de poupouilles dans l'enquête..."
146
-
147
- player = game_manager.join_game(game_id, player_name)
148
- if not player:
149
- return "❌ Une poupée en pénitence calisse ! Impossible de rejoindre l'enquête !"
150
-
151
- state.game_id = game_id
152
- state.player_id = player.id
153
- state.player_name = player_name
154
-
155
- return (
156
- f"✅ Enquête rejointe avec succès !\n\n"
157
- f"👋 Bienvenue, {player_name} !\n\n"
158
- f"⏳ Attendez que le chnawax originel (le créateur) démarre la partie...\n"
159
- f"📖 Allez dans l'onglet 🔎 Enquêter pour voir l'état de la partie."
160
- )
161
- else:
162
- # HTTP API call (local mode)
163
- response = requests.post(
164
- f"{API_BASE}/games/join",
165
- json={
166
- "game_id": game_id,
167
- "player_name": player_name,
168
- },
169
- timeout=5,
170
- )
171
-
172
- if response.status_code == 200:
173
- data = response.json()
174
- state.game_id = data["game_id"]
175
- state.player_id = data["player_id"]
176
- state.player_name = player_name
177
-
178
- return (
179
- f"✅ Enquête rejointe avec succès !\n\n"
180
- f"👋 Bienvenue, {player_name} !\n\n"
181
- f"⏳ Attendez que le chnawax originel (le créateur) démarre la partie...\n"
182
- f"📖 Allez dans l'onglet 🔎 Enquêter pour voir l'état de la partie."
183
- )
184
- else:
185
- return f"❌ All RS5, erreur réseau (fourlestourtes et les bourbillats) : {response.json().get('detail', 'Erreur inconnue')}"
186
-
187
- except Exception as e:
188
- return f"❌ Koikoubaiseyyyyy ! Erreur : {str(e)}"
189
-
190
-
191
- def start_game(game_id: str):
192
- """
193
- Start the game.
194
- """
195
- if not game_id:
196
- return "❌ Triple monstre coucouuuuu ! Aucune enuête sélectionnée !"
197
-
198
- try:
199
- game_id = game_id.strip().upper()
200
-
201
- if IS_HUGGINGFACE:
202
- # Direct backend call
203
- from game_manager import game_manager
204
-
205
- success = game_manager.start_game(game_id)
206
-
207
- if not success:
208
- return "❌ Erreur ! Pas assez de tchoupinoux masqués pour commencer la partie !"
209
-
210
- return (
211
- f"🩸 LE MASSACRE COMMENCE ! Triple monstre coucouuuuu !\n\n"
212
- f"🎲 Les cartes ont été distribuées.\n"
213
- f"🔪 Tous les joueurs peuvent maintenant consulter leurs cartes et commencer à jouer.\n\n"
214
- f"➡️ Allez dans l'onglet 🔎 Enquêter pour voir votre dossier."
215
- )
216
- else:
217
- # HTTP API call (local mode)
218
- response = requests.post(f"{API_BASE}/games/{game_id}/start", timeout=5)
219
-
220
- if response.status_code == 200:
221
- return (
222
- f"🩸 LE MASSACRE COMMENCE ! Triple monstre coucouuuuu !\n\n"
223
- f"🎲 Les cartes ont été distribuées.\n"
224
- f"🔪 Tous les joueurs peuvent maintenant consulter leurs cartes et commencer à jouer.\n\n"
225
- f"➡️ Allez dans l'onglet 🔎 Enquêter pour voir votre dossier."
226
- )
227
- else:
228
- return f"❌ All RS5 erreur réseau : {response.json().get('detail', 'Erreur inconnue')}"
229
-
230
- except Exception as e:
231
- return f"❌ Yamete coudasai ! Erreur au démarrage : {str(e)}"
232
-
233
-
234
- def get_player_view():
235
- """
236
- Get current game state for the player.
237
- """
238
- if not state.game_id or not state.player_id:
239
- return (
240
- "❌ Eskilibass ! Vous n'êtes pas dans une enquête.\n\n"
241
- "➡️ Créer une nouvelle enquête ou rejoignez d'autres péchailloux masqués."
242
- )
243
-
244
- try:
245
- if IS_HUGGINGFACE:
246
- # Direct backend call
247
- from game_manager import game_manager
248
-
249
- game = game_manager.get_game(state.game_id)
250
-
251
- if not game:
252
- return "❌ All RS5, erreur réseau ! Enquête introuvable... Fourlestourtes et les bourbillats !"
253
-
254
- player = next((p for p in game.players if p.id == state.player_id), None)
255
-
256
- if not player:
257
- return "❌ Poupée en pénitence calisse ! Péchailloux masqué..."
258
-
259
- # Build safe view
260
- other_players = [
261
- {"name": p.name, "is_active": p.is_active, "card_count": len(p.cards)}
262
- for p in game.players
263
- if p.id != state.player_id
264
- ]
265
-
266
- current_player = game.get_current_player()
267
-
268
- data = {
269
- "game_id": game.game_id,
270
- "game_name": game.name,
271
- "status": game.status,
272
- "scenario": game.scenario,
273
- "rooms": game.rooms,
274
- "characters": [c.name for c in game.characters],
275
- "weapons": [w.name for w in game.weapons],
276
- "my_cards": [c.name for c in player.cards],
277
- "other_players": other_players,
278
- "current_turn": current_player.name if current_player else None,
279
- "is_my_turn": (
280
- current_player.id == state.player_id if current_player else False
281
- ),
282
- "recent_turns": game.turns[-5:] if game.turns else [],
283
- "winner": game.winner,
284
- }
285
- else:
286
- # HTTP API call (local mode)
287
- response = requests.get(
288
- f"{API_BASE}/games/{state.game_id}/player/{state.player_id}", timeout=5
289
- )
290
-
291
- if response.status_code == 200:
292
- data = response.json()
293
- else:
294
- return f"❌ Erreur : {response.json().get('detail', 'Erreur inconnue')}"
295
-
296
- # Format output (common for both modes)
297
- output = []
298
- output.append(f"═══ 🩸 {data['game_name']} - LE CARNAGE SANGLANT 🩸 ═══\n")
299
-
300
- status_map = {
301
- "waiting": "⏳ Les poupouilles masquées arrivent...",
302
- "in_progress": "🔪 CARNAGE EN COURS",
303
- "finished": "💀 MASSACRE TERMINÉ - Un chnawax a gagné",
304
- }
305
- output.append(f"📊 Statut : {status_map.get(data['status'], data['status'])}\n")
306
-
307
- if data.get("scenario"):
308
- output.append(f"\n📜 Scénario :\n{data['scenario']}\n")
309
-
310
- output.append(f"\n━━━ 🃏 VOS CARTES ━━━")
311
- output.append("(Ces éléments NE SONT PAS la solution)")
312
- for card in data["my_cards"]:
313
- output.append(f" 🔸 {card}")
314
-
315
- output.append(f"\n━━━ ℹ️ INFORMATIONS DE JEU ━━━")
316
- output.append(f"🚪 Lieux : {', '.join(data['rooms'])}")
317
- output.append(f"👤 Personnages : {', '.join(data['characters'])}")
318
- output.append(f"🔪 Armes : {', '.join(data['weapons'])}")
319
-
320
- output.append(f"\n━━━ 👥 DÉTECTIVES ━━━")
321
- for player in data["other_players"]:
322
- status_icon = "✅" if player["is_active"] else "❌"
323
- output.append(
324
- f" {status_icon} {player['name']} ({player['card_count']} cartes)"
325
- )
326
-
327
- if data["current_turn"]:
328
- turn_marker = (
329
- "👉 C'EST TON TOUR MON PÉCHAILLOUX !" if data["is_my_turn"] else ""
330
- )
331
- output.append(f"\n━━━ 🎯 TOUR ACTUEL ━━━")
332
- output.append(f"🎲 {data['current_turn']} {turn_marker}")
333
-
334
- if data.get("winner"):
335
- output.append(
336
- f"\n\n🏆🏆🏆 QUOICOUBAIDEYYYYY ! VAINQUEUR : {data['winner']} 🏆🏆🏆"
337
- )
338
-
339
- if data["recent_turns"]:
340
- output.append(f"\n━━━ 📰 ACTIONS RÉCENTES ━━━")
341
- for turn in data["recent_turns"][-5:]:
342
- output.append(f" • {turn['player_name']}: {turn['action']}")
343
- if turn.get("details"):
344
- output.append(f" ↪ {turn['details']}")
345
-
346
- return "\n".join(output)
347
-
348
- except Exception as e:
349
- return f"❌ Erreur réseau (fourlestourtes et les bourbillats) : {str(e)}"
350
-
351
-
352
- def make_suggestion(character: str, weapon: str, room: str):
353
- """
354
- Make a suggestion.
355
- """
356
- if not state.game_id or not state.player_id:
357
- return "❌ Ekelesbikes ! Vous n'êtes pas dans une enquête"
358
-
359
- if not all([character, weapon, room]):
360
- return "❌ Eskilibass (I'm a spiderman), choisissez un personnage, une arme et un lieu"
361
-
362
- try:
363
- if IS_HUGGINGFACE:
364
- # Direct backend call
365
- from game_manager import game_manager
366
- from game_engine import GameEngine
367
-
368
- game = game_manager.get_game(state.game_id)
369
-
370
- if not game:
371
- return "❌ All RS5, erreur réseau ! Enquête introuvable"
372
-
373
- # Verify it's the player's turn
374
- if not GameEngine.can_player_act(game, state.player_id):
375
- return "❌ Yamete coudasai ! C'est pas ton tour !"
376
-
377
- player = next((p for p in game.players if p.id == state.player_id), None)
378
- if not player:
379
- return "❌ All RS5, erreur réseau ! Péchailloux masqué introuvable"
380
-
381
- can_disprove, disprover, card = GameEngine.check_suggestion(
382
- game, state.player_id, character, weapon, room
383
- )
384
-
385
- suggestion_text = f"Suggested: {character} with {weapon} in {room}"
386
-
387
- if can_disprove and disprover and card:
388
- message = (
389
- f"{disprover} disproved the suggestion by showing: {card.name}"
390
- )
391
- else:
392
- message = "No one could disprove the suggestion!"
393
-
394
- GameEngine.add_turn_record(
395
- game, state.player_id, "suggest", suggestion_text
396
- )
397
- game.next_turn()
398
- game_manager.save_games()
399
-
400
- # Translate common responses
401
- if "disproved" in message.lower():
402
- return (
403
- f"💭 {message}\n\n➡️ Notes cette information pour tes déductions !"
404
- )
405
- else:
406
- return f"💭 {message}\n\n⚠️ Aucun chnawax n'a pu réfuter ta théorie !"
407
- else:
408
- # HTTP API call (local mode)
409
- response = requests.post(
410
- f"{API_BASE}/games/{state.game_id}/action",
411
- json={
412
- "game_id": state.game_id,
413
- "player_id": state.player_id,
414
- "action_type": "suggest",
415
- "character": character,
416
- "weapon": weapon,
417
- "room": room,
418
- },
419
- timeout=5,
420
- )
421
-
422
- if response.status_code == 200:
423
- data = response.json()
424
- message = data["message"]
425
-
426
- # Translate common responses
427
- if "disproved" in message.lower():
428
- return f"💭 {message}\n\n➡️ Notez cette information pour vos déductions !"
429
- else:
430
- return f"💭 {message}\n\n⚠️ Personne n'a pu réfuter votre théorie !"
431
- else:
432
- error = response.json().get("detail", "Erreur inconnue")
433
- if "Not your turn" in error:
434
- return "❌ Yamete coudasai ! C'est pas ton tour !"
435
- return f"❌ Erreur réseau (fourlestourtes et les bourbillats) : {error}"
436
-
437
- except Exception as e:
438
- return f"❌ Koikoubaiseyyyyy ! Erreur : {str(e)}"
439
-
440
-
441
- def make_accusation(character: str, weapon: str, room: str):
442
- """
443
- Make an accusation.
444
- """
445
- if not state.game_id or not state.player_id:
446
- return "❌ Vous n'êtes pas dans une enquête"
447
-
448
- if not all([character, weapon, room]):
449
- return "❌ Armankaboul ! Choisissez un personnage, une arme et un lieu"
450
-
451
- try:
452
- if IS_HUGGINGFACE:
453
- # Direct backend call
454
- from game_manager import game_manager
455
- from game_engine import GameEngine
456
- from models import GameStatus
457
-
458
- game = game_manager.get_game(state.game_id)
459
-
460
- if not game:
461
- return "❌ All RS5, erreur réseau : Enquête introuvable"
462
-
463
- # Verify it's the player's turn
464
- if not GameEngine.can_player_act(game, state.player_id):
465
- return "❌ Yamete coudasai ! C'est pas ton tour !"
466
-
467
- player = next((p for p in game.players if p.id == state.player_id), None)
468
- if not player:
469
- return "❌ All RS5, erreur réseau : Joueur introuvable"
470
-
471
- accusation_text = f"Accused: {character} with {weapon} in {room}"
472
-
473
- is_correct, message = GameEngine.process_accusation(
474
- game, state.player_id, character, weapon, room
475
- )
476
-
477
- GameEngine.add_turn_record(game, state.player_id, "accuse", accusation_text)
478
-
479
- if not is_correct and game.status == GameStatus.IN_PROGRESS:
480
- game.next_turn()
481
-
482
- game_manager.save_games()
483
-
484
- # Check if win or lose
485
- if "wins" in message.lower() or "correct" in message.lower():
486
- return f"🎉🏆 {message} 🎉🏆\n\nTRIPLE MONSTRE COUCOUUUUU ! Tu as résolu le mystère ! (3 entre chat 4 et 1 brisé)"
487
- elif "wrong" in message.lower() or "eliminated" in message.lower():
488
- return f"💀 {message}\n\n😔 Fourlestourtes et les bourbillats... Tu as été éliminé calisse en pénitence siboère !\nTu peux toujours aider en réfutant les théories des autres."
489
- else:
490
- return f"⚖️ {message}"
491
- else:
492
- # HTTP API call (local mode)
493
- response = requests.post(
494
- f"{API_BASE}/games/{state.game_id}/action",
495
- json={
496
- "game_id": state.game_id,
497
- "player_id": state.player_id,
498
- "action_type": "accuse",
499
- "character": character,
500
- "weapon": weapon,
501
- "room": room,
502
- },
503
- timeout=5,
504
- )
505
-
506
- if response.status_code == 200:
507
- data = response.json()
508
- message = data["message"]
509
-
510
- # Check if win or lose
511
- if "wins" in message.lower() or "correct" in message.lower():
512
- return f"🎉🏆 {message} 🎉🏆\n\nTRIPLE MONSTRE COUCOUUUUU ! Tu as résolu le mystère ! (3 entre chat 4 et 1 brisé)"
513
- elif "wrong" in message.lower() or "eliminated" in message.lower():
514
- return f"💀 {message}\n\n😔 Fourlestourtes et les bourbillats... Tu as été éliminé calisse en pénitence siboère !\nTu peux toujours aider en réfutant les théories des autres."
515
- else:
516
- return f"⚖�� {message}"
517
- else:
518
- error = response.json().get("detail", "Erreur inconnue")
519
- if "Not your turn" in error:
520
- return "❌ Yamete coudasai ! C'est pas ton tour !"
521
- return f"❌ All RS5, erreur réseau : {error}"
522
-
523
- except Exception as e:
524
- return f"❌ Koikoubaiseyyyyy ! Erreur : {str(e)}"
525
-
526
-
527
- def pass_turn():
528
- """
529
- Pass the current turn.
530
- """
531
- if not state.game_id or not state.player_id:
532
- return "❌ Eskilibass ! Vous n'êtes pas dans une enquête"
533
-
534
- try:
535
- if IS_HUGGINGFACE:
536
- # Direct backend call
537
- from game_manager import game_manager
538
- from game_engine import GameEngine
539
-
540
- game = game_manager.get_game(state.game_id)
541
-
542
- if not game:
543
- return "❌ All RS5, erreur réseau ! Enquête introuvable"
544
-
545
- # Verify it's the player's turn
546
- if not GameEngine.can_player_act(game, state.player_id):
547
- return "❌ Yamete coudasai ! C'est pas ton tour !"
548
-
549
- player = next((p for p in game.players if p.id == state.player_id), None)
550
- if not player:
551
- return "❌ All RS5, erreur réseau ! Joueur introuvable"
552
-
553
- # Pass turn
554
- GameEngine.add_turn_record(game, state.player_id, "pass", "Passed turn")
555
- game.next_turn()
556
- game_manager.save_games()
557
-
558
- return f"✅ Tour passé !\n\n➡️ C'est maintenant au tour de la prochaine poupouille."
559
- else:
560
- # HTTP API call (local mode)
561
- response = requests.post(
562
- f"{API_BASE}/games/{state.game_id}/action",
563
- json={
564
- "game_id": state.game_id,
565
- "player_id": state.player_id,
566
- "action_type": "pass",
567
- },
568
- timeout=5,
569
- )
570
-
571
- if response.status_code == 200:
572
- data = response.json()
573
- return f"✅ Tour passé !\n\n➡️ C'est maintenant au tour de la prochaine poupouille."
574
- else:
575
- error = response.json().get("detail", "Erreur inconnue")
576
- if "Not your turn" in error:
577
- return "❌ Yamete coudasai ! C'est pas ton tour !"
578
- return f"❌ All RS5, erreur réseau (fourlestourtes et les bourbillats) : {error}"
579
-
580
- except Exception as e:
581
- return f"❌ Koikoubaiseyyyyy ! Erreur : {str(e)}"
582
-
583
-
584
- # Sample lists for dropdowns
585
- from game_engine import DEFAULT_CHARACTERS, DEFAULT_WEAPONS
586
-
587
-
588
- def create_gradio_interface():
589
- """
590
- Create the Gradio interface.
591
- """
592
- # Custom dark detective/horror theme
593
- custom_theme = gr.themes.Base(
594
- primary_hue="red",
595
- secondary_hue="slate",
596
- neutral_hue="stone",
597
- font=("ui-serif", "Georgia", "serif"),
598
- ).set(
599
- body_background_fill="*neutral_950",
600
- body_background_fill_dark="*neutral_950",
601
- body_text_color="*neutral_200",
602
- body_text_color_dark="*neutral_200",
603
- button_primary_background_fill="*primary_700",
604
- button_primary_background_fill_dark="*primary_800",
605
- button_primary_text_color="white",
606
- button_secondary_background_fill="*neutral_700",
607
- button_secondary_background_fill_dark="*neutral_800",
608
- input_background_fill="*neutral_800",
609
- input_background_fill_dark="*neutral_900",
610
- input_border_color="*neutral_700",
611
- block_background_fill="*neutral_900",
612
- block_background_fill_dark="*neutral_900",
613
- block_border_color="*neutral_700",
614
- block_label_text_color="*primary_400",
615
- block_title_text_color="*primary_300",
616
- )
617
-
618
- custom_css = """
619
- @import url('https://fonts.googleapis.com/css2?family=Creepster&family=Cinzel:wght@400;600&display=swap');
620
-
621
- .gradio-container {
622
- background:
623
- linear-gradient(180deg, rgba(10,0,0,0.95) 0%, rgba(26,0,0,0.9) 50%, rgba(10,5,5,0.95) 100%),
624
- repeating-linear-gradient(90deg, transparent, transparent 2px, rgba(0,0,0,0.3) 2px, rgba(0,0,0,0.3) 4px),
625
- url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><filter id="noise"><feTurbulence baseFrequency="0.9" /></filter><rect width="100" height="100" filter="url(%23noise)" opacity="0.05"/></svg>') !important;
626
- font-family: 'Cinzel', 'Georgia', serif !important;
627
- position: relative;
628
- }
629
-
630
- .gradio-container::before {
631
- content: '';
632
- position: fixed;
633
- top: 0;
634
- left: 0;
635
- width: 100%;
636
- height: 100%;
637
- background: radial-gradient(ellipse at center, transparent 0%, rgba(0,0,0,0.7) 100%);
638
- pointer-events: none;
639
- z-index: 0;
640
- }
641
-
642
- h1 {
643
- color: #8b0000 !important;
644
- text-shadow:
645
- 0 0 10px rgba(139,0,0,0.8),
646
- 0 0 20px rgba(139,0,0,0.6),
647
- 3px 3px 6px rgba(0,0,0,0.9),
648
- 0 0 40px rgba(220,20,60,0.4);
649
- font-family: 'Creepster', 'Georgia', cursive !important;
650
- letter-spacing: 4px;
651
- font-size: 3em !important;
652
- animation: flicker 3s infinite alternate;
653
- }
654
-
655
- @keyframes flicker {
656
- 0%, 100% { opacity: 1; text-shadow: 0 0 10px rgba(139,0,0,0.8), 0 0 20px rgba(139,0,0,0.6), 3px 3px 6px rgba(0,0,0,0.9); }
657
- 50% { opacity: 0.95; text-shadow: 0 0 15px rgba(139,0,0,1), 0 0 25px rgba(139,0,0,0.8), 3px 3px 6px rgba(0,0,0,0.9); }
658
- }
659
-
660
- h2, h3 {
661
- color: #b91c1c !important;
662
- text-shadow: 2px 2px 4px rgba(0,0,0,0.9), 0 0 10px rgba(185,28,28,0.5);
663
- font-family: 'Cinzel', 'Georgia', serif !important;
664
- letter-spacing: 3px;
665
- border-bottom: 1px solid rgba(139,0,0,0.3);
666
- padding-bottom: 8px;
667
- }
668
-
669
- .tabs button {
670
- background: linear-gradient(180deg, #1a0f0f 0%, #0a0000 100%) !important;
671
- border: 1px solid #44403c !important;
672
- color: #d6d3d1 !important;
673
- transition: all 0.3s ease;
674
- font-family: 'Cinzel', serif !important;
675
- letter-spacing: 1px;
676
- }
677
-
678
- .tabs button:hover {
679
- background: linear-gradient(180deg, #2a0f0f 0%, #1a0000 100%) !important;
680
- border-color: #8b0000 !important;
681
- box-shadow: 0 0 15px rgba(139,0,0,0.5);
682
- }
683
-
684
- .tabs button[aria-selected="true"] {
685
- background: linear-gradient(180deg, #8b0000 0%, #5a0000 100%) !important;
686
- border-color: #dc2626 !important;
687
- color: #fef2f2 !important;
688
- box-shadow:
689
- 0 0 20px rgba(139,0,0,0.6),
690
- inset 0 0 10px rgba(0,0,0,0.5);
691
- }
692
-
693
- .gr-button {
694
- background: linear-gradient(180deg, #7c2d12 0%, #5a1a0a 100%) !important;
695
- border: 1px solid #8b0000 !important;
696
- color: #fef2f2 !important;
697
- text-shadow: 1px 1px 2px rgba(0,0,0,0.8);
698
- transition: all 0.3s ease;
699
- font-family: 'Cinzel', serif !important;
700
- letter-spacing: 1px;
701
- }
702
-
703
- .gr-button:hover {
704
- background: linear-gradient(180deg, #8b0000 0%, #6a0000 100%) !important;
705
- box-shadow: 0 0 20px rgba(139,0,0,0.7), 0 5px 15px rgba(0,0,0,0.5);
706
- transform: translateY(-2px);
707
- }
708
-
709
- .gr-button-primary {
710
- background: linear-gradient(180deg, #991b1b 0%, #7f1d1d 100%) !important;
711
- border: 2px solid #dc2626 !important;
712
- box-shadow: 0 0 15px rgba(153,27,27,0.5);
713
- }
714
-
715
- .gr-button-stop {
716
- background: linear-gradient(180deg, #450a0a 0%, #1a0000 100%) !important;
717
- border: 2px solid #7f1d1d !important;
718
- animation: pulse-danger 2s infinite;
719
- }
720
-
721
- @keyframes pulse-danger {
722
- 0%, 100% { box-shadow: 0 0 10px rgba(127,29,29,0.5); }
723
- 50% { box-shadow: 0 0 25px rgba(127,29,29,0.9), 0 0 40px rgba(220,38,38,0.5); }
724
- }
725
-
726
- .gr-textbox, .gr-dropdown {
727
- background: rgba(20,10,10,0.8) !important;
728
- border: 1px solid #44403c !important;
729
- color: #e7e5e4 !important;
730
- box-shadow: inset 0 2px 4px rgba(0,0,0,0.5);
731
- }
732
-
733
- .gr-textbox:focus, .gr-dropdown:focus {
734
- border-color: #8b0000 !important;
735
- box-shadow: 0 0 15px rgba(139,0,0,0.4), inset 0 2px 4px rgba(0,0,0,0.5);
736
- }
737
-
738
- .gr-group, .gr-box {
739
- background: rgba(15,5,5,0.6) !important;
740
- border: 1px solid rgba(68,64,60,0.5) !important;
741
- border-radius: 8px !important;
742
- box-shadow:
743
- 0 4px 6px rgba(0,0,0,0.5),
744
- inset 0 1px 2px rgba(139,0,0,0.1);
745
- }
746
-
747
- .gr-accordion {
748
- background: rgba(26,10,10,0.7) !important;
749
- border: 1px solid rgba(139,0,0,0.3) !important;
750
- border-radius: 6px;
751
- }
752
-
753
- label {
754
- color: #fca5a5 !important;
755
- font-family: 'Cinzel', serif !important;
756
- letter-spacing: 1px;
757
- text-shadow: 1px 1px 2px rgba(0,0,0,0.8);
758
- }
759
-
760
- .markdown {
761
- color: #d6d3d1 !important;
762
- }
763
-
764
- .warning-text {
765
- color: #fca5a5 !important;
766
- font-style: italic;
767
- text-shadow: 1px 1px 2px rgba(0,0,0,0.8), 0 0 10px rgba(252,165,165,0.3);
768
- }
769
-
770
- /* Effet de brouillard fantomatique */
771
- @keyframes ghost-float {
772
- 0%, 100% { opacity: 0.05; transform: translateY(0px); }
773
- 50% { opacity: 0.15; transform: translateY(-20px); }
774
- }
775
-
776
- .gradio-container::after {
777
- content: '';
778
- position: fixed;
779
- top: -50%;
780
- left: -50%;
781
- width: 200%;
782
- height: 200%;
783
- background: radial-gradient(circle, rgba(139,0,0,0.03) 0%, transparent 50%);
784
- animation: ghost-float 10s infinite ease-in-out;
785
- pointer-events: none;
786
- z-index: 1;
787
- }
788
- """
789
-
790
- with gr.Blocks(title=settings.APP_NAME, theme=custom_theme, css=custom_css) as demo:
791
- gr.Markdown(f"# 🔍 {settings.APP_NAME} 🔪")
792
- gr.Markdown("*Un mystère mortel vous attend dans votre propre lieu...*")
793
-
794
- # Rules section (collapsible)
795
- with gr.Accordion("📖 Règles du Jeu & Guide", open=False):
796
- gr.Markdown(
797
- """
798
- ## 🎯 Objectif
799
- Soyez le premier détective à résoudre le meurtre en identifiant correctement :
800
- - **Le meurtrier** (personnage)
801
- - **L'arme du crime** (arme)
802
- - **Le lieu du crime** (pièce)
803
-
804
- ## 🎮 Étapes de Jeu
805
-
806
- ### 1️⃣ Création de la Partie
807
- - Un joueur crée la partie en définissant 6-12 lieux personnalisés
808
- - Partagez le **Code d'Enquête** avec les autres détectives
809
-
810
- ### 2️⃣ Rejoindre l'Enquête
811
- - Les détectives rejoignent avec le code partagé (min. 3 joueurs)
812
- - Le créateur lance l'enquête quand tous sont prêts
813
-
814
- ### 3️⃣ Distribution des Cartes
815
- - Une solution secrète est créée (1 personnage + 1 arme + 1 lieu)
816
- - Les cartes restantes sont distribuées équitablement entre les joueurs
817
- - Vous voyez vos propres cartes = ces éléments **NE SONT PAS** la solution
818
-
819
- ### 4️⃣ Votre Tour
820
- Trois actions possibles :
821
-
822
- **💭 Proposer une Théorie** (Suggestion)
823
- - Proposez une combinaison personnage + arme + lieu
824
- - Les autres joueurs essaient de réfuter en montrant UNE carte correspondante
825
- - Seul VOUS voyez la carte révélée
826
- - Utilisez cela pour éliminer des possibilités
827
-
828
- **⚡ Accusation Finale**
829
- - Si vous pensez connaître la solution, faites une accusation
830
- - ✅ **Correct** = Vous gagnez immédiatement !
831
- - ❌ **Faux** = Vous êtes éliminé de l'enquête (mais pouvez encore réfuter)
832
-
833
- **⏭️ Passer le Tour**
834
- - Passez votre tour si vous n'avez rien à proposer
835
-
836
- ## 🏆 Conditions de Victoire
837
- - Premier joueur à faire une **accusation correcte**
838
- - Dernier joueur actif si tous les autres sont éliminés
839
-
840
- ## 💡 Conseils Stratégiques
841
- - Notez les cartes que vous voyez (sur papier)
842
- - Déduisez les cartes des autres joueurs par élimination
843
- - Ne faites pas d'accusation tant que vous n'êtes pas sûr !
844
- - Les suggestions peuvent forcer les joueurs à révéler des informations
845
-
846
- ## 🤖 Mode IA Narrateur (optionnel)
847
- Active une narration générée par IA incarnée dans Desland, un vieux jardinier mystérieux qui semble toujours en savoir plus qu'il ne devrait... Il se trompe souvent sur son nom (Leland? Non, c'est Desland...) et parle de manière étrangement suspicieuse, comme s'il cachait quelque chose de très sombre.
848
- """
849
- )
850
-
851
- with gr.Tab("🕯️ Créer une Partie"):
852
- gr.Markdown("### 📜 Établir un Nouveau Mystère")
853
- gr.Markdown("*Préparez la scène d'un meurtre des plus ignobles...*")
854
-
855
- game_name_input = gr.Textbox(
856
- label="🎭 Nom de l'enquête",
857
- placeholder="Le Meurtre au Manoir des Poupouilles",
858
- info="Donnez un nom à votre affaire (ex : armankaboul)",
859
- )
860
-
861
- rooms_input = gr.Textbox(
862
- label=f"🚪 Lieux de la scène de crime ({settings.MIN_ROOMS}-{settings.MAX_ROOMS} pièces)",
863
- placeholder="Le salon des péchailloux, La chambre du Viande, Bureau des Chnawax, Le B15 des Tchoupinoux, Le jardin de la poupouille",
864
- lines=4,
865
- info="Séparez les pièces par des virgules ou des retours à la ligne",
866
- )
867
-
868
- use_ai_checkbox = gr.Checkbox(
869
- label="🤖 Activer le Narrateur IA - Lesland... euh non Desland",
870
- value=False,
871
- visible=settings.USE_OPENAI,
872
- info="Un vieux jardinier suspicieux qui semble en savoir plus qu'il n'y paraît...",
873
- )
874
-
875
- create_btn = gr.Button(
876
- "🩸 Commencer l'Enquête", variant="primary", size="lg"
877
- )
878
- create_output = gr.Textbox(
879
- label="📋 Dossier de l'Affaire", lines=5, show_copy_button=True
880
- )
881
- game_id_display = gr.Textbox(
882
- label="🔑 Code d'Enquête (partagez avec les autres poupouilles masquées)",
883
- interactive=False,
884
- show_copy_button=True,
885
- )
886
-
887
- create_btn.click(
888
- create_game,
889
- inputs=[game_name_input, rooms_input, use_ai_checkbox],
890
- outputs=[create_output, game_id_display],
891
- )
892
-
893
- with gr.Tab("🕵️ Rejoindre"):
894
- gr.Markdown("### 👥 Entrer sur la Scène de Crime")
895
- gr.Markdown("*Rassemblez vos confrères détectives...*")
896
-
897
- with gr.Group():
898
- join_game_id = gr.Textbox(
899
- label="🔑 Code d'Enquête",
900
- placeholder="ABC123",
901
- info="Code fourni par le créateur de la partie",
902
- )
903
-
904
- join_player_name = gr.Textbox(
905
- label="🎩 Nom du Détective",
906
- placeholder="Chnawax Masquée",
907
- info="Votre nom d'enquêteur",
908
- )
909
-
910
- join_btn = gr.Button(
911
- "🚪 Rejoindre l'Enquête", variant="primary", size="lg"
912
- )
913
- join_output = gr.Textbox(
914
- label="📋 Statut", lines=3, show_copy_button=True
915
- )
916
-
917
- join_btn.click(
918
- join_game,
919
- inputs=[join_game_id, join_player_name],
920
- outputs=join_output,
921
- )
922
-
923
- gr.Markdown("---")
924
- gr.Markdown("### 🎬 Lancer l'Enquête")
925
- gr.Markdown(
926
- "*Une fois que tous les détectives sont présents (min. 3 poupouilles)*"
927
- )
928
-
929
- with gr.Group():
930
- start_game_id = gr.Textbox(
931
- label="🔑 Code d'Enquête",
932
- placeholder="ABC123",
933
- info="Seul le chnawax originel (le créateur) peut démarrer la partie",
934
- )
935
-
936
- start_btn = gr.Button(
937
- "⚡ Démarrer le Mystère", variant="secondary", size="lg"
938
- )
939
- start_output = gr.Textbox(label="📋 Statut", lines=2)
940
-
941
- start_btn.click(start_game, inputs=start_game_id, outputs=start_output)
942
-
943
- with gr.Tab("🔎 Enquêter"):
944
- gr.Markdown("### 📰 Tableau d'Enquête")
945
- gr.Markdown("*Étudiez les preuves et faites vos déductions...*")
946
-
947
- with gr.Group():
948
- refresh_btn = gr.Button(
949
- "🔄 Actualiser le Dossier", size="lg", variant="secondary"
950
- )
951
- game_view = gr.Textbox(
952
- label="🗂️ Dossier du Détective",
953
- lines=20,
954
- max_lines=30,
955
- show_copy_button=True,
956
- info="Cliquez sur Actualiser pour voir l'état actuel de la partie",
957
- )
958
-
959
- refresh_btn.click(get_player_view, outputs=game_view)
960
-
961
- gr.Markdown("---")
962
- gr.Markdown("### 🔮 Proposition de Théorie")
963
- gr.Markdown("*Testez une hypothèse auprès des autres détectives...*")
964
-
965
- with gr.Group():
966
- with gr.Row():
967
- suggest_character = gr.Dropdown(
968
- label="👤 Suspect",
969
- choices=DEFAULT_CHARACTERS,
970
- info="Choisissez un personnage",
971
- )
972
- suggest_weapon = gr.Dropdown(
973
- label="🔪 Arme du Crime",
974
- choices=DEFAULT_WEAPONS,
975
- info="Choisissez une arme",
976
- )
977
- suggest_room = gr.Dropdown(
978
- label="🚪 Lieu du Crime",
979
- choices=[], # Will be populated from game
980
- info="Choisissez un lieu",
981
- )
982
-
983
- suggest_btn = gr.Button(
984
- "💭 Proposer une Théorie", variant="primary", size="lg"
985
- )
986
- suggest_output = gr.Textbox(
987
- label="🗨️ Réponse", lines=3, show_copy_button=True
988
- )
989
-
990
- suggest_btn.click(
991
- make_suggestion,
992
- inputs=[suggest_character, suggest_weapon, suggest_room],
993
- outputs=suggest_output,
994
- )
995
-
996
- gr.Markdown("---")
997
- gr.Markdown("### ⚖️ Accusation Finale")
998
- gr.Markdown(
999
- "### ⚠️ *Yamete cudasaï ! Une fausse accusation vous élimine de l'enquête !*"
1000
- )
1001
-
1002
- with gr.Group():
1003
- with gr.Row():
1004
- accuse_character = gr.Dropdown(
1005
- label="👤 Le Meurtrier",
1006
- choices=DEFAULT_CHARACTERS,
1007
- info="Qui a commis le crime ?",
1008
- )
1009
- accuse_weapon = gr.Dropdown(
1010
- label="🔪 L'Arme",
1011
- choices=DEFAULT_WEAPONS,
1012
- info="Avec quelle arme ?",
1013
- )
1014
- accuse_room = gr.Dropdown(
1015
- label="🚪 Le Lieu", choices=[], info="Dans quel lieu ?"
1016
- )
1017
-
1018
- accuse_btn = gr.Button(
1019
- "⚡ FAIRE L'ACCUSATION", variant="stop", size="lg"
1020
- )
1021
- accuse_output = gr.Textbox(
1022
- label="⚖️ Verdict", lines=3, show_copy_button=True
1023
- )
1024
-
1025
- accuse_btn.click(
1026
- make_accusation,
1027
- inputs=[accuse_character, accuse_weapon, accuse_room],
1028
- outputs=accuse_output,
1029
- )
1030
-
1031
- gr.Markdown("---")
1032
-
1033
- with gr.Group():
1034
- pass_btn = gr.Button(
1035
- "⏭️ Passer Mon Tour", variant="secondary", size="lg"
1036
- )
1037
- pass_output = gr.Textbox(label="📋 Statut", lines=1)
1038
-
1039
- pass_btn.click(pass_turn, outputs=pass_output)
1040
-
1041
- return demo
1042
-
1043
-
1044
- def run_fastapi():
1045
- """
1046
- Run FastAPI server in a separate thread.
1047
- """
1048
- from api import app
1049
-
1050
- uvicorn.run(app, host=settings.HOST, port=settings.PORT, log_level="info")
1051
-
1052
-
1053
- if __name__ == "__main__":
1054
- # IS_HUGGINGFACE is already defined at the top of the file
1055
-
1056
- if not IS_HUGGINGFACE:
1057
- # Local development: run FastAPI in background
1058
- def run_fastapi_bg():
1059
- """Run FastAPI on port 8000 in background"""
1060
- from api import app
1061
-
1062
- uvicorn.run(app, host="0.0.0.0", port=8000, log_level="info")
1063
-
1064
- api_thread = threading.Thread(target=run_fastapi_bg, daemon=True)
1065
- api_thread.start()
1066
-
1067
- # Wait for API to start
1068
- time.sleep(2)
1069
-
1070
- # Create and launch Gradio interface
1071
- demo = create_gradio_interface()
1072
-
1073
- if IS_HUGGINGFACE:
1074
- # On Hugging Face Spaces: Gradio only on port 7860 (no FastAPI)
1075
- demo.launch(
1076
- server_name="0.0.0.0",
1077
- share=False,
1078
- show_error=True,
1079
- )
1080
- else:
1081
- # Local development: Gradio on port 7861, FastAPI on 8000
1082
- demo.launch(
1083
- server_name="127.0.0.1",
1084
- server_port=7861,
1085
- share=False,
1086
- show_error=True,
1087
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ai_service.py → backend/ai_service.py RENAMED
@@ -5,7 +5,7 @@ Only active when USE_OPENAI environment variable is set to true.
5
 
6
  from typing import Optional
7
  from openai import OpenAI
8
- from config import settings
9
  import asyncio
10
 
11
 
@@ -26,7 +26,8 @@ class AIService:
26
  async def generate_scenario(
27
  self,
28
  rooms: list[str],
29
- characters: list[str]
 
30
  ) -> Optional[str]:
31
  """
32
  Generate a mystery scenario based on the game setup.
@@ -36,22 +37,19 @@ class AIService:
36
  return None
37
 
38
  try:
39
- prompt = f"""Create a brief mystery scenario (2-3 sentences) for a Cluedo game narrated by Desland, an old suspicious gardener.
40
 
41
- IMPORTANT: The narrator is Desland (he used to say his name was Leland, but he always corrects himself to Desland). He's an old gardener who is extremely suspicious and seems to know dark secrets about what haunts this place. He speaks in a creepy, unsettling manner, always pretending everything is fine while subtly hinting at something deeply wrong. He never openly reveals the horror, but his words should make players feel uneasy.
42
 
43
- Rooms: {', '.join(rooms)}
44
- Characters: {', '.join(characters)}
 
45
 
46
- Start with Desland introducing himself (getting his name wrong first: "Je suis Leland... euh non, c'est Desland, Desland..." or variations), then write his introduction to the murder mystery. Make it atmospheric, creepy, and subtly horrifying. He should act like everything is normal while hinting at dark secrets."""
47
 
48
  # Run with timeout
49
  response = await asyncio.wait_for(
50
- asyncio.to_thread(
51
- self._generate_text,
52
- prompt
53
- ),
54
- timeout=3.0
55
  )
56
 
57
  return response
@@ -63,65 +61,127 @@ Start with Desland introducing himself (getting his name wrong first: "Je suis L
63
  print(f"Error generating scenario: {e}")
64
  return None
65
 
66
- async def generate_turn_narration(
67
  self,
68
  player_name: str,
69
- action: str
 
 
 
 
70
  ) -> Optional[str]:
71
  """
72
- Generate narrative text for a player's turn.
73
  Returns None if AI is disabled or if generation fails.
74
  """
75
  if not self.enabled or not self.client:
76
  return None
77
 
78
  try:
79
- prompt = f"""Create a brief, atmospheric narration (1 sentence) for this Cluedo game action, narrated by Desland the suspicious old gardener:
 
80
 
81
- Player: {player_name}
82
- Action: {action}
 
83
 
84
- Desland comments on the action. He's creepy and unsettling, always acting like everything is normal while subtly hinting at dark secrets. He knows something sinister about what haunts this place but pretends everything is fine. Make it suspenseful and slightly horrifying but very concise (1 sentence)."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
 
86
  response = await asyncio.wait_for(
87
- asyncio.to_thread(
88
- self._generate_text,
89
- prompt
90
- ),
91
- timeout=3.0
92
  )
93
 
94
  return response
95
 
96
  except asyncio.TimeoutError:
97
- print("AI narration generation timed out")
98
  return None
99
  except Exception as e:
100
- print(f"Error generating narration: {e}")
101
  return None
102
 
103
  def _generate_text(self, prompt: str) -> str:
104
  """
105
  Internal method to generate text using OpenAI API.
106
- Uses low temperature for consistent output.
107
  """
108
  if not self.client:
109
  return ""
110
 
111
  response = self.client.chat.completions.create(
112
- model="gpt-5-nano",
113
  messages=[
114
  {
115
  "role": "system",
116
- "content": "You are Desland, a creepy old gardener narrating a mystery game. You're deeply suspicious and seem to know dark secrets about what haunts the place. You always pretend everything is fine while subtly hinting at something sinister. You often get your own name wrong at first (saying Leland instead of Desland). Your tone is unsettling but you never openly reveal the horror. Keep responses brief, atmospheric, and creepy."
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  },
118
- {
119
- "role": "user",
120
- "content": prompt
121
- }
122
  ],
123
- temperature=0.8,
124
- max_tokens=200
125
  )
126
 
127
  return response.choices[0].message.content.strip()
 
5
 
6
  from typing import Optional
7
  from openai import OpenAI
8
+ from backend.config import settings
9
  import asyncio
10
 
11
 
 
26
  async def generate_scenario(
27
  self,
28
  rooms: list[str],
29
+ characters: list[str],
30
+ narrative_tone: str = "🕵️ Sérieuse",
31
  ) -> Optional[str]:
32
  """
33
  Generate a mystery scenario based on the game setup.
 
37
  return None
38
 
39
  try:
40
+ prompt = f"""Crée un scénario de mystère bref (2-3 phrases) pour un jeu de Cluedo narré par Desland.
41
 
42
+ IMPORTANT: Desland est un vieux jardinier suspect, sarcastique et incisif. Il se trompe TOUJOURS sur son nom au début: "Moi c'est Lesland, euh non c'est Desland, Desland !" (ou variations). Il est condescendant, moqueur envers les détectives, et fait des remarques cinglantes.
43
 
44
+ Ton narratif: {narrative_tone}
45
+ Pièces: {', '.join(rooms)}
46
+ Personnages: {', '.join(characters)}
47
 
48
+ COMMENCE obligatoirement par Desland se trompant sur son nom, puis introduis le meurtre avec son ton sarcastique et suspect caractéristique. Moque subtilement la situation et l'intelligence des enquêteurs."""
49
 
50
  # Run with timeout
51
  response = await asyncio.wait_for(
52
+ asyncio.to_thread(self._generate_text, prompt), timeout=10.0
 
 
 
 
53
  )
54
 
55
  return response
 
61
  print(f"Error generating scenario: {e}")
62
  return None
63
 
64
+ async def generate_suggestion_comment(
65
  self,
66
  player_name: str,
67
+ character: str,
68
+ weapon: str,
69
+ room: str,
70
+ was_disproven: bool,
71
+ narrative_tone: str = "🕵️ Sérieuse",
72
  ) -> Optional[str]:
73
  """
74
+ Generate a sarcastic comment from Desland about a suggestion.
75
  Returns None if AI is disabled or if generation fails.
76
  """
77
  if not self.enabled or not self.client:
78
  return None
79
 
80
  try:
81
+ result = "réfutée" if was_disproven else "pas réfutée"
82
+ prompt = f"""Desland, le vieux jardinier sarcastique, commente cette suggestion (1 phrase max):
83
 
84
+ Joueur: {player_name}
85
+ Suggestion: {character} avec {weapon} dans {room}
86
+ Résultat: {result}
87
 
88
+ IMPORTANT: Desland est SARCASTIQUE et INCISIF. Il se moque des théories absurdes avec des remarques cinglantes. Exemples:
89
+ - "Et toi ça te semble logique que Pierre ait tué Daniel avec une clé USB à côté de l'étendoir ?? Sans surprise c'est pas la bonne réponse..."
90
+ - "Une capsule de café comme arme du crime ? Brillant. Je suppose qu'il l'a noyé dans un expresso."
91
+ - "Ah oui, très crédible. Le meurtrier qui laisse traîner son arme préférée dans la salle de bain. Excellent travail, détective."
92
+
93
+ Ton narratif: {narrative_tone}
94
+ Sois sarcastique, condescendant et incisif. Moque la logique (ou l'absence de logique) de la suggestion."""
95
+
96
+ response = await asyncio.wait_for(
97
+ asyncio.to_thread(self._generate_text, prompt), timeout=10.0
98
+ )
99
+
100
+ return response
101
+
102
+ except asyncio.TimeoutError:
103
+ print("AI comment generation timed out")
104
+ return None
105
+ except Exception as e:
106
+ print(f"Error generating comment: {e}")
107
+ return None
108
+
109
+ async def generate_accusation_comment(
110
+ self,
111
+ player_name: str,
112
+ character: str,
113
+ weapon: str,
114
+ room: str,
115
+ was_correct: bool,
116
+ narrative_tone: str = "🕵️ Sérieuse",
117
+ ) -> Optional[str]:
118
+ """
119
+ Generate a comment from Desland about an accusation.
120
+ Returns None if AI is disabled or if generation fails.
121
+ """
122
+ if not self.enabled or not self.client:
123
+ return None
124
+
125
+ try:
126
+ result = "correcte" if was_correct else "fausse"
127
+ prompt = f"""Desland commente cette accusation finale (1 phrase max):
128
+
129
+ Joueur: {player_name}
130
+ Accusation: {character} avec {weapon} dans {room}
131
+ Résultat: {result}
132
+
133
+ Ton narratif: {narrative_tone}
134
+
135
+ Si correcte: Desland est surpris et impressionné à contrecœur (mais toujours sarcastique).
136
+ Si fausse: Desland est condescendant et moqueur à propos de leur échec.
137
+
138
+ Rends-le incisif et mémorable."""
139
 
140
  response = await asyncio.wait_for(
141
+ asyncio.to_thread(self._generate_text, prompt), timeout=10.0
 
 
 
 
142
  )
143
 
144
  return response
145
 
146
  except asyncio.TimeoutError:
147
+ print("AI comment generation timed out")
148
  return None
149
  except Exception as e:
150
+ print(f"Error generating comment: {e}")
151
  return None
152
 
153
  def _generate_text(self, prompt: str) -> str:
154
  """
155
  Internal method to generate text using OpenAI API.
156
+ Uses higher temperature for creative sarcasm.
157
  """
158
  if not self.client:
159
  return ""
160
 
161
  response = self.client.chat.completions.create(
162
+ model="gpt-4o-mini",
163
  messages=[
164
  {
165
  "role": "system",
166
+ "content": """Tu es Desland, un vieux jardinier suspect, sarcastique et incisif.
167
+
168
+ Traits clés:
169
+ - SARCASTIQUE: Tu te moques des théories absurdes et des déductions illogiques avec des remarques cinglantes
170
+ - INCISIF: Tes commentaires sont aiguisés, spirituels et parfois condescendants
171
+ - SUSPECT: Tu agis comme si tu en savais plus que tu ne le dis, mais tu ne révèles jamais rien directement
172
+ - Tu te trompes SOUVENT sur ton nom: "Moi c'est Lesland, euh non c'est Desland, Desland !" (surtout en introduction)
173
+
174
+ Exemples de ton style:
175
+ "Et toi ça te semble logique que Pierre ait tué Daniel avec une clé USB à côté de l'étendoir ?? Sans surprise c'est pas la bonne réponse..."
176
+ "Une capsule de café ? Brillant. Parce que évidemment, on commet des meurtres avec du Nespresso maintenant."
177
+ "Ah oui, excellente déduction Sherlock. Prochaine étape : accuser le chat du voisin."
178
+
179
+ Garde tes réponses brèves (1 phrase pour les commentaires, 2-3 pour les scénarios), EN FRANÇAIS, sarcastiques et mémorables.""",
180
  },
181
+ {"role": "user", "content": prompt},
 
 
 
182
  ],
183
+ temperature=0.9,
184
+ max_tokens=150,
185
  )
186
 
187
  return response.choices[0].message.content.strip()
config.py → backend/config.py RENAMED
File without changes
backend/defaults.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Default game presets for quick setup
3
+ """
4
+
5
+ DEFAULT_THEMES = {
6
+ "classic": {
7
+ "name": "Meurtre au Manoir",
8
+ "tone": "🎬 Thriller",
9
+ "rooms": [
10
+ "Cuisine",
11
+ "Salon",
12
+ "Bureau",
13
+ "Chambre",
14
+ "Garage",
15
+ "Jardin"
16
+ ],
17
+ "weapons": [
18
+ "Poignard",
19
+ "Revolver",
20
+ "Corde",
21
+ "Chandelier",
22
+ "Clé anglaise",
23
+ "Poison"
24
+ ],
25
+ "suspects": [
26
+ "Mme Leblanc",
27
+ "Col. Moutarde",
28
+ "Mlle Rose",
29
+ "Prof. Violet",
30
+ "Mme Pervenche",
31
+ "M. Olive"
32
+ ]
33
+ },
34
+ "office": {
35
+ "name": "Meurtre au Bureau",
36
+ "tone": "😂 Parodique",
37
+ "rooms": [
38
+ "Open Space",
39
+ "Salle de réunion",
40
+ "Cafétéria",
41
+ "Bureau CEO",
42
+ "Toilettes",
43
+ "Parking"
44
+ ],
45
+ "weapons": [
46
+ "Clé USB",
47
+ "Agrafeuse",
48
+ "Câble HDMI",
49
+ "Capsule de café",
50
+ "Souris d'ordinateur",
51
+ "Plante verte"
52
+ ],
53
+ "suspects": [
54
+ "Claire",
55
+ "Pierre",
56
+ "Daniel",
57
+ "Marie",
58
+ "Thomas",
59
+ "Sophie"
60
+ ]
61
+ },
62
+ "fantasy": {
63
+ "name": "Meurtre au Château",
64
+ "tone": "🧙‍♂️ Fantastique",
65
+ "rooms": [
66
+ "Grande Salle",
67
+ "Tour des Mages",
68
+ "Donjon",
69
+ "Bibliothèque",
70
+ "Armurerie",
71
+ "Crypte"
72
+ ],
73
+ "weapons": [
74
+ "Épée enchantée",
75
+ "Potion empoisonnée",
76
+ "Grimoire maudit",
77
+ "Dague runique",
78
+ "Bâton magique",
79
+ "Amulette sombre"
80
+ ],
81
+ "suspects": [
82
+ "Merlin le Sage",
83
+ "Dame Morgane",
84
+ "Chevalier Lancelot",
85
+ "Elfe Aranelle",
86
+ "Nain Thorin",
87
+ "Sorcière Malva"
88
+ ]
89
+ }
90
+ }
91
+
92
+ # Default theme to use
93
+ DEFAULT_THEME = "classic"
94
+
95
+ def get_default_game_config(theme: str = DEFAULT_THEME):
96
+ """Get default game configuration"""
97
+ if theme not in DEFAULT_THEMES:
98
+ theme = DEFAULT_THEME
99
+
100
+ config = DEFAULT_THEMES[theme].copy()
101
+ return config
game_engine.py → backend/game_engine.py RENAMED
@@ -5,11 +5,11 @@ Handles game logic, card distribution, turn management, and game rules.
5
 
6
  import random
7
  from typing import List, Optional, Tuple
8
- from models import Game, Card, CardType, Solution, GameStatus, Player
9
  from datetime import datetime
10
 
11
 
12
- # Default character names (can be customized)
13
  DEFAULT_CHARACTERS = [
14
  "Miss Scarlett",
15
  "Colonel Mustard",
@@ -19,7 +19,7 @@ DEFAULT_CHARACTERS = [
19
  "Professor Plum"
20
  ]
21
 
22
- # Default weapon names (can be customized)
23
  DEFAULT_WEAPONS = [
24
  "Candlestick",
25
  "Knife",
@@ -39,16 +39,20 @@ class GameEngine:
39
  Initialize a game with cards and solution.
40
  Distributes cards among players after setting aside the solution.
41
  """
 
 
 
 
42
  # Create character cards
43
  game.characters = [
44
  Card(name=name, card_type=CardType.CHARACTER)
45
- for name in DEFAULT_CHARACTERS
46
  ]
47
 
48
  # Create weapon cards
49
  game.weapons = [
50
  Card(name=name, card_type=CardType.WEAPON)
51
- for name in DEFAULT_WEAPONS
52
  ]
53
 
54
  # Create room cards
@@ -206,10 +210,11 @@ class GameEngine:
206
  game: Game,
207
  player_id: str,
208
  action: str,
209
- details: Optional[str] = None
 
210
  ):
211
  """Add a turn record to the game history."""
212
- from models import Turn
213
 
214
  player = next((p for p in game.players if p.id == player_id), None)
215
  if not player:
@@ -220,6 +225,7 @@ class GameEngine:
220
  player_name=player.name,
221
  action=action,
222
  details=details,
 
223
  timestamp=datetime.now().isoformat()
224
  )
225
  game.turns.append(turn)
@@ -241,3 +247,49 @@ class GameEngine:
241
  current_player.is_active and
242
  game.status == GameStatus.IN_PROGRESS
243
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
  import random
7
  from typing import List, Optional, Tuple
8
+ from backend.models import Game, Card, CardType, Solution, GameStatus, Player
9
  from datetime import datetime
10
 
11
 
12
+ # Default character names (fallback if no custom suspects provided)
13
  DEFAULT_CHARACTERS = [
14
  "Miss Scarlett",
15
  "Colonel Mustard",
 
19
  "Professor Plum"
20
  ]
21
 
22
+ # Default weapon names (fallback if no custom weapons provided)
23
  DEFAULT_WEAPONS = [
24
  "Candlestick",
25
  "Knife",
 
39
  Initialize a game with cards and solution.
40
  Distributes cards among players after setting aside the solution.
41
  """
42
+ # Use custom suspects or defaults
43
+ suspects = game.custom_suspects if game.custom_suspects else DEFAULT_CHARACTERS
44
+ weapons = game.custom_weapons if game.custom_weapons else DEFAULT_WEAPONS
45
+
46
  # Create character cards
47
  game.characters = [
48
  Card(name=name, card_type=CardType.CHARACTER)
49
+ for name in suspects
50
  ]
51
 
52
  # Create weapon cards
53
  game.weapons = [
54
  Card(name=name, card_type=CardType.WEAPON)
55
+ for name in weapons
56
  ]
57
 
58
  # Create room cards
 
210
  game: Game,
211
  player_id: str,
212
  action: str,
213
+ details: Optional[str] = None,
214
+ ai_comment: Optional[str] = None
215
  ):
216
  """Add a turn record to the game history."""
217
+ from backend.models import Turn
218
 
219
  player = next((p for p in game.players if p.id == player_id), None)
220
  if not player:
 
225
  player_name=player.name,
226
  action=action,
227
  details=details,
228
+ ai_comment=ai_comment,
229
  timestamp=datetime.now().isoformat()
230
  )
231
  game.turns.append(turn)
 
247
  current_player.is_active and
248
  game.status == GameStatus.IN_PROGRESS
249
  )
250
+
251
+ @staticmethod
252
+ def roll_dice() -> int:
253
+ """Roll a single die (1-6)."""
254
+ return random.randint(1, 6)
255
+
256
+ @staticmethod
257
+ def move_player(game: Game, player_id: str, dice_roll: int) -> Tuple[bool, str, int]:
258
+ """
259
+ Move a player to a room based on dice roll.
260
+ Dice value maps directly to room index (1→room 0, 2→room 1, etc.)
261
+ Returns (success, message, new_room_index).
262
+ """
263
+ player = next((p for p in game.players if p.id == player_id), None)
264
+ if not player:
265
+ return False, "Joueur introuvable", -1
266
+
267
+ num_rooms = len(game.rooms)
268
+ if num_rooms == 0:
269
+ return False, "Pas de pièces disponibles", -1
270
+
271
+ # Map dice roll to room (1-6 maps to rooms, if more rooms, use modulo)
272
+ new_room_index = (dice_roll - 1) % num_rooms
273
+ old_room = game.rooms[player.current_room_index] if player.current_room_index < num_rooms else "???"
274
+ new_room = game.rooms[new_room_index]
275
+
276
+ player.current_room_index = new_room_index
277
+
278
+ message = f"🎲 Dé: {dice_roll} → {new_room}"
279
+ return True, message, new_room_index
280
+
281
+ @staticmethod
282
+ def can_make_suggestion(game: Game, player_id: str, room: str) -> Tuple[bool, str]:
283
+ """
284
+ Check if a player can make a suggestion.
285
+ Players can only suggest in the room they're currently in.
286
+ """
287
+ player = next((p for p in game.players if p.id == player_id), None)
288
+ if not player:
289
+ return False, "Joueur introuvable"
290
+
291
+ current_room = game.rooms[player.current_room_index]
292
+ if current_room != room:
293
+ return False, f"Tu dois être dans {room} pour faire cette suggestion ! Tu es actuellement dans {current_room}."
294
+
295
+ return True, ""
game_manager.py → backend/game_manager.py RENAMED
@@ -6,9 +6,9 @@ Provides in-memory storage and game lifecycle management.
6
  import json
7
  import os
8
  from typing import Dict, Optional, List
9
- from models import Game, Player, CreateGameRequest, GameStatus
10
- from game_engine import GameEngine
11
- from config import settings
12
 
13
 
14
  class GameManager:
@@ -31,8 +31,13 @@ class GameManager:
31
  game = Game(
32
  game_id=game_id,
33
  name=request.game_name,
 
 
34
  rooms=request.rooms,
 
 
35
  use_ai=request.use_ai,
 
36
  max_players=settings.MAX_PLAYERS
37
  )
38
 
 
6
  import json
7
  import os
8
  from typing import Dict, Optional, List
9
+ from backend.models import Game, Player, CreateGameRequest, GameStatus
10
+ from backend.game_engine import GameEngine
11
+ from backend.config import settings
12
 
13
 
14
  class GameManager:
 
31
  game = Game(
32
  game_id=game_id,
33
  name=request.game_name,
34
+ narrative_tone=request.narrative_tone,
35
+ custom_prompt=request.custom_prompt,
36
  rooms=request.rooms,
37
+ custom_weapons=request.custom_weapons,
38
+ custom_suspects=request.custom_suspects,
39
  use_ai=request.use_ai,
40
+ board_layout=request.board_layout,
41
  max_players=settings.MAX_PLAYERS
42
  )
43
 
backend/main.py ADDED
@@ -0,0 +1,467 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI backend for Cluedo Custom
3
+ Serves both API and React frontend
4
+ """
5
+
6
+ from fastapi import FastAPI, HTTPException
7
+ from fastapi.middleware.cors import CORSMiddleware
8
+ from fastapi.staticfiles import StaticFiles
9
+ from fastapi.responses import FileResponse
10
+ from pydantic import BaseModel
11
+ from typing import Optional, List
12
+ import os
13
+
14
+ from backend.models import CreateGameRequest, GameStatus
15
+ from backend.game_manager import game_manager
16
+ from backend.game_engine import GameEngine
17
+ from backend.defaults import get_default_game_config, DEFAULT_THEMES
18
+
19
+ app = FastAPI(title="Cluedo Custom API")
20
+
21
+ # CORS for development
22
+ app.add_middleware(
23
+ CORSMiddleware,
24
+ allow_origins=["*"],
25
+ allow_credentials=True,
26
+ allow_methods=["*"],
27
+ allow_headers=["*"],
28
+ )
29
+
30
+ # ==================== API ROUTES ====================
31
+
32
+ @app.get("/api/health")
33
+ async def health():
34
+ return {"status": "ok", "message": "Cluedo Custom API is running"}
35
+
36
+
37
+ @app.get("/api/themes")
38
+ async def get_themes():
39
+ """Get available game themes"""
40
+ return {"themes": DEFAULT_THEMES}
41
+
42
+
43
+ class QuickCreateRequest(BaseModel):
44
+ theme: str = "classic"
45
+ player_name: str
46
+
47
+
48
+ @app.post("/api/games/quick-create")
49
+ async def quick_create_game(req: QuickCreateRequest):
50
+ """Create a game quickly with default theme"""
51
+ try:
52
+ config = get_default_game_config(req.theme)
53
+
54
+ # Create default board layout
55
+ from backend.models import BoardLayout, RoomPosition
56
+ board_layout = BoardLayout(
57
+ rooms=[
58
+ RoomPosition(name=room, x=i % 3, y=i // 3)
59
+ for i, room in enumerate(config["rooms"])
60
+ ],
61
+ grid_width=3,
62
+ grid_height=2
63
+ )
64
+
65
+ game_req = CreateGameRequest(
66
+ game_name=config["name"],
67
+ narrative_tone=config["tone"],
68
+ custom_prompt=None,
69
+ rooms=config["rooms"],
70
+ custom_weapons=config["weapons"],
71
+ custom_suspects=config["suspects"],
72
+ use_ai=True, # Enable AI by default
73
+ board_layout=board_layout
74
+ )
75
+
76
+ game = game_manager.create_game(game_req)
77
+
78
+ # Auto-join creator as first player
79
+ player = game_manager.join_game(game.game_id, req.player_name)
80
+
81
+ return {
82
+ "game_id": game.game_id,
83
+ "player_id": player.id if player else None,
84
+ "game_name": game.name,
85
+ "theme": req.theme
86
+ }
87
+ except Exception as e:
88
+ raise HTTPException(status_code=500, detail=str(e))
89
+
90
+
91
+ class JoinRequest(BaseModel):
92
+ game_id: str
93
+ player_name: str
94
+
95
+
96
+ @app.post("/api/games/join")
97
+ async def join_game(req: JoinRequest):
98
+ """Join an existing game"""
99
+ game = game_manager.get_game(req.game_id.upper())
100
+
101
+ if not game:
102
+ raise HTTPException(status_code=404, detail="Game not found")
103
+
104
+ if game.status != GameStatus.WAITING:
105
+ raise HTTPException(status_code=400, detail="Game already started")
106
+
107
+ if game.is_full():
108
+ raise HTTPException(status_code=400, detail="Game is full")
109
+
110
+ player = game_manager.join_game(req.game_id.upper(), req.player_name)
111
+
112
+ if not player:
113
+ raise HTTPException(status_code=400, detail="Could not join game")
114
+
115
+ return {
116
+ "game_id": game.game_id,
117
+ "player_id": player.id,
118
+ "player_name": player.name
119
+ }
120
+
121
+
122
+ @app.post("/api/games/{game_id}/start")
123
+ async def start_game(game_id: str):
124
+ """Start a game"""
125
+ success = game_manager.start_game(game_id.upper())
126
+
127
+ if not success:
128
+ raise HTTPException(status_code=400, detail="Cannot start game (need min 3 players)")
129
+
130
+ game = game_manager.get_game(game_id.upper())
131
+
132
+ # Generate AI scenario if enabled
133
+ if game and game.use_ai and not game.scenario:
134
+ try:
135
+ from backend.ai_service import ai_service
136
+ game.scenario = await ai_service.generate_scenario(
137
+ game.rooms,
138
+ [c.name for c in game.characters],
139
+ game.narrative_tone
140
+ )
141
+ game_manager.save_games()
142
+ except Exception as e:
143
+ print(f"AI scenario generation failed: {e}")
144
+
145
+ return {
146
+ "status": "started",
147
+ "first_player": game.get_current_player().name if game else None,
148
+ "scenario": game.scenario if game else None
149
+ }
150
+
151
+
152
+ @app.get("/api/games/{game_id}/state/{player_id}")
153
+ async def get_game_state(game_id: str, player_id: str):
154
+ """Get game state for a specific player"""
155
+ game = game_manager.get_game(game_id.upper())
156
+
157
+ if not game:
158
+ raise HTTPException(status_code=404, detail="Game not found")
159
+
160
+ player = next((p for p in game.players if p.id == player_id), None)
161
+
162
+ if not player:
163
+ raise HTTPException(status_code=404, detail="Player not found")
164
+
165
+ current_player = game.get_current_player()
166
+
167
+ # Build player-specific view
168
+ return {
169
+ "game_id": game.game_id,
170
+ "game_name": game.name,
171
+ "status": game.status.value,
172
+ "scenario": game.scenario,
173
+ "use_ai": game.use_ai,
174
+ "rooms": game.rooms,
175
+ "suspects": [c.name for c in game.characters],
176
+ "weapons": [w.name for w in game.weapons],
177
+ "my_cards": [{"name": c.name, "type": c.card_type.value} for c in player.cards],
178
+ "my_position": player.current_room_index,
179
+ "current_room": game.rooms[player.current_room_index] if game.rooms else None,
180
+ "board_layout": game.board_layout.model_dump() if game.board_layout else None,
181
+ "players": [
182
+ {
183
+ "name": p.name,
184
+ "is_active": p.is_active,
185
+ "position": p.current_room_index,
186
+ "room": game.rooms[p.current_room_index] if game.rooms else None,
187
+ "is_me": p.id == player_id
188
+ }
189
+ for p in game.players
190
+ ],
191
+ "current_turn": {
192
+ "player_name": current_player.name if current_player else None,
193
+ "is_my_turn": current_player.id == player_id if current_player else False
194
+ },
195
+ "recent_actions": [
196
+ {
197
+ "player": t.player_name,
198
+ "action": t.action,
199
+ "details": t.details,
200
+ "ai_comment": t.ai_comment
201
+ }
202
+ for t in game.turns[-10:] # Show more history
203
+ ],
204
+ "winner": game.winner
205
+ }
206
+
207
+
208
+ class DiceRollRequest(BaseModel):
209
+ player_id: str
210
+
211
+
212
+ @app.post("/api/games/{game_id}/roll")
213
+ async def roll_dice(game_id: str, req: DiceRollRequest):
214
+ """Roll dice and move player"""
215
+ game = game_manager.get_game(game_id.upper())
216
+
217
+ if not game:
218
+ raise HTTPException(status_code=404, detail="Game not found")
219
+
220
+ if not GameEngine.can_player_act(game, req.player_id):
221
+ raise HTTPException(status_code=400, detail="Not your turn")
222
+
223
+ # Check if player already rolled
224
+ player = next((p for p in game.players if p.id == req.player_id), None)
225
+ if player and player.has_rolled:
226
+ raise HTTPException(status_code=400, detail="Vous avez déjà lancé les dés ce tour")
227
+
228
+ # Roll dice
229
+ dice = GameEngine.roll_dice()
230
+
231
+ # Move player
232
+ success, msg, new_pos = GameEngine.move_player(game, req.player_id, dice)
233
+
234
+ if not success:
235
+ raise HTTPException(status_code=400, detail=msg)
236
+
237
+ # Get player name
238
+ player = next((p for p in game.players if p.id == req.player_id), None)
239
+ player_name = player.name if player else "Inconnu"
240
+
241
+ # Generate AI comment if enabled
242
+ ai_comment = None
243
+ if game.use_ai:
244
+ try:
245
+ from backend.ai_service import ai_service
246
+ # Simple comment about movement
247
+ prompts = [
248
+ f"{player_name} se dirige vers {game.rooms[new_pos]}... Intéressant choix.",
249
+ f"Ah, {game.rooms[new_pos]}. {player_name} pense y trouver quelque chose ?",
250
+ f"{player_name} va fouiner dans {game.rooms[new_pos]}. Bonne chance avec ça."
251
+ ]
252
+ import random
253
+ ai_comment = random.choice(prompts)
254
+ except Exception as e:
255
+ print(f"AI comment generation failed: {e}")
256
+
257
+ # Mark player as having rolled
258
+ if player:
259
+ player.has_rolled = True
260
+
261
+ # Record turn with AI comment
262
+ GameEngine.add_turn_record(game, req.player_id, "move", msg, ai_comment=ai_comment)
263
+ game_manager.save_games()
264
+
265
+ return {
266
+ "dice_value": dice,
267
+ "new_position": new_pos,
268
+ "new_room": game.rooms[new_pos],
269
+ "message": msg
270
+ }
271
+
272
+
273
+ class SuggestionRequest(BaseModel):
274
+ player_id: str
275
+ suspect: str
276
+ weapon: str
277
+ room: str
278
+
279
+
280
+ @app.post("/api/games/{game_id}/suggest")
281
+ async def make_suggestion(game_id: str, req: SuggestionRequest):
282
+ """Make a suggestion"""
283
+ game = game_manager.get_game(game_id.upper())
284
+
285
+ if not game:
286
+ raise HTTPException(status_code=404, detail="Game not found")
287
+
288
+ if not GameEngine.can_player_act(game, req.player_id):
289
+ raise HTTPException(status_code=400, detail="Not your turn")
290
+
291
+ # Check if player is in the room
292
+ can_suggest, error = GameEngine.can_make_suggestion(game, req.player_id, req.room)
293
+ if not can_suggest:
294
+ raise HTTPException(status_code=400, detail=error)
295
+
296
+ # Process suggestion
297
+ can_disprove, disprover, card = GameEngine.check_suggestion(
298
+ game, req.player_id, req.suspect, req.weapon, req.room
299
+ )
300
+
301
+ # Get player name
302
+ player = next((p for p in game.players if p.id == req.player_id), None)
303
+ player_name = player.name if player else "Inconnu"
304
+
305
+ # Generate AI comment if enabled
306
+ ai_comment = None
307
+ if game.use_ai:
308
+ try:
309
+ from backend.ai_service import ai_service
310
+ import asyncio
311
+ ai_comment = await ai_service.generate_suggestion_comment(
312
+ player_name,
313
+ req.suspect,
314
+ req.weapon,
315
+ req.room,
316
+ can_disprove,
317
+ game.narrative_tone
318
+ )
319
+ except Exception as e:
320
+ print(f"AI comment generation failed: {e}")
321
+
322
+ result = {
323
+ "suggestion": f"{req.suspect} + {req.weapon} + {req.room}",
324
+ "was_disproven": can_disprove,
325
+ "disprover": disprover if can_disprove else None,
326
+ "card_shown": {
327
+ "name": card.name,
328
+ "type": card.card_type.value
329
+ } if card else None
330
+ }
331
+
332
+ # Record turn with AI comment
333
+ GameEngine.add_turn_record(
334
+ game,
335
+ req.player_id,
336
+ "suggest",
337
+ result["suggestion"],
338
+ ai_comment=ai_comment
339
+ )
340
+
341
+ game.next_turn()
342
+ game_manager.save_games()
343
+
344
+ return result
345
+
346
+
347
+ class AccusationRequest(BaseModel):
348
+ player_id: str
349
+ suspect: str
350
+ weapon: str
351
+ room: str
352
+
353
+
354
+ @app.post("/api/games/{game_id}/accuse")
355
+ async def make_accusation(game_id: str, req: AccusationRequest):
356
+ """Make an accusation"""
357
+ game = game_manager.get_game(game_id.upper())
358
+
359
+ if not game:
360
+ raise HTTPException(status_code=404, detail="Game not found")
361
+
362
+ if not GameEngine.can_player_act(game, req.player_id):
363
+ raise HTTPException(status_code=400, detail="Not your turn")
364
+
365
+ # Process accusation
366
+ is_correct, message = GameEngine.process_accusation(
367
+ game, req.player_id, req.suspect, req.weapon, req.room
368
+ )
369
+
370
+ # Get player name
371
+ player = next((p for p in game.players if p.id == req.player_id), None)
372
+ player_name = player.name if player else "Inconnu"
373
+
374
+ # Generate AI comment if enabled
375
+ ai_comment = None
376
+ if game.use_ai:
377
+ try:
378
+ from backend.ai_service import ai_service
379
+ ai_comment = await ai_service.generate_accusation_comment(
380
+ player_name,
381
+ req.suspect,
382
+ req.weapon,
383
+ req.room,
384
+ is_correct,
385
+ game.narrative_tone
386
+ )
387
+ except Exception as e:
388
+ print(f"AI comment generation failed: {e}")
389
+
390
+ # Record turn with AI comment
391
+ GameEngine.add_turn_record(
392
+ game,
393
+ req.player_id,
394
+ "accuse",
395
+ f"{req.suspect} + {req.weapon} + {req.room}",
396
+ ai_comment=ai_comment
397
+ )
398
+
399
+ if not is_correct and game.status == GameStatus.IN_PROGRESS:
400
+ game.next_turn()
401
+
402
+ game_manager.save_games()
403
+
404
+ return {
405
+ "is_correct": is_correct,
406
+ "message": message,
407
+ "winner": game.winner
408
+ }
409
+
410
+
411
+ class PassRequest(BaseModel):
412
+ player_id: str
413
+
414
+
415
+ @app.post("/api/games/{game_id}/pass")
416
+ async def pass_turn(game_id: str, req: PassRequest):
417
+ """Pass the turn"""
418
+ game = game_manager.get_game(game_id.upper())
419
+
420
+ if not game:
421
+ raise HTTPException(status_code=404, detail="Game not found")
422
+
423
+ if not GameEngine.can_player_act(game, req.player_id):
424
+ raise HTTPException(status_code=400, detail="Not your turn")
425
+
426
+ # Record turn
427
+ GameEngine.add_turn_record(game, req.player_id, "pass", "Passed turn")
428
+
429
+ game.next_turn()
430
+ game_manager.save_games()
431
+
432
+ next_player = game.get_current_player()
433
+
434
+ return {
435
+ "message": "Turn passed",
436
+ "next_player": next_player.name if next_player else None
437
+ }
438
+
439
+
440
+ # ==================== SERVE REACT APP ====================
441
+
442
+ # Check if frontend build exists
443
+ frontend_path = os.path.join(os.path.dirname(__file__), "..", "frontend", "dist")
444
+
445
+ if os.path.exists(frontend_path):
446
+ # Serve static files
447
+ app.mount("/assets", StaticFiles(directory=os.path.join(frontend_path, "assets")), name="assets")
448
+
449
+ @app.get("/{full_path:path}")
450
+ async def serve_react(full_path: str):
451
+ """Serve React app for all non-API routes"""
452
+ if full_path.startswith("api/"):
453
+ raise HTTPException(status_code=404, detail="API route not found")
454
+
455
+ index_file = os.path.join(frontend_path, "index.html")
456
+ if os.path.exists(index_file):
457
+ return FileResponse(index_file)
458
+ else:
459
+ raise HTTPException(status_code=404, detail="Frontend not built")
460
+ else:
461
+ @app.get("/")
462
+ async def root():
463
+ return {
464
+ "message": "Cluedo Custom API",
465
+ "docs": "/docs",
466
+ "frontend": "Not built yet. Run: cd frontend && npm run build"
467
+ }
models.py → backend/models.py RENAMED
@@ -10,6 +10,15 @@ import random
10
  import string
11
 
12
 
 
 
 
 
 
 
 
 
 
13
  class CardType(str, Enum):
14
  """Types of cards in the game."""
15
  CHARACTER = "character"
@@ -29,6 +38,8 @@ class Player(BaseModel):
29
  name: str
30
  cards: List[Card] = Field(default_factory=list)
31
  is_active: bool = True
 
 
32
 
33
 
34
  class GameStatus(str, Enum):
@@ -42,8 +53,9 @@ class Turn(BaseModel):
42
  """Represents a turn action in the game."""
43
  player_id: str
44
  player_name: str
45
- action: str # "move", "suggest", "accuse"
46
  details: Optional[str] = None
 
47
  timestamp: str
48
 
49
 
@@ -54,12 +66,43 @@ class Solution(BaseModel):
54
  room: Card
55
 
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  class Game(BaseModel):
58
  """Represents a complete game instance."""
59
  game_id: str
60
  name: str
61
  status: GameStatus = GameStatus.WAITING
 
 
 
 
 
 
62
  rooms: List[str]
 
 
 
63
  use_ai: bool = False
64
 
65
  # Players
@@ -79,18 +122,26 @@ class Game(BaseModel):
79
  turns: List[Turn] = Field(default_factory=list)
80
  winner: Optional[str] = None
81
 
 
 
 
 
 
 
82
  # AI-generated content
83
  scenario: Optional[str] = None
84
 
85
  @staticmethod
86
  def generate_game_id() -> str:
87
- """Generate a unique 6-character game ID."""
88
- return ''.join(random.choices(string.ascii_uppercase + string.digits, k=6))
 
89
 
90
  def add_player(self, player_name: str) -> Player:
91
  """Add a new player to the game."""
92
  player_id = ''.join(random.choices(string.ascii_lowercase + string.digits, k=8))
93
- player = Player(id=player_id, name=player_name)
 
94
  self.players.append(player)
95
  return player
96
 
@@ -101,9 +152,21 @@ class Game(BaseModel):
101
  return self.players[self.current_player_index]
102
 
103
  def next_turn(self):
104
- """Move to the next player's turn."""
105
- if self.players:
 
 
 
 
 
 
 
 
 
106
  self.current_player_index = (self.current_player_index + 1) % len(self.players)
 
 
 
107
 
108
  def is_full(self) -> bool:
109
  """Check if the game has reached maximum players."""
@@ -113,8 +176,13 @@ class Game(BaseModel):
113
  class CreateGameRequest(BaseModel):
114
  """Request to create a new game."""
115
  game_name: str
 
 
116
  rooms: List[str]
 
 
117
  use_ai: bool = False
 
118
 
119
 
120
  class JoinGameRequest(BaseModel):
@@ -127,7 +195,11 @@ class GameAction(BaseModel):
127
  """Request to perform a game action."""
128
  game_id: str
129
  player_id: str
130
- action_type: str # "suggest", "accuse", "pass"
 
 
 
 
131
  character: Optional[str] = None
132
  weapon: Optional[str] = None
133
  room: Optional[str] = None
 
10
  import string
11
 
12
 
13
+ class NarrativeTone(str, Enum):
14
+ """Narrative tone options for the game."""
15
+ SERIOUS = "🕵️ Sérieuse"
16
+ PARODY = "😂 Parodique"
17
+ FANTASY = "🧙‍♂️ Fantastique"
18
+ THRILLER = "🎬 Thriller"
19
+ HORROR = "👻 Horreur"
20
+
21
+
22
  class CardType(str, Enum):
23
  """Types of cards in the game."""
24
  CHARACTER = "character"
 
38
  name: str
39
  cards: List[Card] = Field(default_factory=list)
40
  is_active: bool = True
41
+ current_room_index: int = 0 # Position on the board
42
+ has_rolled: bool = False # Track if player rolled dice this turn
43
 
44
 
45
  class GameStatus(str, Enum):
 
53
  """Represents a turn action in the game."""
54
  player_id: str
55
  player_name: str
56
+ action: str # "move", "suggest", "accuse", "pass"
57
  details: Optional[str] = None
58
+ ai_comment: Optional[str] = None # Desland's sarcastic comment
59
  timestamp: str
60
 
61
 
 
66
  room: Card
67
 
68
 
69
+ class InvestigationNote(BaseModel):
70
+ """Player's notes on the investigation."""
71
+ player_id: str
72
+ element_name: str # Name of suspect/weapon/room
73
+ element_type: str # "suspect", "weapon", "room"
74
+ status: str # "unknown", "eliminated", "maybe"
75
+
76
+
77
+ class RoomPosition(BaseModel):
78
+ """Position of a room on the board."""
79
+ name: str
80
+ x: int # Grid X position
81
+ y: int # Grid Y position
82
+
83
+
84
+ class BoardLayout(BaseModel):
85
+ """Board layout configuration."""
86
+ rooms: List[RoomPosition] = Field(default_factory=list)
87
+ grid_width: int = 8
88
+ grid_height: int = 8
89
+
90
+
91
  class Game(BaseModel):
92
  """Represents a complete game instance."""
93
  game_id: str
94
  name: str
95
  status: GameStatus = GameStatus.WAITING
96
+
97
+ # Theme and narrative
98
+ narrative_tone: str = NarrativeTone.SERIOUS.value
99
+ custom_prompt: Optional[str] = None
100
+
101
+ # Game elements (customizable)
102
  rooms: List[str]
103
+ custom_weapons: List[str] = Field(default_factory=list)
104
+ custom_suspects: List[str] = Field(default_factory=list)
105
+
106
  use_ai: bool = False
107
 
108
  # Players
 
122
  turns: List[Turn] = Field(default_factory=list)
123
  winner: Optional[str] = None
124
 
125
+ # Investigation notes (for UI)
126
+ investigation_notes: List[InvestigationNote] = Field(default_factory=list)
127
+
128
+ # Board layout
129
+ board_layout: Optional[BoardLayout] = None
130
+
131
  # AI-generated content
132
  scenario: Optional[str] = None
133
 
134
  @staticmethod
135
  def generate_game_id() -> str:
136
+ """Generate a unique 4-character game ID (like AB7F)."""
137
+ chars = string.ascii_uppercase + string.digits
138
+ return ''.join(random.choices(chars, k=4))
139
 
140
  def add_player(self, player_name: str) -> Player:
141
  """Add a new player to the game."""
142
  player_id = ''.join(random.choices(string.ascii_lowercase + string.digits, k=8))
143
+ # All players start in the first room
144
+ player = Player(id=player_id, name=player_name, current_room_index=0)
145
  self.players.append(player)
146
  return player
147
 
 
152
  return self.players[self.current_player_index]
153
 
154
  def next_turn(self):
155
+ """Move to the next active player's turn."""
156
+ if not self.players:
157
+ return
158
+
159
+ # Reset has_rolled for current player
160
+ if self.current_player_index < len(self.players):
161
+ self.players[self.current_player_index].has_rolled = False
162
+
163
+ # Skip eliminated players
164
+ attempts = 0
165
+ while attempts < len(self.players):
166
  self.current_player_index = (self.current_player_index + 1) % len(self.players)
167
+ if self.players[self.current_player_index].is_active:
168
+ break
169
+ attempts += 1
170
 
171
  def is_full(self) -> bool:
172
  """Check if the game has reached maximum players."""
 
176
  class CreateGameRequest(BaseModel):
177
  """Request to create a new game."""
178
  game_name: str
179
+ narrative_tone: str = NarrativeTone.SERIOUS.value
180
+ custom_prompt: Optional[str] = None
181
  rooms: List[str]
182
+ custom_weapons: List[str]
183
+ custom_suspects: List[str]
184
  use_ai: bool = False
185
+ board_layout: Optional[BoardLayout] = None
186
 
187
 
188
  class JoinGameRequest(BaseModel):
 
195
  """Request to perform a game action."""
196
  game_id: str
197
  player_id: str
198
+ action_type: str # "move", "suggest", "accuse", "pass"
199
+ # For movement
200
+ dice_roll: Optional[int] = None
201
+ target_room_index: Optional[int] = None
202
+ # For suggestions/accusations
203
  character: Optional[str] = None
204
  weapon: Optional[str] = None
205
  room: Optional[str] = None
backend/requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ fastapi==0.104.1
2
+ uvicorn[standard]==0.24.0
3
+ pydantic==2.5.0
4
+ python-multipart==0.0.6
5
+ openai==1.3.0
docker-compose.yml ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: '3.8'
2
+
3
+ services:
4
+ app:
5
+ build: .
6
+ ports:
7
+ - "7860:7860"
8
+ environment:
9
+ - PYTHONUNBUFFERED=1
10
+ volumes:
11
+ - ./backend:/app/backend
12
+ - ./frontend/dist:/app/frontend/dist
frontend/index.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="fr">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>🔍 Cluedo Custom</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.jsx"></script>
12
+ </body>
13
+ </html>
frontend/package.json ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "cluedo-custom-frontend",
3
+ "private": true,
4
+ "version": "1.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "dependencies": {
12
+ "react": "^18.2.0",
13
+ "react-dom": "^18.2.0",
14
+ "react-router-dom": "^6.20.0",
15
+ "axios": "^1.6.2"
16
+ },
17
+ "devDependencies": {
18
+ "@types/react": "^18.2.43",
19
+ "@types/react-dom": "^18.2.17",
20
+ "@vitejs/plugin-react": "^4.2.1",
21
+ "autoprefixer": "^10.4.16",
22
+ "postcss": "^8.4.32",
23
+ "tailwindcss": "^3.3.6",
24
+ "vite": "^5.0.8"
25
+ }
26
+ }
frontend/postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ }
frontend/src/App.jsx ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { BrowserRouter, Routes, Route } from 'react-router-dom'
2
+ import Home from './pages/Home'
3
+ import Join from './pages/Join'
4
+ import Game from './pages/Game'
5
+
6
+ function App() {
7
+ return (
8
+ <BrowserRouter>
9
+ <Routes>
10
+ <Route path="/" element={<Home />} />
11
+ <Route path="/join" element={<Join />} />
12
+ <Route path="/game/:gameId/:playerId" element={<Game />} />
13
+ </Routes>
14
+ </BrowserRouter>
15
+ )
16
+ }
17
+
18
+ export default App
frontend/src/api.js ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import axios from 'axios';
2
+
3
+ const API_BASE = import.meta.env.VITE_API_URL || '/api';
4
+
5
+ const api = axios.create({
6
+ baseURL: API_BASE,
7
+ headers: {
8
+ 'Content-Type': 'application/json',
9
+ },
10
+ });
11
+
12
+ // Helper functions for easier usage
13
+ export const createQuickGame = async (playerName, theme = 'classic') => {
14
+ const response = await api.post('/games/quick-create', { theme, player_name: playerName });
15
+ return response.data;
16
+ };
17
+
18
+ export const joinGame = async (gameId, playerName) => {
19
+ const response = await api.post('/games/join', { game_id: gameId, player_name: playerName });
20
+ return response.data;
21
+ };
22
+
23
+ export const startGame = async (gameId, playerId) => {
24
+ const response = await api.post(`/games/${gameId}/start`, { player_id: playerId });
25
+ return response.data;
26
+ };
27
+
28
+ export const getGameState = async (gameId, playerId) => {
29
+ const response = await api.get(`/games/${gameId}/state/${playerId}`);
30
+ return response.data;
31
+ };
32
+
33
+ export const rollDice = async (gameId, playerId) => {
34
+ const response = await api.post(`/games/${gameId}/roll`, { player_id: playerId });
35
+ return response.data;
36
+ };
37
+
38
+ export const makeSuggestion = async (gameId, playerId, suspect, weapon, room) => {
39
+ const response = await api.post(`/games/${gameId}/suggest`, {
40
+ player_id: playerId,
41
+ suspect,
42
+ weapon,
43
+ room,
44
+ });
45
+ return response.data;
46
+ };
47
+
48
+ export const makeAccusation = async (gameId, playerId, suspect, weapon, room) => {
49
+ const response = await api.post(`/games/${gameId}/accuse`, {
50
+ player_id: playerId,
51
+ suspect,
52
+ weapon,
53
+ room,
54
+ });
55
+ return response.data;
56
+ };
57
+
58
+ export const passTurn = async (gameId, playerId) => {
59
+ const response = await api.post(`/games/${gameId}/pass`, { player_id: playerId });
60
+ return response.data;
61
+ };
62
+
63
+ export const gameAPI = {
64
+ // Get available themes
65
+ getThemes: () => api.get('/themes'),
66
+
67
+ // Quick create with defaults
68
+ quickCreate: (theme, playerName) =>
69
+ api.post('/games/quick-create', { theme, player_name: playerName }),
70
+
71
+ // Join existing game
72
+ join: (gameId, playerName) =>
73
+ api.post('/games/join', { game_id: gameId, player_name: playerName }),
74
+
75
+ // Start game
76
+ start: (gameId) => api.post(`/games/${gameId}/start`),
77
+
78
+ // Get game state
79
+ getState: (gameId, playerId) => api.get(`/games/${gameId}/state/${playerId}`),
80
+
81
+ // Roll dice
82
+ rollDice: (gameId, playerId) =>
83
+ api.post(`/games/${gameId}/roll`, { player_id: playerId }),
84
+
85
+ // Make suggestion
86
+ suggest: (gameId, playerId, suspect, weapon, room) =>
87
+ api.post(`/games/${gameId}/suggest`, {
88
+ player_id: playerId,
89
+ suspect,
90
+ weapon,
91
+ room,
92
+ }),
93
+
94
+ // Make accusation
95
+ accuse: (gameId, playerId, suspect, weapon, room) =>
96
+ api.post(`/games/${gameId}/accuse`, {
97
+ player_id: playerId,
98
+ suspect,
99
+ weapon,
100
+ room,
101
+ }),
102
+
103
+ // Pass turn
104
+ pass: (gameId, playerId) =>
105
+ api.post(`/games/${gameId}/pass`, { player_id: playerId }),
106
+ };
107
+
108
+ export default api;
frontend/src/components/AINavigator.jsx ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useRef } from 'react'
2
+
3
+ function AINavigator({ recentActions, gameStatus }) {
4
+ const [comments, setComments] = useState([])
5
+ const scrollRef = useRef(null)
6
+
7
+ useEffect(() => {
8
+ // Extract AI comments from recent actions
9
+ if (recentActions) {
10
+ const aiComments = recentActions
11
+ .filter(action => action.ai_comment)
12
+ .map(action => ({
13
+ id: `${action.player}-${action.action}-${Date.now()}`,
14
+ text: action.ai_comment,
15
+ player: action.player,
16
+ action: action.action
17
+ }))
18
+
19
+ setComments(aiComments)
20
+ }
21
+ }, [recentActions])
22
+
23
+ useEffect(() => {
24
+ // Auto-scroll to latest comment
25
+ if (scrollRef.current) {
26
+ scrollRef.current.scrollTop = scrollRef.current.scrollHeight
27
+ }
28
+ }, [comments])
29
+
30
+ if (gameStatus === 'waiting') {
31
+ return (
32
+ <div className="bg-black/60 backdrop-blur-md p-6 rounded-lg border-2 border-haunted-purple/30">
33
+ <h2 className="text-xl font-bold text-haunted-purple mb-4 animate-flicker">
34
+ 👻 Desland, le Narrateur
35
+ </h2>
36
+ <div className="text-haunted-fog/70 italic">
37
+ <p className="mb-2">
38
+ "Je suis Leland... euh non, Desland. Le vieux jardinier de ce manoir maudit."
39
+ </p>
40
+ <p>
41
+ En attendant que les enquêteurs arrivent... s'ils osent.
42
+ </p>
43
+ </div>
44
+ </div>
45
+ )
46
+ }
47
+
48
+ return (
49
+ <div className="bg-black/60 backdrop-blur-md p-6 rounded-lg border-2 border-haunted-purple/30">
50
+ <h2 className="text-xl font-bold text-haunted-purple mb-4 animate-flicker flex items-center gap-2">
51
+ 👻 Desland, le Narrateur
52
+ <span className="text-xs text-haunted-fog/60 font-normal">(IA activée)</span>
53
+ </h2>
54
+
55
+ <div
56
+ ref={scrollRef}
57
+ className="space-y-3 max-h-64 overflow-y-auto scrollbar-thin scrollbar-thumb-haunted-purple/50 scrollbar-track-black/20"
58
+ >
59
+ {comments.length === 0 ? (
60
+ <div className="text-haunted-fog/60 italic text-sm">
61
+ <p>"Alors, on attend quoi pour commencer cette enquête ridicule ?"</p>
62
+ </div>
63
+ ) : (
64
+ comments.map((comment, idx) => (
65
+ <div
66
+ key={idx}
67
+ className="bg-black/40 p-3 rounded border-l-4 border-haunted-purple animate-fade-in"
68
+ >
69
+ <div className="text-xs text-haunted-fog/50 mb-1">
70
+ {comment.player} • {comment.action}
71
+ </div>
72
+ <div className="text-haunted-fog italic">
73
+ "{comment.text}"
74
+ </div>
75
+ </div>
76
+ ))
77
+ )}
78
+ </div>
79
+
80
+ <div className="mt-4 pt-4 border-t border-haunted-shadow text-xs text-haunted-fog/60">
81
+ <p className="italic">
82
+ 💀 Desland commente vos actions avec son sarcasme légendaire...
83
+ </p>
84
+ </div>
85
+ </div>
86
+ )
87
+ }
88
+
89
+ export default AINavigator
frontend/src/components/GameBoard.jsx ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react'
2
+
3
+ function GameBoard({ boardLayout, players, rooms, myPosition }) {
4
+ if (!boardLayout || !boardLayout.rooms) {
5
+ return (
6
+ <div className="bg-black/60 backdrop-blur-md p-6 rounded-lg border-2 border-haunted-shadow">
7
+ <h2 className="text-xl font-bold text-haunted-blood mb-4 animate-flicker">🏰 Plateau de Jeu</h2>
8
+ <p className="text-haunted-fog/60">Plateau en cours de chargement...</p>
9
+ </div>
10
+ )
11
+ }
12
+
13
+ const gridWidth = boardLayout.grid_width || 8
14
+ const gridHeight = boardLayout.grid_height || 8
15
+
16
+ // Get players in each room
17
+ const getPlayersInRoom = (roomName) => {
18
+ return players?.filter(p => {
19
+ const roomIndex = rooms?.indexOf(roomName)
20
+ return roomIndex !== -1 && p.position === roomIndex
21
+ }) || []
22
+ }
23
+
24
+ return (
25
+ <div className="bg-black/60 backdrop-blur-md p-6 rounded-lg border-2 border-haunted-shadow">
26
+ <h2 className="text-xl font-bold text-haunted-blood mb-4 animate-flicker">🏰 Plateau du Manoir</h2>
27
+
28
+ <div
29
+ className="grid gap-2 mx-auto"
30
+ style={{
31
+ gridTemplateColumns: `repeat(${gridWidth}, minmax(0, 1fr))`,
32
+ maxWidth: `${gridWidth * 100}px`
33
+ }}
34
+ >
35
+ {Array.from({ length: gridHeight }).map((_, y) =>
36
+ Array.from({ length: gridWidth }).map((_, x) => {
37
+ const room = boardLayout.rooms.find(r => r.x === x && r.y === y)
38
+ const playersHere = room ? getPlayersInRoom(room.name) : []
39
+ const isMyLocation = room && rooms?.indexOf(room.name) === myPosition
40
+
41
+ return (
42
+ <div
43
+ key={`${x}-${y}`}
44
+ className={`
45
+ aspect-square rounded-lg border-2 transition-all
46
+ ${room
47
+ ? isMyLocation
48
+ ? 'bg-haunted-blood/20 border-haunted-blood shadow-[0_0_20px_rgba(139,0,0,0.3)]'
49
+ : 'bg-black/40 border-haunted-shadow hover:border-haunted-purple/50'
50
+ : 'bg-dark-800/20 border-dark-700'
51
+ }
52
+ `}
53
+ >
54
+ {room ? (
55
+ <div className="h-full flex flex-col items-center justify-center p-1 text-center">
56
+ <div className="text-xs font-semibold text-haunted-fog mb-1 truncate w-full">
57
+ {room.name}
58
+ </div>
59
+ {playersHere.length > 0 && (
60
+ <div className="flex flex-wrap gap-1 justify-center">
61
+ {playersHere.map((p, i) => (
62
+ <div
63
+ key={i}
64
+ className={`
65
+ w-6 h-6 rounded-full flex items-center justify-center text-xs
66
+ ${p.is_me
67
+ ? 'bg-haunted-blood text-white font-bold'
68
+ : 'bg-haunted-purple/70 text-white'
69
+ }
70
+ `}
71
+ title={p.name}
72
+ >
73
+ {p.name.charAt(0).toUpperCase()}
74
+ </div>
75
+ ))}
76
+ </div>
77
+ )}
78
+ </div>
79
+ ) : null}
80
+ </div>
81
+ )
82
+ })
83
+ )}
84
+ </div>
85
+
86
+ <div className="mt-4 pt-4 border-t border-haunted-shadow text-xs text-haunted-fog/60">
87
+ <p>🔴 Votre position • 🟣 Autres joueurs</p>
88
+ </div>
89
+ </div>
90
+ )
91
+ }
92
+
93
+ export default GameBoard
frontend/src/components/InvestigationGrid.jsx ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react'
2
+
3
+ function InvestigationGrid({ suspects, weapons, rooms, myCards }) {
4
+ const [notes, setNotes] = useState({})
5
+
6
+ // Initialize notes from localStorage and set my cards
7
+ useEffect(() => {
8
+ const saved = localStorage.getItem('investigation_notes')
9
+ const initialNotes = saved ? JSON.parse(saved) : {}
10
+
11
+ // Initialize all items as 'unknown' if not already set
12
+ // This ensures cards not in my hand have a default status
13
+ const allItems = [
14
+ ...(suspects?.map(s => ({ name: s, type: 'character' })) || []),
15
+ ...(weapons?.map(w => ({ name: w, type: 'weapon' })) || []),
16
+ ...(rooms?.map(r => ({ name: r, type: 'room' })) || [])
17
+ ]
18
+
19
+ allItems.forEach(item => {
20
+ const key = `${item.type}:${item.name}`
21
+ // Only set to unknown if not already tracked
22
+ if (!(key in initialNotes)) {
23
+ initialNotes[key] = 'unknown'
24
+ }
25
+ })
26
+
27
+ setNotes(initialNotes)
28
+ }, [suspects, weapons, rooms])
29
+
30
+ // Save notes to localStorage
31
+ useEffect(() => {
32
+ localStorage.setItem('investigation_notes', JSON.stringify(notes))
33
+ }, [notes])
34
+
35
+ const toggleNote = (item, type) => {
36
+ const key = `${type}:${item}`
37
+ setNotes(prev => {
38
+ const current = prev[key] || 'unknown'
39
+ const next = current === 'unknown' ? 'eliminated' :
40
+ current === 'eliminated' ? 'maybe' : 'unknown'
41
+ return { ...prev, [key]: next }
42
+ })
43
+ }
44
+
45
+ const getStatus = (item, type) => {
46
+ const key = `${type}:${item}`
47
+ return notes[key] || 'unknown'
48
+ }
49
+
50
+ const getIcon = (item, type) => {
51
+ // Check if this is one of my cards
52
+ const isMyCard = myCards?.some(card =>
53
+ card.name === item && card.type === type
54
+ )
55
+
56
+ if (isMyCard) return '✅' // I have this card
57
+
58
+ const status = getStatus(item, type)
59
+ if (status === 'eliminated') return '❌'
60
+ if (status === 'maybe') return '❓'
61
+ return '⬜'
62
+ }
63
+
64
+ return (
65
+ <div className="bg-black/60 backdrop-blur-md p-6 rounded-lg border-2 border-haunted-shadow">
66
+ <h2 className="text-xl font-bold text-haunted-blood mb-4 animate-flicker">📋 Grille d'Enquête</h2>
67
+
68
+ <div className="space-y-4">
69
+ {/* Suspects */}
70
+ <div>
71
+ <h3 className="text-sm font-semibold text-haunted-fog mb-2">👤 SUSPECTS</h3>
72
+ <div className="grid grid-cols-2 gap-2">
73
+ {suspects?.map((suspect, i) => (
74
+ <button
75
+ key={i}
76
+ onClick={() => toggleNote(suspect, 'character')}
77
+ className="flex items-center gap-2 bg-black/40 px-3 py-2 rounded text-left text-sm text-haunted-fog border border-haunted-shadow hover:border-haunted-blood/50 transition-all"
78
+ >
79
+ <span className="text-lg">{getIcon(suspect, 'character')}</span>
80
+ <span className="flex-1 truncate">{suspect}</span>
81
+ </button>
82
+ ))}
83
+ </div>
84
+ </div>
85
+
86
+ {/* Weapons */}
87
+ <div>
88
+ <h3 className="text-sm font-semibold text-haunted-fog mb-2">🔪 ARMES</h3>
89
+ <div className="grid grid-cols-2 gap-2">
90
+ {weapons?.map((weapon, i) => (
91
+ <button
92
+ key={i}
93
+ onClick={() => toggleNote(weapon, 'weapon')}
94
+ className="flex items-center gap-2 bg-black/40 px-3 py-2 rounded text-left text-sm text-haunted-fog border border-haunted-shadow hover:border-haunted-blood/50 transition-all"
95
+ >
96
+ <span className="text-lg">{getIcon(weapon, 'weapon')}</span>
97
+ <span className="flex-1 truncate">{weapon}</span>
98
+ </button>
99
+ ))}
100
+ </div>
101
+ </div>
102
+
103
+ {/* Rooms */}
104
+ <div>
105
+ <h3 className="text-sm font-semibold text-haunted-fog mb-2">🏚️ SALLES</h3>
106
+ <div className="grid grid-cols-2 gap-2">
107
+ {rooms?.map((room, i) => (
108
+ <button
109
+ key={i}
110
+ onClick={() => toggleNote(room, 'room')}
111
+ className="flex items-center gap-2 bg-black/40 px-3 py-2 rounded text-left text-sm text-haunted-fog border border-haunted-shadow hover:border-haunted-blood/50 transition-all"
112
+ >
113
+ <span className="text-lg">{getIcon(room, 'room')}</span>
114
+ <span className="flex-1 truncate">{room}</span>
115
+ </button>
116
+ ))}
117
+ </div>
118
+ </div>
119
+ </div>
120
+
121
+ <div className="mt-4 pt-4 border-t border-haunted-shadow text-xs text-haunted-fog/60 space-y-1">
122
+ <p>✅ Mes cartes • ❌ Éliminé • ❓ Peut-être • ⬜ Inconnu</p>
123
+ <p className="italic">Cliquez pour changer le statut</p>
124
+ </div>
125
+ </div>
126
+ )
127
+ }
128
+
129
+ export default InvestigationGrid
frontend/src/index.css ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ body {
6
+ margin: 0;
7
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
8
+ 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
9
+ sans-serif;
10
+ -webkit-font-smoothing: antialiased;
11
+ -moz-osx-font-smoothing: grayscale;
12
+ @apply bg-dark-950 text-gray-100;
13
+ }
14
+
15
+ #root {
16
+ min-height: 100vh;
17
+ }
frontend/src/main.jsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+ import ReactDOM from 'react-dom/client'
3
+ import App from './App.jsx'
4
+ import './index.css'
5
+
6
+ ReactDOM.createRoot(document.getElementById('root')).render(
7
+ <React.StrictMode>
8
+ <App />
9
+ </React.StrictMode>,
10
+ )
frontend/src/pages/Game.jsx ADDED
@@ -0,0 +1,353 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react'
2
+ import { useParams } from 'react-router-dom'
3
+ import { getGameState, startGame, rollDice, makeSuggestion, makeAccusation, passTurn } from '../api'
4
+ import InvestigationGrid from '../components/InvestigationGrid'
5
+ import GameBoard from '../components/GameBoard'
6
+ import AINavigator from '../components/AINavigator'
7
+
8
+ function Game() {
9
+ const { gameId, playerId } = useParams()
10
+ const [gameState, setGameState] = useState(null)
11
+ const [loading, setLoading] = useState(true)
12
+ const [actionLoading, setActionLoading] = useState(false)
13
+
14
+ // Suggestion form
15
+ const [selectedSuspect, setSelectedSuspect] = useState('')
16
+ const [selectedWeapon, setSelectedWeapon] = useState('')
17
+ const [selectedRoom, setSelectedRoom] = useState('')
18
+ const [revealedCard, setRevealedCard] = useState(null)
19
+
20
+ useEffect(() => {
21
+ loadGameState()
22
+ const interval = setInterval(loadGameState, 2000)
23
+ return () => clearInterval(interval)
24
+ }, [gameId, playerId])
25
+
26
+ const loadGameState = async () => {
27
+ try {
28
+ const state = await getGameState(gameId, playerId)
29
+ setGameState(state)
30
+ setLoading(false)
31
+ } catch (error) {
32
+ console.error('Erreur chargement:', error)
33
+ }
34
+ }
35
+
36
+ const handleStartGame = async () => {
37
+ setActionLoading(true)
38
+ try {
39
+ await startGame(gameId, playerId)
40
+ await loadGameState()
41
+ } catch (error) {
42
+ alert(error.response?.data?.detail || 'Erreur au démarrage')
43
+ } finally {
44
+ setActionLoading(false)
45
+ }
46
+ }
47
+
48
+ const handleRollDice = async () => {
49
+ setActionLoading(true)
50
+ try {
51
+ await rollDice(gameId, playerId)
52
+ await loadGameState()
53
+ } catch (error) {
54
+ alert(error.response?.data?.detail || 'Erreur lors du lancer de dés')
55
+ } finally {
56
+ setActionLoading(false)
57
+ }
58
+ }
59
+
60
+ const handleSuggestion = async () => {
61
+ if (!selectedSuspect || !selectedWeapon || !selectedRoom) {
62
+ alert('Sélectionnez tous les éléments')
63
+ return
64
+ }
65
+ setActionLoading(true)
66
+ try {
67
+ const result = await makeSuggestion(gameId, playerId, selectedSuspect, selectedWeapon, selectedRoom)
68
+
69
+ // Show revealed card if any
70
+ if (result.card_shown) {
71
+ setRevealedCard({
72
+ ...result.card_shown,
73
+ disprover: result.disprover
74
+ })
75
+ setTimeout(() => setRevealedCard(null), 5000) // Hide after 5 seconds
76
+ } else {
77
+ alert('Personne ne peut réfuter votre suggestion !')
78
+ }
79
+
80
+ await loadGameState()
81
+ setSelectedSuspect('')
82
+ setSelectedWeapon('')
83
+ setSelectedRoom('')
84
+ } catch (error) {
85
+ alert(error.response?.data?.detail || 'Erreur lors de la suggestion')
86
+ } finally {
87
+ setActionLoading(false)
88
+ }
89
+ }
90
+
91
+ const handleAccusation = async () => {
92
+ if (!selectedSuspect || !selectedWeapon || !selectedRoom) {
93
+ alert('Sélectionnez tous les éléments')
94
+ return
95
+ }
96
+ if (!confirm('⚠️ Une accusation incorrecte vous élimine. Continuer ?')) return
97
+
98
+ setActionLoading(true)
99
+ try {
100
+ await makeAccusation(gameId, playerId, selectedSuspect, selectedWeapon, selectedRoom)
101
+ await loadGameState()
102
+ setSelectedSuspect('')
103
+ setSelectedWeapon('')
104
+ setSelectedRoom('')
105
+ } catch (error) {
106
+ alert(error.response?.data?.detail || 'Erreur lors de l\'accusation')
107
+ } finally {
108
+ setActionLoading(false)
109
+ }
110
+ }
111
+
112
+ const handlePassTurn = async () => {
113
+ setActionLoading(true)
114
+ try {
115
+ await passTurn(gameId, playerId)
116
+ await loadGameState()
117
+ } catch (error) {
118
+ alert(error.response?.data?.detail || 'Erreur')
119
+ } finally {
120
+ setActionLoading(false)
121
+ }
122
+ }
123
+
124
+ if (loading) {
125
+ return (
126
+ <div className="min-h-screen bg-haunted-gradient flex items-center justify-center">
127
+ <div className="text-haunted-blood text-2xl animate-flicker">🕯️ Chargement des ténèbres...</div>
128
+ </div>
129
+ )
130
+ }
131
+
132
+ const me = gameState.players.find(p => p.is_me)
133
+ const isMyTurn = gameState.current_turn?.is_my_turn && me?.is_active
134
+ const canStart = gameState.status === 'waiting' && gameState.players.length >= 3
135
+ const isEliminated = me && !me.is_active
136
+
137
+ return (
138
+ <div className="min-h-screen bg-haunted-gradient p-4 relative overflow-hidden">
139
+ {/* Animated fog effect */}
140
+ <div className="absolute inset-0 bg-fog-gradient opacity-10 animate-pulse-slow pointer-events-none"></div>
141
+
142
+ <div className="max-w-6xl mx-auto space-y-6 relative z-10">
143
+ {/* Header */}
144
+ <div className="bg-black/60 backdrop-blur-md p-6 rounded-lg border-2 border-haunted-blood/30 shadow-[0_0_30px_rgba(139,0,0,0.2)]">
145
+ <div className="flex justify-between items-center">
146
+ <div>
147
+ <h1 className="text-4xl font-bold text-haunted-blood mb-1 animate-flicker drop-shadow-[0_0_10px_rgba(139,0,0,0.5)]">
148
+ 🏰 {gameState.game_name} ({gameState.game_id})
149
+ </h1>
150
+ <p className="text-haunted-fog/80">👤 {me?.name} {!me?.is_active && '💀 (Éliminé)'}</p>
151
+ </div>
152
+ <div className="text-right">
153
+ <p className="text-haunted-fog">
154
+ {gameState.status === 'waiting' ? '⏳ En attente des âmes' :
155
+ gameState.status === 'in_progress' ? '🎮 Enquête en cours' :
156
+ '🏆 Mystère résolu'}
157
+ </p>
158
+ <p className="text-haunted-fog/60 text-sm">{gameState.players.length} âmes perdues</p>
159
+ </div>
160
+ </div>
161
+ {canStart && (
162
+ <button
163
+ onClick={handleStartGame}
164
+ disabled={actionLoading}
165
+ className="mt-4 px-6 py-2 bg-haunted-blood hover:bg-red-800 disabled:bg-dark-600 disabled:opacity-50 text-white font-bold rounded-lg transition-all hover:shadow-[0_0_20px_rgba(139,0,0,0.5)] border border-red-900"
166
+ >
167
+ 🚀 Commencer l'enquête
168
+ </button>
169
+ )}
170
+ </div>
171
+
172
+ {/* Scenario */}
173
+ {gameState.scenario && gameState.status === 'in_progress' && (
174
+ <div className="bg-black/60 backdrop-blur-md p-6 rounded-lg border-2 border-haunted-purple/30">
175
+ <h2 className="text-xl font-bold text-haunted-purple mb-3 animate-flicker">📜 Le Mystère</h2>
176
+ <p className="text-haunted-fog italic">{gameState.scenario}</p>
177
+ </div>
178
+ )}
179
+
180
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
181
+ {/* Game Board */}
182
+ <div className="lg:col-span-2">
183
+ <GameBoard
184
+ boardLayout={gameState.board_layout}
185
+ players={gameState.players}
186
+ rooms={gameState.rooms}
187
+ myPosition={gameState.my_position}
188
+ />
189
+ </div>
190
+
191
+ {/* My Cards */}
192
+ <div className="bg-black/60 backdrop-blur-md p-6 rounded-lg border-2 border-haunted-shadow">
193
+ <h2 className="text-xl font-bold text-haunted-blood mb-4 animate-flicker">🃏 Vos Indices</h2>
194
+ <div className="space-y-2">
195
+ {gameState.my_cards?.map((card, idx) => (
196
+ <div key={idx} className="bg-black/40 px-4 py-2 rounded text-haunted-fog border border-haunted-shadow hover:border-haunted-blood/50 transition-all">
197
+ {card.type === 'character' && '👤 '}
198
+ {card.type === 'weapon' && '🔪 '}
199
+ {card.type === 'room' && '🏚️ '}
200
+ {card.name}
201
+ </div>
202
+ ))}
203
+ </div>
204
+ </div>
205
+ </div>
206
+
207
+ {/* Investigation Grid */}
208
+ <InvestigationGrid
209
+ suspects={gameState.suspects}
210
+ weapons={gameState.weapons}
211
+ rooms={gameState.rooms}
212
+ myCards={gameState.my_cards}
213
+ />
214
+
215
+ {/* AI Narrator */}
216
+ {gameState.use_ai && (
217
+ <AINavigator
218
+ recentActions={gameState.recent_actions}
219
+ gameStatus={gameState.status}
220
+ />
221
+ )}
222
+
223
+ {/* Revealed Card Modal */}
224
+ {revealedCard && (
225
+ <div className="fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center z-50 animate-fade-in">
226
+ <div className="bg-black/90 border-4 border-haunted-blood p-8 rounded-lg shadow-2xl max-w-md">
227
+ <h3 className="text-2xl font-bold text-haunted-blood mb-4 animate-flicker">🃏 Carte Révélée</h3>
228
+ <p className="text-haunted-fog mb-2">
229
+ <span className="font-semibold">{revealedCard.disprover}</span> vous montre :
230
+ </p>
231
+ <div className="bg-haunted-blood/20 border-2 border-haunted-blood p-4 rounded-lg">
232
+ <p className="text-2xl font-bold text-haunted-fog text-center">
233
+ {revealedCard.type === 'character' && '👤 '}
234
+ {revealedCard.type === 'weapon' && '🔪 '}
235
+ {revealedCard.type === 'room' && '🏚️ '}
236
+ {revealedCard.name}
237
+ </p>
238
+ </div>
239
+ <p className="text-haunted-fog/60 text-sm mt-4 text-center italic">
240
+ Cette carte disparaîtra dans quelques secondes...
241
+ </p>
242
+ </div>
243
+ </div>
244
+ )}
245
+
246
+ {/* Actions */}
247
+ {gameState.status === 'in_progress' && (
248
+ <div className="bg-black/60 backdrop-blur-md p-6 rounded-lg border-2 border-haunted-blood/30">
249
+ <h2 className="text-2xl font-bold text-haunted-blood mb-4 animate-flicker">
250
+ {isEliminated ? '💀 Vous êtes éliminé - Vous pouvez toujours observer' :
251
+ isMyTurn ? '⚡ À vous de jouer !' :
252
+ '⏳ ' + gameState.current_turn?.player_name + ' enquête...'}
253
+ </h2>
254
+
255
+ {isMyTurn && !isEliminated && (
256
+ <div className="space-y-4">
257
+ <div className="flex gap-4 mb-4">
258
+ <button
259
+ onClick={handleRollDice}
260
+ disabled={actionLoading}
261
+ className="px-6 py-3 bg-haunted-blood hover:bg-red-800 disabled:bg-dark-600 disabled:opacity-50 text-white font-bold rounded-lg transition-all hover:shadow-[0_0_20px_rgba(139,0,0,0.5)] border border-red-900"
262
+ >
263
+ 🎲 Lancer les dés
264
+ </button>
265
+ <button
266
+ onClick={handlePassTurn}
267
+ disabled={actionLoading}
268
+ className="px-6 py-3 bg-black/40 hover:bg-black/60 disabled:opacity-50 text-haunted-fog border-2 border-haunted-shadow font-semibold rounded-lg transition-all"
269
+ >
270
+ ⏭️ Passer
271
+ </button>
272
+ </div>
273
+ <div className="space-y-4">
274
+ <div className="grid grid-cols-3 gap-4">
275
+ <div>
276
+ <label className="block text-sm text-haunted-fog mb-2">👤 Suspect</label>
277
+ <select
278
+ value={selectedSuspect}
279
+ onChange={(e) => setSelectedSuspect(e.target.value)}
280
+ className="w-full px-3 py-2 bg-black/40 text-haunted-fog rounded border-2 border-haunted-shadow focus:border-haunted-blood focus:outline-none"
281
+ >
282
+ <option value="">--</option>
283
+ {gameState.suspects?.map((s, i) => (
284
+ <option key={i} value={s}>{s}</option>
285
+ ))}
286
+ </select>
287
+ </div>
288
+ <div>
289
+ <label className="block text-sm text-haunted-fog mb-2">🔪 Arme</label>
290
+ <select
291
+ value={selectedWeapon}
292
+ onChange={(e) => setSelectedWeapon(e.target.value)}
293
+ className="w-full px-3 py-2 bg-black/40 text-haunted-fog rounded border-2 border-haunted-shadow focus:border-haunted-blood focus:outline-none"
294
+ >
295
+ <option value="">--</option>
296
+ {gameState.weapons?.map((w, i) => (
297
+ <option key={i} value={w}>{w}</option>
298
+ ))}
299
+ </select>
300
+ </div>
301
+ <div>
302
+ <label className="block text-sm text-haunted-fog mb-2">🏚️ Pièce</label>
303
+ <select
304
+ value={selectedRoom}
305
+ onChange={(e) => setSelectedRoom(e.target.value)}
306
+ className="w-full px-3 py-2 bg-black/40 text-haunted-fog rounded border-2 border-haunted-shadow focus:border-haunted-blood focus:outline-none"
307
+ >
308
+ <option value="">--</option>
309
+ {gameState.rooms?.map((r, i) => (
310
+ <option key={i} value={r}>{r}</option>
311
+ ))}
312
+ </select>
313
+ </div>
314
+ </div>
315
+ <div className="flex gap-4">
316
+ <button
317
+ onClick={handleSuggestion}
318
+ disabled={actionLoading}
319
+ className="px-6 py-3 bg-haunted-purple hover:bg-purple-800 disabled:bg-dark-600 disabled:opacity-50 text-white font-bold rounded-lg transition-all hover:shadow-[0_0_20px_rgba(107,33,168,0.5)] border border-purple-900"
320
+ >
321
+ 💬 Suggérer
322
+ </button>
323
+ <button
324
+ onClick={handleAccusation}
325
+ disabled={actionLoading}
326
+ className="px-6 py-3 bg-haunted-blood hover:bg-red-800 disabled:bg-dark-600 disabled:opacity-50 text-white font-bold rounded-lg transition-all hover:shadow-[0_0_20px_rgba(139,0,0,0.5)] border border-red-900"
327
+ >
328
+ ⚠️ Accuser
329
+ </button>
330
+ </div>
331
+ </div>
332
+ </div>
333
+ )}
334
+ </div>
335
+ )}
336
+
337
+ {/* Historique */}
338
+ <div className="bg-black/60 backdrop-blur-md p-6 rounded-lg border-2 border-haunted-shadow">
339
+ <h2 className="text-xl font-bold text-haunted-blood mb-4 animate-flicker">📜 Journal de l'Enquête</h2>
340
+ <div className="space-y-2 max-h-64 overflow-y-auto">
341
+ {gameState.recent_actions?.slice().reverse().map((action, idx) => (
342
+ <div key={idx} className="text-haunted-fog/80 text-sm border-l-2 border-haunted-blood pl-3 py-1 hover:bg-black/20 transition-all">
343
+ <span className="font-semibold">{action.player}</span> - {action.action}: {action.details}
344
+ </div>
345
+ ))}
346
+ </div>
347
+ </div>
348
+ </div>
349
+ </div>
350
+ )
351
+ }
352
+
353
+ export default Game
frontend/src/pages/Home.jsx ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react'
2
+ import { useNavigate } from 'react-router-dom'
3
+ import { createQuickGame } from '../api'
4
+
5
+ function Home() {
6
+ const [playerName, setPlayerName] = useState('')
7
+ const [loading, setLoading] = useState(false)
8
+ const navigate = useNavigate()
9
+
10
+ const handleCreateGame = async () => {
11
+ if (!playerName.trim()) {
12
+ alert('Veuillez entrer votre nom')
13
+ return
14
+ }
15
+
16
+ setLoading(true)
17
+ try {
18
+ const response = await createQuickGame(playerName.trim())
19
+ navigate(`/game/${response.game_id}/${response.player_id}`)
20
+ } catch (error) {
21
+ alert('Erreur lors de la création de la partie')
22
+ console.error(error)
23
+ } finally {
24
+ setLoading(false)
25
+ }
26
+ }
27
+
28
+ return (
29
+ <div className="min-h-screen bg-haunted-gradient flex items-center justify-center p-4 relative overflow-hidden">
30
+ {/* Animated fog effect */}
31
+ <div className="absolute inset-0 bg-fog-gradient opacity-20 animate-pulse-slow pointer-events-none"></div>
32
+
33
+ {/* Floating ghost elements */}
34
+ <div className="absolute top-20 left-10 w-32 h-32 bg-haunted-ghost opacity-5 rounded-full blur-3xl animate-float"></div>
35
+ <div className="absolute bottom-20 right-10 w-40 h-40 bg-haunted-purple opacity-5 rounded-full blur-3xl animate-float" style={{animationDelay: '2s'}}></div>
36
+
37
+ <div className="max-w-md w-full space-y-8 bg-black/60 backdrop-blur-md p-8 rounded-lg shadow-2xl border-2 border-haunted-blood/30 animate-fade-in relative z-10">
38
+ <div className="text-center">
39
+ <h1 className="text-6xl font-bold text-haunted-blood mb-2 animate-flicker drop-shadow-[0_0_10px_rgba(139,0,0,0.5)]">
40
+ 🕯️ Cluedo Custom
41
+ </h1>
42
+ <p className="text-haunted-fog/80 text-lg italic">Le manoir vous attend dans l'obscurité...</p>
43
+ <div className="mt-2 text-xs text-haunted-ghost/50">💀 Osez-vous entrer ? 💀</div>
44
+ </div>
45
+
46
+ <div className="space-y-4">
47
+ <div>
48
+ <label className="block text-sm font-medium text-haunted-fog mb-2">
49
+ Votre nom
50
+ </label>
51
+ <input
52
+ type="text"
53
+ value={playerName}
54
+ onChange={(e) => setPlayerName(e.target.value)}
55
+ onKeyPress={(e) => e.key === 'Enter' && handleCreateGame()}
56
+ placeholder="Qui ose s'aventurer..."
57
+ className="w-full px-4 py-3 bg-black/40 border-2 border-haunted-shadow rounded-lg text-haunted-fog placeholder-dark-500 focus:outline-none focus:ring-2 focus:ring-haunted-blood focus:border-haunted-blood transition-all"
58
+ />
59
+ </div>
60
+
61
+ <button
62
+ onClick={handleCreateGame}
63
+ disabled={loading}
64
+ className="w-full py-3 px-4 bg-haunted-blood hover:bg-red-800 disabled:bg-dark-600 disabled:opacity-50 text-white font-bold rounded-lg transition-all transform hover:scale-105 hover:shadow-[0_0_20px_rgba(139,0,0,0.5)] border border-red-900"
65
+ >
66
+ {loading ? '🕯️ Création...' : '🚪 Entrer dans le Manoir'}
67
+ </button>
68
+
69
+ <div className="text-center pt-4">
70
+ <button
71
+ onClick={() => navigate('/join')}
72
+ className="text-haunted-fog/70 hover:text-haunted-blood underline transition-colors"
73
+ >
74
+ 👻 Rejoindre une partie existante
75
+ </button>
76
+ </div>
77
+ </div>
78
+
79
+ <div className="mt-8 pt-6 border-t border-haunted-shadow text-center text-sm text-haunted-fog/60">
80
+ <p className="italic">⚰️ Thème : Meurtre au Manoir ⚰️</p>
81
+ <p className="mt-1">3-6 âmes perdues recommandées</p>
82
+ </div>
83
+ </div>
84
+ </div>
85
+ )
86
+ }
87
+
88
+ export default Home
frontend/src/pages/Join.jsx ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react'
2
+ import { useNavigate } from 'react-router-dom'
3
+ import { joinGame } from '../api'
4
+
5
+ function Join() {
6
+ const [gameCode, setGameCode] = useState('')
7
+ const [playerName, setPlayerName] = useState('')
8
+ const [loading, setLoading] = useState(false)
9
+ const navigate = useNavigate()
10
+
11
+ const handleJoinGame = async () => {
12
+ if (!gameCode.trim() || !playerName.trim()) {
13
+ alert('Veuillez remplir tous les champs')
14
+ return
15
+ }
16
+
17
+ setLoading(true)
18
+ try {
19
+ const response = await joinGame(gameCode.trim(), playerName.trim())
20
+ navigate(`/game/${response.game_id}/${response.player_id}`)
21
+ } catch (error) {
22
+ alert('Erreur : ' + (error.response?.data?.detail || 'Impossible de rejoindre la partie'))
23
+ console.error(error)
24
+ } finally {
25
+ setLoading(false)
26
+ }
27
+ }
28
+
29
+ return (
30
+ <div className="min-h-screen bg-haunted-gradient flex items-center justify-center p-4 relative overflow-hidden">
31
+ {/* Animated fog effect */}
32
+ <div className="absolute inset-0 bg-fog-gradient opacity-20 animate-pulse-slow pointer-events-none"></div>
33
+
34
+ {/* Floating ghost elements */}
35
+ <div className="absolute top-20 left-10 w-32 h-32 bg-haunted-ghost opacity-5 rounded-full blur-3xl animate-float"></div>
36
+ <div className="absolute bottom-20 right-10 w-40 h-40 bg-haunted-purple opacity-5 rounded-full blur-3xl animate-float" style={{animationDelay: '2s'}}></div>
37
+
38
+ <div className="max-w-md w-full space-y-8 bg-black/60 backdrop-blur-md p-8 rounded-lg shadow-2xl border-2 border-haunted-blood/30 animate-fade-in relative z-10">
39
+ <div className="text-center">
40
+ <h1 className="text-5xl font-bold text-haunted-blood mb-2 animate-flicker drop-shadow-[0_0_10px_rgba(139,0,0,0.5)]">
41
+ 👻 Rejoindre la Séance
42
+ </h1>
43
+ <p className="text-haunted-fog/80 italic">Entrez le code maudit...</p>
44
+ </div>
45
+
46
+ <div className="space-y-4">
47
+ <div>
48
+ <label className="block text-sm font-medium text-haunted-fog mb-2">
49
+ 🔮 Code de partie
50
+ </label>
51
+ <input
52
+ type="text"
53
+ value={gameCode}
54
+ onChange={(e) => setGameCode(e.target.value.toUpperCase())}
55
+ placeholder="?????"
56
+ maxLength={4}
57
+ className="w-full px-4 py-3 bg-black/40 border-2 border-haunted-shadow rounded-lg text-haunted-blood text-center text-3xl font-mono placeholder-dark-500 focus:outline-none focus:ring-2 focus:ring-haunted-blood focus:border-haunted-blood transition-all uppercase tracking-widest"
58
+ />
59
+ </div>
60
+
61
+ <div>
62
+ <label className="block text-sm font-medium text-haunted-fog mb-2">
63
+ Votre nom
64
+ </label>
65
+ <input
66
+ type="text"
67
+ value={playerName}
68
+ onChange={(e) => setPlayerName(e.target.value)}
69
+ onKeyPress={(e) => e.key === 'Enter' && handleJoinGame()}
70
+ placeholder="Qui ose s'aventurer..."
71
+ className="w-full px-4 py-3 bg-black/40 border-2 border-haunted-shadow rounded-lg text-haunted-fog placeholder-dark-500 focus:outline-none focus:ring-2 focus:ring-haunted-blood focus:border-haunted-blood transition-all"
72
+ />
73
+ </div>
74
+
75
+ <button
76
+ onClick={handleJoinGame}
77
+ disabled={loading}
78
+ className="w-full py-3 px-4 bg-haunted-blood hover:bg-red-800 disabled:bg-dark-600 disabled:opacity-50 text-white font-bold rounded-lg transition-all transform hover:scale-105 hover:shadow-[0_0_20px_rgba(139,0,0,0.5)] border border-red-900"
79
+ >
80
+ {loading ? '🕯️ Connexion...' : '🚪 Entrer dans le Manoir'}
81
+ </button>
82
+
83
+ <div className="text-center pt-4">
84
+ <button
85
+ onClick={() => navigate('/')}
86
+ className="text-haunted-fog/70 hover:text-haunted-blood underline transition-colors"
87
+ >
88
+ ← Retourner dans les ténèbres
89
+ </button>
90
+ </div>
91
+ </div>
92
+ </div>
93
+ </div>
94
+ )
95
+ }
96
+
97
+ export default Join
frontend/tailwind.config.js ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('tailwindcss').Config} */
2
+ export default {
3
+ content: [
4
+ "./index.html",
5
+ "./src/**/*.{js,ts,jsx,tsx}",
6
+ ],
7
+ theme: {
8
+ extend: {
9
+ colors: {
10
+ primary: {
11
+ 50: '#fef2f2',
12
+ 100: '#fee2e2',
13
+ 200: '#fecaca',
14
+ 300: '#fca5a5',
15
+ 400: '#f87171',
16
+ 500: '#ef4444',
17
+ 600: '#dc2626',
18
+ 700: '#b91c1c',
19
+ 800: '#991b1b',
20
+ 900: '#7f1d1d',
21
+ },
22
+ dark: {
23
+ 50: '#f8fafc',
24
+ 100: '#f1f5f9',
25
+ 200: '#e2e8f0',
26
+ 300: '#cbd5e1',
27
+ 400: '#94a3b8',
28
+ 500: '#64748b',
29
+ 600: '#475569',
30
+ 700: '#334155',
31
+ 800: '#1e293b',
32
+ 900: '#0f172a',
33
+ 950: '#020617',
34
+ },
35
+ accent: {
36
+ 300: '#fca5a5',
37
+ 400: '#f87171',
38
+ 500: '#ef4444',
39
+ 600: '#dc2626',
40
+ 700: '#b91c1c',
41
+ },
42
+ haunted: {
43
+ fog: '#e5e7eb',
44
+ shadow: '#1f1f1f',
45
+ blood: '#8b0000',
46
+ ghost: '#f3f4f6',
47
+ purple: '#6b21a8',
48
+ }
49
+ },
50
+ backgroundImage: {
51
+ 'haunted-gradient': 'linear-gradient(135deg, #0a0a0a 0%, #1a0a14 50%, #0a0a0a 100%)',
52
+ 'fog-gradient': 'linear-gradient(to bottom, transparent, rgba(255,255,255,0.05), transparent)',
53
+ },
54
+ animation: {
55
+ 'flicker': 'flicker 3s infinite',
56
+ 'float': 'float 6s ease-in-out infinite',
57
+ 'pulse-slow': 'pulse 4s cubic-bezier(0.4, 0, 0.6, 1) infinite',
58
+ 'fade-in': 'fadeIn 0.5s ease-in',
59
+ },
60
+ keyframes: {
61
+ flicker: {
62
+ '0%, 100%': { opacity: '1' },
63
+ '50%': { opacity: '0.8' },
64
+ '75%': { opacity: '0.9' },
65
+ },
66
+ float: {
67
+ '0%, 100%': { transform: 'translateY(0px)' },
68
+ '50%': { transform: 'translateY(-10px)' },
69
+ },
70
+ fadeIn: {
71
+ '0%': { opacity: '0' },
72
+ '100%': { opacity: '1' },
73
+ },
74
+ },
75
+ },
76
+ },
77
+ plugins: [],
78
+ }
frontend/vite.config.js ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ server: {
7
+ port: 3000,
8
+ proxy: {
9
+ '/api': {
10
+ target: 'http://localhost:8000',
11
+ changeOrigin: true,
12
+ },
13
+ },
14
+ },
15
+ })
specifications.txt CHANGED
@@ -14,21 +14,21 @@ L’hôte choisit :
14
 
15
  Un titre de partie (ex : Meurtre au coworking ou Drame à la coloc).
16
 
17
- Une tonalité narrative : 🕵️ Sérieuse, 😂 Parodique, 🧙‍♂️ Fantastique, 🎬 Thriller, etc.
18
 
19
- Optionnel : un prompt personnalisé pour l’IA (ex : “style Agatha Christie avec humour noir”).
20
 
21
- 🏠 Étape 2 Création des “Lieux”
22
 
23
- L’hôte définit les pièces du plateau :
24
 
25
- En tapant leurs noms : Cuisine, Toit, Salle serveurs, Cafétéria…
26
 
27
- Le must serait d'avoir une interface drag & drop (petit éditeur de plan simplifié avec des tuiles).
28
 
29
- 💡 L’ordre spatial a un impact : les joueurs doivent “se déplacer” virtuellement d’un lieu à l’autre.
30
 
31
- 🔪 Étape 3 Liste des “Armes” et “Suspects”
32
 
33
  L’hôte ajoute les suspects (réels ou fictifs).
34
 
@@ -57,8 +57,9 @@ Soit une carte générée dynamiquement via JSON de la config hôte.
57
 
58
  Lorsqu'au moins 3 joueurs (dont l'hote) sont connectés, l'hote peut lancer la partie
59
 
60
- 🚶 3. Déroulement du jeu
61
- 🎲 Tour par tour
 
62
 
63
  Chaque joueur :
64
 
@@ -77,15 +78,15 @@ L’IA :
77
 
78
  Garde trace des suggestions.
79
 
80
- Indique si un autre joueur peut infirmer la théorie (en montrant une carte virtuelle).
81
-
82
- Peut glisser des indices narratifs ou des fausses pistes : “Une odeur de café froid flotte près du bureau…”
83
 
84
  🧠 4. Feuille d’enquête numérique
85
 
86
- Chaque joueur dispose d’une grille (cases cliquables ✅❌) :
87
 
88
- Colonnes : Suspects / Armes / Lieux
 
 
89
 
90
 
91
  🎭 5. L’IA comme moteur de narration dynamique
@@ -118,8 +119,63 @@ Multi-joueurs WebSocket léger (ou polling HTTP + threads)
118
 
119
  💬 Exemple de tour complet
120
 
121
- 👤 Toi : Selection de "Claire" avec l'arme "Clé USB" dans le lieu "Cuisine"
122
- 🤖 IA : “Et toi ça te semble logique que Claire ait assassiné Daniel à côté de l’étendoir, en plein après-midi, avec un périphérique USB ? 🧠 Mauvais câble, détective.”
123
- (Un joueur montre virtuellement la carte “Claire”)
124
- ✅ Tu coches “Claire” comme innocente.
125
- 🔄 Prochain tour.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
  Un titre de partie (ex : Meurtre au coworking ou Drame à la coloc).
16
 
17
+ * Créer une nouvelle partie
18
 
19
+ * Saisir un nom de partie.
20
 
21
+ * Choisir le nombre de salles et leurs noms (ex : "Cuisine", "Salle de réunion", "Terrasse"...).
22
 
23
+ * (Optionnel) Activer ou non le mode IA pour générer des descriptions ou scénarios.
24
 
25
+ * Rejoindre une partie existante
26
 
27
+ * Via un code ou lien de partage (UUID court).
28
 
29
+ * Le joueur saisit son nom ou pseudo.
30
 
31
+ * Lister les parties actives (en cours sur le serveur).
32
 
33
  L’hôte ajoute les suspects (réels ou fictifs).
34
 
 
57
 
58
  Lorsqu'au moins 3 joueurs (dont l'hote) sont connectés, l'hote peut lancer la partie
59
 
60
+ Grille a cocher pour éliminer les possibilités
61
+
62
+ Navigation intuitive :
63
 
64
  Chaque joueur :
65
 
 
78
 
79
  Garde trace des suggestions.
80
 
81
+ Reformulation fluide des messages de jeu.
 
 
82
 
83
  🧠 4. Feuille d’enquête numérique
84
 
85
+ 🏗️ 4. Architecture technique
86
 
87
+ Front-end
88
+
89
+ Framework léger : React + FastAPI backend (selon préférences).
90
 
91
 
92
  🎭 5. L’IA comme moteur de narration dynamique
 
119
 
120
  💬 Exemple de tour complet
121
 
122
+ Hugging Face Space (Gradio ou API + frontend minimal).
123
+
124
+ Exposition sur un seul port HTTP (7860 par défaut HF).
125
+
126
+ Pas d’état long terme : la perte du conteneur efface les parties (ok pour usage récréatif).
127
+
128
+ ⚙️ 5. Variables d’environnement
129
+ Nom Description Valeur par défaut
130
+ USE_OPENAI Active la génération IA false
131
+ OPENAI_API_KEY Clé OpenAI (optionnelle) ""
132
+ APP_NAME Nom affiché dans l’interface "Cluedo Custom"
133
+ MAX_PLAYERS Nombre max de joueurs par partie 8
134
+
135
+ 🧠 6. Comportement IA (si activé)
136
+
137
+ Utiliser GPT (via OpenAI API) pour :
138
+
139
+ Générer la mise en scène du lieu : “Le meurtre a eu lieu dans la Salle des serveurs…”
140
+
141
+ Personnaliser les noms et descriptions de personnages.
142
+
143
+ Ajouter du dialogue narratif léger pendant les tours. Le bot s'appelle
144
+
145
+ Les prompts doivent être courts et à température basse pour éviter les délais.
146
+
147
+ Pas d’appel IA bloquant : timeout max 3 secondes par requête.
148
+
149
+ 🔒 8. Gestion des sessions / multi-joueurs
150
+
151
+ Génération d’un Game ID unique à 6 caractères.
152
+
153
+ Stockage des sessions en mémoire avec la liste des joueurs connectés.
154
+
155
+ Chaque joueur identifié par un UUID stocké côté navigateur.
156
+
157
+ Synchronisation légère entre joueurs (polling toutes les 2–3 secondes au lieu de WebSocket si besoin d’économie CPU).
158
+
159
+ 🧪 9. Tests & validation
160
+
161
+ Unitaires : logique du moteur de jeu (distribution, accusation, vérification).
162
+
163
+ End-to-end : création et déroulement d’une partie complète à 3 joueurs simulés.
164
+
165
+ Tests IA (optionnels) : vérification du format et temps de réponse des prompts.
166
+
167
+ 📦 10. Livrables attendus
168
+
169
+ Dockerfile léger (< 300 MB image finale).
170
+
171
+ Fichier requirements.txt clair et court.
172
+
173
+ Script app.py ou main.py lançant l’app HF.
174
+
175
+ Documentation README.md :
176
+
177
+ Variables d’environnement
178
+
179
+ Instructions pour exécution locale et Space.
180
+
181
+ Description du mode IA.