Home-screen web app; phone layout; reload on deploy

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-18 14:31:41 +01:00
parent 9058039eb7
commit 14b4c07208
8 changed files with 121 additions and 25 deletions

16
app.py
View file

@ -4,6 +4,7 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import hashlib
import json import json
import logging import logging
import mimetypes import mimetypes
@ -27,6 +28,19 @@ MAX_BODY = 16 * 1024
NAME = re.compile(r"^[\w .'-]{2,20}$") NAME = re.compile(r"^[\w .'-]{2,20}$")
TICK_SECONDS = 15 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") mimetypes.add_type("application/manifest+json", ".webmanifest")
@ -360,7 +374,7 @@ class Handler(BaseHTTPRequestHandler):
for e in reversed(world["events"]) if e["status"] == "done" for e in reversed(world["events"]) if e["status"] == "done"
][:10] ][:10]
return { 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, "me": player_view(world, player, now) if player else None,
"summary": summary, "summary": summary,
"city": city_view(world, now), "city": city_view(world, now),

View file

@ -14,6 +14,8 @@ let selected = null; // bank item
let pickedClass = null; // registration let pickedClass = null; // registration
let refreshing = false; let refreshing = false;
let refreshedFor = null; // action start a completion refresh was sent for 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 $ = (sel, root = document) => root.querySelector(sel);
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]); const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
@ -56,14 +58,22 @@ async function refresh() {
refreshing = true; refreshing = true;
try { try {
accept(await api("/api/state")); accept(await api("/api/state"));
failures = 0;
} catch (err) { } 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 { } finally {
refreshing = false; refreshing = false;
} }
} }
function accept(state) { 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; S = state;
skew = state.now - Date.now() / 1000; skew = state.now - Date.now() / 1000;
if (state.summary) showSummary(state.summary); 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" }); 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) { function ago(t) {
const s = serverNow() - t; const s = serverNow() - t;
if (s < 60) return "just now"; 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) => `<span data-countdown="${t}"></span>`;
// --- toasts & summaries --------------------------------------------------- // --- toasts & summaries ---------------------------------------------------
function toast(text, kind = "") { function toast(text, kind = "") {
@ -101,7 +117,8 @@ function toast(text, kind = "") {
el.textContent = text; el.textContent = text;
$("#toasts").append(el); $("#toasts").append(el);
setTimeout(() => el.remove(), kind === "level" ? 6000 : 3500); 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) { function showSummary(sum) {
@ -147,9 +164,9 @@ function render() {
for (const el of document.querySelectorAll("[data-keep]")) kept[el.dataset.keep] = el.value; for (const el of document.querySelectorAll("[data-keep]")) kept[el.dataset.keep] = el.value;
const focus = document.activeElement?.dataset?.keep; const focus = document.activeElement?.dataset?.keep;
$("#side").innerHTML = S.me ? sidebar() : `<div class="brand"><img src="/static/icon.svg" alt="">Guildhall <small>alpha</small></div>${S.dev ? devbar() : ""}`; setHtml($("#side"), S.me ? sidebar() : `<div class="brand"><img src="/static/icon.svg" alt="">Guildhall <small>alpha</small></div>${S.dev ? devbar() : ""}`);
$("#top").innerHTML = topbar(); setHtml($("#top"), topbar());
$("#main").innerHTML = S.me ? pageHtml() : register(); setHtml($("#main"), S.me ? pageHtml() : register());
for (const [key, value] of Object.entries(kept)) { for (const [key, value] of Object.entries(kept)) {
const el = document.querySelector(`[data-keep="${key}"]`); const el = document.querySelector(`[data-keep="${key}"]`);
@ -161,6 +178,21 @@ function render() {
animate(); 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 = "") { function nav(id, icon, label, meta = "", extra = "") {
return `<button class="nav ${page === id ? "on" : ""}" data-nav="${id}"><span class="ic">${icon}</span><span class="label">${label}</span>${extra}<span class="meta">${meta}</span></button>`; return `<button class="nav ${page === id ? "on" : ""}" data-nav="${id}"><span class="ic">${icon}</span><span class="label">${label}</span>${extra}<span class="meta">${meta}</span></button>`;
} }
@ -229,9 +261,8 @@ function eventIcon(kind) {
} }
function eventWhen(ev, short = false) { function eventWhen(ev, short = false) {
const now = serverNow(); if (ev.exact) return short ? `in ${countdown(ev.exact)}` : `${clock(ev.exact)} (in ${countdown(ev.exact)})`;
if (ev.exact) return short ? `in ${dur(ev.exact - now)}` : `${clock(ev.exact)} (in ${dur(ev.exact - now)})`; if (short) return ev.earliest <= serverNow() + 60 ? `within ${countdown(ev.latest)}` : `in ${countdown(ev.earliest)}–${countdown(ev.latest)}`;
if (short) return `in ${dur(ev.earliest - now)}–${dur(ev.latest - now)}`;
return `between ${clock(ev.earliest)} and ${clock(ev.latest)}`; return `between ${clock(ev.earliest)} and ${clock(ev.latest)}`;
} }
@ -397,11 +428,11 @@ function town() {
<div class="stat"><div class="k">Raids repelled</div><div class="v">${city.repelled}</div></div> <div class="stat"><div class="k">Raids repelled</div><div class="v">${city.repelled}</div></div>
</div>`; </div>`;
if (city.starving) html += `<div class="banner bad">🍞<div class="grow"><b>The granary is empty.</b> People are leaving and the city defends worse. Donate bread, cooked meat or stew.</div></div>`; if (city.starving) html += `<div class="banner bad">🍞<div class="grow"><b>The granary is empty.</b> People are leaving and the city defends worse. Donate bread, cooked meat or stew.</div></div>`;
if (city.merchant_until > serverNow()) html += `<div class="banner">🐪<div class="grow"><b>A merchant caravan is in town.</b> Everything sells for 50% more for another ${dur(city.merchant_until - serverNow())}.</div></div>`; if (city.merchant_until > serverNow()) html += `<div class="banner">🐪<div class="grow"><b>A merchant caravan is in town.</b> Everything sells for 50% more for another ${countdown(city.merchant_until)}.</div></div>`;
const work = city.construction; const work = city.construction;
if (work) { if (work) {
const spec = C.buildings[work.building]; const spec = C.buildings[work.building];
html += `<div class="banner">🏗️<div class="grow"><b>${work.repair ? "Repairing" : "Building"} ${esc(spec.name)}</b> · done in <span data-countdown="${work.done_at}">${dur(work.done_at - serverNow())}</span> html += `<div class="banner">🏗️<div class="grow"><b>${work.repair ? "Repairing" : "Building"} ${esc(spec.name)}</b> · done in ${countdown(work.done_at)}
<div class="bar gold" style="margin-top:6px"><span data-span="${work.start},${work.done_at}"></span></div></div></div>`; <div class="bar gold" style="margin-top:6px"><span data-span="${work.start},${work.done_at}"></span></div></div></div>`;
} }
html += `<div class="section-title">Buildings</div><div class="grid wide">`; html += `<div class="section-title">Buildings</div><div class="grid wide">`;
@ -575,16 +606,22 @@ document.addEventListener("click", (e) => {
if (!t) return; if (!t) return;
const d = t.dataset; const d = t.dataset;
if (t.matches("[data-close]") || e.target === $("#modal")) { $("#modal").hidden = true; return; } if (t.matches("[data-close]") || e.target === $("#modal")) { $("#modal").hidden = true; return; }
if ("menu" in d) { $("#app").classList.toggle("open"); return; } if ("menu" in d) { setMenu(!$("#app").classList.contains("open")); return; }
if (d.nav) { if (d.nav) {
page = d.nav; remember("page", page); selected = null; page = d.nav; remember("page", page); selected = null;
history.replaceState(null, "", "#" + page); history.replaceState(null, "", "#" + page);
$("#app").classList.remove("open"); setMenu(false);
render(); window.scrollTo(0, 0); return; render(); window.scrollTo(0, 0); return;
} }
if (d.start) return act("/api/action", { id: d.start }); if (d.start) return act("/api/action", { id: d.start });
if ("stop" in d) return act("/api/stop", {}); if ("stop" in d) return act("/api/stop", {});
if ("select" in d) { selected = d.select || null; render(); return; } if ("select" in d) {
selected = d.select || null;
render();
// On a phone the item panel sits under the bank grid: bring it into view.
if (selected && matchMedia("(max-width: 900px)").matches) $(".item-panel")?.scrollIntoView({ behavior: "smooth", block: "start" });
return;
}
const qty = d.qty === "all" ? "all" : Number(d.qty); const qty = d.qty === "all" ? "all" : Number(d.qty);
if (d.donate) return act("/api/donate", { item: d.donate, qty }); if (d.donate) return act("/api/donate", { item: d.donate, qty });
if (d.sell) return act("/api/sell", { item: d.sell, qty }); if (d.sell) return act("/api/sell", { item: d.sell, qty });

BIN
static/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
static/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
static/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

View file

@ -4,8 +4,14 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#14161d"> <meta name="theme-color" content="#14161d">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="Guildhall">
<title>Guildhall</title> <title>Guildhall</title>
<link rel="manifest" href="/static/manifest.webmanifest" crossorigin="use-credentials">
<link rel="icon" href="/static/icon.svg" type="image/svg+xml"> <link rel="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
<link rel="stylesheet" href="/static/styles.css"> <link rel="stylesheet" href="/static/styles.css">
</head> </head>
<body> <body>

View file

@ -0,0 +1,15 @@
{
"name": "Guildhall",
"short_name": "Guildhall",
"description": "A co-op idle game for a handful of friends.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#14161d",
"theme_color": "#14161d",
"icons": [
{ "src": "/static/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/static/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/static/icon.svg", "sizes": "any", "type": "image/svg+xml" }
]
}

View file

@ -24,7 +24,7 @@ body {
font: 15px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; font: 15px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
} }
button, input, textarea, select { font: inherit; color: inherit; } button, input, textarea, select { font: inherit; color: inherit; }
button { cursor: pointer; } button { cursor: pointer; touch-action: manipulation; }
a { color: var(--xp); } a { color: var(--xp); }
#app { display: flex; min-height: 100vh; } #app { display: flex; min-height: 100vh; }
@ -125,8 +125,8 @@ main { padding: 24px; max-width: 1200px; width: 100%; }
.section-title { margin: 26px 0 12px; } .section-title { margin: 26px 0 12px; }
.muted { color: var(--muted); } .muted { color: var(--muted); }
.small { font-size: 13px; } .small { font-size: 13px; }
.grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); } .grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(min(250px, 100%), 1fr)); }
.grid.wide { grid-template-columns: repeat(auto-fill, minmax(330px, 1fr)); } .grid.wide { grid-template-columns: repeat(auto-fill, minmax(min(330px, 100%), 1fr)); }
.cols { display: grid; gap: 18px; grid-template-columns: 1fr 1fr; } .cols { display: grid; gap: 18px; grid-template-columns: 1fr 1fr; }
.stats { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); margin-bottom: 18px; } .stats { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); margin-bottom: 18px; }
.stat { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 12px 14px; } .stat { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 12px 14px; }
@ -264,7 +264,7 @@ table.parts td:last-child { text-align: right; font-variant-numeric: tabular-num
.welcome { max-width: 760px; } .welcome { max-width: 760px; }
.welcome h2 { font-size: 26px; margin: 0 0 6px; } .welcome h2 { font-size: 26px; margin: 0 0 6px; }
.classes { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); margin: 16px 0; } .classes { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr)); margin: 16px 0; }
.class-card { .class-card {
text-align: left; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); text-align: left; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius);
padding: 14px; display: flex; gap: 12px; padding: 14px; display: flex; gap: 12px;
@ -275,7 +275,7 @@ table.parts td:last-child { text-align: right; font-variant-numeric: tabular-num
/* --- toasts and modal ------------------------------------------------- */ /* --- toasts and modal ------------------------------------------------- */
#toasts { position: fixed; right: 16px; bottom: 16px; display: flex; flex-direction: column; gap: 8px; z-index: 30; } #toasts { position: fixed; right: 16px; bottom: calc(16px + env(safe-area-inset-bottom)); display: flex; flex-direction: column; gap: 8px; z-index: 30; }
.toast { .toast {
background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px; padding: 8px 14px; background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px; padding: 8px 14px;
font-size: 14px; box-shadow: 0 6px 18px rgba(0, 0, 0, .35); animation: rise .2s ease-out; font-size: 14px; box-shadow: 0 6px 18px rgba(0, 0, 0, .35); animation: rise .2s ease-out;
@ -294,12 +294,36 @@ table.parts td:last-child { text-align: right; font-variant-numeric: tabular-num
#scrim { display: none; } #scrim { display: none; }
@media (max-width: 900px) { @media (max-width: 900px) {
#side { position: fixed; left: 0; top: 0; z-index: 20; transform: translateX(-100%); transition: transform .2s; } /* A drawer pinned to the screen edges (not 100vh, which iOS sizes for the
#app.open #side { transform: none; } largest viewport) that keeps its scrolling to itself. */
#app.open #scrim { display: block; position: fixed; inset: 0; z-index: 15; background: rgba(0, 0, 0, .5); } #side {
.menu-btn { display: block; } position: fixed; left: 0; top: 0; bottom: 0; height: auto; z-index: 20;
#top { padding: 10px 16px; flex-wrap: wrap; } width: min(280px, 85vw);
main { padding: 16px; } transform: translateX(-100%); transition: transform .2s;
.cols, .bank-wrap { grid-template-columns: 1fr; } overscroll-behavior: contain; -webkit-overflow-scrolling: touch;
.chip.act .label { max-width: 110px; overflow: hidden; text-overflow: ellipsis; } padding-top: env(safe-area-inset-top);
padding-bottom: calc(24px + env(safe-area-inset-bottom));
}
#app.open #side { transform: none; box-shadow: 0 0 40px rgba(0, 0, 0, .5); }
#app.open #scrim { display: block; position: fixed; inset: 0; z-index: 15; background: rgba(0, 0, 0, .5); }
html.menu-open, html.menu-open body { overflow: hidden; }
.menu-btn { display: block; }
#top { padding: 8px max(16px, env(safe-area-inset-right)) 8px max(16px, env(safe-area-inset-left)); flex-wrap: wrap; gap: 8px; }
#top h1 { flex: 1 1 auto; }
main { padding: 16px max(16px, env(safe-area-inset-right)) calc(32px + env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left)); }
.cols, .bank-wrap { grid-template-columns: 1fr; }
.chip { max-width: 100%; }
.chip.act .label { max-width: 120px; overflow: hidden; text-overflow: ellipsis; }
#toasts { left: 16px; right: 16px; }
.stats { grid-template-columns: repeat(2, 1fr); }
.skill-head { flex-wrap: wrap; }
.skill-head .info { flex-basis: 60%; }
.slots { grid-template-columns: repeat(auto-fill, minmax(64px, 1fr)); }
.levels { grid-template-columns: repeat(2, 1fr); }
/* iOS zooms into any input under 16px when it's focused. */
input[type=text], input[type=number], textarea, select { font-size: 16px; }
}
@media (pointer: coarse) {
.nav { padding: 11px 18px; }
.btn.sm { padding: 6px 11px; }
} }