Spaces:
Sleeping
Sleeping
Merge branch 'cp/change_app_architecture'
Browse files- .gitignore +4 -0
- CHANGELOG.md +86 -0
- Dockerfile +19 -19
- README.md +169 -224
- api.py +0 -304
- app.py +0 -1087
- ai_service.py → backend/ai_service.py +95 -35
- config.py → backend/config.py +0 -0
- backend/defaults.py +101 -0
- game_engine.py → backend/game_engine.py +59 -7
- game_manager.py → backend/game_manager.py +8 -3
- backend/main.py +467 -0
- models.py → backend/models.py +79 -7
- backend/requirements.txt +5 -0
- docker-compose.yml +12 -0
- frontend/index.html +13 -0
- frontend/package.json +26 -0
- frontend/postcss.config.js +6 -0
- frontend/src/App.jsx +18 -0
- frontend/src/api.js +108 -0
- frontend/src/components/AINavigator.jsx +89 -0
- frontend/src/components/GameBoard.jsx +93 -0
- frontend/src/components/InvestigationGrid.jsx +129 -0
- frontend/src/index.css +17 -0
- frontend/src/main.jsx +10 -0
- frontend/src/pages/Game.jsx +353 -0
- frontend/src/pages/Home.jsx +88 -0
- frontend/src/pages/Join.jsx +97 -0
- frontend/tailwind.config.js +78 -0
- frontend/vite.config.js +15 -0
- specifications.txt +76 -20
.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 |
-
#
|
| 2 |
-
FROM
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
-
#
|
|
|
|
| 5 |
WORKDIR /app
|
| 6 |
|
| 7 |
-
#
|
| 8 |
-
|
| 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
|
| 17 |
-
COPY
|
| 18 |
|
| 19 |
-
#
|
| 20 |
-
|
| 21 |
|
| 22 |
-
#
|
| 23 |
-
|
| 24 |
-
CMD python -c "import requests; requests.get('http://localhost:7860/')"
|
| 25 |
|
| 26 |
-
#
|
| 27 |
-
CMD ["
|
|
|
|
| 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 |
-
|
| 24 |
|
| 25 |
-
##
|
| 26 |
|
| 27 |
-
- **
|
| 28 |
-
- **
|
| 29 |
-
- **
|
| 30 |
-
-
|
| 31 |
-
- **
|
| 32 |
-
- **
|
|
|
|
|
|
|
| 33 |
|
| 34 |
-
##
|
| 35 |
|
| 36 |
-
###
|
| 37 |
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 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 |
-
|
|
|
|
| 64 |
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
|
|
|
| 69 |
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
```
|
| 74 |
|
| 75 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
| 89 |
|
| 90 |
-
|
| 91 |
-
- Upload all project files to your Space
|
| 92 |
-
- Ensure `app.py` is the main entry point
|
| 93 |
|
| 94 |
-
|
| 95 |
-
-
|
| 96 |
-
-
|
| 97 |
|
| 98 |
-
|
| 99 |
```
|
| 100 |
USE_OPENAI=true
|
| 101 |
-
|
| 102 |
-
MAX_PLAYERS=8
|
| 103 |
```
|
| 104 |
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
| 106 |
|
| 107 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
|
| 116 |
-
|
| 117 |
|
| 118 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
-
|
| 128 |
-
- Enter the Game ID provided by the game creator
|
| 129 |
-
- Enter your player name
|
| 130 |
-
- Click "Join Game"
|
| 131 |
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
| 133 |
|
| 134 |
-
|
| 135 |
-
- The game creator clicks "Start Game"
|
| 136 |
-
- Cards are automatically distributed to all players
|
| 137 |
|
| 138 |
-
|
|
|
|
|
|
|
| 139 |
|
| 140 |
-
|
| 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 |
-
|
| 148 |
|
| 149 |
-
|
| 150 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
|
| 152 |
-
|
| 153 |
|
| 154 |
-
|
|
|
|
| 155 |
|
| 156 |
-
|
| 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 |
-
|
| 162 |
|
| 163 |
-
|
| 164 |
-
- All other cards are distributed evenly among players
|
| 165 |
|
| 166 |
-
|
| 167 |
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
|
| 173 |
-
|
| 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 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
|
| 182 |
-
|
|
|
|
| 183 |
|
| 184 |
-
|
|
|
|
|
|
|
| 185 |
|
| 186 |
-
|
| 187 |
-
- **Turn Narration** (optional): Brief narrative elements during gameplay
|
| 188 |
|
| 189 |
-
|
| 190 |
-
-
|
| 191 |
-
- 3
|
| 192 |
-
-
|
| 193 |
|
| 194 |
-
##
|
| 195 |
|
| 196 |
```
|
| 197 |
custom-cluedo/
|
| 198 |
-
├──
|
| 199 |
-
├──
|
| 200 |
-
├──
|
| 201 |
-
├──
|
| 202 |
-
├──
|
| 203 |
-
├──
|
| 204 |
-
├──
|
| 205 |
-
|
| 206 |
-
├──
|
| 207 |
-
├──
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 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 |
-
|
| 264 |
|
| 265 |
-
|
| 266 |
-
-
|
| 267 |
-
-
|
|
|
|
|
|
|
|
|
|
| 268 |
|
| 269 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
|
| 271 |
-
###
|
| 272 |
-
|
| 273 |
-
Change the port in `config.py` or use environment variable:
|
| 274 |
-
```bash
|
| 275 |
-
PORT=8000 python app.py
|
| 276 |
-
```
|
| 277 |
|
| 278 |
-
|
| 279 |
|
| 280 |
-
-
|
| 281 |
-
-
|
| 282 |
-
-
|
| 283 |
-
-
|
|
|
|
| 284 |
|
| 285 |
-
|
| 286 |
|
| 287 |
-
-
|
| 288 |
-
-
|
| 289 |
-
-
|
|
|
|
| 290 |
|
| 291 |
-
|
|
|
|
|
|
|
|
|
|
| 292 |
|
| 293 |
-
|
|
|
|
|
|
|
|
|
|
| 294 |
|
| 295 |
-
##
|
| 296 |
|
| 297 |
-
|
| 298 |
-
1. Fork the repository
|
| 299 |
-
2. Create a feature branch
|
| 300 |
-
3. Submit a pull request
|
| 301 |
|
| 302 |
-
##
|
| 303 |
|
| 304 |
-
|
| 305 |
-
-
|
| 306 |
-
-
|
| 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"""
|
| 40 |
|
| 41 |
-
IMPORTANT:
|
| 42 |
|
| 43 |
-
|
| 44 |
-
|
|
|
|
| 45 |
|
| 46 |
-
|
| 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
|
| 67 |
self,
|
| 68 |
player_name: str,
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
) -> Optional[str]:
|
| 71 |
"""
|
| 72 |
-
Generate
|
| 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 |
-
|
|
|
|
| 80 |
|
| 81 |
-
|
| 82 |
-
|
|
|
|
| 83 |
|
| 84 |
-
Desland
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 98 |
return None
|
| 99 |
except Exception as e:
|
| 100 |
-
print(f"Error generating
|
| 101 |
return None
|
| 102 |
|
| 103 |
def _generate_text(self, prompt: str) -> str:
|
| 104 |
"""
|
| 105 |
Internal method to generate text using OpenAI API.
|
| 106 |
-
Uses
|
| 107 |
"""
|
| 108 |
if not self.client:
|
| 109 |
return ""
|
| 110 |
|
| 111 |
response = self.client.chat.completions.create(
|
| 112 |
-
model="gpt-
|
| 113 |
messages=[
|
| 114 |
{
|
| 115 |
"role": "system",
|
| 116 |
-
"content": "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
},
|
| 118 |
-
{
|
| 119 |
-
"role": "user",
|
| 120 |
-
"content": prompt
|
| 121 |
-
}
|
| 122 |
],
|
| 123 |
-
temperature=0.
|
| 124 |
-
max_tokens=
|
| 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 (
|
| 13 |
DEFAULT_CHARACTERS = [
|
| 14 |
"Miss Scarlett",
|
| 15 |
"Colonel Mustard",
|
|
@@ -19,7 +19,7 @@ DEFAULT_CHARACTERS = [
|
|
| 19 |
"Professor Plum"
|
| 20 |
]
|
| 21 |
|
| 22 |
-
# Default weapon names (
|
| 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
|
| 46 |
]
|
| 47 |
|
| 48 |
# Create weapon cards
|
| 49 |
game.weapons = [
|
| 50 |
Card(name=name, card_type=CardType.WEAPON)
|
| 51 |
-
for name in
|
| 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
|
| 88 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 18 |
|
| 19 |
-
|
| 20 |
|
| 21 |
-
|
| 22 |
|
| 23 |
-
|
| 24 |
|
| 25 |
-
|
| 26 |
|
| 27 |
-
|
| 28 |
|
| 29 |
-
|
| 30 |
|
| 31 |
-
|
| 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 |
-
|
| 61 |
-
|
|
|
|
| 62 |
|
| 63 |
Chaque joueur :
|
| 64 |
|
|
@@ -77,15 +78,15 @@ L’IA :
|
|
| 77 |
|
| 78 |
Garde trace des suggestions.
|
| 79 |
|
| 80 |
-
|
| 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 |
-
|
| 87 |
|
| 88 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|