This commit is contained in:
2026-10-21 14:02:50 -04:00
parent 69d49073f0
commit 613647cb50
6 changed files with 580 additions and 22 deletions
+16 -1
View File
@@ -1,6 +1,6 @@
# The Button
A web game of patience and nerve. A 24-hour countdown resets every time any player presses the button. If the countdown ever reaches zero, the game is over forever, and the final global stats are published.
A web game of patience and nerve. A 24-hour countdown resets every time any player presses the button. If it reaches zero, that season's game ends and final stats are published. Each equinox and solstice brings a fresh season.
Inspired by Reddit's [The Button](https://en.wikipedia.org/wiki/The_Button_(Reddit)) experiment.
@@ -31,6 +31,20 @@ The Chat section is a simple public chatroom. Logged-in players can post plain-t
Every successful button press also posts an automatic chat announcement with the player's name, exact time remaining, and points earned. These announcements are saved with the press and do not count toward the player's chat cooldown.
## Seasons and champions
Seasons begin at **00:00 UTC on the UTC date of each astronomical equinox or solstice**, not at the event's exact hour and not on fixed March/June/September/December dates. They are displayed as **Season I, Season II, Season III**, etc., including in the subtitle under **THE BUTTON**. Numbering begins at the earliest saved season (including existing archives on upgrade); its origin is persisted so numbering remains stable across restarts. Each astronomical boundary advances the number, including seasons missed during downtime. Date-based internal IDs and start/end dates remain unchanged. The built-in Meeus equinox/solstice calculation (with periodic and Delta-T corrections) supports 2000–2100 without network access or dependencies. The 2026 boundaries are March 20, June 21, September 23, and December 21; the December 2027 boundary is December 22. Calendar tests check these against USNO data. The far-future calendar is an astronomical estimate; extend/revalidate it before 2101.
At each boundary the server archives the old season and starts a fresh countdown anchored to that midnight. Seasonal points, press history, personal bests, achievements, and milestone progress reset. Accounts, password hashes, login sessions, mailing-list subscriptions, and chat are preserved. Previous press histories remain stored in the season archive; public archives expose only score summaries, not private per-player histories. Old milestone announcements remain visible in chat.
A season finishes when the countdown expires **or** the next seasonal boundary is reached. Pressing cannot extend the timer beyond that boundary. If the timer expires early, pressing and new registrations remain unavailable until the next season; existing players can still log in and chat. The watcher continues running and also catches up boundaries after downtime. A missed season starts at its scheduled midnight, not when the server restarts: without presses its countdown may already be over. Missed seasons have no invented winners.
The **Seasons** section shows the current season and next start, a champions table, and expandable final standings. Each result shows the winner(s), points per winner, and how long play lasted from actual game start to timeout/boundary (not the calendar season's full length). Equal top scores share the win; alphabetical ordering only orders the display. A player must have pressed at least once to win; a zero-point press qualifies, but an idle account does not. A season without presses has no winner.
**Upgrade behavior:** the existing JSON/SQLite game is adopted unchanged into the calendar season containing the upgrade time, including all old scores and presses. Its actual original start is retained for duration reporting, even if it predates that calendar season. A brand-new installation similarly starts a partial first season immediately rather than fabricating past play. All subsequent seasons begin at the scheduled UTC midnight. Archives and current-season metadata are added automatically in SQLite; no manual conversion is needed. Keep a database backup before deployment. The first watcher/state request archives a game that was already ended; historical seasons are not reconstructed from an undivided pre-season game.
Public `GET /api/seasons` returns `{current, seasons}` with current boundary metadata/standings and immutable finished results. `GET /api/state` includes `season` metadata. The main leaderboard, achievements, rivals, and final statistics refer to the current season. Client views refresh across seasonal boundaries without a reload; local alert preferences remain enabled but alerts are re-baselined for the new season. One-life mode is still separate and unimplemented.
## Engagement roadmap
The goal is to make waiting social and consequential without rewarding button spam or changing the current game's rules. Planned delivery order:
@@ -169,6 +183,7 @@ Tip: `BUTTON_DURATION_SECONDS=60` makes for quick testing.
| POST | `/api/press` | ✓ | Press the button |
| GET | `/api/me` | ✓ | Your score, zone counts, and press history |
| GET | `/api/leaderboard` | | Public player names, total points, and press counts |
| GET | `/api/seasons` | | Current season and archived champions, scores, durations, and standings |
| GET | `/api/chat` | | Latest 100 public chat messages |
| POST | `/api/chat` | ✓ | `{message}` → saved message; 200-character limit |
| GET | `/api/presence` | | Opted-in online count and chat display names |
+233 -15
View File
@@ -4,6 +4,7 @@ import csv
import hashlib
import hmac
import json
import math
import os
import re
import secrets
@@ -11,6 +12,8 @@ import sqlite3
import threading
import time
from contextlib import contextmanager
from datetime import datetime, timezone
from functools import lru_cache
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
@@ -74,6 +77,107 @@ def now_ms():
return int(time.time() * 1000)
@lru_cache(maxsize=64)
def season_boundaries(year):
"""UTC midnights of astronomical events (Meeus, Astronomical Algorithms ch. 27).
Uses the 2000-era equinox/solstice polynomials and periodic correction, then
a Delta-T estimate to convert terrestrial time to UTC before taking the date.
Dates, not the event's hour, define the game boundary. Supports 2000-2100;
2026/2027 UTC dates are regression checked against USNO data.
"""
if not 1999 <= year <= 2101:
raise ValueError("Season calendar supports years 2000-2100")
y = (year - 2000) / 1000
coefficients = (
(2451623.80984, 365242.37404, .05169, -.00411, -.00057),
(2451716.56767, 365241.62603, .00325, .00888, -.00030),
(2451810.21715, 365242.01767, -.11575, .00337, .00078),
(2451900.05952, 365242.74049, -.06223, -.00823, .00032),
)
terms = (
(485, 324.96, 1934.136), (203, 337.23, 32964.467), (199, 342.08, 20.186),
(182, 27.85, 445267.112), (156, 73.14, 45036.886), (136, 171.52, 22518.443),
(77, 222.54, 65928.934), (74, 296.72, 3034.906), (70, 243.58, 9037.513),
(58, 119.81, 33718.147), (52, 297.17, 150.678), (50, 21.02, 2281.226),
(45, 247.54, 29929.562), (44, 325.15, 31555.956), (29, 60.93, 4443.417),
(18, 155.12, 67555.328), (17, 288.79, 4562.452), (16, 198.04, 62894.029),
(14, 199.76, 31436.921), (12, 95.39, 14577.848), (12, 287.11, 31931.756),
(12, 320.81, 34777.259), (9, 227.73, 1222.114), (8, 15.45, 16859.074),
)
result = []
for index, coefficients_for_event in enumerate(coefficients):
jde = sum(c * y ** power for power, c in enumerate(coefficients_for_event))
t = (jde - 2451545) / 36525
w = math.radians(35999.373 * t - 2.47)
correction = sum(a * math.cos(math.radians(b + c * t)) for a, b, c in terms)
jde += .00001 * correction / (1 + .0334 * math.cos(w) + .0007 * math.cos(2 * w))
decimal_year = year + (index * 3 + 2.5) / 12
u = decimal_year - 2000
if decimal_year < 2005:
delta_t = 63.86 + .3345 * u - .060374 * u**2 + .0017275 * u**3 + .000651814 * u**4 + .00002373599 * u**5
elif decimal_year < 2050:
delta_t = 62.92 + .32217 * u + .005589 * u**2
else:
delta_t = -20 + 32 * ((decimal_year - 1820) / 100)**2 - .5628 * (2150 - decimal_year)
event = datetime.fromtimestamp((jde - 2440587.5) * 86400 - delta_t, timezone.utc)
midnight = event.replace(hour=0, minute=0, second=0, microsecond=0)
result.append({"id": midnight.strftime("%Y-%m-%d"),
"name": f"{('March equinox', 'June solstice', 'September equinox', 'December solstice')[index]} {year}",
"starts_at": int(midnight.timestamp() * 1000)})
return tuple(result)
def season_for_time(at):
year = datetime.fromtimestamp(at / 1000, timezone.utc).year
if not 2000 <= year <= 2100:
raise ValueError("Season calendar supports years 2000-2100")
boundaries = [entry for y in (year - 1, year, year + 1) for entry in season_boundaries(y)]
index = max(i for i, entry in enumerate(boundaries) if entry["starts_at"] <= at)
return {**boundaries[index], "ends_at": boundaries[index + 1]["starts_at"]}
def ensure_season(connection, at=None):
"""Adopt the existing game into the current season without deleting its history."""
if connection.execute("SELECT 1 FROM season_clock WHERE singleton = 1").fetchone() is None:
season = season_for_time(now_ms() if at is None else at)
connection.execute(
"INSERT INTO season_clock (singleton, id, name, starts_at, ends_at) VALUES (1, ?, ?, ?, ?)",
(season["id"], season["name"], season["starts_at"], season["ends_at"]),
)
connection.execute("""INSERT OR IGNORE INTO season_numbering (singleton, origin_at)
SELECT 1, MIN(starts_at) FROM (
SELECT starts_at FROM season_clock UNION ALL SELECT starts_at FROM season_results
)""")
def roman_numeral(number):
if number < 1:
raise ValueError("Season numbers must be positive")
result = []
for value, symbol in ((1000, "M"), (900, "CM"), (500, "D"), (400, "CD"), (100, "C"),
(90, "XC"), (50, "L"), (40, "XL"), (10, "X"), (9, "IX"),
(5, "V"), (4, "IV"), (1, "I")):
count, number = divmod(number, value)
result.append(symbol * count)
return "".join(result)
def numbered_season(connection, season):
origin = connection.execute("SELECT origin_at FROM season_numbering WHERE singleton = 1").fetchone()[0]
def ordinal(at):
date = datetime.fromtimestamp(at / 1000, timezone.utc)
return date.year * 4 + (date.month - 3) // 3
number = ordinal(season["starts_at"]) - ordinal(origin) + 1
return {**season, "number": number, "name": f"Season {roman_numeral(number)}"}
def current_season(connection):
row = dict(connection.execute("SELECT * FROM season_clock WHERE singleton = 1").fetchone())
del row["singleton"]
return numbered_season(connection, row)
def hash_token(token):
return hashlib.sha256(token.encode("utf-8")).hexdigest()
@@ -239,6 +343,24 @@ def load_state():
announced_at INTEGER,
message TEXT
);
CREATE TABLE IF NOT EXISTS season_clock (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
id TEXT NOT NULL, name TEXT NOT NULL,
starts_at INTEGER NOT NULL, ends_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS season_results (
id TEXT PRIMARY KEY, name TEXT NOT NULL,
starts_at INTEGER NOT NULL, ends_at INTEGER NOT NULL,
played_from INTEGER NOT NULL, finished_at INTEGER NOT NULL,
reason TEXT NOT NULL, leaderboard TEXT NOT NULL, presses TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS season_milestone_messages (
id TEXT PRIMARY KEY, at INTEGER NOT NULL, message TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS season_numbering (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
origin_at INTEGER NOT NULL
);
""")
columns = {row["name"] for row in connection.execute("PRAGMA table_info(chat_messages)")}
if "kind" not in columns:
@@ -260,6 +382,7 @@ def load_state():
# JSON source or committing a partial game. The next start can retry.
write_state(connection, loaded)
sync_milestones(connection, loaded)
ensure_season(connection)
return loaded
loaded = dict(game)
@@ -280,6 +403,7 @@ def load_state():
del press["position"]
loaded["presses"].append(press)
sync_milestones(connection, loaded)
ensure_season(connection)
return loaded
@@ -322,6 +446,9 @@ def chat_messages():
UNION ALL
SELECT 'milestone:' || id, 'The Button', announced_at, message, 'milestone', 1, rowid
FROM community_milestones WHERE announced_at IS NOT NULL
UNION ALL
SELECT id, 'The Button', at, message, 'milestone', 1, rowid
FROM season_milestone_messages
) ORDER BY at DESC, source DESC, position DESC LIMIT ?""",
(CHAT_HISTORY_LIMIT,),
).fetchall()
@@ -361,15 +488,96 @@ def validate_credentials(data):
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)
else:
with database() as connection:
sync_milestones(connection, state)
def archive_season(connection, season, game_state, finished_at, reason):
"""Immutable score/history snapshots have no FK to mutable player rows."""
if connection.execute("SELECT 1 FROM season_results WHERE id = ?", (season["id"],)).fetchone():
return
scores = {pid: {"name": p["name"], "total_points": 0, "press_count": 0}
for pid, p in game_state["players"].items()}
for press in game_state["presses"]:
scores[press["player_id"]]["total_points"] += press["points"]
scores[press["player_id"]]["press_count"] += 1
standings = sorted(scores.values(), key=lambda p: (-p["total_points"], p["name"].lower()))
connection.execute(
"""INSERT OR IGNORE INTO season_results
(id, name, starts_at, ends_at, played_from, finished_at, reason, leaderboard, presses)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(season["id"], season["name"], season["starts_at"], season["ends_at"],
game_state["started_at"], max(game_state["started_at"], finished_at), reason,
json.dumps(standings), json.dumps(game_state["presses"])),
)
def check_ended(at=None):
"""Advance season and game atomically; caller must hold the game lock."""
t = now_ms() if at is None else at
replacement = None
with database() as connection:
connection.execute("BEGIN IMMEDIATE")
season = current_season(connection)
while t >= season["ends_at"]:
previous = replacement if replacement is not None else state
finish = min(previous["ended_at"] if previous["ended_at"] is not None else previous["deadline"], season["ends_at"])
archive_season(connection, season, previous, finish,
"countdown" if finish < season["ends_at"] else "boundary")
# Preserve old milestone announcements before resetting seasonal goals.
connection.execute(
"""INSERT OR IGNORE INTO season_milestone_messages (id, at, message)
SELECT ? || id, announced_at, message FROM community_milestones
WHERE announced_at IS NOT NULL""", (f"milestone:{season['id']}:",),
)
connection.execute("DELETE FROM community_milestones")
season = season_for_time(season["ends_at"])
connection.execute(
"UPDATE season_clock SET id = ?, name = ?, starts_at = ?, ends_at = ? WHERE singleton = 1",
(season["id"], season["name"], season["starts_at"], season["ends_at"]),
)
replacement = {**previous, "duration_ms": DURATION_MS, "started_at": season["starts_at"],
"deadline": season["starts_at"] + DURATION_MS, "ended": False,
"ended_at": None, "presses": []}
current = replacement if replacement is not None else state
if not current["ended"] and current["deadline"] > season["ends_at"]:
current = {**current, "deadline": season["ends_at"]}
replacement = current
if not current["ended"] and t >= current["deadline"]:
current = {**current, "ended": True, "ended_at": current["deadline"]}
replacement = current
if replacement is not None:
write_state(connection, current)
sync_milestones(connection, current, min(t, season["ends_at"]))
if current["ended"]:
archive_season(connection, season, current, current["ended_at"],
"boundary" if current["ended_at"] == season["ends_at"] else "countdown")
if replacement is not None:
state.clear()
state.update(replacement)
def seasonal_leaderboard():
with database() as connection:
season = current_season(connection)
rows = connection.execute("""SELECT id, name, starts_at, ends_at, played_from,
finished_at, reason, leaderboard FROM season_results ORDER BY starts_at DESC""").fetchall()
rows = [numbered_season(connection, dict(row)) for row in rows]
archives = []
for row in rows:
standings = json.loads(row["leaderboard"])
participants = [player for player in standings if player["press_count"] > 0]
top = max((p["total_points"] for p in participants), default=None)
archives.append({
"id": row["id"], "name": row["name"], "number": row["number"], "starts_at": row["starts_at"],
"ends_at": row["ends_at"], "played_from": row["played_from"], "finished_at": row["finished_at"],
"duration_ms": max(0, row["finished_at"] - row["played_from"]), "reason": row["reason"],
"winners": [p for p in participants if p["total_points"] == top],
"leaderboard": standings,
})
return {"current": {**season, "played_from": state["started_at"], "ended": state["ended"],
"leaderboard": public_leaderboard()}, "seasons": archives}
def season_summary():
with database() as connection:
return {**current_season(connection), "played_from": state["started_at"], "ended": state["ended"]}
def empty_zone_counts():
@@ -590,9 +798,15 @@ class Handler(BaseHTTPRequestHandler):
"total_players": len(state["players"]),
"total_presses": len(state["presses"]),
"zones": ZONES,
"season": season_summary(),
})
if path == "/api/seasons":
with lock:
check_ended()
return self.send_json(HTTPStatus.OK, seasonal_leaderboard())
if path == "/api/leaderboard":
with lock:
check_ended()
return self.send_json(HTTPStatus.OK, {"leaderboard": public_leaderboard()})
if path == "/api/chat":
return self.send_json(HTTPStatus.OK, {"messages": chat_messages()})
@@ -601,6 +815,7 @@ class Handler(BaseHTTPRequestHandler):
return self.send_json(HTTPStatus.OK, public_presence())
if path == "/api/me":
with lock:
check_ended()
pid = self.authed_player()
if pid is None:
return self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Not registered"})
@@ -759,10 +974,10 @@ class Handler(BaseHTTPRequestHandler):
pid = self.authed_player()
if pid is None:
return self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Not registered"})
check_ended()
t = now_ms()
check_ended(t)
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 = {
@@ -773,7 +988,9 @@ class Handler(BaseHTTPRequestHandler):
"points": zone["points"],
}
state["presses"].append(press)
state["deadline"] = t + DURATION_MS
with database() as connection:
season_end = current_season(connection)["ends_at"]
state["deadline"] = min(t + DURATION_MS, season_end)
hours, remainder = divmod(max(0, remaining), 3600000)
minutes, remainder = divmod(remainder, 60000)
seconds, milliseconds = divmod(remainder, 1000)
@@ -866,9 +1083,10 @@ def end_watcher():
while True:
with lock:
check_ended()
if state["ended"]:
return
wait = (state["deadline"] - now_ms()) / 1000
with database() as connection:
next_boundary = current_season(connection)["ends_at"]
wake_at = next_boundary if state["ended"] else min(state["deadline"], next_boundary)
wait = (wake_at - now_ms()) / 1000
time.sleep(min(max(wait, 0.05), 60))
+61 -1
View File
@@ -218,6 +218,7 @@ function renderSections() {
const available = {
"account-section": true,
"leaderboard-section": true,
"seasons-section": true,
"chat-section": true,
"alerts-section": true,
"stats-section": statsLoaded,
@@ -251,6 +252,7 @@ $("section-nav").addEventListener("click", (event) => {
if (selectedSection === "account-section") loadMe();
if (selectedSection === "leaderboard-section") loadLeaderboard();
if (selectedSection === "chat-section") loadChat();
if (selectedSection === "seasons-section") loadSeasons();
syncPresence();
});
@@ -387,8 +389,20 @@ async function refreshState() {
const t0 = Date.now();
const s = await api("GET", "/api/state");
const first = !game;
const changedSeason = game && game.season?.id !== s.season?.id;
if (changedSeason) {
statsLoaded = false;
currentShareText = "";
alertObservation = null;
$("press-result").textContent = "";
$("me-section").hidden = true;
}
clockOffset = s.now - (t0 + Date.now()) / 2;
game = s;
if (s.season) {
$("season-subtitle").textContent = s.season.name;
$("season-label").textContent = `${s.ended ? "Season finished. " : ""}Next season: ${new Date(s.season.ends_at).toISOString().slice(0, 10)} UTC.`;
}
observeZoneAlerts(s);
$("history-global-summary").textContent =
`There have been ${s.total_presses.toLocaleString()} total presses and there are ${s.total_players.toLocaleString()} total players.`;
@@ -399,6 +413,7 @@ async function refreshState() {
await loadLeaderboard();
if (selectedSection === "chat-section") await loadChat();
if (selectedSection === "account-section") await loadMe();
if (selectedSection === "seasons-section") await loadSeasons();
}
function updateButton() {
@@ -417,7 +432,7 @@ function tick() {
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"}`;
$("zone-label").textContent = game.ended ? "season finished" : `${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(() => {});
@@ -513,6 +528,49 @@ async function loadMe() {
}
}
function seasonDuration(ms) {
const days = Math.floor(ms / 86400000);
return `${days ? `${days}d ` : ""}${formatMs(ms % 86400000).slice(0, -3)}`;
}
async function loadSeasons() {
try {
const result = await api("GET", "/api/seasons");
const current = result.current;
$("season-current").replaceChildren(
el("p", "CURRENT SEASON", "eyebrow"), el("h3", current.name),
el("p", `${current.ended ? "Finished - next countdown" : "Next season"}: ${new Date(current.ends_at).toISOString().slice(0, 10)} at 00:00 UTC`, "fine-print"),
);
$("seasons-status").textContent = result.seasons.length ? "" : "No finished seasons yet. This chapter is still being written.";
$("season-winners").hidden = result.seasons.length === 0;
fillTable($("season-winners"), ["Season", "Winner(s)", "Points each", "Lasted"], result.seasons.map((season) => [
season.name, season.winners.map((p) => p.name).join(", ") || "No winner (no presses)",
season.winners.length ? season.winners[0].total_points.toLocaleString() : "--", seasonDuration(season.duration_ms),
]));
const container = $("season-standings");
// Do not replace focused/open archive details on every poll.
const signature = JSON.stringify(result.seasons);
if (container.dataset.signature !== signature) {
container.dataset.signature = signature;
container.replaceChildren(...result.seasons.map((season) => {
const details = el("details");
details.append(el("summary", `${season.name} - final standings`));
details.append(el("p", `Played from ${new Date(season.played_from).toLocaleString()} until ${new Date(season.finished_at).toLocaleString()}. Ended by ${season.reason === "boundary" ? "the next season" : "the countdown"}.`, "fine-print"));
const wrapper = el("div", undefined, "table-scroll");
const table = el("table");
fillTable(table, ["#", "Player", "Points", "Presses"], season.leaderboard.map((player, index) => [
index + 1, player.name, player.total_points.toLocaleString(), player.press_count.toLocaleString(),
]));
wrapper.append(table);
details.append(wrapper);
return details;
}));
}
} catch {
$("seasons-status").textContent = "Seasons could not be refreshed. Retrying automatically...";
}
}
async function loadLeaderboard() {
try {
const { leaderboard } = await api("GET", "/api/leaderboard");
@@ -592,8 +650,10 @@ $("chat-form").addEventListener("submit", async (event) => {
});
async function loadStats() {
const seasonId = game?.season?.id;
try {
const s = await api("GET", "/api/stats");
if (game?.season?.id !== seasonId) return;
statsLoaded = true;
renderSections();
const summary = $("stats-summary");
+21 -3
View File
@@ -19,6 +19,7 @@
<div class="game-panel">
<p class="eyebrow game-eyebrow">A SHARED EXPERIMENT</p>
<h1>THE BUTTON</h1>
<p id="season-subtitle" class="season-subtitle">Loading season&hellip;</p>
<p class="game-tagline">One clock. Everyone's nerve.</p>
<section id="timer-section">
@@ -27,6 +28,7 @@
<div id="zone-bar"><div id="zone-marker"></div></div>
<p id="history-global-summary" aria-live="polite"></p>
<p id="presence-count" class="presence-count" role="status">Checking who's here&hellip;</p>
<p id="season-label" class="season-label"></p>
</section>
<section id="button-section">
@@ -41,6 +43,7 @@
<nav id="section-nav" aria-label="Game sections">
<button type="button" data-section="account-section" aria-controls="account-section" aria-expanded="false">Log in / Join</button>
<button type="button" data-section="leaderboard-section" aria-controls="leaderboard-section" aria-expanded="false">Leaderboard</button>
<button type="button" data-section="seasons-section" aria-controls="seasons-section" aria-expanded="false">Seasons</button>
<button type="button" data-section="chat-section" aria-controls="chat-section" aria-expanded="false">Chat</button>
<button type="button" data-section="alerts-section" aria-controls="alerts-section" aria-expanded="false">Alerts</button>
<button type="button" data-section="stats-section" aria-controls="stats-section" aria-expanded="false" hidden>Final stats</button>
@@ -58,6 +61,21 @@
<p class="welcome-hint">Choose a section above to join in, compare scores, or say hello.</p>
</div>
<section id="seasons-section" data-panel hidden aria-labelledby="seasons-heading">
<header class="panel-header"><p class="eyebrow">A NEW CHAPTER, FOUR TIMES A YEAR</p>
<h2 id="seasons-heading">The seasonal leaderboard.</h2>
<p class="panel-description">New scores. Familiar faces. Every equinox and solstice brings a fresh start.</p>
</header>
<div class="panel-body">
<div id="season-current" class="rival-card"></div>
<h3 class="subheading">Season champions</h3>
<p class="fine-print">Starts at 00:00 UTC on the astronomical equinox/solstice date. Joint top scorers share the win. Only players who pressed can win, including zero-point presses.</p>
<p id="seasons-status" role="status">Loading seasons&hellip;</p>
<div class="table-scroll"><table id="season-winners" aria-label="Season winners and durations"></table></div>
<div id="season-standings"></div>
</div>
</section>
<section id="alerts-section" data-panel hidden aria-labelledby="alerts-heading">
<header class="panel-header"><p class="eyebrow">WAIT ON YOUR TERMS</p>
<h2 id="alerts-heading">Don't miss your moment.</h2>
@@ -168,7 +186,7 @@
<header class="panel-header">
<div class="header-topline"><p class="eyebrow">THE LONG GAME</p><span class="site-badge">Updates every 5s</span></div>
<h2 id="leaderboard-heading">Leaderboard</h2>
<p class="panel-description">A little patience. A little nerve. See who's making every second count.</p>
<p class="panel-description">This season's scores. A little patience. A little nerve. See who's making every second count.</p>
</header>
<div class="panel-body">
<p id="leaderboard-status" role="status">Loading scores&hellip;</p>
@@ -213,7 +231,7 @@
<section id="stats-section" data-panel hidden>
<header class="panel-header"><p class="eyebrow">THE FINAL CHAPTER</p>
<h2>Every second had a story.</h2>
<p class="panel-description">The clock has stopped. Here's how we got here.</p></header>
<p class="panel-description">This season's clock has stopped. Here's how we got here. A new game starts next season.</p></header>
<div class="panel-body">
<div id="stats-summary"></div>
<h3>Presses per zone</h3>
@@ -235,7 +253,7 @@
<ol class="rules-list">
<li><div><h3>Keep the clock alive</h3><p>Every press resets the countdown for everyone.</p></div></li>
<li><div><h3>Wait for your moment</h3><p>The lower the countdown, the more you score. Each zone awards a fixed number of points per press.</p></div></li>
<li><div><h3>Don't wait too long</h3><p>If the countdown reaches zero, the game is over. Forever.</p></div></li>
<li><div><h3>Don't wait too long</h3><p>If the countdown reaches zero, this season's game ends. The next equinox or solstice starts a new countdown and resets scores. Accounts and chat stay.</p></div></li>
</ol>
<h3 class="subheading">Six zones. Increasing stakes.</h3>
<div id="rules-zones" class="zone-guide"></div>
+10 -2
View File
@@ -57,6 +57,7 @@ main {
box-shadow: 0 12px 32px #11111b40;
}
.game-panel h1 { font-size: 1.4rem; margin: 0.5rem 0 0; }
.season-subtitle { margin: 0.25rem 0 0; color: var(--mauve); font-size: 0.85rem; font-weight: 600; letter-spacing: 0.12em; }
.game-tagline { font-size: 0.8rem; color: var(--subtext); margin: 0.35rem 0 1.75rem; }
.game-panel #countdown { font-size: clamp(1.8rem, 3.5vw, 2.55rem); font-variant-numeric: tabular-nums; line-height: 1.3; letter-spacing: -0.05em; }
.game-panel #history-global-summary { font-size: 0.75rem; color: var(--subtext); margin: 1rem 0 0; }
@@ -399,6 +400,12 @@ stripe-buy-button { display: block; max-width: 100%; margin: 1.5rem 0; }
.game-panel #presence-count { color: var(--teal); font-size: 0.7rem; margin: 0.65rem 0 0; }
.presence-count::before { content: ""; display: inline-block; width: 6px; height: 6px; border-radius: 50%; margin-right: 0.4rem; background: var(--teal); }
.season-label { margin: 0.65rem 0 0; color: var(--lavender); font-size: 0.65rem; line-height: 1.5; }
#season-current { margin-top: 0; }
#season-current h3 { margin: 0.5rem 0; font-size: 1.1rem; color: var(--lavender); }
#season-winners td { min-width: 75px; overflow-wrap: anywhere; }
#season-winners td:nth-child(2) { min-width: 130px; }
#seasons-status:empty { display: none; }
.presence-preference { border: 1px solid var(--surface); border-radius: 12px; background: var(--base); padding: 0.9rem 1rem; margin-bottom: 1rem; }
.presence-preference label { display: flex; align-items: center; gap: 0.6rem; font-size: 0.8rem; font-weight: 600; color: var(--lavender); cursor: pointer; }
#presence-opt-in { width: 18px; height: 18px; min-height: 0; margin: 0; padding: 0; accent-color: var(--mauve); flex-shrink: 0; }
@@ -426,15 +433,16 @@ stripe-buy-button { display: block; max-width: 100%; margin: 1.5rem 0; }
}
.game-panel .game-eyebrow, .game-tagline, .button-caption { display: none; }
.game-panel h1 { grid-column: 1 / -1; margin: 0; font-size: 1rem; }
.game-panel .season-subtitle { grid-column: 1 / -1; grid-row: 2; margin: 0; font-size: 0.7rem; }
.game-panel section { margin: 0; }
.game-panel #countdown { font-size: clamp(1.4rem, 6vw, 2.5rem); }
#zone-label { font-size: 0.7rem; letter-spacing: 0.1em; }
#zone-bar { margin: 0.35rem auto; }
.game-panel #history-global-summary { margin: 0.25rem 0; font-size: 0.7rem; }
#the-button { width: 84px; height: 84px; font-size: 1rem; border-width: 4px; }
#timer-section { grid-column: 1; grid-row: 2; }
#timer-section { grid-column: 1; grid-row: 3; }
#button-section { display: contents; }
#the-button { grid-column: 2; grid-row: 2; align-self: center; }
#the-button { grid-column: 2; grid-row: 3; align-self: center; }
#press-result:not(:empty) { grid-column: 1 / -1; margin: 0.4rem 0 0; padding: 0.45rem; font-size: 0.7rem; line-height: 1.5; }
#press-result:empty { display: none; }
.section-browser { margin-top: 1rem; }
+239
View File
@@ -11,6 +11,7 @@ import unittest
from http.client import HTTPConnection
from http.server import ThreadingHTTPServer
from pathlib import Path
from datetime import datetime, timezone
from unittest.mock import patch
@@ -66,6 +67,243 @@ class PersistenceTests(unittest.TestCase):
def write_legacy(self, state):
self.game.DATA_FILE.write_text(json.dumps(state), encoding="utf-8")
def season_fixture(self):
at = int(datetime(2026, 10, 21, tzinfo=timezone.utc).timestamp() * 1000)
with patch.object(self.game, "now_ms", return_value=at):
self.game.state = self.game.load_state()
return at
def test_astronomical_calendar_known_usno_dates_and_exact_midnight(self):
# USNO API: https://aa.usno.navy.mil/api/seasons?year=2026 (also 2027).
for year, dates in ((2026, ("03-20", "06-21", "09-23", "12-21")),
(2027, ("03-20", "06-21", "09-23", "12-22"))):
boundaries = self.game.season_boundaries(year)
self.assertEqual([b["id"] for b in boundaries], [f"{year}-{date}" for date in dates])
for boundary in boundaries:
self.assertEqual(boundary["starts_at"] % 86400000, 0)
self.assertEqual(self.game.season_for_time(boundary["starts_at"])["id"], boundary["id"])
self.assertNotEqual(self.game.season_for_time(boundary["starts_at"] - 1)["id"], boundary["id"])
january = int(datetime(2027, 1, 1, tzinfo=timezone.utc).timestamp() * 1000)
self.assertEqual(self.game.season_for_time(january)["id"], "2026-12-21")
with self.assertRaises(ValueError):
self.game.season_for_time(0)
def test_season_timeout_archives_winner_and_waits_until_boundary(self):
at = self.season_fixture()
state = self.sample_state()
state.update(started_at=at, deadline=at + 10000, ended=False, ended_at=None)
self.game.state = state
self.game.save_state(state)
with patch.object(self.game, "now_ms", return_value=at + 10000):
self.game.check_ended()
result = self.game.seasonal_leaderboard()
self.assertTrue(self.game.state["ended"])
archive = result["seasons"][0]
self.assertEqual(archive["duration_ms"], 10000)
self.assertEqual(archive["winners"][0]["total_points"], 40)
self.assertEqual(archive["reason"], "countdown")
with patch.object(self.game, "now_ms", return_value=at + 20000):
self.game.check_ended()
self.game.state = self.game.load_state()
self.game.check_ended()
self.assertEqual(self.game.seasonal_leaderboard()["seasons"], result["seasons"])
self.assertTrue(self.game.state["ended"])
def test_season_boundary_resets_scores_keeps_accounts_chat_and_archives_history(self):
self.season_fixture()
with self.game.database() as connection:
season = self.game.current_season(connection)
boundary = season["ends_at"]
self.game.state = self.sample_state()
self.game.state.update(started_at=boundary - 10000, deadline=boundary + 5000)
with patch.object(self.game, "now_ms", return_value=boundary - 100):
self.game.save_state(self.game.state)
players = copy.deepcopy(self.game.state["players"])
original_presses = copy.deepcopy(self.game.state["presses"])
with self.game.database() as connection:
connection.execute("INSERT INTO chat_messages (player_id, name, at, message) VALUES (?, ?, ?, ?)",
("alice", "Alice", boundary - 100, "See you next season"))
with patch.object(self.game, "now_ms", return_value=boundary - 1):
self.game.check_ended()
self.assertEqual(self.game.state["deadline"], boundary)
with patch.object(self.game, "now_ms", return_value=boundary):
self.game.check_ended()
self.game.check_ended()
self.assertFalse(self.game.state["ended"])
self.assertEqual(self.game.state["started_at"], boundary)
self.assertEqual(self.game.state["deadline"], boundary + self.game.DURATION_MS)
self.assertEqual(self.game.state["players"], players)
self.assertEqual(self.game.state["presses"], [])
self.assertEqual(self.game.player_summary("alice")["total_points"], 0)
self.assertTrue(any(m["message"] == "See you next season" for m in self.game.chat_messages()))
archive = self.game.seasonal_leaderboard()["seasons"][0]
self.assertEqual(archive["id"], season["id"])
self.assertEqual(archive["reason"], "boundary")
self.assertEqual(archive["duration_ms"], 10000)
with self.game.database() as connection:
row = connection.execute("SELECT presses FROM season_results WHERE id = ?", (season["id"],)).fetchone()
self.assertEqual(json.loads(row["presses"]), original_presses)
self.assertEqual(connection.execute("PRAGMA foreign_key_check").fetchall(), [])
def test_season_tied_winners_include_zero_score_participants_not_idle_accounts(self):
self.season_fixture()
self.game.state = self.sample_state()
self.game.state["players"]["idle"] = dict(self.game.state["players"]["bob"], name="Idle", token_hashes=[])
for press in self.game.state["presses"]:
press["points"] = 0
with self.game.database() as connection:
season = self.game.current_season(connection)
self.game.archive_season(connection, season, self.game.state, self.game.state["deadline"], "countdown")
winners = self.game.seasonal_leaderboard()["seasons"][0]["winners"]
self.assertEqual({w["name"] for w in winners}, {self.game.state["players"]["alice"]["name"], "Bøb"})
self.assertTrue(all(w["total_points"] == 0 for w in winners))
def test_seasons_catch_up_downtime_without_fabricating_winners(self):
self.season_fixture()
at = int(datetime(2027, 7, 1, tzinfo=timezone.utc).timestamp() * 1000)
with patch.object(self.game, "now_ms", return_value=at):
self.game.check_ended()
self.game.state = self.game.load_state()
self.game.check_ended()
result = self.game.seasonal_leaderboard()
self.assertEqual(result["current"]["id"], "2027-06-21")
self.assertTrue(result["current"]["ended"])
self.assertEqual(len(result["seasons"]), 4)
self.assertTrue(all(row["winners"] == [] for row in result["seasons"]))
def test_season_rollover_failure_does_not_change_memory_or_database(self):
self.season_fixture()
before = copy.deepcopy(self.game.state)
with self.game.database() as connection:
boundary = self.game.current_season(connection)["ends_at"]
connection.execute("""CREATE TRIGGER reject_archive BEFORE INSERT ON season_results
BEGIN SELECT RAISE(ABORT, 'test rollback'); END""")
with patch.object(self.game, "now_ms", return_value=boundary):
with self.assertRaises(sqlite3.IntegrityError):
self.game.check_ended()
self.assertEqual(self.game.state, before)
with self.game.database() as connection:
self.assertEqual(self.game.current_season(connection)["ends_at"], boundary)
self.assertEqual(connection.execute("SELECT COUNT(*) FROM season_results").fetchone()[0], 0)
connection.execute("DROP TRIGGER reject_archive")
self.assertEqual(self.game.load_state(), before)
def test_season_upgrade_adopts_existing_game_without_losing_data(self):
source = self.sample_state()
self.write_legacy(source)
self.assertEqual(self.game.load_state(), source)
with self.game.database() as connection:
original = self.game.current_season(connection)
connection.execute("DROP TABLE season_clock")
connection.execute("DROP TABLE season_results")
connection.execute("DROP TABLE season_milestone_messages")
self.assertEqual(self.game.load_state(), source)
with self.game.database() as connection:
self.assertEqual(self.game.current_season(connection)["id"], original["id"])
self.assertEqual(self.game.seasonal_leaderboard()["seasons"], [])
def test_seasons_api_is_public_and_contains_no_private_credentials(self):
request = self.presence_client()
status, result = request(path="/api/seasons")
self.assertEqual(status, 200)
self.assertEqual(set(result), {"current", "seasons"})
for player in result["current"]["leaderboard"]:
self.assertEqual(set(player), {"name", "total_points", "press_count"})
self.assertNotIn("password_hash", json.dumps(result))
self.assertNotIn("token_hashes", json.dumps(result))
def test_press_at_season_boundary_scores_only_in_new_season(self):
request = self.presence_client()
with self.game.database() as connection:
boundary = self.game.current_season(connection)["ends_at"]
self.game.state["deadline"] = boundary + 1000
before = copy.deepcopy(self.game.state["presses"])
with patch.object(self.game, "now_ms", return_value=boundary):
status, result = request("POST", {}, "alice-token", "/api/press")
self.assertEqual(status, 200)
self.assertEqual(result["press"]["points"], 0)
self.assertEqual(len(self.game.state["presses"]), 1)
self.assertEqual(self.game.state["presses"][0]["at"], boundary)
self.assertEqual(self.game.state["players"]["alice"]["token_hashes"][0], self.game.hash_token("alice-token"))
with self.game.database() as connection:
archive = connection.execute("SELECT presses FROM season_results ORDER BY starts_at LIMIT 1").fetchone()
self.assertEqual(json.loads(archive["presses"]), before)
def test_finished_season_rejects_presses_and_keeps_archive_immutable(self):
request = self.presence_client()
deadline = self.game.state["deadline"]
with patch.object(self.game, "now_ms", return_value=deadline):
self.assertEqual(request("POST", {}, "alice-token", "/api/press")[0], 409)
first = request(path="/api/seasons")[1]
self.assertEqual(request("POST", {}, "alice-token", "/api/press")[0], 409)
self.assertEqual(request(path="/api/seasons")[1], first)
self.assertEqual(first["seasons"][0]["winners"][0]["total_points"], 40)
self.assertEqual(len(self.game.state["presses"]), 3)
def test_season_reset_preserves_prior_milestone_announcements(self):
self.season_fixture()
with self.game.database() as connection:
season = self.game.current_season(connection)
connection.execute("UPDATE community_milestones SET reached_at = ?, announced_at = ?, message = ? WHERE id = 'ten_players'",
(self.game.state["started_at"], self.game.state["started_at"], "Historic milestone"))
self.game.state["deadline"] = season["ends_at"] + 100
with patch.object(self.game, "now_ms", return_value=season["ends_at"]):
self.game.check_ended()
messages = [message for message in self.game.chat_messages() if message["message"] == "Historic milestone"]
self.assertEqual(len(messages), 1)
self.assertIn(season["id"], messages[0]["id"])
with self.game.database() as connection:
self.assertIsNone(connection.execute("SELECT reached_at FROM community_milestones WHERE id = 'ten_players'").fetchone()[0])
def test_season_watcher_keeps_running_after_countdown_ends(self):
at = self.season_fixture()
self.game.state.update(ended=True, ended_at=at, deadline=at)
with patch.object(self.game, "now_ms", return_value=at):
with patch.object(self.game.time, "sleep", side_effect=RuntimeError("stop watcher")) as sleep:
with self.assertRaisesRegex(RuntimeError, "stop watcher"):
self.game.end_watcher()
sleep.assert_called_once()
self.assertGreater(sleep.call_args.args[0], 0)
self.assertLessEqual(sleep.call_args.args[0], 60)
def test_season_roman_numerals(self):
for number, expected in ((1, "I"), (2, "II"), (3, "III"), (4, "IV"), (9, "IX"),
(14, "XIV"), (40, "XL"), (49, "XLIX"), (99, "XCIX"), (400, "CD")):
self.assertEqual(self.game.roman_numeral(number), expected)
with self.assertRaises(ValueError):
self.game.roman_numeral(0)
def test_season_numbering_stable_on_rollover_and_restart(self):
self.season_fixture()
with self.game.database() as connection:
first = self.game.current_season(connection)
self.assertEqual((first["number"], first["name"]), (1, "Season I"))
with patch.object(self.game, "now_ms", return_value=first["ends_at"]):
self.game.check_ended()
self.game.state = self.game.load_state()
result = self.game.seasonal_leaderboard()
self.assertEqual((result["current"]["number"], result["current"]["name"]), (2, "Season II"))
self.assertEqual(result["current"]["starts_at"], first["ends_at"])
self.assertEqual((result["seasons"][0]["number"], result["seasons"][0]["name"]), (1, "Season I"))
with self.game.database() as connection:
self.assertEqual(self.game.current_season(connection)["name"], "Season II")
def test_season_numbering_upgrade_starts_with_earliest_archive(self):
self.season_fixture()
with self.game.database() as connection:
earlier = self.game.season_for_time(self.game.current_season(connection)["starts_at"] - 1)
self.game.archive_season(connection, earlier, self.game.state, self.game.state["deadline"], "countdown")
connection.execute("DROP TABLE season_numbering")
self.game.state = self.game.load_state()
result = self.game.seasonal_leaderboard()
self.assertEqual(result["seasons"][0]["name"], "Season I")
self.assertEqual(result["current"]["name"], "Season II")
with self.game.database() as connection:
origin = connection.execute("SELECT origin_at FROM season_numbering").fetchone()[0]
self.assertEqual(origin, earlier["starts_at"])
self.game.load_state()
self.assertEqual(self.game.seasonal_leaderboard()["current"]["name"], "Season II")
def presence_client(self):
self.game.load_state()
self.game.state = self.sample_state()
@@ -592,6 +830,7 @@ class PersistenceTests(unittest.TestCase):
for ended in (False, True):
self.game.state["ended"] = ended
self.game.state["ended_at"] = self.game.state["deadline"] if ended else None
status, payload, cache = get("/api/leaderboard")
self.assertEqual(status, 200)
self.assertEqual(cache, "no-store")