Spaces:
Sleeping
Sleeping
Joffrey Thomas
commited on
Commit
·
860e1a7
1
Parent(s):
e743ebd
merge geoguessr
Browse files- geoguessr/geo_server.py +28 -14
- geoguessr/static/admin.js +139 -0
- geoguessr/static/script.js +243 -0
- geoguessr/static/style.css +171 -0
- geoguessr/templates/admin.html +81 -0
- geoguessr/templates/index.html +45 -0
- geoguessr/web_app.py +431 -0
- geoguessr/zones.json +67 -0
- server.py +4 -0
geoguessr/geo_server.py
CHANGED
|
@@ -4,6 +4,8 @@ import base64
|
|
| 4 |
from dotenv import load_dotenv
|
| 5 |
from mcp.server.fastmcp import FastMCP, Image
|
| 6 |
|
|
|
|
|
|
|
| 7 |
mcp = FastMCP(name="GeoServer", stateless_http=True)
|
| 8 |
|
| 9 |
|
|
@@ -11,10 +13,22 @@ mcp = FastMCP(name="GeoServer", stateless_http=True)
|
|
| 11 |
# Store the current game ID and basic state
|
| 12 |
active_game = {}
|
| 13 |
|
| 14 |
-
# ---
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
try:
|
| 19 |
if method == 'POST':
|
| 20 |
response = requests.post(url, json=json_data, headers={'Content-Type': 'application/json'}, timeout=30)
|
|
@@ -25,14 +39,14 @@ def call_flask_api(endpoint, method='GET', json_data=None):
|
|
| 25 |
return response.json()
|
| 26 |
else:
|
| 27 |
error_msg = f"API call failed: {response.status_code} - {response.text}"
|
| 28 |
-
print(f"
|
| 29 |
raise Exception(error_msg)
|
| 30 |
except requests.exceptions.ConnectionError as e:
|
| 31 |
-
error_msg = f"Could not connect to
|
| 32 |
print(f"Connection Error: {error_msg}")
|
| 33 |
raise Exception(error_msg)
|
| 34 |
except requests.exceptions.Timeout as e:
|
| 35 |
-
error_msg = f"Timeout calling
|
| 36 |
print(f"Timeout Error: {error_msg}")
|
| 37 |
raise Exception(error_msg)
|
| 38 |
except Exception as e:
|
|
@@ -77,8 +91,8 @@ def start_game(difficulty: str = "easy", player_name: str = "MCP Agent") -> Imag
|
|
| 77 |
"""
|
| 78 |
global active_game
|
| 79 |
|
| 80 |
-
# Call
|
| 81 |
-
game_data =
|
| 82 |
'difficulty': difficulty,
|
| 83 |
'player_name': player_name
|
| 84 |
})
|
|
@@ -131,9 +145,9 @@ def move(direction: str = None, degree: float = None, distance: float = 0.1) ->
|
|
| 131 |
else:
|
| 132 |
raise ValueError("Must provide either direction or degree parameter.")
|
| 133 |
|
| 134 |
-
# Call
|
| 135 |
game_id = active_game['game_id']
|
| 136 |
-
move_result =
|
| 137 |
|
| 138 |
# Convert base64 image to bytes and return as Image
|
| 139 |
if move_result.get('streetview_image'):
|
|
@@ -188,9 +202,9 @@ def make_final_guess() -> dict:
|
|
| 188 |
# Get the placeholder guess
|
| 189 |
guess_location = active_game['placeholder_guess']
|
| 190 |
|
| 191 |
-
# Call
|
| 192 |
game_id = active_game['game_id']
|
| 193 |
-
guess_result =
|
| 194 |
'lat': guess_location['lat'],
|
| 195 |
'lng': guess_location['lng']
|
| 196 |
})
|
|
@@ -217,7 +231,7 @@ def get_game_state() -> dict:
|
|
| 217 |
raise ValueError("Game not started.")
|
| 218 |
|
| 219 |
game_id = active_game['game_id']
|
| 220 |
-
game_state =
|
| 221 |
|
| 222 |
# Don't expose the actual coordinates - keep the guessing challenge
|
| 223 |
state_info = {
|
|
|
|
| 4 |
from dotenv import load_dotenv
|
| 5 |
from mcp.server.fastmcp import FastMCP, Image
|
| 6 |
|
| 7 |
+
load_dotenv()
|
| 8 |
+
|
| 9 |
mcp = FastMCP(name="GeoServer", stateless_http=True)
|
| 10 |
|
| 11 |
|
|
|
|
| 13 |
# Store the current game ID and basic state
|
| 14 |
active_game = {}
|
| 15 |
|
| 16 |
+
# --- FastAPI endpoint base URL ---
|
| 17 |
+
PORT = os.environ.get("PORT", "10000")
|
| 18 |
+
SPACE_HOST = os.environ.get("SPACE_HOST")
|
| 19 |
+
if SPACE_HOST and SPACE_HOST.strip():
|
| 20 |
+
BASE_HOST = SPACE_HOST.strip().rstrip("/")
|
| 21 |
+
else:
|
| 22 |
+
BASE_HOST = f"http://localhost:{PORT}"
|
| 23 |
+
|
| 24 |
+
# Allow override; default to the mounted FastAPI app
|
| 25 |
+
API_BASE_URL = os.environ.get("GEOGUESSR_API_URL", f"{BASE_HOST}/geoguessr_app")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# --- HTTP Helper Function ---
|
| 29 |
+
def call_api(endpoint, method='GET', json_data=None):
|
| 30 |
+
"""Helper function to call GeoGuessr FastAPI endpoints"""
|
| 31 |
+
url = f"{API_BASE_URL}{endpoint}"
|
| 32 |
try:
|
| 33 |
if method == 'POST':
|
| 34 |
response = requests.post(url, json=json_data, headers={'Content-Type': 'application/json'}, timeout=30)
|
|
|
|
| 39 |
return response.json()
|
| 40 |
else:
|
| 41 |
error_msg = f"API call failed: {response.status_code} - {response.text}"
|
| 42 |
+
print(f"Geo API Error: {error_msg}")
|
| 43 |
raise Exception(error_msg)
|
| 44 |
except requests.exceptions.ConnectionError as e:
|
| 45 |
+
error_msg = f"Could not connect to Geo API at {API_BASE_URL}. Make sure the FastAPI server is running. Error: {str(e)}"
|
| 46 |
print(f"Connection Error: {error_msg}")
|
| 47 |
raise Exception(error_msg)
|
| 48 |
except requests.exceptions.Timeout as e:
|
| 49 |
+
error_msg = f"Timeout calling Geo API at {API_BASE_URL}. Error: {str(e)}"
|
| 50 |
print(f"Timeout Error: {error_msg}")
|
| 51 |
raise Exception(error_msg)
|
| 52 |
except Exception as e:
|
|
|
|
| 91 |
"""
|
| 92 |
global active_game
|
| 93 |
|
| 94 |
+
# Call FastAPI to start game
|
| 95 |
+
game_data = call_api('/start_game', 'POST', {
|
| 96 |
'difficulty': difficulty,
|
| 97 |
'player_name': player_name
|
| 98 |
})
|
|
|
|
| 145 |
else:
|
| 146 |
raise ValueError("Must provide either direction or degree parameter.")
|
| 147 |
|
| 148 |
+
# Call FastAPI to move
|
| 149 |
game_id = active_game['game_id']
|
| 150 |
+
move_result = call_api(f'/game/{game_id}/move', 'POST', move_data)
|
| 151 |
|
| 152 |
# Convert base64 image to bytes and return as Image
|
| 153 |
if move_result.get('streetview_image'):
|
|
|
|
| 202 |
# Get the placeholder guess
|
| 203 |
guess_location = active_game['placeholder_guess']
|
| 204 |
|
| 205 |
+
# Call FastAPI to make the final guess
|
| 206 |
game_id = active_game['game_id']
|
| 207 |
+
guess_result = call_api(f'/game/{game_id}/guess', 'POST', {
|
| 208 |
'lat': guess_location['lat'],
|
| 209 |
'lng': guess_location['lng']
|
| 210 |
})
|
|
|
|
| 231 |
raise ValueError("Game not started.")
|
| 232 |
|
| 233 |
game_id = active_game['game_id']
|
| 234 |
+
game_state = call_api(f'/game/{game_id}/state')
|
| 235 |
|
| 236 |
# Don't expose the actual coordinates - keep the guessing challenge
|
| 237 |
state_info = {
|
geoguessr/static/admin.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
let map;
|
| 2 |
+
let drawingManager;
|
| 3 |
+
let lastDrawnShape = null;
|
| 4 |
+
let displayedShapes = [];
|
| 5 |
+
|
| 6 |
+
const difficultyColors = {
|
| 7 |
+
easy: '#34A853',
|
| 8 |
+
medium: '#F9AB00',
|
| 9 |
+
hard: '#EA4335'
|
| 10 |
+
};
|
| 11 |
+
|
| 12 |
+
function api(path) {
|
| 13 |
+
return path.startsWith('/') ? path.slice(1) : path;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
function initAdminMap() {
|
| 17 |
+
map = new google.maps.Map(document.getElementById('map'), {
|
| 18 |
+
center: { lat: 20, lng: 0 },
|
| 19 |
+
zoom: 2,
|
| 20 |
+
});
|
| 21 |
+
|
| 22 |
+
drawingManager = new google.maps.drawing.DrawingManager({
|
| 23 |
+
drawingMode: google.maps.drawing.OverlayType.RECTANGLE,
|
| 24 |
+
drawingControl: true,
|
| 25 |
+
drawingControlOptions: {
|
| 26 |
+
position: google.maps.ControlPosition.TOP_CENTER,
|
| 27 |
+
drawingModes: [google.maps.drawing.OverlayType.RECTANGLE],
|
| 28 |
+
},
|
| 29 |
+
rectangleOptions: {
|
| 30 |
+
fillColor: '#F97316',
|
| 31 |
+
fillOpacity: 0.3,
|
| 32 |
+
strokeWeight: 1,
|
| 33 |
+
clickable: true,
|
| 34 |
+
editable: true,
|
| 35 |
+
zIndex: 1,
|
| 36 |
+
},
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
+
drawingManager.setMap(map);
|
| 40 |
+
|
| 41 |
+
google.maps.event.addListener(drawingManager, 'overlaycomplete', function(event) {
|
| 42 |
+
if (lastDrawnShape) {
|
| 43 |
+
lastDrawnShape.setMap(null);
|
| 44 |
+
}
|
| 45 |
+
lastDrawnShape = event.overlay;
|
| 46 |
+
drawingManager.setDrawingMode(null);
|
| 47 |
+
document.getElementById('save-zone').disabled = false;
|
| 48 |
+
});
|
| 49 |
+
|
| 50 |
+
document.getElementById('save-zone').addEventListener('click', saveLastZone);
|
| 51 |
+
document.getElementById('new-zone-btn').addEventListener('click', () => {
|
| 52 |
+
drawingManager.setDrawingMode(google.maps.drawing.OverlayType.RECTANGLE);
|
| 53 |
+
document.getElementById('save-zone').disabled = true;
|
| 54 |
+
});
|
| 55 |
+
|
| 56 |
+
loadExistingZones();
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
function saveLastZone() {
|
| 60 |
+
if (!lastDrawnShape) {
|
| 61 |
+
alert('Please draw a zone first.');
|
| 62 |
+
return;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
const difficulty = document.getElementById('difficulty-select').value;
|
| 66 |
+
const bounds = lastDrawnShape.getBounds().toJSON();
|
| 67 |
+
|
| 68 |
+
const zoneData = { type: 'rectangle', bounds: bounds };
|
| 69 |
+
|
| 70 |
+
fetch(api('/api/zones'), {
|
| 71 |
+
method: 'POST',
|
| 72 |
+
headers: { 'Content-Type': 'application/json' },
|
| 73 |
+
body: JSON.stringify({ difficulty: difficulty, zone: zoneData }),
|
| 74 |
+
})
|
| 75 |
+
.then(response => response.json())
|
| 76 |
+
.then(data => {
|
| 77 |
+
const statusMsg = document.getElementById('status-message');
|
| 78 |
+
statusMsg.textContent = data.message || `Error: ${data.error}`;
|
| 79 |
+
setTimeout(() => statusMsg.textContent = '', 3000);
|
| 80 |
+
if (lastDrawnShape) {
|
| 81 |
+
lastDrawnShape.setMap(null);
|
| 82 |
+
lastDrawnShape = null;
|
| 83 |
+
}
|
| 84 |
+
document.getElementById('save-zone').disabled = true;
|
| 85 |
+
loadExistingZones();
|
| 86 |
+
});
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
function loadExistingZones() {
|
| 90 |
+
displayedShapes.forEach(shape => shape.setMap(null));
|
| 91 |
+
displayedShapes = [];
|
| 92 |
+
|
| 93 |
+
fetch(api('/api/zones'))
|
| 94 |
+
.then(response => response.json())
|
| 95 |
+
.then(zones => {
|
| 96 |
+
for (const difficulty in zones) {
|
| 97 |
+
zones[difficulty].forEach(zone => {
|
| 98 |
+
if (zone.type === 'rectangle') {
|
| 99 |
+
const rectangle = new google.maps.Rectangle({
|
| 100 |
+
bounds: zone.bounds,
|
| 101 |
+
map: map,
|
| 102 |
+
fillColor: difficultyColors[difficulty],
|
| 103 |
+
fillOpacity: 0.35,
|
| 104 |
+
strokeColor: difficultyColors[difficulty],
|
| 105 |
+
strokeWeight: 2,
|
| 106 |
+
editable: false,
|
| 107 |
+
clickable: true,
|
| 108 |
+
});
|
| 109 |
+
rectangle.zoneId = zone.id;
|
| 110 |
+
google.maps.event.addListener(rectangle, 'click', function() {
|
| 111 |
+
if (confirm('Are you sure you want to delete this zone?')) {
|
| 112 |
+
deleteZone(this.zoneId, this);
|
| 113 |
+
}
|
| 114 |
+
});
|
| 115 |
+
displayedShapes.push(rectangle);
|
| 116 |
+
}
|
| 117 |
+
});
|
| 118 |
+
}
|
| 119 |
+
});
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
function deleteZone(zoneId, shape) {
|
| 123 |
+
fetch(api('/api/zones'), {
|
| 124 |
+
method: 'DELETE',
|
| 125 |
+
headers: { 'Content-Type': 'application/json' },
|
| 126 |
+
body: JSON.stringify({ zone_id: zoneId })
|
| 127 |
+
})
|
| 128 |
+
.then(response => response.json())
|
| 129 |
+
.then(data => {
|
| 130 |
+
const statusMsg = document.getElementById('status-message');
|
| 131 |
+
statusMsg.textContent = data.message || `Error: ${data.error}`;
|
| 132 |
+
setTimeout(() => statusMsg.textContent = '', 3000);
|
| 133 |
+
if (data.message) {
|
| 134 |
+
shape.setMap(null);
|
| 135 |
+
}
|
| 136 |
+
});
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
|
geoguessr/static/script.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
let map, panorama, guessMarker, gameId, googleMapsApiKey;
|
| 2 |
+
let startLocation;
|
| 3 |
+
let onFirstLinksLoaded;
|
| 4 |
+
|
| 5 |
+
function api(path) {
|
| 6 |
+
// Use relative paths so mounting under a subpath works
|
| 7 |
+
return path.startsWith('/') ? path.slice(1) : path;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
function initLobby() {
|
| 11 |
+
const replayForm = document.getElementById('replay-form');
|
| 12 |
+
if (replayForm) {
|
| 13 |
+
replayForm.addEventListener('submit', (e) => {
|
| 14 |
+
e.preventDefault();
|
| 15 |
+
replayGame();
|
| 16 |
+
});
|
| 17 |
+
}
|
| 18 |
+
const playAgain = document.getElementById('play-again');
|
| 19 |
+
if (playAgain) {
|
| 20 |
+
playAgain.addEventListener('click', showLobby);
|
| 21 |
+
}
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
function showLobby() {
|
| 25 |
+
document.getElementById('lobby-container').style.display = 'block';
|
| 26 |
+
document.getElementById('game-container').style.display = 'none';
|
| 27 |
+
document.getElementById('result-screen').style.display = 'none';
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
function showGame() {
|
| 31 |
+
document.getElementById('lobby-container').style.display = 'none';
|
| 32 |
+
document.getElementById('game-container').style.display = 'flex';
|
| 33 |
+
document.getElementById('result-screen').style.display = 'none';
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
function startGame() {
|
| 37 |
+
showGame();
|
| 38 |
+
const difficultyEl = document.getElementById('difficulty-select-lobby');
|
| 39 |
+
const difficulty = difficultyEl ? difficultyEl.value : 'easy';
|
| 40 |
+
fetch(api('/start_game'), {
|
| 41 |
+
method: 'POST',
|
| 42 |
+
headers: { 'Content-Type': 'application/json' },
|
| 43 |
+
body: JSON.stringify({ difficulty: difficulty })
|
| 44 |
+
})
|
| 45 |
+
.then(response => response.json())
|
| 46 |
+
.then(data => {
|
| 47 |
+
if (data.error) {
|
| 48 |
+
alert(data.error);
|
| 49 |
+
showLobby();
|
| 50 |
+
return;
|
| 51 |
+
}
|
| 52 |
+
gameId = data.game_id;
|
| 53 |
+
startLocation = data.start_location || null;
|
| 54 |
+
googleMapsApiKey = data.google_maps_api_key || null;
|
| 55 |
+
|
| 56 |
+
const chatLog = document.getElementById('chat-log');
|
| 57 |
+
chatLog.innerHTML = '';
|
| 58 |
+
addChatMessage('Agent', `New game started (ID: ${gameId}). Finding my location...`);
|
| 59 |
+
|
| 60 |
+
if (startLocation) {
|
| 61 |
+
initStreetView(startLocation);
|
| 62 |
+
initMap();
|
| 63 |
+
}
|
| 64 |
+
});
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
function replayGame() {
|
| 68 |
+
const replayId = document.getElementById('replay-id-input').value;
|
| 69 |
+
if (!replayId) {
|
| 70 |
+
alert('Please enter a Game ID to replay.');
|
| 71 |
+
return;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
fetch(api(`/game/${replayId}/state`))
|
| 75 |
+
.then(response => {
|
| 76 |
+
if (!response.ok) {
|
| 77 |
+
throw new Error('Game not found.');
|
| 78 |
+
}
|
| 79 |
+
return response.json();
|
| 80 |
+
})
|
| 81 |
+
.then(data => {
|
| 82 |
+
if (!data.game_over) {
|
| 83 |
+
alert('This game has not finished yet.');
|
| 84 |
+
return;
|
| 85 |
+
}
|
| 86 |
+
showGame();
|
| 87 |
+
gameId = replayId;
|
| 88 |
+
startLocation = data.start_location;
|
| 89 |
+
|
| 90 |
+
const chatLog = document.getElementById('chat-log');
|
| 91 |
+
chatLog.innerHTML = '';
|
| 92 |
+
addChatMessage('System', `Replaying game: ${gameId}`);
|
| 93 |
+
|
| 94 |
+
initStreetView(startLocation);
|
| 95 |
+
initMap(true);
|
| 96 |
+
replayActions(data.actions);
|
| 97 |
+
})
|
| 98 |
+
.catch(error => {
|
| 99 |
+
alert(error.message);
|
| 100 |
+
});
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
async function replayActions(actions) {
|
| 104 |
+
for (const action of actions) {
|
| 105 |
+
await sleep(2000);
|
| 106 |
+
if (action.type === 'move') {
|
| 107 |
+
addChatMessage('Agent (Replay)', `Moved to: ${action.location.lat.toFixed(4)}, ${action.location.lng.toFixed(4)}`);
|
| 108 |
+
panorama.setPosition(action.location);
|
| 109 |
+
} else if (action.type === 'guess') {
|
| 110 |
+
addChatMessage('Agent (Replay)', `Guessed: ${action.location.lat.toFixed(4)}, ${action.location.lng.toFixed(4)}`);
|
| 111 |
+
placeGuessMarker(action.location);
|
| 112 |
+
await sleep(2000);
|
| 113 |
+
const resultData = {
|
| 114 |
+
guess_location: action.location,
|
| 115 |
+
actual_location: startLocation,
|
| 116 |
+
distance_km: action.result.distance_km,
|
| 117 |
+
score: action.result.score
|
| 118 |
+
};
|
| 119 |
+
showResultScreen(resultData);
|
| 120 |
+
}
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
function initStreetView(location) {
|
| 125 |
+
onFirstLinksLoaded = new Promise(resolve => {
|
| 126 |
+
panorama = new google.maps.StreetViewPanorama(
|
| 127 |
+
document.getElementById('streetview'), {
|
| 128 |
+
position: location,
|
| 129 |
+
pov: { heading: 34, pitch: 10 },
|
| 130 |
+
visible: true,
|
| 131 |
+
linksControl: true,
|
| 132 |
+
clickToGo: true,
|
| 133 |
+
}
|
| 134 |
+
);
|
| 135 |
+
|
| 136 |
+
const linksChangedListener = panorama.addListener('links_changed', () => {
|
| 137 |
+
google.maps.event.removeListener(linksChangedListener);
|
| 138 |
+
resolve();
|
| 139 |
+
});
|
| 140 |
+
|
| 141 |
+
panorama.addListener('position_changed', function() {
|
| 142 |
+
const newLocation = panorama.getPosition();
|
| 143 |
+
updateAgentLocation(newLocation.lat(), newLocation.lng());
|
| 144 |
+
});
|
| 145 |
+
});
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
function initMap(isReplay = false) {
|
| 149 |
+
map = new google.maps.Map(document.getElementById('map'), {
|
| 150 |
+
center: { lat: 0, lng: 0 },
|
| 151 |
+
zoom: 1,
|
| 152 |
+
});
|
| 153 |
+
|
| 154 |
+
if (!isReplay) {
|
| 155 |
+
map.addListener('click', function(e) {
|
| 156 |
+
placeGuessMarker(e.latLng);
|
| 157 |
+
makeGuess(e.latLng.lat(), e.latLng.lng());
|
| 158 |
+
});
|
| 159 |
+
}
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
function placeGuessMarker(location) {
|
| 163 |
+
if (guessMarker) {
|
| 164 |
+
guessMarker.setMap(null);
|
| 165 |
+
}
|
| 166 |
+
guessMarker = new google.maps.Marker({
|
| 167 |
+
position: location,
|
| 168 |
+
map: map
|
| 169 |
+
});
|
| 170 |
+
map.setCenter(location);
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
function addChatMessage(sender, message) {
|
| 174 |
+
const chatLog = document.getElementById('chat-log');
|
| 175 |
+
const messageElement = document.createElement('div');
|
| 176 |
+
messageElement.innerHTML = `<strong>${sender}:</strong> ${message}`;
|
| 177 |
+
chatLog.appendChild(messageElement);
|
| 178 |
+
chatLog.scrollTop = chatLog.scrollHeight;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
async function runFakeAgent() {}
|
| 182 |
+
|
| 183 |
+
async function takeActionWithScreenshot(actionMessage) {}
|
| 184 |
+
|
| 185 |
+
async function updateAgentLocation(lat, lng) {
|
| 186 |
+
await fetch(api(`/game/${gameId}/move`), {
|
| 187 |
+
method: 'POST',
|
| 188 |
+
headers: { 'Content-Type': 'application/json' },
|
| 189 |
+
body: JSON.stringify({ lat: lat, lng: lng }),
|
| 190 |
+
});
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
async function makeGuess(lat, lng) {
|
| 194 |
+
addChatMessage('You', `Guessed: ${lat.toFixed(4)}, ${lng.toFixed(4)}`);
|
| 195 |
+
const response = await fetch(api(`/game/${gameId}/guess`), {
|
| 196 |
+
method: 'POST',
|
| 197 |
+
headers: { 'Content-Type': 'application/json' },
|
| 198 |
+
body: JSON.stringify({ lat: lat, lng: lng }),
|
| 199 |
+
});
|
| 200 |
+
const result = await response.json();
|
| 201 |
+
showResultScreen(result);
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
function showResultScreen(result) {
|
| 205 |
+
document.getElementById('game-container').style.display = 'none';
|
| 206 |
+
document.getElementById('result-screen').style.display = 'block';
|
| 207 |
+
|
| 208 |
+
const resultSummary = document.getElementById('result-summary');
|
| 209 |
+
resultSummary.innerHTML = `
|
| 210 |
+
<p>Your guess was ${result.distance_km.toFixed(2)} km away.</p>
|
| 211 |
+
<p>You scored ${result.score.toFixed(0)} points.</p>
|
| 212 |
+
`;
|
| 213 |
+
|
| 214 |
+
const resultMap = new google.maps.Map(document.getElementById('result-map'), {
|
| 215 |
+
zoom: 3,
|
| 216 |
+
center: result.actual_location
|
| 217 |
+
});
|
| 218 |
+
|
| 219 |
+
new google.maps.Marker({
|
| 220 |
+
position: result.actual_location,
|
| 221 |
+
map: resultMap,
|
| 222 |
+
label: 'A'
|
| 223 |
+
});
|
| 224 |
+
new google.maps.Marker({
|
| 225 |
+
position: result.guess_location,
|
| 226 |
+
map: resultMap,
|
| 227 |
+
label: 'G'
|
| 228 |
+
});
|
| 229 |
+
new google.maps.Polyline({
|
| 230 |
+
path: [result.actual_location, result.guess_location],
|
| 231 |
+
geodesic: true,
|
| 232 |
+
strokeColor: '#F97316',
|
| 233 |
+
strokeOpacity: 1.0,
|
| 234 |
+
strokeWeight: 2,
|
| 235 |
+
map: resultMap
|
| 236 |
+
});
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
function sleep(ms) {
|
| 240 |
+
return new Promise(resolve => setTimeout(resolve, ms));
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
|
geoguessr/static/style.css
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
--primary-color: #F97316; /* Orange */
|
| 3 |
+
--secondary-color: #EA580C; /* Darker Orange */
|
| 4 |
+
--accent-color: #FB923C; /* Lighter Orange */
|
| 5 |
+
--dark-color: #202124;
|
| 6 |
+
--light-color: #FFFBEB; /* Creamy White */
|
| 7 |
+
--border-radius: 8px;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
body {
|
| 11 |
+
font-family: 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
| 12 |
+
margin: 0;
|
| 13 |
+
padding: 0;
|
| 14 |
+
background-color: var(--light-color);
|
| 15 |
+
color: var(--dark-color);
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
h1 {
|
| 19 |
+
text-align: center;
|
| 20 |
+
margin: 20px 0;
|
| 21 |
+
color: var(--primary-color);
|
| 22 |
+
font-size: 2.5rem;
|
| 23 |
+
text-shadow: 1px 1px 2px rgba(0,0,0,0.1);
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
#game-container {
|
| 27 |
+
display: flex;
|
| 28 |
+
justify-content: space-around;
|
| 29 |
+
margin: 20px;
|
| 30 |
+
height: 80vh;
|
| 31 |
+
gap: 20px;
|
| 32 |
+
display: none; /* Hidden by default */
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
#streetview-container {
|
| 36 |
+
width: 50%;
|
| 37 |
+
height: 100%;
|
| 38 |
+
border-radius: var(--border-radius);
|
| 39 |
+
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
#map-container {
|
| 43 |
+
width: 30%;
|
| 44 |
+
height: 100%;
|
| 45 |
+
border-radius: var(--border-radius);
|
| 46 |
+
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
#chat-container {
|
| 50 |
+
width: 20%;
|
| 51 |
+
height: 100%;
|
| 52 |
+
display: flex;
|
| 53 |
+
flex-direction: column;
|
| 54 |
+
border-radius: var(--border-radius);
|
| 55 |
+
background-color: white;
|
| 56 |
+
padding: 15px;
|
| 57 |
+
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
#streetview, #map, #result-map {
|
| 61 |
+
height: 100%;
|
| 62 |
+
width: 100%;
|
| 63 |
+
border-radius: var(--border-radius);
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
#chat-log {
|
| 67 |
+
flex-grow: 1;
|
| 68 |
+
overflow-y: auto;
|
| 69 |
+
margin-bottom: 15px;
|
| 70 |
+
padding: 10px;
|
| 71 |
+
background-color: #f8f9fa;
|
| 72 |
+
border-radius: var(--border-radius);
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
#chat-log div {
|
| 76 |
+
margin-bottom: 10px;
|
| 77 |
+
padding: 8px 12px;
|
| 78 |
+
background-color: white;
|
| 79 |
+
border-radius: 18px;
|
| 80 |
+
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
#chat-log div strong {
|
| 84 |
+
color: var(--primary-color);
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
#controls {
|
| 88 |
+
display: flex;
|
| 89 |
+
justify-content: center;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
button {
|
| 93 |
+
background: linear-gradient(135deg, var(--secondary-color), var(--primary-color));
|
| 94 |
+
color: white;
|
| 95 |
+
border: none;
|
| 96 |
+
padding: 10px 20px;
|
| 97 |
+
border-radius: var(--border-radius);
|
| 98 |
+
cursor: pointer;
|
| 99 |
+
font-weight: 500;
|
| 100 |
+
transition: all 0.2s;
|
| 101 |
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
button:hover {
|
| 105 |
+
background: linear-gradient(135deg, #D9500B, #E56A15); /* Slightly darker gradient on hover */
|
| 106 |
+
transform: translateY(-1px);
|
| 107 |
+
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
#result-screen {
|
| 111 |
+
margin: 20px auto;
|
| 112 |
+
max-width: 800px;
|
| 113 |
+
text-align: center;
|
| 114 |
+
background-color: white;
|
| 115 |
+
padding: 30px;
|
| 116 |
+
border-radius: var(--border-radius);
|
| 117 |
+
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
#result-summary {
|
| 121 |
+
margin: 20px 0;
|
| 122 |
+
font-size: 1.2rem;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
#result-summary p {
|
| 126 |
+
margin: 10px 0;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
.marker-label {
|
| 130 |
+
color: white;
|
| 131 |
+
font-weight: bold;
|
| 132 |
+
text-align: center;
|
| 133 |
+
padding: 2px 6px;
|
| 134 |
+
border-radius: 50%;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
#lobby-container {
|
| 138 |
+
max-width: 500px;
|
| 139 |
+
margin: 40px auto;
|
| 140 |
+
padding: 30px;
|
| 141 |
+
background-color: white;
|
| 142 |
+
border-radius: var(--border-radius);
|
| 143 |
+
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
| 144 |
+
text-align: center;
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
#lobby-container h2 {
|
| 148 |
+
margin-top: 0;
|
| 149 |
+
margin-bottom: 20px;
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
#lobby-container hr {
|
| 153 |
+
margin: 20px 0;
|
| 154 |
+
border: 0;
|
| 155 |
+
border-top: 1px solid #eee;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
#lobby-container input {
|
| 159 |
+
width: calc(100% - 22px);
|
| 160 |
+
padding: 10px;
|
| 161 |
+
margin-bottom: 10px;
|
| 162 |
+
border: 1px solid #ccc;
|
| 163 |
+
border-radius: var(--border-radius);
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
/* Hide the address text in Street View */
|
| 167 |
+
.gm-iv-address {
|
| 168 |
+
display: none !important;
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
|
geoguessr/templates/admin.html
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html>
|
| 3 |
+
<head>
|
| 4 |
+
<title>Admin Panel - LLM GeoGuessr</title>
|
| 5 |
+
<link rel="stylesheet" href="{{ url_for('static', path='style.css') }}">
|
| 6 |
+
<style>
|
| 7 |
+
h1 {
|
| 8 |
+
color: var(--dark-color);
|
| 9 |
+
text-align: center;
|
| 10 |
+
}
|
| 11 |
+
#controls {
|
| 12 |
+
width: 90%;
|
| 13 |
+
max-width: 800px;
|
| 14 |
+
margin: 20px auto;
|
| 15 |
+
padding: 20px;
|
| 16 |
+
background-color: white;
|
| 17 |
+
border-radius: var(--border-radius);
|
| 18 |
+
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
| 19 |
+
display: flex;
|
| 20 |
+
align-items: center;
|
| 21 |
+
justify-content: space-around;
|
| 22 |
+
flex-wrap: wrap;
|
| 23 |
+
}
|
| 24 |
+
#controls h2 {
|
| 25 |
+
display: none; /* Title is obvious from context */
|
| 26 |
+
}
|
| 27 |
+
#controls .control-item {
|
| 28 |
+
margin: 5px 10px;
|
| 29 |
+
}
|
| 30 |
+
#controls .control-item p {
|
| 31 |
+
margin: 0;
|
| 32 |
+
font-size: 0.9rem;
|
| 33 |
+
color: #555;
|
| 34 |
+
}
|
| 35 |
+
#controls .control-item label {
|
| 36 |
+
margin-right: 5px;
|
| 37 |
+
}
|
| 38 |
+
#controls .control-item select,
|
| 39 |
+
#controls .control-item button {
|
| 40 |
+
width: 100%;
|
| 41 |
+
padding: 10px;
|
| 42 |
+
box-sizing: border-box; /* Ensures padding is included in the width */
|
| 43 |
+
}
|
| 44 |
+
#map {
|
| 45 |
+
height: 75vh;
|
| 46 |
+
margin: 0 auto;
|
| 47 |
+
width: calc(100% - 40px);
|
| 48 |
+
border-radius: var(--border-radius);
|
| 49 |
+
}
|
| 50 |
+
</style>
|
| 51 |
+
</head>
|
| 52 |
+
<body>
|
| 53 |
+
<h1>Admin Panel</h1>
|
| 54 |
+
<div id="controls">
|
| 55 |
+
<div class="control-item">
|
| 56 |
+
<p>Select "Draw" then use the rectangle tool on the map.</p>
|
| 57 |
+
</div>
|
| 58 |
+
<div class="control-item">
|
| 59 |
+
<button id="new-zone-btn">Draw New Zone</button>
|
| 60 |
+
</div>
|
| 61 |
+
<div class="control-item">
|
| 62 |
+
<label for="difficulty-select">Difficulty:</label>
|
| 63 |
+
<select id="difficulty-select">
|
| 64 |
+
<option value="easy">Easy</option>
|
| 65 |
+
<option value="medium">Medium</option>
|
| 66 |
+
<option value="hard">Hard</option>
|
| 67 |
+
</select>
|
| 68 |
+
</div>
|
| 69 |
+
<div class="control-item">
|
| 70 |
+
<button id="save-zone" disabled>Save Zone</button>
|
| 71 |
+
</div>
|
| 72 |
+
</div>
|
| 73 |
+
<div id="status-message-container" style="text-align: center; margin-bottom: 10px;">
|
| 74 |
+
<p id="status-message"></p>
|
| 75 |
+
</div>
|
| 76 |
+
<div id="map"></div>
|
| 77 |
+
<script src="{{ url_for('static', path='admin.js') }}"></script>
|
| 78 |
+
<script async defer src="https://maps.googleapis.com/maps/api/js?key={{ google_maps_api_key }}&libraries=drawing&callback=initAdminMap"></script>
|
| 79 |
+
</body>
|
| 80 |
+
</html>
|
| 81 |
+
|
geoguessr/templates/index.html
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html>
|
| 3 |
+
<head>
|
| 4 |
+
<title>LLM GeoGuessr</title>
|
| 5 |
+
<link rel="stylesheet" href="{{ url_for('static', path='style.css') }}">
|
| 6 |
+
</head>
|
| 7 |
+
<body>
|
| 8 |
+
<h1>LLM GeoGuessr</h1>
|
| 9 |
+
|
| 10 |
+
<div id="lobby-container">
|
| 11 |
+
<h2>Welcome</h2>
|
| 12 |
+
<form id="replay-form">
|
| 13 |
+
<p>Replay a previous game:</p>
|
| 14 |
+
<input type="text" id="replay-id-input" placeholder="Enter Game ID">
|
| 15 |
+
<button type="submit">Replay Game</button>
|
| 16 |
+
</form>
|
| 17 |
+
</div>
|
| 18 |
+
|
| 19 |
+
<div id="game-container" style="display: none;">
|
| 20 |
+
<div id="streetview-container">
|
| 21 |
+
<div id="streetview"></div>
|
| 22 |
+
</div>
|
| 23 |
+
<div id="map-container">
|
| 24 |
+
<div id="map"></div>
|
| 25 |
+
</div>
|
| 26 |
+
<div id="chat-container">
|
| 27 |
+
<div id="chat-log"></div>
|
| 28 |
+
<div id="controls">
|
| 29 |
+
<!-- In-game controls can go here -->
|
| 30 |
+
</div>
|
| 31 |
+
</div>
|
| 32 |
+
</div>
|
| 33 |
+
|
| 34 |
+
<div id="result-screen" style="display: none;">
|
| 35 |
+
<h2>Results</h2>
|
| 36 |
+
<div id="result-map"></div>
|
| 37 |
+
<div id="result-summary"></div>
|
| 38 |
+
<button id="play-again">Back to Lobby</button>
|
| 39 |
+
</div>
|
| 40 |
+
|
| 41 |
+
<script src="{{ url_for('static', path='script.js') }}"></script>
|
| 42 |
+
<script async defer src="https://maps.googleapis.com/maps/api/js?key={{ google_maps_api_key }}&callback=initLobby"></script>
|
| 43 |
+
</body>
|
| 44 |
+
</html>
|
| 45 |
+
|
geoguessr/web_app.py
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import uuid
|
| 4 |
+
import math
|
| 5 |
+
import random
|
| 6 |
+
import base64
|
| 7 |
+
import io
|
| 8 |
+
import requests
|
| 9 |
+
|
| 10 |
+
from fastapi import FastAPI, Request, Depends, HTTPException, status
|
| 11 |
+
from fastapi.responses import JSONResponse, HTMLResponse
|
| 12 |
+
from fastapi.staticfiles import StaticFiles
|
| 13 |
+
from fastapi.templating import Jinja2Templates
|
| 14 |
+
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
| 15 |
+
from dotenv import load_dotenv
|
| 16 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
load_dotenv()
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# Paths
|
| 23 |
+
BASE_DIR = os.path.dirname(__file__)
|
| 24 |
+
TEMPLATES_DIR = os.path.join(BASE_DIR, "templates")
|
| 25 |
+
STATIC_DIR = os.path.join(BASE_DIR, "static")
|
| 26 |
+
ZONES_FILE = os.path.join(BASE_DIR, "zones.json")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
app = FastAPI()
|
| 30 |
+
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
| 31 |
+
templates = Jinja2Templates(directory=TEMPLATES_DIR)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# In-memory state
|
| 35 |
+
games = {}
|
| 36 |
+
zones = {"easy": [], "medium": [], "hard": []}
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# --- Zone Persistence Functions ---
|
| 40 |
+
def save_zones_to_file() -> None:
|
| 41 |
+
try:
|
| 42 |
+
with open(ZONES_FILE, 'w') as f:
|
| 43 |
+
json.dump(zones, f, indent=4)
|
| 44 |
+
except Exception as e:
|
| 45 |
+
print(f"Error saving zones: {e}")
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def load_zones_from_file() -> None:
|
| 49 |
+
global zones
|
| 50 |
+
if os.path.exists(ZONES_FILE):
|
| 51 |
+
try:
|
| 52 |
+
with open(ZONES_FILE, 'r') as f:
|
| 53 |
+
loaded_zones = json.load(f)
|
| 54 |
+
|
| 55 |
+
if not (isinstance(loaded_zones, dict) and all(k in loaded_zones for k in ["easy", "medium", "hard"])):
|
| 56 |
+
raise ValueError("Invalid zones format")
|
| 57 |
+
|
| 58 |
+
migrated = False
|
| 59 |
+
for difficulty in loaded_zones:
|
| 60 |
+
for zone in loaded_zones[difficulty]:
|
| 61 |
+
if 'id' not in zone:
|
| 62 |
+
zone['id'] = uuid.uuid4().hex
|
| 63 |
+
migrated = True
|
| 64 |
+
|
| 65 |
+
zones = loaded_zones
|
| 66 |
+
if migrated:
|
| 67 |
+
save_zones_to_file()
|
| 68 |
+
except Exception as e:
|
| 69 |
+
print(f"Warning: '{ZONES_FILE}' is corrupted or invalid ({e}). Recreating with empty zones.")
|
| 70 |
+
save_zones_to_file()
|
| 71 |
+
else:
|
| 72 |
+
save_zones_to_file()
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
# Predefined fallback locations
|
| 76 |
+
LOCATIONS = [
|
| 77 |
+
{'lat': 48.85824, 'lng': 2.2945}, # Eiffel Tower, Paris
|
| 78 |
+
{'lat': 40.748440, 'lng': -73.985664}, # Empire State Building, New York
|
| 79 |
+
{'lat': 35.689487, 'lng': 139.691711}, # Tokyo, Japan
|
| 80 |
+
{'lat': -33.856784, 'lng': 151.215297} # Sydney Opera House, Australia
|
| 81 |
+
]
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def generate_game_id() -> str:
|
| 85 |
+
return ''.join(random.choices('abcdefghijklmnopqrstuvwxyz0123456789', k=10))
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def draw_compass_on_image(image_data_base64: str, heading: int) -> str:
|
| 89 |
+
try:
|
| 90 |
+
img = Image.open(io.BytesIO(base64.b64decode(image_data_base64)))
|
| 91 |
+
|
| 92 |
+
img_with_compass = img.copy()
|
| 93 |
+
draw = ImageDraw.Draw(img_with_compass)
|
| 94 |
+
|
| 95 |
+
compass_size = 80
|
| 96 |
+
margin = 20
|
| 97 |
+
x = img.width - compass_size - margin
|
| 98 |
+
y = margin
|
| 99 |
+
center_x = x + compass_size // 2
|
| 100 |
+
center_y = y + compass_size // 2
|
| 101 |
+
|
| 102 |
+
draw.ellipse([x, y, x + compass_size, y + compass_size], fill=(240, 240, 240), outline=(249, 115, 22), width=3)
|
| 103 |
+
|
| 104 |
+
font_size = 12
|
| 105 |
+
try:
|
| 106 |
+
font = ImageFont.truetype("/System/Library/Fonts/Arial.ttf", font_size)
|
| 107 |
+
except Exception:
|
| 108 |
+
try:
|
| 109 |
+
font = ImageFont.truetype("arial.ttf", font_size)
|
| 110 |
+
except Exception:
|
| 111 |
+
font = ImageFont.load_default()
|
| 112 |
+
|
| 113 |
+
directions = [
|
| 114 |
+
("N", center_x, y + 8, (220, 38, 38)),
|
| 115 |
+
("E", x + compass_size - 15, center_y, (249, 115, 22)),
|
| 116 |
+
("S", center_x, y + compass_size - 20, (249, 115, 22)),
|
| 117 |
+
("W", x + 8, center_y, (249, 115, 22)),
|
| 118 |
+
]
|
| 119 |
+
for text, text_x, text_y, color in directions:
|
| 120 |
+
bbox = draw.textbbox((0, 0), text, font=font)
|
| 121 |
+
text_width = bbox[2] - bbox[0]
|
| 122 |
+
text_height = bbox[3] - bbox[1]
|
| 123 |
+
|
| 124 |
+
circle_radius = 10
|
| 125 |
+
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)
|
| 126 |
+
draw.text((text_x - text_width // 2, text_y - text_height // 2), text, font=font, fill=color)
|
| 127 |
+
|
| 128 |
+
needle_length = compass_size // 2 - 15
|
| 129 |
+
needle_angle = math.radians(heading)
|
| 130 |
+
end_x = center_x + needle_length * math.sin(needle_angle)
|
| 131 |
+
end_y = center_y - needle_length * math.cos(needle_angle)
|
| 132 |
+
draw.line([center_x, center_y, end_x, end_y], fill=(220, 38, 38), width=4)
|
| 133 |
+
tip_radius = 3
|
| 134 |
+
draw.ellipse([end_x - tip_radius, end_y - tip_radius, end_x + tip_radius, end_y + tip_radius], fill=(220, 38, 38))
|
| 135 |
+
center_radius = 4
|
| 136 |
+
draw.ellipse([center_x - center_radius, center_y - center_radius, center_x + center_radius, center_y + center_radius], fill=(249, 115, 22))
|
| 137 |
+
|
| 138 |
+
label_y = y + compass_size + 5
|
| 139 |
+
label_text = f"{heading}°"
|
| 140 |
+
bbox = draw.textbbox((0, 0), label_text, font=font)
|
| 141 |
+
label_width = bbox[2] - bbox[0]
|
| 142 |
+
draw.text((center_x - label_width // 2, label_y), label_text, font=font, fill=(249, 115, 22))
|
| 143 |
+
|
| 144 |
+
buffer = io.BytesIO()
|
| 145 |
+
img_with_compass.save(buffer, format='JPEG', quality=85)
|
| 146 |
+
return base64.b64encode(buffer.getvalue()).decode('utf-8')
|
| 147 |
+
except Exception as e:
|
| 148 |
+
print(f"Error drawing compass: {e}")
|
| 149 |
+
return image_data_base64
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
# --- Auth ---
|
| 153 |
+
security = HTTPBasic()
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def verify_basic_auth(credentials: HTTPBasicCredentials = Depends(security)) -> None:
|
| 157 |
+
admin_user = os.getenv('ADMIN_USERNAME', 'admin')
|
| 158 |
+
admin_pass = os.getenv('ADMIN_PASSWORD', 'password')
|
| 159 |
+
if not (credentials.username == admin_user and credentials.password == admin_pass):
|
| 160 |
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized", headers={"WWW-Authenticate": "Basic"})
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
@app.get("/", response_class=HTMLResponse)
|
| 164 |
+
def index(request: Request):
|
| 165 |
+
google_maps_api_key = os.getenv('GOOGLE_MAPS_API_KEY')
|
| 166 |
+
if not google_maps_api_key:
|
| 167 |
+
return HTMLResponse("Error: GOOGLE_MAPS_API_KEY not set. Please set it in a .env file.", status_code=500)
|
| 168 |
+
base_path = request.scope.get('root_path', '')
|
| 169 |
+
return templates.TemplateResponse("index.html", {"request": request, "google_maps_api_key": google_maps_api_key, "base_path": base_path})
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
@app.get("/admin", response_class=HTMLResponse)
|
| 173 |
+
def admin(request: Request, _: None = Depends(verify_basic_auth)):
|
| 174 |
+
google_maps_api_key = os.getenv('GOOGLE_MAPS_API_KEY')
|
| 175 |
+
if not google_maps_api_key:
|
| 176 |
+
return HTMLResponse("Error: GOOGLE_MAPS_API_KEY not set. Please set it in a .env file.", status_code=500)
|
| 177 |
+
base_path = request.scope.get('root_path', '')
|
| 178 |
+
return templates.TemplateResponse("admin.html", {"request": request, "google_maps_api_key": google_maps_api_key, "base_path": base_path})
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
@app.get("/api/zones")
|
| 182 |
+
def get_zones():
|
| 183 |
+
return JSONResponse(zones)
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
@app.post("/api/zones")
|
| 187 |
+
def create_zone(payload: dict):
|
| 188 |
+
difficulty = payload.get('difficulty')
|
| 189 |
+
zone_data = payload.get('zone')
|
| 190 |
+
if difficulty and zone_data and difficulty in zones:
|
| 191 |
+
zone_data['id'] = uuid.uuid4().hex
|
| 192 |
+
zones[difficulty].append(zone_data)
|
| 193 |
+
save_zones_to_file()
|
| 194 |
+
return {"message": "Zone saved successfully"}
|
| 195 |
+
raise HTTPException(status_code=400, detail="Invalid data")
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
@app.delete("/api/zones")
|
| 199 |
+
def delete_zone(payload: dict):
|
| 200 |
+
zone_id = payload.get('zone_id')
|
| 201 |
+
if not zone_id:
|
| 202 |
+
raise HTTPException(status_code=400, detail="Zone ID is required")
|
| 203 |
+
for difficulty in zones:
|
| 204 |
+
zones[difficulty] = [z for z in zones[difficulty] if z.get('id') != zone_id]
|
| 205 |
+
save_zones_to_file()
|
| 206 |
+
return {"message": "Zone deleted successfully"}
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def direction_to_degree(direction: str):
|
| 210 |
+
directions = {
|
| 211 |
+
'N': 0, 'NORTH': 0,
|
| 212 |
+
'NE': 45, 'NORTHEAST': 45,
|
| 213 |
+
'E': 90, 'EAST': 90,
|
| 214 |
+
'SE': 135, 'SOUTHEAST': 135,
|
| 215 |
+
'S': 180, 'SOUTH': 180,
|
| 216 |
+
'SW': 225, 'SOUTHWEST': 225,
|
| 217 |
+
'W': 270, 'WEST': 270,
|
| 218 |
+
'NW': 315, 'NORTHWEST': 315
|
| 219 |
+
}
|
| 220 |
+
return directions.get(direction.upper()) if isinstance(direction, str) else None
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def calculate_new_location(current_lat: float, current_lng: float, degree: float, distance_km: float = 0.1):
|
| 224 |
+
lat_rad = math.radians(current_lat)
|
| 225 |
+
lng_rad = math.radians(current_lng)
|
| 226 |
+
bearing_rad = math.radians(degree)
|
| 227 |
+
R = 6371.0
|
| 228 |
+
|
| 229 |
+
new_lat_rad = math.asin(
|
| 230 |
+
math.sin(lat_rad) * math.cos(distance_km / R) +
|
| 231 |
+
math.cos(lat_rad) * math.sin(distance_km / R) * math.cos(bearing_rad)
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
new_lng_rad = lng_rad + math.atan2(
|
| 235 |
+
math.sin(bearing_rad) * math.sin(distance_km / R) * math.cos(lat_rad),
|
| 236 |
+
math.cos(distance_km / R) - math.sin(lat_rad) * math.sin(new_lat_rad)
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
new_lat = math.degrees(new_lat_rad)
|
| 240 |
+
new_lng = math.degrees(new_lng_rad)
|
| 241 |
+
new_lng = ((new_lng + 180) % 360) - 180
|
| 242 |
+
return new_lat, new_lng
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
@app.post("/start_game")
|
| 246 |
+
def start_game(payload: dict):
|
| 247 |
+
difficulty = payload.get('difficulty', 'easy') if payload else 'easy'
|
| 248 |
+
player_name = payload.get('player_name', 'Anonymous Player') if payload else 'Anonymous Player'
|
| 249 |
+
player_google_api_key = payload.get('google_api_key') if payload else None
|
| 250 |
+
|
| 251 |
+
start_location = None
|
| 252 |
+
if difficulty in zones and zones[difficulty]:
|
| 253 |
+
selected_zone = random.choice(zones[difficulty])
|
| 254 |
+
if selected_zone.get('type') == 'rectangle':
|
| 255 |
+
bounds = selected_zone['bounds']
|
| 256 |
+
north, south, east, west = bounds['north'], bounds['south'], bounds['east'], bounds['west']
|
| 257 |
+
if west > east:
|
| 258 |
+
east += 360
|
| 259 |
+
rand_lng = random.uniform(west, east)
|
| 260 |
+
if rand_lng > 180:
|
| 261 |
+
rand_lng -= 360
|
| 262 |
+
rand_lat = random.uniform(south, north)
|
| 263 |
+
start_location = {'lat': rand_lat, 'lng': rand_lng}
|
| 264 |
+
|
| 265 |
+
if not start_location:
|
| 266 |
+
start_location = random.choice(LOCATIONS)
|
| 267 |
+
|
| 268 |
+
game_id = generate_game_id()
|
| 269 |
+
games[game_id] = {
|
| 270 |
+
'start_location': start_location,
|
| 271 |
+
'current_location': start_location,
|
| 272 |
+
'guesses': [],
|
| 273 |
+
'moves': 0,
|
| 274 |
+
'actions': [],
|
| 275 |
+
'game_over': False,
|
| 276 |
+
'player_name': player_name,
|
| 277 |
+
'player_google_api_key': player_google_api_key,
|
| 278 |
+
'created_at': __import__('datetime').datetime.now().isoformat()
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
google_maps_api_key = player_google_api_key or os.getenv('GOOGLE_MAPS_API_KEY')
|
| 282 |
+
|
| 283 |
+
streetview_image = None
|
| 284 |
+
compass_heading = random.randint(0, 359)
|
| 285 |
+
if google_maps_api_key:
|
| 286 |
+
try:
|
| 287 |
+
lat, lng = start_location['lat'], start_location['lng']
|
| 288 |
+
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}"
|
| 289 |
+
response = requests.get(streetview_url, timeout=20)
|
| 290 |
+
if response.status_code == 200:
|
| 291 |
+
base_image = base64.b64encode(response.content).decode('utf-8')
|
| 292 |
+
streetview_image = draw_compass_on_image(base_image, compass_heading)
|
| 293 |
+
except Exception as e:
|
| 294 |
+
print(f"Error fetching Street View image: {e}")
|
| 295 |
+
|
| 296 |
+
return {
|
| 297 |
+
'game_id': game_id,
|
| 298 |
+
'player_name': player_name,
|
| 299 |
+
'streetview_image': streetview_image,
|
| 300 |
+
'compass_heading': compass_heading
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
@app.get("/game/{game_id}/state")
|
| 305 |
+
def get_game_state(game_id: str):
|
| 306 |
+
game = games.get(game_id)
|
| 307 |
+
if not game:
|
| 308 |
+
raise HTTPException(status_code=404, detail='Game not found')
|
| 309 |
+
return game
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
@app.post("/game/{game_id}/move")
|
| 313 |
+
def move(game_id: str, payload: dict):
|
| 314 |
+
game = games.get(game_id)
|
| 315 |
+
if not game:
|
| 316 |
+
raise HTTPException(status_code=404, detail='Game not found')
|
| 317 |
+
if game['game_over']:
|
| 318 |
+
raise HTTPException(status_code=400, detail='Game is over')
|
| 319 |
+
|
| 320 |
+
direction = payload.get('direction') if payload else None
|
| 321 |
+
degree = payload.get('degree') if payload else None
|
| 322 |
+
distance = payload.get('distance', 0.1) if payload else 0.1
|
| 323 |
+
|
| 324 |
+
if direction is None and degree is None:
|
| 325 |
+
raise HTTPException(status_code=400, detail='Must provide either direction (N, NE, E, etc.) or degree (0-360)')
|
| 326 |
+
|
| 327 |
+
if direction is not None:
|
| 328 |
+
degree = direction_to_degree(direction)
|
| 329 |
+
if degree is None:
|
| 330 |
+
raise HTTPException(status_code=400, detail='Invalid direction. Use N, NE, E, SE, S, SW, W, NW or their full names')
|
| 331 |
+
|
| 332 |
+
if not (0 <= degree <= 360):
|
| 333 |
+
raise HTTPException(status_code=400, detail='Degree must be between 0 and 360')
|
| 334 |
+
|
| 335 |
+
if not (0.01 <= distance <= 10):
|
| 336 |
+
raise HTTPException(status_code=400, detail='Distance must be between 0.01 and 10 km')
|
| 337 |
+
|
| 338 |
+
current_lat = game['current_location']['lat']
|
| 339 |
+
current_lng = game['current_location']['lng']
|
| 340 |
+
new_lat, new_lng = calculate_new_location(current_lat, current_lng, degree, distance)
|
| 341 |
+
|
| 342 |
+
game['current_location'] = {'lat': new_lat, 'lng': new_lng}
|
| 343 |
+
game['moves'] += 1
|
| 344 |
+
game['actions'].append({
|
| 345 |
+
'type': 'move',
|
| 346 |
+
'location': {'lat': new_lat, 'lng': new_lng},
|
| 347 |
+
'direction': direction,
|
| 348 |
+
'degree': degree,
|
| 349 |
+
'distance_km': distance
|
| 350 |
+
})
|
| 351 |
+
|
| 352 |
+
google_maps_api_key = game.get('player_google_api_key') or os.getenv('GOOGLE_MAPS_API_KEY')
|
| 353 |
+
streetview_image = None
|
| 354 |
+
compass_heading = random.randint(0, 359)
|
| 355 |
+
if google_maps_api_key:
|
| 356 |
+
try:
|
| 357 |
+
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}"
|
| 358 |
+
response = requests.get(streetview_url, timeout=20)
|
| 359 |
+
if response.status_code == 200:
|
| 360 |
+
base_image = base64.b64encode(response.content).decode('utf-8')
|
| 361 |
+
streetview_image = draw_compass_on_image(base_image, compass_heading)
|
| 362 |
+
except Exception as e:
|
| 363 |
+
print(f"Error fetching Street View image: {e}")
|
| 364 |
+
|
| 365 |
+
return {
|
| 366 |
+
'message': 'Move successful',
|
| 367 |
+
'streetview_image': streetview_image,
|
| 368 |
+
'compass_heading': compass_heading,
|
| 369 |
+
'moved_direction': direction or f"{degree}°",
|
| 370 |
+
'distance_moved_km': distance
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
@app.post("/game/{game_id}/guess")
|
| 375 |
+
def guess(game_id: str, payload: dict):
|
| 376 |
+
game = games.get(game_id)
|
| 377 |
+
if not game:
|
| 378 |
+
raise HTTPException(status_code=404, detail='Game not found')
|
| 379 |
+
if game['game_over']:
|
| 380 |
+
raise HTTPException(status_code=400, detail='Game is over')
|
| 381 |
+
|
| 382 |
+
guess_lat = payload.get('lat') if payload else None
|
| 383 |
+
guess_lng = payload.get('lng') if payload else None
|
| 384 |
+
if guess_lat is None or guess_lng is None:
|
| 385 |
+
raise HTTPException(status_code=400, detail='Missing lat/lng for guess')
|
| 386 |
+
|
| 387 |
+
guess_location = {'lat': guess_lat, 'lng': guess_lng}
|
| 388 |
+
game['guesses'].append(guess_location)
|
| 389 |
+
|
| 390 |
+
from math import radians, cos, sin, asin, sqrt
|
| 391 |
+
|
| 392 |
+
def haversine(lat1, lon1, lat2, lon2):
|
| 393 |
+
lon1, lat1, lon2, lat2 = map(radians, [lon1, lat1, lon2, lat2])
|
| 394 |
+
dlon = lon2 - lon1
|
| 395 |
+
dlat = lat2 - lat1
|
| 396 |
+
a = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlon/2)**2
|
| 397 |
+
c = 2 * asin(sqrt(a))
|
| 398 |
+
r = 6371
|
| 399 |
+
return c * r
|
| 400 |
+
|
| 401 |
+
distance = haversine(
|
| 402 |
+
game['start_location']['lat'], game['start_location']['lng'],
|
| 403 |
+
guess_lat, guess_lng
|
| 404 |
+
)
|
| 405 |
+
|
| 406 |
+
max_score = 5000
|
| 407 |
+
score = max(0, max_score - distance)
|
| 408 |
+
|
| 409 |
+
game['actions'].append({
|
| 410 |
+
'type': 'guess',
|
| 411 |
+
'location': guess_location,
|
| 412 |
+
'result': {
|
| 413 |
+
'distance_km': distance,
|
| 414 |
+
'score': score
|
| 415 |
+
}
|
| 416 |
+
})
|
| 417 |
+
game['game_over'] = True
|
| 418 |
+
|
| 419 |
+
return {
|
| 420 |
+
'message': 'Guess received',
|
| 421 |
+
'guess_location': guess_location,
|
| 422 |
+
'actual_location': game['start_location'],
|
| 423 |
+
'distance_km': distance,
|
| 424 |
+
'score': score
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
|
| 428 |
+
# Load zones at startup
|
| 429 |
+
load_zones_from_file()
|
| 430 |
+
|
| 431 |
+
|
geoguessr/zones.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"easy": [
|
| 3 |
+
{
|
| 4 |
+
"type": "rectangle",
|
| 5 |
+
"bounds": {
|
| 6 |
+
"south": 48.8336736971006,
|
| 7 |
+
"west": 2.28515625,
|
| 8 |
+
"north": 48.87885155432368,
|
| 9 |
+
"east": 2.39776611328125
|
| 10 |
+
},
|
| 11 |
+
"id": "fc849008dae8481092eb79750ffb29b3"
|
| 12 |
+
},
|
| 13 |
+
{
|
| 14 |
+
"type": "rectangle",
|
| 15 |
+
"bounds": {
|
| 16 |
+
"south": 35.66847408359237,
|
| 17 |
+
"west": 139.62718704877395,
|
| 18 |
+
"north": 35.737615509324385,
|
| 19 |
+
"east": 139.8218510502388
|
| 20 |
+
},
|
| 21 |
+
"id": "898886b44cad43b9abfec296a553daea"
|
| 22 |
+
},
|
| 23 |
+
{
|
| 24 |
+
"type": "rectangle",
|
| 25 |
+
"bounds": {
|
| 26 |
+
"south": 37.536938882023136,
|
| 27 |
+
"west": 126.94755460738277,
|
| 28 |
+
"north": 37.557898527241505,
|
| 29 |
+
"east": 127.01244260787105
|
| 30 |
+
},
|
| 31 |
+
"id": "ba27424f2f584ed0b238795608503149"
|
| 32 |
+
},
|
| 33 |
+
{
|
| 34 |
+
"type": "rectangle",
|
| 35 |
+
"bounds": {
|
| 36 |
+
"south": 18.953448012353313,
|
| 37 |
+
"west": 72.81336899465802,
|
| 38 |
+
"north": 18.976825403712557,
|
| 39 |
+
"east": 72.83911820120099
|
| 40 |
+
},
|
| 41 |
+
"id": "71ec558ad7d7476297452801a170f10c"
|
| 42 |
+
},
|
| 43 |
+
{
|
| 44 |
+
"type": "rectangle",
|
| 45 |
+
"bounds": {
|
| 46 |
+
"south": 40.713060179679026,
|
| 47 |
+
"west": -74.00079248380419,
|
| 48 |
+
"north": 40.76040587151275,
|
| 49 |
+
"east": -73.97933481168505
|
| 50 |
+
},
|
| 51 |
+
"id": "c03e32aeb33a444f8eac20b24d946b34"
|
| 52 |
+
},
|
| 53 |
+
{
|
| 54 |
+
"type": "rectangle",
|
| 55 |
+
"bounds": {
|
| 56 |
+
"south": 37.77180843179515,
|
| 57 |
+
"west": -122.4442846812313,
|
| 58 |
+
"north": 37.80328200680126,
|
| 59 |
+
"east": -122.40857911482505
|
| 60 |
+
},
|
| 61 |
+
"id": "41ce7507e12a44b8b964d9c1d2755c42"
|
| 62 |
+
}
|
| 63 |
+
],
|
| 64 |
+
"medium": [],
|
| 65 |
+
"hard": []
|
| 66 |
+
}
|
| 67 |
+
|
server.py
CHANGED
|
@@ -5,6 +5,7 @@ from fastapi.staticfiles import StaticFiles
|
|
| 5 |
from fastapi.templating import Jinja2Templates
|
| 6 |
from pokemon.pokemon_server import mcp as pokemon_mcp
|
| 7 |
from geoguessr.geo_server import mcp as geogussr_mcp
|
|
|
|
| 8 |
import os
|
| 9 |
|
| 10 |
|
|
@@ -42,6 +43,9 @@ async def index(request: Request):
|
|
| 42 |
app.mount("/geoguessr", geogussr_mcp.streamable_http_app())
|
| 43 |
app.mount("/Pokemon", pokemon_mcp.streamable_http_app())
|
| 44 |
|
|
|
|
|
|
|
|
|
|
| 45 |
PORT = int(os.environ.get("PORT", "10000"))
|
| 46 |
|
| 47 |
if __name__ == "__main__":
|
|
|
|
| 5 |
from fastapi.templating import Jinja2Templates
|
| 6 |
from pokemon.pokemon_server import mcp as pokemon_mcp
|
| 7 |
from geoguessr.geo_server import mcp as geogussr_mcp
|
| 8 |
+
from geoguessr.web_app import app as geoguessr_app
|
| 9 |
import os
|
| 10 |
|
| 11 |
|
|
|
|
| 43 |
app.mount("/geoguessr", geogussr_mcp.streamable_http_app())
|
| 44 |
app.mount("/Pokemon", pokemon_mcp.streamable_http_app())
|
| 45 |
|
| 46 |
+
# Mount GeoGuessr FastAPI web app (UI + API)
|
| 47 |
+
app.mount("/geoguessr_app", geoguessr_app)
|
| 48 |
+
|
| 49 |
PORT = int(os.environ.get("PORT", "10000"))
|
| 50 |
|
| 51 |
if __name__ == "__main__":
|