Joffrey Thomas
merge geoguessr
860e1a7
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"})
@app.get("/", response_class=HTMLResponse)
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})
@app.get("/admin", response_class=HTMLResponse)
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})
@app.get("/api/zones")
def get_zones():
return JSONResponse(zones)
@app.post("/api/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")
@app.delete("/api/zones")
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
@app.post("/start_game")
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
}
@app.get("/game/{game_id}/state")
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
@app.post("/game/{game_id}/move")
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
}
@app.post("/game/{game_id}/guess")
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()