clementpep commited on
Commit
8beb1aa
·
1 Parent(s): 1d68989

feat: improve architecture using React + Vite + FastAPI

Browse files
ARCHITECTURE_V3.md ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 🏗️ Architecture v3.0 - React + FastAPI + Docker
2
+
3
+ ## 🎯 Objectif
4
+ Interface simple et intuitive, déployable sur Hugging Face Spaces via Docker.
5
+
6
+ ## 📐 Architecture
7
+
8
+ ```
9
+ custom-cluedo/
10
+ ├── backend/ # FastAPI
11
+ │ ├── main.py # API principale
12
+ │ ├── models.py # Modèles Pydantic
13
+ │ ├── game_engine.py # Logique métier
14
+ │ ├── game_manager.py # Gestion parties
15
+ │ └── requirements.txt
16
+
17
+ ├── frontend/ # React
18
+ │ ├── public/
19
+ │ ├── src/
20
+ │ │ ├── App.jsx
21
+ │ │ ├── pages/
22
+ │ │ │ ├── Home.jsx
23
+ │ │ │ ├── Join.jsx
24
+ │ │ │ └── Game.jsx
25
+ │ │ ├── components/
26
+ │ │ │ ├── Board.jsx
27
+ │ │ │ ├── PlayerCards.jsx
28
+ │ │ │ └── ActionPanel.jsx
29
+ │ │ └── api.js
30
+ │ ├── package.json
31
+ │ └── vite.config.js
32
+
33
+ ├── Dockerfile # Multi-stage build
34
+ ├── docker-compose.yml # Dev local
35
+ └── README.md
36
+ ```
37
+
38
+ ## 🎮 Flux Simplifié
39
+
40
+ ### 1. Création (Valeurs par Défaut)
41
+ ```
42
+ Titre: "Meurtre au Manoir"
43
+ Tonalité: "Thriller"
44
+ Lieux: ["Cuisine", "Salon", "Bureau", "Chambre", "Garage", "Jardin"]
45
+ Armes: ["Poignard", "Revolver", "Corde", "Chandelier", "Clé anglaise", "Poison"]
46
+ Suspects: ["Mme Leblanc", "Col. Moutarde", "Mlle Rose", "Prof. Violet", "Mme Pervenche", "M. Olive"]
47
+ ```
48
+
49
+ **Personnalisation optionnelle** (accordéon replié par défaut)
50
+
51
+ ### 2. Interface de Jeu
52
+
53
+ **Layout simple :**
54
+ ```
55
+ ┌─────────────────────────────────────┐
56
+ │ Cluedo Custom - Code: AB7F │
57
+ ├─────────────────────────────────────┤
58
+ │ │
59
+ │ [Plateau avec positions] │
60
+ │ │
61
+ ├─────────────────────────────────────┤
62
+ │ Vos cartes: [🃏] [🃏] [🃏] │
63
+ ├─────────────────────────────────────┤
64
+ │ Tour de: Alice │
65
+ │ [🎲 Lancer dés] ou [⏭️ Passer] │
66
+ │ │
67
+ │ Si déplacé: │
68
+ │ [💭 Suggérer] [⚡ Accuser] │
69
+ └─────────────────────────────────────┘
70
+ ```
71
+
72
+ ## 🐳 Docker pour Hugging Face
73
+
74
+ ### Dockerfile
75
+ ```dockerfile
76
+ # Stage 1: Build frontend
77
+ FROM node:18-alpine AS frontend-build
78
+ WORKDIR /app/frontend
79
+ COPY frontend/package*.json ./
80
+ RUN npm install
81
+ COPY frontend/ ./
82
+ RUN npm run build
83
+
84
+ # Stage 2: Run backend + serve frontend
85
+ FROM python:3.11-slim
86
+ WORKDIR /app
87
+
88
+ # Install backend deps
89
+ COPY backend/requirements.txt ./
90
+ RUN pip install --no-cache-dir -r requirements.txt
91
+
92
+ # Copy backend
93
+ COPY backend/ ./backend/
94
+
95
+ # Copy built frontend
96
+ COPY --from=frontend-build /app/frontend/dist ./frontend/dist
97
+
98
+ # Expose port
99
+ EXPOSE 7860
100
+
101
+ # Start FastAPI (serves both API and static frontend)
102
+ CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "7860"]
103
+ ```
104
+
105
+ ## 🚀 Déploiement Hugging Face
106
+
107
+ ### README.md du Space
108
+ ```yaml
109
+ ---
110
+ title: Cluedo Custom
111
+ emoji: 🔍
112
+ colorFrom: red
113
+ colorTo: purple
114
+ sdk: docker
115
+ pinned: false
116
+ ---
117
+ ```
118
+
119
+ ## ⚙️ Configuration FastAPI pour Servir React
120
+
121
+ ```python
122
+ from fastapi import FastAPI
123
+ from fastapi.staticfiles import StaticFiles
124
+ from fastapi.responses import FileResponse
125
+
126
+ app = FastAPI()
127
+
128
+ # API routes
129
+ @app.get("/api/health")
130
+ async def health():
131
+ return {"status": "ok"}
132
+
133
+ # ... autres routes API ...
134
+
135
+ # Serve React app
136
+ app.mount("/", StaticFiles(directory="frontend/dist", html=True), name="static")
137
+
138
+ @app.get("/{full_path:path}")
139
+ async def serve_react(full_path: str):
140
+ return FileResponse("frontend/dist/index.html")
141
+ ```
142
+
143
+ ## 📦 Valeurs par Défaut
144
+
145
+ ### Thèmes Prédéfinis
146
+ ```python
147
+ DEFAULT_THEMES = {
148
+ "classic": {
149
+ "name": "Meurtre au Manoir",
150
+ "rooms": ["Cuisine", "Salon", "Bureau", "Chambre", "Garage", "Jardin"],
151
+ "weapons": ["Poignard", "Revolver", "Corde", "Chandelier", "Clé anglaise", "Poison"],
152
+ "suspects": ["Mme Leblanc", "Col. Moutarde", "Mlle Rose", "Prof. Violet", "Mme Pervenche", "M. Olive"]
153
+ },
154
+ "office": {
155
+ "name": "Meurtre au Bureau",
156
+ "rooms": ["Open Space", "Salle de réunion", "Cafétéria", "Bureau CEO", "Toilettes", "Parking"],
157
+ "weapons": ["Clé USB", "Agrafeuse", "Câble", "Capsule café", "Souris", "Plante"],
158
+ "suspects": ["Claire", "Pierre", "Daniel", "Marie", "Thomas", "Sophie"]
159
+ }
160
+ }
161
+ ```
162
+
163
+ ## 🎨 Design Simple (TailwindCSS)
164
+
165
+ - **Palette** : Tons sombres mystérieux
166
+ - **Composants** : shadcn/ui ou Chakra UI
167
+ - **Animations** : Framer Motion (minimales)
168
+
169
+ ## 🔧 APIs Essentielles
170
+
171
+ ```
172
+ POST /api/games/create # Créer (avec défauts)
173
+ POST /api/games/join # Rejoindre
174
+ POST /api/games/{id}/start # Démarrer
175
+ GET /api/games/{id}/state # État du jeu
176
+ POST /api/games/{id}/roll # Lancer dés
177
+ POST /api/games/{id}/suggest # Suggestion
178
+ POST /api/games/{id}/accuse # Accusation
179
+ POST /api/games/{id}/pass # Passer tour
180
+ ```
181
+
182
+ ## ✨ Fonctionnalités Simplifiées
183
+
184
+ ### MVP (Version 1)
185
+ - ✅ Création avec valeurs par défaut
186
+ - ✅ Rejoindre avec code
187
+ - ✅ Déplacement avec dés
188
+ - ✅ Suggestions/Accusations
189
+ - ✅ Desland (commentaires simples, pas d'IA)
190
+
191
+ ### Future (Version 2)
192
+ - [ ] IA avec OpenAI
193
+ - [ ] Personnalisation complète
194
+ - [ ] Thèmes multiples
195
+ - [ ] Animations avancées
196
+
197
+ ---
198
+
199
+ **Cette architecture sera plus robuste, plus simple à utiliser et déployable facilement sur Hugging Face Spaces.**
SETUP_REACT.md ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 🚀 Setup React + Docker - Cluedo Custom v3
2
+
3
+ ## ✅ Ce qui est fait
4
+
5
+ ### Backend
6
+ - ✅ `backend/main.py` - API FastAPI complète
7
+ - ✅ `backend/defaults.py` - 3 thèmes prédéfinis (classic, office, fantasy)
8
+ - ✅ `backend/models.py`, `game_engine.py`, `game_manager.py` - Logique métier
9
+ - ✅ `backend/requirements.txt` - Dépendances Python
10
+
11
+ ### Frontend (structure)
12
+ - ✅ `frontend/package.json` - Config React + Vite + Tailwind
13
+ - ✅ `frontend/vite.config.js` - Config Vite avec proxy API
14
+ - ✅ `frontend/tailwind.config.js` - Theme sombre
15
+ - ✅ `frontend/src/api.js` - Client API
16
+
17
+ ## 📋 Ce qu'il reste à faire
18
+
19
+ ### 1. Créer les composants React
20
+
21
+ Créer ces fichiers dans `frontend/src/` :
22
+
23
+ #### `main.jsx`
24
+ ```jsx
25
+ import React from 'react'
26
+ import ReactDOM from 'react-dom/client'
27
+ import App from './App.jsx'
28
+ import './index.css'
29
+
30
+ ReactDOM.createRoot(document.getElementById('root')).render(
31
+ <React.StrictMode>
32
+ <App />
33
+ </React.StrictMode>,
34
+ )
35
+ ```
36
+
37
+ #### `App.jsx`
38
+ ```jsx
39
+ import { BrowserRouter, Routes, Route } from 'react-router-dom'
40
+ import Home from './pages/Home'
41
+ import Join from './pages/Join'
42
+ import Game from './pages/Game'
43
+
44
+ function App() {
45
+ return (
46
+ <BrowserRouter>
47
+ <div className="min-h-screen bg-gradient-to-br from-dark-950 via-dark-900 to-dark-950">
48
+ <Routes>
49
+ <Route path="/" element={<Home />} />
50
+ <Route path="/join" element={<Join />} />
51
+ <Route path="/game/:gameId/:playerId" element={<Game />} />
52
+ </Routes>
53
+ </div>
54
+ </BrowserRouter>
55
+ )
56
+ }
57
+
58
+ export default App
59
+ ```
60
+
61
+ #### `pages/Home.jsx` - Page d'accueil
62
+ - Bouton "Créer une partie" (thème classic par défaut)
63
+ - Input nom du joueur
64
+ - Lien vers "Rejoindre une partie"
65
+
66
+ #### `pages/Join.jsx` - Rejoindre une partie
67
+ - Input code de partie (4 caractères)
68
+ - Input nom du joueur
69
+ - Bouton rejoindre
70
+
71
+ #### `pages/Game.jsx` - Interface de jeu
72
+ - Plateau avec positions
73
+ - Cartes du joueur
74
+ - Actions (Dés, Suggestion, Accusation, Passer)
75
+ - Historique
76
+ - Bouton "Démarrer" (si créateur et 3+ joueurs)
77
+
78
+ ### 2. Créer le Dockerfile
79
+
80
+ ```dockerfile
81
+ # Stage 1: Build frontend
82
+ FROM node:18-alpine AS frontend-build
83
+ WORKDIR /app/frontend
84
+ COPY frontend/package*.json ./
85
+ RUN npm install
86
+ COPY frontend/ ./
87
+ RUN npm run build
88
+
89
+ # Stage 2: Python backend
90
+ FROM python:3.11-slim
91
+ WORKDIR /app
92
+
93
+ # Install backend deps
94
+ COPY backend/requirements.txt ./
95
+ RUN pip install --no-cache-dir -r requirements.txt
96
+
97
+ # Copy backend
98
+ COPY backend/ ./backend/
99
+
100
+ # Copy built frontend
101
+ COPY --from=frontend-build /app/frontend/dist ./frontend/dist
102
+
103
+ # Expose port for Hugging Face
104
+ EXPOSE 7860
105
+
106
+ # Start server
107
+ CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "7860"]
108
+ ```
109
+
110
+ ### 3. Créer docker-compose.yml (pour test local)
111
+
112
+ ```yaml
113
+ version: '3.8'
114
+
115
+ services:
116
+ backend:
117
+ build: .
118
+ ports:
119
+ - "7860:7860"
120
+ volumes:
121
+ - ./backend:/app/backend
122
+ - ./frontend/dist:/app/frontend/dist
123
+ environment:
124
+ - PYTHONUNBUFFERED=1
125
+ ```
126
+
127
+ ### 4. Créer README.md pour Hugging Face
128
+
129
+ ```yaml
130
+ ---
131
+ title: Cluedo Custom
132
+ emoji: 🔍
133
+ colorFrom: red
134
+ colorTo: purple
135
+ sdk: docker
136
+ pinned: false
137
+ app_port: 7860
138
+ ---
139
+
140
+ # 🔍 Cluedo Custom
141
+
142
+ Créez votre propre jeu de Cluedo avec des suspects, armes et lieux personnalisés !
143
+
144
+ ## 🎮 Comment jouer
145
+
146
+ 1. **Créer une partie** : Choisissez un thème (Manoir classique, Bureau, Château)
147
+ 2. **Partager le code** : Envoyez le code à 4 caractères à vos amis
148
+ 3. **Jouer** : Lancez les dés, déplacez-vous, faites des suggestions !
149
+
150
+ ## 🛠️ Développement local
151
+
152
+ \`\`\`bash
153
+ # Build et lancer avec Docker
154
+ docker-compose up --build
155
+
156
+ # Ou séparément :
157
+ # Backend
158
+ cd backend && pip install -r requirements.txt && uvicorn main:app --port 8000
159
+
160
+ # Frontend
161
+ cd frontend && npm install && npm run dev
162
+ \`\`\`
163
+ ```
164
+
165
+ ### 5. Commandes pour tester
166
+
167
+ ```bash
168
+ # 1. Installer frontend
169
+ cd frontend
170
+ npm install
171
+
172
+ # 2. Builder frontend
173
+ npm run build
174
+
175
+ # 3. Tester backend seul (sert le frontend builded)
176
+ cd ../backend
177
+ pip install -r requirements.txt
178
+ uvicorn main:app --port 7860
179
+
180
+ # Ouvrir http://localhost:7860
181
+
182
+ # 4. Build Docker
183
+ cd ..
184
+ docker build -t cluedo-custom .
185
+ docker run -p 7860:7860 cluedo-custom
186
+
187
+ # 5. Deploy sur Hugging Face
188
+ # - Créer un nouveau Space (SDK: Docker)
189
+ # - Push le code
190
+ # - Hugging Face va build automatiquement
191
+ ```
192
+
193
+ ## 🎨 Templates de composants React simplifiés
194
+
195
+ Les composants doivent être **ultra-simples** :
196
+
197
+ ### Home.jsx (minimal)
198
+ - Titre "Cluedo Custom"
199
+ - Input "Votre nom"
200
+ - Bouton "Créer une partie"
201
+ - Lien "Rejoindre une partie existante"
202
+
203
+ ### Game.jsx (minimal)
204
+ - Section "Plateau" : Liste des pièces avec icônes joueurs
205
+ - Section "Mes cartes" : Liste simple
206
+ - Section "Actions" :
207
+ - Si mon tour : Bouton "Lancer dés" OU "Passer"
208
+ - Si déplacé : Selects (suspect/arme/pièce) + "Suggérer" / "Accuser"
209
+ - Section "Historique" : 5 dernières actions
210
+
211
+ ## ⚡ Quick Start (après avoir créé les composants)
212
+
213
+ ```bash
214
+ # Terminal 1 - Backend
215
+ cd backend
216
+ pip install -r requirements.txt
217
+ uvicorn main:app --reload --port 8000
218
+
219
+ # Terminal 2 - Frontend (dev mode)
220
+ cd frontend
221
+ npm install
222
+ npm run dev
223
+ # Ouvre http://localhost:3000
224
+
225
+ # Pour production (single server)
226
+ cd frontend
227
+ npm run build
228
+ cd ../backend
229
+ uvicorn main:app --port 7860
230
+ # Ouvre http://localhost:7860
231
+ ```
232
+
233
+ ## 🐳 Déployer sur Hugging Face
234
+
235
+ 1. Créer un Space : https://huggingface.co/new-space
236
+ 2. Choisir SDK: **Docker**
237
+ 3. Cloner le repo du Space
238
+ 4. Copier tous les fichiers dedans
239
+ 5. Ajouter `README.md` avec le header YAML
240
+ 6. Push:
241
+ ```bash
242
+ git add .
243
+ git commit -m "Initial commit"
244
+ git push
245
+ ```
246
+
247
+ 7. Attendre le build (~5-10 min)
248
+ 8. Votre jeu est en ligne ! 🎉
249
+
250
+ ---
251
+
252
+ **Note** : Les composants React sont volontairement simplifiés pour aller vite. Tu pourras les améliorer après le déploiement.
backend/config.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Configuration module for Cluedo Custom application.
3
+ Loads environment variables and provides application settings.
4
+ """
5
+
6
+ import os
7
+ from dotenv import load_dotenv
8
+
9
+ # Load environment variables from .env file
10
+ load_dotenv()
11
+
12
+
13
+ class Settings:
14
+ """Application settings loaded from environment variables."""
15
+
16
+ # Application settings
17
+ APP_NAME: str = os.getenv("APP_NAME", "Cluedo Custom")
18
+ MAX_PLAYERS: int = int(os.getenv("MAX_PLAYERS", "8"))
19
+
20
+ # AI settings
21
+ USE_OPENAI: bool = os.getenv("USE_OPENAI", "false").lower() == "true"
22
+ OPENAI_API_KEY: str = os.getenv("OPENAI_API_KEY", "")
23
+
24
+ # Game settings
25
+ MIN_PLAYERS: int = 3
26
+ MIN_ROOMS: int = 6
27
+ MAX_ROOMS: int = 12
28
+
29
+ # Server settings
30
+ HOST: str = "0.0.0.0"
31
+ PORT: int = 7860
32
+
33
+ # Game data file
34
+ GAMES_FILE: str = "games.json"
35
+
36
+
37
+ settings = Settings()
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
backend/game_engine.py ADDED
@@ -0,0 +1,294 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Game engine for Cluedo Custom.
3
+ Handles game logic, card distribution, turn management, and game rules.
4
+ """
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 (fallback if no custom suspects provided)
13
+ DEFAULT_CHARACTERS = [
14
+ "Miss Scarlett",
15
+ "Colonel Mustard",
16
+ "Mrs. White",
17
+ "Reverend Green",
18
+ "Mrs. Peacock",
19
+ "Professor Plum"
20
+ ]
21
+
22
+ # Default weapon names (fallback if no custom weapons provided)
23
+ DEFAULT_WEAPONS = [
24
+ "Candlestick",
25
+ "Knife",
26
+ "Lead Pipe",
27
+ "Revolver",
28
+ "Rope",
29
+ "Wrench"
30
+ ]
31
+
32
+
33
+ class GameEngine:
34
+ """Core game logic engine for Cluedo Custom."""
35
+
36
+ @staticmethod
37
+ def initialize_game(game: Game) -> Game:
38
+ """
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
59
+ game.room_cards = [
60
+ Card(name=room, card_type=CardType.ROOM)
61
+ for room in game.rooms
62
+ ]
63
+
64
+ # Select solution (one of each type)
65
+ solution_character = random.choice(game.characters)
66
+ solution_weapon = random.choice(game.weapons)
67
+ solution_room = random.choice(game.room_cards)
68
+
69
+ game.solution = Solution(
70
+ character=solution_character,
71
+ weapon=solution_weapon,
72
+ room=solution_room
73
+ )
74
+
75
+ # Remaining cards to distribute
76
+ remaining_cards = []
77
+ remaining_cards.extend([c for c in game.characters if c.name != solution_character.name])
78
+ remaining_cards.extend([w for w in game.weapons if w.name != solution_weapon.name])
79
+ remaining_cards.extend([r for r in game.room_cards if r.name != solution_room.name])
80
+
81
+ # Shuffle and distribute
82
+ random.shuffle(remaining_cards)
83
+ GameEngine._distribute_cards(game, remaining_cards)
84
+
85
+ # Set game status to in progress
86
+ game.status = GameStatus.IN_PROGRESS
87
+ game.current_player_index = 0
88
+
89
+ return game
90
+
91
+ @staticmethod
92
+ def _distribute_cards(game: Game, cards: List[Card]):
93
+ """
94
+ Distribute cards evenly among all players.
95
+ """
96
+ num_players = len(game.players)
97
+ if num_players == 0:
98
+ return
99
+
100
+ for i, card in enumerate(cards):
101
+ player_index = i % num_players
102
+ game.players[player_index].cards.append(card)
103
+
104
+ @staticmethod
105
+ def check_suggestion(
106
+ game: Game,
107
+ player_id: str,
108
+ character: str,
109
+ weapon: str,
110
+ room: str
111
+ ) -> Tuple[bool, Optional[str], Optional[Card]]:
112
+ """
113
+ Process a player's suggestion.
114
+ Returns (can_disprove, disprover_name, card_shown).
115
+
116
+ Starting with the next player clockwise, check if anyone can disprove
117
+ the suggestion by showing one matching card.
118
+ """
119
+ player_index = next(
120
+ (i for i, p in enumerate(game.players) if p.id == player_id),
121
+ None
122
+ )
123
+ if player_index is None:
124
+ return False, None, None
125
+
126
+ num_players = len(game.players)
127
+
128
+ # Check other players clockwise
129
+ for offset in range(1, num_players):
130
+ check_index = (player_index + offset) % num_players
131
+ checker = game.players[check_index]
132
+
133
+ # Find matching cards
134
+ matching_cards = [
135
+ card for card in checker.cards
136
+ if card.name in [character, weapon, room]
137
+ ]
138
+
139
+ if matching_cards:
140
+ # Show one random matching card
141
+ card_to_show = random.choice(matching_cards)
142
+ return True, checker.name, card_to_show
143
+
144
+ # No one can disprove
145
+ return False, None, None
146
+
147
+ @staticmethod
148
+ def check_accusation(
149
+ game: Game,
150
+ character: str,
151
+ weapon: str,
152
+ room: str
153
+ ) -> bool:
154
+ """
155
+ Check if an accusation is correct.
156
+ Returns True if the accusation matches the solution.
157
+ """
158
+ if not game.solution:
159
+ return False
160
+
161
+ return (
162
+ game.solution.character.name == character and
163
+ game.solution.weapon.name == weapon and
164
+ game.solution.room.name == room
165
+ )
166
+
167
+ @staticmethod
168
+ def process_accusation(
169
+ game: Game,
170
+ player_id: str,
171
+ character: str,
172
+ weapon: str,
173
+ room: str
174
+ ) -> Tuple[bool, str]:
175
+ """
176
+ Process a player's accusation.
177
+ Returns (is_correct, message).
178
+
179
+ If correct, player wins.
180
+ If incorrect, player is eliminated from the game.
181
+ """
182
+ player = next((p for p in game.players if p.id == player_id), None)
183
+ if not player:
184
+ return False, "Player not found"
185
+
186
+ is_correct = GameEngine.check_accusation(game, character, weapon, room)
187
+
188
+ if is_correct:
189
+ game.winner = player.name
190
+ game.status = GameStatus.FINISHED
191
+ return True, f"{player.name} wins! The accusation was correct."
192
+ else:
193
+ # Eliminate player
194
+ player.is_active = False
195
+
196
+ # Check if only one or no players remain active
197
+ active_players = [p for p in game.players if p.is_active]
198
+ if len(active_players) <= 1:
199
+ game.status = GameStatus.FINISHED
200
+ if active_players:
201
+ game.winner = active_players[0].name
202
+ return False, f"{player.name}'s accusation was wrong. {game.winner} wins by elimination!"
203
+ else:
204
+ return False, "All players eliminated. Game over!"
205
+
206
+ return False, f"{player.name}'s accusation was wrong and is eliminated from the game."
207
+
208
+ @staticmethod
209
+ def add_turn_record(
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 models import Turn
218
+
219
+ player = next((p for p in game.players if p.id == player_id), None)
220
+ if not player:
221
+ return
222
+
223
+ turn = Turn(
224
+ player_id=player_id,
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)
232
+
233
+ @staticmethod
234
+ def get_player_card_names(player: Player) -> List[str]:
235
+ """Get list of card names for a player."""
236
+ return [card.name for card in player.cards]
237
+
238
+ @staticmethod
239
+ def can_player_act(game: Game, player_id: str) -> bool:
240
+ """Check if it's the player's turn and they can act."""
241
+ current_player = game.get_current_player()
242
+ if not current_player:
243
+ return False
244
+
245
+ return (
246
+ current_player.id == player_id and
247
+ current_player.is_active and
248
+ game.status == GameStatus.IN_PROGRESS
249
+ )
250
+
251
+ @staticmethod
252
+ def roll_dice() -> int:
253
+ """Roll two dice and return the sum (2-12)."""
254
+ return random.randint(1, 6) + 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 on the board based on dice roll.
260
+ Returns (success, message, new_room_index).
261
+ """
262
+ player = next((p for p in game.players if p.id == player_id), None)
263
+ if not player:
264
+ return False, "Joueur introuvable", -1
265
+
266
+ num_rooms = len(game.rooms)
267
+ if num_rooms == 0:
268
+ return False, "Pas de pièces disponibles", -1
269
+
270
+ # Calculate new position (circular movement)
271
+ new_room_index = (player.current_room_index + dice_roll) % num_rooms
272
+ old_room = game.rooms[player.current_room_index]
273
+ new_room = game.rooms[new_room_index]
274
+
275
+ player.current_room_index = new_room_index
276
+
277
+ message = f"🎲 Dés: {dice_roll} | {old_room} → {new_room}"
278
+ return True, message, new_room_index
279
+
280
+ @staticmethod
281
+ def can_make_suggestion(game: Game, player_id: str, room: str) -> Tuple[bool, str]:
282
+ """
283
+ Check if a player can make a suggestion.
284
+ Players can only suggest in the room they're currently in.
285
+ """
286
+ player = next((p for p in game.players if p.id == player_id), None)
287
+ if not player:
288
+ return False, "Joueur introuvable"
289
+
290
+ current_room = game.rooms[player.current_room_index]
291
+ if current_room != room:
292
+ return False, f"Tu dois être dans {room} pour faire cette suggestion ! Tu es actuellement dans {current_room}."
293
+
294
+ return True, ""
backend/game_manager.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Game manager for handling multiple concurrent games.
3
+ Provides in-memory storage and game lifecycle management.
4
+ """
5
+
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:
15
+ """Manages multiple game instances in memory."""
16
+
17
+ def __init__(self):
18
+ self.games: Dict[str, Game] = {}
19
+ self.load_games()
20
+
21
+ def create_game(self, request: CreateGameRequest) -> Game:
22
+ """
23
+ Create a new game instance.
24
+ """
25
+ game_id = Game.generate_game_id()
26
+
27
+ # Ensure unique game ID
28
+ while game_id in self.games:
29
+ game_id = Game.generate_game_id()
30
+
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
+ max_players=settings.MAX_PLAYERS
41
+ )
42
+
43
+ self.games[game_id] = game
44
+ self.save_games()
45
+
46
+ return game
47
+
48
+ def get_game(self, game_id: str) -> Optional[Game]:
49
+ """Retrieve a game by ID."""
50
+ return self.games.get(game_id)
51
+
52
+ def join_game(self, game_id: str, player_name: str) -> Optional[Player]:
53
+ """
54
+ Add a player to an existing game.
55
+ Returns the player if successful, None otherwise.
56
+ """
57
+ game = self.get_game(game_id)
58
+
59
+ if not game:
60
+ return None
61
+
62
+ if game.status != GameStatus.WAITING:
63
+ return None # Can't join a game in progress
64
+
65
+ if game.is_full():
66
+ return None # Game is full
67
+
68
+ player = game.add_player(player_name)
69
+ self.save_games()
70
+
71
+ return player
72
+
73
+ def start_game(self, game_id: str) -> bool:
74
+ """
75
+ Start a game (initialize cards and solution).
76
+ Returns True if successful.
77
+ """
78
+ game = self.get_game(game_id)
79
+
80
+ if not game:
81
+ return False
82
+
83
+ if game.status != GameStatus.WAITING:
84
+ return False # Game already started
85
+
86
+ if len(game.players) < settings.MIN_PLAYERS:
87
+ return False # Not enough players
88
+
89
+ # Initialize the game
90
+ GameEngine.initialize_game(game)
91
+ self.save_games()
92
+
93
+ return True
94
+
95
+ def list_active_games(self) -> List[Dict]:
96
+ """
97
+ List all active games (waiting or in progress).
98
+ Returns simplified game info for listing.
99
+ """
100
+ active_games = []
101
+
102
+ for game in self.games.values():
103
+ if game.status in [GameStatus.WAITING, GameStatus.IN_PROGRESS]:
104
+ active_games.append({
105
+ "game_id": game.game_id,
106
+ "name": game.name,
107
+ "status": game.status,
108
+ "players": len(game.players),
109
+ "max_players": game.max_players,
110
+ })
111
+
112
+ return active_games
113
+
114
+ def delete_game(self, game_id: str) -> bool:
115
+ """Delete a game from memory."""
116
+ if game_id in self.games:
117
+ del self.games[game_id]
118
+ self.save_games()
119
+ return True
120
+ return False
121
+
122
+ def save_games(self):
123
+ """Persist games to JSON file."""
124
+ try:
125
+ games_data = {
126
+ game_id: game.model_dump()
127
+ for game_id, game in self.games.items()
128
+ }
129
+
130
+ with open(settings.GAMES_FILE, 'w') as f:
131
+ json.dump(games_data, f, indent=2)
132
+ except Exception as e:
133
+ print(f"Error saving games: {e}")
134
+
135
+ def load_games(self):
136
+ """Load games from JSON file if it exists."""
137
+ if not os.path.exists(settings.GAMES_FILE):
138
+ return
139
+
140
+ try:
141
+ with open(settings.GAMES_FILE, 'r') as f:
142
+ games_data = json.load(f)
143
+
144
+ for game_id, game_dict in games_data.items():
145
+ self.games[game_id] = Game(**game_dict)
146
+ except Exception as e:
147
+ print(f"Error loading games: {e}")
148
+
149
+
150
+ # Global game manager instance
151
+ game_manager = GameManager()
backend/main.py ADDED
@@ -0,0 +1,363 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 models import CreateGameRequest, GameStatus
15
+ from game_manager import game_manager
16
+ from game_engine import GameEngine
17
+ from 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
+ game_req = CreateGameRequest(
55
+ game_name=config["name"],
56
+ narrative_tone=config["tone"],
57
+ custom_prompt=None,
58
+ rooms=config["rooms"],
59
+ custom_weapons=config["weapons"],
60
+ custom_suspects=config["suspects"],
61
+ use_ai=False
62
+ )
63
+
64
+ game = game_manager.create_game(game_req)
65
+
66
+ # Auto-join creator as first player
67
+ player = game_manager.join_game(game.game_id, req.player_name)
68
+
69
+ return {
70
+ "game_id": game.game_id,
71
+ "player_id": player.id if player else None,
72
+ "game_name": game.name,
73
+ "theme": req.theme
74
+ }
75
+ except Exception as e:
76
+ raise HTTPException(status_code=500, detail=str(e))
77
+
78
+
79
+ class JoinRequest(BaseModel):
80
+ game_id: str
81
+ player_name: str
82
+
83
+
84
+ @app.post("/api/games/join")
85
+ async def join_game(req: JoinRequest):
86
+ """Join an existing game"""
87
+ game = game_manager.get_game(req.game_id.upper())
88
+
89
+ if not game:
90
+ raise HTTPException(status_code=404, detail="Game not found")
91
+
92
+ if game.status != GameStatus.WAITING:
93
+ raise HTTPException(status_code=400, detail="Game already started")
94
+
95
+ if game.is_full():
96
+ raise HTTPException(status_code=400, detail="Game is full")
97
+
98
+ player = game_manager.join_game(req.game_id.upper(), req.player_name)
99
+
100
+ if not player:
101
+ raise HTTPException(status_code=400, detail="Could not join game")
102
+
103
+ return {
104
+ "game_id": game.game_id,
105
+ "player_id": player.id,
106
+ "player_name": player.name
107
+ }
108
+
109
+
110
+ @app.post("/api/games/{game_id}/start")
111
+ async def start_game(game_id: str):
112
+ """Start a game"""
113
+ success = game_manager.start_game(game_id.upper())
114
+
115
+ if not success:
116
+ raise HTTPException(status_code=400, detail="Cannot start game (need min 3 players)")
117
+
118
+ game = game_manager.get_game(game_id.upper())
119
+ return {
120
+ "status": "started",
121
+ "first_player": game.get_current_player().name if game else None
122
+ }
123
+
124
+
125
+ @app.get("/api/games/{game_id}/state/{player_id}")
126
+ async def get_game_state(game_id: str, player_id: str):
127
+ """Get game state for a specific player"""
128
+ game = game_manager.get_game(game_id.upper())
129
+
130
+ if not game:
131
+ raise HTTPException(status_code=404, detail="Game not found")
132
+
133
+ player = next((p for p in game.players if p.id == player_id), None)
134
+
135
+ if not player:
136
+ raise HTTPException(status_code=404, detail="Player not found")
137
+
138
+ current_player = game.get_current_player()
139
+
140
+ # Build player-specific view
141
+ return {
142
+ "game_id": game.game_id,
143
+ "game_name": game.name,
144
+ "status": game.status.value,
145
+ "scenario": game.scenario,
146
+ "rooms": game.rooms,
147
+ "suspects": [c.name for c in game.characters],
148
+ "weapons": [w.name for w in game.weapons],
149
+ "my_cards": [{"name": c.name, "type": c.card_type.value} for c in player.cards],
150
+ "my_position": player.current_room_index,
151
+ "current_room": game.rooms[player.current_room_index] if game.rooms else None,
152
+ "players": [
153
+ {
154
+ "name": p.name,
155
+ "is_active": p.is_active,
156
+ "position": p.current_room_index,
157
+ "room": game.rooms[p.current_room_index] if game.rooms else None,
158
+ "is_me": p.id == player_id
159
+ }
160
+ for p in game.players
161
+ ],
162
+ "current_turn": {
163
+ "player_name": current_player.name if current_player else None,
164
+ "is_my_turn": current_player.id == player_id if current_player else False
165
+ },
166
+ "recent_actions": [
167
+ {
168
+ "player": t.player_name,
169
+ "action": t.action,
170
+ "details": t.details,
171
+ "ai_comment": t.ai_comment
172
+ }
173
+ for t in game.turns[-5:]
174
+ ],
175
+ "winner": game.winner
176
+ }
177
+
178
+
179
+ class DiceRollRequest(BaseModel):
180
+ player_id: str
181
+
182
+
183
+ @app.post("/api/games/{game_id}/roll")
184
+ async def roll_dice(game_id: str, req: DiceRollRequest):
185
+ """Roll dice and move player"""
186
+ game = game_manager.get_game(game_id.upper())
187
+
188
+ if not game:
189
+ raise HTTPException(status_code=404, detail="Game not found")
190
+
191
+ if not GameEngine.can_player_act(game, req.player_id):
192
+ raise HTTPException(status_code=400, detail="Not your turn")
193
+
194
+ # Roll dice
195
+ dice = GameEngine.roll_dice()
196
+
197
+ # Move player
198
+ success, msg, new_pos = GameEngine.move_player(game, req.player_id, dice)
199
+
200
+ if not success:
201
+ raise HTTPException(status_code=400, detail=msg)
202
+
203
+ # Record turn
204
+ GameEngine.add_turn_record(game, req.player_id, "move", msg)
205
+ game_manager.save_games()
206
+
207
+ return {
208
+ "dice_value": dice,
209
+ "new_position": new_pos,
210
+ "new_room": game.rooms[new_pos],
211
+ "message": msg
212
+ }
213
+
214
+
215
+ class SuggestionRequest(BaseModel):
216
+ player_id: str
217
+ suspect: str
218
+ weapon: str
219
+ room: str
220
+
221
+
222
+ @app.post("/api/games/{game_id}/suggest")
223
+ async def make_suggestion(game_id: str, req: SuggestionRequest):
224
+ """Make a suggestion"""
225
+ game = game_manager.get_game(game_id.upper())
226
+
227
+ if not game:
228
+ raise HTTPException(status_code=404, detail="Game not found")
229
+
230
+ if not GameEngine.can_player_act(game, req.player_id):
231
+ raise HTTPException(status_code=400, detail="Not your turn")
232
+
233
+ # Check if player is in the room
234
+ can_suggest, error = GameEngine.can_make_suggestion(game, req.player_id, req.room)
235
+ if not can_suggest:
236
+ raise HTTPException(status_code=400, detail=error)
237
+
238
+ # Process suggestion
239
+ can_disprove, disprover, card = GameEngine.check_suggestion(
240
+ game, req.player_id, req.suspect, req.weapon, req.room
241
+ )
242
+
243
+ result = {
244
+ "suggestion": f"{req.suspect} + {req.weapon} + {req.room}",
245
+ "was_disproven": can_disprove,
246
+ "disprover": disprover if can_disprove else None,
247
+ "card_shown": card.name if card else None
248
+ }
249
+
250
+ # Record turn
251
+ GameEngine.add_turn_record(
252
+ game,
253
+ req.player_id,
254
+ "suggest",
255
+ result["suggestion"]
256
+ )
257
+
258
+ game.next_turn()
259
+ game_manager.save_games()
260
+
261
+ return result
262
+
263
+
264
+ class AccusationRequest(BaseModel):
265
+ player_id: str
266
+ suspect: str
267
+ weapon: str
268
+ room: str
269
+
270
+
271
+ @app.post("/api/games/{game_id}/accuse")
272
+ async def make_accusation(game_id: str, req: AccusationRequest):
273
+ """Make an accusation"""
274
+ game = game_manager.get_game(game_id.upper())
275
+
276
+ if not game:
277
+ raise HTTPException(status_code=404, detail="Game not found")
278
+
279
+ if not GameEngine.can_player_act(game, req.player_id):
280
+ raise HTTPException(status_code=400, detail="Not your turn")
281
+
282
+ # Process accusation
283
+ is_correct, message = GameEngine.process_accusation(
284
+ game, req.player_id, req.suspect, req.weapon, req.room
285
+ )
286
+
287
+ # Record turn
288
+ GameEngine.add_turn_record(
289
+ game,
290
+ req.player_id,
291
+ "accuse",
292
+ f"{req.suspect} + {req.weapon} + {req.room}"
293
+ )
294
+
295
+ if not is_correct and game.status == GameStatus.IN_PROGRESS:
296
+ game.next_turn()
297
+
298
+ game_manager.save_games()
299
+
300
+ return {
301
+ "is_correct": is_correct,
302
+ "message": message,
303
+ "winner": game.winner
304
+ }
305
+
306
+
307
+ class PassRequest(BaseModel):
308
+ player_id: str
309
+
310
+
311
+ @app.post("/api/games/{game_id}/pass")
312
+ async def pass_turn(game_id: str, req: PassRequest):
313
+ """Pass the turn"""
314
+ game = game_manager.get_game(game_id.upper())
315
+
316
+ if not game:
317
+ raise HTTPException(status_code=404, detail="Game not found")
318
+
319
+ if not GameEngine.can_player_act(game, req.player_id):
320
+ raise HTTPException(status_code=400, detail="Not your turn")
321
+
322
+ # Record turn
323
+ GameEngine.add_turn_record(game, req.player_id, "pass", "Passed turn")
324
+
325
+ game.next_turn()
326
+ game_manager.save_games()
327
+
328
+ next_player = game.get_current_player()
329
+
330
+ return {
331
+ "message": "Turn passed",
332
+ "next_player": next_player.name if next_player else None
333
+ }
334
+
335
+
336
+ # ==================== SERVE REACT APP ====================
337
+
338
+ # Check if frontend build exists
339
+ frontend_path = os.path.join(os.path.dirname(__file__), "..", "frontend", "dist")
340
+
341
+ if os.path.exists(frontend_path):
342
+ # Serve static files
343
+ app.mount("/assets", StaticFiles(directory=os.path.join(frontend_path, "assets")), name="assets")
344
+
345
+ @app.get("/{full_path:path}")
346
+ async def serve_react(full_path: str):
347
+ """Serve React app for all non-API routes"""
348
+ if full_path.startswith("api/"):
349
+ raise HTTPException(status_code=404, detail="API route not found")
350
+
351
+ index_file = os.path.join(frontend_path, "index.html")
352
+ if os.path.exists(index_file):
353
+ return FileResponse(index_file)
354
+ else:
355
+ raise HTTPException(status_code=404, detail="Frontend not built")
356
+ else:
357
+ @app.get("/")
358
+ async def root():
359
+ return {
360
+ "message": "Cluedo Custom API",
361
+ "docs": "/docs",
362
+ "frontend": "Not built yet. Run: cd frontend && npm run build"
363
+ }
backend/models.py ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Data models for Cluedo Custom game.
3
+ Defines the structure of players, cards, and game state.
4
+ """
5
+
6
+ from typing import List, Optional, Dict
7
+ from pydantic import BaseModel, Field
8
+ from enum import Enum
9
+ import random
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"
25
+ WEAPON = "weapon"
26
+ ROOM = "room"
27
+
28
+
29
+ class Card(BaseModel):
30
+ """Represents a single card in the game."""
31
+ name: str
32
+ card_type: CardType
33
+
34
+
35
+ class Player(BaseModel):
36
+ """Represents a player in the game."""
37
+ id: str
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
+
43
+
44
+ class GameStatus(str, Enum):
45
+ """Status of a game."""
46
+ WAITING = "waiting" # Waiting for players to join
47
+ IN_PROGRESS = "in_progress" # Game is running
48
+ FINISHED = "finished" # Game has ended
49
+
50
+
51
+ class Turn(BaseModel):
52
+ """Represents a turn action in the game."""
53
+ player_id: str
54
+ player_name: str
55
+ action: str # "move", "suggest", "accuse", "pass"
56
+ details: Optional[str] = None
57
+ ai_comment: Optional[str] = None # Desland's sarcastic comment
58
+ timestamp: str
59
+
60
+
61
+ class Solution(BaseModel):
62
+ """The secret solution to the mystery."""
63
+ character: Card
64
+ weapon: Card
65
+ room: Card
66
+
67
+
68
+ class InvestigationNote(BaseModel):
69
+ """Player's notes on the investigation."""
70
+ player_id: str
71
+ element_name: str # Name of suspect/weapon/room
72
+ element_type: str # "suspect", "weapon", "room"
73
+ status: str # "unknown", "eliminated", "maybe"
74
+
75
+
76
+ class Game(BaseModel):
77
+ """Represents a complete game instance."""
78
+ game_id: str
79
+ name: str
80
+ status: GameStatus = GameStatus.WAITING
81
+
82
+ # Theme and narrative
83
+ narrative_tone: str = NarrativeTone.SERIOUS.value
84
+ custom_prompt: Optional[str] = None
85
+
86
+ # Game elements (customizable)
87
+ rooms: List[str]
88
+ custom_weapons: List[str] = Field(default_factory=list)
89
+ custom_suspects: List[str] = Field(default_factory=list)
90
+
91
+ use_ai: bool = False
92
+
93
+ # Players
94
+ players: List[Player] = Field(default_factory=list)
95
+ max_players: int = 8
96
+ current_player_index: int = 0
97
+
98
+ # Cards
99
+ characters: List[Card] = Field(default_factory=list)
100
+ weapons: List[Card] = Field(default_factory=list)
101
+ room_cards: List[Card] = Field(default_factory=list)
102
+
103
+ # Solution
104
+ solution: Optional[Solution] = None
105
+
106
+ # Game state
107
+ turns: List[Turn] = Field(default_factory=list)
108
+ winner: Optional[str] = None
109
+
110
+ # Investigation notes (for UI)
111
+ investigation_notes: List[InvestigationNote] = Field(default_factory=list)
112
+
113
+ # AI-generated content
114
+ scenario: Optional[str] = None
115
+
116
+ @staticmethod
117
+ def generate_game_id() -> str:
118
+ """Generate a unique 4-character game ID (like AB7F)."""
119
+ chars = string.ascii_uppercase + string.digits
120
+ return ''.join(random.choices(chars, k=4))
121
+
122
+ def add_player(self, player_name: str) -> Player:
123
+ """Add a new player to the game."""
124
+ player_id = ''.join(random.choices(string.ascii_lowercase + string.digits, k=8))
125
+ # All players start in the first room
126
+ player = Player(id=player_id, name=player_name, current_room_index=0)
127
+ self.players.append(player)
128
+ return player
129
+
130
+ def get_current_player(self) -> Optional[Player]:
131
+ """Get the player whose turn it is."""
132
+ if not self.players:
133
+ return None
134
+ return self.players[self.current_player_index]
135
+
136
+ def next_turn(self):
137
+ """Move to the next active player's turn."""
138
+ if not self.players:
139
+ return
140
+
141
+ # Skip eliminated players
142
+ attempts = 0
143
+ while attempts < len(self.players):
144
+ self.current_player_index = (self.current_player_index + 1) % len(self.players)
145
+ if self.players[self.current_player_index].is_active:
146
+ break
147
+ attempts += 1
148
+
149
+ def is_full(self) -> bool:
150
+ """Check if the game has reached maximum players."""
151
+ return len(self.players) >= self.max_players
152
+
153
+
154
+ class CreateGameRequest(BaseModel):
155
+ """Request to create a new game."""
156
+ game_name: str
157
+ narrative_tone: str = NarrativeTone.SERIOUS.value
158
+ custom_prompt: Optional[str] = None
159
+ rooms: List[str]
160
+ custom_weapons: List[str]
161
+ custom_suspects: List[str]
162
+ use_ai: bool = False
163
+
164
+
165
+ class JoinGameRequest(BaseModel):
166
+ """Request to join an existing game."""
167
+ game_id: str
168
+ player_name: str
169
+
170
+
171
+ class GameAction(BaseModel):
172
+ """Request to perform a game action."""
173
+ game_id: str
174
+ player_id: str
175
+ action_type: str # "move", "suggest", "accuse", "pass"
176
+ # For movement
177
+ dice_roll: Optional[int] = None
178
+ target_room_index: Optional[int] = None
179
+ # For suggestions/accusations
180
+ character: Optional[str] = None
181
+ weapon: Optional[str] = None
182
+ room: Optional[str] = None
backend/requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ fastapi==0.104.1
2
+ uvicorn[standard]==0.24.0
3
+ pydantic==2.5.0
4
+ python-multipart==0.0.6
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/api.js ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ export const gameAPI = {
13
+ // Get available themes
14
+ getThemes: () => api.get('/themes'),
15
+
16
+ // Quick create with defaults
17
+ quickCreate: (theme, playerName) =>
18
+ api.post('/games/quick-create', { theme, player_name: playerName }),
19
+
20
+ // Join existing game
21
+ join: (gameId, playerName) =>
22
+ api.post('/games/join', { game_id: gameId, player_name: playerName }),
23
+
24
+ // Start game
25
+ start: (gameId) => api.post(`/games/${gameId}/start`),
26
+
27
+ // Get game state
28
+ getState: (gameId, playerId) => api.get(`/games/${gameId}/state/${playerId}`),
29
+
30
+ // Roll dice
31
+ rollDice: (gameId, playerId) =>
32
+ api.post(`/games/${gameId}/roll`, { player_id: playerId }),
33
+
34
+ // Make suggestion
35
+ suggest: (gameId, playerId, suspect, weapon, room) =>
36
+ api.post(`/games/${gameId}/suggest`, {
37
+ player_id: playerId,
38
+ suspect,
39
+ weapon,
40
+ room,
41
+ }),
42
+
43
+ // Make accusation
44
+ accuse: (gameId, playerId, suspect, weapon, room) =>
45
+ api.post(`/games/${gameId}/accuse`, {
46
+ player_id: playerId,
47
+ suspect,
48
+ weapon,
49
+ room,
50
+ }),
51
+
52
+ // Pass turn
53
+ pass: (gameId, playerId) =>
54
+ api.post(`/games/${gameId}/pass`, { player_id: playerId }),
55
+ };
56
+
57
+ export default api;
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/tailwind.config.js ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ },
36
+ },
37
+ },
38
+ plugins: [],
39
+ }
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
+ })