Spaces:
Sleeping
Sleeping
| import os | |
| import json | |
| import uuid | |
| import math | |
| import random | |
| import base64 | |
| import io | |
| import requests | |
| from fastapi import FastAPI, Request, Depends, HTTPException, status | |
| from fastapi.responses import JSONResponse, HTMLResponse | |
| from fastapi.staticfiles import StaticFiles | |
| from fastapi.templating import Jinja2Templates | |
| from fastapi.security import HTTPBasic, HTTPBasicCredentials | |
| from dotenv import load_dotenv | |
| from PIL import Image, ImageDraw, ImageFont | |
| load_dotenv() | |
| # Paths | |
| BASE_DIR = os.path.dirname(__file__) | |
| TEMPLATES_DIR = os.path.join(BASE_DIR, "templates") | |
| STATIC_DIR = os.path.join(BASE_DIR, "static") | |
| ZONES_FILE = os.path.join(BASE_DIR, "zones.json") | |
| app = FastAPI() | |
| app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") | |
| templates = Jinja2Templates(directory=TEMPLATES_DIR) | |
| # In-memory state | |
| games = {} | |
| zones = {"easy": [], "medium": [], "hard": []} | |
| # --- Zone Persistence Functions --- | |
| def save_zones_to_file() -> None: | |
| try: | |
| with open(ZONES_FILE, 'w') as f: | |
| json.dump(zones, f, indent=4) | |
| except Exception as e: | |
| print(f"Error saving zones: {e}") | |
| def load_zones_from_file() -> None: | |
| global zones | |
| if os.path.exists(ZONES_FILE): | |
| try: | |
| with open(ZONES_FILE, 'r') as f: | |
| loaded_zones = json.load(f) | |
| if not (isinstance(loaded_zones, dict) and all(k in loaded_zones for k in ["easy", "medium", "hard"])): | |
| raise ValueError("Invalid zones format") | |
| migrated = False | |
| for difficulty in loaded_zones: | |
| for zone in loaded_zones[difficulty]: | |
| if 'id' not in zone: | |
| zone['id'] = uuid.uuid4().hex | |
| migrated = True | |
| zones = loaded_zones | |
| if migrated: | |
| save_zones_to_file() | |
| except Exception as e: | |
| print(f"Warning: '{ZONES_FILE}' is corrupted or invalid ({e}). Recreating with empty zones.") | |
| save_zones_to_file() | |
| else: | |
| save_zones_to_file() | |
| # Predefined fallback locations | |
| LOCATIONS = [ | |
| {'lat': 48.85824, 'lng': 2.2945}, # Eiffel Tower, Paris | |
| {'lat': 40.748440, 'lng': -73.985664}, # Empire State Building, New York | |
| {'lat': 35.689487, 'lng': 139.691711}, # Tokyo, Japan | |
| {'lat': -33.856784, 'lng': 151.215297} # Sydney Opera House, Australia | |
| ] | |
| def generate_game_id() -> str: | |
| return ''.join(random.choices('abcdefghijklmnopqrstuvwxyz0123456789', k=10)) | |
| def draw_compass_on_image(image_data_base64: str, heading: int) -> str: | |
| try: | |
| img = Image.open(io.BytesIO(base64.b64decode(image_data_base64))) | |
| img_with_compass = img.copy() | |
| draw = ImageDraw.Draw(img_with_compass) | |
| compass_size = 80 | |
| margin = 20 | |
| x = img.width - compass_size - margin | |
| y = margin | |
| center_x = x + compass_size // 2 | |
| center_y = y + compass_size // 2 | |
| draw.ellipse([x, y, x + compass_size, y + compass_size], fill=(240, 240, 240), outline=(249, 115, 22), width=3) | |
| font_size = 12 | |
| try: | |
| font = ImageFont.truetype("/System/Library/Fonts/Arial.ttf", font_size) | |
| except Exception: | |
| try: | |
| font = ImageFont.truetype("arial.ttf", font_size) | |
| except Exception: | |
| font = ImageFont.load_default() | |
| directions = [ | |
| ("N", center_x, y + 8, (220, 38, 38)), | |
| ("E", x + compass_size - 15, center_y, (249, 115, 22)), | |
| ("S", center_x, y + compass_size - 20, (249, 115, 22)), | |
| ("W", x + 8, center_y, (249, 115, 22)), | |
| ] | |
| for text, text_x, text_y, color in directions: | |
| bbox = draw.textbbox((0, 0), text, font=font) | |
| text_width = bbox[2] - bbox[0] | |
| text_height = bbox[3] - bbox[1] | |
| circle_radius = 10 | |
| draw.ellipse([text_x - circle_radius, text_y - circle_radius, text_x + circle_radius, text_y + circle_radius], fill=(255, 255, 255), outline=color, width=1) | |
| draw.text((text_x - text_width // 2, text_y - text_height // 2), text, font=font, fill=color) | |
| needle_length = compass_size // 2 - 15 | |
| needle_angle = math.radians(heading) | |
| end_x = center_x + needle_length * math.sin(needle_angle) | |
| end_y = center_y - needle_length * math.cos(needle_angle) | |
| draw.line([center_x, center_y, end_x, end_y], fill=(220, 38, 38), width=4) | |
| tip_radius = 3 | |
| draw.ellipse([end_x - tip_radius, end_y - tip_radius, end_x + tip_radius, end_y + tip_radius], fill=(220, 38, 38)) | |
| center_radius = 4 | |
| draw.ellipse([center_x - center_radius, center_y - center_radius, center_x + center_radius, center_y + center_radius], fill=(249, 115, 22)) | |
| label_y = y + compass_size + 5 | |
| label_text = f"{heading}°" | |
| bbox = draw.textbbox((0, 0), label_text, font=font) | |
| label_width = bbox[2] - bbox[0] | |
| draw.text((center_x - label_width // 2, label_y), label_text, font=font, fill=(249, 115, 22)) | |
| buffer = io.BytesIO() | |
| img_with_compass.save(buffer, format='JPEG', quality=85) | |
| return base64.b64encode(buffer.getvalue()).decode('utf-8') | |
| except Exception as e: | |
| print(f"Error drawing compass: {e}") | |
| return image_data_base64 | |
| # --- Auth --- | |
| security = HTTPBasic() | |
| def verify_basic_auth(credentials: HTTPBasicCredentials = Depends(security)) -> None: | |
| admin_user = os.getenv('ADMIN_USERNAME', 'admin') | |
| admin_pass = os.getenv('ADMIN_PASSWORD', 'password') | |
| if not (credentials.username == admin_user and credentials.password == admin_pass): | |
| raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized", headers={"WWW-Authenticate": "Basic"}) | |
| def index(request: Request): | |
| google_maps_api_key = os.getenv('GOOGLE_MAPS_API_KEY') | |
| if not google_maps_api_key: | |
| return HTMLResponse("Error: GOOGLE_MAPS_API_KEY not set. Please set it in a .env file.", status_code=500) | |
| base_path = request.scope.get('root_path', '') | |
| return templates.TemplateResponse("index.html", {"request": request, "google_maps_api_key": google_maps_api_key, "base_path": base_path}) | |
| def admin(request: Request, _: None = Depends(verify_basic_auth)): | |
| google_maps_api_key = os.getenv('GOOGLE_MAPS_API_KEY') | |
| if not google_maps_api_key: | |
| return HTMLResponse("Error: GOOGLE_MAPS_API_KEY not set. Please set it in a .env file.", status_code=500) | |
| base_path = request.scope.get('root_path', '') | |
| return templates.TemplateResponse("admin.html", {"request": request, "google_maps_api_key": google_maps_api_key, "base_path": base_path}) | |
| def get_zones(): | |
| return JSONResponse(zones) | |
| def create_zone(payload: dict): | |
| difficulty = payload.get('difficulty') | |
| zone_data = payload.get('zone') | |
| if difficulty and zone_data and difficulty in zones: | |
| zone_data['id'] = uuid.uuid4().hex | |
| zones[difficulty].append(zone_data) | |
| save_zones_to_file() | |
| return {"message": "Zone saved successfully"} | |
| raise HTTPException(status_code=400, detail="Invalid data") | |
| def delete_zone(payload: dict): | |
| zone_id = payload.get('zone_id') | |
| if not zone_id: | |
| raise HTTPException(status_code=400, detail="Zone ID is required") | |
| for difficulty in zones: | |
| zones[difficulty] = [z for z in zones[difficulty] if z.get('id') != zone_id] | |
| save_zones_to_file() | |
| return {"message": "Zone deleted successfully"} | |
| def direction_to_degree(direction: str): | |
| directions = { | |
| 'N': 0, 'NORTH': 0, | |
| 'NE': 45, 'NORTHEAST': 45, | |
| 'E': 90, 'EAST': 90, | |
| 'SE': 135, 'SOUTHEAST': 135, | |
| 'S': 180, 'SOUTH': 180, | |
| 'SW': 225, 'SOUTHWEST': 225, | |
| 'W': 270, 'WEST': 270, | |
| 'NW': 315, 'NORTHWEST': 315 | |
| } | |
| return directions.get(direction.upper()) if isinstance(direction, str) else None | |
| def calculate_new_location(current_lat: float, current_lng: float, degree: float, distance_km: float = 0.1): | |
| lat_rad = math.radians(current_lat) | |
| lng_rad = math.radians(current_lng) | |
| bearing_rad = math.radians(degree) | |
| R = 6371.0 | |
| new_lat_rad = math.asin( | |
| math.sin(lat_rad) * math.cos(distance_km / R) + | |
| math.cos(lat_rad) * math.sin(distance_km / R) * math.cos(bearing_rad) | |
| ) | |
| new_lng_rad = lng_rad + math.atan2( | |
| math.sin(bearing_rad) * math.sin(distance_km / R) * math.cos(lat_rad), | |
| math.cos(distance_km / R) - math.sin(lat_rad) * math.sin(new_lat_rad) | |
| ) | |
| new_lat = math.degrees(new_lat_rad) | |
| new_lng = math.degrees(new_lng_rad) | |
| new_lng = ((new_lng + 180) % 360) - 180 | |
| return new_lat, new_lng | |
| def start_game(payload: dict): | |
| difficulty = payload.get('difficulty', 'easy') if payload else 'easy' | |
| player_name = payload.get('player_name', 'Anonymous Player') if payload else 'Anonymous Player' | |
| player_google_api_key = payload.get('google_api_key') if payload else None | |
| start_location = None | |
| if difficulty in zones and zones[difficulty]: | |
| selected_zone = random.choice(zones[difficulty]) | |
| if selected_zone.get('type') == 'rectangle': | |
| bounds = selected_zone['bounds'] | |
| north, south, east, west = bounds['north'], bounds['south'], bounds['east'], bounds['west'] | |
| if west > east: | |
| east += 360 | |
| rand_lng = random.uniform(west, east) | |
| if rand_lng > 180: | |
| rand_lng -= 360 | |
| rand_lat = random.uniform(south, north) | |
| start_location = {'lat': rand_lat, 'lng': rand_lng} | |
| if not start_location: | |
| start_location = random.choice(LOCATIONS) | |
| game_id = generate_game_id() | |
| games[game_id] = { | |
| 'start_location': start_location, | |
| 'current_location': start_location, | |
| 'guesses': [], | |
| 'moves': 0, | |
| 'actions': [], | |
| 'game_over': False, | |
| 'player_name': player_name, | |
| 'player_google_api_key': player_google_api_key, | |
| 'created_at': __import__('datetime').datetime.now().isoformat() | |
| } | |
| google_maps_api_key = player_google_api_key or os.getenv('GOOGLE_MAPS_API_KEY') | |
| streetview_image = None | |
| compass_heading = random.randint(0, 359) | |
| if google_maps_api_key: | |
| try: | |
| lat, lng = start_location['lat'], start_location['lng'] | |
| streetview_url = f"https://maps.googleapis.com/maps/api/streetview?size=640x400&location={lat},{lng}&heading={compass_heading}&pitch=0&fov=90&key={google_maps_api_key}" | |
| response = requests.get(streetview_url, timeout=20) | |
| if response.status_code == 200: | |
| base_image = base64.b64encode(response.content).decode('utf-8') | |
| streetview_image = draw_compass_on_image(base_image, compass_heading) | |
| except Exception as e: | |
| print(f"Error fetching Street View image: {e}") | |
| return { | |
| 'game_id': game_id, | |
| 'player_name': player_name, | |
| 'streetview_image': streetview_image, | |
| 'compass_heading': compass_heading | |
| } | |
| def get_game_state(game_id: str): | |
| game = games.get(game_id) | |
| if not game: | |
| raise HTTPException(status_code=404, detail='Game not found') | |
| return game | |
| def move(game_id: str, payload: dict): | |
| game = games.get(game_id) | |
| if not game: | |
| raise HTTPException(status_code=404, detail='Game not found') | |
| if game['game_over']: | |
| raise HTTPException(status_code=400, detail='Game is over') | |
| direction = payload.get('direction') if payload else None | |
| degree = payload.get('degree') if payload else None | |
| distance = payload.get('distance', 0.1) if payload else 0.1 | |
| if direction is None and degree is None: | |
| raise HTTPException(status_code=400, detail='Must provide either direction (N, NE, E, etc.) or degree (0-360)') | |
| if direction is not None: | |
| degree = direction_to_degree(direction) | |
| if degree is None: | |
| raise HTTPException(status_code=400, detail='Invalid direction. Use N, NE, E, SE, S, SW, W, NW or their full names') | |
| if not (0 <= degree <= 360): | |
| raise HTTPException(status_code=400, detail='Degree must be between 0 and 360') | |
| if not (0.01 <= distance <= 10): | |
| raise HTTPException(status_code=400, detail='Distance must be between 0.01 and 10 km') | |
| current_lat = game['current_location']['lat'] | |
| current_lng = game['current_location']['lng'] | |
| new_lat, new_lng = calculate_new_location(current_lat, current_lng, degree, distance) | |
| game['current_location'] = {'lat': new_lat, 'lng': new_lng} | |
| game['moves'] += 1 | |
| game['actions'].append({ | |
| 'type': 'move', | |
| 'location': {'lat': new_lat, 'lng': new_lng}, | |
| 'direction': direction, | |
| 'degree': degree, | |
| 'distance_km': distance | |
| }) | |
| google_maps_api_key = game.get('player_google_api_key') or os.getenv('GOOGLE_MAPS_API_KEY') | |
| streetview_image = None | |
| compass_heading = random.randint(0, 359) | |
| if google_maps_api_key: | |
| try: | |
| streetview_url = f"https://maps.googleapis.com/maps/api/streetview?size=640x400&location={new_lat},{new_lng}&heading={compass_heading}&pitch=0&fov=90&key={google_maps_api_key}" | |
| response = requests.get(streetview_url, timeout=20) | |
| if response.status_code == 200: | |
| base_image = base64.b64encode(response.content).decode('utf-8') | |
| streetview_image = draw_compass_on_image(base_image, compass_heading) | |
| except Exception as e: | |
| print(f"Error fetching Street View image: {e}") | |
| return { | |
| 'message': 'Move successful', | |
| 'streetview_image': streetview_image, | |
| 'compass_heading': compass_heading, | |
| 'moved_direction': direction or f"{degree}°", | |
| 'distance_moved_km': distance | |
| } | |
| def guess(game_id: str, payload: dict): | |
| game = games.get(game_id) | |
| if not game: | |
| raise HTTPException(status_code=404, detail='Game not found') | |
| if game['game_over']: | |
| raise HTTPException(status_code=400, detail='Game is over') | |
| guess_lat = payload.get('lat') if payload else None | |
| guess_lng = payload.get('lng') if payload else None | |
| if guess_lat is None or guess_lng is None: | |
| raise HTTPException(status_code=400, detail='Missing lat/lng for guess') | |
| guess_location = {'lat': guess_lat, 'lng': guess_lng} | |
| game['guesses'].append(guess_location) | |
| from math import radians, cos, sin, asin, sqrt | |
| def haversine(lat1, lon1, lat2, lon2): | |
| lon1, lat1, lon2, lat2 = map(radians, [lon1, lat1, lon2, lat2]) | |
| dlon = lon2 - lon1 | |
| dlat = lat2 - lat1 | |
| a = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlon/2)**2 | |
| c = 2 * asin(sqrt(a)) | |
| r = 6371 | |
| return c * r | |
| distance = haversine( | |
| game['start_location']['lat'], game['start_location']['lng'], | |
| guess_lat, guess_lng | |
| ) | |
| max_score = 5000 | |
| score = max(0, max_score - distance) | |
| game['actions'].append({ | |
| 'type': 'guess', | |
| 'location': guess_location, | |
| 'result': { | |
| 'distance_km': distance, | |
| 'score': score | |
| } | |
| }) | |
| game['game_over'] = True | |
| return { | |
| 'message': 'Guess received', | |
| 'guess_location': guess_location, | |
| 'actual_location': game['start_location'], | |
| 'distance_km': distance, | |
| 'score': score | |
| } | |
| # Load zones at startup | |
| load_zones_from_file() | |