Spaces:
Runtime error
Runtime error
Commit ·
07ed12b
0
Parent(s):
First commit
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +9 -0
- .env.example +4 -0
- .gitattributes +2 -0
- .gitignore +1 -0
- Dockerfile +30 -0
- README.md +214 -0
- backend/__pycache__/config.cpython-39.pyc +0 -0
- backend/__pycache__/main.cpython-39.pyc +0 -0
- backend/config.py +18 -0
- backend/game/__init__.py +0 -0
- backend/game/__pycache__/__init__.cpython-39.pyc +0 -0
- backend/game/__pycache__/buildings.cpython-39.pyc +0 -0
- backend/game/__pycache__/commands.cpython-39.pyc +0 -0
- backend/game/__pycache__/engine.cpython-39.pyc +0 -0
- backend/game/__pycache__/map.cpython-39.pyc +0 -0
- backend/game/__pycache__/state.cpython-39.pyc +0 -0
- backend/game/__pycache__/tech_tree.cpython-39.pyc +0 -0
- backend/game/__pycache__/units.cpython-39.pyc +0 -0
- backend/game/buildings.py +109 -0
- backend/game/commands.py +78 -0
- backend/game/engine.py +743 -0
- backend/game/map.py +106 -0
- backend/game/state.py +145 -0
- backend/game/tech_tree.py +70 -0
- backend/game/units.py +131 -0
- backend/lobby/__init__.py +0 -0
- backend/lobby/__pycache__/__init__.cpython-39.pyc +0 -0
- backend/lobby/__pycache__/manager.cpython-39.pyc +0 -0
- backend/lobby/manager.py +198 -0
- backend/main.py +341 -0
- backend/requirements.txt +8 -0
- backend/voice/__init__.py +0 -0
- backend/voice/__pycache__/__init__.cpython-39.pyc +0 -0
- backend/voice/__pycache__/command_parser.cpython-39.pyc +0 -0
- backend/voice/__pycache__/stt.cpython-39.pyc +0 -0
- backend/voice/__pycache__/tts.cpython-39.pyc +0 -0
- backend/voice/command_parser.py +117 -0
- backend/voice/stt.py +41 -0
- backend/voice/tts.py +49 -0
- frontend/.svelte-kit/ambient.d.ts +406 -0
- frontend/.svelte-kit/generated/client-optimized/app.js +31 -0
- frontend/.svelte-kit/generated/client-optimized/matchers.js +1 -0
- frontend/.svelte-kit/generated/client-optimized/nodes/0.js +1 -0
- frontend/.svelte-kit/generated/client-optimized/nodes/1.js +1 -0
- frontend/.svelte-kit/generated/client-optimized/nodes/2.js +3 -0
- frontend/.svelte-kit/generated/client-optimized/nodes/3.js +3 -0
- frontend/.svelte-kit/generated/client/app.js +31 -0
- frontend/.svelte-kit/generated/client/matchers.js +1 -0
- frontend/.svelte-kit/generated/client/nodes/0.js +1 -0
- frontend/.svelte-kit/generated/client/nodes/1.js +1 -0
.dockerignore
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
**/__pycache__
|
| 2 |
+
**/*.pyc
|
| 3 |
+
**/*.pyo
|
| 4 |
+
**/.env
|
| 5 |
+
**/node_modules
|
| 6 |
+
frontend/.svelte-kit
|
| 7 |
+
frontend/build
|
| 8 |
+
.git
|
| 9 |
+
*.md
|
.env.example
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MISTRAL_API_KEY=your_mistral_api_key_here
|
| 2 |
+
ELEVENLABS_API_KEY=your_elevenlabs_api_key_here
|
| 3 |
+
ELEVENLABS_VOICE_ID=21m00Tcm4TlvDq8ikWAM
|
| 4 |
+
SECRET_KEY=change-me-in-production
|
.gitattributes
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.PNG filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
.env
|
Dockerfile
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ── Stage 1 : Build SvelteKit ────────────────────────────────────────────────
|
| 2 |
+
FROM node:20-slim AS frontend-builder
|
| 3 |
+
|
| 4 |
+
WORKDIR /app/frontend
|
| 5 |
+
COPY frontend/package*.json ./
|
| 6 |
+
RUN npm ci --prefer-offline
|
| 7 |
+
|
| 8 |
+
COPY frontend/ ./
|
| 9 |
+
RUN npm run build # → frontend/build/
|
| 10 |
+
|
| 11 |
+
# ── Stage 2 : Runtime Python ─────────────────────────────────────────────────
|
| 12 |
+
FROM python:3.11-slim
|
| 13 |
+
|
| 14 |
+
WORKDIR /app
|
| 15 |
+
|
| 16 |
+
# Deps Python
|
| 17 |
+
COPY backend/requirements.txt ./
|
| 18 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 19 |
+
|
| 20 |
+
# Code backend
|
| 21 |
+
COPY backend/ ./backend/
|
| 22 |
+
|
| 23 |
+
# Build frontend statique (servi par FastAPI)
|
| 24 |
+
COPY --from=frontend-builder /app/frontend/build ./frontend/build
|
| 25 |
+
|
| 26 |
+
# HF Spaces exige le port 7860
|
| 27 |
+
EXPOSE 7860
|
| 28 |
+
|
| 29 |
+
WORKDIR /app/backend
|
| 30 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
|
README.md
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: VoiceStrike
|
| 3 |
+
emoji: ⚡
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# VoiceStrike — RTS commandé à la voix
|
| 12 |
+
|
| 13 |
+
> Hackathon Mistral × ElevenLabs — Hébergé sur Hugging Face Spaces
|
| 14 |
+
|
| 15 |
+
Un jeu de stratégie en temps réel 1v1 entièrement contrôlé à la voix. Inspiré de la race Terran de StarCraft, simplifié pour être jouable sur mobile avec seulement un micro.
|
| 16 |
+
|
| 17 |
+
---
|
| 18 |
+
|
| 19 |
+
## Concept
|
| 20 |
+
|
| 21 |
+
Le joueur parle pour donner des ordres : *"Construis des barracks, entraîne 4 marines, attaque la base ennemie"*. L'IA (Mistral) interprète les commandes en langage naturel et les traduit en actions de jeu. ElevenLabs restitue les retours audio du jeu (*"Construction terminée", "Sous attaque !"*).
|
| 22 |
+
|
| 23 |
+
Le toucher / la souris servent uniquement à naviguer sur la carte, inspecter les unités ou les techs — jamais pour jouer.
|
| 24 |
+
|
| 25 |
+
---
|
| 26 |
+
|
| 27 |
+
## Stack
|
| 28 |
+
|
| 29 |
+
| Couche | Technologie |
|
| 30 |
+
|---|---|
|
| 31 |
+
| Frontend | SvelteKit + socket.io-client |
|
| 32 |
+
| Backend | FastAPI + python-socketio (asyncio) |
|
| 33 |
+
| Interprétation commandes | Mistral API (mistral-large) |
|
| 34 |
+
| Voix → Texte | ElevenLabs Speech-to-Text |
|
| 35 |
+
| Texte → Voix | ElevenLabs TTS |
|
| 36 |
+
| Hosting | Hugging Face Spaces (Docker) |
|
| 37 |
+
|
| 38 |
+
---
|
| 39 |
+
|
| 40 |
+
## Architecture du projet
|
| 41 |
+
|
| 42 |
+
```
|
| 43 |
+
hackaton/
|
| 44 |
+
├── Dockerfile # HF Spaces — expose port 7860
|
| 45 |
+
├── docker-compose.yml # Dev local
|
| 46 |
+
├── .env.example
|
| 47 |
+
│
|
| 48 |
+
├── backend/
|
| 49 |
+
│ ├── main.py # Point d'entrée : FastAPI + Socket.IO montés ensemble
|
| 50 |
+
│ ├── requirements.txt
|
| 51 |
+
│ │
|
| 52 |
+
│ ├── lobby/
|
| 53 |
+
│ │ └── manager.py # Création / jointure de rooms, matchmaking
|
| 54 |
+
│ │
|
| 55 |
+
│ ├── game/
|
| 56 |
+
│ │ ├── engine.py # Boucle de jeu (tick ~250ms), résolution des actions
|
| 57 |
+
│ │ ├── state.py # GameState : ressources, unités, bâtiments par joueur
|
| 58 |
+
│ │ ├── units.py # Définitions Marine / Medic / Goliath / Tank / Wraith / SCV
|
| 59 |
+
│ │ ├── buildings.py # Command Center / Barracks / Factory / Starport / Depot…
|
| 60 |
+
│ │ ├── tech_tree.py # Prérequis de construction et de production
|
| 61 |
+
│ │ └── map.py # Grille, pathfinding simple (A*)
|
| 62 |
+
│ │
|
| 63 |
+
│ └── voice/
|
| 64 |
+
│ ├── stt.py # ElevenLabs STT : audio bytes → transcription texte
|
| 65 |
+
│ ├── tts.py # ElevenLabs TTS : texte → audio bytes streamé au client
|
| 66 |
+
│ └── command_parser.py # Mistral : texte + état du jeu → commandes structurées
|
| 67 |
+
│
|
| 68 |
+
└── frontend/
|
| 69 |
+
├── package.json
|
| 70 |
+
└── src/
|
| 71 |
+
├── routes/
|
| 72 |
+
│ ├── +page.svelte # Lobby : créer / rejoindre une partie
|
| 73 |
+
│ └── game/
|
| 74 |
+
│ └── +page.svelte # Vue jeu principale
|
| 75 |
+
│
|
| 76 |
+
└── lib/
|
| 77 |
+
├── socket.ts # Singleton socket.io-client
|
| 78 |
+
├── voice.ts # Capture micro → envoi backend → lecture réponse audio
|
| 79 |
+
├── stores/
|
| 80 |
+
│ └── gameState.ts # Store Svelte réactif (état jeu reçu du serveur)
|
| 81 |
+
└── components/
|
| 82 |
+
├── Map.svelte # Carte SVG scrollable / zoomable
|
| 83 |
+
├── UnitPanel.svelte # Détail unité au tap
|
| 84 |
+
├── TechTree.svelte # Arbre tech en lecture seule
|
| 85 |
+
├── ResourceBar.svelte # Minéraux / Gaz / Supply
|
| 86 |
+
└── VoiceButton.svelte # Bouton push-to-talk (PTT)
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
---
|
| 90 |
+
|
| 91 |
+
## Flux d'une commande vocale
|
| 92 |
+
|
| 93 |
+
```
|
| 94 |
+
[Joueur maintient PTT]
|
| 95 |
+
│
|
| 96 |
+
▼
|
| 97 |
+
MediaRecorder (browser)
|
| 98 |
+
│ audio blob
|
| 99 |
+
▼
|
| 100 |
+
Socket.IO ──voice_input──► backend
|
| 101 |
+
│
|
| 102 |
+
▼
|
| 103 |
+
ElevenLabs STT
|
| 104 |
+
"entraîne 3 marines"
|
| 105 |
+
│
|
| 106 |
+
▼
|
| 107 |
+
Mistral API (prompt système = état du jeu)
|
| 108 |
+
→ { action: "train", unit: "marine", count: 3 }
|
| 109 |
+
│
|
| 110 |
+
▼
|
| 111 |
+
engine.apply_command()
|
| 112 |
+
│
|
| 113 |
+
┌───────┴───────┐
|
| 114 |
+
▼ ▼
|
| 115 |
+
game_state ElevenLabs TTS
|
| 116 |
+
broadcast "Entraînement de 3 marines lancé"
|
| 117 |
+
│ │
|
| 118 |
+
▼ ▼
|
| 119 |
+
◄──game_update── ◄──voice_feedback──
|
| 120 |
+
```
|
| 121 |
+
|
| 122 |
+
---
|
| 123 |
+
|
| 124 |
+
## Jeu — Race Terran simplifiée
|
| 125 |
+
|
| 126 |
+
### Ressources
|
| 127 |
+
| Ressource | Source | Usage |
|
| 128 |
+
|---|---|---|
|
| 129 |
+
| Minéraux | Patchs collectés par SCV | Tout |
|
| 130 |
+
| Gaz Vespène | Geysers collectés par SCV | Unités avancées, upgrades |
|
| 131 |
+
| Supply | Supply Depots + Command Center | Limite de population |
|
| 132 |
+
|
| 133 |
+
### Bâtiments
|
| 134 |
+
| Bâtiment | Prérequis | Rôle |
|
| 135 |
+
|---|---|---|
|
| 136 |
+
| Command Center | — | Départ, produit SCV |
|
| 137 |
+
| Supply Depot | — | +8 supply |
|
| 138 |
+
| Barracks | — | Marine, Medic |
|
| 139 |
+
| Engineering Bay | Barracks | Upgrades infanterie |
|
| 140 |
+
| Factory | Barracks | Goliath, Tank |
|
| 141 |
+
| Armory | Factory | Upgrades véhicules |
|
| 142 |
+
| Starport | Factory | Wraith |
|
| 143 |
+
|
| 144 |
+
### Unités
|
| 145 |
+
| Unité | Coût | Prérequis | Rôle |
|
| 146 |
+
|---|---|---|---|
|
| 147 |
+
| SCV | 50m | CC | Collecte, construction |
|
| 148 |
+
| Marine | 50m | Barracks | Infanterie de base, anti-air |
|
| 149 |
+
| Medic | 50m / 25g | Barracks + Academy* | Soigne l'infanterie |
|
| 150 |
+
| Goliath | 100m / 50g | Factory | Anti-air + sol |
|
| 151 |
+
| Siege Tank | 150m / 100g | Factory + Machine Shop* | DPS sol, mode siège |
|
| 152 |
+
| Wraith | 150m / 100g | Starport | Air, camouflage |
|
| 153 |
+
|
| 154 |
+
*simplifié : add-ons remplacés par simple prérequis de bâtiment*
|
| 155 |
+
|
| 156 |
+
### Exemples de commandes vocales
|
| 157 |
+
- *"Construis un supply depot près du command center"*
|
| 158 |
+
- *"Entraîne quatre marines"*
|
| 159 |
+
- *"Envoie tous les marines attaquer en haut à gauche"*
|
| 160 |
+
- *"Passe le tank en mode siège"*
|
| 161 |
+
- *"Fais patrouiller les wraiths autour de ma base"*
|
| 162 |
+
- *"Combien de gaz j'ai ?"*
|
| 163 |
+
- *"Scout avec un SCV vers le centre"*
|
| 164 |
+
|
| 165 |
+
---
|
| 166 |
+
|
| 167 |
+
## Démarrage local
|
| 168 |
+
|
| 169 |
+
```bash
|
| 170 |
+
# Variables d'environnement
|
| 171 |
+
cp .env.example .env
|
| 172 |
+
# Remplir MISTRAL_API_KEY, ELEVENLABS_API_KEY
|
| 173 |
+
|
| 174 |
+
# Backend
|
| 175 |
+
cd backend
|
| 176 |
+
pip install -r requirements.txt
|
| 177 |
+
uvicorn main:app --reload --port 8000
|
| 178 |
+
|
| 179 |
+
# Frontend
|
| 180 |
+
cd frontend
|
| 181 |
+
npm install
|
| 182 |
+
npm run dev
|
| 183 |
+
```
|
| 184 |
+
|
| 185 |
+
---
|
| 186 |
+
|
| 187 |
+
## Déploiement Hugging Face Spaces
|
| 188 |
+
|
| 189 |
+
Le `Dockerfile` à la racine expose le port **7860** (requis par HF Spaces).
|
| 190 |
+
Il sert à la fois le backend FastAPI/Socket.IO et le build statique SvelteKit (`adapter-static`).
|
| 191 |
+
|
| 192 |
+
```bash
|
| 193 |
+
# Push suffit à déclencher le build sur HF Spaces
|
| 194 |
+
git push
|
| 195 |
+
```
|
| 196 |
+
|
| 197 |
+
---
|
| 198 |
+
|
| 199 |
+
## Variables d'environnement
|
| 200 |
+
|
| 201 |
+
| Variable | Description |
|
| 202 |
+
|---|---|
|
| 203 |
+
| `MISTRAL_API_KEY` | Clé API Mistral |
|
| 204 |
+
| `ELEVENLABS_API_KEY` | Clé API ElevenLabs |
|
| 205 |
+
| `SECRET_KEY` | Clé pour signer les sessions lobby |
|
| 206 |
+
|
| 207 |
+
---
|
| 208 |
+
|
| 209 |
+
## Notes de conception
|
| 210 |
+
|
| 211 |
+
- **python-socketio** avec transport asyncio est choisi pour la gestion des rooms et la diffusion d'état — il gère nativement les rooms, les namespaces et la reconnexion, évitant d'implémenter tout ça à la main avec des WebSockets bruts.
|
| 212 |
+
- La boucle de jeu tourne côté serveur (tick toutes les 250ms). Les clients reçoivent un snapshot d'état complet à chaque tick — pas de réconciliation client-side pour rester simple.
|
| 213 |
+
- Mistral reçoit en contexte système un résumé de l'état courant du joueur (ressources, bâtiments actifs, supply) pour que l'interprétation soit cohérente avec la situation réelle.
|
| 214 |
+
- Le PTT (push-to-talk) évite les faux positifs sur mobile et réduit la latence en limitant la taille des chunks audio envoyés.
|
backend/__pycache__/config.cpython-39.pyc
ADDED
|
Binary file (677 Bytes). View file
|
|
|
backend/__pycache__/main.cpython-39.pyc
ADDED
|
Binary file (8.26 kB). View file
|
|
|
backend/config.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from dotenv import load_dotenv
|
| 3 |
+
|
| 4 |
+
load_dotenv()
|
| 5 |
+
|
| 6 |
+
MISTRAL_API_KEY: str = os.getenv("MISTRAL_API_KEY", "")
|
| 7 |
+
ELEVENLABS_API_KEY: str = os.getenv("ELEVENLABS_API_KEY", "")
|
| 8 |
+
SECRET_KEY: str = os.getenv("SECRET_KEY", "dev-secret-key-change-me")
|
| 9 |
+
|
| 10 |
+
# ElevenLabs voice ID for game feedback (default: Rachel)
|
| 11 |
+
ELEVENLABS_VOICE_ID: str = os.getenv("ELEVENLABS_VOICE_ID", "21m00Tcm4TlvDq8ikWAM")
|
| 12 |
+
|
| 13 |
+
# Game constants
|
| 14 |
+
TICK_INTERVAL: float = 0.25 # seconds per game tick
|
| 15 |
+
TICKS_PER_SECOND: int = 4
|
| 16 |
+
MINERAL_PER_HARVEST: int = 8 # minerals per SCV per harvest cycle
|
| 17 |
+
GAS_PER_HARVEST: int = 8 # gas per SCV per harvest cycle
|
| 18 |
+
HARVEST_INTERVAL_TICKS: int = 4 # harvest every N ticks (~1s)
|
backend/game/__init__.py
ADDED
|
File without changes
|
backend/game/__pycache__/__init__.cpython-39.pyc
ADDED
|
Binary file (139 Bytes). View file
|
|
|
backend/game/__pycache__/buildings.cpython-39.pyc
ADDED
|
Binary file (3.41 kB). View file
|
|
|
backend/game/__pycache__/commands.cpython-39.pyc
ADDED
|
Binary file (2.25 kB). View file
|
|
|
backend/game/__pycache__/engine.cpython-39.pyc
ADDED
|
Binary file (23 kB). View file
|
|
|
backend/game/__pycache__/map.cpython-39.pyc
ADDED
|
Binary file (4.98 kB). View file
|
|
|
backend/game/__pycache__/state.cpython-39.pyc
ADDED
|
Binary file (6.23 kB). View file
|
|
|
backend/game/__pycache__/tech_tree.cpython-39.pyc
ADDED
|
Binary file (2.74 kB). View file
|
|
|
backend/game/__pycache__/units.cpython-39.pyc
ADDED
|
Binary file (3.87 kB). View file
|
|
|
backend/game/buildings.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import uuid
|
| 4 |
+
from enum import Enum
|
| 5 |
+
from typing import Optional
|
| 6 |
+
|
| 7 |
+
from pydantic import BaseModel, Field
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class BuildingType(str, Enum):
|
| 11 |
+
COMMAND_CENTER = "command_center"
|
| 12 |
+
SUPPLY_DEPOT = "supply_depot"
|
| 13 |
+
BARRACKS = "barracks"
|
| 14 |
+
ENGINEERING_BAY = "engineering_bay"
|
| 15 |
+
REFINERY = "refinery"
|
| 16 |
+
FACTORY = "factory"
|
| 17 |
+
ARMORY = "armory"
|
| 18 |
+
STARPORT = "starport"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class BuildingStatus(str, Enum):
|
| 22 |
+
CONSTRUCTING = "constructing"
|
| 23 |
+
ACTIVE = "active"
|
| 24 |
+
PRODUCING = "producing"
|
| 25 |
+
DESTROYED = "destroyed"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class BuildingDef(BaseModel):
|
| 29 |
+
max_hp: int
|
| 30 |
+
mineral_cost: int
|
| 31 |
+
gas_cost: int
|
| 32 |
+
build_time_ticks: int
|
| 33 |
+
width: int = 2
|
| 34 |
+
height: int = 2
|
| 35 |
+
supply_provided: int = 0
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
BUILDING_DEFS: dict[BuildingType, BuildingDef] = {
|
| 39 |
+
BuildingType.COMMAND_CENTER: BuildingDef(
|
| 40 |
+
max_hp=1500, mineral_cost=400, gas_cost=0, build_time_ticks=120,
|
| 41 |
+
width=4, height=3, supply_provided=10,
|
| 42 |
+
),
|
| 43 |
+
BuildingType.SUPPLY_DEPOT: BuildingDef(
|
| 44 |
+
max_hp=500, mineral_cost=100, gas_cost=0, build_time_ticks=30,
|
| 45 |
+
width=3, height=2, supply_provided=8,
|
| 46 |
+
),
|
| 47 |
+
BuildingType.BARRACKS: BuildingDef(
|
| 48 |
+
max_hp=1000, mineral_cost=150, gas_cost=0, build_time_ticks=80,
|
| 49 |
+
width=4, height=3,
|
| 50 |
+
),
|
| 51 |
+
BuildingType.ENGINEERING_BAY: BuildingDef(
|
| 52 |
+
max_hp=850, mineral_cost=125, gas_cost=0, build_time_ticks=60,
|
| 53 |
+
width=3, height=2,
|
| 54 |
+
),
|
| 55 |
+
BuildingType.REFINERY: BuildingDef(
|
| 56 |
+
max_hp=500, mineral_cost=100, gas_cost=0, build_time_ticks=40,
|
| 57 |
+
width=2, height=2,
|
| 58 |
+
),
|
| 59 |
+
BuildingType.FACTORY: BuildingDef(
|
| 60 |
+
max_hp=1250, mineral_cost=200, gas_cost=100, build_time_ticks=80,
|
| 61 |
+
width=4, height=3,
|
| 62 |
+
),
|
| 63 |
+
BuildingType.ARMORY: BuildingDef(
|
| 64 |
+
max_hp=800, mineral_cost=100, gas_cost=50, build_time_ticks=60,
|
| 65 |
+
width=3, height=2,
|
| 66 |
+
),
|
| 67 |
+
BuildingType.STARPORT: BuildingDef(
|
| 68 |
+
max_hp=1300, mineral_cost=150, gas_cost=100, build_time_ticks=70,
|
| 69 |
+
width=4, height=2,
|
| 70 |
+
),
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class ProductionItem(BaseModel):
|
| 75 |
+
unit_type: str
|
| 76 |
+
ticks_remaining: int
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
class Building(BaseModel):
|
| 80 |
+
id: str = Field(default_factory=lambda: str(uuid.uuid4())[:8])
|
| 81 |
+
building_type: BuildingType
|
| 82 |
+
owner: str
|
| 83 |
+
x: int
|
| 84 |
+
y: int
|
| 85 |
+
hp: float
|
| 86 |
+
max_hp: int
|
| 87 |
+
status: BuildingStatus = BuildingStatus.CONSTRUCTING
|
| 88 |
+
construction_ticks_remaining: int = 0
|
| 89 |
+
production_queue: list[ProductionItem] = Field(default_factory=list)
|
| 90 |
+
rally_x: Optional[float] = None
|
| 91 |
+
rally_y: Optional[float] = None
|
| 92 |
+
|
| 93 |
+
@classmethod
|
| 94 |
+
def create(cls, bt: BuildingType, owner: str, x: int, y: int) -> "Building":
|
| 95 |
+
defn = BUILDING_DEFS[bt]
|
| 96 |
+
return cls(
|
| 97 |
+
building_type=bt,
|
| 98 |
+
owner=owner,
|
| 99 |
+
x=x,
|
| 100 |
+
y=y,
|
| 101 |
+
hp=float(defn.max_hp),
|
| 102 |
+
max_hp=defn.max_hp,
|
| 103 |
+
construction_ticks_remaining=defn.build_time_ticks,
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
def spawn_point(self) -> tuple[float, float]:
|
| 107 |
+
"""Position where units appear when produced."""
|
| 108 |
+
defn = BUILDING_DEFS[self.building_type]
|
| 109 |
+
return (float(self.x) + defn.width / 2, float(self.y) + defn.height + 1)
|
backend/game/commands.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Command models: output of the Mistral voice command parser.
|
| 3 |
+
|
| 4 |
+
ActionType values and GameAction fields mirror the JSON schema
|
| 5 |
+
given to Mistral in voice/command_parser.py.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from enum import Enum
|
| 9 |
+
from typing import Optional
|
| 10 |
+
|
| 11 |
+
from pydantic import BaseModel
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class ActionType(str, Enum):
|
| 15 |
+
BUILD = "build"
|
| 16 |
+
TRAIN = "train"
|
| 17 |
+
MOVE = "move"
|
| 18 |
+
ATTACK = "attack"
|
| 19 |
+
SIEGE = "siege"
|
| 20 |
+
UNSIEGE = "unsiege"
|
| 21 |
+
CLOAK = "cloak"
|
| 22 |
+
DECLOAK = "decloak"
|
| 23 |
+
GATHER = "gather"
|
| 24 |
+
STOP = "stop"
|
| 25 |
+
PATROL = "patrol"
|
| 26 |
+
QUERY = "query"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# Unit selectors understood by the engine
|
| 30 |
+
# "all" → every unit the player owns
|
| 31 |
+
# "all_military" → all non-SCV units
|
| 32 |
+
# "all_marines" → all marines (same pattern for other types)
|
| 33 |
+
# "all_scv" → all SCVs
|
| 34 |
+
# "idle_scv" → idle SCVs only
|
| 35 |
+
# "most_damaged" → the most injured unit
|
| 36 |
+
UNIT_SELECTORS = [
|
| 37 |
+
"all", "all_military", "all_marines", "all_medics",
|
| 38 |
+
"all_goliaths", "all_tanks", "all_wraiths",
|
| 39 |
+
"all_scv", "idle_scv", "most_damaged",
|
| 40 |
+
]
|
| 41 |
+
|
| 42 |
+
# Zone names resolved by the engine to (x, y) coordinates
|
| 43 |
+
TARGET_ZONES = [
|
| 44 |
+
"my_base", "enemy_base", "center",
|
| 45 |
+
"top_left", "top_right", "bottom_left", "bottom_right",
|
| 46 |
+
"front_line",
|
| 47 |
+
]
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class GameAction(BaseModel):
|
| 51 |
+
type: ActionType
|
| 52 |
+
# build
|
| 53 |
+
building_type: Optional[str] = None
|
| 54 |
+
# train
|
| 55 |
+
unit_type: Optional[str] = None
|
| 56 |
+
count: int = 1
|
| 57 |
+
# move / attack / patrol / stop
|
| 58 |
+
unit_selector: Optional[str] = None
|
| 59 |
+
target_zone: Optional[str] = None
|
| 60 |
+
# gather
|
| 61 |
+
resource_type: Optional[str] = None # "minerals" or "gas"
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
class ParsedCommand(BaseModel):
|
| 65 |
+
"""Full Mistral response: one or more actions + French feedback text."""
|
| 66 |
+
actions: list[GameAction]
|
| 67 |
+
feedback: str
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class ActionResult(BaseModel):
|
| 71 |
+
action_type: str
|
| 72 |
+
success: bool
|
| 73 |
+
message: str
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class CommandResult(BaseModel):
|
| 77 |
+
results: list[ActionResult]
|
| 78 |
+
feedback_override: Optional[str] = None # replaces Mistral feedback on hard error
|
backend/game/engine.py
ADDED
|
@@ -0,0 +1,743 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
GameEngine — server-side game loop running at 4 ticks/second.
|
| 3 |
+
|
| 4 |
+
One engine instance per active room. Responsibilities:
|
| 5 |
+
- Tick loop (asyncio task)
|
| 6 |
+
- Apply parsed voice commands
|
| 7 |
+
- Mining, construction, production, movement, combat
|
| 8 |
+
- Win-condition check
|
| 9 |
+
- State broadcast via Socket.IO
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import asyncio
|
| 15 |
+
import logging
|
| 16 |
+
import math
|
| 17 |
+
from typing import TYPE_CHECKING, Optional
|
| 18 |
+
|
| 19 |
+
from config import TICK_INTERVAL, HARVEST_INTERVAL_TICKS, MINERAL_PER_HARVEST, GAS_PER_HARVEST
|
| 20 |
+
|
| 21 |
+
from .buildings import Building, BuildingDef, BuildingStatus, BuildingType, BUILDING_DEFS
|
| 22 |
+
from .commands import ActionResult, ActionType, CommandResult, GameAction, ParsedCommand
|
| 23 |
+
from .map import MAP_HEIGHT, MAP_WIDTH, ResourceType
|
| 24 |
+
from .state import GamePhase, GameState, PlayerState
|
| 25 |
+
from .tech_tree import can_build, can_train, get_producer, missing_for_build, missing_for_train
|
| 26 |
+
from .units import Unit, UnitDef, UnitStatus, UnitType, UNIT_DEFS
|
| 27 |
+
|
| 28 |
+
if TYPE_CHECKING:
|
| 29 |
+
import socketio
|
| 30 |
+
|
| 31 |
+
log = logging.getLogger(__name__)
|
| 32 |
+
|
| 33 |
+
# Auto-attack trigger range (fraction of weapon range)
|
| 34 |
+
AUTO_ATTACK_RANGE_FACTOR = 0.6
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class GameEngine:
|
| 38 |
+
def __init__(self, state: GameState, sio: "socketio.AsyncServer") -> None:
|
| 39 |
+
self.state = state
|
| 40 |
+
self.sio = sio
|
| 41 |
+
self._task: Optional[asyncio.Task] = None # type: ignore[type-arg]
|
| 42 |
+
|
| 43 |
+
# ------------------------------------------------------------------
|
| 44 |
+
# Lifecycle
|
| 45 |
+
# ------------------------------------------------------------------
|
| 46 |
+
|
| 47 |
+
def start(self) -> None:
|
| 48 |
+
self._task = asyncio.create_task(self._loop())
|
| 49 |
+
|
| 50 |
+
async def stop(self) -> None:
|
| 51 |
+
if self._task:
|
| 52 |
+
self._task.cancel()
|
| 53 |
+
try:
|
| 54 |
+
await self._task
|
| 55 |
+
except asyncio.CancelledError:
|
| 56 |
+
pass
|
| 57 |
+
|
| 58 |
+
# ------------------------------------------------------------------
|
| 59 |
+
# Public command entry point
|
| 60 |
+
# ------------------------------------------------------------------
|
| 61 |
+
|
| 62 |
+
def apply_command(self, player_id: str, parsed: ParsedCommand) -> CommandResult:
|
| 63 |
+
player = self.state.players.get(player_id)
|
| 64 |
+
if not player or self.state.phase != GamePhase.PLAYING:
|
| 65 |
+
return CommandResult(results=[], feedback_override="La partie n'est pas en cours.")
|
| 66 |
+
|
| 67 |
+
results: list[ActionResult] = []
|
| 68 |
+
for action in parsed.actions:
|
| 69 |
+
result = self._dispatch(player, action)
|
| 70 |
+
results.append(result)
|
| 71 |
+
|
| 72 |
+
player.recalculate_supply()
|
| 73 |
+
return CommandResult(results=results)
|
| 74 |
+
|
| 75 |
+
# ------------------------------------------------------------------
|
| 76 |
+
# Tick loop
|
| 77 |
+
# ------------------------------------------------------------------
|
| 78 |
+
|
| 79 |
+
async def _loop(self) -> None:
|
| 80 |
+
while self.state.phase == GamePhase.PLAYING:
|
| 81 |
+
await asyncio.sleep(TICK_INTERVAL)
|
| 82 |
+
self._tick()
|
| 83 |
+
await self._broadcast()
|
| 84 |
+
|
| 85 |
+
def _tick(self) -> None:
|
| 86 |
+
self.state.tick += 1
|
| 87 |
+
|
| 88 |
+
for player in self.state.players.values():
|
| 89 |
+
self._tick_construction(player)
|
| 90 |
+
self._tick_production(player)
|
| 91 |
+
if self.state.tick % HARVEST_INTERVAL_TICKS == 0:
|
| 92 |
+
self._tick_mining(player)
|
| 93 |
+
|
| 94 |
+
self._tick_movement_and_combat()
|
| 95 |
+
self._tick_healing()
|
| 96 |
+
self._remove_dead()
|
| 97 |
+
|
| 98 |
+
for player in self.state.players.values():
|
| 99 |
+
player.recalculate_supply()
|
| 100 |
+
|
| 101 |
+
winner = self._check_win()
|
| 102 |
+
if winner:
|
| 103 |
+
self.state.phase = GamePhase.GAME_OVER
|
| 104 |
+
self.state.winner = winner
|
| 105 |
+
|
| 106 |
+
# ------------------------------------------------------------------
|
| 107 |
+
# Sub-tick processors
|
| 108 |
+
# ------------------------------------------------------------------
|
| 109 |
+
|
| 110 |
+
def _tick_construction(self, player: PlayerState) -> None:
|
| 111 |
+
"""Advance SCV-built buildings under construction."""
|
| 112 |
+
for building in player.buildings.values():
|
| 113 |
+
if building.status != BuildingStatus.CONSTRUCTING:
|
| 114 |
+
continue
|
| 115 |
+
building.construction_ticks_remaining -= 1
|
| 116 |
+
if building.construction_ticks_remaining <= 0:
|
| 117 |
+
building.status = BuildingStatus.ACTIVE
|
| 118 |
+
building.construction_ticks_remaining = 0
|
| 119 |
+
# Mark the assigned SCV as idle
|
| 120 |
+
for unit in player.units.values():
|
| 121 |
+
if unit.building_target_id == building.id:
|
| 122 |
+
unit.status = UnitStatus.IDLE
|
| 123 |
+
unit.building_target_id = None
|
| 124 |
+
unit.target_x = unit.target_y = None
|
| 125 |
+
|
| 126 |
+
def _tick_production(self, player: PlayerState) -> None:
|
| 127 |
+
"""Tick building production queues and spawn units."""
|
| 128 |
+
for building in player.buildings.values():
|
| 129 |
+
if not building.production_queue:
|
| 130 |
+
building.status = BuildingStatus.ACTIVE
|
| 131 |
+
continue
|
| 132 |
+
item = building.production_queue[0]
|
| 133 |
+
item.ticks_remaining -= 1
|
| 134 |
+
building.status = BuildingStatus.PRODUCING
|
| 135 |
+
if item.ticks_remaining <= 0:
|
| 136 |
+
building.production_queue.pop(0)
|
| 137 |
+
building.status = (
|
| 138 |
+
BuildingStatus.PRODUCING if building.production_queue
|
| 139 |
+
else BuildingStatus.ACTIVE
|
| 140 |
+
)
|
| 141 |
+
self._spawn_unit(player, building, UnitType(item.unit_type))
|
| 142 |
+
|
| 143 |
+
def _spawn_unit(self, player: PlayerState, building: Building, ut: UnitType) -> None:
|
| 144 |
+
sx, sy = building.spawn_point()
|
| 145 |
+
# Rally point overrides default spawn position
|
| 146 |
+
tx = building.rally_x if building.rally_x is not None else sx
|
| 147 |
+
ty = building.rally_y if building.rally_y is not None else sy
|
| 148 |
+
unit = Unit.create(ut, player.player_id, sx, sy)
|
| 149 |
+
if tx != sx or ty != sy:
|
| 150 |
+
unit.status = UnitStatus.MOVING
|
| 151 |
+
unit.target_x = tx
|
| 152 |
+
unit.target_y = ty
|
| 153 |
+
player.units[unit.id] = unit
|
| 154 |
+
|
| 155 |
+
def _tick_mining(self, player: PlayerState) -> None:
|
| 156 |
+
"""SCVs collect resources every HARVEST_INTERVAL_TICKS ticks."""
|
| 157 |
+
for unit in player.units.values():
|
| 158 |
+
if unit.status == UnitStatus.MINING_MINERALS:
|
| 159 |
+
resource = self.state.game_map.get_resource(unit.assigned_resource_id or "")
|
| 160 |
+
if resource and not resource.is_depleted:
|
| 161 |
+
gathered = min(MINERAL_PER_HARVEST, resource.amount)
|
| 162 |
+
player.minerals += gathered
|
| 163 |
+
resource.amount -= gathered
|
| 164 |
+
else:
|
| 165 |
+
# Patch depleted: find another
|
| 166 |
+
unit.assigned_resource_id = None
|
| 167 |
+
unit.status = UnitStatus.IDLE
|
| 168 |
+
|
| 169 |
+
elif unit.status == UnitStatus.MINING_GAS:
|
| 170 |
+
resource = self.state.game_map.get_resource(unit.assigned_resource_id or "")
|
| 171 |
+
if resource and resource.has_refinery:
|
| 172 |
+
player.gas += GAS_PER_HARVEST
|
| 173 |
+
else:
|
| 174 |
+
unit.assigned_resource_id = None
|
| 175 |
+
unit.status = UnitStatus.IDLE
|
| 176 |
+
|
| 177 |
+
def _tick_movement_and_combat(self) -> None:
|
| 178 |
+
"""Move units toward targets and resolve attacks."""
|
| 179 |
+
# Build flat lookup of all units across both players
|
| 180 |
+
all_units: dict[str, tuple[Unit, str]] = {} # id -> (unit, owner_id)
|
| 181 |
+
for pid, player in self.state.players.items():
|
| 182 |
+
for uid, unit in player.units.items():
|
| 183 |
+
all_units[uid] = (unit, pid)
|
| 184 |
+
|
| 185 |
+
for pid, player in self.state.players.items():
|
| 186 |
+
enemy = self.state.enemy_of(pid)
|
| 187 |
+
if not enemy:
|
| 188 |
+
continue
|
| 189 |
+
|
| 190 |
+
for unit in player.units.values():
|
| 191 |
+
defn = UNIT_DEFS[unit.unit_type]
|
| 192 |
+
|
| 193 |
+
# Siege tanks in siege mode cannot move
|
| 194 |
+
if unit.is_sieged:
|
| 195 |
+
self._combat_attack(unit, defn, all_units, player, enemy, sieged=True)
|
| 196 |
+
continue
|
| 197 |
+
|
| 198 |
+
# Units on a mining or building task skip combat movement
|
| 199 |
+
if unit.status in (UnitStatus.MINING_MINERALS, UnitStatus.MINING_GAS,
|
| 200 |
+
UnitStatus.BUILDING):
|
| 201 |
+
continue
|
| 202 |
+
|
| 203 |
+
# Movement
|
| 204 |
+
if unit.status in (UnitStatus.MOVING, UnitStatus.ATTACKING, UnitStatus.PATROLLING):
|
| 205 |
+
target_x = unit.target_x
|
| 206 |
+
target_y = unit.target_y
|
| 207 |
+
if target_x is not None and target_y is not None:
|
| 208 |
+
self._move_toward(unit, defn, target_x, target_y)
|
| 209 |
+
|
| 210 |
+
# Auto-attack: idle units defend themselves
|
| 211 |
+
auto_range = defn.attack_range * AUTO_ATTACK_RANGE_FACTOR
|
| 212 |
+
if unit.status == UnitStatus.IDLE:
|
| 213 |
+
nearest_enemy = self._nearest_enemy_in_range(unit, enemy, auto_range)
|
| 214 |
+
if nearest_enemy:
|
| 215 |
+
unit.attack_target_id = nearest_enemy.id
|
| 216 |
+
|
| 217 |
+
# Combat
|
| 218 |
+
if unit.attack_target_id:
|
| 219 |
+
self._combat_attack(unit, defn, all_units, player, enemy, sieged=False)
|
| 220 |
+
|
| 221 |
+
def _move_toward(self, unit: Unit, defn: UnitDef, tx: float, ty: float) -> None:
|
| 222 |
+
dx = tx - unit.x
|
| 223 |
+
dy = ty - unit.y
|
| 224 |
+
dist = math.sqrt(dx * dx + dy * dy)
|
| 225 |
+
step = defn.move_speed * TICK_INTERVAL
|
| 226 |
+
if dist <= step:
|
| 227 |
+
unit.x = tx
|
| 228 |
+
unit.y = ty
|
| 229 |
+
if unit.status == UnitStatus.MOVING:
|
| 230 |
+
unit.status = UnitStatus.IDLE
|
| 231 |
+
unit.target_x = unit.target_y = None
|
| 232 |
+
elif unit.status == UnitStatus.PATROLLING:
|
| 233 |
+
# Swap waypoints
|
| 234 |
+
unit.target_x, unit.patrol_x = unit.patrol_x, unit.target_x
|
| 235 |
+
unit.target_y, unit.patrol_y = unit.patrol_y, unit.target_y
|
| 236 |
+
else:
|
| 237 |
+
unit.x += (dx / dist) * step
|
| 238 |
+
unit.y += (dy / dist) * step
|
| 239 |
+
|
| 240 |
+
def _combat_attack(
|
| 241 |
+
self,
|
| 242 |
+
unit: Unit,
|
| 243 |
+
defn: UnitDef,
|
| 244 |
+
all_units: dict[str, tuple[Unit, str]],
|
| 245 |
+
player: PlayerState,
|
| 246 |
+
enemy: PlayerState,
|
| 247 |
+
sieged: bool,
|
| 248 |
+
) -> None:
|
| 249 |
+
if unit.attack_cooldown > 0:
|
| 250 |
+
unit.attack_cooldown -= 1
|
| 251 |
+
return
|
| 252 |
+
|
| 253 |
+
if not unit.attack_target_id or unit.attack_target_id not in all_units:
|
| 254 |
+
# Acquire nearest enemy
|
| 255 |
+
rng = defn.siege_range if sieged else defn.attack_range
|
| 256 |
+
target = self._nearest_enemy_in_range(unit, enemy, rng)
|
| 257 |
+
if not target:
|
| 258 |
+
unit.attack_target_id = None
|
| 259 |
+
return
|
| 260 |
+
unit.attack_target_id = target.id
|
| 261 |
+
|
| 262 |
+
target_unit, _ = all_units.get(unit.attack_target_id, (None, None))
|
| 263 |
+
if target_unit is None or target_unit.hp <= 0:
|
| 264 |
+
unit.attack_target_id = None
|
| 265 |
+
return
|
| 266 |
+
|
| 267 |
+
# Check range
|
| 268 |
+
dist = unit.dist_to(target_unit.x, target_unit.y)
|
| 269 |
+
attack_range = defn.siege_range if sieged else defn.attack_range
|
| 270 |
+
if dist > attack_range + 0.5:
|
| 271 |
+
# Move into range (unless sieged)
|
| 272 |
+
if not sieged:
|
| 273 |
+
unit.target_x = target_unit.x
|
| 274 |
+
unit.target_y = target_unit.y
|
| 275 |
+
unit.status = UnitStatus.ATTACKING
|
| 276 |
+
return
|
| 277 |
+
|
| 278 |
+
# Determine damage
|
| 279 |
+
if sieged:
|
| 280 |
+
dmg = defn.siege_damage
|
| 281 |
+
splash = defn.siege_splash_radius
|
| 282 |
+
cooldown = defn.siege_cooldown_ticks
|
| 283 |
+
# Splash: damage all enemies in splash radius
|
| 284 |
+
for eu in enemy.units.values():
|
| 285 |
+
if eu.dist_to(target_unit.x, target_unit.y) <= splash:
|
| 286 |
+
self._apply_damage(eu, dmg)
|
| 287 |
+
else:
|
| 288 |
+
target_flying = UNIT_DEFS[target_unit.unit_type].is_flying
|
| 289 |
+
dmg = defn.air_damage if target_flying else defn.ground_damage
|
| 290 |
+
cooldown = defn.attack_cooldown_ticks
|
| 291 |
+
if dmg > 0:
|
| 292 |
+
self._apply_damage(target_unit, dmg)
|
| 293 |
+
|
| 294 |
+
unit.attack_cooldown = cooldown
|
| 295 |
+
unit.status = UnitStatus.ATTACKING
|
| 296 |
+
|
| 297 |
+
def _apply_damage(self, target: Unit, raw_dmg: int) -> None:
|
| 298 |
+
defn = UNIT_DEFS[target.unit_type]
|
| 299 |
+
effective = max(0, raw_dmg - defn.armor)
|
| 300 |
+
target.hp = max(0.0, target.hp - effective)
|
| 301 |
+
|
| 302 |
+
def _tick_healing(self) -> None:
|
| 303 |
+
"""Medics heal the most-injured adjacent infantry unit."""
|
| 304 |
+
for player in self.state.players.values():
|
| 305 |
+
for medic in player.units_of(UnitType.MEDIC):
|
| 306 |
+
defn = UNIT_DEFS[UnitType.MEDIC]
|
| 307 |
+
healable = [
|
| 308 |
+
u for u in player.units.values()
|
| 309 |
+
if u.unit_type in (UnitType.MARINE, UnitType.MEDIC)
|
| 310 |
+
and u.hp < u.max_hp
|
| 311 |
+
and medic.dist_to(u.x, u.y) <= defn.attack_range
|
| 312 |
+
and u.id != medic.id
|
| 313 |
+
]
|
| 314 |
+
if healable:
|
| 315 |
+
target = min(healable, key=lambda u: u.hp / u.max_hp)
|
| 316 |
+
target.hp = min(float(target.max_hp), target.hp + defn.heal_per_tick)
|
| 317 |
+
medic.status = UnitStatus.HEALING
|
| 318 |
+
|
| 319 |
+
def _remove_dead(self) -> None:
|
| 320 |
+
"""Remove units with hp <= 0 and mark buildings as destroyed."""
|
| 321 |
+
for player in self.state.players.values():
|
| 322 |
+
dead = [uid for uid, u in player.units.items() if u.hp <= 0]
|
| 323 |
+
for uid in dead:
|
| 324 |
+
# Unassign from resource patches
|
| 325 |
+
unit = player.units[uid]
|
| 326 |
+
if unit.assigned_resource_id:
|
| 327 |
+
res = self.state.game_map.get_resource(unit.assigned_resource_id)
|
| 328 |
+
if res and uid in res.assigned_scv_ids:
|
| 329 |
+
res.assigned_scv_ids.remove(uid)
|
| 330 |
+
del player.units[uid]
|
| 331 |
+
|
| 332 |
+
for building in player.buildings.values():
|
| 333 |
+
if building.hp <= 0 and building.status != BuildingStatus.DESTROYED:
|
| 334 |
+
building.status = BuildingStatus.DESTROYED
|
| 335 |
+
building.production_queue.clear()
|
| 336 |
+
|
| 337 |
+
def _check_win(self) -> Optional[str]:
|
| 338 |
+
for player_id, player in self.state.players.items():
|
| 339 |
+
cc = player.command_center()
|
| 340 |
+
if cc is None:
|
| 341 |
+
enemy = self.state.enemy_of(player_id)
|
| 342 |
+
return enemy.player_id if enemy else None
|
| 343 |
+
return None
|
| 344 |
+
|
| 345 |
+
# ------------------------------------------------------------------
|
| 346 |
+
# Helpers
|
| 347 |
+
# ------------------------------------------------------------------
|
| 348 |
+
|
| 349 |
+
def _nearest_enemy_in_range(
|
| 350 |
+
self, unit: Unit, enemy: PlayerState, max_range: float
|
| 351 |
+
) -> Optional[Unit]:
|
| 352 |
+
candidates = [
|
| 353 |
+
u for u in enemy.units.values()
|
| 354 |
+
if unit.dist_to(u.x, u.y) <= max_range
|
| 355 |
+
]
|
| 356 |
+
# Also consider enemy buildings as targets
|
| 357 |
+
enemy_structs = [
|
| 358 |
+
b for b in enemy.buildings.values()
|
| 359 |
+
if b.status != BuildingStatus.DESTROYED
|
| 360 |
+
and unit.dist_to(b.x, b.y) <= max_range
|
| 361 |
+
]
|
| 362 |
+
if not candidates and not enemy_structs:
|
| 363 |
+
return None
|
| 364 |
+
if not candidates:
|
| 365 |
+
return None
|
| 366 |
+
return min(candidates, key=lambda u: unit.dist_to(u.x, u.y))
|
| 367 |
+
|
| 368 |
+
def _resolve_zone(self, player_id: str, zone: str) -> tuple[float, float]:
|
| 369 |
+
player = self.state.players[player_id]
|
| 370 |
+
enemy = self.state.enemy_of(player_id)
|
| 371 |
+
cc = player.command_center()
|
| 372 |
+
base_x = float(cc.x) + 2 if cc else float(MAP_WIDTH) / 2
|
| 373 |
+
base_y = float(cc.y) + 2 if cc else float(MAP_HEIGHT) / 2
|
| 374 |
+
|
| 375 |
+
if zone == "my_base":
|
| 376 |
+
return (base_x, base_y)
|
| 377 |
+
if zone == "enemy_base" and enemy:
|
| 378 |
+
ecc = enemy.command_center()
|
| 379 |
+
return (float(ecc.x) + 2, float(ecc.y) + 2) if ecc else (MAP_WIDTH - 5, MAP_HEIGHT - 5)
|
| 380 |
+
if zone == "center":
|
| 381 |
+
return (MAP_WIDTH / 2, MAP_HEIGHT / 2)
|
| 382 |
+
if zone == "top_left":
|
| 383 |
+
return (4.0, 4.0)
|
| 384 |
+
if zone == "top_right":
|
| 385 |
+
return (MAP_WIDTH - 4.0, 4.0)
|
| 386 |
+
if zone == "bottom_left":
|
| 387 |
+
return (4.0, MAP_HEIGHT - 4.0)
|
| 388 |
+
if zone == "bottom_right":
|
| 389 |
+
return (MAP_WIDTH - 4.0, MAP_HEIGHT - 4.0)
|
| 390 |
+
if zone == "front_line":
|
| 391 |
+
military = [
|
| 392 |
+
u for u in player.units.values()
|
| 393 |
+
if u.unit_type != UnitType.SCV
|
| 394 |
+
]
|
| 395 |
+
if military:
|
| 396 |
+
avg_x = sum(u.x for u in military) / len(military)
|
| 397 |
+
avg_y = sum(u.y for u in military) / len(military)
|
| 398 |
+
return (avg_x, avg_y)
|
| 399 |
+
# Fallback: enemy base
|
| 400 |
+
if enemy:
|
| 401 |
+
ecc = enemy.command_center()
|
| 402 |
+
if ecc:
|
| 403 |
+
return (float(ecc.x) + 2, float(ecc.y) + 2)
|
| 404 |
+
return (MAP_WIDTH / 2, MAP_HEIGHT / 2)
|
| 405 |
+
|
| 406 |
+
def _resolve_selector(self, player: PlayerState, selector: str) -> list[Unit]:
|
| 407 |
+
s = selector.lower()
|
| 408 |
+
if s == "all":
|
| 409 |
+
return list(player.units.values())
|
| 410 |
+
if s == "all_military":
|
| 411 |
+
return [u for u in player.units.values() if u.unit_type != UnitType.SCV]
|
| 412 |
+
if s == "all_marines":
|
| 413 |
+
return player.units_of(UnitType.MARINE)
|
| 414 |
+
if s == "all_medics":
|
| 415 |
+
return player.units_of(UnitType.MEDIC)
|
| 416 |
+
if s == "all_goliaths":
|
| 417 |
+
return player.units_of(UnitType.GOLIATH)
|
| 418 |
+
if s == "all_tanks":
|
| 419 |
+
return player.units_of(UnitType.TANK)
|
| 420 |
+
if s == "all_wraiths":
|
| 421 |
+
return player.units_of(UnitType.WRAITH)
|
| 422 |
+
if s == "all_scv":
|
| 423 |
+
return player.units_of(UnitType.SCV)
|
| 424 |
+
if s == "idle_scv":
|
| 425 |
+
return [u for u in player.units_of(UnitType.SCV) if u.status == UnitStatus.IDLE]
|
| 426 |
+
if s == "most_damaged":
|
| 427 |
+
units = list(player.units.values())
|
| 428 |
+
return [min(units, key=lambda u: u.hp / u.max_hp)] if units else []
|
| 429 |
+
return []
|
| 430 |
+
|
| 431 |
+
def _find_build_position(
|
| 432 |
+
self, player: PlayerState, bt: BuildingType
|
| 433 |
+
) -> Optional[tuple[int, int]]:
|
| 434 |
+
cc = player.command_center()
|
| 435 |
+
if not cc:
|
| 436 |
+
return None
|
| 437 |
+
origin_x, origin_y = cc.x, cc.y
|
| 438 |
+
defn = BUILDING_DEFS[bt]
|
| 439 |
+
|
| 440 |
+
for radius in range(3, 18):
|
| 441 |
+
for dx in range(-radius, radius + 1):
|
| 442 |
+
for dy in range(-radius, radius + 1):
|
| 443 |
+
x, y = origin_x + dx, origin_y + dy
|
| 444 |
+
if self._can_place(x, y, defn):
|
| 445 |
+
return (x, y)
|
| 446 |
+
return None
|
| 447 |
+
|
| 448 |
+
def _can_place(self, x: int, y: int, defn: BuildingDef) -> bool:
|
| 449 |
+
if x < 0 or y < 0 or x + defn.width > MAP_WIDTH or y + defn.height > MAP_HEIGHT:
|
| 450 |
+
return False
|
| 451 |
+
# Check overlap with all buildings
|
| 452 |
+
for player in self.state.players.values():
|
| 453 |
+
for b in player.buildings.values():
|
| 454 |
+
if b.status == BuildingStatus.DESTROYED:
|
| 455 |
+
continue
|
| 456 |
+
bd = BUILDING_DEFS[b.building_type]
|
| 457 |
+
if x < b.x + bd.width and x + defn.width > b.x \
|
| 458 |
+
and y < b.y + bd.height and y + defn.height > b.y:
|
| 459 |
+
return False
|
| 460 |
+
# Check overlap with resources
|
| 461 |
+
for res in self.state.game_map.resources:
|
| 462 |
+
if x <= res.x < x + defn.width and y <= res.y < y + defn.height:
|
| 463 |
+
return False
|
| 464 |
+
return True
|
| 465 |
+
|
| 466 |
+
# ------------------------------------------------------------------
|
| 467 |
+
# Command dispatchers
|
| 468 |
+
# ------------------------------------------------------------------
|
| 469 |
+
|
| 470 |
+
def _dispatch(self, player: PlayerState, action: GameAction) -> ActionResult:
|
| 471 |
+
try:
|
| 472 |
+
t = action.type
|
| 473 |
+
if t == ActionType.BUILD:
|
| 474 |
+
return self._cmd_build(player, action)
|
| 475 |
+
if t == ActionType.TRAIN:
|
| 476 |
+
return self._cmd_train(player, action)
|
| 477 |
+
if t == ActionType.MOVE:
|
| 478 |
+
return self._cmd_move(player, action)
|
| 479 |
+
if t == ActionType.ATTACK:
|
| 480 |
+
return self._cmd_attack(player, action)
|
| 481 |
+
if t == ActionType.SIEGE:
|
| 482 |
+
return self._cmd_siege(player, action, siege=True)
|
| 483 |
+
if t == ActionType.UNSIEGE:
|
| 484 |
+
return self._cmd_siege(player, action, siege=False)
|
| 485 |
+
if t == ActionType.CLOAK:
|
| 486 |
+
return self._cmd_cloak(player, action, cloak=True)
|
| 487 |
+
if t == ActionType.DECLOAK:
|
| 488 |
+
return self._cmd_cloak(player, action, cloak=False)
|
| 489 |
+
if t == ActionType.GATHER:
|
| 490 |
+
return self._cmd_gather(player, action)
|
| 491 |
+
if t == ActionType.STOP:
|
| 492 |
+
return self._cmd_stop(player, action)
|
| 493 |
+
if t == ActionType.PATROL:
|
| 494 |
+
return self._cmd_patrol(player, action)
|
| 495 |
+
if t == ActionType.QUERY:
|
| 496 |
+
return self._cmd_query(player, action)
|
| 497 |
+
return ActionResult(action_type=t, success=False, message="Action inconnue.")
|
| 498 |
+
except Exception as exc:
|
| 499 |
+
log.exception("Error applying action %s", action.type)
|
| 500 |
+
return ActionResult(action_type=str(action.type), success=False, message=str(exc))
|
| 501 |
+
|
| 502 |
+
def _cmd_build(self, player: PlayerState, action: GameAction) -> ActionResult:
|
| 503 |
+
raw = action.building_type
|
| 504 |
+
if not raw:
|
| 505 |
+
return ActionResult(action_type="build", success=False, message="Type de bâtiment manquant.")
|
| 506 |
+
try:
|
| 507 |
+
bt = BuildingType(raw)
|
| 508 |
+
except ValueError:
|
| 509 |
+
return ActionResult(action_type="build", success=False, message=f"Bâtiment inconnu: {raw}.")
|
| 510 |
+
|
| 511 |
+
defn = BUILDING_DEFS[bt]
|
| 512 |
+
if player.minerals < defn.mineral_cost or player.gas < defn.gas_cost:
|
| 513 |
+
return ActionResult(action_type="build", success=False,
|
| 514 |
+
message=f"Ressources insuffisantes ({defn.mineral_cost}m/{defn.gas_cost}g requis).")
|
| 515 |
+
|
| 516 |
+
if not can_build(bt, player):
|
| 517 |
+
missing = missing_for_build(bt, player)
|
| 518 |
+
names = ", ".join(m.value for m in missing)
|
| 519 |
+
return ActionResult(action_type="build", success=False,
|
| 520 |
+
message=f"Prérequis manquants: {names}.")
|
| 521 |
+
|
| 522 |
+
# For a refinery, place on nearest geyser without refinery
|
| 523 |
+
if bt == BuildingType.REFINERY:
|
| 524 |
+
cc = player.command_center()
|
| 525 |
+
cx, cy = (float(cc.x), float(cc.y)) if cc else (0.0, 0.0)
|
| 526 |
+
geyser = self.state.game_map.nearest_geyser_without_refinery(cx, cy)
|
| 527 |
+
if not geyser:
|
| 528 |
+
return ActionResult(action_type="build", success=False,
|
| 529 |
+
message="Aucun geyser disponible pour la raffinerie.")
|
| 530 |
+
pos: tuple[int, int] = (geyser.x, geyser.y)
|
| 531 |
+
geyser.has_refinery = True
|
| 532 |
+
else:
|
| 533 |
+
pos_opt = self._find_build_position(player, bt)
|
| 534 |
+
if not pos_opt:
|
| 535 |
+
return ActionResult(action_type="build", success=False, message="Impossible de trouver un emplacement.")
|
| 536 |
+
pos = pos_opt
|
| 537 |
+
|
| 538 |
+
# Find idle SCV
|
| 539 |
+
idle_scv = next(
|
| 540 |
+
(u for u in player.units_of(UnitType.SCV) if u.status == UnitStatus.IDLE), None
|
| 541 |
+
)
|
| 542 |
+
if not idle_scv:
|
| 543 |
+
return ActionResult(action_type="build", success=False, message="Aucun SCV disponible.")
|
| 544 |
+
|
| 545 |
+
player.minerals -= defn.mineral_cost
|
| 546 |
+
player.gas -= defn.gas_cost
|
| 547 |
+
|
| 548 |
+
building = Building.create(bt, player.player_id, pos[0], pos[1])
|
| 549 |
+
player.buildings[building.id] = building
|
| 550 |
+
|
| 551 |
+
idle_scv.status = UnitStatus.BUILDING
|
| 552 |
+
idle_scv.building_target_id = building.id
|
| 553 |
+
idle_scv.target_x = float(pos[0])
|
| 554 |
+
idle_scv.target_y = float(pos[1])
|
| 555 |
+
|
| 556 |
+
return ActionResult(action_type="build", success=True,
|
| 557 |
+
message=f"Construction de {bt.value} commencée.")
|
| 558 |
+
|
| 559 |
+
def _cmd_train(self, player: PlayerState, action: GameAction) -> ActionResult:
|
| 560 |
+
raw = action.unit_type
|
| 561 |
+
if not raw:
|
| 562 |
+
return ActionResult(action_type="train", success=False, message="Type d'unité manquant.")
|
| 563 |
+
try:
|
| 564 |
+
ut = UnitType(raw)
|
| 565 |
+
except ValueError:
|
| 566 |
+
return ActionResult(action_type="train", success=False, message=f"Unité inconnue: {raw}.")
|
| 567 |
+
|
| 568 |
+
if not can_train(ut, player):
|
| 569 |
+
missing = missing_for_train(ut, player)
|
| 570 |
+
names = ", ".join(m.value for m in missing)
|
| 571 |
+
return ActionResult(action_type="train", success=False,
|
| 572 |
+
message=f"Prérequis manquants: {names}.")
|
| 573 |
+
|
| 574 |
+
defn = UNIT_DEFS[ut]
|
| 575 |
+
producer_type = get_producer(ut)
|
| 576 |
+
producers = player.active_buildings_of(producer_type)
|
| 577 |
+
if not producers:
|
| 578 |
+
return ActionResult(action_type="train", success=False,
|
| 579 |
+
message=f"Aucun {producer_type.value} actif.")
|
| 580 |
+
|
| 581 |
+
count = max(1, min(action.count, 20))
|
| 582 |
+
trained = 0
|
| 583 |
+
idx = 0
|
| 584 |
+
|
| 585 |
+
while trained < count:
|
| 586 |
+
if player.minerals < defn.mineral_cost or player.gas < defn.gas_cost:
|
| 587 |
+
break
|
| 588 |
+
if player.supply_used + defn.supply_cost > player.supply_max:
|
| 589 |
+
break
|
| 590 |
+
|
| 591 |
+
building = producers[idx % len(producers)]
|
| 592 |
+
from .buildings import ProductionItem # local import to avoid cycle
|
| 593 |
+
building.production_queue.append(
|
| 594 |
+
ProductionItem(unit_type=ut.value, ticks_remaining=defn.build_time_ticks)
|
| 595 |
+
)
|
| 596 |
+
player.minerals -= defn.mineral_cost
|
| 597 |
+
player.gas -= defn.gas_cost
|
| 598 |
+
player.supply_used += defn.supply_cost
|
| 599 |
+
trained += 1
|
| 600 |
+
idx += 1
|
| 601 |
+
|
| 602 |
+
if trained == 0:
|
| 603 |
+
return ActionResult(action_type="train", success=False,
|
| 604 |
+
message="Ressources ou supply insuffisants.")
|
| 605 |
+
return ActionResult(action_type="train", success=True,
|
| 606 |
+
message=f"{trained} {ut.value}(s) en production.")
|
| 607 |
+
|
| 608 |
+
def _cmd_move(self, player: PlayerState, action: GameAction) -> ActionResult:
|
| 609 |
+
units = self._resolve_selector(player, action.unit_selector or "all_military")
|
| 610 |
+
if not units:
|
| 611 |
+
return ActionResult(action_type="move", success=False, message="Aucune unité sélectionnée.")
|
| 612 |
+
tx, ty = self._resolve_zone(player.player_id, action.target_zone or "center")
|
| 613 |
+
for unit in units:
|
| 614 |
+
if unit.is_sieged:
|
| 615 |
+
continue
|
| 616 |
+
unit.status = UnitStatus.MOVING
|
| 617 |
+
unit.target_x = tx
|
| 618 |
+
unit.target_y = ty
|
| 619 |
+
unit.attack_target_id = None
|
| 620 |
+
return ActionResult(action_type="move", success=True,
|
| 621 |
+
message=f"{len(units)} unité(s) en mouvement vers {action.target_zone}.")
|
| 622 |
+
|
| 623 |
+
def _cmd_attack(self, player: PlayerState, action: GameAction) -> ActionResult:
|
| 624 |
+
units = self._resolve_selector(player, action.unit_selector or "all_military")
|
| 625 |
+
if not units:
|
| 626 |
+
return ActionResult(action_type="attack", success=False, message="Aucune unité sélectionnée.")
|
| 627 |
+
tx, ty = self._resolve_zone(player.player_id, action.target_zone or "enemy_base")
|
| 628 |
+
for unit in units:
|
| 629 |
+
if unit.is_sieged:
|
| 630 |
+
continue
|
| 631 |
+
unit.status = UnitStatus.ATTACKING
|
| 632 |
+
unit.target_x = tx
|
| 633 |
+
unit.target_y = ty
|
| 634 |
+
unit.attack_target_id = None
|
| 635 |
+
return ActionResult(action_type="attack", success=True,
|
| 636 |
+
message=f"{len(units)} unité(s) envoyées à l'attaque vers {action.target_zone}.")
|
| 637 |
+
|
| 638 |
+
def _cmd_siege(self, player: PlayerState, action: GameAction, siege: bool) -> ActionResult:
|
| 639 |
+
tanks = self._resolve_selector(player, action.unit_selector or "all_tanks")
|
| 640 |
+
tanks = [u for u in tanks if u.unit_type == UnitType.TANK]
|
| 641 |
+
if not tanks:
|
| 642 |
+
return ActionResult(action_type="siege", success=False, message="Aucun tank disponible.")
|
| 643 |
+
for tank in tanks:
|
| 644 |
+
tank.is_sieged = siege
|
| 645 |
+
tank.status = UnitStatus.SIEGED if siege else UnitStatus.IDLE
|
| 646 |
+
if siege:
|
| 647 |
+
tank.target_x = tank.target_y = None
|
| 648 |
+
mode = "siège" if siege else "mobile"
|
| 649 |
+
return ActionResult(action_type="siege", success=True,
|
| 650 |
+
message=f"{len(tanks)} tank(s) en mode {mode}.")
|
| 651 |
+
|
| 652 |
+
def _cmd_cloak(self, player: PlayerState, action: GameAction, cloak: bool) -> ActionResult:
|
| 653 |
+
wraiths = self._resolve_selector(player, action.unit_selector or "all_wraiths")
|
| 654 |
+
wraiths = [u for u in wraiths if u.unit_type == UnitType.WRAITH]
|
| 655 |
+
if not wraiths:
|
| 656 |
+
return ActionResult(action_type="cloak", success=False, message="Aucun wraith disponible.")
|
| 657 |
+
for wraith in wraiths:
|
| 658 |
+
wraith.is_cloaked = cloak
|
| 659 |
+
state = "activé" if cloak else "désactivé"
|
| 660 |
+
return ActionResult(action_type="cloak", success=True,
|
| 661 |
+
message=f"Camouflage {state} sur {len(wraiths)} wraith(s).")
|
| 662 |
+
|
| 663 |
+
def _cmd_gather(self, player: PlayerState, action: GameAction) -> ActionResult:
|
| 664 |
+
resource_type = (action.resource_type or "minerals").lower()
|
| 665 |
+
cc = player.command_center()
|
| 666 |
+
cx, cy = (float(cc.x), float(cc.y)) if cc else (0.0, 0.0)
|
| 667 |
+
|
| 668 |
+
scvs = self._resolve_selector(player, action.unit_selector or "idle_scv")
|
| 669 |
+
scvs = [u for u in scvs if u.unit_type == UnitType.SCV]
|
| 670 |
+
if not scvs:
|
| 671 |
+
return ActionResult(action_type="gather", success=False, message="Aucun SCV disponible.")
|
| 672 |
+
|
| 673 |
+
assigned = 0
|
| 674 |
+
if resource_type == "gas":
|
| 675 |
+
for scv in scvs:
|
| 676 |
+
geyser = self.state.game_map.nearest_available_geyser(cx, cy)
|
| 677 |
+
if not geyser:
|
| 678 |
+
break
|
| 679 |
+
if scv.assigned_resource_id and scv.assigned_resource_id in \
|
| 680 |
+
[r.id for r in self.state.game_map.resources]:
|
| 681 |
+
old = self.state.game_map.get_resource(scv.assigned_resource_id)
|
| 682 |
+
if old and scv.id in old.assigned_scv_ids:
|
| 683 |
+
old.assigned_scv_ids.remove(scv.id)
|
| 684 |
+
scv.status = UnitStatus.MINING_GAS
|
| 685 |
+
scv.assigned_resource_id = geyser.id
|
| 686 |
+
geyser.assigned_scv_ids.append(scv.id)
|
| 687 |
+
assigned += 1
|
| 688 |
+
else:
|
| 689 |
+
for scv in scvs:
|
| 690 |
+
patch = self.state.game_map.nearest_mineral(cx, cy)
|
| 691 |
+
if not patch:
|
| 692 |
+
break
|
| 693 |
+
if scv.assigned_resource_id:
|
| 694 |
+
old = self.state.game_map.get_resource(scv.assigned_resource_id)
|
| 695 |
+
if old and scv.id in old.assigned_scv_ids:
|
| 696 |
+
old.assigned_scv_ids.remove(scv.id)
|
| 697 |
+
scv.status = UnitStatus.MINING_MINERALS
|
| 698 |
+
scv.assigned_resource_id = patch.id
|
| 699 |
+
patch.assigned_scv_ids.append(scv.id)
|
| 700 |
+
assigned += 1
|
| 701 |
+
|
| 702 |
+
if assigned == 0:
|
| 703 |
+
return ActionResult(action_type="gather", success=False,
|
| 704 |
+
message="Aucune ressource disponible ou aucun SCV libre.")
|
| 705 |
+
return ActionResult(action_type="gather", success=True,
|
| 706 |
+
message=f"{assigned} SCV(s) envoyés collecter {resource_type}.")
|
| 707 |
+
|
| 708 |
+
def _cmd_stop(self, player: PlayerState, action: GameAction) -> ActionResult:
|
| 709 |
+
units = self._resolve_selector(player, action.unit_selector or "all_military")
|
| 710 |
+
for unit in units:
|
| 711 |
+
unit.status = UnitStatus.IDLE
|
| 712 |
+
unit.target_x = unit.target_y = None
|
| 713 |
+
unit.attack_target_id = None
|
| 714 |
+
return ActionResult(action_type="stop", success=True,
|
| 715 |
+
message=f"{len(units)} unité(s) stoppées.")
|
| 716 |
+
|
| 717 |
+
def _cmd_patrol(self, player: PlayerState, action: GameAction) -> ActionResult:
|
| 718 |
+
units = self._resolve_selector(player, action.unit_selector or "all_military")
|
| 719 |
+
if not units:
|
| 720 |
+
return ActionResult(action_type="patrol", success=False, message="Aucune unité sélectionnée.")
|
| 721 |
+
tx, ty = self._resolve_zone(player.player_id, action.target_zone or "center")
|
| 722 |
+
for unit in units:
|
| 723 |
+
if unit.is_sieged:
|
| 724 |
+
continue
|
| 725 |
+
# Return waypoint = current position
|
| 726 |
+
unit.patrol_x = unit.x
|
| 727 |
+
unit.patrol_y = unit.y
|
| 728 |
+
unit.target_x = tx
|
| 729 |
+
unit.target_y = ty
|
| 730 |
+
unit.status = UnitStatus.PATROLLING
|
| 731 |
+
return ActionResult(action_type="patrol", success=True,
|
| 732 |
+
message=f"{len(units)} unité(s) en patrouille vers {action.target_zone}.")
|
| 733 |
+
|
| 734 |
+
def _cmd_query(self, player: PlayerState, action: GameAction) -> ActionResult:
|
| 735 |
+
return ActionResult(action_type="query", success=True, message=player.summary())
|
| 736 |
+
|
| 737 |
+
# ------------------------------------------------------------------
|
| 738 |
+
# Broadcast
|
| 739 |
+
# ------------------------------------------------------------------
|
| 740 |
+
|
| 741 |
+
async def _broadcast(self) -> None:
|
| 742 |
+
payload = self.state.model_dump(mode="json")
|
| 743 |
+
await self.sio.emit("game_update", payload, room=self.state.room_id)
|
backend/game/map.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import uuid
|
| 4 |
+
from enum import Enum
|
| 5 |
+
|
| 6 |
+
from pydantic import BaseModel, Field
|
| 7 |
+
|
| 8 |
+
MAP_WIDTH = 40
|
| 9 |
+
MAP_HEIGHT = 40
|
| 10 |
+
|
| 11 |
+
# Starting positions (top-left corner of Command Center footprint)
|
| 12 |
+
PLAYER1_START: tuple[int, int] = (4, 5)
|
| 13 |
+
PLAYER2_START: tuple[int, int] = (32, 32)
|
| 14 |
+
|
| 15 |
+
# Absolute resource positions
|
| 16 |
+
_P1_MINERALS: list[tuple[int, int]] = [
|
| 17 |
+
(2, 2), (3, 2), (4, 2), (5, 2), (6, 2),
|
| 18 |
+
(2, 3), (6, 3), (3, 9),
|
| 19 |
+
]
|
| 20 |
+
_P1_GEYSERS: list[tuple[int, int]] = [(2, 9), (7, 9)]
|
| 21 |
+
|
| 22 |
+
_P2_MINERALS: list[tuple[int, int]] = [
|
| 23 |
+
(33, 37), (34, 37), (35, 37), (36, 37), (37, 37),
|
| 24 |
+
(33, 36), (37, 36), (34, 30),
|
| 25 |
+
]
|
| 26 |
+
_P2_GEYSERS: list[tuple[int, int]] = [(32, 30), (37, 30)]
|
| 27 |
+
|
| 28 |
+
# Named map zones resolved to (x, y) center coordinates
|
| 29 |
+
# Zone values depend on player — resolved at engine level using player start positions
|
| 30 |
+
ZONE_NAMES = [
|
| 31 |
+
"my_base", "enemy_base", "center",
|
| 32 |
+
"top_left", "top_right", "bottom_left", "bottom_right",
|
| 33 |
+
"front_line",
|
| 34 |
+
]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class ResourceType(str, Enum):
|
| 38 |
+
MINERAL = "mineral"
|
| 39 |
+
GEYSER = "geyser"
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class Resource(BaseModel):
|
| 43 |
+
id: str = Field(default_factory=lambda: str(uuid.uuid4())[:8])
|
| 44 |
+
resource_type: ResourceType
|
| 45 |
+
x: int
|
| 46 |
+
y: int
|
| 47 |
+
amount: int = 1500 # minerals per patch (geysers are unlimited)
|
| 48 |
+
max_scv: int = 3
|
| 49 |
+
assigned_scv_ids: list[str] = Field(default_factory=list)
|
| 50 |
+
has_refinery: bool = False # geysers only
|
| 51 |
+
|
| 52 |
+
@property
|
| 53 |
+
def is_depleted(self) -> bool:
|
| 54 |
+
return self.resource_type == ResourceType.MINERAL and self.amount <= 0
|
| 55 |
+
|
| 56 |
+
@property
|
| 57 |
+
def has_capacity(self) -> bool:
|
| 58 |
+
return len(self.assigned_scv_ids) < self.max_scv
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class GameMap(BaseModel):
|
| 62 |
+
width: int = MAP_WIDTH
|
| 63 |
+
height: int = MAP_HEIGHT
|
| 64 |
+
resources: list[Resource] = Field(default_factory=list)
|
| 65 |
+
|
| 66 |
+
@classmethod
|
| 67 |
+
def create_default(cls) -> "GameMap":
|
| 68 |
+
resources: list[Resource] = []
|
| 69 |
+
for x, y in _P1_MINERALS:
|
| 70 |
+
resources.append(Resource(resource_type=ResourceType.MINERAL, x=x, y=y))
|
| 71 |
+
for x, y in _P1_GEYSERS:
|
| 72 |
+
resources.append(Resource(resource_type=ResourceType.GEYSER, x=x, y=y))
|
| 73 |
+
for x, y in _P2_MINERALS:
|
| 74 |
+
resources.append(Resource(resource_type=ResourceType.MINERAL, x=x, y=y))
|
| 75 |
+
for x, y in _P2_GEYSERS:
|
| 76 |
+
resources.append(Resource(resource_type=ResourceType.GEYSER, x=x, y=y))
|
| 77 |
+
return cls(resources=resources)
|
| 78 |
+
|
| 79 |
+
def get_resource(self, resource_id: str) -> Resource | None:
|
| 80 |
+
return next((r for r in self.resources if r.id == resource_id), None)
|
| 81 |
+
|
| 82 |
+
def nearest_mineral(self, x: float, y: float) -> Resource | None:
|
| 83 |
+
candidates = [
|
| 84 |
+
r for r in self.resources
|
| 85 |
+
if r.resource_type == ResourceType.MINERAL
|
| 86 |
+
and not r.is_depleted
|
| 87 |
+
and r.has_capacity
|
| 88 |
+
]
|
| 89 |
+
return min(candidates, key=lambda r: (r.x - x) ** 2 + (r.y - y) ** 2, default=None)
|
| 90 |
+
|
| 91 |
+
def nearest_available_geyser(self, x: float, y: float) -> Resource | None:
|
| 92 |
+
"""Geyser with a refinery that still has SCV capacity."""
|
| 93 |
+
candidates = [
|
| 94 |
+
r for r in self.resources
|
| 95 |
+
if r.resource_type == ResourceType.GEYSER
|
| 96 |
+
and r.has_refinery
|
| 97 |
+
and r.has_capacity
|
| 98 |
+
]
|
| 99 |
+
return min(candidates, key=lambda r: (r.x - x) ** 2 + (r.y - y) ** 2, default=None)
|
| 100 |
+
|
| 101 |
+
def nearest_geyser_without_refinery(self, x: float, y: float) -> Resource | None:
|
| 102 |
+
candidates = [
|
| 103 |
+
r for r in self.resources
|
| 104 |
+
if r.resource_type == ResourceType.GEYSER and not r.has_refinery
|
| 105 |
+
]
|
| 106 |
+
return min(candidates, key=lambda r: (r.x - x) ** 2 + (r.y - y) ** 2, default=None)
|
backend/game/state.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from enum import Enum
|
| 4 |
+
from typing import Optional
|
| 5 |
+
|
| 6 |
+
from pydantic import BaseModel, Field
|
| 7 |
+
|
| 8 |
+
from .buildings import Building, BuildingStatus, BuildingType, BUILDING_DEFS
|
| 9 |
+
from .map import GameMap, PLAYER1_START, PLAYER2_START
|
| 10 |
+
from .units import Unit, UnitType, UNIT_DEFS, UnitStatus
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class GamePhase(str, Enum):
|
| 14 |
+
LOBBY = "lobby"
|
| 15 |
+
PLAYING = "playing"
|
| 16 |
+
GAME_OVER = "game_over"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class PlayerState(BaseModel):
|
| 20 |
+
player_id: str
|
| 21 |
+
player_name: str
|
| 22 |
+
minerals: int = 50
|
| 23 |
+
gas: int = 0
|
| 24 |
+
supply_used: int = 0
|
| 25 |
+
supply_max: int = 10
|
| 26 |
+
units: dict[str, Unit] = Field(default_factory=dict)
|
| 27 |
+
buildings: dict[str, Building] = Field(default_factory=dict)
|
| 28 |
+
is_defeated: bool = False
|
| 29 |
+
|
| 30 |
+
def recalculate_supply(self) -> None:
|
| 31 |
+
self.supply_max = sum(
|
| 32 |
+
BUILDING_DEFS[b.building_type].supply_provided
|
| 33 |
+
for b in self.buildings.values()
|
| 34 |
+
if b.status not in (BuildingStatus.CONSTRUCTING, BuildingStatus.DESTROYED)
|
| 35 |
+
)
|
| 36 |
+
self.supply_used = sum(
|
| 37 |
+
UNIT_DEFS[u.unit_type].supply_cost
|
| 38 |
+
for u in self.units.values()
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
def has_active(self, bt: BuildingType) -> bool:
|
| 42 |
+
return any(
|
| 43 |
+
b.building_type == bt
|
| 44 |
+
and b.status not in (BuildingStatus.CONSTRUCTING, BuildingStatus.DESTROYED)
|
| 45 |
+
for b in self.buildings.values()
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
def active_buildings_of(self, bt: BuildingType) -> list[Building]:
|
| 49 |
+
return [
|
| 50 |
+
b for b in self.buildings.values()
|
| 51 |
+
if b.building_type == bt
|
| 52 |
+
and b.status not in (BuildingStatus.CONSTRUCTING, BuildingStatus.DESTROYED)
|
| 53 |
+
]
|
| 54 |
+
|
| 55 |
+
def units_of(self, ut: UnitType) -> list[Unit]:
|
| 56 |
+
return [u for u in self.units.values() if u.unit_type == ut]
|
| 57 |
+
|
| 58 |
+
def command_center(self) -> Optional[Building]:
|
| 59 |
+
return next(
|
| 60 |
+
(b for b in self.buildings.values()
|
| 61 |
+
if b.building_type == BuildingType.COMMAND_CENTER
|
| 62 |
+
and b.status != BuildingStatus.DESTROYED),
|
| 63 |
+
None,
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
def summary(self) -> str:
|
| 67 |
+
active = [
|
| 68 |
+
b.building_type.value for b in self.buildings.values()
|
| 69 |
+
if b.status not in (BuildingStatus.CONSTRUCTING, BuildingStatus.DESTROYED)
|
| 70 |
+
]
|
| 71 |
+
constructing = [
|
| 72 |
+
f"{b.building_type.value}({b.construction_ticks_remaining}t)"
|
| 73 |
+
for b in self.buildings.values()
|
| 74 |
+
if b.status == BuildingStatus.CONSTRUCTING
|
| 75 |
+
]
|
| 76 |
+
counts: dict[str, int] = {}
|
| 77 |
+
for u in self.units.values():
|
| 78 |
+
counts[u.unit_type.value] = counts.get(u.unit_type.value, 0) + 1
|
| 79 |
+
|
| 80 |
+
lines = [
|
| 81 |
+
f"Minéraux: {self.minerals}, Gaz: {self.gas}, Supply: {self.supply_used}/{self.supply_max}",
|
| 82 |
+
f"Bâtiments actifs: {', '.join(active) or 'aucun'}",
|
| 83 |
+
]
|
| 84 |
+
if constructing:
|
| 85 |
+
lines.append(f"En construction: {', '.join(constructing)}")
|
| 86 |
+
if counts:
|
| 87 |
+
lines.append(f"Unités: {', '.join(f'{v} {k}' for k, v in counts.items())}")
|
| 88 |
+
return "\n".join(lines)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class GameState(BaseModel):
|
| 92 |
+
room_id: str
|
| 93 |
+
tick: int = 0
|
| 94 |
+
phase: GamePhase = GamePhase.LOBBY
|
| 95 |
+
players: dict[str, PlayerState] = Field(default_factory=dict)
|
| 96 |
+
game_map: GameMap = Field(default_factory=GameMap.create_default)
|
| 97 |
+
winner: Optional[str] = None
|
| 98 |
+
|
| 99 |
+
@classmethod
|
| 100 |
+
def create_new(
|
| 101 |
+
cls,
|
| 102 |
+
room_id: str,
|
| 103 |
+
player1_id: str,
|
| 104 |
+
player1_name: str,
|
| 105 |
+
player2_id: str,
|
| 106 |
+
player2_name: str,
|
| 107 |
+
) -> "GameState":
|
| 108 |
+
state = cls(room_id=room_id, phase=GamePhase.PLAYING)
|
| 109 |
+
|
| 110 |
+
p1 = PlayerState(player_id=player1_id, player_name=player1_name)
|
| 111 |
+
p2 = PlayerState(player_id=player2_id, player_name=player2_name)
|
| 112 |
+
|
| 113 |
+
# Starting Command Centers (already built)
|
| 114 |
+
cc1 = Building.create(BuildingType.COMMAND_CENTER, player1_id, *PLAYER1_START)
|
| 115 |
+
cc1.status = BuildingStatus.ACTIVE
|
| 116 |
+
cc1.construction_ticks_remaining = 0
|
| 117 |
+
p1.buildings[cc1.id] = cc1
|
| 118 |
+
|
| 119 |
+
cc2 = Building.create(BuildingType.COMMAND_CENTER, player2_id, *PLAYER2_START)
|
| 120 |
+
cc2.status = BuildingStatus.ACTIVE
|
| 121 |
+
cc2.construction_ticks_remaining = 0
|
| 122 |
+
p2.buildings[cc2.id] = cc2
|
| 123 |
+
|
| 124 |
+
# 5 starting SCVs per player, positioned south of CC
|
| 125 |
+
for i in range(5):
|
| 126 |
+
scv1 = Unit.create(UnitType.SCV, player1_id,
|
| 127 |
+
PLAYER1_START[0] + 1 + i * 0.8,
|
| 128 |
+
PLAYER1_START[1] + 4)
|
| 129 |
+
p1.units[scv1.id] = scv1
|
| 130 |
+
|
| 131 |
+
scv2 = Unit.create(UnitType.SCV, player2_id,
|
| 132 |
+
PLAYER2_START[0] + 1 + i * 0.8,
|
| 133 |
+
PLAYER2_START[1] - 1)
|
| 134 |
+
p2.units[scv2.id] = scv2
|
| 135 |
+
|
| 136 |
+
p1.recalculate_supply()
|
| 137 |
+
p2.recalculate_supply()
|
| 138 |
+
|
| 139 |
+
state.players[player1_id] = p1
|
| 140 |
+
state.players[player2_id] = p2
|
| 141 |
+
|
| 142 |
+
return state
|
| 143 |
+
|
| 144 |
+
def enemy_of(self, player_id: str) -> Optional[PlayerState]:
|
| 145 |
+
return next((p for pid, p in self.players.items() if pid != player_id), None)
|
backend/game/tech_tree.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import TYPE_CHECKING
|
| 4 |
+
|
| 5 |
+
from .buildings import BuildingType, BuildingStatus
|
| 6 |
+
from .units import UnitType
|
| 7 |
+
|
| 8 |
+
if TYPE_CHECKING:
|
| 9 |
+
from .state import PlayerState
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
# unit_type -> required active buildings
|
| 13 |
+
UNIT_REQUIREMENTS: dict[UnitType, list[BuildingType]] = {
|
| 14 |
+
UnitType.SCV: [BuildingType.COMMAND_CENTER],
|
| 15 |
+
UnitType.MARINE: [BuildingType.BARRACKS],
|
| 16 |
+
UnitType.MEDIC: [BuildingType.BARRACKS, BuildingType.ENGINEERING_BAY],
|
| 17 |
+
UnitType.GOLIATH: [BuildingType.FACTORY],
|
| 18 |
+
UnitType.TANK: [BuildingType.FACTORY, BuildingType.ARMORY],
|
| 19 |
+
UnitType.WRAITH: [BuildingType.STARPORT],
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
# building_type -> required active buildings (to be able to construct it)
|
| 23 |
+
BUILDING_REQUIREMENTS: dict[BuildingType, list[BuildingType]] = {
|
| 24 |
+
BuildingType.COMMAND_CENTER: [],
|
| 25 |
+
BuildingType.SUPPLY_DEPOT: [],
|
| 26 |
+
BuildingType.REFINERY: [],
|
| 27 |
+
BuildingType.BARRACKS: [],
|
| 28 |
+
BuildingType.ENGINEERING_BAY: [BuildingType.BARRACKS],
|
| 29 |
+
BuildingType.FACTORY: [BuildingType.BARRACKS],
|
| 30 |
+
BuildingType.ARMORY: [BuildingType.FACTORY],
|
| 31 |
+
BuildingType.STARPORT: [BuildingType.FACTORY],
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
# unit_type -> building that produces it
|
| 35 |
+
PRODUCTION_SOURCES: dict[UnitType, BuildingType] = {
|
| 36 |
+
UnitType.SCV: BuildingType.COMMAND_CENTER,
|
| 37 |
+
UnitType.MARINE: BuildingType.BARRACKS,
|
| 38 |
+
UnitType.MEDIC: BuildingType.BARRACKS,
|
| 39 |
+
UnitType.GOLIATH: BuildingType.FACTORY,
|
| 40 |
+
UnitType.TANK: BuildingType.FACTORY,
|
| 41 |
+
UnitType.WRAITH: BuildingType.STARPORT,
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _active(player: "PlayerState", bt: BuildingType) -> bool:
|
| 46 |
+
return any(
|
| 47 |
+
b.building_type == bt
|
| 48 |
+
and b.status not in (BuildingStatus.CONSTRUCTING, BuildingStatus.DESTROYED)
|
| 49 |
+
for b in player.buildings.values()
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def can_build(bt: BuildingType, player: "PlayerState") -> bool:
|
| 54 |
+
return all(_active(player, req) for req in BUILDING_REQUIREMENTS[bt])
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def can_train(ut: UnitType, player: "PlayerState") -> bool:
|
| 58 |
+
return all(_active(player, req) for req in UNIT_REQUIREMENTS[ut])
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def missing_for_build(bt: BuildingType, player: "PlayerState") -> list[BuildingType]:
|
| 62 |
+
return [req for req in BUILDING_REQUIREMENTS[bt] if not _active(player, req)]
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def missing_for_train(ut: UnitType, player: "PlayerState") -> list[BuildingType]:
|
| 66 |
+
return [req for req in UNIT_REQUIREMENTS[ut] if not _active(player, req)]
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def get_producer(ut: UnitType) -> BuildingType:
|
| 70 |
+
return PRODUCTION_SOURCES[ut]
|
backend/game/units.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import uuid
|
| 4 |
+
from enum import Enum
|
| 5 |
+
from typing import Optional
|
| 6 |
+
|
| 7 |
+
from pydantic import BaseModel, Field
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class UnitType(str, Enum):
|
| 11 |
+
SCV = "scv"
|
| 12 |
+
MARINE = "marine"
|
| 13 |
+
MEDIC = "medic"
|
| 14 |
+
GOLIATH = "goliath"
|
| 15 |
+
TANK = "tank"
|
| 16 |
+
WRAITH = "wraith"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class UnitStatus(str, Enum):
|
| 20 |
+
IDLE = "idle"
|
| 21 |
+
MOVING = "moving"
|
| 22 |
+
ATTACKING = "attacking"
|
| 23 |
+
MINING_MINERALS = "mining_minerals"
|
| 24 |
+
MINING_GAS = "mining_gas"
|
| 25 |
+
BUILDING = "building"
|
| 26 |
+
HEALING = "healing"
|
| 27 |
+
SIEGED = "sieged"
|
| 28 |
+
PATROLLING = "patrolling"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class UnitDef(BaseModel):
|
| 32 |
+
max_hp: int
|
| 33 |
+
armor: int
|
| 34 |
+
ground_damage: int
|
| 35 |
+
air_damage: int
|
| 36 |
+
attack_range: float
|
| 37 |
+
move_speed: float # tiles/second
|
| 38 |
+
mineral_cost: int
|
| 39 |
+
gas_cost: int
|
| 40 |
+
supply_cost: int
|
| 41 |
+
build_time_ticks: int # ticks to produce (at 4 ticks/s)
|
| 42 |
+
is_flying: bool = False
|
| 43 |
+
can_cloak: bool = False
|
| 44 |
+
can_siege: bool = False
|
| 45 |
+
heal_per_tick: float = 0.0 # medic healing
|
| 46 |
+
attack_cooldown_ticks: int = 4
|
| 47 |
+
# Siege-mode stats (tanks only)
|
| 48 |
+
siege_damage: int = 0
|
| 49 |
+
siege_range: float = 0.0
|
| 50 |
+
siege_splash_radius: float = 0.0
|
| 51 |
+
siege_cooldown_ticks: int = 0
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
UNIT_DEFS: dict[UnitType, UnitDef] = {
|
| 55 |
+
UnitType.SCV: UnitDef(
|
| 56 |
+
max_hp=60, armor=0, ground_damage=5, air_damage=0,
|
| 57 |
+
attack_range=1, move_speed=1.5, mineral_cost=50, gas_cost=0,
|
| 58 |
+
supply_cost=1, build_time_ticks=20, attack_cooldown_ticks=6,
|
| 59 |
+
),
|
| 60 |
+
UnitType.MARINE: UnitDef(
|
| 61 |
+
max_hp=40, armor=0, ground_damage=6, air_damage=6,
|
| 62 |
+
attack_range=4, move_speed=1.5, mineral_cost=50, gas_cost=0,
|
| 63 |
+
supply_cost=1, build_time_ticks=24, attack_cooldown_ticks=4,
|
| 64 |
+
),
|
| 65 |
+
UnitType.MEDIC: UnitDef(
|
| 66 |
+
max_hp=60, armor=1, ground_damage=0, air_damage=0,
|
| 67 |
+
attack_range=2, move_speed=1.5, mineral_cost=50, gas_cost=25,
|
| 68 |
+
supply_cost=1, build_time_ticks=24, heal_per_tick=1.5,
|
| 69 |
+
attack_cooldown_ticks=999, # medics don't attack
|
| 70 |
+
),
|
| 71 |
+
UnitType.GOLIATH: UnitDef(
|
| 72 |
+
max_hp=125, armor=1, ground_damage=12, air_damage=20,
|
| 73 |
+
attack_range=5, move_speed=1.0, mineral_cost=100, gas_cost=50,
|
| 74 |
+
supply_cost=2, build_time_ticks=40, attack_cooldown_ticks=4,
|
| 75 |
+
),
|
| 76 |
+
UnitType.TANK: UnitDef(
|
| 77 |
+
max_hp=150, armor=1, ground_damage=15, air_damage=0,
|
| 78 |
+
attack_range=7, move_speed=0.75, mineral_cost=150, gas_cost=100,
|
| 79 |
+
supply_cost=2, build_time_ticks=50, can_siege=True,
|
| 80 |
+
attack_cooldown_ticks=5,
|
| 81 |
+
siege_damage=35, siege_range=12.0, siege_splash_radius=2.0,
|
| 82 |
+
siege_cooldown_ticks=8,
|
| 83 |
+
),
|
| 84 |
+
UnitType.WRAITH: UnitDef(
|
| 85 |
+
max_hp=120, armor=0, ground_damage=8, air_damage=20,
|
| 86 |
+
attack_range=5, move_speed=2.5, mineral_cost=150, gas_cost=100,
|
| 87 |
+
supply_cost=2, build_time_ticks=60, is_flying=True, can_cloak=True,
|
| 88 |
+
attack_cooldown_ticks=4,
|
| 89 |
+
),
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
class Unit(BaseModel):
|
| 94 |
+
id: str = Field(default_factory=lambda: str(uuid.uuid4())[:8])
|
| 95 |
+
unit_type: UnitType
|
| 96 |
+
owner: str
|
| 97 |
+
x: float
|
| 98 |
+
y: float
|
| 99 |
+
hp: float
|
| 100 |
+
max_hp: int
|
| 101 |
+
status: UnitStatus = UnitStatus.IDLE
|
| 102 |
+
# Movement
|
| 103 |
+
target_x: Optional[float] = None
|
| 104 |
+
target_y: Optional[float] = None
|
| 105 |
+
# Patrol waypoints (current + return)
|
| 106 |
+
patrol_x: Optional[float] = None
|
| 107 |
+
patrol_y: Optional[float] = None
|
| 108 |
+
# Combat
|
| 109 |
+
attack_target_id: Optional[str] = None
|
| 110 |
+
attack_cooldown: int = 0
|
| 111 |
+
# Modes
|
| 112 |
+
is_sieged: bool = False
|
| 113 |
+
is_cloaked: bool = False
|
| 114 |
+
# SCV tasks
|
| 115 |
+
assigned_resource_id: Optional[str] = None
|
| 116 |
+
building_target_id: Optional[str] = None
|
| 117 |
+
|
| 118 |
+
@classmethod
|
| 119 |
+
def create(cls, unit_type: UnitType, owner: str, x: float, y: float) -> "Unit":
|
| 120 |
+
defn = UNIT_DEFS[unit_type]
|
| 121 |
+
return cls(
|
| 122 |
+
unit_type=unit_type,
|
| 123 |
+
owner=owner,
|
| 124 |
+
x=x,
|
| 125 |
+
y=y,
|
| 126 |
+
hp=float(defn.max_hp),
|
| 127 |
+
max_hp=defn.max_hp,
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
def dist_to(self, x: float, y: float) -> float:
|
| 131 |
+
return ((self.x - x) ** 2 + (self.y - y) ** 2) ** 0.5
|
backend/lobby/__init__.py
ADDED
|
File without changes
|
backend/lobby/__pycache__/__init__.cpython-39.pyc
ADDED
|
Binary file (140 Bytes). View file
|
|
|
backend/lobby/__pycache__/manager.cpython-39.pyc
ADDED
|
Binary file (7.29 kB). View file
|
|
|
backend/lobby/manager.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LobbyManager — room lifecycle and matchmaking.
|
| 3 |
+
|
| 4 |
+
Rooms go through: waiting → ready → playing → finished
|
| 5 |
+
|
| 6 |
+
Socket.IO events consumed (called from main.py):
|
| 7 |
+
create_room → { name: str }
|
| 8 |
+
join_room → { room_id: str, name: str }
|
| 9 |
+
quick_match → { name: str }
|
| 10 |
+
player_ready → {}
|
| 11 |
+
leave_room → {}
|
| 12 |
+
|
| 13 |
+
Events emitted back:
|
| 14 |
+
room_created → { room_id, room }
|
| 15 |
+
room_joined → { room_id, room }
|
| 16 |
+
room_update → { room }
|
| 17 |
+
match_found → { room_id, room }
|
| 18 |
+
error → { message }
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import logging
|
| 24 |
+
import random
|
| 25 |
+
import string
|
| 26 |
+
from typing import Optional
|
| 27 |
+
|
| 28 |
+
log = logging.getLogger(__name__)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _gen_room_id(length: int = 6) -> str:
|
| 32 |
+
return "".join(random.choices(string.ascii_uppercase + string.digits, k=length))
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class RoomPlayer:
|
| 36 |
+
__slots__ = ("sid", "name", "ready")
|
| 37 |
+
|
| 38 |
+
def __init__(self, sid: str, name: str) -> None:
|
| 39 |
+
self.sid = sid
|
| 40 |
+
self.name = name
|
| 41 |
+
self.ready = False
|
| 42 |
+
|
| 43 |
+
def to_dict(self) -> dict:
|
| 44 |
+
return {"sid": self.sid, "name": self.name, "ready": self.ready}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class Room:
|
| 48 |
+
def __init__(self, room_id: str) -> None:
|
| 49 |
+
self.room_id = room_id
|
| 50 |
+
self.players: list[RoomPlayer] = []
|
| 51 |
+
self.status: str = "waiting" # waiting | ready | playing | finished
|
| 52 |
+
self.engine = None # set by main.py when game starts
|
| 53 |
+
|
| 54 |
+
@property
|
| 55 |
+
def is_full(self) -> bool:
|
| 56 |
+
return len(self.players) >= 2
|
| 57 |
+
|
| 58 |
+
@property
|
| 59 |
+
def all_ready(self) -> bool:
|
| 60 |
+
return self.is_full and all(p.ready for p in self.players)
|
| 61 |
+
|
| 62 |
+
def get_player(self, sid: str) -> Optional[RoomPlayer]:
|
| 63 |
+
return next((p for p in self.players if p.sid == sid), None)
|
| 64 |
+
|
| 65 |
+
def to_dict(self) -> dict:
|
| 66 |
+
return {
|
| 67 |
+
"room_id": self.room_id,
|
| 68 |
+
"status": self.status,
|
| 69 |
+
"players": [p.to_dict() for p in self.players],
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
class LobbyManager:
|
| 74 |
+
def __init__(self) -> None:
|
| 75 |
+
self._rooms: dict[str, Room] = {}
|
| 76 |
+
self._sid_to_room: dict[str, str] = {} # sid → room_id
|
| 77 |
+
self._queue: list[tuple[str, str]] = [] # (sid, name) pairs waiting for match
|
| 78 |
+
|
| 79 |
+
# ------------------------------------------------------------------
|
| 80 |
+
# Room management
|
| 81 |
+
# ------------------------------------------------------------------
|
| 82 |
+
|
| 83 |
+
def create_room(self, sid: str, name: str) -> Room:
|
| 84 |
+
self._leave_current(sid)
|
| 85 |
+
room_id = _gen_room_id()
|
| 86 |
+
while room_id in self._rooms:
|
| 87 |
+
room_id = _gen_room_id()
|
| 88 |
+
room = Room(room_id)
|
| 89 |
+
room.players.append(RoomPlayer(sid, name))
|
| 90 |
+
self._rooms[room_id] = room
|
| 91 |
+
self._sid_to_room[sid] = room_id
|
| 92 |
+
log.info("Room %s created by %s (%s)", room_id, name, sid)
|
| 93 |
+
return room
|
| 94 |
+
|
| 95 |
+
def join_room(self, sid: str, room_id: str, name: str) -> tuple[Optional[Room], Optional[str]]:
|
| 96 |
+
room = self._rooms.get(room_id)
|
| 97 |
+
if not room:
|
| 98 |
+
return None, "Room introuvable."
|
| 99 |
+
if room.is_full:
|
| 100 |
+
return None, "Room pleine."
|
| 101 |
+
if room.status not in ("waiting",):
|
| 102 |
+
return None, "La partie est déjà commencée."
|
| 103 |
+
|
| 104 |
+
self._leave_current(sid)
|
| 105 |
+
room.players.append(RoomPlayer(sid, name))
|
| 106 |
+
self._sid_to_room[sid] = room_id
|
| 107 |
+
log.info("%s (%s) joined room %s", name, sid, room_id)
|
| 108 |
+
return room, None
|
| 109 |
+
|
| 110 |
+
def quick_match(self, sid: str, name: str) -> tuple[Optional[Room], bool]:
|
| 111 |
+
"""
|
| 112 |
+
Add player to matchmaking queue.
|
| 113 |
+
Returns (room, is_new_game):
|
| 114 |
+
- (None, False) → queued, waiting for opponent
|
| 115 |
+
- (room, True) → match found, room ready to start
|
| 116 |
+
"""
|
| 117 |
+
self._leave_current(sid)
|
| 118 |
+
|
| 119 |
+
# Check if there's someone waiting
|
| 120 |
+
if self._queue:
|
| 121 |
+
opp_sid, opp_name = self._queue.pop(0)
|
| 122 |
+
room = self.create_room(opp_sid, opp_name)
|
| 123 |
+
room.players.append(RoomPlayer(sid, name))
|
| 124 |
+
self._sid_to_room[sid] = room.room_id
|
| 125 |
+
log.info("Match found: %s vs %s in room %s", opp_name, name, room.room_id)
|
| 126 |
+
return room, True
|
| 127 |
+
else:
|
| 128 |
+
self._queue.append((sid, name))
|
| 129 |
+
self._sid_to_room[sid] = "__queue__"
|
| 130 |
+
log.info("%s (%s) added to matchmaking queue", name, sid)
|
| 131 |
+
return None, False
|
| 132 |
+
|
| 133 |
+
def set_ready(self, sid: str) -> tuple[Optional[Room], bool]:
|
| 134 |
+
"""Mark player as ready. Returns (room, all_ready)."""
|
| 135 |
+
room = self._get_room(sid)
|
| 136 |
+
if not room:
|
| 137 |
+
return None, False
|
| 138 |
+
player = room.get_player(sid)
|
| 139 |
+
if player:
|
| 140 |
+
player.ready = True
|
| 141 |
+
all_ready = room.all_ready
|
| 142 |
+
if all_ready:
|
| 143 |
+
room.status = "playing"
|
| 144 |
+
return room, all_ready
|
| 145 |
+
|
| 146 |
+
def set_playing(self, room_id: str) -> None:
|
| 147 |
+
room = self._rooms.get(room_id)
|
| 148 |
+
if room:
|
| 149 |
+
room.status = "playing"
|
| 150 |
+
|
| 151 |
+
def finish_room(self, room_id: str) -> None:
|
| 152 |
+
room = self._rooms.get(room_id)
|
| 153 |
+
if room:
|
| 154 |
+
room.status = "finished"
|
| 155 |
+
|
| 156 |
+
def disconnect(self, sid: str) -> Optional[Room]:
|
| 157 |
+
"""Handle disconnection. Returns affected room (if any)."""
|
| 158 |
+
room = self._get_room(sid)
|
| 159 |
+
if room:
|
| 160 |
+
player = room.get_player(sid)
|
| 161 |
+
if player:
|
| 162 |
+
room.players.remove(player)
|
| 163 |
+
if not room.players:
|
| 164 |
+
del self._rooms[room.room_id]
|
| 165 |
+
room = None
|
| 166 |
+
elif room.status == "playing":
|
| 167 |
+
room.status = "finished"
|
| 168 |
+
# Remove from queue if waiting
|
| 169 |
+
self._queue = [(s, n) for s, n in self._queue if s != sid]
|
| 170 |
+
self._sid_to_room.pop(sid, None)
|
| 171 |
+
return room
|
| 172 |
+
|
| 173 |
+
# ------------------------------------------------------------------
|
| 174 |
+
# Helpers
|
| 175 |
+
# ------------------------------------------------------------------
|
| 176 |
+
|
| 177 |
+
def get_room(self, room_id: str) -> Optional[Room]:
|
| 178 |
+
return self._rooms.get(room_id)
|
| 179 |
+
|
| 180 |
+
def get_room_for_sid(self, sid: str) -> Optional[Room]:
|
| 181 |
+
return self._get_room(sid)
|
| 182 |
+
|
| 183 |
+
def _get_room(self, sid: str) -> Optional[Room]:
|
| 184 |
+
room_id = self._sid_to_room.get(sid)
|
| 185 |
+
if not room_id or room_id == "__queue__":
|
| 186 |
+
return None
|
| 187 |
+
return self._rooms.get(room_id)
|
| 188 |
+
|
| 189 |
+
def _leave_current(self, sid: str) -> None:
|
| 190 |
+
current_room_id = self._sid_to_room.get(sid)
|
| 191 |
+
if current_room_id and current_room_id != "__queue__":
|
| 192 |
+
current_room = self._rooms.get(current_room_id)
|
| 193 |
+
if current_room:
|
| 194 |
+
player = current_room.get_player(sid)
|
| 195 |
+
if player:
|
| 196 |
+
current_room.players.remove(player)
|
| 197 |
+
self._queue = [(s, n) for s, n in self._queue if s != sid]
|
| 198 |
+
self._sid_to_room.pop(sid, None)
|
backend/main.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Entry point — FastAPI + Socket.IO ASGI app.
|
| 3 |
+
|
| 4 |
+
Run with:
|
| 5 |
+
uvicorn main:app --reload --port 8000
|
| 6 |
+
|
| 7 |
+
Socket.IO events
|
| 8 |
+
────────────────
|
| 9 |
+
Client → Server
|
| 10 |
+
create_room { name }
|
| 11 |
+
join_room { room_id, name }
|
| 12 |
+
quick_match { name }
|
| 13 |
+
player_ready {}
|
| 14 |
+
voice_input { audio_b64, mime_type? }
|
| 15 |
+
disconnect (automatic)
|
| 16 |
+
|
| 17 |
+
Server → Client
|
| 18 |
+
room_created { room_id, room }
|
| 19 |
+
room_joined { room_id, room }
|
| 20 |
+
room_update { room } — broadcast to room on any lobby change
|
| 21 |
+
match_found { room_id, room } — to both players in quick match
|
| 22 |
+
game_start { game_state } — when both ready
|
| 23 |
+
game_update { game_state } — every tick (~250ms)
|
| 24 |
+
voice_result { transcription, feedback_text, feedback_audio_b64, results }
|
| 25 |
+
game_over { winner_id, winner_name }
|
| 26 |
+
error { message }
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
from __future__ import annotations
|
| 30 |
+
|
| 31 |
+
import base64
|
| 32 |
+
import logging
|
| 33 |
+
import os
|
| 34 |
+
from pathlib import Path
|
| 35 |
+
from typing import Optional
|
| 36 |
+
|
| 37 |
+
import socketio
|
| 38 |
+
from fastapi import FastAPI
|
| 39 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 40 |
+
from fastapi.staticfiles import StaticFiles
|
| 41 |
+
|
| 42 |
+
from game.engine import GameEngine
|
| 43 |
+
from game.state import GameState
|
| 44 |
+
from lobby.manager import LobbyManager
|
| 45 |
+
from voice import command_parser, stt, tts
|
| 46 |
+
|
| 47 |
+
logging.basicConfig(level=logging.INFO)
|
| 48 |
+
log = logging.getLogger(__name__)
|
| 49 |
+
|
| 50 |
+
# ---------------------------------------------------------------------------
|
| 51 |
+
# Socket.IO + FastAPI setup
|
| 52 |
+
# ---------------------------------------------------------------------------
|
| 53 |
+
|
| 54 |
+
sio = socketio.AsyncServer(
|
| 55 |
+
async_mode="asgi",
|
| 56 |
+
cors_allowed_origins="*",
|
| 57 |
+
ping_timeout=60,
|
| 58 |
+
ping_interval=25,
|
| 59 |
+
logger=False,
|
| 60 |
+
engineio_logger=False,
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
fastapi_app = FastAPI(title="VoiceStrike API")
|
| 64 |
+
fastapi_app.add_middleware(
|
| 65 |
+
CORSMiddleware,
|
| 66 |
+
allow_origins=["*"],
|
| 67 |
+
allow_methods=["*"],
|
| 68 |
+
allow_headers=["*"],
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
# Serve SvelteKit static build if present (production / HF Spaces)
|
| 72 |
+
_FRONTEND_BUILD = Path(__file__).parent.parent / "frontend" / "build"
|
| 73 |
+
if _FRONTEND_BUILD.exists():
|
| 74 |
+
fastapi_app.mount(
|
| 75 |
+
"/",
|
| 76 |
+
StaticFiles(directory=str(_FRONTEND_BUILD), html=True),
|
| 77 |
+
name="frontend",
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
# ASGI app: Socket.IO wraps FastAPI
|
| 81 |
+
app = socketio.ASGIApp(sio, fastapi_app)
|
| 82 |
+
|
| 83 |
+
# ---------------------------------------------------------------------------
|
| 84 |
+
# Global state
|
| 85 |
+
# ---------------------------------------------------------------------------
|
| 86 |
+
|
| 87 |
+
lobby = LobbyManager()
|
| 88 |
+
engines: dict[str, GameEngine] = {} # room_id → GameEngine
|
| 89 |
+
|
| 90 |
+
# ---------------------------------------------------------------------------
|
| 91 |
+
# Helpers
|
| 92 |
+
# ---------------------------------------------------------------------------
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _player_name_in_room(sid: str, room_id: str) -> str:
|
| 96 |
+
room = lobby.get_room(room_id)
|
| 97 |
+
if not room:
|
| 98 |
+
return sid
|
| 99 |
+
player = room.get_player(sid)
|
| 100 |
+
return player.name if player else sid
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
async def _emit_error(sid: str, message: str) -> None:
|
| 104 |
+
await sio.emit("error", {"message": message}, to=sid)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
async def _start_game(room_id: str) -> None:
|
| 108 |
+
"""Create game state + engine, emit game_start to both players."""
|
| 109 |
+
room = lobby.get_room(room_id)
|
| 110 |
+
if not room or len(room.players) != 2:
|
| 111 |
+
return
|
| 112 |
+
|
| 113 |
+
p1, p2 = room.players[0], room.players[1]
|
| 114 |
+
game_state = GameState.create_new(
|
| 115 |
+
room_id=room_id,
|
| 116 |
+
player1_id=p1.sid,
|
| 117 |
+
player1_name=p1.name,
|
| 118 |
+
player2_id=p2.sid,
|
| 119 |
+
player2_name=p2.name,
|
| 120 |
+
)
|
| 121 |
+
engine = GameEngine(game_state, sio)
|
| 122 |
+
engines[room_id] = engine
|
| 123 |
+
room.engine = engine
|
| 124 |
+
|
| 125 |
+
payload = game_state.model_dump(mode="json")
|
| 126 |
+
# Send each player their own perspective (same state for now — no fog of war)
|
| 127 |
+
await sio.emit("game_start", {"game_state": payload, "your_id": p1.sid}, to=p1.sid)
|
| 128 |
+
await sio.emit("game_start", {"game_state": payload, "your_id": p2.sid}, to=p2.sid)
|
| 129 |
+
|
| 130 |
+
engine.start()
|
| 131 |
+
log.info("Game started in room %s: %s vs %s", room_id, p1.name, p2.name)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
# ---------------------------------------------------------------------------
|
| 135 |
+
# Connection lifecycle
|
| 136 |
+
# ---------------------------------------------------------------------------
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
@sio.event
|
| 140 |
+
async def connect(sid: str, environ: dict, auth: Optional[dict] = None) -> None:
|
| 141 |
+
log.info("Client connected: %s", sid)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
@sio.event
|
| 145 |
+
async def disconnect(sid: str) -> None:
|
| 146 |
+
log.info("Client disconnected: %s", sid)
|
| 147 |
+
room = lobby.disconnect(sid)
|
| 148 |
+
if room:
|
| 149 |
+
if room.status == "finished" and room.room_id in engines:
|
| 150 |
+
await engines[room.room_id].stop()
|
| 151 |
+
del engines[room.room_id]
|
| 152 |
+
# Notify remaining players
|
| 153 |
+
for player in room.players:
|
| 154 |
+
await sio.emit(
|
| 155 |
+
"room_update",
|
| 156 |
+
{"room": room.to_dict(), "message": "Un joueur a quitté la partie."},
|
| 157 |
+
to=player.sid,
|
| 158 |
+
)
|
| 159 |
+
if room.status == "finished":
|
| 160 |
+
await sio.emit(
|
| 161 |
+
"game_over",
|
| 162 |
+
{"winner_id": player.sid, "winner_name": player.name,
|
| 163 |
+
"reason": "opponent_disconnected"},
|
| 164 |
+
to=player.sid,
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
# ---------------------------------------------------------------------------
|
| 169 |
+
# Lobby events
|
| 170 |
+
# ---------------------------------------------------------------------------
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
@sio.event
|
| 174 |
+
async def create_room(sid: str, data: dict) -> None:
|
| 175 |
+
name = str(data.get("name", "Player")).strip() or "Player"
|
| 176 |
+
room = lobby.create_room(sid, name)
|
| 177 |
+
await sio.enter_room(sid, room.room_id)
|
| 178 |
+
await sio.emit("room_created", {"room_id": room.room_id, "room": room.to_dict()}, to=sid)
|
| 179 |
+
log.info("Room %s created by %s", room.room_id, name)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
@sio.event
|
| 183 |
+
async def join_room(sid: str, data: dict) -> None:
|
| 184 |
+
room_id = str(data.get("room_id", "")).upper().strip()
|
| 185 |
+
name = str(data.get("name", "Player")).strip() or "Player"
|
| 186 |
+
|
| 187 |
+
room, err = lobby.join_room(sid, room_id, name)
|
| 188 |
+
if err:
|
| 189 |
+
await _emit_error(sid, err)
|
| 190 |
+
return
|
| 191 |
+
|
| 192 |
+
await sio.enter_room(sid, room_id)
|
| 193 |
+
await sio.emit("room_joined", {"room_id": room_id, "room": room.to_dict()}, to=sid)
|
| 194 |
+
# Notify host
|
| 195 |
+
await sio.emit("room_update", {"room": room.to_dict()}, room=room_id)
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
@sio.event
|
| 199 |
+
async def quick_match(sid: str, data: dict) -> None:
|
| 200 |
+
name = str(data.get("name", "Player")).strip() or "Player"
|
| 201 |
+
room, is_new = lobby.quick_match(sid, name)
|
| 202 |
+
|
| 203 |
+
if not is_new:
|
| 204 |
+
await sio.emit("match_queued", {"message": "En attente d'un adversaire…"}, to=sid)
|
| 205 |
+
return
|
| 206 |
+
|
| 207 |
+
# Match found — notify both players
|
| 208 |
+
await sio.enter_room(room.players[0].sid, room.room_id)
|
| 209 |
+
await sio.enter_room(room.players[1].sid, room.room_id)
|
| 210 |
+
for player in room.players:
|
| 211 |
+
await sio.emit(
|
| 212 |
+
"match_found",
|
| 213 |
+
{"room_id": room.room_id, "room": room.to_dict()},
|
| 214 |
+
to=player.sid,
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
# Auto-ready both players in quick match and start the game
|
| 218 |
+
for player in room.players:
|
| 219 |
+
player.ready = True
|
| 220 |
+
room.status = "playing"
|
| 221 |
+
await _start_game(room.room_id)
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
@sio.event
|
| 225 |
+
async def player_ready(sid: str, data: dict) -> None:
|
| 226 |
+
room, all_ready = lobby.set_ready(sid)
|
| 227 |
+
if not room:
|
| 228 |
+
await _emit_error(sid, "Tu n'es dans aucune room.")
|
| 229 |
+
return
|
| 230 |
+
|
| 231 |
+
await sio.emit("room_update", {"room": room.to_dict()}, room=room.room_id)
|
| 232 |
+
|
| 233 |
+
if all_ready:
|
| 234 |
+
await _start_game(room.room_id)
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
# ---------------------------------------------------------------------------
|
| 238 |
+
# Voice pipeline
|
| 239 |
+
# ---------------------------------------------------------------------------
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
@sio.event
|
| 243 |
+
async def voice_input(sid: str, data: dict) -> None:
|
| 244 |
+
"""
|
| 245 |
+
Receives base64-encoded audio from the client.
|
| 246 |
+
Pipeline: STT → Mistral → game commands → TTS → response
|
| 247 |
+
"""
|
| 248 |
+
room = lobby.get_room_for_sid(sid)
|
| 249 |
+
if not room:
|
| 250 |
+
await _emit_error(sid, "Tu n'es dans aucune room.")
|
| 251 |
+
return
|
| 252 |
+
|
| 253 |
+
engine = engines.get(room.room_id)
|
| 254 |
+
if not engine:
|
| 255 |
+
await _emit_error(sid, "La partie n'a pas encore commencé.")
|
| 256 |
+
return
|
| 257 |
+
|
| 258 |
+
player = engine.state.players.get(sid)
|
| 259 |
+
if not player:
|
| 260 |
+
await _emit_error(sid, "Joueur introuvable dans la partie.")
|
| 261 |
+
return
|
| 262 |
+
|
| 263 |
+
audio_b64: str = data.get("audio_b64", "")
|
| 264 |
+
mime_type: str = data.get("mime_type", "audio/webm")
|
| 265 |
+
|
| 266 |
+
if not audio_b64:
|
| 267 |
+
await _emit_error(sid, "Aucune donnée audio reçue.")
|
| 268 |
+
return
|
| 269 |
+
|
| 270 |
+
try:
|
| 271 |
+
audio_bytes = base64.b64decode(audio_b64)
|
| 272 |
+
except Exception:
|
| 273 |
+
await _emit_error(sid, "Données audio invalides (base64 incorrect).")
|
| 274 |
+
return
|
| 275 |
+
|
| 276 |
+
# 1. Speech-to-text
|
| 277 |
+
try:
|
| 278 |
+
transcription = await stt.transcribe(audio_bytes, mime_type)
|
| 279 |
+
except Exception as exc:
|
| 280 |
+
log.exception("STT failed")
|
| 281 |
+
await _emit_error(sid, f"Erreur de reconnaissance vocale: {exc}")
|
| 282 |
+
return
|
| 283 |
+
|
| 284 |
+
if not transcription:
|
| 285 |
+
await sio.emit("voice_result", {
|
| 286 |
+
"transcription": "",
|
| 287 |
+
"feedback_text": "Je n'ai rien entendu. Appuie et parle!",
|
| 288 |
+
"feedback_audio_b64": "",
|
| 289 |
+
"results": [],
|
| 290 |
+
}, to=sid)
|
| 291 |
+
return
|
| 292 |
+
|
| 293 |
+
# 2. Parse command with Mistral
|
| 294 |
+
try:
|
| 295 |
+
parsed = await command_parser.parse(transcription, player)
|
| 296 |
+
except Exception as exc:
|
| 297 |
+
log.exception("Command parsing failed")
|
| 298 |
+
await _emit_error(sid, f"Erreur d'interprétation: {exc}")
|
| 299 |
+
return
|
| 300 |
+
|
| 301 |
+
# 3. Apply commands to game engine
|
| 302 |
+
cmd_result = engine.apply_command(sid, parsed)
|
| 303 |
+
|
| 304 |
+
feedback_text = (
|
| 305 |
+
cmd_result.feedback_override
|
| 306 |
+
if cmd_result.feedback_override
|
| 307 |
+
else parsed.feedback
|
| 308 |
+
)
|
| 309 |
+
|
| 310 |
+
# Append query result to feedback if present
|
| 311 |
+
for r in cmd_result.results:
|
| 312 |
+
if r.action_type == "query" and r.success:
|
| 313 |
+
feedback_text += "\n" + r.message
|
| 314 |
+
|
| 315 |
+
# 4. Text-to-speech
|
| 316 |
+
try:
|
| 317 |
+
audio_out = await tts.synthesize(feedback_text)
|
| 318 |
+
feedback_audio_b64 = base64.b64encode(audio_out).decode()
|
| 319 |
+
except Exception as exc:
|
| 320 |
+
log.warning("TTS failed (non-fatal): %s", exc)
|
| 321 |
+
feedback_audio_b64 = ""
|
| 322 |
+
|
| 323 |
+
# 5. Send result back to client
|
| 324 |
+
await sio.emit("voice_result", {
|
| 325 |
+
"transcription": transcription,
|
| 326 |
+
"feedback_text": feedback_text,
|
| 327 |
+
"feedback_audio_b64": feedback_audio_b64,
|
| 328 |
+
"results": [r.model_dump() for r in cmd_result.results],
|
| 329 |
+
}, to=sid)
|
| 330 |
+
|
| 331 |
+
# Check win condition after command
|
| 332 |
+
if engine.state.winner:
|
| 333 |
+
winner_state = engine.state.players.get(engine.state.winner)
|
| 334 |
+
winner_name = winner_state.player_name if winner_state else engine.state.winner
|
| 335 |
+
await sio.emit("game_over", {
|
| 336 |
+
"winner_id": engine.state.winner,
|
| 337 |
+
"winner_name": winner_name,
|
| 338 |
+
}, room=room.room_id)
|
| 339 |
+
await engines[room.room_id].stop()
|
| 340 |
+
del engines[room.room_id]
|
| 341 |
+
lobby.finish_room(room.room_id)
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.110.0
|
| 2 |
+
uvicorn[standard]>=0.27.0
|
| 3 |
+
python-socketio>=5.11.0
|
| 4 |
+
python-dotenv>=1.0.0
|
| 5 |
+
pydantic>=2.0.0
|
| 6 |
+
mistralai>=1.0.0
|
| 7 |
+
elevenlabs>=1.0.0
|
| 8 |
+
httpx>=0.27.0
|
backend/voice/__init__.py
ADDED
|
File without changes
|
backend/voice/__pycache__/__init__.cpython-39.pyc
ADDED
|
Binary file (140 Bytes). View file
|
|
|
backend/voice/__pycache__/command_parser.cpython-39.pyc
ADDED
|
Binary file (4.72 kB). View file
|
|
|
backend/voice/__pycache__/stt.cpython-39.pyc
ADDED
|
Binary file (1.29 kB). View file
|
|
|
backend/voice/__pycache__/tts.cpython-39.pyc
ADDED
|
Binary file (1.39 kB). View file
|
|
|
backend/voice/command_parser.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Voice command parser — Mistral LLM interprets transcribed text
|
| 3 |
+
and returns structured GameActions.
|
| 4 |
+
|
| 5 |
+
The player's current game state is injected as context so Mistral
|
| 6 |
+
can understand references like "envoie tous mes marines" or
|
| 7 |
+
"construis quelque chose pour produire des tanks".
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
import logging
|
| 14 |
+
|
| 15 |
+
from mistralai import Mistral
|
| 16 |
+
|
| 17 |
+
from config import MISTRAL_API_KEY
|
| 18 |
+
from game.commands import ActionType, GameAction, ParsedCommand
|
| 19 |
+
from game.state import PlayerState
|
| 20 |
+
|
| 21 |
+
log = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
_SYSTEM_PROMPT = """\
|
| 24 |
+
Tu es l'interprète de commandes vocales d'un jeu de stratégie en temps réel (style StarCraft, race Terran).
|
| 25 |
+
Ton rôle est de convertir les instructions vocales d'un joueur en actions JSON structurées.
|
| 26 |
+
|
| 27 |
+
=== UNITÉS DISPONIBLES ===
|
| 28 |
+
scv, marine, medic, goliath, tank, wraith
|
| 29 |
+
|
| 30 |
+
=== BÂTIMENTS DISPONIBLES ===
|
| 31 |
+
supply_depot, barracks, engineering_bay, refinery, factory, armory, starport
|
| 32 |
+
|
| 33 |
+
=== TYPES D'ACTIONS ===
|
| 34 |
+
- build : construire un bâtiment → champs: building_type
|
| 35 |
+
- train : entraîner des unités → champs: unit_type, count (défaut 1)
|
| 36 |
+
- move : déplacer des unités → champs: unit_selector, target_zone
|
| 37 |
+
- attack : attaquer une zone → champs: unit_selector, target_zone
|
| 38 |
+
- siege : tank en mode siège → champs: unit_selector (optionnel)
|
| 39 |
+
- unsiege : retirer le mode siège → champs: unit_selector (optionnel)
|
| 40 |
+
- cloak : activer camouflage → champs: unit_selector (optionnel)
|
| 41 |
+
- decloak : désactiver camouflage → champs: unit_selector (optionnel)
|
| 42 |
+
- gather : collecter ressources → champs: unit_selector, resource_type ("minerals"|"gas")
|
| 43 |
+
- stop : arrêter des unités → champs: unit_selector
|
| 44 |
+
- patrol : patrouiller → champs: unit_selector, target_zone
|
| 45 |
+
- query : information sur l'état → aucun champ requis
|
| 46 |
+
|
| 47 |
+
=== SÉLECTEURS D'UNITÉS ===
|
| 48 |
+
all, all_military, all_marines, all_medics, all_goliaths, all_tanks, all_wraiths, all_scv, idle_scv, most_damaged
|
| 49 |
+
|
| 50 |
+
=== ZONES CIBLES ===
|
| 51 |
+
my_base, enemy_base, center, top_left, top_right, bottom_left, bottom_right, front_line
|
| 52 |
+
|
| 53 |
+
=== ÉTAT ACTUEL DU JOUEUR ===
|
| 54 |
+
{player_state}
|
| 55 |
+
|
| 56 |
+
=== CONSIGNES ===
|
| 57 |
+
- Réponds UNIQUEMENT avec un JSON valide, aucun texte avant ou après.
|
| 58 |
+
- Une commande peut générer PLUSIEURS actions (ex: "entraîne 4 marines et attaque la base").
|
| 59 |
+
- Le champ "feedback" est un message en français à lire au joueur pour confirmer l'action.
|
| 60 |
+
- Si la commande est incompréhensible, génère une action de type "query" avec un feedback explicatif.
|
| 61 |
+
- Si le joueur demande son état / ses ressources, utilise l'action "query".
|
| 62 |
+
|
| 63 |
+
=== FORMAT DE RÉPONSE ===
|
| 64 |
+
{
|
| 65 |
+
"actions": [
|
| 66 |
+
{
|
| 67 |
+
"type": "<action_type>",
|
| 68 |
+
"building_type": "<valeur ou omis>",
|
| 69 |
+
"unit_type": "<valeur ou omis>",
|
| 70 |
+
"count": <entier, défaut 1>,
|
| 71 |
+
"unit_selector": "<valeur ou omis>",
|
| 72 |
+
"target_zone": "<valeur ou omis>",
|
| 73 |
+
"resource_type": "<valeur ou omis>"
|
| 74 |
+
}
|
| 75 |
+
],
|
| 76 |
+
"feedback": "<message en français>"
|
| 77 |
+
}
|
| 78 |
+
"""
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
async def parse(transcription: str, player: PlayerState) -> ParsedCommand:
|
| 82 |
+
"""
|
| 83 |
+
Send transcription + player state to Mistral and return parsed command.
|
| 84 |
+
Falls back to a query action if parsing fails.
|
| 85 |
+
"""
|
| 86 |
+
if not MISTRAL_API_KEY:
|
| 87 |
+
raise RuntimeError("MISTRAL_API_KEY not set")
|
| 88 |
+
|
| 89 |
+
client = Mistral(api_key=MISTRAL_API_KEY)
|
| 90 |
+
system = _SYSTEM_PROMPT.replace("{player_state}", player.summary())
|
| 91 |
+
|
| 92 |
+
response = await client.chat.complete_async(
|
| 93 |
+
model="mistral-large-latest",
|
| 94 |
+
messages=[
|
| 95 |
+
{"role": "system", "content": system},
|
| 96 |
+
{"role": "user", "content": transcription},
|
| 97 |
+
],
|
| 98 |
+
response_format={"type": "json_object"},
|
| 99 |
+
temperature=0.1,
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
raw = response.choices[0].message.content or "{}"
|
| 103 |
+
log.info("Mistral raw response: %s", raw[:300])
|
| 104 |
+
|
| 105 |
+
try:
|
| 106 |
+
data = json.loads(raw)
|
| 107 |
+
actions = [GameAction(**a) for a in data.get("actions", [])]
|
| 108 |
+
feedback = data.get("feedback", "Commande reçue.")
|
| 109 |
+
if not actions:
|
| 110 |
+
raise ValueError("Empty actions list")
|
| 111 |
+
return ParsedCommand(actions=actions, feedback=feedback)
|
| 112 |
+
except Exception as exc:
|
| 113 |
+
log.warning("Failed to parse Mistral response: %s — %s", exc, raw[:200])
|
| 114 |
+
return ParsedCommand(
|
| 115 |
+
actions=[GameAction(type=ActionType.QUERY)],
|
| 116 |
+
feedback="Je n'ai pas compris cette commande. Voici ton état actuel.",
|
| 117 |
+
)
|
backend/voice/stt.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Speech-to-Text via ElevenLabs STT API.
|
| 3 |
+
|
| 4 |
+
Input : raw audio bytes (WebM/Opus from MediaRecorder, or WAV)
|
| 5 |
+
Output: transcribed text string
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import logging
|
| 11 |
+
|
| 12 |
+
import httpx
|
| 13 |
+
|
| 14 |
+
from config import ELEVENLABS_API_KEY
|
| 15 |
+
|
| 16 |
+
log = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
_STT_URL = "https://api.elevenlabs.io/v1/speech-to-text"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
async def transcribe(audio_bytes: bytes, mime_type: str = "audio/webm") -> str:
|
| 22 |
+
"""
|
| 23 |
+
Send audio bytes to ElevenLabs STT and return transcribed text.
|
| 24 |
+
Raises on HTTP errors.
|
| 25 |
+
"""
|
| 26 |
+
if not ELEVENLABS_API_KEY:
|
| 27 |
+
raise RuntimeError("ELEVENLABS_API_KEY not set")
|
| 28 |
+
|
| 29 |
+
headers = {"xi-api-key": ELEVENLABS_API_KEY}
|
| 30 |
+
|
| 31 |
+
# ElevenLabs STT expects multipart/form-data with field "audio"
|
| 32 |
+
files = {"audio": ("audio.webm", audio_bytes, mime_type)}
|
| 33 |
+
data = {"model_id": "scribe_v1"}
|
| 34 |
+
|
| 35 |
+
async with httpx.AsyncClient(timeout=30) as client:
|
| 36 |
+
resp = await client.post(_STT_URL, headers=headers, files=files, data=data)
|
| 37 |
+
resp.raise_for_status()
|
| 38 |
+
body = resp.json()
|
| 39 |
+
text: str = body.get("text", "").strip()
|
| 40 |
+
log.info("STT result: %r", text)
|
| 41 |
+
return text
|
backend/voice/tts.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Text-to-Speech via ElevenLabs TTS API.
|
| 3 |
+
|
| 4 |
+
Input : text string
|
| 5 |
+
Output: MP3 bytes (streamed and collected)
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import logging
|
| 11 |
+
|
| 12 |
+
import httpx
|
| 13 |
+
|
| 14 |
+
from config import ELEVENLABS_API_KEY, ELEVENLABS_VOICE_ID
|
| 15 |
+
|
| 16 |
+
log = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
_TTS_URL = "https://api.elevenlabs.io/v1/text-to-speech/{voice_id}"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
async def synthesize(text: str) -> bytes:
|
| 22 |
+
"""
|
| 23 |
+
Convert text to speech using ElevenLabs TTS.
|
| 24 |
+
Returns MP3 bytes.
|
| 25 |
+
"""
|
| 26 |
+
if not ELEVENLABS_API_KEY:
|
| 27 |
+
raise RuntimeError("ELEVENLABS_API_KEY not set")
|
| 28 |
+
|
| 29 |
+
url = _TTS_URL.format(voice_id=ELEVENLABS_VOICE_ID)
|
| 30 |
+
headers = {
|
| 31 |
+
"xi-api-key": ELEVENLABS_API_KEY,
|
| 32 |
+
"Content-Type": "application/json",
|
| 33 |
+
"Accept": "audio/mpeg",
|
| 34 |
+
}
|
| 35 |
+
payload = {
|
| 36 |
+
"text": text,
|
| 37 |
+
"model_id": "eleven_multilingual_v2",
|
| 38 |
+
"voice_settings": {
|
| 39 |
+
"stability": 0.5,
|
| 40 |
+
"similarity_boost": 0.75,
|
| 41 |
+
},
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
async with httpx.AsyncClient(timeout=30) as client:
|
| 45 |
+
resp = await client.post(url, headers=headers, json=payload)
|
| 46 |
+
resp.raise_for_status()
|
| 47 |
+
audio = resp.content
|
| 48 |
+
log.info("TTS generated %d bytes for: %r", len(audio), text[:60])
|
| 49 |
+
return audio
|
frontend/.svelte-kit/ambient.d.ts
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
// this file is generated — do not edit it
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
/// <reference types="@sveltejs/kit" />
|
| 6 |
+
|
| 7 |
+
/**
|
| 8 |
+
* This module provides access to environment variables that are injected _statically_ into your bundle at build time and are limited to _private_ access.
|
| 9 |
+
*
|
| 10 |
+
* | | Runtime | Build time |
|
| 11 |
+
* | ------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
| 12 |
+
* | Private | [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private) | [`$env/static/private`](https://svelte.dev/docs/kit/$env-static-private) |
|
| 13 |
+
* | Public | [`$env/dynamic/public`](https://svelte.dev/docs/kit/$env-dynamic-public) | [`$env/static/public`](https://svelte.dev/docs/kit/$env-static-public) |
|
| 14 |
+
*
|
| 15 |
+
* Static environment variables are [loaded by Vite](https://vitejs.dev/guide/env-and-mode.html#env-files) from `.env` files and `process.env` at build time and then statically injected into your bundle at build time, enabling optimisations like dead code elimination.
|
| 16 |
+
*
|
| 17 |
+
* **_Private_ access:**
|
| 18 |
+
*
|
| 19 |
+
* - This module cannot be imported into client-side code
|
| 20 |
+
* - This module only includes variables that _do not_ begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) _and do_ start with [`config.kit.env.privatePrefix`](https://svelte.dev/docs/kit/configuration#env) (if configured)
|
| 21 |
+
*
|
| 22 |
+
* For example, given the following build time environment:
|
| 23 |
+
*
|
| 24 |
+
* ```env
|
| 25 |
+
* ENVIRONMENT=production
|
| 26 |
+
* PUBLIC_BASE_URL=http://site.com
|
| 27 |
+
* ```
|
| 28 |
+
*
|
| 29 |
+
* With the default `publicPrefix` and `privatePrefix`:
|
| 30 |
+
*
|
| 31 |
+
* ```ts
|
| 32 |
+
* import { ENVIRONMENT, PUBLIC_BASE_URL } from '$env/static/private';
|
| 33 |
+
*
|
| 34 |
+
* console.log(ENVIRONMENT); // => "production"
|
| 35 |
+
* console.log(PUBLIC_BASE_URL); // => throws error during build
|
| 36 |
+
* ```
|
| 37 |
+
*
|
| 38 |
+
* The above values will be the same _even if_ different values for `ENVIRONMENT` or `PUBLIC_BASE_URL` are set at runtime, as they are statically replaced in your code with their build time values.
|
| 39 |
+
*/
|
| 40 |
+
declare module '$env/static/private' {
|
| 41 |
+
export const GJS_DEBUG_TOPICS: string;
|
| 42 |
+
export const LESSOPEN: string;
|
| 43 |
+
export const VSCODE_CWD: string;
|
| 44 |
+
export const CURSOR_EXTENSION_HOST_ROLE: string;
|
| 45 |
+
export const VSCODE_ESM_ENTRYPOINT: string;
|
| 46 |
+
export const CONDA_PROMPT_MODIFIER: string;
|
| 47 |
+
export const USER: string;
|
| 48 |
+
export const VSCODE_NLS_CONFIG: string;
|
| 49 |
+
export const npm_config_user_agent: string;
|
| 50 |
+
export const CI: string;
|
| 51 |
+
export const VSCODE_HANDLES_UNCAUGHT_ERRORS: string;
|
| 52 |
+
export const XDG_SESSION_TYPE: string;
|
| 53 |
+
export const npm_node_execpath: string;
|
| 54 |
+
export const SHLVL: string;
|
| 55 |
+
export const LD_LIBRARY_PATH: string;
|
| 56 |
+
export const npm_config_noproxy: string;
|
| 57 |
+
export const HOME: string;
|
| 58 |
+
export const CHROME_DESKTOP: string;
|
| 59 |
+
export const APPDIR: string;
|
| 60 |
+
export const CONDA_SHLVL: string;
|
| 61 |
+
export const OLDPWD: string;
|
| 62 |
+
export const DESKTOP_SESSION: string;
|
| 63 |
+
export const npm_package_json: string;
|
| 64 |
+
export const PERLLIB: string;
|
| 65 |
+
export const VSCODE_IPC_HOOK: string;
|
| 66 |
+
export const GIO_LAUNCHED_DESKTOP_FILE: string;
|
| 67 |
+
export const GNOME_SHELL_SESSION_MODE: string;
|
| 68 |
+
export const GTK_MODULES: string;
|
| 69 |
+
export const MANAGERPID: string;
|
| 70 |
+
export const npm_config_userconfig: string;
|
| 71 |
+
export const npm_config_local_prefix: string;
|
| 72 |
+
export const SYSTEMD_EXEC_PID: string;
|
| 73 |
+
export const IM_CONFIG_CHECK_ENV: string;
|
| 74 |
+
export const NO_COLOR: string;
|
| 75 |
+
export const DBUS_SESSION_BUS_ADDRESS: string;
|
| 76 |
+
export const _CE_M: string;
|
| 77 |
+
export const GIO_LAUNCHED_DESKTOP_FILE_PID: string;
|
| 78 |
+
export const COLOR: string;
|
| 79 |
+
export const VSCODE_CRASH_REPORTER_PROCESS_TYPE: string;
|
| 80 |
+
export const DEBUGINFOD_URLS: string;
|
| 81 |
+
export const IM_CONFIG_PHASE: string;
|
| 82 |
+
export const WAYLAND_DISPLAY: string;
|
| 83 |
+
export const LOGNAME: string;
|
| 84 |
+
export const FORCE_COLOR: string;
|
| 85 |
+
export const OWD: string;
|
| 86 |
+
export const JOURNAL_STREAM: string;
|
| 87 |
+
export const _: string;
|
| 88 |
+
export const npm_config_prefix: string;
|
| 89 |
+
export const npm_config_npm_version: string;
|
| 90 |
+
export const MEMORY_PRESSURE_WATCH: string;
|
| 91 |
+
export const XDG_SESSION_CLASS: string;
|
| 92 |
+
export const USERNAME: string;
|
| 93 |
+
export const TERM: string;
|
| 94 |
+
export const npm_config_cache: string;
|
| 95 |
+
export const GNOME_DESKTOP_SESSION_ID: string;
|
| 96 |
+
export const _CE_CONDA: string;
|
| 97 |
+
export const FC_FONTATIONS: string;
|
| 98 |
+
export const npm_config_node_gyp: string;
|
| 99 |
+
export const PATH: string;
|
| 100 |
+
export const INVOCATION_ID: string;
|
| 101 |
+
export const APPIMAGE: string;
|
| 102 |
+
export const NODE: string;
|
| 103 |
+
export const npm_package_name: string;
|
| 104 |
+
export const XDG_MENU_PREFIX: string;
|
| 105 |
+
export const VSCODE_PROCESS_TITLE: string;
|
| 106 |
+
export const GNOME_SETUP_DISPLAY: string;
|
| 107 |
+
export const XDG_RUNTIME_DIR: string;
|
| 108 |
+
export const GDK_BACKEND: string;
|
| 109 |
+
export const CURSOR_AGENT: string;
|
| 110 |
+
export const DISPLAY: string;
|
| 111 |
+
export const LANG: string;
|
| 112 |
+
export const XDG_CURRENT_DESKTOP: string;
|
| 113 |
+
export const XMODIFIERS: string;
|
| 114 |
+
export const XDG_SESSION_DESKTOP: string;
|
| 115 |
+
export const XAUTHORITY: string;
|
| 116 |
+
export const LS_COLORS: string;
|
| 117 |
+
export const CURSOR_TRACE_ID: string;
|
| 118 |
+
export const npm_lifecycle_script: string;
|
| 119 |
+
export const SSH_AUTH_SOCK: string;
|
| 120 |
+
export const GSETTINGS_SCHEMA_DIR: string;
|
| 121 |
+
export const CONDA_PYTHON_EXE: string;
|
| 122 |
+
export const SHELL: string;
|
| 123 |
+
export const ARGV0: string;
|
| 124 |
+
export const npm_package_version: string;
|
| 125 |
+
export const npm_lifecycle_event: string;
|
| 126 |
+
export const QT_ACCESSIBILITY: string;
|
| 127 |
+
export const GDMSESSION: string;
|
| 128 |
+
export const LESSCLOSE: string;
|
| 129 |
+
export const CONDA_DEFAULT_ENV: string;
|
| 130 |
+
export const GPG_AGENT_INFO: string;
|
| 131 |
+
export const GJS_DEBUG_OUTPUT: string;
|
| 132 |
+
export const QT_IM_MODULE: string;
|
| 133 |
+
export const npm_config_globalconfig: string;
|
| 134 |
+
export const npm_config_init_module: string;
|
| 135 |
+
export const PWD: string;
|
| 136 |
+
export const npm_execpath: string;
|
| 137 |
+
export const XDG_CONFIG_DIRS: string;
|
| 138 |
+
export const CONDA_EXE: string;
|
| 139 |
+
export const VSCODE_CODE_CACHE_PATH: string;
|
| 140 |
+
export const XDG_DATA_DIRS: string;
|
| 141 |
+
export const npm_config_global_prefix: string;
|
| 142 |
+
export const npm_command: string;
|
| 143 |
+
export const CONDA_PREFIX: string;
|
| 144 |
+
export const QT_PLUGIN_PATH: string;
|
| 145 |
+
export const _ZO_DOCTOR: string;
|
| 146 |
+
export const QT_IM_MODULES: string;
|
| 147 |
+
export const MEMORY_PRESSURE_WRITE: string;
|
| 148 |
+
export const VSCODE_PID: string;
|
| 149 |
+
export const INIT_CWD: string;
|
| 150 |
+
export const EDITOR: string;
|
| 151 |
+
export const NODE_ENV: string;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
/**
|
| 155 |
+
* This module provides access to environment variables that are injected _statically_ into your bundle at build time and are _publicly_ accessible.
|
| 156 |
+
*
|
| 157 |
+
* | | Runtime | Build time |
|
| 158 |
+
* | ------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
| 159 |
+
* | Private | [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private) | [`$env/static/private`](https://svelte.dev/docs/kit/$env-static-private) |
|
| 160 |
+
* | Public | [`$env/dynamic/public`](https://svelte.dev/docs/kit/$env-dynamic-public) | [`$env/static/public`](https://svelte.dev/docs/kit/$env-static-public) |
|
| 161 |
+
*
|
| 162 |
+
* Static environment variables are [loaded by Vite](https://vitejs.dev/guide/env-and-mode.html#env-files) from `.env` files and `process.env` at build time and then statically injected into your bundle at build time, enabling optimisations like dead code elimination.
|
| 163 |
+
*
|
| 164 |
+
* **_Public_ access:**
|
| 165 |
+
*
|
| 166 |
+
* - This module _can_ be imported into client-side code
|
| 167 |
+
* - **Only** variables that begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) (which defaults to `PUBLIC_`) are included
|
| 168 |
+
*
|
| 169 |
+
* For example, given the following build time environment:
|
| 170 |
+
*
|
| 171 |
+
* ```env
|
| 172 |
+
* ENVIRONMENT=production
|
| 173 |
+
* PUBLIC_BASE_URL=http://site.com
|
| 174 |
+
* ```
|
| 175 |
+
*
|
| 176 |
+
* With the default `publicPrefix` and `privatePrefix`:
|
| 177 |
+
*
|
| 178 |
+
* ```ts
|
| 179 |
+
* import { ENVIRONMENT, PUBLIC_BASE_URL } from '$env/static/public';
|
| 180 |
+
*
|
| 181 |
+
* console.log(ENVIRONMENT); // => throws error during build
|
| 182 |
+
* console.log(PUBLIC_BASE_URL); // => "http://site.com"
|
| 183 |
+
* ```
|
| 184 |
+
*
|
| 185 |
+
* The above values will be the same _even if_ different values for `ENVIRONMENT` or `PUBLIC_BASE_URL` are set at runtime, as they are statically replaced in your code with their build time values.
|
| 186 |
+
*/
|
| 187 |
+
declare module '$env/static/public' {
|
| 188 |
+
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
/**
|
| 192 |
+
* This module provides access to environment variables set _dynamically_ at runtime and that are limited to _private_ access.
|
| 193 |
+
*
|
| 194 |
+
* | | Runtime | Build time |
|
| 195 |
+
* | ------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
| 196 |
+
* | Private | [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private) | [`$env/static/private`](https://svelte.dev/docs/kit/$env-static-private) |
|
| 197 |
+
* | Public | [`$env/dynamic/public`](https://svelte.dev/docs/kit/$env-dynamic-public) | [`$env/static/public`](https://svelte.dev/docs/kit/$env-static-public) |
|
| 198 |
+
*
|
| 199 |
+
* Dynamic environment variables are defined by the platform you're running on. For example if you're using [`adapter-node`](https://github.com/sveltejs/kit/tree/main/packages/adapter-node) (or running [`vite preview`](https://svelte.dev/docs/kit/cli)), this is equivalent to `process.env`.
|
| 200 |
+
*
|
| 201 |
+
* **_Private_ access:**
|
| 202 |
+
*
|
| 203 |
+
* - This module cannot be imported into client-side code
|
| 204 |
+
* - This module includes variables that _do not_ begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) _and do_ start with [`config.kit.env.privatePrefix`](https://svelte.dev/docs/kit/configuration#env) (if configured)
|
| 205 |
+
*
|
| 206 |
+
* > [!NOTE] In `dev`, `$env/dynamic` includes environment variables from `.env`. In `prod`, this behavior will depend on your adapter.
|
| 207 |
+
*
|
| 208 |
+
* > [!NOTE] To get correct types, environment variables referenced in your code should be declared (for example in an `.env` file), even if they don't have a value until the app is deployed:
|
| 209 |
+
* >
|
| 210 |
+
* > ```env
|
| 211 |
+
* > MY_FEATURE_FLAG=
|
| 212 |
+
* > ```
|
| 213 |
+
* >
|
| 214 |
+
* > You can override `.env` values from the command line like so:
|
| 215 |
+
* >
|
| 216 |
+
* > ```sh
|
| 217 |
+
* > MY_FEATURE_FLAG="enabled" npm run dev
|
| 218 |
+
* > ```
|
| 219 |
+
*
|
| 220 |
+
* For example, given the following runtime environment:
|
| 221 |
+
*
|
| 222 |
+
* ```env
|
| 223 |
+
* ENVIRONMENT=production
|
| 224 |
+
* PUBLIC_BASE_URL=http://site.com
|
| 225 |
+
* ```
|
| 226 |
+
*
|
| 227 |
+
* With the default `publicPrefix` and `privatePrefix`:
|
| 228 |
+
*
|
| 229 |
+
* ```ts
|
| 230 |
+
* import { env } from '$env/dynamic/private';
|
| 231 |
+
*
|
| 232 |
+
* console.log(env.ENVIRONMENT); // => "production"
|
| 233 |
+
* console.log(env.PUBLIC_BASE_URL); // => undefined
|
| 234 |
+
* ```
|
| 235 |
+
*/
|
| 236 |
+
declare module '$env/dynamic/private' {
|
| 237 |
+
export const env: {
|
| 238 |
+
GJS_DEBUG_TOPICS: string;
|
| 239 |
+
LESSOPEN: string;
|
| 240 |
+
VSCODE_CWD: string;
|
| 241 |
+
CURSOR_EXTENSION_HOST_ROLE: string;
|
| 242 |
+
VSCODE_ESM_ENTRYPOINT: string;
|
| 243 |
+
CONDA_PROMPT_MODIFIER: string;
|
| 244 |
+
USER: string;
|
| 245 |
+
VSCODE_NLS_CONFIG: string;
|
| 246 |
+
npm_config_user_agent: string;
|
| 247 |
+
CI: string;
|
| 248 |
+
VSCODE_HANDLES_UNCAUGHT_ERRORS: string;
|
| 249 |
+
XDG_SESSION_TYPE: string;
|
| 250 |
+
npm_node_execpath: string;
|
| 251 |
+
SHLVL: string;
|
| 252 |
+
LD_LIBRARY_PATH: string;
|
| 253 |
+
npm_config_noproxy: string;
|
| 254 |
+
HOME: string;
|
| 255 |
+
CHROME_DESKTOP: string;
|
| 256 |
+
APPDIR: string;
|
| 257 |
+
CONDA_SHLVL: string;
|
| 258 |
+
OLDPWD: string;
|
| 259 |
+
DESKTOP_SESSION: string;
|
| 260 |
+
npm_package_json: string;
|
| 261 |
+
PERLLIB: string;
|
| 262 |
+
VSCODE_IPC_HOOK: string;
|
| 263 |
+
GIO_LAUNCHED_DESKTOP_FILE: string;
|
| 264 |
+
GNOME_SHELL_SESSION_MODE: string;
|
| 265 |
+
GTK_MODULES: string;
|
| 266 |
+
MANAGERPID: string;
|
| 267 |
+
npm_config_userconfig: string;
|
| 268 |
+
npm_config_local_prefix: string;
|
| 269 |
+
SYSTEMD_EXEC_PID: string;
|
| 270 |
+
IM_CONFIG_CHECK_ENV: string;
|
| 271 |
+
NO_COLOR: string;
|
| 272 |
+
DBUS_SESSION_BUS_ADDRESS: string;
|
| 273 |
+
_CE_M: string;
|
| 274 |
+
GIO_LAUNCHED_DESKTOP_FILE_PID: string;
|
| 275 |
+
COLOR: string;
|
| 276 |
+
VSCODE_CRASH_REPORTER_PROCESS_TYPE: string;
|
| 277 |
+
DEBUGINFOD_URLS: string;
|
| 278 |
+
IM_CONFIG_PHASE: string;
|
| 279 |
+
WAYLAND_DISPLAY: string;
|
| 280 |
+
LOGNAME: string;
|
| 281 |
+
FORCE_COLOR: string;
|
| 282 |
+
OWD: string;
|
| 283 |
+
JOURNAL_STREAM: string;
|
| 284 |
+
_: string;
|
| 285 |
+
npm_config_prefix: string;
|
| 286 |
+
npm_config_npm_version: string;
|
| 287 |
+
MEMORY_PRESSURE_WATCH: string;
|
| 288 |
+
XDG_SESSION_CLASS: string;
|
| 289 |
+
USERNAME: string;
|
| 290 |
+
TERM: string;
|
| 291 |
+
npm_config_cache: string;
|
| 292 |
+
GNOME_DESKTOP_SESSION_ID: string;
|
| 293 |
+
_CE_CONDA: string;
|
| 294 |
+
FC_FONTATIONS: string;
|
| 295 |
+
npm_config_node_gyp: string;
|
| 296 |
+
PATH: string;
|
| 297 |
+
INVOCATION_ID: string;
|
| 298 |
+
APPIMAGE: string;
|
| 299 |
+
NODE: string;
|
| 300 |
+
npm_package_name: string;
|
| 301 |
+
XDG_MENU_PREFIX: string;
|
| 302 |
+
VSCODE_PROCESS_TITLE: string;
|
| 303 |
+
GNOME_SETUP_DISPLAY: string;
|
| 304 |
+
XDG_RUNTIME_DIR: string;
|
| 305 |
+
GDK_BACKEND: string;
|
| 306 |
+
CURSOR_AGENT: string;
|
| 307 |
+
DISPLAY: string;
|
| 308 |
+
LANG: string;
|
| 309 |
+
XDG_CURRENT_DESKTOP: string;
|
| 310 |
+
XMODIFIERS: string;
|
| 311 |
+
XDG_SESSION_DESKTOP: string;
|
| 312 |
+
XAUTHORITY: string;
|
| 313 |
+
LS_COLORS: string;
|
| 314 |
+
CURSOR_TRACE_ID: string;
|
| 315 |
+
npm_lifecycle_script: string;
|
| 316 |
+
SSH_AUTH_SOCK: string;
|
| 317 |
+
GSETTINGS_SCHEMA_DIR: string;
|
| 318 |
+
CONDA_PYTHON_EXE: string;
|
| 319 |
+
SHELL: string;
|
| 320 |
+
ARGV0: string;
|
| 321 |
+
npm_package_version: string;
|
| 322 |
+
npm_lifecycle_event: string;
|
| 323 |
+
QT_ACCESSIBILITY: string;
|
| 324 |
+
GDMSESSION: string;
|
| 325 |
+
LESSCLOSE: string;
|
| 326 |
+
CONDA_DEFAULT_ENV: string;
|
| 327 |
+
GPG_AGENT_INFO: string;
|
| 328 |
+
GJS_DEBUG_OUTPUT: string;
|
| 329 |
+
QT_IM_MODULE: string;
|
| 330 |
+
npm_config_globalconfig: string;
|
| 331 |
+
npm_config_init_module: string;
|
| 332 |
+
PWD: string;
|
| 333 |
+
npm_execpath: string;
|
| 334 |
+
XDG_CONFIG_DIRS: string;
|
| 335 |
+
CONDA_EXE: string;
|
| 336 |
+
VSCODE_CODE_CACHE_PATH: string;
|
| 337 |
+
XDG_DATA_DIRS: string;
|
| 338 |
+
npm_config_global_prefix: string;
|
| 339 |
+
npm_command: string;
|
| 340 |
+
CONDA_PREFIX: string;
|
| 341 |
+
QT_PLUGIN_PATH: string;
|
| 342 |
+
_ZO_DOCTOR: string;
|
| 343 |
+
QT_IM_MODULES: string;
|
| 344 |
+
MEMORY_PRESSURE_WRITE: string;
|
| 345 |
+
VSCODE_PID: string;
|
| 346 |
+
INIT_CWD: string;
|
| 347 |
+
EDITOR: string;
|
| 348 |
+
NODE_ENV: string;
|
| 349 |
+
[key: `PUBLIC_${string}`]: undefined;
|
| 350 |
+
[key: `${string}`]: string | undefined;
|
| 351 |
+
}
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
/**
|
| 355 |
+
* This module provides access to environment variables set _dynamically_ at runtime and that are _publicly_ accessible.
|
| 356 |
+
*
|
| 357 |
+
* | | Runtime | Build time |
|
| 358 |
+
* | ------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
| 359 |
+
* | Private | [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private) | [`$env/static/private`](https://svelte.dev/docs/kit/$env-static-private) |
|
| 360 |
+
* | Public | [`$env/dynamic/public`](https://svelte.dev/docs/kit/$env-dynamic-public) | [`$env/static/public`](https://svelte.dev/docs/kit/$env-static-public) |
|
| 361 |
+
*
|
| 362 |
+
* Dynamic environment variables are defined by the platform you're running on. For example if you're using [`adapter-node`](https://github.com/sveltejs/kit/tree/main/packages/adapter-node) (or running [`vite preview`](https://svelte.dev/docs/kit/cli)), this is equivalent to `process.env`.
|
| 363 |
+
*
|
| 364 |
+
* **_Public_ access:**
|
| 365 |
+
*
|
| 366 |
+
* - This module _can_ be imported into client-side code
|
| 367 |
+
* - **Only** variables that begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) (which defaults to `PUBLIC_`) are included
|
| 368 |
+
*
|
| 369 |
+
* > [!NOTE] In `dev`, `$env/dynamic` includes environment variables from `.env`. In `prod`, this behavior will depend on your adapter.
|
| 370 |
+
*
|
| 371 |
+
* > [!NOTE] To get correct types, environment variables referenced in your code should be declared (for example in an `.env` file), even if they don't have a value until the app is deployed:
|
| 372 |
+
* >
|
| 373 |
+
* > ```env
|
| 374 |
+
* > MY_FEATURE_FLAG=
|
| 375 |
+
* > ```
|
| 376 |
+
* >
|
| 377 |
+
* > You can override `.env` values from the command line like so:
|
| 378 |
+
* >
|
| 379 |
+
* > ```sh
|
| 380 |
+
* > MY_FEATURE_FLAG="enabled" npm run dev
|
| 381 |
+
* > ```
|
| 382 |
+
*
|
| 383 |
+
* For example, given the following runtime environment:
|
| 384 |
+
*
|
| 385 |
+
* ```env
|
| 386 |
+
* ENVIRONMENT=production
|
| 387 |
+
* PUBLIC_BASE_URL=http://example.com
|
| 388 |
+
* ```
|
| 389 |
+
*
|
| 390 |
+
* With the default `publicPrefix` and `privatePrefix`:
|
| 391 |
+
*
|
| 392 |
+
* ```ts
|
| 393 |
+
* import { env } from '$env/dynamic/public';
|
| 394 |
+
* console.log(env.ENVIRONMENT); // => undefined, not public
|
| 395 |
+
* console.log(env.PUBLIC_BASE_URL); // => "http://example.com"
|
| 396 |
+
* ```
|
| 397 |
+
*
|
| 398 |
+
* ```
|
| 399 |
+
*
|
| 400 |
+
* ```
|
| 401 |
+
*/
|
| 402 |
+
declare module '$env/dynamic/public' {
|
| 403 |
+
export const env: {
|
| 404 |
+
[key: `PUBLIC_${string}`]: string | undefined;
|
| 405 |
+
}
|
| 406 |
+
}
|
frontend/.svelte-kit/generated/client-optimized/app.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export { matchers } from './matchers.js';
|
| 2 |
+
|
| 3 |
+
export const nodes = [
|
| 4 |
+
() => import('./nodes/0'),
|
| 5 |
+
() => import('./nodes/1'),
|
| 6 |
+
() => import('./nodes/2'),
|
| 7 |
+
() => import('./nodes/3')
|
| 8 |
+
];
|
| 9 |
+
|
| 10 |
+
export const server_loads = [];
|
| 11 |
+
|
| 12 |
+
export const dictionary = {
|
| 13 |
+
"/": [2],
|
| 14 |
+
"/game": [3]
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
export const hooks = {
|
| 18 |
+
handleError: (({ error }) => { console.error(error) }),
|
| 19 |
+
|
| 20 |
+
reroute: (() => {}),
|
| 21 |
+
transport: {}
|
| 22 |
+
};
|
| 23 |
+
|
| 24 |
+
export const decoders = Object.fromEntries(Object.entries(hooks.transport).map(([k, v]) => [k, v.decode]));
|
| 25 |
+
export const encoders = Object.fromEntries(Object.entries(hooks.transport).map(([k, v]) => [k, v.encode]));
|
| 26 |
+
|
| 27 |
+
export const hash = false;
|
| 28 |
+
|
| 29 |
+
export const decode = (type, value) => decoders[type](value);
|
| 30 |
+
|
| 31 |
+
export { default as root } from '../root.svelte';
|
frontend/.svelte-kit/generated/client-optimized/matchers.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
export const matchers = {};
|
frontend/.svelte-kit/generated/client-optimized/nodes/0.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
export { default as component } from "../../../../src/routes/+layout.svelte";
|
frontend/.svelte-kit/generated/client-optimized/nodes/1.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
export { default as component } from "../../../../node_modules/@sveltejs/kit/src/runtime/components/svelte-4/error.svelte";
|
frontend/.svelte-kit/generated/client-optimized/nodes/2.js
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as universal from "../../../../src/routes/+page.js";
|
| 2 |
+
export { universal };
|
| 3 |
+
export { default as component } from "../../../../src/routes/+page.svelte";
|
frontend/.svelte-kit/generated/client-optimized/nodes/3.js
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as universal from "../../../../src/routes/game/+page.js";
|
| 2 |
+
export { universal };
|
| 3 |
+
export { default as component } from "../../../../src/routes/game/+page.svelte";
|
frontend/.svelte-kit/generated/client/app.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export { matchers } from './matchers.js';
|
| 2 |
+
|
| 3 |
+
export const nodes = [
|
| 4 |
+
() => import('./nodes/0'),
|
| 5 |
+
() => import('./nodes/1'),
|
| 6 |
+
() => import('./nodes/2'),
|
| 7 |
+
() => import('./nodes/3')
|
| 8 |
+
];
|
| 9 |
+
|
| 10 |
+
export const server_loads = [];
|
| 11 |
+
|
| 12 |
+
export const dictionary = {
|
| 13 |
+
"/": [2],
|
| 14 |
+
"/game": [3]
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
export const hooks = {
|
| 18 |
+
handleError: (({ error }) => { console.error(error) }),
|
| 19 |
+
|
| 20 |
+
reroute: (() => {}),
|
| 21 |
+
transport: {}
|
| 22 |
+
};
|
| 23 |
+
|
| 24 |
+
export const decoders = Object.fromEntries(Object.entries(hooks.transport).map(([k, v]) => [k, v.decode]));
|
| 25 |
+
export const encoders = Object.fromEntries(Object.entries(hooks.transport).map(([k, v]) => [k, v.encode]));
|
| 26 |
+
|
| 27 |
+
export const hash = false;
|
| 28 |
+
|
| 29 |
+
export const decode = (type, value) => decoders[type](value);
|
| 30 |
+
|
| 31 |
+
export { default as root } from '../root.svelte';
|
frontend/.svelte-kit/generated/client/matchers.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
export const matchers = {};
|
frontend/.svelte-kit/generated/client/nodes/0.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
export { default as component } from "../../../../src/routes/+layout.svelte";
|
frontend/.svelte-kit/generated/client/nodes/1.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
export { default as component } from "../../../../node_modules/@sveltejs/kit/src/runtime/components/svelte-4/error.svelte";
|