The front door now continues the story. An act is its league cup, whose final is the boss's own body (acts II and IV play their band out of ladder order for that), then a ten-game gore chapter on the act's court. The chapter has no duel at all, takes the act's weapon stock into the rack, meets a mid-boss after game 5 (the checkpoint: restock and full blood) and the act boss after game 10, then the act card. Death is not permadeath: it counts the attempt, banks nothing on the boards, and TRY AGAIN restarts from the checkpoint the profile holds. Barrels hold what the act has earned. The chapter plays the arcade's games base+1..base+10 so every director curve keeps its slope. Profiles from before the story start it from the top (league, cups and champion reset; level kept). The server mirror stores the act and checkpoint and only ever moves them forward. Plain cards stand in for the screens (phase 3) and the transformation (phase 2). Six new tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
491 lines
24 KiB
Python
491 lines
24 KiB
Python
#!/usr/bin/env python3
|
|
"""The whole backend: static files plus a two-route high-score API, on the standard library.
|
|
|
|
It replaced the nginx image because the leaderboard needs ONE writable process and nginx is not
|
|
one. Everything nginx was doing here is four lines of headers, so a second container (and a second
|
|
Coolify resource, and a proxy rule) would have bought separation we have no use for at this size.
|
|
|
|
GET /api/scores?m=g|z|c -> {"m", "scores": [{"n","k","g","t","m"}, ...]} best first
|
|
POST /api/scores <- {"n","k","g","m"} -> {"ok", "rank", "m", "scores"}
|
|
|
|
One board per game mode; `m` defaults to 'g' (splatter gore) at both ends, which is what every row
|
|
written before the split is.
|
|
|
|
Scores live in ONE json file under SPLAT_DATA (default /data, which must be a persistent volume —
|
|
anything written inside the container image is gone on the next deploy). There is no auth: the
|
|
board is a wall for writing your name on, and the client is public JS that could be made to post
|
|
anything regardless. What IS enforced is that nothing here can be made to crash, hang or grow
|
|
without bound by a hostile request — see clean() and the caps below.
|
|
"""
|
|
import base64, hashlib, hmac, json, os, random, re, threading, time
|
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
DATA = os.environ.get('SPLAT_DATA') or ('/data' if os.path.isdir('/data') else HERE)
|
|
FILE = os.path.join(DATA, 'scores.json')
|
|
PORT = int(os.environ.get('PORT', 80))
|
|
|
|
# ---- THE DOOR (2026-09-08). The game is not public yet, so staging and prod both sit behind one
|
|
# shared password. SPLAT_AUTH is 'user:pass'; set it EMPTY in the environment to open the site again
|
|
# (that is also how local dev and the browser-pane automation stay unprompted).
|
|
AUTH = os.environ.get('SPLAT_AUTH', 'tennis:BummBummBoris')
|
|
AUTH = ('Basic ' + base64.b64encode(AUTH.encode()).decode()) if AUTH else ''
|
|
|
|
KEEP = 200 # rows kept on disk PER BOARD; the board only ever shows the top slice of this
|
|
MAX_BODY = 2048 # a submission is ~60 bytes. Anything larger is not one.
|
|
NAME_MAX = 12
|
|
# ONE BOARD PER GAME MODE (2026-08-15). The three do not share a currency — gore ranks bodies
|
|
# killed, zombie tennis ranks opponents felled, classic ranks games taken off the CPU — so a single
|
|
# board was really the gore mode's with the other two locked out of it. The server does not know or
|
|
# care what the numbers MEAN (see stats.BOARDS on the client); it sorts them within a mode and
|
|
# nothing crosses. Rows written before the split carry no mode and ARE gore rows, which is why 'g'
|
|
# is the default everywhere and no migration is needed.
|
|
MODES = ('g', 'z', 'c', 'u')
|
|
# THE CUP BOARD ('u', 2026-08-31) is the one board whose second column is better LOW: it ranks the
|
|
# LEAGUE a player took and breaks ties on the LEVEL they took it at. The client says the same thing
|
|
# in stats.BOARDS (subLow) — this is the other half of it, and the only place the sort differs.
|
|
SUB_LOW = ('u',)
|
|
|
|
|
|
def rank_key(m):
|
|
"""Board order: score DESC always, then the sub column, then age."""
|
|
low = m in SUB_LOW
|
|
return lambda r: (-r.get('k', 0), r.get('g', 0) if low else -r.get('g', 0), r.get('t', 0))
|
|
|
|
|
|
lock = threading.Lock()
|
|
|
|
# ---- THE PROFILE MIRROR (2026-08-29, the leagues). localStorage alone dies with cleared browser
|
|
# data, and the meta-progression asks for hours of investment — so the client mirrors its profile
|
|
# here, keyed by the same random id that owns board names. This is a BACKUP, not an account: the
|
|
# client is the authority while it has one, and adopts the mirror only when its own storage is
|
|
# empty (or behind on xp). Same stance as the scores: no auth beyond the id, nothing here may
|
|
# crash, hang or grow without bound, every field re-derived before it touches disk.
|
|
PROFILE_FILE = os.path.join(DATA, 'profiles.json')
|
|
PROFILE_KEEP = 5000 # profiles kept; oldest-touched evicted past this
|
|
plock = threading.Lock()
|
|
|
|
|
|
def pload():
|
|
try:
|
|
with open(PROFILE_FILE) as f:
|
|
d = json.load(f)
|
|
return d if isinstance(d, dict) else {}
|
|
except (OSError, ValueError):
|
|
return {}
|
|
|
|
|
|
def psave(d):
|
|
os.makedirs(DATA, exist_ok=True)
|
|
tmp = PROFILE_FILE + '.tmp'
|
|
with open(tmp, 'w') as f:
|
|
json.dump(d, f)
|
|
os.replace(tmp, PROFILE_FILE)
|
|
|
|
|
|
def clean_profile(payload):
|
|
"""(id, profile) or None. Whitelisted and bounded — this file is read back and rendered."""
|
|
if not isinstance(payload, dict) or not isinstance(payload.get('p'), dict):
|
|
return None
|
|
who = re.sub(r'[^a-z0-9]', '', str(payload.get('id', '')))[:40]
|
|
if not who:
|
|
return None
|
|
p = payload['p']
|
|
word = lambda k, n=12: re.sub(r'[^a-z0-9_]', '', str(p.get(k, '')))[:n]
|
|
# THE CUP RECORD (THE SEASON): league -> rounds ever reached. Re-derived key by key like every
|
|
# other field — five small ints, nothing else survives the trip.
|
|
cups = {}
|
|
raw = p.get('cups')
|
|
if isinstance(raw, dict):
|
|
for lg in range(1, 6):
|
|
try:
|
|
v = int(raw.get(str(lg), raw.get(lg, 0)))
|
|
except (TypeError, ValueError):
|
|
v = 0
|
|
if v > 0:
|
|
cups[str(lg)] = min(9, v)
|
|
# ...and the INSCRIPTION: league -> the level you took its cup at (2026-08-31, the cup board)
|
|
at = {}
|
|
raw_at = p.get('at')
|
|
if isinstance(raw_at, dict):
|
|
for lg in range(1, 6):
|
|
try:
|
|
v = int(raw_at.get(str(lg), raw_at.get(lg, 0)))
|
|
except (TypeError, ValueError):
|
|
v = 0
|
|
if v > 0:
|
|
at[str(lg)] = min(50, v)
|
|
try:
|
|
out = {'v': 1,
|
|
'xp': max(0, min(int(p.get('xp', 0)), 1_000_000_000)),
|
|
'lg': max(1, min(int(p.get('lg', 1)), 5)),
|
|
'ch': 1 if p.get('ch') else 0,
|
|
'setup': 1 if p.get('setup') else 0,
|
|
'cups': cups,
|
|
'out': max(0, min(int(p.get('out', 0)), 99)),
|
|
'at': at,
|
|
'n': re.sub(r'[^A-Z0-9 ._-]', '', str(p.get('n', '')).upper()).strip()[:NAME_MAX],
|
|
'tour': word('tour'), 'kit': word('kit'), 'skin': word('skin')}
|
|
# THE STORY (2026-10-15): the act and the checkpoint, nothing else. The rack a checkpoint
|
|
# hands back stays on the device — a wiped phone restarts the act's chapter, not its middle.
|
|
st = p.get('story')
|
|
if isinstance(st, dict):
|
|
cp = str(st.get('cp', 'cup'))
|
|
out['story'] = {'act': max(1, min(int(st.get('act', 1)), 5)),
|
|
'cp': cp if cp in ('pro', 'cup', 'chap', 'mid', 'done') else 'cup'}
|
|
except (TypeError, ValueError):
|
|
return None
|
|
return who, out
|
|
|
|
|
|
def mode_of(r):
|
|
return r.get('m') or 'g'
|
|
|
|
|
|
def load():
|
|
try:
|
|
with open(FILE) as f:
|
|
rows = json.load(f)
|
|
return rows if isinstance(rows, list) else []
|
|
except (OSError, ValueError):
|
|
return [] # missing, empty or corrupt — an empty board, never a 500
|
|
|
|
|
|
def save(rows):
|
|
os.makedirs(DATA, exist_ok=True)
|
|
tmp = FILE + '.tmp' # write-then-rename: a torn file here is a wiped leaderboard
|
|
with open(tmp, 'w') as f:
|
|
json.dump(rows, f)
|
|
os.replace(tmp, FILE)
|
|
|
|
|
|
def clean(payload):
|
|
"""A submission, or None. Every field is re-derived — nothing from the request reaches the file
|
|
unexamined, because that file is read back and rendered into the page."""
|
|
if not isinstance(payload, dict):
|
|
return None
|
|
name = re.sub(r'[^A-Z0-9 ._-]', '', str(payload.get('n', '')).upper()).strip()[:NAME_MAX]
|
|
if not name:
|
|
return None
|
|
try:
|
|
kills, games = int(payload['k']), int(payload['g'])
|
|
except (KeyError, TypeError, ValueError):
|
|
return None
|
|
if not (0 <= kills <= 1_000_000 and 0 <= games <= 10_000):
|
|
return None # bounds keep the sort honest and the column narrow
|
|
who = re.sub(r'[^a-z0-9]', '', str(payload.get('id', '')))[:40]
|
|
mode = str(payload.get('m', 'g'))
|
|
if mode not in MODES:
|
|
mode = 'g' # an unknown board is the default one, never a new one
|
|
try: # the league badge (THE LEAGUES): context, not a sort key
|
|
lg = max(1, min(5, int(payload.get('lg', 1))))
|
|
except (TypeError, ValueError):
|
|
lg = 1
|
|
return {'n': name, 'k': kills, 'g': games, 't': int(time.time()), 'id': who, 'm': mode, 'lg': lg}
|
|
|
|
|
|
def public(rows):
|
|
"""The board as anyone may see it. `id` is the ONLY thing standing between a player and someone
|
|
else typing their name, so it must never leave the server."""
|
|
return [{k: v for k, v in r.items() if k != 'id'} for r in rows[:100]]
|
|
|
|
|
|
def rank_of(rows, name):
|
|
return next((i + 1 for i, r in enumerate(rows) if r.get('n') == name), len(rows))
|
|
|
|
|
|
# ---- the multiplayer relay: RFC6455 on the standard library, because that is all a dumb relay
|
|
# needs. The netcode's sync unit is THE SHOT (see PROJECT.md, LATER item 7), so this server holds
|
|
# no game state at all: it mints a 4-letter room code, pairs exactly two sockets, and forwards
|
|
# text frames verbatim between them. The clients judge everything; a hostile client can only cheat
|
|
# its friend, which is the agreed trust model for friend codes. Caps mirror the score API's stance:
|
|
# nothing here may crash, hang or grow without bound on a hostile request.
|
|
WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
|
WS_ALPHABET = 'ABCDEFGHJKMNPQRSTVWXYZ23456789' # read aloud between phones: no I/L/O/U/0/1
|
|
WS_MAX = 2048 # a shot event is ~120 bytes; anything larger is not one
|
|
ROOMS = {} # code -> [host_conn] while waiting; deleted on pairing
|
|
ws_lock = threading.Lock()
|
|
|
|
|
|
def ws_send(conn, text):
|
|
data = text if isinstance(text, bytes) else text.encode()
|
|
head = bytes([0x81, len(data)]) if len(data) < 126 else bytes([0x81, 126]) + len(data).to_bytes(2, 'big')
|
|
try:
|
|
with conn['lock']: # relay thread and own thread share this socket
|
|
conn['sock'].sendall(head + data)
|
|
except OSError:
|
|
pass # a dead peer is handled by its own recv loop
|
|
|
|
|
|
def ws_read(rf):
|
|
"""One frame -> (opcode, payload) or (None, b'') on EOF/oversize. Client frames are masked."""
|
|
h = rf.read(2)
|
|
if len(h) < 2:
|
|
return None, b''
|
|
op, n = h[0] & 0x0f, h[1] & 0x7f
|
|
if n == 126:
|
|
n = int.from_bytes(rf.read(2), 'big')
|
|
elif n == 127:
|
|
n = int.from_bytes(rf.read(8), 'big')
|
|
if n > WS_MAX:
|
|
return None, b''
|
|
mask = rf.read(4) if h[1] & 0x80 else None
|
|
data = rf.read(n)
|
|
if mask:
|
|
data = bytes(b ^ mask[i & 3] for i, b in enumerate(data))
|
|
return op, data
|
|
|
|
|
|
class Handler(SimpleHTTPRequestHandler):
|
|
def __init__(self, *a, **kw):
|
|
super().__init__(*a, directory=HERE, **kw)
|
|
|
|
def log_message(self, *a):
|
|
pass # one line per sprite request is not a log, it is noise
|
|
|
|
# ---- the door ----
|
|
def parse_request(self):
|
|
"""The password gate. It lives here, not in do_GET/do_POST, because parse_request is the
|
|
ONE place every method passes through — GET, POST, HEAD and the websocket upgrade — and
|
|
returning False from it drops the request before anything is dispatched."""
|
|
if not super().parse_request():
|
|
return False
|
|
if not AUTH:
|
|
return True
|
|
path = self.path.split('?')[0]
|
|
# /api and /ws STAY OPEN and this is not an oversight: the iOS shell serves the page out of
|
|
# its own bundle and talks to prod by name from capacitor://localhost, with no credentials
|
|
# it could ever send — gating them would kill the app's board, profile mirror and netplay
|
|
# while leaving the thing we actually want private (the game itself) no more private.
|
|
if path == '/ws' or path.startswith('/api/'):
|
|
return True
|
|
if hmac.compare_digest(self.headers.get('Authorization', ''), AUTH):
|
|
return True
|
|
self.send_response(401)
|
|
self.send_header('WWW-Authenticate', 'Basic realm="splatter tennis"')
|
|
self.send_header('Content-Length', '0')
|
|
self.end_headers()
|
|
self.close_connection = True # a POST body is still unread — do not parse it as the next request
|
|
return False
|
|
|
|
# ---- the API ----
|
|
def reply(self, code, obj):
|
|
body = json.dumps(obj).encode()
|
|
self.send_response(code)
|
|
self.send_header('Content-Type', 'application/json')
|
|
self.send_header('Content-Length', str(len(body)))
|
|
self.send_header('Cache-Control', 'no-store')
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
# ---- the relay endpoint ----
|
|
def handle_ws(self):
|
|
key = self.headers.get('Sec-WebSocket-Key')
|
|
if not key or (self.headers.get('Upgrade') or '').lower() != 'websocket':
|
|
return self.send_error(400)
|
|
accept = base64.b64encode(hashlib.sha1((key + WS_GUID).encode()).digest()).decode()
|
|
self.connection.sendall(b'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n'
|
|
b'Connection: Upgrade\r\nSec-WebSocket-Accept: ' + accept.encode() + b'\r\n\r\n')
|
|
self.close_connection = True
|
|
# a phone that vanishes without FIN must not hold a thread and a room forever — the client
|
|
# heartbeats every 20 s, so 75 s of silence is a dead radio, not a slow one
|
|
self.connection.settimeout(75)
|
|
me = {'sock': self.connection, 'lock': threading.Lock(), 'peer': None, 'room': None}
|
|
want = re.sub(r'[^A-Z0-9]', '', (self.path.split('room=')[1] if 'room=' in self.path else '').upper())[:8]
|
|
with ws_lock:
|
|
if want: # joining a friend
|
|
room = ROOMS.pop(want, None)
|
|
if not room:
|
|
ws_send(me, '{"t":"gone"}')
|
|
return
|
|
host = room[0]
|
|
me['peer'] = host
|
|
host['peer'] = me # the code is single-use: popped above, never re-joinable
|
|
else: # hosting: mint a code nobody is holding
|
|
code = ''.join(random.choice(WS_ALPHABET) for _ in range(4))
|
|
while code in ROOMS:
|
|
code = ''.join(random.choice(WS_ALPHABET) for _ in range(4))
|
|
ROOMS[code] = [me]
|
|
me['room'] = code
|
|
# the host's decisions ride the room: classic tennis or the death match, and WHICH
|
|
# COURT. The start message carries both to both phones, so the joiner is never
|
|
# asked — and never ends up playing the same rally on a different surface.
|
|
me['death'] = 'death=1' in self.path
|
|
me['court'] = re.sub(r'[^a-z]', '', self.path.split('court=')[1].split('&')[0]
|
|
if 'court=' in self.path else '')[:12]
|
|
if len(ROOMS) > 200: # a lobby is two people; 200 stale rooms is a flood
|
|
ROOMS.pop(next(iter(ROOMS)))
|
|
if me['peer']: # paired: the host serves first, on both scoreboards
|
|
host = me['peer'] # ...and the host's picks are what both phones get
|
|
for who, side in ((host, 'A'), (me, 'B')):
|
|
ws_send(who, json.dumps({'t': 'start', 'side': side,
|
|
'death': bool(host.get('death')),
|
|
'court': host.get('court') or ''}))
|
|
else:
|
|
ws_send(me, json.dumps({'t': 'room', 'code': me['room']}))
|
|
try:
|
|
while True:
|
|
try:
|
|
op, data = ws_read(self.rfile)
|
|
except OSError: # timeout or reset: same as hanging up
|
|
break
|
|
if op is None or op == 8:
|
|
break
|
|
if op == 9: # protocol ping -> pong, payload echoed
|
|
try:
|
|
with me['lock']:
|
|
me['sock'].sendall(bytes([0x8A, len(data)]) + data)
|
|
except OSError:
|
|
pass
|
|
continue
|
|
if op != 1 or data == b'{"t":"hb"}':
|
|
continue # the heartbeat is for the socket, not the friend
|
|
if me['peer']:
|
|
ws_send(me['peer'], data) # the whole job: forward the frame verbatim
|
|
finally:
|
|
with ws_lock:
|
|
if me['room'] and ROOMS.get(me['room']) and ROOMS[me['room']][0] is me:
|
|
del ROOMS[me['room']] # nobody ever came
|
|
peer = me['peer']
|
|
me['peer'] = None
|
|
if peer:
|
|
peer['peer'] = None
|
|
if peer:
|
|
ws_send(peer, '{"t":"bye"}')
|
|
|
|
def do_GET(self):
|
|
path = self.path.split('?')[0]
|
|
if path == '/ws':
|
|
return self.handle_ws()
|
|
if path == '/api/scores':
|
|
# DERIVED FROM MODES, never spelled out again (2026-08-31): this filter was a literal
|
|
# `[^gzc]` character class, so the day the cup board was added the server answered its
|
|
# requests with the GORE board — and the client printed 7121 kills as "LEAGUE 7121".
|
|
want = (self.path.split('m=')[1].split('&')[0] if 'm=' in self.path else '')[:1]
|
|
if want not in MODES:
|
|
want = 'g' # an unknown board is the default one, never a new one
|
|
with lock:
|
|
return self.reply(200, {'m': want,
|
|
'scores': public([r for r in load() if mode_of(r) == want])})
|
|
if path == '/api/profile':
|
|
who = re.sub(r'[^a-z0-9]', '', (self.path.split('id=')[1].split('&')[0]
|
|
if 'id=' in self.path else ''))[:40]
|
|
if not who:
|
|
return self.reply(400, {'error': 'no id'})
|
|
with plock:
|
|
row = pload().get(who)
|
|
return self.reply(200, {'ok': True, 'p': row and row.get('p')})
|
|
# Don't hand out the source. Nothing secret lives in it — the .env is not in the image and
|
|
# the client is public JS regardless — but serving server.py and the asset-pipeline scripts
|
|
# to anyone who guesses the name is untidy, and it publishes the exact validation rules.
|
|
if path.endswith(('.py', '.pyc')) or '__pycache__' in path:
|
|
return self.send_error(404)
|
|
return super().do_GET()
|
|
|
|
def do_POST(self):
|
|
path = self.path.split('?')[0]
|
|
if path not in ('/api/scores', '/api/profile'):
|
|
return self.send_error(404)
|
|
try:
|
|
n = int(self.headers.get('Content-Length') or 0)
|
|
except ValueError:
|
|
return self.reply(400, {'error': 'bad length'})
|
|
if not 0 < n <= MAX_BODY:
|
|
return self.reply(400, {'error': 'bad length'})
|
|
if path == '/api/profile':
|
|
try:
|
|
got = clean_profile(json.loads(self.rfile.read(n)))
|
|
except ValueError:
|
|
got = None
|
|
if not got:
|
|
return self.reply(400, {'error': 'bad profile'})
|
|
who, prof = got
|
|
with plock:
|
|
d = pload()
|
|
# the mirror never goes BACKWARD on xp: a second device with a stale localStorage
|
|
# boots, finds nothing local, adopts the mirror — but if it posts before adopting
|
|
# (or a wiped phone posts a fresh profile), a lower xp must not overwrite the
|
|
# investment the mirror exists to protect.
|
|
old = d.get(who)
|
|
if old and old.get('p', {}).get('xp', 0) > prof['xp']:
|
|
return self.reply(200, {'ok': True, 'p': old['p']})
|
|
d[who] = {'p': prof, 't': int(time.time())}
|
|
if len(d) > PROFILE_KEEP:
|
|
for k in sorted(d, key=lambda k: d[k].get('t', 0))[:len(d) - PROFILE_KEEP]:
|
|
del d[k]
|
|
psave(d)
|
|
return self.reply(200, {'ok': True, 'p': prof})
|
|
try:
|
|
row = clean(json.loads(self.rfile.read(n)))
|
|
except ValueError:
|
|
row = None
|
|
if not row:
|
|
return self.reply(400, {'error': 'bad score'})
|
|
with lock:
|
|
rows = load()
|
|
# A NAME BELONGS TO WHOEVER TOOK IT FIRST, ACROSS ALL THREE BOARDS. The claim is on the
|
|
# NAME, not on a row: one person owns "OLIVER" everywhere, or a stranger could take it
|
|
# on the classic board while you hold it on gore. The client mints a random id once and
|
|
# keeps it; the row remembers it, and a submission under someone else's name with a
|
|
# different id is refused. That is the whole of the "security" here — enough that a
|
|
# stranger cannot overwrite your row, and not one line more. Clear your browser storage
|
|
# and you lose the name, which is the honest cost of having no accounts.
|
|
owner = next((r for r in rows if r.get('n') == row['n'] and r.get('id')), None)
|
|
if owner and owner['id'] != row['id']:
|
|
return self.reply(409, {'error': 'name taken'})
|
|
# EXACTLY ONE ROW PER NAME PER BOARD, their best on it. Without this the board is one
|
|
# player's session: every personal best posts again and a good run buries everyone else
|
|
# under itself. The client only posts on a personal best, but its idea of "best" is
|
|
# per-device — the same player on a second phone starts from zero and posts worse runs,
|
|
# so a weaker submission has to be dropped HERE rather than merely not sorted to the top.
|
|
mine = [r for r in rows if r.get('n') == row['n'] and mode_of(r) == row['m']]
|
|
# "better" is the BOARD's own order, not a bare >: on the cup board the same league at
|
|
# a lower level beats the row already standing there.
|
|
key = rank_key(row['m'])
|
|
if not mine or key(row)[:2] < min(key(r)[:2] for r in mine):
|
|
rows = [r for r in rows
|
|
if not (r.get('n') == row['n'] and mode_of(r) == row['m'])]
|
|
rows.append(row)
|
|
# sorted and trimmed PER BOARD, so a busy mode can never evict a quiet one
|
|
kept = []
|
|
for m in MODES:
|
|
rs = [r for r in rows if mode_of(r) == m]
|
|
rs.sort(key=rank_key(m))
|
|
kept += rs[:KEEP]
|
|
rows = kept
|
|
save(rows)
|
|
board = [r for r in rows if mode_of(r) == row['m']]
|
|
board.sort(key=rank_key(row['m']))
|
|
return self.reply(200, {'ok': True, 'm': row['m'],
|
|
'rank': rank_of(board, row['n']), 'scores': public(board)})
|
|
|
|
# ---- static, with the headers the nginx conf used to set ----
|
|
def end_headers(self):
|
|
if self.path.startswith('/api/'):
|
|
pass # reply() sets its own no-store
|
|
elif self.path.startswith('/assets/'):
|
|
# THE COLD-START FIX. no-cache here meant ~230 sprites, three fonts and the audio were
|
|
# REVALIDATED on every launch — 230 round trips before the game had a full set of
|
|
# frames, on a phone, sometimes over a connection that dropped a few. That is why the
|
|
# fonts arrived late and the stats screen showed gaps. A ?v= URL can be cached forever
|
|
# because changing the file changes the URL (see SPRV in render.js); anything
|
|
# unversioned gets a day, so a replaced logo or icon still lands without a year of
|
|
# staleness. The HTML and JS stay no-cache — that is what makes a deploy visible.
|
|
self.send_header('Cache-Control', 'public, max-age=31536000, immutable'
|
|
if '?v=' in self.path else 'public, max-age=86400')
|
|
else:
|
|
self.send_header('Cache-Control', 'no-cache, must-revalidate')
|
|
super().end_headers()
|
|
|
|
def guess_type(self, path):
|
|
# Chrome refuses to install a homescreen app whose manifest arrives as octet-stream, and
|
|
# mimetypes has no .webmanifest entry.
|
|
if path.endswith('.webmanifest'):
|
|
return 'application/manifest+json'
|
|
return super().guess_type(path)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
print(f'splatter-tennis on :{PORT}, scores in {FILE}', flush=True)
|
|
ThreadingHTTPServer(('', PORT), Handler).serve_forever()
|