Upload 4 files
Browse files- Dockerfile +26 -0
- app.py +289 -0
- requirements.txt +5 -0
- zones.json +66 -0
Dockerfile
ADDED
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Use an official Python runtime as a parent image
|
2 |
+
FROM python:3.9-slim
|
3 |
+
|
4 |
+
# Set the working directory in the container
|
5 |
+
WORKDIR /app
|
6 |
+
|
7 |
+
# Copy the requirements file into the container at /app
|
8 |
+
COPY requirements.txt .
|
9 |
+
|
10 |
+
# Install any needed packages specified in requirements.txt
|
11 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
12 |
+
|
13 |
+
# Copy the rest of the application's code into the container at /app
|
14 |
+
COPY . .
|
15 |
+
|
16 |
+
# Make port 7860 available to the world outside this container
|
17 |
+
# Hugging Face Spaces uses this port by default
|
18 |
+
EXPOSE 7860
|
19 |
+
|
20 |
+
# Define environment variable for the port
|
21 |
+
ENV PORT=7860
|
22 |
+
|
23 |
+
# Run app.py when the container launches using Gunicorn
|
24 |
+
# Gunicorn is a production-ready web server.
|
25 |
+
# We bind to 0.0.0.0 to allow external connections.
|
26 |
+
CMD ["gunicorn", "--bind", "0.0.0.0:7860", "--workers", "1", "app:app"]
|
app.py
ADDED
@@ -0,0 +1,289 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import random
|
3 |
+
import json
|
4 |
+
import uuid
|
5 |
+
import requests
|
6 |
+
import base64
|
7 |
+
from flask import Flask, jsonify, render_template, request
|
8 |
+
from dotenv import load_dotenv
|
9 |
+
|
10 |
+
load_dotenv()
|
11 |
+
|
12 |
+
app = Flask(__name__)
|
13 |
+
app.config['SECRET_KEY'] = os.urandom(24)
|
14 |
+
|
15 |
+
# In-memory "database"
|
16 |
+
ZONES_FILE = 'zones.json'
|
17 |
+
games = {}
|
18 |
+
zones = {
|
19 |
+
"easy": [],
|
20 |
+
"medium": [],
|
21 |
+
"hard": []
|
22 |
+
}
|
23 |
+
|
24 |
+
# --- Zone Persistence Functions ---
|
25 |
+
def save_zones_to_file():
|
26 |
+
with open(ZONES_FILE, 'w') as f:
|
27 |
+
json.dump(zones, f, indent=4)
|
28 |
+
|
29 |
+
def load_zones_from_file():
|
30 |
+
global zones
|
31 |
+
if os.path.exists(ZONES_FILE):
|
32 |
+
try:
|
33 |
+
with open(ZONES_FILE, 'r') as f:
|
34 |
+
loaded_zones = json.load(f)
|
35 |
+
|
36 |
+
# Basic format validation
|
37 |
+
if not (isinstance(loaded_zones, dict) and all(k in loaded_zones for k in ["easy", "medium", "hard"])):
|
38 |
+
raise ValueError("Invalid format")
|
39 |
+
|
40 |
+
migrated = False
|
41 |
+
for difficulty in loaded_zones:
|
42 |
+
for zone in loaded_zones[difficulty]:
|
43 |
+
# Assign ID if missing
|
44 |
+
if 'id' not in zone:
|
45 |
+
zone['id'] = uuid.uuid4().hex
|
46 |
+
migrated = True
|
47 |
+
|
48 |
+
zones = loaded_zones
|
49 |
+
print(zones)
|
50 |
+
if migrated:
|
51 |
+
print("Info: Migrated old zone data by adding unique IDs.")
|
52 |
+
save_zones_to_file()
|
53 |
+
|
54 |
+
except (json.JSONDecodeError, IOError, ValueError):
|
55 |
+
print(f"Warning: '{ZONES_FILE}' is corrupted or invalid. Recreating with empty zones.")
|
56 |
+
save_zones_to_file() # This creates a fresh, empty, and valid file
|
57 |
+
else:
|
58 |
+
# If file doesn't exist, create an empty one.
|
59 |
+
save_zones_to_file()
|
60 |
+
|
61 |
+
# Predefined locations for the game, used as a fallback
|
62 |
+
LOCATIONS = [
|
63 |
+
{'lat': 48.85824, 'lng': 2.2945}, # Eiffel Tower, Paris
|
64 |
+
{'lat': 40.748440, 'lng': -73.985664}, # Empire State Building, New York
|
65 |
+
{'lat': 35.689487, 'lng': 139.691711}, # Tokyo, Japan
|
66 |
+
{'lat': -33.856784, 'lng': 151.215297} # Sydney Opera House, Australia
|
67 |
+
]
|
68 |
+
|
69 |
+
def generate_game_id():
|
70 |
+
return ''.join(random.choices('abcdefghijklmnopqrstuvwxyz0123456789', k=10))
|
71 |
+
|
72 |
+
@app.route('/')
|
73 |
+
def index():
|
74 |
+
google_maps_api_key = os.getenv('GOOGLE_MAPS_API_KEY')
|
75 |
+
if not google_maps_api_key:
|
76 |
+
return "Error: GOOGLE_MAPS_API_KEY not set. Please set it in a .env file.", 500
|
77 |
+
return render_template('index.html', google_maps_api_key=google_maps_api_key)
|
78 |
+
|
79 |
+
@app.route('/admin')
|
80 |
+
def admin():
|
81 |
+
google_maps_api_key = os.getenv('GOOGLE_MAPS_API_KEY')
|
82 |
+
if not google_maps_api_key:
|
83 |
+
return "Error: GOOGLE_MAPS_API_KEY not set. Please set it in a .env file.", 500
|
84 |
+
return render_template('admin.html', google_maps_api_key=google_maps_api_key)
|
85 |
+
|
86 |
+
@app.route('/api/zones', methods=['GET', 'POST', 'DELETE'])
|
87 |
+
def handle_zones():
|
88 |
+
if request.method == 'POST':
|
89 |
+
data = request.json
|
90 |
+
difficulty = data.get('difficulty')
|
91 |
+
zone_data = data.get('zone')
|
92 |
+
if difficulty and zone_data and difficulty in zones:
|
93 |
+
zone_data['id'] = uuid.uuid4().hex
|
94 |
+
zones[difficulty].append(zone_data)
|
95 |
+
save_zones_to_file()
|
96 |
+
return jsonify({'message': 'Zone saved successfully'}), 201
|
97 |
+
return jsonify({'error': 'Invalid data'}), 400
|
98 |
+
|
99 |
+
if request.method == 'DELETE':
|
100 |
+
data = request.json
|
101 |
+
zone_id = data.get('zone_id')
|
102 |
+
if not zone_id:
|
103 |
+
return jsonify({'error': 'Zone ID is required'}), 400
|
104 |
+
|
105 |
+
for difficulty in zones:
|
106 |
+
zones[difficulty] = [z for z in zones[difficulty] if z.get('id') != zone_id]
|
107 |
+
|
108 |
+
save_zones_to_file()
|
109 |
+
return jsonify({'message': 'Zone deleted successfully'})
|
110 |
+
|
111 |
+
# GET request
|
112 |
+
return jsonify(zones)
|
113 |
+
|
114 |
+
@app.route('/start_game', methods=['POST'])
|
115 |
+
def start_game():
|
116 |
+
data = request.json or {}
|
117 |
+
difficulty = data.get('difficulty', 'easy')
|
118 |
+
|
119 |
+
start_location = None
|
120 |
+
|
121 |
+
if difficulty in zones and zones[difficulty]:
|
122 |
+
selected_zone_list = zones[difficulty]
|
123 |
+
print("selected_zone_list", selected_zone_list)
|
124 |
+
selected_zone = random.choice(selected_zone_list)
|
125 |
+
print("selected_zone", selected_zone)
|
126 |
+
|
127 |
+
if selected_zone['type'] == 'rectangle':
|
128 |
+
bounds = selected_zone['bounds']
|
129 |
+
north, south, east, west = bounds['north'], bounds['south'], bounds['east'], bounds['west']
|
130 |
+
|
131 |
+
# Handle antimeridian crossing
|
132 |
+
if west > east:
|
133 |
+
east += 360
|
134 |
+
|
135 |
+
rand_lng = random.uniform(west, east)
|
136 |
+
if rand_lng > 180:
|
137 |
+
rand_lng -= 360
|
138 |
+
|
139 |
+
rand_lat = random.uniform(south, north)
|
140 |
+
start_location = {'lat': rand_lat, 'lng': rand_lng}
|
141 |
+
|
142 |
+
# Fallback to predefined locations if no zones are defined
|
143 |
+
if not start_location:
|
144 |
+
start_location = random.choice(LOCATIONS)
|
145 |
+
|
146 |
+
game_id = generate_game_id()
|
147 |
+
games[game_id] = {
|
148 |
+
'start_location': start_location,
|
149 |
+
'current_location': start_location,
|
150 |
+
'guesses': [],
|
151 |
+
'moves': 0,
|
152 |
+
'actions': [],
|
153 |
+
'game_over': False
|
154 |
+
}
|
155 |
+
|
156 |
+
google_maps_api_key = os.getenv('GOOGLE_MAPS_API_KEY')
|
157 |
+
|
158 |
+
# Fetch Street View image
|
159 |
+
streetview_image = None
|
160 |
+
compass_heading = random.randint(0, 359) # Random compass direction
|
161 |
+
if google_maps_api_key:
|
162 |
+
try:
|
163 |
+
lat, lng = start_location['lat'], start_location['lng']
|
164 |
+
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}"
|
165 |
+
|
166 |
+
response = requests.get(streetview_url)
|
167 |
+
if response.status_code == 200:
|
168 |
+
# Convert image to base64
|
169 |
+
streetview_image = base64.b64encode(response.content).decode('utf-8')
|
170 |
+
except Exception as e:
|
171 |
+
print(f"Error fetching Street View image: {e}")
|
172 |
+
|
173 |
+
return jsonify({
|
174 |
+
'game_id': game_id,
|
175 |
+
'streetview_image': streetview_image,
|
176 |
+
'compass_heading': compass_heading
|
177 |
+
})
|
178 |
+
|
179 |
+
@app.route('/game/<game_id>/state', methods=['GET'])
|
180 |
+
def get_game_state(game_id):
|
181 |
+
game = games.get(game_id)
|
182 |
+
if not game:
|
183 |
+
return jsonify({'error': 'Game not found'}), 404
|
184 |
+
return jsonify(game)
|
185 |
+
|
186 |
+
@app.route('/game/<game_id>/move', methods=['POST'])
|
187 |
+
def move(game_id):
|
188 |
+
game = games.get(game_id)
|
189 |
+
if not game:
|
190 |
+
return jsonify({'error': 'Game not found'}), 404
|
191 |
+
if game['game_over']:
|
192 |
+
return jsonify({'error': 'Game is over'}), 400
|
193 |
+
|
194 |
+
data = request.json
|
195 |
+
new_lat = data.get('lat')
|
196 |
+
new_lng = data.get('lng')
|
197 |
+
|
198 |
+
if new_lat is None or new_lng is None:
|
199 |
+
return jsonify({'error': 'Missing lat/lng for move'}), 400
|
200 |
+
|
201 |
+
game['current_location'] = {'lat': new_lat, 'lng': new_lng}
|
202 |
+
game['moves'] += 1
|
203 |
+
game['actions'].append({'type': 'move', 'location': {'lat': new_lat, 'lng': new_lng}})
|
204 |
+
|
205 |
+
# Fetch Street View image for the new location
|
206 |
+
google_maps_api_key = os.getenv('GOOGLE_MAPS_API_KEY')
|
207 |
+
streetview_image = None
|
208 |
+
compass_heading = random.randint(0, 359) # Random compass direction
|
209 |
+
if google_maps_api_key:
|
210 |
+
try:
|
211 |
+
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}"
|
212 |
+
|
213 |
+
response = requests.get(streetview_url)
|
214 |
+
if response.status_code == 200:
|
215 |
+
# Convert image to base64
|
216 |
+
streetview_image = base64.b64encode(response.content).decode('utf-8')
|
217 |
+
except Exception as e:
|
218 |
+
print(f"Error fetching Street View image: {e}")
|
219 |
+
|
220 |
+
return jsonify({
|
221 |
+
'message': 'Move successful',
|
222 |
+
'streetview_image': streetview_image,
|
223 |
+
'compass_heading': compass_heading
|
224 |
+
})
|
225 |
+
|
226 |
+
@app.route('/game/<game_id>/guess', methods=['POST'])
|
227 |
+
def guess(game_id):
|
228 |
+
game = games.get(game_id)
|
229 |
+
if not game:
|
230 |
+
return jsonify({'error': 'Game not found'}), 404
|
231 |
+
if game['game_over']:
|
232 |
+
return jsonify({'error': 'Game is over'}), 400
|
233 |
+
|
234 |
+
data = request.json
|
235 |
+
guess_lat = data.get('lat')
|
236 |
+
guess_lng = data.get('lng')
|
237 |
+
|
238 |
+
if guess_lat is None or guess_lng is None:
|
239 |
+
return jsonify({'error': 'Missing lat/lng for guess'}), 400
|
240 |
+
|
241 |
+
guess_location = {'lat': guess_lat, 'lng': guess_lng}
|
242 |
+
game['guesses'].append(guess_location)
|
243 |
+
|
244 |
+
# Calculate score (simple distance for now)
|
245 |
+
# This is a placeholder for a more complex scoring function.
|
246 |
+
from math import radians, cos, sin, asin, sqrt
|
247 |
+
def haversine(lat1, lon1, lat2, lon2):
|
248 |
+
# convert decimal degrees to radians
|
249 |
+
lon1, lat1, lon2, lat2 = map(radians, [lon1, lat1, lon2, lat2])
|
250 |
+
# haversine formula
|
251 |
+
dlon = lon2 - lon1
|
252 |
+
dlat = lat2 - lat1
|
253 |
+
a = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlon/2)**2
|
254 |
+
c = 2 * asin(sqrt(a))
|
255 |
+
r = 6371 # Radius of earth in kilometers.
|
256 |
+
return c * r
|
257 |
+
|
258 |
+
distance = haversine(
|
259 |
+
game['start_location']['lat'], game['start_location']['lng'],
|
260 |
+
guess_lat, guess_lng
|
261 |
+
)
|
262 |
+
|
263 |
+
# Simple scoring
|
264 |
+
max_score = 5000
|
265 |
+
score = max(0, max_score - distance) # The closer, the higher the score
|
266 |
+
|
267 |
+
game['actions'].append({
|
268 |
+
'type': 'guess',
|
269 |
+
'location': guess_location,
|
270 |
+
'result': {
|
271 |
+
'distance_km': distance,
|
272 |
+
'score': score
|
273 |
+
}
|
274 |
+
})
|
275 |
+
game['game_over'] = True # For now, one guess ends the game.
|
276 |
+
|
277 |
+
return jsonify({
|
278 |
+
'message': 'Guess received',
|
279 |
+
'guess_location': guess_location,
|
280 |
+
'actual_location': game['start_location'],
|
281 |
+
'distance_km': distance,
|
282 |
+
'score': score
|
283 |
+
})
|
284 |
+
|
285 |
+
# Load zones at startup
|
286 |
+
load_zones_from_file()
|
287 |
+
|
288 |
+
if __name__ == '__main__':
|
289 |
+
app.run(debug=True)
|
requirements.txt
ADDED
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
Flask
|
2 |
+
python-dotenv
|
3 |
+
requests
|
4 |
+
gunicorn
|
5 |
+
mcp[cli]
|
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 |
+
}
|