Seasons
This commit is contained in:
@@ -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))
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user