File size: 18,994 Bytes
07ed12b
 
5c0862e
07ed12b
5c0862e
07ed12b
 
 
 
dd96d2f
 
07ed12b
5c0862e
dd96d2f
 
07ed12b
5c0862e
07ed12b
dd96d2f
 
07ed12b
dd96d2f
07ed12b
 
dd96d2f
 
07ed12b
dd96d2f
07ed12b
5c0862e
 
 
 
 
 
dd96d2f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29a88f8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dd96d2f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5c0862e
 
 
dd96d2f
5c0862e
 
 
 
 
 
dd96d2f
 
 
 
 
 
5c0862e
dd96d2f
 
 
 
 
 
5c0862e
 
dd96d2f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5c0862e
07ed12b
 
 
 
 
 
 
 
29a88f8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
07ed12b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dd96d2f
 
 
 
5c0862e
 
 
 
 
dd96d2f
 
5c0862e
 
 
dd96d2f
5c0862e
 
 
dd96d2f
 
 
5c0862e
 
dd96d2f
 
 
07ed12b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
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)