Gore opens in the gore, the theme comes back, and every mode gets a board
Splatter gore no longer makes you win a game of tennis first — the mode you pick is the mode you get, with the man you came to play already turned. The transition itself is untouched and still reachable; it just isn't a toll booth. The menu theme could only ever be started, never brought back, so the first run of a session silenced it for good while the court's ambience followed you into the menu. Starting a bed now un-ducks it too. Three world boards instead of one: kills, opponents felled, and games taken off the CPU, each with its own name prompt and its own row per player. ...and the gore economy stops paying a barrel for every game won past game 16, while the shamblers, the mix and the ball machines keep escalating past the point they all used to stop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9cc3ac54b3
commit
3cf3e029a4
11 files changed
+440
-88
No files matched your search
@@ -5,8 +5,11 @@ It replaced the nginx image because the leaderboard needs ONE writable process a
|
||||
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"}
|
||||
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
|
||||
@@ -22,12 +25,23 @@ DATA = os.environ.get('SPLAT_DATA') or ('/data' if os.path.isdir('/data') else H
|
||||
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
|
||||
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')
|
||||
lock = threading.Lock()
|
||||
|
||||
|
||||
def mode_of(r):
|
||||
return r.get('m') or 'g'
|
||||
|
||||
|
||||
def load():
|
||||
try:
|
||||
with open(FILE) as f:
|
||||
@@ -60,7 +74,10 @@ def clean(payload):
|
||||
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}
|
||||
mode = str(payload.get('m', 'g'))
|
||||
if mode not in MODES:
|
||||
mode = 'g' # an unknown board is the default one, never a new one
|
||||
return {'n': name, 'k': kills, 'g': games, 't': int(time.time()), 'id': who, 'm': mode}
|
||||
|
||||
|
||||
def public(rows):
|
||||
@@ -212,8 +229,11 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
if path == '/ws':
|
||||
return self.handle_ws()
|
||||
if path == '/api/scores':
|
||||
want = re.sub(r'[^gzc]', '', (self.path.split('m=')[1].split('&')[0]
|
||||
if 'm=' in self.path else ''))[:1] or 'g'
|
||||
with lock:
|
||||
return self.reply(200, {'scores': public(load())})
|
||||
return self.reply(200, {'m': want,
|
||||
'scores': public([r for r in load() if mode_of(r) == want])})
|
||||
# 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.
|
||||
@@ -238,26 +258,38 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
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']:
|
||||
# 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, 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.
|
||||
# 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']]
|
||||
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 = [r for r in rows
|
||||
if not (r.get('n') == row['n'] and mode_of(r) == row['m'])]
|
||||
rows.append(row)
|
||||
rows.sort(key=lambda r: (-r.get('k', 0), -r.get('g', 0), r.get('t', 0)))
|
||||
rows = rows[:KEEP]
|
||||
# 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=lambda r: (-r.get('k', 0), -r.get('g', 0), r.get('t', 0)))
|
||||
kept += rs[:KEEP]
|
||||
rows = kept
|
||||
save(rows)
|
||||
return self.reply(200, {'ok': True, 'rank': rank_of(rows, row['n']), 'scores': public(rows)})
|
||||
board = [r for r in rows if mode_of(r) == row['m']]
|
||||
board.sort(key=lambda r: (-r.get('k', 0), -r.get('g', 0), r.get('t', 0)))
|
||||
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):
|
||||
|
||||
Reference in new issue
Block a user