Joffrey Thomas commited on
Commit
860e1a7
·
1 Parent(s): e743ebd

merge geoguessr

Browse files
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
- # --- Flask API Helper Functions ---
15
- def call_flask_api(endpoint, method='GET', json_data=None):
16
- """Helper function to call Flask API endpoints"""
17
- url = f"{FLASK_API_URL}{endpoint}"
 
 
 
 
 
 
 
 
 
 
 
 
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"Flask API Error: {error_msg}")
29
  raise Exception(error_msg)
30
  except requests.exceptions.ConnectionError as e:
31
- error_msg = f"Could not connect to Flask API at {FLASK_API_URL}. Make sure the Flask server is running. Error: {str(e)}"
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 Flask API at {FLASK_API_URL}. Error: {str(e)}"
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 Flask API to start game
81
- game_data = call_flask_api('/start_game', 'POST', {
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 Flask API to move
135
  game_id = active_game['game_id']
136
- move_result = call_flask_api(f'/game/{game_id}/move', 'POST', move_data)
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 Flask API to make the final guess
192
  game_id = active_game['game_id']
193
- guess_result = call_flask_api(f'/game/{game_id}/guess', 'POST', {
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 = call_flask_api(f'/game/{game_id}/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__":