diff --git a/app.py b/app.py index 5a3ce6a..675ec0c 100644 --- a/app.py +++ b/app.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import hashlib import json import logging import mimetypes @@ -27,6 +28,19 @@ MAX_BODY = 16 * 1024 NAME = re.compile(r"^[\w .'-]{2,20}$") TICK_SECONDS = 15 + +def build_version() -> str: + """Changes whenever the code or client does; open tabs reload when it does.""" + digest = hashlib.sha256() + for path in sorted([APP_DIR / "app.py", APP_DIR / "game.py", *STATIC_DIR.rglob("*")]): + if path.is_file(): + digest.update(path.name.encode()) + digest.update(path.read_bytes()) + return digest.hexdigest()[:12] + + +VERSION = build_version() + mimetypes.add_type("application/manifest+json", ".webmanifest") @@ -360,7 +374,7 @@ class Handler(BaseHTTPRequestHandler): for e in reversed(world["events"]) if e["status"] == "done" ][:10] return { - "now": now, "dev": self.dev, "uid": uid, + "now": now, "dev": self.dev, "uid": uid, "version": VERSION, "me": player_view(world, player, now) if player else None, "summary": summary, "city": city_view(world, now), diff --git a/static/app.js b/static/app.js index fc8848c..7fc8688 100644 --- a/static/app.js +++ b/static/app.js @@ -14,6 +14,8 @@ let selected = null; // bank item let pickedClass = null; // registration let refreshing = false; let refreshedFor = null; // action start a completion refresh was sent for +let version = null; // server build this page was loaded from +let failures = 0; // polls in a row that didn't reach the server const $ = (sel, root = document) => root.querySelector(sel); const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]); @@ -56,14 +58,22 @@ async function refresh() { refreshing = true; try { accept(await api("/api/state")); + failures = 0; } catch (err) { - toast(err.message, "bad"); + // A deploy restarts the server for a second or two; only speak up if + // the city stays out of reach. + if (++failures === 3) toast("Lost contact with the city. Retrying…", "bad"); } finally { refreshing = false; } } function accept(state) { + version ??= state.version; + if (state.version !== version && !document.querySelector("[data-keep]:focus") && $("#modal").hidden) { + location.reload(); // a new build was deployed + return; + } S = state; skew = state.now - Date.now() / 1000; if (state.summary) showSummary(state.summary); @@ -87,12 +97,18 @@ function clock(t) { return new Date((t - skew) * 1000).toLocaleString("en-GB", { weekday: "short", hour: "2-digit", minute: "2-digit" }); } +// Coarse on purpose: the markup stays the same between polls (see setHtml). function ago(t) { const s = serverNow() - t; if (s < 60) return "just now"; - return dur(s) + " ago"; + if (s < 3600) return `${Math.floor(s / 60)}m ago`; + if (s < 86400) return `${Math.floor(s / 3600)}h ago`; + return `${Math.floor(s / 86400)}d ago`; } +// A live "3h 12m" until `t`, filled in and kept current by animate(). +const countdown = (t) => ``; + // --- toasts & summaries --------------------------------------------------- function toast(text, kind = "") { @@ -101,7 +117,8 @@ function toast(text, kind = "") { el.textContent = text; $("#toasts").append(el); setTimeout(() => el.remove(), kind === "level" ? 6000 : 3500); - while ($("#toasts").children.length > 5) $("#toasts").firstChild.remove(); + const keep = matchMedia("(max-width: 900px)").matches ? 2 : 4; + while ($("#toasts").children.length > keep) $("#toasts").firstChild.remove(); } function showSummary(sum) { @@ -147,9 +164,9 @@ function render() { for (const el of document.querySelectorAll("[data-keep]")) kept[el.dataset.keep] = el.value; const focus = document.activeElement?.dataset?.keep; - $("#side").innerHTML = S.me ? sidebar() : `