Six new opponent families (head coach, stringer, strawberry seller, chair umpire, spectator, roller man) through the famroll/zsheet route, placed by hierarchy into an 18-rung ladder; the champion steps up behind them. THE DRAW: picking the tournament now rolls its own bracket in front of you — only the final was ever settled — and what lands is what walks on. Netplay, off Oliver's cellular report: the mid-flight early-decide was missing its !G.net gate, so the striker judged its own outbound flight and the two scoreboards drifted a point apart into a court nobody could serve on. The host's court now rides the room, its scoreboard rides the pretoss stream, and a called ball rolls to its own halt instead of freezing where the call caught it. Plus the night court, and somebody in the stand is taking pictures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
291 lines
14 KiB
Python
291 lines
14 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 -> {"scores": [{"n","k","g","t"}, ...]} best first
|
|
POST /api/scores <- {"n","k","g"} -> {"ok", "rank", "scores"}
|
|
|
|
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, 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))
|
|
|
|
KEEP = 200 # rows kept on disk; 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
|
|
lock = threading.Lock()
|
|
|
|
|
|
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]
|
|
return {'n': name, 'k': kills, 'g': games, 't': int(time.time()), 'id': who}
|
|
|
|
|
|
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 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':
|
|
with lock:
|
|
return self.reply(200, {'scores': public(load())})
|
|
# 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):
|
|
if self.path.split('?')[0] != '/api/scores':
|
|
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'})
|
|
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()
|
|
mine = [r for r in rows if r.get('n') == row['n']]
|
|
# A NAME BELONGS TO WHOEVER TOOK IT FIRST. 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.
|
|
if mine and mine[0].get('id') and mine[0]['id'] != row['id']:
|
|
return self.reply(409, {'error': 'name taken'})
|
|
# EXACTLY ONE ROW PER NAME, their best. 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.
|
|
if row['k'] > max((r.get('k', 0) for r in mine), default=-1):
|
|
rows = [r for r in rows if r.get('n') != row['n']]
|
|
rows.append(row)
|
|
rows.sort(key=lambda r: (-r.get('k', 0), -r.get('g', 0), r.get('t', 0)))
|
|
rows = rows[:KEEP]
|
|
save(rows)
|
|
return self.reply(200, {'ok': True, 'rank': rank_of(rows, row['n']), 'scores': public(rows)})
|
|
|
|
# ---- 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()
|