Spaces:
Sleeping
Sleeping
Commit
·
8beb1aa
1
Parent(s):
1d68989
feat: improve architecture using React + Vite + FastAPI
Browse files- ARCHITECTURE_V3.md +199 -0
- SETUP_REACT.md +252 -0
- backend/config.py +37 -0
- backend/defaults.py +101 -0
- backend/game_engine.py +294 -0
- backend/game_manager.py +151 -0
- backend/main.py +363 -0
- backend/models.py +182 -0
- backend/requirements.txt +4 -0
- frontend/index.html +13 -0
- frontend/package.json +26 -0
- frontend/postcss.config.js +6 -0
- frontend/src/api.js +57 -0
- frontend/src/index.css +17 -0
- frontend/tailwind.config.js +39 -0
- frontend/vite.config.js +15 -0
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 |
+
})
|