From fb7a34a95ee7b54e2a291ae4892aa6e3bf0df74f Mon Sep 17 00:00:00 2001 From: "Eric Meehan -X (ermeehan - ACTALENT SERVICES LLC at Cisco)" Date: Sun, 18 Oct 2026 13:53:19 -0400 Subject: [PATCH] Initial commit --- .gitignore | 5 + server.py | 495 ++++++++++++++++++++++++++++++++++++++++++++++ static/app.js | 273 +++++++++++++++++++++++++ static/index.html | 87 ++++++++ static/style.css | 107 ++++++++++ 5 files changed, 967 insertions(+) create mode 100644 .gitignore create mode 100644 server.py create mode 100644 static/app.js create mode 100644 static/index.html create mode 100644 static/style.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ab24b6e --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +data.json +data.tmp +mailing_list.csv +test-data.json +__pycache__/ diff --git a/server.py b/server.py new file mode 100644 index 0000000..101ccc8 --- /dev/null +++ b/server.py @@ -0,0 +1,495 @@ +"""The Button - a single-file, dependency-free game server (Python 3.9+).""" + +import csv +import hashlib +import hmac +import json +import os +import re +import secrets +import threading +import time +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +STATIC_DIR = ROOT / "static" +DATA_FILE = Path(os.environ.get("BUTTON_DATA_FILE", ROOT / "data.json")) +MAILING_LIST_FILE = Path(os.environ.get("BUTTON_MAILING_LIST_FILE", ROOT / "mailing_list.csv")) +MAILING_LIST_FIELDS = ["subscribed_at", "player", "first_name", "last_name", "email", "phone"] +EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") +PHONE_RE = re.compile(r"^\+?[0-9 ()\-.]{7,25}$") +HOST = os.environ.get("BUTTON_HOST", "127.0.0.1") +PORT = int(os.environ.get("BUTTON_PORT", "8000")) +DURATION_MS = int(float(os.environ.get("BUTTON_DURATION_SECONDS", 24 * 60 * 60)) * 1000) +MAX_BODY_BYTES = 1024 +MAX_NAME_LEN = 24 +MIN_PASSWORD_LEN = 8 +MAX_PASSWORD_LEN = 128 +PBKDF2_ITERATIONS = 600_000 +MAX_SESSIONS_PER_PLAYER = 10 +LOGIN_MAX_FAILURES = 5 +LOGIN_LOCKOUT_MS = 15 * 60 * 1000 + +# Ordered from a freshly reset countdown to an almost-expired one. +# "share" is each zone's relative slice of the timer, so high-value zones are brief. +ZONES = [ + {"name": "green", "points": 0, "share": 32}, + {"name": "blue", "points": 2, "share": 16}, + {"name": "yellow", "points": 4, "share": 8}, + {"name": "red", "points": 8, "share": 4}, + {"name": "black", "points": 16, "share": 2}, + {"name": "white", "points": 32, "share": 1}, +] +_total_share = sum(z["share"] for z in ZONES) +_upper = DURATION_MS +for _z in ZONES: + _z["remaining_from_ms"] = _upper + _upper = round(_upper - DURATION_MS * _z["share"] / _total_share) + _z["remaining_to_ms"] = max(_upper, 0) +ZONES[-1]["remaining_to_ms"] = 0 + +STATIC_FILES = { + "/": ("index.html", "text/html; charset=utf-8"), + "/index.html": ("index.html", "text/html; charset=utf-8"), + "/app.js": ("app.js", "text/javascript; charset=utf-8"), + "/style.css": ("style.css", "text/css; charset=utf-8"), +} + +lock = threading.Lock() + + +def now_ms(): + return int(time.time() * 1000) + + +def hash_token(token): + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def hash_password(password, salt=None, iterations=PBKDF2_ITERATIONS): + salt = salt or secrets.token_hex(16) + digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), bytes.fromhex(salt), iterations).hex() + return f"pbkdf2_sha256${iterations}${salt}${digest}" + + +def verify_password(password, stored): + _, iterations, salt, _ = stored.split("$") + return hmac.compare_digest(hash_password(password, salt, int(iterations)), stored) + + +# Verified against when the name doesn't exist so response timing doesn't reveal valid names. +DUMMY_PASSWORD_HASH = hash_password(secrets.token_hex(16)) + + +def zone_for_remaining(remaining_ms): + for zone in ZONES: + if remaining_ms > zone["remaining_to_ms"]: + return zone + return ZONES[-1] + + +def new_game(): + t = now_ms() + return { + "duration_ms": DURATION_MS, + "started_at": t, + "deadline": t + DURATION_MS, + "ended": False, + "ended_at": None, + "players": {}, # id -> {name, password_hash, token_hashes, created_at} + "presses": [], # {player_id, at, remaining_ms, zone, points} + } + + +def load_state(): + if DATA_FILE.exists(): + with DATA_FILE.open("r", encoding="utf-8") as f: + return json.load(f) + state = new_game() + save_state(state) + return state + + +def save_state(state): + tmp = DATA_FILE.with_suffix(".tmp") + with tmp.open("w", encoding="utf-8") as f: + json.dump(state, f) + os.replace(tmp, DATA_FILE) + + +state = load_state() +for _p in state["players"].values(): + if "token_hash" in _p: # migrate pre-login data + _p["token_hashes"] = [_p.pop("token_hash")] +token_index = {th: pid for pid, p in state["players"].items() for th in p["token_hashes"]} +failed_logins = {} # lowercase name -> (failure count, first failure ms) + + +def find_player_by_name(name): + for pid, p in state["players"].items(): + if p["name"].lower() == name.lower(): + return pid + return None + + +def issue_token(pid): + """Must be called with the lock held.""" + token = secrets.token_urlsafe(32) + th = hash_token(token) + hashes = state["players"][pid]["token_hashes"] + hashes.append(th) + token_index[th] = pid + while len(hashes) > MAX_SESSIONS_PER_PLAYER: + token_index.pop(hashes.pop(0), None) + save_state(state) + return token + + +def validate_credentials(data): + """Returns (name, password, error).""" + name, password = data.get("name"), data.get("password") + if not isinstance(name, str) or not (1 <= len(name.strip()) <= MAX_NAME_LEN): + return None, None, f"Name must be 1-{MAX_NAME_LEN} characters" + name = " ".join(name.split()) + if not name.isprintable(): + return None, None, "Name contains invalid characters" + if not isinstance(password, str) or not (MIN_PASSWORD_LEN <= len(password) <= MAX_PASSWORD_LEN): + return None, None, f"Password must be {MIN_PASSWORD_LEN}-{MAX_PASSWORD_LEN} characters" + return name, password, None + + +def check_ended(): + """Must be called with the lock held.""" + if not state["ended"] and now_ms() >= state["deadline"]: + state["ended"] = True + state["ended_at"] = state["deadline"] + save_state(state) + + +def empty_zone_counts(): + return {z["name"]: 0 for z in ZONES} + + +def player_summary(pid): + presses = [p for p in state["presses"] if p["player_id"] == pid] + counts = empty_zone_counts() + for p in presses: + counts[p["zone"]] += 1 + return { + "name": state["players"][pid]["name"], + "subscribed": state["players"][pid].get("subscribed", False), + "total_points": sum(p["points"] for p in presses), + "press_count": len(presses), + "zone_counts": counts, + "best_remaining_ms": min((p["remaining_ms"] for p in presses), default=None), + "presses": [ + {k: p[k] for k in ("at", "remaining_ms", "zone", "points")} for p in presses + ], + } + + +def public_press(p): + return { + "name": state["players"][p["player_id"]]["name"], + "at": p["at"], + "remaining_ms": p["remaining_ms"], + "zone": p["zone"], + "points": p["points"], + } + + +def global_stats(): + counts = empty_zone_counts() + for p in state["presses"]: + counts[p["zone"]] += 1 + leaderboard = [] + for pid in state["players"]: + s = player_summary(pid) + del s["presses"], s["subscribed"] + leaderboard.append(s) + leaderboard.sort(key=lambda s: (-s["total_points"], s["name"].lower())) + presses = state["presses"] + lowest = min(presses, key=lambda p: p["remaining_ms"], default=None) + return { + "started_at": state["started_at"], + "ended_at": state["ended_at"], + "total_players": len(state["players"]), + "total_presses": len(presses), + "zone_counts": counts, + "leaderboard": leaderboard, + "last_press": public_press(presses[-1]) if presses else None, + "lowest_press": public_press(lowest) if lowest else None, + "presses": [public_press(p) for p in presses], + } + + +class Handler(BaseHTTPRequestHandler): + server_version = "TheButton/1.0" + + def log_message(self, fmt, *args): + pass + + def send_json(self, status, payload): + body = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Cache-Control", "no-store") + self.send_common_headers() + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def send_common_headers(self): + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("X-Frame-Options", "DENY") + self.send_header("Referrer-Policy", "no-referrer") + self.send_header( + "Content-Security-Policy", + "default-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'", + ) + + def read_json(self): + length = int(self.headers.get("Content-Length") or 0) + if length > MAX_BODY_BYTES: + raise ValueError("Request body too large") + raw = self.rfile.read(length) if length else b"{}" + data = json.loads(raw.decode("utf-8")) + if not isinstance(data, dict): + raise ValueError("Expected a JSON object") + return data + + def bearer_hash(self): + auth = self.headers.get("Authorization", "") + if not auth.startswith("Bearer "): + return None + return hash_token(auth[7:].strip()) + + def authed_player(self): + th = self.bearer_hash() + return token_index.get(th) if th else None + + def do_GET(self): + path = self.path.split("?", 1)[0] + if path == "/api/state": + with lock: + check_ended() + return self.send_json(HTTPStatus.OK, { + "now": now_ms(), + "started_at": state["started_at"], + "deadline": state["deadline"], + "duration_ms": DURATION_MS, + "ended": state["ended"], + "zones": ZONES, + }) + if path == "/api/me": + with lock: + pid = self.authed_player() + if pid is None: + return self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Not registered"}) + return self.send_json(HTTPStatus.OK, player_summary(pid)) + if path == "/api/stats": + with lock: + check_ended() + if not state["ended"]: + return self.send_json(HTTPStatus.FORBIDDEN, {"error": "Stats are published when the game ends"}) + return self.send_json(HTTPStatus.OK, global_stats()) + if path in STATIC_FILES: + filename, ctype = STATIC_FILES[path] + body = (STATIC_DIR / filename).read_bytes() + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", ctype) + self.send_common_headers() + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return + self.send_json(HTTPStatus.NOT_FOUND, {"error": "Not found"}) + + def do_POST(self): + path = self.path.split("?", 1)[0] + try: + data = self.read_json() + except (ValueError, UnicodeDecodeError): + return self.send_json(HTTPStatus.BAD_REQUEST, {"error": "Invalid JSON body"}) + + if path == "/api/register": + name, password, error = validate_credentials(data) + if error: + return self.send_json(HTTPStatus.BAD_REQUEST, {"error": error}) + password_hash = hash_password(password) # slow; done outside the lock + with lock: + check_ended() + if state["ended"]: + return self.send_json(HTTPStatus.CONFLICT, {"error": "The game is over"}) + if find_player_by_name(name) is not None: + return self.send_json(HTTPStatus.CONFLICT, {"error": "Name already taken"}) + pid = secrets.token_hex(8) + state["players"][pid] = { + "name": name, + "password_hash": password_hash, + "token_hashes": [], + "created_at": now_ms(), + } + token = issue_token(pid) + return self.send_json(HTTPStatus.CREATED, {"name": name, "token": token}) + + if path == "/api/login": + name, password = data.get("name"), data.get("password") + if not isinstance(name, str) or not isinstance(password, str) or len(password) > MAX_PASSWORD_LEN: + return self.send_json(HTTPStatus.BAD_REQUEST, {"error": "Name and password required"}) + name = " ".join(name.split()) + key = name.lower() + with lock: + count, first = failed_logins.get(key, (0, 0)) + if count >= LOGIN_MAX_FAILURES and now_ms() - first < LOGIN_LOCKOUT_MS: + return self.send_json(HTTPStatus.TOO_MANY_REQUESTS, {"error": "Too many failed attempts; try again later"}) + pid = find_player_by_name(name) + stored = state["players"][pid].get("password_hash") if pid else None + ok = verify_password(password, stored or DUMMY_PASSWORD_HASH) and stored is not None + with lock: + if not ok: + count, first = failed_logins.get(key, (0, 0)) + if now_ms() - first >= LOGIN_LOCKOUT_MS: + count, first = 0, now_ms() + failed_logins[key] = (count + 1, first) + return self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Invalid name or password"}) + failed_logins.pop(key, None) + token = issue_token(pid) + return self.send_json(HTTPStatus.OK, {"name": state["players"][pid]["name"], "token": token}) + + if path == "/api/logout": + with lock: + th = self.bearer_hash() + pid = token_index.pop(th, None) if th else None + if pid is not None: + state["players"][pid]["token_hashes"].remove(th) + save_state(state) + return self.send_json(HTTPStatus.OK, {"ok": True}) + + if path == "/api/subscribe": + with lock: + pid = self.authed_player() + player_name = state["players"][pid]["name"] if pid else None + already_subscribed = bool(pid and state["players"][pid].get("subscribed")) + if pid is None: + return self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Log in to join the mailing list"}) + if already_subscribed: + return self.send_json(HTTPStatus.OK, {"ok": True}) + entry, error = validate_subscription(data) + if error: + return self.send_json(HTTPStatus.BAD_REQUEST, {"error": error}) + entry["player"] = player_name + with mailing_lock: + # Same response for repeat emails so the endpoint doesn't reveal who is subscribed. + if entry["email"].lower() not in subscribed_emails: + add_subscriber(entry) + with lock: + state["players"][pid]["subscribed"] = True + save_state(state) + return self.send_json(HTTPStatus.OK, {"ok": True}) + + if path == "/api/press": + with lock: + pid = self.authed_player() + if pid is None: + return self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Not registered"}) + check_ended() + if state["ended"]: + return self.send_json(HTTPStatus.CONFLICT, {"error": "The game is over"}) + t = now_ms() + remaining = state["deadline"] - t + zone = zone_for_remaining(remaining) + press = { + "player_id": pid, + "at": t, + "remaining_ms": remaining, + "zone": zone["name"], + "points": zone["points"], + } + state["presses"].append(press) + state["deadline"] = t + DURATION_MS + save_state(state) + return self.send_json(HTTPStatus.OK, { + "press": {k: press[k] for k in ("at", "remaining_ms", "zone", "points")}, + "deadline": state["deadline"], + "now": t, + }) + + self.send_json(HTTPStatus.NOT_FOUND, {"error": "Not found"}) + + +mailing_lock = threading.Lock() + + +def load_subscribed_emails(): + if not MAILING_LIST_FILE.exists(): + return set() + with MAILING_LIST_FILE.open("r", newline="", encoding="utf-8") as f: + return {row["email"].lower() for row in csv.DictReader(f)} + + +subscribed_emails = load_subscribed_emails() + + +def clean_text(value, max_len): + """Returns a whitespace-normalized string, "" for missing values, or None if invalid.""" + if value is None: + return "" + if not isinstance(value, str): + return None + value = " ".join(value.split()) + if len(value) > max_len or not value.isprintable(): + return None + return value + + +def validate_subscription(data): + first = clean_text(data.get("first_name"), 50) + last = clean_text(data.get("last_name"), 50) + email = clean_text(data.get("email"), 254) + phone = clean_text(data.get("phone"), 25) + if not first: + return None, "First name is required (max 50 characters)" + if last is None: + return None, "Last name must be at most 50 characters" + if not email or not EMAIL_RE.match(email): + return None, "A valid email address is required" + if phone is None or (phone and not PHONE_RE.match(phone)): + return None, "Phone number is invalid" + return {"first_name": first, "last_name": last, "email": email, "phone": phone}, None + + +def csv_safe(value): + # Stops spreadsheet apps from interpreting user input as a formula. + return "'" + value if value[:1] in ("=", "+", "-", "@") else value + + +def add_subscriber(entry): + """Must be called with mailing_lock held.""" + is_new_file = not MAILING_LIST_FILE.exists() + row = {k: csv_safe(v) for k, v in entry.items()} + row["subscribed_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + with MAILING_LIST_FILE.open("a", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=MAILING_LIST_FIELDS) + if is_new_file: + writer.writeheader() + writer.writerow(row) + subscribed_emails.add(entry["email"].lower()) + + +def end_watcher(): + while True: + with lock: + check_ended() + if state["ended"]: + return + wait = (state["deadline"] - now_ms()) / 1000 + time.sleep(min(max(wait, 0.05), 60)) + + +if __name__ == "__main__": + threading.Thread(target=end_watcher, daemon=True).start() + print(f"The Button running on http://{HOST}:{PORT} (timer: {DURATION_MS / 1000:.0f}s)") + ThreadingHTTPServer((HOST, PORT), Handler).serve_forever() diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..7e0caee --- /dev/null +++ b/static/app.js @@ -0,0 +1,273 @@ +"use strict"; + +const TOKEN_KEY = "thebutton-token"; +const $ = (id) => document.getElementById(id); + +let game = null; // latest /api/state payload +let clockOffset = 0; // serverTime - clientTime +let token = localStorage.getItem(TOKEN_KEY); +let statsLoaded = false; +let subscribed = false; + +async function api(method, path, body) { + const headers = { "Content-Type": "application/json" }; + if (token) headers.Authorization = `Bearer ${token}`; + const res = await fetch(path, { method, headers, body: body ? JSON.stringify(body) : undefined }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw Object.assign(new Error(data.error || res.statusText), { status: res.status }); + return data; +} + +function serverNow() { + return Date.now() + clockOffset; +} + +function formatMs(ms) { + ms = Math.max(0, ms); + const h = Math.floor(ms / 3600000); + const m = Math.floor((ms % 3600000) / 60000); + const s = Math.floor((ms % 60000) / 1000); + const cs = Math.floor((ms % 1000) / 10); + const pad = (n) => String(n).padStart(2, "0"); + return `${pad(h)}:${pad(m)}:${pad(s)}.${pad(cs)}`; +} + +function formatPrecise(ms) { + return `${formatMs(ms).slice(0, -3)}.${String(Math.max(0, ms) % 1000).padStart(3, "0")}`; +} + +function zoneFor(remaining) { + return game.zones.find((z) => remaining > z.remaining_to_ms) ?? game.zones[game.zones.length - 1]; +} + +function formatDuration(ms) { + const totalMin = Math.round(ms / 60000); + const h = Math.floor(totalMin / 60); + const m = totalMin % 60; + if (h && m) return `${h}h ${m}m`; + if (h) return `${h}h`; + if (m) return `${m}m`; + return `${Math.round(ms / 1000)}s`; +} + +function setZoneClass(el, zoneName) { + el.className = el.className.replace(/\bzone-\w+/g, "").trim(); + el.classList.add(`zone-${zoneName}`); +} + +function el(tag, text, className) { + const e = document.createElement(tag); + if (text !== undefined) e.textContent = text; + if (className) e.className = className; + return e; +} + +function fillTable(table, headers, rows) { + table.replaceChildren(); + const head = table.createTHead().insertRow(); + headers.forEach((h) => head.appendChild(el("th", h))); + const tbody = table.createTBody(); + rows.forEach((cells) => { + const tr = tbody.insertRow(); + cells.forEach((c) => { + const td = tr.insertCell(); + if (c instanceof Node) td.appendChild(c); + else td.textContent = c; + }); + }); +} + +function zoneSwatch(name) { + return el("span", name, `swatch zone-${name}`); +} + +function zoneRows(counts) { + return game.zones.map((z) => [zoneSwatch(z.name), z.points, counts[z.name] ?? 0]); +} + +function buildStaticUi() { + const bar = $("zone-bar"); + game.zones.forEach((z) => { + const seg = el("span", undefined, `zone-${z.name}`); + seg.style.flexGrow = z.remaining_from_ms - z.remaining_to_ms; + bar.insertBefore(seg, $("zone-marker")); + }); + $("rules-zones").replaceChildren(...game.zones.map((z) => { + const s = zoneSwatch(z.name); + s.textContent = `${z.name} (${z.points} pt${z.points === 1 ? "" : "s"}, ${formatDuration(z.remaining_from_ms - z.remaining_to_ms)})`; + return s; + })); +} + +async function refreshState() { + const t0 = Date.now(); + const s = await api("GET", "/api/state"); + const first = !game; + clockOffset = s.now - (t0 + Date.now()) / 2; + game = s; + if (first) buildStaticUi(); + if (game.ended && !statsLoaded) loadStats(); + updateButton(); +} + +function updateButton() { + $("the-button").disabled = !token || !game || game.ended; + $("register-section").hidden = !!token; + $("mailing-list").hidden = !token || subscribed; + $("my-subscribed").hidden = !token || !subscribed; +} + +function tick() { + if (game) { + const remaining = game.ended ? 0 : game.deadline - serverNow(); + const zone = zoneFor(remaining); + const cd = $("countdown"); + cd.textContent = formatMs(remaining); + setZoneClass(cd, zone.name); + setZoneClass($("the-button"), zone.name); + $("zone-label").textContent = game.ended ? "game over" : `${zone.name} zone \u00b7 ${zone.points} pt${zone.points === 1 ? "" : "s"}`; + const pct = Math.min(100, Math.max(0, ((game.duration_ms - remaining) / game.duration_ms) * 100)); + $("zone-marker").style.left = `${pct}%`; + if (!game.ended && remaining <= 0) refreshState().catch(() => {}); + } + requestAnimationFrame(tick); +} + +async function loadMe() { + if (!token) return; + try { + const me = await api("GET", "/api/me"); + $("me-section").hidden = false; + $("my-name").textContent = me.name; + $("my-points").textContent = me.total_points.toLocaleString(); + $("my-presses").textContent = me.press_count; + subscribed = me.subscribed; + updateButton(); + fillTable($("my-zones"), ["Zone", "Points each", "Your presses"], zoneRows(me.zone_counts)); + fillTable( + $("my-history"), + ["When", "Remaining", "Zone", "Points"], + me.presses.slice().reverse().map((p) => [ + new Date(p.at).toLocaleString(), formatPrecise(p.remaining_ms), zoneSwatch(p.zone), p.points, + ]), + ); + } catch (e) { + if (e.status === 401) { + token = null; + localStorage.removeItem(TOKEN_KEY); + updateButton(); + } + } +} + +async function loadStats() { + try { + const s = await api("GET", "/api/stats"); + statsLoaded = true; + $("stats-section").hidden = false; + const summary = $("stats-summary"); + summary.replaceChildren( + el("p", `Lasted ${formatMs(s.ended_at - s.started_at)} \u00b7 ${s.total_players} players \u00b7 ${s.total_presses} presses`), + ); + if (s.last_press) { + summary.appendChild(el("p", `Last press: ${s.last_press.name} with ${formatPrecise(s.last_press.remaining_ms)} remaining`)); + } + if (s.lowest_press) { + summary.appendChild(el("p", `Closest call: ${s.lowest_press.name} with ${formatPrecise(s.lowest_press.remaining_ms)} remaining`)); + } + fillTable($("stats-zones"), ["Zone", "Points each", "Presses"], zoneRows(s.zone_counts)); + fillTable( + $("stats-leaderboard"), + ["#", "Player", "Points", "Presses", ...game.zones.map((z) => z.name), "Best remaining"], + s.leaderboard.map((p, i) => [ + i + 1, p.name, p.total_points.toLocaleString(), p.press_count, + ...game.zones.map((z) => p.zone_counts[z.name]), + p.best_remaining_ms == null ? "-" : formatPrecise(p.best_remaining_ms), + ]), + ); + fillTable( + $("stats-log"), + ["When", "Player", "Remaining", "Zone", "Points"], + s.presses.map((p) => [ + new Date(p.at).toLocaleString(), p.name, formatPrecise(p.remaining_ms), zoneSwatch(p.zone), p.points, + ]), + ); + } catch { + // Not ended yet; ignore. + } +} + +$("the-button").addEventListener("click", async () => { + const btn = $("the-button"); + btn.disabled = true; + try { + const r = await api("POST", "/api/press"); + game.deadline = r.deadline; + $("press-result").textContent = + `You pressed in the ${r.press.zone} zone with ${formatPrecise(r.press.remaining_ms)} remaining: +${r.press.points} points`; + await loadMe(); + } catch (e) { + $("press-result").textContent = e.message; + await refreshState().catch(() => {}); + } finally { + updateButton(); + } +}); + +$("register-form").addEventListener("submit", async (ev) => { + ev.preventDefault(); + $("register-error").textContent = ""; + const action = ev.submitter?.value === "register" ? "register" : "login"; + try { + const r = await api("POST", `/api/${action}`, { + name: $("name-input").value, + password: $("password-input").value, + }); + token = r.token; + localStorage.setItem(TOKEN_KEY, token); + $("password-input").value = ""; + updateButton(); + await loadMe(); + } catch (e) { + $("register-error").textContent = e.message; + } +}); + +$("logout-button").addEventListener("click", async () => { + await api("POST", "/api/logout").catch(() => {}); + token = null; + subscribed = false; + localStorage.removeItem(TOKEN_KEY); + $("me-section").hidden = true; + $("press-result").textContent = ""; + $("subscribe-result").textContent = ""; + updateButton(); +}); + +$("subscribe-form").addEventListener("submit", async (ev) => { + ev.preventDefault(); + const result = $("subscribe-result"); + result.className = ""; + try { + await api("POST", "/api/subscribe", { + first_name: $("sub-first").value, + last_name: $("sub-last").value, + email: $("sub-email").value, + phone: $("sub-phone").value, + }); + ev.target.reset(); + result.textContent = ""; + subscribed = true; + updateButton(); + } catch (e) { + result.className = "error"; + result.textContent = e.message; + } +}); + +(async function init() { + await refreshState(); + await loadMe(); + requestAnimationFrame(tick); + setInterval(() => refreshState().catch(() => {}), 5000); +})(); diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..58714c1 --- /dev/null +++ b/static/index.html @@ -0,0 +1,87 @@ + + + + + + The Button + + + +
+

THE BUTTON

+ +
+
--:--:--.--
+
+
+
+ +
+ +

+
+ + + + + + + +
+

Rules

+
    +
  • A 24 hour countdown resets whenever anyone presses the button.
  • +
  • If the countdown reaches zero, the game is over forever.
  • +
  • The lower the countdown when you press, the more points you score.
  • +
  • Zones:
  • +
  • Each press scores the fixed point value of the zone it lands in.
  • +
  • Global stats are published when the game ends.
  • +
  • Inspired by the original Reddit "The Button" experiment.
  • +
+
+ + +
+ + + diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..64ac8f9 --- /dev/null +++ b/static/style.css @@ -0,0 +1,107 @@ +:root { + --green: #2ecc40; + --blue: #0074d9; + --yellow: #ffdc00; + --red: #ff4136; + --black: #000; + --white: #fff; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: #444; + color: #eee; + font-family: system-ui, sans-serif; +} + +main { + max-width: 720px; + margin: 0 auto; + padding: 1rem; + text-align: center; +} + +h1 { letter-spacing: 0.3em; } + +section { margin: 1.5rem 0; } + +#countdown { + font-family: ui-monospace, monospace; + font-size: clamp(2rem, 9vw, 4rem); + font-weight: bold; + background: none !important; + border: none !important; +} + +#zone-label { text-transform: uppercase; letter-spacing: 0.2em; } + +#zone-bar { + position: relative; + display: flex; + height: 14px; + margin: 0.75rem auto; + border: 1px solid #222; +} +#zone-bar span { flex: 1 1 0; } +#zone-marker { + position: absolute; + top: -6px; + width: 3px; + height: 26px; + background: #f0f; + transition: left 0.2s linear; +} + +#the-button { + width: 200px; + height: 200px; + border-radius: 50%; + font-size: 1.6rem; + font-weight: bold; + cursor: pointer; + border: 6px solid #222; + box-shadow: 0 8px 0 #222; + transition: transform 0.05s; +} +#the-button:active:not(:disabled) { transform: translateY(6px); box-shadow: 0 2px 0 #222; } +#the-button:disabled { cursor: not-allowed; opacity: 0.5; } + +.zone-green { background: var(--green); color: #000; } +.zone-blue { background: var(--blue); color: #fff; } +.zone-yellow { background: var(--yellow); color: #000; } +.zone-red { background: var(--red); color: #fff; } +.zone-black { background: var(--black); color: #fff; border-color: #888 !important; } +.zone-white { background: var(--white); color: #000; } + +#countdown.zone-green { color: var(--green); } +#countdown.zone-blue { color: var(--blue); } +#countdown.zone-yellow { color: var(--yellow); } +#countdown.zone-red { color: var(--red); } +#countdown.zone-black { color: var(--black); } +#countdown.zone-white { color: var(--white); } + +.swatch { + display: inline-block; + padding: 0 0.4em; + margin: 0 0.15em; + border: 1px solid #222; + border-radius: 3px; +} + +table { + margin: 0.5rem auto; + border-collapse: collapse; + min-width: 60%; +} +th, td { padding: 0.3rem 0.6rem; border-bottom: 1px solid #555; } + +input, form button { + font-size: 1rem; + padding: 0.4rem 0.8rem; +} + +.error { color: var(--red); } + +#rules { text-align: left; font-size: 0.9rem; color: #ccc; }