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 @property def is_depleted(self) -> bool: return self.resource_type == ResourceType.MINERAL and self.amount <= 0 @property 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) @classmethod 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)