Jofthomas commited on
Commit
a303b0f
·
verified ·
1 Parent(s): ce27d5a

Upload 12 files

Browse files
Files changed (12) hide show
  1. README.md +10 -5
  2. app.py +941 -0
  3. data_manager.py +92 -0
  4. reference.py +102 -0
  5. requirements.txt +12 -0
  6. static/admin.js +143 -0
  7. static/script.js +217 -0
  8. static/style.css +169 -0
  9. templates/admin.html +80 -0
  10. templates/game.html +65 -0
  11. templates/index.html +60 -0
  12. zones.json +66 -0
README.md CHANGED
@@ -1,12 +1,17 @@
1
  ---
2
- title: Magistral Geogussr Challenge
3
- emoji: 📈
4
- colorFrom: gray
5
- colorTo: yellow
6
  sdk: gradio
7
- sdk_version: 5.48.0
8
  app_file: app.py
9
  pinned: false
 
 
 
 
 
10
  ---
11
 
12
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
  ---
2
+ title: Geogussr LLM
3
+ emoji: 📚
4
+ colorFrom: pink
5
+ colorTo: pink
6
  sdk: gradio
7
+ sdk_version: 5.46.0
8
  app_file: app.py
9
  pinned: false
10
+ hf_oauth: true
11
+ hf_oauth_expiration_minutes: 1440
12
+ hf_oauth_scopes:
13
+ - read-repos
14
+ - write-repos
15
  ---
16
 
