← backrunner.py
1"""Terrapin Trail: browse the Live Music Archive and play any tour on it, with shared, tagged leaderboards.
2 
3Services run from /home/user/workspace (the repo root). Conventions:
4 
5- Persistent state (anything written and read across runs -- cursors,
6 caches, snapshots, user records): read and write it under ``DATA_DIR``
7 (defined below), never a hardcoded ``data/.apps/terrapin-trail/`` at the
8 call site. ``DATA_DIR`` defaults to ``data/.apps/terrapin-trail/`` but
9 honors the ``TERRAPIN_TRAIL_DATA_DIR`` env var, so an editing agent can point a
10 throwaway instance at a *copy* of the data instead of the live store
11 (see the update-app skill). Do NOT use ``Path(__file__)``-based
12 paths for state -- the bug to avoid is one process writing to
13 ``/home/user/workspace/data/.apps/...`` while another reads from
14 ``/home/user/workspace/system/apps/<pkg>/data/...``.
15- Static assets shipped alongside this file (templates, default
16 configs, bundled JSON): ``Path(__file__).parent / "assets/..."`` is
17 fine and is the right pattern.
18- Listen port: bind ``PORT`` (defined below), which defaults to this
19 app's assigned port but honors the ``TERRAPIN_TRAIL_PORT`` env var, so
20 an editing agent can boot a throwaway instance on a *spare* port
21 alongside the live one (see the update-app skill). Never hardcode
22 the port at the ``run_simple`` call.
23 
24This is a synchronous Flask app served by the threaded Werkzeug server.
25The app owns its own browser origin (the forwarder routes
26``http://terrapin-trail.<workspace-host>/`` straight to this port), so it serves
27at ``/`` and root-absolute URLs, cookies, and service workers all work
28unmodified -- nothing rewrites anything. Use ``flask_sock`` if you need
29WebSockets.
30"""
31 
32import hashlib
33import html
34import json
35import re
36import os
37import secrets
38import time
39from datetime import datetime, timezone
40from pathlib import Path
41 
42from flask import Flask, Response, jsonify, redirect, request, send_from_directory
43from redis.exceptions import RedisError
44from werkzeug.middleware.dispatcher import DispatcherMiddleware
45from werkzeug.serving import run_simple
46from werkzeug.wrappers import Response as BaseResponse
47 
48from terrapin_trail import archive, bugs, dna, geocode, ogcard, tags, trust
49from terrapin_trail.guide.app import app as guide_app
50from terrapin_trail.store import make_store
51 
52# Persistent state for this app lives under DATA_DIR. It defaults to
53# ``data/.apps/terrapin-trail/`` but is overridable via the ``TERRAPIN_TRAIL_DATA_DIR`` env var
54# so a throwaway instance can run against a *copy* of the data while editing --
55# see the update-app skill. Always read/write state through DATA_DIR;
56# never hardcode ``data/.apps/terrapin-trail/`` at a call site, or the override is
57# bypassed. A writing call site should ``DATA_DIR.mkdir(parents=True,
58# exist_ok=True)`` before writing.
59# On Vercel only /tmp is writable, so the archive caches live there (shared state
60# such as leaderboards goes to Redis instead -- see store.py).
61_DEFAULT_DATA_DIR = "/tmp/terrapin-trail" if os.environ.get("VERCEL") else "data/.apps/terrapin-trail"
62DATA_DIR = Path(os.environ.get("TERRAPIN_TRAIL_DATA_DIR", _DEFAULT_DATA_DIR))
63 
64# Listen port. Defaults to this app's assigned port but is overridable via
65# the ``TERRAPIN_TRAIL_PORT`` env var so an editing agent can boot a throwaway
66# instance on a spare port next to the live one (see the update-app skill).
67# Never hardcode the port at the ``run_simple`` call, or the override is bypassed.
68PORT = int(os.environ.get("TERRAPIN_TRAIL_PORT", "8094"))
69 
70# How long an archive answer (a band's years, a year's tours, a built tour) is shared
71# between servers before it is looked up again. Old concerts rarely change, so a
72# month is long enough that only a band's first visitor each month waits on the archive.
73SHARED_TTL = 30 * 86400
74 
75# The Hall of the Fallen, as the original site stored it.
76HALL_KEY = "terrapin:hall"
77HALL_MAX = 500
78_HALL_FIELDS = {
79 "name": str, "role": str, "outcome": str, "cause": str, "crew": str, "band": str,
80 "score": int, "tapes": int, "legendary": int, "shows": int, "miles": int, "day": int, "year": int,
81}
82# The most an honest run can reach, with headroom. The original site capped a
83# ten-stop Dead tour; any band's tour can now run longer, so the caps are wider.
84# A record with any number outside its range is rejected whole.
85_HALL_BOUNDS = {
86 "score": (0, 20000), "tapes": (0, 400), "legendary": (0, 40), "shows": (0, 80),
87 "miles": (0, 30000), "day": (0, 600), "year": (1950, 2100),
88}
89 
90 
91def hall_record(payload: object) -> dict:
92 """Only the known fields of a run, so a record can't carry anything else; empty
93 when the run has no score or a number no honest run could reach."""
94 rec: dict = {}
95 if not isinstance(payload, dict):
96 return {}
97 for key, typ in _HALL_FIELDS.items():
98 val = payload.get(key)
99 if val is None or isinstance(val, (dict, list)):
100 continue
101 if typ is int:
102 try:
103 n = int(float(val))
104 except (TypeError, ValueError):
105 continue
106 lo, hi = _HALL_BOUNDS.get(key, (0, 10**9))
107 if not lo <= n <= hi:
108 return {}
109 rec[key] = n
110 else:
111 rec[key] = str(val)[:120 if key == "crew" else 80]
112 return rec if "score" in rec else {}
113 
114ASSETS = Path(__file__).parent / "assets"
115GAME_DIR = ASSETS / "game"
116WEB_DIR = ASSETS / "web"
117 
118DONATE_URL = "https://archive.org/donate"
119BOARD_SIZE = 50
120# Tour of the Day draws from this many of the most-played bands, so the daily
121# pick always has plenty of shows and recordings behind it.
122DAILY_BAND_POOL = 40
123DAILY_PRESETS = ["CLASSIC", "HardRoad", "TapersCode", "EasyRider"]
124 
125 
126def _error(message: str, status: int = 400) -> Response:
127 resp = jsonify({"error": message})
128 resp.status_code = status
129 return resp
130 
131 
132SITE_PREVIEW = {
133 "type": "website",
134 "title": "Terrapin Trail: every band on the Live Music Archive, one van",
135 "description": "Pick any band on the Live Music Archive, pick a year and a tour, and drive it: "
136 "a comedic Oregon Trail-style road game with the real recordings playing in the van.",
137}
138# The phone home-screen icon and Vercel's visitor stats (privacy-friendly, no cookies;
139# the script only exists on Vercel, and a missing script is harmless elsewhere).
140HEAD_SITE = (
141 '<link rel="apple-touch-icon" href="/guide/apple-touch-icon.png">'
142 '<script defer src="/_vercel/insights/script.js"></script>'
143)
144 
145 
146def _client_ip() -> str:
147 fwd = request.headers.get("X-Forwarded-For", "")
148 return fwd.split(",")[0].strip() if fwd else (request.remote_addr or "")
149 
150 
151def _page(name: str, boot: dict, og: dict | None = None) -> Response:
152 """Serve one of the web pages with its boot data (and share-preview tags) filled in."""
153 doc = (WEB_DIR / name).read_text()
154 doc = doc.replace("/*__BOOT__*/null", json.dumps(boot).replace("</", "<\\/"))
155 # Link previews: the page's own title and text when it has them (a shared tour),
156 # the site's otherwise, always with the site's preview image.
157 og = {**SITE_PREVIEW, "url": request.base_url, **(og or {})}
158 og["image"] = request.host_url.rstrip("/") + og.get("image", "/og-image.png?v=3")
159 meta = "".join(f'<meta property="og:{k}" content="{html.escape(str(v), quote=True)}">' for k, v in og.items())
160 meta += (
161 f'<meta name="description" content="{html.escape(og["description"], quote=True)}">'
162 '<meta property="og:image:width" content="1200"><meta property="og:image:height" content="630">'
163 '<meta name="twitter:card" content="summary_large_image">'
164 f"<title>{html.escape(og['title'])}</title>"
165 )
166 doc = doc.replace("<!--__OG__-->", meta + HEAD_SITE)
167 resp = Response(doc, mimetype="text/html")
168 resp.headers["Cache-Control"] = "no-store"
169 return resp
170 
171 
172def create_app(data_dir: Path, store=None) -> Flask:
173 """Build the Flask app, reading and writing its caches under ``data_dir``.
174 
175 ``data_dir`` is a parameter (not a hardcoded path) so a test can point a
176 throwaway instance at a temp directory seeded with cache fixtures and drive
177 the whole flow offline, exactly as the live app runs against ``DATA_DIR``.
178 ``store`` holds the shared state (leaderboards, tags, run tickets); it
179 defaults to Redis when configured, else files under ``data_dir``.
180 """
181 app = Flask("terrapin_trail", static_folder=None)
182 store = store if store is not None else make_store(data_dir)
183 
184 # ------------------------------------------------------------ helpers
185 def band_name_for(collection: str) -> str:
186 try:
187 meta = archive.fetch_metadata(collection, data_dir)
188 return archive._first(meta.get("metadata", {}).get("title")) or collection
189 except archive.ArchiveError:
190 return collection
191 
192 def tour_info(base: str) -> dict | None:
193 return tags.lookup(store, base)
194 
195 def shared(key: str, build):
196 """An archive answer every server shares: the first visitor's lookup is kept in
197 the store for SHARED_TTL, so the next visitor, on any server, gets it at once
198 (each server's own disk cache starts empty on Vercel). Failures aren't kept,
199 and a store that can't take the copy (full, or unreachable) doesn't cost the
200 visitor their answer."""
201 hit = store.get("cache:" + key)
202 if hit is not None:
203 return hit
204 value = build()
205 try:
206 store.set("cache:" + key, value, ttl=SHARED_TTL)
207 except (OSError, RedisError) as e:
208 app.logger.warning("couldn't keep %s in the shared store: %s", key, e)
209 return value
210 
211 def build_tagged_tour(base: str) -> tuple[dict, dict]:
212 info = tour_info(base)
213 if not info:
214 raise archive.ArchiveError("That tour tag isn't one we know yet.")
215 
216 def build() -> dict:
217 shows = archive.load_shows(info["collection"], data_dir)
218 tour = archive.build_tour(shows, info["year"], info["band"], info["collection"], seed_ymd=info["start"])
219 for stop in tour["stops"]:
220 place = geocode.resolve(stop["city"])
221 stop["lat"], stop["lon"] = (place.lat, place.lon) if place else (None, None)
222 return tour
223 
224 tour = shared(f"tour:{info['collection']}:{info['year']}:{info['start']}", build)
225 tour["tag"] = info.get("tag", base)
226 return info, tour
227 
228 def register_tours(collection: str, band: str, year: int, tours: list[dict]) -> list[dict]:
229 """Give each of a year's tours its hashtag, registered so a shared link resolves."""
230 out = []
231 for t, base in zip(tours, tags.base_tags(band, tours)):
232 info = {"collection": collection, "band": band, "year": year, "start": t["start"]}
233 final = tags.register(store, base, {**info, "tag": base})
234 if final != base:
235 store.set("tag:" + final.lower(), {**info, "tag": final})
236 out.append({**t, "tag": final})
237 return out
238 
239 def rules_label(code: str) -> str:
240 meta = None if code in dna.PRESETS else store.get("ruleset:" + code)
241 return meta["name"] if meta else dna.label(code)
242 
243 def board_key(base: str, code: str) -> str:
244 return "board:" + tags.full_tag(base, code).lower()
245 
246 def board_view(base: str, code: str) -> dict:
247 full = tags.full_tag(base, code)
248 return {
249 "tag": full,
250 "base": base,
251 "dna": code,
252 "rules": rules_label(code),
253 "top": store.board_top(board_key(base, code), BOARD_SIZE),
254 "players": store.board_count(board_key(base, code)),
255 "sources": dict(store.ztop("src:" + full.lower(), 10)),
256 }
257 
258 # ------------------------------------------------------------ pages
259 @app.route("/")
260 def index() -> Response:
261 return _page("browser.html", {"tag": None, "donate": DONATE_URL})
262 
263 @app.route("/t/<path:tag>")
264 def tag_landing(tag: str) -> BaseResponse:
265 """A shared tour link: count where the visit came from, then open that tour."""
266 parts = tags.split(tag)
267 if not parts:
268 return redirect("/")
269 base, code = parts
270 code = dna.encode(dna.decode(request.args.get("dna") or code))
271 info = tour_info(base)
272 if not info:
273 return redirect("/")
274 base = info.get("tag", base)
275 full = tags.full_tag(base, code)
276 source = (request.args.get("utm_source") or "direct")[:40].lower()
277 store.zincr("src:" + full.lower(), source)
278 og = {
279 "title": f"#{full} - {info['band']} {info['year']} on Terrapin Trail",
280 "description": f"Drive the {info['band']} tour that started {info['start']}, "
281 "tape the shows, and beat the leaderboard. Real recordings from the Internet Archive.",
282 "type": "website",
283 "image": f"/og/{full}.png",
284 }
285 boot = {
286 "tag": base, "dna": code, "luck": request.args.get("luck"), "from": source,
287 "by": (request.args.get("utm_content") or "")[:24], "donate": DONATE_URL,
288 }
289 return _page("browser.html", boot, og)
290 
291 @app.route("/play")
292 def play() -> BaseResponse:
293 """The game. With ?tag= it plays that shared tour under the chosen rules and
294 hands out a run ticket so the score can go on the tour's public board."""
295 base = request.args.get("tag")
296 boot: dict = {"donate": DONATE_URL}
297 if base:
298 try:
299 info, tour = build_tagged_tour(base)
300 except archive.ArchiveError:
301 return redirect("/")
302 values = dna.decode(request.args.get("dna") or "CLASSIC")
303 code = dna.encode(values)
304 luck = request.args.get("luck")
305 try:
306 luck_n = int(luck) if luck else None
307 except ValueError:
308 luck_n = None
309 base = tour["tag"]
310 run = trust.start_run(store, base, code, len(tour["stops"]), 1, luck_n)
311 if code not in dna.PRESETS and store.get("ruleset:" + code):
312 store.zincr("rulesets:plays", code)
313 boot.update({
314 "tag": base, "fullTag": tags.full_tag(base, code), "dna": code, "rules": dna.label(code),
315 "values": values, "tour": tour, "run": run["run"], "seed": run["seed"],
316 "band": info["band"], "year": info["year"],
317 })
318 return _page("play.html", boot)
319 
320 @app.route("/game/<path:fname>")
321 def game_asset(fname: str) -> Response:
322 # The real game's static files (game.js patched to accept a generated tour,
323 # plus the phosphor-CRT game.css), served straight from disk.
324 return send_from_directory(GAME_DIR, fname)
325 
326 @app.route("/og/<tag>.png")
327 def og_card(tag: str) -> BaseResponse:
328 """A shared tour's link-preview picture: band, year, hashtag, route and top score."""
329 parts = tags.split(tag)
330 if not parts:
331 return redirect("/og-image.png")
332 base, code = parts
333 try:
334 info, tour = build_tagged_tour(base)
335 except archive.ArchiveError:
336 return redirect("/og-image.png")
337 top = (board_view(tour["tag"], code)["top"] or [None])[0]
338 resp = Response(ogcard.render(info, tour, top, tags.full_tag(tour["tag"], code)), mimetype="image/png")
339 # The top score changes, so link previews refresh within the hour.
340 resp.headers["Cache-Control"] = "public, max-age=600, s-maxage=3600, stale-while-revalidate=86400"
341 return resp
342 
343 @app.route("/og-image.png")
344 @app.route("/apple-touch-icon.png")
345 def site_image() -> Response:
346 return send_from_directory(WEB_DIR, request.path.lstrip("/"), max_age=86400)
347 
348 @app.route("/favicon.ico")
349 def favicon() -> Response:
350 return send_from_directory(Path(__file__).parent.parent.parent, "icon.svg", mimetype="image/svg+xml")
351 
352 @app.route("/web/<path:fname>")
353 def web_asset(fname: str) -> Response:
354 return send_from_directory(WEB_DIR, fname)
355 
356 # Archive answers and the page's own files change rarely, so Vercel's edge keeps a
357 # copy for a day (and serves a stale one while refreshing): a player then rarely
358 # waits on the Internet Archive. Scores, boards and tickets are never cached.
359 cacheable = ("/api/bands", "/api/stats", "/api/years/", "/api/tours/", "/api/show/", "/api/dna", "/web/", "/game/")
360 
361 @app.after_request
362 def edge_cache(resp: Response) -> Response:
363 if request.method == "GET" and resp.status_code == 200 and request.path.startswith(cacheable):
364 resp.headers["Cache-Control"] = "public, max-age=300, s-maxage=86400, stale-while-revalidate=604800"
365 return resp
366 
367 # ------------------------------------------------------------ archive browser API
368 @app.get("/api/bands")
369 def api_bands() -> Response:
370 """Bands on the Live Music Archive, A to Z (?sort=az, the default; ?letter= jumps to
371 one letter, '#' for names starting with a digit) or most played first (?sort=plays);
372 ?q= filters by name."""
373 q = (request.args.get("q") or "").strip().lower()
374 try:
375 bands = archive.list_bands(data_dir)
376 except (archive.ArchiveError, OSError, ValueError): # archive.org down: say so rather than crash the page
377 return _error("Couldn't reach the Internet Archive. Try again in a minute.", 502)
378 if q:
379 bands = [b for b in bands if q in b["name"].lower() or q in b["id"].lower()]
380 if request.args.get("sort", "az") == "az":
381 bands = sorted(bands, key=lambda b: archive.sort_name(b["name"]))
382 letter = (request.args.get("letter") or "").upper()
383 if letter == "#":
384 bands = [b for b in bands if not archive.sort_name(b["name"])[:1].isalpha()]
385 elif len(letter) == 1 and letter.isalpha():
386 bands = [b for b in bands if archive.sort_name(b["name"]).upper().startswith(letter)]
387 try:
388 limit = min(int(request.args.get("limit", 60)), 600)
389 except ValueError:
390 limit = 60
391 return jsonify({"total": len(bands), "bands": bands[:limit]})
392 
393 @app.get("/api/stats")
394 def api_stats() -> Response:
395 """The headline numbers: bands, recordings, and years of shows on the archive."""
396 try:
397 bands = archive.list_bands(data_dir)
398 first = archive.first_show_year(data_dir)
399 except (archive.ArchiveError, OSError, ValueError):
400 return _error("Couldn't reach the Internet Archive.", 502)
401 return jsonify({
402 "bands": len(bands),
403 "tapes": sum(b["items"] for b in bands),
404 "firstYear": first,
405 "years": datetime.now(timezone.utc).year - first,
406 })
407 
408 @app.get("/api/years/<collection>")
409 def api_years(collection: str) -> Response:
410 """One band's recorded shows per year."""
411 try:
412 years = shared("years:" + collection, lambda: archive.year_list(archive.load_shows(collection, data_dir)))
413 except (archive.ArchiveError, OSError, ValueError):
414 return _error("Couldn't load that band from the Internet Archive.", 502)
415 return jsonify({"collection": collection, "band": band_name_for(collection), "years": years})
416 
417 @app.get("/api/tours/<collection>/<int:year>")
418 def api_tours(collection: str, year: int) -> Response:
419 """A year's tours, each with its hashtag (registered so shared links resolve)."""
420 try:
421 raw = shared(f"tours:{collection}:{year}",
422 lambda: archive.tours_for_year(archive.load_shows(collection, data_dir), year))
423 except (archive.ArchiveError, OSError, ValueError):
424 return _error("Couldn't load that band from the Internet Archive.", 502)
425 band = band_name_for(collection)
426 tours = register_tours(collection, band, year, raw)
427 return jsonify({"collection": collection, "band": band, "year": year, "tours": tours})
428 
429 @app.get("/api/tour/<tag>")
430 def api_tour_by_tag(tag: str) -> Response:
431 """A tour by its hashtag: stops with map positions, plus its leaderboard."""
432 parts = tags.split(tag)
433 if not parts:
434 return _error("That isn't a tour tag.", 404)
435 base, code = parts
436 try:
437 info, tour = build_tagged_tour(base)
438 except archive.ArchiveError as e:
439 return _error(str(e), 404)
440 return jsonify({"info": info, "tour": tour, "board": board_view(tour["tag"], code)})
441 
442 @app.get("/api/show/<identifier>")
443 def api_show(identifier: str) -> Response:
444 """One recording's playable songs and who taped it."""
445 try:
446 return jsonify(archive.show_tracks(identifier, data_dir))
447 except archive.ArchiveError as e:
448 return _error(str(e), 404)
449 except (OSError, ValueError):
450 return _error("Couldn't reach the Internet Archive.", 502)
451 
452 @app.get("/api/setlist/<collection>/<ymd>")
453 def api_setlist(collection: str, ymd: str) -> Response:
454 """The songs a band played one night, from that night's best-rated recording
455 (or the recording named in ?id=). Kept for a week: setlists don't change."""
456 if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", ymd):
457 return _error("Dates look like 1977-05-08.")
458 wanted = (request.args.get("id") or "").strip()
459 
460 def build() -> dict:
461 ident = wanted or archive.best_recording(collection, ymd)
462 if not ident:
463 return {"identifier": None, "songs": []}
464 return {"identifier": ident, "songs": archive.setlist(ident, data_dir)}
465 
466 try:
467 body = shared(f"setlist:{collection}:{ymd}:{wanted}", build)
468 except (archive.ArchiveError, OSError, ValueError):
469 return _error("Couldn't reach the Internet Archive.", 502)
470 resp = jsonify(body)
471 resp.headers["Cache-Control"] = "public, max-age=3600, s-maxage=604800, stale-while-revalidate=604800"
472 return resp
473 
474 @app.get("/api/dna")
475 def api_dna() -> Response:
476 """The rule sliders and ready-made rule sets."""
477 return jsonify({
478 "rules": dna.RULES,
479 "presets": {k: {"label": v["label"], "values": dna.normalize(v["values"])} for k, v in dna.PRESETS.items()},
480 })
481 
482 @app.post("/api/dna/encode")
483 def api_dna_encode() -> Response:
484 """Slider positions -> the rules code that rides on the tag."""
485 values = dna.normalize((request.get_json(silent=True) or {}).get("values") or {})
486 code = dna.encode(values)
487 return jsonify({"code": code, "label": dna.label(code), "values": values})
488 
489 @app.get("/api/dna/<code>")
490 def api_dna_code(code: str) -> Response:
491 """A rules code -> its slider positions."""
492 values = dna.decode(code)
493 code = dna.encode(values)
494 return jsonify({"code": code, "label": dna.label(code), "values": values})
495 
496 # ------------------------------------------------------------ tour of the day
497 def pick_daily(day: str) -> dict:
498 """Deterministic for a given date: a top band, then its show closest to
499 today's month and day in any year, then the tour around that show."""
500 seed = int(hashlib.sha256(day.encode()).hexdigest(), 16)
501 bands = [b for b in archive.list_bands(data_dir)[:DAILY_BAND_POOL] if b["items"] >= 50]
502 mmdd = day[5:]
503 for k in range(len(bands)):
504 band = bands[(seed + k) % len(bands)]
505 shows = archive.load_shows(band["id"], data_dir)
506 nights = archive.concert_nights(shows)
507 if not nights:
508 continue
509 
510 def distance(n: dict) -> int:
511 d = abs(int(n["ymd"][5:7]) * 31 + int(n["ymd"][8:10]) - (int(mmdd[:2]) * 31 + int(mmdd[3:])))
512 return min(d, 372 - d)
513 
514 best = min(distance(n) for n in nights)
515 candidates = [n for n in nights if distance(n) == best]
516 night = candidates[seed % len(candidates)]
517 year = int(night["ymd"][:4])
518 name = band_name_for(band["id"])
519 for t in register_tours(band["id"], name, year, archive.tours_for_year(shows, year)):
520 final = t["tag"]
521 if t["start"] <= night["ymd"] <= t["end"]:
522 code = DAILY_PRESETS[seed % len(DAILY_PRESETS)]
523 return {
524 "date": day, "tag": final, "dna": code, "rules": dna.label(code), "fullTag": tags.full_tag(final, code),
525 "band": name, "collection": band["id"], "year": year, "night": night["ymd"],
526 "venue": night["venue"], "city": night["city"], "onThisDay": night["ymd"][5:] == mmdd,
527 }
528 raise archive.ArchiveError("No tour of the day yet.")
529 
530 @app.get("/api/daily")
531 def api_daily() -> Response:
532 """Today's shared tour: one tag, one rule set, one leaderboard for everyone."""
533 day = request.args.get("date") or datetime.now(timezone.utc).strftime("%Y-%m-%d")
534 if len(day) != 10:
535 return _error("Use a YYYY-MM-DD date.")
536 cached = store.get("daily:" + day)
537 if cached:
538 return jsonify(cached)
539 try:
540 pick = pick_daily(day)
541 except (archive.ArchiveError, OSError, ValueError):
542 return _error("Couldn't pick today's tour. Try again in a minute.", 502)
543 store.set("daily:" + day, pick, ttl=8 * 86400)
544 return jsonify(pick)
545 
546 # ------------------------------------------------------------ rule-set gallery
547 @app.get("/api/rulesets")
548 def api_rulesets() -> Response:
549 """Rule sets players named and published, most played first."""
550 out = []
551 for code, plays in store.ztop("rulesets:plays", 30):
552 meta = store.get("ruleset:" + code)
553 if meta:
554 out.append({**meta, "plays": plays})
555 return jsonify({"rulesets": out[:12]})
556 
557 @app.post("/api/rulesets")
558 def api_rulesets_publish() -> Response:
559 """Name and publish a custom rule set so others can play it."""
560 body = request.get_json(silent=True) or {}
561 values = dna.normalize(body.get("values") or {})
562 code = dna.encode(values)
563 if code in dna.PRESETS:
564 return _error("Those are the " + dna.PRESETS[code]["label"] + " rules already.")
565 name, why = trust.clean_name(str(body.get("name") or ""))
566 if not name:
567 return _error(why or "Give the rules a name.")
568 by, _ = trust.clean_name(str(body.get("by") or ""))
569 ip = _client_ip()
570 if ip and store.incr("rlpub:" + ip, ttl=3600) > 10:
571 return _error("Too many rule sets from here in the last hour.", 429)
572 meta = {"code": code, "name": name, "by": by or "anonymous", "values": values}
573 if not store.set_if_absent("ruleset:" + code, meta):
574 meta = store.get("ruleset:" + code) or meta
575 store.zincr("rulesets:plays", code, 0)
576 return jsonify(meta)
577 
578 # ------------------------------------------------------------ bug reports
579 @app.post("/api/bug")
580 def api_bug() -> Response:
581 """Save a bug report or a player's idea (their words plus the page's details) under
582 a short id and file it as an issue in the maker's GitHub. If it can't be filed, the
583 answer carries the maker's email so the page can open the player's email app."""
584 body = request.get_json(silent=True) or {}
585 text = str(body.get("text") or "").strip()[:3000]
586 if not text:
587 return _error("Describe what happened first.")
588 ip = _client_ip()
589 if ip and store.incr("rlbug:" + ip, ttl=3600) > 20:
590 return _error("Too many reports from here in the last hour.", 429)
591 details = body.get("details") if isinstance(body.get("details"), dict) else {}
592 raw = json.dumps(details)[:6000]
593 report_id = secrets.token_hex(3)
594 saved = json.loads(raw) if len(raw) < 6000 else {"raw": raw}
595 kind = body.get("kind")
596 kind = kind if isinstance(kind, str) and kind in bugs.KINDS else "bug"
597 issue = bugs.file_issue(report_id, text, saved, kind)
598 store.set("bug:" + report_id, {"id": report_id, "kind": kind, "text": text, "details": saved, "issue": issue,
599 "at": int(time.time())}, ttl=180 * 86400)
600 store.zincr("bugs", report_id, int(time.time()))
601 if issue:
602 return jsonify({"id": report_id, "filed": True})
603 return jsonify({"id": report_id, "filed": False, "email": bugs.FALLBACK_EMAIL})
604 
605 # ------------------------------------------------------------ daily upkeep
606 @app.get("/api/cron/daily")
607 def api_cron_daily() -> Response:
608 """Daily upkeep, run by Vercel's scheduler: remind the maker on GitHub before the
609 bug-report key expires."""
610 secret = os.environ.get("CRON_SECRET")
611 if secret and request.headers.get("Authorization") != "Bearer " + secret:
612 return _error("Not allowed.", 401)
613 return jsonify({"key": bugs.check_key(store)})
614 
615 # ------------------------------------------------------------ the players' wish list
616 @app.get("/api/wishlist")
617 def api_wishlist() -> Response:
618 """Players' ideas the maker has marked planned or shipped on GitHub."""
619 items = store.get("cache:wishlist")
620 if items is None:
621 items = bugs.wishlist()
622 # The workspace copy has no daily scheduler; the wish list refresh does the
623 # key check instead, at most once a day.
624 if store.set_if_absent("keycheck:" + datetime.now(timezone.utc).strftime("%Y-%m-%d"), 1, ttl=2 * 86400):
625 bugs.check_key(store)
626 if items is None:
627 return jsonify({"items": [], "available": False})
628 store.set("cache:wishlist", items, ttl=600)
629 resp = jsonify({"items": items, "available": True})
630 resp.headers["Cache-Control"] = "public, max-age=120, s-maxage=600, stale-while-revalidate=3600"
631 return resp
632 
633 # ------------------------------------------------------------ leaderboards
634 @app.get("/api/board/<tag>")
635 def api_board(tag: str) -> Response:
636 """A tour-and-rules leaderboard, plus where its players came from."""
637 parts = tags.split(tag)
638 if not parts:
639 return _error("That isn't a tour tag.", 404)
640 base, code = parts
641 info = tour_info(base)
642 return jsonify(board_view(info.get("tag", base) if info else base, code))
643 
644 @app.post("/api/score")
645 def api_score() -> Response:
646 """Post a finished run to its tour's public leaderboard."""
647 body = request.get_json(silent=True) or {}
648 name, why = trust.clean_name(str(body.get("name") or ""))
649 if not name:
650 return _error(why or "Pick a name.")
651 rec_in = body.get("record") or {}
652 rec = {}
653 for k in ("score", "tapes", "legendary", "shows", "miles", "day"):
654 try:
655 rec[k] = int(rec_in.get(k, 0))
656 except (TypeError, ValueError):
657 return _error("That run record is garbled.")
658 run_id = str(body.get("run") or "")
659 ticket_peek = store.get("run:" + run_id) or {}
660 legend_pts = int(dna.decode(ticket_peek.get("dna")).get("legend", 60))
661 ticket, why = trust.check_run(store, run_id, rec, legend_pts, _client_ip())
662 if not ticket:
663 return _error(why or "That run can't go on the board.", 403)
664 entry = {
665 "id": secrets.token_hex(4), "name": name, "score": rec["score"],
666 "from": str(body.get("from") or "direct")[:40].lower(),
667 "outcome": "won" if rec_in.get("outcome") == "won" else "lost",
668 "role": str(rec_in.get("role") or "")[:24], "tapes": rec["tapes"], "shows": rec["shows"],
669 "legendary": rec["legendary"], "miles": rec["miles"], "seed": ticket["seed"], "ts": int(time.time()),
670 }
671 rank = store.board_add(board_key(ticket["tag"], ticket["dna"]), entry, rec["score"])
672 return jsonify({"rank": rank, "entry": entry, "board": board_view(ticket["tag"], ticket["dna"])})
673 
674 # ------------------------------------------------------------ the Hall of the Fallen
675 @app.route("/api/hall", methods=["GET", "POST"])
676 def api_hall() -> Response:
677 """The shared Hall of the Fallen: every finished run, newest first.
678 
679 Kept in the same place and shape as the original terrapin-trail.com
680 (a Redis list at ``terrapin:hall``), so the runs players finished there
681 stay in the Hall."""
682 if request.method == "GET":
683 return jsonify({"runs": store.list_all(HALL_KEY)})
684 rec = hall_record(request.get_json(silent=True))
685 if not rec:
686 return jsonify({"runs": store.list_all(HALL_KEY)})
687 ip = _client_ip()
688 if ip and store.incr("rlhall:" + ip, ttl=3600) > 30:
689 return _error("Too many runs from here in the last hour.", 429)
690 if rec.get("name") and trust.clean_name(str(rec["name"]))[0] is None:
691 rec["name"] = "A traveler"
692 return jsonify({"runs": store.list_push(HALL_KEY, rec, HALL_MAX)})
693 
694 @app.post("/api/band")
695 def api_band() -> Response:
696 """Identify the band behind a pasted archive.org link."""
697 body = request.get_json(silent=True) or {}
698 try:
699 identifier = archive.parse_identifier(body.get("url", ""))
700 meta = archive.fetch_metadata(identifier, data_dir)
701 collection = archive.band_collection(meta)
702 band = archive.band_name(meta, collection)
703 md = meta.get("metadata", {})
704 except archive.ArchiveError as e:
705 return _error(str(e))
706 return jsonify({"collection": collection, "band": band, "year": int((md.get("date") or "0")[:4] or 0),
707 "date": (md.get("date") or "")[:10]})
708 
709 @app.route("/health")
710 def health() -> Response:
711 return Response('{"status": "ok"}', mimetype="application/json")
712 
713 # The Code Bible, built in as the game's guide.
714 app.wsgi_app = DispatcherMiddleware(app.wsgi_app, {"/guide": guide_app}) # ty: ignore[invalid-assignment]
715 return app
716 
717 
718app = create_app(DATA_DIR)
719 
720 
721def main() -> None:
722 run_simple(
723 "127.0.0.1", PORT, app, threaded=True, use_reloader=False, use_debugger=False
724 )
725 
726 
727if __name__ == "__main__":
728 main()
729