gabraken commited on
Commit
07ed12b
·
0 Parent(s):

First commit

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +9 -0
  2. .env.example +4 -0
  3. .gitattributes +2 -0
  4. .gitignore +1 -0
  5. Dockerfile +30 -0
  6. README.md +214 -0
  7. backend/__pycache__/config.cpython-39.pyc +0 -0
  8. backend/__pycache__/main.cpython-39.pyc +0 -0
  9. backend/config.py +18 -0
  10. backend/game/__init__.py +0 -0
  11. backend/game/__pycache__/__init__.cpython-39.pyc +0 -0
  12. backend/game/__pycache__/buildings.cpython-39.pyc +0 -0
  13. backend/game/__pycache__/commands.cpython-39.pyc +0 -0
  14. backend/game/__pycache__/engine.cpython-39.pyc +0 -0
  15. backend/game/__pycache__/map.cpython-39.pyc +0 -0
  16. backend/game/__pycache__/state.cpython-39.pyc +0 -0
  17. backend/game/__pycache__/tech_tree.cpython-39.pyc +0 -0
  18. backend/game/__pycache__/units.cpython-39.pyc +0 -0
  19. backend/game/buildings.py +109 -0
  20. backend/game/commands.py +78 -0
  21. backend/game/engine.py +743 -0
  22. backend/game/map.py +106 -0
  23. backend/game/state.py +145 -0
  24. backend/game/tech_tree.py +70 -0
  25. backend/game/units.py +131 -0
  26. backend/lobby/__init__.py +0 -0
  27. backend/lobby/__pycache__/__init__.cpython-39.pyc +0 -0
  28. backend/lobby/__pycache__/manager.cpython-39.pyc +0 -0
  29. backend/lobby/manager.py +198 -0
  30. backend/main.py +341 -0
  31. backend/requirements.txt +8 -0
  32. backend/voice/__init__.py +0 -0
  33. backend/voice/__pycache__/__init__.cpython-39.pyc +0 -0
  34. backend/voice/__pycache__/command_parser.cpython-39.pyc +0 -0
  35. backend/voice/__pycache__/stt.cpython-39.pyc +0 -0
  36. backend/voice/__pycache__/tts.cpython-39.pyc +0 -0
  37. backend/voice/command_parser.py +117 -0
  38. backend/voice/stt.py +41 -0
  39. backend/voice/tts.py +49 -0
  40. frontend/.svelte-kit/ambient.d.ts +406 -0
  41. frontend/.svelte-kit/generated/client-optimized/app.js +31 -0
  42. frontend/.svelte-kit/generated/client-optimized/matchers.js +1 -0
  43. frontend/.svelte-kit/generated/client-optimized/nodes/0.js +1 -0
  44. frontend/.svelte-kit/generated/client-optimized/nodes/1.js +1 -0
  45. frontend/.svelte-kit/generated/client-optimized/nodes/2.js +3 -0
  46. frontend/.svelte-kit/generated/client-optimized/nodes/3.js +3 -0
  47. frontend/.svelte-kit/generated/client/app.js +31 -0
  48. frontend/.svelte-kit/generated/client/matchers.js +1 -0
  49. frontend/.svelte-kit/generated/client/nodes/0.js +1 -0
  50. 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";