ChatCraft / backend /game /map.py
gabraken's picture
feat: add game engine, voice commands, leaderboard, tutorial overlay, and stats tracking
29a88f8
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)