17
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
app.py ADDED
@@ -0,0 +1,941 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import random
3
+ import json
4
+ import uuid
5
+ import time
6
+ from datetime import datetime, timedelta, timezone
7
+ from typing import Dict, Any, List, Optional
8
+ import spaces
9
+
10
+ import requests
11
+ from dotenv import load_dotenv
12
+ import gradio as gr
13
+ from gradio.components import LoginButton
14
+
15
+ import data_manager
16
+
17
+ from huggingface_hub import HfApi, hf_hub_download, whoami
18
+
19
+ from transformers import Mistral3ForConditionalGeneration, AutoTokenizer, TextIteratorStreamer
20
+ import threading
21
+ import torch
22
+
23
+ load_dotenv()
24
+
25
+ APP_SECRET = os.urandom(24)
26
+
27
+ ZONES_FILE = 'zones.json'
28
+ zones = {
29
+ "easy": [],
30
+ "medium": [],
31
+ "hard": []
32
+ }
33
+
34
+ user_sessions: Dict[str, Dict[str, Any]] = {}
35
+ DEFAULT_USERNAME = "player"
36
+
37
+ def save_zones_to_file():
38
+ with open(ZONES_FILE, 'w') as f:
39
+ json.dump(zones, f, indent=4)
40
+
41
+ def load_zones_from_file():
42
+ global zones
43
+ if os.path.exists(ZONES_FILE):
44
+ try:
45
+ with open(ZONES_FILE, 'r') as f:
46
+ loaded_zones = json.load(f)
47
+ if not (isinstance(loaded_zones, dict) and all(k in loaded_zones for k in ["easy", "medium", "hard"])):
48
+ raise ValueError("Invalid format")
49
+ migrated = False
50
+ for difficulty in loaded_zones:
51
+ for zone in loaded_zones[difficulty]:
52
+ if 'id' not in zone:
53
+ zone['id'] = uuid.uuid4().hex
54
+ migrated = True
55
+ zones = loaded_zones
56
+ print(zones)
57
+ if migrated:
58
+ print("Info: Migrated old zone data by adding unique IDs.")
59
+ save_zones_to_file()
60
+ except (json.JSONDecodeError, IOError, ValueError):
61
+ print(f"Warning: '{ZONES_FILE}' is corrupted or invalid. Recreating with empty zones.")
62
+ save_zones_to_file()
63
+ else:
64
+ save_zones_to_file()
65
+
66
+ LOCATIONS = [
67
+ {'lat': 48.85824, 'lng': 2.2945},
68
+ {'lat': 40.748440, 'lng': -73.985664},
69
+ {'lat': 35.689487, 'lng': 139.691711},
70
+ {'lat': -33.856784, 'lng': 151.215297}
71
+ ]
72
+
73
+ def generate_id():
74
+ return ''.join(random.choices('abcdefghijklmnopqrstuvwxyz0123456789', k=10))
75
+
76
+ HF_DATASET_REPO = 'jofthomas/geoguessr_game_of_the_day'
77
+
78
+ GOOGLE_MAPS_API_KEY = os.getenv('GOOGLE_MAPS_API_KEY')
79
+ SERVER_HF_TOKEN = os.getenv('HF_TOKEN', '')
80
+
81
+ model_id = "mistralai/Magistral-Small-2509"
82
+ tokenizer = AutoTokenizer.from_pretrained(model_id, tokenizer_type="mistral", use_fast=False)
83
+ model = Mistral3ForConditionalGeneration.from_pretrained(
84
+ model_id, torch_dtype=torch.bfloat16, device_map="auto"
85
+ ).eval()
86
+
87
+
88
+ SYSTEM_PROMPT_TEXT = (
89
+ "You are a world-class geolocation expert. Given a street-view style image, "
90
+ "think step by step about visual clues and infer approximate coordinates. "
91
+ "When you conclude, output your answer inside [ANSWER]lat,lng[/ANSWER]."
92
+ )
93
+ @spaces.GPU(duration=120)
94
+ def llm_decode_image_return_text(image_bytes: bytes) -> str:
95
+ print(f"[llm] decode start. image_bytes={len(image_bytes)} bytes")
96
+ import base64, mimetypes
97
+ try:
98
+ encoded_image = base64.b64encode(image_bytes).decode('utf-8')
99
+ mime_type = 'image/jpeg'
100
+ data_url = f"data:{mime_type};base64,{encoded_image}"
101
+ messages = [
102
+ {"role": "system", "content": [{"type": "text", "text": SYSTEM_PROMPT_TEXT}]},
103
+ {"role": "user", "content": [
104
+ {"type": "text", "text": "Please analyze this image and provide coordinates."},
105
+ {"type": "image_url", "image_url": {"url": data_url}},
106
+ ]},
107
+ ]
108
+ print(f"[llm] messages prepared. system+user with image_url length={len(data_url)}")
109
+ tokenized = tokenizer.apply_chat_template(messages, return_dict=True)
110
+ print(f"[llm] tokenized keys={list(tokenized.keys())}")
111
+ import torch
112
+ input_ids = torch.tensor(tokenized.input_ids).unsqueeze(0)
113
+ attention_mask = torch.tensor(tokenized.attention_mask).unsqueeze(0)
114
+ print(f"[llm] input_ids shape={tuple(input_ids.shape)} attn_mask shape={tuple(attention_mask.shape)} device={model.device}")
115
+ kwargs = {
116
+ 'input_ids': input_ids.to(model.device),
117
+ 'attention_mask': attention_mask.to(model.device),
118
+ }
119
+ if 'pixel_values' in tokenized and len(tokenized.pixel_values) > 0:
120
+ pixel_values = torch.tensor(tokenized.pixel_values[0], dtype=model.dtype).unsqueeze(0).to(model.device)
121
+ image_sizes = torch.tensor(pixel_values.shape[-2:]).unsqueeze(0).to(model.device)
122
+ kwargs.update({'pixel_values': pixel_values, 'image_sizes': image_sizes})
123
+ print(f"[llm] pixel_values shape={tuple(pixel_values.shape)} image_sizes={tuple(image_sizes.shape)}")
124
+ output = model.generate(**kwargs)[0]
125
+ print(f"[llm] generate done. output length={len(output)}")
126
+ decoded = tokenizer.decode(output[len(tokenized.input_ids): ( -1 if output[-1] == tokenizer.eos_token_id else len(output) )])
127
+ print(f"[llm] decode done. text length={len(decoded)}")
128
+ return decoded
129
+ except Exception as e:
130
+ print(f"[llm] decode failed: {e}")
131
+ return f"[Error] {e}"
132
+
133
+ @spaces.GPU(duration=120)
134
+ def llm_stream_image_text(image_bytes: bytes):
135
+ print(f"[llm-stream] start. image_bytes={len(image_bytes)} bytes")
136
+ import base64
137
+ try:
138
+ encoded_image = base64.b64encode(image_bytes).decode('utf-8')
139
+ data_url = f"data:image/jpeg;base64,{encoded_image}"
140
+ messages = [
141
+ {"role": "system", "content": [{"type": "text", "text": SYSTEM_PROMPT_TEXT}]},
142
+ {"role": "user", "content": [
143
+ {"type": "text", "text": "Please analyze this image and provide coordinates."},
144
+ {"type": "image_url", "image_url": {"url": data_url}},
145
+ ]},
146
+ ]
147
+ tokenized = tokenizer.apply_chat_template(messages, return_dict=True)
148
+ input_ids = torch.tensor(tokenized.input_ids).unsqueeze(0)
149
+ attention_mask = torch.tensor(tokenized.attention_mask).unsqueeze(0)
150
+ kwargs = {
151
+ 'input_ids': input_ids.to(model.device),
152
+ 'attention_mask': attention_mask.to(model.device),
153
+ 'max_new_tokens': 512,
154
+ }
155
+ if 'pixel_values' in tokenized and len(tokenized.pixel_values) > 0:
156
+ pixel_values = torch.tensor(tokenized.pixel_values[0], dtype=model.dtype).unsqueeze(0).to(model.device)
157
+ image_sizes = torch.tensor(pixel_values.shape[-2:]).unsqueeze(0).to(model.device)
158
+ kwargs.update({'pixel_values': pixel_values, 'image_sizes': image_sizes})
159
+ streamer = TextIteratorStreamer(tokenizer, skip_prompt=True, skip_special_tokens=False)
160
+ kwargs['streamer'] = streamer
161
+ thread = threading.Thread(target=model.generate, kwargs=kwargs)
162
+ thread.start()
163
+ acc = ""
164
+ for new_text in streamer:
165
+ acc += new_text
166
+ yield acc
167
+ except Exception as e:
168
+ yield f"[Error] {e}"
169
+
170
+
171
+ def zones_get() -> dict:
172
+ return zones
173
+
174
+
175
+ def seconds_until_midnight_utc() -> int:
176
+ now = datetime.now(timezone.utc)
177
+ tomorrow = (now + timedelta(days=1)).date()
178
+ midnight = datetime.combine(tomorrow, datetime.min.time(), tzinfo=timezone.utc)
179
+ return max(0, int((midnight - now).total_seconds()))
180
+
181
+
182
+
183
+ def pick_random_location(difficulty: str) -> Dict[str, float]:
184
+ candidates = zones.get(difficulty, [])
185
+ if candidates:
186
+ selected_zone = random.choice(candidates)
187
+ if selected_zone.get('type') == 'rectangle':
188
+ b = selected_zone['bounds']
189
+ north, south, east, west = b['north'], b['south'], b['east'], b['west']
190
+ if west > east:
191
+ east += 360
192
+ lng = random.uniform(west, east)
193
+ if lng > 180:
194
+ lng -= 360
195
+ lat = random.uniform(south, north)
196
+ ensured = _ensure_street_view_location(lat, lng)
197
+ if ensured:
198
+ return ensured
199
+ fallback = random.choice(LOCATIONS)
200
+ ensured_fallback = _ensure_street_view_location(fallback['lat'], fallback['lng'])
201
+ return ensured_fallback or fallback
202
+
203
+
204
+ def street_view_image_url(lat: float, lng: float) -> str:
205
+ if not GOOGLE_MAPS_API_KEY:
206
+ # Fallback placeholder to avoid blank image when key is missing
207
+ return "https://picsum.photos/960/540"
208
+ return (
209
+ f"https://maps.googleapis.com/maps/api/streetview?size=960x540&location={lat},{lng}&fov=90&pitch=0&key={GOOGLE_MAPS_API_KEY}"
210
+ )
211
+
212
+
213
+ def _has_street_view(lat: float, lng: float) -> bool:
214
+ if not GOOGLE_MAPS_API_KEY:
215
+ return True
216
+ try:
217
+ resp = requests.get(
218
+ "https://maps.googleapis.com/maps/api/streetview/metadata",
219
+ params={"location": f"{lat},{lng}", "key": GOOGLE_MAPS_API_KEY},
220
+ timeout=5,
221
+ )
222
+ resp.raise_for_status()
223
+ data = resp.json()
224
+ return data.get("status") == "OK"
225
+ except Exception:
226
+ return False
227
+
228
+
229
+ def _snap_to_nearest_road(lat: float, lng: float) -> Optional[Dict[str, float]]:
230
+ if not GOOGLE_MAPS_API_KEY:
231
+ return None
232
+ try:
233
+ resp = requests.get(
234
+ "https://roads.googleapis.com/v1/nearestRoads",
235
+ params={"points": f"{lat},{lng}", "key": GOOGLE_MAPS_API_KEY},
236
+ timeout=5,
237
+ )
238
+ resp.raise_for_status()
239
+ data = resp.json()
240
+ points = data.get("snappedPoints") or []
241
+ if not points:
242
+ return None
243
+ loc = points[0].get("location") or {}
244
+ if "latitude" in loc and "longitude" in loc:
245
+ return {"lat": float(loc["latitude"]), "lng": float(loc["longitude"])}
246
+ except Exception:
247
+ pass
248
+ return None
249
+
250
+
251
+ def _ensure_street_view_location(lat: float, lng: float) -> Optional[Dict[str, float]]:
252
+ """Return a coordinate with confirmed Street View coverage, snapped near a road when possible."""
253
+ if not GOOGLE_MAPS_API_KEY:
254
+ return {"lat": lat, "lng": lng}
255
+
256
+ checked: set[tuple] = set()
257
+ snapped = _snap_to_nearest_road(lat, lng)
258
+ candidates: List[Dict[str, float]] = []
259
+ if snapped:
260
+ candidates.append(snapped)
261
+ candidates.append({"lat": lat, "lng": lng})
262
+
263
+ # Explore a few jittered points if needed
264
+ if not snapped:
265
+ increments = [0.0005, -0.0005, 0.001, -0.001]
266
+ for d_lat in increments:
267
+ for d_lng in increments:
268
+ if d_lat == 0 and d_lng == 0:
269
+ continue
270
+ candidates.append({"lat": lat + d_lat, "lng": lng + d_lng})
271
+
272
+ for candidate in candidates:
273
+ key = (round(candidate["lat"], 6), round(candidate["lng"], 6))
274
+ if key in checked:
275
+ continue
276
+ checked.add(key)
277
+ if _has_street_view(candidate["lat"], candidate["lng"]):
278
+ return candidate
279
+
280
+ return None
281
+
282
+
283
+ def haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
284
+ from math import radians, cos, sin, asin, sqrt
285
+ lon1, lat1, lon2, lat2 = map(radians, [lon1, lat1, lon2, lat2])
286
+ dlon = lon2 - lon1
287
+ dlat = lat2 - lat1
288
+ a = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlon/2)**2
289
+ c = 2 * asin(sqrt(a))
290
+ r = 6371
291
+ return c * r
292
+
293
+
294
+ def score_from_distance_km(distance_km: float) -> float:
295
+ max_score = 5000.0
296
+ return max(0.0, max_score - distance_km)
297
+
298
+
299
+ def build_street_html(image_url: str) -> str:
300
+ base = """
301
+ <div id="image-container" style="position:relative;max-width:960px;margin:0 auto;">
302
+ <img id="street-image" src="__IMG_URL__" style="width:100%;height:auto;border-radius:8px;box-shadow:0 4px 6px rgba(0,0,0,0.1)" />
303
+ <div id="mini-map-wrap" style="transition:all 0.3s ease;position:absolute;right:10px;bottom:10px;border:2px solid #fff;border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,0.2);background:#fff;">
304
+ <div id="mini-map" style="width:100%;height:100%;cursor:pointer"></div>
305
+ <div id="map-controls" style="position:absolute;right:8px;top:8px;display:flex;gap:6px;z-index:5;">
306
+ <button id="map-size-minus" class="map-ctrl" style="width:34px;height:28px;border-radius:6px;border:1px solid rgba(0,0,0,0.2);background:#f97316;color:#fff;">−</button>
307
+ <button id="map-size-plus" class="map-ctrl" style="width:34px;height:28px;border-radius:6px;border:1px solid rgba(0,0,0,0.2);background:#f97316;color:#fff;">+</button>
308
+ </div>
309
+ </div>
310
+ </div>
311
+ """
312
+ return base.replace('__IMG_URL__', image_url)
313
+
314
+
315
+ def gr_start_game(difficulty: str, username: str, request: gr.Request):
316
+ rounds: List[Dict[str, Any]] = []
317
+ date_str = datetime.now(timezone.utc).date().isoformat()
318
+ for _ in range(3):
319
+ loc = pick_random_location(difficulty)
320
+ round_id = generate_id()
321
+ rounds.append({
322
+ 'id': round_id,
323
+ 'lat': loc['lat'],
324
+ 'lng': loc['lng'],
325
+ 'image_url': street_view_image_url(loc['lat'], loc['lng']),
326
+ 'human_guess': None,
327
+ 'ai_guess': None,
328
+ 'human_score': 0.0,
329
+ 'ai_score': 0.0,
330
+ })
331
+
332
+ user_sessions[username] = {
333
+ 'difficulty': difficulty,
334
+ 'rounds': rounds,
335
+ 'total_score': 0.0,
336
+ 'completed': False,
337
+ 'date': date_str,
338
+ }
339
+ r0 = rounds[0]
340
+ street_html = build_street_html(r0['image_url'])
341
+ return rounds, 0, r0['id'], street_html, "", ""
342
+
343
+
344
+ def get_round(username: str, round_id: str) -> Optional[Dict[str, Any]]:
345
+ session_data = user_sessions.get(username)
346
+ if not session_data:
347
+ return None
348
+ for r in session_data['rounds']:
349
+ if r['id'] == round_id:
350
+ return r
351
+ return None
352
+
353
+
354
+ def gr_submit_guess(round_id: str, lat: float, lng: float, username: str, request: gr.Request):
355
+ rnd = get_round(username, round_id)
356
+ if not rnd:
357
+ return "", "Round not found", gr.update(), gr.update(), gr.update()
358
+ distance_km = haversine_km(rnd['lat'], rnd['lng'], float(lat), float(lng))
359
+ score = score_from_distance_km(distance_km)
360
+ rnd['human_guess'] = {'lat': float(lat), 'lng': float(lng)}
361
+ rnd['human_score'] = score
362
+ rnd['human_distance_km'] = float(distance_km)
363
+ result_text = f"Your guess was {distance_km:.2f} km away. You scored {score:.0f} points."
364
+ scoreboard_html = (
365
+ f"<div style='margin:8px 0;padding:26px;background:#040b1a;border-radius:18px;color:#f9fafb;border:1px solid rgba(148,163,184,0.4);box-shadow:0 26px 60px rgba(4,7,15,0.6);'>"
366
+ f"<div style='font-weight:700;font-size:1.25rem;margin-bottom:14px;text-shadow:0 0 12px rgba(4,7,15,0.75);'>Human Guess Recorded</div>"
367
+ f"<div style='margin-bottom:20px;color:#f8fafc;font-size:1.02rem;line-height:1.6;text-shadow:0 0 10px rgba(4,7,15,0.65);'>{result_text}</div>"
368
+ f"<div style='display:flex;gap:24px;font-size:0.96rem;margin-bottom:18px;flex-wrap:wrap;color:#f8fafc;'>"
369
+ f" <span><strong>Distance:</strong> {distance_km:.2f} km</span>"
370
+ f" <span><strong>Score:</strong> {score:.0f} pts</span>"
371
+ f"</div>"
372
+ f"<div style='font-size:0.92rem;color:#e2e8f0;'>AI analysis will be added once the model finishes.</div>"
373
+ f"</div>"
374
+ )
375
+ popup_html = """
376
+ <div id=\"popup-overlay\" style=\"position:fixed;inset:0;background:rgba(8,11,20,0.78);display:flex;align-items:center;justify-content:center;z-index:1000;\">
377
+ <div id=\"popup-card\" style=\"background:#04070f;padding:26px;border-radius:18px;width:92%;max-width:960px;color:#f9fafb;position:relative;box-shadow:0 28px 80px rgba(4,7,15,0.75);border:1px solid rgba(148,163,184,0.35);\" onclick=\"event.stopPropagation();\">
378
+ <button id=\"popup-close-next\" style=\"position:absolute;top:16px;right:18px;padding:10px 18px;border-radius:999px;border:1px solid rgba(96,165,250,0.4);background:linear-gradient(135deg,#1d4ed8,#2563eb);color:#f8fafc;font-weight:600;cursor:pointer;box-shadow:0 8px 18px rgba(37,99,235,0.45);\">Close & Next</button>
379
+ <h3 style=\"margin:0 0 20px;color:#f8fafc;text-shadow:0 0 14px rgba(37,99,235,0.35);\">Round Results</h3>
380
+ __SCOREBOARD__
381
+ <div id=\"popup-map\" data-rnd-lat=\"__RND_LAT__\" data-rnd-lng=\"__RND_LNG__\" data-h-lat=\"__H_LAT__\" data-h-lng=\"__H_LNG__\" data-ai-lat=\"\" data-ai-lng=\"\" style=\"width:100%;height:440px;border-radius:16px;overflow:hidden;border:1px solid rgba(148,163,184,0.35);box-shadow:0 16px 40px rgba(4,7,15,0.72);\"></div>
382
+ <div style=\"display:flex;justify-content:space-between;align-items:center;margin-top:24px;font-size:0.98rem;color:#e2e8f0;flex-wrap:wrap;gap:14px;\">
383
+ <div style=\"display:flex;gap:16px;align-items:center;flex-wrap:wrap;\">
384
+ <span style=\"display:flex;align-items:center;gap:8px;\"><span style=\"display:inline-flex;width:13px;height:13px;border-radius:50%;background:#22c55e;box-shadow:0 0 8px rgba(34,197,94,0.6);\"></span>G = Ground Truth</span>
385
+ <span style=\"display:flex;align-items:center;gap:8px;\"><span style=\"display:inline-flex;width:13px;height:13px;border-radius:50%;background:#f97316;box-shadow:0 0 8px rgba(249,115,22,0.55);\"></span>H = Human</span>
386
+ <span style=\"display:flex;align-items:center;gap:8px;\"><span style=\"display:inline-flex;width:13px;height:13px;border-radius:50%;background:#2563EB;box-shadow:0 0 8px rgba(37,99,235,0.6);\"></span>A = AI</span>
387
+ </div>
388
+ <button id=\"popup-close-next-footer\" style=\"padding:12px 22px;border-radius:12px;border:1px solid rgba(96,165,250,0.4);background:linear-gradient(135deg,#1d4ed8,#2563eb);color:#f8fafc;font-weight:600;cursor:pointer;box-shadow:0 10px 22px rgba(37,99,235,0.48);\">Next Round</button>
389
+ </div>
390
+ </div>
391
+ </div>
392
+ <script>
393
+ (function() {
394
+ function boot() {
395
+ if (window.__initPopupIfPresent) {
396
+ window.__initPopupIfPresent();
397
+ }
398
+ }
399
+ if (document.readyState === 'complete' || document.readyState === 'interactive') {
400
+ setTimeout(boot, 0);
401
+ } else {
402
+ document.addEventListener('DOMContentLoaded', boot, { once: true });
403
+ }
404
+ })();
405
+ </script>
406
+ """.replace('__SCOREBOARD__', scoreboard_html)\
407
+ .replace('__RND_LAT__', str(rnd['lat']))\
408
+ .replace('__RND_LNG__', str(rnd['lng']))\
409
+ .replace('__H_LAT__', str(float(lat)))\
410
+ .replace('__H_LNG__', str(float(lng)))\
411
+ .replace('__GMAPS_KEY__', GOOGLE_MAPS_API_KEY or '')
412
+ return popup_html, result_text, rnd['lat'], rnd['lng'], score
413
+
414
+ def extract_coords_from_text(text: str) -> Optional[Dict[str, float]]:
415
+ import re
416
+ m = re.search(r"\[ANSWER\]\s*([+-]?\d+(?:\.\d+)?)\s*,\s*([+-]?\d+(?:\.\d+)?)\s*\[/ANSWER\]", text, re.IGNORECASE)
417
+ if not m:
418
+ return None
419
+ try:
420
+ lat = float(m.group(1))
421
+ lng = float(m.group(2))
422
+ return {'lat': lat, 'lng': lng}
423
+ except Exception:
424
+ return None
425
+
426
+
427
+ def geocode_text_to_coords(query: str) -> Optional[Dict[str, float]]:
428
+ if not GOOGLE_MAPS_API_KEY:
429
+ return None
430
+ resp = requests.get('https://maps.googleapis.com/maps/api/geocode/json', params={'address': query, 'key': GOOGLE_MAPS_API_KEY})
431
+ try:
432
+ j = resp.json()
433
+ if j.get('results'):
434
+ loc = j['results'][0]['geometry']['location']
435
+ return {'lat': loc['lat'], 'lng': loc['lng']}
436
+ except Exception:
437
+ return None
438
+ return None
439
+
440
+
441
+ def gr_ai_analyze(round_id: str, request: gr.Request):
442
+ username = DEFAULT_USERNAME
443
+ rnd = get_round(username, round_id)
444
+ if not rnd:
445
+ return "Round not found", gr.update()
446
+ try:
447
+ img_resp = requests.get(rnd['image_url'])
448
+ img_resp.raise_for_status()
449
+ full_text = llm_decode_image_return_text(img_resp.content)
450
+ except Exception as e:
451
+ return f"[Error] {e}", gr.update()
452
+ ai_coords = extract_coords_from_text(full_text) or geocode_text_to_coords(full_text[-256:])
453
+ ai_marker = None
454
+ if ai_coords:
455
+ rnd['ai_guess'] = ai_coords
456
+ distance_km = haversine_km(rnd['lat'], rnd['lng'], ai_coords['lat'], ai_coords['lng'])
457
+ rnd['ai_score'] = score_from_distance_km(distance_km)
458
+ ai_marker = ai_coords
459
+ return full_text, ai_marker
460
+
461
+
462
+ def gr_session_summary(username: str, request: gr.Request):
463
+ sess = user_sessions.get(username)
464
+ if not sess:
465
+ return "No active session"
466
+ total = sum(float(r.get('human_score', 0.0)) for r in sess['rounds'])
467
+ return f"Your total score: {total:.0f} / 15000"
468
+
469
+ load_zones_from_file()
470
+
471
+ def _read_text(path: str) -> str:
472
+ try:
473
+ with open(path, 'r') as f:
474
+ return f.read()
475
+ except Exception:
476
+ return ""
477
+
478
+ APP_CSS = _read_text('static/style.css') + "\n#lat_box, #lng_box { display:none; }\n" + """
479
+ #lobby_group, #game_group{max-width:1024px;margin:24px auto;padding:16px;background:#fff;border-radius:12px;box-shadow:0 8px 24px rgba(0,0,0,0.08)}
480
+ #start_btn{height:48px;font-weight:700}
481
+ .gradio-container{background:var(--light-color,#FFFBEB)}
482
+ body, .gradio-container, .gradio-container *{color:#111 !important}
483
+ /* force markdown text to be dark */
484
+ .gradio-container .prose, .gradio-container .prose *{color:#111 !important}
485
+ /* override: LLM output textbox uses white font on dark background */
486
+ #ai_chat, #ai_chat *{color:#fff !important}
487
+ #ai_chat textarea{background:#111 !important;color:#fff !important}
488
+ #ai_chat label{color:#fff !important}
489
+ /* difficulty dropdown white text */
490
+ #difficulty_select, #difficulty_select *{color:#fff !important}
491
+ /* keep dropdown menu items readable */
492
+ .svelte-3lgy39 .wrap-inner, .wrap-inner{ color: inherit; }
493
+ #popup-overlay, #popup-overlay * {color:#f9fafb !important}
494
+ #popup-overlay #ai-analysis-box, #popup-overlay #ai-analysis-box * { color: #1e293b !important; }
495
+ """
496
+
497
+ # Client boot JS to initialize the mini-map reliably in Gradio (scripts in HTML are sanitized)
498
+ APP_BOOT_JS = """
499
+ () => {
500
+ const GMAPS_KEY = "__GMAPS_KEY__";
501
+ const log = (...a) => { try { console.log('[boot]', ...a); } catch(_) {} };
502
+ function ensureMapsLoaded(cb) {
503
+ if (window.google && google.maps) return cb();
504
+ if (!GMAPS_KEY) { log('No GOOGLE_MAPS_API_KEY; mini-map disabled'); return; }
505
+ window.__gmapsQueue = window.__gmapsQueue || [];
506
+ window.__gmapsQueue.push(cb);
507
+ if (window.__gmapsLoading) return;
508
+ window.__gmapsLoading = true;
509
+ window.__mini_cb__ = () => {
510
+ log('Google Maps ready');
511
+ const q = window.__gmapsQueue || [];
512
+ q.forEach(fn => { try { fn(); } catch(_) {} });
513
+ window.__gmapsQueue = [];
514
+ };
515
+ const s = document.createElement('script');
516
+ s.async = true; s.defer = true; s.dataset.gmapsLoader = '1';
517
+ s.src = 'https://maps.googleapis.com/maps/api/js?key=' + GMAPS_KEY + '&callback=__mini_cb__';
518
+ s.onerror = () => log('Failed to load Google Maps script');
519
+ document.head.appendChild(s);
520
+ }
521
+ function initMiniMapIfPresent() {
522
+ const el = document.getElementById('mini-map');
523
+ if (!el || el.dataset.initialized === '1') return;
524
+ ensureMapsLoaded(() => {
525
+ try {
526
+ const map = new google.maps.Map(el, { center: { lat: 0, lng: 0 }, zoom: 1, streetViewControl: false, mapTypeControl: false, fullscreenControl: false });
527
+ window._miniMapInstance = map;
528
+ el.dataset.initialized = '1';
529
+ let marker=null;
530
+ map.addListener('click',(e)=>{
531
+ if(marker) marker.setMap(null);
532
+ marker=new google.maps.Marker({position:e.latLng, map});
533
+ const latBox=document.querySelector('#lat_box input, #lat_box textarea, #lat_box input[type=number]');
534
+ const lngBox=document.querySelector('#lng_box input, #lng_box textarea, #lng_box input[type=number]');
535
+ if(latBox){ latBox.value=e.latLng.lat(); latBox.dispatchEvent(new Event('input',{bubbles:true})); }
536
+ if(lngBox){ lngBox.value=e.latLng.lng(); lngBox.dispatchEvent(new Event('input',{bubbles:true})); }
537
+ });
538
+ setTimeout(() => { try { google.maps.event.trigger(map, 'resize'); map.setCenter({ lat: 0, lng: 0 }); } catch(_) {} }, 150);
539
+ log('Mini-map initialized');
540
+ } catch (e) { log('Mini-map init error', e); }
541
+ });
542
+ }
543
+ function initPopupIfPresent() {
544
+ log('initPopupIfPresent called');
545
+ const el = document.getElementById('popup-map');
546
+ if (!el || el.dataset.initialized === '1') return;
547
+ log("Raw AI dataset values:", { lat: el.dataset.aiLat, lng: el.dataset.aiLng });
548
+ const rnd = { lat: parseFloat(el.dataset.rndLat), lng: parseFloat(el.dataset.rndLng) };
549
+ const human = { lat: parseFloat(el.dataset.hLat), lng: parseFloat(el.dataset.hLng) };
550
+ const ai = { lat: parseFloat(el.dataset.aiLat), lng: parseFloat(el.dataset.aiLng) };
551
+ log("Parsed AI coords:", ai);
552
+ ensureMapsLoaded(() => {
553
+ try {
554
+ log('Popup map element found, ensuring maps loaded...');
555
+ const mapOpts={zoom:6,center:rnd,mapTypeControl:false,streetViewControl:false,fullscreenControl:false};
556
+ const m = new google.maps.Map(el, mapOpts);
557
+ el.dataset.initialized = '1';
558
+ const bounds = new google.maps.LatLngBounds();
559
+ const markerIcon = (fill, stroke) => ({ path: google.maps.SymbolPath.CIRCLE, scale: 9.5, fillColor: fill, fillOpacity: 1, strokeColor: stroke, strokeWeight: 2 });
560
+ const markerLabel = (text) => ({ text, color: '#ffffff', fontWeight: '700', fontSize: '12px' });
561
+ log("Marker label style:", markerLabel("A"));
562
+ const gMk = new google.maps.Marker({ position: rnd, map: m, label: markerLabel('G'), icon: markerIcon('#22c55e', '#166534') });
563
+ bounds.extend(gMk.getPosition());
564
+ if (Number.isFinite(ai.lat) && Number.isFinite(ai.lng)) {
565
+ log("AI coordinates are valid, creating marker.");
566
+ const aMk = new google.maps.Marker({ position: ai, map: m, label: markerLabel('A'), icon: markerIcon('#2563EB', '#1e3a8a') });
567
+ bounds.extend(aMk.getPosition());
568
+ new google.maps.Polyline({ path: [rnd, ai], geodesic: true, strokeColor: '#2563EB', strokeOpacity: 1.0, strokeWeight: 2, map: m });
569
+ } else {
570
+ log("AI coordinates are NOT valid, skipping marker creation.");
571
+ }
572
+ if (Number.isFinite(human.lat) && Number.isFinite(human.lng)) {
573
+ const hMk = new google.maps.Marker({ position: human, map: m, label: markerLabel('H'), icon: markerIcon('#f97316', '#c2410c') });
574
+ bounds.extend(hMk.getPosition());
575
+ new google.maps.Polyline({ path: [rnd, human], geodesic: true, strokeColor: '#f97316', strokeOpacity: 1.0, strokeWeight: 2, map: m });
576
+ }
577
+ const ne = bounds.getNorthEast();
578
+ const sw = bounds.getSouthWest();
579
+ if (ne && sw && ne.equals(sw)) {
580
+ m.setCenter(ne);
581
+ log("Setting zoom to 18");
582
+ m.setZoom(18);
583
+ } else {
584
+ log("Fitting map to bounds");
585
+ m.fitBounds(bounds);
586
+ }
587
+ setTimeout(() => {
588
+ try {
589
+ google.maps.event.trigger(m, 'resize');
590
+ const ne2 = bounds.getNorthEast();
591
+ const sw2 = bounds.getSouthWest();
592
+ if (ne2 && sw2 && ne2.equals(sw2)) {
593
+ m.setCenter(ne2);
594
+ log("Setting zoom to 18 after resize");
595
+ m.setZoom(18);
596
+ } else {
597
+ log("Fitting map to bounds after resize");
598
+ m.fitBounds(bounds);
599
+ }
600
+ } catch (e) { log('Resize error', e); }
601
+ }, 120);
602
+ const closeButtons = [document.getElementById('popup-close-next'), document.getElementById('popup-close-next-footer')];
603
+ closeButtons.forEach((btn) => {
604
+ if (!btn) return;
605
+ if (!btn.dataset.bound) {
606
+ btn.addEventListener('click', () => {
607
+ const ov = document.getElementById('popup-overlay');
608
+ if (ov) ov.remove();
609
+ const nxt = document.getElementById('next_btn');
610
+ if (nxt) nxt.click();
611
+ });
612
+ btn.dataset.bound = '1';
613
+ }
614
+ });
615
+ window.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') { const ov = document.getElementById('popup-overlay'); if (ov) ov.remove(); const nxt = document.getElementById('next_btn'); if (nxt) nxt.click(); } }, { once: true });
616
+ log('Popup map initialized');
617
+ } catch (e) { log('Popup map init error', e); }
618
+ });
619
+ }
620
+ function initMapControlsIfPresent() {
621
+ const plusBtn = document.getElementById('map-size-plus');
622
+ const minusBtn = document.getElementById('map-size-minus');
623
+ const mapWrap = document.getElementById('mini-map-wrap');
624
+ if (!plusBtn || !minusBtn || !mapWrap || mapWrap.dataset.controlsInitialized) return;
625
+ const sizes = [{w: 200, h: 130}, {w: 280, h: 180}, {w: 400, h: 260}, {w: 550, h: 360}];
626
+ let currentSizeIndex = 1;
627
+ const updateSize = () => {
628
+ const newSize = sizes[currentSizeIndex];
629
+ mapWrap.style.width = newSize.w + 'px';
630
+ mapWrap.style.height = newSize.h + 'px';
631
+ if (window._miniMapInstance) {
632
+ setTimeout(() => {
633
+ google.maps.event.trigger(window._miniMapInstance, 'resize');
634
+ }, 300);
635
+ }
636
+ log('Map size changed to', newSize);
637
+ };
638
+ plusBtn.addEventListener('click', (e) => {
639
+ e.stopPropagation();
640
+ if (currentSizeIndex < sizes.length - 1) {
641
+ currentSizeIndex++;
642
+ updateSize();
643
+ }
644
+ });
645
+ minusBtn.addEventListener('click', (e) => {
646
+ e.stopPropagation();
647
+ if (currentSizeIndex > 0) {
648
+ currentSizeIndex--;
649
+ updateSize();
650
+ }
651
+ });
652
+ mapWrap.dataset.controlsInitialized = '1';
653
+ updateSize();
654
+ log('Map controls initialized');
655
+ }
656
+ const obs = new MutationObserver(() => { initMiniMapIfPresent(); initPopupIfPresent(); initMapControlsIfPresent(); });
657
+ obs.observe(document.documentElement, { childList: true, subtree: true });
658
+ initMiniMapIfPresent();
659
+ initPopupIfPresent();
660
+ initMapControlsIfPresent();
661
+ }
662
+ """.replace("__GMAPS_KEY__", GOOGLE_MAPS_API_KEY or '')
663
+
664
+ with gr.Blocks(css=APP_CSS, title="LLM GeoGuessr") as demo:
665
+ user_profile = gr.State()
666
+
667
+ with gr.Row():
668
+ gr.Markdown("## LLM GeoGuessr", elem_id="title_md")
669
+ gr.Markdown("""
670
+ ### How to Play
671
+ 1. **Select a difficulty** and click "Start Game".
672
+ 2. You'll be shown a random Street View image for 3 rounds.
673
+ 3. Place a marker on the mini-map to guess the location.
674
+ 4. Submit your guess and see how your score compares to the AI's!
675
+ """)
676
+
677
+ login_prompt_md = gr.Markdown("### Please log in with your Hugging Face account to play.", visible=True)
678
+ login_button = LoginButton(visible=True)
679
+
680
+ logged_in_md = gr.Markdown(visible=False)
681
+
682
+ with gr.Group(visible=False, elem_id="lobby_group") as lobby_group:
683
+ with gr.Row():
684
+ difficulty = gr.Dropdown(choices=["easy", "medium", "hard"], value="easy", label="Difficulty", elem_id="difficulty_select")
685
+ start_btn = gr.Button("Start Game", variant="primary", elem_id="start_btn")
686
+ limit_msg = gr.Markdown(visible=False)
687
+
688
+ with gr.Group(visible=False, elem_id="game_group") as game_group:
689
+ rounds_state = gr.State([])
690
+ idx_state = gr.State(0)
691
+ round_id_box = gr.Textbox(visible=False)
692
+ lat_box = gr.Number(visible=True, elem_id="lat_box", label="lat")
693
+ lng_box = gr.Number(visible=True, elem_id="lng_box", label="lng")
694
+ street_html = gr.HTML(visible=True)
695
+ map_html = gr.HTML(visible=False)
696
+ validate_btn = gr.Button("Validate Guess", visible=True)
697
+ result_md = gr.Markdown()
698
+ popup_html = gr.HTML()
699
+ ai_chat = gr.Textbox(label="AI Analysis", interactive=False, elem_id="ai_chat")
700
+ next_btn = gr.Button("Next", visible=True, elem_id="next_btn")
701
+ final_md = gr.Markdown(visible=True)
702
+
703
+ def on_login(token: gr.OAuthToken | None):
704
+ if not token:
705
+ return (
706
+ None,
707
+ gr.update(visible=True),
708
+ gr.update(visible=True),
709
+ gr.update(visible=False),
710
+ gr.update(visible=False),
711
+ gr.update(),
712
+ gr.update(visible=False),
713
+ )
714
+
715
+ try:
716
+ profile = whoami(token=token.token)
717
+ username = profile["name"]
718
+ todays_games = data_manager.get_todays_games(token=token.token)
719
+ has_played = data_manager.has_user_played_today(username, todays_games)
720
+ except Exception as e:
721
+ gr.Warning(f"Could not check your game status. Please try again. Error: {e}")
722
+ return (
723
+ None,
724
+ gr.update(visible=True),
725
+ gr.update(visible=True),
726
+ gr.update(visible=False),
727
+ gr.update(visible=False),
728
+ gr.update(),
729
+ gr.update(visible=False),
730
+ )
731
+
732
+ welcome_message = f"Welcome, **{profile.get('fullname', username)}**! You are logged in as **{username}**."
733
+
734
+ updates = [
735
+ profile,
736
+ gr.update(visible=False),
737
+ gr.update(visible=False),
738
+ gr.update(visible=True, value=welcome_message),
739
+ gr.update(visible=True),
740
+ ]
741
+
742
+ if has_played:
743
+ limit_message = "You have already played today. Please come back tomorrow for a new challenge!"
744
+ updates.extend([
745
+ gr.update(interactive=False),
746
+ gr.update(visible=True, value=limit_message),
747
+ ])
748
+ else:
749
+ updates.extend([
750
+ gr.update(interactive=True),
751
+ gr.update(visible=False),
752
+ ])
753
+
754
+ return tuple(updates)
755
+
756
+ # Use demo.load to set the initial UI state when the app loads with an existing token.
757
+ # This is the key fix for the UI flickering issue.
758
+ demo.load(
759
+ on_login,
760
+ outputs=[
761
+ user_profile,
762
+ login_prompt_md,
763
+ login_button,
764
+ logged_in_md,
765
+ lobby_group,
766
+ start_btn,
767
+ limit_msg,
768
+ ],
769
+ )
770
+
771
+ # The click handler is still needed to initiate the login flow if the user is not logged in.
772
+ login_button.click(
773
+ on_login,
774
+ outputs=[
775
+ user_profile,
776
+ login_prompt_md,
777
+ login_button,
778
+ logged_in_md,
779
+ lobby_group,
780
+ start_btn,
781
+ limit_msg,
782
+ ],
783
+ )
784
+
785
+ def start_click(difficulty: str, profile: dict, request: gr.Request):
786
+ if not profile:
787
+ gr.Warning("Please log in before starting the game.")
788
+ # Return no-ops for all outputs to prevent errors
789
+ return None, 0, "", "", "", "", gr.update(), gr.update(), gr.update()
790
+
791
+ r, idx, rid, s_html, m_html, err = gr_start_game(difficulty, profile["name"], request)
792
+ return (
793
+ r,
794
+ idx,
795
+ rid,
796
+ s_html,
797
+ m_html,
798
+ gr.update(value=""),
799
+ gr.update(visible=True),
800
+ gr.update(visible=False),
801
+ gr.update(value="")
802
+ )
803
+
804
+ start_btn.click(
805
+ start_click,
806
+ inputs=[difficulty, user_profile],
807
+ outputs=[rounds_state, idx_state, round_id_box, street_html, map_html, result_md, game_group, lobby_group, limit_msg]
808
+ )
809
+
810
+ def on_validate(rid, lat, lng, profile: dict, request: gr.Request):
811
+ if not profile: return
812
+ username = profile["name"]
813
+ _popup, txt, a_lat, a_lng, _score = gr_submit_guess(rid, lat, lng, username, request)
814
+ yield "", txt + "\n\n[AI] Analyzing image..."
815
+
816
+ rnd = get_round(username, rid)
817
+ if not rnd:
818
+ yield "", txt + "\n\n[Error] Round not found"
819
+ return
820
+ try:
821
+ img_resp = requests.get(rnd['image_url'])
822
+ img_resp.raise_for_status()
823
+ last_text = ""
824
+ for partial_text in llm_stream_image_text(img_resp.content):
825
+ last_text = partial_text
826
+ yield "", txt + "\n\n" + partial_text
827
+ # After stream completes, compute AI guess and score
828
+ ai_coords = extract_coords_from_text(last_text) or geocode_text_to_coords(last_text[-256:])
829
+ if ai_coords:
830
+ rnd['ai_guess'] = ai_coords
831
+ ai_dist_km = haversine_km(rnd['lat'], rnd['lng'], ai_coords['lat'], ai_coords['lng'])
832
+ rnd['ai_distance_km'] = float(ai_dist_km)
833
+ rnd['ai_score'] = score_from_distance_km(ai_dist_km)
834
+ except Exception as e:
835
+ yield "", txt + f"\n\n[Error] {e}"
836
+ return
837
+
838
+ # 3) Show popup with both guesses and update scoreboard
839
+ sess = user_sessions.get(username, {})
840
+ total_human = sum(float(r.get('human_score', 0.0)) for r in sess.get('rounds', []))
841
+ total_ai = sum(float(r.get('ai_score', 0.0)) for r in sess.get('rounds', []))
842
+ round_idx = next((i for i, rr in enumerate(sess.get('rounds', [])) if rr['id'] == rid), 0) + 1
843
+ # Escape AI analysis to display safely inside HTML
844
+ import html as _html
845
+ ai_text_safe = _html.escape(last_text or "")
846
+ summary_safe = _html.escape(txt)
847
+ scoreboard_html = (
848
+ f"<div style='margin:8px 0;padding:24px;background:#09101f;border-radius:18px;color:#f9fafb;border:1px solid rgba(148,163,184,0.35);box-shadow:0 22px 60px rgba(8,12,24,0.6);'>"
849
+ f"<div style='font-weight:700;font-size:1.25rem;margin-bottom:12px;text-shadow:0 0 12px rgba(8,12,24,0.8);'>Round {round_idx}</div>"
850
+ f"<div style='margin-bottom:18px;color:#f8fafc;font-size:1rem;line-height:1.55;text-shadow:0 0 10px rgba(8,12,24,0.75);'>{summary_safe}</div>"
851
+ f"<div style='display:flex;gap:26px;font-size:0.98rem;margin-bottom:18px;flex-wrap:wrap;'>"
852
+ f" <span><strong>Human:</strong> {rnd.get('human_score',0):.0f} pts <span style='color:#dbeafe;'>({rnd.get('human_distance_km',0.0):.1f} km)</span></span>"
853
+ f" <span><strong>AI:</strong> {rnd.get('ai_score',0):.0f} pts <span style='color:#dbeafe;'>({rnd.get('ai_distance_km',0.0):.1f} km)</span></span>"
854
+ f"</div>"
855
+ f"<div style='margin-bottom:16px;font-weight:600;font-size:1rem;color:#e2e8f0;text-shadow:0 0 10px rgba(8,12,24,0.65);'>Totals — Human {total_human:.0f} / AI {total_ai:.0f}</div>"
856
+ f"<div id='ai-analysis-box' style='background:#f1f5f9; border-radius:14px;border:1px solid rgba(148,163,184,0.45);padding:16px;max-height:280px;overflow:auto;'>"
857
+ f" <div style='margin-bottom:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.04em;'>AI Analysis</div>"
858
+ f" <pre style='margin:0;background:transparent;color:inherit;font-size:1rem;white-space:pre-wrap;line-height:1.6;font-family:\"SFMono-Regular\",Menlo,Monaco,Consolas,\"Liberation Mono\",\"Courier New\",monospace;'>" + ai_text_safe + "</pre>"
859
+ f"</div>"
860
+ f"</div>"
861
+ )
862
+
863
+ ai = rnd.get('ai_guess')
864
+ ai_lat_str = ""
865
+ ai_lng_str = ""
866
+ if ai:
867
+ ai_lat = float(ai.get('lat', 0.0))
868
+ ai_lng = float(ai.get('lng', 0.0))
869
+ ai_lat_str = str(ai_lat)
870
+ ai_lng_str = str(ai_lng)
871
+
872
+ popup_html = """
873
+ <div id="popup-overlay" style="position:fixed;inset:0;background:rgba(8,11,20,0.78);display:flex;align-items:center;justify-content:center;z-index:1000;">
874
+ <div id="popup-card" style="background:#04070f;padding:26px;border-radius:18px;width:92%;max-width:960px;color:#f9fafb;position:relative;box-shadow:0 28px 80px rgba(4,7,15,0.75);border:1px solid rgba(148,163,184,0.35);" onclick="event.stopPropagation();">
875
+ <button id="popup-close-next" style="position:absolute;top:16px;right:18px;padding:10px 18px;border-radius:999px;border:1px solid rgba(96,165,250,0.4);background:linear-gradient(135deg,#1d4ed8,#2563eb);color:#f8fafc;font-weight:600;cursor:pointer;box-shadow:0 8px 18px rgba(37,99,235,0.45);">Close & Next</button>
876
+ <h3 style="margin:0 0 20px;color:#f8fafc;text-shadow:0 0 14px rgba(37,99,235,0.35);">Round Results</h3>
877
+ __SCOREBOARD__
878
+ <div id="popup-map" data-rnd-lat="__RND_LAT__" data-rnd-lng="__RND_LNG__" data-h-lat="__H_LAT__" data-h-lng="__H_LNG__" data-ai-lat="__AI_LAT__" data-ai-lng="__AI_LNG__" style="width:100%;height:440px;border-radius:16px;overflow:hidden;border:1px solid rgba(148,163,184,0.35);box-shadow:0 16px 40px rgba(4,7,15,0.72);"></div>
879
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-top:24px;font-size:0.98rem;color:#e2e8f0;flex-wrap:wrap;gap:14px;">
880
+ <div style="display:flex;gap:16px;align-items:center;flex-wrap:wrap;">
881
+ <span style="display:flex;align-items:center;gap:8px;"><span style="display:inline-flex;width:13px;height:13px;border-radius:50%;background:#22c55e;box-shadow:0 0 8px rgba(34,197,94,0.6);"></span>G = Ground Truth</span>
882
+ <span style="display:flex;align-items:center;gap:8px;"><span style="display:inline-flex;width:13px;height:13px;border-radius:50%;background:#f97316;box-shadow:0 0 8px rgba(249,115,22,0.55);"></span>H = Human</span>
883
+ <span style="display:flex;align-items:center;gap:8px;"><span style="display:inline-flex;width:13px;height:13px;border-radius:50%;background:#2563EB;box-shadow:0 0 8px rgba(37,99,235,0.6);"></span>A = AI</span>
884
+ </div>
885
+ <button id="popup-close-next-footer" style="padding:12px 22px;border-radius:12px;border:1px solid rgba(96,165,250,0.4);background:linear-gradient(135deg,#1d4ed8,#2563eb);color:#f8fafc;font-weight:600;cursor:pointer;box-shadow:0 10px 22px rgba(37,99,235,0.48);">Next Round</button>
886
+ </div>
887
+ </div>
888
+ </div>
889
+ """
890
+ popup_html = popup_html.replace('__SCOREBOARD__', scoreboard_html) \
891
+ .replace('__RND_LAT__', str(rnd['lat'])) \
892
+ .replace('__RND_LNG__', str(rnd['lng'])) \
893
+ .replace('__H_LAT__', str(float(lat))) \
894
+ .replace('__H_LNG__', str(float(lng))) \
895
+ .replace('__AI_LAT__', ai_lat_str) \
896
+ .replace('__AI_LNG__', ai_lng_str) \
897
+ .replace('__GMAPS_KEY__', GOOGLE_MAPS_API_KEY or '')
898
+
899
+ yield popup_html, (txt + ("\n\n" + (last_text or "")))
900
+
901
+ validate_btn.click(on_validate, inputs=[round_id_box, lat_box, lng_box, user_profile], outputs=[popup_html, ai_chat])
902
+
903
+ def on_next(r_state: list, idx: int, profile: dict, request: gr.Request):
904
+ if not profile: return
905
+ username = profile["name"]
906
+ idx += 1
907
+ sess = user_sessions.get(username)
908
+ if not sess or idx >= len(sess['rounds']):
909
+ total_human = sum(float(r.get('human_score', 0.0)) for r in sess.get('rounds', []))
910
+ total_ai = sum(float(r.get('ai_score', 0.0)) for r in sess.get('rounds', []))
911
+
912
+ data_manager.record_game(username, total_human)
913
+
914
+ winner_message = "It's a tie!"
915
+ if total_human > total_ai:
916
+ winner_message = "Congratulations, you won!"
917
+ elif total_ai > total_human:
918
+ winner_message = "The AI won this time."
919
+
920
+ summary_html = f"""
921
+ <div style='text-align:center; padding: 40px; font-size: 1.2em;'>
922
+ <h2>Game Over!</h2>
923
+ <p>Here are the final scores:</p>
924
+ <p><strong>Your Score:</strong> {total_human:.0f}</p>
925
+ <p><strong>AI's Score:</strong> {total_ai:.0f}</p>
926
+ <h3>{winner_message}</h3>
927
+ </div>
928
+ """
929
+ return idx, gr.update(value=summary_html), gr.update(value=""), gr.update(value="")
930
+ r = sess['rounds'][idx]
931
+ s_html = build_street_html(r['image_url'])
932
+ return idx, gr.update(value=s_html), gr.update(value=r['id']), gr.update(value="")
933
+
934
+ next_btn.click(on_next, inputs=[rounds_state, idx_state, user_profile], outputs=[idx_state, street_html, round_id_box, popup_html])
935
+
936
+ # Inject boot JS using load(js=callable) compatible format
937
+ demo.load(fn=lambda: None, inputs=None, outputs=None, js=APP_BOOT_JS)
938
+
939
+
940
+ if __name__ == "__main__":
941
+ demo.queue().launch(server_name="0.0.0.0", server_port=7860)
data_manager.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import tempfile
4
+ from datetime import datetime, timezone
5
+ from huggingface_hub import hf_hub_download, upload_file
6
+ from huggingface_hub.utils import HfHubHTTPError
7
+
8
+ # Constant for the dataset repository, configurable via environment variable
9
+ DATASET_REPO = os.getenv("HF_DATASET_REPO", "jofthomas/geoguessr_game_of_the_day")
10
+
11
+ def get_todays_records_path() -> str:
12
+ """Gets the path for today's game records file, e.g., 'records/2025-10-03.json'."""
13
+ date_str = datetime.now(timezone.utc).strftime('%Y-%m-%d')
14
+ return f"records/{date_str}.json"
15
+
16
+ def get_todays_games(token: str) -> list:
17
+ """
18
+ Downloads and reads the game records for the current day from the HF Hub.
19
+ Returns an empty list if the file for today doesn't exist yet.
20
+ """
21
+ filepath = get_todays_records_path()
22
+ try:
23
+ # Use the provided token for read access
24
+ local_path = hf_hub_download(
25
+ repo_id=DATASET_REPO,
26
+ filename=filepath,
27
+ repo_type="dataset",
28
+ token=token,
29
+ )
30
+ with open(local_path, "r", encoding="utf-8") as f:
31
+ return json.load(f)
32
+ except HfHubHTTPError as e:
33
+ if e.response.status_code == 404:
34
+ return [] # No games played today yet, which is normal.
35
+ else:
36
+ print(f"Error downloading daily records: {e}")
37
+ raise # Re-raise other HTTP errors
38
+ except Exception as e:
39
+ print(f"An unexpected error occurred while getting today's games: {e}")
40
+ return []
41
+
42
+ def has_user_played_today(username: str, todays_games: list) -> bool:
43
+ """Checks if a user's record already exists in today's games."""
44
+ return any(game.get("username") == username for game in todays_games)
45
+
46
+ def record_game(username: str, score: float):
47
+ """
48
+ Records a completed game to the daily records file on the HF Hub.
49
+ This function reads the existing file, appends the new record, and uploads it back.
50
+ It uses the server's write token from environment variables.
51
+ """
52
+ write_token = os.getenv("HF_TOKEN", "")
53
+ if not write_token:
54
+ print("Warning: Server HF_TOKEN not set. Cannot record game score.")
55
+ return
56
+
57
+ try:
58
+ # Fetch the latest records using the write token to ensure we have the most recent data
59
+ todays_games = get_todays_games(token=write_token)
60
+
61
+ # Final check to prevent duplicate entries in case of concurrent games
62
+ if has_user_played_today(username, todays_games):
63
+ print(f"User {username} has already played today. Skipping record.")
64
+ return
65
+
66
+ todays_games.append({
67
+ "username": username,
68
+ "score": score,
69
+ "timestamp": datetime.now(timezone.utc).isoformat()
70
+ })
71
+
72
+ filepath_in_repo = get_todays_records_path()
73
+
74
+ with tempfile.NamedTemporaryFile(mode="w+", delete=False, suffix=".json", encoding="utf-8") as tmp_file:
75
+ json.dump(todays_games, tmp_file, indent=2)
76
+ tmp_file_path = tmp_file.name
77
+
78
+ upload_file(
79
+ path_or_fileobj=tmp_file_path,
80
+ path_in_repo=filepath_in_repo,
81
+ repo_id=DATASET_REPO,
82
+ repo_type="dataset",
83
+ token=write_token,
84
+ commit_message=f"Game result for {username}"
85
+ )
86
+ print(f"Successfully recorded game for {username} with score {score}")
87
+
88
+ except Exception as e:
89
+ print(f"Error recording game for {username}: {e}")
90
+ finally:
91
+ if 'tmp_file_path' in locals() and os.path.exists(tmp_file_path):
92
+ os.remove(tmp_file_path)
reference.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ import spaces
3
+ import torch
4
+ from huggingface_hub import hf_hub_download
5
+ from transformers import Mistral3ForConditionalGeneration, AutoTokenizer
6
+ from typing import Any, List, Dict
7
+ import base64
8
+ import mimetypes
9
+ from pathlib import Path
10
+
11
+ def load_system_prompt(repo_id: str, filename: str) -> dict[str, Any]:
12
+ file_path = hf_hub_download(repo_id=repo_id, filename=filename)
13
+ with open(file_path, "r") as file:
14
+ system_prompt = file.read()
15
+
16
+ index_begin_think = system_prompt.find("[THINK]")
17
+ index_end_think = system_prompt.find("[/THINK]")
18
+
19
+ return {
20
+ "role": "system",
21
+ "content": [
22
+ {"type": "text", "text": system_prompt[:index_begin_think]},
23
+ {
24
+ "type": "text",
25
+ "text": system_prompt[index_end_think + len("[/THINK]") :],
26
+ },
27
+ ],
28
+ }
29
+
30
+ model_id = "mistralai/Magistral-Small-2509"
31
+ tokenizer = AutoTokenizer.from_pretrained(model_id, tokenizer_type="mistral")
32
+ model = Mistral3ForConditionalGeneration.from_pretrained(
33
+ model_id, torch_dtype=torch.bfloat16, device_map="auto"
34
+ ).eval()
35
+
36
+
37
+ SYSTEM_PROMPT = load_system_prompt(model_id, "SYSTEM_PROMPT.txt")
38
+
39
+ @spaces.GPU(duration=120)
40
+ def predict(message: dict, history: list) -> str:
41
+ # Build messages for the model from history
42
+ messages = [SYSTEM_PROMPT]
43
+ for user_msg, assistant_msg in history:
44
+ messages.append({"role": "user", "content": [{"type": "text", "text": user_msg}]})
45
+ if assistant_msg:
46
+ messages.append({"role": "assistant", "content": [{"type": "text", "text": assistant_msg}]})
47
+
48
+ # Process current user message (with potential image)
49
+ user_content = [{"type": "text", "text": message['text']}]
50
+ if message['files']:
51
+ # Assuming one image file from multimodal textbox
52
+ image_path = Path(message['files'][0])
53
+ image_bytes = image_path.read_bytes()
54
+ encoded_image = base64.b64encode(image_bytes).decode("utf-8")
55
+ mime_type, _ = mimetypes.guess_type(image_path)
56
+ if mime_type is None:
57
+ mime_type = "image/png"
58
+ data_url = f"data:{mime_type};base64,{encoded_image}"
59
+ user_content.append({"type": "image_url", "image_url": {"url": data_url}})
60
+
61
+ messages.append({"role": "user", "content": user_content})
62
+
63
+ tokenized = tokenizer.apply_chat_template(messages, return_dict=True)
64
+
65
+ input_ids = torch.tensor(tokenized.input_ids, device="cuda").unsqueeze(0)
66
+ attention_mask = torch.tensor(tokenized.attention_mask, device="cuda").unsqueeze(0)
67
+
68
+ if 'pixel_values' in tokenized and len(tokenized.pixel_values) > 0:
69
+ pixel_values = torch.tensor(
70
+ tokenized.pixel_values[0], dtype=torch.bfloat16, device="cuda"
71
+ ).unsqueeze(0)
72
+ image_sizes = torch.tensor(pixel_values.shape[-2:], device="cuda").unsqueeze(0)
73
+ output = model.generate(
74
+ input_ids=input_ids,
75
+ attention_mask=attention_mask,
76
+ pixel_values=pixel_values,
77
+ image_sizes=image_sizes,
78
+ )[0]
79
+ else:
80
+ output = model.generate(
81
+ input_ids=input_ids,
82
+ attention_mask=attention_mask,
83
+ )[0]
84
+
85
+ decoded_output = tokenizer.decode(
86
+ output[
87
+ len(tokenized.input_ids) : (
88
+ -1 if output[-1] == tokenizer.eos_token_id else len(output)
89
+ )
90
+ ]
91
+ )
92
+ return decoded_output
93
+
94
+ demo = gr.ChatInterface(
95
+ fn=predict,
96
+ multimodal=True,
97
+ title="Magistral Chat App",
98
+ description='Chat with Magistral AI. Upload an image if relevant to your question.<br>Built with <a href="https://huggingface.co/spaces/akhaliq/anycoder" target="_blank">anycoder</a>',
99
+ )
100
+
101
+ if __name__ == "__main__":
102
+ demo.launch()
requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Flask
2
+ python-dotenv
3
+ requests
4
+ mcp[cli]
5
+ huggingface_hub
6
+ transformers[mistral-common]
7
+ gradio
8
+ torch
9
+ sentencepiece
10
+ spaces
11
+ accelerate
12
+ tokenizers
static/admin.js ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ let map;
2
+ let drawingManager;
3
+ let lastDrawnShape = null;
4
+ let displayedShapes = [];
5
+
6
+ const difficultyColors = {
7
+ easy: '#34A853', // Green
8
+ medium: '#F9AB00', // Yellow
9
+ hard: '#EA4335' // Red
10
+ };
11
+
12
+ function initAdminMap() {
13
+ map = new google.maps.Map(document.getElementById('map'), {
14
+ center: { lat: 20, lng: 0 },
15
+ zoom: 2,
16
+ });
17
+
18
+ drawingManager = new google.maps.drawing.DrawingManager({
19
+ drawingMode: google.maps.drawing.OverlayType.RECTANGLE,
20
+ drawingControl: true,
21
+ drawingControlOptions: {
22
+ position: google.maps.ControlPosition.TOP_CENTER,
23
+ drawingModes: [google.maps.drawing.OverlayType.RECTANGLE],
24
+ },
25
+ rectangleOptions: {
26
+ fillColor: '#F97316',
27
+ fillOpacity: 0.3,
28
+ strokeWeight: 1,
29
+ clickable: true,
30
+ editable: true,
31
+ zIndex: 1,
32
+ },
33
+ });
34
+
35
+ drawingManager.setMap(map);
36
+
37
+ google.maps.event.addListener(drawingManager, 'overlaycomplete', function(event) {
38
+ if (lastDrawnShape) {
39
+ lastDrawnShape.setMap(null);
40
+ }
41
+ lastDrawnShape = event.overlay;
42
+ drawingManager.setDrawingMode(null); // Exit drawing mode
43
+ document.getElementById('save-zone').disabled = false;
44
+ });
45
+
46
+ document.getElementById('save-zone').addEventListener('click', saveLastZone);
47
+ document.getElementById('new-zone-btn').addEventListener('click', () => {
48
+ drawingManager.setDrawingMode(google.maps.drawing.OverlayType.RECTANGLE);
49
+ document.getElementById('save-zone').disabled = true;
50
+ });
51
+
52
+ loadExistingZones();
53
+ }
54
+
55
+ function saveLastZone() {
56
+ if (!lastDrawnShape) {
57
+ alert('Please draw a zone first.');
58
+ return;
59
+ }
60
+
61
+ const difficulty = document.getElementById('difficulty-select').value;
62
+ const bounds = lastDrawnShape.getBounds().toJSON();
63
+
64
+ const zoneData = {
65
+ type: 'rectangle',
66
+ bounds: bounds,
67
+ };
68
+
69
+ fetch('/api/zones', {
70
+ method: 'POST',
71
+ headers: { 'Content-Type': 'application/json' },
72
+ body: JSON.stringify({ difficulty: difficulty, zone: zoneData }),
73
+ })
74
+ .then(response => response.json())
75
+ .then(data => {
76
+ const statusMsg = document.getElementById('status-message');
77
+ statusMsg.textContent = data.message || `Error: ${data.error}`;
78
+ setTimeout(() => statusMsg.textContent = '', 3000);
79
+
80
+ // Clean up the drawn shape and reload all zones to get the new one with its listener
81
+ if (lastDrawnShape) {
82
+ lastDrawnShape.setMap(null);
83
+ lastDrawnShape = null;
84
+ }
85
+ document.getElementById('save-zone').disabled = true;
86
+ loadExistingZones();
87
+ });
88
+ }
89
+
90
+ function loadExistingZones() {
91
+ // Clear existing shapes from the map
92
+ displayedShapes.forEach(shape => shape.setMap(null));
93
+ displayedShapes = [];
94
+
95
+ fetch('/api/zones')
96
+ .then(response => response.json())
97
+ .then(zones => {
98
+ for (const difficulty in zones) {
99
+ zones[difficulty].forEach(zone => {
100
+ if (zone.type === 'rectangle') {
101
+ const rectangle = new google.maps.Rectangle({
102
+ bounds: zone.bounds,
103
+ map: map,
104
+ fillColor: difficultyColors[difficulty],
105
+ fillOpacity: 0.35,
106
+ strokeColor: difficultyColors[difficulty],
107
+ strokeWeight: 2,
108
+ editable: false,
109
+ clickable: true,
110
+ });
111
+
112
+ rectangle.zoneId = zone.id;
113
+
114
+ google.maps.event.addListener(rectangle, 'click', function() {
115
+ if (confirm('Are you sure you want to delete this zone?')) {
116
+ deleteZone(this.zoneId, this);
117
+ }
118
+ });
119
+
120
+ displayedShapes.push(rectangle);
121
+ }
122
+ });
123
+ }
124
+ });
125
+ }
126
+
127
+ function deleteZone(zoneId, shape) {
128
+ fetch('/api/zones', {
129
+ method: 'DELETE',
130
+ headers: { 'Content-Type': 'application/json' },
131
+ body: JSON.stringify({ zone_id: zoneId })
132
+ })
133
+ .then(response => response.json())
134
+ .then(data => {
135
+ const statusMsg = document.getElementById('status-message');
136
+ statusMsg.textContent = data.message || `Error: ${data.error}`;
137
+ setTimeout(() => statusMsg.textContent = '', 3000);
138
+
139
+ if (data.message) {
140
+ shape.setMap(null);
141
+ }
142
+ });
143
+ }
static/script.js ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ let miniMap, guessMarker, currentRounds = [], currentRoundIdx = 0, aiEventSource = null;
2
+
3
+ function initIndexApp() {
4
+ hydrateAuth();
5
+ document.getElementById('start-btn').addEventListener('click', async () => {
6
+ const difficulty = document.getElementById('difficulty-select-lobby').value;
7
+ const url = new URL(window.location.origin + '/game');
8
+ url.searchParams.set('difficulty', difficulty);
9
+ window.location.href = url.toString();
10
+ });
11
+ }
12
+
13
+ async function initGameApp() {
14
+ const params = new URLSearchParams(window.location.search);
15
+ const difficulty = params.get('difficulty') || 'easy';
16
+ const res = await fetch('/api/start', {
17
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ difficulty })
18
+ });
19
+ const data = await res.json();
20
+ if (!res.ok) { alert(data.error || 'Failed to start game'); window.location.href = '/'; return; }
21
+ currentRounds = data.rounds;
22
+ currentRoundIdx = 0;
23
+ document.getElementById('game-container').style.display = 'block';
24
+ document.getElementById('chat-container').style.display = 'none';
25
+ document.getElementById('validate-btn').addEventListener('click', validateGuess);
26
+ document.getElementById('next-round-btn').addEventListener('click', nextRound);
27
+ const wrapEl = document.getElementById('mini-map-wrap');
28
+ document.getElementById('map-size-plus').addEventListener('click', (e) => { e.stopPropagation(); resizeMiniMap(wrapEl, 1); });
29
+ document.getElementById('map-size-minus').addEventListener('click', (e) => { e.stopPropagation(); resizeMiniMap(wrapEl, -1); });
30
+ showRound();
31
+ }
32
+
33
+ async function hydrateAuth() {
34
+ const res = await fetch('/api/me');
35
+ const me = await res.json();
36
+ const signedin = document.getElementById('signedin');
37
+ const signinBtn = document.getElementById('signin-btn');
38
+ const startBtn = document.getElementById('start-btn');
39
+ const limitMsg = document.getElementById('limit-msg');
40
+ if (me.authenticated) {
41
+ signinBtn.style.display = 'none';
42
+ signedin.style.display = 'block';
43
+ document.getElementById('username').textContent = me.username;
44
+ if (me.can_play_today) {
45
+ startBtn.disabled = false;
46
+ limitMsg.style.display = 'none';
47
+ } else {
48
+ startBtn.disabled = true;
49
+ limitMsg.style.display = 'block';
50
+ limitMsg.textContent = `You already played today. Try again in ${formatCountdown(me.seconds_until_midnight)}.`;
51
+ startCountdown(limitMsg, me.seconds_until_midnight);
52
+ }
53
+ } else {
54
+ signinBtn.style.display = 'inline-block';
55
+ signedin.style.display = 'none';
56
+ startBtn.disabled = true;
57
+ }
58
+ }
59
+
60
+ function formatCountdown(seconds) {
61
+ const h = Math.floor(seconds / 3600);
62
+ const m = Math.floor((seconds % 3600) / 60);
63
+ const s = seconds % 60;
64
+ return `${pad(h)}:${pad(m)}:${pad(s)}`;
65
+ }
66
+
67
+ function pad(n) { return n.toString().padStart(2, '0'); }
68
+
69
+ function startCountdown(el, seconds) {
70
+ let remaining = seconds;
71
+ const id = setInterval(() => {
72
+ remaining -= 1;
73
+ if (remaining <= 0) { clearInterval(id); location.reload(); return; }
74
+ el.textContent = `You already played today. Try again in ${formatCountdown(remaining)}.`;
75
+ }, 1000);
76
+ }
77
+
78
+ async function startGame() {
79
+ const difficulty = document.getElementById('difficulty-select-lobby').value;
80
+ const res = await fetch('/api/start', {
81
+ method: 'POST',
82
+ headers: { 'Content-Type': 'application/json' },
83
+ body: JSON.stringify({ difficulty })
84
+ });
85
+ const data = await res.json();
86
+ if (!res.ok) {
87
+ alert(data.error || 'Failed to start game');
88
+ return;
89
+ }
90
+ currentRounds = data.rounds;
91
+ currentRoundIdx = 0;
92
+ document.getElementById('game-container').style.display = 'block';
93
+ document.getElementById('chat-container').style.display = 'none';
94
+ showRound();
95
+ }
96
+
97
+ function showRound() {
98
+ const round = currentRounds[currentRoundIdx];
99
+ const img = document.getElementById('street-image');
100
+ img.src = round.image_url;
101
+ document.getElementById('validate-btn').style.display = 'none';
102
+ initMiniMap();
103
+ }
104
+
105
+ function initMiniMap() {
106
+ const miniEl = document.getElementById('mini-map');
107
+ miniMap = new google.maps.Map(miniEl, {
108
+ center: { lat: 0, lng: 0 },
109
+ zoom: 1,
110
+ streetViewControl: false,
111
+ mapTypeControl: false,
112
+ fullscreenControl: false,
113
+ });
114
+ miniMap.addListener('click', (e) => {
115
+ if (miniMap._locked) return;
116
+ placeGuessMarker(e.latLng);
117
+ document.getElementById('validate-btn').style.display = 'inline-block';
118
+ });
119
+ }
120
+
121
+ function resizeMiniMap(el, delta) {
122
+ const sizes = ['size-small', 'size-medium', 'size-large'];
123
+ let idx = sizes.findIndex(c => el.classList.contains(c));
124
+ if (idx === -1) idx = 1;
125
+ idx = Math.min(2, Math.max(0, idx + (delta > 0 ? 1 : -1)));
126
+ sizes.forEach(c => el.classList.remove(c));
127
+ el.classList.add(sizes[idx]);
128
+ if (miniMap) {
129
+ const miniEl = document.getElementById('mini-map');
130
+ google.maps.event.trigger(miniMap, 'resize');
131
+ const center = miniMap.getCenter();
132
+ miniMap.setCenter(center);
133
+ }
134
+ }
135
+
136
+ function placeGuessMarker(latLng) {
137
+ if (guessMarker) guessMarker.setMap(null);
138
+ guessMarker = new google.maps.Marker({ position: latLng, map: miniMap });
139
+ miniMap.setCenter(latLng);
140
+ }
141
+
142
+ async function validateGuess() {
143
+ if (!guessMarker) { alert('Click on the mini-map to pick your guess.'); return; }
144
+ const round = currentRounds[currentRoundIdx];
145
+ const pos = guessMarker.getPosition();
146
+ const payload = { round_id: round.id, lat: pos.lat(), lng: pos.lng() };
147
+ const res = await fetch('/api/guess', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
148
+ const result = await res.json();
149
+ if (!res.ok) { alert(result.error || 'Guess failed'); return; }
150
+ miniMap._locked = true;
151
+ document.getElementById('validate-btn').style.display = 'none';
152
+ document.getElementById('chat-container').style.display = 'block';
153
+ const chat = document.getElementById('chat-log');
154
+ chat.textContent = '';
155
+ chat.textContent += 'Analyzing image...\n';
156
+ const aiRes = await fetch('/api/ai_analyze', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ round_id: round.id }) });
157
+ const aiData = await aiRes.json();
158
+ if (!aiRes.ok) {
159
+ chat.textContent += `[Error] ${aiData.error || 'AI failed'}\n`;
160
+ document.getElementById('next-round-btn').disabled = false;
161
+ openResultsPopup({ actual: result.actual_location, human: result.guess_location });
162
+ return;
163
+ }
164
+ chat.textContent += (aiData.text || '') + '\n';
165
+ openResultsPopup({ actual: result.actual_location, human: result.guess_location });
166
+ if (aiData.ai_guess) renderAIGuessOnPopup(aiData.ai_guess);
167
+ document.getElementById('next-round-btn').disabled = false;
168
+ }
169
+
170
+ function openResultsPopup(points) {
171
+ const overlay = document.getElementById('popup-overlay');
172
+ overlay.style.display = 'flex';
173
+ const popupMap = new google.maps.Map(document.getElementById('popup-map'), { zoom: 3, center: points.actual });
174
+ new google.maps.Marker({ position: points.actual, map: popupMap, label: 'A' });
175
+ new google.maps.Marker({ position: points.human, map: popupMap, label: 'H' });
176
+ new google.maps.Polyline({ path: [points.actual, points.human], geodesic: true, strokeColor: '#F97316', strokeOpacity: 1.0, strokeWeight: 2, map: popupMap });
177
+ document.getElementById('next-round-btn').disabled = true;
178
+ }
179
+
180
+ function appendChatToken(t) {
181
+ const el = document.getElementById('chat-log');
182
+ const span = document.createElement('span');
183
+ span.textContent = t;
184
+ el.appendChild(span);
185
+ el.scrollTop = el.scrollHeight;
186
+ }
187
+
188
+ function renderAIGuessOnPopup(ai) {
189
+ const mapEl = document.getElementById('popup-map');
190
+ const popupMap = mapEl._map || new google.maps.Map(mapEl, { zoom: 3, center: ai });
191
+ mapEl._map = popupMap;
192
+ new google.maps.Marker({ position: ai, map: popupMap, label: 'AI' });
193
+ }
194
+
195
+ async function nextRound() {
196
+ if (aiEventSource) { try { aiEventSource.close(); } catch (e) {} }
197
+ currentRoundIdx += 1;
198
+ if (currentRoundIdx >= currentRounds.length) {
199
+ await showFinalResults();
200
+ return;
201
+ }
202
+ document.getElementById('popup-overlay').style.display = 'none';
203
+ showRound();
204
+ }
205
+
206
+ async function showFinalResults() {
207
+ const res = await fetch('/api/session_summary');
208
+ const data = await res.json();
209
+ document.getElementById('popup-overlay').style.display = 'none';
210
+ document.getElementById('game-container').style.display = 'none';
211
+ const fin = document.getElementById('final-results');
212
+ fin.style.display = 'block';
213
+ const total = data.total_score?.toFixed ? data.total_score.toFixed(0) : data.total_score;
214
+ document.getElementById('final-summary').textContent = `Your total score: ${total} / 15000`;
215
+ }
216
+
217
+ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
static/style.css ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ }
templates/admin.html ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Admin Panel - LLM GeoGuessr</title>
5
+ <link rel="stylesheet" href="{{ url_for('static', filename='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', filename='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>
templates/game.html ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Play - LLM GeoGuessr</title>
5
+ <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
6
+ <style>
7
+ #image-container { position: relative; max-width: 960px; margin: 0 auto; }
8
+ #street-image { width: 100%; height: auto; border-radius: var(--border-radius); box-shadow: 0 4px 6px rgba(0,0,0,0.1); user-select: none; }
9
+ #mini-map-wrap { position: absolute; right: 10px; bottom: 10px; border: 2px solid #fff; border-radius: var(--border-radius); box-shadow: 0 2px 8px rgba(0,0,0,0.2); background: #fff; }
10
+ #mini-map-wrap.size-small { width: 220px; height: 140px; }
11
+ #mini-map-wrap.size-medium { width: 280px; height: 180px; }
12
+ #mini-map-wrap.size-large { width: 420px; height: 270px; }
13
+ #mini-map { width: 100%; height: 100%; cursor: pointer; }
14
+ #map-controls { position: absolute; right: 8px; top: 8px; display: flex; flex-direction: row; gap: 6px; z-index: 5; }
15
+ .map-ctrl { width: 34px; height: 28px; border-radius: 6px; border: 1px solid rgba(0,0,0,0.2); background: #202124; color: #fff; box-shadow: 0 1px 4px rgba(0,0,0,0.25); cursor: pointer; font-weight: 600; line-height: 26px; }
16
+ .map-ctrl:hover { background: #3c4043; }
17
+ #validate-btn { position: absolute; left: 50%; transform: translateX(-50%); bottom: 10px; display: none; }
18
+ #chat-container { position: absolute; left: 10px; bottom: 10px; width: 360px; max-height: 45%; background: rgba(255,255,255,0.95); border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.2); padding: 12px; overflow: hidden; z-index: 20; }
19
+ #chat-log { overflow-y: auto; max-height: calc(45vh - 64px); white-space: pre-wrap; font-size: 0.95rem; line-height: 1.3; }
20
+ #popup-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: none; align-items: center; justify-content: center; z-index: 1000; }
21
+ #popup-card { background: white; padding: 20px; border-radius: var(--border-radius); width: 90%; max-width: 900px; }
22
+ #popup-map { width: 100%; height: 420px; border-radius: var(--border-radius); }
23
+ </style>
24
+ </head>
25
+ <body>
26
+ <h1>Play</h1>
27
+ <div id="game-container">
28
+ <div id="image-container">
29
+ <img id="street-image" alt="Street View" />
30
+ <div id="mini-map-wrap" class="size-medium" title="Click to place your guess">
31
+ <div id="mini-map"></div>
32
+ <div id="map-controls">
33
+ <button id="map-size-minus" class="map-ctrl">−</button>
34
+ <button id="map-size-plus" class="map-ctrl">+</button>
35
+ </div>
36
+ </div>
37
+ <button id="validate-btn">Validate Guess</button>
38
+ </div>
39
+ <div id="chat-container" style="display:none;">
40
+ <h3>AI Analysis</h3>
41
+ <div id="chat-log"></div>
42
+ </div>
43
+ </div>
44
+
45
+ <div id="popup-overlay">
46
+ <div id="popup-card">
47
+ <h3>Round Results</h3>
48
+ <div id="popup-map"></div>
49
+ <div style="display:flex; gap:10px; justify-content:flex-end; margin-top:12px;">
50
+ <button id="next-round-btn">Next</button>
51
+ </div>
52
+ </div>
53
+ </div>
54
+
55
+ <div id="final-results" style="display:none;" class="muted">
56
+ <h2>Game Complete</h2>
57
+ <p id="final-summary"></p>
58
+ <a href="/"><button>Back to Home</button></a>
59
+ </div>
60
+
61
+ <script src="{{ url_for('static', filename='script.js') }}"> </script>
62
+ <script async defer src="https://maps.googleapis.com/maps/api/js?key={{ google_maps_api_key }}&callback=initGameApp"></script>
63
+ </body>
64
+ </html>
65
+
templates/index.html ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>LLM GeoGuessr</title>
5
+ <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
6
+ <style>
7
+ #auth-card, #gate-card { max-width: 560px; margin: 20px auto; background: white; padding: 24px; border-radius: var(--border-radius); box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
8
+ .muted { color: #666; font-size: 0.95rem; }
9
+ </style>
10
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
11
+ <meta name="referrer" content="no-referrer" />
12
+ <meta http-equiv="Cross-Origin-Opener-Policy" content="same-origin" />
13
+ <meta http-equiv="Cross-Origin-Embedder-Policy" content="require-corp" />
14
+ <meta http-equiv="X-Content-Type-Options" content="nosniff" />
15
+ <meta http-equiv="X-Frame-Options" content="DENY" />
16
+ <meta http-equiv="Referrer-Policy" content="strict-origin-when-cross-origin" />
17
+ <meta http-equiv="Cache-Control" content="no-store" />
18
+ <meta http-equiv="Pragma" content="no-cache" />
19
+ <meta http-equiv="Expires" content="0" />
20
+ <meta name="google-maps-api-key" content="{{ google_maps_api_key }}" />
21
+ <meta name="description" content="Daily GeoGuessr-style challenge with LLM rival" />
22
+ <meta name="theme-color" content="#F97316" />
23
+ <link rel="preconnect" href="https://maps.googleapis.com" crossorigin>
24
+ <link rel="preconnect" href="https://maps.gstatic.com" crossorigin>
25
+ <link rel="dns-prefetch" href="https://maps.googleapis.com">
26
+ <link rel="dns-prefetch" href="https://maps.gstatic.com">
27
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>🗺️</text></svg>">
28
+ <meta name="robots" content="noindex, nofollow">
29
+ </head>
30
+ <body>
31
+ <h1>LLM GeoGuessr</h1>
32
+
33
+ <div id="auth-card">
34
+ <div id="auth-section">
35
+ <p class="muted">Sign in to play today’s single round set.</p>
36
+ <a id="signin-btn" href="/login"><button>Sign in with Hugging Face</button></a>
37
+ <div id="signedin" style="display:none;">
38
+ <p>Signed in as <strong id="username"></strong></p>
39
+ <a href="/logout"><button>Sign out</button></a>
40
+ </div>
41
+ </div>
42
+ </div>
43
+
44
+ <div id="gate-card">
45
+ <div id="play-gate">
46
+ <label for="difficulty-select-lobby">Choose a difficulty:</label>
47
+ <select id="difficulty-select-lobby">
48
+ <option value="easy">Easy</option>
49
+ <option value="medium">Medium</option>
50
+ <option value="hard">Hard</option>
51
+ </select>
52
+ <button id="start-btn" disabled>Start Today’s Game</button>
53
+ <p id="limit-msg" class="muted" style="display:none;"></p>
54
+ </div>
55
+ </div>
56
+
57
+ <script src="{{ url_for('static', filename='script.js') }}"></script>
58
+ <script async defer src="https://maps.googleapis.com/maps/api/js?key={{ google_maps_api_key }}&callback=initIndexApp"></script>
59
+ </body>
60
+ </html>
zones.json ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ }