Spaces:
Sleeping
Sleeping
| from __future__ import annotations | |
| import json | |
| import uuid | |
| from pathlib import Path | |
| from enum import Enum | |
| from pydantic import BaseModel, Field | |
| MAP_WIDTH = 80 | |
| MAP_HEIGHT = 80 | |
| # Starting positions (top-left corner of Command Center footprint) — fallback if no game_positions.json | |
| PLAYER1_START: tuple[int, int] = (8, 10) | |
| PLAYER2_START: tuple[int, int] = (64, 64) | |
| # Absolute resource positions (fallback) | |
| _P1_MINERALS: list[tuple[int, int]] = [ | |
| (4, 4), (6, 4), (8, 4), (10, 4), (12, 4), | |
| (4, 6), (12, 6), (6, 18), | |
| ] | |
| _P1_GEYSERS: list[tuple[int, int]] = [(4, 18), (14, 18)] | |
| _P2_MINERALS: list[tuple[int, int]] = [ | |
| (66, 74), (68, 74), (70, 74), (72, 74), (74, 74), | |
| (66, 72), (74, 72), (68, 60), | |
| ] | |
| _P2_GEYSERS: list[tuple[int, int]] = [(64, 60), (74, 60)] | |
| def _game_positions_path() -> Path | None: | |
| p = Path(__file__).resolve().parent.parent / "static" / "game_positions.json" | |
| return p if p.exists() else None | |
| def _to_game_coords(x: float, y: float) -> tuple[int, int]: | |
| gx = max(0, min(MAP_WIDTH - 1, int(round(x * MAP_WIDTH / 100.0)))) | |
| gy = max(0, min(MAP_HEIGHT - 1, int(round(y * MAP_HEIGHT / 100.0)))) | |
| return (gx, gy) | |
| def _resources_from_start_entry(entry: dict) -> list["Resource"]: | |
| """Build list of Resource from a starting_position entry that has nested minerals/geysers.""" | |
| resources: list[Resource] = [] | |
| for m in entry.get("minerals") or []: | |
| x, y = int(m.get("x", 0)), int(m.get("y", 0)) | |
| if 0 <= x < MAP_WIDTH and 0 <= y < MAP_HEIGHT: | |
| resources.append(Resource(resource_type=ResourceType.MINERAL, x=x, y=y)) | |
| for g in entry.get("geysers") or []: | |
| x, y = int(g.get("x", 0)), int(g.get("y", 0)) | |
| if 0 <= x < MAP_WIDTH and 0 <= y < MAP_HEIGHT: | |
| resources.append(Resource(resource_type=ResourceType.GEYSER, x=x, y=y)) | |
| return resources | |
| # 8 minerals in a ring at ~6 tiles from center, 2 geysers at ~8 tiles. | |
| # All distances are deterministic so every base gets the same layout. | |
| _RESOURCE_OFFSETS_MINERAL = [ | |
| (6, 0), (5, 3), (0, 6), (-5, 3), (-6, 0), (-5, -3), (0, -6), (5, -3), | |
| ] | |
| _RESOURCE_OFFSETS_GEYSER = [(7, 4), (-7, 4)] | |
| def _generate_resources_at(gx: int, gy: int) -> list["Resource"]: | |
| """Generate 8 mineral patches and 2 geysers at fixed distances around a game coordinate. | |
| Every base always gets the same symmetric layout so distances are consistent. | |
| """ | |
| resources: list[Resource] = [] | |
| seen: set[tuple[int, int]] = set() | |
| for dx, dy in _RESOURCE_OFFSETS_MINERAL: | |
| x = max(0, min(MAP_WIDTH - 1, gx + dx)) | |
| y = max(0, min(MAP_HEIGHT - 1, gy + dy)) | |
| if (x, y) not in seen: | |
| seen.add((x, y)) | |
| resources.append(Resource(resource_type=ResourceType.MINERAL, x=x, y=y)) | |
| for dx, dy in _RESOURCE_OFFSETS_GEYSER: | |
| x = max(0, min(MAP_WIDTH - 1, gx + dx)) | |
| y = max(0, min(MAP_HEIGHT - 1, gy + dy)) | |
| if (x, y) not in seen: | |
| seen.add((x, y)) | |
| resources.append(Resource(resource_type=ResourceType.GEYSER, x=x, y=y)) | |
| return resources | |
| def get_start_positions() -> tuple[tuple[float, float], tuple[float, float]]: | |
| """Return (player1_start, player2_start) in game coords. With 3 positions in file, 2 are chosen at random.""" | |
| start1, _, start2, _ = get_start_data() | |
| return start1, start2 | |
| # Ashen Crater fixed position in map.json percentage coords | |
| _ASHEN_CRATER_PCT = (15.320061683654785, 81.99957275390625) | |
| ASHEN_CRATER_NAME = "Ashen Crater" | |
| def get_tutorial_start_data() -> tuple[tuple[int, int], list["Resource"], tuple[int, int]]: | |
| """Return (player_start, player_resources, tutorial_target) for the tutorial. | |
| The tutorial target is always Ashen Crater (fixed zone). | |
| The player starts at the farthest starting position from Ashen Crater. | |
| """ | |
| target = _to_game_coords(*_ASHEN_CRATER_PCT) | |
| path = _game_positions_path() | |
| if path: | |
| try: | |
| with open(path, encoding="utf-8") as f: | |
| data = json.load(f) | |
| starts = data.get("starting_positions") or [] | |
| if starts: | |
| tx, ty = target | |
| best = max( | |
| starts, | |
| key=lambda s: ( | |
| (_to_game_coords(s.get("x", 0), s.get("y", 0))[0] - tx) ** 2 | |
| + (_to_game_coords(s.get("x", 0), s.get("y", 0))[1] - ty) ** 2 | |
| ), | |
| ) | |
| player_start = _to_game_coords(best.get("x", 0), best.get("y", 0)) | |
| player_res = _resources_from_start_entry(best) if "minerals" in best else [] | |
| return player_start, player_res, target | |
| except (OSError, json.JSONDecodeError, KeyError): | |
| pass | |
| # Fallback: use PLAYER2_START as player start (top-right, far from Ashen Crater bottom-left) | |
| return PLAYER2_START, [], target | |
| def get_all_map_resources() -> list["Resource"]: | |
| """Return resources for ALL starting positions AND expansion positions. | |
| Resources are ALWAYS generated via _generate_resources_at() using fixed offsets so that | |
| every base — regardless of origin — has minerals and geysers at a consistent distance | |
| from its Command Center. Embedded minerals in game_positions.json are intentionally | |
| ignored in favour of this deterministic layout. | |
| """ | |
| path = _game_positions_path() | |
| if path: | |
| try: | |
| with open(path, encoding="utf-8") as f: | |
| data = json.load(f) | |
| all_entries = ( | |
| list(data.get("starting_positions") or []) | |
| + list(data.get("expansion_positions") or []) | |
| ) | |
| if all_entries: | |
| resources: list[Resource] = [] | |
| for entry in all_entries: | |
| gx, gy = _to_game_coords(float(entry.get("x", 0)), float(entry.get("y", 0))) | |
| resources.extend(_generate_resources_at(gx, gy)) | |
| return resources | |
| except (OSError, json.JSONDecodeError, KeyError): | |
| pass | |
| # Fallback: derive starts + expansions from nav_points | |
| compiled_path = Path(__file__).resolve().parent.parent / "static" / "compiled_map.json" | |
| if compiled_path.exists(): | |
| try: | |
| with open(compiled_path, encoding="utf-8") as f: | |
| nav_data = json.load(f) | |
| pts = nav_data.get("nav_points") or [] | |
| if len(pts) >= 4: | |
| coords = [(float(p[0]), float(p[1])) for p in pts] | |
| starts, expansions, _ = _fallback_all_resources(coords) | |
| resources = [] | |
| for pos in starts + expansions: | |
| gx, gy = int(round(pos[0])), int(round(pos[1])) | |
| resources.extend(_generate_resources_at(gx, gy)) | |
| return resources | |
| except (OSError, json.JSONDecodeError, KeyError, ValueError): | |
| pass | |
| _, r1, _, r2 = _fallback_from_nav() | |
| return r1 + r2 | |
| def get_start_data() -> tuple[ | |
| tuple[float, float], list[Resource], tuple[float, float], list[Resource] | |
| ]: | |
| """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.""" | |
| import random | |
| path = _game_positions_path() | |
| if not path: | |
| return _fallback_from_nav() | |
| try: | |
| with open(path, encoding="utf-8") as f: | |
| data = json.load(f) | |
| starts = data.get("starting_positions") or [] | |
| if len(starts) >= 3: | |
| chosen = random.sample(starts, 2) | |
| s1, s2 = chosen[0], chosen[1] | |
| start1 = _to_game_coords(s1.get("x", 0), s1.get("y", 0)) | |
| start2 = _to_game_coords(s2.get("x", 0), s2.get("y", 0)) | |
| res1 = _resources_from_start_entry(s1) if "minerals" in s1 else [] | |
| res2 = _resources_from_start_entry(s2) if "minerals" in s2 else [] | |
| return (start1, res1, start2, res2) | |
| if len(starts) >= 2: | |
| s1, s2 = starts[0], starts[1] | |
| start1 = _to_game_coords(s1.get("x", 0), s1.get("y", 0)) | |
| start2 = _to_game_coords(s2.get("x", 0), s2.get("y", 0)) | |
| res1 = _resources_from_start_entry(s1) if "minerals" in s1 else [] | |
| res2 = _resources_from_start_entry(s2) if "minerals" in s2 else [] | |
| return (start1, res1, start2, res2) | |
| except (OSError, json.JSONDecodeError, KeyError): | |
| pass | |
| return _fallback_from_nav() | |
| def _pick_nav_corners(coords: list[tuple[float, float]]) -> tuple[tuple[float, float], tuple[float, float]]: | |
| """Pick the 2 most separated nav_points (opposing corners).""" | |
| pa1 = min(coords, key=lambda p: p[0] + p[1]) | |
| pa2 = max(coords, key=lambda p: p[0] + p[1]) | |
| pb1 = min(coords, key=lambda p: p[0] - p[1]) | |
| pb2 = max(coords, key=lambda p: p[0] - p[1]) | |
| d_a = (pa2[0] - pa1[0]) ** 2 + (pa2[1] - pa1[1]) ** 2 | |
| d_b = (pb2[0] - pb1[0]) ** 2 + (pb2[1] - pb1[1]) ** 2 | |
| return (pa1, pa2) if d_a >= d_b else (pb1, pb2) | |
| def _pick_expansion_nav_positions( | |
| coords: list[tuple[float, float]], | |
| start_positions: list[tuple[float, float]], | |
| count: int = 3, | |
| min_dist_from_start: float = 12.0, | |
| ) -> list[tuple[float, float]]: | |
| """Pick expansion positions: closest nav_point to each pair midpoint, at least min_dist from any start.""" | |
| def dist2(a: tuple[float, float], b: tuple[float, float]) -> float: | |
| return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 | |
| candidates: list[tuple[float, float]] = [] | |
| n = len(start_positions) | |
| for i in range(n): | |
| for j in range(i + 1, n): | |
| mx = (start_positions[i][0] + start_positions[j][0]) / 2 | |
| my = (start_positions[i][1] + start_positions[j][1]) / 2 | |
| # Find closest nav_point to midpoint that is far enough from all starts | |
| nearby = [ | |
| p for p in coords | |
| if all(dist2(p, s) >= min_dist_from_start ** 2 for s in start_positions) | |
| ] | |
| if nearby: | |
| best = min(nearby, key=lambda p: dist2(p, (mx, my))) | |
| # Avoid duplicates | |
| if all(dist2(best, c) > 4.0 for c in candidates): | |
| candidates.append(best) | |
| return candidates[:count] | |
| def _fallback_all_resources( | |
| coords: list[tuple[float, float]], | |
| ) -> tuple[list[tuple[float, float]], list[tuple[float, float]], list[list[Resource]]]: | |
| """Pick start and expansion positions from nav_points and generate resources at each. | |
| Returns (start_positions, expansion_positions, all_resource_lists). | |
| Resources are generated via _generate_resources_at for consistent fixed-distance placement. | |
| """ | |
| s1_f, s2_f = _pick_nav_corners(coords) | |
| def min_dist2(p: tuple[float, float]) -> float: | |
| 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) | |
| s3_f = max(coords, key=min_dist2) | |
| starts = [s1_f, s2_f, s3_f] | |
| expansions = _pick_expansion_nav_positions(coords, starts, count=3) | |
| all_resources: list[list[Resource]] = [] | |
| for pos in starts + expansions: | |
| gx, gy = int(round(pos[0])), int(round(pos[1])) | |
| all_resources.append(_generate_resources_at(gx, gy)) | |
| return starts, expansions, all_resources | |
| def _fallback_from_nav() -> tuple[ | |
| tuple[int, int], list[Resource], tuple[int, int], list[Resource] | |
| ]: | |
| """Pick 2 well-separated start positions from compiled nav_points and generate minerals near each.""" | |
| compiled_path = Path(__file__).resolve().parent.parent / "static" / "compiled_map.json" | |
| if compiled_path.exists(): | |
| try: | |
| with open(compiled_path, encoding="utf-8") as f: | |
| data = json.load(f) | |
| pts = data.get("nav_points") or [] | |
| if len(pts) >= 4: | |
| coords = [(float(p[0]), float(p[1])) for p in pts] | |
| s1_f, s2_f = _pick_nav_corners(coords) | |
| res1 = _nav_minerals_around(s1_f, coords) | |
| res2 = _nav_minerals_around(s2_f, coords) | |
| return (s1_f, res1, s2_f, res2) | |
| except (OSError, json.JSONDecodeError, KeyError, ValueError): | |
| pass | |
| # Last resort: use original hardcoded values (may be outside walkable area on this map) | |
| resources: list[Resource] = [] | |
| for x, y in _P1_MINERALS: | |
| resources.append(Resource(resource_type=ResourceType.MINERAL, x=x, y=y)) | |
| for x, y in _P1_GEYSERS: | |
| resources.append(Resource(resource_type=ResourceType.GEYSER, x=x, y=y)) | |
| r2: list[Resource] = [] | |
| for x, y in _P2_MINERALS: | |
| r2.append(Resource(resource_type=ResourceType.MINERAL, x=x, y=y)) | |
| for x, y in _P2_GEYSERS: | |
| r2.append(Resource(resource_type=ResourceType.GEYSER, x=x, y=y)) | |
| return ( | |
| (float(PLAYER1_START[0]), float(PLAYER1_START[1])), | |
| resources, | |
| (float(PLAYER2_START[0]), float(PLAYER2_START[1])), | |
| r2, | |
| ) | |
| def _nav_minerals_around( | |
| center: tuple[float, float], | |
| all_nav: list[tuple[float, float]], | |
| mineral_count: int = 7, | |
| geyser_count: int = 1, | |
| mineral_radius: float = 6.0, | |
| geyser_radius: float = 8.0, | |
| ) -> list[Resource]: | |
| """Generate minerals and geysers at nav_points near a start position.""" | |
| cx, cy = center | |
| nearby = sorted( | |
| [p for p in all_nav if p != center and (p[0]-cx)**2 + (p[1]-cy)**2 <= mineral_radius**2], | |
| key=lambda p: (p[0]-cx)**2 + (p[1]-cy)**2, | |
| ) | |
| resources: list[Resource] = [] | |
| for p in nearby[:mineral_count]: | |
| resources.append(Resource(resource_type=ResourceType.MINERAL, x=int(round(p[0])), y=int(round(p[1])))) | |
| geyser_nearby = sorted( | |
| [p for p in all_nav if p != center and (p[0]-cx)**2 + (p[1]-cy)**2 <= geyser_radius**2], | |
| key=lambda p: -((p[0]-cx)**2 + (p[1]-cy)**2), | |
| ) | |
| for p in geyser_nearby[:geyser_count]: | |
| resources.append(Resource(resource_type=ResourceType.GEYSER, x=int(round(p[0])), y=int(round(p[1])))) | |
| return resources | |
| # Named map zones resolved to (x, y) center coordinates | |
| # Zone values depend on player — resolved at engine level using player start positions | |
| ZONE_NAMES = [ | |
| "my_base", "enemy_base", "center", | |
| "top_left", "top_right", "bottom_left", "bottom_right", | |
| "front_line", | |
| ] | |
| def _slugify(name: str) -> str: | |
| """Convert a location name to a lowercase underscore slug.""" | |
| import re as _re | |
| return _re.sub(r'[^a-z0-9]+', '_', name.lower()).strip('_') | |
| def _load_map_landmarks() -> list[dict]: | |
| """Load named locations from static/map.json and convert % coords to game coords.""" | |
| p = Path(__file__).resolve().parent.parent / "static" / "map.json" | |
| try: | |
| data = json.loads(p.read_text(encoding="utf-8")) | |
| except Exception: | |
| return [] | |
| result = [] | |
| for loc in data.get("locations", []): | |
| name = loc.get("name", "") | |
| if not name: | |
| continue | |
| x_pct = float(loc.get("x", 0)) | |
| y_pct = float(loc.get("y", 0)) | |
| gx = max(0.0, min(float(MAP_WIDTH), round(x_pct * MAP_WIDTH / 100.0, 1))) | |
| gy = max(0.0, min(float(MAP_HEIGHT), round(y_pct * MAP_HEIGHT / 100.0, 1))) | |
| result.append({"slug": _slugify(name), "name": name, "x": gx, "y": gy, "description": name}) | |
| return result | |
| # Named geographic landmarks loaded from static/map.json. | |
| # slug: used as target_zone value in voice commands | |
| # name: display label on the map | |
| # x, y: game coordinates (0-80 range) | |
| MAP_LANDMARKS: list[dict] = _load_map_landmarks() | |
| class ResourceType(str, Enum): | |
| MINERAL = "mineral" | |
| GEYSER = "geyser" | |
| class Resource(BaseModel): | |
| id: str = Field(default_factory=lambda: str(uuid.uuid4())[:8]) | |
| resource_type: ResourceType | |
| x: int | |
| y: int | |
| amount: int = 1500 # minerals per patch (geysers are unlimited) | |
| max_scv: int = 3 | |
| assigned_scv_ids: list[str] = Field(default_factory=list) | |
| has_refinery: bool = False # geysers only | |
| def is_depleted(self) -> bool: | |
| return self.resource_type == ResourceType.MINERAL and self.amount <= 0 | |
| def has_capacity(self) -> bool: | |
| return len(self.assigned_scv_ids) < self.max_scv | |
| class GameMap(BaseModel): | |
| width: int = MAP_WIDTH | |
| height: int = MAP_HEIGHT | |
| resources: list[Resource] = Field(default_factory=list) | |
| def create_default(cls, resources: list[Resource] | None = None) -> "GameMap": | |
| """Create map with given resources, or load from file (legacy flat minerals/geysers), or use hardcoded fallback.""" | |
| if resources is not None: | |
| return cls(resources=resources) | |
| path = _game_positions_path() | |
| if path: | |
| try: | |
| with open(path, encoding="utf-8") as f: | |
| data = json.load(f) | |
| # Legacy format: top-level minerals/geysers | |
| flat_resources = [] | |
| for m in data.get("minerals") or []: | |
| x, y = int(m.get("x", 0)), int(m.get("y", 0)) | |
| if 0 <= x < MAP_WIDTH and 0 <= y < MAP_HEIGHT: | |
| flat_resources.append(Resource(resource_type=ResourceType.MINERAL, x=x, y=y)) | |
| for g in data.get("geysers") or []: | |
| x, y = int(g.get("x", 0)), int(g.get("y", 0)) | |
| if 0 <= x < MAP_WIDTH and 0 <= y < MAP_HEIGHT: | |
| flat_resources.append(Resource(resource_type=ResourceType.GEYSER, x=x, y=y)) | |
| if flat_resources: | |
| return cls(resources=flat_resources) | |
| except (OSError, json.JSONDecodeError, KeyError): | |
| pass | |
| # Use nav-based fallback which derives positions from the actual compiled map | |
| _, r1, _, r2 = _fallback_from_nav() | |
| return cls(resources=r1 + r2) | |
| def get_resource(self, resource_id: str) -> Resource | None: | |
| return next((r for r in self.resources if r.id == resource_id), None) | |
| def nearest_mineral(self, x: float, y: float) -> Resource | None: | |
| candidates = [ | |
| r for r in self.resources | |
| if r.resource_type == ResourceType.MINERAL | |
| and not r.is_depleted | |
| and r.has_capacity | |
| ] | |
| return min(candidates, key=lambda r: (r.x - x) ** 2 + (r.y - y) ** 2, default=None) | |
| def nearest_available_geyser(self, x: float, y: float) -> Resource | None: | |
| """Geyser with a refinery that still has SCV capacity.""" | |
| candidates = [ | |
| r for r in self.resources | |
| if r.resource_type == ResourceType.GEYSER | |
| and r.has_refinery | |
| and r.has_capacity | |
| ] | |
| return min(candidates, key=lambda r: (r.x - x) ** 2 + (r.y - y) ** 2, default=None) | |
| def nearest_geyser_without_refinery(self, x: float, y: float) -> Resource | None: | |
| candidates = [ | |
| r for r in self.resources | |
| if r.resource_type == ResourceType.GEYSER and not r.has_refinery | |
| ] | |
| return min(candidates, key=lambda r: (r.x - x) ** 2 + (r.y - y) ** 2, default=None) | |