gabraken commited on
Commit
dd96d2f
·
1 Parent(s): e0e943f

feat: add new units/buildings/map assets, admin routes, and frontend build

Browse files

- Add new sprites (goliath, medic, tank, wraith, armory, barracks, etc.)
- Add MAP_half/quarter LOD variants via generate_map_lod script
- Add compiled_map.json and game_positions.json static assets
- Add cover.jpg and HF Spaces logos (LFS-tracked)
- Add admin compiled-map route and layout
- Add map_compiler.py and safe_name.py modules
- Track *.jpg/*.jpeg in Git LFS; remove frontend/build from .gitignore
- Update game engine, pathfinding, tech tree, bot, buildings, commands, units
- Refresh SvelteKit build and type definitions

Made-with: Cursor

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.example +5 -0
  2. .gitattributes +2 -0
  3. .gitignore +1 -2
  4. TODO.md +7 -4
  5. backend/config.py +5 -0
  6. backend/game/bot.py +1 -1
  7. backend/game/buildings.py +19 -8
  8. backend/game/commands.py +7 -6
  9. backend/game/engine.py +469 -147
  10. backend/game/map.py +271 -40
  11. backend/game/map_compiler.py +157 -0
  12. backend/game/pathfinding.py +308 -46
  13. backend/game/state.py +35 -18
  14. backend/game/tech_tree.py +1 -1
  15. backend/game/units.py +5 -5
  16. backend/lobby/manager.py +11 -0
  17. backend/lobby/safe_name.py +75 -0
  18. backend/main.py +227 -55
  19. backend/requirements.txt +2 -0
  20. backend/scripts/generate_map_lod.py +55 -0
  21. backend/scripts/generate_sprites.py +276 -58
  22. backend/scripts/upscale_cover.py +124 -0
  23. backend/scripts/upscale_map.py +103 -0
  24. backend/static/MAP.png +2 -2
  25. backend/static/MAP_half.png +3 -0
  26. backend/static/MAP_quarter.png +3 -0
  27. backend/static/compiled_map.json +4211 -0
  28. backend/static/game_positions.json +362 -0
  29. backend/static/sprites/buildings/armory.png +3 -0
  30. backend/static/sprites/buildings/barracks.png +3 -0
  31. backend/static/sprites/buildings/command_center.png +2 -2
  32. backend/static/sprites/buildings/engineering_bay.png +3 -0
  33. backend/static/sprites/buildings/factory.png +3 -0
  34. backend/static/sprites/buildings/refinery.png +3 -0
  35. backend/static/sprites/buildings/starport.png +3 -0
  36. backend/static/sprites/buildings/supply_depot.png +3 -0
  37. backend/static/sprites/icons/gas.png +3 -0
  38. backend/static/sprites/icons/mineral.png +3 -0
  39. backend/static/sprites/icons/supply.png +3 -0
  40. backend/static/sprites/resources/geyser.png +3 -0
  41. backend/static/sprites/resources/mineral.png +3 -0
  42. backend/static/sprites/units/goliath.png +3 -0
  43. backend/static/sprites/units/marine.png +2 -2
  44. backend/static/sprites/units/medic.png +3 -0
  45. backend/static/sprites/units/scv.png +2 -2
  46. backend/static/sprites/units/tank.png +3 -0
  47. backend/static/sprites/units/wraith.png +3 -0
  48. backend/static/walkable.json +34 -34
  49. backend/voice/command_parser.py +78 -12
  50. frontend/.svelte-kit/non-ambient.d.ts +4 -3
.env.example CHANGED
@@ -1,2 +1,7 @@
1
  MISTRAL_API_KEY=your_mistral_api_key_here
2
  SECRET_KEY=change-me-in-production
 
 
 
 
 
 
1
  MISTRAL_API_KEY=your_mistral_api_key_here
2
  SECRET_KEY=change-me-in-production
3
+
4
+ # Optionnel : génération sprites via Vertex AI Imagen (ex. project GCP temporaire)
5
+ # Si défini, le script generate_sprites utilise Imagen au lieu de Mistral. À retirer en prod.
6
+ # GCP_PROJECT_ID=mtgbinder
7
+ # GCP_LOCATION=us-central1
.gitattributes CHANGED
@@ -1,2 +1,4 @@
1
  *.png filter=lfs diff=lfs merge=lfs -text
2
  *.PNG filter=lfs diff=lfs merge=lfs -text
 
 
 
1
  *.png filter=lfs diff=lfs merge=lfs -text
2
  *.PNG filter=lfs diff=lfs merge=lfs -text
3
+ *.jpeg filter=lfs diff=lfs merge=lfs -text
4
+ *.jpg filter=lfs diff=lfs merge=lfs -text
.gitignore CHANGED
@@ -6,5 +6,4 @@ __pycache__/
6
  **/*.pyc
7
  **/__pycache__/
8
  frontend/.svelte-kit/generated/
9
- frontend/.svelte-kit/output/
10
- frontend/build/
 
6
  **/*.pyc
7
  **/__pycache__/
8
  frontend/.svelte-kit/generated/
9
+ frontend/.svelte-kit/output/
 
TODO.md CHANGED
@@ -1,4 +1,7 @@
1
- AJouter detection safename
2
- Ajouter observing des matches existants si trop de matches en cours.
3
- Refaire la landing page pour etre plus précis engageant design plus cool etc... expliquer dans la peau d'un général
4
- Ajouter PVs unités
 
 
 
 
1
+ + Refaire la landing page pour etre plus précis engageant design plus cool etc... expliquer dans la peau d'un général
2
+ + AJouter detection safename
3
+ + Ajouter observing des matches existants si trop de matches en cours.
4
+ Ajouter PVs unités
5
+ utiliser poisiotns de départ et placement des minéraux
6
+ pathfinding
7
+ shoot animation
backend/config.py CHANGED
@@ -17,6 +17,11 @@ VOXTRAL_REALTIME_MODEL: str = "voxtral-mini-transcribe-realtime-2602" # streami
17
  # Mistral LLM model for command parsing
18
  MISTRAL_CHAT_MODEL: str = "mistral-large-latest"
19
 
 
 
 
 
 
20
  # Game constants
21
  TICK_INTERVAL: float = 0.25 # seconds per game tick
22
  TICKS_PER_SECOND: int = 4
 
17
  # Mistral LLM model for command parsing
18
  MISTRAL_CHAT_MODEL: str = "mistral-large-latest"
19
 
20
+ # GCP Vertex AI Imagen (optionnel, pour génération sprites si Mistral restreint)
21
+ # Ex: project mtgbinder — à retirer en prod
22
+ GCP_PROJECT_ID: str = os.getenv("GCP_PROJECT_ID", "")
23
+ GCP_LOCATION: str = os.getenv("GCP_LOCATION", "us-central1")
24
+
25
  # Game constants
26
  TICK_INTERVAL: float = 0.25 # seconds per game tick
27
  TICKS_PER_SECOND: int = 4
backend/game/bot.py CHANGED
@@ -31,7 +31,7 @@ BOT_TICK_INTERVAL = 8
31
 
32
 
33
  def _cmd(*actions: GameAction) -> ParsedCommand:
34
- return ParsedCommand(actions=list(actions), feedback="")
35
 
36
 
37
  class BotPlayer:
 
31
 
32
 
33
  def _cmd(*actions: GameAction) -> ParsedCommand:
34
+ return ParsedCommand(actions=list(actions), feedback_template="")
35
 
36
 
37
  class BotPlayer:
backend/game/buildings.py CHANGED
@@ -33,6 +33,17 @@ class BuildingDef(BaseModel):
33
  width: int = 2
34
  height: int = 2
35
  supply_provided: int = 0
 
 
 
 
 
 
 
 
 
 
 
36
 
37
 
38
  BUILDING_DEFS: dict[BuildingType, BuildingDef] = {
@@ -54,7 +65,7 @@ BUILDING_DEFS: dict[BuildingType, BuildingDef] = {
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,
@@ -81,8 +92,8 @@ class Building(BaseModel):
81
  id: str = Field(default_factory=lambda: str(uuid.uuid4())[:8])
82
  building_type: BuildingType
83
  owner: str
84
- x: int
85
- y: int
86
  hp: float
87
  max_hp: int
88
  status: BuildingStatus = BuildingStatus.CONSTRUCTING
@@ -93,20 +104,20 @@ class Building(BaseModel):
93
  rally_y: Optional[float] = None
94
 
95
  @classmethod
96
- def create(cls, bt: BuildingType, owner: str, x: int, y: int) -> "Building":
97
  defn = BUILDING_DEFS[bt]
98
  return cls(
99
  building_type=bt,
100
  owner=owner,
101
  x=x,
102
  y=y,
103
- hp=float(defn.max_hp),
104
  max_hp=defn.max_hp,
105
  construction_ticks_remaining=defn.build_time_ticks,
106
- construction_max_ticks=defn.build_time_ticks,
107
  )
108
 
109
  def spawn_point(self) -> tuple[float, float]:
110
- """Position where units appear when produced."""
111
  defn = BUILDING_DEFS[self.building_type]
112
- return (float(self.x) + defn.width / 2, float(self.y) + defn.height + 1)
 
33
  width: int = 2
34
  height: int = 2
35
  supply_provided: int = 0
36
+ # Collision box is shrunk by this amount on each side vs. the visual box,
37
+ # so units can squeeze between buildings that have a small visual gap.
38
+ collision_shrink: float = 0.4
39
+
40
+ def col_hw(self) -> float:
41
+ """Half collision width."""
42
+ return max(0.5, self.width / 2 - self.collision_shrink)
43
+
44
+ def col_hh(self) -> float:
45
+ """Half collision height."""
46
+ return max(0.5, self.height / 2 - self.collision_shrink)
47
 
48
 
49
  BUILDING_DEFS: dict[BuildingType, BuildingDef] = {
 
65
  ),
66
  BuildingType.REFINERY: BuildingDef(
67
  max_hp=500, mineral_cost=100, gas_cost=0, build_time_ticks=40,
68
+ width=2, height=2, collision_shrink=0.2,
69
  ),
70
  BuildingType.FACTORY: BuildingDef(
71
  max_hp=1250, mineral_cost=200, gas_cost=100, build_time_ticks=80,
 
92
  id: str = Field(default_factory=lambda: str(uuid.uuid4())[:8])
93
  building_type: BuildingType
94
  owner: str
95
+ x: float # center x
96
+ y: float # center y
97
  hp: float
98
  max_hp: int
99
  status: BuildingStatus = BuildingStatus.CONSTRUCTING
 
104
  rally_y: Optional[float] = None
105
 
106
  @classmethod
107
+ def create(cls, bt: BuildingType, owner: str, x: float, y: float) -> "Building":
108
  defn = BUILDING_DEFS[bt]
109
  return cls(
110
  building_type=bt,
111
  owner=owner,
112
  x=x,
113
  y=y,
114
+ hp=float(defn.max_hp) * 0.15,
115
  max_hp=defn.max_hp,
116
  construction_ticks_remaining=defn.build_time_ticks,
117
+ construction_max_ticks=defn.build_time_ticks,
118
  )
119
 
120
  def spawn_point(self) -> tuple[float, float]:
121
+ """Position where units appear when produced (below bottom edge, horizontally centered)."""
122
  defn = BUILDING_DEFS[self.building_type]
123
+ return (self.x, self.y + defn.height / 2 + 1.0)
backend/game/commands.py CHANGED
@@ -6,9 +6,9 @@ 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):
@@ -68,19 +68,20 @@ class GameAction(BaseModel):
68
 
69
 
70
  class ParsedCommand(BaseModel):
71
- """Full Mistral response: one or more actions + French feedback text."""
72
  actions: list[GameAction]
73
- feedback: str
 
74
 
75
 
76
  class ActionResult(BaseModel):
77
  action_type: str
78
  success: bool
79
- message: str
80
  sound_events: list[dict] = [] # e.g. [{"kind": "move_ack", "unit_type": "marine"}]
81
  unit_ids: list[str] = [] # for query_units: list of unit IDs matching the filter
82
 
83
 
84
  class CommandResult(BaseModel):
85
  results: list[ActionResult]
86
- feedback_override: Optional[str] = None # replaces Mistral feedback on hard error
 
6
  """
7
 
8
  from enum import Enum
9
+ from typing import Any, Optional
10
 
11
+ from pydantic import BaseModel, Field
12
 
13
 
14
  class ActionType(str, Enum):
 
68
 
69
 
70
  class ParsedCommand(BaseModel):
71
+ """Full Mistral response: one or more actions + feedback template to fill with game data."""
72
  actions: list[GameAction]
73
+ feedback_template: str # sentence in command language with placeholders {n}, {zone}, {building}, etc.
74
+ language: str = "fr" # ISO code of the user's command language (fr, en, ...)
75
 
76
 
77
  class ActionResult(BaseModel):
78
  action_type: str
79
  success: bool
80
+ data: dict[str, Any] = Field(default_factory=dict) # template variables: n, zone, building, count, summary, ...
81
  sound_events: list[dict] = [] # e.g. [{"kind": "move_ack", "unit_type": "marine"}]
82
  unit_ids: list[str] = [] # for query_units: list of unit IDs matching the filter
83
 
84
 
85
  class CommandResult(BaseModel):
86
  results: list[ActionResult]
87
+ feedback_override: Optional[str] = None # error key for API to generate message (e.g. "game_not_in_progress")
backend/game/engine.py CHANGED
@@ -22,7 +22,7 @@ from .bot import BOT_TICK_INTERVAL, BotPlayer
22
  from .buildings import Building, BuildingDef, BuildingStatus, BuildingType, BUILDING_DEFS
23
  from .commands import ActionResult, ActionType, CommandResult, GameAction, ParsedCommand
24
  from .map import MAP_HEIGHT, MAP_WIDTH, ResourceType
25
- from .pathfinding import find_path, is_walkable, snap_to_walkable
26
  from .state import GamePhase, GameState, PlayerState
27
  from .tech_tree import can_build, can_train, get_producer, missing_for_build, missing_for_train
28
  from .units import Unit, UnitDef, UnitStatus, UnitType, UNIT_DEFS
@@ -47,6 +47,7 @@ class GameEngine:
47
  self._task: Optional[asyncio.Task] = None # type: ignore[type-arg]
48
  self.bot: Optional[BotPlayer] = None
49
  self._sound_events: list[dict] = [] # fire/death per tick, sent in game_update
 
50
 
51
  # ------------------------------------------------------------------
52
  # Lifecycle
@@ -68,9 +69,10 @@ class GameEngine:
68
  # ------------------------------------------------------------------
69
 
70
  def apply_command(self, player_id: str, parsed: ParsedCommand) -> CommandResult:
 
71
  player = self.state.players.get(player_id)
72
  if not player or self.state.phase != GamePhase.PLAYING:
73
- return CommandResult(results=[], feedback_override="La partie n'est pas en cours.")
74
 
75
  results: list[ActionResult] = []
76
  last_query_unit_ids: Optional[list[str]] = None
@@ -137,20 +139,44 @@ class GameEngine:
137
  for building in player.buildings.values():
138
  if building.status != BuildingStatus.CONSTRUCTING:
139
  continue
 
 
 
 
 
 
 
 
140
  building.construction_ticks_remaining -= 1
 
 
 
141
  if building.construction_ticks_remaining <= 0:
142
  building.status = BuildingStatus.ACTIVE
143
  building.construction_ticks_remaining = 0
144
- # Mark the assigned SCV as idle
145
- for unit in player.units.values():
146
- if unit.building_target_id == building.id:
147
- unit.status = UnitStatus.IDLE
148
- unit.building_target_id = None
149
- unit.target_x = unit.target_y = None
 
 
 
 
 
 
 
 
 
 
 
150
 
151
  def _tick_production(self, player: PlayerState) -> None:
152
  """Tick building production queues and spawn units."""
153
  for building in player.buildings.values():
 
 
154
  if not building.production_queue:
155
  building.status = BuildingStatus.ACTIVE
156
  continue
@@ -166,13 +192,19 @@ class GameEngine:
166
  self._spawn_unit(player, building, UnitType(item.unit_type))
167
 
168
  def _spawn_unit(self, player: PlayerState, building: Building, ut: UnitType) -> None:
169
- sx, sy = building.spawn_point()
 
 
 
 
 
 
170
  tx = building.rally_x if building.rally_x is not None else sx
171
  ty = building.rally_y if building.rally_y is not None else sy
172
  unit = Unit.create(ut, player.player_id, sx, sy)
173
  if tx != sx or ty != sy:
174
  unit.status = UnitStatus.MOVING
175
- self._set_unit_destination(unit, tx, ty, is_flying=UNIT_DEFS[ut].is_flying)
176
  player.units[unit.id] = unit
177
 
178
  def _tick_mining(self, player: PlayerState) -> None:
@@ -193,36 +225,47 @@ class GameEngine:
193
  unit.harvest_carry = False
194
  return
195
 
196
- cc_x = float(cc.x) + 2.0
197
- cc_y = float(cc.y) + 1.5
198
- arrive_dist = 1.5
199
 
200
  for unit in player.units.values():
201
  if unit.status == UnitStatus.MINING_MINERALS:
202
  resource = self.state.game_map.get_resource(unit.assigned_resource_id or "")
203
  if not resource or resource.is_depleted:
 
 
 
204
  unit.assigned_resource_id = None
205
- unit.status = UnitStatus.IDLE
206
- unit.target_x = unit.target_y = None
207
- unit.path_waypoints = []
208
- unit.harvest_carry = False
 
 
 
 
 
 
 
209
  continue
210
 
211
  rx, ry = float(resource.x), float(resource.y)
212
 
213
  if not unit.harvest_carry:
214
- if unit.target_x != rx or unit.target_y != ry:
215
  self._set_unit_destination(unit, rx, ry, is_flying=False)
216
- if unit.dist_to(rx, ry) <= arrive_dist:
217
  gathered = min(MINERAL_PER_HARVEST, resource.amount)
218
  resource.amount -= gathered
219
  unit.harvest_carry = True
220
  unit.harvest_amount = gathered
221
- self._set_unit_destination(unit, cc_x, cc_y, is_flying=False)
 
222
  else:
223
- if unit.target_x != cc_x or unit.target_y != cc_y:
224
- self._set_unit_destination(unit, cc_x, cc_y, is_flying=False)
225
- if unit.dist_to(cc_x, cc_y) <= arrive_dist:
 
226
  player.minerals += unit.harvest_amount
227
  unit.harvest_carry = False
228
  unit.harvest_amount = 0
@@ -241,16 +284,18 @@ class GameEngine:
241
  rx, ry = float(resource.x), float(resource.y)
242
 
243
  if not unit.harvest_carry:
244
- if unit.target_x != rx or unit.target_y != ry:
245
  self._set_unit_destination(unit, rx, ry, is_flying=False)
246
- if unit.dist_to(rx, ry) <= arrive_dist:
247
  unit.harvest_carry = True
248
  unit.harvest_amount = GAS_PER_HARVEST
249
- self._set_unit_destination(unit, cc_x, cc_y, is_flying=False)
 
250
  else:
251
- if unit.target_x != cc_x or unit.target_y != cc_y:
252
- self._set_unit_destination(unit, cc_x, cc_y, is_flying=False)
253
- if unit.dist_to(cc_x, cc_y) <= arrive_dist:
 
254
  player.gas += unit.harvest_amount
255
  unit.harvest_carry = False
256
  unit.harvest_amount = 0
@@ -280,6 +325,10 @@ class GameEngine:
280
  # Building SCVs stay put; mining SCVs move but skip combat
281
  if unit.status == UnitStatus.BUILDING:
282
  continue
 
 
 
 
283
  if unit.status in (UnitStatus.MINING_MINERALS, UnitStatus.MINING_GAS):
284
  if unit.target_x is not None and unit.target_y is not None:
285
  self._move_toward(unit, defn, unit.target_x, unit.target_y)
@@ -325,6 +374,22 @@ class GameEngine:
325
  if unit.attack_target_id or unit.attack_target_building_id:
326
  self._combat_attack(unit, defn, all_units, player, enemy, sieged=False)
327
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
  def _set_unit_destination(
329
  self, unit: Unit, tx: float, ty: float, *, is_flying: bool
330
  ) -> None:
@@ -334,40 +399,157 @@ class GameEngine:
334
  unit.path_waypoints = []
335
  if is_flying:
336
  return
337
- path = find_path(unit.x, unit.y, tx, ty)
 
 
338
  if path is None:
339
- snapped = snap_to_walkable(tx, ty)
340
- unit.target_x, unit.target_y = snapped[0], snapped[1]
341
- return
 
 
 
 
 
342
  if not path:
343
  return
344
  unit.target_x, unit.target_y = path[0][0], path[0][1]
345
  unit.path_waypoints = [[p[0], p[1]] for p in path[1:]]
346
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
347
  def _move_toward(self, unit: Unit, defn: UnitDef, tx: float, ty: float) -> None:
348
- dx = tx - unit.x
349
- dy = ty - unit.y
350
- dist = math.sqrt(dx * dx + dy * dy)
351
  step = defn.move_speed * TICK_INTERVAL
352
- if dist <= step:
353
- unit.x = tx
354
- unit.y = ty
355
- if unit.path_waypoints:
356
- next_wp = unit.path_waypoints.pop(0)
357
- unit.target_x, unit.target_y = next_wp[0], next_wp[1]
358
- return
359
- if unit.status == UnitStatus.MOVING:
360
- unit.status = UnitStatus.IDLE
361
- unit.target_x = unit.target_y = None
362
- elif unit.status == UnitStatus.ATTACKING:
363
- unit.status = UnitStatus.IDLE
364
- unit.target_x = unit.target_y = None
365
- elif unit.status == UnitStatus.PATROLLING:
366
- unit.target_x, unit.patrol_x = unit.patrol_x, unit.target_x
367
- unit.target_y, unit.patrol_y = unit.patrol_y, unit.target_y
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
368
  else:
369
- unit.x += (dx / dist) * step
370
- unit.y += (dy / dist) * step
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
371
 
372
  def _combat_attack(
373
  self,
@@ -517,18 +699,38 @@ class GameEngine:
517
  # ------------------------------------------------------------------
518
 
519
  def _building_center(self, b: Building) -> tuple[float, float]:
520
- defn = BUILDING_DEFS[b.building_type]
521
- return (float(b.x) + defn.width / 2, float(b.y) + defn.height / 2)
522
 
523
  def _dist_unit_to_building(self, unit: Unit, building: Building) -> float:
524
- """Distance from unit center to nearest point on building footprint (carré le plus proche)."""
525
  defn = BUILDING_DEFS[building.building_type]
526
- x0, x1 = float(building.x), float(building.x) + defn.width
527
- y0, y1 = float(building.y), float(building.y) + defn.height
 
528
  px = max(x0, min(x1, unit.x))
529
  py = max(y0, min(y1, unit.y))
530
  return math.sqrt((unit.x - px) ** 2 + (unit.y - py) ** 2)
531
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
532
  def _nearest_enemy_in_range(
533
  self,
534
  unit: Unit,
@@ -557,17 +759,18 @@ class GameEngine:
557
  return best[1] if best else None
558
 
559
  def _resolve_zone(self, player_id: str, zone: str) -> tuple[float, float]:
 
560
  player = self.state.players[player_id]
561
  enemy = self.state.enemy_of(player_id)
562
  cc = player.command_center()
563
- base_x = float(cc.x) + 2 if cc else float(MAP_WIDTH) / 2
564
- base_y = float(cc.y) + 2 if cc else float(MAP_HEIGHT) / 2
565
 
566
  if zone == "my_base":
567
  return (base_x, base_y)
568
  if zone == "enemy_base" and enemy:
569
  ecc = enemy.command_center()
570
- return (float(ecc.x) + 2, float(ecc.y) + 2) if ecc else (MAP_WIDTH - 5, MAP_HEIGHT - 5)
571
  if zone == "center":
572
  return (MAP_WIDTH / 2, MAP_HEIGHT / 2)
573
  if zone == "top_left":
@@ -587,6 +790,29 @@ class GameEngine:
587
  avg_x = sum(u.x for u in military) / len(military)
588
  avg_y = sum(u.y for u in military) / len(military)
589
  return (avg_x, avg_y)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
590
  # Fallback: enemy base
591
  if enemy:
592
  ecc = enemy.command_center()
@@ -635,38 +861,71 @@ class GameEngine:
635
  pass
636
  return [u.id for u in units]
637
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
638
  def _find_build_position(
639
- self, player: PlayerState, bt: BuildingType
640
- ) -> Optional[tuple[int, int]]:
641
- cc = player.command_center()
642
- if not cc:
643
- return None
644
- origin_x, origin_y = cc.x, cc.y
645
  defn = BUILDING_DEFS[bt]
 
646
 
647
- for radius in range(3, 18):
648
  for dx in range(-radius, radius + 1):
649
  for dy in range(-radius, radius + 1):
650
- x, y = origin_x + dx, origin_y + dy
651
- if self._can_place(x, y, defn):
652
- return (x, y)
 
 
 
 
 
 
 
653
  return None
654
 
655
- def _can_place(self, x: int, y: int, defn: BuildingDef) -> bool:
656
- if x < 0 or y < 0 or x + defn.width > MAP_WIDTH or y + defn.height > MAP_HEIGHT:
 
657
  return False
658
- # Check overlap with all buildings
659
  for player in self.state.players.values():
660
  for b in player.buildings.values():
661
  if b.status == BuildingStatus.DESTROYED:
662
  continue
663
  bd = BUILDING_DEFS[b.building_type]
664
- if x < b.x + bd.width and x + defn.width > b.x \
665
- and y < b.y + bd.height and y + defn.height > b.y:
666
  return False
667
  # Check overlap with resources
668
  for res in self.state.game_map.resources:
669
- if x <= res.x < x + defn.width and y <= res.y < y + defn.height:
670
  return False
671
  return True
672
 
@@ -705,30 +964,37 @@ class GameEngine:
705
  return self._cmd_query_units(player, action)
706
  if t == ActionType.ASSIGN_TO_GROUP:
707
  return self._cmd_assign_to_group(player, action)
708
- return ActionResult(action_type=t, success=False, message="Action inconnue.")
 
 
709
  except Exception as exc:
710
  log.exception("Error applying action %s", action.type)
711
- return ActionResult(action_type=str(action.type), success=False, message=str(exc))
 
 
712
 
713
  def _cmd_build(self, player: PlayerState, action: GameAction) -> ActionResult:
714
  raw = action.building_type
715
  if not raw:
716
- return ActionResult(action_type="build", success=False, message="Type de bâtiment manquant.")
717
  try:
718
  bt = BuildingType(raw)
719
  except ValueError:
720
- return ActionResult(action_type="build", success=False, message=f"Bâtiment inconnu: {raw}.")
721
 
722
  if not can_build(bt, player):
723
  missing = missing_for_build(bt, player)
724
  names = ", ".join(m.value for m in missing)
725
- return ActionResult(action_type="build", success=False,
726
- message=f"Prérequis manquants: {names}.")
 
727
 
728
  defn = BUILDING_DEFS[bt]
729
  if player.minerals < defn.mineral_cost or player.gas < defn.gas_cost:
730
- return ActionResult(action_type="build", success=False,
731
- message=f"Ressources insuffisantes ({defn.mineral_cost}m/{defn.gas_cost}g requis).")
 
 
732
 
733
  # Clamp count to what resources and SCVs allow
734
  count = max(1, min(action.count, 5))
@@ -745,27 +1011,31 @@ class GameEngine:
745
  )
746
  count = min(count, len(available_scvs))
747
  if count == 0:
748
- return ActionResult(action_type="build", success=False, message="Aucun SCV disponible.")
749
 
750
  cc = player.command_center()
751
  cx, cy = (float(cc.x), float(cc.y)) if cc else (0.0, 0.0)
752
 
753
  built = 0
754
  for i in range(count):
755
- # Find position for this building
 
 
756
  if bt == BuildingType.REFINERY:
757
  geyser = self.state.game_map.nearest_geyser_without_refinery(cx, cy)
758
  if not geyser:
759
  break
760
- pos: tuple[int, int] = (geyser.x, geyser.y)
 
 
 
 
761
  geyser.has_refinery = True
762
  else:
763
- pos_opt = self._find_build_position(player, bt)
764
  if not pos_opt:
765
  break
766
- pos = pos_opt
767
-
768
- scv = available_scvs[i]
769
  # Unassign from resource if mining
770
  if scv.assigned_resource_id:
771
  res = self.state.game_map.get_resource(scv.assigned_resource_id)
@@ -778,62 +1048,80 @@ class GameEngine:
778
  player.minerals -= defn.mineral_cost
779
  player.gas -= defn.gas_cost
780
 
781
- building = Building.create(bt, player.player_id, pos[0], pos[1])
782
  player.buildings[building.id] = building
783
 
784
- scv.status = UnitStatus.BUILDING
785
  scv.building_target_id = building.id
786
- scv.target_x = float(pos[0])
787
- scv.target_y = float(pos[1])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
788
  built += 1
789
 
790
  if built == 0:
791
- return ActionResult(action_type="build", success=False,
792
- message="Impossible de trouver un emplacement.")
793
- return ActionResult(action_type="build", success=True,
794
- message=f"{built} {bt.value}(s) en construction.")
 
795
 
796
  def _cmd_train(self, player: PlayerState, action: GameAction) -> ActionResult:
797
  raw = action.unit_type
798
  if not raw:
799
- return ActionResult(action_type="train", success=False, message="Type d'unité manquant.")
800
  try:
801
  ut = UnitType(raw)
802
  except ValueError:
803
- return ActionResult(action_type="train", success=False, message=f"Unité inconnue: {raw}.")
804
 
805
  if not can_train(ut, player):
806
  missing = missing_for_train(ut, player)
807
  names = ", ".join(m.value for m in missing)
808
- return ActionResult(action_type="train", success=False,
809
- message=f"Prérequis manquants: {names}.")
 
810
 
811
  defn = UNIT_DEFS[ut]
812
  producer_type = get_producer(ut)
813
  producers = player.active_buildings_of(producer_type)
814
  if not producers:
815
- return ActionResult(action_type="train", success=False,
816
- message=f"Aucun {producer_type.value} actif.")
 
 
817
 
818
  count = max(1, min(action.count, 20))
819
  num_producers = len(producers)
820
 
821
- # Resources are checked for the full requested count
822
  total_minerals = defn.mineral_cost * count
823
  total_gas = defn.gas_cost * count
824
  if player.minerals < total_minerals or player.gas < total_gas:
825
- return ActionResult(action_type="train", success=False,
826
- message="Ressources insuffisantes.")
 
827
 
828
- # Supply check: account for actual units + already-queued units + new batch
829
  queued_supply = sum(
830
  UNIT_DEFS[UnitType(item.unit_type)].supply_cost
831
  for b in player.buildings.values()
832
  for item in b.production_queue
833
  )
834
  if player.supply_used + queued_supply + defn.supply_cost * count > player.supply_max:
835
- return ActionResult(action_type="train", success=False,
836
- message="Supply insuffisant.")
 
837
 
838
  from .buildings import ProductionItem # local import to avoid cycle
839
  for i in range(count):
@@ -849,13 +1137,15 @@ class GameEngine:
849
  player.minerals -= total_minerals
850
  player.gas -= total_gas
851
 
852
- return ActionResult(action_type="train", success=True,
853
- message=f"{count} {ut.value}(s) en production.")
 
 
854
 
855
  def _cmd_move(self, player: PlayerState, action: GameAction) -> ActionResult:
856
  units = self._resolve_selector(player, action.unit_selector or "all_military")
857
  if not units:
858
- return ActionResult(action_type="move", success=False, message="Aucune unité sélectionnée.")
859
  tx, ty = self._resolve_zone(player.player_id, action.target_zone or "center")
860
  for unit in units:
861
  if unit.is_sieged:
@@ -865,14 +1155,16 @@ class GameEngine:
865
  unit.attack_target_building_id = None
866
  self._set_unit_destination(unit, tx, ty, is_flying=UNIT_DEFS[unit.unit_type].is_flying)
867
  move_ack = [{"kind": "move_ack", "unit_type": units[0].unit_type.value}]
868
- return ActionResult(action_type="move", success=True,
869
- message=f"{len(units)} unité(s) en mouvement vers {action.target_zone}.",
870
- sound_events=move_ack)
 
 
871
 
872
  def _cmd_attack(self, player: PlayerState, action: GameAction) -> ActionResult:
873
  units = self._resolve_selector(player, action.unit_selector or "all_military")
874
  if not units:
875
- return ActionResult(action_type="attack", success=False, message="Aucune unité sélectionnée.")
876
  tx, ty = self._resolve_zone(player.player_id, action.target_zone or "enemy_base")
877
  for unit in units:
878
  if unit.is_sieged:
@@ -882,34 +1174,40 @@ class GameEngine:
882
  unit.attack_target_building_id = None
883
  self._set_unit_destination(unit, tx, ty, is_flying=UNIT_DEFS[unit.unit_type].is_flying)
884
  move_ack = [{"kind": "move_ack", "unit_type": units[0].unit_type.value}]
885
- return ActionResult(action_type="attack", success=True,
886
- message=f"{len(units)} unité(s) envoyées à l'attaque vers {action.target_zone}.",
887
- sound_events=move_ack)
 
 
888
 
889
  def _cmd_siege(self, player: PlayerState, action: GameAction, siege: bool) -> ActionResult:
890
  tanks = self._resolve_selector(player, action.unit_selector or "all_tanks")
891
  tanks = [u for u in tanks if u.unit_type == UnitType.TANK]
892
  if not tanks:
893
- return ActionResult(action_type="siege", success=False, message="Aucun tank disponible.")
894
  for tank in tanks:
895
  tank.is_sieged = siege
896
  tank.status = UnitStatus.SIEGED if siege else UnitStatus.IDLE
897
  if siege:
898
  tank.target_x = tank.target_y = None
899
- mode = "siège" if siege else "mobile"
900
- return ActionResult(action_type="siege", success=True,
901
- message=f"{len(tanks)} tank(s) en mode {mode}.")
 
 
902
 
903
  def _cmd_cloak(self, player: PlayerState, action: GameAction, cloak: bool) -> ActionResult:
904
  wraiths = self._resolve_selector(player, action.unit_selector or "all_wraiths")
905
  wraiths = [u for u in wraiths if u.unit_type == UnitType.WRAITH]
906
  if not wraiths:
907
- return ActionResult(action_type="cloak", success=False, message="Aucun wraith disponible.")
908
  for wraith in wraiths:
909
  wraith.is_cloaked = cloak
910
- state = "activé" if cloak else "désactivé"
911
- return ActionResult(action_type="cloak", success=True,
912
- message=f"Camouflage {state} sur {len(wraiths)} wraith(s).")
 
 
913
 
914
  def _cmd_gather(self, player: PlayerState, action: GameAction) -> ActionResult:
915
  resource_type = (action.resource_type or "minerals").lower()
@@ -919,7 +1217,7 @@ class GameEngine:
919
  scvs = self._resolve_selector(player, action.unit_selector or "idle_scv")
920
  scvs = [u for u in scvs if u.unit_type == UnitType.SCV]
921
  if not scvs:
922
- return ActionResult(action_type="gather", success=False, message="Aucun SCV disponible.")
923
 
924
  assigned = 0
925
  if resource_type == "gas":
@@ -940,8 +1238,23 @@ class GameEngine:
940
  geyser.assigned_scv_ids.append(scv.id)
941
  assigned += 1
942
  else:
 
 
 
 
 
 
 
 
 
943
  for scv in scvs:
944
- patch = self.state.game_map.nearest_mineral(cx, cy)
 
 
 
 
 
 
945
  if not patch:
946
  break
947
  if scv.assigned_resource_id:
@@ -955,12 +1268,17 @@ class GameEngine:
955
  self._set_unit_destination(scv, float(patch.x), float(patch.y), is_flying=False)
956
  patch.assigned_scv_ids.append(scv.id)
957
  assigned += 1
 
 
958
 
959
  if assigned == 0:
960
- return ActionResult(action_type="gather", success=False,
961
- message="Aucune ressource disponible ou aucun SCV libre.")
962
- return ActionResult(action_type="gather", success=True,
963
- message=f"{assigned} SCV(s) envoyés collecter {resource_type}.")
 
 
 
964
 
965
  def _cmd_stop(self, player: PlayerState, action: GameAction) -> ActionResult:
966
  units = self._resolve_selector(player, action.unit_selector or "all_military")
@@ -970,13 +1288,12 @@ class GameEngine:
970
  unit.path_waypoints = []
971
  unit.attack_target_id = None
972
  unit.attack_target_building_id = None
973
- return ActionResult(action_type="stop", success=True,
974
- message=f"{len(units)} unité(s) stoppées.")
975
 
976
  def _cmd_patrol(self, player: PlayerState, action: GameAction) -> ActionResult:
977
  units = self._resolve_selector(player, action.unit_selector or "all_military")
978
  if not units:
979
- return ActionResult(action_type="patrol", success=False, message="Aucune unité sélectionnée.")
980
  tx, ty = self._resolve_zone(player.player_id, action.target_zone or "center")
981
  for unit in units:
982
  if unit.is_sieged:
@@ -986,12 +1303,17 @@ class GameEngine:
986
  unit.status = UnitStatus.PATROLLING
987
  self._set_unit_destination(unit, tx, ty, is_flying=UNIT_DEFS[unit.unit_type].is_flying)
988
  move_ack = [{"kind": "move_ack", "unit_type": units[0].unit_type.value}]
989
- return ActionResult(action_type="patrol", success=True,
990
- message=f"{len(units)} unité(s) en patrouille vers {action.target_zone}.",
991
- sound_events=move_ack)
 
 
992
 
993
  def _cmd_query(self, player: PlayerState, action: GameAction) -> ActionResult:
994
- return ActionResult(action_type="query", success=True, message=player.summary())
 
 
 
995
 
996
  def _cmd_query_units(self, player: PlayerState, action: GameAction) -> ActionResult:
997
  """Query units by zone and/or type; return their IDs in result.unit_ids."""
@@ -1001,7 +1323,7 @@ class GameEngine:
1001
  return ActionResult(
1002
  action_type="query_units",
1003
  success=True,
1004
- message=f"{len(ids)} unité(s) trouvée(s).",
1005
  unit_ids=ids,
1006
  )
1007
 
@@ -1012,7 +1334,7 @@ class GameEngine:
1012
  return ActionResult(
1013
  action_type="assign_to_group",
1014
  success=False,
1015
- message="Groupe invalide (utilise 1, 2 ou 3).",
1016
  )
1017
  ids = list(action.unit_ids) if action.unit_ids else []
1018
  valid_ids = [uid for uid in ids if uid in player.units]
@@ -1020,7 +1342,7 @@ class GameEngine:
1020
  return ActionResult(
1021
  action_type="assign_to_group",
1022
  success=True,
1023
- message=f"Groupe {gi} : {len(valid_ids)} unité(s) assignée(s).",
1024
  )
1025
 
1026
  # ------------------------------------------------------------------
 
22
  from .buildings import Building, BuildingDef, BuildingStatus, BuildingType, BUILDING_DEFS
23
  from .commands import ActionResult, ActionType, CommandResult, GameAction, ParsedCommand
24
  from .map import MAP_HEIGHT, MAP_WIDTH, ResourceType
25
+ from .pathfinding import find_path, is_walkable, nearest_walkable_navpoint, snap_to_walkable
26
  from .state import GamePhase, GameState, PlayerState
27
  from .tech_tree import can_build, can_train, get_producer, missing_for_build, missing_for_train
28
  from .units import Unit, UnitDef, UnitStatus, UnitType, UNIT_DEFS
 
47
  self._task: Optional[asyncio.Task] = None # type: ignore[type-arg]
48
  self.bot: Optional[BotPlayer] = None
49
  self._sound_events: list[dict] = [] # fire/death per tick, sent in game_update
50
+ self._cmd_lang: str = "fr"
51
 
52
  # ------------------------------------------------------------------
53
  # Lifecycle
 
69
  # ------------------------------------------------------------------
70
 
71
  def apply_command(self, player_id: str, parsed: ParsedCommand) -> CommandResult:
72
+ self._cmd_lang = getattr(parsed, "language", "fr") or "fr"
73
  player = self.state.players.get(player_id)
74
  if not player or self.state.phase != GamePhase.PLAYING:
75
+ return CommandResult(results=[], feedback_override="game_not_in_progress")
76
 
77
  results: list[ActionResult] = []
78
  last_query_unit_ids: Optional[list[str]] = None
 
139
  for building in player.buildings.values():
140
  if building.status != BuildingStatus.CONSTRUCTING:
141
  continue
142
+ # Only progress if the assigned SCV has arrived (status == BUILDING)
143
+ scv = next(
144
+ (u for u in player.units.values()
145
+ if u.building_target_id == building.id and u.status == UnitStatus.BUILDING),
146
+ None,
147
+ )
148
+ if not scv:
149
+ continue
150
  building.construction_ticks_remaining -= 1
151
+ # HP grows linearly from 15% to 100% over build time
152
+ hp_gain = building.max_hp * 0.85 / max(1, building.construction_max_ticks)
153
+ building.hp = min(building.hp + hp_gain, float(building.max_hp))
154
  if building.construction_ticks_remaining <= 0:
155
  building.status = BuildingStatus.ACTIVE
156
  building.construction_ticks_remaining = 0
157
+ building.hp = float(building.max_hp)
158
+ scv.building_target_id = None
159
+ scv.target_x = scv.target_y = None
160
+ # Auto-return SCV to nearest mineral patch
161
+ cc = player.command_center()
162
+ cx = float(cc.x) + 2.0 if cc else scv.x
163
+ cy = float(cc.y) + 1.5 if cc else scv.y
164
+ patch = self.state.game_map.nearest_mineral(cx, cy)
165
+ if patch:
166
+ scv.status = UnitStatus.MINING_MINERALS
167
+ scv.assigned_resource_id = patch.id
168
+ scv.harvest_carry = False
169
+ scv.harvest_amount = 0
170
+ patch.assigned_scv_ids.append(scv.id)
171
+ self._set_unit_destination(scv, float(patch.x), float(patch.y), is_flying=False)
172
+ else:
173
+ scv.status = UnitStatus.IDLE
174
 
175
  def _tick_production(self, player: PlayerState) -> None:
176
  """Tick building production queues and spawn units."""
177
  for building in player.buildings.values():
178
+ if building.status == BuildingStatus.CONSTRUCTING:
179
+ continue
180
  if not building.production_queue:
181
  building.status = BuildingStatus.ACTIVE
182
  continue
 
192
  self._spawn_unit(player, building, UnitType(item.unit_type))
193
 
194
  def _spawn_unit(self, player: PlayerState, building: Building, ut: UnitType) -> None:
195
+ raw_sx, raw_sy = building.spawn_point()
196
+ is_flying = UNIT_DEFS[ut].is_flying
197
+ if is_flying:
198
+ sx, sy = raw_sx, raw_sy
199
+ else:
200
+ blocked = self._building_blocked_rects()
201
+ sx, sy = nearest_walkable_navpoint(raw_sx, raw_sy, blocked_rects=blocked)
202
  tx = building.rally_x if building.rally_x is not None else sx
203
  ty = building.rally_y if building.rally_y is not None else sy
204
  unit = Unit.create(ut, player.player_id, sx, sy)
205
  if tx != sx or ty != sy:
206
  unit.status = UnitStatus.MOVING
207
+ self._set_unit_destination(unit, tx, ty, is_flying=is_flying)
208
  player.units[unit.id] = unit
209
 
210
  def _tick_mining(self, player: PlayerState) -> None:
 
225
  unit.harvest_carry = False
226
  return
227
 
228
+ mineral_arrive = 1.2 # SCV stops right next to the patch
229
+ cc_edge_arrive = 1.0 # SCV triggers deposit when within 1 tile of any CC edge
 
230
 
231
  for unit in player.units.values():
232
  if unit.status == UnitStatus.MINING_MINERALS:
233
  resource = self.state.game_map.get_resource(unit.assigned_resource_id or "")
234
  if not resource or resource.is_depleted:
235
+ # Remove from old patch assignment
236
+ if resource and unit.id in resource.assigned_scv_ids:
237
+ resource.assigned_scv_ids.remove(unit.id)
238
  unit.assigned_resource_id = None
239
+ # Auto-reassign to next available mineral instead of going idle
240
+ next_patch = self.state.game_map.nearest_mineral(cc.x, cc.y)
241
+ if next_patch:
242
+ unit.assigned_resource_id = next_patch.id
243
+ next_patch.assigned_scv_ids.append(unit.id)
244
+ self._set_unit_destination(unit, float(next_patch.x), float(next_patch.y), is_flying=False)
245
+ else:
246
+ unit.status = UnitStatus.IDLE
247
+ unit.target_x = unit.target_y = None
248
+ unit.path_waypoints = []
249
+ unit.harvest_carry = False
250
  continue
251
 
252
  rx, ry = float(resource.x), float(resource.y)
253
 
254
  if not unit.harvest_carry:
255
+ if unit.target_x is None:
256
  self._set_unit_destination(unit, rx, ry, is_flying=False)
257
+ if unit.dist_to(rx, ry) <= mineral_arrive:
258
  gathered = min(MINERAL_PER_HARVEST, resource.amount)
259
  resource.amount -= gathered
260
  unit.harvest_carry = True
261
  unit.harvest_amount = gathered
262
+ tx, ty = self._nearest_building_entry(unit, cc)
263
+ self._set_unit_destination(unit, tx, ty, is_flying=False)
264
  else:
265
+ if unit.target_x is None:
266
+ tx, ty = self._nearest_building_entry(unit, cc)
267
+ self._set_unit_destination(unit, tx, ty, is_flying=False)
268
+ if self._dist_unit_to_building(unit, cc) <= cc_edge_arrive:
269
  player.minerals += unit.harvest_amount
270
  unit.harvest_carry = False
271
  unit.harvest_amount = 0
 
284
  rx, ry = float(resource.x), float(resource.y)
285
 
286
  if not unit.harvest_carry:
287
+ if unit.target_x is None:
288
  self._set_unit_destination(unit, rx, ry, is_flying=False)
289
+ if unit.dist_to(rx, ry) <= mineral_arrive:
290
  unit.harvest_carry = True
291
  unit.harvest_amount = GAS_PER_HARVEST
292
+ tx, ty = self._nearest_building_entry(unit, cc)
293
+ self._set_unit_destination(unit, tx, ty, is_flying=False)
294
  else:
295
+ if unit.target_x is None:
296
+ tx, ty = self._nearest_building_entry(unit, cc)
297
+ self._set_unit_destination(unit, tx, ty, is_flying=False)
298
+ if self._dist_unit_to_building(unit, cc) <= cc_edge_arrive:
299
  player.gas += unit.harvest_amount
300
  unit.harvest_carry = False
301
  unit.harvest_amount = 0
 
325
  # Building SCVs stay put; mining SCVs move but skip combat
326
  if unit.status == UnitStatus.BUILDING:
327
  continue
328
+ if unit.status == UnitStatus.MOVING_TO_BUILD:
329
+ if unit.target_x is not None and unit.target_y is not None:
330
+ self._move_toward(unit, defn, unit.target_x, unit.target_y)
331
+ continue
332
  if unit.status in (UnitStatus.MINING_MINERALS, UnitStatus.MINING_GAS):
333
  if unit.target_x is not None and unit.target_y is not None:
334
  self._move_toward(unit, defn, unit.target_x, unit.target_y)
 
374
  if unit.attack_target_id or unit.attack_target_building_id:
375
  self._combat_attack(unit, defn, all_units, player, enemy, sieged=False)
376
 
377
+ def _building_blocked_rects(self) -> list[tuple[float, float, float, float]]:
378
+ """Collision footprints (x, y, w, h) used for pathfinding and unit collision.
379
+
380
+ Uses the shrunk collision box so units can pass between buildings
381
+ that have a small visual gap.
382
+ """
383
+ rects: list[tuple[float, float, float, float]] = []
384
+ for player in self.state.players.values():
385
+ for b in player.buildings.values():
386
+ if b.status == BuildingStatus.DESTROYED:
387
+ continue
388
+ defn = BUILDING_DEFS[b.building_type]
389
+ chw, chh = defn.col_hw(), defn.col_hh()
390
+ rects.append((b.x - chw, b.y - chh, chw * 2, chh * 2))
391
+ return rects
392
+
393
  def _set_unit_destination(
394
  self, unit: Unit, tx: float, ty: float, *, is_flying: bool
395
  ) -> None:
 
399
  unit.path_waypoints = []
400
  if is_flying:
401
  return
402
+ blocked = self._building_blocked_rects()
403
+ sx, sy = unit.x, unit.y
404
+ path = find_path(sx, sy, tx, ty, blocked_rects=blocked)
405
  if path is None:
406
+ # Start is inside a building footprint — snap start outside first
407
+ snapped_start = snap_to_walkable(sx, sy, blocked_rects=blocked)
408
+ if snapped_start != (sx, sy):
409
+ path = find_path(snapped_start[0], snapped_start[1], tx, ty, blocked_rects=blocked)
410
+ if path is None:
411
+ snapped_dst = snap_to_walkable(tx, ty, blocked_rects=blocked)
412
+ unit.target_x, unit.target_y = snapped_dst[0], snapped_dst[1]
413
+ return
414
  if not path:
415
  return
416
  unit.target_x, unit.target_y = path[0][0], path[0][1]
417
  unit.path_waypoints = [[p[0], p[1]] for p in path[1:]]
418
 
419
+ def _would_overlap(
420
+ self, unit: Unit, new_x: float, new_y: float, *, exclude_unit_id: Optional[str] = None
421
+ ) -> bool:
422
+ """True if (new_x, new_y) would overlap another unit or a building.
423
+
424
+ Mining SCVs use soft collision (they can pass through each other, like in SC).
425
+ For other units, if two already overlap we allow moves that increase separation.
426
+ """
427
+ _mining = (UnitStatus.MINING_MINERALS, UnitStatus.MINING_GAS)
428
+ unit_is_miner = unit.status in _mining
429
+ for player in self.state.players.values():
430
+ for u in player.units.values():
431
+ if u.id == unit.id or (exclude_unit_id and u.id == exclude_unit_id):
432
+ continue
433
+ # Miners pass through other miners (soft collision, matching SC behaviour)
434
+ if unit_is_miner and u.status in _mining:
435
+ continue
436
+ min_dist = 2 * UNIT_RADIUS
437
+ new_dist = math.hypot(new_x - u.x, new_y - u.y)
438
+ if new_dist >= min_dist:
439
+ continue
440
+ # Already overlapping: allow the move only if it increases separation
441
+ cur_dist = math.hypot(unit.x - u.x, unit.y - u.y)
442
+ if cur_dist < min_dist and new_dist >= cur_dist:
443
+ continue # moving away from an already-overlapping unit — allow
444
+ return True
445
+ for rx, ry, w, h in self._building_blocked_rects():
446
+ px = max(rx, min(rx + w, new_x))
447
+ py = max(ry, min(ry + h, new_y))
448
+ if math.hypot(new_x - px, new_y - py) < UNIT_RADIUS:
449
+ return True
450
+ return False
451
+
452
  def _move_toward(self, unit: Unit, defn: UnitDef, tx: float, ty: float) -> None:
453
+ """Move unit up to one full step toward target, consuming intermediate waypoints smoothly."""
 
 
454
  step = defn.move_speed * TICK_INTERVAL
455
+ remaining = step
456
+ cur_tx, cur_ty = tx, ty
457
+ moved = False
458
+ arrived = False
459
+
460
+ while remaining > 1e-6:
461
+ dx = cur_tx - unit.x
462
+ dy = cur_ty - unit.y
463
+ dist = math.sqrt(dx * dx + dy * dy)
464
+
465
+ if dist < 1e-6:
466
+ # Already on this waypoint — advance immediately
467
+ if unit.path_waypoints:
468
+ nw = unit.path_waypoints.pop(0)
469
+ cur_tx = nw[0]; cur_ty = nw[1]
470
+ unit.target_x = cur_tx; unit.target_y = cur_ty
471
+ else:
472
+ arrived = True
473
+ break
474
+
475
+ if dist <= remaining:
476
+ # Can reach this waypoint within remaining budget — snap and continue
477
+ unit.x = cur_tx
478
+ unit.y = cur_ty
479
+ remaining -= dist
480
+ moved = True
481
+ if unit.path_waypoints:
482
+ nw = unit.path_waypoints.pop(0)
483
+ cur_tx = nw[0]; cur_ty = nw[1]
484
+ unit.target_x = cur_tx; unit.target_y = cur_ty
485
+ else:
486
+ arrived = True
487
+ break
488
+ else:
489
+ # Partial move — try full remaining, then half, then slide
490
+ nx = dx / dist
491
+ ny = dy / dist
492
+ new_x = unit.x + nx * remaining
493
+ new_y = unit.y + ny * remaining
494
+
495
+ if not self._would_overlap(unit, new_x, new_y):
496
+ unit.x = new_x
497
+ unit.y = new_y
498
+ moved = True
499
+ else:
500
+ hx = unit.x + nx * remaining * 0.5
501
+ hy = unit.y + ny * remaining * 0.5
502
+ if not self._would_overlap(unit, hx, hy):
503
+ unit.x = hx
504
+ unit.y = hy
505
+ moved = True
506
+ elif abs(nx) >= abs(ny):
507
+ if abs(nx) > 0.05 and not self._would_overlap(unit, unit.x + nx * remaining, unit.y):
508
+ unit.x += nx * remaining
509
+ moved = True
510
+ elif abs(ny) > 0.05 and not self._would_overlap(unit, unit.x, unit.y + ny * remaining):
511
+ unit.y += ny * remaining
512
+ moved = True
513
+ else:
514
+ if abs(ny) > 0.05 and not self._would_overlap(unit, unit.x, unit.y + ny * remaining):
515
+ unit.y += ny * remaining
516
+ moved = True
517
+ elif abs(nx) > 0.05 and not self._would_overlap(unit, unit.x + nx * remaining, unit.y):
518
+ unit.x += nx * remaining
519
+ moved = True
520
+ break
521
+
522
+ unit.target_x = cur_tx
523
+ unit.target_y = cur_ty
524
+
525
+ if moved or arrived:
526
+ unit.stuck_ticks = 0
527
  else:
528
+ unit.stuck_ticks += 1
529
+ if unit.stuck_ticks >= 4:
530
+ unit.stuck_ticks = 0
531
+ if unit.path_waypoints:
532
+ nw = unit.path_waypoints.pop(0)
533
+ unit.target_x, unit.target_y = nw[0], nw[1]
534
+ else:
535
+ arrived = True
536
+ else:
537
+ return
538
+
539
+ if not arrived:
540
+ return
541
+ if unit.status == UnitStatus.MOVING:
542
+ unit.status = UnitStatus.IDLE
543
+ unit.target_x = unit.target_y = None
544
+ elif unit.status == UnitStatus.ATTACKING:
545
+ unit.status = UnitStatus.IDLE
546
+ unit.target_x = unit.target_y = None
547
+ elif unit.status == UnitStatus.MOVING_TO_BUILD:
548
+ unit.status = UnitStatus.BUILDING
549
+ unit.target_x = unit.target_y = None
550
+ elif unit.status == UnitStatus.PATROLLING:
551
+ unit.target_x, unit.patrol_x = unit.patrol_x, unit.target_x
552
+ unit.target_y, unit.patrol_y = unit.patrol_y, unit.target_y
553
 
554
  def _combat_attack(
555
  self,
 
699
  # ------------------------------------------------------------------
700
 
701
  def _building_center(self, b: Building) -> tuple[float, float]:
702
+ return (b.x, b.y)
 
703
 
704
  def _dist_unit_to_building(self, unit: Unit, building: Building) -> float:
705
+ """Distance from unit center to nearest point on the building collision box."""
706
  defn = BUILDING_DEFS[building.building_type]
707
+ chw, chh = defn.col_hw(), defn.col_hh()
708
+ x0, x1 = building.x - chw, building.x + chw
709
+ y0, y1 = building.y - chh, building.y + chh
710
  px = max(x0, min(x1, unit.x))
711
  py = max(y0, min(y1, unit.y))
712
  return math.sqrt((unit.x - px) ** 2 + (unit.y - py) ** 2)
713
 
714
+ def _nearest_building_entry(self, unit: Unit, building: Building) -> tuple[float, float]:
715
+ """Return a point just outside the nearest visual edge of a building, on the unit's side.
716
+
717
+ SCVs use this to approach from whichever direction they're coming from,
718
+ so they can deposit/interact from any side instead of always queuing at one point.
719
+ Uses the visual box (not the collision box) so the SCV visually touches the building.
720
+ """
721
+ defn = BUILDING_DEFS[building.building_type]
722
+ hw = defn.width / 2
723
+ hh = defn.height / 2
724
+ px = max(building.x - hw, min(building.x + hw, unit.x))
725
+ py = max(building.y - hh, min(building.y + hh, unit.y))
726
+ dx = unit.x - px
727
+ dy = unit.y - py
728
+ d = math.hypot(dx, dy)
729
+ margin = UNIT_RADIUS + 0.3
730
+ if d > 0:
731
+ return (px + dx / d * margin, py + dy / d * margin)
732
+ return (building.x, building.y + hh + margin)
733
+
734
  def _nearest_enemy_in_range(
735
  self,
736
  unit: Unit,
 
759
  return best[1] if best else None
760
 
761
  def _resolve_zone(self, player_id: str, zone: str) -> tuple[float, float]:
762
+ import re as _re
763
  player = self.state.players[player_id]
764
  enemy = self.state.enemy_of(player_id)
765
  cc = player.command_center()
766
+ base_x = cc.x if cc else float(MAP_WIDTH) / 2
767
+ base_y = cc.y if cc else float(MAP_HEIGHT) / 2
768
 
769
  if zone == "my_base":
770
  return (base_x, base_y)
771
  if zone == "enemy_base" and enemy:
772
  ecc = enemy.command_center()
773
+ return (ecc.x, ecc.y) if ecc else (MAP_WIDTH - 5, MAP_HEIGHT - 5)
774
  if zone == "center":
775
  return (MAP_WIDTH / 2, MAP_HEIGHT / 2)
776
  if zone == "top_left":
 
790
  avg_x = sum(u.x for u in military) / len(military)
791
  avg_y = sum(u.y for u in military) / len(military)
792
  return (avg_x, avg_y)
793
+
794
+ m = _re.match(r'^mineral_(\d+)$', zone)
795
+ if m:
796
+ idx = int(m.group(1)) - 1
797
+ minerals = [
798
+ r for r in self.state.game_map.resources
799
+ if r.resource_type == ResourceType.MINERAL and not r.is_depleted
800
+ ]
801
+ minerals.sort(key=lambda r: (r.x - base_x) ** 2 + (r.y - base_y) ** 2)
802
+ if 0 <= idx < len(minerals):
803
+ return (float(minerals[idx].x), float(minerals[idx].y))
804
+
805
+ m = _re.match(r'^geyser_(\d+)$', zone)
806
+ if m:
807
+ idx = int(m.group(1)) - 1
808
+ geysers = [
809
+ r for r in self.state.game_map.resources
810
+ if r.resource_type == ResourceType.GEYSER
811
+ ]
812
+ geysers.sort(key=lambda r: (r.x - base_x) ** 2 + (r.y - base_y) ** 2)
813
+ if 0 <= idx < len(geysers):
814
+ return (float(geysers[idx].x), float(geysers[idx].y))
815
+
816
  # Fallback: enemy base
817
  if enemy:
818
  ecc = enemy.command_center()
 
861
  pass
862
  return [u.id for u in units]
863
 
864
+ # Vision radii (in cells) — must match frontend constants
865
+ _UNIT_VISION: dict[UnitType, float] = {
866
+ UnitType.SCV: 6, UnitType.MARINE: 6, UnitType.MEDIC: 6,
867
+ UnitType.GOLIATH: 8, UnitType.TANK: 8, UnitType.WRAITH: 9,
868
+ }
869
+ _BUILDING_VISION: dict[BuildingType, float] = {
870
+ BuildingType.COMMAND_CENTER: 10, BuildingType.SUPPLY_DEPOT: 7,
871
+ BuildingType.BARRACKS: 7, BuildingType.ENGINEERING_BAY: 7,
872
+ BuildingType.REFINERY: 7, BuildingType.FACTORY: 7,
873
+ BuildingType.ARMORY: 7, BuildingType.STARPORT: 7,
874
+ }
875
+ _SCV_BUILD_RANGE: float = 6.0
876
+
877
+ def _is_visible(self, player: PlayerState, x: float, y: float) -> bool:
878
+ """Return True if tile (x, y) is within vision of any own unit or building."""
879
+ for u in player.units.values():
880
+ r = self._UNIT_VISION.get(u.unit_type, 6.0)
881
+ if (u.x - x) ** 2 + (u.y - y) ** 2 <= r * r:
882
+ return True
883
+ for b in player.buildings.values():
884
+ if b.status == BuildingStatus.DESTROYED:
885
+ continue
886
+ r = self._BUILDING_VISION.get(b.building_type, 7.0)
887
+ if (b.x - x) ** 2 + (b.y - y) ** 2 <= r * r:
888
+ return True
889
+ return False
890
+
891
  def _find_build_position(
892
+ self, player: PlayerState, bt: BuildingType, near_scv: Unit
893
+ ) -> Optional[tuple[float, float]]:
894
+ """Return CENTER coordinates for a new building, or None if no valid spot found."""
 
 
 
895
  defn = BUILDING_DEFS[bt]
896
+ cx, cy = near_scv.x, near_scv.y
897
 
898
+ for radius in range(1, int(self._SCV_BUILD_RANGE) + 2):
899
  for dx in range(-radius, radius + 1):
900
  for dy in range(-radius, radius + 1):
901
+ tl_x, tl_y = int(cx) + dx, int(cy) + dy
902
+ tile_cx = tl_x + defn.width / 2.0
903
+ tile_cy = tl_y + defn.height / 2.0
904
+ if (tile_cx - cx) ** 2 + (tile_cy - cy) ** 2 > self._SCV_BUILD_RANGE ** 2:
905
+ continue
906
+ if not self._can_place(tl_x, tl_y, defn):
907
+ continue
908
+ if not self._is_visible(player, tile_cx, tile_cy):
909
+ continue
910
+ return (tile_cx, tile_cy)
911
  return None
912
 
913
+ def _can_place(self, tl_x: int, tl_y: int, defn: BuildingDef) -> bool:
914
+ """Check if a building with given top-left corner can be placed (no overlap, in bounds)."""
915
+ if tl_x < 0 or tl_y < 0 or tl_x + defn.width > MAP_WIDTH or tl_y + defn.height > MAP_HEIGHT:
916
  return False
917
+ # Check overlap with existing buildings (stored as center coords)
918
  for player in self.state.players.values():
919
  for b in player.buildings.values():
920
  if b.status == BuildingStatus.DESTROYED:
921
  continue
922
  bd = BUILDING_DEFS[b.building_type]
923
+ if tl_x < b.x + bd.width / 2 and tl_x + defn.width > b.x - bd.width / 2 \
924
+ and tl_y < b.y + bd.height / 2 and tl_y + defn.height > b.y - bd.height / 2:
925
  return False
926
  # Check overlap with resources
927
  for res in self.state.game_map.resources:
928
+ if tl_x <= res.x < tl_x + defn.width and tl_y <= res.y < tl_y + defn.height:
929
  return False
930
  return True
931
 
 
964
  return self._cmd_query_units(player, action)
965
  if t == ActionType.ASSIGN_TO_GROUP:
966
  return self._cmd_assign_to_group(player, action)
967
+ return ActionResult(
968
+ action_type=t, success=False, data={"error": "unknown_action"}
969
+ )
970
  except Exception as exc:
971
  log.exception("Error applying action %s", action.type)
972
+ return ActionResult(
973
+ action_type=str(action.type), success=False, data={"error": "exception", "detail": str(exc)}
974
+ )
975
 
976
  def _cmd_build(self, player: PlayerState, action: GameAction) -> ActionResult:
977
  raw = action.building_type
978
  if not raw:
979
+ return ActionResult(action_type="build", success=False, data={"error": "build_missing_type"})
980
  try:
981
  bt = BuildingType(raw)
982
  except ValueError:
983
+ return ActionResult(action_type="build", success=False, data={"error": "build_unknown", "raw": raw})
984
 
985
  if not can_build(bt, player):
986
  missing = missing_for_build(bt, player)
987
  names = ", ".join(m.value for m in missing)
988
+ return ActionResult(
989
+ action_type="build", success=False, data={"error": "build_missing_prereq", "names": names}
990
+ )
991
 
992
  defn = BUILDING_DEFS[bt]
993
  if player.minerals < defn.mineral_cost or player.gas < defn.gas_cost:
994
+ return ActionResult(
995
+ action_type="build", success=False,
996
+ data={"error": "build_insufficient_resources", "mineral": defn.mineral_cost, "gas": defn.gas_cost},
997
+ )
998
 
999
  # Clamp count to what resources and SCVs allow
1000
  count = max(1, min(action.count, 5))
 
1011
  )
1012
  count = min(count, len(available_scvs))
1013
  if count == 0:
1014
+ return ActionResult(action_type="build", success=False, data={"error": "build_no_scv"})
1015
 
1016
  cc = player.command_center()
1017
  cx, cy = (float(cc.x), float(cc.y)) if cc else (0.0, 0.0)
1018
 
1019
  built = 0
1020
  for i in range(count):
1021
+ scv = available_scvs[i]
1022
+
1023
+ # Find center position for this building
1024
  if bt == BuildingType.REFINERY:
1025
  geyser = self.state.game_map.nearest_geyser_without_refinery(cx, cy)
1026
  if not geyser:
1027
  break
1028
+ # Refinery (2×2) centered on geyser tile (+1 from integer geyser coord)
1029
+ pos_cx: float = geyser.x + 1.0
1030
+ pos_cy: float = geyser.y + 1.0
1031
+ if not self._is_visible(player, pos_cx, pos_cy):
1032
+ break
1033
  geyser.has_refinery = True
1034
  else:
1035
+ pos_opt = self._find_build_position(player, bt, scv)
1036
  if not pos_opt:
1037
  break
1038
+ pos_cx, pos_cy = pos_opt
 
 
1039
  # Unassign from resource if mining
1040
  if scv.assigned_resource_id:
1041
  res = self.state.game_map.get_resource(scv.assigned_resource_id)
 
1048
  player.minerals -= defn.mineral_cost
1049
  player.gas -= defn.gas_cost
1050
 
1051
+ building = Building.create(bt, player.player_id, pos_cx, pos_cy)
1052
  player.buildings[building.id] = building
1053
 
1054
+ scv.status = UnitStatus.MOVING_TO_BUILD
1055
  scv.building_target_id = building.id
1056
+ # Navigate to the nearest point just outside the building edge
1057
+ bw = float(defn.width)
1058
+ bh = float(defn.height)
1059
+ edge_x = max(pos_cx - bw / 2, min(pos_cx + bw / 2, scv.x))
1060
+ edge_y = max(pos_cy - bh / 2, min(pos_cy + bh / 2, scv.y))
1061
+ dx = scv.x - edge_x
1062
+ dy = scv.y - edge_y
1063
+ edge_dist = math.hypot(dx, dy)
1064
+ approach_margin = UNIT_RADIUS + 0.6
1065
+ if edge_dist > 0:
1066
+ dest_x = edge_x + dx / edge_dist * approach_margin
1067
+ dest_y = edge_y + dy / edge_dist * approach_margin
1068
+ else:
1069
+ dest_x = pos_cx + bw / 2 + approach_margin
1070
+ dest_y = pos_cy
1071
+ self._set_unit_destination(scv, dest_x, dest_y, is_flying=False)
1072
  built += 1
1073
 
1074
  if built == 0:
1075
+ return ActionResult(action_type="build", success=False, data={"error": "build_no_placement"})
1076
+ return ActionResult(
1077
+ action_type="build", success=True,
1078
+ data={"built": built, "building": bt.value},
1079
+ )
1080
 
1081
  def _cmd_train(self, player: PlayerState, action: GameAction) -> ActionResult:
1082
  raw = action.unit_type
1083
  if not raw:
1084
+ return ActionResult(action_type="train", success=False, data={"error": "train_missing_type"})
1085
  try:
1086
  ut = UnitType(raw)
1087
  except ValueError:
1088
+ return ActionResult(action_type="train", success=False, data={"error": "train_unknown", "raw": raw})
1089
 
1090
  if not can_train(ut, player):
1091
  missing = missing_for_train(ut, player)
1092
  names = ", ".join(m.value for m in missing)
1093
+ return ActionResult(
1094
+ action_type="train", success=False, data={"error": "train_missing_prereq", "names": names}
1095
+ )
1096
 
1097
  defn = UNIT_DEFS[ut]
1098
  producer_type = get_producer(ut)
1099
  producers = player.active_buildings_of(producer_type)
1100
  if not producers:
1101
+ return ActionResult(
1102
+ action_type="train", success=False,
1103
+ data={"error": "train_no_producer", "producer": producer_type.value},
1104
+ )
1105
 
1106
  count = max(1, min(action.count, 20))
1107
  num_producers = len(producers)
1108
 
 
1109
  total_minerals = defn.mineral_cost * count
1110
  total_gas = defn.gas_cost * count
1111
  if player.minerals < total_minerals or player.gas < total_gas:
1112
+ return ActionResult(
1113
+ action_type="train", success=False, data={"error": "train_insufficient_resources"}
1114
+ )
1115
 
 
1116
  queued_supply = sum(
1117
  UNIT_DEFS[UnitType(item.unit_type)].supply_cost
1118
  for b in player.buildings.values()
1119
  for item in b.production_queue
1120
  )
1121
  if player.supply_used + queued_supply + defn.supply_cost * count > player.supply_max:
1122
+ return ActionResult(
1123
+ action_type="train", success=False, data={"error": "train_insufficient_supply"}
1124
+ )
1125
 
1126
  from .buildings import ProductionItem # local import to avoid cycle
1127
  for i in range(count):
 
1137
  player.minerals -= total_minerals
1138
  player.gas -= total_gas
1139
 
1140
+ return ActionResult(
1141
+ action_type="train", success=True,
1142
+ data={"count": count, "unit": ut.value},
1143
+ )
1144
 
1145
  def _cmd_move(self, player: PlayerState, action: GameAction) -> ActionResult:
1146
  units = self._resolve_selector(player, action.unit_selector or "all_military")
1147
  if not units:
1148
+ return ActionResult(action_type="move", success=False, data={"error": "no_units_selected"})
1149
  tx, ty = self._resolve_zone(player.player_id, action.target_zone or "center")
1150
  for unit in units:
1151
  if unit.is_sieged:
 
1155
  unit.attack_target_building_id = None
1156
  self._set_unit_destination(unit, tx, ty, is_flying=UNIT_DEFS[unit.unit_type].is_flying)
1157
  move_ack = [{"kind": "move_ack", "unit_type": units[0].unit_type.value}]
1158
+ zone = action.target_zone or "center"
1159
+ return ActionResult(
1160
+ action_type="move", success=True,
1161
+ data={"n": len(units), "zone": zone}, sound_events=move_ack,
1162
+ )
1163
 
1164
  def _cmd_attack(self, player: PlayerState, action: GameAction) -> ActionResult:
1165
  units = self._resolve_selector(player, action.unit_selector or "all_military")
1166
  if not units:
1167
+ return ActionResult(action_type="attack", success=False, data={"error": "no_units_selected"})
1168
  tx, ty = self._resolve_zone(player.player_id, action.target_zone or "enemy_base")
1169
  for unit in units:
1170
  if unit.is_sieged:
 
1174
  unit.attack_target_building_id = None
1175
  self._set_unit_destination(unit, tx, ty, is_flying=UNIT_DEFS[unit.unit_type].is_flying)
1176
  move_ack = [{"kind": "move_ack", "unit_type": units[0].unit_type.value}]
1177
+ zone = action.target_zone or "enemy_base"
1178
+ return ActionResult(
1179
+ action_type="attack", success=True,
1180
+ data={"n": len(units), "zone": zone}, sound_events=move_ack,
1181
+ )
1182
 
1183
  def _cmd_siege(self, player: PlayerState, action: GameAction, siege: bool) -> ActionResult:
1184
  tanks = self._resolve_selector(player, action.unit_selector or "all_tanks")
1185
  tanks = [u for u in tanks if u.unit_type == UnitType.TANK]
1186
  if not tanks:
1187
+ return ActionResult(action_type="siege", success=False, data={"error": "siege_no_tanks"})
1188
  for tank in tanks:
1189
  tank.is_sieged = siege
1190
  tank.status = UnitStatus.SIEGED if siege else UnitStatus.IDLE
1191
  if siege:
1192
  tank.target_x = tank.target_y = None
1193
+ mode = "siege" if siege else "mobile"
1194
+ return ActionResult(
1195
+ action_type="siege", success=True,
1196
+ data={"n": len(tanks), "mode": mode},
1197
+ )
1198
 
1199
  def _cmd_cloak(self, player: PlayerState, action: GameAction, cloak: bool) -> ActionResult:
1200
  wraiths = self._resolve_selector(player, action.unit_selector or "all_wraiths")
1201
  wraiths = [u for u in wraiths if u.unit_type == UnitType.WRAITH]
1202
  if not wraiths:
1203
+ return ActionResult(action_type="cloak", success=False, data={"error": "cloak_no_wraiths"})
1204
  for wraith in wraiths:
1205
  wraith.is_cloaked = cloak
1206
+ state = "on" if cloak else "off"
1207
+ return ActionResult(
1208
+ action_type="cloak", success=True,
1209
+ data={"n": len(wraiths), "state": state},
1210
+ )
1211
 
1212
  def _cmd_gather(self, player: PlayerState, action: GameAction) -> ActionResult:
1213
  resource_type = (action.resource_type or "minerals").lower()
 
1217
  scvs = self._resolve_selector(player, action.unit_selector or "idle_scv")
1218
  scvs = [u for u in scvs if u.unit_type == UnitType.SCV]
1219
  if not scvs:
1220
+ return ActionResult(action_type="gather", success=False, data={"error": "gather_no_scv"})
1221
 
1222
  assigned = 0
1223
  if resource_type == "gas":
 
1238
  geyser.assigned_scv_ids.append(scv.id)
1239
  assigned += 1
1240
  else:
1241
+ # Distribute SCVs across patches evenly: pick the patch with fewest assigned SCVs
1242
+ # (avoids funnelling all 5 SCVs to the same nearest patch)
1243
+ mineral_patches = [
1244
+ r for r in self.state.game_map.resources
1245
+ if r.resource_type.value == "mineral" and not r.is_depleted and r.has_capacity
1246
+ ]
1247
+ if not mineral_patches:
1248
+ return ActionResult(action_type="gather", success=False, data={"error": "gather_no_resource"})
1249
+
1250
  for scv in scvs:
1251
+ if not mineral_patches:
1252
+ break
1253
+ # Choose patch with fewest assigned SCVs (tie-break: nearest to CC)
1254
+ patch = min(
1255
+ mineral_patches,
1256
+ key=lambda r: (len(r.assigned_scv_ids), (r.x - cx) ** 2 + (r.y - cy) ** 2)
1257
+ )
1258
  if not patch:
1259
  break
1260
  if scv.assigned_resource_id:
 
1268
  self._set_unit_destination(scv, float(patch.x), float(patch.y), is_flying=False)
1269
  patch.assigned_scv_ids.append(scv.id)
1270
  assigned += 1
1271
+ # Remove full patches from candidates
1272
+ mineral_patches = [r for r in mineral_patches if r.has_capacity]
1273
 
1274
  if assigned == 0:
1275
+ return ActionResult(
1276
+ action_type="gather", success=False, data={"error": "gather_no_resource"}
1277
+ )
1278
+ return ActionResult(
1279
+ action_type="gather", success=True,
1280
+ data={"n": assigned, "resource": resource_type},
1281
+ )
1282
 
1283
  def _cmd_stop(self, player: PlayerState, action: GameAction) -> ActionResult:
1284
  units = self._resolve_selector(player, action.unit_selector or "all_military")
 
1288
  unit.path_waypoints = []
1289
  unit.attack_target_id = None
1290
  unit.attack_target_building_id = None
1291
+ return ActionResult(action_type="stop", success=True, data={"n": len(units)})
 
1292
 
1293
  def _cmd_patrol(self, player: PlayerState, action: GameAction) -> ActionResult:
1294
  units = self._resolve_selector(player, action.unit_selector or "all_military")
1295
  if not units:
1296
+ return ActionResult(action_type="patrol", success=False, data={"error": "no_units_selected"})
1297
  tx, ty = self._resolve_zone(player.player_id, action.target_zone or "center")
1298
  for unit in units:
1299
  if unit.is_sieged:
 
1303
  unit.status = UnitStatus.PATROLLING
1304
  self._set_unit_destination(unit, tx, ty, is_flying=UNIT_DEFS[unit.unit_type].is_flying)
1305
  move_ack = [{"kind": "move_ack", "unit_type": units[0].unit_type.value}]
1306
+ zone = action.target_zone or "center"
1307
+ return ActionResult(
1308
+ action_type="patrol", success=True,
1309
+ data={"n": len(units), "zone": zone}, sound_events=move_ack,
1310
+ )
1311
 
1312
  def _cmd_query(self, player: PlayerState, action: GameAction) -> ActionResult:
1313
+ return ActionResult(
1314
+ action_type="query", success=True,
1315
+ data={"summary": player.summary(self._cmd_lang)},
1316
+ )
1317
 
1318
  def _cmd_query_units(self, player: PlayerState, action: GameAction) -> ActionResult:
1319
  """Query units by zone and/or type; return their IDs in result.unit_ids."""
 
1323
  return ActionResult(
1324
  action_type="query_units",
1325
  success=True,
1326
+ data={"n": len(ids)},
1327
  unit_ids=ids,
1328
  )
1329
 
 
1334
  return ActionResult(
1335
  action_type="assign_to_group",
1336
  success=False,
1337
+ data={"error": "group_invalid"},
1338
  )
1339
  ids = list(action.unit_ids) if action.unit_ids else []
1340
  valid_ids = [uid for uid in ids if uid in player.units]
 
1342
  return ActionResult(
1343
  action_type="assign_to_group",
1344
  success=True,
1345
+ data={"gi": gi, "n": len(valid_ids)},
1346
  )
1347
 
1348
  # ------------------------------------------------------------------
backend/game/map.py CHANGED
@@ -7,25 +7,25 @@ from enum import Enum
7
 
8
  from pydantic import BaseModel, Field
9
 
10
- MAP_WIDTH = 40
11
- MAP_HEIGHT = 40
12
 
13
  # Starting positions (top-left corner of Command Center footprint) — fallback if no game_positions.json
14
- PLAYER1_START: tuple[int, int] = (4, 5)
15
- PLAYER2_START: tuple[int, int] = (32, 32)
16
 
17
  # Absolute resource positions (fallback)
18
  _P1_MINERALS: list[tuple[int, int]] = [
19
- (2, 2), (3, 2), (4, 2), (5, 2), (6, 2),
20
- (2, 3), (6, 3), (3, 9),
21
  ]
22
- _P1_GEYSERS: list[tuple[int, int]] = [(2, 9), (7, 9)]
23
 
24
  _P2_MINERALS: list[tuple[int, int]] = [
25
- (33, 37), (34, 37), (35, 37), (36, 37), (37, 37),
26
- (33, 36), (37, 36), (34, 30),
27
  ]
28
- _P2_GEYSERS: list[tuple[int, int]] = [(32, 30), (37, 30)]
29
 
30
 
31
  def _game_positions_path() -> Path | None:
@@ -33,32 +33,266 @@ def _game_positions_path() -> Path | None:
33
  return p if p.exists() else None
34
 
35
 
36
- def get_start_positions() -> tuple[tuple[int, int], tuple[int, int]]:
37
- """Return (player1_start, player2_start) in game grid. With 3 positions in file, 2 are chosen at random."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  import random
39
  path = _game_positions_path()
40
  if not path:
41
- return PLAYER1_START, PLAYER2_START
42
  try:
43
  with open(path, encoding="utf-8") as f:
44
  data = json.load(f)
45
  starts = data.get("starting_positions") or []
46
  if len(starts) >= 3:
47
- def to_game(p: dict) -> tuple[int, int]:
48
- x = p.get("x", 0) * MAP_WIDTH / 100.0
49
- y = p.get("y", 0) * MAP_HEIGHT / 100.0
50
- return (max(0, min(MAP_WIDTH - 1, int(round(x)))), max(0, min(MAP_HEIGHT - 1, int(round(y)))))
51
  chosen = random.sample(starts, 2)
52
- return to_game(chosen[0]), to_game(chosen[1])
 
 
 
 
 
53
  if len(starts) >= 2:
54
- def to_game(p: dict) -> tuple[int, int]:
55
- x = p.get("x", 0) * MAP_WIDTH / 100.0
56
- y = p.get("y", 0) * MAP_HEIGHT / 100.0
57
- return (max(0, min(MAP_WIDTH - 1, int(round(x)))), max(0, min(MAP_HEIGHT - 1, int(round(y)))))
58
- return to_game(starts[0]), to_game(starts[1])
 
59
  except (OSError, json.JSONDecodeError, KeyError):
60
  pass
61
- return PLAYER1_START, PLAYER2_START
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
 
63
  # Named map zones resolved to (x, y) center coordinates
64
  # Zone values depend on player — resolved at engine level using player start positions
@@ -99,35 +333,32 @@ class GameMap(BaseModel):
99
  resources: list[Resource] = Field(default_factory=list)
100
 
101
  @classmethod
102
- def create_default(cls) -> "GameMap":
 
 
 
103
  path = _game_positions_path()
104
  if path:
105
  try:
106
  with open(path, encoding="utf-8") as f:
107
  data = json.load(f)
108
- resources = []
 
109
  for m in data.get("minerals") or []:
110
  x, y = int(m.get("x", 0)), int(m.get("y", 0))
111
  if 0 <= x < MAP_WIDTH and 0 <= y < MAP_HEIGHT:
112
- resources.append(Resource(resource_type=ResourceType.MINERAL, x=x, y=y))
113
  for g in data.get("geysers") or []:
114
  x, y = int(g.get("x", 0)), int(g.get("y", 0))
115
  if 0 <= x < MAP_WIDTH and 0 <= y < MAP_HEIGHT:
116
- resources.append(Resource(resource_type=ResourceType.GEYSER, x=x, y=y))
117
- if resources:
118
- return cls(resources=resources)
119
  except (OSError, json.JSONDecodeError, KeyError):
120
  pass
121
- resources = []
122
- for x, y in _P1_MINERALS:
123
- resources.append(Resource(resource_type=ResourceType.MINERAL, x=x, y=y))
124
- for x, y in _P1_GEYSERS:
125
- resources.append(Resource(resource_type=ResourceType.GEYSER, x=x, y=y))
126
- for x, y in _P2_MINERALS:
127
- resources.append(Resource(resource_type=ResourceType.MINERAL, x=x, y=y))
128
- for x, y in _P2_GEYSERS:
129
- resources.append(Resource(resource_type=ResourceType.GEYSER, x=x, y=y))
130
- return cls(resources=resources)
131
 
132
  def get_resource(self, resource_id: str) -> Resource | None:
133
  return next((r for r in self.resources if r.id == resource_id), None)
 
7
 
8
  from pydantic import BaseModel, Field
9
 
10
+ MAP_WIDTH = 80
11
+ MAP_HEIGHT = 80
12
 
13
  # Starting positions (top-left corner of Command Center footprint) — fallback if no game_positions.json
14
+ PLAYER1_START: tuple[int, int] = (8, 10)
15
+ PLAYER2_START: tuple[int, int] = (64, 64)
16
 
17
  # Absolute resource positions (fallback)
18
  _P1_MINERALS: list[tuple[int, int]] = [
19
+ (4, 4), (6, 4), (8, 4), (10, 4), (12, 4),
20
+ (4, 6), (12, 6), (6, 18),
21
  ]
22
+ _P1_GEYSERS: list[tuple[int, int]] = [(4, 18), (14, 18)]
23
 
24
  _P2_MINERALS: list[tuple[int, int]] = [
25
+ (66, 74), (68, 74), (70, 74), (72, 74), (74, 74),
26
+ (66, 72), (74, 72), (68, 60),
27
  ]
28
+ _P2_GEYSERS: list[tuple[int, int]] = [(64, 60), (74, 60)]
29
 
30
 
31
  def _game_positions_path() -> Path | None:
 
33
  return p if p.exists() else None
34
 
35
 
36
+ def _to_game_coords(x: float, y: float) -> tuple[int, int]:
37
+ gx = max(0, min(MAP_WIDTH - 1, int(round(x * MAP_WIDTH / 100.0))))
38
+ gy = max(0, min(MAP_HEIGHT - 1, int(round(y * MAP_HEIGHT / 100.0))))
39
+ return (gx, gy)
40
+
41
+
42
+ def _resources_from_start_entry(entry: dict) -> list["Resource"]:
43
+ """Build list of Resource from a starting_position entry that has nested minerals/geysers."""
44
+ resources: list[Resource] = []
45
+ for m in entry.get("minerals") or []:
46
+ x, y = int(m.get("x", 0)), int(m.get("y", 0))
47
+ if 0 <= x < MAP_WIDTH and 0 <= y < MAP_HEIGHT:
48
+ resources.append(Resource(resource_type=ResourceType.MINERAL, x=x, y=y))
49
+ for g in entry.get("geysers") or []:
50
+ x, y = int(g.get("x", 0)), int(g.get("y", 0))
51
+ if 0 <= x < MAP_WIDTH and 0 <= y < MAP_HEIGHT:
52
+ resources.append(Resource(resource_type=ResourceType.GEYSER, x=x, y=y))
53
+ return resources
54
+
55
+
56
+ # 8 minerals in a ring at ~6 tiles from center, 2 geysers at ~8 tiles.
57
+ # All distances are deterministic so every base gets the same layout.
58
+ _RESOURCE_OFFSETS_MINERAL = [
59
+ (6, 0), (5, 3), (0, 6), (-5, 3), (-6, 0), (-5, -3), (0, -6), (5, -3),
60
+ ]
61
+ _RESOURCE_OFFSETS_GEYSER = [(7, 4), (-7, 4)]
62
+
63
+
64
+ def _generate_resources_at(gx: int, gy: int) -> list["Resource"]:
65
+ """Generate 8 mineral patches and 2 geysers at fixed distances around a game coordinate.
66
+
67
+ Every base always gets the same symmetric layout so distances are consistent.
68
+ """
69
+ resources: list[Resource] = []
70
+ seen: set[tuple[int, int]] = set()
71
+ for dx, dy in _RESOURCE_OFFSETS_MINERAL:
72
+ x = max(0, min(MAP_WIDTH - 1, gx + dx))
73
+ y = max(0, min(MAP_HEIGHT - 1, gy + dy))
74
+ if (x, y) not in seen:
75
+ seen.add((x, y))
76
+ resources.append(Resource(resource_type=ResourceType.MINERAL, x=x, y=y))
77
+ for dx, dy in _RESOURCE_OFFSETS_GEYSER:
78
+ x = max(0, min(MAP_WIDTH - 1, gx + dx))
79
+ y = max(0, min(MAP_HEIGHT - 1, gy + dy))
80
+ if (x, y) not in seen:
81
+ seen.add((x, y))
82
+ resources.append(Resource(resource_type=ResourceType.GEYSER, x=x, y=y))
83
+ return resources
84
+
85
+
86
+ def get_start_positions() -> tuple[tuple[float, float], tuple[float, float]]:
87
+ """Return (player1_start, player2_start) in game coords. With 3 positions in file, 2 are chosen at random."""
88
+ start1, _, start2, _ = get_start_data()
89
+ return start1, start2
90
+
91
+
92
+ def get_all_map_resources() -> list["Resource"]:
93
+ """Return resources for ALL starting positions AND expansion positions.
94
+
95
+ Resources are ALWAYS generated via _generate_resources_at() using fixed offsets so that
96
+ every base — regardless of origin — has minerals and geysers at a consistent distance
97
+ from its Command Center. Embedded minerals in game_positions.json are intentionally
98
+ ignored in favour of this deterministic layout.
99
+ """
100
+ path = _game_positions_path()
101
+ if path:
102
+ try:
103
+ with open(path, encoding="utf-8") as f:
104
+ data = json.load(f)
105
+ all_entries = (
106
+ list(data.get("starting_positions") or [])
107
+ + list(data.get("expansion_positions") or [])
108
+ )
109
+ if all_entries:
110
+ resources: list[Resource] = []
111
+ for entry in all_entries:
112
+ gx, gy = _to_game_coords(float(entry.get("x", 0)), float(entry.get("y", 0)))
113
+ resources.extend(_generate_resources_at(gx, gy))
114
+ return resources
115
+ except (OSError, json.JSONDecodeError, KeyError):
116
+ pass
117
+ # Fallback: derive starts + expansions from nav_points
118
+ compiled_path = Path(__file__).resolve().parent.parent / "static" / "compiled_map.json"
119
+ if compiled_path.exists():
120
+ try:
121
+ with open(compiled_path, encoding="utf-8") as f:
122
+ nav_data = json.load(f)
123
+ pts = nav_data.get("nav_points") or []
124
+ if len(pts) >= 4:
125
+ coords = [(float(p[0]), float(p[1])) for p in pts]
126
+ starts, expansions, _ = _fallback_all_resources(coords)
127
+ resources = []
128
+ for pos in starts + expansions:
129
+ gx, gy = int(round(pos[0])), int(round(pos[1]))
130
+ resources.extend(_generate_resources_at(gx, gy))
131
+ return resources
132
+ except (OSError, json.JSONDecodeError, KeyError, ValueError):
133
+ pass
134
+ _, r1, _, r2 = _fallback_from_nav()
135
+ return r1 + r2
136
+
137
+
138
+ def get_start_data() -> tuple[
139
+ tuple[float, float], list[Resource], tuple[float, float], list[Resource]
140
+ ]:
141
+ """Return (start1, resources1, start2, resources2) for the 2 chosen bases. With 3 positions, 2 are chosen at random; resources are only those for the chosen bases."""
142
  import random
143
  path = _game_positions_path()
144
  if not path:
145
+ return _fallback_from_nav()
146
  try:
147
  with open(path, encoding="utf-8") as f:
148
  data = json.load(f)
149
  starts = data.get("starting_positions") or []
150
  if len(starts) >= 3:
 
 
 
 
151
  chosen = random.sample(starts, 2)
152
+ s1, s2 = chosen[0], chosen[1]
153
+ start1 = _to_game_coords(s1.get("x", 0), s1.get("y", 0))
154
+ start2 = _to_game_coords(s2.get("x", 0), s2.get("y", 0))
155
+ res1 = _resources_from_start_entry(s1) if "minerals" in s1 else []
156
+ res2 = _resources_from_start_entry(s2) if "minerals" in s2 else []
157
+ return (start1, res1, start2, res2)
158
  if len(starts) >= 2:
159
+ s1, s2 = starts[0], starts[1]
160
+ start1 = _to_game_coords(s1.get("x", 0), s1.get("y", 0))
161
+ start2 = _to_game_coords(s2.get("x", 0), s2.get("y", 0))
162
+ res1 = _resources_from_start_entry(s1) if "minerals" in s1 else []
163
+ res2 = _resources_from_start_entry(s2) if "minerals" in s2 else []
164
+ return (start1, res1, start2, res2)
165
  except (OSError, json.JSONDecodeError, KeyError):
166
  pass
167
+ return _fallback_from_nav()
168
+
169
+
170
+ def _pick_nav_corners(coords: list[tuple[float, float]]) -> tuple[tuple[float, float], tuple[float, float]]:
171
+ """Pick the 2 most separated nav_points (opposing corners)."""
172
+ pa1 = min(coords, key=lambda p: p[0] + p[1])
173
+ pa2 = max(coords, key=lambda p: p[0] + p[1])
174
+ pb1 = min(coords, key=lambda p: p[0] - p[1])
175
+ pb2 = max(coords, key=lambda p: p[0] - p[1])
176
+ d_a = (pa2[0] - pa1[0]) ** 2 + (pa2[1] - pa1[1]) ** 2
177
+ d_b = (pb2[0] - pb1[0]) ** 2 + (pb2[1] - pb1[1]) ** 2
178
+ return (pa1, pa2) if d_a >= d_b else (pb1, pb2)
179
+
180
+
181
+ def _pick_expansion_nav_positions(
182
+ coords: list[tuple[float, float]],
183
+ start_positions: list[tuple[float, float]],
184
+ count: int = 3,
185
+ min_dist_from_start: float = 12.0,
186
+ ) -> list[tuple[float, float]]:
187
+ """Pick expansion positions: closest nav_point to each pair midpoint, at least min_dist from any start."""
188
+ def dist2(a: tuple[float, float], b: tuple[float, float]) -> float:
189
+ return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2
190
+
191
+ candidates: list[tuple[float, float]] = []
192
+ n = len(start_positions)
193
+ for i in range(n):
194
+ for j in range(i + 1, n):
195
+ mx = (start_positions[i][0] + start_positions[j][0]) / 2
196
+ my = (start_positions[i][1] + start_positions[j][1]) / 2
197
+ # Find closest nav_point to midpoint that is far enough from all starts
198
+ nearby = [
199
+ p for p in coords
200
+ if all(dist2(p, s) >= min_dist_from_start ** 2 for s in start_positions)
201
+ ]
202
+ if nearby:
203
+ best = min(nearby, key=lambda p: dist2(p, (mx, my)))
204
+ # Avoid duplicates
205
+ if all(dist2(best, c) > 4.0 for c in candidates):
206
+ candidates.append(best)
207
+
208
+ return candidates[:count]
209
+
210
+
211
+ def _fallback_all_resources(
212
+ coords: list[tuple[float, float]],
213
+ ) -> tuple[list[tuple[float, float]], list[tuple[float, float]], list[list[Resource]]]:
214
+ """Pick start and expansion positions from nav_points and generate resources at each.
215
+
216
+ Returns (start_positions, expansion_positions, all_resource_lists).
217
+ Resources are generated via _generate_resources_at for consistent fixed-distance placement.
218
+ """
219
+ s1_f, s2_f = _pick_nav_corners(coords)
220
+ def min_dist2(p: tuple[float, float]) -> float:
221
+ return min((p[0]-s1_f[0])**2+(p[1]-s1_f[1])**2, (p[0]-s2_f[0])**2+(p[1]-s2_f[1])**2)
222
+ s3_f = max(coords, key=min_dist2)
223
+
224
+ starts = [s1_f, s2_f, s3_f]
225
+ expansions = _pick_expansion_nav_positions(coords, starts, count=3)
226
+
227
+ all_resources: list[list[Resource]] = []
228
+ for pos in starts + expansions:
229
+ gx, gy = int(round(pos[0])), int(round(pos[1]))
230
+ all_resources.append(_generate_resources_at(gx, gy))
231
+
232
+ return starts, expansions, all_resources
233
+
234
+
235
+ def _fallback_from_nav() -> tuple[
236
+ tuple[int, int], list[Resource], tuple[int, int], list[Resource]
237
+ ]:
238
+ """Pick 2 well-separated start positions from compiled nav_points and generate minerals near each."""
239
+ compiled_path = Path(__file__).resolve().parent.parent / "static" / "compiled_map.json"
240
+ if compiled_path.exists():
241
+ try:
242
+ with open(compiled_path, encoding="utf-8") as f:
243
+ data = json.load(f)
244
+ pts = data.get("nav_points") or []
245
+ if len(pts) >= 4:
246
+ coords = [(float(p[0]), float(p[1])) for p in pts]
247
+ s1_f, s2_f = _pick_nav_corners(coords)
248
+ res1 = _nav_minerals_around(s1_f, coords)
249
+ res2 = _nav_minerals_around(s2_f, coords)
250
+ return (s1_f, res1, s2_f, res2)
251
+ except (OSError, json.JSONDecodeError, KeyError, ValueError):
252
+ pass
253
+ # Last resort: use original hardcoded values (may be outside walkable area on this map)
254
+ resources: list[Resource] = []
255
+ for x, y in _P1_MINERALS:
256
+ resources.append(Resource(resource_type=ResourceType.MINERAL, x=x, y=y))
257
+ for x, y in _P1_GEYSERS:
258
+ resources.append(Resource(resource_type=ResourceType.GEYSER, x=x, y=y))
259
+ r2: list[Resource] = []
260
+ for x, y in _P2_MINERALS:
261
+ r2.append(Resource(resource_type=ResourceType.MINERAL, x=x, y=y))
262
+ for x, y in _P2_GEYSERS:
263
+ r2.append(Resource(resource_type=ResourceType.GEYSER, x=x, y=y))
264
+ return (
265
+ (float(PLAYER1_START[0]), float(PLAYER1_START[1])),
266
+ resources,
267
+ (float(PLAYER2_START[0]), float(PLAYER2_START[1])),
268
+ r2,
269
+ )
270
+
271
+
272
+ def _nav_minerals_around(
273
+ center: tuple[float, float],
274
+ all_nav: list[tuple[float, float]],
275
+ mineral_count: int = 7,
276
+ geyser_count: int = 1,
277
+ mineral_radius: float = 6.0,
278
+ geyser_radius: float = 8.0,
279
+ ) -> list[Resource]:
280
+ """Generate minerals and geysers at nav_points near a start position."""
281
+ cx, cy = center
282
+ nearby = sorted(
283
+ [p for p in all_nav if p != center and (p[0]-cx)**2 + (p[1]-cy)**2 <= mineral_radius**2],
284
+ key=lambda p: (p[0]-cx)**2 + (p[1]-cy)**2,
285
+ )
286
+ resources: list[Resource] = []
287
+ for p in nearby[:mineral_count]:
288
+ resources.append(Resource(resource_type=ResourceType.MINERAL, x=int(round(p[0])), y=int(round(p[1]))))
289
+ geyser_nearby = sorted(
290
+ [p for p in all_nav if p != center and (p[0]-cx)**2 + (p[1]-cy)**2 <= geyser_radius**2],
291
+ key=lambda p: -((p[0]-cx)**2 + (p[1]-cy)**2),
292
+ )
293
+ for p in geyser_nearby[:geyser_count]:
294
+ resources.append(Resource(resource_type=ResourceType.GEYSER, x=int(round(p[0])), y=int(round(p[1]))))
295
+ return resources
296
 
297
  # Named map zones resolved to (x, y) center coordinates
298
  # Zone values depend on player — resolved at engine level using player start positions
 
333
  resources: list[Resource] = Field(default_factory=list)
334
 
335
  @classmethod
336
+ def create_default(cls, resources: list[Resource] | None = None) -> "GameMap":
337
+ """Create map with given resources, or load from file (legacy flat minerals/geysers), or use hardcoded fallback."""
338
+ if resources is not None:
339
+ return cls(resources=resources)
340
  path = _game_positions_path()
341
  if path:
342
  try:
343
  with open(path, encoding="utf-8") as f:
344
  data = json.load(f)
345
+ # Legacy format: top-level minerals/geysers
346
+ flat_resources = []
347
  for m in data.get("minerals") or []:
348
  x, y = int(m.get("x", 0)), int(m.get("y", 0))
349
  if 0 <= x < MAP_WIDTH and 0 <= y < MAP_HEIGHT:
350
+ flat_resources.append(Resource(resource_type=ResourceType.MINERAL, x=x, y=y))
351
  for g in data.get("geysers") or []:
352
  x, y = int(g.get("x", 0)), int(g.get("y", 0))
353
  if 0 <= x < MAP_WIDTH and 0 <= y < MAP_HEIGHT:
354
+ flat_resources.append(Resource(resource_type=ResourceType.GEYSER, x=x, y=y))
355
+ if flat_resources:
356
+ return cls(resources=flat_resources)
357
  except (OSError, json.JSONDecodeError, KeyError):
358
  pass
359
+ # Use nav-based fallback which derives positions from the actual compiled map
360
+ _, r1, _, r2 = _fallback_from_nav()
361
+ return cls(resources=r1 + r2)
 
 
 
 
 
 
 
362
 
363
  def get_resource(self, resource_id: str) -> Resource | None:
364
  return next((r for r in self.resources if r.id == resource_id), None)
backend/game/map_compiler.py ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Compile walkable polygons into a single figure: one exterior boundary + holes (internal non-walkable zones).
3
+ Produces compiled_map.json used by pathfinding for walkable test and optional nav points.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from pathlib import Path
10
+
11
+
12
+ def _signed_area(polygon: list[tuple[float, float]]) -> float:
13
+ """Signed area (positive = CCW)."""
14
+ n = len(polygon)
15
+ if n < 3:
16
+ return 0.0
17
+ area = 0.0
18
+ for i in range(n):
19
+ j = (i + 1) % n
20
+ area += polygon[i][0] * polygon[j][1]
21
+ area -= polygon[j][0] * polygon[i][1]
22
+ return area / 2.0
23
+
24
+
25
+ def _centroid(polygon: list[tuple[float, float]]) -> tuple[float, float]:
26
+ n = len(polygon)
27
+ if n == 0:
28
+ return (0.0, 0.0)
29
+ cx = sum(p[0] for p in polygon) / n
30
+ cy = sum(p[1] for p in polygon) / n
31
+ return (cx, cy)
32
+
33
+
34
+ def _point_in_polygon(x: float, y: float, polygon: list[tuple[float, float]]) -> bool:
35
+ n = len(polygon)
36
+ inside = False
37
+ j = n - 1
38
+ for i in range(n):
39
+ xi, yi = polygon[i]
40
+ xj, yj = polygon[j]
41
+ if ((yi > y) != (yj > y)) and (x < (xj - xi) * (y - yi) / (yj - yi) + xi):
42
+ inside = not inside
43
+ j = i
44
+ return inside
45
+
46
+
47
+ def compile_walkable(
48
+ polygons: list[list[tuple[float, float]]],
49
+ ) -> tuple[list[tuple[float, float]], list[list[tuple[float, float]]]]:
50
+ """
51
+ From a list of polygons (each list of (x,y) in 0-100 space),
52
+ return (exterior, holes).
53
+ Exterior = polygon with largest absolute area.
54
+ Holes = polygons whose centroid is inside the exterior.
55
+ """
56
+ if not polygons:
57
+ return ([], [])
58
+
59
+ # Filter to valid polygons
60
+ valid = [p for p in polygons if len(p) >= 3]
61
+ if not valid:
62
+ return ([], [])
63
+
64
+ # Exterior = largest area
65
+ by_area = [(abs(_signed_area(p)), p) for p in valid]
66
+ by_area.sort(key=lambda x: -x[0])
67
+ exterior = by_area[0][1]
68
+ others = [p for _, p in by_area[1:]]
69
+
70
+ holes: list[list[tuple[float, float]]] = []
71
+ for poly in others:
72
+ cx, cy = _centroid(poly)
73
+ if _point_in_polygon(cx, cy, exterior):
74
+ holes.append(poly)
75
+
76
+ return (exterior, holes)
77
+
78
+
79
+ def build_nav_points(
80
+ polygons: list[list[tuple[float, float]]],
81
+ scale_x: float,
82
+ scale_y: float,
83
+ step: float = 2.0,
84
+ ) -> list[list[float]]:
85
+ """
86
+ Generate navigation points (in game coordinates) for ALL walkable polygons.
87
+ Used for pathfinding graph: units can path between these points.
88
+ """
89
+ if not polygons:
90
+ return []
91
+
92
+ all_xs = [p[0] for poly in polygons for p in poly]
93
+ all_ys = [p[1] for poly in polygons for p in poly]
94
+ min_x, max_x = min(all_xs), max(all_xs)
95
+ min_y, max_y = min(all_ys), max(all_ys)
96
+
97
+ def is_walkable_100(x: float, y: float) -> bool:
98
+ return any(_point_in_polygon(x, y, poly) for poly in polygons)
99
+
100
+ points: list[list[float]] = []
101
+ x = min_x
102
+ while x <= max_x:
103
+ y = min_y
104
+ while y <= max_y:
105
+ if is_walkable_100(x, y):
106
+ points.append([x * scale_x / 100.0, y * scale_y / 100.0])
107
+ y += step
108
+ x += step
109
+ return points
110
+
111
+
112
+ def run_compiler(
113
+ walkable_path: Path,
114
+ output_path: Path,
115
+ map_width: float = 80.0,
116
+ map_height: float = 80.0,
117
+ nav_step: float = 2.0,
118
+ ) -> None:
119
+ """Load walkable.json, compile to exterior + holes, write compiled_map.json."""
120
+ scale_x = map_width / 100.0
121
+ scale_y = map_height / 100.0
122
+
123
+ if not walkable_path.exists():
124
+ raise FileNotFoundError(f"Walkable file not found: {walkable_path}")
125
+
126
+ with open(walkable_path, encoding="utf-8") as f:
127
+ data = json.load(f)
128
+
129
+ raw = data.get("polygons", [])
130
+ if not raw and data.get("polygon"):
131
+ raw = [data["polygon"]]
132
+
133
+ polygons: list[list[tuple[float, float]]] = []
134
+ for poly in raw:
135
+ if len(poly) < 3:
136
+ continue
137
+ polygons.append([(float(p[0]), float(p[1])) for p in poly])
138
+
139
+ exterior, holes = compile_walkable(polygons)
140
+
141
+ nav_points_game = build_nav_points(polygons, map_width, map_height, step=nav_step)
142
+
143
+ out = {
144
+ "exterior": [[round(x, 4), round(y, 4)] for x, y in exterior],
145
+ "holes": [[[round(x, 4), round(y, 4)] for x, y in h] for h in holes],
146
+ "nav_points": [[round(x, 4), round(y, 4)] for x, y in nav_points_game],
147
+ }
148
+ with open(output_path, "w", encoding="utf-8") as f:
149
+ json.dump(out, f, indent=2)
150
+
151
+
152
+ if __name__ == "__main__":
153
+ static_dir = Path(__file__).resolve().parent.parent / "static"
154
+ run_compiler(
155
+ static_dir / "walkable.json",
156
+ static_dir / "compiled_map.json",
157
+ )
backend/game/pathfinding.py CHANGED
@@ -1,28 +1,150 @@
1
  """
2
- Pathfinding constrained to walkable polygons (walkable.json).
3
- Coordinates: walkable.json uses 0-100 space; game uses 0-MAP_WIDTH, 0-MAP_HEIGHT.
 
4
  """
5
 
6
  from __future__ import annotations
7
 
8
  import heapq
9
  import json
 
10
  from pathlib import Path
11
  from typing import Optional
12
 
13
  from .map import MAP_HEIGHT, MAP_WIDTH
14
 
15
- # Walkable data is in 0-100; game is 0-40
16
- WALKABLE_SCALE = 0.01 # 100 -> 1.0, then we scale by MAP_WIDTH/MAP_HEIGHT
17
- # So game_x = raw_x * (MAP_WIDTH / 100) = raw_x * 0.4
18
  SCALE_X = MAP_WIDTH / 100.0
19
  SCALE_Y = MAP_HEIGHT / 100.0
20
 
21
  _GRID_STEP = 0.5
22
  _GRID_W = int(MAP_WIDTH / _GRID_STEP) + 1
23
  _GRID_H = int(MAP_HEIGHT / _GRID_STEP) + 1
 
 
 
 
24
  _polygons_cache: Optional[list[list[tuple[float, float]]]] = None
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
  def _load_polygons() -> list[list[tuple[float, float]]]:
28
  global _polygons_cache
@@ -63,8 +185,12 @@ def _point_in_polygon(x: float, y: float, polygon: list[tuple[float, float]]) ->
63
  return inside
64
 
65
 
66
- def is_walkable(x: float, y: float) -> bool:
67
- """True if (x, y) is inside any walkable polygon. Uses game coordinates 0..MAP_WIDTH, 0..MAP_HEIGHT."""
 
 
 
 
68
  if x < 0 or x > MAP_WIDTH or y < 0 or y > MAP_HEIGHT:
69
  return False
70
  polygons = _load_polygons()
@@ -76,6 +202,25 @@ def is_walkable(x: float, y: float) -> bool:
76
  return False
77
 
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  def _cell_center(ci: int, cj: int) -> tuple[float, float]:
80
  return (ci * _GRID_STEP, cj * _GRID_STEP)
81
 
@@ -88,35 +233,126 @@ def _to_cell(x: float, y: float) -> tuple[int, int]:
88
  return (ci, cj)
89
 
90
 
91
- def _cell_walkable(ci: int, cj: int) -> bool:
 
 
 
 
92
  cx, cy = _cell_center(ci, cj)
93
- return is_walkable(cx, cy)
94
 
95
 
96
- def _neighbors(ci: int, cj: int) -> list[tuple[int, int, float]]:
 
 
 
 
97
  out: list[tuple[int, int, float]] = []
98
  for di in (-1, 0, 1):
99
  for dj in (-1, 0, 1):
100
  if di == 0 and dj == 0:
101
  continue
102
  ni, nj = ci + di, cj + dj
103
- if 0 <= ni < _GRID_W and 0 <= nj < _GRID_H and _cell_walkable(ni, nj):
104
  cost = 1.414 if di != 0 and dj != 0 else 1.0
105
  out.append((ni, nj, cost))
106
  return out
107
 
108
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  def find_path(
110
- sx: float, sy: float, tx: float, ty: float
 
 
 
 
 
111
  ) -> Optional[list[tuple[float, float]]]:
112
  """
113
  A* path from (sx,sy) to (tx,ty) in game coordinates.
 
 
114
  Returns list of waypoints (including end), or None if no path.
115
- Returns [] if start/end not walkable or start==end.
116
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  si, sj = _to_cell(sx, sy)
118
  ti, tj = _to_cell(tx, ty)
119
- if not _cell_walkable(si, sj) or not _cell_walkable(ti, tj):
120
  return None
121
  if si == ti and sj == tj:
122
  return [(tx, ty)]
@@ -124,59 +360,85 @@ def find_path(
124
  def heuristic(i: int, j: int) -> float:
125
  return ((ti - i) ** 2 + (tj - j) ** 2) ** 0.5
126
 
127
- # (f, counter, (ci, cj), path)
128
  counter = 0
129
- open_set: list[tuple[float, int, int, int, list[tuple[int, int]]]] = []
130
- heapq.heappush(
131
- open_set,
132
- (heuristic(si, sj), counter, si, sj, [(si, sj)])
133
- )
134
  counter += 1
135
- closed: set[tuple[int, int]] = set()
 
 
136
 
137
  while open_set:
138
- _, _, ci, cj, path = heapq.heappop(open_set)
139
- if (ci, cj) in closed:
140
- continue
141
- closed.add((ci, cj))
142
  if ci == ti and cj == tj:
143
- # path = [start_cell, ..., end_cell]; waypoints from first step to end
144
- waypoints = [_cell_center(i, j) for i, j in path[1:]]
 
 
 
 
 
 
145
  if waypoints:
146
  waypoints[-1] = (tx, ty)
147
  else:
148
  waypoints = [(tx, ty)]
149
  return waypoints
150
- for ni, nj, cost in _neighbors(ci, cj):
151
- if (ni, nj) in closed:
152
- continue
153
- new_path = path + [(ni, nj)]
154
- g = sum(
155
- (new_path[k+1][0] - new_path[k][0]) ** 2 + (new_path[k+1][1] - new_path[k][1]) ** 2
156
- for k in range(len(new_path) - 1)
157
- ) ** 0.5
158
- f = g + heuristic(ni, nj)
159
- heapq.heappush(open_set, (f, counter, ni, nj, new_path))
160
- counter += 1
161
  return None
162
 
163
 
164
- def snap_to_walkable(x: float, y: float) -> tuple[float, float]:
165
- """Return nearest walkable point to (x,y). If (x,y) is walkable, return it."""
166
- if is_walkable(x, y):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  return (x, y)
168
  best = (x, y)
169
  best_d = 1e9
170
- for radius in [0.5, 1.0, 1.5, 2.0]:
171
- for dx in [-radius, 0, radius]:
172
- for dy in [-radius, 0, radius]:
173
- if dx == 0 and dy == 0 and radius != 0:
174
  continue
175
  nx = max(0, min(MAP_WIDTH, x + dx))
176
  ny = max(0, min(MAP_HEIGHT, y + dy))
177
- if is_walkable(nx, ny):
178
  d = (nx - x) ** 2 + (ny - y) ** 2
179
  if d < best_d:
180
  best_d = d
181
  best = (nx, ny)
 
 
182
  return best
 
1
  """
2
+ Pathfinding constrained to walkable area: one exterior polygon + holes (compiled_map.json),
3
+ or fallback to walkable.json polygons. Supports dynamic obstacles (building footprints).
4
+ Coordinates: sources use 0-100; game uses 0-MAP_WIDTH, 0-MAP_HEIGHT.
5
  """
6
 
7
  from __future__ import annotations
8
 
9
  import heapq
10
  import json
11
+ import math
12
  from pathlib import Path
13
  from typing import Optional
14
 
15
  from .map import MAP_HEIGHT, MAP_WIDTH
16
 
 
 
 
17
  SCALE_X = MAP_WIDTH / 100.0
18
  SCALE_Y = MAP_HEIGHT / 100.0
19
 
20
  _GRID_STEP = 0.5
21
  _GRID_W = int(MAP_WIDTH / _GRID_STEP) + 1
22
  _GRID_H = int(MAP_HEIGHT / _GRID_STEP) + 1
23
+
24
+ # Compiled map: (exterior_scaled, holes_scaled) in game coords, or None
25
+ _compiled_cache: Optional[tuple[list[tuple[float, float]], list[list[tuple[float, float]]]]] = None
26
+ # Legacy: list of polygons in game coords (when no compiled map)
27
  _polygons_cache: Optional[list[list[tuple[float, float]]]] = None
28
 
29
+ # Nav graph: (points_list, adjacency_dict). points_list[i] = (x, y) in game coords; adjacency[i] = [(j, cost), ...]
30
+ _nav_graph_cache: Optional[tuple[list[tuple[float, float]], dict[int, list[tuple[int, float]]]]] = None
31
+ # step=2.0 → cardinal dist=2.0, diagonal dist≈2.83; use 2.9 to include diagonals
32
+ _NAV_CONNECT_RADIUS = 2.9
33
+ # Clearance margin around buildings when filtering nav points (should match UNIT_RADIUS in engine)
34
+ _NAV_BUILDING_MARGIN = 0.6
35
+
36
+
37
+ def _load_compiled() -> Optional[tuple[list[tuple[float, float]], list[list[tuple[float, float]]]]]:
38
+ global _compiled_cache
39
+ if _compiled_cache is not None:
40
+ return _compiled_cache
41
+ path = Path(__file__).resolve().parent.parent / "static" / "compiled_map.json"
42
+ if not path.exists():
43
+ return None
44
+ try:
45
+ with open(path, encoding="utf-8") as f:
46
+ data = json.load(f)
47
+ except (OSError, json.JSONDecodeError):
48
+ return None
49
+ exterior_raw = data.get("exterior", [])
50
+ holes_raw = data.get("holes", [])
51
+ if len(exterior_raw) < 3:
52
+ return None
53
+ exterior = [(float(p[0]) * SCALE_X, float(p[1]) * SCALE_Y) for p in exterior_raw]
54
+ holes = [
55
+ [(float(p[0]) * SCALE_X, float(p[1]) * SCALE_Y) for p in h]
56
+ for h in holes_raw if len(h) >= 3
57
+ ]
58
+ _compiled_cache = (exterior, holes)
59
+ return _compiled_cache
60
+
61
+
62
+ def _load_nav_graph() -> Optional[tuple[list[tuple[float, float]], dict[int, list[tuple[int, float]]]]]:
63
+ """Load nav_points from compiled_map.json (game coords) and build adjacency graph. Returns (points, adj) or None."""
64
+ global _nav_graph_cache
65
+ if _nav_graph_cache is not None:
66
+ return _nav_graph_cache
67
+ path = Path(__file__).resolve().parent.parent / "static" / "compiled_map.json"
68
+ if not path.exists():
69
+ return None
70
+ try:
71
+ with open(path, encoding="utf-8") as f:
72
+ data = json.load(f)
73
+ except (OSError, json.JSONDecodeError):
74
+ return None
75
+ raw = data.get("nav_points", [])
76
+ if len(raw) < 2:
77
+ return None
78
+ points: list[tuple[float, float]] = [(float(p[0]), float(p[1])) for p in raw]
79
+ adj: dict[int, list[tuple[int, float]]] = {i: [] for i in range(len(points))}
80
+ for i in range(len(points)):
81
+ xi, yi = points[i]
82
+ for j in range(i + 1, len(points)):
83
+ xj, yj = points[j]
84
+ d = ((xj - xi) ** 2 + (yj - yi) ** 2) ** 0.5
85
+ if d <= _NAV_CONNECT_RADIUS and d > 0:
86
+ adj[i].append((j, d))
87
+ adj[j].append((i, d))
88
+ _nav_graph_cache = (points, adj)
89
+ return _nav_graph_cache
90
+
91
+
92
+ def _nav_point_blocked(
93
+ x: float,
94
+ y: float,
95
+ blocked_rects: list[tuple[float, float, float, float]],
96
+ ) -> bool:
97
+ """True if nav point (x, y) is inside or too close to any building rect."""
98
+ for rx, ry, w, h in blocked_rects:
99
+ cx = max(rx, min(rx + w, x))
100
+ cy = max(ry, min(ry + h, y))
101
+ if math.hypot(x - cx, y - cy) < _NAV_BUILDING_MARGIN:
102
+ return True
103
+ return False
104
+
105
+
106
+ def _nav_valid_set(
107
+ points: list[tuple[float, float]],
108
+ blocked_rects: Optional[list[tuple[float, float, float, float]]],
109
+ ) -> Optional[set[int]]:
110
+ """Return set of nav point indices not blocked by buildings, or None if no filtering needed."""
111
+ if not blocked_rects:
112
+ return None # all valid
113
+ return {
114
+ i for i, (px, py) in enumerate(points)
115
+ if not _nav_point_blocked(px, py, blocked_rects)
116
+ }
117
+
118
+
119
+ def _nearest_nav_index(
120
+ x: float,
121
+ y: float,
122
+ points: list[tuple[float, float]],
123
+ valid: Optional[set[int]] = None,
124
+ ) -> int:
125
+ """Return index of nav point closest to (x, y), restricted to valid set if given."""
126
+ best_i = -1
127
+ best_d = float("inf")
128
+ for i in range(len(points)):
129
+ if valid is not None and i not in valid:
130
+ continue
131
+ px, py = points[i]
132
+ d = (px - x) ** 2 + (py - y) ** 2
133
+ if d < best_d:
134
+ best_d = d
135
+ best_i = i
136
+ if best_i == -1:
137
+ # fallback: ignore valid constraint
138
+ best_i = 0
139
+ best_d = (points[0][0] - x) ** 2 + (points[0][1] - y) ** 2
140
+ for i in range(1, len(points)):
141
+ px, py = points[i]
142
+ d = (px - x) ** 2 + (py - y) ** 2
143
+ if d < best_d:
144
+ best_d = d
145
+ best_i = i
146
+ return best_i
147
+
148
 
149
  def _load_polygons() -> list[list[tuple[float, float]]]:
150
  global _polygons_cache
 
185
  return inside
186
 
187
 
188
+ def _point_in_rect(x: float, y: float, rx: float, ry: float, w: float, h: float) -> bool:
189
+ return rx <= x < rx + w and ry <= y < ry + h
190
+
191
+
192
+ def _static_walkable(x: float, y: float) -> bool:
193
+ """True if (x,y) is in any walkable polygon (all polygons from walkable.json)."""
194
  if x < 0 or x > MAP_WIDTH or y < 0 or y > MAP_HEIGHT:
195
  return False
196
  polygons = _load_polygons()
 
202
  return False
203
 
204
 
205
+ def is_walkable(
206
+ x: float,
207
+ y: float,
208
+ *,
209
+ blocked_rects: Optional[list[tuple[float, float, float, float]]] = None,
210
+ ) -> bool:
211
+ """
212
+ True if (x, y) is walkable (inside exterior, not in holes, not in any blocked rect).
213
+ blocked_rects: optional list of (x, y, width, height) in game coordinates (e.g. building footprints).
214
+ """
215
+ if not _static_walkable(x, y):
216
+ return False
217
+ if blocked_rects:
218
+ for rx, ry, w, h in blocked_rects:
219
+ if _point_in_rect(x, y, rx, ry, w, h):
220
+ return False
221
+ return True
222
+
223
+
224
  def _cell_center(ci: int, cj: int) -> tuple[float, float]:
225
  return (ci * _GRID_STEP, cj * _GRID_STEP)
226
 
 
233
  return (ci, cj)
234
 
235
 
236
+ def _cell_walkable(
237
+ ci: int,
238
+ cj: int,
239
+ blocked_rects: Optional[list[tuple[float, float, float, float]]] = None,
240
+ ) -> bool:
241
  cx, cy = _cell_center(ci, cj)
242
+ return is_walkable(cx, cy, blocked_rects=blocked_rects)
243
 
244
 
245
+ def _neighbors(
246
+ ci: int,
247
+ cj: int,
248
+ blocked_rects: Optional[list[tuple[float, float, float, float]]] = None,
249
+ ) -> list[tuple[int, int, float]]:
250
  out: list[tuple[int, int, float]] = []
251
  for di in (-1, 0, 1):
252
  for dj in (-1, 0, 1):
253
  if di == 0 and dj == 0:
254
  continue
255
  ni, nj = ci + di, cj + dj
256
+ if 0 <= ni < _GRID_W and 0 <= nj < _GRID_H and _cell_walkable(ni, nj, blocked_rects):
257
  cost = 1.414 if di != 0 and dj != 0 else 1.0
258
  out.append((ni, nj, cost))
259
  return out
260
 
261
 
262
+ def _find_path_navgraph(
263
+ sx: float, sy: float, tx: float, ty: float,
264
+ points: list[tuple[float, float]],
265
+ adj: dict[int, list[tuple[int, float]]],
266
+ blocked_rects: Optional[list[tuple[float, float, float, float]]] = None,
267
+ ) -> Optional[list[tuple[float, float]]]:
268
+ """A* on nav graph, skipping points inside buildings. Returns waypoints or None."""
269
+ if not points or not adj:
270
+ return None
271
+
272
+ valid = _nav_valid_set(points, blocked_rects)
273
+ start_i = _nearest_nav_index(sx, sy, points, valid)
274
+ end_i = _nearest_nav_index(tx, ty, points, valid)
275
+ if start_i == end_i:
276
+ return [(tx, ty)]
277
+
278
+ def heuristic(i: int) -> float:
279
+ px, py = points[i]
280
+ return ((tx - px) ** 2 + (ty - py) ** 2) ** 0.5
281
+
282
+ counter = 0
283
+ best_g: dict[int, float] = {start_i: 0.0}
284
+ came_from: dict[int, Optional[int]] = {start_i: None}
285
+ open_set: list[tuple[float, int, int]] = []
286
+ heapq.heappush(open_set, (heuristic(start_i), counter, start_i))
287
+ counter += 1
288
+
289
+ while open_set:
290
+ f, _, node_i = heapq.heappop(open_set)
291
+ g = best_g.get(node_i, float("inf"))
292
+ if f > g + heuristic(node_i) + 1e-9:
293
+ continue # stale entry
294
+ if node_i == end_i:
295
+ # Reconstruct
296
+ path_indices: list[int] = []
297
+ cur: Optional[int] = node_i
298
+ while cur is not None:
299
+ path_indices.append(cur)
300
+ cur = came_from[cur]
301
+ path_indices.reverse()
302
+ result = [points[i] for i in path_indices]
303
+ if result:
304
+ result[-1] = (tx, ty)
305
+ else:
306
+ result = [(tx, ty)]
307
+ return result
308
+ for j, cost in adj.get(node_i, []):
309
+ if valid is not None and j not in valid:
310
+ continue
311
+ new_g = g + cost
312
+ if new_g < best_g.get(j, float("inf")):
313
+ best_g[j] = new_g
314
+ came_from[j] = node_i
315
+ heapq.heappush(open_set, (new_g + heuristic(j), counter, j))
316
+ counter += 1
317
+ return None
318
+
319
+
320
  def find_path(
321
+ sx: float,
322
+ sy: float,
323
+ tx: float,
324
+ ty: float,
325
+ *,
326
+ blocked_rects: Optional[list[tuple[float, float, float, float]]] = None,
327
  ) -> Optional[list[tuple[float, float]]]:
328
  """
329
  A* path from (sx,sy) to (tx,ty) in game coordinates.
330
+ Uses nav-point graph when available and no dynamic obstacles; else grid A*.
331
+ blocked_rects: optional list of (x, y, width, height) to avoid (e.g. buildings).
332
  Returns list of waypoints (including end), or None if no path.
 
333
  """
334
+ nav = _load_nav_graph()
335
+ if nav is not None:
336
+ points, adj = nav
337
+ path = _find_path_navgraph(sx, sy, tx, ty, points, adj, blocked_rects=blocked_rects)
338
+ if path is not None:
339
+ return path
340
+
341
+ return _find_path_grid(sx, sy, tx, ty, blocked_rects=blocked_rects)
342
+
343
+
344
+ def _find_path_grid(
345
+ sx: float,
346
+ sy: float,
347
+ tx: float,
348
+ ty: float,
349
+ *,
350
+ blocked_rects: Optional[list[tuple[float, float, float, float]]] = None,
351
+ ) -> Optional[list[tuple[float, float]]]:
352
+ """Grid A* path (used when nav graph unavailable or blocked_rects present)."""
353
  si, sj = _to_cell(sx, sy)
354
  ti, tj = _to_cell(tx, ty)
355
+ if not _cell_walkable(si, sj, blocked_rects) or not _cell_walkable(ti, tj, blocked_rects):
356
  return None
357
  if si == ti and sj == tj:
358
  return [(tx, ty)]
 
360
  def heuristic(i: int, j: int) -> float:
361
  return ((ti - i) ** 2 + (tj - j) ** 2) ** 0.5
362
 
 
363
  counter = 0
364
+ # Heap entries: (f, counter, ci, cj, g, parent_cell_or_None)
365
+ open_set: list[tuple[float, int, int, int, float, Optional[tuple[int, int]]]] = []
366
+ heapq.heappush(open_set, (heuristic(si, sj), counter, si, sj, 0.0, None))
 
 
367
  counter += 1
368
+ # best g seen per cell; also acts as closed set when set to a finite value
369
+ best_g: dict[tuple[int, int], float] = {(si, sj): 0.0}
370
+ came_from: dict[tuple[int, int], Optional[tuple[int, int]]] = {(si, sj): None}
371
 
372
  while open_set:
373
+ f, _, ci, cj, g, _ = heapq.heappop(open_set)
374
+ if g > best_g.get((ci, cj), float("inf")):
375
+ continue # stale entry
 
376
  if ci == ti and cj == tj:
377
+ # Reconstruct path
378
+ cells: list[tuple[int, int]] = []
379
+ cur: Optional[tuple[int, int]] = (ci, cj)
380
+ while cur is not None:
381
+ cells.append(cur)
382
+ cur = came_from[cur]
383
+ cells.reverse()
384
+ waypoints = [_cell_center(i, j) for i, j in cells[1:]]
385
  if waypoints:
386
  waypoints[-1] = (tx, ty)
387
  else:
388
  waypoints = [(tx, ty)]
389
  return waypoints
390
+ for ni, nj, cost in _neighbors(ci, cj, blocked_rects):
391
+ new_g = g + cost
392
+ if new_g < best_g.get((ni, nj), float("inf")):
393
+ best_g[(ni, nj)] = new_g
394
+ came_from[(ni, nj)] = (ci, cj)
395
+ f_new = new_g + heuristic(ni, nj)
396
+ heapq.heappush(open_set, (f_new, counter, ni, nj, new_g, (ci, cj)))
397
+ counter += 1
 
 
 
398
  return None
399
 
400
 
401
+ def nearest_walkable_navpoint(
402
+ x: float,
403
+ y: float,
404
+ *,
405
+ blocked_rects: Optional[list[tuple[float, float, float, float]]] = None,
406
+ ) -> tuple[float, float]:
407
+ """Return the nearest nav point that is walkable (not inside any building).
408
+ Falls back to snap_to_walkable if no nav graph available."""
409
+ nav = _load_nav_graph()
410
+ if nav is not None:
411
+ points, _ = nav
412
+ valid = _nav_valid_set(points, blocked_rects)
413
+ best_i = _nearest_nav_index(x, y, points, valid)
414
+ if best_i >= 0:
415
+ return points[best_i]
416
+ return snap_to_walkable(x, y, blocked_rects=blocked_rects)
417
+
418
+
419
+ def snap_to_walkable(
420
+ x: float,
421
+ y: float,
422
+ *,
423
+ blocked_rects: Optional[list[tuple[float, float, float, float]]] = None,
424
+ ) -> tuple[float, float]:
425
+ """Return nearest walkable point to (x,y)."""
426
+ if is_walkable(x, y, blocked_rects=blocked_rects):
427
  return (x, y)
428
  best = (x, y)
429
  best_d = 1e9
430
+ for radius in [0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 5.0]:
431
+ for dx in [-radius, -radius * 0.5, 0, radius * 0.5, radius]:
432
+ for dy in [-radius, -radius * 0.5, 0, radius * 0.5, radius]:
433
+ if dx == 0 and dy == 0:
434
  continue
435
  nx = max(0, min(MAP_WIDTH, x + dx))
436
  ny = max(0, min(MAP_HEIGHT, y + dy))
437
+ if is_walkable(nx, ny, blocked_rects=blocked_rects):
438
  d = (nx - x) ** 2 + (ny - y) ** 2
439
  if d < best_d:
440
  best_d = d
441
  best = (nx, ny)
442
+ if best_d < 1e9:
443
+ break
444
  return best
backend/game/state.py CHANGED
@@ -6,7 +6,8 @@ from typing import Optional
6
  from pydantic import BaseModel, Field
7
 
8
  from .buildings import Building, BuildingStatus, BuildingType, BUILDING_DEFS
9
- from .map import GameMap, get_start_positions
 
10
  from .units import Unit, UnitType, UNIT_DEFS, UnitStatus
11
 
12
 
@@ -68,7 +69,7 @@ class PlayerState(BaseModel):
68
  None,
69
  )
70
 
71
- def summary(self) -> str:
72
  active = [
73
  b.building_type.value for b in self.buildings.values()
74
  if b.status not in (BuildingStatus.CONSTRUCTING, BuildingStatus.DESTROYED)
@@ -82,14 +83,21 @@ class PlayerState(BaseModel):
82
  for u in self.units.values():
83
  counts[u.unit_type.value] = counts.get(u.unit_type.value, 0) + 1
84
 
 
 
 
 
 
 
 
85
  lines = [
86
- f"Minéraux: {self.minerals}, Gaz: {self.gas}, Supply: {self.supply_used}/{self.supply_max}",
87
- f"Bâtiments actifs: {', '.join(active) or 'aucun'}",
88
  ]
89
  if constructing:
90
- lines.append(f"En construction: {', '.join(constructing)}")
91
  if counts:
92
- lines.append(f"Unités: {', '.join(f'{v} {k}' for k, v in counts.items())}")
93
  return "\n".join(lines)
94
 
95
 
@@ -115,29 +123,38 @@ class GameState(BaseModel):
115
  p1 = PlayerState(player_id=player1_id, player_name=player1_name)
116
  p2 = PlayerState(player_id=player2_id, player_name=player2_name)
117
 
118
- start1, start2 = get_start_positions()
 
119
 
120
- # Starting Command Centers (already built)
121
- cc1 = Building.create(BuildingType.COMMAND_CENTER, player1_id, *start1)
122
  cc1.status = BuildingStatus.ACTIVE
123
  cc1.construction_ticks_remaining = 0
 
124
  p1.buildings[cc1.id] = cc1
125
 
126
- cc2 = Building.create(BuildingType.COMMAND_CENTER, player2_id, *start2)
127
  cc2.status = BuildingStatus.ACTIVE
128
  cc2.construction_ticks_remaining = 0
 
129
  p2.buildings[cc2.id] = cc2
130
 
131
- # 5 starting SCVs per player, positioned south of CC
132
- for i in range(5):
133
- scv1 = Unit.create(UnitType.SCV, player1_id,
134
- start1[0] + 1 + i * 0.8,
135
- start1[1] + 4)
 
 
 
 
 
136
  p1.units[scv1.id] = scv1
137
 
138
- scv2 = Unit.create(UnitType.SCV, player2_id,
139
- start2[0] + 1 + i * 0.8,
140
- start2[1] - 1)
 
141
  p2.units[scv2.id] = scv2
142
 
143
  p1.recalculate_supply()
 
6
  from pydantic import BaseModel, Field
7
 
8
  from .buildings import Building, BuildingStatus, BuildingType, BUILDING_DEFS
9
+ from .map import GameMap, get_start_data, get_all_map_resources
10
+ from .pathfinding import snap_to_walkable
11
  from .units import Unit, UnitType, UNIT_DEFS, UnitStatus
12
 
13
 
 
69
  None,
70
  )
71
 
72
+ def summary(self, lang: str = "fr") -> str:
73
  active = [
74
  b.building_type.value for b in self.buildings.values()
75
  if b.status not in (BuildingStatus.CONSTRUCTING, BuildingStatus.DESTROYED)
 
83
  for u in self.units.values():
84
  counts[u.unit_type.value] = counts.get(u.unit_type.value, 0) + 1
85
 
86
+ if (lang or "fr").lower() == "en":
87
+ minerals_l, gas_l, supply_l = "Minerals", "Gas", "Supply"
88
+ buildings_l, constructing_l, units_l, none_l = "Active buildings", "Under construction", "Units", "none"
89
+ else:
90
+ minerals_l, gas_l, supply_l = "Minéraux", "Gaz", "Supply"
91
+ buildings_l, constructing_l, units_l, none_l = "Bâtiments actifs", "En construction", "Unités", "aucun"
92
+
93
  lines = [
94
+ f"{minerals_l}: {self.minerals}, {gas_l}: {self.gas}, {supply_l}: {self.supply_used}/{self.supply_max}",
95
+ f"{buildings_l}: {', '.join(active) or none_l}",
96
  ]
97
  if constructing:
98
+ lines.append(f"{constructing_l}: {', '.join(constructing)}")
99
  if counts:
100
+ lines.append(f"{units_l}: {', '.join(f'{v} {k}' for k, v in counts.items())}")
101
  return "\n".join(lines)
102
 
103
 
 
123
  p1 = PlayerState(player_id=player1_id, player_name=player1_name)
124
  p2 = PlayerState(player_id=player2_id, player_name=player2_name)
125
 
126
+ start1, _, start2, _ = get_start_data()
127
+ state.game_map = GameMap.create_default(resources=get_all_map_resources())
128
 
129
+ # Starting Command Centers (already built); x,y is center of building footprint
130
+ cc1 = Building.create(BuildingType.COMMAND_CENTER, player1_id, float(start1[0]), float(start1[1]))
131
  cc1.status = BuildingStatus.ACTIVE
132
  cc1.construction_ticks_remaining = 0
133
+ cc1.hp = float(cc1.max_hp)
134
  p1.buildings[cc1.id] = cc1
135
 
136
+ cc2 = Building.create(BuildingType.COMMAND_CENTER, player2_id, float(start2[0]), float(start2[1]))
137
  cc2.status = BuildingStatus.ACTIVE
138
  cc2.construction_ticks_remaining = 0
139
+ cc2.hp = float(cc2.max_hp)
140
  p2.buildings[cc2.id] = cc2
141
 
142
+ # 5 starting SCVs per player, spread in x around CC; snap each to nearest walkable point
143
+ _MAP_W = 80.0
144
+ _MAP_H = 80.0
145
+ # Spacing must be > 2*UNIT_RADIUS (1.0) to avoid collision-blocking at spawn
146
+ _scv_offsets = [(-2.4, 3), (-1.2, 3), (0.0, 3), (1.2, 3), (2.4, 3)]
147
+ for i, (ox, oy) in enumerate(_scv_offsets):
148
+ raw1x = max(0.5, min(_MAP_W - 0.5, start1[0] + ox))
149
+ raw1y = max(0.5, min(_MAP_H - 0.5, start1[1] + oy))
150
+ sx1, sy1 = snap_to_walkable(raw1x, raw1y)
151
+ scv1 = Unit.create(UnitType.SCV, player1_id, sx1, sy1)
152
  p1.units[scv1.id] = scv1
153
 
154
+ raw2x = max(0.5, min(_MAP_W - 0.5, start2[0] + ox))
155
+ raw2y = max(0.5, min(_MAP_H - 0.5, start2[1] - oy))
156
+ sx2, sy2 = snap_to_walkable(raw2x, raw2y)
157
+ scv2 = Unit.create(UnitType.SCV, player2_id, sx2, sy2)
158
  p2.units[scv2.id] = scv2
159
 
160
  p1.recalculate_supply()
backend/game/tech_tree.py CHANGED
@@ -24,7 +24,7 @@ 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],
 
24
  BuildingType.COMMAND_CENTER: [],
25
  BuildingType.SUPPLY_DEPOT: [],
26
  BuildingType.REFINERY: [],
27
+ BuildingType.BARRACKS: [BuildingType.SUPPLY_DEPOT],
28
  BuildingType.ENGINEERING_BAY: [BuildingType.BARRACKS],
29
  BuildingType.FACTORY: [BuildingType.BARRACKS],
30
  BuildingType.ARMORY: [BuildingType.FACTORY],
backend/game/units.py CHANGED
@@ -22,6 +22,7 @@ class UnitStatus(str, Enum):
22
  ATTACKING = "attacking"
23
  MINING_MINERALS = "mining_minerals"
24
  MINING_GAS = "mining_gas"
 
25
  BUILDING = "building"
26
  HEALING = "healing"
27
  SIEGED = "sieged"
@@ -74,17 +75,15 @@ UNIT_DEFS: dict[UnitType, UnitDef] = {
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
  }
@@ -118,6 +117,7 @@ class Unit(BaseModel):
118
  building_target_id: Optional[str] = None
119
  harvest_carry: bool = False # True while carrying resources back to CC
120
  harvest_amount: int = 0 # amount being carried (deposited on CC arrival)
 
121
 
122
  @classmethod
123
  def create(cls, unit_type: UnitType, owner: str, x: float, y: float) -> "Unit":
 
22
  ATTACKING = "attacking"
23
  MINING_MINERALS = "mining_minerals"
24
  MINING_GAS = "mining_gas"
25
+ MOVING_TO_BUILD = "moving_to_build"
26
  BUILDING = "building"
27
  HEALING = "healing"
28
  SIEGED = "sieged"
 
75
  supply_cost=2, build_time_ticks=40, attack_cooldown_ticks=4,
76
  ),
77
  UnitType.TANK: UnitDef(
78
+ max_hp=150, armor=1, ground_damage=35, air_damage=0,
79
  attack_range=7, move_speed=0.75, mineral_cost=150, gas_cost=100,
80
+ supply_cost=3, build_time_ticks=50,
81
  attack_cooldown_ticks=5,
 
 
82
  ),
83
  UnitType.WRAITH: UnitDef(
84
  max_hp=120, armor=0, ground_damage=8, air_damage=20,
85
  attack_range=5, move_speed=2.5, mineral_cost=150, gas_cost=100,
86
+ supply_cost=2, build_time_ticks=60, is_flying=True,
87
  attack_cooldown_ticks=4,
88
  ),
89
  }
 
117
  building_target_id: Optional[str] = None
118
  harvest_carry: bool = False # True while carrying resources back to CC
119
  harvest_amount: int = 0 # amount being carried (deposited on CC arrival)
120
+ stuck_ticks: int = 0 # consecutive ticks the unit couldn't move
121
 
122
  @classmethod
123
  def create(cls, unit_type: UnitType, owner: str, x: float, y: float) -> "Unit":
backend/lobby/manager.py CHANGED
@@ -199,6 +199,17 @@ class LobbyManager:
199
  def get_room(self, room_id: str) -> Optional[Room]:
200
  return self._rooms.get(room_id)
201
 
 
 
 
 
 
 
 
 
 
 
 
202
  def get_room_for_sid(self, sid: str) -> Optional[Room]:
203
  return self._get_room(sid)
204
 
 
199
  def get_room(self, room_id: str) -> Optional[Room]:
200
  return self._rooms.get(room_id)
201
 
202
+ def get_playing_count(self) -> int:
203
+ """Return number of rooms currently in 'playing' status."""
204
+ return sum(1 for r in self._rooms.values() if r.status == "playing")
205
+
206
+ def get_a_playing_room_id(self) -> Optional[str]:
207
+ """Return one room_id in 'playing' status for observe, or None."""
208
+ for room_id, room in self._rooms.items():
209
+ if room.status == "playing":
210
+ return room_id
211
+ return None
212
+
213
  def get_room_for_sid(self, sid: str) -> Optional[Room]:
214
  return self._get_room(sid)
215
 
backend/lobby/safe_name.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Validation du pseudo joueur via Mistral : détection d'insultes / noms inappropriés.
3
+ Si le nom est jugé insultant, il est remplacé par un nom aléatoire inoffensif.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import logging
10
+ import random
11
+
12
+ from config import MISTRAL_API_KEY, MISTRAL_CHAT_MODEL
13
+
14
+ log = logging.getLogger(__name__)
15
+
16
+ _FALLBACK_NAMES = [
17
+ "Player",
18
+ "Guest",
19
+ "Strategist",
20
+ "Commander",
21
+ "Pilot",
22
+ "Scout",
23
+ "Rookie",
24
+ ]
25
+
26
+ _PROMPT = """Tu dois juger si un pseudo de joueur pour un jeu en ligne est insultant, vulgaire, discriminatoire ou inapproprié pour un public familial.
27
+
28
+ Réponds UNIQUEMENT avec un JSON valide, sans texte avant ni après, avec exactement ces deux champs :
29
+ - "is_insulting": true si le pseudo est inapproprié (insulte, gros mot, contenu choquant), false sinon.
30
+ - "replacement": si is_insulting est true, un pseudo de remplacement court, sympa et inoffensif (ex: "Chevalier", "Pilote"); sinon la chaîne vide "".
31
+
32
+ Pseudo à évaluer : "{name}"
33
+ """
34
+
35
+
36
+ async def sanitize_player_name(name: str) -> str:
37
+ """
38
+ Vérifie le nom via Mistral. Si insultant, retourne un remplacement ; sinon retourne le nom tel quel.
39
+ En cas d'absence de clé API ou d'erreur, retourne le nom original.
40
+ """
41
+ name = (name or "").strip() or "Player"
42
+ if not MISTRAL_API_KEY:
43
+ return name
44
+
45
+ try:
46
+ from mistralai import Mistral
47
+
48
+ client = Mistral(api_key=MISTRAL_API_KEY)
49
+ response = await client.chat.complete_async(
50
+ model=MISTRAL_CHAT_MODEL,
51
+ messages=[{"role": "user", "content": _PROMPT.format(name=name)}],
52
+ response_format={"type": "json_object"},
53
+ )
54
+ content = response.choices[0].message.content
55
+ if isinstance(content, str):
56
+ raw = content.strip()
57
+ elif content:
58
+ first = content[0]
59
+ raw = (getattr(first, "text", None) or str(first)).strip()
60
+ else:
61
+ raw = ""
62
+ # Extraire un éventuel bloc JSON
63
+ if "```" in raw:
64
+ raw = raw.split("```")[1]
65
+ if raw.startswith("json"):
66
+ raw = raw[4:]
67
+ data = json.loads(raw)
68
+ if data.get("is_insulting") and data.get("replacement"):
69
+ replacement = str(data["replacement"]).strip() or random.choice(_FALLBACK_NAMES)
70
+ log.info("Safe name: %r replaced by %r", name, replacement)
71
+ return replacement[:50]
72
+ return name[:50]
73
+ except Exception as e:
74
+ log.warning("Safe name check failed for %r: %s", name, e)
75
+ return name[:50]
backend/main.py CHANGED
@@ -11,6 +11,8 @@ Client → Server
11
  join_room { room_id, name }
12
  quick_match { name }
13
  player_ready {}
 
 
14
  voice_input { audio_b64, mime_type? }
15
  text_input { text }
16
  disconnect (automatic)
@@ -25,6 +27,8 @@ Server → Client
25
  voice_result { transcription, feedback_text, feedback_audio_b64, results }
26
  game_over { winner_id, winner_name }
27
  error { message }
 
 
28
  """
29
 
30
  from __future__ import annotations
@@ -33,13 +37,14 @@ import asyncio
33
  import base64
34
  import logging
35
  import os
 
36
  import subprocess
37
  import sys
38
  from pathlib import Path
39
- from typing import Optional
40
 
41
  import socketio
42
- from fastapi import Body, FastAPI, HTTPException
43
  from fastapi.middleware.cors import CORSMiddleware
44
  from fastapi.staticfiles import StaticFiles
45
  from fastapi.responses import FileResponse
@@ -48,6 +53,7 @@ from game.bot import BOT_PLAYER_ID, BotPlayer
48
  from game.engine import GameEngine
49
  from game.state import GameState
50
  from lobby.manager import LobbyManager
 
51
  from voice import command_parser, stt
52
 
53
  BOT_OFFER_DELAY = 10 # seconds before offering bot opponent
@@ -55,6 +61,35 @@ BOT_OFFER_DELAY = 10 # seconds before offering bot opponent
55
  logging.basicConfig(level=logging.INFO)
56
  log = logging.getLogger(__name__)
57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  # ---------------------------------------------------------------------------
59
  # Socket.IO + FastAPI setup
60
  # ---------------------------------------------------------------------------
@@ -76,6 +111,32 @@ fastapi_app.add_middleware(
76
  allow_headers=["*"],
77
  )
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  # Unit sound effects (generated by scripts/generate_unit_sounds.py)
80
  _SOUNDS_DIR = Path(__file__).parent / "static" / "sounds"
81
  _UNITS_SOUNDS_DIR = _SOUNDS_DIR / "units"
@@ -84,23 +145,32 @@ _UNITS_SOUNDS_DIR.mkdir(parents=True, exist_ok=True)
84
  fastapi_app.mount("/sounds", StaticFiles(directory=str(_SOUNDS_DIR)), name="sounds")
85
 
86
  # Sprites (unit/building icons generated via Mistral image API)
87
- _SPRITES_DIR = Path(__file__).parent / "static" / "sprites"
88
  _SPRITES_DIR.mkdir(parents=True, exist_ok=True)
89
  if _SPRITES_DIR.exists():
90
  fastapi_app.mount("/sprites", StaticFiles(directory=str(_SPRITES_DIR)), name="sprites")
91
 
92
- # Map image, map.json, walkable polygon
93
- _STATIC_DIR = Path(__file__).parent / "static"
94
  if _STATIC_DIR.exists():
95
  fastapi_app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
96
 
97
  _MAP_JSON = _STATIC_DIR / "map.json"
98
  _WALKABLE_JSON = _STATIC_DIR / "walkable.json"
 
99
  _GAME_POSITIONS_JSON = _STATIC_DIR / "game_positions.json"
100
 
101
  # Game grid size (must match game/map.py)
102
- _MAP_GRID_W = 40
103
- _MAP_GRID_H = 40
 
 
 
 
 
 
 
 
 
 
104
 
105
 
106
  def _load_game_positions() -> dict:
@@ -238,12 +308,12 @@ def _generate_geysers_around(cx: int, cy: int, count: int = 1, radius: float = 4
238
 
239
  @fastapi_app.put("/api/map/positions")
240
  async def save_map_positions(body: dict = Body(...)):
241
- """Enregistre positions de départ (exactement 3, 2 seront tirées au sort par partie) et expansions, génère minerais (et geysers) autour de chacune."""
242
  import json
243
  starts = body.get("starting_positions")
244
  expansions = body.get("expansion_positions")
245
- if not isinstance(starts, list) or len(starts) != 3:
246
- raise HTTPException(400, "starting_positions requis (exactement 3 positions {x, y} en 0-100)")
247
  if not isinstance(expansions, list):
248
  expansions = []
249
  for i, p in enumerate(starts):
@@ -259,27 +329,37 @@ async def save_map_positions(body: dict = Body(...)):
259
  if not (0 <= x <= 100 and 0 <= y <= 100):
260
  raise HTTPException(400, f"expansion_positions[{i}] hors [0,100]")
261
 
262
- # Keep positions in 0-100 for admin display
263
- starting_positions = [{"x": float(p["x"]), "y": float(p["y"])} for p in starts]
264
- expansion_positions = [{"x": float(p["x"]), "y": float(p["y"])} for p in expansions]
265
-
266
- minerals = []
267
- geysers = []
268
- for p in starting_positions + expansion_positions:
269
- gx, gy = _admin_to_game_x(p["x"]), _admin_to_game_y(p["y"])
270
- minerals.extend(_generate_minerals_around(gx, gy, count=7))
271
- geysers.extend(_generate_geysers_around(gx, gy, count=1))
 
 
 
 
 
 
 
 
 
 
 
 
272
 
273
  payload = {
274
  "starting_positions": starting_positions,
275
  "expansion_positions": expansion_positions,
276
- "minerals": minerals,
277
- "geysers": geysers,
278
  }
279
  _STATIC_DIR.mkdir(parents=True, exist_ok=True)
280
  with open(_GAME_POSITIONS_JSON, "w", encoding="utf-8") as f:
281
  json.dump(payload, f, indent=2)
282
- return {"status": "ok", "minerals_count": len(minerals), "geysers_count": len(geysers)}
283
 
284
 
285
  @fastapi_app.get("/api/sounds/units")
@@ -340,7 +420,7 @@ async def generate_sprites():
340
 
341
  def run():
342
  return subprocess.run(
343
- [sys.executable, "-m", "scripts.generate_sprites"],
344
  cwd=str(Path(__file__).parent),
345
  capture_output=True,
346
  text=True,
@@ -398,6 +478,49 @@ async def generate_one_building_sprite(building_id: str):
398
  return {"status": "ok", "id": building_id}
399
 
400
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
401
  # Serve SvelteKit static build if present (production / HF Spaces)
402
  _FRONTEND_BUILD = Path(__file__).parent.parent / "frontend" / "build"
403
  if _FRONTEND_BUILD.exists():
@@ -435,6 +558,28 @@ async def _emit_error(sid: str, message: str) -> None:
435
  await sio.emit("error", {"message": message}, to=sid)
436
 
437
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
438
  async def _start_game(room_id: str) -> None:
439
  """Create game state + engine, emit game_start to human players."""
440
  room = lobby.get_room(room_id)
@@ -540,9 +685,30 @@ async def disconnect(sid: str) -> None:
540
  # ---------------------------------------------------------------------------
541
 
542
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
543
  @sio.event
544
  async def create_room(sid: str, data: dict) -> None:
545
  name = str(data.get("name", "Player")).strip() or "Player"
 
546
  room = lobby.create_room(sid, name)
547
  await sio.enter_room(sid, room.room_id)
548
  await sio.emit("room_created", {"room_id": room.room_id, "room": room.to_dict()}, to=sid)
@@ -556,6 +722,7 @@ async def create_room(sid: str, data: dict) -> None:
556
  async def join_room(sid: str, data: dict) -> None:
557
  room_id = str(data.get("room_id", "")).upper().strip()
558
  name = str(data.get("name", "Player")).strip() or "Player"
 
559
 
560
  room, err = lobby.join_room(sid, room_id, name)
561
  if err:
@@ -576,6 +743,7 @@ async def join_room(sid: str, data: dict) -> None:
576
  @sio.event
577
  async def quick_match(sid: str, data: dict) -> None:
578
  name = str(data.get("name", "Player")).strip() or "Player"
 
579
  room, is_new = lobby.quick_match(sid, name)
580
 
581
  if not is_new:
@@ -706,14 +874,15 @@ async def voice_input(sid: str, data: dict) -> None:
706
  await sio.emit("voice_result", {
707
  "transcription": "",
708
  "feedback_text": "Je n'ai rien entendu. Appuie et parle!",
709
- "feedback_audio_b64": "",
710
  "results": [],
711
  }, to=sid)
712
  return
713
 
714
  # 2. Parse command with Mistral
 
715
  try:
716
- parsed = await command_parser.parse(transcription, player)
717
  except Exception as exc:
718
  log.exception("Command parsing failed")
719
  await _emit_error(sid, f"Erreur d'interprétation: {exc}")
@@ -722,21 +891,20 @@ async def voice_input(sid: str, data: dict) -> None:
722
  # 3. Apply commands to game engine
723
  cmd_result = engine.apply_command(sid, parsed)
724
 
725
- feedback_text = (
726
- cmd_result.feedback_override
727
- if cmd_result.feedback_override
728
- else parsed.feedback
729
- )
730
-
731
- # Replace/append actual engine results so failures are always heard
732
- failures = [r.message for r in cmd_result.results if not r.success and r.message]
733
- if failures:
734
- feedback_text = " ".join(failures)
735
-
736
- # Append query result to feedback if present
737
- for r in cmd_result.results:
738
- if r.action_type == "query" and r.success:
739
- feedback_text += "\n" + r.message
740
 
741
  sound_events = []
742
  for r in cmd_result.results:
@@ -744,6 +912,7 @@ async def voice_input(sid: str, data: dict) -> None:
744
  await sio.emit("voice_result", {
745
  "transcription": transcription,
746
  "feedback_text": feedback_text,
 
747
  "results": [r.model_dump() for r in cmd_result.results],
748
  "sound_events": sound_events,
749
  }, to=sid)
@@ -779,8 +948,9 @@ async def _process_text_command(sid: str, transcription: str) -> None:
779
  return
780
 
781
  # Parse command with Mistral
 
782
  try:
783
- parsed = await command_parser.parse(transcription, player)
784
  except Exception as exc:
785
  log.exception("Command parsing failed")
786
  await _emit_error(sid, f"Erreur d'interprétation: {exc}")
@@ -789,19 +959,20 @@ async def _process_text_command(sid: str, transcription: str) -> None:
789
  # Apply commands to game engine
790
  cmd_result = engine.apply_command(sid, parsed)
791
 
792
- feedback_text = (
793
- cmd_result.feedback_override
794
- if cmd_result.feedback_override
795
- else parsed.feedback
796
- )
797
-
798
- failures = [r.message for r in cmd_result.results if not r.success and r.message]
799
- if failures:
800
- feedback_text = " ".join(failures)
801
-
802
- for r in cmd_result.results:
803
- if r.action_type == "query" and r.success:
804
- feedback_text += "\n" + r.message
 
805
 
806
  sound_events = []
807
  for r in cmd_result.results:
@@ -809,6 +980,7 @@ async def _process_text_command(sid: str, transcription: str) -> None:
809
  await sio.emit("voice_result", {
810
  "transcription": transcription,
811
  "feedback_text": feedback_text,
 
812
  "results": [r.model_dump() for r in cmd_result.results],
813
  "sound_events": sound_events,
814
  }, to=sid)
 
11
  join_room { room_id, name }
12
  quick_match { name }
13
  player_ready {}
14
+ get_playing_count {} — server responds with playing_count
15
+ observe {} — server responds with observe_room or error
16
  voice_input { audio_b64, mime_type? }
17
  text_input { text }
18
  disconnect (automatic)
 
27
  voice_result { transcription, feedback_text, feedback_audio_b64, results }
28
  game_over { winner_id, winner_name }
29
  error { message }
30
+ playing_count { count } — in response to get_playing_count
31
+ observe_room { room_id } — in response to observe (spectator)
32
  """
33
 
34
  from __future__ import annotations
 
37
  import base64
38
  import logging
39
  import os
40
+ import re
41
  import subprocess
42
  import sys
43
  from pathlib import Path
44
+ from typing import Any, Optional
45
 
46
  import socketio
47
+ from fastapi import APIRouter, Body, FastAPI, HTTPException
48
  from fastapi.middleware.cors import CORSMiddleware
49
  from fastapi.staticfiles import StaticFiles
50
  from fastapi.responses import FileResponse
 
53
  from game.engine import GameEngine
54
  from game.state import GameState
55
  from lobby.manager import LobbyManager
56
+ from lobby.safe_name import sanitize_player_name
57
  from voice import command_parser, stt
58
 
59
  BOT_OFFER_DELAY = 10 # seconds before offering bot opponent
 
61
  logging.basicConfig(level=logging.INFO)
62
  log = logging.getLogger(__name__)
63
 
64
+
65
+ def _fill_template(template: str, data: dict[str, Any]) -> str:
66
+ """Fill placeholders {key} in template with data; missing keys become empty string."""
67
+ placeholders = re.findall(r"\{(\w+)\}", template)
68
+ safe = {k: str(data.get(k, "")) for k in placeholders}
69
+ try:
70
+ return template.format(**safe)
71
+ except KeyError:
72
+ return template
73
+
74
+
75
+ def _compute_feedback_level(cmd_result) -> str:
76
+ """Derive ok/warning/error from command results."""
77
+ if cmd_result.feedback_override:
78
+ return "error"
79
+ results = cmd_result.results
80
+ if not results:
81
+ return "error"
82
+ successes = sum(1 for r in results if r.success)
83
+ if successes == 0:
84
+ return "error"
85
+ if successes < len(results):
86
+ return "warning"
87
+ # All succeeded — check if any result carries an error key
88
+ for r in results:
89
+ if r.data and "error" in r.data:
90
+ return "warning"
91
+ return "ok"
92
+
93
  # ---------------------------------------------------------------------------
94
  # Socket.IO + FastAPI setup
95
  # ---------------------------------------------------------------------------
 
111
  allow_headers=["*"],
112
  )
113
 
114
+ # Paths for static assets (must serve before any catch-all mount)
115
+ _STATIC_DIR = Path(__file__).parent / "static"
116
+ _SPRITES_DIR = Path(__file__).parent / "static" / "sprites"
117
+
118
+ _static_router = APIRouter()
119
+
120
+ @_static_router.get("/static/MAP.png", response_class=FileResponse)
121
+ def _serve_map_png():
122
+ p = _STATIC_DIR / "MAP.png"
123
+ if not p.is_file():
124
+ raise HTTPException(404, "MAP.png not found")
125
+ return FileResponse(p, media_type="image/png")
126
+
127
+ @_static_router.get("/sprites/{kind}/{filename:path}", response_class=FileResponse)
128
+ def _serve_sprite(kind: str, filename: str):
129
+ p = _SPRITES_DIR / kind / filename
130
+ if not p.is_file():
131
+ raise HTTPException(404, "Sprite not found")
132
+ return FileResponse(
133
+ p,
134
+ media_type="image/png",
135
+ headers={"Cache-Control": "no-store"},
136
+ )
137
+
138
+ fastapi_app.include_router(_static_router)
139
+
140
  # Unit sound effects (generated by scripts/generate_unit_sounds.py)
141
  _SOUNDS_DIR = Path(__file__).parent / "static" / "sounds"
142
  _UNITS_SOUNDS_DIR = _SOUNDS_DIR / "units"
 
145
  fastapi_app.mount("/sounds", StaticFiles(directory=str(_SOUNDS_DIR)), name="sounds")
146
 
147
  # Sprites (unit/building icons generated via Mistral image API)
 
148
  _SPRITES_DIR.mkdir(parents=True, exist_ok=True)
149
  if _SPRITES_DIR.exists():
150
  fastapi_app.mount("/sprites", StaticFiles(directory=str(_SPRITES_DIR)), name="sprites")
151
 
152
+ # Map image, map.json, walkable polygon (fallback for other static files)
 
153
  if _STATIC_DIR.exists():
154
  fastapi_app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
155
 
156
  _MAP_JSON = _STATIC_DIR / "map.json"
157
  _WALKABLE_JSON = _STATIC_DIR / "walkable.json"
158
+ _COMPILED_MAP_JSON = _STATIC_DIR / "compiled_map.json"
159
  _GAME_POSITIONS_JSON = _STATIC_DIR / "game_positions.json"
160
 
161
  # Game grid size (must match game/map.py)
162
+ _MAP_GRID_W = 80
163
+ _MAP_GRID_H = 80
164
+
165
+ # Compile walkable → exterior + holes once at server startup
166
+ if _WALKABLE_JSON.exists():
167
+ try:
168
+ from game.map import MAP_WIDTH as _MAP_W, MAP_HEIGHT as _MAP_H
169
+ from game.map_compiler import run_compiler
170
+ run_compiler(_WALKABLE_JSON, _COMPILED_MAP_JSON, map_width=_MAP_W, map_height=_MAP_H)
171
+ log.info("Map compiled: %s", _COMPILED_MAP_JSON)
172
+ except Exception as e:
173
+ log.warning("Map compiler skipped: %s", e)
174
 
175
 
176
  def _load_game_positions() -> dict:
 
308
 
309
  @fastapi_app.put("/api/map/positions")
310
  async def save_map_positions(body: dict = Body(...)):
311
+ """Enregistre positions de départ (2 ou 3, si 3 alors 2 seront tirées au sort par partie) et expansions."""
312
  import json
313
  starts = body.get("starting_positions")
314
  expansions = body.get("expansion_positions")
315
+ if not isinstance(starts, list) or len(starts) < 2:
316
+ raise HTTPException(400, "starting_positions requis (au moins 2 positions {x, y} en 0-100)")
317
  if not isinstance(expansions, list):
318
  expansions = []
319
  for i, p in enumerate(starts):
 
329
  if not (0 <= x <= 100 and 0 <= y <= 100):
330
  raise HTTPException(400, f"expansion_positions[{i}] hors [0,100]")
331
 
332
+ # Keep positions in 0-100 for admin display; embed minerals/geysers per start so we load only the 2 chosen bases' resources
333
+ starting_positions = []
334
+ total_minerals = 0
335
+ total_geysers = 0
336
+ for p in starts:
337
+ x, y = float(p["x"]), float(p["y"])
338
+ gx, gy = _admin_to_game_x(x), _admin_to_game_y(y)
339
+ minerals = _generate_minerals_around(gx, gy, count=7)
340
+ geysers = _generate_geysers_around(gx, gy, count=1)
341
+ total_minerals += len(minerals)
342
+ total_geysers += len(geysers)
343
+ starting_positions.append({"x": x, "y": y, "minerals": minerals, "geysers": geysers})
344
+
345
+ expansion_positions = []
346
+ for p in expansions:
347
+ x, y = float(p["x"]), float(p["y"])
348
+ gx, gy = _admin_to_game_x(x), _admin_to_game_y(y)
349
+ minerals = _generate_minerals_around(gx, gy, count=7)
350
+ geysers = _generate_geysers_around(gx, gy, count=1)
351
+ total_minerals += len(minerals)
352
+ total_geysers += len(geysers)
353
+ expansion_positions.append({"x": x, "y": y, "minerals": minerals, "geysers": geysers})
354
 
355
  payload = {
356
  "starting_positions": starting_positions,
357
  "expansion_positions": expansion_positions,
 
 
358
  }
359
  _STATIC_DIR.mkdir(parents=True, exist_ok=True)
360
  with open(_GAME_POSITIONS_JSON, "w", encoding="utf-8") as f:
361
  json.dump(payload, f, indent=2)
362
+ return {"status": "ok", "minerals_count": total_minerals, "geysers_count": total_geysers}
363
 
364
 
365
  @fastapi_app.get("/api/sounds/units")
 
420
 
421
  def run():
422
  return subprocess.run(
423
+ [sys.executable, "-m", "scripts.generate_sprites", "--skip-existing"],
424
  cwd=str(Path(__file__).parent),
425
  capture_output=True,
426
  text=True,
 
478
  return {"status": "ok", "id": building_id}
479
 
480
 
481
+ @fastapi_app.get("/api/sprites/resources")
482
+ async def list_resource_sprites():
483
+ """Liste les sprites de ressources présents."""
484
+ return {"sprites": _list_sprites(_SPRITES_DIR / "resources")}
485
+
486
+
487
+ @fastapi_app.post("/api/sprites/generate/resources/{resource_id}")
488
+ async def generate_one_resource_sprite(resource_id: str):
489
+ """Régénère une seule icône de ressource (mineral, geyser)."""
490
+ resource_id = resource_id.strip().lower()
491
+ valid = ["mineral", "geyser"]
492
+ if resource_id not in valid:
493
+ raise HTTPException(400, f"Resource invalide. Valides: {valid}")
494
+ loop = asyncio.get_event_loop()
495
+ result = await loop.run_in_executor(None, lambda: _run_sprite_script(["--resource", resource_id]))
496
+ if result.returncode != 0:
497
+ raise HTTPException(500, result.stderr or result.stdout or "Échec génération")
498
+ return {"status": "ok", "id": resource_id}
499
+
500
+
501
+ _ICONS_DIR = _SPRITES_DIR / "icons"
502
+
503
+
504
+ @fastapi_app.get("/api/icons")
505
+ async def list_icons():
506
+ """Liste les icônes UI présentes."""
507
+ return {"icons": _list_sprites(_ICONS_DIR)}
508
+
509
+
510
+ @fastapi_app.post("/api/icons/generate/{icon_id}")
511
+ async def generate_one_icon(icon_id: str):
512
+ """Génère une icône UI symbolique (mineral, gas, supply)."""
513
+ icon_id = icon_id.strip().lower()
514
+ valid = ["mineral", "gas", "supply"]
515
+ if icon_id not in valid:
516
+ raise HTTPException(400, f"Icône invalide. Valides: {valid}")
517
+ loop = asyncio.get_event_loop()
518
+ result = await loop.run_in_executor(None, lambda: _run_sprite_script(["--icon", icon_id]))
519
+ if result.returncode != 0:
520
+ raise HTTPException(500, result.stderr or result.stdout or "Échec génération")
521
+ return {"status": "ok", "id": icon_id}
522
+
523
+
524
  # Serve SvelteKit static build if present (production / HF Spaces)
525
  _FRONTEND_BUILD = Path(__file__).parent.parent / "frontend" / "build"
526
  if _FRONTEND_BUILD.exists():
 
558
  await sio.emit("error", {"message": message}, to=sid)
559
 
560
 
561
+ def _build_resource_zones(engine: Any, player_id: str) -> list[str]:
562
+ """Return sorted resource zone names (mineral_1…N, geyser_1…M) by proximity to player base."""
563
+ from game.map import ResourceType as _RT
564
+ player = engine.state.players.get(player_id)
565
+ cc = player.command_center() if player else None
566
+ base_x = float(cc.x) + 2 if cc else 40.0
567
+ base_y = float(cc.y) + 2 if cc else 40.0
568
+ resources = engine.state.game_map.resources
569
+
570
+ minerals = sorted(
571
+ [r for r in resources if r.resource_type == _RT.MINERAL and not r.is_depleted],
572
+ key=lambda r: (r.x - base_x) ** 2 + (r.y - base_y) ** 2,
573
+ )
574
+ geysers = sorted(
575
+ [r for r in resources if r.resource_type == _RT.GEYSER],
576
+ key=lambda r: (r.x - base_x) ** 2 + (r.y - base_y) ** 2,
577
+ )
578
+ zones = [f"mineral_{i + 1}" for i in range(len(minerals))]
579
+ zones += [f"geyser_{i + 1}" for i in range(len(geysers))]
580
+ return zones
581
+
582
+
583
  async def _start_game(room_id: str) -> None:
584
  """Create game state + engine, emit game_start to human players."""
585
  room = lobby.get_room(room_id)
 
685
  # ---------------------------------------------------------------------------
686
 
687
 
688
+ @sio.event
689
+ async def get_playing_count(sid: str, data: dict) -> None:
690
+ count = lobby.get_playing_count()
691
+ await sio.emit("playing_count", {"count": count}, to=sid)
692
+
693
+
694
+ @sio.event
695
+ async def observe(sid: str, data: dict) -> None:
696
+ room_id = lobby.get_a_playing_room_id()
697
+ if not room_id:
698
+ await _emit_error(sid, "Aucune partie en cours à observer.")
699
+ return
700
+ await sio.enter_room(sid, room_id)
701
+ engine = engines.get(room_id)
702
+ payload = {"room_id": room_id}
703
+ if engine:
704
+ payload["game_state"] = engine.state.model_dump(mode="json")
705
+ await sio.emit("observe_room", payload, to=sid)
706
+
707
+
708
  @sio.event
709
  async def create_room(sid: str, data: dict) -> None:
710
  name = str(data.get("name", "Player")).strip() or "Player"
711
+ name = await sanitize_player_name(name)
712
  room = lobby.create_room(sid, name)
713
  await sio.enter_room(sid, room.room_id)
714
  await sio.emit("room_created", {"room_id": room.room_id, "room": room.to_dict()}, to=sid)
 
722
  async def join_room(sid: str, data: dict) -> None:
723
  room_id = str(data.get("room_id", "")).upper().strip()
724
  name = str(data.get("name", "Player")).strip() or "Player"
725
+ name = await sanitize_player_name(name)
726
 
727
  room, err = lobby.join_room(sid, room_id, name)
728
  if err:
 
743
  @sio.event
744
  async def quick_match(sid: str, data: dict) -> None:
745
  name = str(data.get("name", "Player")).strip() or "Player"
746
+ name = await sanitize_player_name(name)
747
  room, is_new = lobby.quick_match(sid, name)
748
 
749
  if not is_new:
 
874
  await sio.emit("voice_result", {
875
  "transcription": "",
876
  "feedback_text": "Je n'ai rien entendu. Appuie et parle!",
877
+ "feedback_level": "warning",
878
  "results": [],
879
  }, to=sid)
880
  return
881
 
882
  # 2. Parse command with Mistral
883
+ resource_zones = _build_resource_zones(engine, sid)
884
  try:
885
+ parsed = await command_parser.parse(transcription, player, resource_zones=resource_zones)
886
  except Exception as exc:
887
  log.exception("Command parsing failed")
888
  await _emit_error(sid, f"Erreur d'interprétation: {exc}")
 
891
  # 3. Apply commands to game engine
892
  cmd_result = engine.apply_command(sid, parsed)
893
 
894
+ if cmd_result.feedback_override:
895
+ feedback_text = await command_parser.generate_feedback(
896
+ cmd_result.feedback_override, parsed.language
897
+ )
898
+ else:
899
+ merged: dict[str, Any] = {}
900
+ for r in cmd_result.results:
901
+ merged.update(r.data or {})
902
+ if "error" in merged:
903
+ feedback_text = await command_parser.generate_feedback(
904
+ merged["error"], parsed.language
905
+ )
906
+ else:
907
+ feedback_text = _fill_template(parsed.feedback_template, merged)
 
908
 
909
  sound_events = []
910
  for r in cmd_result.results:
 
912
  await sio.emit("voice_result", {
913
  "transcription": transcription,
914
  "feedback_text": feedback_text,
915
+ "feedback_level": _compute_feedback_level(cmd_result),
916
  "results": [r.model_dump() for r in cmd_result.results],
917
  "sound_events": sound_events,
918
  }, to=sid)
 
948
  return
949
 
950
  # Parse command with Mistral
951
+ resource_zones = _build_resource_zones(engine, sid)
952
  try:
953
+ parsed = await command_parser.parse(transcription, player, resource_zones=resource_zones)
954
  except Exception as exc:
955
  log.exception("Command parsing failed")
956
  await _emit_error(sid, f"Erreur d'interprétation: {exc}")
 
959
  # Apply commands to game engine
960
  cmd_result = engine.apply_command(sid, parsed)
961
 
962
+ if cmd_result.feedback_override:
963
+ feedback_text = await command_parser.generate_feedback(
964
+ cmd_result.feedback_override, parsed.language
965
+ )
966
+ else:
967
+ merged = {}
968
+ for r in cmd_result.results:
969
+ merged.update(r.data or {})
970
+ if "error" in merged:
971
+ feedback_text = await command_parser.generate_feedback(
972
+ merged["error"], parsed.language
973
+ )
974
+ else:
975
+ feedback_text = _fill_template(parsed.feedback_template, merged)
976
 
977
  sound_events = []
978
  for r in cmd_result.results:
 
980
  await sio.emit("voice_result", {
981
  "transcription": transcription,
982
  "feedback_text": feedback_text,
983
+ "feedback_level": _compute_feedback_level(cmd_result),
984
  "results": [r.model_dump() for r in cmd_result.results],
985
  "sound_events": sound_events,
986
  }, to=sid)
backend/requirements.txt CHANGED
@@ -5,3 +5,5 @@ python-dotenv>=1.0.0
5
  pydantic>=2.0.0
6
  mistralai>=1.0.0
7
  httpx>=0.27.0
 
 
 
5
  pydantic>=2.0.0
6
  mistralai>=1.0.0
7
  httpx>=0.27.0
8
+ google-genai>=1.0.0
9
+ Pillow>=10.0.0
backend/scripts/generate_map_lod.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Génère des versions basse résolution de MAP.png pour le chargement progressif.
4
+
5
+ MAP_quarter.png → 1/4 de la taille originale (chargement rapide ~1-2 Mo)
6
+ MAP_half.png → 1/2 de la taille originale (qualité intermédiaire ~7 Mo)
7
+
8
+ Usage :
9
+ cd backend && python -m scripts.generate_map_lod
10
+ cd backend && python -m scripts.generate_map_lod --input static/MAP.png
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ from PIL import Image
19
+
20
+ _backend = Path(__file__).resolve().parent.parent
21
+ if str(_backend) not in sys.path:
22
+ sys.path.insert(0, str(_backend))
23
+
24
+
25
+ def generate_lods(input_path: Path) -> None:
26
+ src = Image.open(input_path).convert("RGB")
27
+ w, h = src.size
28
+ size_mb = input_path.stat().st_size / 1024 / 1024
29
+ print(f"Source : {w}×{h} ({size_mb:.1f} Mo) → {input_path}")
30
+
31
+ lods = [
32
+ ("MAP_half.png", (w // 2, h // 2)),
33
+ ("MAP_quarter.png", (w // 4, h // 4)),
34
+ ]
35
+
36
+ for filename, size in lods:
37
+ out_path = input_path.parent / filename
38
+ resized = src.resize(size, Image.LANCZOS)
39
+ resized.save(out_path, format="PNG", optimize=True)
40
+ out_mb = out_path.stat().st_size / 1024 / 1024
41
+ print(f" {filename:25s} {size[0]}×{size[1]} ({out_mb:.1f} Mo) → {out_path}")
42
+
43
+
44
+ def main() -> None:
45
+ default = _backend / "static" / "MAP.png"
46
+ parser = argparse.ArgumentParser(description="Génère MAP_half.png et MAP_quarter.png")
47
+ parser.add_argument("--input", type=Path, default=default)
48
+ args = parser.parse_args()
49
+ if not args.input.is_file():
50
+ raise SystemExit(f"Fichier introuvable : {args.input}")
51
+ generate_lods(args.input)
52
+
53
+
54
+ if __name__ == "__main__":
55
+ main()
backend/scripts/generate_sprites.py CHANGED
@@ -1,14 +1,16 @@
1
  #!/usr/bin/env python3
2
  """
3
- Génère les sprites PNG (vues de dessus, transparent, style SF Starcraft/Warhammer)
4
- via l'API Mistral (agent avec outil image_generation).
5
- Chaque unité et bâtiment a un prompt détaillé pour un rendu homogène.
 
 
6
 
7
  Usage : cd backend && python -m scripts.generate_sprites
8
- Nécessite MISTRAL_API_KEY dans .env
9
  """
10
  from __future__ import annotations
11
 
 
12
  import sys
13
  from pathlib import Path
14
 
@@ -21,12 +23,10 @@ import random
21
  import time
22
  from typing import Any
23
 
24
- from mistralai import Mistral
25
- from mistralai.models import ToolFileChunk
26
-
27
- from config import MISTRAL_API_KEY
28
  from game.units import UnitType
29
  from game.buildings import BuildingType, BUILDING_DEFS
 
30
 
31
  logging.basicConfig(level=logging.INFO)
32
  log = logging.getLogger(__name__)
@@ -34,6 +34,18 @@ log = logging.getLogger(__name__)
34
  SPRITES_DIR = _backend / "static" / "sprites"
35
  UNITS_DIR = SPRITES_DIR / "units"
36
  BUILDINGS_DIR = SPRITES_DIR / "buildings"
 
 
 
 
 
 
 
 
 
 
 
 
37
 
38
  # Backoff pour rate limit (429)
39
  BACKOFF_BASE_SEC = 30
@@ -49,25 +61,41 @@ CRITICAL - SMALL SIZE (to avoid rate limits):
49
  - Generate SMALL, LOW-RESOLUTION images only. For units: exactly 128x128 pixels. For buildings: longest side 128 pixels. Do NOT generate large or high-resolution images (no 512, 1024, etc.). Small output is required.
50
  - MANDATORY transparent background: only the subject has visible pixels; everything else fully transparent (PNG with alpha). No ground, no floor, no solid color.
51
 
52
- CRITICAL - VIEW ANGLE:
53
- - Strict top-down view (bird's eye, camera directly above). Only the top surface/footprint visible. No side view, no 3/4, no isometric.
 
 
 
54
 
55
  Other: subject fills frame, minimal margins. Dark metallic, blue/gray palette, industrial sci-fi. No text or labels. One image per request, no commentary."""
56
 
57
  # Vue strictement de dessus + fond transparent + remplir le cadre
58
  _TOP = "Strict top-down view only (bird's eye, camera directly above, 90°). Only the top surface/footprint visible, no side or perspective. "
59
- _FILL = "MANDATORY transparent background only (no ground, no floor, no solid color). Subject must fill the frame tightly, edge to edge. "
60
  # Petite image demandée à l'API pour éviter le rate limit (pas de post-traitement)
61
  _SMALL = "Generate a SMALL low-resolution image only: 128x128 pixels for square sprites, or longest side 128 for rectangles. Tiny size. Do NOT output large or high-resolution (no 512, 1024, etc.). "
62
 
63
  # Prompts par unité : petite 128x128, fond transparent
64
  UNIT_PROMPTS: dict[str, str] = {
65
- UnitType.SCV.value: _TOP + _FILL + _SMALL + "Single SCV worker from above: mech footprint, two arms, cockpit. Industrial gray and blue.",
66
- UnitType.MARINE.value: _TOP + _FILL + _SMALL + "Single Terran Marine from above: oval footprint, weapon. Dark blue armor.",
67
- UnitType.MEDIC.value: _TOP + _FILL + _SMALL + "Single Medic from above: compact footprint, cross/medical symbol. Sci-fi armor.",
68
- UnitType.GOLIATH.value: _TOP + _FILL + _SMALL + "Goliath walker from above: two gun pods, central body. Dark metal and blue.",
69
- UnitType.TANK.value: _TOP + _FILL + _SMALL + "Siege Tank from above: elongated hull, round turret, treads. Military gray and blue.",
70
- UnitType.WRAITH.value: _TOP + _FILL + _SMALL + "Wraith starfighter from above: delta wing, cockpit. Dark with blue.",
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  }
72
 
73
  # Prompts par bâtiment : petite image (longest side 128), fond transparent
@@ -86,8 +114,110 @@ def _building_prompt(building_type: BuildingType) -> str:
86
  return base + " Transparent background only. Dark metal and blue."
87
 
88
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  def _find_file_id_in_response(response: Any) -> str | None:
90
  """Extrait le file_id du premier ToolFileChunk dans response.outputs."""
 
91
  for entry in getattr(response, "outputs", []) or []:
92
  content = getattr(entry, "content", None)
93
  if content is None:
@@ -172,98 +302,186 @@ def _generate_one(client: Mistral, agent_id: str, prompt: str, out_path: Path, p
172
  if data is None:
173
  return False
174
 
175
- out_path.parent.mkdir(parents=True, exist_ok=True)
176
- out_path.write_bytes(data)
177
- size_kb = len(data) / 1024
178
- log.info("[%s] OK %s — sauvegardé (%s Ko) → %s", progress or name, name, round(size_kb, 1), out_path)
179
  return True
180
 
181
 
182
  def main() -> None:
183
  import argparse
184
- parser = argparse.ArgumentParser(description="Generate unit/building sprites via Mistral")
185
  parser.add_argument("--unit", type=str, metavar="ID", help="Generate only this unit (e.g. marine, scv)")
186
  parser.add_argument("--building", type=str, metavar="ID", help="Generate only this building (e.g. barracks)")
 
 
 
187
  args = parser.parse_args()
188
 
189
  only_unit = args.unit.strip().lower() if args.unit else None
 
190
  only_building = args.building.strip().lower().replace(" ", "_") if args.building else None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
 
192
- if not MISTRAL_API_KEY:
193
- log.error("MISTRAL_API_KEY not set. Add it to .env")
194
- sys.exit(1)
195
 
196
- client = Mistral(api_key=MISTRAL_API_KEY)
197
-
198
- log.info("Creating image generation agent...")
199
- try:
200
- agent = client.beta.agents.create(
201
- model="mistral-medium-2505",
202
- name="Sprite Generator",
203
- description="Generates top-down game sprites for units and buildings.",
204
- instructions=AGENT_INSTRUCTIONS,
205
- tools=[{"type": "image_generation"}],
206
- completion_args={"temperature": 0.3, "top_p": 0.95},
207
- )
208
- agent_id = agent.id
209
- log.info("Agent id: %s", agent_id)
210
- except Exception as e:
211
- log.exception("Failed to create agent: %s", e)
212
- sys.exit(1)
213
 
214
  UNITS_DIR.mkdir(parents=True, exist_ok=True)
215
  BUILDINGS_DIR.mkdir(parents=True, exist_ok=True)
 
 
216
 
217
- # Si --unit X : uniquement cette unité (pas de bâtiments). Si --building Y : uniquement ce bâtiment (pas d'unités).
218
  units_to_run: list = []
219
- if not only_building:
220
- units_to_run = [ut for ut in UnitType if not only_unit or ut.value == only_unit]
221
- if only_unit and not units_to_run:
222
  log.error("Unknown unit: %s. Valid: %s", only_unit, [u.value for u in UnitType])
223
  sys.exit(1)
224
 
225
  buildings_to_run: list = []
226
- if not only_unit:
227
- buildings_to_run = [bt for bt in BuildingType if not only_building or bt.value == only_building]
228
- if only_building and not buildings_to_run:
229
  log.error("Unknown building: %s. Valid: %s", only_building, [b.value for b in BuildingType])
230
  sys.exit(1)
231
 
232
- total = len(units_to_run) + len(buildings_to_run)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  log.info("========== GÉNÉRATION SPRITES ==========")
234
  log.info("Unités à générer: %s (%s)", [u.value for u in units_to_run], len(units_to_run))
235
  log.info("Bâtiments à générer: %s (%s)", [b.value for b in buildings_to_run], len(buildings_to_run))
 
 
236
  log.info("Total: %s sprites. Délai entre chaque: %s s.", total, DELAY_BETWEEN_SPRITES_SEC)
237
  log.info("=========================================")
238
 
239
  ok, fail = 0, 0
240
  t0 = time.monotonic()
 
241
 
242
  for i, ut in enumerate(units_to_run):
243
- if i > 0:
244
- log.info("--- Pause %s s (rate limit) avant prochaine unité ---", DELAY_BETWEEN_SPRITES_SEC)
245
- time.sleep(DELAY_BETWEEN_SPRITES_SEC)
246
  out = UNITS_DIR / f"{ut.value}.png"
 
 
 
 
 
 
 
247
  prompt = UNIT_PROMPTS.get(ut.value, _TOP + _FILL + _SMALL + f"{ut.value} unit from above, sci-fi RTS. Transparent background only.")
248
  progress = "unit %s/%s" % (i + 1, len(units_to_run))
249
- if _generate_one(client, agent_id, prompt, out, progress=progress):
250
  ok += 1
251
  else:
252
  fail += 1
253
  log.warning("Échec pour unité %s", ut.value)
 
254
 
255
  for i, bt in enumerate(buildings_to_run):
256
- if i > 0 or units_to_run:
257
- log.info("--- Pause %s s (rate limit) avant prochain bâtiment ---", DELAY_BETWEEN_SPRITES_SEC)
258
- time.sleep(DELAY_BETWEEN_SPRITES_SEC)
259
  out = BUILDINGS_DIR / f"{bt.value}.png"
 
 
 
 
 
 
 
260
  prompt = _building_prompt(bt)
261
  progress = "building %s/%s" % (i + 1, len(buildings_to_run))
262
- if _generate_one(client, agent_id, prompt, out, progress=progress):
263
  ok += 1
264
  else:
265
  fail += 1
266
  log.warning("Échec pour bâtiment %s", bt.value)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
 
268
  elapsed = time.monotonic() - t0
269
  log.info("========== FIN ==========")
 
1
  #!/usr/bin/env python3
2
  """
3
+ Génère les sprites PNG (vues de dessus, style SF Starcraft/Warhammer).
4
+
5
+ Backend au choix (priorité GCP si configuré) :
6
+ - GCP Vertex AI Imagen : définir GCP_PROJECT_ID (et optionnellement GCP_LOCATION) dans .env
7
+ - Mistral : définir MISTRAL_API_KEY dans .env
8
 
9
  Usage : cd backend && python -m scripts.generate_sprites
 
10
  """
11
  from __future__ import annotations
12
 
13
+ import os
14
  import sys
15
  from pathlib import Path
16
 
 
23
  import time
24
  from typing import Any
25
 
26
+ from config import GCP_PROJECT_ID, GCP_LOCATION, MISTRAL_API_KEY
 
 
 
27
  from game.units import UnitType
28
  from game.buildings import BuildingType, BUILDING_DEFS
29
+ from game.map import ResourceType
30
 
31
  logging.basicConfig(level=logging.INFO)
32
  log = logging.getLogger(__name__)
 
34
  SPRITES_DIR = _backend / "static" / "sprites"
35
  UNITS_DIR = SPRITES_DIR / "units"
36
  BUILDINGS_DIR = SPRITES_DIR / "buildings"
37
+ RESOURCES_DIR = SPRITES_DIR / "resources"
38
+ ICONS_DIR = SPRITES_DIR / "icons"
39
+
40
+ # Taille cible et fond transparent obligatoires
41
+ TARGET_SIZE = 128
42
+
43
+ # Chroma vert : fond #00ff00 demandé à l'API, détourage sur cette couleur uniquement
44
+ CHROMA_GREEN_RGB = (0, 255, 0) # #00ff00 pour le prompt
45
+ CHROMA_GREEN_TOLERANCE = 55 # pour vert vif proche de #00ff00
46
+ # Vert "dominant" (G > R et G > B) pour attraper les verts plus foncés renvoyés par Imagen
47
+ CHROMA_GREEN_MIN_G = 60 # G >= ce seuil et G dominant → considéré fond vert
48
+ CHROMA_GREEN_PROMPT = " The background must be solid green #00ff00 only, like a green screen. Subject in gray/blue/metallic, no white or other background."
49
 
50
  # Backoff pour rate limit (429)
51
  BACKOFF_BASE_SEC = 30
 
61
  - Generate SMALL, LOW-RESOLUTION images only. For units: exactly 128x128 pixels. For buildings: longest side 128 pixels. Do NOT generate large or high-resolution images (no 512, 1024, etc.). Small output is required.
62
  - MANDATORY transparent background: only the subject has visible pixels; everything else fully transparent (PNG with alpha). No ground, no floor, no solid color.
63
 
64
+ CRITICAL - VIEW ANGLE (NO EXCEPTIONS):
65
+ - Strict top-down view ONLY (bird's eye, camera at exactly 90 degrees directly above the subject). This applies to EVERY sprite without exception, including soldiers, mechs, vehicles, and buildings.
66
+ - You must ONLY see the top surface/footprint of the subject. No side view, no 3/4 view, no isometric, no perspective angle.
67
+ - For units with weapons (marine, goliath, tank, etc.): the weapon barrel must point straight forward, visible from above as a shape pointing toward the top of the image.
68
+ - Reject any tendency to show a front/side/3D perspective — the camera is directly overhead, period.
69
 
70
  Other: subject fills frame, minimal margins. Dark metallic, blue/gray palette, industrial sci-fi. No text or labels. One image per request, no commentary."""
71
 
72
  # Vue strictement de dessus + fond transparent + remplir le cadre
73
  _TOP = "Strict top-down view only (bird's eye, camera directly above, 90°). Only the top surface/footprint visible, no side or perspective. "
74
+ _FILL = "MANDATORY green #00ff00 background only (no ground, no floor, no details). Subject must fill the frame tightly, edge to edge. "
75
  # Petite image demandée à l'API pour éviter le rate limit (pas de post-traitement)
76
  _SMALL = "Generate a SMALL low-resolution image only: 128x128 pixels for square sprites, or longest side 128 for rectangles. Tiny size. Do NOT output large or high-resolution (no 512, 1024, etc.). "
77
 
78
  # Prompts par unité : petite 128x128, fond transparent
79
  UNIT_PROMPTS: dict[str, str] = {
80
+ UnitType.SCV.value: _TOP + _FILL + _SMALL + "Single SCV worker robot seen strictly from directly above at 90 degrees, bird's eye view only. Top surface visible: mechanical body footprint, two mechanical arms extended sideways, cockpit hatch on top. Industrial gray and blue. Camera is directly overhead, no side or perspective angle.",
81
+ UnitType.MARINE.value: _TOP + _FILL + _SMALL + "Single Terran Marine soldier seen strictly from directly above at 90 degrees, bird's eye view only. Top of armored helmet visible at center, heavy shoulder pads on both sides, rifle/assault gun barrel pointing straight forward (upward in the image). Dark blue armor. No side view, no isometric, camera directly overhead.",
82
+ UnitType.MEDIC.value: _TOP + _FILL + _SMALL + "Single Terran Medic soldier seen strictly from directly above at 90 degrees, bird's eye view only. Top of helmet visible at center, white/light armor with red cross medical symbol on back, medkit or injector pointing forward. Sci-fi armor. Camera directly overhead, no side or perspective.",
83
+ UnitType.GOLIATH.value: _TOP + _FILL + _SMALL + "Goliath combat walker mech seen strictly from directly above at 90 degrees, bird's eye view only. Top surface: wide armored torso, two autocannon gun pods mounted on shoulders pointing straight forward (upward in image), legs spread below. Dark metal and blue. Camera directly overhead at 90 degrees, no side view.",
84
+ UnitType.TANK.value: _TOP + _FILL + _SMALL + "Siege Tank seen strictly from directly above at 90 degrees, bird's eye view only. Top surface: elongated armored hull, round turret on top with long cannon barrel pointing straight forward (upward in image), tank treads visible on both sides. Military gray and blue. Camera directly overhead.",
85
+ UnitType.WRAITH.value: _TOP + _FILL + _SMALL + "Wraith starfighter aircraft seen strictly from directly above at 90 degrees, bird's eye view only. Top surface: delta wing shape, cockpit bubble at center-front, weapon pods on wing edges pointing forward. Dark metallic with blue highlights. Camera directly overhead.",
86
+ }
87
+
88
+ # Icônes UI : style flat symbolique, fond vert chroma pour détourage
89
+ ICON_PROMPTS: dict[str, str] = {
90
+ "mineral": _FILL + _SMALL + "Flat symbolic icon of a blue mineral crystal, bold angular shape, solid blue-cyan color, thick black outline, 2D game UI icon style. No background, no text, no shading.",
91
+ "gas": _FILL + _SMALL + "Flat symbolic icon of a purple vespene gas canister or flask, bold shape, solid purple-violet color, thick black outline, 2D game UI icon style. No green. No background, no text, no shading.",
92
+ "supply": _FILL + _SMALL + "Flat symbolic icon of a chevron/arrow pointing up or a small house shape, bold, solid yellow-orange color, thick black outline, 2D game UI icon style. No background, no text, no shading.",
93
+ }
94
+
95
+ # Prompts par ressource : icône top-down 64x64
96
+ RESOURCE_PROMPTS: dict[str, str] = {
97
+ ResourceType.MINERAL.value: _TOP + _FILL + _SMALL + "Mineral crystal cluster from above: angular blue-teal crystalline shards, glowing. Sci-fi RTS style. No text.",
98
+ ResourceType.GEYSER.value: _TOP + _FILL + _SMALL + "Vespene gas geyser from above: purple/violet glowing vent/crater with purple gas fumes rising. Purple and magenta colors only, NO green. Sci-fi RTS style. No text.",
99
  }
100
 
101
  # Prompts par bâtiment : petite image (longest side 128), fond transparent
 
114
  return base + " Transparent background only. Dark metal and blue."
115
 
116
 
117
+ def _is_chroma_green(r: int, g: int, b: int) -> bool:
118
+ """True si le pixel est fond vert à détourer : proche de #00ff00 ou vert dominant (G > R, G > B)."""
119
+ # Vert vif type #00ff00
120
+ r0, g0, b0 = CHROMA_GREEN_RGB
121
+ if (
122
+ abs(r - r0) <= CHROMA_GREEN_TOLERANCE
123
+ and abs(g - g0) <= CHROMA_GREEN_TOLERANCE
124
+ and abs(b - b0) <= CHROMA_GREEN_TOLERANCE
125
+ ):
126
+ return True
127
+ # Vert plus foncé (Imagen renvoie souvent ~50,127,70) : G dominant et G assez élevé
128
+ if g >= CHROMA_GREEN_MIN_G and g >= r and g >= b:
129
+ # exclure les gris (r≈g≈b) et garder un vrai vert (g nettement > r ou b)
130
+ if r > 100 and b > 100 and abs(r - g) < 30 and abs(b - g) < 30:
131
+ return False # gris, pas vert
132
+ return True
133
+ return False
134
+
135
+
136
+ def _resize_and_make_transparent(data: bytes, out_path: Path, size: int = TARGET_SIZE) -> None:
137
+ """Redimensionne à size px (côté max). Rend transparent : fond vert chroma ET blanc/clair. Sauvegarde en PNG."""
138
+ from PIL import Image
139
+ import io
140
+ img = Image.open(io.BytesIO(data)).convert("RGBA")
141
+ w, h = img.size
142
+ if w > size or h > size:
143
+ scale = size / max(w, h)
144
+ nw, nh = max(1, int(w * scale)), max(1, int(h * scale))
145
+ img = img.resize((nw, nh), Image.Resampling.LANCZOS)
146
+ pixels = img.load()
147
+ for y in range(img.height):
148
+ for x in range(img.width):
149
+ r, g, b, a = pixels[x, y]
150
+ if _is_chroma_green(r, g, b):
151
+ pixels[x, y] = (r, g, b, 0)
152
+ out_path.parent.mkdir(parents=True, exist_ok=True)
153
+ img.save(out_path, "PNG")
154
+
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # GCP Vertex AI Imagen (optionnel)
158
+ # ---------------------------------------------------------------------------
159
+
160
+ def _generate_one_vertex(prompt: str, out_path: Path, progress: str = "") -> bool:
161
+ """Génère une image via Vertex AI Imagen et la sauvegarde."""
162
+ from google import genai
163
+ from google.genai.types import GenerateImagesConfig
164
+
165
+ name = out_path.stem
166
+ for attempt in range(BACKOFF_MAX_RETRIES):
167
+ try:
168
+ if attempt == 0:
169
+ log.info("[%s] Appel Vertex Imagen pour %s...", progress or name, name)
170
+ else:
171
+ log.info("[%s] Retry %s/%s — Imagen pour %s...", progress or name, attempt + 1, BACKOFF_MAX_RETRIES, name)
172
+ # GOOGLE_CLOUD_PROJECT, GOOGLE_CLOUD_LOCATION, GOOGLE_GENAI_USE_VERTEXAI set in main()
173
+ client = genai.Client(vertexai=True)
174
+ vertex_prompt = prompt + CHROMA_GREEN_PROMPT
175
+ response = client.models.generate_images(
176
+ model="imagen-3.0-fast-generate-001",
177
+ prompt=vertex_prompt,
178
+ config=GenerateImagesConfig(
179
+ numberOfImages=1,
180
+ aspectRatio="1:1",
181
+ ),
182
+ )
183
+ if not response.generated_images or not response.generated_images[0].image:
184
+ log.warning("[%s] Réponse Imagen vide pour %s", progress or name, name)
185
+ return False
186
+ img = response.generated_images[0].image
187
+ raw_bytes: bytes
188
+ if hasattr(img, "image_bytes") and img.image_bytes is not None:
189
+ raw_bytes = img.image_bytes
190
+ elif hasattr(img, "save") and callable(img.save):
191
+ import io
192
+ buf = io.BytesIO()
193
+ img.save(buf)
194
+ raw_bytes = buf.getvalue()
195
+ else:
196
+ log.error("[%s] Format image Imagen inattendu pour %s", progress or name, name)
197
+ return False
198
+ _resize_and_make_transparent(raw_bytes, out_path)
199
+ size_kb = out_path.stat().st_size / 1024
200
+ log.info("[%s] OK %s — sauvegardé 128px transparent (%s Ko) → %s", progress or name, name, round(size_kb, 1), out_path)
201
+ return True
202
+ except Exception as e:
203
+ err_msg = str(e)
204
+ if ("429" in err_msg or "rate limit" in err_msg.lower() or "resource exhausted" in err_msg.lower()) and attempt < BACKOFF_MAX_RETRIES - 1:
205
+ delay = min(BACKOFF_BASE_SEC * (2 ** attempt), BACKOFF_MAX_SEC)
206
+ jitter = random.uniform(0, delay * 0.2)
207
+ time.sleep(delay + jitter)
208
+ else:
209
+ log.error("[%s] Échec Imagen pour %s: %s", progress or name, name, err_msg)
210
+ return False
211
+ return False
212
+
213
+
214
+ # ---------------------------------------------------------------------------
215
+ # Mistral (fallback si GCP non configuré)
216
+ # ---------------------------------------------------------------------------
217
+
218
  def _find_file_id_in_response(response: Any) -> str | None:
219
  """Extrait le file_id du premier ToolFileChunk dans response.outputs."""
220
+ from mistralai.models import ToolFileChunk
221
  for entry in getattr(response, "outputs", []) or []:
222
  content = getattr(entry, "content", None)
223
  if content is None:
 
302
  if data is None:
303
  return False
304
 
305
+ _resize_and_make_transparent(data, out_path)
306
+ size_kb = out_path.stat().st_size / 1024
307
+ log.info("[%s] OK %s — sauvegardé 128px transparent (%s Ko) %s", progress or name, name, round(size_kb, 1), out_path)
 
308
  return True
309
 
310
 
311
  def main() -> None:
312
  import argparse
313
+ parser = argparse.ArgumentParser(description="Generate unit/building sprites (Vertex Imagen or Mistral)")
314
  parser.add_argument("--unit", type=str, metavar="ID", help="Generate only this unit (e.g. marine, scv)")
315
  parser.add_argument("--building", type=str, metavar="ID", help="Generate only this building (e.g. barracks)")
316
+ parser.add_argument("--resource", type=str, metavar="ID", help="Generate only this resource (e.g. mineral, geyser)")
317
+ parser.add_argument("--icon", type=str, metavar="ID", help="Generate only this UI icon (e.g. mineral, gas, supply)")
318
+ parser.add_argument("--skip-existing", action="store_true", help="Skip sprites that already exist (do not regenerate)")
319
  args = parser.parse_args()
320
 
321
  only_unit = args.unit.strip().lower() if args.unit else None
322
+ skip_existing = args.skip_existing
323
  only_building = args.building.strip().lower().replace(" ", "_") if args.building else None
324
+ only_resource = args.resource.strip().lower() if args.resource else None
325
+ only_icon = args.icon.strip().lower() if args.icon else None
326
+
327
+ use_vertex = bool(GCP_PROJECT_ID)
328
+ if use_vertex:
329
+ os.environ["GOOGLE_CLOUD_PROJECT"] = GCP_PROJECT_ID
330
+ os.environ["GOOGLE_CLOUD_LOCATION"] = GCP_LOCATION
331
+ os.environ["GOOGLE_GENAI_USE_VERTEXAI"] = "true"
332
+ log.info("Backend: Vertex AI Imagen (project=%s, location=%s)", GCP_PROJECT_ID, GCP_LOCATION)
333
+
334
+ def do_generate(prompt: str, out: Path, progress: str) -> bool:
335
+ return _generate_one_vertex(prompt, out, progress)
336
+ else:
337
+ if not MISTRAL_API_KEY:
338
+ log.error("Aucun backend image configuré. Définir GCP_PROJECT_ID (ex: mtgbinder) ou MISTRAL_API_KEY dans .env")
339
+ sys.exit(1)
340
+ from mistralai import Mistral
341
+ client = Mistral(api_key=MISTRAL_API_KEY)
342
+ log.info("Creating Mistral image generation agent...")
343
+ try:
344
+ agent = client.beta.agents.create(
345
+ model="mistral-medium-2505",
346
+ name="Sprite Generator",
347
+ description="Generates top-down game sprites for units and buildings.",
348
+ instructions=AGENT_INSTRUCTIONS,
349
+ tools=[{"type": "image_generation"}],
350
+ completion_args={"temperature": 0.3, "top_p": 0.95},
351
+ )
352
+ agent_id = agent.id
353
+ log.info("Agent id: %s", agent_id)
354
+ except Exception as e:
355
+ log.exception("Failed to create agent: %s", e)
356
+ sys.exit(1)
357
 
358
+ def do_generate(prompt: str, out: Path, progress: str) -> bool:
359
+ return _generate_one(client, agent_id, prompt, out, progress=progress)
 
360
 
361
+ only_resource_flag = only_resource is not None
362
+ only_unit_flag = only_unit is not None
363
+ only_building_flag = only_building is not None
364
+ only_icon_flag = only_icon is not None
 
 
 
 
 
 
 
 
 
 
 
 
 
365
 
366
  UNITS_DIR.mkdir(parents=True, exist_ok=True)
367
  BUILDINGS_DIR.mkdir(parents=True, exist_ok=True)
368
+ RESOURCES_DIR.mkdir(parents=True, exist_ok=True)
369
+ ICONS_DIR.mkdir(parents=True, exist_ok=True)
370
 
 
371
  units_to_run: list = []
372
+ if not only_building_flag and not only_resource_flag and not only_icon_flag:
373
+ units_to_run = [ut for ut in UnitType if not only_unit_flag or ut.value == only_unit]
374
+ if only_unit_flag and not units_to_run:
375
  log.error("Unknown unit: %s. Valid: %s", only_unit, [u.value for u in UnitType])
376
  sys.exit(1)
377
 
378
  buildings_to_run: list = []
379
+ if not only_unit_flag and not only_resource_flag and not only_icon_flag:
380
+ buildings_to_run = [bt for bt in BuildingType if not only_building_flag or bt.value == only_building]
381
+ if only_building_flag and not buildings_to_run:
382
  log.error("Unknown building: %s. Valid: %s", only_building, [b.value for b in BuildingType])
383
  sys.exit(1)
384
 
385
+ resources_to_run: list = []
386
+ if not only_unit_flag and not only_building_flag and not only_icon_flag:
387
+ valid_resources = list(RESOURCE_PROMPTS.keys())
388
+ resources_to_run = [r for r in valid_resources if not only_resource_flag or r == only_resource]
389
+ if only_resource_flag and not resources_to_run:
390
+ log.error("Unknown resource: %s. Valid: %s", only_resource, valid_resources)
391
+ sys.exit(1)
392
+
393
+ icons_to_run: list = []
394
+ if not only_unit_flag and not only_building_flag and not only_resource_flag:
395
+ valid_icons = list(ICON_PROMPTS.keys())
396
+ icons_to_run = [ic for ic in valid_icons if not only_icon_flag or ic == only_icon]
397
+ if only_icon_flag and not icons_to_run:
398
+ log.error("Unknown icon: %s. Valid: %s", only_icon, valid_icons)
399
+ sys.exit(1)
400
+
401
+ total = len(units_to_run) + len(buildings_to_run) + len(resources_to_run) + len(icons_to_run)
402
  log.info("========== GÉNÉRATION SPRITES ==========")
403
  log.info("Unités à générer: %s (%s)", [u.value for u in units_to_run], len(units_to_run))
404
  log.info("Bâtiments à générer: %s (%s)", [b.value for b in buildings_to_run], len(buildings_to_run))
405
+ log.info("Ressources à générer: %s (%s)", resources_to_run, len(resources_to_run))
406
+ log.info("Icônes UI à générer: %s (%s)", icons_to_run, len(icons_to_run))
407
  log.info("Total: %s sprites. Délai entre chaque: %s s.", total, DELAY_BETWEEN_SPRITES_SEC)
408
  log.info("=========================================")
409
 
410
  ok, fail = 0, 0
411
  t0 = time.monotonic()
412
+ generated_count = 0
413
 
414
  for i, ut in enumerate(units_to_run):
 
 
 
415
  out = UNITS_DIR / f"{ut.value}.png"
416
+ if skip_existing and out.exists():
417
+ log.info("[unit %s/%s] Déjà présent, ignoré: %s", i + 1, len(units_to_run), out.name)
418
+ ok += 1
419
+ continue
420
+ if generated_count > 0:
421
+ log.info("--- Pause %s s (rate limit) ---", DELAY_BETWEEN_SPRITES_SEC)
422
+ time.sleep(DELAY_BETWEEN_SPRITES_SEC)
423
  prompt = UNIT_PROMPTS.get(ut.value, _TOP + _FILL + _SMALL + f"{ut.value} unit from above, sci-fi RTS. Transparent background only.")
424
  progress = "unit %s/%s" % (i + 1, len(units_to_run))
425
+ if do_generate(prompt, out, progress):
426
  ok += 1
427
  else:
428
  fail += 1
429
  log.warning("Échec pour unité %s", ut.value)
430
+ generated_count += 1
431
 
432
  for i, bt in enumerate(buildings_to_run):
 
 
 
433
  out = BUILDINGS_DIR / f"{bt.value}.png"
434
+ if skip_existing and out.exists():
435
+ log.info("[building %s/%s] Déjà présent, ignoré: %s", i + 1, len(buildings_to_run), out.name)
436
+ ok += 1
437
+ continue
438
+ if generated_count > 0:
439
+ log.info("--- Pause %s s (rate limit) ---", DELAY_BETWEEN_SPRITES_SEC)
440
+ time.sleep(DELAY_BETWEEN_SPRITES_SEC)
441
  prompt = _building_prompt(bt)
442
  progress = "building %s/%s" % (i + 1, len(buildings_to_run))
443
+ if do_generate(prompt, out, progress):
444
  ok += 1
445
  else:
446
  fail += 1
447
  log.warning("Échec pour bâtiment %s", bt.value)
448
+ generated_count += 1
449
+
450
+ for i, rid in enumerate(resources_to_run):
451
+ out = RESOURCES_DIR / f"{rid}.png"
452
+ if skip_existing and out.exists():
453
+ log.info("[resource %s/%s] Déjà présent, ignoré: %s", i + 1, len(resources_to_run), out.name)
454
+ ok += 1
455
+ continue
456
+ if generated_count > 0:
457
+ log.info("--- Pause %s s (rate limit) ---", DELAY_BETWEEN_SPRITES_SEC)
458
+ time.sleep(DELAY_BETWEEN_SPRITES_SEC)
459
+ prompt = RESOURCE_PROMPTS[rid]
460
+ progress = "resource %s/%s" % (i + 1, len(resources_to_run))
461
+ if do_generate(prompt, out, progress):
462
+ ok += 1
463
+ else:
464
+ fail += 1
465
+ log.warning("Échec pour ressource %s", rid)
466
+ generated_count += 1
467
+
468
+ for i, ic in enumerate(icons_to_run):
469
+ out = ICONS_DIR / f"{ic}.png"
470
+ if skip_existing and out.exists():
471
+ log.info("[icon %s/%s] Déjà présent, ignoré: %s", i + 1, len(icons_to_run), out.name)
472
+ ok += 1
473
+ continue
474
+ if generated_count > 0:
475
+ log.info("--- Pause %s s (rate limit) ---", DELAY_BETWEEN_SPRITES_SEC)
476
+ time.sleep(DELAY_BETWEEN_SPRITES_SEC)
477
+ prompt = ICON_PROMPTS[ic]
478
+ progress = "icon %s/%s" % (i + 1, len(icons_to_run))
479
+ if do_generate(prompt, out, progress):
480
+ ok += 1
481
+ else:
482
+ fail += 1
483
+ log.warning("Échec pour icône %s", ic)
484
+ generated_count += 1
485
 
486
  elapsed = time.monotonic() - t0
487
  log.info("========== FIN ==========")
backend/scripts/upscale_cover.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Upscale l'image cover (lobby) via Vertex AI Imagen 4.0 upscale.
4
+
5
+ Prérequis : GCP_PROJECT_ID et GCP_LOCATION dans .env, et `gcloud auth application-default login`
6
+ (ou GOOGLE_APPLICATION_CREDENTIALS).
7
+
8
+ Usage :
9
+ cd backend && python -m scripts.upscale_cover
10
+ cd backend && python -m scripts.upscale_cover --factor x2 --input ../frontend/src/cover.jpg --output ../frontend/src/cover.jpg
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import base64
16
+ import subprocess
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ _backend = Path(__file__).resolve().parent.parent
21
+ if str(_backend) not in sys.path:
22
+ sys.path.insert(0, str(_backend))
23
+
24
+ from config import GCP_PROJECT_ID, GCP_LOCATION
25
+
26
+
27
+ def get_access_token() -> str:
28
+ """Token via gcloud (ADC)."""
29
+ out = subprocess.run(
30
+ ["gcloud", "auth", "print-access-token"],
31
+ capture_output=True,
32
+ text=True,
33
+ check=False,
34
+ )
35
+ if out.returncode != 0:
36
+ raise RuntimeError(
37
+ "Échec gcloud auth. Lancez: gcloud auth application-default login"
38
+ )
39
+ return out.stdout.strip()
40
+
41
+
42
+ def upscale_image(
43
+ image_path: Path,
44
+ out_path: Path,
45
+ factor: str = "x4",
46
+ region: str | None = None,
47
+ project_id: str | None = None,
48
+ ) -> None:
49
+ region = region or GCP_LOCATION
50
+ project_id = project_id or GCP_PROJECT_ID
51
+ if not project_id:
52
+ raise SystemExit("Définir GCP_PROJECT_ID dans .env")
53
+
54
+ data = image_path.read_bytes()
55
+ if len(data) > 10 * 1024 * 1024:
56
+ raise SystemExit("Image trop lourde (max 10 Mo)")
57
+
58
+ b64 = base64.standard_b64encode(data).decode("ascii")
59
+ token = get_access_token()
60
+ url = (
61
+ f"https://{region}-aiplatform.googleapis.com/v1/projects/{project_id}"
62
+ f"/locations/{region}/publishers/google/models/imagen-4.0-upscale-preview:predict"
63
+ )
64
+ import httpx
65
+
66
+ payload = {
67
+ "instances": [
68
+ {"prompt": "Upscale the image", "image": {"bytesBase64Encoded": b64}}
69
+ ],
70
+ "parameters": {
71
+ "mode": "upscale",
72
+ "upscaleConfig": {"upscaleFactor": factor},
73
+ "outputOptions": {"mimeType": "image/png"},
74
+ },
75
+ }
76
+ resp = httpx.post(
77
+ url,
78
+ json=payload,
79
+ headers={
80
+ "Authorization": f"Bearer {token}",
81
+ "Content-Type": "application/json",
82
+ },
83
+ timeout=120.0,
84
+ )
85
+ resp.raise_for_status()
86
+ body = resp.json()
87
+ preds = body.get("predictions") or []
88
+ if not preds or "bytesBase64Encoded" not in preds[0]:
89
+ raise SystemExit("Réponse Vertex sans image: " + str(body)[:500])
90
+ out_b64 = preds[0]["bytesBase64Encoded"]
91
+ out_path.parent.mkdir(parents=True, exist_ok=True)
92
+ out_path.write_bytes(base64.standard_b64decode(out_b64))
93
+ print(f"Upscale {factor} OK → {out_path} ({out_path.stat().st_size / 1024:.1f} Ko)")
94
+
95
+
96
+ def main() -> None:
97
+ parser = argparse.ArgumentParser(description="Upscale cover image via Vertex AI Imagen")
98
+ parser.add_argument(
99
+ "--input",
100
+ type=Path,
101
+ default=_backend.parent / "frontend" / "src" / "cover.jpg",
102
+ help="Image source",
103
+ )
104
+ parser.add_argument(
105
+ "--output",
106
+ type=Path,
107
+ default=None,
108
+ help="Image de sortie (défaut: --input)",
109
+ )
110
+ parser.add_argument(
111
+ "--factor",
112
+ choices=("x2", "x3", "x4"),
113
+ default="x4",
114
+ help="Facteur d'upscale (défaut: x4)",
115
+ )
116
+ args = parser.parse_args()
117
+ out = args.output or args.input
118
+ if not args.input.is_file():
119
+ raise SystemExit(f"Fichier introuvable: {args.input}")
120
+ upscale_image(args.input, out, factor=args.factor)
121
+
122
+
123
+ if __name__ == "__main__":
124
+ main()
backend/scripts/upscale_map.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Upscale MAP.png en 4 passes (4 quadrants) via Vertex AI Imagen upscale.
4
+
5
+ La map étant trop lourde pour l'API en un seul appel (> 10 Mo), on :
6
+ 1. Découpe en 4 quadrants de 2048×2048
7
+ 2. Upscale chaque quadrant ×2 → 4096×4096 chacun
8
+ 3. Recolle en une image finale 8192×8192
9
+
10
+ Usage :
11
+ cd backend && python -m scripts.upscale_map
12
+ cd backend && python -m scripts.upscale_map --factor x2 --input static/MAP.png --output static/MAP.png
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import io
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ from PIL import Image
22
+
23
+ _backend = Path(__file__).resolve().parent.parent
24
+ if str(_backend) not in sys.path:
25
+ sys.path.insert(0, str(_backend))
26
+
27
+ from scripts.upscale_cover import upscale_image
28
+
29
+
30
+ def upscale_map(
31
+ input_path: Path,
32
+ output_path: Path,
33
+ factor: str = "x2",
34
+ ) -> None:
35
+ src = Image.open(input_path).convert("RGB")
36
+ w, h = src.size
37
+ print(f"Image source : {w}×{h} ({input_path.stat().st_size / 1024 / 1024:.1f} Mo)")
38
+
39
+ half_w, half_h = w // 2, h // 2
40
+ quadrants = [
41
+ (0, 0, half_w, half_h), # top-left
42
+ (half_w, 0, w, half_h), # top-right
43
+ (0, half_h, half_w, h ), # bottom-left
44
+ (half_w, half_h, w, h ), # bottom-right
45
+ ]
46
+ labels = ["top-left", "top-right", "bottom-left", "bottom-right"]
47
+
48
+ upscaled_quadrants: list[Image.Image] = []
49
+
50
+ for i, (box, label) in enumerate(zip(quadrants, labels)):
51
+ print(f"\n[{i+1}/4] Quadrant {label} {box}")
52
+
53
+ quad_img = src.crop(box)
54
+ tmp_in = Path(f"/tmp/map_quad_{i}.png")
55
+ tmp_out = Path(f"/tmp/map_quad_{i}_up.png")
56
+
57
+ buf = io.BytesIO()
58
+ quad_img.save(buf, format="PNG", optimize=False)
59
+ size_mb = len(buf.getvalue()) / 1024 / 1024
60
+ print(f" Taille quadrant : {size_mb:.1f} Mo", end="")
61
+ if size_mb > 10:
62
+ print(" ⚠ > 10 Mo, passage en JPEG lossy pour respecter la limite API")
63
+ buf = io.BytesIO()
64
+ quad_img.save(buf, format="JPEG", quality=92)
65
+ tmp_in = Path(f"/tmp/map_quad_{i}.jpg")
66
+ else:
67
+ print()
68
+
69
+ tmp_in.write_bytes(buf.getvalue())
70
+
71
+ upscale_image(tmp_in, tmp_out, factor=factor)
72
+ upscaled_quadrants.append(Image.open(tmp_out).convert("RGB"))
73
+
74
+ uw, uh = upscaled_quadrants[0].size
75
+ print(f"\nRecomposition : quadrants upscalés {uw}×{uh} → image finale {uw*2}×{uh*2}")
76
+ final = Image.new("RGB", (uw * 2, uh * 2))
77
+ final.paste(upscaled_quadrants[0], (0, 0))
78
+ final.paste(upscaled_quadrants[1], (uw, 0))
79
+ final.paste(upscaled_quadrants[2], (0, uh))
80
+ final.paste(upscaled_quadrants[3], (uw, uh))
81
+
82
+ output_path.parent.mkdir(parents=True, exist_ok=True)
83
+ final.save(output_path, format="PNG", optimize=False)
84
+ print(f"\nSauvegardé : {output_path} ({output_path.stat().st_size / 1024 / 1024:.1f} Mo)")
85
+
86
+
87
+ def main() -> None:
88
+ default_map = _backend / "static" / "MAP.png"
89
+ parser = argparse.ArgumentParser(description="Upscale MAP.png en 4 quadrants via Vertex AI")
90
+ parser.add_argument("--input", type=Path, default=default_map)
91
+ parser.add_argument("--output", type=Path, default=None)
92
+ parser.add_argument("--factor", choices=("x2", "x4"), default="x2")
93
+ args = parser.parse_args()
94
+
95
+ out = args.output or args.input
96
+ if not args.input.is_file():
97
+ raise SystemExit(f"Fichier introuvable : {args.input}")
98
+
99
+ upscale_map(args.input, out, factor=args.factor)
100
+
101
+
102
+ if __name__ == "__main__":
103
+ main()
backend/static/MAP.png CHANGED

Git LFS Details

  • SHA256: 9cae7f23990a7aabcb16e876f3995e2362a474ee5f93392a04271aab19c7bc2a
  • Pointer size: 132 Bytes
  • Size of remote file: 2.79 MB

Git LFS Details

  • SHA256: 5f81f0fcbe37f3beb86b0babb4970b95992f303dd81b48ea2f7cae9abe212912
  • Pointer size: 134 Bytes
  • Size of remote file: 122 MB
backend/static/MAP_half.png ADDED

Git LFS Details

  • SHA256: 9d0882150a2810c73f251862d8ef201a2074e0605c5674b38ae383d64e0da8ba
  • Pointer size: 133 Bytes
  • Size of remote file: 34.3 MB
backend/static/MAP_quarter.png ADDED

Git LFS Details

  • SHA256: 7c086468b655e4f86723b4024ea572cd75908b04bf4dabd894b5b09de3ec1413
  • Pointer size: 132 Bytes
  • Size of remote file: 9.16 MB
backend/static/compiled_map.json ADDED
@@ -0,0 +1,4211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "exterior": [
3
+ [
4
+ 43.7365,
5
+ 65.5492
6
+ ],
7
+ [
8
+ 47.7007,
9
+ 67.2865
10
+ ],
11
+ [
12
+ 41.6913,
13
+ 75.7753
14
+ ],
15
+ [
16
+ 45.6539,
17
+ 77.8206
18
+ ],
19
+ [
20
+ 41.18,
21
+ 80.8884
22
+ ],
23
+ [
24
+ 46.4852,
25
+ 85.1941
26
+ ],
27
+ [
28
+ 41.9342,
29
+ 88.3091
30
+ ],
31
+ [
32
+ 51.1504,
33
+ 95.2049
34
+ ],
35
+ [
36
+ 58.3087,
37
+ 94.2606
38
+ ],
39
+ [
40
+ 62.1435,
41
+ 99.1675
42
+ ],
43
+ [
44
+ 46.8043,
45
+ 98.9119
46
+ ],
47
+ [
48
+ 41.18,
49
+ 95.4606
50
+ ],
51
+ [
52
+ 36.5783,
53
+ 98.1449
54
+ ],
55
+ [
56
+ 29.5478,
57
+ 93.2875
58
+ ],
59
+ [
60
+ 25.0739,
61
+ 94.6936
62
+ ],
63
+ [
64
+ 18.4269,
65
+ 93.0319
66
+ ],
67
+ [
68
+ 14.72,
69
+ 90.4753
70
+ ],
71
+ [
72
+ 11.2687,
73
+ 87.9188
74
+ ],
75
+ [
76
+ 7.6895,
77
+ 80.7606
78
+ ],
79
+ [
80
+ 5.6443,
81
+ 77.8206
82
+ ],
83
+ [
84
+ 10.1182,
85
+ 76.5423
86
+ ],
87
+ [
88
+ 11.1408,
89
+ 72.5797
90
+ ],
91
+ [
92
+ 16.1261,
93
+ 72.1962
94
+ ],
95
+ [
96
+ 16.1261,
97
+ 74.6249
98
+ ],
99
+ [
100
+ 23.2843,
101
+ 72.9632
102
+ ],
103
+ [
104
+ 29.6756,
105
+ 69.8953
106
+ ],
107
+ [
108
+ 33.8939,
109
+ 73.091
110
+ ],
111
+ [
112
+ 37.6009,
113
+ 73.9858
114
+ ],
115
+ [
116
+ 43.0878,
117
+ 65.1726
118
+ ]
119
+ ],
120
+ "holes": [],
121
+ "nav_points": [
122
+ [
123
+ 2.7408,
124
+ 47.5359
125
+ ],
126
+ [
127
+ 4.3408,
128
+ 45.9359
129
+ ],
130
+ [
131
+ 4.3408,
132
+ 47.5359
133
+ ],
134
+ [
135
+ 5.9408,
136
+ 44.3359
137
+ ],
138
+ [
139
+ 5.9408,
140
+ 45.9359
141
+ ],
142
+ [
143
+ 5.9408,
144
+ 47.5359
145
+ ],
146
+ [
147
+ 5.9408,
148
+ 49.1359
149
+ ],
150
+ [
151
+ 5.9408,
152
+ 61.9359
153
+ ],
154
+ [
155
+ 5.9408,
156
+ 63.5359
157
+ ],
158
+ [
159
+ 7.5408,
160
+ 20.3359
161
+ ],
162
+ [
163
+ 7.5408,
164
+ 44.3359
165
+ ],
166
+ [
167
+ 7.5408,
168
+ 45.9359
169
+ ],
170
+ [
171
+ 7.5408,
172
+ 47.5359
173
+ ],
174
+ [
175
+ 7.5408,
176
+ 61.9359
177
+ ],
178
+ [
179
+ 7.5408,
180
+ 63.5359
181
+ ],
182
+ [
183
+ 7.5408,
184
+ 65.1359
185
+ ],
186
+ [
187
+ 7.5408,
188
+ 66.7359
189
+ ],
190
+ [
191
+ 9.1408,
192
+ 17.1359
193
+ ],
194
+ [
195
+ 9.1408,
196
+ 18.7359
197
+ ],
198
+ [
199
+ 9.1408,
200
+ 20.3359
201
+ ],
202
+ [
203
+ 9.1408,
204
+ 21.9359
205
+ ],
206
+ [
207
+ 9.1408,
208
+ 44.3359
209
+ ],
210
+ [
211
+ 9.1408,
212
+ 45.9359
213
+ ],
214
+ [
215
+ 9.1408,
216
+ 47.5359
217
+ ],
218
+ [
219
+ 9.1408,
220
+ 58.7359
221
+ ],
222
+ [
223
+ 9.1408,
224
+ 60.3359
225
+ ],
226
+ [
227
+ 9.1408,
228
+ 61.9359
229
+ ],
230
+ [
231
+ 9.1408,
232
+ 63.5359
233
+ ],
234
+ [
235
+ 9.1408,
236
+ 65.1359
237
+ ],
238
+ [
239
+ 9.1408,
240
+ 66.7359
241
+ ],
242
+ [
243
+ 9.1408,
244
+ 68.3359
245
+ ],
246
+ [
247
+ 9.1408,
248
+ 69.9359
249
+ ],
250
+ [
251
+ 10.7408,
252
+ 17.1359
253
+ ],
254
+ [
255
+ 10.7408,
256
+ 18.7359
257
+ ],
258
+ [
259
+ 10.7408,
260
+ 20.3359
261
+ ],
262
+ [
263
+ 10.7408,
264
+ 21.9359
265
+ ],
266
+ [
267
+ 10.7408,
268
+ 23.5359
269
+ ],
270
+ [
271
+ 10.7408,
272
+ 28.3359
273
+ ],
274
+ [
275
+ 10.7408,
276
+ 44.3359
277
+ ],
278
+ [
279
+ 10.7408,
280
+ 45.9359
281
+ ],
282
+ [
283
+ 10.7408,
284
+ 47.5359
285
+ ],
286
+ [
287
+ 10.7408,
288
+ 58.7359
289
+ ],
290
+ [
291
+ 10.7408,
292
+ 60.3359
293
+ ],
294
+ [
295
+ 10.7408,
296
+ 61.9359
297
+ ],
298
+ [
299
+ 10.7408,
300
+ 63.5359
301
+ ],
302
+ [
303
+ 10.7408,
304
+ 65.1359
305
+ ],
306
+ [
307
+ 10.7408,
308
+ 66.7359
309
+ ],
310
+ [
311
+ 10.7408,
312
+ 68.3359
313
+ ],
314
+ [
315
+ 10.7408,
316
+ 69.9359
317
+ ],
318
+ [
319
+ 10.7408,
320
+ 71.5359
321
+ ],
322
+ [
323
+ 12.3408,
324
+ 7.5359
325
+ ],
326
+ [
327
+ 12.3408,
328
+ 9.1359
329
+ ],
330
+ [
331
+ 12.3408,
332
+ 15.5359
333
+ ],
334
+ [
335
+ 12.3408,
336
+ 17.1359
337
+ ],
338
+ [
339
+ 12.3408,
340
+ 18.7359
341
+ ],
342
+ [
343
+ 12.3408,
344
+ 20.3359
345
+ ],
346
+ [
347
+ 12.3408,
348
+ 21.9359
349
+ ],
350
+ [
351
+ 12.3408,
352
+ 23.5359
353
+ ],
354
+ [
355
+ 12.3408,
356
+ 26.7359
357
+ ],
358
+ [
359
+ 12.3408,
360
+ 28.3359
361
+ ],
362
+ [
363
+ 12.3408,
364
+ 42.7359
365
+ ],
366
+ [
367
+ 12.3408,
368
+ 44.3359
369
+ ],
370
+ [
371
+ 12.3408,
372
+ 45.9359
373
+ ],
374
+ [
375
+ 12.3408,
376
+ 47.5359
377
+ ],
378
+ [
379
+ 12.3408,
380
+ 49.1359
381
+ ],
382
+ [
383
+ 12.3408,
384
+ 58.7359
385
+ ],
386
+ [
387
+ 12.3408,
388
+ 60.3359
389
+ ],
390
+ [
391
+ 12.3408,
392
+ 61.9359
393
+ ],
394
+ [
395
+ 12.3408,
396
+ 63.5359
397
+ ],
398
+ [
399
+ 12.3408,
400
+ 65.1359
401
+ ],
402
+ [
403
+ 12.3408,
404
+ 66.7359
405
+ ],
406
+ [
407
+ 12.3408,
408
+ 68.3359
409
+ ],
410
+ [
411
+ 12.3408,
412
+ 69.9359
413
+ ],
414
+ [
415
+ 12.3408,
416
+ 71.5359
417
+ ],
418
+ [
419
+ 13.9408,
420
+ 7.5359
421
+ ],
422
+ [
423
+ 13.9408,
424
+ 9.1359
425
+ ],
426
+ [
427
+ 13.9408,
428
+ 10.7359
429
+ ],
430
+ [
431
+ 13.9408,
432
+ 12.3359
433
+ ],
434
+ [
435
+ 13.9408,
436
+ 15.5359
437
+ ],
438
+ [
439
+ 13.9408,
440
+ 17.1359
441
+ ],
442
+ [
443
+ 13.9408,
444
+ 18.7359
445
+ ],
446
+ [
447
+ 13.9408,
448
+ 20.3359
449
+ ],
450
+ [
451
+ 13.9408,
452
+ 21.9359
453
+ ],
454
+ [
455
+ 13.9408,
456
+ 23.5359
457
+ ],
458
+ [
459
+ 13.9408,
460
+ 25.1359
461
+ ],
462
+ [
463
+ 13.9408,
464
+ 26.7359
465
+ ],
466
+ [
467
+ 13.9408,
468
+ 28.3359
469
+ ],
470
+ [
471
+ 13.9408,
472
+ 41.1359
473
+ ],
474
+ [
475
+ 13.9408,
476
+ 42.7359
477
+ ],
478
+ [
479
+ 13.9408,
480
+ 44.3359
481
+ ],
482
+ [
483
+ 13.9408,
484
+ 45.9359
485
+ ],
486
+ [
487
+ 13.9408,
488
+ 47.5359
489
+ ],
490
+ [
491
+ 13.9408,
492
+ 49.1359
493
+ ],
494
+ [
495
+ 13.9408,
496
+ 50.7359
497
+ ],
498
+ [
499
+ 13.9408,
500
+ 60.3359
501
+ ],
502
+ [
503
+ 13.9408,
504
+ 61.9359
505
+ ],
506
+ [
507
+ 13.9408,
508
+ 63.5359
509
+ ],
510
+ [
511
+ 13.9408,
512
+ 65.1359
513
+ ],
514
+ [
515
+ 13.9408,
516
+ 66.7359
517
+ ],
518
+ [
519
+ 13.9408,
520
+ 68.3359
521
+ ],
522
+ [
523
+ 13.9408,
524
+ 69.9359
525
+ ],
526
+ [
527
+ 13.9408,
528
+ 71.5359
529
+ ],
530
+ [
531
+ 13.9408,
532
+ 73.1359
533
+ ],
534
+ [
535
+ 15.5408,
536
+ 5.9359
537
+ ],
538
+ [
539
+ 15.5408,
540
+ 7.5359
541
+ ],
542
+ [
543
+ 15.5408,
544
+ 9.1359
545
+ ],
546
+ [
547
+ 15.5408,
548
+ 10.7359
549
+ ],
550
+ [
551
+ 15.5408,
552
+ 12.3359
553
+ ],
554
+ [
555
+ 15.5408,
556
+ 13.9359
557
+ ],
558
+ [
559
+ 15.5408,
560
+ 15.5359
561
+ ],
562
+ [
563
+ 15.5408,
564
+ 17.1359
565
+ ],
566
+ [
567
+ 15.5408,
568
+ 18.7359
569
+ ],
570
+ [
571
+ 15.5408,
572
+ 20.3359
573
+ ],
574
+ [
575
+ 15.5408,
576
+ 21.9359
577
+ ],
578
+ [
579
+ 15.5408,
580
+ 23.5359
581
+ ],
582
+ [
583
+ 15.5408,
584
+ 25.1359
585
+ ],
586
+ [
587
+ 15.5408,
588
+ 26.7359
589
+ ],
590
+ [
591
+ 15.5408,
592
+ 28.3359
593
+ ],
594
+ [
595
+ 15.5408,
596
+ 29.9359
597
+ ],
598
+ [
599
+ 15.5408,
600
+ 39.5359
601
+ ],
602
+ [
603
+ 15.5408,
604
+ 41.1359
605
+ ],
606
+ [
607
+ 15.5408,
608
+ 42.7359
609
+ ],
610
+ [
611
+ 15.5408,
612
+ 44.3359
613
+ ],
614
+ [
615
+ 15.5408,
616
+ 45.9359
617
+ ],
618
+ [
619
+ 15.5408,
620
+ 47.5359
621
+ ],
622
+ [
623
+ 15.5408,
624
+ 49.1359
625
+ ],
626
+ [
627
+ 15.5408,
628
+ 50.7359
629
+ ],
630
+ [
631
+ 15.5408,
632
+ 60.3359
633
+ ],
634
+ [
635
+ 15.5408,
636
+ 61.9359
637
+ ],
638
+ [
639
+ 15.5408,
640
+ 63.5359
641
+ ],
642
+ [
643
+ 15.5408,
644
+ 65.1359
645
+ ],
646
+ [
647
+ 15.5408,
648
+ 66.7359
649
+ ],
650
+ [
651
+ 15.5408,
652
+ 68.3359
653
+ ],
654
+ [
655
+ 15.5408,
656
+ 69.9359
657
+ ],
658
+ [
659
+ 15.5408,
660
+ 71.5359
661
+ ],
662
+ [
663
+ 15.5408,
664
+ 73.1359
665
+ ],
666
+ [
667
+ 17.1408,
668
+ 5.9359
669
+ ],
670
+ [
671
+ 17.1408,
672
+ 7.5359
673
+ ],
674
+ [
675
+ 17.1408,
676
+ 9.1359
677
+ ],
678
+ [
679
+ 17.1408,
680
+ 10.7359
681
+ ],
682
+ [
683
+ 17.1408,
684
+ 12.3359
685
+ ],
686
+ [
687
+ 17.1408,
688
+ 13.9359
689
+ ],
690
+ [
691
+ 17.1408,
692
+ 15.5359
693
+ ],
694
+ [
695
+ 17.1408,
696
+ 17.1359
697
+ ],
698
+ [
699
+ 17.1408,
700
+ 18.7359
701
+ ],
702
+ [
703
+ 17.1408,
704
+ 21.9359
705
+ ],
706
+ [
707
+ 17.1408,
708
+ 23.5359
709
+ ],
710
+ [
711
+ 17.1408,
712
+ 25.1359
713
+ ],
714
+ [
715
+ 17.1408,
716
+ 26.7359
717
+ ],
718
+ [
719
+ 17.1408,
720
+ 28.3359
721
+ ],
722
+ [
723
+ 17.1408,
724
+ 29.9359
725
+ ],
726
+ [
727
+ 17.1408,
728
+ 39.5359
729
+ ],
730
+ [
731
+ 17.1408,
732
+ 41.1359
733
+ ],
734
+ [
735
+ 17.1408,
736
+ 42.7359
737
+ ],
738
+ [
739
+ 17.1408,
740
+ 47.5359
741
+ ],
742
+ [
743
+ 17.1408,
744
+ 49.1359
745
+ ],
746
+ [
747
+ 17.1408,
748
+ 50.7359
749
+ ],
750
+ [
751
+ 17.1408,
752
+ 52.3359
753
+ ],
754
+ [
755
+ 17.1408,
756
+ 58.7359
757
+ ],
758
+ [
759
+ 17.1408,
760
+ 60.3359
761
+ ],
762
+ [
763
+ 17.1408,
764
+ 61.9359
765
+ ],
766
+ [
767
+ 17.1408,
768
+ 63.5359
769
+ ],
770
+ [
771
+ 17.1408,
772
+ 65.1359
773
+ ],
774
+ [
775
+ 17.1408,
776
+ 66.7359
777
+ ],
778
+ [
779
+ 17.1408,
780
+ 68.3359
781
+ ],
782
+ [
783
+ 17.1408,
784
+ 69.9359
785
+ ],
786
+ [
787
+ 17.1408,
788
+ 71.5359
789
+ ],
790
+ [
791
+ 17.1408,
792
+ 73.1359
793
+ ],
794
+ [
795
+ 17.1408,
796
+ 74.7359
797
+ ],
798
+ [
799
+ 18.7408,
800
+ 5.9359
801
+ ],
802
+ [
803
+ 18.7408,
804
+ 7.5359
805
+ ],
806
+ [
807
+ 18.7408,
808
+ 9.1359
809
+ ],
810
+ [
811
+ 18.7408,
812
+ 10.7359
813
+ ],
814
+ [
815
+ 18.7408,
816
+ 12.3359
817
+ ],
818
+ [
819
+ 18.7408,
820
+ 13.9359
821
+ ],
822
+ [
823
+ 18.7408,
824
+ 15.5359
825
+ ],
826
+ [
827
+ 18.7408,
828
+ 17.1359
829
+ ],
830
+ [
831
+ 18.7408,
832
+ 23.5359
833
+ ],
834
+ [
835
+ 18.7408,
836
+ 25.1359
837
+ ],
838
+ [
839
+ 18.7408,
840
+ 26.7359
841
+ ],
842
+ [
843
+ 18.7408,
844
+ 28.3359
845
+ ],
846
+ [
847
+ 18.7408,
848
+ 29.9359
849
+ ],
850
+ [
851
+ 18.7408,
852
+ 37.9359
853
+ ],
854
+ [
855
+ 18.7408,
856
+ 39.5359
857
+ ],
858
+ [
859
+ 18.7408,
860
+ 41.1359
861
+ ],
862
+ [
863
+ 18.7408,
864
+ 49.1359
865
+ ],
866
+ [
867
+ 18.7408,
868
+ 50.7359
869
+ ],
870
+ [
871
+ 18.7408,
872
+ 52.3359
873
+ ],
874
+ [
875
+ 18.7408,
876
+ 53.9359
877
+ ],
878
+ [
879
+ 18.7408,
880
+ 58.7359
881
+ ],
882
+ [
883
+ 18.7408,
884
+ 60.3359
885
+ ],
886
+ [
887
+ 18.7408,
888
+ 61.9359
889
+ ],
890
+ [
891
+ 18.7408,
892
+ 63.5359
893
+ ],
894
+ [
895
+ 18.7408,
896
+ 65.1359
897
+ ],
898
+ [
899
+ 18.7408,
900
+ 66.7359
901
+ ],
902
+ [
903
+ 18.7408,
904
+ 68.3359
905
+ ],
906
+ [
907
+ 18.7408,
908
+ 69.9359
909
+ ],
910
+ [
911
+ 18.7408,
912
+ 71.5359
913
+ ],
914
+ [
915
+ 18.7408,
916
+ 73.1359
917
+ ],
918
+ [
919
+ 18.7408,
920
+ 74.7359
921
+ ],
922
+ [
923
+ 20.3408,
924
+ 5.9359
925
+ ],
926
+ [
927
+ 20.3408,
928
+ 7.5359
929
+ ],
930
+ [
931
+ 20.3408,
932
+ 9.1359
933
+ ],
934
+ [
935
+ 20.3408,
936
+ 10.7359
937
+ ],
938
+ [
939
+ 20.3408,
940
+ 12.3359
941
+ ],
942
+ [
943
+ 20.3408,
944
+ 13.9359
945
+ ],
946
+ [
947
+ 20.3408,
948
+ 15.5359
949
+ ],
950
+ [
951
+ 20.3408,
952
+ 17.1359
953
+ ],
954
+ [
955
+ 20.3408,
956
+ 23.5359
957
+ ],
958
+ [
959
+ 20.3408,
960
+ 25.1359
961
+ ],
962
+ [
963
+ 20.3408,
964
+ 26.7359
965
+ ],
966
+ [
967
+ 20.3408,
968
+ 29.9359
969
+ ],
970
+ [
971
+ 20.3408,
972
+ 31.5359
973
+ ],
974
+ [
975
+ 20.3408,
976
+ 37.9359
977
+ ],
978
+ [
979
+ 20.3408,
980
+ 39.5359
981
+ ],
982
+ [
983
+ 20.3408,
984
+ 41.1359
985
+ ],
986
+ [
987
+ 20.3408,
988
+ 49.1359
989
+ ],
990
+ [
991
+ 20.3408,
992
+ 52.3359
993
+ ],
994
+ [
995
+ 20.3408,
996
+ 53.9359
997
+ ],
998
+ [
999
+ 20.3408,
1000
+ 55.5359
1001
+ ],
1002
+ [
1003
+ 20.3408,
1004
+ 58.7359
1005
+ ],
1006
+ [
1007
+ 20.3408,
1008
+ 60.3359
1009
+ ],
1010
+ [
1011
+ 20.3408,
1012
+ 61.9359
1013
+ ],
1014
+ [
1015
+ 20.3408,
1016
+ 63.5359
1017
+ ],
1018
+ [
1019
+ 20.3408,
1020
+ 65.1359
1021
+ ],
1022
+ [
1023
+ 20.3408,
1024
+ 66.7359
1025
+ ],
1026
+ [
1027
+ 20.3408,
1028
+ 68.3359
1029
+ ],
1030
+ [
1031
+ 20.3408,
1032
+ 69.9359
1033
+ ],
1034
+ [
1035
+ 20.3408,
1036
+ 71.5359
1037
+ ],
1038
+ [
1039
+ 20.3408,
1040
+ 73.1359
1041
+ ],
1042
+ [
1043
+ 20.3408,
1044
+ 74.7359
1045
+ ],
1046
+ [
1047
+ 21.9408,
1048
+ 5.9359
1049
+ ],
1050
+ [
1051
+ 21.9408,
1052
+ 7.5359
1053
+ ],
1054
+ [
1055
+ 21.9408,
1056
+ 9.1359
1057
+ ],
1058
+ [
1059
+ 21.9408,
1060
+ 10.7359
1061
+ ],
1062
+ [
1063
+ 21.9408,
1064
+ 12.3359
1065
+ ],
1066
+ [
1067
+ 21.9408,
1068
+ 13.9359
1069
+ ],
1070
+ [
1071
+ 21.9408,
1072
+ 15.5359
1073
+ ],
1074
+ [
1075
+ 21.9408,
1076
+ 17.1359
1077
+ ],
1078
+ [
1079
+ 21.9408,
1080
+ 25.1359
1081
+ ],
1082
+ [
1083
+ 21.9408,
1084
+ 29.9359
1085
+ ],
1086
+ [
1087
+ 21.9408,
1088
+ 31.5359
1089
+ ],
1090
+ [
1091
+ 21.9408,
1092
+ 39.5359
1093
+ ],
1094
+ [
1095
+ 21.9408,
1096
+ 47.5359
1097
+ ],
1098
+ [
1099
+ 21.9408,
1100
+ 49.1359
1101
+ ],
1102
+ [
1103
+ 21.9408,
1104
+ 53.9359
1105
+ ],
1106
+ [
1107
+ 21.9408,
1108
+ 55.5359
1109
+ ],
1110
+ [
1111
+ 21.9408,
1112
+ 57.1359
1113
+ ],
1114
+ [
1115
+ 21.9408,
1116
+ 58.7359
1117
+ ],
1118
+ [
1119
+ 21.9408,
1120
+ 60.3359
1121
+ ],
1122
+ [
1123
+ 21.9408,
1124
+ 61.9359
1125
+ ],
1126
+ [
1127
+ 21.9408,
1128
+ 63.5359
1129
+ ],
1130
+ [
1131
+ 21.9408,
1132
+ 65.1359
1133
+ ],
1134
+ [
1135
+ 21.9408,
1136
+ 66.7359
1137
+ ],
1138
+ [
1139
+ 21.9408,
1140
+ 68.3359
1141
+ ],
1142
+ [
1143
+ 21.9408,
1144
+ 69.9359
1145
+ ],
1146
+ [
1147
+ 21.9408,
1148
+ 71.5359
1149
+ ],
1150
+ [
1151
+ 21.9408,
1152
+ 73.1359
1153
+ ],
1154
+ [
1155
+ 21.9408,
1156
+ 74.7359
1157
+ ],
1158
+ [
1159
+ 23.5408,
1160
+ 5.9359
1161
+ ],
1162
+ [
1163
+ 23.5408,
1164
+ 7.5359
1165
+ ],
1166
+ [
1167
+ 23.5408,
1168
+ 9.1359
1169
+ ],
1170
+ [
1171
+ 23.5408,
1172
+ 10.7359
1173
+ ],
1174
+ [
1175
+ 23.5408,
1176
+ 12.3359
1177
+ ],
1178
+ [
1179
+ 23.5408,
1180
+ 13.9359
1181
+ ],
1182
+ [
1183
+ 23.5408,
1184
+ 15.5359
1185
+ ],
1186
+ [
1187
+ 23.5408,
1188
+ 17.1359
1189
+ ],
1190
+ [
1191
+ 23.5408,
1192
+ 31.5359
1193
+ ],
1194
+ [
1195
+ 23.5408,
1196
+ 42.7359
1197
+ ],
1198
+ [
1199
+ 23.5408,
1200
+ 45.9359
1201
+ ],
1202
+ [
1203
+ 23.5408,
1204
+ 47.5359
1205
+ ],
1206
+ [
1207
+ 23.5408,
1208
+ 57.1359
1209
+ ],
1210
+ [
1211
+ 23.5408,
1212
+ 58.7359
1213
+ ],
1214
+ [
1215
+ 23.5408,
1216
+ 60.3359
1217
+ ],
1218
+ [
1219
+ 23.5408,
1220
+ 61.9359
1221
+ ],
1222
+ [
1223
+ 23.5408,
1224
+ 63.5359
1225
+ ],
1226
+ [
1227
+ 23.5408,
1228
+ 65.1359
1229
+ ],
1230
+ [
1231
+ 23.5408,
1232
+ 66.7359
1233
+ ],
1234
+ [
1235
+ 23.5408,
1236
+ 68.3359
1237
+ ],
1238
+ [
1239
+ 23.5408,
1240
+ 69.9359
1241
+ ],
1242
+ [
1243
+ 23.5408,
1244
+ 71.5359
1245
+ ],
1246
+ [
1247
+ 23.5408,
1248
+ 73.1359
1249
+ ],
1250
+ [
1251
+ 25.1408,
1252
+ 7.5359
1253
+ ],
1254
+ [
1255
+ 25.1408,
1256
+ 9.1359
1257
+ ],
1258
+ [
1259
+ 25.1408,
1260
+ 10.7359
1261
+ ],
1262
+ [
1263
+ 25.1408,
1264
+ 12.3359
1265
+ ],
1266
+ [
1267
+ 25.1408,
1268
+ 13.9359
1269
+ ],
1270
+ [
1271
+ 25.1408,
1272
+ 15.5359
1273
+ ],
1274
+ [
1275
+ 25.1408,
1276
+ 17.1359
1277
+ ],
1278
+ [
1279
+ 25.1408,
1280
+ 31.5359
1281
+ ],
1282
+ [
1283
+ 25.1408,
1284
+ 33.1359
1285
+ ],
1286
+ [
1287
+ 25.1408,
1288
+ 42.7359
1289
+ ],
1290
+ [
1291
+ 25.1408,
1292
+ 45.9359
1293
+ ],
1294
+ [
1295
+ 25.1408,
1296
+ 57.1359
1297
+ ],
1298
+ [
1299
+ 25.1408,
1300
+ 58.7359
1301
+ ],
1302
+ [
1303
+ 25.1408,
1304
+ 60.3359
1305
+ ],
1306
+ [
1307
+ 25.1408,
1308
+ 61.9359
1309
+ ],
1310
+ [
1311
+ 25.1408,
1312
+ 63.5359
1313
+ ],
1314
+ [
1315
+ 25.1408,
1316
+ 65.1359
1317
+ ],
1318
+ [
1319
+ 25.1408,
1320
+ 66.7359
1321
+ ],
1322
+ [
1323
+ 25.1408,
1324
+ 68.3359
1325
+ ],
1326
+ [
1327
+ 25.1408,
1328
+ 69.9359
1329
+ ],
1330
+ [
1331
+ 25.1408,
1332
+ 71.5359
1333
+ ],
1334
+ [
1335
+ 25.1408,
1336
+ 73.1359
1337
+ ],
1338
+ [
1339
+ 25.1408,
1340
+ 74.7359
1341
+ ],
1342
+ [
1343
+ 26.7408,
1344
+ 9.1359
1345
+ ],
1346
+ [
1347
+ 26.7408,
1348
+ 10.7359
1349
+ ],
1350
+ [
1351
+ 26.7408,
1352
+ 12.3359
1353
+ ],
1354
+ [
1355
+ 26.7408,
1356
+ 13.9359
1357
+ ],
1358
+ [
1359
+ 26.7408,
1360
+ 15.5359
1361
+ ],
1362
+ [
1363
+ 26.7408,
1364
+ 17.1359
1365
+ ],
1366
+ [
1367
+ 26.7408,
1368
+ 18.7359
1369
+ ],
1370
+ [
1371
+ 26.7408,
1372
+ 31.5359
1373
+ ],
1374
+ [
1375
+ 26.7408,
1376
+ 33.1359
1377
+ ],
1378
+ [
1379
+ 26.7408,
1380
+ 42.7359
1381
+ ],
1382
+ [
1383
+ 26.7408,
1384
+ 44.3359
1385
+ ],
1386
+ [
1387
+ 26.7408,
1388
+ 45.9359
1389
+ ],
1390
+ [
1391
+ 26.7408,
1392
+ 58.7359
1393
+ ],
1394
+ [
1395
+ 26.7408,
1396
+ 60.3359
1397
+ ],
1398
+ [
1399
+ 26.7408,
1400
+ 61.9359
1401
+ ],
1402
+ [
1403
+ 26.7408,
1404
+ 63.5359
1405
+ ],
1406
+ [
1407
+ 26.7408,
1408
+ 65.1359
1409
+ ],
1410
+ [
1411
+ 26.7408,
1412
+ 66.7359
1413
+ ],
1414
+ [
1415
+ 26.7408,
1416
+ 68.3359
1417
+ ],
1418
+ [
1419
+ 26.7408,
1420
+ 69.9359
1421
+ ],
1422
+ [
1423
+ 26.7408,
1424
+ 71.5359
1425
+ ],
1426
+ [
1427
+ 26.7408,
1428
+ 73.1359
1429
+ ],
1430
+ [
1431
+ 26.7408,
1432
+ 74.7359
1433
+ ],
1434
+ [
1435
+ 26.7408,
1436
+ 76.3359
1437
+ ],
1438
+ [
1439
+ 28.3408,
1440
+ 10.7359
1441
+ ],
1442
+ [
1443
+ 28.3408,
1444
+ 12.3359
1445
+ ],
1446
+ [
1447
+ 28.3408,
1448
+ 13.9359
1449
+ ],
1450
+ [
1451
+ 28.3408,
1452
+ 15.5359
1453
+ ],
1454
+ [
1455
+ 28.3408,
1456
+ 17.1359
1457
+ ],
1458
+ [
1459
+ 28.3408,
1460
+ 18.7359
1461
+ ],
1462
+ [
1463
+ 28.3408,
1464
+ 20.3359
1465
+ ],
1466
+ [
1467
+ 28.3408,
1468
+ 31.5359
1469
+ ],
1470
+ [
1471
+ 28.3408,
1472
+ 33.1359
1473
+ ],
1474
+ [
1475
+ 28.3408,
1476
+ 42.7359
1477
+ ],
1478
+ [
1479
+ 28.3408,
1480
+ 44.3359
1481
+ ],
1482
+ [
1483
+ 28.3408,
1484
+ 45.9359
1485
+ ],
1486
+ [
1487
+ 28.3408,
1488
+ 47.5359
1489
+ ],
1490
+ [
1491
+ 28.3408,
1492
+ 60.3359
1493
+ ],
1494
+ [
1495
+ 28.3408,
1496
+ 61.9359
1497
+ ],
1498
+ [
1499
+ 28.3408,
1500
+ 63.5359
1501
+ ],
1502
+ [
1503
+ 28.3408,
1504
+ 65.1359
1505
+ ],
1506
+ [
1507
+ 28.3408,
1508
+ 66.7359
1509
+ ],
1510
+ [
1511
+ 28.3408,
1512
+ 68.3359
1513
+ ],
1514
+ [
1515
+ 28.3408,
1516
+ 69.9359
1517
+ ],
1518
+ [
1519
+ 28.3408,
1520
+ 71.5359
1521
+ ],
1522
+ [
1523
+ 28.3408,
1524
+ 73.1359
1525
+ ],
1526
+ [
1527
+ 28.3408,
1528
+ 74.7359
1529
+ ],
1530
+ [
1531
+ 28.3408,
1532
+ 76.3359
1533
+ ],
1534
+ [
1535
+ 29.9408,
1536
+ 10.7359
1537
+ ],
1538
+ [
1539
+ 29.9408,
1540
+ 12.3359
1541
+ ],
1542
+ [
1543
+ 29.9408,
1544
+ 13.9359
1545
+ ],
1546
+ [
1547
+ 29.9408,
1548
+ 15.5359
1549
+ ],
1550
+ [
1551
+ 29.9408,
1552
+ 18.7359
1553
+ ],
1554
+ [
1555
+ 29.9408,
1556
+ 20.3359
1557
+ ],
1558
+ [
1559
+ 29.9408,
1560
+ 21.9359
1561
+ ],
1562
+ [
1563
+ 29.9408,
1564
+ 23.5359
1565
+ ],
1566
+ [
1567
+ 29.9408,
1568
+ 25.1359
1569
+ ],
1570
+ [
1571
+ 29.9408,
1572
+ 26.7359
1573
+ ],
1574
+ [
1575
+ 29.9408,
1576
+ 33.1359
1577
+ ],
1578
+ [
1579
+ 29.9408,
1580
+ 34.7359
1581
+ ],
1582
+ [
1583
+ 29.9408,
1584
+ 42.7359
1585
+ ],
1586
+ [
1587
+ 29.9408,
1588
+ 44.3359
1589
+ ],
1590
+ [
1591
+ 29.9408,
1592
+ 47.5359
1593
+ ],
1594
+ [
1595
+ 29.9408,
1596
+ 49.1359
1597
+ ],
1598
+ [
1599
+ 29.9408,
1600
+ 60.3359
1601
+ ],
1602
+ [
1603
+ 29.9408,
1604
+ 61.9359
1605
+ ],
1606
+ [
1607
+ 29.9408,
1608
+ 63.5359
1609
+ ],
1610
+ [
1611
+ 29.9408,
1612
+ 65.1359
1613
+ ],
1614
+ [
1615
+ 29.9408,
1616
+ 66.7359
1617
+ ],
1618
+ [
1619
+ 29.9408,
1620
+ 68.3359
1621
+ ],
1622
+ [
1623
+ 29.9408,
1624
+ 69.9359
1625
+ ],
1626
+ [
1627
+ 29.9408,
1628
+ 71.5359
1629
+ ],
1630
+ [
1631
+ 29.9408,
1632
+ 73.1359
1633
+ ],
1634
+ [
1635
+ 29.9408,
1636
+ 74.7359
1637
+ ],
1638
+ [
1639
+ 29.9408,
1640
+ 76.3359
1641
+ ],
1642
+ [
1643
+ 29.9408,
1644
+ 77.9359
1645
+ ],
1646
+ [
1647
+ 31.5408,
1648
+ 10.7359
1649
+ ],
1650
+ [
1651
+ 31.5408,
1652
+ 12.3359
1653
+ ],
1654
+ [
1655
+ 31.5408,
1656
+ 13.9359
1657
+ ],
1658
+ [
1659
+ 31.5408,
1660
+ 21.9359
1661
+ ],
1662
+ [
1663
+ 31.5408,
1664
+ 23.5359
1665
+ ],
1666
+ [
1667
+ 31.5408,
1668
+ 25.1359
1669
+ ],
1670
+ [
1671
+ 31.5408,
1672
+ 26.7359
1673
+ ],
1674
+ [
1675
+ 31.5408,
1676
+ 33.1359
1677
+ ],
1678
+ [
1679
+ 31.5408,
1680
+ 34.7359
1681
+ ],
1682
+ [
1683
+ 31.5408,
1684
+ 42.7359
1685
+ ],
1686
+ [
1687
+ 31.5408,
1688
+ 49.1359
1689
+ ],
1690
+ [
1691
+ 31.5408,
1692
+ 50.7359
1693
+ ],
1694
+ [
1695
+ 31.5408,
1696
+ 57.1359
1697
+ ],
1698
+ [
1699
+ 31.5408,
1700
+ 58.7359
1701
+ ],
1702
+ [
1703
+ 31.5408,
1704
+ 60.3359
1705
+ ],
1706
+ [
1707
+ 31.5408,
1708
+ 61.9359
1709
+ ],
1710
+ [
1711
+ 31.5408,
1712
+ 63.5359
1713
+ ],
1714
+ [
1715
+ 31.5408,
1716
+ 65.1359
1717
+ ],
1718
+ [
1719
+ 31.5408,
1720
+ 66.7359
1721
+ ],
1722
+ [
1723
+ 31.5408,
1724
+ 68.3359
1725
+ ],
1726
+ [
1727
+ 31.5408,
1728
+ 69.9359
1729
+ ],
1730
+ [
1731
+ 31.5408,
1732
+ 71.5359
1733
+ ],
1734
+ [
1735
+ 31.5408,
1736
+ 73.1359
1737
+ ],
1738
+ [
1739
+ 31.5408,
1740
+ 74.7359
1741
+ ],
1742
+ [
1743
+ 31.5408,
1744
+ 76.3359
1745
+ ],
1746
+ [
1747
+ 33.1408,
1748
+ 10.7359
1749
+ ],
1750
+ [
1751
+ 33.1408,
1752
+ 12.3359
1753
+ ],
1754
+ [
1755
+ 33.1408,
1756
+ 23.5359
1757
+ ],
1758
+ [
1759
+ 33.1408,
1760
+ 25.1359
1761
+ ],
1762
+ [
1763
+ 33.1408,
1764
+ 26.7359
1765
+ ],
1766
+ [
1767
+ 33.1408,
1768
+ 28.3359
1769
+ ],
1770
+ [
1771
+ 33.1408,
1772
+ 33.1359
1773
+ ],
1774
+ [
1775
+ 33.1408,
1776
+ 34.7359
1777
+ ],
1778
+ [
1779
+ 33.1408,
1780
+ 42.7359
1781
+ ],
1782
+ [
1783
+ 33.1408,
1784
+ 47.5359
1785
+ ],
1786
+ [
1787
+ 33.1408,
1788
+ 49.1359
1789
+ ],
1790
+ [
1791
+ 33.1408,
1792
+ 50.7359
1793
+ ],
1794
+ [
1795
+ 33.1408,
1796
+ 55.5359
1797
+ ],
1798
+ [
1799
+ 33.1408,
1800
+ 57.1359
1801
+ ],
1802
+ [
1803
+ 33.1408,
1804
+ 58.7359
1805
+ ],
1806
+ [
1807
+ 33.1408,
1808
+ 60.3359
1809
+ ],
1810
+ [
1811
+ 33.1408,
1812
+ 61.9359
1813
+ ],
1814
+ [
1815
+ 33.1408,
1816
+ 63.5359
1817
+ ],
1818
+ [
1819
+ 33.1408,
1820
+ 65.1359
1821
+ ],
1822
+ [
1823
+ 33.1408,
1824
+ 66.7359
1825
+ ],
1826
+ [
1827
+ 33.1408,
1828
+ 68.3359
1829
+ ],
1830
+ [
1831
+ 33.1408,
1832
+ 69.9359
1833
+ ],
1834
+ [
1835
+ 33.1408,
1836
+ 71.5359
1837
+ ],
1838
+ [
1839
+ 33.1408,
1840
+ 73.1359
1841
+ ],
1842
+ [
1843
+ 33.1408,
1844
+ 74.7359
1845
+ ],
1846
+ [
1847
+ 33.1408,
1848
+ 76.3359
1849
+ ],
1850
+ [
1851
+ 34.7408,
1852
+ 10.7359
1853
+ ],
1854
+ [
1855
+ 34.7408,
1856
+ 12.3359
1857
+ ],
1858
+ [
1859
+ 34.7408,
1860
+ 25.1359
1861
+ ],
1862
+ [
1863
+ 34.7408,
1864
+ 26.7359
1865
+ ],
1866
+ [
1867
+ 34.7408,
1868
+ 28.3359
1869
+ ],
1870
+ [
1871
+ 34.7408,
1872
+ 33.1359
1873
+ ],
1874
+ [
1875
+ 34.7408,
1876
+ 34.7359
1877
+ ],
1878
+ [
1879
+ 34.7408,
1880
+ 42.7359
1881
+ ],
1882
+ [
1883
+ 34.7408,
1884
+ 47.5359
1885
+ ],
1886
+ [
1887
+ 34.7408,
1888
+ 49.1359
1889
+ ],
1890
+ [
1891
+ 34.7408,
1892
+ 50.7359
1893
+ ],
1894
+ [
1895
+ 34.7408,
1896
+ 52.3359
1897
+ ],
1898
+ [
1899
+ 34.7408,
1900
+ 53.9359
1901
+ ],
1902
+ [
1903
+ 34.7408,
1904
+ 55.5359
1905
+ ],
1906
+ [
1907
+ 34.7408,
1908
+ 57.1359
1909
+ ],
1910
+ [
1911
+ 34.7408,
1912
+ 61.9359
1913
+ ],
1914
+ [
1915
+ 34.7408,
1916
+ 66.7359
1917
+ ],
1918
+ [
1919
+ 34.7408,
1920
+ 68.3359
1921
+ ],
1922
+ [
1923
+ 34.7408,
1924
+ 73.1359
1925
+ ],
1926
+ [
1927
+ 34.7408,
1928
+ 74.7359
1929
+ ],
1930
+ [
1931
+ 34.7408,
1932
+ 76.3359
1933
+ ],
1934
+ [
1935
+ 36.3408,
1936
+ 10.7359
1937
+ ],
1938
+ [
1939
+ 36.3408,
1940
+ 12.3359
1941
+ ],
1942
+ [
1943
+ 36.3408,
1944
+ 25.1359
1945
+ ],
1946
+ [
1947
+ 36.3408,
1948
+ 26.7359
1949
+ ],
1950
+ [
1951
+ 36.3408,
1952
+ 33.1359
1953
+ ],
1954
+ [
1955
+ 36.3408,
1956
+ 34.7359
1957
+ ],
1958
+ [
1959
+ 36.3408,
1960
+ 36.3359
1961
+ ],
1962
+ [
1963
+ 36.3408,
1964
+ 45.9359
1965
+ ],
1966
+ [
1967
+ 36.3408,
1968
+ 47.5359
1969
+ ],
1970
+ [
1971
+ 36.3408,
1972
+ 49.1359
1973
+ ],
1974
+ [
1975
+ 36.3408,
1976
+ 50.7359
1977
+ ],
1978
+ [
1979
+ 36.3408,
1980
+ 52.3359
1981
+ ],
1982
+ [
1983
+ 36.3408,
1984
+ 53.9359
1985
+ ],
1986
+ [
1987
+ 36.3408,
1988
+ 55.5359
1989
+ ],
1990
+ [
1991
+ 36.3408,
1992
+ 68.3359
1993
+ ],
1994
+ [
1995
+ 36.3408,
1996
+ 73.1359
1997
+ ],
1998
+ [
1999
+ 36.3408,
2000
+ 74.7359
2001
+ ],
2002
+ [
2003
+ 36.3408,
2004
+ 76.3359
2005
+ ],
2006
+ [
2007
+ 36.3408,
2008
+ 77.9359
2009
+ ],
2010
+ [
2011
+ 37.9408,
2012
+ 10.7359
2013
+ ],
2014
+ [
2015
+ 37.9408,
2016
+ 12.3359
2017
+ ],
2018
+ [
2019
+ 37.9408,
2020
+ 25.1359
2021
+ ],
2022
+ [
2023
+ 37.9408,
2024
+ 34.7359
2025
+ ],
2026
+ [
2027
+ 37.9408,
2028
+ 36.3359
2029
+ ],
2030
+ [
2031
+ 37.9408,
2032
+ 45.9359
2033
+ ],
2034
+ [
2035
+ 37.9408,
2036
+ 47.5359
2037
+ ],
2038
+ [
2039
+ 37.9408,
2040
+ 49.1359
2041
+ ],
2042
+ [
2043
+ 37.9408,
2044
+ 50.7359
2045
+ ],
2046
+ [
2047
+ 37.9408,
2048
+ 52.3359
2049
+ ],
2050
+ [
2051
+ 37.9408,
2052
+ 53.9359
2053
+ ],
2054
+ [
2055
+ 37.9408,
2056
+ 74.7359
2057
+ ],
2058
+ [
2059
+ 37.9408,
2060
+ 76.3359
2061
+ ],
2062
+ [
2063
+ 37.9408,
2064
+ 77.9359
2065
+ ],
2066
+ [
2067
+ 39.5408,
2068
+ 10.7359
2069
+ ],
2070
+ [
2071
+ 39.5408,
2072
+ 12.3359
2073
+ ],
2074
+ [
2075
+ 39.5408,
2076
+ 34.7359
2077
+ ],
2078
+ [
2079
+ 39.5408,
2080
+ 36.3359
2081
+ ],
2082
+ [
2083
+ 39.5408,
2084
+ 41.1359
2085
+ ],
2086
+ [
2087
+ 39.5408,
2088
+ 45.9359
2089
+ ],
2090
+ [
2091
+ 39.5408,
2092
+ 47.5359
2093
+ ],
2094
+ [
2095
+ 39.5408,
2096
+ 49.1359
2097
+ ],
2098
+ [
2099
+ 39.5408,
2100
+ 50.7359
2101
+ ],
2102
+ [
2103
+ 39.5408,
2104
+ 52.3359
2105
+ ],
2106
+ [
2107
+ 39.5408,
2108
+ 53.9359
2109
+ ],
2110
+ [
2111
+ 39.5408,
2112
+ 76.3359
2113
+ ],
2114
+ [
2115
+ 39.5408,
2116
+ 77.9359
2117
+ ],
2118
+ [
2119
+ 41.1408,
2120
+ 10.7359
2121
+ ],
2122
+ [
2123
+ 41.1408,
2124
+ 12.3359
2125
+ ],
2126
+ [
2127
+ 41.1408,
2128
+ 34.7359
2129
+ ],
2130
+ [
2131
+ 41.1408,
2132
+ 36.3359
2133
+ ],
2134
+ [
2135
+ 41.1408,
2136
+ 37.9359
2137
+ ],
2138
+ [
2139
+ 41.1408,
2140
+ 39.5359
2141
+ ],
2142
+ [
2143
+ 41.1408,
2144
+ 44.3359
2145
+ ],
2146
+ [
2147
+ 41.1408,
2148
+ 45.9359
2149
+ ],
2150
+ [
2151
+ 41.1408,
2152
+ 47.5359
2153
+ ],
2154
+ [
2155
+ 41.1408,
2156
+ 49.1359
2157
+ ],
2158
+ [
2159
+ 41.1408,
2160
+ 50.7359
2161
+ ],
2162
+ [
2163
+ 41.1408,
2164
+ 52.3359
2165
+ ],
2166
+ [
2167
+ 41.1408,
2168
+ 53.9359
2169
+ ],
2170
+ [
2171
+ 41.1408,
2172
+ 76.3359
2173
+ ],
2174
+ [
2175
+ 41.1408,
2176
+ 77.9359
2177
+ ],
2178
+ [
2179
+ 42.7408,
2180
+ 10.7359
2181
+ ],
2182
+ [
2183
+ 42.7408,
2184
+ 12.3359
2185
+ ],
2186
+ [
2187
+ 42.7408,
2188
+ 34.7359
2189
+ ],
2190
+ [
2191
+ 42.7408,
2192
+ 36.3359
2193
+ ],
2194
+ [
2195
+ 42.7408,
2196
+ 37.9359
2197
+ ],
2198
+ [
2199
+ 42.7408,
2200
+ 39.5359
2201
+ ],
2202
+ [
2203
+ 42.7408,
2204
+ 44.3359
2205
+ ],
2206
+ [
2207
+ 42.7408,
2208
+ 45.9359
2209
+ ],
2210
+ [
2211
+ 42.7408,
2212
+ 47.5359
2213
+ ],
2214
+ [
2215
+ 42.7408,
2216
+ 49.1359
2217
+ ],
2218
+ [
2219
+ 42.7408,
2220
+ 50.7359
2221
+ ],
2222
+ [
2223
+ 42.7408,
2224
+ 52.3359
2225
+ ],
2226
+ [
2227
+ 42.7408,
2228
+ 53.9359
2229
+ ],
2230
+ [
2231
+ 42.7408,
2232
+ 76.3359
2233
+ ],
2234
+ [
2235
+ 42.7408,
2236
+ 77.9359
2237
+ ],
2238
+ [
2239
+ 44.3408,
2240
+ 10.7359
2241
+ ],
2242
+ [
2243
+ 44.3408,
2244
+ 12.3359
2245
+ ],
2246
+ [
2247
+ 44.3408,
2248
+ 34.7359
2249
+ ],
2250
+ [
2251
+ 44.3408,
2252
+ 36.3359
2253
+ ],
2254
+ [
2255
+ 44.3408,
2256
+ 37.9359
2257
+ ],
2258
+ [
2259
+ 44.3408,
2260
+ 39.5359
2261
+ ],
2262
+ [
2263
+ 44.3408,
2264
+ 44.3359
2265
+ ],
2266
+ [
2267
+ 44.3408,
2268
+ 45.9359
2269
+ ],
2270
+ [
2271
+ 44.3408,
2272
+ 47.5359
2273
+ ],
2274
+ [
2275
+ 44.3408,
2276
+ 49.1359
2277
+ ],
2278
+ [
2279
+ 44.3408,
2280
+ 50.7359
2281
+ ],
2282
+ [
2283
+ 44.3408,
2284
+ 52.3359
2285
+ ],
2286
+ [
2287
+ 44.3408,
2288
+ 53.9359
2289
+ ],
2290
+ [
2291
+ 44.3408,
2292
+ 76.3359
2293
+ ],
2294
+ [
2295
+ 44.3408,
2296
+ 77.9359
2297
+ ],
2298
+ [
2299
+ 45.9408,
2300
+ 9.1359
2301
+ ],
2302
+ [
2303
+ 45.9408,
2304
+ 10.7359
2305
+ ],
2306
+ [
2307
+ 45.9408,
2308
+ 12.3359
2309
+ ],
2310
+ [
2311
+ 45.9408,
2312
+ 13.9359
2313
+ ],
2314
+ [
2315
+ 45.9408,
2316
+ 34.7359
2317
+ ],
2318
+ [
2319
+ 45.9408,
2320
+ 36.3359
2321
+ ],
2322
+ [
2323
+ 45.9408,
2324
+ 39.5359
2325
+ ],
2326
+ [
2327
+ 45.9408,
2328
+ 41.1359
2329
+ ],
2330
+ [
2331
+ 45.9408,
2332
+ 45.9359
2333
+ ],
2334
+ [
2335
+ 45.9408,
2336
+ 47.5359
2337
+ ],
2338
+ [
2339
+ 45.9408,
2340
+ 49.1359
2341
+ ],
2342
+ [
2343
+ 45.9408,
2344
+ 50.7359
2345
+ ],
2346
+ [
2347
+ 45.9408,
2348
+ 52.3359
2349
+ ],
2350
+ [
2351
+ 45.9408,
2352
+ 53.9359
2353
+ ],
2354
+ [
2355
+ 45.9408,
2356
+ 76.3359
2357
+ ],
2358
+ [
2359
+ 45.9408,
2360
+ 77.9359
2361
+ ],
2362
+ [
2363
+ 47.5408,
2364
+ 9.1359
2365
+ ],
2366
+ [
2367
+ 47.5408,
2368
+ 10.7359
2369
+ ],
2370
+ [
2371
+ 47.5408,
2372
+ 12.3359
2373
+ ],
2374
+ [
2375
+ 47.5408,
2376
+ 25.1359
2377
+ ],
2378
+ [
2379
+ 47.5408,
2380
+ 26.7359
2381
+ ],
2382
+ [
2383
+ 47.5408,
2384
+ 34.7359
2385
+ ],
2386
+ [
2387
+ 47.5408,
2388
+ 36.3359
2389
+ ],
2390
+ [
2391
+ 47.5408,
2392
+ 41.1359
2393
+ ],
2394
+ [
2395
+ 47.5408,
2396
+ 45.9359
2397
+ ],
2398
+ [
2399
+ 47.5408,
2400
+ 47.5359
2401
+ ],
2402
+ [
2403
+ 47.5408,
2404
+ 49.1359
2405
+ ],
2406
+ [
2407
+ 47.5408,
2408
+ 50.7359
2409
+ ],
2410
+ [
2411
+ 47.5408,
2412
+ 52.3359
2413
+ ],
2414
+ [
2415
+ 47.5408,
2416
+ 53.9359
2417
+ ],
2418
+ [
2419
+ 47.5408,
2420
+ 76.3359
2421
+ ],
2422
+ [
2423
+ 47.5408,
2424
+ 77.9359
2425
+ ],
2426
+ [
2427
+ 49.1408,
2428
+ 9.1359
2429
+ ],
2430
+ [
2431
+ 49.1408,
2432
+ 10.7359
2433
+ ],
2434
+ [
2435
+ 49.1408,
2436
+ 23.5359
2437
+ ],
2438
+ [
2439
+ 49.1408,
2440
+ 25.1359
2441
+ ],
2442
+ [
2443
+ 49.1408,
2444
+ 26.7359
2445
+ ],
2446
+ [
2447
+ 49.1408,
2448
+ 34.7359
2449
+ ],
2450
+ [
2451
+ 49.1408,
2452
+ 36.3359
2453
+ ],
2454
+ [
2455
+ 49.1408,
2456
+ 41.1359
2457
+ ],
2458
+ [
2459
+ 49.1408,
2460
+ 47.5359
2461
+ ],
2462
+ [
2463
+ 49.1408,
2464
+ 49.1359
2465
+ ],
2466
+ [
2467
+ 49.1408,
2468
+ 50.7359
2469
+ ],
2470
+ [
2471
+ 49.1408,
2472
+ 52.3359
2473
+ ],
2474
+ [
2475
+ 49.1408,
2476
+ 53.9359
2477
+ ],
2478
+ [
2479
+ 49.1408,
2480
+ 74.7359
2481
+ ],
2482
+ [
2483
+ 49.1408,
2484
+ 76.3359
2485
+ ],
2486
+ [
2487
+ 49.1408,
2488
+ 77.9359
2489
+ ],
2490
+ [
2491
+ 50.7408,
2492
+ 9.1359
2493
+ ],
2494
+ [
2495
+ 50.7408,
2496
+ 10.7359
2497
+ ],
2498
+ [
2499
+ 50.7408,
2500
+ 23.5359
2501
+ ],
2502
+ [
2503
+ 50.7408,
2504
+ 25.1359
2505
+ ],
2506
+ [
2507
+ 50.7408,
2508
+ 26.7359
2509
+ ],
2510
+ [
2511
+ 50.7408,
2512
+ 28.3359
2513
+ ],
2514
+ [
2515
+ 50.7408,
2516
+ 29.9359
2517
+ ],
2518
+ [
2519
+ 50.7408,
2520
+ 31.5359
2521
+ ],
2522
+ [
2523
+ 50.7408,
2524
+ 34.7359
2525
+ ],
2526
+ [
2527
+ 50.7408,
2528
+ 36.3359
2529
+ ],
2530
+ [
2531
+ 50.7408,
2532
+ 41.1359
2533
+ ],
2534
+ [
2535
+ 50.7408,
2536
+ 42.7359
2537
+ ],
2538
+ [
2539
+ 50.7408,
2540
+ 47.5359
2541
+ ],
2542
+ [
2543
+ 50.7408,
2544
+ 49.1359
2545
+ ],
2546
+ [
2547
+ 50.7408,
2548
+ 50.7359
2549
+ ],
2550
+ [
2551
+ 50.7408,
2552
+ 52.3359
2553
+ ],
2554
+ [
2555
+ 50.7408,
2556
+ 53.9359
2557
+ ],
2558
+ [
2559
+ 50.7408,
2560
+ 55.5359
2561
+ ],
2562
+ [
2563
+ 50.7408,
2564
+ 73.1359
2565
+ ],
2566
+ [
2567
+ 50.7408,
2568
+ 74.7359
2569
+ ],
2570
+ [
2571
+ 50.7408,
2572
+ 76.3359
2573
+ ],
2574
+ [
2575
+ 52.3408,
2576
+ 9.1359
2577
+ ],
2578
+ [
2579
+ 52.3408,
2580
+ 10.7359
2581
+ ],
2582
+ [
2583
+ 52.3408,
2584
+ 23.5359
2585
+ ],
2586
+ [
2587
+ 52.3408,
2588
+ 25.1359
2589
+ ],
2590
+ [
2591
+ 52.3408,
2592
+ 26.7359
2593
+ ],
2594
+ [
2595
+ 52.3408,
2596
+ 28.3359
2597
+ ],
2598
+ [
2599
+ 52.3408,
2600
+ 29.9359
2601
+ ],
2602
+ [
2603
+ 52.3408,
2604
+ 31.5359
2605
+ ],
2606
+ [
2607
+ 52.3408,
2608
+ 33.1359
2609
+ ],
2610
+ [
2611
+ 52.3408,
2612
+ 34.7359
2613
+ ],
2614
+ [
2615
+ 52.3408,
2616
+ 36.3359
2617
+ ],
2618
+ [
2619
+ 52.3408,
2620
+ 39.5359
2621
+ ],
2622
+ [
2623
+ 52.3408,
2624
+ 41.1359
2625
+ ],
2626
+ [
2627
+ 52.3408,
2628
+ 42.7359
2629
+ ],
2630
+ [
2631
+ 52.3408,
2632
+ 47.5359
2633
+ ],
2634
+ [
2635
+ 52.3408,
2636
+ 49.1359
2637
+ ],
2638
+ [
2639
+ 52.3408,
2640
+ 50.7359
2641
+ ],
2642
+ [
2643
+ 52.3408,
2644
+ 52.3359
2645
+ ],
2646
+ [
2647
+ 52.3408,
2648
+ 53.9359
2649
+ ],
2650
+ [
2651
+ 52.3408,
2652
+ 55.5359
2653
+ ],
2654
+ [
2655
+ 52.3408,
2656
+ 57.1359
2657
+ ],
2658
+ [
2659
+ 52.3408,
2660
+ 58.7359
2661
+ ],
2662
+ [
2663
+ 52.3408,
2664
+ 63.5359
2665
+ ],
2666
+ [
2667
+ 52.3408,
2668
+ 65.1359
2669
+ ],
2670
+ [
2671
+ 52.3408,
2672
+ 71.5359
2673
+ ],
2674
+ [
2675
+ 52.3408,
2676
+ 73.1359
2677
+ ],
2678
+ [
2679
+ 52.3408,
2680
+ 74.7359
2681
+ ],
2682
+ [
2683
+ 52.3408,
2684
+ 76.3359
2685
+ ],
2686
+ [
2687
+ 53.9408,
2688
+ 9.1359
2689
+ ],
2690
+ [
2691
+ 53.9408,
2692
+ 15.5359
2693
+ ],
2694
+ [
2695
+ 53.9408,
2696
+ 21.9359
2697
+ ],
2698
+ [
2699
+ 53.9408,
2700
+ 23.5359
2701
+ ],
2702
+ [
2703
+ 53.9408,
2704
+ 25.1359
2705
+ ],
2706
+ [
2707
+ 53.9408,
2708
+ 26.7359
2709
+ ],
2710
+ [
2711
+ 53.9408,
2712
+ 28.3359
2713
+ ],
2714
+ [
2715
+ 53.9408,
2716
+ 29.9359
2717
+ ],
2718
+ [
2719
+ 53.9408,
2720
+ 31.5359
2721
+ ],
2722
+ [
2723
+ 53.9408,
2724
+ 33.1359
2725
+ ],
2726
+ [
2727
+ 53.9408,
2728
+ 34.7359
2729
+ ],
2730
+ [
2731
+ 53.9408,
2732
+ 39.5359
2733
+ ],
2734
+ [
2735
+ 53.9408,
2736
+ 41.1359
2737
+ ],
2738
+ [
2739
+ 53.9408,
2740
+ 42.7359
2741
+ ],
2742
+ [
2743
+ 53.9408,
2744
+ 47.5359
2745
+ ],
2746
+ [
2747
+ 53.9408,
2748
+ 49.1359
2749
+ ],
2750
+ [
2751
+ 53.9408,
2752
+ 50.7359
2753
+ ],
2754
+ [
2755
+ 53.9408,
2756
+ 52.3359
2757
+ ],
2758
+ [
2759
+ 53.9408,
2760
+ 57.1359
2761
+ ],
2762
+ [
2763
+ 53.9408,
2764
+ 58.7359
2765
+ ],
2766
+ [
2767
+ 53.9408,
2768
+ 60.3359
2769
+ ],
2770
+ [
2771
+ 53.9408,
2772
+ 61.9359
2773
+ ],
2774
+ [
2775
+ 53.9408,
2776
+ 63.5359
2777
+ ],
2778
+ [
2779
+ 53.9408,
2780
+ 65.1359
2781
+ ],
2782
+ [
2783
+ 53.9408,
2784
+ 66.7359
2785
+ ],
2786
+ [
2787
+ 53.9408,
2788
+ 71.5359
2789
+ ],
2790
+ [
2791
+ 53.9408,
2792
+ 73.1359
2793
+ ],
2794
+ [
2795
+ 53.9408,
2796
+ 74.7359
2797
+ ],
2798
+ [
2799
+ 55.5408,
2800
+ 9.1359
2801
+ ],
2802
+ [
2803
+ 55.5408,
2804
+ 10.7359
2805
+ ],
2806
+ [
2807
+ 55.5408,
2808
+ 13.9359
2809
+ ],
2810
+ [
2811
+ 55.5408,
2812
+ 15.5359
2813
+ ],
2814
+ [
2815
+ 55.5408,
2816
+ 17.1359
2817
+ ],
2818
+ [
2819
+ 55.5408,
2820
+ 18.7359
2821
+ ],
2822
+ [
2823
+ 55.5408,
2824
+ 20.3359
2825
+ ],
2826
+ [
2827
+ 55.5408,
2828
+ 21.9359
2829
+ ],
2830
+ [
2831
+ 55.5408,
2832
+ 23.5359
2833
+ ],
2834
+ [
2835
+ 55.5408,
2836
+ 25.1359
2837
+ ],
2838
+ [
2839
+ 55.5408,
2840
+ 26.7359
2841
+ ],
2842
+ [
2843
+ 55.5408,
2844
+ 28.3359
2845
+ ],
2846
+ [
2847
+ 55.5408,
2848
+ 29.9359
2849
+ ],
2850
+ [
2851
+ 55.5408,
2852
+ 31.5359
2853
+ ],
2854
+ [
2855
+ 55.5408,
2856
+ 33.1359
2857
+ ],
2858
+ [
2859
+ 55.5408,
2860
+ 34.7359
2861
+ ],
2862
+ [
2863
+ 55.5408,
2864
+ 37.9359
2865
+ ],
2866
+ [
2867
+ 55.5408,
2868
+ 39.5359
2869
+ ],
2870
+ [
2871
+ 55.5408,
2872
+ 41.1359
2873
+ ],
2874
+ [
2875
+ 55.5408,
2876
+ 42.7359
2877
+ ],
2878
+ [
2879
+ 55.5408,
2880
+ 44.3359
2881
+ ],
2882
+ [
2883
+ 55.5408,
2884
+ 47.5359
2885
+ ],
2886
+ [
2887
+ 55.5408,
2888
+ 49.1359
2889
+ ],
2890
+ [
2891
+ 55.5408,
2892
+ 50.7359
2893
+ ],
2894
+ [
2895
+ 55.5408,
2896
+ 52.3359
2897
+ ],
2898
+ [
2899
+ 55.5408,
2900
+ 58.7359
2901
+ ],
2902
+ [
2903
+ 55.5408,
2904
+ 60.3359
2905
+ ],
2906
+ [
2907
+ 55.5408,
2908
+ 61.9359
2909
+ ],
2910
+ [
2911
+ 55.5408,
2912
+ 63.5359
2913
+ ],
2914
+ [
2915
+ 55.5408,
2916
+ 65.1359
2917
+ ],
2918
+ [
2919
+ 55.5408,
2920
+ 66.7359
2921
+ ],
2922
+ [
2923
+ 55.5408,
2924
+ 68.3359
2925
+ ],
2926
+ [
2927
+ 55.5408,
2928
+ 69.9359
2929
+ ],
2930
+ [
2931
+ 55.5408,
2932
+ 71.5359
2933
+ ],
2934
+ [
2935
+ 55.5408,
2936
+ 73.1359
2937
+ ],
2938
+ [
2939
+ 55.5408,
2940
+ 74.7359
2941
+ ],
2942
+ [
2943
+ 57.1408,
2944
+ 9.1359
2945
+ ],
2946
+ [
2947
+ 57.1408,
2948
+ 10.7359
2949
+ ],
2950
+ [
2951
+ 57.1408,
2952
+ 12.3359
2953
+ ],
2954
+ [
2955
+ 57.1408,
2956
+ 13.9359
2957
+ ],
2958
+ [
2959
+ 57.1408,
2960
+ 15.5359
2961
+ ],
2962
+ [
2963
+ 57.1408,
2964
+ 17.1359
2965
+ ],
2966
+ [
2967
+ 57.1408,
2968
+ 18.7359
2969
+ ],
2970
+ [
2971
+ 57.1408,
2972
+ 20.3359
2973
+ ],
2974
+ [
2975
+ 57.1408,
2976
+ 21.9359
2977
+ ],
2978
+ [
2979
+ 57.1408,
2980
+ 26.7359
2981
+ ],
2982
+ [
2983
+ 57.1408,
2984
+ 28.3359
2985
+ ],
2986
+ [
2987
+ 57.1408,
2988
+ 29.9359
2989
+ ],
2990
+ [
2991
+ 57.1408,
2992
+ 31.5359
2993
+ ],
2994
+ [
2995
+ 57.1408,
2996
+ 33.1359
2997
+ ],
2998
+ [
2999
+ 57.1408,
3000
+ 37.9359
3001
+ ],
3002
+ [
3003
+ 57.1408,
3004
+ 39.5359
3005
+ ],
3006
+ [
3007
+ 57.1408,
3008
+ 41.1359
3009
+ ],
3010
+ [
3011
+ 57.1408,
3012
+ 42.7359
3013
+ ],
3014
+ [
3015
+ 57.1408,
3016
+ 44.3359
3017
+ ],
3018
+ [
3019
+ 57.1408,
3020
+ 45.9359
3021
+ ],
3022
+ [
3023
+ 57.1408,
3024
+ 47.5359
3025
+ ],
3026
+ [
3027
+ 57.1408,
3028
+ 60.3359
3029
+ ],
3030
+ [
3031
+ 57.1408,
3032
+ 61.9359
3033
+ ],
3034
+ [
3035
+ 57.1408,
3036
+ 63.5359
3037
+ ],
3038
+ [
3039
+ 57.1408,
3040
+ 65.1359
3041
+ ],
3042
+ [
3043
+ 57.1408,
3044
+ 66.7359
3045
+ ],
3046
+ [
3047
+ 57.1408,
3048
+ 68.3359
3049
+ ],
3050
+ [
3051
+ 57.1408,
3052
+ 69.9359
3053
+ ],
3054
+ [
3055
+ 57.1408,
3056
+ 71.5359
3057
+ ],
3058
+ [
3059
+ 57.1408,
3060
+ 73.1359
3061
+ ],
3062
+ [
3063
+ 58.7408,
3064
+ 7.5359
3065
+ ],
3066
+ [
3067
+ 58.7408,
3068
+ 9.1359
3069
+ ],
3070
+ [
3071
+ 58.7408,
3072
+ 10.7359
3073
+ ],
3074
+ [
3075
+ 58.7408,
3076
+ 12.3359
3077
+ ],
3078
+ [
3079
+ 58.7408,
3080
+ 13.9359
3081
+ ],
3082
+ [
3083
+ 58.7408,
3084
+ 15.5359
3085
+ ],
3086
+ [
3087
+ 58.7408,
3088
+ 17.1359
3089
+ ],
3090
+ [
3091
+ 58.7408,
3092
+ 18.7359
3093
+ ],
3094
+ [
3095
+ 58.7408,
3096
+ 20.3359
3097
+ ],
3098
+ [
3099
+ 58.7408,
3100
+ 21.9359
3101
+ ],
3102
+ [
3103
+ 58.7408,
3104
+ 29.9359
3105
+ ],
3106
+ [
3107
+ 58.7408,
3108
+ 31.5359
3109
+ ],
3110
+ [
3111
+ 58.7408,
3112
+ 37.9359
3113
+ ],
3114
+ [
3115
+ 58.7408,
3116
+ 39.5359
3117
+ ],
3118
+ [
3119
+ 58.7408,
3120
+ 41.1359
3121
+ ],
3122
+ [
3123
+ 58.7408,
3124
+ 42.7359
3125
+ ],
3126
+ [
3127
+ 58.7408,
3128
+ 44.3359
3129
+ ],
3130
+ [
3131
+ 58.7408,
3132
+ 45.9359
3133
+ ],
3134
+ [
3135
+ 58.7408,
3136
+ 58.7359
3137
+ ],
3138
+ [
3139
+ 58.7408,
3140
+ 60.3359
3141
+ ],
3142
+ [
3143
+ 58.7408,
3144
+ 61.9359
3145
+ ],
3146
+ [
3147
+ 58.7408,
3148
+ 63.5359
3149
+ ],
3150
+ [
3151
+ 58.7408,
3152
+ 65.1359
3153
+ ],
3154
+ [
3155
+ 58.7408,
3156
+ 66.7359
3157
+ ],
3158
+ [
3159
+ 58.7408,
3160
+ 68.3359
3161
+ ],
3162
+ [
3163
+ 58.7408,
3164
+ 69.9359
3165
+ ],
3166
+ [
3167
+ 58.7408,
3168
+ 71.5359
3169
+ ],
3170
+ [
3171
+ 60.3408,
3172
+ 7.5359
3173
+ ],
3174
+ [
3175
+ 60.3408,
3176
+ 9.1359
3177
+ ],
3178
+ [
3179
+ 60.3408,
3180
+ 10.7359
3181
+ ],
3182
+ [
3183
+ 60.3408,
3184
+ 12.3359
3185
+ ],
3186
+ [
3187
+ 60.3408,
3188
+ 13.9359
3189
+ ],
3190
+ [
3191
+ 60.3408,
3192
+ 15.5359
3193
+ ],
3194
+ [
3195
+ 60.3408,
3196
+ 17.1359
3197
+ ],
3198
+ [
3199
+ 60.3408,
3200
+ 18.7359
3201
+ ],
3202
+ [
3203
+ 60.3408,
3204
+ 20.3359
3205
+ ],
3206
+ [
3207
+ 60.3408,
3208
+ 21.9359
3209
+ ],
3210
+ [
3211
+ 60.3408,
3212
+ 23.5359
3213
+ ],
3214
+ [
3215
+ 60.3408,
3216
+ 29.9359
3217
+ ],
3218
+ [
3219
+ 60.3408,
3220
+ 31.5359
3221
+ ],
3222
+ [
3223
+ 60.3408,
3224
+ 36.3359
3225
+ ],
3226
+ [
3227
+ 60.3408,
3228
+ 37.9359
3229
+ ],
3230
+ [
3231
+ 60.3408,
3232
+ 39.5359
3233
+ ],
3234
+ [
3235
+ 60.3408,
3236
+ 41.1359
3237
+ ],
3238
+ [
3239
+ 60.3408,
3240
+ 42.7359
3241
+ ],
3242
+ [
3243
+ 60.3408,
3244
+ 44.3359
3245
+ ],
3246
+ [
3247
+ 60.3408,
3248
+ 58.7359
3249
+ ],
3250
+ [
3251
+ 60.3408,
3252
+ 60.3359
3253
+ ],
3254
+ [
3255
+ 60.3408,
3256
+ 63.5359
3257
+ ],
3258
+ [
3259
+ 60.3408,
3260
+ 65.1359
3261
+ ],
3262
+ [
3263
+ 60.3408,
3264
+ 66.7359
3265
+ ],
3266
+ [
3267
+ 60.3408,
3268
+ 68.3359
3269
+ ],
3270
+ [
3271
+ 60.3408,
3272
+ 69.9359
3273
+ ],
3274
+ [
3275
+ 60.3408,
3276
+ 71.5359
3277
+ ],
3278
+ [
3279
+ 61.9408,
3280
+ 10.7359
3281
+ ],
3282
+ [
3283
+ 61.9408,
3284
+ 12.3359
3285
+ ],
3286
+ [
3287
+ 61.9408,
3288
+ 13.9359
3289
+ ],
3290
+ [
3291
+ 61.9408,
3292
+ 15.5359
3293
+ ],
3294
+ [
3295
+ 61.9408,
3296
+ 17.1359
3297
+ ],
3298
+ [
3299
+ 61.9408,
3300
+ 18.7359
3301
+ ],
3302
+ [
3303
+ 61.9408,
3304
+ 20.3359
3305
+ ],
3306
+ [
3307
+ 61.9408,
3308
+ 21.9359
3309
+ ],
3310
+ [
3311
+ 61.9408,
3312
+ 23.5359
3313
+ ],
3314
+ [
3315
+ 61.9408,
3316
+ 25.1359
3317
+ ],
3318
+ [
3319
+ 61.9408,
3320
+ 29.9359
3321
+ ],
3322
+ [
3323
+ 61.9408,
3324
+ 33.1359
3325
+ ],
3326
+ [
3327
+ 61.9408,
3328
+ 34.7359
3329
+ ],
3330
+ [
3331
+ 61.9408,
3332
+ 36.3359
3333
+ ],
3334
+ [
3335
+ 61.9408,
3336
+ 37.9359
3337
+ ],
3338
+ [
3339
+ 61.9408,
3340
+ 39.5359
3341
+ ],
3342
+ [
3343
+ 61.9408,
3344
+ 41.1359
3345
+ ],
3346
+ [
3347
+ 61.9408,
3348
+ 42.7359
3349
+ ],
3350
+ [
3351
+ 61.9408,
3352
+ 57.1359
3353
+ ],
3354
+ [
3355
+ 61.9408,
3356
+ 58.7359
3357
+ ],
3358
+ [
3359
+ 61.9408,
3360
+ 60.3359
3361
+ ],
3362
+ [
3363
+ 61.9408,
3364
+ 63.5359
3365
+ ],
3366
+ [
3367
+ 61.9408,
3368
+ 65.1359
3369
+ ],
3370
+ [
3371
+ 61.9408,
3372
+ 66.7359
3373
+ ],
3374
+ [
3375
+ 61.9408,
3376
+ 68.3359
3377
+ ],
3378
+ [
3379
+ 61.9408,
3380
+ 69.9359
3381
+ ],
3382
+ [
3383
+ 63.5408,
3384
+ 9.1359
3385
+ ],
3386
+ [
3387
+ 63.5408,
3388
+ 10.7359
3389
+ ],
3390
+ [
3391
+ 63.5408,
3392
+ 12.3359
3393
+ ],
3394
+ [
3395
+ 63.5408,
3396
+ 13.9359
3397
+ ],
3398
+ [
3399
+ 63.5408,
3400
+ 15.5359
3401
+ ],
3402
+ [
3403
+ 63.5408,
3404
+ 17.1359
3405
+ ],
3406
+ [
3407
+ 63.5408,
3408
+ 18.7359
3409
+ ],
3410
+ [
3411
+ 63.5408,
3412
+ 20.3359
3413
+ ],
3414
+ [
3415
+ 63.5408,
3416
+ 21.9359
3417
+ ],
3418
+ [
3419
+ 63.5408,
3420
+ 23.5359
3421
+ ],
3422
+ [
3423
+ 63.5408,
3424
+ 25.1359
3425
+ ],
3426
+ [
3427
+ 63.5408,
3428
+ 33.1359
3429
+ ],
3430
+ [
3431
+ 63.5408,
3432
+ 34.7359
3433
+ ],
3434
+ [
3435
+ 63.5408,
3436
+ 36.3359
3437
+ ],
3438
+ [
3439
+ 63.5408,
3440
+ 37.9359
3441
+ ],
3442
+ [
3443
+ 63.5408,
3444
+ 39.5359
3445
+ ],
3446
+ [
3447
+ 63.5408,
3448
+ 41.1359
3449
+ ],
3450
+ [
3451
+ 63.5408,
3452
+ 42.7359
3453
+ ],
3454
+ [
3455
+ 63.5408,
3456
+ 57.1359
3457
+ ],
3458
+ [
3459
+ 63.5408,
3460
+ 58.7359
3461
+ ],
3462
+ [
3463
+ 63.5408,
3464
+ 65.1359
3465
+ ],
3466
+ [
3467
+ 63.5408,
3468
+ 66.7359
3469
+ ],
3470
+ [
3471
+ 63.5408,
3472
+ 68.3359
3473
+ ],
3474
+ [
3475
+ 63.5408,
3476
+ 69.9359
3477
+ ],
3478
+ [
3479
+ 63.5408,
3480
+ 71.5359
3481
+ ],
3482
+ [
3483
+ 65.1408,
3484
+ 7.5359
3485
+ ],
3486
+ [
3487
+ 65.1408,
3488
+ 9.1359
3489
+ ],
3490
+ [
3491
+ 65.1408,
3492
+ 10.7359
3493
+ ],
3494
+ [
3495
+ 65.1408,
3496
+ 12.3359
3497
+ ],
3498
+ [
3499
+ 65.1408,
3500
+ 13.9359
3501
+ ],
3502
+ [
3503
+ 65.1408,
3504
+ 15.5359
3505
+ ],
3506
+ [
3507
+ 65.1408,
3508
+ 17.1359
3509
+ ],
3510
+ [
3511
+ 65.1408,
3512
+ 18.7359
3513
+ ],
3514
+ [
3515
+ 65.1408,
3516
+ 20.3359
3517
+ ],
3518
+ [
3519
+ 65.1408,
3520
+ 21.9359
3521
+ ],
3522
+ [
3523
+ 65.1408,
3524
+ 23.5359
3525
+ ],
3526
+ [
3527
+ 65.1408,
3528
+ 25.1359
3529
+ ],
3530
+ [
3531
+ 65.1408,
3532
+ 31.5359
3533
+ ],
3534
+ [
3535
+ 65.1408,
3536
+ 33.1359
3537
+ ],
3538
+ [
3539
+ 65.1408,
3540
+ 34.7359
3541
+ ],
3542
+ [
3543
+ 65.1408,
3544
+ 36.3359
3545
+ ],
3546
+ [
3547
+ 65.1408,
3548
+ 37.9359
3549
+ ],
3550
+ [
3551
+ 65.1408,
3552
+ 39.5359
3553
+ ],
3554
+ [
3555
+ 65.1408,
3556
+ 41.1359
3557
+ ],
3558
+ [
3559
+ 65.1408,
3560
+ 42.7359
3561
+ ],
3562
+ [
3563
+ 65.1408,
3564
+ 57.1359
3565
+ ],
3566
+ [
3567
+ 65.1408,
3568
+ 58.7359
3569
+ ],
3570
+ [
3571
+ 65.1408,
3572
+ 60.3359
3573
+ ],
3574
+ [
3575
+ 65.1408,
3576
+ 65.1359
3577
+ ],
3578
+ [
3579
+ 65.1408,
3580
+ 66.7359
3581
+ ],
3582
+ [
3583
+ 65.1408,
3584
+ 68.3359
3585
+ ],
3586
+ [
3587
+ 65.1408,
3588
+ 69.9359
3589
+ ],
3590
+ [
3591
+ 66.7408,
3592
+ 7.5359
3593
+ ],
3594
+ [
3595
+ 66.7408,
3596
+ 9.1359
3597
+ ],
3598
+ [
3599
+ 66.7408,
3600
+ 10.7359
3601
+ ],
3602
+ [
3603
+ 66.7408,
3604
+ 12.3359
3605
+ ],
3606
+ [
3607
+ 66.7408,
3608
+ 13.9359
3609
+ ],
3610
+ [
3611
+ 66.7408,
3612
+ 15.5359
3613
+ ],
3614
+ [
3615
+ 66.7408,
3616
+ 17.1359
3617
+ ],
3618
+ [
3619
+ 66.7408,
3620
+ 18.7359
3621
+ ],
3622
+ [
3623
+ 66.7408,
3624
+ 20.3359
3625
+ ],
3626
+ [
3627
+ 66.7408,
3628
+ 21.9359
3629
+ ],
3630
+ [
3631
+ 66.7408,
3632
+ 23.5359
3633
+ ],
3634
+ [
3635
+ 66.7408,
3636
+ 25.1359
3637
+ ],
3638
+ [
3639
+ 66.7408,
3640
+ 31.5359
3641
+ ],
3642
+ [
3643
+ 66.7408,
3644
+ 33.1359
3645
+ ],
3646
+ [
3647
+ 66.7408,
3648
+ 34.7359
3649
+ ],
3650
+ [
3651
+ 66.7408,
3652
+ 36.3359
3653
+ ],
3654
+ [
3655
+ 66.7408,
3656
+ 37.9359
3657
+ ],
3658
+ [
3659
+ 66.7408,
3660
+ 39.5359
3661
+ ],
3662
+ [
3663
+ 66.7408,
3664
+ 41.1359
3665
+ ],
3666
+ [
3667
+ 66.7408,
3668
+ 42.7359
3669
+ ],
3670
+ [
3671
+ 66.7408,
3672
+ 58.7359
3673
+ ],
3674
+ [
3675
+ 66.7408,
3676
+ 60.3359
3677
+ ],
3678
+ [
3679
+ 66.7408,
3680
+ 61.9359
3681
+ ],
3682
+ [
3683
+ 66.7408,
3684
+ 66.7359
3685
+ ],
3686
+ [
3687
+ 66.7408,
3688
+ 68.3359
3689
+ ],
3690
+ [
3691
+ 68.3408,
3692
+ 7.5359
3693
+ ],
3694
+ [
3695
+ 68.3408,
3696
+ 9.1359
3697
+ ],
3698
+ [
3699
+ 68.3408,
3700
+ 10.7359
3701
+ ],
3702
+ [
3703
+ 68.3408,
3704
+ 12.3359
3705
+ ],
3706
+ [
3707
+ 68.3408,
3708
+ 13.9359
3709
+ ],
3710
+ [
3711
+ 68.3408,
3712
+ 15.5359
3713
+ ],
3714
+ [
3715
+ 68.3408,
3716
+ 17.1359
3717
+ ],
3718
+ [
3719
+ 68.3408,
3720
+ 18.7359
3721
+ ],
3722
+ [
3723
+ 68.3408,
3724
+ 20.3359
3725
+ ],
3726
+ [
3727
+ 68.3408,
3728
+ 21.9359
3729
+ ],
3730
+ [
3731
+ 68.3408,
3732
+ 23.5359
3733
+ ],
3734
+ [
3735
+ 68.3408,
3736
+ 25.1359
3737
+ ],
3738
+ [
3739
+ 68.3408,
3740
+ 31.5359
3741
+ ],
3742
+ [
3743
+ 68.3408,
3744
+ 33.1359
3745
+ ],
3746
+ [
3747
+ 68.3408,
3748
+ 34.7359
3749
+ ],
3750
+ [
3751
+ 68.3408,
3752
+ 36.3359
3753
+ ],
3754
+ [
3755
+ 68.3408,
3756
+ 37.9359
3757
+ ],
3758
+ [
3759
+ 68.3408,
3760
+ 39.5359
3761
+ ],
3762
+ [
3763
+ 68.3408,
3764
+ 41.1359
3765
+ ],
3766
+ [
3767
+ 68.3408,
3768
+ 42.7359
3769
+ ],
3770
+ [
3771
+ 68.3408,
3772
+ 44.3359
3773
+ ],
3774
+ [
3775
+ 68.3408,
3776
+ 58.7359
3777
+ ],
3778
+ [
3779
+ 68.3408,
3780
+ 60.3359
3781
+ ],
3782
+ [
3783
+ 68.3408,
3784
+ 61.9359
3785
+ ],
3786
+ [
3787
+ 68.3408,
3788
+ 66.7359
3789
+ ],
3790
+ [
3791
+ 68.3408,
3792
+ 68.3359
3793
+ ],
3794
+ [
3795
+ 69.9408,
3796
+ 9.1359
3797
+ ],
3798
+ [
3799
+ 69.9408,
3800
+ 10.7359
3801
+ ],
3802
+ [
3803
+ 69.9408,
3804
+ 12.3359
3805
+ ],
3806
+ [
3807
+ 69.9408,
3808
+ 13.9359
3809
+ ],
3810
+ [
3811
+ 69.9408,
3812
+ 15.5359
3813
+ ],
3814
+ [
3815
+ 69.9408,
3816
+ 17.1359
3817
+ ],
3818
+ [
3819
+ 69.9408,
3820
+ 18.7359
3821
+ ],
3822
+ [
3823
+ 69.9408,
3824
+ 20.3359
3825
+ ],
3826
+ [
3827
+ 69.9408,
3828
+ 21.9359
3829
+ ],
3830
+ [
3831
+ 69.9408,
3832
+ 23.5359
3833
+ ],
3834
+ [
3835
+ 69.9408,
3836
+ 25.1359
3837
+ ],
3838
+ [
3839
+ 69.9408,
3840
+ 31.5359
3841
+ ],
3842
+ [
3843
+ 69.9408,
3844
+ 33.1359
3845
+ ],
3846
+ [
3847
+ 69.9408,
3848
+ 34.7359
3849
+ ],
3850
+ [
3851
+ 69.9408,
3852
+ 36.3359
3853
+ ],
3854
+ [
3855
+ 69.9408,
3856
+ 37.9359
3857
+ ],
3858
+ [
3859
+ 69.9408,
3860
+ 39.5359
3861
+ ],
3862
+ [
3863
+ 69.9408,
3864
+ 41.1359
3865
+ ],
3866
+ [
3867
+ 69.9408,
3868
+ 42.7359
3869
+ ],
3870
+ [
3871
+ 69.9408,
3872
+ 44.3359
3873
+ ],
3874
+ [
3875
+ 69.9408,
3876
+ 60.3359
3877
+ ],
3878
+ [
3879
+ 69.9408,
3880
+ 61.9359
3881
+ ],
3882
+ [
3883
+ 69.9408,
3884
+ 63.5359
3885
+ ],
3886
+ [
3887
+ 71.5408,
3888
+ 7.5359
3889
+ ],
3890
+ [
3891
+ 71.5408,
3892
+ 9.1359
3893
+ ],
3894
+ [
3895
+ 71.5408,
3896
+ 10.7359
3897
+ ],
3898
+ [
3899
+ 71.5408,
3900
+ 12.3359
3901
+ ],
3902
+ [
3903
+ 71.5408,
3904
+ 13.9359
3905
+ ],
3906
+ [
3907
+ 71.5408,
3908
+ 18.7359
3909
+ ],
3910
+ [
3911
+ 71.5408,
3912
+ 20.3359
3913
+ ],
3914
+ [
3915
+ 71.5408,
3916
+ 21.9359
3917
+ ],
3918
+ [
3919
+ 71.5408,
3920
+ 23.5359
3921
+ ],
3922
+ [
3923
+ 71.5408,
3924
+ 25.1359
3925
+ ],
3926
+ [
3927
+ 71.5408,
3928
+ 29.9359
3929
+ ],
3930
+ [
3931
+ 71.5408,
3932
+ 31.5359
3933
+ ],
3934
+ [
3935
+ 71.5408,
3936
+ 34.7359
3937
+ ],
3938
+ [
3939
+ 71.5408,
3940
+ 36.3359
3941
+ ],
3942
+ [
3943
+ 71.5408,
3944
+ 37.9359
3945
+ ],
3946
+ [
3947
+ 71.5408,
3948
+ 39.5359
3949
+ ],
3950
+ [
3951
+ 71.5408,
3952
+ 41.1359
3953
+ ],
3954
+ [
3955
+ 71.5408,
3956
+ 42.7359
3957
+ ],
3958
+ [
3959
+ 71.5408,
3960
+ 44.3359
3961
+ ],
3962
+ [
3963
+ 71.5408,
3964
+ 58.7359
3965
+ ],
3966
+ [
3967
+ 71.5408,
3968
+ 60.3359
3969
+ ],
3970
+ [
3971
+ 71.5408,
3972
+ 61.9359
3973
+ ],
3974
+ [
3975
+ 71.5408,
3976
+ 63.5359
3977
+ ],
3978
+ [
3979
+ 73.1408,
3980
+ 7.5359
3981
+ ],
3982
+ [
3983
+ 73.1408,
3984
+ 9.1359
3985
+ ],
3986
+ [
3987
+ 73.1408,
3988
+ 10.7359
3989
+ ],
3990
+ [
3991
+ 73.1408,
3992
+ 12.3359
3993
+ ],
3994
+ [
3995
+ 73.1408,
3996
+ 21.9359
3997
+ ],
3998
+ [
3999
+ 73.1408,
4000
+ 23.5359
4001
+ ],
4002
+ [
4003
+ 73.1408,
4004
+ 25.1359
4005
+ ],
4006
+ [
4007
+ 73.1408,
4008
+ 29.9359
4009
+ ],
4010
+ [
4011
+ 73.1408,
4012
+ 34.7359
4013
+ ],
4014
+ [
4015
+ 73.1408,
4016
+ 36.3359
4017
+ ],
4018
+ [
4019
+ 73.1408,
4020
+ 37.9359
4021
+ ],
4022
+ [
4023
+ 73.1408,
4024
+ 39.5359
4025
+ ],
4026
+ [
4027
+ 73.1408,
4028
+ 41.1359
4029
+ ],
4030
+ [
4031
+ 73.1408,
4032
+ 42.7359
4033
+ ],
4034
+ [
4035
+ 73.1408,
4036
+ 44.3359
4037
+ ],
4038
+ [
4039
+ 73.1408,
4040
+ 58.7359
4041
+ ],
4042
+ [
4043
+ 73.1408,
4044
+ 60.3359
4045
+ ],
4046
+ [
4047
+ 74.7408,
4048
+ 7.5359
4049
+ ],
4050
+ [
4051
+ 74.7408,
4052
+ 9.1359
4053
+ ],
4054
+ [
4055
+ 74.7408,
4056
+ 10.7359
4057
+ ],
4058
+ [
4059
+ 74.7408,
4060
+ 12.3359
4061
+ ],
4062
+ [
4063
+ 74.7408,
4064
+ 21.9359
4065
+ ],
4066
+ [
4067
+ 74.7408,
4068
+ 23.5359
4069
+ ],
4070
+ [
4071
+ 74.7408,
4072
+ 25.1359
4073
+ ],
4074
+ [
4075
+ 74.7408,
4076
+ 29.9359
4077
+ ],
4078
+ [
4079
+ 74.7408,
4080
+ 34.7359
4081
+ ],
4082
+ [
4083
+ 74.7408,
4084
+ 36.3359
4085
+ ],
4086
+ [
4087
+ 74.7408,
4088
+ 37.9359
4089
+ ],
4090
+ [
4091
+ 74.7408,
4092
+ 39.5359
4093
+ ],
4094
+ [
4095
+ 74.7408,
4096
+ 41.1359
4097
+ ],
4098
+ [
4099
+ 74.7408,
4100
+ 42.7359
4101
+ ],
4102
+ [
4103
+ 74.7408,
4104
+ 44.3359
4105
+ ],
4106
+ [
4107
+ 74.7408,
4108
+ 58.7359
4109
+ ],
4110
+ [
4111
+ 74.7408,
4112
+ 60.3359
4113
+ ],
4114
+ [
4115
+ 74.7408,
4116
+ 61.9359
4117
+ ],
4118
+ [
4119
+ 76.3408,
4120
+ 9.1359
4121
+ ],
4122
+ [
4123
+ 76.3408,
4124
+ 10.7359
4125
+ ],
4126
+ [
4127
+ 76.3408,
4128
+ 21.9359
4129
+ ],
4130
+ [
4131
+ 76.3408,
4132
+ 23.5359
4133
+ ],
4134
+ [
4135
+ 76.3408,
4136
+ 25.1359
4137
+ ],
4138
+ [
4139
+ 76.3408,
4140
+ 29.9359
4141
+ ],
4142
+ [
4143
+ 76.3408,
4144
+ 34.7359
4145
+ ],
4146
+ [
4147
+ 76.3408,
4148
+ 36.3359
4149
+ ],
4150
+ [
4151
+ 76.3408,
4152
+ 37.9359
4153
+ ],
4154
+ [
4155
+ 76.3408,
4156
+ 39.5359
4157
+ ],
4158
+ [
4159
+ 76.3408,
4160
+ 41.1359
4161
+ ],
4162
+ [
4163
+ 76.3408,
4164
+ 44.3359
4165
+ ],
4166
+ [
4167
+ 77.9408,
4168
+ 20.3359
4169
+ ],
4170
+ [
4171
+ 77.9408,
4172
+ 21.9359
4173
+ ],
4174
+ [
4175
+ 77.9408,
4176
+ 23.5359
4177
+ ],
4178
+ [
4179
+ 77.9408,
4180
+ 25.1359
4181
+ ],
4182
+ [
4183
+ 77.9408,
4184
+ 36.3359
4185
+ ],
4186
+ [
4187
+ 77.9408,
4188
+ 37.9359
4189
+ ],
4190
+ [
4191
+ 77.9408,
4192
+ 39.5359
4193
+ ],
4194
+ [
4195
+ 77.9408,
4196
+ 44.3359
4197
+ ],
4198
+ [
4199
+ 79.5408,
4200
+ 20.3359
4201
+ ],
4202
+ [
4203
+ 79.5408,
4204
+ 21.9359
4205
+ ],
4206
+ [
4207
+ 79.5408,
4208
+ 23.5359
4209
+ ]
4210
+ ]
4211
+ }
backend/static/game_positions.json ADDED
@@ -0,0 +1,362 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "starting_positions": [
3
+ {
4
+ "x": 25.57603645324707,
5
+ "y": 84.8847885131836,
6
+ "minerals": [
7
+ {
8
+ "x": 19,
9
+ "y": 67
10
+ },
11
+ {
12
+ "x": 21,
13
+ "y": 67
14
+ },
15
+ {
16
+ "x": 21,
17
+ "y": 66
18
+ },
19
+ {
20
+ "x": 21,
21
+ "y": 69
22
+ },
23
+ {
24
+ "x": 19,
25
+ "y": 70
26
+ },
27
+ {
28
+ "x": 21,
29
+ "y": 71
30
+ }
31
+ ],
32
+ "geysers": [
33
+ {
34
+ "x": 22,
35
+ "y": 66
36
+ }
37
+ ]
38
+ },
39
+ {
40
+ "x": 85.71428680419922,
41
+ "y": 47.096771240234375,
42
+ "minerals": [
43
+ {
44
+ "x": 72,
45
+ "y": 37
46
+ },
47
+ {
48
+ "x": 67,
49
+ "y": 36
50
+ },
51
+ {
52
+ "x": 68,
53
+ "y": 41
54
+ },
55
+ {
56
+ "x": 70,
57
+ "y": 37
58
+ },
59
+ {
60
+ "x": 66,
61
+ "y": 39
62
+ },
63
+ {
64
+ "x": 69,
65
+ "y": 41
66
+ },
67
+ {
68
+ "x": 70,
69
+ "y": 36
70
+ }
71
+ ],
72
+ "geysers": [
73
+ {
74
+ "x": 66,
75
+ "y": 37
76
+ }
77
+ ]
78
+ },
79
+ {
80
+ "x": 24.654376983642578,
81
+ "y": 13.80184268951416,
82
+ "minerals": [
83
+ {
84
+ "x": 21,
85
+ "y": 8
86
+ },
87
+ {
88
+ "x": 22,
89
+ "y": 9
90
+ },
91
+ {
92
+ "x": 19,
93
+ "y": 10
94
+ },
95
+ {
96
+ "x": 21,
97
+ "y": 10
98
+ },
99
+ {
100
+ "x": 20,
101
+ "y": 13
102
+ },
103
+ {
104
+ "x": 22,
105
+ "y": 11
106
+ },
107
+ {
108
+ "x": 18,
109
+ "y": 12
110
+ }
111
+ ],
112
+ "geysers": [
113
+ {
114
+ "x": 22,
115
+ "y": 13
116
+ }
117
+ ]
118
+ }
119
+ ],
120
+ "expansion_positions": [
121
+ {
122
+ "x": 81.10598754882812,
123
+ "y": 17.373271942138672,
124
+ "minerals": [
125
+ {
126
+ "x": 64,
127
+ "y": 11
128
+ },
129
+ {
130
+ "x": 66,
131
+ "y": 15
132
+ },
133
+ {
134
+ "x": 65,
135
+ "y": 16
136
+ },
137
+ {
138
+ "x": 66,
139
+ "y": 14
140
+ },
141
+ {
142
+ "x": 65,
143
+ "y": 15
144
+ },
145
+ {
146
+ "x": 63,
147
+ "y": 16
148
+ },
149
+ {
150
+ "x": 67,
151
+ "y": 13
152
+ }
153
+ ],
154
+ "geysers": [
155
+ {
156
+ "x": 67,
157
+ "y": 10
158
+ }
159
+ ]
160
+ },
161
+ {
162
+ "x": 54.838706970214844,
163
+ "y": 61.49769592285156,
164
+ "minerals": [
165
+ {
166
+ "x": 42,
167
+ "y": 49
168
+ },
169
+ {
170
+ "x": 45,
171
+ "y": 52
172
+ },
173
+ {
174
+ "x": 44,
175
+ "y": 48
176
+ },
177
+ {
178
+ "x": 44,
179
+ "y": 50
180
+ },
181
+ {
182
+ "x": 45,
183
+ "y": 48
184
+ },
185
+ {
186
+ "x": 44,
187
+ "y": 46
188
+ },
189
+ {
190
+ "x": 43,
191
+ "y": 47
192
+ }
193
+ ],
194
+ "geysers": [
195
+ {
196
+ "x": 44,
197
+ "y": 53
198
+ }
199
+ ]
200
+ },
201
+ {
202
+ "x": 76.61289978027344,
203
+ "y": 83.38709259033203,
204
+ "minerals": [
205
+ {
206
+ "x": 61,
207
+ "y": 68
208
+ },
209
+ {
210
+ "x": 61,
211
+ "y": 70
212
+ },
213
+ {
214
+ "x": 61,
215
+ "y": 69
216
+ },
217
+ {
218
+ "x": 59,
219
+ "y": 65
220
+ },
221
+ {
222
+ "x": 62,
223
+ "y": 64
224
+ },
225
+ {
226
+ "x": 60,
227
+ "y": 66
228
+ },
229
+ {
230
+ "x": 60,
231
+ "y": 70
232
+ }
233
+ ],
234
+ "geysers": [
235
+ {
236
+ "x": 65,
237
+ "y": 66
238
+ }
239
+ ]
240
+ },
241
+ {
242
+ "x": 20.967741012573242,
243
+ "y": 32.926265716552734,
244
+ "minerals": [
245
+ {
246
+ "x": 19,
247
+ "y": 25
248
+ },
249
+ {
250
+ "x": 18,
251
+ "y": 27
252
+ },
253
+ {
254
+ "x": 20,
255
+ "y": 26
256
+ },
257
+ {
258
+ "x": 16,
259
+ "y": 26
260
+ },
261
+ {
262
+ "x": 14,
263
+ "y": 27
264
+ },
265
+ {
266
+ "x": 18,
267
+ "y": 28
268
+ },
269
+ {
270
+ "x": 17,
271
+ "y": 23
272
+ }
273
+ ],
274
+ "geysers": [
275
+ {
276
+ "x": 17,
277
+ "y": 23
278
+ }
279
+ ]
280
+ },
281
+ {
282
+ "x": 67.97235107421875,
283
+ "y": 36.036865234375,
284
+ "minerals": [
285
+ {
286
+ "x": 56,
287
+ "y": 31
288
+ },
289
+ {
290
+ "x": 57,
291
+ "y": 29
292
+ },
293
+ {
294
+ "x": 54,
295
+ "y": 28
296
+ },
297
+ {
298
+ "x": 52,
299
+ "y": 30
300
+ },
301
+ {
302
+ "x": 54,
303
+ "y": 29
304
+ },
305
+ {
306
+ "x": 52,
307
+ "y": 27
308
+ },
309
+ {
310
+ "x": 53,
311
+ "y": 31
312
+ }
313
+ ],
314
+ "geysers": [
315
+ {
316
+ "x": 53,
317
+ "y": 33
318
+ }
319
+ ]
320
+ },
321
+ {
322
+ "x": 16.705068588256836,
323
+ "y": 57.926265716552734,
324
+ "minerals": [
325
+ {
326
+ "x": 16,
327
+ "y": 46
328
+ },
329
+ {
330
+ "x": 15,
331
+ "y": 47
332
+ },
333
+ {
334
+ "x": 14,
335
+ "y": 46
336
+ },
337
+ {
338
+ "x": 14,
339
+ "y": 48
340
+ },
341
+ {
342
+ "x": 12,
343
+ "y": 49
344
+ },
345
+ {
346
+ "x": 10,
347
+ "y": 45
348
+ },
349
+ {
350
+ "x": 15,
351
+ "y": 44
352
+ }
353
+ ],
354
+ "geysers": [
355
+ {
356
+ "x": 15,
357
+ "y": 43
358
+ }
359
+ ]
360
+ }
361
+ ]
362
+ }
backend/static/sprites/buildings/armory.png ADDED

Git LFS Details

  • SHA256: a864f8a0c9d06bd7c8db6ea810c188abcc6a667368e7f19526bebef827ce0751
  • Pointer size: 130 Bytes
  • Size of remote file: 22.4 kB
backend/static/sprites/buildings/barracks.png ADDED

Git LFS Details

  • SHA256: 87156d68975712a50f388ab6435ebd158742138cc14c004cb22a82257ba94b89
  • Pointer size: 130 Bytes
  • Size of remote file: 21.2 kB
backend/static/sprites/buildings/command_center.png CHANGED

Git LFS Details

  • SHA256: f8ca0874151eddb85d0e71fe0b2f43c57c9d4af1eae3241ce160f21572c42e18
  • Pointer size: 131 Bytes
  • Size of remote file: 183 kB

Git LFS Details

  • SHA256: 9e50ecac5ec9944b049b4cf7cbbcad8995f76ad07748084880317dd28acc4be3
  • Pointer size: 130 Bytes
  • Size of remote file: 21.5 kB
backend/static/sprites/buildings/engineering_bay.png ADDED

Git LFS Details

  • SHA256: 91d85a6e9aa63ff690bc7b711eb5165a0e20be275a5808bc62d41cb2d4abfe2e
  • Pointer size: 130 Bytes
  • Size of remote file: 20.9 kB
backend/static/sprites/buildings/factory.png ADDED

Git LFS Details

  • SHA256: eb67a56aa740b4fce5d2e240047d2ce2566272f04e9752ec7da4ce0d9915ff06
  • Pointer size: 130 Bytes
  • Size of remote file: 21.6 kB
backend/static/sprites/buildings/refinery.png ADDED

Git LFS Details

  • SHA256: c2760e6faf9f737645b646f665ed9ca73a88b432dc82444732ac1278ef0cec21
  • Pointer size: 130 Bytes
  • Size of remote file: 28.2 kB
backend/static/sprites/buildings/starport.png ADDED

Git LFS Details

  • SHA256: 5fb25bc20f5cf03c712c3facbee5289d0750e56a705ca3907edf1f7190b09c20
  • Pointer size: 130 Bytes
  • Size of remote file: 19.8 kB
backend/static/sprites/buildings/supply_depot.png ADDED

Git LFS Details

  • SHA256: e366256fbd2f59a4a99d71b820c6e7f63cc0acbf5404c7e7f52c683605c3b485
  • Pointer size: 130 Bytes
  • Size of remote file: 26.7 kB
backend/static/sprites/icons/gas.png ADDED

Git LFS Details

  • SHA256: 2bf2bccce9a1d4c2f86f0543439c040c92fe07a41adb8f286da437d863266924
  • Pointer size: 130 Bytes
  • Size of remote file: 12.9 kB
backend/static/sprites/icons/mineral.png ADDED

Git LFS Details

  • SHA256: b79480507cbaca437a65c1b437b516a3cdc72c89af6601c4436902b5e132a339
  • Pointer size: 130 Bytes
  • Size of remote file: 18.6 kB
backend/static/sprites/icons/supply.png ADDED

Git LFS Details

  • SHA256: 276dd6a366c22137229030a50100ff956d47b4c45b096b12c350894badd576db
  • Pointer size: 130 Bytes
  • Size of remote file: 12.6 kB
backend/static/sprites/resources/geyser.png ADDED

Git LFS Details

  • SHA256: 2998e671151cabb5b9371194f04c3a9db38f7179b3ce61dded269960f85a26c0
  • Pointer size: 130 Bytes
  • Size of remote file: 28.3 kB
backend/static/sprites/resources/mineral.png ADDED

Git LFS Details

  • SHA256: 4a5fb03684e78fcdabe5806198d2efd51705c011a3d1938af28a502cf1b3efc8
  • Pointer size: 130 Bytes
  • Size of remote file: 28.2 kB
backend/static/sprites/units/goliath.png ADDED

Git LFS Details

  • SHA256: abc2fd6c5b1ec2c60995fe34d022bf02c1c911a37fd3c1ae656f473f63ff3ab6
  • Pointer size: 130 Bytes
  • Size of remote file: 21.8 kB
backend/static/sprites/units/marine.png CHANGED

Git LFS Details

  • SHA256: 13aa0977f92a48719af480774df540612aeb2efdf943273ae634de85d9731901
  • Pointer size: 130 Bytes
  • Size of remote file: 59 kB

Git LFS Details

  • SHA256: 909a6cdff856fe47b49f19fbbd12ced39215cc1482f119aa3a07cac392b7df4f
  • Pointer size: 130 Bytes
  • Size of remote file: 21.4 kB
backend/static/sprites/units/medic.png ADDED

Git LFS Details

  • SHA256: 7235289521de24882286b2ed2cd7abcebe7a6bec1d77a902b454ea87c4b936ac
  • Pointer size: 130 Bytes
  • Size of remote file: 19.3 kB
backend/static/sprites/units/scv.png CHANGED

Git LFS Details

  • SHA256: 2c57e3bf925b6cba56b386a1c9a356114c63661c0d4b1b85ddf6e996bbe60d4b
  • Pointer size: 131 Bytes
  • Size of remote file: 118 kB

Git LFS Details

  • SHA256: 158eb323342eb035a859caef8be75d616cb01b75dc09889ab83cf4bd94b6f3b5
  • Pointer size: 130 Bytes
  • Size of remote file: 18.3 kB
backend/static/sprites/units/tank.png ADDED

Git LFS Details

  • SHA256: b8171e59c411e8fd9c9b5f2ac3f44d78fb58fa0e20d337065e28172a6cd610ba
  • Pointer size: 130 Bytes
  • Size of remote file: 21.6 kB
backend/static/sprites/units/wraith.png ADDED

Git LFS Details

  • SHA256: 5a8b7cc137c6ab6ca68b42d7cdaff6aae77685b66e692f2fa03b006be2bccc84
  • Pointer size: 130 Bytes
  • Size of remote file: 22.2 kB
backend/static/walkable.json CHANGED
@@ -356,12 +356,12 @@
356
  12.373571395874023
357
  ],
358
  [
359
- 83.61827850341797,
360
- 8.538786888122559
361
  ],
362
  [
363
- 86.0469741821289,
364
- 10.200526237487793
365
  ],
366
  [
367
  91.03219604492188,
@@ -380,8 +380,8 @@
380
  20.17096710205078
381
  ],
382
  [
383
- 90.77654266357422,
384
- 22.727489471435547
385
  ],
386
  [
387
  88.7313232421875,
@@ -392,24 +392,24 @@
392
  26.690099716186523
393
  ],
394
  [
395
- 96.65654754638672,
396
- 25.02836036682129
397
  ],
398
  [
399
- 98.57393646240234,
400
- 30.141407012939453
401
  ],
402
  [
403
- 92.18263244628906,
404
- 31.547494888305664
405
  ],
406
  [
407
- 83.74610137939453,
408
- 31.164016723632812
409
  ],
410
  [
411
- 77.35479736328125,
412
- 32.18662643432617
413
  ],
414
  [
415
  72.62522888183594,
@@ -466,12 +466,12 @@
466
  56.21794128417969
467
  ],
468
  [
469
- 28.141727447509766,
470
- 49.187503814697266
471
  ],
472
  [
473
- 25.329551696777344,
474
- 46.375328063964844
475
  ],
476
  [
477
  16.765199661254883,
@@ -532,8 +532,8 @@
532
  74.11360168457031
533
  ],
534
  [
535
- 77.35479736328125,
536
- 70.40664672851562
537
  ],
538
  [
539
  86.55828094482422,
@@ -564,8 +564,8 @@
564
  77.30925750732422
565
  ],
566
  [
567
- 88.22001647949219,
568
- 80.24925994873047
569
  ],
570
  [
571
  79.01653289794922,
@@ -622,8 +622,8 @@
622
  65.54924774169922
623
  ],
624
  [
625
- 47.18782424926758,
626
- 67.08316802978516
627
  ],
628
  [
629
  41.69129943847656,
@@ -638,20 +638,20 @@
638
  80.88838958740234
639
  ],
640
  [
641
- 45.90956115722656,
642
- 84.59535217285156
643
  ],
644
  [
645
- 42.33042907714844,
646
- 88.43013000488281
647
  ],
648
  [
649
  51.15043640136719,
650
  95.20491790771484
651
  ],
652
  [
653
- 58.18087387084961,
654
- 94.4379653930664
655
  ],
656
  [
657
  62.14348220825195,
@@ -730,8 +730,8 @@
730
  73.98577880859375
731
  ],
732
  [
733
- 43.99216842651367,
734
- 65.03794860839844
735
  ]
736
  ],
737
  [
 
356
  12.373571395874023
357
  ],
358
  [
359
+ 84.77071380615234,
360
+ 7.241750717163086
361
  ],
362
  [
363
+ 87.53814697265625,
364
+ 9.529439926147461
365
  ],
366
  [
367
  91.03219604492188,
 
380
  20.17096710205078
381
  ],
382
  [
383
+ 90.1014633178711,
384
+ 22.626026153564453
385
  ],
386
  [
387
  88.7313232421875,
 
392
  26.690099716186523
393
  ],
394
  [
395
+ 100,
396
+ 24.53423309326172
397
  ],
398
  [
399
+ 100,
400
+ 30.92753791809082
401
  ],
402
  [
403
+ 92.45945739746094,
404
+ 32.453460693359375
405
  ],
406
  [
407
+ 84.401611328125,
408
+ 31.450428009033203
409
  ],
410
  [
411
+ 77.79100799560547,
412
+ 33.05105209350586
413
  ],
414
  [
415
  72.62522888183594,
 
466
  56.21794128417969
467
  ],
468
  [
469
+ 29.418794631958008,
470
+ 49.158748626708984
471
  ],
472
  [
473
+ 24.801074981689453,
474
+ 45.830467224121094
475
  ],
476
  [
477
  16.765199661254883,
 
532
  74.11360168457031
533
  ],
534
  [
535
+ 79.2590103149414,
536
+ 69.9880142211914
537
  ],
538
  [
539
  86.55828094482422,
 
564
  77.30925750732422
565
  ],
566
  [
567
+ 88.57514953613281,
568
+ 81.07293701171875
569
  ],
570
  [
571
  79.01653289794922,
 
622
  65.54924774169922
623
  ],
624
  [
625
+ 47.70072937011719,
626
+ 67.28649139404297
627
  ],
628
  [
629
  41.69129943847656,
 
638
  80.88838958740234
639
  ],
640
  [
641
+ 46.48517608642578,
642
+ 85.19413757324219
643
  ],
644
  [
645
+ 41.934173583984375,
646
+ 88.30909729003906
647
  ],
648
  [
649
  51.15043640136719,
650
  95.20491790771484
651
  ],
652
  [
653
+ 58.30870056152344,
654
+ 94.26060485839844
655
  ],
656
  [
657
  62.14348220825195,
 
730
  73.98577880859375
731
  ],
732
  [
733
+ 43.087799072265625,
734
+ 65.17256164550781
735
  ]
736
  ],
737
  [
backend/voice/command_parser.py CHANGED
@@ -50,7 +50,7 @@ supply_depot, barracks, engineering_bay, refinery, factory, armory, starport
50
  all, all_military, all_marines, all_medics, all_goliaths, all_tanks, all_wraiths, all_scv, idle_scv, most_damaged
51
 
52
  === ZONES CIBLES ===
53
- my_base, enemy_base, center, top_left, top_right, bottom_left, bottom_right, front_line
54
 
55
  === ÉTAT ACTUEL DU JOUEUR ===
56
  {player_state}
@@ -58,9 +58,9 @@ my_base, enemy_base, center, top_left, top_right, bottom_left, bottom_right, fro
58
  === CONSIGNES ===
59
  - Réponds UNIQUEMENT avec un JSON valide, aucun texte avant ou après.
60
  - Une commande peut générer PLUSIEURS actions (ex: "entraîne 4 marines et attaque la base").
61
- - Le champ "feedback" est un message en français à lire au joueur pour confirmer l'action.
62
- - Si la commande est incompréhensible, génère une action de type "query" avec un feedback explicatif.
63
- - Si le joueur demande son état / ses ressources, utilise l'action "query".
64
 
65
  === FORMAT DE RÉPONSE ===
66
  {
@@ -72,26 +72,41 @@ my_base, enemy_base, center, top_left, top_right, bottom_left, bottom_right, fro
72
  "count": <entier, défaut 1>,
73
  "unit_selector": "<valeur ou omis>",
74
  "target_zone": "<valeur ou omis>",
75
- "resource_type": "<valeur ou omis>",
76
  "group_index": <1, 2 ou 3 pour assign_to_group, omis sinon>,
77
- "unit_ids": [<liste d'ids pour assign_to_group, vide si on enchaîne après query_units>]
78
  }
79
  ],
80
- "feedback": "<message en français>"
 
81
  }
82
  """
83
 
84
 
85
- async def parse(transcription: str, player: PlayerState) -> ParsedCommand:
 
 
 
 
86
  """
87
  Send transcription + player state to Mistral and return parsed command.
88
  Falls back to a query action if parsing fails.
 
 
89
  """
90
  if not MISTRAL_API_KEY:
91
  raise RuntimeError("MISTRAL_API_KEY not set")
92
 
93
  client = Mistral(api_key=MISTRAL_API_KEY)
94
- system = _SYSTEM_PROMPT.replace("{player_state}", player.summary())
 
 
 
 
 
 
 
 
95
 
96
  response = await client.chat.complete_async(
97
  model=MISTRAL_CHAT_MODEL,
@@ -109,13 +124,64 @@ async def parse(transcription: str, player: PlayerState) -> ParsedCommand:
109
  try:
110
  data = json.loads(raw)
111
  actions = [GameAction(**a) for a in data.get("actions", [])]
112
- feedback = data.get("feedback", "Commande reçue.")
 
 
 
113
  if not actions:
114
  raise ValueError("Empty actions list")
115
- return ParsedCommand(actions=actions, feedback=feedback)
 
 
116
  except Exception as exc:
117
  log.warning("Failed to parse Mistral response: %s — %s", exc, raw[:200])
 
 
 
 
 
 
118
  return ParsedCommand(
119
  actions=[GameAction(type=ActionType.QUERY)],
120
- feedback="Je n'ai pas compris cette commande. Voici ton état actuel.",
 
121
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  all, all_military, all_marines, all_medics, all_goliaths, all_tanks, all_wraiths, all_scv, idle_scv, most_damaged
51
 
52
  === ZONES CIBLES ===
53
+ my_base, enemy_base, center, top_left, top_right, bottom_left, bottom_right, front_line{resource_zones}
54
 
55
  === ÉTAT ACTUEL DU JOUEUR ===
56
  {player_state}
 
58
  === CONSIGNES ===
59
  - Réponds UNIQUEMENT avec un JSON valide, aucun texte avant ou après.
60
  - Une commande peut générer PLUSIEURS actions (ex: "entraîne 4 marines et attaque la base").
61
+ - Le champ "feedback_template" est une phrase courte DANS LA MÊME LANGUE que la commande, avec des placeholders pour les infos du jeu. Placeholders possibles: {n} (nombre d'unités), {zone}, {building}, {count}, {unit}, {resource}, {summary}, {mode}, {names}, {raw}. Ex: "Déplacement de {n} unités vers {zone}." ou "État: {summary}".
62
+ - Ajoute le champ "language" avec le code ISO de la langue: "fr", "en".
63
+ - Si la commande est incompréhensible, génère une action "query" avec feedback_template explicatif (ex: "Je n'ai pas compris. Voici ton état: {summary}").
64
 
65
  === FORMAT DE RÉPONSE ===
66
  {
 
72
  "count": <entier, défaut 1>,
73
  "unit_selector": "<valeur ou omis>",
74
  "target_zone": "<valeur ou omis>",
75
+ "resource_type": "<'minerals' ou 'gas', omis sinon>",
76
  "group_index": <1, 2 ou 3 pour assign_to_group, omis sinon>,
77
+ "unit_ids": []
78
  }
79
  ],
80
+ "feedback_template": "<phrase avec placeholders {n}, {zone}, etc. dans la langue du joueur>",
81
+ "language": "fr"
82
  }
83
  """
84
 
85
 
86
+ async def parse(
87
+ transcription: str,
88
+ player: PlayerState,
89
+ resource_zones: list[str] | None = None,
90
+ ) -> ParsedCommand:
91
  """
92
  Send transcription + player state to Mistral and return parsed command.
93
  Falls back to a query action if parsing fails.
94
+ resource_zones: list of zone names like ["mineral_1", ..., "geyser_1", ...]
95
+ sorted by proximity to the player's base.
96
  """
97
  if not MISTRAL_API_KEY:
98
  raise RuntimeError("MISTRAL_API_KEY not set")
99
 
100
  client = Mistral(api_key=MISTRAL_API_KEY)
101
+ if resource_zones:
102
+ rz_str = ", " + ", ".join(resource_zones)
103
+ else:
104
+ rz_str = ""
105
+ system = (
106
+ _SYSTEM_PROMPT
107
+ .replace("{player_state}", player.summary())
108
+ .replace("{resource_zones}", rz_str)
109
+ )
110
 
111
  response = await client.chat.complete_async(
112
  model=MISTRAL_CHAT_MODEL,
 
124
  try:
125
  data = json.loads(raw)
126
  actions = [GameAction(**a) for a in data.get("actions", [])]
127
+ language = (data.get("language") or "fr").strip().lower() or "fr"
128
+ if language not in ("fr", "en"):
129
+ language = "fr"
130
+ feedback_template = data.get("feedback_template") or data.get("feedback") or "{summary}"
131
  if not actions:
132
  raise ValueError("Empty actions list")
133
+ return ParsedCommand(
134
+ actions=actions, feedback_template=feedback_template, language=language
135
+ )
136
  except Exception as exc:
137
  log.warning("Failed to parse Mistral response: %s — %s", exc, raw[:200])
138
+ lang = _detect_language(transcription)
139
+ fallback_template = (
140
+ "I didn't understand. Here is your status: {summary}"
141
+ if lang == "en"
142
+ else "Je n'ai pas compris. Voici ton état: {summary}"
143
+ )
144
  return ParsedCommand(
145
  actions=[GameAction(type=ActionType.QUERY)],
146
+ feedback_template=fallback_template,
147
+ language=lang,
148
  )
149
+
150
+
151
+ def _detect_language(text: str) -> str:
152
+ """Heuristic: if transcription looks like English, return 'en', else 'fr'."""
153
+ if not text or not text.strip():
154
+ return "fr"
155
+ lower = text.lower().strip()
156
+ en_words = {
157
+ "build", "train", "attack", "move", "send", "create", "gather", "stop",
158
+ "patrol", "go", "select", "unit", "units", "base", "minerals", "gas",
159
+ "barracks", "factory", "scv", "marine", "tank", "all", "my", "the",
160
+ }
161
+ words = set(lower.split())
162
+ if words & en_words:
163
+ return "en"
164
+ return "fr"
165
+
166
+
167
+ async def generate_feedback(error_key: str, language: str) -> str:
168
+ """
169
+ Call the API to generate a short feedback message for an error, in the given language.
170
+ error_key: e.g. "game_not_in_progress"
171
+ """
172
+ if not MISTRAL_API_KEY:
173
+ return "Game is not in progress." if language == "en" else "La partie n'est pas en cours."
174
+
175
+ client = Mistral(api_key=MISTRAL_API_KEY)
176
+ prompt = f"""Generate exactly one short sentence for a strategy game feedback.
177
+ Context/error: {error_key}.
178
+ Language: {language}.
179
+ Reply with only that sentence, no quotes, no explanation."""
180
+ response = await client.chat.complete_async(
181
+ model=MISTRAL_CHAT_MODEL,
182
+ messages=[{"role": "user", "content": prompt}],
183
+ temperature=0.2,
184
+ max_tokens=80,
185
+ )
186
+ text = (response.choices[0].message.content or "").strip()
187
+ return text or ("Game is not in progress." if language == "en" else "La partie n'est pas en cours.")
frontend/.svelte-kit/non-ambient.d.ts CHANGED
@@ -27,20 +27,21 @@ export {};
27
 
28
  declare module "$app/types" {
29
  export interface AppTypes {
30
- RouteId(): "/" | "/admin" | "/admin/map" | "/admin/sounds" | "/admin/sprites" | "/game";
31
  RouteParams(): {
32
 
33
  };
34
  LayoutParams(): {
35
  "/": Record<string, never>;
36
  "/admin": Record<string, never>;
 
37
  "/admin/map": Record<string, never>;
38
  "/admin/sounds": Record<string, never>;
39
  "/admin/sprites": Record<string, never>;
40
  "/game": Record<string, never>
41
  };
42
- Pathname(): "/" | "/admin/map" | "/admin/sounds" | "/admin/sprites" | "/game";
43
  ResolvedPathname(): `${"" | `/${string}`}${ReturnType<AppTypes['Pathname']>}`;
44
- Asset(): string & {};
45
  }
46
  }
 
27
 
28
  declare module "$app/types" {
29
  export interface AppTypes {
30
+ RouteId(): "/" | "/admin" | "/admin/compiled-map" | "/admin/map" | "/admin/sounds" | "/admin/sprites" | "/game";
31
  RouteParams(): {
32
 
33
  };
34
  LayoutParams(): {
35
  "/": Record<string, never>;
36
  "/admin": Record<string, never>;
37
+ "/admin/compiled-map": Record<string, never>;
38
  "/admin/map": Record<string, never>;
39
  "/admin/sounds": Record<string, never>;
40
  "/admin/sprites": Record<string, never>;
41
  "/game": Record<string, never>
42
  };
43
+ Pathname(): "/" | "/admin/compiled-map" | "/admin/map" | "/admin/sounds" | "/admin/sprites" | "/game";
44
  ResolvedPathname(): `${"" | `/${string}`}${ReturnType<AppTypes['Pathname']>}`;
45
+ Asset(): "/logos/elevenlabs.svg" | "/logos/huggingface.svg" | "/logos/mistral.svg" | string & {};
46
  }
47
  }