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() : `
Guildhall alpha
${S.dev ? devbar() : ""}`; - $("#top").innerHTML = topbar(); - $("#main").innerHTML = S.me ? pageHtml() : register(); + setHtml($("#side"), S.me ? sidebar() : `
Guildhall alpha
${S.dev ? devbar() : ""}`); + setHtml($("#top"), topbar()); + setHtml($("#main"), S.me ? pageHtml() : register()); for (const [key, value] of Object.entries(kept)) { const el = document.querySelector(`[data-keep="${key}"]`); @@ -161,6 +178,21 @@ function render() { animate(); } +// Only touch the DOM when the markup changed. Replacing an element mid-swipe +// cancels the swipe on iOS, and polls arrive every few seconds. +function setHtml(el, html) { + if (el.dataset.html === html) return; + const scroll = el.scrollTop; + el.innerHTML = html; + el.dataset.html = html; + el.scrollTop = scroll; +} + +function setMenu(open) { + $("#app").classList.toggle("open", open); + document.documentElement.classList.toggle("menu-open", open); +} + function nav(id, icon, label, meta = "", extra = "") { return ``; } @@ -229,9 +261,8 @@ function eventIcon(kind) { } function eventWhen(ev, short = false) { - const now = serverNow(); - if (ev.exact) return short ? `in ${dur(ev.exact - now)}` : `${clock(ev.exact)} (in ${dur(ev.exact - now)})`; - if (short) return `in ${dur(ev.earliest - now)}–${dur(ev.latest - now)}`; + if (ev.exact) return short ? `in ${countdown(ev.exact)}` : `${clock(ev.exact)} (in ${countdown(ev.exact)})`; + if (short) return ev.earliest <= serverNow() + 60 ? `within ${countdown(ev.latest)}` : `in ${countdown(ev.earliest)}–${countdown(ev.latest)}`; return `between ${clock(ev.earliest)} and ${clock(ev.latest)}`; } @@ -397,11 +428,11 @@ function town() {
Raids repelled
${city.repelled}
`; if (city.starving) html += ``; - if (city.merchant_until > serverNow()) html += ``; + if (city.merchant_until > serverNow()) html += ``; const work = city.construction; if (work) { const spec = C.buildings[work.building]; - html += `